eclipse 0.13.0__py3-none-any.whl

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 (50) hide show
  1. eclipse/__init__.py +95 -0
  2. eclipse/analysis.py +853 -0
  3. eclipse/atmosphere.py +859 -0
  4. eclipse/band.py +477 -0
  5. eclipse/cli.py +138 -0
  6. eclipse/config.py +1710 -0
  7. eclipse/continuum.py +413 -0
  8. eclipse/data/eis_response/EIS_EffArea_A.004 +61 -0
  9. eclipse/data/eis_response/EIS_EffArea_B.004 +60 -0
  10. eclipse/data/eis_response/EIS_EffArea_B.005 +64 -0
  11. eclipse/data/eis_response/README_dz2025 +50 -0
  12. eclipse/data/eis_response/fit_eis_ea_2023-05-04_smooth.sav +0 -0
  13. eclipse/data/eis_response/source.txt +33 -0
  14. eclipse/data/science_cases.yaml +365 -0
  15. eclipse/data/throughput/grating_reflection_efficiency.dat +25 -0
  16. eclipse/data/throughput/primary_mirror_coating_reflectance.dat +25 -0
  17. eclipse/data/throughput/source.txt +6 -0
  18. eclipse/data/throughput/throughput_aluminium_1000_angstrom.dat +503 -0
  19. eclipse/data/throughput/throughput_aluminium_oxide_1000_angstrom.dat +503 -0
  20. eclipse/data/throughput/throughput_carbon_1000_angstrom.dat +503 -0
  21. eclipse/data_processing.py +904 -0
  22. eclipse/deprecated.py +161 -0
  23. eclipse/eis_calibration.py +573 -0
  24. eclipse/extern/__init__.py +0 -0
  25. eclipse/extern/mpfit.py +2388 -0
  26. eclipse/fitting.py +1734 -0
  27. eclipse/frame.py +594 -0
  28. eclipse/full_frame.py +944 -0
  29. eclipse/main.py +1646 -0
  30. eclipse/monte_carlo.py +660 -0
  31. eclipse/pinhole_diffraction.py +409 -0
  32. eclipse/psf.py +6 -0
  33. eclipse/radiometric.py +1328 -0
  34. eclipse/raster.py +869 -0
  35. eclipse/readout.py +1002 -0
  36. eclipse/resolution_budget.py +108 -0
  37. eclipse/results_file.py +1226 -0
  38. eclipse/sampling.py +351 -0
  39. eclipse/science_cases.py +364 -0
  40. eclipse/synthesis.py +2492 -0
  41. eclipse/synthesis_cli.py +12 -0
  42. eclipse/synthesis_file.py +882 -0
  43. eclipse/utils.py +1145 -0
  44. eclipse-0.13.0.dist-info/METADATA +70 -0
  45. eclipse-0.13.0.dist-info/RECORD +50 -0
  46. eclipse-0.13.0.dist-info/WHEEL +5 -0
  47. eclipse-0.13.0.dist-info/entry_points.txt +7 -0
  48. eclipse-0.13.0.dist-info/licenses/LICENSE +3 -0
  49. eclipse-0.13.0.dist-info/top_level.txt +2 -0
  50. euvst_response/__init__.py +129 -0
@@ -0,0 +1,364 @@
1
+ """
2
+ Science cases from the Concept Study Report, and ECLIPSE configurations for them.
3
+
4
+ `data/science_cases.yaml` holds every case with all its lines. The functions
5
+ here turn the lines ECLIPSE can currently simulate into uniform-intensity
6
+ configurations, and the `eclipse-science-cases` command writes them to files.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import copy
13
+ from dataclasses import dataclass
14
+ from decimal import Decimal
15
+ from functools import lru_cache
16
+ from importlib.resources import files
17
+ from pathlib import Path
18
+
19
+ import astropy.constants as const
20
+ import astropy.units as u
21
+ import numpy as np
22
+ import yaml
23
+
24
+ from .config import Telescope_EUVST, _load_throughput_table
25
+ from .utils import element_data, load_yaml_config
26
+
27
+ # Defaults for every configuration unless the base settings override them:
28
+ # the settings the cases were run with for the NASA PDR analysis.
29
+ DEFAULT_SETTINGS = {
30
+ "n_iter": 512,
31
+ "ncpu": -1,
32
+ "offchip_bin_slit": [1, 2],
33
+ "simulation": {"psf": True},
34
+ }
35
+
36
+ # Keys that each configuration takes from its line, so the base settings
37
+ # cannot set them.
38
+ _LINE_KEYS = ("instrument", "uniform_intensity", "rest_wavelength",
39
+ "thermal_width", "synthesis_file", "reference_line")
40
+
41
+ # Time-series keys, not allowed in a configuration observing one line's
42
+ # intensity.
43
+ _SERIES_KEYS = ("atmosphere_series", "synthesis_series", "raster", "synthesis")
44
+
45
+ # Two table entries for the same line can be written to different precision,
46
+ # e.g. 195.119 and 195.12, so a wavelength matches lines within this.
47
+ _WAVELENGTH_MATCH = 0.01 * u.AA
48
+
49
+ _ROMAN = {numeral: value for value, numeral in enumerate(
50
+ "I II III IV V VI VII VIII IX X XI XII XIII XIV XV XVI XVII XVIII XIX XX "
51
+ "XXI XXII XXIII XXIV XXV XXVI XXVII XXVIII XXIX XXX".split(), start=1)}
52
+
53
+
54
+ @dataclass
55
+ class ScienceCaseConfig:
56
+ """ECLIPSE configuration for one line of one science case."""
57
+ name: str
58
+ case: dict
59
+ line: dict
60
+ config: dict
61
+
62
+ def to_yaml(self) -> str:
63
+ """Configuration as YAML text, with a header giving its source."""
64
+ header = (
65
+ f"# Science case {self.case['task']} ({self.case['name']}): "
66
+ f"{self.case['description']}\n"
67
+ f"# Line: {line_label(self.line)}, log T_max = {float(self.line['log_t_max']):.2f}, "
68
+ f"intensity {self.line['intensity']} times filling factor "
69
+ f"{self.case['filling_factor']}\n"
70
+ f"# Written by eclipse-science-cases from the science case table\n\n"
71
+ )
72
+ return header + yaml.safe_dump(self.config, sort_keys=False,
73
+ default_flow_style=None)
74
+
75
+
76
+ def load_science_cases(path: str | Path | None = None) -> list[dict]:
77
+ """
78
+ Read the science case table.
79
+
80
+ Parameters
81
+ ----------
82
+ path : str or Path, optional
83
+ Table to read instead of the one included with ECLIPSE.
84
+
85
+ Returns
86
+ -------
87
+ list of dict
88
+ One entry per case, as written in the table.
89
+ """
90
+ if path is None:
91
+ source = files("eclipse") / "data" / "science_cases.yaml"
92
+ else:
93
+ source = Path(path)
94
+ return yaml.safe_load(source.read_text())
95
+
96
+
97
+ def line_label(line: dict) -> str:
98
+ """A line as written in the table, e.g. `Fe XII 195.119`."""
99
+ return f"{line['ion']} {line['wavelength'].split()[0]}"
100
+
101
+
102
+ def line_id(line: dict) -> str:
103
+ """A line as used in file names, e.g. `fe12_195119`."""
104
+ symbol, numeral = line["ion"].split()
105
+ wavelength = u.Quantity(line["wavelength"]).to_value(u.AA)
106
+ return f"{symbol.lower()}{_ROMAN[numeral]:02d}_{wavelength:.3f}".replace(".", "")
107
+
108
+
109
+ def thermal_width(line: dict) -> u.Quantity:
110
+ """1-sigma thermal velocity, sqrt(k T / m), at the line's T_max."""
111
+ mass = element_data(line["ion"].split()[0])[1] * const.u
112
+ temperature = 10.0 ** float(line["log_t_max"]) * u.K
113
+ return np.sqrt(const.k_B * temperature / mass).to(u.km / u.s)
114
+
115
+
116
+ @lru_cache(maxsize=None)
117
+ def short_wavelength_range() -> tuple[u.Quantity, u.Quantity]:
118
+ """
119
+ Wavelength range ECLIPSE can simulate in the short wavelength channel.
120
+
121
+ This is where both the primary mirror and grating efficiency tables have
122
+ values; the effective area is undefined outside them.
123
+ """
124
+ telescope = Telescope_EUVST()
125
+ tables = [_load_throughput_table(table)[0]
126
+ for table in (telescope.pm_table, telescope.grating_table)]
127
+ return (max(t.min() for t in tables).to(u.AA),
128
+ min(t.max() for t in tables).to(u.AA))
129
+
130
+
131
+ def instrument_for(line: dict) -> str | None:
132
+ """ECLIPSE instrument that can simulate `line`, or None if there is none yet."""
133
+ low, high = short_wavelength_range()
134
+ return "SWC" if low <= u.Quantity(line["wavelength"]) <= high else None
135
+
136
+
137
+ def _line_matches(line: dict, selector: str) -> bool:
138
+ """Whether `selector`, an ion or an ion and wavelength, matches `line`."""
139
+ tokens = selector.split()
140
+ if len(tokens) not in (2, 3):
141
+ raise ValueError(
142
+ f"Choose a line by ion, e.g. 'Fe XII', or by ion and wavelength in "
143
+ f"Angstrom, e.g. 'Fe XII 195.119'; got {selector!r}."
144
+ )
145
+ if line["ion"] != " ".join(tokens[:2]):
146
+ return False
147
+ if len(tokens) == 2:
148
+ return True
149
+ wavelength = u.Quantity(line["wavelength"])
150
+ return abs(wavelength - float(tokens[2]) * u.AA) <= _WAVELENGTH_MATCH
151
+
152
+
153
+ def _select_cases(table: list[dict], selectors: list[str] | None) -> list[dict]:
154
+ if not selectors:
155
+ return table
156
+ unknown = [s for s in selectors
157
+ if not any(s in (case["task"], case["name"]) for case in table)]
158
+ if unknown:
159
+ raise ValueError(
160
+ f"No science case is called {unknown}. The cases are "
161
+ f"{[case['task'] for case in table]}, or their names, e.g. "
162
+ f"{table[0]['name']!r}."
163
+ )
164
+ return [case for case in table
165
+ if any(s in (case["task"], case["name"]) for s in selectors)]
166
+
167
+
168
+ def _chosen_lines(table: list[dict], cases: list[str] | None,
169
+ lines: list[str] | None) -> list[tuple[dict, dict]]:
170
+ """Chosen `(case, line)` pairs; a line selector that matches none raises ValueError."""
171
+ chosen, matched = [], set()
172
+ for case in _select_cases(table, cases):
173
+ for line in case["lines"]:
174
+ if lines:
175
+ picked = {s for s in lines if _line_matches(line, s)}
176
+ if not picked:
177
+ continue
178
+ matched |= picked
179
+ chosen.append((case, line))
180
+ unmatched = [s for s in (lines or []) if s not in matched]
181
+ if unmatched:
182
+ raise ValueError(f"No line in the chosen cases matches {unmatched}.")
183
+ return chosen
184
+
185
+
186
+ def _config(case: dict, line: dict, instrument: str, base: dict) -> dict:
187
+ """One line's configuration: defaults, then the case, then the base settings."""
188
+ value, unit = line["intensity"].split(maxsplit=1)
189
+ # Decimal keeps every digit of the table without binary rounding,
190
+ # e.g. 823 x 0.2 is 164.6, not 164.60000000000002.
191
+ intensity = Decimal(value) * Decimal(str(case["filling_factor"]))
192
+
193
+ settings = {**DEFAULT_SETTINGS, **base}
194
+ config = {"instrument": instrument}
195
+ config.update({k: v for k, v in settings.items() if not isinstance(v, dict)})
196
+ config["uniform_intensity"] = f"{intensity.normalize():f} {unit}"
197
+ config["rest_wavelength"] = line["wavelength"]
198
+ config["thermal_width"] = f"{thermal_width(line).to_value(u.km / u.s):.2f} km/s"
199
+ config["simulation"] = {
200
+ **DEFAULT_SETTINGS["simulation"],
201
+ "expos": case["exposure"],
202
+ "slit_width": case["slit_width"],
203
+ **base.get("simulation", {}),
204
+ }
205
+ config.update({k: v for k, v in settings.items()
206
+ if isinstance(v, dict) and k != "simulation"})
207
+ # Deep copy, so no list or section is shared with the defaults or the
208
+ # other configurations.
209
+ return copy.deepcopy(config)
210
+
211
+
212
+ def science_case_configs(cases: list[str] | None = None,
213
+ lines: list[str] | None = None,
214
+ base: dict | None = None,
215
+ table: list[dict] | None = None,
216
+ ) -> tuple[list[ScienceCaseConfig], list[str]]:
217
+ """
218
+ ECLIPSE configurations for the science case lines ECLIPSE can simulate.
219
+
220
+ Each uses uniform intensity mode: the line's intensity times the case's
221
+ filling factor, the thermal width at the line's T_max, and the case's
222
+ exposure time and slit width.
223
+
224
+ Parameters
225
+ ----------
226
+ cases : list of str, optional
227
+ Cases to include, by task (`I-1-1`) or name
228
+ (`1.1.1-nanoflares_events`). Default: every case.
229
+ lines : list of str, optional
230
+ Lines to include, by ion (`Fe XII`) or by ion and wavelength in
231
+ Angstrom (`Fe XII 195.119`, matched to 0.01 Angstrom). Default:
232
+ every line.
233
+ base : dict, optional
234
+ Settings for every configuration, as in a configuration file. These
235
+ override the defaults (512 iterations on every CPU, PSF on, off-chip
236
+ binning of 1 and 2) and, where given, the case's exposure time and
237
+ slit width. They cannot set the instrument or the line.
238
+ table : list of dict, optional
239
+ Science cases to use instead of the table included with ECLIPSE.
240
+
241
+ Returns
242
+ -------
243
+ configs : list of ScienceCaseConfig
244
+ One per chosen line that ECLIPSE can simulate.
245
+ skipped : list of str
246
+ Chosen lines ECLIPSE cannot simulate yet, as `<task> <line>`.
247
+ """
248
+ from .main import _require_mapping, _validate_config_keys
249
+
250
+ table = load_science_cases() if table is None else table
251
+ base = {} if base is None else base
252
+ for key in _LINE_KEYS:
253
+ if key in base:
254
+ raise ValueError(
255
+ f"The base settings cannot set '{key}': every configuration "
256
+ f"takes it from its line."
257
+ )
258
+ for key in _SERIES_KEYS:
259
+ if key in base:
260
+ raise ValueError(
261
+ f"The base settings cannot set '{key}': every configuration "
262
+ f"observes its line's intensity, not a time series."
263
+ )
264
+
265
+ # The simulation section is merged with each case's settings, so check it
266
+ # here; the other sections are checked with the whole configuration.
267
+ if "simulation" in base:
268
+ _require_mapping(base["simulation"], "The base settings' 'simulation:' section")
269
+
270
+ configs, skipped = [], []
271
+ for case, line in _chosen_lines(table, cases, lines):
272
+ instrument = instrument_for(line)
273
+ if instrument is None:
274
+ skipped.append(f"{case['task']} {line_label(line)}")
275
+ continue
276
+ config = _config(case, line, instrument, base)
277
+ _validate_config_keys(config, instrument)
278
+ configs.append(ScienceCaseConfig(
279
+ name=f"{case['name']}_{line_id(line)}",
280
+ case=case, line=line, config=config))
281
+ return configs, skipped
282
+
283
+
284
+ def write_science_case_configs(out_dir: str | Path, **kwargs) -> tuple[list[Path], list[str]]:
285
+ """
286
+ Write the configurations from :func:`science_case_configs` to files.
287
+
288
+ Parameters
289
+ ----------
290
+ out_dir : str or Path
291
+ Folder to write to. Each file is named after its case and line, and
292
+ so are the results of running it.
293
+ **kwargs
294
+ Passed to :func:`science_case_configs`.
295
+
296
+ Returns
297
+ -------
298
+ paths : list of Path
299
+ Files written.
300
+ skipped : list of str
301
+ Chosen lines ECLIPSE cannot simulate yet.
302
+ """
303
+ configs, skipped = science_case_configs(**kwargs)
304
+ out_dir = Path(out_dir)
305
+ out_dir.mkdir(parents=True, exist_ok=True)
306
+ paths = []
307
+ for item in configs:
308
+ path = out_dir / f"{item.name}.yaml"
309
+ path.write_text(item.to_yaml())
310
+ paths.append(path)
311
+ return paths, skipped
312
+
313
+
314
+ def main(argv: list[str] | None = None) -> None:
315
+ """Command line entry point for `eclipse-science-cases`."""
316
+ parser = argparse.ArgumentParser(
317
+ prog="eclipse-science-cases",
318
+ description="Write ECLIPSE configurations for the science cases in "
319
+ "the Concept Study Report.",
320
+ )
321
+ parser.add_argument("--out", default="run/input",
322
+ help="folder to write the configurations to "
323
+ "(default: run/input)")
324
+ parser.add_argument("--case", action="append", dest="cases", metavar="CASE",
325
+ help="a case to include, by task (I-1-1) or name "
326
+ "(1.1.1-nanoflares_events); repeat for more "
327
+ "(default: every case)")
328
+ parser.add_argument("--line", action="append", dest="lines", metavar="LINE",
329
+ help="a line to include, by ion ('Fe XII') or by ion "
330
+ "and wavelength in Angstrom ('Fe XII 195.119'); "
331
+ "repeat for more (default: every line)")
332
+ parser.add_argument("--base", metavar="YAML",
333
+ help="a configuration file whose settings go into every "
334
+ "configuration, e.g. a detector section or n_iter")
335
+ parser.add_argument("--list", action="store_true",
336
+ help="list the chosen cases and lines instead of "
337
+ "writing anything")
338
+ args = parser.parse_args(argv)
339
+
340
+ if args.list:
341
+ shown = None
342
+ for case, line in _chosen_lines(load_science_cases(), args.cases, args.lines):
343
+ if case is not shown:
344
+ print(f"{case['task']} {case['name']}: {case['description']}")
345
+ shown = case
346
+ instrument = instrument_for(line) or "not simulated yet"
347
+ print(f" {line_label(line):<22} {instrument}")
348
+ return
349
+
350
+ base = {}
351
+ if args.base:
352
+ base = load_yaml_config(Path(args.base).read_text()) or {}
353
+ if not isinstance(base, dict):
354
+ raise ValueError(f"{args.base} has to hold a mapping of settings.")
355
+
356
+ paths, skipped = write_science_case_configs(
357
+ args.out, cases=args.cases, lines=args.lines, base=base)
358
+ low, high = short_wavelength_range()
359
+ print(f"Wrote {len(paths)} configuration{'' if len(paths) == 1 else 's'} "
360
+ f"to {args.out}")
361
+ if skipped:
362
+ print(f"Skipped {len(skipped)} line{'' if len(skipped) == 1 else 's'} "
363
+ f"outside the {low.value:g} to {high.value:g} Angstrom ECLIPSE can "
364
+ f"simulate yet; --list shows which")