some fixes
This commit is contained in:
@@ -12,13 +12,15 @@ import html
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import traceback
|
||||
|
||||
from PyQt6.QtCore import QTimer
|
||||
from PyQt6.QtGui import QTextCursor
|
||||
from PyQt6.QtWidgets import QMainWindow, QMessageBox
|
||||
from PyQt6.QtWidgets import QApplication, QMainWindow, QMessageBox
|
||||
|
||||
from python_app.gui.controllers.app_window_config_mixin import AppWindowConfigMixin
|
||||
from python_app.gui.controllers.app_window_control_button_mixin import AppWindowControlButtonMixin
|
||||
from python_app.gui.controllers.app_window_pipeline_mixin import AppWindowPipelineMixin
|
||||
from python_app.gui.controllers.app_window_plot_mixin import AppWindowPlotMixin
|
||||
from python_app.gui.controllers.app_window_preprocess_mixin import AppWindowPreprocessMixin
|
||||
@@ -47,6 +49,7 @@ class AppWindow(
|
||||
AppWindowPlotMixin,
|
||||
AppWindowPipelineMixin,
|
||||
AppWindowSnapshotMixin,
|
||||
AppWindowControlButtonMixin,
|
||||
QMainWindow,
|
||||
):
|
||||
"""Top-level window coordinating GUI state and acquisition runtime."""
|
||||
@@ -64,6 +67,7 @@ class AppWindow(
|
||||
self._init_history_state()
|
||||
self._init_runtime_limits()
|
||||
self._init_polling_timer()
|
||||
self._init_control_button_state()
|
||||
self._bootstrap_ui_runtime()
|
||||
|
||||
def _init_paths(self, project_root: Path) -> None:
|
||||
@@ -234,6 +238,9 @@ class AppWindow(
|
||||
self._pipeline_metrics.set_log_sink(self._log)
|
||||
self._timer.start()
|
||||
self._maybe_auto_start_pipeline()
|
||||
self._start_control_button_watcher()
|
||||
if self._is_truthy_env("RADAR_SYSTEM_HEADLESS"):
|
||||
self._install_headless_watchdog()
|
||||
|
||||
def _resolve_startup_profile_path(self) -> Path:
|
||||
"""Resolve active profile path from session-state or root fallback path."""
|
||||
@@ -278,7 +285,7 @@ class AppWindow(
|
||||
if self._is_truthy_env("RADAR_SYSTEM_AUTO_APPLY_RADAR"):
|
||||
QTimer.singleShot(500, self._auto_apply_radar_then_start)
|
||||
else:
|
||||
QTimer.singleShot(500, self._start_run)
|
||||
QTimer.singleShot(500, self._auto_start_pipeline_step)
|
||||
|
||||
def _auto_apply_radar_then_start(self) -> None:
|
||||
"""Apply current radar settings then start the pipeline (headless boot)."""
|
||||
@@ -287,8 +294,59 @@ class AppWindow(
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self._log_exception("Auto apply-radar failed", exc, level="WARN")
|
||||
# Hand control back to the event loop so widget updates from
|
||||
# _apply_radar_settings can flush before _start_run takes over.
|
||||
QTimer.singleShot(100, self._start_run)
|
||||
# _apply_radar_settings can flush, then wait one second before the
|
||||
# start takes over so the device settles after apply-radar.
|
||||
QTimer.singleShot(1000, self._auto_start_pipeline_step)
|
||||
|
||||
def _auto_start_pipeline_step(self) -> None:
|
||||
"""Run the launcher-requested pipeline start.
|
||||
|
||||
In headless mode a start that does not bring the pipeline up is fatal: we
|
||||
exit non-zero so `systemd Restart=on-failure` restarts the unit instead of
|
||||
leaving an idle daemon producing nothing. (The producer itself waits for
|
||||
the device forever, so a live-but-deviceless producer counts as running.)
|
||||
"""
|
||||
self._start_run()
|
||||
if self._is_truthy_env("RADAR_SYSTEM_HEADLESS") and not self._supervisor.is_running():
|
||||
self._headless_fatal("Headless auto-start did not bring the pipeline up")
|
||||
|
||||
def _install_headless_watchdog(self) -> None:
|
||||
"""Self-heal a headless daemon: if a managed pipeline process crashes (exits
|
||||
without us stopping it), exit non-zero so the service restarts clean.
|
||||
|
||||
Intentional stops drop processes from the supervisor first, so a normal
|
||||
stop/start or tmp-reference transition never trips this.
|
||||
"""
|
||||
self._headless_watchdog = QTimer(self)
|
||||
self._headless_watchdog.setInterval(2000)
|
||||
self._headless_watchdog.timeout.connect(self._headless_watchdog_tick)
|
||||
self._headless_watchdog.start()
|
||||
|
||||
def _headless_watchdog_tick(self) -> None:
|
||||
"""Escalate any unexpected managed-process exit to a fatal headless restart."""
|
||||
crashed = [
|
||||
report
|
||||
for report in self._supervisor.collect_exit_reports()
|
||||
if not report.expected_clean_exit
|
||||
]
|
||||
if crashed:
|
||||
names = ", ".join(report.name for report in crashed)
|
||||
details = "\n\n".join(report.format() for report in crashed)
|
||||
self._headless_fatal(f"Pipeline process exited unexpectedly: {names}", details=details)
|
||||
|
||||
def _headless_fatal(self, reason: str, *, details: str | None = None) -> None:
|
||||
"""Log loudly to stderr and exit non-zero so systemd restarts the service.
|
||||
|
||||
Headless deployments have no operator and the in-app log only reaches an
|
||||
offscreen widget, so a dead pipeline would otherwise go unnoticed.
|
||||
"""
|
||||
self._log_error(reason, details=details)
|
||||
print(f"[radar] FATAL (headless): {reason}", file=sys.stderr, flush=True)
|
||||
if details:
|
||||
print(details, file=sys.stderr, flush=True)
|
||||
app = QApplication.instance()
|
||||
if app is not None:
|
||||
app.exit(1)
|
||||
|
||||
@staticmethod
|
||||
def _is_truthy_env(name: str) -> bool:
|
||||
@@ -507,6 +565,8 @@ class AppWindow(
|
||||
def closeEvent(self, event) -> None: # noqa: N802
|
||||
"""Ensure workers and dialogs are closed before window destruction."""
|
||||
try:
|
||||
# 0) Stop the GPIO button watcher so a late press cannot start work.
|
||||
self._stop_control_button_watcher()
|
||||
self._resume_pipeline_after_capture = False
|
||||
# 1) Abort active capture first (releases exclusive hardware resources).
|
||||
self._abort_capture_sequence(resume_pipeline=False)
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
"""Background watcher that turns a physical GPIO button press into a Qt signal.
|
||||
|
||||
A single :class:`GpioLineEventWatcher` is polled on a dedicated thread via
|
||||
:func:`select.select`, woken either by a GPIO edge or by a self-pipe used for
|
||||
clean shutdown. Because the watcher is a ``QObject``, its ``pressed`` signal is
|
||||
delivered through the event loop on the thread that owns it (the GUI thread),
|
||||
so connected slots may touch widgets exactly as a button click would.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import select
|
||||
import threading
|
||||
|
||||
from PyQt6.QtCore import QObject, pyqtSignal
|
||||
|
||||
from python_app.hardware_full.switch_drivers.gpio_uapi import (
|
||||
GPIO_V2_LINE_EVENT_FALLING_EDGE,
|
||||
GPIO_V2_LINE_EVENT_RISING_EDGE,
|
||||
GPIO_V2_LINE_FLAG_BIAS_DISABLED,
|
||||
GPIO_V2_LINE_FLAG_BIAS_PULL_DOWN,
|
||||
GPIO_V2_LINE_FLAG_BIAS_PULL_UP,
|
||||
GPIO_V2_LINE_FLAG_EDGE_FALLING,
|
||||
GPIO_V2_LINE_FLAG_EDGE_RISING,
|
||||
GpioLineEventWatcher,
|
||||
)
|
||||
|
||||
_BIAS_FLAGS = {
|
||||
"pull_up": GPIO_V2_LINE_FLAG_BIAS_PULL_UP,
|
||||
"pull_down": GPIO_V2_LINE_FLAG_BIAS_PULL_DOWN,
|
||||
"disabled": GPIO_V2_LINE_FLAG_BIAS_DISABLED,
|
||||
}
|
||||
|
||||
|
||||
class ControlButtonWatcher(QObject):
|
||||
"""Monitor a GPIO push-button on a background thread and emit ``pressed``.
|
||||
|
||||
The press is detected on a single edge (falling for active-low wiring,
|
||||
rising otherwise), so a normal push produces exactly one ``pressed`` signal.
|
||||
``failed`` reports an unrecoverable watcher error as a human-readable string.
|
||||
"""
|
||||
|
||||
pressed = pyqtSignal()
|
||||
failed = pyqtSignal(str)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
chip: str,
|
||||
pin: int,
|
||||
active_low: bool = True,
|
||||
bias: str = "",
|
||||
debounce_ms: int = 50,
|
||||
parent: QObject | None = None,
|
||||
) -> None:
|
||||
"""Configure the watcher; the GPIO line stays closed until :meth:`start`."""
|
||||
super().__init__(parent)
|
||||
active_low = bool(active_low)
|
||||
# Active-low wiring idles high and falls on press; active-high is the mirror.
|
||||
self._press_edge = (
|
||||
GPIO_V2_LINE_EVENT_FALLING_EDGE if active_low else GPIO_V2_LINE_EVENT_RISING_EDGE
|
||||
)
|
||||
edge_flag = (
|
||||
GPIO_V2_LINE_FLAG_EDGE_FALLING if active_low else GPIO_V2_LINE_FLAG_EDGE_RISING
|
||||
)
|
||||
self._line = GpioLineEventWatcher(
|
||||
chip,
|
||||
int(pin),
|
||||
edge_flags=edge_flag,
|
||||
bias_flags=self._resolve_bias_flags(bias, active_low),
|
||||
debounce_us=max(0, int(debounce_ms)) * 1000,
|
||||
consumer="radar_control_button",
|
||||
)
|
||||
self._thread: threading.Thread | None = None
|
||||
self._stop_read_fd = -1
|
||||
self._stop_write_fd = -1
|
||||
|
||||
@staticmethod
|
||||
def _resolve_bias_flags(bias: str, active_low: bool) -> int:
|
||||
"""Return GPIO bias flags, defaulting to the bias that matches the wiring."""
|
||||
name = (bias or "").strip().lower()
|
||||
if name in _BIAS_FLAGS:
|
||||
return _BIAS_FLAGS[name]
|
||||
return GPIO_V2_LINE_FLAG_BIAS_PULL_UP if active_low else GPIO_V2_LINE_FLAG_BIAS_PULL_DOWN
|
||||
|
||||
def start(self) -> None:
|
||||
"""Open the GPIO line and begin watching for presses on a background thread."""
|
||||
self._line.open()
|
||||
self._stop_read_fd, self._stop_write_fd = os.pipe()
|
||||
self._thread = threading.Thread(
|
||||
target=self._run, name="control-button-watcher", daemon=True
|
||||
)
|
||||
self._thread.start()
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Signal the watcher thread to exit and release the GPIO line and pipe."""
|
||||
if self._stop_write_fd >= 0:
|
||||
try:
|
||||
os.write(self._stop_write_fd, b"\x00")
|
||||
except OSError:
|
||||
pass
|
||||
if self._thread is not None:
|
||||
self._thread.join(timeout=2.0)
|
||||
self._thread = None
|
||||
self._close_stop_pipe()
|
||||
self._line.close()
|
||||
|
||||
def _run(self) -> None:
|
||||
"""Block on the GPIO line until an edge fires or shutdown is requested."""
|
||||
line_fd = self._line.fileno()
|
||||
try:
|
||||
while True:
|
||||
readable, _, _ = select.select([line_fd, self._stop_read_fd], [], [])
|
||||
if self._stop_read_fd in readable:
|
||||
return
|
||||
if line_fd in readable and self._line.read_event() == self._press_edge:
|
||||
self.pressed.emit()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self.failed.emit(str(exc))
|
||||
|
||||
def _close_stop_pipe(self) -> None:
|
||||
"""Close both ends of the self-pipe used to wake the watcher thread."""
|
||||
for attr in ("_stop_read_fd", "_stop_write_fd"):
|
||||
fd = getattr(self, attr)
|
||||
if fd >= 0:
|
||||
try:
|
||||
os.close(fd)
|
||||
except OSError:
|
||||
pass
|
||||
setattr(self, attr, -1)
|
||||
@@ -0,0 +1,90 @@
|
||||
"""Mixin wiring a physical GPIO control button to a runtime action.
|
||||
|
||||
Both GUI and headless launches construct :class:`AppWindow`, so attaching the
|
||||
watcher here makes the button behave identically in both modes. The button
|
||||
reuses the existing "Capture Tmp Reference" flow (stop the pipeline, capture a
|
||||
fresh tmp reference, then restart the pipeline if it had been running), so no
|
||||
acquisition logic is duplicated for the hardware trigger.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from python_app.gui.control_button import ControlButtonWatcher
|
||||
from python_app.models.run_config_schema import ControlButtonModel
|
||||
|
||||
|
||||
class AppWindowControlButtonMixin:
|
||||
"""Start and stop a background GPIO button watcher bound to a runtime action."""
|
||||
|
||||
def _init_control_button_state(self) -> None:
|
||||
"""Initialize the watcher handle before the watcher is started."""
|
||||
self._control_button_watcher: ControlButtonWatcher | None = None
|
||||
|
||||
def _start_control_button_watcher(self) -> None:
|
||||
"""Open the configured GPIO button line and begin watching for presses.
|
||||
|
||||
Any failure is logged and swallowed: the same build runs on developer
|
||||
machines and non-Pi hosts where the GPIO chip is absent, and a missing
|
||||
button must never abort startup.
|
||||
"""
|
||||
config = getattr(self._defaults_config, "control_button", None)
|
||||
if config is None or not config.enabled:
|
||||
return
|
||||
if config.pin < 0 or not config.gpio_chip:
|
||||
self._log_warning(
|
||||
"Control button enabled but gpio_chip/pin are unset; watcher not started."
|
||||
)
|
||||
return
|
||||
if config.action != ControlButtonModel.ACTION_CAPTURE_TMP_REFERENCE:
|
||||
self._log_warning(
|
||||
f"Control button action '{config.action}' is not supported; watcher not started."
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
watcher = ControlButtonWatcher(
|
||||
chip=config.gpio_chip,
|
||||
pin=config.pin,
|
||||
active_low=config.active_low,
|
||||
bias=config.bias,
|
||||
debounce_ms=config.debounce_ms,
|
||||
parent=self,
|
||||
)
|
||||
watcher.pressed.connect(self._on_control_button_pressed)
|
||||
watcher.failed.connect(self._on_control_button_failed)
|
||||
watcher.start()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self._log_exception("Failed to start GPIO control button watcher", exc, level="WARN")
|
||||
return
|
||||
|
||||
self._control_button_watcher = watcher
|
||||
self._log(
|
||||
"GPIO control button watcher started: "
|
||||
f"chip={config.gpio_chip}, pin={config.pin}, active_low={config.active_low}, "
|
||||
f"debounce_ms={config.debounce_ms}, action={config.action}"
|
||||
)
|
||||
|
||||
def _on_control_button_pressed(self) -> None:
|
||||
"""Run the configured action for a physical press on the Qt main thread.
|
||||
|
||||
Delivered as a queued signal from the watcher thread, so this executes
|
||||
on the GUI thread exactly like a click on "Capture Tmp Reference".
|
||||
"""
|
||||
self._log("GPIO control button pressed: capturing tmp reference.")
|
||||
self._capture_tmp_reference()
|
||||
|
||||
def _on_control_button_failed(self, message: str) -> None:
|
||||
"""Log an unrecoverable watcher error reported from the background thread."""
|
||||
self._log_warning("GPIO control button watcher stopped", details=message)
|
||||
|
||||
def _stop_control_button_watcher(self) -> None:
|
||||
"""Stop the watcher and release its GPIO line during shutdown."""
|
||||
watcher = getattr(self, "_control_button_watcher", None)
|
||||
if watcher is None:
|
||||
return
|
||||
try:
|
||||
watcher.stop()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self._log_warning("Error stopping GPIO control button watcher", details=str(exc))
|
||||
finally:
|
||||
self._control_button_watcher = None
|
||||
@@ -6,7 +6,11 @@ import time
|
||||
|
||||
|
||||
from python_app.gui.runtime.constraints import validate_processing_mode_constraints
|
||||
from python_app.gui.runtime.history import build_run_history_signature, record_result_history
|
||||
from python_app.gui.runtime.history import (
|
||||
build_processor_run_signature,
|
||||
build_run_history_signature,
|
||||
record_result_history,
|
||||
)
|
||||
from python_app.hardware_full.kamil_adc_service import apply_kamil_adc_laser_control
|
||||
from python_app.hardware_full.single_radar_service import create_single_radar_service
|
||||
from python_app.models.dataset_model import ComboKey, ResultCollection, SweepCollection
|
||||
@@ -57,7 +61,8 @@ class AppWindowPipelineMixin:
|
||||
processor_was_running = self._supervisor.is_processor_running()
|
||||
config = self._build_config()
|
||||
run_signature = self._build_run_history_signature(config)
|
||||
if self._processor_requires_restart(run_signature):
|
||||
processor_signature = self._build_processor_run_signature(config)
|
||||
if self._processor_requires_restart(processor_signature):
|
||||
self._log("Restarting data_processor because stable run settings changed")
|
||||
self._stop_all_processes()
|
||||
processor_was_running = False
|
||||
@@ -122,7 +127,7 @@ class AppWindowPipelineMixin:
|
||||
self._raw_reader = ShmRingReader(config.rings.raw_tap.name)
|
||||
self._pre_reader = ShmRingReader(config.rings.preprocessed_tap.name)
|
||||
self._result_reader = ShmRingReader(config.rings.results.name)
|
||||
self._processor_run_signature = run_signature
|
||||
self._processor_run_signature = processor_signature
|
||||
self._single_capture_active = single_capture
|
||||
self._single_capture_start_ns = None
|
||||
self._single_capture_seen_raw = False
|
||||
@@ -167,8 +172,8 @@ class AppWindowPipelineMixin:
|
||||
config = self._build_config()
|
||||
if config.radar.driver_mode == "native":
|
||||
self._refresh_radar_limits_from_device()
|
||||
run_signature = self._build_run_history_signature(config)
|
||||
if processor_only_running and self._processor_requires_restart(run_signature):
|
||||
processor_signature = self._build_processor_run_signature(config)
|
||||
if processor_only_running and self._processor_requires_restart(processor_signature):
|
||||
self._stop_all_processes()
|
||||
self._reset_runtime_history()
|
||||
self._history_run_signature = None
|
||||
@@ -492,9 +497,13 @@ class AppWindowPipelineMixin:
|
||||
self._result_history.extend(result_tail)
|
||||
|
||||
def _build_run_history_signature(self, config: RunConfigModel) -> tuple[object, ...]:
|
||||
"""Build signature used to decide when history should be reset."""
|
||||
"""Build signature used to decide when display history should be reset."""
|
||||
return build_run_history_signature(config)
|
||||
|
||||
def _build_processor_run_signature(self, config: RunConfigModel) -> tuple[object, ...]:
|
||||
"""Build signature used to decide when the data_processor must be restarted."""
|
||||
return build_processor_run_signature(config)
|
||||
|
||||
def _validate_processing_mode_constraints(self, config: RunConfigModel) -> None:
|
||||
"""Validate processing-mode constraints for run start."""
|
||||
validate_processing_mode_constraints(
|
||||
|
||||
@@ -142,11 +142,19 @@ class AppWindowPreprocessMixin:
|
||||
)
|
||||
self._selected_preprocess_sets["s21_reference"] = TMP_REFERENCE_SET_NAME
|
||||
self._selected_preprocess_radar_key = radar_key
|
||||
self._processor_run_signature = None
|
||||
# Do NOT restart data_processor for a reference change: it does not consume
|
||||
# the S21 reference (the data_preprocessor does), and restarting it would tear
|
||||
# down the locator server it hosts and drop every connected client. The
|
||||
# preprocessor reloads the new reference when acquisition restarts below; the
|
||||
# processor keeps running. We only re-baseline the on-screen display history.
|
||||
self._history_run_signature = None
|
||||
if self._supervisor.is_processor_running():
|
||||
self._stop_all_processes()
|
||||
self._reset_runtime_history()
|
||||
# Tell the still-running data_processor to drop its accumulated background/
|
||||
# history so the new reference takes effect cleanly (no old/new-reference
|
||||
# blend) without restarting the process. Reuses the live-config clear_all
|
||||
# command, applied on the processor's next poll.
|
||||
if self._supervisor.is_processor_running():
|
||||
self._write_live_processing_config(history_command="clear_all", bump_history_seq=True)
|
||||
self._refresh_preprocess_summary_labels()
|
||||
if collection.traces:
|
||||
self._draw_single_trace(
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import suppress
|
||||
import os
|
||||
from pathlib import Path
|
||||
import signal
|
||||
@@ -63,7 +64,12 @@ def main() -> int:
|
||||
_install_unix_signal_handlers(app, window)
|
||||
else:
|
||||
window.showMaximized()
|
||||
return app.exec()
|
||||
exit_code = app.exec()
|
||||
# Release hardware, SHM readers and child processes before exiting so that a
|
||||
# systemd restart (after a headless fatal exit) starts from a clean slate.
|
||||
with suppress(Exception):
|
||||
window.close()
|
||||
return exit_code
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -59,12 +59,18 @@ def remove_last_aligned_histories(
|
||||
return retained_raw, retained_preprocessed, retained_results
|
||||
|
||||
|
||||
def build_run_history_signature(
|
||||
def build_processor_run_signature(
|
||||
config: RunConfigModel,
|
||||
) -> tuple[object, ...]:
|
||||
"""Build deterministic signature to detect run-settings changes (excluding live processing params)."""
|
||||
"""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)
|
||||
preprocess_signature = tuple(preprocess_asset_model(config, key).set_name for key in PREPROCESS_ASSET_KEYS)
|
||||
sweep_points_signature: object = "adc" if config.is_kamil_adc else int(config.radar.sweep.points)
|
||||
return (
|
||||
str(config.radar.model),
|
||||
@@ -85,11 +91,23 @@ def build_run_history_signature(
|
||||
str(config.output_switch.driver),
|
||||
int(config.output_switch.positions),
|
||||
bool(config.output_switch.invert_logic),
|
||||
preprocess_signature,
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user