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.
- timetag_explorer/__init__.py +21 -0
- timetag_explorer/analysis.py +93 -0
- timetag_explorer/config.py +25 -0
- timetag_explorer/constants.py +57 -0
- timetag_explorer/dataset.py +459 -0
- timetag_explorer/frames.py +282 -0
- timetag_explorer/gdcorr.py +41 -0
- timetag_explorer/horizons.py +166 -0
- timetag_explorer/log.py +20 -0
- timetag_explorer/planet.py +246 -0
- timetag_explorer/regions.py +67 -0
- timetag_explorer/selection_script.py +494 -0
- timetag_explorer/transforms.py +578 -0
- timetag_explorer-1.0.1.dist-info/METADATA +129 -0
- timetag_explorer-1.0.1.dist-info/RECORD +18 -0
- timetag_explorer-1.0.1.dist-info/WHEEL +5 -0
- timetag_explorer-1.0.1.dist-info/licenses/LICENSE +29 -0
- timetag_explorer-1.0.1.dist-info/top_level.txt +1 -0
|
@@ -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))
|