sed-model 0.3.2__tar.gz → 0.4.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. {sed_model-0.3.2 → sed_model-0.4.0}/CHANGELOG.md +58 -0
  2. {sed_model-0.3.2 → sed_model-0.4.0}/CITATION.cff +1 -1
  3. {sed_model-0.3.2 → sed_model-0.4.0}/PKG-INFO +2 -2
  4. {sed_model-0.3.2 → sed_model-0.4.0}/README.md +1 -1
  5. {sed_model-0.3.2 → sed_model-0.4.0}/docs/guide/forward.md +1 -1
  6. {sed_model-0.3.2 → sed_model-0.4.0}/docs/guide/grids-and-filters.md +1 -1
  7. sed_model-0.4.0/docs/guide/inverse.md +116 -0
  8. {sed_model-0.3.2 → sed_model-0.4.0}/docs/index.md +1 -1
  9. sed_model-0.4.0/docs/testing.md +62 -0
  10. {sed_model-0.3.2 → sed_model-0.4.0}/fortran/cc_api.f90 +20 -9
  11. {sed_model-0.3.2 → sed_model-0.4.0}/fortran/private/bolometric.f90 +2 -2
  12. {sed_model-0.3.2 → sed_model-0.4.0}/fortran/private/synthetic.f90 +28 -34
  13. {sed_model-0.3.2 → sed_model-0.4.0}/fortran/public/colors_def.f90 +5 -2
  14. {sed_model-0.3.2 → sed_model-0.4.0}/meson.build +2 -1
  15. sed_model-0.4.0/sed_model/_inv_core.py +565 -0
  16. {sed_model-0.3.2 → sed_model-0.4.0}/sed_model/filters.py +35 -21
  17. {sed_model-0.3.2 → sed_model-0.4.0}/sed_model/forward.py +14 -1
  18. {sed_model-0.3.2 → sed_model-0.4.0}/sed_model/grid.py +76 -16
  19. {sed_model-0.3.2 → sed_model-0.4.0}/sed_model/inverse.py +189 -26
  20. {sed_model-0.3.2 → sed_model-0.4.0}/sed_model/io.py +66 -0
  21. {sed_model-0.3.2 → sed_model-0.4.0}/tests/test_inverse.py +174 -0
  22. sed_model-0.4.0/tests/test_kernels.py +309 -0
  23. sed_model-0.3.2/docs/guide/inverse.md +0 -91
  24. sed_model-0.3.2/docs/testing.md +0 -38
  25. sed_model-0.3.2/tests/test_kernels.py +0 -167
  26. {sed_model-0.3.2 → sed_model-0.4.0}/.f2py_f2cmap +0 -0
  27. {sed_model-0.3.2 → sed_model-0.4.0}/.gitattributes +0 -0
  28. {sed_model-0.3.2 → sed_model-0.4.0}/.github/workflows/build.yml +0 -0
  29. {sed_model-0.3.2 → sed_model-0.4.0}/.github/workflows/docs.yml +0 -0
  30. {sed_model-0.3.2 → sed_model-0.4.0}/.gitignore +0 -0
  31. {sed_model-0.3.2 → sed_model-0.4.0}/CODE_OF_CONDUCT.md +0 -0
  32. {sed_model-0.3.2 → sed_model-0.4.0}/CONTRIBUTING.md +0 -0
  33. {sed_model-0.3.2 → sed_model-0.4.0}/LICENSE +0 -0
  34. {sed_model-0.3.2 → sed_model-0.4.0}/MIGRATION.md +0 -0
  35. {sed_model-0.3.2 → sed_model-0.4.0}/Makefile +0 -0
  36. {sed_model-0.3.2 → sed_model-0.4.0}/SECURITY.md +0 -0
  37. {sed_model-0.3.2 → sed_model-0.4.0}/demos/api_usage.py +0 -0
  38. {sed_model-0.3.2 → sed_model-0.4.0}/demos/demo_forward.py +0 -0
  39. {sed_model-0.3.2 → sed_model-0.4.0}/demos/demo_inverse.py +0 -0
  40. {sed_model-0.3.2 → sed_model-0.4.0}/demos/demo_param_modes.py +0 -0
  41. {sed_model-0.3.2 → sed_model-0.4.0}/demos/extinction_prescription_comparison.py +0 -0
  42. {sed_model-0.3.2 → sed_model-0.4.0}/demos/plot_utils.py +0 -0
  43. {sed_model-0.3.2 → sed_model-0.4.0}/demos/sed_playground_demo.py +0 -0
  44. {sed_model-0.3.2 → sed_model-0.4.0}/demos/simbad_query_inverse.py +0 -0
  45. {sed_model-0.3.2 → sed_model-0.4.0}/demos/single_test.py +0 -0
  46. {sed_model-0.3.2 → sed_model-0.4.0}/docs/SED_Model_logo.jpeg +0 -0
  47. {sed_model-0.3.2 → sed_model-0.4.0}/docs/api.md +0 -0
  48. {sed_model-0.3.2 → sed_model-0.4.0}/docs/demos/api-usage.md +0 -0
  49. {sed_model-0.3.2 → sed_model-0.4.0}/docs/demos/extinction-comparison.md +0 -0
  50. {sed_model-0.3.2 → sed_model-0.4.0}/docs/demos/forward.md +0 -0
  51. {sed_model-0.3.2 → sed_model-0.4.0}/docs/demos/index.md +0 -0
  52. {sed_model-0.3.2 → sed_model-0.4.0}/docs/demos/inverse.md +0 -0
  53. {sed_model-0.3.2 → sed_model-0.4.0}/docs/demos/param-modes.md +0 -0
  54. {sed_model-0.3.2 → sed_model-0.4.0}/docs/demos/playground.md +0 -0
  55. {sed_model-0.3.2 → sed_model-0.4.0}/docs/favicon.png +0 -0
  56. {sed_model-0.3.2 → sed_model-0.4.0}/docs/guide/extinction.md +0 -0
  57. {sed_model-0.3.2 → sed_model-0.4.0}/docs/guide/parameters.md +0 -0
  58. {sed_model-0.3.2 → sed_model-0.4.0}/docs/guide/results-io.md +0 -0
  59. {sed_model-0.3.2 → sed_model-0.4.0}/docs/installation.md +0 -0
  60. {sed_model-0.3.2 → sed_model-0.4.0}/docs/quickstart.md +0 -0
  61. {sed_model-0.3.2 → sed_model-0.4.0}/fortran/private/colors_utils.f90 +0 -0
  62. {sed_model-0.3.2 → sed_model-0.4.0}/fortran/private/hermite_interp.f90 +0 -0
  63. {sed_model-0.3.2 → sed_model-0.4.0}/fortran/private/hermite_interp_bounded.f90 +0 -0
  64. {sed_model-0.3.2 → sed_model-0.4.0}/fortran/private/linear_interp.f90 +0 -0
  65. {sed_model-0.3.2 → sed_model-0.4.0}/fortran/public/colors_lib.f90 +0 -0
  66. {sed_model-0.3.2 → sed_model-0.4.0}/meson_options.txt +0 -0
  67. {sed_model-0.3.2 → sed_model-0.4.0}/mkdocs.yml +0 -0
  68. {sed_model-0.3.2 → sed_model-0.4.0}/pyproject.toml +0 -0
  69. {sed_model-0.3.2 → sed_model-0.4.0}/sed_model/__init__.py +0 -0
  70. {sed_model-0.3.2 → sed_model-0.4.0}/sed_model/_cc_ext.py +0 -0
  71. {sed_model-0.3.2 → sed_model-0.4.0}/sed_model/params.py +0 -0
  72. {sed_model-0.3.2 → sed_model-0.4.0}/sed_model/sed_extinction.py +0 -0
  73. {sed_model-0.3.2 → sed_model-0.4.0}/tests/_data_paths.py +0 -0
  74. {sed_model-0.3.2 → sed_model-0.4.0}/tests/fortran/smoke.f90 +0 -0
  75. {sed_model-0.3.2 → sed_model-0.4.0}/tests/test_forward.py +0 -0
  76. {sed_model-0.3.2 → sed_model-0.4.0}/tools/ci/build_fortran_lib.sh +0 -0
  77. {sed_model-0.3.2 → sed_model-0.4.0}/tools/post_install_fortran.py +0 -0
@@ -1,5 +1,63 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.4.0] — 2026-10-25
4
+
5
+ Validated against the MESA `colors` module, MSG and MIST (see
6
+ [Testing and Validation](docs/testing.md)); forward parity fixes and a rebuilt
7
+ inverse model.
8
+
9
+ ### Forward model
10
+
11
+ Forward-model parity fixes found by sweeping against MESA `colors` (see
12
+ `tests/test_kernels.py`). SEDs and magnitudes otherwise agree with MESA to
13
+ rounding error.
14
+
15
+ - **`bol_mag` is `99.0` when the integrated flux is not positive** (MESA's
16
+ value), instead of `-99.9`.
17
+ - **`nearest_grid_distance` now equals MESA's `Interp_rad`**: the distance to
18
+ the nearest tabulated model (empty cube nodes excluded), with axes
19
+ normalised by the range of the tabulated models. Previously it measured to
20
+ every node of the rectangular cube.
21
+ - **`run_forward` warns** (`UserWarning`) when the query's grid cell touches a
22
+ node with no model; the interpolated result there is unreliable.
23
+ `AtmosphereGrid.cell_has_empty_nodes` exposes the check.
24
+ - **`clamped` is no longer set by `interp_method='linear'`** merely because some
25
+ wavelength bins fell back to the nearest node at a populated, in-bounds point.
26
+
27
+ ### Inverse model
28
+
29
+ The new fast evaluator calls the same Fortran kernels and agrees with
30
+ `run_forward` to 1e-9 mag.
31
+
32
+ ### Changed (default behaviour)
33
+
34
+ - **`run_inverse` now initialises walkers with a global search + Levenberg-
35
+ Marquardt refinement (`init='auto'`)** instead of a 2 % Gaussian ball at the
36
+ middle of the prior box. For the full Kurucz grid that ball sat in an empty
37
+ grid corner, and walkers that started there often never left it. In an
38
+ inject-and-recover benchmark (16-24 stars, 0.005-0.05 mag noise, Johnson /
39
+ optical+NIR / 11-band sets) the old default gave, with Av free, a Teff error
40
+ MAD of 10.9 % (now 1.6 %) and 12 % of fits with logg off by more than 4 sigma
41
+ (now 0.7 %); with Av and distance free the 95 % Teff coverage went from 0.72
42
+ to 0.91. Giving `p0_centre` (or `init='ball'`) restores the old behaviour
43
+ exactly, including emcee's stretch move.
44
+ - With `init='auto'` the default sampler move is a differential-evolution
45
+ mixture (`moves='de'`), which halves the autocorrelation time and lets
46
+ walkers rebalance between modes; `moves='stretch'` restores the old move.
47
+ - Grid nodes holding an all-zero SED (unphysical corners of Kurucz grids) now
48
+ give a zero posterior instead of finite magnitudes of ~770.
49
+ - The likelihood no longer builds a full `ForwardResult` per call.
50
+
51
+ ### Added
52
+
53
+ - `run_inverse` options: `init`, `sys_floor`, `fit_jitter` / `jitter_max`,
54
+ `priors` (`normal`, `halfnormal`, `parallax`, `edsd`, or callables),
55
+ `upper_limits`, `auto_stop`, `moves`, `pool`; `n_burn=None` for automatic
56
+ burn-in. Non-finite magnitudes are dropped with a warning.
57
+ - `InverseResult`: `converged`, `n_eff`, `chi2_best`, `n_data`, `init_info`
58
+ (saved and loaded with `.npz`), and `credible_interval(name, level, method)`
59
+ with highest-posterior-density intervals.
60
+
3
61
  ## [0.3.2] — 2026-10-25
4
62
 
5
63
  - **Filter files from SED_Tools 0.4.0 load again.** SED_Tools 0.4.0 puts its
@@ -7,7 +7,7 @@ authors:
7
7
  - family-names: "Miller"
8
8
  given-names: "Niall"
9
9
  email: "niall.j.miller@gmail.com"
10
- version: "0.3.2"
10
+ version: "0.4.0"
11
11
  repository-code: "https://github.com/nialljmiller/SED_Model"
12
12
  url: "https://nialljmiller.github.io/SED_Model/"
13
13
  license: MIT
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: sed-model
3
- Version: 0.3.2
3
+ Version: 0.4.0
4
4
  Summary: Forward and inverse stellar photometry from SED grids.
5
5
  Keywords: astronomy,astrophysics,stellar astronomy,synthetic photometry,spectral energy distributions,sed,stellar parameters,bayesian inference,mcmc
6
6
  Author-Email: Niall Miller <niall.j.miller@gmail.com>
@@ -36,7 +36,7 @@ Description-Content-Type: text/markdown
36
36
 
37
37
  SED_Model is a Python package for computing observer-ready synthetic spectral energy distributions (SEDs) and broadband magnitudes from stellar atmosphere grids, and for recovering stellar parameters from observed photometry via Bayesian MCMC inference. It is designed to work directly downstream of `SED_Tools` and as a standalone drop-in for workflows that use MESA-style atmosphere grids and filter transmission curves.
38
38
 
39
- Version: `0.3.2`
39
+ Version: `0.4.0`
40
40
 
41
41
  **Documentation:** https://nialljmiller.github.io/SED_Model/
42
42
 
@@ -8,7 +8,7 @@
8
8
 
9
9
  SED_Model is a Python package for computing observer-ready synthetic spectral energy distributions (SEDs) and broadband magnitudes from stellar atmosphere grids, and for recovering stellar parameters from observed photometry via Bayesian MCMC inference. It is designed to work directly downstream of `SED_Tools` and as a standalone drop-in for workflows that use MESA-style atmosphere grids and filter transmission curves.
10
10
 
11
- Version: `0.3.2`
11
+ Version: `0.4.0`
12
12
 
13
13
  **Documentation:** https://nialljmiller.github.io/SED_Model/
14
14
 
