added timing
This commit is contained in:
@@ -1,10 +1,11 @@
|
||||
"""Helpers for extracting GPR objects and locator observations from results."""
|
||||
"""Helpers for extracting GPR objects from result collections.
|
||||
|
||||
Locator TCP delivery now lives in the C++ data_processor. This module retains
|
||||
only the inspection helpers that the GUI uses for plotting.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
|
||||
from python_app.models.dataset_model import ResultCollection, ResultPayload
|
||||
@@ -64,64 +65,3 @@ def gpr_object_rows(collection: ResultCollection) -> np.ndarray:
|
||||
return centers[:, :3]
|
||||
|
||||
return np.zeros((0, 3), dtype=np.float32)
|
||||
|
||||
|
||||
def locator_observations_from_collection(
|
||||
collection: ResultCollection,
|
||||
min_score: float,
|
||||
*,
|
||||
visible_bounds: tuple[float, float, float, float] | None = None,
|
||||
object_draw_limits: tuple[int, int] | None = None,
|
||||
) -> list[dict[str, float]]:
|
||||
"""Build locator observations from GPR rows using score threshold and optional X/Z bounds."""
|
||||
rows = gpr_object_rows(collection)
|
||||
if rows.size == 0:
|
||||
return []
|
||||
|
||||
finite_mask = np.all(np.isfinite(rows[:, :3]), axis=1)
|
||||
visible_mask = finite_mask & (rows[:, 2] >= float(min_score))
|
||||
if visible_bounds is not None:
|
||||
x_min, x_max, z_min, z_max = (float(value) for value in visible_bounds)
|
||||
visible_mask &= (
|
||||
(rows[:, 0] >= x_min)
|
||||
& (rows[:, 0] <= x_max)
|
||||
& (rows[:, 1] >= z_min)
|
||||
& (rows[:, 1] <= z_max)
|
||||
)
|
||||
filtered = rows[visible_mask]
|
||||
if object_draw_limits is not None and filtered.size > 0:
|
||||
max_detected_objects, draw_top_objects = object_draw_limits
|
||||
if filtered.shape[0] > int(max_detected_objects):
|
||||
filtered = np.zeros((0, filtered.shape[1]), dtype=filtered.dtype)
|
||||
else:
|
||||
filtered = filtered[: max(0, int(draw_top_objects))]
|
||||
|
||||
observations: list[dict[str, float]] = []
|
||||
for x_m, z_m, _score in filtered:
|
||||
observations.append(
|
||||
{
|
||||
"dst": round(float(z_m), 2),
|
||||
"crs": round(float(x_m), 2),
|
||||
}
|
||||
)
|
||||
return observations
|
||||
|
||||
|
||||
def build_locator_payload(
|
||||
observations: list[dict[str, float]],
|
||||
*,
|
||||
protocol_version: int,
|
||||
status: int = 1,
|
||||
) -> dict[str, Any]:
|
||||
"""Assemble one outbound locator payload from precomputed observations."""
|
||||
return {
|
||||
"ver": int(protocol_version),
|
||||
"tim": _format_timestamp(),
|
||||
"sts": int(status),
|
||||
"obs": observations,
|
||||
}
|
||||
|
||||
|
||||
def _format_timestamp() -> str:
|
||||
"""Return wall-clock timestamp with millisecond precision."""
|
||||
return datetime.now().strftime("%H:%M:%S.%f")[:-3]
|
||||
|
||||
@@ -44,6 +44,12 @@ class ProcessingLiveConfig:
|
||||
gpr_background_mean_count: int = 10
|
||||
gpr_remove_sidelobe_objects_enabled: bool = True
|
||||
gpr_imaging_plane_y_m: float = 0.0
|
||||
# Locator filter parameters consumed by the C++ TCP locator server.
|
||||
gpr_min_visible_score: float = 0.0
|
||||
legacy_gpr_min_visible_pair_count: float = 0.0
|
||||
# When true, the C++ data_processor ignores socket-supplied vlc updates
|
||||
# and keeps using `gpr_speed_m_s` from this file.
|
||||
ignore_socket_speed: bool = False
|
||||
reprocess_current_result: bool = True
|
||||
history_command_seq: int = 0
|
||||
history_command: str = "none"
|
||||
@@ -95,6 +101,9 @@ class ProcessingLiveConfig:
|
||||
"gpr_background_mean_count": int(self.gpr_background_mean_count),
|
||||
"gpr_remove_sidelobe_objects_enabled": bool(self.gpr_remove_sidelobe_objects_enabled),
|
||||
"gpr_imaging_plane_y_m": float(self.gpr_imaging_plane_y_m),
|
||||
"gpr_min_visible_score": float(self.gpr_min_visible_score),
|
||||
"legacy_gpr_min_visible_pair_count": float(self.legacy_gpr_min_visible_pair_count),
|
||||
"ignore_socket_speed": bool(self.ignore_socket_speed),
|
||||
"reprocess_current_result": bool(self.reprocess_current_result),
|
||||
"history_command_seq": int(self.history_command_seq),
|
||||
"history_command": str(self.history_command),
|
||||
|
||||
@@ -1,460 +0,0 @@
|
||||
"""Event-driven locator TCP service fed by already-consumed GUI GPR results."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
from dataclasses import dataclass
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
import queue
|
||||
import struct
|
||||
import threading
|
||||
from typing import Any
|
||||
|
||||
from python_app.models.dataset_model import ResultCollection
|
||||
from python_app.orchestration.gpr_locator import (
|
||||
build_locator_payload,
|
||||
locator_observations_from_collection,
|
||||
)
|
||||
|
||||
_PACKET_HEADER_STRUCT = struct.Struct("<II")
|
||||
|
||||
|
||||
def encode_packet(payload: dict[str, Any], device_id: int) -> bytes:
|
||||
"""Serialize a JSON payload with the protocol binary header."""
|
||||
payload_bytes = json.dumps(
|
||||
payload,
|
||||
ensure_ascii=True,
|
||||
separators=(",", ":"),
|
||||
).encode("utf-8")
|
||||
return _PACKET_HEADER_STRUCT.pack(device_id, len(payload_bytes)) + payload_bytes
|
||||
|
||||
|
||||
def decode_packet(header_bytes: bytes, payload_bytes: bytes) -> tuple[int, Any]:
|
||||
"""Decode one protocol packet from its binary header and JSON payload."""
|
||||
if len(header_bytes) != _PACKET_HEADER_STRUCT.size:
|
||||
raise ValueError(f"Packet header must be exactly {_PACKET_HEADER_STRUCT.size} bytes long.")
|
||||
|
||||
device_id, payload_length = _PACKET_HEADER_STRUCT.unpack(header_bytes)
|
||||
if payload_length != len(payload_bytes):
|
||||
raise ValueError("Payload length does not match the header value.")
|
||||
|
||||
try:
|
||||
payload = json.loads(payload_bytes.decode("utf-8"))
|
||||
except UnicodeDecodeError as error:
|
||||
raise ValueError("Payload is not valid UTF-8.") from error
|
||||
except json.JSONDecodeError as error:
|
||||
raise ValueError("Payload is not valid JSON.") from error
|
||||
|
||||
return device_id, payload
|
||||
|
||||
|
||||
def parse_vlc(payload: dict[str, Any]) -> float:
|
||||
"""Validate and normalize inbound speed payload."""
|
||||
try:
|
||||
vlc = float(payload["vlc"])
|
||||
except (KeyError, TypeError, ValueError) as error:
|
||||
raise ValueError("Payload field 'vlc' must be numeric.") from error
|
||||
|
||||
if not math.isfinite(vlc):
|
||||
raise ValueError("Payload field 'vlc' must be finite.")
|
||||
return vlc
|
||||
|
||||
|
||||
def format_payload_for_log(payload: Any) -> str:
|
||||
"""Return compact JSON-ish payload text for logs."""
|
||||
return json.dumps(payload, ensure_ascii=True, separators=(",", ":"))
|
||||
|
||||
|
||||
def decode_packet_for_log(packet: bytes) -> tuple[int, str]:
|
||||
"""Decode an outbound packet into `(device_id, payload_text)` for logging."""
|
||||
if len(packet) < _PACKET_HEADER_STRUCT.size:
|
||||
raise ValueError("Packet is shorter than the locator header")
|
||||
header_bytes = packet[: _PACKET_HEADER_STRUCT.size]
|
||||
payload_bytes = packet[_PACKET_HEADER_STRUCT.size :]
|
||||
device_id, payload = decode_packet(header_bytes, payload_bytes)
|
||||
return device_id, format_payload_for_log(payload)
|
||||
|
||||
|
||||
def format_peer_name(writer: asyncio.StreamWriter) -> str:
|
||||
"""Return a readable peer address for logs."""
|
||||
peer_name = writer.get_extra_info("peername")
|
||||
if isinstance(peer_name, tuple) and len(peer_name) >= 2:
|
||||
return f"{peer_name[0]}:{peer_name[1]}"
|
||||
return str(peer_name or "unknown")
|
||||
|
||||
|
||||
async def read_packet_with_limit(reader: asyncio.StreamReader, max_payload_bytes: int) -> tuple[int, Any]:
|
||||
"""Read and decode a single packet using the requested payload limit."""
|
||||
header_bytes = await reader.readexactly(_PACKET_HEADER_STRUCT.size)
|
||||
_, payload_length = _PACKET_HEADER_STRUCT.unpack(header_bytes)
|
||||
if payload_length > int(max_payload_bytes):
|
||||
raise ValueError(
|
||||
"Payload length %d exceeds the %d byte limit."
|
||||
% (payload_length, int(max_payload_bytes))
|
||||
)
|
||||
payload_bytes = await reader.readexactly(payload_length)
|
||||
return decode_packet(header_bytes, payload_bytes)
|
||||
|
||||
|
||||
@dataclass(eq=False, slots=True)
|
||||
class _ClientConnection:
|
||||
"""Runtime state for one connected locator client."""
|
||||
|
||||
writer: asyncio.StreamWriter
|
||||
peer_name: str
|
||||
queue: asyncio.Queue[bytes]
|
||||
closed: bool = False
|
||||
|
||||
|
||||
class LocatorTcpService:
|
||||
"""Background-thread TCP service for locator packets."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
host: str,
|
||||
port: int,
|
||||
*,
|
||||
device_id: int,
|
||||
protocol_version: int,
|
||||
max_payload_bytes: int,
|
||||
client_queue_size: int,
|
||||
logger_name: str,
|
||||
logger: logging.Logger | None = None,
|
||||
) -> None:
|
||||
"""Create a stopped service instance."""
|
||||
self._host = host
|
||||
self._port = int(port)
|
||||
self._device_id = int(device_id)
|
||||
self._protocol_version = int(protocol_version)
|
||||
self._max_payload_bytes = int(max_payload_bytes)
|
||||
self._logger = logger or logging.getLogger(str(logger_name))
|
||||
self._client_queue_size = int(client_queue_size)
|
||||
self._speed_updates: queue.Queue[float] = queue.Queue()
|
||||
self._log_updates: queue.Queue[str] = queue.Queue()
|
||||
self._loop: asyncio.AbstractEventLoop | None = None
|
||||
self._server: asyncio.AbstractServer | None = None
|
||||
self._thread: threading.Thread | None = None
|
||||
self._startup_event = threading.Event()
|
||||
self._startup_error: Exception | None = None
|
||||
self._clients: set[_ClientConnection] = set()
|
||||
self._snapshot_lock = threading.Lock()
|
||||
self._latest_packet: bytes | None = None
|
||||
|
||||
@property
|
||||
def host(self) -> str:
|
||||
"""Return bind host."""
|
||||
return self._host
|
||||
|
||||
@property
|
||||
def port(self) -> int:
|
||||
"""Return bind port."""
|
||||
return self._port
|
||||
|
||||
def start(self) -> None:
|
||||
"""Start the background event loop and TCP listener."""
|
||||
if self.is_running():
|
||||
return
|
||||
|
||||
self._startup_event = threading.Event()
|
||||
self._startup_error = None
|
||||
self._thread = threading.Thread(
|
||||
target=self._thread_main,
|
||||
name="locator-tcp-service",
|
||||
daemon=True,
|
||||
)
|
||||
self._thread.start()
|
||||
|
||||
if not self._startup_event.wait(timeout=5.0):
|
||||
raise RuntimeError("Timed out waiting for locator TCP service startup.")
|
||||
|
||||
if self._startup_error is not None:
|
||||
error = self._startup_error
|
||||
self.stop()
|
||||
raise RuntimeError(f"Failed to start locator TCP service: {error}") from error
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Stop listener, disconnect clients, and join the background thread."""
|
||||
loop = self._loop
|
||||
thread = self._thread
|
||||
|
||||
if loop is not None:
|
||||
with contextlib.suppress(RuntimeError):
|
||||
loop.call_soon_threadsafe(loop.stop)
|
||||
|
||||
if thread is not None:
|
||||
thread.join(timeout=5.0)
|
||||
|
||||
self._thread = None
|
||||
self._loop = None
|
||||
self._server = None
|
||||
self._clients.clear()
|
||||
|
||||
def is_running(self) -> bool:
|
||||
"""Return whether the background loop is alive."""
|
||||
return self._thread is not None and self._thread.is_alive() and self._loop is not None
|
||||
|
||||
def publish_collection(
|
||||
self,
|
||||
collection: ResultCollection,
|
||||
min_score: float,
|
||||
*,
|
||||
visible_bounds: tuple[float, float, float, float] | None = None,
|
||||
object_draw_limits: tuple[int, int] | None = None,
|
||||
) -> None:
|
||||
"""Publish one locator payload derived from a GPR result collection."""
|
||||
observations = locator_observations_from_collection(
|
||||
collection,
|
||||
min_score,
|
||||
visible_bounds=visible_bounds,
|
||||
object_draw_limits=object_draw_limits,
|
||||
)
|
||||
payload = build_locator_payload(
|
||||
observations,
|
||||
protocol_version=self._protocol_version,
|
||||
status=1,
|
||||
)
|
||||
self._publish_packet(encode_packet(payload, device_id=self._device_id))
|
||||
|
||||
def publish_empty(self) -> None:
|
||||
"""Publish an empty locator snapshot."""
|
||||
payload = build_locator_payload(
|
||||
[],
|
||||
protocol_version=self._protocol_version,
|
||||
status=1,
|
||||
)
|
||||
self._publish_packet(encode_packet(payload, device_id=self._device_id))
|
||||
|
||||
def drain_speed_updates(self) -> float | None:
|
||||
"""Drain queued speed updates and return the newest one, if any."""
|
||||
latest: float | None = None
|
||||
while True:
|
||||
try:
|
||||
latest = float(self._speed_updates.get_nowait())
|
||||
except queue.Empty:
|
||||
return latest
|
||||
|
||||
def drain_log_updates(self) -> list[str]:
|
||||
"""Drain queued socket traffic log lines."""
|
||||
lines: list[str] = []
|
||||
while True:
|
||||
try:
|
||||
lines.append(str(self._log_updates.get_nowait()))
|
||||
except queue.Empty:
|
||||
return lines
|
||||
|
||||
def _queue_log_update(self, message: str) -> None:
|
||||
"""Queue one socket traffic line for the GUI runtime log."""
|
||||
self._log_updates.put(str(message))
|
||||
|
||||
def _log_socket_traffic(self, message: str) -> None:
|
||||
"""Log socket traffic to both Python logging and the GUI-visible queue."""
|
||||
self._logger.info(message)
|
||||
self._queue_log_update(message)
|
||||
|
||||
def _publish_packet(self, packet: bytes) -> None:
|
||||
"""Store latest packet and broadcast it to all connected clients."""
|
||||
with self._snapshot_lock:
|
||||
self._latest_packet = packet
|
||||
|
||||
loop = self._loop
|
||||
if loop is None:
|
||||
return
|
||||
|
||||
with contextlib.suppress(RuntimeError):
|
||||
loop.call_soon_threadsafe(self._broadcast_packet, packet)
|
||||
|
||||
def _get_latest_packet(self) -> bytes | None:
|
||||
"""Return the latest stored packet snapshot."""
|
||||
with self._snapshot_lock:
|
||||
return self._latest_packet
|
||||
|
||||
def _thread_main(self) -> None:
|
||||
"""Own the event loop and TCP listener lifecycle."""
|
||||
loop = asyncio.new_event_loop()
|
||||
self._loop = loop
|
||||
asyncio.set_event_loop(loop)
|
||||
|
||||
try:
|
||||
self._server = loop.run_until_complete(
|
||||
asyncio.start_server(self._handle_client, self._host, self._port)
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self._startup_error = exc
|
||||
self._startup_event.set()
|
||||
self._loop = None
|
||||
asyncio.set_event_loop(None)
|
||||
loop.close()
|
||||
return
|
||||
|
||||
self._startup_event.set()
|
||||
try:
|
||||
loop.run_forever()
|
||||
finally:
|
||||
with contextlib.suppress(Exception):
|
||||
loop.run_until_complete(self._shutdown_async())
|
||||
asyncio.set_event_loop(None)
|
||||
loop.close()
|
||||
self._server = None
|
||||
self._loop = None
|
||||
|
||||
async def _shutdown_async(self) -> None:
|
||||
"""Close listener and all active client connections."""
|
||||
server = self._server
|
||||
if server is not None:
|
||||
server.close()
|
||||
await server.wait_closed()
|
||||
|
||||
clients = list(self._clients)
|
||||
self._clients.clear()
|
||||
for client in clients:
|
||||
client.closed = True
|
||||
client.writer.close()
|
||||
|
||||
for client in clients:
|
||||
with contextlib.suppress(BrokenPipeError, ConnectionResetError):
|
||||
await client.writer.wait_closed()
|
||||
|
||||
pending = [
|
||||
task
|
||||
for task in asyncio.all_tasks()
|
||||
if task is not asyncio.current_task()
|
||||
]
|
||||
for task in pending:
|
||||
task.cancel()
|
||||
for task in pending:
|
||||
with contextlib.suppress(asyncio.CancelledError, Exception):
|
||||
await task
|
||||
|
||||
async def _handle_client(
|
||||
self,
|
||||
reader: asyncio.StreamReader,
|
||||
writer: asyncio.StreamWriter,
|
||||
) -> None:
|
||||
"""Handle one client until disconnect or protocol failure."""
|
||||
peer_name = format_peer_name(writer)
|
||||
client = _ClientConnection(
|
||||
writer=writer,
|
||||
peer_name=peer_name,
|
||||
queue=asyncio.Queue(maxsize=self._client_queue_size),
|
||||
)
|
||||
self._clients.add(client)
|
||||
self._logger.info("Locator client connected: %s", peer_name)
|
||||
|
||||
latest_packet = self._get_latest_packet()
|
||||
if latest_packet is not None:
|
||||
self._enqueue_packet(client, latest_packet)
|
||||
|
||||
send_task = asyncio.create_task(
|
||||
self._send_packets(client),
|
||||
name=f"locator_send:{peer_name}",
|
||||
)
|
||||
receive_task = asyncio.create_task(
|
||||
self._receive_packets(reader, client),
|
||||
name=f"locator_receive:{peer_name}",
|
||||
)
|
||||
|
||||
done, pending = await asyncio.wait(
|
||||
{send_task, receive_task},
|
||||
return_when=asyncio.FIRST_COMPLETED,
|
||||
)
|
||||
|
||||
for task in pending:
|
||||
task.cancel()
|
||||
for task in pending:
|
||||
with contextlib.suppress(asyncio.CancelledError):
|
||||
await task
|
||||
|
||||
self._clients.discard(client)
|
||||
client.closed = True
|
||||
writer.close()
|
||||
with contextlib.suppress(BrokenPipeError, ConnectionResetError):
|
||||
await writer.wait_closed()
|
||||
|
||||
for task in done:
|
||||
exception = task.exception()
|
||||
if exception is None:
|
||||
continue
|
||||
if isinstance(exception, asyncio.IncompleteReadError):
|
||||
self._logger.info("Locator client closed the connection: %s", peer_name)
|
||||
continue
|
||||
if isinstance(exception, (BrokenPipeError, ConnectionResetError)):
|
||||
self._logger.info("Locator connection lost: %s", peer_name)
|
||||
continue
|
||||
if isinstance(exception, ValueError):
|
||||
self._logger.warning(
|
||||
"Closing locator client %s after protocol error: %s",
|
||||
peer_name,
|
||||
exception,
|
||||
)
|
||||
continue
|
||||
self._logger.error(
|
||||
"Unexpected locator client error: %s",
|
||||
peer_name,
|
||||
exc_info=(type(exception), exception, exception.__traceback__),
|
||||
)
|
||||
|
||||
self._logger.info("Locator client disconnected: %s", peer_name)
|
||||
|
||||
async def _send_packets(self, client: _ClientConnection) -> None:
|
||||
"""Drain one client's outbound queue."""
|
||||
while True:
|
||||
packet = await client.queue.get()
|
||||
client.writer.write(packet)
|
||||
await client.writer.drain()
|
||||
try:
|
||||
device_id, payload_text = decode_packet_for_log(packet)
|
||||
self._log_socket_traffic(
|
||||
"Locator socket sent to %s: device_id=%d payload=%s"
|
||||
% (client.peer_name, device_id, payload_text)
|
||||
)
|
||||
except ValueError as error:
|
||||
self._log_socket_traffic(
|
||||
"Locator socket sent undecodable packet to %s: bytes=%d error=%s"
|
||||
% (client.peer_name, len(packet), error)
|
||||
)
|
||||
|
||||
async def _receive_packets(
|
||||
self,
|
||||
reader: asyncio.StreamReader,
|
||||
client: _ClientConnection,
|
||||
) -> None:
|
||||
"""Receive inbound client packets and queue valid speed updates."""
|
||||
while True:
|
||||
device_id, payload = await read_packet_with_limit(reader, self._max_payload_bytes)
|
||||
payload_text = format_payload_for_log(payload)
|
||||
if isinstance(payload, dict) and "vlc" in payload:
|
||||
speed_m_s = parse_vlc(payload)
|
||||
self._speed_updates.put(speed_m_s)
|
||||
self._log_socket_traffic(
|
||||
"Locator socket received from %s: device_id=%d payload=%s speed_m_s=%g"
|
||||
% (client.peer_name, device_id, payload_text, speed_m_s)
|
||||
)
|
||||
continue
|
||||
|
||||
self._log_socket_traffic(
|
||||
"Locator socket received from %s: device_id=%d payload=%s"
|
||||
% (client.peer_name, device_id, payload_text)
|
||||
)
|
||||
|
||||
def _broadcast_packet(self, packet: bytes) -> None:
|
||||
"""Enqueue one packet for all connected clients."""
|
||||
for client in list(self._clients):
|
||||
self._enqueue_packet(client, packet)
|
||||
|
||||
def _enqueue_packet(self, client: _ClientConnection, packet: bytes) -> None:
|
||||
"""Enqueue one packet or disconnect a backpressured client."""
|
||||
if client.closed:
|
||||
return
|
||||
|
||||
try:
|
||||
client.queue.put_nowait(packet)
|
||||
except asyncio.QueueFull:
|
||||
client.closed = True
|
||||
self._logger.warning(
|
||||
"Disconnecting locator client %s after outbound queue overflow.",
|
||||
client.peer_name,
|
||||
)
|
||||
client.writer.close()
|
||||
@@ -0,0 +1,104 @@
|
||||
"""Rolling pipeline timing metrics emitted to the runtime log.
|
||||
|
||||
Three independent samples are accumulated:
|
||||
* acquisition — `capture_end_ns - capture_start_ns` from each raw sweep
|
||||
* processing — `processing_duration_ns` from each result collection
|
||||
* rendering — wall time of the Python render call
|
||||
|
||||
Each metric flushes an averaged report to a caller-supplied logger as soon as
|
||||
its rolling buffer reaches `report_every` samples (default 50). Metrics are
|
||||
strictly read-only: malformed or missing input is silently ignored so a busy
|
||||
pipeline never blocks on a stray sample.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import deque
|
||||
from dataclasses import dataclass
|
||||
from typing import Callable, Iterable
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class MetricReport:
|
||||
"""Summary of one rolling-window flush.
|
||||
|
||||
All durations are in nanoseconds. `n` is the number of samples that fed the
|
||||
summary — never less than 1. `min_ns` / `max_ns` mark the extremes of the
|
||||
window so spikes are visible even when the average stays calm.
|
||||
"""
|
||||
|
||||
name: str
|
||||
n: int
|
||||
avg_ns: int
|
||||
min_ns: int
|
||||
max_ns: int
|
||||
|
||||
def format_ms(self) -> str:
|
||||
"""Format the summary as a one-line `ms`-scaled log message."""
|
||||
return (
|
||||
f"metrics: {self.name} n={self.n} "
|
||||
f"avg={self.avg_ns / 1_000_000:.2f}ms "
|
||||
f"min={self.min_ns / 1_000_000:.2f}ms "
|
||||
f"max={self.max_ns / 1_000_000:.2f}ms"
|
||||
)
|
||||
|
||||
|
||||
class PipelineMetrics:
|
||||
"""Accumulate per-stage durations and flush averaged reports.
|
||||
|
||||
The caller supplies a `log_sink` (a function taking a single string) that
|
||||
receives one report line per flushed metric. Wiring `log_sink` to the GUI
|
||||
log writer keeps metric output co-located with the rest of the runtime
|
||||
log; routing it to `print` keeps the class trivially unit-testable.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
report_every: int = 50,
|
||||
log_sink: Callable[[str], None] | None = None,
|
||||
) -> None:
|
||||
"""Create a collector with a flush threshold and optional log sink."""
|
||||
if report_every < 1:
|
||||
raise ValueError("report_every must be >= 1")
|
||||
self._report_every = int(report_every)
|
||||
self._log_sink = log_sink
|
||||
self._buffers: dict[str, deque[int]] = {}
|
||||
|
||||
def set_log_sink(self, log_sink: Callable[[str], None] | None) -> None:
|
||||
"""Reassign the log sink (used when the GUI log appears after init)."""
|
||||
self._log_sink = log_sink
|
||||
|
||||
def record(self, name: str, duration_ns: int) -> MetricReport | None:
|
||||
"""Append one sample. Return a flushed report if the buffer is full."""
|
||||
if duration_ns <= 0:
|
||||
return None
|
||||
buffer = self._buffers.setdefault(name, deque())
|
||||
buffer.append(int(duration_ns))
|
||||
if len(buffer) < self._report_every:
|
||||
return None
|
||||
|
||||
samples = list(buffer)
|
||||
buffer.clear()
|
||||
report = self._summarize(name, samples)
|
||||
if self._log_sink is not None:
|
||||
self._log_sink(report.format_ms())
|
||||
return report
|
||||
|
||||
def reset(self) -> None:
|
||||
"""Discard all buffered samples without emitting a report."""
|
||||
self._buffers.clear()
|
||||
|
||||
@staticmethod
|
||||
def _summarize(name: str, samples: Iterable[int]) -> MetricReport:
|
||||
"""Reduce a sample sequence to one report."""
|
||||
sample_list = list(samples)
|
||||
total = sum(sample_list)
|
||||
count = len(sample_list)
|
||||
return MetricReport(
|
||||
name=name,
|
||||
n=count,
|
||||
avg_ns=total // count,
|
||||
min_ns=min(sample_list),
|
||||
max_ns=max(sample_list),
|
||||
)
|
||||
@@ -16,7 +16,7 @@ from python_app.orchestration.shm.binary_cursor import ByteCursor
|
||||
|
||||
RAW_MAGIC = 0x32574152
|
||||
PREPROC_MAGIC = 0x32525050
|
||||
RESULT_MAGIC = 0x314C5352
|
||||
RESULT_MAGIC = 0x324C5352 # RSL2: adds processing_duration_ns after monotonic_ns
|
||||
|
||||
|
||||
def decode_trace_collection(payload: bytes, expected_magic: int) -> SweepCollection:
|
||||
@@ -136,6 +136,7 @@ def decode_result_collection(payload: bytes) -> ResultCollection:
|
||||
|
||||
collection_id = cursor.read_u64()
|
||||
monotonic_ns = cursor.read_u64()
|
||||
processing_duration_ns = cursor.read_u64()
|
||||
collection_payload_count = cursor.read_u32()
|
||||
block_count = cursor.read_u32()
|
||||
|
||||
@@ -163,6 +164,7 @@ def decode_result_collection(payload: bytes) -> ResultCollection:
|
||||
return ResultCollection(
|
||||
collection_id=collection_id,
|
||||
monotonic_ns=monotonic_ns,
|
||||
processing_duration_ns=processing_duration_ns,
|
||||
collection_payloads=collection_payloads,
|
||||
blocks=blocks,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user