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.
- eigsep_observing/__init__.py +25 -0
- eigsep_observing/_scripts_util.py +115 -0
- eigsep_observing/_test_fixtures.py +293 -0
- eigsep_observing/adc.py +134 -0
- eigsep_observing/blocks.py +846 -0
- eigsep_observing/client.py +998 -0
- eigsep_observing/config/corr_config.yaml +41 -0
- eigsep_observing/config/dummy_config.yaml +64 -0
- eigsep_observing/config/live_status_thresholds.yaml +52 -0
- eigsep_observing/config/obs_config.yaml +79 -0
- eigsep_observing/config/snap_only.yaml +39 -0
- eigsep_observing/config/test_config.yaml +39 -0
- eigsep_observing/config/wiring.yaml +52 -0
- eigsep_observing/contract_tests/__init__.py +12 -0
- eigsep_observing/contract_tests/test_key_uniqueness.py +58 -0
- eigsep_observing/contract_tests/test_producer_contracts.py +352 -0
- eigsep_observing/corr.py +298 -0
- eigsep_observing/data/eigsep_fengine_1g_v2_3_2024-07-08_1858.fpg +5481 -19
- eigsep_observing/file_heartbeat.py +82 -0
- eigsep_observing/fpga.py +1289 -0
- eigsep_observing/io.py +1765 -0
- eigsep_observing/keys.py +15 -0
- eigsep_observing/live_status/__init__.py +20 -0
- eigsep_observing/live_status/aggregator.py +1113 -0
- eigsep_observing/live_status/app.py +685 -0
- eigsep_observing/live_status/calibration.py +76 -0
- eigsep_observing/live_status/signals.py +263 -0
- eigsep_observing/live_status/snap_probe.py +61 -0
- eigsep_observing/live_status/static/css/dashboard.css +248 -0
- eigsep_observing/live_status/static/js/dashboard.js +809 -0
- eigsep_observing/live_status/templates/index.html +99 -0
- eigsep_observing/live_status/thresholds.py +238 -0
- eigsep_observing/motion_switch.py +100 -0
- eigsep_observing/motor_client.py +373 -0
- eigsep_observing/motor_zeroer.py +172 -0
- eigsep_observing/obs_config_owner.py +108 -0
- eigsep_observing/observer.py +620 -0
- eigsep_observing/plot.py +225 -0
- eigsep_observing/run_tag.py +161 -0
- eigsep_observing/scripts/__init__.py +0 -0
- eigsep_observing/scripts/fpga_init.py +204 -0
- eigsep_observing/scripts/no_switch_observation.py +190 -0
- eigsep_observing/scripts/observe.py +201 -0
- eigsep_observing/scripts/panda_observe.py +162 -0
- eigsep_observing/scripts/vna_position_sweep.py +196 -0
- eigsep_observing/snap_reinit.py +104 -0
- eigsep_observing/status_log_handler.py +214 -0
- eigsep_observing/tempctrl_client.py +362 -0
- eigsep_observing/testing/__init__.py +12 -0
- eigsep_observing/testing/client.py +146 -0
- eigsep_observing/testing/fpga.py +197 -0
- eigsep_observing/testing/observer.py +34 -0
- eigsep_observing/testing/utils.py +152 -0
- eigsep_observing/utils.py +251 -0
- eigsep_observing/vna.py +475 -0
- eigsep_observing/vna_calibration.py +95 -0
- eigsep_observing-2.5.0.dist-info/METADATA +79 -0
- eigsep_observing-2.5.0.dist-info/RECORD +62 -0
- eigsep_observing-2.5.0.dist-info/WHEEL +5 -0
- eigsep_observing-2.5.0.dist-info/entry_points.txt +6 -0
- eigsep_observing-2.5.0.dist-info/licenses/LICENSE +21 -0
- 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
|
+
}
|
eigsep_observing/adc.py
ADDED
|
@@ -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
|