@@ -85,7 +85,7 @@ F_{\rm band} = \frac{\int F(\lambda)\, T(\lambda)\, \lambda\, d\lambda}{\int T(\
85
85
  m = -2.5 \log_{10}\!\left( F_{\rm band} / F_{zp} \right)
86
86
  \]
87
87
 
88
- with the zero-point \(F_{zp}\) precomputed per filter and system at load time ([Grids and Filters](grids-and-filters.md#zero-points)). Failed integrations or non-positive fluxes produce a sentinel magnitude of `-99.9` with a non-zero error flag rather than crashing.
88
+ with the zero-point \(F_{zp}\) precomputed per filter and system at load time ([Grids and Filters](grids-and-filters.md#zero-points)). Failed integrations or non-positive fluxes produce a non-zero error flag rather than crashing: band magnitudes become `NaN` in `ForwardResult.magnitudes`, and the bolometric magnitude is `99.0` (MESA's value) when the integrated flux is not positive.
89
89
 
90
90
  ## Batch evaluation
91
91
 
@@ -56,7 +56,7 @@ Filter files are two-column (wavelength Å, transmission 0–1); `#` comments ar
56
56
 
57
57
  ### Zero-points
58
58
 
59
- Each `Filter` carries precomputed photon-counting zero-points using the same integrals as MESA `colors/private/synthetic.f90`:
59
+ Each `Filter` carries precomputed zero-points using the same integrals as MESA `colors/private/synthetic.f90` (photon counting, as MESA does by default). The photon-counting forms are:
60
60
 
61
61
  | System | Definition |
62
62
  |---|---|
@@ -0,0 +1,116 @@
1
+ # Inverse Model
2
+
3
+ The inverse model recovers a posterior over stellar parameters from observed broadband photometry, using [`emcee`](https://emcee.readthedocs.io/) ensemble MCMC.
4
+
5
+ ## Statistical model
6
+
7
+ - **Prior** — flat within the bounds of each free parameter (from [`FitParams`](parameters.md)); \(-\infty\) outside.
8
+ - **Likelihood** — Gaussian per filter, summed:
9
+
10
+ \[
11
+ \ln \mathcal{L} = -\frac{1}{2} \sum_i \left[ \frac{(m_i^{\rm obs} - m_i^{\rm model})^2}{\sigma_i^2} + \ln\left(2\pi \sigma_i^2\right) \right]
12
+ \]
13
+
14
+ Model magnitudes come from the **same Fortran kernels, called in the same order, as `run_forward`** (interpolate, dilute, extinguish, convolve), minus the parts that do not affect magnitudes (bolometric integral, nearest-grid diagnostic, rebuilding the extinction model every call). A test checks agreement with `run_forward` to 1e-9 mag. Any exception inside the evaluation, a non-finite model magnitude, or a query that touches an *empty* grid node (Kurucz-type grids store unphysical corners, e.g. hot + low gravity, as all-zero SEDs that the forward model turns into finite magnitudes near 770) returns \(-\infty\) so the walker simply moves away.
15
+
16
+ Optional extensions, all off by default:
17
+
18
+ - `sys_floor=0.03` adds a systematic error floor (mag) in quadrature to every uncertainty.
19
+ - `fit_jitter=True` samples one extra common term `sigma_sys` (flat on `[0, jitter_max]`), added in quadrature to all uncertainties; it appears as the last column of `samples`.
20
+ - `priors={'d': ('parallax', 2.0, 0.05), 'a_v': ('halfnormal', 0.5), ...}` multiplies in extra priors (`normal`, `halfnormal`, `parallax` and `edsd` for distance, or any callable returning a log prior).
21
+ - `upper_limits=[False, True, ...]` flags bands whose entry is a magnitude lower limit; they contribute \(\ln\Phi((m^{\rm model}-m^{\rm obs})/\sigma)\).
22
+ - Non-finite magnitudes or uncertainties are dropped (with a warning) instead of poisoning the likelihood.
23
+
24
+ ## Basic usage
25
+
26
+ ```python
27
+ from sed_model import run_inverse
28
+
29
+ posterior = run_inverse(
30
+ obs_magnitudes=[5.03, 4.17],
31
+ obs_uncertainties=[0.01, 0.02],
32
+ filter_names=["G", "J"],
33
+ R=6.957e10, # cm — always required, not sampled
34
+ d=3.086e19, # cm — convenience: builds default FitParams with d fixed
35
+ grid=grid,
36
+ filters=filters,
37
+ mag_system="AB",
38
+ n_walkers=32, n_steps=2000, n_burn=500, n_thin=1,
39
+ seed=42,
40
+ progress=True,
41
+ )
42
+ ```
43
+
44
+ With no `fit_params` supplied, a default is built from the grid: `Teff`, `logg`, `[M/H]` free across the full grid, `Av` fixed at 0, distance fixed at `d` (default 1 pc). If `fit_params` **is** supplied, the `d` keyword is ignored — set distance through `fit_params`.
45
+
46
+ ## Choosing what to sample
47
+
48
+ ```python
49
+ from sed_model import fit_params_from_grid
50
+ from sed_model.params import PC_TO_CM
51
+
52
+ # Av free with flat prior over [0, 3] mag
53
+ params = fit_params_from_grid(grid, a_v=(0.0, 3.0), d_cm=500 * PC_TO_CM)
54
+
55
+ # Av and distance both free — expect a correlated Av–d posterior
56
+ params = fit_params_from_grid(
57
+ grid,
58
+ a_v=(0.0, 2.0),
59
+ d_cm=(100 * PC_TO_CM, 2000 * PC_TO_CM),
60
+ )
61
+
62
+ posterior = run_inverse(..., fit_params=params)
63
+ ```
64
+
65
+ When `Av` is active (free, or fixed > 0) and no `extinction` model is supplied, a default Fitzpatrick (1999) model is created automatically; pass `extinction_law=...` or a full `ExtinctionModel` to control the prescription. See [Extinction](extinction.md).
66
+
67
+ ## Walker initialisation
68
+
69
+ By default (`init='auto'`) the sampler does not start from a ball at the middle of the prior box (which, for the full Kurucz grid, is a 27 000 K, logg 2.5 point that lies in an empty corner of the grid). Instead it
70
+
71
+ 1. evaluates the posterior on a stratified (Latin hypercube) draw over the prior box, log-uniform in Teff and distance;
72
+ 2. refines the best few distinct candidates with Levenberg-Marquardt on the magnitude residuals (pure NumPy);
73
+ 3. estimates each optimum's covariance from the Gauss-Newton Jacobian and its relative posterior mass by importance sampling, and
74
+ 4. starts the walkers in those ellipsoids, giving every plausible mode a viable sub-ensemble.
75
+
76
+ With `init='auto'` the ensemble uses a differential-evolution move mixture (`moves='de'`, including mode-hopping jumps), which rebalances walkers between modes and decorrelates about twice as fast as emcee's stretch move. Pass `moves='stretch'` for the old behaviour.
77
+
78
+ To use the legacy Gaussian ball, give a starting point or ask for it:
79
+
80
+ ```python
81
+ posterior = run_inverse(
82
+ ...,
83
+ p0_centre={"teff": 5800, "logg": 4.4, "meta": 0.0}, # implies init='ball'
84
+ p0_scatter=0.02, # fraction of each range
85
+ )
86
+ ```
87
+
88
+ `n_burn=None` picks the burn-in automatically (twice the autocorrelation time, at most half the chain), and `auto_stop=True` samples in blocks and stops once the chain exceeds 50 autocorrelation times.
89
+
90
+ ## Input validation
91
+
92
+ `run_inverse` raises `ValueError` before any sampling when:
93
+
94
+ - `obs_magnitudes` and `obs_uncertainties` differ in length, or `filter_names` does not match them;
95
+ - any uncertainty is ≤ 0 (the likelihood would be undefined);
96
+ - a filter name is not among the supplied filters;
97
+ - `n_walkers < 6`, `n_walkers < 2 × n_free`, or an integer `n_burn >= n_steps`;
98
+ - every parameter is fixed (nothing to sample).
99
+
100
+ ## Diagnostics
101
+
102
+ After sampling, a `UserWarning` is emitted if any walker's acceptance fraction falls below 0.1 or above 0.9. The result carries
103
+
104
+ - `autocorr_time` (integrated autocorrelation time per parameter, `None` if it could not be estimated),
105
+ - `converged` (`True` when the post-burn-in chain is longer than 50 autocorrelation times for every parameter) and `n_eff` (effective number of independent samples per parameter),
106
+ - `chi2_best` / `n_data` (data chi-square of the maximum-posterior sample and the number of detected bands), and
107
+ - `init_info` (how the walkers were started: method, number of modes, walkers per mode, likelihood evaluations).
108
+
109
+ `posterior.credible_interval('meta', 0.68, method='hpd')` returns the shortest interval containing the requested mass; unlike the equal-tailed intervals in `summary()`, it extends to the bound when the posterior mode sits on a prior bound.
110
+
111
+ !!! note "Calibration caveats"
112
+ Intervals are only as good as the model: with real photometry the atmosphere library, filter curves and zero points contribute systematic errors that the Gaussian likelihood does not know about (use `sys_floor` or `fit_jitter`). Fitting independent MIST bolometric-correction photometry (different atmospheres, filter curves and Vega zero point) with the Kurucz grid, the nominal 95 % Teff intervals covered the truth 46 % of the time; `sys_floor=0.03` raised that to 62 % and `fit_jitter=True` to 89 %. Weakly constrained parameters (logg from a few broad bands, Teff-Av degeneracy for few bands) have posteriors dominated by the flat prior and can be genuinely multimodal; check `converged`, and consider informative priors via `priors=`.
113
+
114
+ ## The result
115
+
116
+ `run_inverse` returns an [`InverseResult`](results-io.md#inverseresult) holding the flattened post-burn-in chain (`samples`, shape `(n_samples, n_free)` with columns in `param_names` order), per-sample `log_prob`, the observations, sampler settings, acceptance fractions, and the fixed-parameter values used. See [Results and I/O](results-io.md) for summaries, MAP estimates, and `.npz` persistence.
@@ -8,7 +8,7 @@
8
8
 
9
9
  SED_Model is a Python package for computing observer-ready synthetic spectral energy distributions (SEDs) and broadband magnitudes from stellar atmosphere grids, and for recovering stellar parameters from observed photometry via Bayesian MCMC inference. It works directly downstream of [SED_Tools](https://github.com/nialljmiller/SED_Tools) and as a standalone drop-in for workflows that use MESA-style atmosphere grids and filter transmission curves.
10
10
 
11
- Current version: **0.3.2** ([PyPI](https://pypi.org/project/sed-model/))
11
+ Current version: **0.4.0** ([PyPI](https://pypi.org/project/sed-model/))
12
12
 
13
13
  ## Two paired models, one parameter language
14
14
 
@@ -0,0 +1,62 @@
1
+ # Testing and Validation
2
+
3
+ ```bash
4
+ pytest tests/test_forward.py
5
+ pytest tests/test_inverse.py
6
+ pytest tests/test_kernels.py
7
+ ```
8
+
9
+ Run them from outside the source tree against an installed build; inside it `import sed_model` finds the uncompiled sources, has no extension, and every test skips.
10
+
11
+ Both suites skip gracefully — with an explanatory reason — when the Fortran extension is not built or the required data products are not on disk, so `pytest` is always safe to run.
12
+
13
+ ## Data locations
14
+
15
+ The tests look for SED_Tools data products in `~/.sed_tools/data`, then `~/SED_Tools/data`, then `$MESA_DIR/data/colors_data`, using the first location that exists. Each path can be overridden with an environment variable:
16
+
17
+ | Variable | Path under the data root |
18
+ |---|---|
19
+ | `CC_KURUCZ_DIR` | `stellar_models/Kurucz2003all` |
20
+ | `CC_JOHNSON_DIR` | `filters/Generic/Johnson` |
21
+ | `CC_VEGA_SED` | `stellar_models/vega_flam.csv` |
22
+
23
+ ## Forward model: validated against MESA
24
+
25
+ `tests/test_forward.py` checks grid loading, bounds/clamping, both interpolation schemes, filter loading and zero-points, and then the headline validation: synthetic magnitudes and bolometric quantities are compared against **reference values produced by the MESA colors unit test** (`colors/test/test_output`, MESA Kurucz2003 grid) for three stars spanning the grid — solar (5778 K / 4.44 / 0.0), a hot main-sequence star (15000 K / 4.0, 5 R☉), and a cool giant (4000 K / 2.0, 20 R☉).
26
+
27
+ Tolerances are `TOL_MAG = 0.05` mag and `TOL_FLUX = 2%` relative — deliberately generous to absorb grid version differences and the trapezoid (Python zero-points) vs adaptive Simpson (Fortran in-band flux) integration difference, while still catching any real regression in the pipeline.
28
+
29
+ ## Inverse model: self-consistent recovery
30
+
31
+ `tests/test_inverse.py` does not use external references. Instead it closes the loop on the package itself:
32
+
33
+ 1. run the forward model at known parameters to produce synthetic magnitudes,
34
+ 2. perturb them with a small known noise level (0.01 mag),
35
+ 3. run the inverse model and require the posterior median to recover the truth within physically motivated tolerances (≲ 2 grid spacings: 500 K in Teff, 0.5 dex in logg and [M/H]).
36
+
37
+ Chains are kept deliberately short (hundreds of steps, 16 walkers) so the suite finishes quickly; the tolerances account for the resulting shot noise. The suite also covers the `InverseResult` API — summary structure and ordering, finite MAP estimates, `.npz` save/load round-trips, thinning behaviour — and the input-validation errors (mismatched array lengths, non-positive uncertainties, unknown filter names).
38
+
39
+ ## Cross-code validation (0.4.0)
40
+
41
+ Before 0.4.0 the forward model was swept against the MESA `colors` module, MSG and MIST, and the inverse model was benchmarked by injection and recovery. Summary of what a user should expect:
42
+
43
+ | Against | Result |
44
+ |---|---|
45
+ | **MESA colors** (standalone driver built from the MESA sources; Kurucz2003all, its alpha-enhanced variant and blackbodies; Johnson, 2MASS, Gaia, TESS, JWST and Argus filters; AB, Vega and ST; all three interpolation methods; on-node, off-node, cell-edge, corner and out-of-range queries) | SEDs, bolometric quantities and wavelengths identical; magnitudes within 3e-13 mag (double-precision rounding). |
46
+ | **MSG** (same atmospheres, built into an MSG grid from the SED_Model cube) | `linear` agrees to about 1e-8. Hermite differs by 1e-5 to 1e-3 in clean cells because MSG's cubic includes cross-derivative terms. Photometry agrees to 1-4 mmag; U and B reach 13 mmag from quadrature on the coarse Johnson files. |
47
+ | **MIST v2.5** bolometric corrections (C3K atmospheres, a different library) | 0.01-0.03 mag in V, I, 2MASS and Gaia at equal Teff, logg and [Fe/H]; up to 0.05-0.1 mag in U. These are model differences, not code errors. |
48
+
49
+ Things that matter when comparing with other work:
50
+
51
+ - **Weighting.** `synthetic_magnitude` counts photons, as MESA does. Energy weighting would change Gaia G by up to 0.25 mag for cool stars, so curves defined for energy-counting detectors (e.g. Bessell UBVRI) are weighted differently from what SED_Model currently does; support for per-filter detector type is pending.
52
+ - **Filter definition.** The shipped `Generic/Johnson` curves are coarse and differ from Bessell/Cousins by up to 0.2 mag in R and I for cool stars.
53
+ - **2MASS Vega zero points** follow Blanton and Roweis, about 0.025 mag from MIST's Cohen-based values.
54
+ - **Empty grid cells.** A rectangular grid cube can contain nodes with no model (for Kurucz2003all about 40% of the cube). `run_forward` emits a `UserWarning` when the query's cell touches one, and the result there is unreliable. Hermite interpolation next to empty nodes can overshoot by 0.02-0.09 mag; this is MESA's behaviour and is reproduced exactly.
55
+
56
+ Inverse recovery on the Kurucz grid (16-24 injected stars per set, 0.005-0.05 mag noise): the old mid-box start left walkers stuck in an empty corner and chains unconverged. With the 0.4.0 defaults, fitting Teff, logg, [M/H] and Av gives a Teff bias near 0.1 % and 95 % intervals that cover the truth about 94 % of the time. Photometry from a different atmosphere library (the MIST test) is far outside the nominal intervals unless `fit_jitter` is used, which is recommended for real photometry.
57
+
58
+ The cross-code harnesses are not part of the repository; the in-repo regression tests (`tests/test_kernels.py`) encode the MESA cases that need no data.
59
+
60
+ ## What this means for the documentation
61
+
62
+ Every numerical claim in these docs traces to one of three places: the source code (embedded directly in the [demo pages](demos/index.md)), the docstrings (rendered in the [API Reference](api.md)), or the test suite described here. Nothing is quoted from memory.
@@ -190,7 +190,9 @@ contains
190
190
  !
191
191
  ! Steps performed internally:
192
192
  ! 1. Interpolate filter transmission onto the SED wavelength grid
193
- ! 2. Photon-counting in-band flux integration
193
+ ! 2. In-band flux integration (photon counting by default; energy
194
+ ! counting when photon_counting = 0, for filters tagged
195
+ ! '# DetectorType: energy' -- see sed_model.filters)
194
196
  ! 3. m = -2.5 log10(F_band / F_zp)
195
197
  !
196
198
  ! Parameters
@@ -200,6 +202,7 @@ contains
200
202
  ! filt_wave(nf) : filter wavelength grid (A)
201
203
  ! filt_trans(nf) : filter transmission [0,1]
202
204
  ! zero_point : precomputed photometric zero-point
205
+ ! photon_counting : optional, 1 (default) photon / 0 energy weighting
203
206
  ! mag : output magnitude
204
207
  ! band_flux : output in-band flux (before zero-point)
205
208
  ! ierr : 0=ok, 1=integration failure, 2=non-positive flux
@@ -207,11 +210,13 @@ contains
207
210
  subroutine synthetic_magnitude(sed_wave, obs_flux, nw, &
208
211
  filt_wave, filt_trans, nf, &
209
212
  zero_point, &
210
- mag, band_flux, ierr)
213
+ mag, band_flux, ierr, photon_counting)
211
214
  !f2py intent(in) :: sed_wave, obs_flux, nw
212
215
  !f2py intent(in) :: filt_wave, filt_trans, nf
213
216
  !f2py intent(in) :: zero_point
214
217
  !f2py intent(out) :: mag, band_flux, ierr
218
+ !f2py integer, intent(in) :: photon_counting = 1
219
+ integer, intent(in) :: photon_counting ! 1 = photon counting (default), 0 = energy counting
215
220
  integer, intent(in) :: nw, nf
216
221
  real(dp), intent(in) :: sed_wave(nw), obs_flux(nw)
217
222
  real(dp), intent(in) :: filt_wave(nf), filt_trans(nf)
@@ -227,7 +232,7 @@ contains
227
232
  mag = BAD_MAG; band_flux = -1.0_dp; return
228
233
  end if
229
234
 
230
- call lib_synthetic_flux(sed_wave, obs_flux * filt_on_sed, filt_on_sed, band_flux)
235
+ call lib_synthetic_flux(sed_wave, obs_flux * filt_on_sed, filt_on_sed, band_flux, photon_counting /= 0)
231
236
  if (band_flux <= 0.0_dp) then
232
237
  mag = BAD_MAG; ierr = 1; return
233
238
  end if
@@ -265,43 +270,49 @@ contains
265
270
 
266
271
  subroutine vega_zero_point(vega_wave, vega_flux, nv, &
267
272
  filt_wave, filt_trans, nf, &
268
- zp, ierr)
273
+ zp, ierr, photon_counting)
269
274
  !f2py intent(in) :: vega_wave, vega_flux, nv
270
275
  !f2py intent(in) :: filt_wave, filt_trans, nf
271
276
  !f2py intent(out) :: zp, ierr
277
+ !f2py integer, intent(in) :: photon_counting = 1
278
+ integer, intent(in) :: photon_counting
272
279
  integer, intent(in) :: nv, nf
273
280
  real(dp), intent(in) :: vega_wave(nv), vega_flux(nv)
274
281
  real(dp), intent(in) :: filt_wave(nf), filt_trans(nf)
275
282
  real(dp), intent(out) :: zp
276
283
  integer, intent(out) :: ierr
277
284
 
278
- zp = lib_vega_zp(vega_wave, vega_flux, filt_wave, filt_trans)
285
+ zp = lib_vega_zp(vega_wave, vega_flux, filt_wave, filt_trans, photon_counting /= 0)
279
286
  ierr = merge(0, 1, zp > 0.0_dp)
280
287
  end subroutine vega_zero_point
281
288
 
282
289
 
283
- subroutine ab_zero_point(filt_wave, filt_trans, nf, zp, ierr)
290
+ subroutine ab_zero_point(filt_wave, filt_trans, nf, zp, ierr, photon_counting)
284
291
  !f2py intent(in) :: filt_wave, filt_trans, nf
285
292
  !f2py intent(out) :: zp, ierr
293
+ !f2py integer, intent(in) :: photon_counting = 1
294
+ integer, intent(in) :: photon_counting
286
295
  integer, intent(in) :: nf
287
296
  real(dp), intent(in) :: filt_wave(nf), filt_trans(nf)
288
297
  real(dp), intent(out) :: zp
289
298
  integer, intent(out) :: ierr
290
299
 
291
- zp = lib_ab_zp(filt_wave, filt_trans)
300
+ zp = lib_ab_zp(filt_wave, filt_trans, photon_counting /= 0)
292
301
  ierr = merge(0, 1, zp > 0.0_dp)
293
302
  end subroutine ab_zero_point
294
303
 
295
304
 
296
- subroutine st_zero_point(filt_wave, filt_trans, nf, zp, ierr)
305
+ subroutine st_zero_point(filt_wave, filt_trans, nf, zp, ierr, photon_counting)
297
306
  !f2py intent(in) :: filt_wave, filt_trans, nf
298
307
  !f2py intent(out) :: zp, ierr
308
+ !f2py integer, intent(in) :: photon_counting = 1
309
+ integer, intent(in) :: photon_counting
299
310
  integer, intent(in) :: nf
300
311
  real(dp), intent(in) :: filt_wave(nf), filt_trans(nf)
301
312
  real(dp), intent(out) :: zp
302
313
  integer, intent(out) :: ierr
303
314
 
304
- zp = lib_st_zp(filt_wave, filt_trans)
315
+ zp = lib_st_zp(filt_wave, filt_trans, photon_counting /= 0)
305
316
  ierr = merge(0, 1, zp > 0.0_dp)
306
317
  end subroutine st_zero_point
307
318
 
@@ -22,7 +22,7 @@
22
22
  ! ***********************************************************************
23
23
 
24
24
  module bolometric
25
- use colors_def, only: dp, BAD_MAG, F_BOL_ZERO_POINT
25
+ use colors_def, only: dp, BOL_MAG_NONPOSITIVE_FLUX, F_BOL_ZERO_POINT
26
26
  use colors_utils, only: simpson_integration, is_finite
27
27
  implicit none
28
28
 
@@ -75,7 +75,7 @@ contains
75
75
  if (bol_flux > 0.0_dp) then
76
76
  bol_mag = -2.5_dp * log10(bol_flux / F_BOL_ZERO_POINT)
77
77
  else
78
- bol_mag = BAD_MAG
78
+ bol_mag = BOL_MAG_NONPOSITIVE_FLUX
79
79
  ierr = 1
80
80
  end if
81
81
  end subroutine bolometric_magnitude
@@ -35,18 +35,30 @@ module synthetic
35
35
 
36
36
  contains
37
37
 
38
- ! Photon-counting in-band flux from an already-convolved SED:
38
+ ! In-band flux from an already-convolved SED. Photon counting (the
39
+ ! default, as in MESA):
39
40
  ! F_band = integral(convolved_flux * lambda) / integral(filter * lambda)
41
+ ! Energy counting (filters tagged '# DetectorType: energy'):
42
+ ! F_band = integral(convolved_flux) / integral(filter)
40
43
  ! `convolved_flux` is obs_flux * filter_on_sed_grid, computed by the
41
44
  ! caller -- matches MESA's calling convention in synthetic.f90.
42
- subroutine calculate_synthetic_flux(wavelengths, convolved_flux, filter_on_sed_grid, synthetic_flux)
45
+ subroutine calculate_synthetic_flux(wavelengths, convolved_flux, filter_on_sed_grid, synthetic_flux, photon_counting)
43
46
  real(dp), intent(in) :: wavelengths(:), convolved_flux(:), filter_on_sed_grid(:)
44
47
  real(dp), intent(out) :: synthetic_flux
48
+ logical, optional, intent(in) :: photon_counting
45
49
 
46
50
  real(dp) :: integrated_flux, integrated_filter
51
+ logical :: use_photons
47
52
 
48
- call simpson_integration(wavelengths, convolved_flux * wavelengths, integrated_flux)
49
- call simpson_integration(wavelengths, filter_on_sed_grid * wavelengths, integrated_filter)
53
+ use_photons = .true.
54
+ if (present(photon_counting)) use_photons = photon_counting
55
+ if (use_photons) then
56
+ call simpson_integration(wavelengths, convolved_flux * wavelengths, integrated_flux)
57
+ call simpson_integration(wavelengths, filter_on_sed_grid * wavelengths, integrated_filter)
58
+ else
59
+ call simpson_integration(wavelengths, convolved_flux, integrated_flux)
60
+ call simpson_integration(wavelengths, filter_on_sed_grid, integrated_filter)
61
+ end if
50
62
 
51
63
  if (integrated_filter > 0.0_dp) then
52
64
  synthetic_flux = integrated_flux / integrated_filter
@@ -72,36 +84,30 @@ contains
72
84
 
73
85
 
74
86
  ! Vega zero-point -- called once at filter-load time.
75
- real(dp) function compute_vega_zero_point(vega_wave, vega_flux, filt_wave, filt_trans)
87
+ real(dp) function compute_vega_zero_point(vega_wave, vega_flux, filt_wave, filt_trans, photon_counting)
76
88
  real(dp), intent(in) :: vega_wave(:), vega_flux(:)
77
89
  real(dp), intent(in) :: filt_wave(:), filt_trans(:)
90
+ logical, optional, intent(in) :: photon_counting
78
91
 
79
- real(dp) :: int_flux, int_filter
80
92
  real(dp), allocatable :: filt_on_vega(:)
81
93
  integer :: ierr
82
94
 
83
95
  allocate (filt_on_vega(size(vega_wave)))
84
96
  call interp_filter_onto_sed(filt_wave, filt_trans, vega_wave, filt_on_vega, ierr)
85
97
 
86
- call simpson_integration(vega_wave, vega_flux * filt_on_vega * vega_wave, int_flux)
87
- call simpson_integration(vega_wave, filt_on_vega * vega_wave, int_filter)
88
-
89
- if (int_filter > 0.0_dp) then
90
- compute_vega_zero_point = int_flux / int_filter
91
- else
92
- compute_vega_zero_point = -1.0_dp
93
- end if
98
+ call calculate_synthetic_flux(vega_wave, vega_flux * filt_on_vega, filt_on_vega, &
99
+ compute_vega_zero_point, photon_counting)
94
100
 
95
101
  deallocate (filt_on_vega)
96
102
  end function compute_vega_zero_point
97
103
 
98
104
 
99
105
  ! AB zero-point -- f_nu = 3631 Jy converted to f_lambda on the filter grid.
100
- real(dp) function compute_ab_zero_point(filt_wave, filt_trans)
106
+ real(dp) function compute_ab_zero_point(filt_wave, filt_trans, photon_counting)
101
107
  real(dp), intent(in) :: filt_wave(:), filt_trans(:)
108
+ logical, optional, intent(in) :: photon_counting
102
109
 
103
110
  integer :: i
104
- real(dp) :: int_flux, int_filter
105
111
  real(dp), allocatable :: f_ab(:)
106
112
 
107
113
  allocate (f_ab(size(filt_wave)))
@@ -113,37 +119,25 @@ contains
113
119
  end if
114
120
  end do
115
121
 
116
- call simpson_integration(filt_wave, f_ab * filt_trans * filt_wave, int_flux)
117
- call simpson_integration(filt_wave, filt_trans * filt_wave, int_filter)
118
-
119
- if (int_filter > 0.0_dp) then
120
- compute_ab_zero_point = int_flux / int_filter
121
- else
122
- compute_ab_zero_point = -1.0_dp
123
- end if
122
+ call calculate_synthetic_flux(filt_wave, f_ab * filt_trans, filt_trans, &
123
+ compute_ab_zero_point, photon_counting)
124
124
 
125
125
  deallocate (f_ab)
126
126
  end function compute_ab_zero_point
127
127
 
128
128
 
129
129
  ! ST zero-point -- flat f_lambda = 3.63e-9 erg/s/cm^2/A.
130
- real(dp) function compute_st_zero_point(filt_wave, filt_trans)
130
+ real(dp) function compute_st_zero_point(filt_wave, filt_trans, photon_counting)
131
131
  real(dp), intent(in) :: filt_wave(:), filt_trans(:)
132
+ logical, optional, intent(in) :: photon_counting
132
133
 
133
- real(dp) :: int_flux, int_filter
134
134
  real(dp), allocatable :: f_st(:)
135
135
 
136
136
  allocate (f_st(size(filt_wave)))
137
137
  f_st = ST_FLAM_ZP
138
138
 
139
- call simpson_integration(filt_wave, f_st * filt_trans * filt_wave, int_flux)
140
- call simpson_integration(filt_wave, filt_trans * filt_wave, int_filter)
141
-
142
- if (int_filter > 0.0_dp) then
143
- compute_st_zero_point = int_flux / int_filter
144
- else
145
- compute_st_zero_point = -1.0_dp
146
- end if
139
+ call calculate_synthetic_flux(filt_wave, f_st * filt_trans, filt_trans, &
140
+ compute_st_zero_point, photon_counting)
147
141
 
148
142
  deallocate (f_st)
149
143
  end function compute_st_zero_point
@@ -25,7 +25,10 @@ module colors_def
25
25
  real(dp), parameter :: CLIGHT_CM_S = 2.99792458d10
26
26
  real(dp), parameter :: AB_FNU_ZP = 3.631d-20 ! 3631 Jy in erg/s/cm^2/Hz
27
27
  real(dp), parameter :: ST_FLAM_ZP = 3.63d-9 ! flat f_lambda zp erg/s/cm^2/A
28
- real(dp), parameter :: BAD_MAG = -99.9d0 ! failure sentinel for magnitudes
28
+ real(dp), parameter :: BAD_MAG = -99.9d0 ! failure sentinel for band magnitudes
29
+ ! MESA colors/private/bolometric.f90 returns 99.0 as the bolometric
30
+ ! magnitude when the integrated flux is not positive.
31
+ real(dp), parameter :: BOL_MAG_NONPOSITIVE_FLUX = 99.0d0
29
32
  real(dp), parameter :: TINY_VALUE = 1.0d-300 ! positivity floor for linear interpolation
30
33
 
31
34
  ! Bolometric zero-point: irradiance corresponding to M_bol = 0 (IAU 2015
@@ -39,7 +42,7 @@ module colors_def
39
42
  LSUN_ERG_S * 10.0_dp**(0.4_dp*MBOL_SUN) / (4.0_dp*PI_DP*(10.0_dp*PC_CM)**2)
40
43
 
41
44
  public :: dp
42
- public :: CLIGHT_CM_S, AB_FNU_ZP, ST_FLAM_ZP, BAD_MAG, TINY_VALUE
45
+ public :: CLIGHT_CM_S, AB_FNU_ZP, ST_FLAM_ZP, BAD_MAG, BOL_MAG_NONPOSITIVE_FLUX, TINY_VALUE
43
46
  public :: F_BOL_ZERO_POINT
44
47
 
45
48
  end module colors_def
@@ -2,7 +2,7 @@
2
2
  # and for the others the checks below give a readable error if one is missing.
3
3
  project(
4
4
  'sed_model',
5
- version: '0.3.2',
5
+ version: '0.4.0',
6
6
  meson_version: '>=1.1.0',
7
7
  )
8
8
 
@@ -34,6 +34,7 @@ if components in ['all', 'python']
34
34
  files(
35
35
  'sed_model/__init__.py',
36
36
  'sed_model/_cc_ext.py',
37
+ 'sed_model/_inv_core.py',
37
38
  'sed_model/filters.py',
38
39
  'sed_model/forward.py',
39
40
  'sed_model/grid.py',