eigsep_observing 2.5.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. eigsep_observing/__init__.py +25 -0
  2. eigsep_observing/_scripts_util.py +115 -0
  3. eigsep_observing/_test_fixtures.py +293 -0
  4. eigsep_observing/adc.py +134 -0
  5. eigsep_observing/blocks.py +846 -0
  6. eigsep_observing/client.py +998 -0
  7. eigsep_observing/config/corr_config.yaml +41 -0
  8. eigsep_observing/config/dummy_config.yaml +64 -0
  9. eigsep_observing/config/live_status_thresholds.yaml +52 -0
  10. eigsep_observing/config/obs_config.yaml +79 -0
  11. eigsep_observing/config/snap_only.yaml +39 -0
  12. eigsep_observing/config/test_config.yaml +39 -0
  13. eigsep_observing/config/wiring.yaml +52 -0
  14. eigsep_observing/contract_tests/__init__.py +12 -0
  15. eigsep_observing/contract_tests/test_key_uniqueness.py +58 -0
  16. eigsep_observing/contract_tests/test_producer_contracts.py +352 -0
  17. eigsep_observing/corr.py +298 -0
  18. eigsep_observing/data/eigsep_fengine_1g_v2_3_2024-07-08_1858.fpg +5481 -19
  19. eigsep_observing/file_heartbeat.py +82 -0
  20. eigsep_observing/fpga.py +1289 -0
  21. eigsep_observing/io.py +1765 -0
  22. eigsep_observing/keys.py +15 -0
  23. eigsep_observing/live_status/__init__.py +20 -0
  24. eigsep_observing/live_status/aggregator.py +1113 -0
  25. eigsep_observing/live_status/app.py +685 -0
  26. eigsep_observing/live_status/calibration.py +76 -0
  27. eigsep_observing/live_status/signals.py +263 -0
  28. eigsep_observing/live_status/snap_probe.py +61 -0
  29. eigsep_observing/live_status/static/css/dashboard.css +248 -0
  30. eigsep_observing/live_status/static/js/dashboard.js +809 -0
  31. eigsep_observing/live_status/templates/index.html +99 -0
  32. eigsep_observing/live_status/thresholds.py +238 -0
  33. eigsep_observing/motion_switch.py +100 -0
  34. eigsep_observing/motor_client.py +373 -0
  35. eigsep_observing/motor_zeroer.py +172 -0
  36. eigsep_observing/obs_config_owner.py +108 -0
  37. eigsep_observing/observer.py +620 -0
  38. eigsep_observing/plot.py +225 -0
  39. eigsep_observing/run_tag.py +161 -0
  40. eigsep_observing/scripts/__init__.py +0 -0
  41. eigsep_observing/scripts/fpga_init.py +204 -0
  42. eigsep_observing/scripts/no_switch_observation.py +190 -0
  43. eigsep_observing/scripts/observe.py +201 -0
  44. eigsep_observing/scripts/panda_observe.py +162 -0
  45. eigsep_observing/scripts/vna_position_sweep.py +196 -0
  46. eigsep_observing/snap_reinit.py +104 -0
  47. eigsep_observing/status_log_handler.py +214 -0
  48. eigsep_observing/tempctrl_client.py +362 -0
  49. eigsep_observing/testing/__init__.py +12 -0
  50. eigsep_observing/testing/client.py +146 -0
  51. eigsep_observing/testing/fpga.py +197 -0
  52. eigsep_observing/testing/observer.py +34 -0
  53. eigsep_observing/testing/utils.py +152 -0
  54. eigsep_observing/utils.py +251 -0
  55. eigsep_observing/vna.py +475 -0
  56. eigsep_observing/vna_calibration.py +95 -0
  57. eigsep_observing-2.5.0.dist-info/METADATA +79 -0
  58. eigsep_observing-2.5.0.dist-info/RECORD +62 -0
  59. eigsep_observing-2.5.0.dist-info/WHEEL +5 -0
  60. eigsep_observing-2.5.0.dist-info/entry_points.txt +6 -0
  61. eigsep_observing-2.5.0.dist-info/licenses/LICENSE +21 -0
  62. eigsep_observing-2.5.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,25 @@
