number stimulus outputs in device stage order

The output index now equals the device stage, so outputs follow the order the
device measures them: master port 1 through its switch positions, then port 2.
The previous position-major numbering permuted the middle two against the
physical order, so data captured before this needs its calibration re-captured.
This commit is contained in:
Ayzen
2026-10-02 13:57:33 +03:00
parent 7329750c0c
commit 61ef8720e1
3 changed files with 97 additions and 46 deletions
@@ -73,13 +73,24 @@ def build_output_channels(
) -> tuple[OutputChannel, ...]:
"""Map every (master port, switch position) pair to an output and a device stage.
Outputs are numbered switch-position-major, so positions of the FIRST switch
setting keep the output indices they have without per-point switching (with
``switch_substage_count=1`` this is the identity). Stages are numbered
port-major, because the master bitstream takes the excited port from the upper
bits of the stage counter and the switch position from the lower bits — which
is also what makes the switch line a plain alternation the controller can drive
without tracking an absolute stage number.
**Output index equals the device stage**, so outputs are numbered in exactly the
order the device measures them: port 1 through all switch positions, then port 2
through all of them. That is the order an operator sees on a scope while hanging
antennas, and matching it is worth more than any renumbering cleverness.
Both numbers are port-major because the master bitstream takes the excited port
from the UPPER bits of the stage counter and the switch position from the lower
bits. The low bit being the switch position is what lets the trigger-loop
controller just invert its pin every cycle instead of tracking an absolute stage.
Before 2026-09-17 outputs were numbered switch-position-major, which kept
outputs 0/1 meaning the same thing with and without per-point switching but
permuted the middle two relative to the physical order (slots 0,1,2,3 landed on
outputs 0,2,1,3). Data captured under that layout is NOT comparable with data
captured now: outputs 1 and 2 swapped meaning, so calibration and reference sets
from before the change have to be re-captured.
With ``switch_substage_count=1`` the layout is the stock one either way.
This is the single source of truth shared by the protocol builder, the cycle
collector and the acquisition service, so the mapping cannot drift between them.
@@ -94,12 +105,13 @@ def build_output_channels(
)
return tuple(
OutputChannel(
index=substage * len(stimulus_ports) + port_index,
# Один и тот же номер: выход — это и есть стадия прибора.
index=port_index * substage_count + substage,
stimulus_port=port,
stage_index=port_index * substage_count + substage,
)
for substage in range(substage_count)
for port_index, port in enumerate(stimulus_ports)
for substage in range(substage_count)
)
+17 -5
View File
@@ -399,20 +399,32 @@ class PerPointStimulusSwitchingTest(unittest.TestCase):
def test_two_positions_give_four_outputs(self) -> None:
channels = build_output_channels((1, 2), 2)
# Outputs are switch-position-major, so outputs 0/1 stay exactly what they are
# without switching; stages are port-major, matching the bitstream taking the
# port from the stage counter's UPPER bits.
# Port-major in BOTH numbers: the output index is the device stage, so outputs
# come in the order the device measures them — port 1 through its switch
# positions, then port 2. Verified against a scope on the switch line.
self.assertEqual(
[(c.index, c.stimulus_port, c.stage_index) for c in channels],
[(0, 1, 0), (1, 2, 2), (2, 1, 1), (3, 2, 3)],
[(0, 1, 0), (1, 1, 1), (2, 2, 2), (3, 2, 3)],
)
def test_output_index_is_the_device_stage(self) -> None:
# The whole point of the 2026-09-17 renumbering: an operator hanging antennas
# sees stage order on a scope, and the output numbering must not permute it.
for substage_count in (1, 2, 4):
channels = build_output_channels((1, 2), substage_count)
self.assertEqual(
[c.index for c in channels],
[c.stage_index for c in channels],
msg=f"substages={substage_count}",
)
self.assertEqual([c.index for c in channels], list(range(len(channels))))
def test_switch_position_is_the_low_stage_bit(self) -> None:
# This is what lets the controller just invert its pin on every trigger cycle
# instead of tracking an absolute stage number.
channels = build_output_channels((1, 2), 2)
for channel in channels:
expected_position = channel.index // 2 # switch-position-major output order
expected_position = channel.index & 1
self.assertEqual(channel.stage_index & 1, expected_position)
def test_stage_budget_is_enforced(self) -> None: