Skip to content

Comments

Adds SRM model with unit handling#227

Open
jajmitchell wants to merge 17 commits intosunpy:mainfrom
jajmitchell:srm-model
Open

Adds SRM model with unit handling#227
jajmitchell wants to merge 17 commits intosunpy:mainfrom
jajmitchell:srm-model

Conversation

@jajmitchell
Copy link
Contributor

SRM model handles units

Adds in the SRM model developed in the PR https://github.com/sunpy/sunkit-spex/pull/163.

This enables physical models to be piped through the SRM model with correct unit handling and then fit to data in count space.

jajmitchell and others added 13 commits August 4, 2025 16:24
SRM model handles units
Adds changelog
Fixes test_models
Import requires modules in test_models
Fixes examples
Tidy Comments
Removes astropy fitting simulated data example
Pre-commit
Renames input and output units as user facing
Fixes Tests
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR introduces a unit-aware SRM (MatrixModel) so that photon-space models can be propagated through an instrument response into count space with correct unit handling, and updates the simulated-data example and docs accordingly.

Changes:

  • Replace the old scalar MatrixModel with an SRM-style MatrixModel that knows about input/output axes, bin widths, and spectral units, and update tests to exercise the new behavior.
  • Rework the fitting_simulated_data example to use astropy.units, SpectralAxis, and Spectrum, piping a physical photon model through the SRM into count space and fitting in count space.
  • Tidy up documentation examples (RST link targets, math formatting) and pin the Sphinx version for docs builds, plus add a changelog entry.

Reviewed changes

Copilot reviewed 10 out of 10 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
sunkit_spex/models/instrument_response.py Replaces the previous minimal MatrixModel with a unit-aware SRM implementation that uses input/output spectral axes, bin widths, and a conversion parameter c to map photon-space spectra into count-space, and declares input/return units for Astropy modeling.
sunkit_spex/tests/test_models.py Extends the model tests to construct MatrixModel with explicit axes and units and to verify that composition with StraightLineModel produces the expected result.
examples/fitting_simulated_data.py Refactors the simulated-data fitting example to use astropy.units, quantity_support, SpectralAxis, and Spectrum, and to fit a count-space model built from a photon-space model piped through the SRM; updates plotting and summary table to work with quantities.
examples/fitting_attenuated_RHESSI_spectra.py Adjusts the narrative docstring to use an RST-style reference name for the Knuth & Glesener 2020 citation and adds the corresponding hyperlink target.
examples/fitting_RHESSI_spectra.py Cleans up the comparison table comments and converts various values to inline LaTeX math for consistency, while keeping the example’s numerical results unchanged.
examples/fitting_NuSTAR_spectra-simultaneously.py Updates the intro docstring to use an RST reference for Cooper et al. 2021 and adds the link target; leaves the fitting workflow intact.
examples/fitting_NuSTAR_spectra-glesener2020.py Updates documentation-style comparison tables in comments to use inline LaTeX math and an RST reference for Glesener et al. 2020.
examples/fitting_NuSTAR_spectra-duncan2021.py Switches the introduction and comparison comments to use RST references and LaTeX math, and slightly reflows long explanatory comments.
examples/fitting_attenuated_RHESSI_spectra.py Introduces an RST hyperlink target for the Knuth & Glesener 2020 reference in the docstring.
pyproject.toml Pins the Sphinx dependency for docs builds to <9.0 to avoid incompatibilities with future major releases.
changelog/227.feature.rst Adds a changelog entry announcing that MatrixModel now has unit handling.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 25 to 35
def evaluate(self, x, c):
# Requires input must have a specific dimensionality
return model_y @ self.matrix

input_widths = np.diff(self.input_axis)
output_widths = np.diff(self.output_axis)

flux = ((x * input_widths) @ self.matrix * c) / output_widths

if hasattr(c, "unit"):
return flux
return flux.value
Copy link