1
+ __author__ = "Christian Hellum Bye"
2
+ __version__ = "2.5.0"
3
+
4
+ from .client import PandaClient
5
+ from .motion_switch import MotionSwitchCoordinator
6
+ from .observer import EigObserver
7
+ from .fpga import EigsepFpga
8
+ from .motor_client import MotorClient
9
+ from .motor_zeroer import MotorZeroer
10
+ from .status_log_handler import StatusStreamHandler
11
+ from .tempctrl_client import TempCtrlClient
12
+
13
+ try:
14
+ from . import testing
15
+ except ImportError as e:
16
+ import logging
17
+
18
+ # Use a named logger (not the root convenience function) so the
19
+ # warning does NOT trigger logging.basicConfig() and silently
20
+ # install a stderr StreamHandler on the root logger — that would
21
+ # defeat configure_eig_logger(console=False) callers downstream.
22
+ logging.getLogger(__name__).warning(
23
+ f"Could not import testing module: {e}, use pip install .[dev] to "
24
+ "install the required dependencies for testing if needed."
25
+ )
@@ -0,0 +1,115 @@
1
+ """Shared helpers for the standalone manual test scripts under ``scripts/``.
2
+
3
+ Three responsibilities, all deliberately small:
4
+
5
+ - :func:`build_transport` mirrors the ``--dummy`` bootstrap used by
6
+ the multi-pico manual scripts (``motor_manual.py`` / ``motor_control.py``
7
+ / ``potmon_manual.py`` / ...) so every per-app manual script can
8
+ share one well-tested transport path.
9
+ - :func:`build_transport_bare` is the equivalent for bring-up scripts
10
+ that build their *own* minimal producer surface and explicitly do
11
+ not want a ``DummyPandaClient`` masquerading on the transport in
12
+ dummy mode (see ``scripts/CLAUDE.md`` for the contract).
13
+ - :func:`require_pico` is a structural availability check the manual
14
+ scripts run before issuing any command, so an operator who forgot to
15
+ start ``pico-manager.service`` (or who flashed the wrong pico) gets a
16
+ one-line actionable error instead of a silent ``send_command`` no-op.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import logging
22
+ import sys
23
+
24
+ from eigsep_redis import Transport
25
+
26
+
27
+ logger = logging.getLogger(__name__)
28
+
29
+
30
+ def build_transport(
31
+ dummy: bool,
32
+ *,
33
+ host: str = "localhost",
34
+ real_port: int = 6379,
35
+ dummy_port: int = 6380,
36
+ ) -> Transport:
37
+ """Return a :class:`Transport` matching the manual-script convention.
38
+
39
+ Real mode just constructs a ``Transport(host, real_port)``.
40
+
41
+ Dummy mode constructs a transport against a separate fakeredis port
42
+ (``dummy_port``, conventionally 6380), resets the fakeredis state,
43
+ and attaches a fully-emulated ``DummyPandaClient`` to the transport
44
+ so every dummy pico (motor, rfswitch, tempctrl, potmon, imu_el,
45
+ imu_az, lidar) is registered and emitting status before the caller
46
+ starts issuing proxy commands. The client is stashed on
47
+ ``transport._dummy_client`` to keep it alive — without that
48
+ reference Python would garbage-collect the embedded PicoManager
49
+ threads and the proxy would see ``is_available=False``.
50
+
51
+ Bring-up scripts that build their own minimal producer surface
52
+ (e.g. ``vna_manual.py`` / ``record_vna.py``) want
53
+ :func:`build_transport_bare` instead — the auto-attached
54
+ ``DummyPandaClient`` here violates the no-PandaClient-masquerade
55
+ rule documented in ``scripts/CLAUDE.md`` and competes with the
56
+ script's own dummy-pico bootstrap.
57
+ """
58
+ if dummy:
59
+ logger.warning("Running in DUMMY mode, no hardware will be used.")
60
+ from .testing import DummyPandaClient
61
+
62
+ transport = Transport(host=host, port=dummy_port)
63
+ transport.reset()
64
+ transport._dummy_client = DummyPandaClient(transport=transport)
65
+ return transport
66
+ return Transport(host=host, port=real_port)
67
+
68
+
69
+ def build_transport_bare(
70
+ dummy: bool,
71
+ *,
72
+ host: str = "localhost",
73
+ real_port: int = 6379,
74
+ dummy_port: int = 6380,
75
+ ) -> Transport:
76
+ """Return a :class:`Transport` with no client attached.
77
+
78
+ The bring-up-script variant of :func:`build_transport`: same port
79
+ convention, same fakeredis reset on dummy, but explicitly does
80
+ **not** instantiate a ``DummyPandaClient`` on the transport.
81
+
82
+ Bring-up scripts (``vna_manual.py``, ``record_vna.py``, etc.) build
83
+ their own minimal producer surface (see ``scripts/CLAUDE.md``) and
84
+ spin up only the dummy picos they actually need. Attaching a
85
+ ``DummyPandaClient`` from this helper would (a) start a heartbeat
86
+ thread that collides with whatever the script is meant to test and
87
+ (b) double-register dummy picos if the script also calls
88
+ ``start_dummy_pico_manager`` directly.
89
+ """
90
+ if dummy:
91
+ logger.warning("Running in DUMMY mode, no hardware will be used.")
92
+ transport = Transport(host=host, port=dummy_port)
93
+ transport.reset()
94
+ return transport
95
+ return Transport(host=host, port=real_port)
96
+
97
+
98
+ def require_pico(proxy, *, hint_script: str = "pico_preflight.py") -> None:
99
+ """Exit with a clear message if ``proxy``'s heartbeat is missing.
100
+
101
+ Manual scripts go through :class:`picohost.proxy.PicoProxy`, which
102
+ silently returns ``None`` from :meth:`send_command` when the
103
+ targeted pico's heartbeat is absent. That's the right behavior for
104
+ long-running observing loops, but a manual operator running a
105
+ bring-up script needs to know immediately that the pico-manager
106
+ service isn't reachable.
107
+ """
108
+ if proxy.is_available:
109
+ return
110
+ sys.stderr.write(
111
+ f"ERROR: pico {proxy.name!r} is not registered with PicoManager.\n"
112
+ f" - Is pico-manager.service running on the panda?\n"
113
+ f" - Was the pico flashed? Run scripts/{hint_script} to verify.\n"
114
+ )
115
+ raise SystemExit(2)
@@ -0,0 +1,293 @@
1
+ """Golden fixtures for the eigsep_observing test suite and for the
2
+ producer-contract tests shipped under ``eigsep_observing.contract_tests``.
3
+
4
+ These constants and helpers are deliberately constructed to mirror the
5
+ shape and types of real production data. See the "Testing philosophy"
6
+ section in CLAUDE.md: fixtures should look like what producers actually
7
+ emit so tests catch contract drift, and any deviations should be called
8
+ out explicitly.
9
+
10
+ They live under ``src/`` (rather than in ``tests/conftest.py``) so the
11
+ producer-contract suite can ship inside the installed wheel — the
12
+ eigsep-field CLI runs it via ``pytest --pyargs
13
+ eigsep_observing.contract_tests`` on nodes that only have the wheel
14
+ (the Pi), not the test tree. The leading underscore marks this module
15
+ as private: it is not part of the supported public API of
16
+ eigsep_observing and its shape can change without a deprecation cycle.
17
+ The in-repo ``tests/conftest.py`` re-exports from here so existing
18
+ ``from conftest import HEADER`` imports in ``test_io.py`` keep working
19
+ unchanged.
20
+
21
+ These are kept as plain module-level constants rather than
22
+ ``@pytest.fixture`` functions because they are referenced from inside
23
+ nested data structures (e.g. ``CORR_METADATA``) and from helper module
24
+ imports — both of which the parameter-injection style does not support
25
+ without significant test rewrites.
26
+ """
27
+
28
+ import numpy as np
29
+ from picohost.base import PicoPeltier
30
+ from picohost.testing import TempCtrlEmulator
31
+
32
+ # One corr file accumulates NTIMES integrations, each of duration
33
+ # INTEGRATION_TIME seconds. FILE_TIME = NTIMES * INTEGRATION_TIME is the
34
+ # wall-clock duration of the file and is included in HEADER so the
35
+ # relationship is explicit at the fixture level (rather than being two
36
+ # independently-set numbers that can drift apart).
37
+ NTIMES = 60
38
+ INTEGRATION_TIME = 1.0 # seconds
39
+ FILE_TIME = NTIMES * INTEGRATION_TIME # seconds
40
+
41
+ # HEADER mimics EigsepFpga.header: the static-configuration portion of a
42
+ # corr file. Units match corr_config.yaml: sample_rate is in MHz (NOT Hz).
43
+ #
44
+ # ``pol_delay`` is a nested dict (one key per pol-pair) matching the
45
+ # shape emitted by the real header property — not three flat keys.
46
+ # ``wiring`` is the hardware manifest (split out of the old ``rf_chain``
47
+ # key in corr_config.yaml); a single antenna with no ``pam:`` block is
48
+ # included so consumers exercise the PAM-absent code path.
49
+ HEADER = {
50
+ "dtype": ">i4",
51
+ "acc_bins": 2,
52
+ "avg_even_odd": True,
53
+ "nchan": 1024,
54
+ "fpg_file": "fpg_files/eigsep_fengine.fpg",
55
+ "fpg_version": [0, 0],
56
+ "corr_acc_len": 2**28,
57
+ "corr_scalar": 2**9,
58
+ "pol_delay": {"01": 0, "23": 0, "45": 0},
59
+ "fft_shift": 0x00FF,
60
+ "sample_rate": 500.0, # MHz, matching corr_config.yaml convention
61
+ "adc_gain": 4,
62
+ "wiring": {
63
+ "snap_id": "C000069",
64
+ "ants": {
65
+ "viv1-N": {
66
+ "fem": {"id": 32, "pol": "N"},
67
+ "snap": {"input": 2, "label": "N4"},
68
+ },
69
+ },
70
+ },
71
+ "sync_time": 1748732903.4203713,
72
+ "integration_time": INTEGRATION_TIME,
73
+ "file_time": FILE_TIME,
74
+ }
75
+
76
+ # Schema-conformant raw IMU reading (as emitted by a pico and pushed into
77
+ # stream:imu_el by picohost). Mirrors the BNO085 UART RVC payload
78
+ # introduced in picohost 1.0.0: yaw/pitch/roll orientation in degrees and
79
+ # accel_x/y/z in m/s². Used to build CORR_METADATA entries and by tests
80
+ # that feed raw stream data into File.add_data.
81
+ IMU_READING = {
82
+ "sensor_name": "imu_el",
83
+ "status": "update",
84
+ "app_id": 3,
85
+ "yaw": 0.0,
86
+ "pitch": 0.0,
87
+ "roll": 0.0,
88
+ "accel_x": 0.0,
89
+ "accel_y": 0.0,
90
+ "accel_z": 9.81,
91
+ }
92
+
93
+
94
+ def _imu_avg_entry(yaw):
95
+ """One per-sample IMU entry as avg_metadata would emit it.
96
+
97
+ All numeric fields are float and take the float→mean reduction in
98
+ ``_avg_sensor_values``. ``yaw`` is the per-sample varying axis used
99
+ by tests that need to assert on a non-constant float field.
100
+ """
101
+ return {
102
+ "sensor_name": "imu_el",
103
+ "status": "update",
104
+ "app_id": 3,
105
+ "yaw": yaw,
106
+ "pitch": 0.0,
107
+ "roll": 0.0,
108
+ "accel_x": 0.0,
109
+ "accel_y": 0.0,
110
+ "accel_z": 9.81,
111
+ }
112
+
113
+
114
+ def _lidar_avg_entry(distance_m):
115
+ """One per-sample lidar entry as avg_metadata would emit it."""
116
+ return {
117
+ "sensor_name": "lidar",
118
+ "status": "update",
119
+ "app_id": 4,
120
+ "distance_m": distance_m,
121
+ }
122
+
123
+
124
+ def tempctrl_post_handler_reading(stream_name):
125
+ """One per-channel tempctrl reading after ``_peltier_redis_handler``.
126
+
127
+ The firmware/emulator emits one combined ``sensor_name="tempctrl"``
128
+ dict per status tick; ``PicoPeltier._peltier_redis_handler`` fans
129
+ that into two ``writer.add(...)`` calls — one per channel — and is
130
+ the boundary that produces the actual Redis-stream shapes. Composing
131
+ the emulator with the handler here means every test fixture downstream
132
+ is anchored to the real producer output rather than drifting on
133
+ hand-typed steady-state values.
134
+ """
135
+ pel = PicoPeltier.__new__(PicoPeltier)
136
+ captured = []
137
+ pel._base_redis_handler = lambda d: captured.append(dict(d))
138
+ pel._peltier_redis_handler(TempCtrlEmulator().get_status())
139
+ for entry in captured:
140
+ if entry.get("sensor_name") == stream_name:
141
+ return entry
142
+ raise AssertionError(
143
+ f"_peltier_redis_handler did not emit a {stream_name!r} entry; "
144
+ f"got sensor_names={[e.get('sensor_name') for e in captured]}"
145
+ )
146
+
147
+
148
+ def _tempctrl_channel_entry(sensor_name, t_now, timestamp):
149
+ """One per-sample tempctrl channel (``tempctrl_lna`` or ``tempctrl_load``).
150
+
151
+ Steady-state fields are derived from
152
+ ``tempctrl_post_handler_reading`` so the fixture stays anchored to
153
+ real ``TempCtrlEmulator`` + ``PicoPeltier._peltier_redis_handler``
154
+ output. Only ``T_now`` and ``timestamp`` are per-sample test inputs;
155
+ everything else (``T_target``, drive flags, watchdog) is the
156
+ emulator's steady-state value.
157
+ """
158
+ entry = tempctrl_post_handler_reading(sensor_name)
159
+ entry["T_now"] = t_now
160
+ entry["timestamp"] = timestamp
161
+ return entry
162
+
163
+
164
+ def _potmon_avg_entry(pot_el_voltage):
165
+ """One per-sample potmon entry as avg_metadata would emit it.
166
+
167
+ Mirrors the post-``_pot_redis_handler`` shape that lands in Redis:
168
+ raw voltages plus the flattened cal slope/intercept and the derived
169
+ angle. All-scalar per the picohost scalar-only contract; the cal
170
+ fields are de-facto invariants for the lifetime of a stream.
171
+
172
+ A *calibrated* reading is used here so every cal/angle field is a
173
+ real float, exercising the float→mean reduction in
174
+ ``_avg_sensor_values``. The uncalibrated-stream case (cal/angle
175
+ fields all ``None``) is a first-class producer state — see the
176
+ ``potmon`` schema comment in ``io.py`` — but it is intentionally
177
+ not exercised by this golden fixture because it would force the
178
+ round-trip assertion to special-case ``None`` survivors and obscure
179
+ the steady-state contract this fixture is meant to pin. Tests that
180
+ need to cover the uncalibrated path should build their own samples.
181
+ Same rationale as ``_potmon_post_handler_reading`` in
182
+ ``test_producer_contracts.py``.
183
+ """
184
+ return {
185
+ "sensor_name": "potmon",
186
+ "status": "update",
187
+ "app_id": 2,
188
+ "pot_el_voltage": pot_el_voltage,
189
+ "pot_az_voltage": 1.5,
190
+ "pot_el_cal_slope": 100.0,
191
+ "pot_el_cal_intercept": -50.0,
192
+ "pot_az_cal_slope": 200.0,
193
+ "pot_az_cal_intercept": -100.0,
194
+ "pot_el_angle": 100.0 * pot_el_voltage - 50.0,
195
+ "pot_az_angle": 200.0 * 1.5 - 100.0,
196
+ }
197
+
198
+
199
+ ERROR_INTEGRATION_INDEX = 30
200
+
201
+
202
+ def _imu_errored_integration_entry(yaw):
203
+ return {**_imu_avg_entry(yaw), "status": "error"}
204
+
205
+
206
+ CORR_METADATA = {
207
+ "imu_el": [
208
+ _imu_avg_entry(0.001 * i)
209
+ if i != ERROR_INTEGRATION_INDEX
210
+ else _imu_errored_integration_entry(0.001 * i)
211
+ for i in range(NTIMES)
212
+ ],
213
+ "imu_az": [
214
+ {**_imu_avg_entry(0.002 * i), "sensor_name": "imu_az", "app_id": 6}
215
+ for i in range(NTIMES)
216
+ ],
217
+ "lidar": [_lidar_avg_entry(1.5 + 0.001 * i) for i in range(NTIMES)],
218
+ "potmon": [_potmon_avg_entry(1.5 + 0.001 * i) for i in range(NTIMES)],
219
+ "tempctrl_lna": [
220
+ _tempctrl_channel_entry("tempctrl_lna", 30.0 + 0.01 * i, 1.0 + i)
221
+ for i in range(NTIMES)
222
+ ],
223
+ "tempctrl_load": [
224
+ _tempctrl_channel_entry("tempctrl_load", 25.0 + 0.01 * i, 1.0 + i)
225
+ for i in range(NTIMES)
226
+ ],
227
+ "rfswitch": (
228
+ ["RFANT"] * 20 # steady state
229
+ + ["UNKNOWN"] * 5 # transition window
230
+ + ["RFNOFF"] * 20 # new steady state
231
+ + [None] * 5 # sensor dropout (gap-fill pad in _insert_sample)
232
+ + ["RFNOFF"] * 10 # recovery
233
+ ),
234
+ }
235
+
236
+ # VNA_METADATA mirrors the flat ``{key: value}`` dict returned by
237
+ # ``MetadataSnapshotReader.get()`` — the snapshot path used by the VNA
238
+ # code in ``PandaClient.measure_s11``. Values are whatever the producer
239
+ # last pushed via ``MetadataWriter.add``:
240
+ # - picohost pushes the raw sensor dict for each sensor key
241
+ # - ``MetadataWriter.add`` auto-appends a ``{key}_ts`` Unix-seconds float
242
+ # - misc. scalars (e.g. ``corr_sync_time``) go in as floats
243
+ # There is NO averaging on this path; unlike CORR_METADATA, values are
244
+ # scalars or nested dicts, never per-sample lists.
245
+ _SNAPSHOT_TS = 1775997296.789012
246
+ VNA_METADATA = {
247
+ "imu_el": IMU_READING,
248
+ "imu_el_ts": _SNAPSHOT_TS,
249
+ "imu_az": {**IMU_READING, "sensor_name": "imu_az", "app_id": 6},
250
+ "imu_az_ts": _SNAPSHOT_TS,
251
+ "lidar": {
252
+ "sensor_name": "lidar",
253
+ "status": "update",
254
+ "app_id": 4,
255
+ "distance_m": 1.52,
256
+ },
257
+ "lidar_ts": _SNAPSHOT_TS,
258
+ "potmon": {
259
+ "sensor_name": "potmon",
260
+ "status": "update",
261
+ "app_id": 2,
262
+ "pot_el_voltage": 1.5,
263
+ "pot_az_voltage": 1.5,
264
+ "pot_el_cal_slope": 100.0,
265
+ "pot_el_cal_intercept": -50.0,
266
+ "pot_az_cal_slope": 200.0,
267
+ "pot_az_cal_intercept": -100.0,
268
+ "pot_el_angle": 100.0,
269
+ "pot_az_angle": 200.0,
270
+ },
271
+ "potmon_ts": _SNAPSHOT_TS,
272
+ "rfswitch": {
273
+ "sensor_name": "rfswitch",
274
+ "status": "update",
275
+ "app_id": 5,
276
+ "sw_state": 3,
277
+ "sw_state_name": "VNAS",
278
+ },
279
+ "rfswitch_ts": _SNAPSHOT_TS,
280
+ "corr_sync_time": 1748732903.4203713,
281
+ "corr_sync_time_ts": _SNAPSHOT_TS,
282
+ }
283
+
284
+ S11_HEADER = {
285
+ "fstart": 1e6,
286
+ "fstop": 250e6,
287
+ "npoints": 1000,
288
+ "ifbw": 100,
289
+ "power_dBm": 0,
290
+ "freqs": np.linspace(1e6, 250e6, 1000),
291
+ "mode": "ant",
292
+ "metadata_snapshot_unix": 1748734379.905014,
293
+ }
@@ -0,0 +1,134 @@
1
+ import json
2
+ import logging
3
+
4
+ import numpy as np
5
+ from eigsep_redis import SingleStreamReader, SingleStreamWriter
6
+
7
+ from .keys import ADC_SNAPSHOT_STREAM
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+
12
+ class AdcSnapshotWriter(SingleStreamWriter):
13
+ """
14
+ Publish raw ADC snapshot arrays onto the diagnostic snapshot stream.
15
+
16
+ Each entry carries the bare int8 samples pulled from the SNAP's
17
+ ``input_snapshot_bram`` by :meth:`Input.get_adc_snapshot`, stacked
18
+ across all antennas, plus a JSON sidecar with timing context and
19
+ wiring so a downstream live-status app can label cores without
20
+ hardcoding the layout. Not routed through ``MetadataWriter`` —
21
+ snapshots are binary and bypass the metadata-averaging path; they
22
+ live in Redis only and are not folded into the HDF5 corr file.
23
+
24
+ ``maxlen`` is a dead-reader failsafe: at the 1 Hz default publish
25
+ cadence, 60 entries is one minute of headroom, which covers any
26
+ realistic live-status reconnect window. Snapshots are diagnostic
27
+ and not recoverable from a different source, so we don't need
28
+ hours of buffering — if the reader fell behind that far, the data
29
+ it would pull is already stale for debugging purposes.
30
+ """
31
+
32
+ stream = ADC_SNAPSHOT_STREAM
33
+ maxlen = 60
34
+
35
+ def _encode(
36
+ self,
37
+ data,
38
+ unix_ts,
39
+ sync_time=None,
40
+ corr_acc_cnt=None,
41
+ wiring=None,
42
+ ):
43
+ if not isinstance(data, np.ndarray):
44
+ raise ValueError("data must be a numpy array")
45
+ # Force C-contiguous so tobytes() and the reader's reshape
46
+ # always agree on memory layout regardless of what the caller
47
+ # passed (views, F-contig, strided, etc.).
48
+ data = np.ascontiguousarray(data)
49
+ arr_meta = {
50
+ "dtype": data.dtype.str,
51
+ "shape": list(data.shape),
52
+ }
53
+ sidecar = {
54
+ "arr_meta": arr_meta,
55
+ "unix_ts": float(unix_ts),
56
+ "sync_time": sync_time,
57
+ "corr_acc_cnt": corr_acc_cnt,
58
+ "wiring": wiring,
59
+ }
60
+ return {
61
+ "data": data.tobytes(),
62
+ "sidecar": json.dumps(sidecar),
63
+ }
64
+
65
+ def add(
66
+ self,
67
+ data,
68
+ unix_ts,
69
+ sync_time=None,
70
+ corr_acc_cnt=None,
71
+ wiring=None,
72
+ ):
73
+ """
74
+ Publish one ADC snapshot frame.
75
+
76
+ Parameters
77
+ ----------
78
+ data : np.ndarray
79
+ Raw ADC samples, shape ``(n_antennas, 2, n_samples)``,
80
+ dtype ``int8``. Axis 0 is the antenna index as consumed by
81
+ :meth:`Input.get_adc_snapshot`; axis 1 is pol (0=x, 1=y);
82
+ axis 2 is the sample time axis.
83
+ unix_ts : float
84
+ Wall clock time at which the snapshot was captured.
85
+ sync_time : float or None
86
+ The SNAP sync_time at capture, if available. Lets the
87
+ consumer place the snapshot on the same time axis as the
88
+ corr stream.
89
+ corr_acc_cnt : int or None
90
+ The ``corr_acc_cnt`` register value at capture, if
91
+ available. Same alignment purpose as ``sync_time``.
92
+ wiring : dict or None
93
+ Subset of ``wiring.yaml`` describing the antenna-to-core
94
+ mapping. Included so the live-status app can label cores
95
+ without parsing the corr header separately.
96
+
97
+ Raises
98
+ ------
99
+ ValueError
100
+ If ``data`` is not a numpy array.
101
+ """
102
+ self.publish(
103
+ data,
104
+ unix_ts,
105
+ sync_time=sync_time,
106
+ corr_acc_cnt=corr_acc_cnt,
107
+ wiring=wiring,
108
+ )
109
+
110
+
111
+ class AdcSnapshotReader(SingleStreamReader):
112
+ """
113
+ Consume ADC snapshot frames from the diagnostic snapshot stream.
114
+
115
+ Returns ``(data, sidecar)`` from :meth:`read`; the
116
+ ``(None, None)`` tuple is returned when the snapshot stream
117
+ isn't registered yet (producer hasn't started).
118
+ """
119
+
120
+ stream = ADC_SNAPSHOT_STREAM
121
+ absent_warning = (
122
+ "No ADC snapshot stream found. Publisher may not have started yet."
123
+ )
124
+
125
+ def _absent_sentinel(self):
126
+ return None, None
127
+
128
+ def _decode(self, entry_id, fields):
129
+ sidecar = json.loads(fields[b"sidecar"].decode("utf-8"))
130
+ arr_meta = sidecar["arr_meta"]
131
+ data = np.frombuffer(
132
+ fields[b"data"], dtype=np.dtype(arr_meta["dtype"])
133
+ ).reshape(arr_meta["shape"])
134
+ return data, sidecar