feat(perception): expire costmap permissions per cell
This commit is contained in:
@@ -0,0 +1,140 @@
|
||||
"""Expiry of individual TGS cells, independent of models and transport.
|
||||
|
||||
Uses the existing TGS states 0=unobserved, 1=ground, 2=occupied, 3=rejected
|
||||
and advisory actions 0=allow-candidate, 1=high-cost, 2=no-go. Expiry can only
|
||||
remove permission. Retained occupied cells keep their prohibition. This is not
|
||||
vehicle clearance, spatial reprojection validation, or motor authority.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Final
|
||||
|
||||
from .realtime_contract import RealtimeContractError, _integer, _number
|
||||
from .realtime_scene import _read_wire_integer, _wire_integer
|
||||
|
||||
CELL_FRESHNESS_SCHEMA: Final = "missioncore.costmap-cell-freshness/v1"
|
||||
MAXIMUM_CELLS: Final = 8192
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CellAssessment:
|
||||
states: tuple[int, ...]
|
||||
actions: tuple[int, ...]
|
||||
expired_cells: int
|
||||
missing_support_cells: int
|
||||
suppressed_permissions: int
|
||||
oldest_permissive_source_time_ns: int
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CostmapCellEvidence:
|
||||
"""Index-aligned with one immutable grid/state/material payload in a scene.
|
||||
|
||||
The enclosing scene binds epoch/clock/sequence and hashes this descriptor
|
||||
together with the grid outputs. No timestamp is manufactured for unseen
|
||||
cells; no coordinate transformation or observation is generated here.
|
||||
"""
|
||||
|
||||
observed_source_time_ns: tuple[int | None, ...]
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if (
|
||||
not isinstance(self.observed_source_time_ns, tuple)
|
||||
or not 1 <= len(self.observed_source_time_ns) <= MAXIMUM_CELLS
|
||||
):
|
||||
raise RealtimeContractError("costmap cell count outside bound")
|
||||
for stamp in self.observed_source_time_ns:
|
||||
if stamp is not None:
|
||||
_wire_integer(stamp)
|
||||
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"schema_version": CELL_FRESHNESS_SCHEMA,
|
||||
"observed_source_time_ns": [
|
||||
None if stamp is None else _wire_integer(stamp)
|
||||
for stamp in self.observed_source_time_ns
|
||||
],
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, value: object) -> CostmapCellEvidence:
|
||||
if (
|
||||
not isinstance(value, dict)
|
||||
or set(value) != {"schema_version", "observed_source_time_ns"}
|
||||
or value["schema_version"] != CELL_FRESHNESS_SCHEMA
|
||||
or not isinstance(value["observed_source_time_ns"], list)
|
||||
or not 1 <= len(value["observed_source_time_ns"]) <= MAXIMUM_CELLS
|
||||
):
|
||||
raise RealtimeContractError("costmap cell evidence schema or bound changed")
|
||||
return cls(
|
||||
tuple(
|
||||
None if stamp is None else _read_wire_integer(stamp)
|
||||
for stamp in value["observed_source_time_ns"]
|
||||
)
|
||||
)
|
||||
|
||||
def assess(
|
||||
self,
|
||||
*,
|
||||
states: tuple[int, ...],
|
||||
actions: tuple[int, ...],
|
||||
source_time_ns: int,
|
||||
observed_source_time_ns: int,
|
||||
maximum_age_ms: float,
|
||||
clock_uncertainty_ms: float,
|
||||
) -> CellAssessment:
|
||||
"""Produce a new view; repeated aging can never restore permissions.
|
||||
|
||||
The same mapped source clock as SceneFreshness must be used. Mandatory
|
||||
segmentation/pose/geometry freshness is still checked for the WHOLE scene
|
||||
separately. Passing individual cells cannot override missing inputs.
|
||||
"""
|
||||
_integer(source_time_ns, "source_time_ns")
|
||||
_integer(observed_source_time_ns, "observed_source_time_ns")
|
||||
_number(maximum_age_ms, "maximum_age_ms", minimum=0.001)
|
||||
_number(clock_uncertainty_ms, "clock_uncertainty_ms")
|
||||
if observed_source_time_ns < source_time_ns:
|
||||
raise RealtimeContractError("costmap assessment precedes source observation")
|
||||
if (
|
||||
not isinstance(states, tuple)
|
||||
or not isinstance(actions, tuple)
|
||||
or len(states) != len(actions)
|
||||
or len(states) != len(self.observed_source_time_ns)
|
||||
):
|
||||
raise RealtimeContractError("costmap arrays must share one bounded grid")
|
||||
output_states, output_actions, permissive_stamps = [], [], []
|
||||
expired = missing = suppressed = 0
|
||||
for state, action, stamp in zip(states, actions, self.observed_source_time_ns, strict=True):
|
||||
if type(state) is not int or not 0 <= state <= 3:
|
||||
raise RealtimeContractError("invalid TGS state")
|
||||
if type(action) is not int or not 0 <= action <= 2:
|
||||
raise RealtimeContractError("invalid advisory action")
|
||||
if state != 1 and action != 2:
|
||||
raise RealtimeContractError("non-ground cell attempted terrain permission")
|
||||
if stamp is not None and stamp > source_time_ns:
|
||||
raise RealtimeContractError("future cell observation")
|
||||
too_old = stamp is not None and (
|
||||
(observed_source_time_ns - stamp) / 1e6 + clock_uncertainty_ms > maximum_age_ms
|
||||
)
|
||||
# Existing occupied/unknown/rejected cells never become free. Only
|
||||
# positive ground evidence requires a current supporting observation.
|
||||
if state == 1 and (stamp is None or too_old):
|
||||
expired += int(too_old)
|
||||
missing += int(stamp is None)
|
||||
suppressed += int(action != 2)
|
||||
state, action = 3, 2
|
||||
if action != 2:
|
||||
assert stamp is not None # validated positive ground support above
|
||||
permissive_stamps.append(stamp)
|
||||
output_states.append(state)
|
||||
output_actions.append(action)
|
||||
return CellAssessment(
|
||||
tuple(output_states),
|
||||
tuple(output_actions),
|
||||
expired,
|
||||
missing,
|
||||
suppressed,
|
||||
min(permissive_stamps, default=source_time_ns),
|
||||
)
|
||||
@@ -79,6 +79,13 @@ def derive_layer(
|
||||
raise RealtimeContractError("derived layer dependencies changed")
|
||||
if any(x.epoch_id != epoch_id for x in inputs):
|
||||
raise RealtimeContractError("derived input belongs to another epoch")
|
||||
_integer(source_sequence, "source_sequence")
|
||||
_integer(source_time_ns, "source_time_ns")
|
||||
_integer(oldest_required_input_time_ns, "oldest_required_input_time_ns")
|
||||
if oldest_required_input_time_ns > source_time_ns or any(
|
||||
x.source_time_ns is not None and x.source_time_ns > source_time_ns for x in inputs
|
||||
):
|
||||
raise RealtimeContractError("derived layer references a future input")
|
||||
if any(x.state == "unavailable" for x in inputs):
|
||||
return LayerEvidence(layer, epoch_id, None, None, "unavailable", None)
|
||||
stamps = [oldest_required_input_time_ns]
|
||||
|
||||
Reference in New Issue
Block a user