The processor appended sweeps the GUI fed back to its own history, so its next replay published them twice and out of order, and the B-scan came out with rotated columns. Fed-back sweeps now stay out of that history, results are kept in timestamp order, picking the displayed combo only redraws and only new sweeps are recorded to disk.
159 lines
6.7 KiB
Python
159 lines
6.7 KiB
Python
"""Helpers for GUI-side runtime history management."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from collections import deque
|
|
from typing import TypeVar
|
|
|
|
from python_app.models.dataset_model import ResultCollection, SweepCollection
|
|
from python_app.models.run_config_model import RunConfigModel
|
|
from python_app.orchestration.preprocess_assets import PREPROCESS_ASSET_KEYS, preprocess_asset_model
|
|
|
|
THistoryCollection = TypeVar("THistoryCollection", SweepCollection, ResultCollection)
|
|
|
|
|
|
def collection_key(collection: SweepCollection | ResultCollection) -> tuple[int, int]:
|
|
"""Identity of a sweep through the pipeline: its result keeps the same id and timestamp.
|
|
|
|
`monotonic_ns` is stamped by `data_preprocessor` on the system monotonic clock, so
|
|
it also orders sweeps by acquisition, across runs too (ids restart from 1).
|
|
"""
|
|
return int(collection.collection_id), int(collection.monotonic_ns)
|
|
|
|
|
|
def record_result_history(
|
|
result_history: deque[ResultCollection],
|
|
collection: ResultCollection,
|
|
) -> bool:
|
|
"""Record a result, keeping the history in acquisition order; return whether it is new.
|
|
|
|
A result of a sweep already present replaces it (the sweep was reprocessed).
|
|
Any other result goes after the newest older one: normally at the end, as the
|
|
result of a new sweep, but the processor reprocessing its retained sweeps also
|
|
delivers sweeps whose first result the GUI missed, and appending those would
|
|
show them out of order.
|
|
"""
|
|
key = collection_key(collection)
|
|
position = len(result_history)
|
|
while position > 0:
|
|
existing = result_history[position - 1]
|
|
if collection_key(existing) == key:
|
|
result_history[position - 1] = collection
|
|
return False
|
|
if existing.monotonic_ns < collection.monotonic_ns:
|
|
break
|
|
position -= 1
|
|
|
|
if position == len(result_history):
|
|
result_history.append(collection)
|
|
return True
|
|
if len(result_history) == result_history.maxlen:
|
|
if position == 0:
|
|
return False # older than everything kept, so it would be the first to go
|
|
result_history.popleft()
|
|
position -= 1
|
|
result_history.insert(position, collection)
|
|
return False
|
|
|
|
|
|
def remove_last_aligned_histories(
|
|
raw_history: list[SweepCollection],
|
|
preprocessed_history: list[SweepCollection],
|
|
result_history: list[ResultCollection],
|
|
) -> tuple[list[SweepCollection], list[SweepCollection], list[ResultCollection]]:
|
|
"""Remove the newest aligned history entry using results as preferred anchor."""
|
|
retained_raw = list(raw_history)
|
|
retained_preprocessed = list(preprocessed_history)
|
|
retained_results = list(result_history)
|
|
|
|
if retained_results:
|
|
target_key = _tail_occurrence_key(retained_results, len(retained_results) - 1)
|
|
retained_results.pop()
|
|
_remove_by_tail_occurrence_key(retained_raw, target_key)
|
|
_remove_by_tail_occurrence_key(retained_preprocessed, target_key)
|
|
return retained_raw, retained_preprocessed, retained_results
|
|
|
|
if retained_preprocessed:
|
|
target_key = _tail_occurrence_key(retained_preprocessed, len(retained_preprocessed) - 1)
|
|
retained_preprocessed.pop()
|
|
_remove_by_tail_occurrence_key(retained_raw, target_key)
|
|
return retained_raw, retained_preprocessed, retained_results
|
|
|
|
if retained_raw:
|
|
retained_raw.pop()
|
|
|
|
return retained_raw, retained_preprocessed, retained_results
|
|
|
|
|
|
def build_processor_run_signature(
|
|
config: RunConfigModel,
|
|
) -> tuple[object, ...]:
|
|
"""Build signature of settings that change the data SHAPE the data_processor parses.
|
|
|
|
Excludes the preprocess set names on purpose: the data_processor does not consume
|
|
calibration/reference sets (those are applied upstream by the data_preprocessor), so
|
|
changing a set must NOT restart the processor. Restarting it would also tear down the
|
|
locator TCP server it hosts and drop every connected client. Only genuine shape changes
|
|
(radar model, sweep, switch layout, combos) require a processor restart.
|
|
"""
|
|
combos_signature = tuple((int(combo.input), int(combo.output)) for combo in config.combos)
|
|
sweep_points_signature: object = "adc" if config.is_kamil_adc else int(config.radar.sweep.points)
|
|
return (
|
|
str(config.radar.model),
|
|
str(config.radar.driver_mode),
|
|
str(config.radar.serial),
|
|
tuple(str(value) for value in config.radar.multi_device.slave_serials),
|
|
bool(config.radar.multi_device.force_external_reference),
|
|
float(config.radar.sweep.start_hz),
|
|
float(config.radar.sweep.stop_hz),
|
|
sweep_points_signature,
|
|
float(config.radar.sweep.if_bandwidth_hz),
|
|
float(config.radar.sweep.power_dbm),
|
|
str(config.input_switch.driver_mode),
|
|
str(config.input_switch.driver),
|
|
int(config.input_switch.positions),
|
|
bool(config.input_switch.invert_logic),
|
|
str(config.output_switch.driver_mode),
|
|
str(config.output_switch.driver),
|
|
int(config.output_switch.positions),
|
|
bool(config.output_switch.invert_logic),
|
|
combos_signature,
|
|
)
|
|
|
|
|
|
def build_run_history_signature(
|
|
config: RunConfigModel,
|
|
) -> tuple[object, ...]:
|
|
"""Build full signature for GUI display-history reset (processor shape + preprocess sets).
|
|
|
|
Display history still resets when the reference/calibration set changes (the on-screen
|
|
B-scan would otherwise mix old- and new-reference frames), even though the processor
|
|
process itself is intentionally kept alive across that change.
|
|
"""
|
|
preprocess_signature = tuple(preprocess_asset_model(config, key).set_name for key in PREPROCESS_ASSET_KEYS)
|
|
return build_processor_run_signature(config) + (preprocess_signature,)
|
|
|
|
|
|
def _tail_occurrence_key(history: list[THistoryCollection], index: int) -> tuple[int, int]:
|
|
"""Return `(collection_id, occurrence_from_tail)` for the item at `index`."""
|
|
collection_id = int(history[index].collection_id)
|
|
occurrence_from_tail = 0
|
|
for cursor in range(len(history) - 1, index, -1):
|
|
if int(history[cursor].collection_id) == collection_id:
|
|
occurrence_from_tail += 1
|
|
return collection_id, occurrence_from_tail
|
|
|
|
|
|
def _remove_by_tail_occurrence_key(history: list[THistoryCollection], key: tuple[int, int]) -> bool:
|
|
"""Remove newest matching entry identified by `(collection_id, occurrence_from_tail)`."""
|
|
collection_id, target_occurrence = key
|
|
occurrence_from_tail = 0
|
|
for index in range(len(history) - 1, -1, -1):
|
|
if int(history[index].collection_id) != collection_id:
|
|
continue
|
|
if occurrence_from_tail == target_occurrence:
|
|
history.pop(index)
|
|
return True
|
|
occurrence_from_tail += 1
|
|
return False
|