simsit 1.0.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.
simsit/__init__.py ADDED
@@ -0,0 +1,23 @@
1
+ from . import tools
2
+ from .cadence import SimpleCadence
3
+ from .catalog import GaiaStarCatalog, MockStarCatalog
4
+ from .instrument import Instrument
5
+ from .passband import Passband
6
+ from .tracker import InertialTracker, OrbitTracker, SiderealTracker, sidereal_tracker
7
+ from .wcs import radialWCS, radial_wcs
8
+
9
+ __all__ = [
10
+ "tools",
11
+ "SimpleCadence",
12
+ "GaiaStarCatalog",
13
+ "MockStarCatalog",
14
+ "Instrument",
15
+ "Passband",
16
+ "InertialTracker",
17
+ "OrbitTracker",
18
+ "sidereal_tracker",
19
+ "radial_wcs",
20
+ # Backward-compatible aliases
21
+ "SiderealTracker",
22
+ "radialWCS",
23
+ ]
simsit/_version.py ADDED
@@ -0,0 +1,4 @@
1
+ __version__ = "1.0.0"
2
+
3
+ _base_version = __version__.split("rc", 1)[0]
4
+ __version_info__ = tuple(map(int, _base_version.split(".")))
simsit/backend.py ADDED
@@ -0,0 +1,235 @@
1
+ """SSATK observation boundary: GCRF states in SI and explicit GPS/Time epochs."""
2
+
3
+ import astropy.units as u
4
+ import numpy as np
5
+ import ssatk
6
+ from astropy.coordinates import EarthLocation
7
+ from astropy.time import Time, TimeDelta
8
+
9
+
10
+ EARTH_RADIUS_M = ssatk.constants.WGS72_EARTH_RADIUS
11
+ OBSERVATION_MODES = ("geometric", "apparent")
12
+ __all__ = [
13
+ "as_time", "observation_mode", "orbit_from_state", "keplerian_propagator",
14
+ "states", "sky_coordinates", "observer_from_location", "observer_location",
15
+ "catalog_to_apparent", "find_passes", "refine_pass",
16
+ "lambertian_reflection", "thermal_emission", "faceted_reflection", "facets_from_config",
17
+ ]
18
+
19
+
20
+ def as_time(time):
21
+ """Accept Astropy Time or explicitly interpreted numeric GPS seconds."""
22
+ if isinstance(time, Time):
23
+ result = time
24
+ else:
25
+ if np.ma.getmaskarray(time).any():
26
+ raise ValueError("Epochs must not be masked.")
27
+ values = np.asarray(time)
28
+ if values.dtype.kind not in "iuf":
29
+ raise TypeError("Epochs must be Astropy Time or numeric GPS seconds.")
30
+ result = Time(values, format="gps")
31
+ if np.any(result.mask):
32
+ raise ValueError("Epochs must not be masked.")
33
+ if result.ndim > 1 or not np.isfinite(result.gps).all():
34
+ raise ValueError("Epochs must be finite scalars or one-dimensional arrays.")
35
+ return result
36
+
37
+
38
+ def observation_mode(mode):
39
+ """Validate the shared satellite pointing/rendering convention."""
40
+ if mode not in OBSERVATION_MODES:
41
+ raise ValueError(f"Observation mode must be one of {OBSERVATION_MODES}.")
42
+ return mode
43
+
44
+
45
+ def _gcrf(frame):
46
+ if frame != "GCRF":
47
+ raise ValueError("Orbit states must use GCRF; transform other frames explicitly.")
48
+
49
+
50
+ def _values(value, unit):
51
+ if np.ma.getmaskarray(value).any():
52
+ raise ValueError("Physical inputs must not be masked.")
53
+ if isinstance(value, u.Quantity):
54
+ value = value.to_value(unit)
55
+ result = np.asarray(value, dtype=float)
56
+ if not np.isfinite(result).all():
57
+ raise ValueError("Physical inputs must be finite.")
58
+ return result
59
+
60
+
61
+ def orbit_from_state(position_m, velocity_m_s, time, *, frame="GCRF"):
62
+ """Construct an SSATK orbit from one finite GCRF SI state."""
63
+ _gcrf(frame)
64
+ position = _values(position_m, u.m)
65
+ velocity = _values(velocity_m_s, u.m / u.s)
66
+ epoch = as_time(time)
67
+ if (position.shape != (3,) or velocity.shape != (3,)
68
+ or not np.isfinite(position).all() or not np.isfinite(velocity).all()
69
+ or not epoch.isscalar):
70
+ raise ValueError("One finite position, velocity and scalar epoch are required.")
71
+ return ssatk.Orbit(position, velocity, epoch)
72
+
73
+
74
+ def keplerian_propagator():
75
+ """Return the two-body SSATK propagator used by synthetic orbit examples."""
76
+ return ssatk.KeplerianPropagator()
77
+
78
+
79
+ def states(orbit, time, *, propagator=None, frame="GCRF"):
80
+ """Return positions in meters and velocities in m/s, both in GCRF."""
81
+ _gcrf(frame)
82
+ kwargs = {} if propagator is None else {"propagator": propagator}
83
+ return ssatk.rv(orbit, as_time(time), **kwargs)
84
+
85
+
86
+ def sky_coordinates(orbit, time, *, observer, propagator=None, mode="geometric"):
87
+ """Return RA/Dec in radians and range in meters at photon arrival time.
88
+
89
+ ``geometric`` uses simultaneous object/site positions. ``apparent`` asks
90
+ SSATK for iterative light-time and observer-velocity aberration correction.
91
+ Both use the same GCRF axes; neither adds atmospheric refraction.
92
+ """
93
+ observation_mode(mode)
94
+ kwargs = {} if propagator is None else {"propagator": propagator}
95
+ return ssatk.radec(
96
+ orbit, as_time(time), observer=observer,
97
+ obsAngleCorrection=None if mode == "geometric" else "exact", **kwargs,
98
+ )
99
+
100
+
101
+ def observer_from_location(location):
102
+ """Construct an SSATK Earth observer from a scalar WGS84 EarthLocation."""
103
+ if not isinstance(location, EarthLocation) or not location.isscalar:
104
+ raise TypeError("A scalar EarthLocation is required.")
105
+ _values(location.itrs.cartesian.xyz, u.m)
106
+ return ssatk.EarthObserver(
107
+ lon=location.lon.to_value(u.deg), lat=location.lat.to_value(u.deg),
108
+ elevation=location.height.to_value(u.m),
109
+ )
110
+
111
+
112
+ def observer_location(observer):
113
+ """Recover a terrestrial site through the observer's public SI ITRS state."""
114
+ itrs = _values(observer.itrs, u.m)
115
+ if itrs.shape != (3,) or not np.isfinite(itrs).all():
116
+ raise ValueError("An Earth observer must expose one finite ITRS position in meters.")
117
+ return EarthLocation.from_geocentric(*itrs, unit=u.m)
118
+
119
+
120
+ def catalog_to_apparent(ra, dec, time, **kwargs):
121
+ """Convert J2000 stellar coordinates through SSATK's astrometry bridge.
122
+
123
+ RA/Dec are radians, proper motions are mu_alpha*/mu_delta in mas/year,
124
+ and parallax is arcseconds. Without an observer this is geocentric.
125
+ """
126
+ ra = _values(ra, u.rad)
127
+ dec = _values(dec, u.rad)
128
+ for name, unit in (("pmra", u.mas / u.yr), ("pmdec", u.mas / u.yr), ("parallax", u.arcsec)):
129
+ if name in kwargs:
130
+ kwargs[name] = _values(kwargs[name], unit)
131
+ return ssatk.ssapy.utils.catalog_to_apparent(ra, dec, as_time(time), **kwargs)
132
+
133
+
134
+ def find_passes(orbit, observers, time, duration, step, **kwargs):
135
+ """SSATK pass search; duration/step are explicit time quantities."""
136
+ intervals = []
137
+ for value in (duration, step):
138
+ if isinstance(value, TimeDelta):
139
+ seconds = value.sec
140
+ elif isinstance(value, u.Quantity):
141
+ seconds = value.to_value(u.s)
142
+ else:
143
+ raise TypeError("Pass intervals must be TimeDelta or time quantities.")
144
+ if np.ndim(seconds) or not np.isfinite(seconds) or seconds <= 0:
145
+ raise ValueError("Pass intervals must be finite positive scalar durations.")
146
+ intervals.append(seconds * u.s)
147
+ return ssatk.ssapy.compute.find_passes(
148
+ orbit, observers, as_time(time), *intervals, **kwargs,
149
+ )
150
+
151
+
152
+ def refine_pass(orbit, observer, time, **kwargs):
153
+ return ssatk.ssapy.compute.refine_pass(orbit, observer, as_time(time), **kwargs)
154
+
155
+
156
+ def _physical_position(position_gcrf_m):
157
+ """Validate and normalize one explicit physical GCRF position."""
158
+ position = _values(position_gcrf_m, u.m)
159
+ if position.shape != (3,):
160
+ raise ValueError("Physical model position must be one GCRF vector in meters.")
161
+ return position
162
+
163
+
164
+ def _physical_observer(observer, time=None):
165
+ """Validate an observer accepted by SSATK's physical models."""
166
+ if isinstance(observer, EarthLocation):
167
+ if not observer.isscalar:
168
+ raise TypeError("A physical model observer must be scalar.")
169
+ _values(observer.itrs.cartesian.xyz, u.m)
170
+ return observer
171
+ if isinstance(observer, ssatk.EarthObserver):
172
+ return observer
173
+ if hasattr(observer, "getRV"):
174
+ if time is None:
175
+ raise ValueError("Resolving an orbital observer requires an explicit epoch.")
176
+ observer, _ = observer.getRV(as_time(time))
177
+ position = _values(observer, u.m)
178
+ if position.shape != (3,):
179
+ raise ValueError("A physical model observer must be a GCRF vector in meters.")
180
+ return position
181
+
182
+
183
+ def lambertian_reflection(position_gcrf_m, time, observer, **kwargs):
184
+ """Call SSATK's released Lambertian reflection model at an explicit state."""
185
+ from ssatk.compute.lambertian_magnitude import lambertian_reflection as model
186
+
187
+ return model(
188
+ _physical_position(position_gcrf_m),
189
+ observer=_physical_observer(observer, time),
190
+ time=as_time(time),
191
+ **kwargs,
192
+ )
193
+
194
+
195
+ def thermal_emission(position_gcrf_m, time, observer, **kwargs):
196
+ """Call SSATK's released gray-body thermal model at an explicit state."""
197
+ from ssatk.compute.lambertian_magnitude import thermal_emission as model
198
+
199
+ return model(
200
+ _physical_position(position_gcrf_m),
201
+ observer=_physical_observer(observer, time),
202
+ time=as_time(time),
203
+ **kwargs,
204
+ )
205
+
206
+
207
+ def faceted_reflection(position_gcrf_m, time, observer, *, quaternion, facets, **kwargs):
208
+ """Call SSATK's released faceted reflection model at an explicit state."""
209
+ from ssatk.compute.faceted_magnitude import faceted_reflection as model
210
+
211
+ if np.asarray(quaternion).shape != (4,):
212
+ raise ValueError("Physical model quaternion must be one [w, x, y, z] vector.")
213
+ if not np.isfinite(np.asarray(quaternion, dtype=float)).all():
214
+ raise ValueError("Physical model quaternion must be finite.")
215
+ return model(
216
+ _physical_position(position_gcrf_m),
217
+ quaternion=np.asarray(quaternion, dtype=float),
218
+ facets=tuple(facets),
219
+ observer=_physical_observer(observer, time),
220
+ time=as_time(time),
221
+ **kwargs,
222
+ )
223
+
224
+
225
+ def facets_from_config(facets):
226
+ """Construct explicit SSATK facets; no mesh or optical properties are inferred."""
227
+ return tuple(facet if isinstance(facet, ssatk.Facet) else ssatk.Facet(**facet) for facet in facets)
228
+
229
+
230
+ def normed(vector):
231
+ return ssatk.normed(vector)
232
+
233
+
234
+ def norm(vector):
235
+ return ssatk.norm(vector)
simsit/brightness.py ADDED
@@ -0,0 +1,279 @@
1
+ """Bounded physical satellite brightness through public SSATK models."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+
7
+ import astropy.units as u
8
+ import numpy as np
9
+
10
+ from . import backend
11
+ from .instrument import Instrument
12
+
13
+ __all__ = ["PhysicalBrightness", "model_from_config"]
14
+
15
+
16
+ _LAMBERTIAN_KEYS = {
17
+ "radius_m", "albedo", "include_sun", "include_earthshine", "include_moonshine",
18
+ "k_extinction", "solar_const", "t_sun", "r_sun_radius", "r_earth",
19
+ "albedo_earth", "olr_earth", "t_earth_lw", "r_moon", "albedo_moon",
20
+ "atmosphere_top_m", "f_nu_ab_zero",
21
+ }
22
+ _FACETED_KEYS = {
23
+ "include_sun", "include_earthshine", "include_moonshine", "k_extinction",
24
+ "solar_const", "t_sun", "r_sun_radius", "r_earth", "albedo_earth",
25
+ "olr_earth", "t_earth_lw", "r_moon", "albedo_moon", "atmosphere_top_m",
26
+ "f_nu_ab_zero",
27
+ "facets", "quaternion", "self_shadowing", "shadow_epsilon",
28
+ "check_line_of_sight",
29
+ }
30
+ _THERMAL_KEYS = {"temperature_K", "emissivity", "include_thermal"}
31
+
32
+
33
+ def model_from_config(instrument, config):
34
+ """Validate an explicit physical or constant photometry configuration."""
35
+ if not isinstance(config, Mapping) or config.get("model") not in ("constant", "lambertian", "faceted"):
36
+ raise ValueError("photometry.model must be constant, lambertian or faceted.")
37
+ allowed = {"model", "params", "spectral_relative_tolerance", "max_spectral_samples"}
38
+ if set(config) - allowed:
39
+ raise ValueError("Unknown photometry configuration fields.")
40
+ if config["model"] == "constant":
41
+ if config.get("params") or set(config) - {"model", "params"}:
42
+ raise ValueError("Constant photometry does not use physical-model parameters.")
43
+ return None
44
+ params = dict(config.get("params", {}))
45
+ if config["model"] == "faceted":
46
+ params["facets"] = backend.facets_from_config(params.get("facets", ()))
47
+ return PhysicalBrightness(
48
+ instrument, model=config["model"], params=params,
49
+ spectral_relative_tolerance=config.get("spectral_relative_tolerance", 1e-4),
50
+ max_spectral_samples=config.get("max_spectral_samples", 257),
51
+ )
52
+
53
+
54
+ def _finite_scalar(value, name, *, minimum=None, maximum=None, strict_min=False):
55
+ if isinstance(value, (bool, np.bool_)) or np.ma.getmaskarray(value).any():
56
+ raise ValueError(f"{name} must be an unmasked numeric scalar.")
57
+ if isinstance(value, u.Quantity):
58
+ value = value.to_value(u.dimensionless_unscaled)
59
+ if np.ndim(value) or not np.isfinite(value):
60
+ raise ValueError(f"{name} must be a finite scalar.")
61
+ value = float(value)
62
+ if minimum is not None and (value <= minimum if strict_min else value < minimum):
63
+ relation = "greater than" if strict_min else "at least"
64
+ raise ValueError(f"{name} must be {relation} {minimum}.")
65
+ if maximum is not None and value > maximum:
66
+ raise ValueError(f"{name} must be at most {maximum}.")
67
+ return value
68
+
69
+
70
+ def _passband_domain(instrument):
71
+ if not isinstance(instrument, Instrument):
72
+ raise TypeError("instrument must be a simsit.Instrument.")
73
+ if instrument.passband is None:
74
+ raise ValueError("Physical brightness requires an explicit instrument passband.")
75
+ wavelengths = instrument.passband.wavelength_nm.astype(float) * 1e-9
76
+ if not np.isfinite(wavelengths).all() or np.any(wavelengths <= 0):
77
+ raise ValueError("Instrument passband wavelengths must be finite and positive.")
78
+ return wavelengths
79
+
80
+
81
+ def _grid(domain, count):
82
+ points = list(domain)
83
+ target = min(9, count)
84
+ while len(points) < target:
85
+ points = sorted(points)
86
+ gaps = np.log(points[1:]) - np.log(points[:-1])
87
+ index = int(np.argmax(gaps))
88
+ points.append(float(np.sqrt(points[index] * points[index + 1])))
89
+ return np.asarray(sorted(points), dtype=float)
90
+
91
+
92
+ def _widest_log_midpoints(wavelengths, count):
93
+ gaps = np.log(wavelengths[1:]) - np.log(wavelengths[:-1])
94
+ order = np.argsort(gaps)[::-1][:count]
95
+ return np.sqrt(wavelengths[order] * wavelengths[order + 1])
96
+
97
+
98
+ class PhysicalBrightness:
99
+ """Estimate incident detector photons from an explicit SSATK brightness model.
100
+
101
+ SSATK supplies band-integrated irradiance using gray Lambertian reflection
102
+ and blackbody approximations for solar/planetary sources. This class
103
+ queries narrow bands and treats their mean as ``F_lambda``; it does not
104
+ add spacecraft spectroscopy, orbit propagation, attitude inference, or
105
+ detector QE/gain. The adaptive estimate is bounded by
106
+ ``max_spectral_samples`` and the stated relative tolerance.
107
+ """
108
+
109
+ def __init__(
110
+ self,
111
+ instrument,
112
+ model="lambertian",
113
+ params=None,
114
+ spectral_relative_tolerance=1e-4,
115
+ max_spectral_samples=257,
116
+ ):
117
+ self.instrument = instrument
118
+ self.model = model
119
+ if params is not None and not isinstance(params, Mapping):
120
+ raise TypeError("params must be a mapping of physical-model parameters.")
121
+ self.params = dict(params or {})
122
+ self.spectral_relative_tolerance = _finite_scalar(
123
+ spectral_relative_tolerance, "spectral_relative_tolerance",
124
+ minimum=0.0, strict_min=True, maximum=1.0,
125
+ )
126
+ if (not isinstance(max_spectral_samples, (int, np.integer))
127
+ or max_spectral_samples < 2):
128
+ raise ValueError("max_spectral_samples must be an integer of at least 2.")
129
+ self.max_spectral_samples = int(max_spectral_samples)
130
+ domain = _passband_domain(instrument)
131
+ if self.max_spectral_samples < len(domain):
132
+ raise ValueError("max_spectral_samples must include every passband knot.")
133
+ if model not in ("lambertian", "faceted"):
134
+ raise ValueError("model must be 'lambertian' or 'faceted'.")
135
+ allowed = _LAMBERTIAN_KEYS if model == "lambertian" else _FACETED_KEYS
136
+ thermal_keys = _THERMAL_KEYS if model == "lambertian" else set()
137
+ unsupported = sorted(set(self.params) - allowed - thermal_keys)
138
+ if unsupported:
139
+ raise ValueError(f"Unsupported {model} physical-model parameters: {unsupported}.")
140
+ if model == "lambertian" and not {"radius_m", "albedo"} <= self.params.keys():
141
+ raise ValueError("lambertian brightness requires explicit radius_m and albedo.")
142
+ self._validate_params()
143
+
144
+ def _validate_params(self):
145
+ params = self.params
146
+ units = {
147
+ "radius_m": u.m, "r_sun_radius": u.m, "r_earth": u.m, "r_moon": u.m,
148
+ "atmosphere_top_m": u.m, "shadow_epsilon": u.m,
149
+ "temperature_K": u.K, "t_sun": u.K, "t_earth_lw": u.K,
150
+ "solar_const": u.W / u.m**2, "olr_earth": u.W / u.m**2,
151
+ "f_nu_ab_zero": u.W / u.m**2 / u.Hz,
152
+ }
153
+ for key, value in tuple(params.items()):
154
+ if isinstance(value, u.Quantity):
155
+ params[key] = value.to_value(units.get(key, u.dimensionless_unscaled))
156
+ for key, value in params.items():
157
+ if key in {
158
+ "facets", "quaternion", "include_sun", "include_earthshine",
159
+ "include_moonshine", "include_thermal", "self_shadowing",
160
+ "check_line_of_sight",
161
+ }:
162
+ continue
163
+ if key in {"albedo", "albedo_earth", "albedo_moon", "emissivity"}:
164
+ _finite_scalar(value, key, minimum=0.0, maximum=1.0)
165
+ elif key == "temperature_K" or key in {"t_sun", "t_earth_lw"}:
166
+ _finite_scalar(value, key, minimum=0.0, strict_min=True)
167
+ elif key == "radius_m":
168
+ _finite_scalar(value, key, minimum=0.0, strict_min=True)
169
+ else:
170
+ _finite_scalar(value, key, minimum=0.0)
171
+ for key in {
172
+ "include_sun", "include_earthshine", "include_moonshine",
173
+ "include_thermal", "self_shadowing", "check_line_of_sight",
174
+ } & params.keys():
175
+ if not isinstance(params[key], (bool, np.bool_)):
176
+ raise TypeError(f"{key} must be boolean.")
177
+ if self.model == "faceted":
178
+ if "facets" not in params or "quaternion" not in params:
179
+ raise ValueError("faceted brightness requires facets and quaternion.")
180
+ if "temperature_K" in params or "emissivity" in params or params.get("include_thermal"):
181
+ raise ValueError("faceted thermal emission is not exposed by SSATK 1.1.4.")
182
+ quaternion = np.asarray(params["quaternion"], dtype=float)
183
+ norm = np.linalg.norm(quaternion)
184
+ if (quaternion.shape != (4,) or not np.isfinite(quaternion).all()
185
+ or not np.isfinite(norm) or not np.isclose(norm, 1.0, rtol=1e-6, atol=1e-8)):
186
+ raise ValueError("quaternion must be one finite [w, x, y, z] vector.")
187
+ if len(params["facets"]) == 0:
188
+ raise ValueError("facets must not be empty.")
189
+ for facet in params["facets"]:
190
+ _finite_scalar(facet.area, "facet.area", minimum=0, strict_min=True)
191
+ normal = np.asarray(facet.normal_body, dtype=float)
192
+ if normal.shape != (3,) or not np.isfinite(normal).all() or np.linalg.norm(normal) == 0:
193
+ raise ValueError("Facet normals must be finite nonzero body-frame vectors.")
194
+ reflectivity = facet.diffuse_reflectivity
195
+ if isinstance(reflectivity, Mapping):
196
+ reflectivity = reflectivity.get("custom")
197
+ if reflectivity is None:
198
+ raise ValueError("Facets require diffuse reflectivity for the custom spectral band.")
199
+ _finite_scalar(reflectivity, "facet.diffuse_reflectivity", minimum=0, maximum=1)
200
+ if self.model == "lambertian" and (
201
+ "temperature_K" in params or "emissivity" in params or params.get("include_thermal")
202
+ ) and "temperature_K" not in params:
203
+ raise ValueError("temperature_K is required when thermal parameters are supplied.")
204
+
205
+ def _band_irradiance(self, position, time, observer, lo, hi):
206
+ kwargs = dict(self.params)
207
+ kwargs.pop("temperature_K", None)
208
+ kwargs.pop("emissivity", None)
209
+ include_thermal = bool(
210
+ kwargs.pop("include_thermal", "temperature_K" in self.params)
211
+ )
212
+ if self.model == "lambertian":
213
+ kwargs.setdefault("radius_m", 1.0)
214
+ reflection = backend.lambertian_reflection(
215
+ position, time, observer, band=(lo, hi), **kwargs,
216
+ )
217
+ else:
218
+ reflection = backend.faceted_reflection(
219
+ position, time, observer, band=(lo, hi), **kwargs,
220
+ )
221
+ value = float(reflection["irradiance_inband_total_at_observer_W_m2"])
222
+ if include_thermal:
223
+ thermal = backend.thermal_emission(
224
+ position, time, observer, band=(lo, hi),
225
+ radius_m=kwargs.get("radius_m", 1.0),
226
+ albedo=kwargs.get("albedo", 0.3),
227
+ temperature_K=self.params["temperature_K"],
228
+ emissivity=self.params.get("emissivity"),
229
+ k_extinction=kwargs.get("k_extinction", 0.16),
230
+ r_earth=kwargs.get("r_earth", 6378137.0),
231
+ atmosphere_top_m=kwargs.get("atmosphere_top_m", 100000.0),
232
+ )
233
+ value += float(thermal["irradiance_inband_total_at_observer_W_m2"])
234
+ if not np.isfinite(value) or value < 0:
235
+ raise ValueError("SSATK physical model returned invalid irradiance.")
236
+ return value / (hi - lo)
237
+
238
+ def _spectrum(self, position, time, observer, wavelengths):
239
+ flux = np.empty(wavelengths.size)
240
+ for index, wavelength in enumerate(wavelengths):
241
+ lo = wavelength * (1.0 - 5.0e-5)
242
+ hi = wavelength * (1.0 + 5.0e-5)
243
+ flux[index] = self._band_irradiance(position, time, observer, lo, hi)
244
+ return wavelengths * u.m, flux * u.W / u.m**2 / u.m
245
+
246
+ def photon_rate(self, position_gcrf_m, time, observer):
247
+ """Return incident photons/sec after the optical passband only."""
248
+ position = backend._physical_position(position_gcrf_m)
249
+ epoch = backend.as_time(time)
250
+ if not epoch.isscalar:
251
+ raise ValueError("Physical brightness requires one scalar epoch.")
252
+ observer = backend._physical_observer(observer, epoch)
253
+ domain = _passband_domain(self.instrument)
254
+ wavelengths = _grid(domain, self.max_spectral_samples)
255
+ previous = None
256
+ while True:
257
+ wavelength, flux = self._spectrum(position, epoch, observer, wavelengths)
258
+ rate = self.instrument.spectral_photon_rate(wavelength, flux)
259
+ value = float(rate.to_value(1 / u.s))
260
+ if not np.isfinite(value) or value < 0:
261
+ raise ValueError("Instrument spectral integration returned invalid photons/sec.")
262
+ if previous is not None and (
263
+ value == previous == 0.0
264
+ or (
265
+ abs(value - previous)
266
+ <= self.spectral_relative_tolerance
267
+ * max(abs(value), abs(previous), 1e-300)
268
+ )
269
+ ):
270
+ return rate.to(1 / u.s)
271
+ if wavelengths.size >= self.max_spectral_samples:
272
+ raise RuntimeError(
273
+ "Physical brightness spectral estimate did not converge within "
274
+ f"{self.max_spectral_samples} samples."
275
+ )
276
+ previous = value
277
+ room = self.max_spectral_samples - wavelengths.size
278
+ midpoints = _widest_log_midpoints(wavelengths, room)
279
+ wavelengths = np.unique(np.concatenate((wavelengths, midpoints)))
simsit/cadence.py ADDED
@@ -0,0 +1,95 @@
1
+ """
2
+ Observation cadence models for simulation.
3
+ """
4
+
5
+ import numpy as np
6
+ import astropy.units as u
7
+ from astropy.time import Time, TimeDelta
8
+
9
+ __all__ = ["SimpleCadence"]
10
+
11
+
12
+ class SimpleCadence:
13
+ """
14
+ Uniformly spaced exposure cadence during the illuminated portion of a pass.
15
+
16
+ Parameters
17
+ ----------
18
+ exptime : astropy.time.TimeDelta, astropy.units.Quantity, or float
19
+ Exposure duration. Bare floats are interpreted as seconds.
20
+ delay : astropy.time.TimeDelta, astropy.units.Quantity, or float
21
+ Time between successive shutter openings. Bare floats are interpreted
22
+ as seconds.
23
+ """
24
+
25
+ def __init__(self, exptime, delay):
26
+ self.exptime = self._as_timedelta(exptime, "exptime")
27
+ self.delay = self._as_timedelta(delay, "delay")
28
+
29
+ if self.exptime.sec <= 0:
30
+ raise ValueError("exptime must be positive.")
31
+ if self.delay.sec <= 0:
32
+ raise ValueError("delay must be positive.")
33
+
34
+ @staticmethod
35
+ def _as_timedelta(value, name):
36
+ if isinstance(value, TimeDelta):
37
+ delta = value
38
+ if np.asarray(delta.sec).ndim != 0:
39
+ raise ValueError(f"{name} must be a scalar duration.")
40
+ elif isinstance(value, u.Quantity):
41
+ if not value.isscalar:
42
+ raise ValueError(f"{name} must be a scalar duration.")
43
+ delta = TimeDelta(value.to_value(u.s), format="sec")
44
+ else:
45
+ value = np.asarray(value)
46
+ if value.ndim != 0:
47
+ raise ValueError(f"{name} must be a scalar duration.")
48
+ seconds = float(value)
49
+ if not np.isfinite(seconds):
50
+ raise ValueError(f"{name} must be finite.")
51
+ delta = TimeDelta(seconds, format="sec")
52
+
53
+ if not np.all(np.isfinite(delta.sec)):
54
+ raise ValueError(f"{name} must be finite.")
55
+ return delta
56
+
57
+ def get_times(self, pass_):
58
+ """
59
+ Return exposure start and end times for a pass.
60
+
61
+ Parameters
62
+ ----------
63
+ ``pass_`` : dict
64
+ Pass description, typically returned by
65
+ ``simsit.backend.refine_pass``.
66
+
67
+ Returns
68
+ -------
69
+ t_start, t_end : astropy.time.Time
70
+ Arrays of exposure start and end times.
71
+ """
72
+ if not pass_["illumAtStart"] and not pass_["illumAtEnd"]:
73
+ return Time([], format="mjd"), Time([], format="mjd")
74
+
75
+ if pass_["illumAtStart"]:
76
+ t_start = pass_["tStart"]
77
+ else:
78
+ t_start = pass_["tTerminator"]
79
+
80
+ if pass_["illumAtEnd"]:
81
+ t_end = pass_["tEnd"]
82
+ else:
83
+ t_end = pass_["tTerminator"]
84
+
85
+ available = (t_end - t_start).sec
86
+ if available < self.exptime.sec:
87
+ return Time([], format="mjd"), Time([], format="mjd")
88
+
89
+ count = int(
90
+ np.floor((available - self.exptime.sec) / self.delay.sec + 1e-12)
91
+ ) + 1
92
+ offsets = np.arange(count, dtype=float) * self.delay.sec
93
+ starts = t_start + TimeDelta(offsets, format="sec")
94
+ ends = starts + self.exptime
95
+ return starts, ends