eclipse 0.13.0__tar.gz → 0.14.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 (112) hide show
  1. {eclipse-0.13.0/src/eclipse.egg-info → eclipse-0.14.0}/PKG-INFO +1 -3
  2. {eclipse-0.13.0 → eclipse-0.14.0}/README.md +0 -2
  3. {eclipse-0.13.0 → eclipse-0.14.0}/src/eclipse/__init__.py +1 -1
  4. {eclipse-0.13.0 → eclipse-0.14.0}/src/eclipse/fitting.py +106 -6
  5. {eclipse-0.13.0 → eclipse-0.14.0}/src/eclipse/main.py +6 -2
  6. {eclipse-0.13.0 → eclipse-0.14.0}/src/eclipse/results_file.py +2 -1
  7. {eclipse-0.13.0 → eclipse-0.14.0/src/eclipse.egg-info}/PKG-INFO +1 -3
  8. {eclipse-0.13.0 → eclipse-0.14.0}/src/eclipse.egg-info/SOURCES.txt +1 -0
  9. eclipse-0.14.0/tests/test_fit_rest_start_and_limits.py +243 -0
  10. {eclipse-0.13.0 → eclipse-0.14.0}/tests/test_raster.py +3 -1
  11. {eclipse-0.13.0 → eclipse-0.14.0}/LICENSE +0 -0
  12. {eclipse-0.13.0 → eclipse-0.14.0}/MANIFEST.in +0 -0
  13. {eclipse-0.13.0 → eclipse-0.14.0}/pyproject.toml +0 -0
  14. {eclipse-0.13.0 → eclipse-0.14.0}/setup.cfg +0 -0
  15. {eclipse-0.13.0 → eclipse-0.14.0}/src/eclipse/analysis.py +0 -0
  16. {eclipse-0.13.0 → eclipse-0.14.0}/src/eclipse/atmosphere.py +0 -0
  17. {eclipse-0.13.0 → eclipse-0.14.0}/src/eclipse/band.py +0 -0
  18. {eclipse-0.13.0 → eclipse-0.14.0}/src/eclipse/cli.py +0 -0
  19. {eclipse-0.13.0 → eclipse-0.14.0}/src/eclipse/config.py +0 -0
  20. {eclipse-0.13.0 → eclipse-0.14.0}/src/eclipse/continuum.py +0 -0
  21. {eclipse-0.13.0 → eclipse-0.14.0}/src/eclipse/data/eis_response/EIS_EffArea_A.004 +0 -0
  22. {eclipse-0.13.0 → eclipse-0.14.0}/src/eclipse/data/eis_response/EIS_EffArea_B.004 +0 -0
  23. {eclipse-0.13.0 → eclipse-0.14.0}/src/eclipse/data/eis_response/EIS_EffArea_B.005 +0 -0
  24. {eclipse-0.13.0 → eclipse-0.14.0}/src/eclipse/data/eis_response/README_dz2025 +0 -0
  25. {eclipse-0.13.0 → eclipse-0.14.0}/src/eclipse/data/eis_response/fit_eis_ea_2023-05-04_smooth.sav +0 -0
  26. {eclipse-0.13.0 → eclipse-0.14.0}/src/eclipse/data/eis_response/source.txt +0 -0
  27. {eclipse-0.13.0 → eclipse-0.14.0}/src/eclipse/data/science_cases.yaml +0 -0
  28. {eclipse-0.13.0 → eclipse-0.14.0}/src/eclipse/data/throughput/grating_reflection_efficiency.dat +0 -0
  29. {eclipse-0.13.0 → eclipse-0.14.0}/src/eclipse/data/throughput/primary_mirror_coating_reflectance.dat +0 -0
  30. {eclipse-0.13.0 → eclipse-0.14.0}/src/eclipse/data/throughput/source.txt +0 -0
  31. {eclipse-0.13.0 → eclipse-0.14.0}/src/eclipse/data/throughput/throughput_aluminium_1000_angstrom.dat +0 -0
  32. {eclipse-0.13.0 → eclipse-0.14.0}/src/eclipse/data/throughput/throughput_aluminium_oxide_1000_angstrom.dat +0 -0
  33. {eclipse-0.13.0 → eclipse-0.14.0}/src/eclipse/data/throughput/throughput_carbon_1000_angstrom.dat +0 -0
  34. {eclipse-0.13.0 → eclipse-0.14.0}/src/eclipse/data_processing.py +0 -0
  35. {eclipse-0.13.0 → eclipse-0.14.0}/src/eclipse/deprecated.py +0 -0
  36. {eclipse-0.13.0 → eclipse-0.14.0}/src/eclipse/eis_calibration.py +0 -0
  37. {eclipse-0.13.0 → eclipse-0.14.0}/src/eclipse/extern/__init__.py +0 -0
  38. {eclipse-0.13.0 → eclipse-0.14.0}/src/eclipse/extern/mpfit.py +0 -0
  39. {eclipse-0.13.0 → eclipse-0.14.0}/src/eclipse/frame.py +0 -0
  40. {eclipse-0.13.0 → eclipse-0.14.0}/src/eclipse/full_frame.py +0 -0
  41. {eclipse-0.13.0 → eclipse-0.14.0}/src/eclipse/monte_carlo.py +0 -0
  42. {eclipse-0.13.0 → eclipse-0.14.0}/src/eclipse/pinhole_diffraction.py +0 -0
  43. {eclipse-0.13.0 → eclipse-0.14.0}/src/eclipse/psf.py +0 -0
  44. {eclipse-0.13.0 → eclipse-0.14.0}/src/eclipse/radiometric.py +0 -0
  45. {eclipse-0.13.0 → eclipse-0.14.0}/src/eclipse/raster.py +0 -0
  46. {eclipse-0.13.0 → eclipse-0.14.0}/src/eclipse/readout.py +0 -0
  47. {eclipse-0.13.0 → eclipse-0.14.0}/src/eclipse/resolution_budget.py +0 -0
  48. {eclipse-0.13.0 → eclipse-0.14.0}/src/eclipse/sampling.py +0 -0
  49. {eclipse-0.13.0 → eclipse-0.14.0}/src/eclipse/science_cases.py +0 -0
  50. {eclipse-0.13.0 → eclipse-0.14.0}/src/eclipse/synthesis.py +0 -0
  51. {eclipse-0.13.0 → eclipse-0.14.0}/src/eclipse/synthesis_cli.py +0 -0
  52. {eclipse-0.13.0 → eclipse-0.14.0}/src/eclipse/synthesis_file.py +0 -0
  53. {eclipse-0.13.0 → eclipse-0.14.0}/src/eclipse/utils.py +0 -0
  54. {eclipse-0.13.0 → eclipse-0.14.0}/src/eclipse.egg-info/dependency_links.txt +0 -0
  55. {eclipse-0.13.0 → eclipse-0.14.0}/src/eclipse.egg-info/entry_points.txt +0 -0
  56. {eclipse-0.13.0 → eclipse-0.14.0}/src/eclipse.egg-info/requires.txt +0 -0
  57. {eclipse-0.13.0 → eclipse-0.14.0}/src/eclipse.egg-info/top_level.txt +0 -0
  58. {eclipse-0.13.0 → eclipse-0.14.0}/src/euvst_response/__init__.py +0 -0
  59. {eclipse-0.13.0 → eclipse-0.14.0}/tests/test_analysis_lookups.py +0 -0
  60. {eclipse-0.13.0 → eclipse-0.14.0}/tests/test_atmosphere_input.py +0 -0
  61. {eclipse-0.13.0 → eclipse-0.14.0}/tests/test_axis_conventions.py +0 -0
  62. {eclipse-0.13.0 → eclipse-0.14.0}/tests/test_band_lines.py +0 -0
  63. {eclipse-0.13.0 → eclipse-0.14.0}/tests/test_common_random_numbers.py +0 -0
  64. {eclipse-0.13.0 → eclipse-0.14.0}/tests/test_config_checks.py +0 -0
  65. {eclipse-0.13.0 → eclipse-0.14.0}/tests/test_config_validation.py +0 -0
  66. {eclipse-0.13.0 → eclipse-0.14.0}/tests/test_continuum.py +0 -0
  67. {eclipse-0.13.0 → eclipse-0.14.0}/tests/test_distributions.py +0 -0
  68. {eclipse-0.13.0 → eclipse-0.14.0}/tests/test_doppler_sign.py +0 -0
  69. {eclipse-0.13.0 → eclipse-0.14.0}/tests/test_downsample.py +0 -0
  70. {eclipse-0.13.0 → eclipse-0.14.0}/tests/test_eis_effective_area.py +0 -0
  71. {eclipse-0.13.0 → eclipse-0.14.0}/tests/test_emission_bins.py +0 -0
  72. {eclipse-0.13.0 → eclipse-0.14.0}/tests/test_fit_conditioning.py +0 -0
  73. {eclipse-0.13.0 → eclipse-0.14.0}/tests/test_fit_config.py +0 -0
  74. {eclipse-0.13.0 → eclipse-0.14.0}/tests/test_fit_constraints.py +0 -0
  75. {eclipse-0.13.0 → eclipse-0.14.0}/tests/test_fit_finite.py +0 -0
  76. {eclipse-0.13.0 → eclipse-0.14.0}/tests/test_fit_multi_start.py +0 -0
  77. {eclipse-0.13.0 → eclipse-0.14.0}/tests/test_fit_quality.py +0 -0
  78. {eclipse-0.13.0 → eclipse-0.14.0}/tests/test_fit_start.py +0 -0
  79. {eclipse-0.13.0 → eclipse-0.14.0}/tests/test_fit_summaries.py +0 -0
  80. {eclipse-0.13.0 → eclipse-0.14.0}/tests/test_fit_weighting.py +0 -0
  81. {eclipse-0.13.0 → eclipse-0.14.0}/tests/test_focal_plane_dispersion.py +0 -0
  82. {eclipse-0.13.0 → eclipse-0.14.0}/tests/test_frame.py +0 -0
  83. {eclipse-0.13.0 → eclipse-0.14.0}/tests/test_full_frame.py +0 -0
  84. {eclipse-0.13.0 → eclipse-0.14.0}/tests/test_goft_temperature_chunks.py +0 -0
  85. {eclipse-0.13.0 → eclipse-0.14.0}/tests/test_ground_truth_pixel_mean.py +0 -0
  86. {eclipse-0.13.0 → eclipse-0.14.0}/tests/test_instrument_settings.py +0 -0
  87. {eclipse-0.13.0 → eclipse-0.14.0}/tests/test_instrument_sources.py +0 -0
  88. {eclipse-0.13.0 → eclipse-0.14.0}/tests/test_line_cube_coordinates.py +0 -0
  89. {eclipse-0.13.0 → eclipse-0.14.0}/tests/test_line_matching.py +0 -0
  90. {eclipse-0.13.0 → eclipse-0.14.0}/tests/test_map_metadata.py +0 -0
  91. {eclipse-0.13.0 → eclipse-0.14.0}/tests/test_mpi_ranks.py +0 -0
  92. {eclipse-0.13.0 → eclipse-0.14.0}/tests/test_noise_toggle.py +0 -0
  93. {eclipse-0.13.0 → eclipse-0.14.0}/tests/test_old_package_name.py +0 -0
  94. {eclipse-0.13.0 → eclipse-0.14.0}/tests/test_photon_energy.py +0 -0
  95. {eclipse-0.13.0 → eclipse-0.14.0}/tests/test_pinhole_config.py +0 -0
  96. {eclipse-0.13.0 → eclipse-0.14.0}/tests/test_pixel_sampling.py +0 -0
  97. {eclipse-0.13.0 → eclipse-0.14.0}/tests/test_psf_boundary.py +0 -0
  98. {eclipse-0.13.0 → eclipse-0.14.0}/tests/test_radiometric_chain.py +0 -0
  99. {eclipse-0.13.0 → eclipse-0.14.0}/tests/test_readout.py +0 -0
  100. {eclipse-0.13.0 → eclipse-0.14.0}/tests/test_readout_dark_current.py +0 -0
  101. {eclipse-0.13.0 → eclipse-0.14.0}/tests/test_resolution_budget.py +0 -0
  102. {eclipse-0.13.0 → eclipse-0.14.0}/tests/test_results_file.py +0 -0
  103. {eclipse-0.13.0 → eclipse-0.14.0}/tests/test_results_file_runs.py +0 -0
  104. {eclipse-0.13.0 → eclipse-0.14.0}/tests/test_science_cases.py +0 -0
  105. {eclipse-0.13.0 → eclipse-0.14.0}/tests/test_slit_mask.py +0 -0
  106. {eclipse-0.13.0 → eclipse-0.14.0}/tests/test_slit_psf.py +0 -0
  107. {eclipse-0.13.0 → eclipse-0.14.0}/tests/test_small_fixes.py +0 -0
  108. {eclipse-0.13.0 → eclipse-0.14.0}/tests/test_spectral_rebinning.py +0 -0
  109. {eclipse-0.13.0 → eclipse-0.14.0}/tests/test_synthesis_file.py +0 -0
  110. {eclipse-0.13.0 → eclipse-0.14.0}/tests/test_synthesis_grids.py +0 -0
  111. {eclipse-0.13.0 → eclipse-0.14.0}/tests/test_uniform_intensity.py +0 -0
  112. {eclipse-0.13.0 → eclipse-0.14.0}/tests/test_untested_numbers.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: eclipse
3
- Version: 0.13.0
3
+ Version: 0.14.0
4
4
  Summary: ECLIPSE: Emission Calculation and Line Prediction for SOLAR-C EUVST
5
5
  Author-email: James McKevitt <jm2@mssl.ucl.ac.uk>
6
6
  Project-URL: Homepage, https://github.com/jamesmckevitt/eclipse
@@ -61,8 +61,6 @@ The instrument model will be updated as EUVST is built, tested and commissioned.
61
61
  [![PyPI](https://img.shields.io/pypi/v/eclipse.svg)](https://pypi.org/project/eclipse/)
62
62
  [![DOI](https://img.shields.io/badge/DOI-10.5281%2Fzenodo.17543844-blue.svg)](https://doi.org/10.5281/zenodo.17543844)
63
63
 
64
- ECLIPSE is installed with `pip install eclipse`. ECLIPSE 0.12 and earlier were on PyPI as `solarc-eclipse`.
65
-
66
64
  The documentation, with installation and a first run, guides to synthesising spectra, simulating the instrument and analysing the results, and a worked example, is at **[solarc-eclipse.readthedocs.io](https://solarc-eclipse.readthedocs.io)**.
67
65
 
68
66
  ## Acknowledgements
@@ -11,8 +11,6 @@ The instrument model will be updated as EUVST is built, tested and commissioned.
11
11
  [![PyPI](https://img.shields.io/pypi/v/eclipse.svg)](https://pypi.org/project/eclipse/)
12
12
  [![DOI](https://img.shields.io/badge/DOI-10.5281%2Fzenodo.17543844-blue.svg)](https://doi.org/10.5281/zenodo.17543844)
13
13
 
14
- ECLIPSE is installed with `pip install eclipse`. ECLIPSE 0.12 and earlier were on PyPI as `solarc-eclipse`.
15
-
16
14
  The documentation, with installation and a first run, guides to synthesising spectra, simulating the instrument and analysing the results, and a worked example, is at **[solarc-eclipse.readthedocs.io](https://solarc-eclipse.readthedocs.io)**.
17
15
 
18
16
  ## Acknowledgements
@@ -4,7 +4,7 @@ ECLIPSE: Emission Calculation and Line Prediction for SOLAR-C EUVST
4
4
  Tools for modeling the performance of EUVST, the EUV spectrograph on SOLAR-C.
5
5
  """
6
6
 
7
- __version__ = "0.13.0"
7
+ __version__ = "0.14.0"
8
8
  __author__ = "James McKevitt"
9
9
  __email__ = "jm2@mssl.ucl.ac.uk"
10
10
 
@@ -71,6 +71,15 @@ class FitComponent:
71
71
  name : str, optional
72
72
  Name in the results. Default the rest wavelength, e.g.
73
73
  `"195.1190 Angstrom"`.
74
+ velocity_limits : tuple of u.Quantity, optional
75
+ Lowest and highest velocity the fit may give this line, measured
76
+ from its rest wavelength, such as `(-60 * u.km / u.s, 60 * u.km / u.s)`.
77
+ Not for a line whose centre is tied to another's. Default None, for
78
+ no limits.
79
+ width_limits : tuple of u.Quantity, optional
80
+ Smallest and largest Gaussian sigma the fit may give this line, such
81
+ as `(0.019 * u.AA, 0.051 * u.AA)`. Not for a line whose width is tied
82
+ to another's. Default None, for no limits.
74
83
  """
75
84
  wavelength: u.Quantity
76
85
  tie_center: Optional[int] = None # index of component whose centre this is tied to
@@ -79,6 +88,8 @@ class FitComponent:
79
88
  # Label for this component in the results. Defaults to its rest
80
89
  # wavelength, e.g. "195.1190 Angstrom".
81
90
  name: Optional[str] = None
91
+ velocity_limits: Optional[tuple] = None
92
+ width_limits: Optional[tuple] = None
82
93
 
83
94
  def __post_init__(self):
84
95
  # Catch a wavelength with no unit here rather than in the fit.
@@ -87,6 +98,38 @@ class FitComponent:
87
98
  and np.isfinite(self.wavelength.value) and self.wavelength.value > 0):
88
99
  raise ValueError(f"A fitting component's wavelength must be the wavelength of a "
89
100
  f"line, such as '195.119 AA'; got {self.wavelength!r}.")
101
+ for key, kind, example in (("velocity_limits", u.km / u.s, "[-60 km/s, 60 km/s]"),
102
+ ("width_limits", u.AA, "[0.019 AA, 0.051 AA]")):
103
+ value = getattr(self, key)
104
+ if value is None:
105
+ continue
106
+ try:
107
+ low, high = (u.Quantity(v) for v in value)
108
+ except (TypeError, ValueError):
109
+ low = high = None
110
+ if (low is None or not low.unit.is_equivalent(kind)
111
+ or not high.unit.is_equivalent(kind)
112
+ or not (np.isfinite(low.value) and np.isfinite(high.value))
113
+ or not low < high or (key == "width_limits" and low <= 0)):
114
+ raise ValueError(
115
+ f"A fitting component's {key} must be two {kind.physical_type}s, the "
116
+ f"lower first{', both above zero' if key == 'width_limits' else ''}, "
117
+ f"such as {example}; got {value!r}.")
118
+ setattr(self, key, (low.to(kind), high.to(kind)))
119
+
120
+ def centre_limits(self) -> Optional[tuple]:
121
+ """Lowest and highest centre in cm that `velocity_limits` allow, or None."""
122
+ if self.velocity_limits is None:
123
+ return None
124
+ rest = self.wavelength.to_value(u.cm)
125
+ c = const.c.to_value(u.km / u.s)
126
+ return tuple(rest * (1 + v.to_value(u.km / u.s) / c) for v in self.velocity_limits)
127
+
128
+ def sigma_limits(self) -> Optional[tuple]:
129
+ """Smallest and largest sigma in cm that `width_limits` allow, or None."""
130
+ if self.width_limits is None:
131
+ return None
132
+ return tuple(w.to_value(u.cm) for w in self.width_limits)
90
133
 
91
134
 
92
135
  def default_component_name(wavelength: u.Quantity) -> str:
@@ -123,6 +166,11 @@ class FitConfig:
123
166
  Weight each pixel of the DN spectra by one over its uncertainty
124
167
  squared, estimated from its own signal as eispac does, and fit the
125
168
  photon spectra by their Poisson likelihood. Default True.
169
+ start : str
170
+ Where each fit of a blend starts: `"search"` (default) slides the
171
+ components together along the spectrum to where they best match it;
172
+ `"rest"` starts every component at its rest wavelength, as eispac's
173
+ templates do.
126
174
  """
127
175
  components: List[FitComponent] = field(default_factory=list)
128
176
  primary_component: int = 0
@@ -146,6 +194,10 @@ class FitConfig:
146
194
  # its position count no more than its noisier peak. Results files from
147
195
  # before this setting are unweighted.
148
196
  weighted: bool = True
197
+ # Where a blend's fit starts. A search can start a faint blend a whole
198
+ # component spacing from the truth; starting at rest, as eispac does,
199
+ # keeps it on the line that is there.
200
+ start: str = "search"
149
201
 
150
202
  def __post_init__(self):
151
203
  if len(self.components) == 1:
@@ -154,6 +206,8 @@ class FitConfig:
154
206
  "needs at least two; for a single Gaussian leave components "
155
207
  "out, and the other fitting settings still apply."
156
208
  )
209
+ if self.start not in ("search", "rest"):
210
+ raise ValueError(f"fitting.start must be 'search' or 'rest', got {self.start!r}.")
157
211
  if self.backend not in (None, "scipy", "mpfit"):
158
212
  raise ValueError(
159
213
  f"Unknown fitting backend '{self.backend}'. "
@@ -194,6 +248,12 @@ class FitConfig:
194
248
  f"has to be the index of another component, from "
195
249
  f"0 to {n - 1}."
196
250
  )
251
+ for key, tie in (("velocity_limits", "tie_center"), ("width_limits", "tie_width")):
252
+ if getattr(comp, key) is not None and getattr(comp, tie) is not None:
253
+ raise ValueError(
254
+ f"fitting.components[{idx}] has {key} and {tie}. A tied "
255
+ f"component follows the one it is tied to, so give the limits "
256
+ f"to that one.")
197
257
  if comp.name is not None and (
198
258
  not isinstance(comp.name, str) or not comp.name.strip()):
199
259
  raise ValueError(
@@ -229,7 +289,7 @@ class FitConfig:
229
289
  f"the components a 'name'."
230
290
  )
231
291
  else:
232
- # Both settings need components; without them they would
292
+ # These settings need components; without them they would
233
293
  # silently do nothing.
234
294
  if self.primary_component != 0:
235
295
  raise ValueError(
@@ -241,6 +301,11 @@ class FitConfig:
241
301
  "fitting.constrain_positive_intensity is only applied to "
242
302
  "multi-component fits, and there are no components."
243
303
  )
304
+ if self.start != "search":
305
+ raise ValueError(
306
+ "fitting.start is only applied to multi-component fits, and there are "
307
+ "no components."
308
+ )
244
309
 
245
310
  @property
246
311
  def n_components(self) -> int:
@@ -678,7 +743,16 @@ def _build_scipy_multi(fit_config: FitConfig, pixel_angstrom: float | None = Non
678
743
  for pos, fi in enumerate(free_indices):
679
744
  if fi % 3 == 0 and fi < 3 * nc and pos not in ratio_spec:
680
745
  lower[pos] = 0.0
681
- has_bounds = ratio_spec or fit_config.constrain_positive_intensity
746
+ # Limits on each component's centre and width, in Angstrom.
747
+ limited = False
748
+ for pos, fi in enumerate(free_indices):
749
+ if fi < 3 * nc and fi % 3 in (1, 2):
750
+ comp = fit_config.components[fi // 3]
751
+ limits = comp.centre_limits() if fi % 3 == 1 else comp.sigma_limits()
752
+ if limits is not None:
753
+ lower[pos], upper[pos] = (value * 1e8 for value in limits)
754
+ limited = True
755
+ has_bounds = ratio_spec or fit_config.constrain_positive_intensity or limited
682
756
  bounds = (lower, upper) if has_bounds else (-np.inf, np.inf)
683
757
 
684
758
  # Order the ratios so parents come before their children.
@@ -881,6 +955,9 @@ def _build_parinfo(fit_config: FitConfig, p0: np.ndarray,
881
955
  / fit_config.components[src].wavelength.to(u.cm).value)
882
956
  # mpfit tie expression references the parameter array p
883
957
  centre_info["tied"] = f"p[{3 * src + 1}] * {factor!r}"
958
+ elif (centre_limits := comp.centre_limits()) is not None:
959
+ centre_info["limited"] = [1, 1]
960
+ centre_info["limits"] = list(centre_limits)
884
961
  parinfo.append(centre_info)
885
962
 
886
963
  # --- sigma (width) ---
@@ -888,6 +965,9 @@ def _build_parinfo(fit_config: FitConfig, p0: np.ndarray,
888
965
  # Width must be strictly positive (avoid divide-by-zero in Gaussian)
889
966
  sigma_info["limited"] = [1, 0]
890
967
  sigma_info["limits"] = [1e-30, 0.0]
968
+ if (sigma_limits := comp.sigma_limits()) is not None:
969
+ sigma_info["limited"] = [1, 1]
970
+ sigma_info["limits"] = list(sigma_limits)
891
971
  if comp.tie_width is not None:
892
972
  src = _tie_root(fit_config, "tie_width", i)
893
973
  sigma_info["tied"] = f"p[{3 * src + 2}]"
@@ -982,16 +1062,28 @@ def _guess_multi_params(wv: np.ndarray, prof: np.ndarray,
982
1062
  rest_wl = np.array([c.wavelength.to(u.cm).value
983
1063
  for c in fit_config.components])
984
1064
  ref_wl = float(rest_wl[fit_config.primary_component])
985
- if peak > 0:
1065
+ # Shifts of the whole comb that keep each component within its velocity
1066
+ # limits.
1067
+ allowed = [-np.inf, np.inf]
1068
+ for comp, rest in zip(fit_config.components, rest_wl):
1069
+ limits = comp.centre_limits()
1070
+ if limits is not None:
1071
+ low_centre, high_centre = limits
1072
+ allowed[0] = max(allowed[0], ref_wl * low_centre / rest - ref_wl)
1073
+ allowed[1] = min(allowed[1], ref_wl * high_centre / rest - ref_wl)
1074
+ # With start "rest", every component starts at its rest wavelength, as
1075
+ # eispac's templates do.
1076
+ if fit_config.start == "search" and peak > 0:
986
1077
  # A trial shift moves the primary centre to ref_wl + shift and every
987
1078
  # other component to its rest wavelength times the same factor. The
988
1079
  # range keeps the whole comb inside the observed window; with all
989
1080
  # components at one wavelength it reduces to shifting the comb bodily.
990
1081
  lo = float(wv.min()) * ref_wl / float(rest_wl.min()) - ref_wl
991
1082
  hi = float(wv.max()) * ref_wl / float(rest_wl.max()) - ref_wl
1083
+ lo, hi = max(lo, allowed[0]), min(hi, allowed[1])
992
1084
  step = float(np.median(np.diff(wv))) if len(wv) > 1 else 0.0
993
1085
  if hi > lo and step > 0:
994
- trial = np.arange(lo, hi + 0.5 * step, step)
1086
+ trial = np.minimum(np.arange(lo, hi + 0.5 * step, step), hi)
995
1087
  score = np.zeros(trial.size)
996
1088
  for r in rest_wl:
997
1089
  score += np.interp(r * (ref_wl + trial) / ref_wl, wv, prof_c,
@@ -1006,8 +1098,8 @@ def _guess_multi_params(wv: np.ndarray, prof: np.ndarray,
1006
1098
  # nothing to recover.
1007
1099
  shift = float(trial[int(np.argmax(score))])
1008
1100
  else:
1009
- # The comb is wider than the window, so no shift keeps all of it
1010
- # inside. Fall back to the brightest pixel and the component
1101
+ # No shift keeps the comb inside the window and within the
1102
+ # limits. Fall back to the brightest pixel and the component
1011
1103
  # nearest to it, read as a Doppler factor.
1012
1104
  peak_wl = float(wv[np.nanargmax(prof_c)])
1013
1105
  nearest = float(rest_wl[np.argmin(np.abs(rest_wl - peak_wl))])
@@ -1015,6 +1107,8 @@ def _guess_multi_params(wv: np.ndarray, prof: np.ndarray,
1015
1107
  else:
1016
1108
  shift = 0.0
1017
1109
 
1110
+ if allowed[0] <= allowed[1]:
1111
+ shift = float(np.clip(shift, *allowed))
1018
1112
  doppler = (ref_wl + shift) / ref_wl
1019
1113
  full_guess = np.zeros(fit_config.n_full_params)
1020
1114
  for i, comp in enumerate(fit_config.components):
@@ -1022,6 +1116,12 @@ def _guess_multi_params(wv: np.ndarray, prof: np.ndarray,
1022
1116
  full_guess[base] = peak if i == fit_config.primary_component else peak * 0.15
1023
1117
  full_guess[base + 1] = rest_wl[i] * doppler
1024
1118
  full_guess[base + 2] = sigma
1119
+ # A start outside a component's limits is moved to the nearest limit, as
1120
+ # the optimisers need one inside them.
1121
+ for i, comp in enumerate(fit_config.components):
1122
+ for offset, limits in ((1, comp.centre_limits()), (2, comp.sigma_limits())):
1123
+ if limits is not None:
1124
+ full_guess[3 * i + offset] = np.clip(full_guess[3 * i + offset], *limits)
1025
1125
  full_guess[-1] = back
1026
1126
  return full_guess
1027
1127
 
@@ -137,9 +137,10 @@ _SIMULATION_KEYS = {"slit_width", "expos", "vis_sl", "psf", "psf_boundary",
137
137
 
138
138
  _FITTING_KEYS = {"components", "primary_component",
139
139
  "constrain_positive_intensity", "backend", "max_iter",
140
- "bessel_correction", "save_iterations", "weighted"}
140
+ "bessel_correction", "save_iterations", "weighted", "start"}
141
141
  _FITTING_COMPONENT_KEYS = {"wavelength", "tie_center", "tie_width",
142
- "amplitude_greater_than", "name"}
142
+ "amplitude_greater_than", "name", "velocity_limits",
143
+ "width_limits"}
143
144
 
144
145
 
145
146
  def _dataclass_keys(cls) -> set:
@@ -472,6 +473,8 @@ def _parse_fitting_config(config: dict) -> FitConfig | None:
472
473
  tie_width=comp_dict.get("tie_width", None),
473
474
  amplitude_greater_than=comp_dict.get("amplitude_greater_than", None),
474
475
  name=comp_dict.get("name", None),
476
+ velocity_limits=comp_dict.get("velocity_limits"),
477
+ width_limits=comp_dict.get("width_limits"),
475
478
  ))
476
479
 
477
480
  return FitConfig(
@@ -484,6 +487,7 @@ def _parse_fitting_config(config: dict) -> FitConfig | None:
484
487
  bessel_correction=fitting_cfg.get("bessel_correction", False),
485
488
  save_iterations=fitting_cfg.get("save_iterations", False),
486
489
  weighted=fitting_cfg.get("weighted", True),
490
+ start=fitting_cfg.get("start", "search"),
487
491
  )
488
492
 
489
493
 
@@ -353,7 +353,8 @@ def _jsonable(value):
353
353
  # FitConfig.weighted, and None is the detector material's value.
354
354
  _MATERIAL = {"fano_factor": None, "pair_energy_300k": None, "pair_energy_slope": None}
355
355
  _EARLIER_VALUES = {
356
- "FitConfig": {"weighted": False},
356
+ "FitConfig": {"weighted": False, "start": "search"},
357
+ "FitComponent": {"velocity_limits": None, "width_limits": None},
357
358
  "Telescope_EUVST": {"resolution_budget": "none"},
358
359
  "Detector_SWC": {**_MATERIAL,
359
360
  "dispersion": (198.885944, 1.2516577, -1.361114e-4),
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: eclipse
3
- Version: 0.13.0
3
+ Version: 0.14.0
4
4
  Summary: ECLIPSE: Emission Calculation and Line Prediction for SOLAR-C EUVST
5
5
  Author-email: James McKevitt <jm2@mssl.ucl.ac.uk>
6
6
  Project-URL: Homepage, https://github.com/jamesmckevitt/eclipse
@@ -61,8 +61,6 @@ The instrument model will be updated as EUVST is built, tested and commissioned.
61
61
  [![PyPI](https://img.shields.io/pypi/v/eclipse.svg)](https://pypi.org/project/eclipse/)
62
62
  [![DOI](https://img.shields.io/badge/DOI-10.5281%2Fzenodo.17543844-blue.svg)](https://doi.org/10.5281/zenodo.17543844)
63
63
 
64
- ECLIPSE is installed with `pip install eclipse`. ECLIPSE 0.12 and earlier were on PyPI as `solarc-eclipse`.
65
-
66
64
  The documentation, with installation and a first run, guides to synthesising spectra, simulating the instrument and analysing the results, and a worked example, is at **[solarc-eclipse.readthedocs.io](https://solarc-eclipse.readthedocs.io)**.
67
65
 
68
66
  ## Acknowledgements
@@ -71,6 +71,7 @@ tests/test_fit_constraints.py
71
71
  tests/test_fit_finite.py
72
72
  tests/test_fit_multi_start.py
73
73
  tests/test_fit_quality.py
74
+ tests/test_fit_rest_start_and_limits.py
74
75
  tests/test_fit_start.py
75
76
  tests/test_fit_summaries.py
76
77
  tests/test_fit_weighting.py
@@ -0,0 +1,243 @@
1
+ """A blend started at its rest wavelengths, and limits on each component's velocity and width.
2
+
3
+ Started this way, as eispac's templates are, a faint blend stays on its line.
4
+ """
5
+ import astropy.constants as const
6
+ import astropy.units as u
7
+ import numpy as np
8
+ import pytest
9
+ import yaml
10
+ from astropy.wcs import WCS
11
+ from ndcube import NDCube
12
+
13
+ from eclipse.fitting import FitComponent, FitConfig, _guess_multi_params, fit_cube_gauss
14
+ from eclipse.results_file import load_results, save_results
15
+
16
+ STEP = 0.0169 * u.Angstrom
17
+ N_WAVE = 31
18
+ CENTRE = 195.13 * u.Angstrom
19
+ STRONG, FAINT = 195.119 * u.Angstrom, 195.179 * u.Angstrom
20
+ SIGMA = 0.027 * u.Angstrom
21
+ C_KM_S = const.c.to_value(u.km / u.s)
22
+ # eispac's fe_12_195_119.2c template: the centre within 0.04 Angstrom of rest
23
+ # and the width between 0.0191 and 0.0510 Angstrom.
24
+ EISPAC = dict(velocity_limits=(-61.5 * u.km / u.s, 61.5 * u.km / u.s),
25
+ width_limits=(0.0191 * u.AA, 0.0510 * u.AA))
26
+
27
+
28
+ def _wave():
29
+ return CENTRE + (np.arange(N_WAVE) - (N_WAVE - 1) / 2) * STEP
30
+
31
+
32
+ def _cube(spectra):
33
+ """Spectra, shaped (n, N_WAVE), as a (1, n, N_WAVE) cube."""
34
+ wcs = WCS(naxis=3)
35
+ wcs.wcs.ctype = ["WAVE", "HPLN-TAN", "HPLT-TAN"]
36
+ wcs.wcs.cunit = ["cm", "arcsec", "arcsec"]
37
+ wcs.wcs.cdelt = [STEP.to_value(u.cm), 0.4, 0.16]
38
+ wcs.wcs.crpix = [(N_WAVE + 1) / 2, 1.0, 1.0]
39
+ wcs.wcs.crval = [CENTRE.to_value(u.cm), 0.0, 0.0]
40
+ return NDCube(np.asarray(spectra)[np.newaxis], wcs=wcs, unit=u.DN / u.pix)
41
+
42
+
43
+ def _blend(velocity=0.0, strong=100.0, ratio=0.06, background=5.0):
44
+ """Fe XII 195.119 and a 195.179 `ratio` as bright, at `velocity` km/s."""
45
+ wave = _wave()
46
+ return background + sum(
47
+ peak * np.exp(-0.5 * ((wave - rest * (1 + velocity / C_KM_S)) / SIGMA)
48
+ .decompose().value ** 2)
49
+ for peak, rest in ((strong, STRONG), (strong * ratio, FAINT)))
50
+
51
+
52
+ def _components(primary=None, **limits):
53
+ return [FitComponent(STRONG, **(primary or {}), **limits),
54
+ FitComponent(FAINT, tie_center=0, tie_width=0)]
55
+
56
+
57
+ def _velocity(params):
58
+ return (params[..., 1] * u.cm / STRONG.to(u.cm) - 1).value * C_KM_S
59
+
60
+
61
+ @pytest.mark.parametrize("backend", ["scipy", "mpfit"])
62
+ def test_a_noisy_faint_blend_started_at_rest_and_limited_stays_on_its_line(backend):
63
+ """
64
+ With a few dozen counts in the line, a search start lands some fits a
65
+ line spacing, 92 km/s, from the truth; at rest within eispac's limits
66
+ none do.
67
+ """
68
+ rng = np.random.default_rng(3)
69
+ truth = _blend(strong=30.0)
70
+ spectra = rng.poisson(np.tile(truth, (400, 1))).astype(float)
71
+ cube = _cube(spectra)
72
+
73
+ def far(**settings):
74
+ config = FitConfig(components=_components(**settings.pop("limits", {})),
75
+ constrain_positive_intensity=True, backend=backend, **settings)
76
+ data, _, failed = fit_cube_gauss(cube, n_jobs=1, fit_config=config, return_failed=True)
77
+ velocity = _velocity(data[0])[~failed[0]]
78
+ return np.mean(np.abs(velocity) > 45)
79
+
80
+ searched = far()
81
+ rested = far(start="rest", limits=EISPAC)
82
+ assert searched > 0.02
83
+ assert rested < searched / 4
84
+
85
+
86
+ @pytest.mark.parametrize("backend", ["scipy", "mpfit"])
87
+ def test_a_line_beyond_its_velocity_limit_is_fitted_at_the_limit(backend):
88
+ """
89
+ The line is to the blue. To the red, the faint component, 92 km/s to the
90
+ red of the other, could reach it from inside the limits.
91
+ """
92
+ config = FitConfig(components=_components(**EISPAC), start="rest", backend=backend)
93
+ data, _, failed = fit_cube_gauss(_cube([_blend(velocity=-80.0)]), n_jobs=1,
94
+ fit_config=config, return_failed=True)
95
+ assert _velocity(data[0, 0]) == pytest.approx(-61.5, abs=0.05)
96
+ width = (data[0, 0, 2] * u.cm).to_value(u.AA)
97
+ assert 0.0191 - 1e-9 <= width <= 0.0510 + 1e-9
98
+
99
+
100
+ @pytest.mark.parametrize("backend", ["scipy", "mpfit"])
101
+ def test_limits_that_hold_the_truth_change_nothing_in_a_noiseless_fit(backend):
102
+ spectrum = _cube([_blend(velocity=20.0, ratio=0.2)])
103
+ fitted = {}
104
+ for name, settings in (("free", {}), ("limited", {"start": "rest"})):
105
+ limits = EISPAC if name == "limited" else {}
106
+ config = FitConfig(components=_components(**limits), backend=backend, **settings)
107
+ fitted[name] = fit_cube_gauss(spectrum, n_jobs=1, fit_config=config)[0][0, 0]
108
+ assert _velocity(fitted["limited"]) == pytest.approx(20.0, abs=1e-3)
109
+ assert np.allclose(fitted["limited"], fitted["free"], rtol=1e-5, atol=0.0)
110
+
111
+
112
+ def test_a_rest_start_puts_each_component_at_its_rest_wavelength():
113
+ """A search finds the shifted blend; a rest start does not look, but stays within limits."""
114
+ spectrum = _blend(velocity=40.0, ratio=0.3)
115
+ wave = _wave().to_value(u.cm)
116
+ searched = _guess_multi_params(wave, spectrum, FitConfig(components=_components()))
117
+ rested = _guess_multi_params(wave, spectrum, FitConfig(components=_components(),
118
+ start="rest"))
119
+ assert _velocity(searched) == pytest.approx(40.0, abs=30.0)
120
+ assert rested[1] == pytest.approx(STRONG.to_value(u.cm), rel=1e-12)
121
+ assert rested[4] == pytest.approx(FAINT.to_value(u.cm), rel=1e-12)
122
+
123
+ narrow = FitConfig(components=_components(width_limits=(0.05 * u.AA, 0.06 * u.AA),
124
+ velocity_limits=(10 * u.km / u.s,
125
+ 20 * u.km / u.s)),
126
+ start="rest")
127
+ clipped = _guess_multi_params(wave, spectrum, narrow)
128
+ assert _velocity(clipped) == pytest.approx(10.0, rel=1e-9)
129
+ assert (clipped[2] * u.cm).to_value(u.AA) == pytest.approx(0.05, rel=1e-12)
130
+
131
+
132
+ @pytest.mark.parametrize("settings, message", [
133
+ ({"start": "middle"}, "fitting.start must be 'search' or 'rest'"),
134
+ ({"components": [FitComponent(STRONG), FitComponent(
135
+ FAINT, tie_center=0, velocity_limits=(-60 * u.km / u.s, 60 * u.km / u.s))]},
136
+ "velocity_limits and tie_center"),
137
+ ({"components": [FitComponent(STRONG), FitComponent(
138
+ FAINT, tie_width=0, width_limits=(0.02 * u.AA, 0.05 * u.AA))]},
139
+ "width_limits and tie_width"),
140
+ ({"start": "rest", "components": []}, "only applied to multi-component fits"),
141
+ ])
142
+ def test_what_cannot_apply_is_refused(settings, message):
143
+ with pytest.raises(ValueError, match=message):
144
+ FitConfig(**{"components": _components(), **settings})
145
+
146
+
147
+ @pytest.mark.parametrize("limits", [
148
+ {"velocity_limits": (60 * u.km / u.s, -60 * u.km / u.s)},
149
+ {"velocity_limits": (-60 * u.AA, 60 * u.AA)},
150
+ {"velocity_limits": (-60 * u.km / u.s,)},
151
+ {"width_limits": (0 * u.AA, 0.05 * u.AA)},
152
+ {"width_limits": (0.02 * u.km / u.s, 0.05 * u.km / u.s)},
153
+ ])
154
+ def test_limits_that_are_not_two_ordered_values_of_the_right_kind_are_refused(limits):
155
+ with pytest.raises(ValueError, match="must be two"):
156
+ FitComponent(STRONG, **limits)
157
+
158
+
159
+ def test_a_configuration_file_gives_them_with_units(tmp_path):
160
+ from eclipse.main import _parse_fitting_config
161
+
162
+ text = """
163
+ fitting:
164
+ start: rest
165
+ constrain_positive_intensity: true
166
+ components:
167
+ - wavelength: 195.119 angstrom
168
+ velocity_limits: [-60 km/s, 60 km/s]
169
+ width_limits: [0.019 angstrom, 0.051 angstrom]
170
+ - wavelength: 195.179 angstrom
171
+ tie_center: 0
172
+ tie_width: 0
173
+ """
174
+ config = _parse_fitting_config(yaml.safe_load(text))
175
+ assert config.start == "rest"
176
+ low, high = config.components[0].velocity_limits
177
+ assert low == -60 * u.km / u.s and high == 60 * u.km / u.s
178
+ assert config.components[0].width_limits[1] == 0.051 * u.AA
179
+
180
+
181
+ def test_the_settings_are_kept_in_a_results_file(tmp_path):
182
+ config = FitConfig(components=_components(**EISPAC), start="rest")
183
+ save_results(tmp_path / "run.h5", {"config_objects": {"fit": config}})
184
+ restored = load_results(tmp_path / "run.h5")["config_objects"]["fit"]
185
+ assert restored.start == "rest"
186
+ assert restored.components[0].velocity_limits == EISPAC["velocity_limits"]
187
+ assert restored.components[0].width_limits == EISPAC["width_limits"]
188
+
189
+
190
+ def test_a_results_file_from_before_the_settings_loads_as_it_was_run(tmp_path):
191
+ """Earlier runs searched and had no limits, which is what the settings give, without a warning."""
192
+ import json
193
+ import warnings
194
+
195
+ import h5py
196
+
197
+ from eclipse import results_file
198
+
199
+ config = FitConfig(components=_components())
200
+ path = save_results(tmp_path / "old.h5", {"instrument": "SWC"})
201
+ encoded = results_file._jsonable(config)
202
+ del encoded["fields"]["start"]
203
+ for component in encoded["fields"]["components"]:
204
+ del component["fields"]["velocity_limits"], component["fields"]["width_limits"]
205
+ with h5py.File(path, "r+") as f:
206
+ f.attrs["fit"] = json.dumps(encoded)
207
+ f.attrs["eclipse_order"] = json.dumps(["instrument", "fit"])
208
+ with warnings.catch_warnings(record=True) as seen:
209
+ warnings.simplefilter("always")
210
+ restored = load_results(path)["fit"]
211
+ assert not seen, [str(w.message) for w in seen]
212
+ assert repr(restored) == repr(config)
213
+
214
+
215
+ def test_a_search_within_limits_stays_within_them():
216
+ """The comb search tries only shifts that keep a limited component within its limits."""
217
+ spectrum = _blend(velocity=-40.0, ratio=0.06, strong=1.0) + _blend(velocity=52.0, ratio=0.0,
218
+ strong=1.2, background=0)
219
+ config = FitConfig(components=_components(velocity_limits=(-60 * u.km / u.s,
220
+ 0 * u.km / u.s)))
221
+ guess = _guess_multi_params(_wave().to_value(u.cm), spectrum, config)
222
+ assert -60.0 <= _velocity(guess) <= 0.0
223
+ assert _velocity(guess) == pytest.approx(-40.0, abs=10.0)
224
+
225
+
226
+ @pytest.mark.parametrize("value", ["60 km/s", 60, ["-60 km/s", "60 kms"]])
227
+ def test_a_mistyped_limit_in_a_configuration_file_says_what_it_should_be(value):
228
+ from eclipse.main import _parse_fitting_config
229
+
230
+ fitting = {"components": [{"wavelength": "195.119 angstrom", "velocity_limits": value},
231
+ {"wavelength": "195.179 angstrom", "tie_center": 0}]}
232
+ with pytest.raises(ValueError, match="velocity_limits must be two"):
233
+ _parse_fitting_config({"fitting": fitting})
234
+
235
+
236
+ def test_limits_set_after_a_component_is_made_are_read():
237
+ component = FitComponent(STRONG)
238
+ component.velocity_limits = (-10 * u.km / u.s, 10 * u.km / u.s)
239
+ component.width_limits = (0.02 * u.AA, 0.03 * u.AA)
240
+ rest = STRONG.to_value(u.cm)
241
+ assert component.centre_limits() == pytest.approx(
242
+ (rest * (1 - 10 / C_KM_S), rest * (1 + 10 / C_KM_S)), rel=1e-12)
243
+ assert component.sigma_limits() == pytest.approx((0.02e-8, 0.03e-8), rel=1e-12)
@@ -134,7 +134,9 @@ def test_an_exposure_is_shared_between_the_snapshots_it_spans(tmp_path):
134
134
  # Within one snapshot's span.
135
135
  assert series.coverage(2 * u.s, 8 * u.s) == [(0, 1.0)]
136
136
  # A quarter in the first, three quarters in the second.
137
- assert series.coverage(5 * u.s, 25 * u.s) == pytest.approx([(0, 0.25), (1, 0.75)])
137
+ shares = series.coverage(5 * u.s, 25 * u.s)
138
+ assert [index for index, _ in shares] == [0, 1]
139
+ assert [fraction for _, fraction in shares] == pytest.approx([0.25, 0.75])
138
140
  # The last snapshot lasts as long as the gap before it.
139
141
  assert series.coverage(40 * u.s, 50 * u.s) == [(2, 1.0)]
140
142
  for start, end in ((-1, 5), (45, 51), (5, 5)):
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes