feat(perception): measure M48S load envelope
This commit is contained in:
@@ -0,0 +1,89 @@
|
|||||||
|
{
|
||||||
|
"schema_version": "missioncore.m48s-load-envelope-profile/v1",
|
||||||
|
"profile_id": "m48s-rf-detr-reference-graph-load-envelope/v1",
|
||||||
|
"decision_question": "Can the unchanged single-pass RF-DETR reference graph sustain the recorded production rate and 20 percent reserve on Worker 006, and where does its bounded latest-wins limit begin?",
|
||||||
|
"hypothesis": "The hardened single-pass graph sustains 10 FPS and 12 FPS without unbounded queues, failed frames, a second inference pass, or more than the predeclared delivery loss; 15 FPS is measured only to locate the limit.",
|
||||||
|
"source": {
|
||||||
|
"source_id": "RAVNOVES00",
|
||||||
|
"source_profile_id": "m4-ravnoves00-recorded-realtime/v1",
|
||||||
|
"frame_count": 4489,
|
||||||
|
"graph_id": "reference-perception-graph/v2",
|
||||||
|
"detector_provider_id": "triton-rf-detr-large-coco-risk-fp16-shadow/v0",
|
||||||
|
"sensor_timestamps_preserved": true
|
||||||
|
},
|
||||||
|
"controlled_change": {
|
||||||
|
"only_variable": "wall_clock_source_rate_hz",
|
||||||
|
"unchanged": [
|
||||||
|
"source frames and source timestamps",
|
||||||
|
"RF-DETR TensorRT engine and threshold",
|
||||||
|
"geometry, temporal, motion, rolling-map and threat providers",
|
||||||
|
"latest-wins queue capacities",
|
||||||
|
"single inference pass per admitted detector frame",
|
||||||
|
"detector prewarm and cyclic-GC hot-loop policy"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"scenarios": [
|
||||||
|
{
|
||||||
|
"id": "production-10fps",
|
||||||
|
"run_id": "m48s-load-envelope-v1-production-10fps-a1",
|
||||||
|
"load_purpose": "production-rate",
|
||||||
|
"source_rate_hz": 10.0,
|
||||||
|
"thresholds": {
|
||||||
|
"minimum_delivery_ratio": 0.999,
|
||||||
|
"minimum_effective_world_state_fps": 9.5,
|
||||||
|
"maximum_world_state_completion_p95_ms": 125.0
|
||||||
|
},
|
||||||
|
"required_for_reserve_decision": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "reserve-12fps",
|
||||||
|
"run_id": "m48s-load-envelope-v1-reserve-12fps-a1",
|
||||||
|
"load_purpose": "reserve-gate",
|
||||||
|
"source_rate_hz": 12.0,
|
||||||
|
"thresholds": {
|
||||||
|
"minimum_delivery_ratio": 0.995,
|
||||||
|
"minimum_effective_world_state_fps": 11.4,
|
||||||
|
"maximum_world_state_completion_p95_ms": 150.0
|
||||||
|
},
|
||||||
|
"required_for_reserve_decision": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "limit-15fps",
|
||||||
|
"run_id": "m48s-load-envelope-v1-limit-15fps-a1",
|
||||||
|
"load_purpose": "limit-discovery",
|
||||||
|
"source_rate_hz": 15.0,
|
||||||
|
"thresholds": {
|
||||||
|
"minimum_delivery_ratio": 0.95,
|
||||||
|
"minimum_effective_world_state_fps": 14.25,
|
||||||
|
"maximum_world_state_completion_p95_ms": 175.0
|
||||||
|
},
|
||||||
|
"required_for_reserve_decision": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"common_integrity_gates": [
|
||||||
|
"closed terminal accounting",
|
||||||
|
"zero failed, stale, rejected or unavailable frames",
|
||||||
|
"all queue high-watermarks at or below capacity two",
|
||||||
|
"complete single-pass pipeline timing",
|
||||||
|
"detector prewarm before source admission",
|
||||||
|
"cyclic GC disabled only during the hot loop and restored after",
|
||||||
|
"all command, actuation, navigation and safety authority remains false"
|
||||||
|
],
|
||||||
|
"visual_evidence": {
|
||||||
|
"binding": "reuse exact M4.8S full camera plus LiDAR plus 3D/PLAN timeline",
|
||||||
|
"reason": "Only wall-clock pacing changes; source frames, sensor timestamps and graph semantics remain immutable.",
|
||||||
|
"independent_ground_truth": false
|
||||||
|
},
|
||||||
|
"exit_decision": {
|
||||||
|
"reserve_accepted_when": "Both production-10fps and reserve-12fps pass every integrity and operating target gate.",
|
||||||
|
"optimization_scope_when_rejected": "Optimize only the measured bottleneck stage; do not add another detector or inference pass.",
|
||||||
|
"limit_discovery_interpretation": "The 15 FPS result is valid evidence when integrity gates pass even if its operating target gate fails."
|
||||||
|
},
|
||||||
|
"authority": {
|
||||||
|
"candidate_accepted": false,
|
||||||
|
"commands_enabled": false,
|
||||||
|
"actuation_allowed": false,
|
||||||
|
"navigation_or_safety_accepted": false,
|
||||||
|
"production_accepted": false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -52,7 +52,7 @@ from k1link.perception.reference_graph_runtime import ReferenceGraphRuntimePaths
|
|||||||
from k1link.perception.rolling_map import RollingLocalObstacleMapProvider
|
from k1link.perception.rolling_map import RollingLocalObstacleMapProvider
|
||||||
from k1link.perception.temporal import BoundedSpatialTemporalProvider
|
from k1link.perception.temporal import BoundedSpatialTemporalProvider
|
||||||
|
|
||||||
SCHEMA_VERSION: Final = "missioncore.m48s-reference-graph-shadow-load/v3"
|
SCHEMA_VERSION: Final = "missioncore.m48s-reference-graph-shadow-load/v4"
|
||||||
FRAME_EVIDENCE_SCHEMA: Final = "missioncore.m48s-reference-graph-frame-evidence/v1"
|
FRAME_EVIDENCE_SCHEMA: Final = "missioncore.m48s-reference-graph-frame-evidence/v1"
|
||||||
PIPELINE_TIMING_SCHEMA: Final = "missioncore.m48s-frame-pipeline-timing/v0"
|
PIPELINE_TIMING_SCHEMA: Final = "missioncore.m48s-frame-pipeline-timing/v0"
|
||||||
GC_POLICY_SCHEMA: Final = "missioncore.cyclic-gc-hot-loop-policy/v0"
|
GC_POLICY_SCHEMA: Final = "missioncore.cyclic-gc-hot-loop-policy/v0"
|
||||||
@@ -63,6 +63,7 @@ AUTHORITY: Final = {
|
|||||||
"actuation_allowed": False,
|
"actuation_allowed": False,
|
||||||
"navigation_or_safety_accepted": False,
|
"navigation_or_safety_accepted": False,
|
||||||
}
|
}
|
||||||
|
LOAD_PURPOSES: Final = ("production-rate", "reserve-gate", "limit-discovery")
|
||||||
|
|
||||||
|
|
||||||
class GpuTelemetry:
|
class GpuTelemetry:
|
||||||
@@ -359,6 +360,13 @@ def main() -> int:
|
|||||||
parser.add_argument("--triton-origin", default="http://127.0.0.1:8000")
|
parser.add_argument("--triton-origin", default="http://127.0.0.1:8000")
|
||||||
parser.add_argument("--loops", type=int, default=1)
|
parser.add_argument("--loops", type=int, default=1)
|
||||||
parser.add_argument("--maximum-frames", type=int)
|
parser.add_argument("--maximum-frames", type=int)
|
||||||
|
parser.add_argument("--source-rate-hz", type=float)
|
||||||
|
parser.add_argument("--minimum-delivery-ratio", type=float, default=0.0)
|
||||||
|
parser.add_argument("--minimum-effective-world-state-fps", type=float, default=9.5)
|
||||||
|
parser.add_argument("--maximum-world-state-completion-p95-ms", type=float, default=175.0)
|
||||||
|
parser.add_argument("--load-purpose", choices=LOAD_PURPOSES, default="production-rate")
|
||||||
|
parser.add_argument("--runtime-artifact-sha256", required=True)
|
||||||
|
parser.add_argument("--runner-sha256", required=True)
|
||||||
parser.add_argument("--telemetry-interval-seconds", type=float, default=1.0)
|
parser.add_argument("--telemetry-interval-seconds", type=float, default=1.0)
|
||||||
parser.add_argument("--output", type=Path, required=True)
|
parser.add_argument("--output", type=Path, required=True)
|
||||||
parser.add_argument("--progress", type=Path, required=True)
|
parser.add_argument("--progress", type=Path, required=True)
|
||||||
@@ -370,6 +378,21 @@ def main() -> int:
|
|||||||
raise RuntimeError("maximum frame count must be positive")
|
raise RuntimeError("maximum frame count must be positive")
|
||||||
if arguments.telemetry_interval_seconds <= 0:
|
if arguments.telemetry_interval_seconds <= 0:
|
||||||
raise RuntimeError("telemetry interval must be positive")
|
raise RuntimeError("telemetry interval must be positive")
|
||||||
|
if arguments.source_rate_hz is not None and (
|
||||||
|
not np.isfinite(arguments.source_rate_hz) or arguments.source_rate_hz <= 0
|
||||||
|
):
|
||||||
|
raise RuntimeError("source rate must be positive and finite")
|
||||||
|
if not 0.0 <= arguments.minimum_delivery_ratio <= 1.0:
|
||||||
|
raise RuntimeError("minimum delivery ratio must be between zero and one")
|
||||||
|
if arguments.minimum_effective_world_state_fps <= 0:
|
||||||
|
raise RuntimeError("minimum effective world-state FPS must be positive")
|
||||||
|
if arguments.maximum_world_state_completion_p95_ms <= 0:
|
||||||
|
raise RuntimeError("maximum completion p95 must be positive")
|
||||||
|
for digest in (arguments.runtime_artifact_sha256, arguments.runner_sha256):
|
||||||
|
if len(digest) != 64 or any(character not in "0123456789abcdef" for character in digest):
|
||||||
|
raise RuntimeError("runtime and runner SHA-256 values must be lowercase hex")
|
||||||
|
if _sha256(Path(__file__)) != arguments.runner_sha256:
|
||||||
|
raise RuntimeError("runner SHA-256 changed")
|
||||||
output = arguments.output.absolute()
|
output = arguments.output.absolute()
|
||||||
progress = arguments.progress.absolute()
|
progress = arguments.progress.absolute()
|
||||||
frame_ledger = arguments.frame_ledger.absolute()
|
frame_ledger = arguments.frame_ledger.absolute()
|
||||||
@@ -426,6 +449,7 @@ def main() -> int:
|
|||||||
decode_timing_observer=timing_store.observe_decode,
|
decode_timing_observer=timing_store.observe_decode,
|
||||||
detector_timing_observer=timing_store.observe_detector,
|
detector_timing_observer=timing_store.observe_detector,
|
||||||
maximum_frames=arguments.maximum_frames,
|
maximum_frames=arguments.maximum_frames,
|
||||||
|
source_rate_hz=arguments.source_rate_hz,
|
||||||
) as runtime:
|
) as runtime:
|
||||||
for stage_id, attribute in (
|
for stage_id, attribute in (
|
||||||
("geometry", "geometry"),
|
("geometry", "geometry"),
|
||||||
@@ -546,7 +570,9 @@ def main() -> int:
|
|||||||
identity = _identity_metrics(all_deliveries)
|
identity = _identity_metrics(all_deliveries)
|
||||||
semantic = _semantic_metrics(all_deliveries, advisories)
|
semantic = _semantic_metrics(all_deliveries, advisories)
|
||||||
world_state_fps = delivered / processing_wall_seconds
|
world_state_fps = delivered / processing_wall_seconds
|
||||||
checks = {
|
delivery_ratio = delivered / admitted if admitted else 0.0
|
||||||
|
completion_distribution = _distribution(completion_ages_ms)
|
||||||
|
integrity_checks = {
|
||||||
"loop_count_completed": len(loop_documents) == arguments.loops,
|
"loop_count_completed": len(loop_documents) == arguments.loops,
|
||||||
"graph_stopped_cleanly": all(
|
"graph_stopped_cleanly": all(
|
||||||
loop["state"] == GraphState.STOPPED.value for loop in loop_documents
|
loop["state"] == GraphState.STOPPED.value for loop in loop_documents
|
||||||
@@ -561,10 +587,6 @@ def main() -> int:
|
|||||||
TerminalOutcomeType.UNAVAILABLE,
|
TerminalOutcomeType.UNAVAILABLE,
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
"minimum_world_state_fps": world_state_fps >= 9.5,
|
|
||||||
"maximum_world_state_completion_p95_ms": (
|
|
||||||
_distribution(completion_ages_ms)["p95"] <= 175.0
|
|
||||||
),
|
|
||||||
"bounded_latest_wins_queues": all(value <= 2 for value in queue_high_watermarks.values()),
|
"bounded_latest_wins_queues": all(value <= 2 for value in queue_high_watermarks.values()),
|
||||||
"temporal_identity_reuse_observed": cast(
|
"temporal_identity_reuse_observed": cast(
|
||||||
int,
|
int,
|
||||||
@@ -588,6 +610,19 @@ def main() -> int:
|
|||||||
),
|
),
|
||||||
"authority_remains_false": all(value is False for value in AUTHORITY.values()),
|
"authority_remains_false": all(value is False for value in AUTHORITY.values()),
|
||||||
}
|
}
|
||||||
|
operating_target_checks = {
|
||||||
|
"minimum_delivery_ratio": delivery_ratio >= arguments.minimum_delivery_ratio,
|
||||||
|
"minimum_world_state_fps": (
|
||||||
|
world_state_fps >= arguments.minimum_effective_world_state_fps
|
||||||
|
),
|
||||||
|
"maximum_world_state_completion_p95_ms": (
|
||||||
|
completion_distribution["p95"]
|
||||||
|
<= arguments.maximum_world_state_completion_p95_ms
|
||||||
|
),
|
||||||
|
}
|
||||||
|
checks = {**integrity_checks, **operating_target_checks}
|
||||||
|
evidence_integrity_gate_passed = all(integrity_checks.values())
|
||||||
|
operating_target_gate_passed = all(operating_target_checks.values())
|
||||||
integrated_runtime_gate_passed = all(checks.values())
|
integrated_runtime_gate_passed = all(checks.values())
|
||||||
document = {
|
document = {
|
||||||
"schema_version": SCHEMA_VERSION,
|
"schema_version": SCHEMA_VERSION,
|
||||||
@@ -595,20 +630,28 @@ def main() -> int:
|
|||||||
"source_id": "RAVNOVES00",
|
"source_id": "RAVNOVES00",
|
||||||
"loops": arguments.loops,
|
"loops": arguments.loops,
|
||||||
"maximum_frames_per_loop": arguments.maximum_frames,
|
"maximum_frames_per_loop": arguments.maximum_frames,
|
||||||
|
"requested_rate_hz": arguments.source_rate_hz,
|
||||||
|
"pacing_contract": "wall-clock-scaled-source-timestamps-immutable/v1",
|
||||||
},
|
},
|
||||||
"identity": {
|
"identity": {
|
||||||
"worker_id": "worker-006",
|
"worker_id": "worker-006",
|
||||||
"graph_id": "reference-perception-graph/v2",
|
"graph_id": "reference-perception-graph/v2",
|
||||||
"detector_provider_id": "triton-rf-detr-large-coco-risk-fp16-shadow/v0",
|
"detector_provider_id": "triton-rf-detr-large-coco-risk-fp16-shadow/v0",
|
||||||
"inputs": _input_digests(paths, arguments.detector_profile),
|
"inputs": _input_digests(paths, arguments.detector_profile),
|
||||||
|
"runtime_artifact_sha256": arguments.runtime_artifact_sha256,
|
||||||
|
"runner_sha256": arguments.runner_sha256,
|
||||||
},
|
},
|
||||||
"execution": {
|
"execution": {
|
||||||
"run_mode": GraphRunMode.SOURCE_PACED_LATEST_WINS.value,
|
"run_mode": GraphRunMode.SOURCE_PACED_LATEST_WINS.value,
|
||||||
|
"load_purpose": arguments.load_purpose,
|
||||||
|
"requested_source_rate_hz": arguments.source_rate_hz,
|
||||||
|
"source_timestamps_preserved": True,
|
||||||
"wall_seconds": round(wall_seconds, 6),
|
"wall_seconds": round(wall_seconds, 6),
|
||||||
"source_processing_wall_seconds": round(processing_wall_seconds, 6),
|
"source_processing_wall_seconds": round(processing_wall_seconds, 6),
|
||||||
"admitted_frames": admitted,
|
"admitted_frames": admitted,
|
||||||
"delivered_world_states": delivered,
|
"delivered_world_states": delivered,
|
||||||
"effective_world_state_fps": round(world_state_fps, 6),
|
"effective_world_state_fps": round(world_state_fps, 6),
|
||||||
|
"delivery_ratio": round(delivery_ratio, 9),
|
||||||
"terminal_outcomes": dict(sorted(accounting.items())),
|
"terminal_outcomes": dict(sorted(accounting.items())),
|
||||||
"queue_high_watermarks": queue_high_watermarks,
|
"queue_high_watermarks": queue_high_watermarks,
|
||||||
"loops": loop_documents,
|
"loops": loop_documents,
|
||||||
@@ -624,7 +667,7 @@ def main() -> int:
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
"metrics": {
|
"metrics": {
|
||||||
"world_state_completion_age_ms": _distribution(completion_ages_ms),
|
"world_state_completion_age_ms": completion_distribution,
|
||||||
"local_obstacle_map_output_age_ms": _distribution(map_output_ages_ms),
|
"local_obstacle_map_output_age_ms": _distribution(map_output_ages_ms),
|
||||||
"identity_continuity": identity,
|
"identity_continuity": identity,
|
||||||
"semantic_advisory": semantic,
|
"semantic_advisory": semantic,
|
||||||
@@ -635,6 +678,19 @@ def main() -> int:
|
|||||||
"process_peak_rss_after_mib": round(rss_after_kib / 1024.0, 6),
|
"process_peak_rss_after_mib": round(rss_after_kib / 1024.0, 6),
|
||||||
},
|
},
|
||||||
"checks": checks,
|
"checks": checks,
|
||||||
|
"integrity_checks": integrity_checks,
|
||||||
|
"operating_target_checks": operating_target_checks,
|
||||||
|
"predeclared_thresholds": {
|
||||||
|
"minimum_delivery_ratio": arguments.minimum_delivery_ratio,
|
||||||
|
"minimum_effective_world_state_fps": (
|
||||||
|
arguments.minimum_effective_world_state_fps
|
||||||
|
),
|
||||||
|
"maximum_world_state_completion_p95_ms": (
|
||||||
|
arguments.maximum_world_state_completion_p95_ms
|
||||||
|
),
|
||||||
|
},
|
||||||
|
"evidence_integrity_gate_passed": evidence_integrity_gate_passed,
|
||||||
|
"operating_target_gate_passed": operating_target_gate_passed,
|
||||||
"integrated_runtime_gate_passed": integrated_runtime_gate_passed,
|
"integrated_runtime_gate_passed": integrated_runtime_gate_passed,
|
||||||
"independent_track_identity_quality_evaluated": False,
|
"independent_track_identity_quality_evaluated": False,
|
||||||
"independent_risk_policy_quality_evaluated": False,
|
"independent_risk_policy_quality_evaluated": False,
|
||||||
@@ -647,7 +703,10 @@ def main() -> int:
|
|||||||
output.write_bytes(_canonical_json(document) + b"\n")
|
output.write_bytes(_canonical_json(document) + b"\n")
|
||||||
print(output)
|
print(output)
|
||||||
print(json.dumps(checks, indent=2, sort_keys=True))
|
print(json.dumps(checks, indent=2, sort_keys=True))
|
||||||
return 0 if integrated_runtime_gate_passed else 2
|
discovery_completed = (
|
||||||
|
arguments.load_purpose == "limit-discovery" and evidence_integrity_gate_passed
|
||||||
|
)
|
||||||
|
return 0 if integrated_runtime_gate_passed or discovery_completed else 2
|
||||||
|
|
||||||
|
|
||||||
def _record_completion_age(
|
def _record_completion_age(
|
||||||
|
|||||||
@@ -12,6 +12,16 @@ param(
|
|||||||
[int]$Loops = 1,
|
[int]$Loops = 1,
|
||||||
[ValidateRange(1, 4489)]
|
[ValidateRange(1, 4489)]
|
||||||
[int]$MaximumFrames = 120,
|
[int]$MaximumFrames = 120,
|
||||||
|
[ValidateRange(1.0, 120.0)]
|
||||||
|
[double]$SourceRateHz = 10.0,
|
||||||
|
[ValidateRange(0.0, 1.0)]
|
||||||
|
[double]$MinimumDeliveryRatio = 0.999,
|
||||||
|
[ValidateRange(0.1, 120.0)]
|
||||||
|
[double]$MinimumEffectiveWorldStateFps = 9.5,
|
||||||
|
[ValidateRange(1.0, 10000.0)]
|
||||||
|
[double]$MaximumWorldStateCompletionP95Ms = 125.0,
|
||||||
|
[ValidateSet("production-rate", "reserve-gate", "limit-discovery")]
|
||||||
|
[string]$LoadPurpose = "production-rate",
|
||||||
[string]$OutputRoot = "D:\NDC_MISSIONCORE\runtime\results\m48s-reference-graph-shadow"
|
[string]$OutputRoot = "D:\NDC_MISSIONCORE\runtime\results\m48s-reference-graph-shadow"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -103,6 +113,7 @@ $runner = Get-Item -LiteralPath (
|
|||||||
if ($runner.PSIsContainer -or ($runner.Attributes -band [IO.FileAttributes]::ReparsePoint)) {
|
if ($runner.PSIsContainer -or ($runner.Attributes -band [IO.FileAttributes]::ReparsePoint)) {
|
||||||
throw "M48S graph runner must be a regular file"
|
throw "M48S graph runner must be a regular file"
|
||||||
}
|
}
|
||||||
|
$runnerSha256 = Get-Sha256 $runner.FullName
|
||||||
|
|
||||||
$experimentRoot = Resolve-DDirectory (
|
$experimentRoot = Resolve-DDirectory (
|
||||||
"D:\NDC_MISSIONCORE\runtime\experiments\m48t-fixed-detector-20260825T095425Z"
|
"D:\NDC_MISSIONCORE\runtime\experiments\m48t-fixed-detector-20260825T095425Z"
|
||||||
@@ -253,6 +264,13 @@ try {
|
|||||||
"--valid-fov-mask", "/source/mask.png",
|
"--valid-fov-mask", "/source/mask.png",
|
||||||
"--triton-origin", "http://127.0.0.1:8000",
|
"--triton-origin", "http://127.0.0.1:8000",
|
||||||
"--loops", ([string]$Loops),
|
"--loops", ([string]$Loops),
|
||||||
|
"--source-rate-hz", ([string]::Format([Globalization.CultureInfo]::InvariantCulture, "{0:R}", $SourceRateHz)),
|
||||||
|
"--minimum-delivery-ratio", ([string]::Format([Globalization.CultureInfo]::InvariantCulture, "{0:R}", $MinimumDeliveryRatio)),
|
||||||
|
"--minimum-effective-world-state-fps", ([string]::Format([Globalization.CultureInfo]::InvariantCulture, "{0:R}", $MinimumEffectiveWorldStateFps)),
|
||||||
|
"--maximum-world-state-completion-p95-ms", ([string]::Format([Globalization.CultureInfo]::InvariantCulture, "{0:R}", $MaximumWorldStateCompletionP95Ms)),
|
||||||
|
"--load-purpose", $LoadPurpose,
|
||||||
|
"--runtime-artifact-sha256", $ExpectedWheelSha256,
|
||||||
|
"--runner-sha256", $runnerSha256,
|
||||||
"--output", "/output/result.json",
|
"--output", "/output/result.json",
|
||||||
"--progress", "/output/progress.jsonl",
|
"--progress", "/output/progress.jsonl",
|
||||||
"--frame-ledger", "/output/frames.jsonl"
|
"--frame-ledger", "/output/frames.jsonl"
|
||||||
|
|||||||
@@ -92,6 +92,7 @@ def build_m48s_reference_graph_runtime(
|
|||||||
decode_timing_observer: DecodeTimingObserver | None = None,
|
decode_timing_observer: DecodeTimingObserver | None = None,
|
||||||
detector_timing_observer: DetectorTimingObserver | None = None,
|
detector_timing_observer: DetectorTimingObserver | None = None,
|
||||||
maximum_frames: int | None = None,
|
maximum_frames: int | None = None,
|
||||||
|
source_rate_hz: float | None = None,
|
||||||
) -> M48sReferenceGraphRuntime:
|
) -> M48sReferenceGraphRuntime:
|
||||||
"""Instantiate the complete graph with only its detector pin replaced."""
|
"""Instantiate the complete graph with only its detector pin replaced."""
|
||||||
|
|
||||||
@@ -125,6 +126,11 @@ def build_m48s_reference_graph_runtime(
|
|||||||
if run_mode is GraphRunMode.SOURCE_PACED_LATEST_WINS
|
if run_mode is GraphRunMode.SOURCE_PACED_LATEST_WINS
|
||||||
else ReplayPacing.UNCAPPED
|
else ReplayPacing.UNCAPPED
|
||||||
),
|
),
|
||||||
|
target_rate_hz=(
|
||||||
|
source_rate_hz
|
||||||
|
if run_mode is GraphRunMode.SOURCE_PACED_LATEST_WINS
|
||||||
|
else None
|
||||||
|
),
|
||||||
),
|
),
|
||||||
decoder=PyAvRecordedImageDecoder(paths.video),
|
decoder=PyAvRecordedImageDecoder(paths.video),
|
||||||
timing_observer=decode_timing_observer,
|
timing_observer=decode_timing_observer,
|
||||||
|
|||||||
@@ -99,7 +99,11 @@ DecodeTimingObserver = Callable[[DecodedFrameTiming], None]
|
|||||||
|
|
||||||
|
|
||||||
class RecordedRavnoves00Source:
|
class RecordedRavnoves00Source:
|
||||||
"""Emit the admitted synchronized source timeline at 1.0x or uncapped speed."""
|
"""Emit the admitted timeline at its recorded or an explicit replay rate.
|
||||||
|
|
||||||
|
A target rate changes wall-clock pacing only. Immutable sensor timestamps stay
|
||||||
|
untouched so accelerated load measurements cannot silently change scene motion.
|
||||||
|
"""
|
||||||
|
|
||||||
provider_id: str = RECORDED_SOURCE_PROVIDER_ID
|
provider_id: str = RECORDED_SOURCE_PROVIDER_ID
|
||||||
|
|
||||||
@@ -111,16 +115,24 @@ class RecordedRavnoves00Source:
|
|||||||
pacing: ReplayPacing = ReplayPacing.UNCAPPED,
|
pacing: ReplayPacing = ReplayPacing.UNCAPPED,
|
||||||
expected_frame_count: int = DEFAULT_FRAME_COUNT,
|
expected_frame_count: int = DEFAULT_FRAME_COUNT,
|
||||||
expected_source_pack_sha256: str | None = RECORDED_SOURCE_PACK_SHA256,
|
expected_source_pack_sha256: str | None = RECORDED_SOURCE_PACK_SHA256,
|
||||||
|
target_rate_hz: float | None = None,
|
||||||
clock_ns: Callable[[], int] = time.monotonic_ns,
|
clock_ns: Callable[[], int] = time.monotonic_ns,
|
||||||
wait: WaitFunction | None = None,
|
wait: WaitFunction | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
if expected_frame_count < 1:
|
if expected_frame_count < 1:
|
||||||
raise RecordedSourceError("expected frame count must be positive")
|
raise RecordedSourceError("expected frame count must be positive")
|
||||||
|
if target_rate_hz is not None and (
|
||||||
|
not np.isfinite(target_rate_hz) or target_rate_hz <= 0
|
||||||
|
):
|
||||||
|
raise RecordedSourceError("target replay rate must be positive and finite")
|
||||||
|
if target_rate_hz is not None and pacing is not ReplayPacing.ONE_X:
|
||||||
|
raise RecordedSourceError("target replay rate requires paced replay")
|
||||||
self.camera_index_path = camera_index_path.resolve()
|
self.camera_index_path = camera_index_path.resolve()
|
||||||
self.source_pack_path = source_pack_path.resolve()
|
self.source_pack_path = source_pack_path.resolve()
|
||||||
self.pacing = pacing
|
self.pacing = pacing
|
||||||
self.expected_frame_count = expected_frame_count
|
self.expected_frame_count = expected_frame_count
|
||||||
self.expected_source_pack_sha256 = expected_source_pack_sha256
|
self.expected_source_pack_sha256 = expected_source_pack_sha256
|
||||||
|
self.target_rate_hz = target_rate_hz
|
||||||
self._clock_ns = clock_ns
|
self._clock_ns = clock_ns
|
||||||
self._wait = wait or _event_wait
|
self._wait = wait or _event_wait
|
||||||
|
|
||||||
@@ -130,6 +142,7 @@ class RecordedRavnoves00Source:
|
|||||||
repository_root: Path,
|
repository_root: Path,
|
||||||
*,
|
*,
|
||||||
pacing: ReplayPacing = ReplayPacing.UNCAPPED,
|
pacing: ReplayPacing = ReplayPacing.UNCAPPED,
|
||||||
|
target_rate_hz: float | None = None,
|
||||||
) -> RecordedRavnoves00Source:
|
) -> RecordedRavnoves00Source:
|
||||||
root = repository_root.resolve()
|
root = repository_root.resolve()
|
||||||
camera_index = (
|
camera_index = (
|
||||||
@@ -148,6 +161,7 @@ class RecordedRavnoves00Source:
|
|||||||
camera_index_path=camera_index,
|
camera_index_path=camera_index,
|
||||||
source_pack_path=source_pack,
|
source_pack_path=source_pack,
|
||||||
pacing=pacing,
|
pacing=pacing,
|
||||||
|
target_rate_hz=target_rate_hz,
|
||||||
)
|
)
|
||||||
|
|
||||||
def packets(self, stop_event: Event) -> Iterator[SourcePacket]:
|
def packets(self, stop_event: Event) -> Iterator[SourcePacket]:
|
||||||
@@ -163,6 +177,7 @@ class RecordedRavnoves00Source:
|
|||||||
|
|
||||||
started_ns = int(self._clock_ns())
|
started_ns = int(self._clock_ns())
|
||||||
source_origin_ns = _source_time_ns(timeline_rows[0])
|
source_origin_ns = _source_time_ns(timeline_rows[0])
|
||||||
|
pacing_scale = _pacing_scale(timeline_rows, self.target_rate_hz)
|
||||||
for frame_index, (camera, timeline) in enumerate(
|
for frame_index, (camera, timeline) in enumerate(
|
||||||
zip(camera_rows, timeline_rows, strict=True)
|
zip(camera_rows, timeline_rows, strict=True)
|
||||||
):
|
):
|
||||||
@@ -170,7 +185,8 @@ class RecordedRavnoves00Source:
|
|||||||
return
|
return
|
||||||
packet = _packet(frame_index, camera, timeline)
|
packet = _packet(frame_index, camera, timeline)
|
||||||
if self.pacing is ReplayPacing.ONE_X:
|
if self.pacing is ReplayPacing.ONE_X:
|
||||||
target_ns = started_ns + packet.envelope.timestamps.source_ns - source_origin_ns
|
source_elapsed_ns = packet.envelope.timestamps.source_ns - source_origin_ns
|
||||||
|
target_ns = started_ns + round(source_elapsed_ns * pacing_scale)
|
||||||
if not self._pace_until(stop_event, target_ns):
|
if not self._pace_until(stop_event, target_ns):
|
||||||
return
|
return
|
||||||
yield packet
|
yield packet
|
||||||
@@ -349,6 +365,21 @@ def _source_time_ns(document: _SourceTimelineRow) -> int:
|
|||||||
return round(value * 1_000_000_000)
|
return round(value * 1_000_000_000)
|
||||||
|
|
||||||
|
|
||||||
|
def _pacing_scale(
|
||||||
|
timeline: tuple[_SourceTimelineRow, ...],
|
||||||
|
target_rate_hz: float | None,
|
||||||
|
) -> float:
|
||||||
|
if target_rate_hz is None:
|
||||||
|
return 1.0
|
||||||
|
if len(timeline) < 2:
|
||||||
|
raise RecordedSourceError("target-rate replay requires at least two frames")
|
||||||
|
duration_seconds = timeline[-1].session_seconds - timeline[0].session_seconds
|
||||||
|
if not np.isfinite(duration_seconds) or duration_seconds <= 0:
|
||||||
|
raise RecordedSourceError("source timeline duration is invalid")
|
||||||
|
recorded_rate_hz = (len(timeline) - 1) / duration_seconds
|
||||||
|
return recorded_rate_hz / target_rate_hz
|
||||||
|
|
||||||
|
|
||||||
def _integer(document: dict[str, object], key: str) -> int:
|
def _integer(document: dict[str, object], key: str) -> int:
|
||||||
value = document.get(key)
|
value = document.get(key)
|
||||||
if not isinstance(value, int) or isinstance(value, bool) or value < 0:
|
if not isinstance(value, int) or isinstance(value, bool) or value < 0:
|
||||||
|
|||||||
@@ -835,6 +835,50 @@ def test_recorded_source_reuses_one_timeline_for_1x_and_uncapped(tmp_path: Path)
|
|||||||
assert one_x[1].registered_point_increment_payload is None
|
assert one_x[1].registered_point_increment_payload is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_recorded_source_target_rate_changes_only_wall_clock_pacing(tmp_path: Path) -> None:
|
||||||
|
camera_path, timeline_path = _write_recorded_fixture(tmp_path)
|
||||||
|
now = [1_000_000_000]
|
||||||
|
waits: list[float] = []
|
||||||
|
|
||||||
|
def wait(stop_event: Event, seconds: float) -> bool:
|
||||||
|
waits.append(seconds)
|
||||||
|
now[0] += round(seconds * 1_000_000_000)
|
||||||
|
return stop_event.is_set()
|
||||||
|
|
||||||
|
source = RecordedRavnoves00Source(
|
||||||
|
camera_index_path=camera_path,
|
||||||
|
source_pack_path=timeline_path,
|
||||||
|
pacing=ReplayPacing.ONE_X,
|
||||||
|
target_rate_hz=20.0,
|
||||||
|
expected_frame_count=2,
|
||||||
|
expected_source_pack_sha256=None,
|
||||||
|
clock_ns=lambda: now[0],
|
||||||
|
wait=wait,
|
||||||
|
)
|
||||||
|
|
||||||
|
packets = list(source.packets(Event()))
|
||||||
|
|
||||||
|
assert waits == pytest.approx([0.05])
|
||||||
|
assert (
|
||||||
|
packets[1].envelope.timestamps.source_ns
|
||||||
|
- packets[0].envelope.timestamps.source_ns
|
||||||
|
== 100_000_000
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_recorded_source_rejects_target_rate_for_uncapped_replay(tmp_path: Path) -> None:
|
||||||
|
camera_path, timeline_path = _write_recorded_fixture(tmp_path)
|
||||||
|
with pytest.raises(RecordedSourceError, match="requires paced replay"):
|
||||||
|
RecordedRavnoves00Source(
|
||||||
|
camera_index_path=camera_path,
|
||||||
|
source_pack_path=timeline_path,
|
||||||
|
pacing=ReplayPacing.UNCAPPED,
|
||||||
|
target_rate_hz=12.0,
|
||||||
|
expected_frame_count=2,
|
||||||
|
expected_source_pack_sha256=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_recorded_source_rejects_timeline_mismatch(tmp_path: Path) -> None:
|
def test_recorded_source_rejects_timeline_mismatch(tmp_path: Path) -> None:
|
||||||
camera_path, timeline_path = _write_recorded_fixture(tmp_path, mismatched=True)
|
camera_path, timeline_path = _write_recorded_fixture(tmp_path, mismatched=True)
|
||||||
source = RecordedRavnoves00Source(
|
source = RecordedRavnoves00Source(
|
||||||
|
|||||||
Reference in New Issue
Block a user