timetag-explorer 1.0.1__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.
@@ -0,0 +1,21 @@
1
+ """TIME-TAG Explorer science code: the calculations behind the web app for HST/STIS FUV TIME-TAG data.
2
+
3
+ This package has no web-server code. It reads a TIME-TAG (and SPT) file, applies the processing steps, transforms
4
+ positions between reference frames and planet maps, and writes the standalone selection scripts.
5
+
6
+ from timetag_explorer import TagDataset, Corrections, Box
7
+ """
8
+ from __future__ import annotations
9
+
10
+ __version__ = '1.0.1'
11
+
12
+ from .analysis import describe_selection
13
+ from .constants import SELECTION_SCRIPT_VERSION
14
+ from .dataset import TagDataset, load_dataset
15
+ from .frames import DEFAULT_CORRECTIONS, FRAMES, Corrections, FrameInfo
16
+ from .regions import Box, Circle, Polygon, Region
17
+ from .selection_script import SelectionScript, build_selection_script
18
+
19
+ __all__ = ['__version__', 'SELECTION_SCRIPT_VERSION', 'TagDataset', 'load_dataset', 'describe_selection',
20
+ 'Corrections', 'DEFAULT_CORRECTIONS', 'FRAMES', 'FrameInfo', 'Box', 'Circle', 'Polygon', 'Region',
21
+ 'SelectionScript', 'build_selection_script']
@@ -0,0 +1,93 @@
1
+ """Numbers and text that describe a selection: the area and mean brightness of a region (on the detector or on a planet
2
+ map), the exposure, and the frame and planet geometry in use."""
3
+ from __future__ import annotations
4
+
5
+ import numpy as np
6
+
7
+ from .constants import SPATIAL_BIN, TIME_CORR_MIN_FRACTION, TimeSel
8
+ from .dataset import TagDataset
9
+ from .frames import ARCSEC_PER_LOWRES_PIXEL, Corrections, frame_label, is_map, is_planet
10
+ from .planet import map_bin_area
11
+ from .regions import Region
12
+ from .transforms import counts_label
13
+
14
+
15
+ def _map_region_stats(ds: TagDataset, region: Region, frame: str, corr: Corrections, time_sel):
16
+ """(surface area in km^2, mean brightness) of a region drawn on a planet map.
17
+
18
+ Mean brightness is the counts over the pixel-seconds that saw the region's bins
19
+ (counts/s per 4x4 hi-res pixels), so it does not depend on how much of the region was visible."""
20
+ grid = ds.grid(frame, corr)
21
+ inside = ds.region_grid(region, frame, corr)
22
+ geom = ds.planet_geometry()
23
+ alt = geom.altitude_km(corr)
24
+ cu = grid.xlo + (np.arange(grid.nx) + 0.5) * grid.binsize
25
+ cv = grid.ylo + (np.arange(grid.ny) + 0.5) * grid.binsize
26
+ gu, gv = np.meshgrid(cu, cv)
27
+ area = float(map_bin_area(gu[inside], gv[inside], frame, grid.binsize,
28
+ geom.body.radius_eq_km + alt, geom.body.radius_pol_km + alt).sum())
29
+ img = ds.image(time_sel, frame, corr)
30
+ pixel_seconds, full = ds.map_exposure(time_sel, frame, corr)
31
+ den = pixel_seconds if corr.timecorr else full
32
+ weight = np.where(den >= TIME_CORR_MIN_FRACTION * full, den, 0.0)[inside]
33
+ mean = float((img[inside] * weight).sum() / weight.sum()) if weight.sum() > 0 else float('nan')
34
+ return area, mean
35
+
36
+
37
+ def describe_selection(ds: TagDataset, corr: Corrections, frame: str, time_sel: TimeSel | None, region: Region | None,
38
+ show_limb: bool = False) -> str:
39
+ """Text summary of the frame, the planet, the time selection and the region."""
40
+ label = frame_label(ds.frame_info, frame)
41
+ lines = []
42
+ if frame != 'image':
43
+ info = ds.frame_info
44
+ if info.slew_rate:
45
+ shift = abs(info.slew_rate) / 86400.0 / ARCSEC_PER_LOWRES_PIXEL * 2.0 * ds.tmax
46
+ detail = (f'slew of {info.slew_rate:g} arcsec/day along {info.slew_angle:.1f} deg '
47
+ f'removed (about {shift:.0f} hi-res px over the exposure)')
48
+ else:
49
+ detail = 'this observation has no time-dependent slew'
50
+ if is_planet(frame) or is_map(frame) and info.planet:
51
+ detail += (f'; {(info.planet or "planet").title()} motion relative to '
52
+ f'{(info.target or "the moon").title()} removed')
53
+ lines.append(f'Frame: {label}; {detail}')
54
+ if ds.frame_info.body and (is_map(frame) or show_limb):
55
+ geom = ds.planet_geometry()
56
+ og = {k: float(v) for k, v in geom.orient.at(0.5 * ds.tmax).items()}
57
+ cx, cy = geom.centre(corr)
58
+ lines.append(
59
+ f'{geom.body.name} at mid-exposure: sub-observer longitude {og["lon_obs"] % 360:.1f} W, '
60
+ f'latitude {og["lat_obs_g"]:.1f}, pole PA {og["pole_pa"]:.1f} deg, radius {og["radius_arcsec"]:.2f}"; '
61
+ f'centre ({cx:.1f}, {cy:.1f}) px = header guess ({geom.centre0[0]:.1f}, {geom.centre0[1]:.1f}) '
62
+ f'+ offset ({corr.centre_dx:g}, {corr.centre_dy:g}); emission altitude {geom.altitude_km(corr):.0f} km')
63
+
64
+ total = ds.exposure()
65
+ if time_sel is None:
66
+ lines.append(f'All times: {total:.1f} s of good exposure')
67
+ else:
68
+ t0, t1 = time_sel
69
+ lines.append(f'Times {t0:.1f}-{t1:.1f} s: {ds.exposure(t0, t1):.1f} s of good '
70
+ f'exposure (of {total:.1f} s total)')
71
+
72
+ if region is None:
73
+ lines.append('Region: none selected')
74
+ elif is_map(frame):
75
+ area, mean_all = _map_region_stats(ds, region, frame, corr, None)
76
+ means = [f'all times {mean_all:.4g}']
77
+ if time_sel is not None:
78
+ means.append(f'selected times {_map_region_stats(ds, region, frame, corr, time_sel)[1]:.4g}')
79
+ lines.append(f'Region: {region.describe()} (map degrees); surface area {area:,.0f} km\u00b2; '
80
+ f'mean brightness {", ".join(means)} ({counts_label(ds, corr)}/s per '
81
+ f'{SPATIAL_BIN}x{SPATIAL_BIN} hi-res px)')
82
+ else:
83
+ grid = ds.region_grid(region, frame, corr)
84
+ area = int(grid.sum()) * SPATIAL_BIN ** 2
85
+ # Summing the rate image over the region's bins gives the region's total rate.
86
+ rates = [f'all times {ds.image(None, frame, corr)[grid].sum():.4g}']
87
+ if time_sel is not None:
88
+ rates.append(f'selected times {ds.image(time_sel, frame, corr)[grid].sum():.4g}')
89
+ lines.append(f'Region: {region.describe()} (hi-res px, {label.lower()}); '
90
+ f'area {area:,} hi-res px\u00b2; total rate {", ".join(rates)} '
91
+ f'({counts_label(ds, corr)}/s)')
92
+ return '\n'.join(lines)
93
+
@@ -0,0 +1,25 @@
1
+ """Where the package finds its files, and the address it writes into the scripts it generates.
2
+
3
+ The defaults come from environment variables (or, failing that, from the current folder). A program that uses the
4
+ package can also assign to these names, which are read each time they are needed:
5
+
6
+ from timetag_explorer import config
7
+ config.FLAT_FILE = Path('/data/combined_flat.fits')
8
+ config.HORIZONS_DIR = Path('/data/horizons')
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import os
13
+ from pathlib import Path
14
+
15
+
16
+ def _env_path(name: str, default: str) -> Path:
17
+ return Path(os.environ.get(name) or default)
18
+
19
+
20
+ # The flat field: a 2048 x 2048 image of multiplicative weights (optional: without it, counts are unweighted).
21
+ FLAT_FILE = _env_path('TAG_EXPLORER_FLAT', 'combined_flat.fits')
22
+ # Where the JPL Horizons ephemerides are saved and reused (one small CSV per exposure and body).
23
+ HORIZONS_DIR = _env_path('TAG_EXPLORER_HORIZONS', 'horizons')
24
+ # Web address of the tool that is written into generated scripts and FITS headers (empty: the default address).
25
+ PUBLIC_URL = os.environ.get('TAG_EXPLORER_URL', '').strip().rstrip('/')
@@ -0,0 +1,57 @@
1
+ """Numbers that the science code shares: bin sizes, limits and version numbers.
2
+
3
+ Nothing here depends on a web server or on where files are kept (see `config` for that)."""
4
+ from __future__ import annotations
5
+
6
+ # Event-table columns (raw _tag files). For _corrtag files you might use
7
+ # 'XCORR' / 'YCORR' or 'XFULL' / 'YFULL' instead.
8
+ TIME_COL, X_COL, Y_COL = 'TIME', 'AXIS1', 'AXIS2'
9
+
10
+ # Flat field: a 2048 x 2048 image in ext 0, indexed [AXIS2, AXIS1] in hi-res
11
+ # pixels. The file already holds the multiplicative weight, so each event gets
12
+ # weight = file value at its pixel (no inversion). TAG AXIS1/AXIS2 are 1-indexed.
13
+ # The flat is optional: without the file, counts are unweighted and its switch is disabled.
14
+ FLAT_URL = 'https://www.planetarylightshow.com/static/data/combined_flat.fits' # where the flat field can be downloaded
15
+ FLAT_SHAPE = (2048, 2048)
16
+
17
+ SPATIAL_BIN = 4 # detector pixels per image bin
18
+ DEFAULT_TIME_BIN = 3 # seconds per light-curve bin
19
+ MAX_LIGHT_CURVE_BINS = 100_000 # a smaller bin would only exhaust memory
20
+ MIN_TIME_BIN = 0.001 # seconds
21
+
22
+ TIME_DECIMALS = 1 # time selections are rounded to 0.1 s
23
+
24
+ RANDOM_SEED = 0 # fixed, so repeated renders of one dataset agree
25
+
26
+ # Version of the script that the "Selection code" button writes. People keep these scripts and the FITS
27
+ # files they produce, and the number is written into both (docstring, SCRIPT_VERSION, the SELVER header
28
+ # keyword, HISTORY), so raise it whenever build_selection_script changes what the script computes or
29
+ # writes (not for wording changes in the dialog).
30
+ SELECTION_SCRIPT_VERSION = '1.0' # the first released version (earlier development versions are not kept)
31
+ DEFAULT_TOOL_URL = 'https://explorer.planetarylightshow.com' # TAG_EXPLORER_URL, when set, takes its place
32
+ MAX_CACHED_FRAMES = 2 # per dataset; each cached frame costs 4 bytes per event
33
+ EVENT_CHUNK = 1 << 20 # events handled at a time, so temporary arrays stay small whatever the file size
34
+
35
+
36
+ def _chunks(n: int, size: int | None = None):
37
+ """(lo, hi) slices of range(n), EVENT_CHUNK events each."""
38
+ size = size or EVENT_CHUNK
39
+ for lo in range(0, n, size):
40
+ yield lo, min(lo + size, n)
41
+
42
+ # Exposure-time correction: a frame pixel seen for less than this fraction of the
43
+ # good exposure (the edges of a drifting field of view) is left blank, not amplified.
44
+ TIME_CORR_MIN_FRACTION = 0.1
45
+ MAP_BIN_DEGREES = 0.25 # bin size of the planet maps (degrees of longitude / latitude / colatitude)
46
+ MAP_SEGMENTS = 8 # the planet's rotation is treated as this many straight segments when mapping exposure
47
+ MAP_SLICE_SECONDS = 60.0 # events are deprojected in time slices this long
48
+ EXPOSURE_SUBSAMPLE = 2 # sub-samples per bin and axis for a moving footprint (static ones use twice as many)
49
+ PLANET_SEGMENTS = 8 # the planet's path is treated as this many straight segments
50
+ # The detector's edges are vignetted. With the exposure-time correction on, events
51
+ # within EDGE_TRIM_PIXELS of any edge are discarded, and the exposure map treats the
52
+ # detector as the square with that border removed (0-indexed hi-res pixel coordinates,
53
+ # a pixel p covering [p - 0.5, p + 0.5]). Set it to 0 to keep the whole detector.
54
+ EDGE_TRIM_PIXELS = 30
55
+ DETECTOR_LIMITS = (EDGE_TRIM_PIXELS - 0.5, 2047.5 - EDGE_TRIM_PIXELS)
56
+
57
+ TimeSel = tuple[float, float]
@@ -0,0 +1,459 @@
1
+ """The event list of one exposure (pure numpy): reading a TIME-TAG file, slicing in time, binning into frames,
2
+ flat-field weights and exposure maps. No web server and no plotting."""
3
+ from __future__ import annotations
4
+
5
+ import threading
6
+ from collections import OrderedDict
7
+ from dataclasses import dataclass, field
8
+ from functools import lru_cache
9
+ from pathlib import Path
10
+
11
+ import numpy as np
12
+ from astropy.io import fits
13
+
14
+ from . import config
15
+ from .constants import (EDGE_TRIM_PIXELS, EVENT_CHUNK, FLAT_SHAPE, FLAT_URL, MAX_CACHED_FRAMES, SPATIAL_BIN,
16
+ TIME_CORR_MIN_FRACTION, TIME_COL, X_COL, Y_COL, TimeSel, _chunks)
17
+ from .frames import (DEFAULT_CORRECTIONS, Corrections, FrameInfo, clamp_binsize, frame_label, is_map, is_planet,
18
+ load_frame_info)
19
+ from .horizons import (PlanetTrack, load_horizons_positions, planet_track_from_offsets,
20
+ sky_offsets_from_positions)
21
+ from .log import logger
22
+ from .regions import Region
23
+ from .transforms import (FrameGrid, PlanetGeometry, build_planet_geometry, compute_exposure_map, load_geo_lut,
24
+ make_frame_grid, map_exposure_from_knots, map_knots)
25
+
26
+
27
+
28
+ # --------------------------------------------------------------------------
29
+ # Data layer (no UI, no matplotlib)
30
+ # --------------------------------------------------------------------------
31
+
32
+ @dataclass(eq=False)
33
+ class TagDataset:
34
+ """Immutable, time-sorted photon event list.
35
+
36
+ Positions are kept as the recorded 1-indexed pixel coordinates; each
37
+ reference frame bins them on demand into a FrameGrid (cached, a few
38
+ frames per dataset). Each event also carries its flat-field weight (the
39
+ flat file's value at its *image-frame* pixel) when the flat is available,
40
+ so images and light curves are weighted sums whatever frame is displayed.
41
+ """
42
+
43
+ name: str
44
+ time: np.ndarray # float32, seconds, sorted ascending
45
+ x: np.ndarray # int16 AXIS1 (1-indexed hi-res pixel)
46
+ y: np.ndarray # int16 AXIS2
47
+ gti: np.ndarray # (n, 2) good-time intervals [start, stop] in seconds
48
+ frame_info: FrameInfo # slew / WCS description for the reference frames
49
+ weight: np.ndarray | None = None # float32 per-event flat-field weight
50
+ flat_problem: str | None = None # why there is no flat, if there is none
51
+ rootname: str = '' # header metadata used for website links
52
+ proposid: int | None = None
53
+ obset_id: str | None = None
54
+ targname: str = ''
55
+ linenum: str = ''
56
+ tdateobs: str = '' # start date / time (UTC) and exposure time, for display
57
+ ttimeobs: str = ''
58
+ texptime: float | None = None
59
+ propaper: str = '' # proposed aperture / filter
60
+ _grids: OrderedDict = field(default_factory=OrderedDict, init=False, repr=False)
61
+ _expo: OrderedDict = field(default_factory=OrderedDict, init=False, repr=False)
62
+ _track: PlanetTrack | None = field(default=None, init=False, repr=False)
63
+ _positions: np.ndarray | None = field(default=None, init=False, repr=False)
64
+ _geom: PlanetGeometry | None = field(default=None, init=False, repr=False)
65
+ _valid: np.ndarray | None = field(default=None, init=False, repr=False)
66
+ _cum: tuple | None = field(default=None, init=False, repr=False) # (key, running sum)
67
+ _knots: tuple | None = field(default=None, init=False, repr=False) # (key, map_knots result)
68
+ _lock: threading.RLock = field(default_factory=threading.RLock, init=False, repr=False)
69
+
70
+ @property
71
+ def tmax(self) -> float:
72
+ return float(self.time[-1])
73
+
74
+ def positions(self) -> np.ndarray:
75
+ """Planet (and moon) positions from the saved Horizons file or a fresh query (once)."""
76
+ with self._lock:
77
+ if self._positions is None:
78
+ self._positions = load_horizons_positions(self.frame_info, self.rootname)
79
+ return self._positions
80
+
81
+ def planet_track(self) -> PlanetTrack:
82
+ """The planet's motion relative to the moon (fetched once, then kept)."""
83
+ with self._lock:
84
+ if self._track is None:
85
+ t, d_xi, d_eta = sky_offsets_from_positions(self.positions(), self.frame_info.expstart)
86
+ self._track = planet_track_from_offsets(t, d_xi, d_eta, self.frame_info.cd)
87
+ return self._track
88
+
89
+ def planet_geometry(self) -> PlanetGeometry:
90
+ """Orientation, scale and centre guess for the planet maps (built once)."""
91
+ with self._lock:
92
+ if self._geom is None:
93
+ self._geom = build_planet_geometry(self.frame_info, self.positions())
94
+ return self._geom
95
+
96
+ def prepare_overlay(self) -> None:
97
+ """Load what the limb/graticule overlay needs (may use the network); ValueError if it can't be shown."""
98
+ reason = self.frame_info.unavailable_reason('map_rect')
99
+ if reason:
100
+ raise ValueError(f'planet limb unavailable: {reason}')
101
+ self.planet_geometry()
102
+
103
+ def prepare_frame(self, frame: str) -> None:
104
+ """Check a frame is available and load what it needs (may use the network).
105
+ Raises ValueError with a reason if it cannot be built."""
106
+ reason = self.frame_info.unavailable_reason(frame)
107
+ if reason:
108
+ raise ValueError(f'{frame_label(self.frame_info, frame)} unavailable: {reason}')
109
+ if is_planet(frame) or (is_map(frame) and is_planet(self.frame_info.map_base_frame())):
110
+ self.planet_track()
111
+ if is_map(frame):
112
+ self.planet_geometry()
113
+
114
+ def grid(self, frame: str = 'image', corr: Corrections = DEFAULT_CORRECTIONS) -> FrameGrid:
115
+ """Bin grid for a frame (computed on first use, then cached)."""
116
+ key = (frame, corr.randcorr, corr.geocorr, corr.map_key() if is_map(frame) else None)
117
+ with self._lock:
118
+ grid = self._grids.get(key)
119
+ if grid is None:
120
+ grid = self._grids[key] = make_frame_grid(self, frame, corr)
121
+ while len(self._grids) > MAX_CACHED_FRAMES:
122
+ self._grids.popitem(last=False) # drop the least recently used
123
+ else:
124
+ self._grids.move_to_end(key)
125
+ return grid
126
+
127
+ def exposure(self, t0: float = -np.inf, t1: float = np.inf) -> float:
128
+ """Good exposure time (s) inside [t0, t1]: only time within a GTI row counts."""
129
+ a, b = self.gti[:, 0], self.gti[:, 1]
130
+ return float(np.sum(np.clip(b, t0, t1) - np.clip(a, t0, t1)))
131
+
132
+ def bin_exposure(self, edges: np.ndarray) -> np.ndarray:
133
+ """Good exposure time (s) in each bin [edges[i], edges[i+1])."""
134
+ a, b = self.gti[:, 0], self.gti[:, 1]
135
+ accumulated = (np.clip(edges[:, None], a, b) - a).sum(axis=1) # exposure up to each edge
136
+ return np.diff(accumulated)
137
+
138
+ def exposure_map(self, time_sel: TimeSel | None, frame: str = 'image',
139
+ corr: Corrections = DEFAULT_CORRECTIONS) -> np.ndarray:
140
+ """(ny, nx) good exposure time each frame pixel was on the detector in the window."""
141
+ window = (-np.inf, np.inf) if time_sel is None else tuple(time_sel)
142
+ key = (frame, corr.randcorr, corr.geocorr, window)
143
+ grid = self.grid(frame, corr)
144
+ with self._lock:
145
+ emap = self._expo.get(key)
146
+ if emap is not None:
147
+ self._expo.move_to_end(key)
148
+ return emap
149
+ track = self.planet_track() if is_planet(frame) else None
150
+ geo_lut = load_geo_lut() if corr.geocorr else None
151
+ emap = compute_exposure_map(self, grid, frame, window, track, geo_lut)
152
+ emap.flags.writeable = False
153
+ with self._lock:
154
+ self._expo[key] = emap
155
+ while len(self._expo) > 6:
156
+ self._expo.popitem(last=False)
157
+ return emap
158
+
159
+ def valid_events(self, corr: Corrections) -> np.ndarray | None:
160
+ """Boolean per-event mask of the events kept, or None to keep all.
161
+
162
+ With the exposure-time correction on, events within EDGE_TRIM_PIXELS of a
163
+ detector edge (vignetted, so not a perfect rectangle) are discarded. This
164
+ uses the recorded pixel, i.e. the detector position, whatever the frame.
165
+ """
166
+ if not corr.timecorr or EDGE_TRIM_PIXELS <= 0:
167
+ return None
168
+ with self._lock:
169
+ if self._valid is None:
170
+ lo, hi = EDGE_TRIM_PIXELS + 1, 2048 - EDGE_TRIM_PIXELS # AXIS values are 1-indexed
171
+ valid = (self.x >= lo) & (self.x <= hi) & (self.y >= lo) & (self.y <= hi)
172
+ valid.flags.writeable = False
173
+ self._valid = valid
174
+ return self._valid
175
+
176
+ def _running_sum(self, weighted: bool, valid: np.ndarray | None) -> np.ndarray:
177
+ """[0, cumsum] over events of the flat weight (or 1) of the kept events, so any
178
+ time range sums in O(1). One is kept at a time (8 bytes per event); switching
179
+ corrections rebuilds it in tens of milliseconds per million events."""
180
+ key = (weighted, valid is not None)
181
+ with self._lock:
182
+ if self._cum is None or self._cum[0] != key:
183
+ n = len(self.time)
184
+ running = np.zeros(n + 1) # float64, filled a chunk at a time
185
+ for lo, hi in _chunks(n):
186
+ if weighted:
187
+ per_event = self.weight[lo:hi] * valid[lo:hi] if valid is not None else self.weight[lo:hi]
188
+ else:
189
+ per_event = valid[lo:hi]
190
+ running[lo + 1:hi + 1] = np.cumsum(per_event, dtype=np.float64) + running[lo]
191
+ running.flags.writeable = False
192
+ self._cum = (key, running)
193
+ return self._cum[1]
194
+
195
+ def map_exposure(self, time_sel: TimeSel | None, frame: str, corr: Corrections):
196
+ """(pixel_seconds, full) per bin of a map frame for the time window (cached)."""
197
+ window = (-np.inf, np.inf) if time_sel is None else tuple(time_sel)
198
+ key = ('map', frame, corr.randcorr, corr.geocorr, corr.map_key(), window)
199
+ grid = self.grid(frame, corr)
200
+ with self._lock:
201
+ hit = self._expo.get(key)
202
+ if hit is not None:
203
+ self._expo.move_to_end(key)
204
+ return hit
205
+ knots_key = (frame, corr.randcorr, corr.geocorr, corr.map_key())
206
+ with self._lock:
207
+ if self._knots is None or self._knots[0] != knots_key:
208
+ track = self.planet_track() if is_planet(self.frame_info.map_base_frame()) else None
209
+ geo_lut = load_geo_lut() if corr.geocorr else None
210
+ self._knots = (knots_key, map_knots(self, grid, frame, corr, track, geo_lut))
211
+ knots = self._knots[1]
212
+ with np.errstate(invalid='ignore', over='ignore'): # inf - inf in the interval arithmetic is harmless
213
+ result = map_exposure_from_knots(knots, self.gti, window)
214
+ with self._lock:
215
+ self._expo[key] = result
216
+ while len(self._expo) > 6:
217
+ self._expo.popitem(last=False)
218
+ return result
219
+
220
+ def time_slice(self, time_sel: TimeSel | None) -> slice:
221
+ """Events are time-sorted, so a time cut is a cheap slice (no copy)."""
222
+ if time_sel is None:
223
+ return slice(None)
224
+ lo, hi = np.searchsorted(self.time, time_sel)
225
+ return slice(int(lo), int(hi))
226
+
227
+ def _weighted(self, corr: Corrections) -> bool:
228
+ return corr.flatcorr and self.weight is not None
229
+
230
+ def region_grid(self, region: Region, frame: str = 'image',
231
+ corr: Corrections = DEFAULT_CORRECTIONS) -> np.ndarray:
232
+ """Boolean (ny, nx) image grid: True where the bin centre is in the region."""
233
+ grid = self.grid(frame, corr)
234
+ xc = grid.xlo + (np.arange(grid.nx) + 0.5) * grid.binsize
235
+ yc = grid.ylo + (np.arange(grid.ny) + 0.5) * grid.binsize
236
+ gx, gy = np.meshgrid(xc, yc) # shape (ny, nx), row-major like bin_index
237
+ return region.contains(gx.ravel(), gy.ravel()).reshape(grid.ny, grid.nx)
238
+
239
+ def region_mask(self, region: Region, frame: str = 'image',
240
+ corr: Corrections = DEFAULT_CORRECTIONS) -> np.ndarray:
241
+ """Per-event boolean mask for a region drawn in a frame.
242
+
243
+ The shape is tested on the image-bin grid (a bin is in if its centre
244
+ is inside), so the cut matches exactly what the user sees highlighted,
245
+ and the cost depends on the image size, not the number of events.
246
+ """
247
+ grid = self.grid(frame, corr)
248
+ in_bin = self.region_grid(region, frame, corr).ravel()
249
+ mask = np.empty(len(grid.bin_index), dtype=bool)
250
+ for lo, hi in _chunks(len(mask)): # (an int32 index array is copied to int64 when indexing)
251
+ mask[lo:hi] = in_bin[grid.bin_index[lo:hi]]
252
+ if grid.keep is not None: # maps: events that miss the planet are in no region
253
+ mask &= grid.keep
254
+ return mask
255
+
256
+ def light_curve(self, binsize: float, region: Region | None = None,
257
+ frame: str = 'image', corr: Corrections = DEFAULT_CORRECTIONS):
258
+ """Return (bin edges, count rate). Optionally restricted to a region.
259
+
260
+ Rates divide by the *good* exposure in each bin (GTI), so a bin that
261
+ straddles a gap is normalised correctly and a bin entirely inside a
262
+ gap is NaN. The region is interpreted in the given frame. With the
263
+ exposure-time correction on, the vignetted detector border is excluded
264
+ (as in the images); its per-pixel exposure map is not applied here.
265
+ """
266
+ binsize = clamp_binsize(binsize, self.tmax) # whatever the caller passed, the arrays stay small
267
+ nbins = int(np.floor(self.tmax / binsize)) + 1
268
+ edges = np.arange(nbins + 1) * binsize
269
+ weighted = self._weighted(corr)
270
+ valid = self.valid_events(corr)
271
+ if region is None:
272
+ # Sorted times: the histogram is the difference of the running
273
+ # sum (of events, or of weights) at the bin edges.
274
+ idx = np.searchsorted(self.time, edges)
275
+ if weighted or valid is not None:
276
+ counts = np.diff(self._running_sum(weighted, valid)[idx])
277
+ else:
278
+ counts = np.diff(idx)
279
+ else:
280
+ mask = self.region_mask(region, frame, corr)
281
+ if valid is not None:
282
+ mask &= valid
283
+ # float64 weights: np.histogram accumulates in the weights' dtype,
284
+ # and float32 sums over ~1e6 events lose ~1e-6 of precision.
285
+ counts = np.zeros(nbins)
286
+ for lo, hi in _chunks(len(mask)):
287
+ chosen = mask[lo:hi]
288
+ if not chosen.any():
289
+ continue
290
+ w = self.weight[lo:hi][chosen].astype(np.float64) if weighted else None
291
+ counts += np.histogram(self.time[lo:hi][chosen], bins=edges, weights=w)[0]
292
+ exposure = self.bin_exposure(edges)
293
+ rate = np.divide(counts, exposure, out=np.full(len(counts), np.nan), where=exposure > 1e-9)
294
+ return edges, rate
295
+
296
+ def image(self, time_sel: TimeSel | None = None, frame: str = 'image',
297
+ corr: Corrections = DEFAULT_CORRECTIONS) -> np.ndarray:
298
+ """Count-rate image (ny, nx) in a frame.
299
+
300
+ With the exposure-time correction, events in the vignetted border of the
301
+ detector are discarded and each bin is divided by the time it was on the
302
+ rest of the detector (so the edges of a drifting field of view are not dim);
303
+ bins seen for less than TIME_CORR_MIN_FRACTION of the good exposure are
304
+ left at 0. Without it, every bin is divided by the total good exposure.
305
+ """
306
+ grid = self.grid(frame, corr)
307
+ start, stop, _ = self.time_slice(time_sel).indices(len(self.time))
308
+ weighted = self._weighted(corr)
309
+ keep = self.valid_events(corr) # the trimmed detector border (exposure-time correction)
310
+ if grid.keep is not None: # maps: only events that land on the planet
311
+ keep = grid.keep if keep is None else keep & grid.keep
312
+ counts = np.zeros(grid.nx * grid.ny)
313
+ for lo in range(start, stop, EVENT_CHUNK): # a chunk at a time: bincount copies its inputs to int64 / float64
314
+ hi = min(lo + EVENT_CHUNK, stop)
315
+ w = self.weight[lo:hi] if weighted else None
316
+ if keep is not None: # a zero weight discards the event
317
+ w = keep[lo:hi].astype(np.float64) if w is None else w * keep[lo:hi]
318
+ counts += np.bincount(grid.bin_index[lo:hi], weights=w, minlength=grid.nx * grid.ny)
319
+ t0, t1 = (-np.inf, np.inf) if time_sel is None else time_sel
320
+ total = self.exposure(t0, t1)
321
+ if total <= 0:
322
+ return np.zeros((grid.ny, grid.nx))
323
+ if is_map(frame):
324
+ # Brightness per 4x4 hi-res pixels: counts over the pixel-seconds that saw each bin.
325
+ pixel_seconds, full = self.map_exposure(time_sel, frame, corr)
326
+ denom = (pixel_seconds if corr.timecorr else full).ravel()
327
+ ok = (full.ravel() > 0) & (denom >= TIME_CORR_MIN_FRACTION * full.ravel())
328
+ rate = np.divide(counts * SPATIAL_BIN ** 2, denom, out=np.zeros(counts.shape), where=ok)
329
+ return rate.reshape(grid.ny, grid.nx)
330
+ if corr.timecorr:
331
+ emap = self.exposure_map(time_sel, frame, corr).ravel()
332
+ rate = np.divide(counts, emap, out=np.zeros(counts.shape),
333
+ where=emap >= TIME_CORR_MIN_FRACTION * total)
334
+ else:
335
+ rate = counts / total
336
+ return rate.reshape(grid.ny, grid.nx)
337
+
338
+
339
+ def _as_float(value) -> float | None:
340
+ try:
341
+ return None if value is None else float(value)
342
+ except (TypeError, ValueError):
343
+ return None
344
+
345
+
346
+ def _read_gti(path: Path, tmax: float) -> np.ndarray:
347
+ """Good-time intervals (ext 'GTI') as an (n, 2) array; whole exposure if absent."""
348
+ gti = np.empty((0, 2))
349
+ try:
350
+ table = fits.getdata(path, extname='GTI')
351
+ gti = np.column_stack((np.asarray(table['START'], dtype=np.float64),
352
+ np.asarray(table['STOP'], dtype=np.float64)))
353
+ gti = gti[gti[:, 1] > gti[:, 0]]
354
+ except KeyError:
355
+ logger.warning('%s has no GTI extension; assuming one interval [0, tmax]', path.name)
356
+ if gti.size == 0:
357
+ gti = np.array([[0.0, tmax]])
358
+ return gti[np.argsort(gti[:, 0])]
359
+
360
+
361
+ @lru_cache(maxsize=1)
362
+ def load_flat_weights() -> np.ndarray:
363
+ """Per-pixel weight map flat as float32, shared by all sessions.
364
+
365
+ Pixels with a non-finite or non-positive flat value get weight 0, so
366
+ those events drop out instead of becoming inf/NaN.
367
+ """
368
+ if not config.FLAT_FILE.is_file():
369
+ raise FileNotFoundError(f'Flat field not found: {config.FLAT_FILE} (it can be downloaded from {FLAT_URL})')
370
+ flat = np.asarray(fits.getdata(config.FLAT_FILE, ext=0), dtype=np.float64)
371
+ if flat.shape != FLAT_SHAPE:
372
+ raise ValueError(f'Flat field has shape {flat.shape}, expected {FLAT_SHAPE}')
373
+ weights = np.zeros(flat.shape, dtype=np.float32)
374
+ ok = np.isfinite(flat) & (flat > 0)
375
+ weights[ok] = flat[ok]
376
+ weights.flags.writeable = False
377
+ return weights
378
+
379
+
380
+ def _is_sorted(a: np.ndarray) -> bool:
381
+ """True if `a` never decreases (checked a chunk at a time, without a temporary of the whole array)."""
382
+ for lo, hi in _chunks(len(a)):
383
+ seg = a[max(lo - 1, 0):hi]
384
+ if np.any(seg[1:] < seg[:-1]):
385
+ return False
386
+ return True
387
+
388
+
389
+ def _load_dataset_uncached(path: str) -> TagDataset:
390
+ """Read a TAG file. Blocking: call via asyncio.to_thread."""
391
+ tag_path = Path(path)
392
+ data = fits.getdata(tag_path, ext=1)
393
+
394
+ missing = [c for c in (TIME_COL, X_COL, Y_COL) if c not in data.names]
395
+ if missing:
396
+ raise ValueError(f'Missing column(s) in event table: {", ".join(missing)}')
397
+ if len(data) == 0:
398
+ raise ValueError('Event table is empty')
399
+
400
+ # FITS arrays are big-endian and the table is memory-mapped: copy each column straight into a
401
+ # compact native dtype (no wider temporaries), then let go of the mapping.
402
+ time = np.asarray(data[TIME_COL], dtype=np.float32)
403
+ x = np.asarray(data[X_COL], dtype=np.int16)
404
+ y = np.asarray(data[Y_COL], dtype=np.int16)
405
+ del data
406
+
407
+ if not _is_sorted(time):
408
+ order = np.argsort(time, kind='stable')
409
+ time, x, y = time[order], x[order], y[order]
410
+ del order
411
+
412
+ weight = flat_problem = None
413
+ try:
414
+ wmap = load_flat_weights()
415
+ except (OSError, ValueError) as exc: # no flat: carry on with unweighted counts
416
+ flat_problem = str(exc)
417
+ logger.warning('flat field unavailable (%s); counts will be unweighted', exc)
418
+ else:
419
+ weight = np.zeros(len(x), dtype=np.float32)
420
+ outside = 0
421
+ for lo, hi in _chunks(len(x)):
422
+ xc, yc = x[lo:hi], y[lo:hi]
423
+ inside = (xc >= 1) & (xc < wmap.shape[1] + 1) & (yc >= 1) & (yc < wmap.shape[0] + 1)
424
+ weight[lo:hi][inside] = wmap[yc[inside] - 1, xc[inside] - 1] # 1-indexed AXIS -> 0-indexed [row=AXIS2, col=AXIS1]
425
+ outside += int((~inside).sum())
426
+ if outside:
427
+ logger.warning('%d events fall outside the flat field and get weight 0', outside)
428
+
429
+ gti = _read_gti(tag_path, float(time[-1]))
430
+ header = fits.getheader(tag_path, ext=0)
431
+ frame_info = load_frame_info(tag_path, fits.getheader(tag_path, ext=1))
432
+
433
+ for arr in (time, x, y, weight, gti):
434
+ if arr is not None:
435
+ arr.flags.writeable = False
436
+
437
+ return TagDataset(
438
+ name=tag_path.name, time=time, x=x, y=y, gti=gti, frame_info=frame_info,
439
+ weight=weight, flat_problem=flat_problem,
440
+ rootname=tag_path.name.split('_', 1)[0].lower(),
441
+ proposid=header.get('PROPOSID'),
442
+ obset_id=None if header.get('OBSET_ID') is None else str(header['OBSET_ID']).strip(),
443
+ targname=str(header.get('TARGNAME', '')).strip(),
444
+ linenum=str(header.get('LINENUM', '')).strip(),
445
+ tdateobs=str(header.get('TDATEOBS', '')).strip(),
446
+ ttimeobs=str(header.get('TTIMEOBS', '')).strip(),
447
+ texptime=_as_float(header.get('TEXPTIME')),
448
+ propaper=str(header.get('PROPAPER', '')).strip(),
449
+ )
450
+
451
+
452
+ # --------------------------------------------------------------------------
453
+
454
+
455
+ def load_dataset(path) -> TagDataset:
456
+ """Read a TIME-TAG file into a TagDataset. If `<rootname>_spt.fits` is in the same folder, the pointing
457
+ information for the rest frames and planet maps is read from it too. The flat field (`config.FLAT_FILE`) is
458
+ applied when it exists."""
459
+ return _load_dataset_uncached(str(path))