Tracks the evolution of the IC 10 X-1 X-ray binary lightcurve simulation, fitting, and inference stack since the original R port.
- Project Overview
- Phase 1 — R → Python Migration
- Phase 2 — Flux Integration & XSPEC Support
- Phase 3 — Light-Curve Data Pipeline
- Phase 4 — Spectral Model Analysis
- Phase 5 — Phase Folding & χ² Fitting
- Phase 6 — Eclipse Geometry & LOS Cutoff Fixes
- Phase 7 — Unified Wind Model
- Phase 8 — MCMC Pipeline & Performance
- Phase 9 — Convergence Improvements (Reparameterization)
- Phase 10 — Wind-Shape MCMC Parameters
- Phase 11 — N-D Precomputed Grid for Shape-Fit MCMC (later removed)
- Phase 12 — flux_t Errors & First-Class Unbinned MCMC
- Phase 13 — Wind Normalization Constants
- Phase 14 — Pooled Direct-Model MCMC
- Phase 15 — Speed/Memory Pass & BIC Replaces WAIC/LOO
- Phase 16 — Frozen Parameters & Kepler Mode (
ParamSpec) - Phase 17 — Per-Sample Phase-Shift Alignment
- Phase 18 — Adaptive Constant-SNR Binning & Grid Removal
- Phase 19 — Gaussian Phase Smoothing, Scattered Flux & Residual Panels
- Phase 20 — Remove Multiplicative Flux Scale from the Single-Model χ² Fit
- Phase 21 — MCMC Scatter-Path Audit
- Phase 22 — Adaptive Binning in the Single-Model CLI
- Phase 23 —
utils/Extraction and a Single Plotting Routine - Phase 24 — Run-Config Persistence and a Replot-Mode Fix
- Phase 25 — MCMC Script Slimming
- Side Investigation — Reference Epoch Recalibration
- Current File Inventory
- Current Status & Quick Commands
Target: IC 10 X-1 — eclipsing X-ray binary in the Local Group galaxy IC 10.
System (working values): WR companion R ≈ 2 R☉, accretion-disk r ≈ 0.001 R☉, inclination i₀ ≈ 26°, separation d ≈ 19 R☉.
Best spectral model: TBabs × powerlaw — nH ≈ 0.75×10²² cm⁻², Γ ≈ 1.86, χ²_red ≈ 1.52 (preferred over phabs by Δχ² ≈ 8.5).
Ported new11.R, grid4.R, wind_los2.R, density_fnc.R (≈260 R lines)
to a single xrb_lightcurve.py with full type hints, an argparse CLI, and
fully vectorized NumPy core (10–100× faster than R). Public surface:
simulate_lightcurve, create_grid, wind_los_integral, density_function.
Companion files: example_usage.py, plot_results.py, requirements.txt.
Added three flux-conversion paths selectable via --flux_method:
legacy— hardcoded exponential fits.interpolate— log-log interpolation from an XSPECflux vs nHCSV.refit— re-fit exponentials to the XSPEC table.
Helpers: load_flux_vs_nh_csv, interpolate_flux_from_nh,
fit_exponential_to_csv. CLI gained --flux_csv and --lam
(target mean nH; --lam2 retired in Phase 7). XSPEC table is generated by
compute_flux_vs_nH.py --specdir … --out_csv data_flux_vs_nH.csv.
Units convention: flx (atoms / R☉⁴, raw column-density integral),
fl (10²² cm⁻²), nfl_{band} (photons/cm²/s).
Chandra .txt light curves were actually FITS binary; built a small
toolchain to (a) convert FITS→TXT and (b) attach a calibrated FLUX column.
Tools (in utils/): convert_fits_to_txt.py and the heasoft wrapper
convert_fits_to_txt_heasoft.sh, add_flux_simple.py,
add_flux_to_lightcurves.py, compute_count_to_flux_factor.py,
get_average_count_rates.py. Output layout:
data/IC_10_X1_LC/{Broad,Soft,Hard}{_converted,_with_flux}/.
Time-averaged count rates (cts/s): broad 0.1132, soft 0.0635, hard 0.0497.
XSPEC tooling: xspec_get_conversion_factors_tbabs.xcm,
get_conversion_factors.sh, get_xspec_nH.py,
compare_absorption_models.xcm, compare_models.sh.
Comparison run picked TBabs × powerlaw over phabs × powerlaw
(Δχ² = 8.54 in favor of TBabs; nH 0.75 vs 0.78). 0.5–7 keV model fluxes:
TBabs 1.032×10⁻¹², phabs 1.031×10⁻¹² erg/cm²/s.
chandra_phase_analysis.py (≈1.0k lines) folds observations onto orbital
phase using the ephemeris (REF_EPOCH = 278801348 s, P = 125431 s) and
fits simulation models via χ² minimization with optional phase-shift /
scale rescaling. Auto-detects flux columns (nfl_*, pho_count_*),
handles both standard whitespace and CIAO #Columns: formats, supports
multi-column simultaneous fitting.
Two physics fixes plus a runtime knob in xrb_lightcurve.py:
- Front-emitter spurious eclipse — eclipse gating moved from
gma < πtosin(gma) > 0so emitter-in-front geometries are not incorrectly occulted. - Total-eclipse flux —
is_eclipsedflag added; during eclipse allnfl_*andpho_count_*columns are forced to 0 (previously they collapsed to maximum flux becauseflx=0→e⁰=1). --Rmaxand--converge-rmax— configurable LOS cutoff. Converged mode integrates to −∞ using a closed form for the constant-velocity term plus a cached quadrature + asymptotic tail for the accelerating-wind term, comparable in speed to the legacyRmax = 2dheuristic. (The cached_ACCEL_*table was retired in Phase 7 once a single Numba kernel covered all profiles.)
Plan: mcmc_wind_shape_params_8b9c89d2.plan.md (precursor),
implementation under unified_wind_model_77726ced.plan.md.
Goal was to drop the hardcoded AV / CV wind duality (flx2, lam2,
*_cv columns) in favour of a pluggable density profile selected at runtime.
New profile registry in xrb_lightcurve.py (each exposes a dimensionless
g(r) consumed inline by a single Numba kernel):
wind_model |
id | Free shape params | Notes |
|---|---|---|---|
broken_pl |
0 | Rb, p |
Piecewise PL (kept for back-compat). |
smooth_pl |
1 | Rb, p, Delta |
Default; smoothly broken PL. |
beta_law |
2 | R_star, beta, H |
CAK velocity profile, g = 1/(r²·v(r)). |
confinement |
3 | R_star, fconf, ell |
1/r² with inner exp. compression. |
Kernel rewrite — _wind_los_profile_numba (and a Gauss-Legendre variant
_los_gl_quadrature) replaces the old converged + fixed-Rmax pair, and
_simulate_phases_numba runs the full per-phase sweep under
@njit(parallel=True, prange). Cached _ACCEL_U_GRID/_ACCEL_F_GRID
deleted.
API changes:
simulate_lightcurve(...)lostlam2and gainedwind_model: str = "smooth_pl"andwind_params: dict | None.- Output collapsed to a single
flx/flplus onenfl_{band}column per band (no more_av/_cvsuffix). - New helpers:
pack_wind_params,default_wind_params,evaluate_g_profile,compute_surface_density(sim_df, lam, R_star, wind_model, wind_params), andwind_density_posterior(...)for converting MCMClamposteriors into surface-densityn₀posteriors. - CLI: removed
--lam2; added--wind-model {broken_pl, smooth_pl, beta_law, confinement}plus per-model--Rb / --p / --Delta / --beta / --H / --fconf / --ell.
Downstream cleanup — plot_results.py, chandra_phase_analysis.py,
chandra_analysis_combined_flux.py, utils/test_flux_methods.py all
updated to look for nfl_{band} (no _av/_cv filter).
Plan: mcmc_performance_and_statistics_8989cd39.plan.md.
mcmc_lightcurve_fit.py (≈3.1k lines) wraps xrb_lightcurve.py in a full
emcee/zeus pipeline. Headline pieces:
- Forward-model paths
PrecomputedModelGrid— geometry-only 6-D grid (d1, d2, r, R, i0, phase) with vectorizedRegularGridInterpolatorlookup. Multi-process build via_compute_single_model+Pool. Supports--save-grid/--load-grid.DirectLightCurveModel— wrapssimulate_lightcurvedirectly; required when shape params are sampled (Phase 10).
- Samplers —
emcee(default, stretch move) andzeus(ensemble slice) via the same harness. - Likelihoods (
--likelihood) —chi2(Gaussian, default),jitter(Gaussian + freelog_fsystematic term),studentt(heavy-tailed, configurable--studentt-nu). Cash/Poisson was scoped out. (PrecomputedModelGridwas deleted in Phase 18, along withstudentt; onlychi2andjitterremain.) - Diagnostics — ArviZ summary (
r_hat,mcse_*,eti89_*), autocorrelation, optional WAIC/LOO via--compute-waic, corner plots, best-fit overlay with reduced χ². (WAIC/LOO replaced by BIC in Phase 15;--compute-waicremoved in Phase 18.)
Performance work done in xrb_lightcurve.py:
- Pre-optimization state (post Phase 7). Introducing the unified wind
profile registry dropped per-LOS Python dispatch into the hot loop: the
LOS integral called back into Python for
g(r)at every angular cell × every step on thedzgrid × every phase. Combined with the fixed-step trapezoid quadrature and per-cell eclipse checks, a singlesimulate_lightcurvecall had ballooned to several seconds (≈3–8 s depending ondz/d2h), which made Phase 8-style MCMC (10⁴–10⁵ model evaluations) completely infeasible without a precomputed grid — and even the grid build was painfully slow. - Fixes (together ≈ 2 orders of magnitude):
@njit(cache=True[, parallel=True])on_g_profile, the LOS integrand,_los_gl_quadrature,_wind_los_profile_numba, and the full per-phase sweep_simulate_phases_numba(usingprange).- Gauss-Legendre quadrature replaces the old fixed-step trapezoid; far fewer integrand evaluations for the same accuracy, and the GL nodes/weights are precomputed once.
- Inlined eclipse test — eclipse gating collapsed into the kernel so eclipsed phases early-exit without a Python round-trip.
- All
wind_params/ sim params flattened to scalar JIT arguments at the Python/Numba boundary; no dict lookups in the hot path.
- Result: a single
simulate_lightcurvecall is now < 1 s (≈ 63 ms) on a laptop, i.e. ~50–100× faster than the post-Phase-7 regression and 10⁵–10⁶× the sustained rate needed for MCMC. The direct evaluator became viable for MCMC without a precomputed grid, and the grid build itself dropped from minutes to seconds.
GPU acceleration was evaluated and rejected (Intel Iris ≠ CUDA / JAX-Metal / PyTorch-MPS targets). NumPyro/NUTS port was scoped out for the same reason plus the non-differentiable interpolator in the fast path.
Plan: mcmc_convergence_improvements_c648afb3.plan.md.
(d1, d2) are strongly correlated because the wind absorption sees only
their sum. --reparam swaps the sampling space to:
a = d1 + d2(separation, well-constrained)q = d1 / (d1 + d2)(ratio, weakly constrained)
Implementation lives entirely in mcmc_lightcurve_fit.py:
REPARAM_PRIORS, get_param_config(reparam=True) returns
['a','q','r','R','i0'], _evaluate_model inverts (a,q) → (d1,d2),
log_prior applies priors in (a,q) space with the +log(a) Jacobian,
walker init / corner / summaries report derived d1, d2. Grid build
itself is unchanged (still indexed in physical d1, d2).
HMC / NUTS was assessed and not pursued — piecewise-linear
RegularGridInterpolator gradients, hard eclipse branches, and Numba
kernels all break autodiff. Future paths (smooth GP/NN emulator, cubic grid
- finite differences, JAX rewrite) are documented in the plan.
Plan: mcmc_wind_shape_params_8b9c89d2.plan.md.
After Phase 8 brought the direct evaluator to ~63 ms/LC, wind-shape
parameters are now first-class MCMC dimensions, gated by --fit-wind-shape.
Per-model active set (in WIND_SHAPE_FIT):
--wind-model |
Free | Fixed | Tied to geometry |
|---|---|---|---|
smooth_pl |
Rb, p |
Delta = 2.0 |
— |
beta_law |
beta |
H = 1.0 |
R_star = R |
confinement |
fconf, ell |
— | R_star = R |
broken_pl is intentionally skipped (smooth_pl generalizes it).
lam (overall normalization) stays fixed from spectral fits — shape
params are constrained only by the LC shape.
Mechanics in mcmc_lightcurve_fit.py:
- New registries:
WIND_MODELS,WIND_SHAPE_FIT,WIND_SHAPE_FIXED,WIND_SHAPE_LABELS,WIND_SHAPE_PRIORS, plus helpersget_active_priors(...)and_to_wind_params(theta, active_names, wind_model, R_value, fit_wind_shape). get_param_configand walker init are now fully dynamic in theactive_nameslist (no more hardcodedn_phys=5/theta[5]/pos[:,5]; the jitterlog_findex is looked up by name).log_prior,log_likelihood_*,_evaluate_model,compute_chi2_for_samples,compute_pointwise_loglik,plot_best_fit,run_arviz_diagnostics,run_single_fit, andreplot_from_existingall acceptwind_model,fit_wind_shape,active_namesand route the wind-shape sample correctly.load_existing_resultsaccepts any column set (geometry columns required, extras kept) and returns(samples, stats, loaded_names)so re-plots work for both geometry-only and shape-fit chains.all_resultskeys switched fromf"{band}_{wind_model}"to(band, wind_model)tuples (the underscore insmooth_pletc. broke the previousrsplit('_', 1)summary writer).
CLI additions:
--wind-model {smooth_pl, beta_law, confinement}(defaultsmooth_pl). The legacyav/cv/bothchoices and the'both'loop are gone.--fit-wind-shape— adds the wind model's active shape params to the MCMC vector. Works with either the precomputed grid (see Phase 11) or--no-grid(direct evaluator).- Per-shape prior overrides:
--prior-Rb / -p / -beta / -fconf / -ellusing the samemean,std,min,maxformat as the geometry priors. --lam2removed everywhere.
Smoke verified — 50-step chains for smooth_pl (geom-only and
+Rb,p), beta_law (+beta), confinement (+fconf, ell), plus a
grid-path geometry-only run. All produce valid corner data, ArviZ
summaries, samples CSV, and mcmc_summary.txt.
Superseded: the entire precomputed-grid path described below was deleted in Phase 18. MCMC now always uses the direct evaluator. Kept here for history.
After Phase 10 a short (~1k-step) shape-fit chain still took ~2 h
because --fit-wind-shape auto-forced the direct evaluator
(≈ 63 ms × 32 walkers × 1000 steps × all phases ≈ hours). The grid
class was extended to cover wind-shape axes dynamically instead of
forcing a slow path.
PrecomputedModelGridis now N-D.self.axis_namesholds the ordered list of axes (geometry first, then the active shape axes for the chosenwind_model).self.param_gridsandself.flux_gridextend correspondingly (geometry-only grids are unchanged shape-wise, so the refactor is zero-cost for existing workflows).- Worker path.
_precompute_modelsnow iterates vianp.ndindexover the full axis set, resolves a per-combowind_paramsdict (fixed template + per-point shape values +R_star = Rwhen the model ties it), and hands it to_compute_single_model. Ther >= Rgeometry filter still applies. - Evaluation. A single
RegularGridInterpolatorspans(axes…, phase);evaluate()looks shape values up from the caller'swind_paramsdict when shape axes are present, and falls back to the fixed template for geometry-only grids. - I/O —
.npzfiles persistaxis_names,shape_axes,fit_wind_shape, and one<name>_gridarray per axis. Legacy geometry-only.npzfiles (missingaxis_names) still load unchanged via a backward-compat branch in_load_grid. - CLI —
--fit-wind-shapeno longer force-disables the grid; instead:- Grid size expands by
(shape_grid_points)^kforkshape axes, with the new--shape-grid-points Nknob (default 5). A[notice]prints the expected total grid size up front. --save-grid/--load-gridwork as usual and are now actually useful for shape-fit MCMC (build once, run many chains).--no-gridstays as the escape hatch for short debugging chains.
- Grid size expands by
- Logging cleanup.
load_flux_vs_nh_csv(..., verbose=verbose)andinterpolate_flux_from_nh(..., warn_extrapolation=verbose)now honor theverboseflag thatsimulate_lightcurvealready threads through. MCMC calls withverbose=False, so the per-call "Detected energy bands in CSV: …" print and repeated "nH outside CSV range / Extrapolation will be used"UserWarningare gone (they only fire in notebooks / interactive runs now). A belt-and-suspenderswarnings.filterwarnings(...)inmcmc_lightcurve_fit.pykeeps the extrapolation warning quiet even if a future callsite forgets to passverbose=False. - Smoke verified — N-D grid build + save/load round-trip (bit-exact),
end-to-end MCMC with
--fit-wind-shapeonsmooth_pl(+Rb, p) andbeta_law(+beta),--no-gridshape-fit path, and legacy geometry-only grid.npzbackward compatibility all pass.
Plan: flux_t_error_and_unbinned_mcmc_1bf81aa9.plan.md. Commit fa672e3.
CIAO files carry flux_t but no flux_t_err, so the MCMC's
error_column = obs_column + "_ERR" guess (flux_t_ERR) never matched and
silently fell back to 0.1·|flux|.
chandra_phase_analysis.py— new_derive_err_from_rate_err(df, obs_col), invoked only when no error column is matched. Derives per-rowerr = rate_err · (obs/rate)forrate > 0, falling back to a file-levelcf = median(obs/rate)for the rest (verified constant per file, e.g.cf ≈ 4.56e-12for soft 11080).- Master-file code removed —
verify_master_contains_individual(), themaster_fileparameter inload_data/read_observation, and the--master-file/--verify-masterCLI args are gone. mcmc_lightcurve_fit.py— passesobs_error_column=Nonewhen the user didn't set it explicitly so auto-derivation fires; keeps theflux > 0drop in both binned and unbinned modes (zero-flux rows are GTI gaps, and with jitterσ²_eff = σ_obs² + (f·model)² ≈ 0would blow uplog σ²), and prints the drop count. The invalid-error patch tightened from0.1·|flux|tomax(0.1·|flux|, median(valid obs_err)).is_binnedthreaded frommain()throughrun_single_fit/replot_from_existing/plot_best_fitso labels and marker style match reality ("Observed (phase-binned)" with error bars vs "Observed (raw 100s)" as translucent scatter).- Jitter-aware χ² —
plot_best_fitandcompute_chi2_for_samplesreport the effective-variance χ² alongside the classical measurement-error χ² when--likelihood jitteris active. --no-phase-binhelp now recommends pairing with--likelihood jitter, and the module docstring gained an unbinnedflux_texample.
Plan: wind_normalization_constants_76f1ef51.plan.md. Commit fa672e3.
Since every profile is coded as a dimensionless g(r), the physical amplitude has
to be recovered after the fit. Added to xrb_lightcurve.py:
- Constants
M_H_G,M_SUN_G,KM_TO_CMalongside the existingR_SUN_CM. compute_wind_normalization_constants(lam, flx_mean, wind_model, wind_params, v_inf=None, mu=1.4)— computesn0 = lam·1e22/(R_sun·flx_mean)then, per model:smooth_pl→g_break,n_break_cm3,rho_b_g_cm3;beta_law/confinement→n_surface_cm3,rho_surface_g_cm3,mdot_over_vinf_g_per_cm, plusmdot_g_s/mdot_msun_yrwhenv_inf(km/s) is supplied.v_infis not fitted — it was absorbed inton0by thelamnormalization — so the caller must provide it for a mass-loss rate.wind_normalization_constants_posterior(...)— mirrorswind_density_posterior, looping the point estimator over posterior samples and returning{samples, median, p16, p84}per constant.
Commit 36c8c8b.
The direct evaluator's simulate_lightcurve is already Numba
parallel=True, so naively adding --n-threads worker processes caused severe
thread oversubscription (workers × numba threads ≫ cores), while pinning workers
to one thread made each LC so slow that process parallelism barely beat serial.
_init_numba_worker(max_numba_threads)is used as thePoolinitializer and callsnumba.set_num_threads(...)inside each worker.--numba-threads-per-workerexposes the knob; the defaultautoismax(1, cpu_count // n_threads), so workers collectively use ≈ one thread per logical CPU.- The pool uses the
spawncontext (safest cross-platform, avoids inheriting heavy state) and is only enabled forDirectLightCurveModel; requesting--n-threads > 1with any other model type prints a[notice]and runs serial. - Also fixed an indentation bug in
xrb_lightcurve.py.
Plan: mcmc_speed_memory_optimization_68fc497a.plan.md. Commit 7116302.
A staged, accuracy-preserving optimization program with a reproducible benchmark
harness (utils/benchmark_mcmc_performance.py, documented in
PERFORMANCE_VALIDATION_REPORT.md; acceptance thresholds: parameter median drift
< 0.1σ, reduced-χ² change ≤ 2%, BIC ranking consistency).
Runtime (xrb_lightcurve.py):
- Module-level
_FLUX_CACHEkeyed by(abs csv path, flux_type)caching cleaned arrays, prebuilt log-loginterp1dobjects, and per-band exponential fits (_build_flux_context,_interpolate_flux_from_context). Previously everysimulate_lightcurvecall re-read and re-sorted the CSV. - Mega-kernel results assembled straight from NumPy arrays into the DataFrame
(no
tolist()/zip()round-trips). - Theta-ring trig tables precomputed once per
_simulate_phases_numbacall instead of per phase.
Memory (mcmc_lightcurve_fit.py): grid precompute streamed worker results
directly into flux_grid instead of materializing a full list(...), and
interpolator setup avoided whole-grid copies during NaN cleanup. (Both became
moot once the grid path was deleted in Phase 18.)
Hot path: likelihood invariants precomputed once in run_mcmc and passed as
a like_terms dict (obs_err2, jitter_logf_index), replacing repeated
active_names.index('log_f') lookups and per-call obs_err**2 allocations.
_interp_periodic_phases gained a monotonic fast path that skips the sort.
Output: save_samples_csv_chunked writes samples in configurable chunks
(--csv-chunk-size, default 50000); --compact-output adds an NPZ companion;
--no-csv-output skips the large CSV entirely.
Model comparison: WAIC/LOO removed in favor of BIC. compute_bic_metrics
computes BIC = k·ln n - 2 ln L̂ with k = len(active_names),
n = len(obs_flux) after all binning/filtering, and L̂ obtained by calling the
run's own likelihood at the max-log-prob sample (not by subtracting priors from
log_prob, avoiding Jacobian bookkeeping). Reports bic, logL_hat,
k_params, n_obs, theta_source (map_log_prob | median_fallback) to the
console, mcmc_summary.txt, and *_model_metrics.csv; ΔBIC is computed
against the best model in the run. ArviZ is now used for convergence
diagnostics only.
Plan: freeze_params_and_kepler_3368d554.plan.md. Commit be56359.
Two features plus the refactor that made both tractable.
ParamSpec dataclass (built once in main() by build_param_spec(...) and
threaded through every consumer) replaces ad-hoc positional theta indexing:
mode, active_names, active_labels, frozen, fit_wind_shape,
fit_scatter, wind_model, likelihood, orbital_period_s, K_kepler.
Central resolvers by name, not index:
_resolve_geom(theta, spec)(replaces_to_physical, kept as a shim),_resolve_shape(theta, spec)(generalizes_to_wind_params),_theta_value(theta, name, names, frozen).
--freeze NAME=VAL[,NAME=VAL,…] — pins parameters and drops them from the
chain. Valid: d1, d2, a, q, r, R, i0, M_X, M_RH, f_scatter, Rb, p, beta, fconf, ell. Shape params can be frozen even without --fit-wind-shape. log_f
cannot be frozen. Unknown names are rejected with the allowed list; values
outside the prior box warn but proceed; Rb < R with both frozen fails fast
before sampling. get_active_priors drops frozen entries.
--kepler — samples (M_X, M_RH) in M☉ with KEPLER_PARAM_NAMES /
KEPLER_PARAM_LABELS / KEPLER_PRIORS, then derives
a = K·M_tot^{1/3} (with K = (G·M☉·P²/4π²)^{1/3}/R☉ precomputed by
_compute_kepler_prefactor) and q = M_RH/M_tot from the lever arm
d1·M_X = d2·M_RH. --orbital-period sets P (default ORBITAL_PERIOD);
--prior-MX / --prior-MRH override the mass priors. Mutually exclusive with
--reparam. Composes with freezing (e.g. --kepler --freeze M_RH=20).
Prior rewrite — log_prior iterates spec.active_names, applies box +
Gaussian per dim, then enforces constraints on resolved values so they hold
under freezing and Kepler mode: r < R (was a positional theta[2] >= theta[3])
and Rb ≥ R for smooth_pl. _log_jacobian contributes +log(a) only in
reparam mode (Kepler priors are already in mass space).
Stats / persistence — compute_statistics derives (a, q, d1, d2) in Kepler
mode and (d1, d2) in reparam mode, and records a MAP entry (max-log-prob single
sample) which — unlike the marginal medians — exactly satisfies d1+d2 = a and
d1/(d1+d2) = q. *_chain.npz gained mode, frozen_names, frozen_values,
orbital_period_s; replot_from_existing reads them back to rebuild the spec,
with old chains defaulting to previous behavior. plot_best_fit gained
_value_from_stats_or_frozen so frozen parameters no longer break the overlay.
Also in this commit: xrb_lightcurve.py --Delta default changed from 2.0 to
1.0. Note this was not mirrored in default_wind_params or
WIND_SHAPE_FIXED, which still use 2.0 — see PROJECT.md "Known rough edges".
Commit 67448c3.
Rather than trusting the ephemeris to align model and data, every likelihood call now minimizes weighted χ² over a phase shift, so the fit is insensitive to residual epoch error.
_build_phase_shift_terms(enabled, obs_phase, grid_size, eval_points, refine_points)precomputes the coarse shift grid, the dense model evaluation grid, and the shifted observation-phase matrix once per run._apply_best_phase_shift(...)does a two-stage search: a coarse uniform grid over[0,1), then a local refinement across ±1 coarse step around the best shift — near-fine-grid accuracy at a fraction of the cost. The model is evaluated once on the dense grid and re-interpolated per trial shift.- Defaults:
DEFAULT_PHASE_SHIFT_GRID_SIZE = 25,DEFAULT_PHASE_SHIFT_EVAL_POINTS = 240,DEFAULT_PHASE_SHIFT_REFINE_POINTS = 9. CLI:--no-fit-phase-shift,--phase-shift-grid-size,--phase-shift-eval-points. - Because the shift is a per-sample nuisance minimization rather than a sampled
parameter, it is applied consistently in
log_likelihood_chi2,log_likelihood_jitter,compute_chi2_for_samples,compute_pointwise_loglik,compute_bic_metrics, andplot_best_fit(which also draws the model shifted by the best-fit value and prints it in the annotation box). mcmc_summary.txtgained a "Run configuration" block recordingfit_phase_shift, grid size, eval points, and wall time (stats['_run_meta']), plus a chain-diagnostics block fromprint_diagnostics(stats['_diagnostics']).
Plan: adaptive_constant-snr_binning_249c8cba.plan.md. Commit b376585.
Constant-counts binning. counts is now carried through
read_observation / load_data (counts_column='counts') and
load_observed_lightcurves. New phase_bin_data_snr(df, counts_per_bin=100, …)
in chandra_phase_analysis.py (with a flux-naming wrapper in the MCMC module)
sorts by phase and greedily accumulates counts until each bin reaches the
target, so every binned point carries roughly equal Poisson weight
(100 counts ⇒ SNR ≈ 10) and low-signal eclipse troughs merge into wide bins
instead of many noisy narrow ones. Per bin it returns the counts-weighted phase
center, inverse-variance weighted flux, error = √(1/Σw), n_points,
total_counts, and phase_lo/phase_hi/width; a trailing under-target bin is
merged into its predecessor.
Mode is selected by argument presence, not a --bin-mode flag:
--no-phase-bin (raw) > --counts-per-bin N (adaptive) > --n-phase-bins N
(fixed) > neither (50 fixed bins, backward compatible). --n-phase-bins default
changed from 50 to None; supplying both binners is an error. Bin widths are
threaded as obs_phase_width into plot_best_fit and drawn as horizontal error
bars.
Precomputed grid deleted. PrecomputedModelGrid and its
_precompute_models / _compute_single_model / _setup_interpolators /
_save_grid / _load_grid machinery are gone, along with --save-grid,
--load-grid, --no-grid, --grid-points, and --shape-grid-points. MCMC
always uses DirectLightCurveModel: at ~60 ms/LC it is fast enough, it avoids
grid interpolation artifacts, and it is the only path that keeps the likelihood
physically faithful for every sample (including per-step wind shape).
Likelihood CLI simplified. Student-t and the deprecated WAIC shim removed:
log_likelihood_studentt(), 'studentt' from LIKELIHOOD_TYPES and
--likelihood choices, --studentt-nu, all studentt_nu plumbing through
log_probability / run_mcmc / compute_chi2_for_samples / BIC /
run_single_fit / save / replot, the scipy.special.gammaln import, and
--compute-waic plus its shim mapping to --compute-bic. Only chi2 and
jitter remain; --compute-bic is the model-comparison flag.
Plan: gaussian_phase_smoothing_reference_d1a46172.plan.md.
Status: implemented but uncommitted on branch add_generic_wind.
Three reusable primitives live in chandra_phase_analysis.py so both the
single-model and MCMC plot paths share one source of truth:
smooth_lightcurve(phase, flux, flux_err, sigma=0.01, n_eval=300, n_mc=2000, random_state=None)— periodic Gaussian-kernel phase smoother. Periodic distanced = |((φ_i-φ_eval+0.5) mod 1) - 0.5|, weightsexp(-½(d/σ)²), so it is continuous acrossphase = 0/1. Generalizes the MATLAB reference intemp/LC_MC/*.mfrom index windows to a phase-distance kernel, so it is correct for fixed-width bins, constant-SNR bins, and raw unbinned data. The 1σ band is a vectorized Monte Carlo (perturb all points at once, one matmul,stdover realizations) rather than a Python loop. Kernel is phase-distance only — no inverse-variance weighting — matching the reference.σ = 0.01sits well below the ~0.1–0.25 phase scale of real features and above the ~0.0002 raw sampling.estimate_scattered_flux(phase, flux, window=(0.4, 0.6))— mean observed flux in the mid-eclipse window (fallback0.1 × median, clamped ≥ 0).add_residual_panel(ax, phase, obs, model, err, xerr=None)— normalized pulls(O-M)/σwith0/±1reference lines.
xrb_lightcurve.py — simulate_lightcurve(scattered_flux=0.0) adds a
constant, phase-independent offset to every nfl_* column after eclipse
handling, so notebooks can bake a scattered-light floor into a directly generated
model. The fit paths deliberately add scatter at overlay/evaluation time instead,
so fit_simulation's multiplicative scale and MCMC's per-step scaling don't
rescale an additive constant.
chandra_phase_analysis.py single-model path — fit_simulation(scatter=…)
adds the constant after scaling inside the inner χ²; plot_phase becomes a
2-panel figure (3:1 heights, shared x) with a residual panel whenever a model
overlay is present, plus the optional dashed-green smoothed curve and MC band.
New CLI: --smooth, --smooth-sigma, --smooth-n-mc, --smooth-seed,
--scatter, --scatter-eclipse-phase. When --fit runs without --scatter,
the value is estimated from the eclipse window.
mcmc_lightcurve_fit.py — imports the three primitives. Same
--smooth* flags, computed once per band and threaded into plot_best_fit,
which is now a 2-panel figure with the residual panel clipped to ±5σ. New
--fit-scatter promotes f_scatter to a free MCMC parameter (mirroring
log_f): added by build_param_spec / ParamSpec.fit_scatter, resolved by
_resolve_scatter and applied in _evaluate_model so all likelihoods, BIC, and
pointwise log-lik inherit it. Its prior is centered on
estimate_scattered_flux(...) with min = 0 and max = nanmax(obs_flux).
Being phase-invariant it is unaffected by the phase-shift search; being physical
it counts toward dof automatically (n_phys subtracts only log_f). It is
saved as a normal active column, excluded from wind-shape extra-dim detection on
replot, and can be pinned via --fit-scatter --freeze f_scatter=<v>.
chandra_phase_analysis.py. Status: uncommitted on branch add_generic_wind.
fit_simulation used to fit two parameters — a phase shift and a
multiplicative flux scale — which meant reduced χ² never tested the model's
absolute normalization. That normalization is not free: it is pinned by lam
(the orbit-averaged nH from the spectral fit) together with the XSPEC
flux vs nH table. A free y-scale therefore absorbed any normalization error
instead of exposing it. The MCMC path never had such a parameter — it fits a
per-sample phase shift and an additive f_scatter, nothing multiplicative —
so the two χ² paths were also inconsistent with each other.
Note the additive scattered-flux floor is not a substitute for the
multiplicative scale (they are different degrees of freedom); the justification
for dropping scale is that the normalization is externally fixed, and the
eclipse-floor offset is what the additive term legitimately covers.
fit_simulation(obs_df, sim_df, sim_column, fit_phase_shift=False, scatter=0.0, n_shift_grid=1000)now returns(shift, reduced_chi2)— thescaleelement is gone from both the fit and the return tuple. The model isinterp((φ_obs - shift) mod 1) + scatter.- Robust shift search. χ²(shift) is periodic and strongly multi-modal
because of the eclipse, and the old
Nelder-Meadstarted atshift = 0routinely settled in the wrong basin — a latent defect that mattered more once the shift became the only fitted parameter. Replaced with a vectorized coarse scan over the full period (default 1000 nodes, onenp.interpover all(shift, φ_obs)pairs) followed by a boundedminimize_scalarrefinement within one coarse step. Verified to recover injected shifts of 0.02, 0.37, 0.51, and 0.95 to < 1e-3. - dof corrected. Was
N - 2in both branches (wrong even before: the no-rescale branch fitted nothing). NowN - 1when the shift is fitted andNwhen it is not. plot_phaselost itsscaleparameter; the overlay is drawn at native normalization plusscatter.rescaled=renamed toshift_fitted=, and the title annotation now reports the phase shift instead of a rescaled/not label.plot_multi_column_fits—fit_resultsentries are(shift, chi2);rescaled=→shift_fitted=.- CLI —
--rescalerenamed to--fit-phase-shift, with--rescalekept as a deprecated alias on the samedestso existing commands keep working. - Unused import removed —
scipy.optimize.minimize→minimize_scalar. - Notebook call sites migrated (10 cells across
notebooks/xrb_toy_wind_models.ipynb,xrb_model_analysis.ipynb,xrb_model_analysis_single_15803.ipynb);.bakcopies left alongside.
Effect on reported fit quality. On the soft band (12 obs, 100 fixed bins,
sim_flux_tbabs_15803_smooth_pl_soft.csv) the old two-parameter fit returned
scale = 0.413 with χ²/dof = 2.06; with the scale removed the same data/model
give χ²/dof = 20.97. The free scale had been hiding a ≈ 59 % flux-normalization
deficit. Expect previously "acceptable" reduced χ² values to rise across the
board — that is the intended behavior, and a coherent non-zero-centered
residual band is now the diagnostic signature of a normalization mismatch (as
opposed to a shape mismatch).
Not changed: chandra_analysis_combined_flux.py carries its own older
duplicate of fit_simulation / plot_phase / plot_multi_column_fits (no
scatter support at all) and still fits a multiplicative scale.
Two ways the plot could disagree with the number printed on it, both caused by "model evaluated at the observed phases" being written out three separate times with three subtly different formulas:
- Overlay could omit the scatter floor.
plot_phasetakes its ownscatterargument, so a caller that passedscatter=tofit_simulationbut not toplot_phasegot a curve drawnscattertoo low while the title showed the χ² that included it.main()always threaded both, but the notebook call sites did not. - The residual panel ignored the phase shift entirely. It built its
interpolation grid from the unshifted model
(
model_interp_phase = phase_sorted) and evaluatednp.interp(obs_phase, …), whereas χ² usesinterp((obs_phase - shift) mod 1, …). The plotted line was shifted correctly, so only the pulls were wrong — and badly: on a syntheticshift = 0.30case with an otherwise perfect fit (χ²/dof = 0), the panel showed residuals implying χ²/dof ≈ 18050, with 50/120 points disagreeing with the χ² model.
Fixed by collapsing all three call sites onto one definition:
_prepare_model_interpolator(sim_df, sim_column)— builds the wrap-around(phase_wrap, flux_wrap)arrays, accepting either aphaseor adegcolumn, and no longer mutates the caller'ssim_df._model_from_wrap(phase_wrap, flux_wrap, phases, shift, scatter)— the single definition of the model. Accepts an array-valuedshiftso the coarse-scan batching infit_simulationuses the same expression as a single evaluation.evaluate_model_at_phases(sim_df, sim_column, phases, shift, scatter)— public one-shot wrapper (useful from notebooks)._obs_errors(obs_df)— shared uncertainty extraction (provided errors elsesqrt(|rate|), with zero/negative/non-finite floored to1e-3), so the fit and the residual panel weight points identically. Non-finite errors are now floored too; previously a NaN error propagated into a NaN χ².plot_phasedraws the overlay on a dense 721-point grid via the shared evaluator instead of shifting the model's own sample points, and computes residuals with the sameshiftandscatter.
Added a self-check. When plot_phase is given a chi2 to display, it
recomputes reduced χ² from the curve it actually drew (matching dof via
shift_fitted) and warns if the two differ by more than 1 %. This catches a
mismatched scatter, shift, or sim_column at the point of display rather
than leaving a plausible-looking number over the wrong curve — it reproduces
both bugs above as warnings. Verified silent on correct calls.
Notebook fix. One genuine mismatch existed —
notebooks/xrb_toy_wind_models.ipynb cell 8 fitted with
scatter=1.92174e-13 but called plot_phase without it; now passes it. An
audit of all fit_simulation → plot_phase/plot_multi_column_fits pairs
across the four notebooks found no others.
mcmc_lightcurve_fit.py. Status: uncommitted on branch add_generic_wind.
All tests run under the henv conda env (Python 3.13 / numpy 2.2 / emcee 3.1.6
/ arviz 1.0).
Audit of whether the Phase 20 fixes have analogues in the MCMC path. The MCMC
never had a multiplicative flux scale, so nothing to remove there, and its
per-sample phase-shift search (Phase 17) was already the model for the
chandra_phase_analysis one. plot_best_fit was already fully consistent — it
applies both the phase shift and f_scatter to the overlay curve, the
shift-search model, and the residual basis (verified to agree with the
likelihood to 1.7e-16 relative). Three genuine problems in the --fit-scatter
path were found and fixed.
1. compute_chi2_for_samples silently dropped f_scatter. It duplicated
the geometry/shape resolution and called model.evaluate directly, never
invoking _resolve_scatter. So every per-sample χ² written to
*_chi2.csv.gz was computed against a model missing the additive floor
whenever --fit-scatter was active. Measured bias on a 40-bin broad-band fit:
f_scatter |
correct χ² | χ² with floor dropped | error |
|---|---|---|---|
| 0 | 6228.11 | 6228.11 | 0.0 % |
| 1e-13 | 7697.40 | 6228.11 | −19.1 % |
| 3e-13 | 11120.15 | 6228.11 | −44.0 % |
| 1e-12 | 28183.54 | 6228.11 | −77.9 % |
Fixed by routing it through _evaluate_model (the same entry point the
likelihood uses) instead of re-implementing the evaluation, which also picks up
wind-shape resolution and any future additions automatically. The now-dead local
frozen binding was removed. Verified: all five reporting paths —
log_likelihood_chi2, compute_chi2_for_samples, plot_best_fit,
compute_bic_metrics, compute_pointwise_loglik — agree to machine precision
with f_scatter active.
2. --fit-scatter could not start at all under emcee. Walker
initialization clipped each dimension to
min + 0.01*|min| + 1e-12 … max - 0.01*|max| - 1e-12. That absolute 1e-12
epsilon is meaningless for a parameter whose natural scale is ~1e-13: with
f_scatter prior min = 0, the lower bound became exactly 1e-12, roughly 6×
larger than the entire plausible range, so every walker was clipped to the
same value. The column had zero variance, emcee.walkers_independent returned
False, and the run died with ValueError: Initial state has a large condition number. The inset is now relative to each parameter's own prior span
(pad = 1e-9 * (max - min)), with a fallback to the raw box if the inset
inverts. Regression-checked: every existing parameter's clip bounds move by
~1e-9 relative (numerically identical); only f_scatter changes. Added a final
guard that re-spreads any dimension that still collapses to a constant, with a
warning naming the parameter, so a badly scaled prior degrades loudly instead of
aborting inside emcee.
3. mcmc_summary.txt reported f_scatter: 0.000000. The summary and console
writers used fixed-point %.6f, which rounds a ~1e-14 flux floor to zero — it
read as "not fitted" even though the posterior was well constrained. Added
_fmt_val(value, width=0), which switches to %.6e for non-zero magnitudes
below 1e-4, and applied it to the marginal-posterior, derived-parameter and MAP
blocks plus the print_results console table.
Verified end-to-end under henv: --fit-scatter with both chi2 and
jitter likelihoods, --save-chi2 --compute-bic --smooth, produces all
artifacts; f_scatter is genuinely sampled (230 unique values spanning
8.2e-17 to 1.7e-13, no longer pinned); the χ² table is 100 % finite; and
plot_best_fit's reduced χ² at the MAP matches the χ² table row for that same
sample (239.2630 vs 239.2631).
4. plot_best_fit duplicated the evaluation logic. It called
eval_fn = getattr(model, 'evaluate_direct', model.evaluate) in three places and
added f_scatter by hand to each, and it re-implemented geometry resolution
(its own if reparam: stats['d1']… branch). Numerically correct, but it was the
same duplication pattern that caused problem 1, and the evaluate_direct
fallback was dead — it referenced PrecomputedModelGrid, removed in Phase 18.
Now it reconstructs the point-estimate theta in active-parameter order and
calls _evaluate_model through a local _eval_at(phases) helper, so geometry
(phys / reparam / kepler / frozen), wind shape, and f_scatter all resolve
through one implementation. f_scatter_best is obtained from
_resolve_scatter and used for display only. Verified behaviour-preserving:
plot_best_fit's χ² matches the likelihood exactly across eight
configurations — phys, phys + f_scatter, reparam, kepler,
jitter + f_scatter, smooth_pl shape fit, beta_law shape fit (with
R_star tied to R), and frozen R + f_scatter.
chandra_phase_analysis.py. Status: uncommitted on branch
add_generic_wind.
Phase 18 added phase_bin_data_snr to chandra_phase_analysis.py but wired the
CLI flag only into mcmc_lightcurve_fit.py, so the single-model script could not
use adaptive constant-counts bins from the command line. Added --counts-per-bin
with the same argument-presence semantics as the MCMC script:
--no-phase-bin > --counts-per-bin N > --n-phase-bins N > 50 fixed-width
bins.
--n-phase-binsdefault changed from50toNoneso mode selection is unambiguous; the effective fallback is still 50, so existing commands behave identically.- Supplying both binners is a
parser.error, as are non-positive values. --counts-per-binon data with nocountscolumn fails fast with a message pointing at--n-phase-bins, rather than raising from inside the binner.--min-points-per-binhelp now notes it applies to fixed-width binning only.- Variable bin widths already flowed through to horizontal error bars, since
plot_phasepicks up thewidthcolumn whenis_binned— no plotting change needed.
Verified on ObsID 15803 soft: --counts-per-bin 100 gives 87 bins averaging
104.3 counts (vs 60 fixed-width bins), and χ²/dof drops from 8.501 to 6.385 as
the noisy narrow eclipse-trough bins merge.
chandra_phase_analysis.py, mcmc_lightcurve_fit.py, new utils/utils.py,
utils/plot_utils.py, utils/__init__.py. Status: uncommitted on branch
add_generic_wind.
Both analysis scripts carried their own plotting functions, and
mcmc_lightcurve_fit.py imported its data-layer helpers from
chandra_phase_analysis.py — so the CLI script was simultaneously a library and
a front end, and mcmc depended on it for reasons unrelated to Chandra data.
Everything shared now lives in a utils package and neither script imports the
other.
| Module | Contents |
|---|---|
utils/utils.py (1071 lines) |
REF_EPOCH, ORBITAL_PERIOD, frac, fmt_val, band_label_from_column, detect_flux_columns, validate_sim_columns, read_observation, load_data, phase_bin_data, phase_bin_data_snr, smooth_lightcurve, estimate_scattered_flux, prepare_model_interpolator, model_from_wrap, evaluate_model_at_phases, interp_periodic_phases, obs_errors, fit_simulation. numpy/pandas/scipy only. |
utils/plot_utils.py (597 lines) |
plot_lightcurve_fit (the one drawing routine), plot_phase, plot_multi_column_fits, plot_corner, plot_trace, add_residual_panel, build_fit_title, format_reduced_chi2, half_widths. |
chandra_phase_analysis.py drops from 1639 to 457 lines and is now only the
argparse CLI plus an __all__ re-export block, so from chandra_phase_analysis import * — the notebooks' import style — is unchanged.
mcmc_lightcurve_fit.py drops from 4124 to 3952 lines.
plot_lightcurve_fit is the drawing code that used to be inlined in
mcmc_lightcurve_fit.plot_best_fit, generalized so both paths reach it. It
draws only what it is handed — observed arrays, an already-shifted overlay
curve, and the model evaluated at the observed phases — which is what makes it
usable from both:
plot_best_fitstill owns the MCMC-specific work (MAP-vs-median point estimate,_evaluate_model,_apply_best_phase_shift) and then delegates.plot_phaseis now a thin adapter: interpolatesim_dfat the givenshiftand additivescatter, then delegate. Its displayed-χ² self-check warning (Phase 20) moved with it.
Feature parity required generalizing three things: an optional obs_group
array (so Chandra's per-observation series still get their own colors and
legend entries), an ax/ax_res pair (so plot_multi_column_fits can still
draw into a grid cell, residual-panel-free), and a title escape hatch for the
no-model data plot. Two incidental fixes fell out: a model overlay with no error
column no longer produces an empty residual panel, and --counts-per-bin widths
reach the x-error bars through one code path instead of two.
Per request, plot_best_fit's wheat-colored annotation box is gone. The title
is now only the energy band and χ²/dof:
SOFT band — χ²/dof = 13.018
The information it carried is not lost — point estimate, phase_shift, f,
chi2_eff/dof and f_scatter are printed to stdout next to the existing
parameter table, which is also written to the run summary. plot_phase titles
gained the band label the same way: nfl_soft -> "SOFT band" via
band_label_from_column, so grid panels remain identifiable.
_interp_periodic_phases(mcmc) and_model_from_wrap(chandra) were two spellings of periodic model interpolation; both now live inutils.utilsasinterp_periodic_phases/model_from_wrap, documented as the array-in and prepared-interpolator forms of the same operation.plot_corner,plot_trace,add_residual_panel,fmt_valmoved out ofmcmc_lightcurve_fit.pyverbatim.mcmc_lightcurve_fit.pyno longer importsmatplotlib.pyplotorcornerat all — all figure work is behindutils.plot_utils.
py_compileon all five files;importof both scripts; star-import exports 23 names with nothing missing.- Chandra CLI, four modes on ObsID soft data: adaptive bins + fitted shift + smoothing (χ²/dof 30.605, no self-check warning), 3-column grid (98.007 / 15.071 / 46.100, one title per band), fixed-width no-fit plot, raw unbinned fit.
- MCMC: 24 walkers × 60 steps with
--fit-scatter --smoothproduced χ²/dof 13.0179;--replotfrom the saved chain reproduced 13.0179 exactly. - Consistency suite, 15 assertions: across
phys/phys, shift fixed/phys + f_scatter, the χ²/dofplot_best_fitreports equals the χ² of the arrays it hands the plotter tortol=1e-12, and the drawn overlay reinterpolated onto the observed phases matches the residual basis to ≤5.0e-4 relative (the Phase 20 regression would blow this up by orders of magnitude).fit_simulationrecovers an injectedshift=0.30to 5 decimals with χ²/dof = 0; the self-check guard stays silent whenshift/scatteragree and fires whenscatteris wrong. - All five call forms the notebooks use for
plot_phase/plot_multi_column_fitsrun against real data with no self-check warnings. (End-to-endnbconvertexecution still stops at the notebooks'import xspeccell — a pre-existinghenvlimitation, unrelated to this change.)
Not migrated: chandra_analysis_combined_flux.py keeps its own older copies of
fit_simulation / plot_phase / plot_multi_column_fits and still fits a
multiplicative flux scale. See Known rough edges.
mcmc_lightcurve_fit.py. Status: uncommitted on branch add_generic_wind.
load_existing_results validated the samples CSV against
REPARAM_PARAM_NAMES if reparam else PARAM_NAMES — the kepler argument it
accepted was never used. Replotting a --kepler run therefore failed with
Error: Samples file missing required geometry columns: ['d1', 'd2']
even though the file correctly held M_X, M_RH, r, R, i0, .... Fixed to select
the geometry block by mode, and two related gaps closed:
- Frozen parameters are not sampled and so are legitimately absent from the CSV;
they are now excluded from the required set (
--freeze R=2.0runs were unreplottable for the same reason). - The error now lists the columns actually present and, when they match another parameterization, names the flag to use.
--replot previously recovered only mode, frozen values, orbital_period_s
and likelihood from the chain .npz. Everything else fell back to argparse
defaults — including --data-dir, --obs-column, --time-column,
--counts-per-bin/--n-phase-bins, --lam, --dth, --d2h and all
--prior-*. Those options determine the observed arrays, so a replot that
missed them reported a χ²/dof for a dataset the posterior had never seen. On the
existing broad kepler run, replotting with default --lam/--flux-csv gave
χ²/dof = 0.916 against the true 1.064.
Every fit now writes <band>_<wind_model>_run_config.json into --output-dir:
{
"created": "2026-08-26T16:53:02-0400",
"command": "mcmc_lightcurve_fit.py --band broad --kepler ...",
"band": "broad",
"wind_model": "smooth_pl",
"args": { "...": "every argparse dest" }
}It is written before sampling starts, so it survives an interrupted run. On
--replot, apply_saved_run_config fills in every option the user did not type:
- Explicit flags always win. Which options were typed is determined by
scanning
sys.argvagainst the parser's option strings (including unambiguous prefixes), not by comparing against defaults — a user who explicitly passes the default value still overrides the saved config. replotandoutput_dirare never restored: the first would cancel the replot (a saved fit always recordedreplot=False), and the second is defined by where the config was found.--bandand--flux-csvchanged fromrequired=Trueto being validated after the restore, so--replotalone is a complete command. They are still required for a real fit.- Ambiguity is handled: with several configs in one directory, ones differing
only by
band(the--band allcase) restore identically and the first is used; genuinely different configs produce an error listing them. - Self-healing: a
--replotthat finds no config writes one from the options it just used, so pre-existing result directories become self-sufficient after one full-CLI replot.
As an independent backstop, replot_from_existing now compares the observed
point count against n_obs in the chain metadata and warns on a mismatch.
n_obs is also now stored unconditionally rather than only when --compute-bic
ran, so the check works for every run.
- The existing
mcmc_results/broad_smooth_plkepler + wind-shape +f_scatterrun replots without error. With the original CLI it reproduces the saved numbers exactly: χ²/dof 1.06423 (summary recorded 1.064) and BIC 195.673 (logL_hat=-77.689, k=8, n=154). - A second
--replot --output-dir mcmc_resultswith no other arguments reproduces the same 1.06423 / 195.673 from the auto-written config. - Fresh 24×40-step soft fit: config written, then bare
--replotround-tripped χ²/dof = 13.2843 identically. --replot --lam 0.9reportskept from the command line: --lam, confirming precedence.- Mismatch guard fires as intended: forcing
--counts-per-bin 300on the broad run warns "Replot is using 53 observed points but the saved fit used 154". --band/--flux-csvstill error out when missing on a non-replot run.
mcmc_lightcurve_fit.py, utils/utils.py. Status: uncommitted on branch
add_generic_wind. Behaviour-preserving throughout — every regression number
below is bit-identical to the pre-cleanup run.
mcmc_lightcurve_fit.py: 4246 -> 3553 lines (-693, -16%).
| Moved | Lines |
|---|---|
resolve_band_directory, load_observed_lightcurves |
90 |
build_phase_shift_terms, apply_best_phase_shift + the DEFAULT_PHASE_SHIFT_* constants |
95 |
run-config persistence: save_run_config, find_run_configs, apply_saved_run_config, _explicit_cli_dests, _jsonable, run_config_path |
165 |
save_samples_csv_chunked |
23 |
The phase-shift search now sits next to fit_simulation, which runs the same
coarse-scan-then-refine algorithm on a tabulated model — both spellings of the
idea are in one place. utils/utils.py gained a stdlib-only dependency
footprint (argparse, csv, json, shlex, time) and stays free of any
import from either analysis script.
phase_bin_data / phase_bin_data_snr hardcoded rate/error as their output
column names, so the MCMC script carried two 25-50 line wrappers that renamed
flux/flux_err in and back out again. The binners now name their value
columns after the rate_column/error_column arguments they were given, so the
MCMC path calls them directly:
bin_cols = dict(rate_column='flux', error_column='flux_err')
obs_df = phase_bin_data_snr(obs_df, counts_per_bin=..., **bin_cols)chandra_phase_analysis passes rate/error and is unaffected. -73 lines.
- Likelihood front half.
log_likelihood_chi2andlog_likelihood_jittershared ~23 lines of identical model-evaluation + phase-shift-alignment preamble. Extracted as_aligned_model_flux; each likelihood is now its own formula plus a call.f = exp(log_f)moved after the alignment (it does not depend on the model, so the result is unchanged). - CLI plumbing.
_phase_shift_opts(args)replaces 8 copies of the same three-linefit_phase_shift=/phase_shift_grid_size=/phase_shift_eval_points=getattrtriple._smooth_plot_kwargs(smoothed, args)replaces two 13-line blocks ofsmoothed[...] if smoothed is not None else None. --prior-*definitions. Nine near-identical 8-lineadd_argumentcalls became a table-driven loop reading defaults straight fromDEFAULT_PRIORS/REPARAM_PRIORS/KEPLER_PRIORS, so the help text cannot drift from the values actually used. It already had:--prior-radvertisedmax=0.01while the code used0.1. -50 lines, one stale-help bug fixed.- Prior parsing. The geometry and wind-shape override loops were the same
20 lines twice; now one
_parse_prior_overrides(parser, args, names). replot_from_existingopened*_chain.npztwice, in two separate try/except blocks, to readmode/frozen/n_obsand thenlikelihood. Merged into one read.- Label construction. The 17-line "name -> corner label"
if/elifchain in the replot path became_labels_for_names(names, mode), built on the existingget_mode_name_label. _default_priors(reparam, kepler)replaces three copies of theif kepler / elif reparam / elseprior-selection block (and upgrades two of them from.copy()todeepcopy, so a prior override can no longer reach the module-level dict)._maybe_save_chi2(...)replaces two 15-line--save-chi2blocks.
compute_pointwise_loglik(84 lines) — the per-observation log-likelihood matrix that WAIC/LOO consumed. Traced before removing: it was added with its caller inb867ff1and called for six commits from insiderun_arviz_diagnostics, gated on--compute-waic, feedinglog_lik_dict = {"obs": ll[np.newaxis, :, :]}into_build_inference_dataand thenceaz.waic/az.loo. Phase 15 (7116302) deleted that call when BIC replaced WAIC/LOO —L̂from the run's own likelihood at the max-log-prob sample needs no pointwise terms — leaving--compute-waicas a shim; Phase 18 (b376585) deleted the flag and shim outright. Unreachable ever since, with the only remaining trace being_build_inference_data's optionallog_lik_dictparameter, whose sole caller passesNone. Note it was maintained dead code (it picked up the Phase 17 phase-shift alignment and lost itsstudenttbranch with Phase 18), so it is a working starting point if LOO is ever wanted back:git show 19dc637:mcmc_lightcurve_fit.py._to_physical— a "backward-compatible wrapper for legacy callers" with no callers anywhere in the repo.- A dead
import gzipinsidecompute_chi2_for_samples(the gzip write goes throughto_csv(compression='gzip')), and now-unused module imports (csv,json,shlex,sys,glob,pickle).
Thirteen multi-line rationale comments (7-10 lines each) cut to 1-3 lines,
keeping the why and dropping the retold debugging narrative — e.g. the
walker-clip block went from eight lines to three while still naming the failure
it prevents (f_scatter collapsing and aborting emcee on the condition number).
-37 lines.
- Bare
--replot --output-dir mcmc_resultson the real broad kepler + wind-shape +f_scatterrun: χ²/dof 1.06423, BIC 195.673 (logL_hat=-77.689, k=8, n=154) — identical to Phase 24. - Consistency suite: 18/18 pass, including three
plot_best_fitconfigurations agreeing with the drawn arrays tortol=1e-12at the same values as before (1.117909384 / 1.118796619 / 1.392422282), and new checks that the moved functions resolve toutils.utilsand the binners preserve column names. - Fresh fits round-trip through
--replotexactly in every mode:chi2+f_scatter(12.9794),jitter+f_scatter+--save-chi2+--compute-bic(16.698, BIC -2011.323),--reparam(5.71919),--kepler --fit-wind-shape --freeze R=13.0(9.82407). - Chandra CLI, all four modes: 30.605 / (98.007, 15.071, 46.100) / 40 bins / 3.129 — unchanged.
- All five notebook
plot_phase/plot_multi_column_fitscall forms run with no self-check warnings. ArviZ, corner and trace paths exercised. - No unused imports remain in any of the four files.
Plan: reference_epoch_recalibration_ae1cf98a.plan.md.
Per Laycock et al. 2015 (stu2151.pdf, §4), T0 = 278801348 s = MJD 54040.87 is
the mid-eclipse time of ObsID 07082, defined to lie at phase 0.5, with a
full eclipse width ~0.2 in phase (~7 h); the paper folds with
φ = (t - T0 - 100000P)/P. The codebase formula frac((t - T0)/P) instead puts
that reference mid-eclipse at phase 0.0, and the precomputed phase column
stored in the CIAO files reproduces neither exactly.
A read-only study script (find_reference_epoch.py) located mid-eclipse two
ways — anchored on ObsID 15803 (the only observation with a full clean eclipse,
~1.73 d > one 1.45 d orbit) via sliding-window / threshold / trapezoid-fit, and
via a joint all-observation scan over trial phase offsets maximizing eclipse
contrast — with the period held fixed at 125431 s.
Outcome: a corrected epoch of 278800407.267 s, which sits commented out
next to REF_EPOCH in chandra_phase_analysis.py; the active value is still
278801348. In practice the Phase 17 per-sample phase-shift search absorbs the
offset, so this now mainly affects the interpretability of plotted phases. The
study script itself is not present in the working tree.
| File | Lines | Status | Description |
|---|---|---|---|
xrb_lightcurve.py |
2115 | active | Forward model: profiles, Numba GL LOS kernels, simulate_lightcurve, physical back-calculation helpers. |
mcmc_lightcurve_fit.py |
3553 | active | emcee/zeus MCMC: ParamSpec (phys/reparam/kepler), freeze, wind-shape, f_scatter, phase-shift search, BIC, ArviZ, replot. Direct evaluator only. |
chandra_phase_analysis.py |
457 | active | CLI front end for the single-model χ² fit; re-exports the shared utils/ API for the notebooks. |
utils/utils.py |
1474 | active | Shared layer: ephemeris, loading, both binners, smoothing, periodic model interpolation + phase-shift search, fit_simulation, run-config persistence. |
utils/plot_utils.py |
597 | active | All plotting, built on the single plot_lightcurve_fit drawing routine. |
compute_flux_vs_nH.py |
934 | active | XSPEC table generator (flux vs nH). |
xspec_fit_mcmc.py |
702 | active | XSPEC-side MCMC for spectral fits. |
chandra_analysis_combined_flux.py |
539 | active | Pre-folded combined-flux phase analysis. |
plot_results.py |
272 | active | Standalone plotting of simulation CSVs. |
compute_count_to_flux_factor.py |
147 | active | Count-rate → flux conversion factor. |
example_usage.py |
96 | active | Programmatic simulate_lightcurve examples. |
compare_models.sh, convert_fits_to_txt_heasoft.sh,
xspec_tbabs_fit_results.xcm, rkp_run_w_mcmc_cmds.sh (command scrapbook —
contains dead flags from earlier phases).
Missing from the working tree despite being referenced:
compare_absorption_models.xcm (invoked by compare_models.sh),
xspec_get_conversion_factors_tbabs.xcm, get_xspec_nH.py,
utils/get_conversion_factors.sh, find_reference_epoch.py.
Now a package (__init__.py). utils.py and plot_utils.py are library code
imported by both analysis scripts (see Core above). Standalone data-prep
scripts, not part of the package API: add_flux_simple.py,
add_flux_to_lightcurves.py, convert_fits_to_txt.py,
get_average_count_rates.py, test_flux_methods.py,
benchmark_mcmc_performance.py.
xrb_toy_wind_models.ipynb (active wind-profile exploration),
xrb_model_analysis.ipynb, xrb_model_analysis_single_15803.ipynb,
xrb_flux_nH_abs.ipynb.
PROJECT.md (current-state reference),
changes_tracked.md (this file),
mcmc_chi2_jitter_explanation.md, PERFORMANCE_VALIDATION_REPORT.md,
FLUX_INTEGRATION_SUMMARY.md, FLUX_METHODS_QUICKREF.md,
XSPEC_CONVERSION_GUIDE.md, FITS_CONVERSION_README.md,
QUICK_START_FLUX_CONVERSION.md, CONVERSION_WORKFLOW.md,
README_CONVERSION_TOOLS.md.
Reference PDFs: Wind_Density.pdf (profile equations),
stu2151.pdf (Laycock et al. 2015 ephemeris).
Stale: README.md, MIGRATION_SUMMARY.md — still describe the original
R→Python port and removed API (--lam2, flx2/fl2, nfl_*_av/_cv,
pho_count_*).
unified_wind_model_77726ced (Phase 7),
mcmc_performance_and_statistics_8989cd39 (Phase 8),
mcmc_convergence_improvements_c648afb3 (Phase 9),
mcmc_wind_shape_params_8b9c89d2 (Phase 10),
flux_t_error_and_unbinned_mcmc_1bf81aa9 (Phase 12),
wind_normalization_constants_76f1ef51 (Phase 13),
mcmc_speed_memory_optimization_68fc497a (Phase 15),
freeze_params_and_kepler_3368d554 (Phase 16),
adaptive_constant-snr_binning_249c8cba (Phase 18),
gaussian_phase_smoothing_reference_d1a46172 (Phase 19),
reference_epoch_recalibration_ae1cf98a (side investigation).
legacy_r_code/ (new11.R, grid4.R, wind_los2.R, density_fnc.R),
light_curve_model_opt_bw.R.
See PROJECT.md for the full current-state reference (module-level API, data layout, outputs, and known rough edges). Summary:
Environment: henv conda env (heasoft/XSPEC + python deps including
numba, emcee, zeus-mcmc, arviz, corner, astropy, scipy>=1.12).
requirements.txt is incomplete — it predates the numba/arviz/zeus additions.
Working spectral model: TBabs × powerlaw, nH = 0.75×10²² cm⁻², Γ = 1.86, χ²_red = 1.52.
Default forward model: smooth_pl wind, single nfl_{band} flux column,
Gauss-Legendre mega-kernel (~60 ms per light curve). Note Rb=5, p=4 throughout,
but Delta defaults to 1.0 on the xrb_lightcurve.py CLI and 2.0 in
default_wind_params / WIND_SHAPE_FIXED.
MCMC defaults: phys mode (d1, d2, r, R, i0), chi2 likelihood,
emcee, 50 fixed-width phase bins, per-sample phase-shift alignment on,
direct evaluator (no grid path exists).
# Build/refresh the XSPEC flux-vs-nH table
python compute_flux_vs_nH.py --specdir ./data/IC10X1_spec --model tbabs \
--bands broad soft medium hard --out_csv flux_vs_nH_tbabs_broad.csv
# FITS pipeline
./convert_fits_to_txt_heasoft.sh
python utils/add_flux_simple.py \
data/IC_10_X1_LC/Broad_converted/ data/IC_10_X1_LC/Broad_with_flux/ 1.500509e-11
# Simulate one light curve
python xrb_lightcurve.py --flux_method interpolate \
--flux_csv flux_vs_nH_tbabs_broad.csv \
--wind-model smooth_pl --Rb 5 --p 4 --Delta 1 \
--i0 12.0 --lam 0.572385 --output sim_broad.csv
# Single-model χ² fit + smoothed overlay + residual panel
python chandra_phase_analysis.py --data-dir data/IC_10_X1_LC_CIAO/broad \
--obs-column flux_t --time-column t_raw \
--fit --sim-file sim_broad.csv --rescale --smooth \
--n-phase-bins 100 --output fit_broad.png
# MCMC: geometry only, reparameterized, adaptive constant-SNR bins
python mcmc_lightcurve_fit.py --band broad \
--flux-csv flux_vs_nH_tbabs_broad.csv --data-dir data/IC_10_X1_LC_CIAO \
--obs-column flux_t --time-column t_raw \
--wind-model smooth_pl --reparam --likelihood chi2 \
--counts-per-bin 100 --sampler zeus --dth 4.0 \
--n-walkers 24 --n-steps 20000 --n-burn 2000 \
--compute-bic --smooth --output-dir mcmc_results/broad/smooth_pl/geom
# MCMC: Kepler masses + wind shape + free scattered-flux floor
python mcmc_lightcurve_fit.py --band broad \
--flux-csv flux_vs_nH_tbabs_broad.csv --data-dir data/IC_10_X1_LC_CIAO \
--obs-column flux_t --time-column t_raw \
--wind-model smooth_pl --fit-wind-shape --kepler --fit-scatter \
--likelihood jitter --counts-per-bin 100 \
--sampler zeus --n-walkers 24 --n-steps 21000 --n-burn 2000 \
--n-threads 4 --dth 4.0 \
--prior-MX 30,10,1,100 --prior-MRH 20,10,1,100 \
--prior-Rb 6,3,3,80 --prior-p 4,2,2,8 \
--compute-bic --output-dir mcmc_results/broad/smooth_pl/kepler_shape
# MCMC: raw unbinned 100s data (pair with jitter)
python mcmc_lightcurve_fit.py --band soft \
--flux-csv flux_vs_nH_tbabs_soft.csv --data-dir data/IC_10_X1_LC_CIAO \
--obs-column flux_t --time-column t_raw \
--no-phase-bin --likelihood jitter --output-dir mcmc_results/soft/raw_jitter
# Freeze parameters out of the chain
python mcmc_lightcurve_fit.py --band broad --flux-csv flux_vs_nH_tbabs_broad.csv \
--reparam --freeze q=0.5,Rb=6.0 --n-steps 2000 \
--output-dir mcmc_results/broad/frozen
# Re-plot / recompute BIC from saved results (pass the same data/binning flags)
python mcmc_lightcurve_fit.py --band broad --flux-csv flux_vs_nH_tbabs_broad.csv \
--data-dir data/IC_10_X1_LC_CIAO --obs-column flux_t --time-column t_raw \
--wind-model smooth_pl --counts-per-bin 100 \
--replot --compute-bic --output-dir mcmc_results/broad/smooth_pl/geomUncommitted work in progress (branch add_generic_wind): Phase 19 —
Gaussian smoothing, f_scatter, and residual panels — is implemented in
chandra_phase_analysis.py, mcmc_lightcurve_fit.py, xrb_lightcurve.py, and
xrb_toy_wind_models.ipynb but not yet committed.
Last Updated: August 12, 2026
Maintainer: R. Panchal