Copilot AI Jan 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new unit-handling logic in MatrixModel.evaluate (using input_axis/output_axis widths, matrix multiplication, and unit-aware c) is only tested for dimensionless units in test_MatrixModel, and there is no test that covers a fully unitful case like the fitting_simulated_data example (keV, ph/keV, ct/keV). To guard against regressions in the SRM unit handling, it would be valuable to add a test that constructs a MatrixModel with physical units on the axes and spectra, asserts the returned quantity has the expected ct keV^-1 units, and checks the numerical result against a known value.

Copilot uses AI. Check for mistakes.
Copy link
Contributor

@settwi settwi Jan 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i agree with the Copilot note

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wrote a similar comment later then found this. I agree too.

samaloney and others added 3 commits January 30, 2026 14:20
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
@jajmitchell
Copy link
Contributor Author

I have read through the co-pilot suggestions, I think it makes sense to merge as I am going to work immediately on the fitter which will require a different approach of unit handling and testing anyway

@jajmitchell jajmitchell requested a review from settwi January 30, 2026 15:44
@settwi
Copy link
Contributor

settwi commented Jan 30, 2026

hey @jajmitchell i'm taking a look through this, i started a review and will request a couple minor changes but i think it looks good overall. i might just push some commits up fixing typos if that is ok

@settwi
Copy link
Contributor

settwi commented Jan 30, 2026

does f38afa1 really remove the example?

@sunpy sunpy deleted a comment from Copilot AI Jan 30, 2026

ph_mod_4astropyfit = StraightLineModel(**guess_cont) + GaussianModel(**guess_line)
count_model_4astropyfit = (ph_mod_4fit | srm_model) + GaussianModel(**guess_gauss)
# guess_cont = {"slope": -0.5 * u.ph / u.keV**2, "intercept": 80 * u.ph / u.keV}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jajmitchell i'm taking a first pass through this PR. i think we need to remove commented blocks if they aren't part of the actual example


c = Parameter(fixed=True)

def __init__(self, matrix, input_axis, output_axis, input_spec_units, output_spec_units, c):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i think this could benefit from type annotations

self.input_axis = input_axis
self.output_axis = output_axis
self.matrix = matrix
super().__init__(c)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what is "c" ? i'm surprised Ruff didn't complain about this

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think, I think, c is a stand in for when the matrix model will have response parameters for gain slopes and offsets. The other reason was because Astropy complains when models don't have a fittable parameter. @jajmitchell can correct me on this.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yea it's the way get the units + model to work with astropy

_input_units_allow_dimensionless = True

def evaluate(self, x, c):
# Requires input must have a specific dimensionality
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Typo

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

# Requires input to have a specific dimensionality

sim_count_model + (2 * np_rand.random(sim_count_model.size)) * np.sqrt(sim_count_model.value) * u.ct / u.keV
)

obs_spec = Spectrum(sim_count_model_wn.reshape(-1), spectral_axis=ph_energies)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jajmitchell this is not used after plotting, especially now since the astropy model is commented out.

i think this example needs to get reworked from the ground up to remove stray bits like this. if i was a user and saw this object created then ignored, i would be rather confused. i was actually just confused looking through this example

Comment on lines 25 to 35
def evaluate(self, x, c):
# Requires input must have a specific dimensionality
return model_y @ self.matrix

input_widths = np.diff(self.input_axis)
output_widths = np.diff(self.output_axis)

flux = ((x * input_widths) @ self.matrix * c) / output_widths

if hasattr(c, "unit"):
return flux
return flux.value
Copy link
Contributor

@settwi settwi Jan 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i agree with the Copilot note

Comment on lines +126 to +129
plt.plot(ph_energies_centers, (ph_model | srm_model)(ph_energies), label="photon model features")
plt.plot(ph_energies_centers, GaussianModel(**sim_gauss)(ph_energies), label="gaussian feature")
plt.plot(ph_energies_centers, sim_count_model, label="total sim. spectrum")
plt.plot(obs_spec._spectral_axis, obs_spec.data, label="total sim. spectrum + noise", lw=0.5)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These should be plotted using plt.stairs to show that the X-ray spectra are binned

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would go further and say any models should use plt.stairs but the final data plt.plot should use plt.errorbar.

true_vals = np.array(tuple(sim_cont.values())[-2:] + tuple(sim_line.values())[-3:] + tuple(sim_gauss.values())[-3:])
guess_vals = np.array(
tuple(guess_cont.values())[-2:] + tuple(guess_line.values())[-3:] + tuple(guess_gauss.values())[-3:]
tuple(sim_cont) + tuple(f"{p}1" for p in tuple(sim_line)) + ("C",) + tuple(f"{p}2" for p in tuple(sim_gauss))
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The scipy method does not respect the fixed=True status on the photon/ct scaling parameter

- pass units rather than dict to matrix model
- make conversion factor transparent to user (input/output units)
- fix quantity_support units in simulated data example
Comment on lines +126 to +129
plt.plot(ph_energies_centers, (ph_model | srm_model)(ph_energies), label="photon model features")
plt.plot(ph_energies_centers, GaussianModel(**sim_gauss)(ph_energies), label="gaussian feature")
plt.plot(ph_energies_centers, sim_count_model, label="total sim. spectrum")
plt.plot(obs_spec._spectral_axis, obs_spec.data, label="total sim. spectrum + noise", lw=0.5)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would go further and say any models should use plt.stairs but the final data plt.plot should use plt.errorbar.

Comment on lines 25 to 35
def evaluate(self, x, c):
# Requires input must have a specific dimensionality
return model_y @ self.matrix

input_widths = np.diff(self.input_axis)
output_widths = np.diff(self.output_axis)

flux = ((x * input_widths) @ self.matrix * c) / output_widths

if hasattr(c, "unit"):
return flux
return flux.value
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wrote a similar comment later then found this. I agree too.

self.input_axis = input_axis
self.output_axis = output_axis
self.matrix = matrix
super().__init__(c)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think, I think, c is a stand in for when the matrix model will have response parameters for gain slopes and offsets. The other reason was because Astropy complains when models don't have a fittable parameter. @jajmitchell can correct me on this.

input_widths = np.diff(self.input_axis)
output_widths = np.diff(self.output_axis)

flux = ((x * input_widths) @ self.matrix * c) / output_widths
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just looking at the maths here, I think the /output_widths here is a typo. This would likely move the returned flux away from the data units, would it not?

@jajmitchell
Copy link
Contributor Author

jajmitchell commented Feb 4, 2026

@settwi @KriSun95, thanks for the comments, I will make some changes based on the suggestions, however perhaps at this stage its best to remove the fitting_simulated_data.py example, as currently the Astropy models can't be used for fitting so this will all dramatically change, unless you think we should include an example fitting simulated data with the legacy code? If so, that should probably be added in a separate pr and the current example removed in this one.

@settwi
Copy link
Contributor

settwi commented Feb 4, 2026

@settwi @KriSun95, thanks for the comments, I will make some changes based on the suggestions, however perhaps at this stage its best to remove the fitting_simulated_data.py example, as currently the Astropy models can't be used for fitting so this will all dramatically change, unless you think we should include an example fitting simulated data with the legacy code? If so, that should probably be added in a separate pr and the current example removed in this one.

Sure, good idea.

Feel free to revert the matrix commit I made btw. I’m not sure if it lines up with the intention of the “c” parameter. Making it transparent (you don’t need to pass it explicitly to the constructor) seemed like a better approach to me

@jajmitchell
Copy link
Contributor Author

@settwi @samaloney @KriSun95 would it make sense to sort this and merge it, or given that the unit handling will completely change within the next week or two with the implementation of the fitter, should we close this PR and add the SRM in the fitting PR, along with the changes to the unit handling of the other models?

@samaloney
Copy link
Member

I don't have a strong opinion either way, but prob lean towards merging. Also there's now bunch of changes to fix the doc builds so if we don't merge this I'll cherry pick then to a separate PR.

@KriSun95
Copy link
Collaborator

KriSun95 commented Feb 13, 2026

Sure! I'm happy to vote to merge then decide on dimension handling stuff later.

When it comes to the fitting_simulated_data.py, can we keep it and just show the fit with Scipy minimize? I know the Astropy fit doesn't work but I still think it's useful to see a full fit from start to finish with what the newer code is capable of just now.

A simulated/custom data fit example for the legacy code already exists in the Fitting Custom Spectra example.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants