feat(simulation): add Worker AI polygon runtime and terrain navigation
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
@@ -8,6 +9,14 @@ from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
def canonical_json_sha256(value: object) -> str:
|
||||
"""Platform-independent identity for shared Core/Worker JSON contracts."""
|
||||
payload = json.dumps(
|
||||
value, ensure_ascii=False, allow_nan=False, separators=(",", ":"), sort_keys=True
|
||||
).encode("utf-8")
|
||||
return hashlib.sha256(payload).hexdigest()
|
||||
|
||||
|
||||
def utc_now_iso() -> str:
|
||||
"""Return a stable UTC timestamp for manifests and capture artifacts."""
|
||||
return datetime.now(UTC).isoformat(timespec="milliseconds").replace("+00:00", "Z")
|
||||
|
||||
@@ -1,33 +1,41 @@
|
||||
"""Observation-only laboratory orchestration contracts."""
|
||||
"""Observation contracts; platform-specific services are loaded only on demand.
|
||||
|
||||
from k1link.observatory.canonical_result import (
|
||||
is_admitted_observatory_recorded_result,
|
||||
)
|
||||
from k1link.observatory.run_preparations import (
|
||||
MAX_RUN_PREPARATION_RECORDS,
|
||||
MAX_RUN_PREPARATION_STORAGE_BYTES,
|
||||
OBSERVATORY_RUN_PREPARATION_REQUEST_SCHEMA,
|
||||
OBSERVATORY_RUN_PREPARATION_SCHEMA,
|
||||
RUN_PREPARATION_DATABASE_NAME,
|
||||
RUN_PREPARATION_SQLITE_LOCK_TIMEOUT_SECONDS,
|
||||
ObservatoryRunPreparation,
|
||||
ObservatoryRunPreparationCapacityError,
|
||||
ObservatoryRunPreparationConflictError,
|
||||
ObservatoryRunPreparationError,
|
||||
ObservatoryRunPreparationIntegrityError,
|
||||
ObservatoryRunPreparationIntent,
|
||||
ObservatoryRunPreparationLedger,
|
||||
ObservatoryRunPreparationNotFoundError,
|
||||
load_observatory_run_preparation_ledger,
|
||||
observatory_run_preparation_request_sha256,
|
||||
)
|
||||
from k1link.observatory.setups import (
|
||||
LABORATORY_SETUP_CATALOG_SCHEMA,
|
||||
LABORATORY_SETUP_REGISTRY_SCHEMA,
|
||||
OBSERVATORY_CALCULATION_PROFILE_SCHEMA,
|
||||
LaboratorySetupRegistry,
|
||||
LaboratorySetupRegistryError,
|
||||
)
|
||||
Importing a portable composition on Windows must not import Core's POSIX
|
||||
artifact gateway or eagerly initialize session/recording infrastructure.
|
||||
"""
|
||||
|
||||
from importlib import import_module
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from k1link.observatory.canonical_result import (
|
||||
is_admitted_observatory_recorded_result,
|
||||
)
|
||||
from k1link.observatory.run_preparations import (
|
||||
MAX_RUN_PREPARATION_RECORDS,
|
||||
MAX_RUN_PREPARATION_STORAGE_BYTES,
|
||||
OBSERVATORY_RUN_PREPARATION_REQUEST_SCHEMA,
|
||||
OBSERVATORY_RUN_PREPARATION_SCHEMA,
|
||||
RUN_PREPARATION_DATABASE_NAME,
|
||||
RUN_PREPARATION_SQLITE_LOCK_TIMEOUT_SECONDS,
|
||||
ObservatoryRunPreparation,
|
||||
ObservatoryRunPreparationCapacityError,
|
||||
ObservatoryRunPreparationConflictError,
|
||||
ObservatoryRunPreparationError,
|
||||
ObservatoryRunPreparationIntegrityError,
|
||||
ObservatoryRunPreparationIntent,
|
||||
ObservatoryRunPreparationLedger,
|
||||
ObservatoryRunPreparationNotFoundError,
|
||||
load_observatory_run_preparation_ledger,
|
||||
observatory_run_preparation_request_sha256,
|
||||
)
|
||||
from k1link.observatory.setups import (
|
||||
LABORATORY_SETUP_CATALOG_SCHEMA,
|
||||
LABORATORY_SETUP_REGISTRY_SCHEMA,
|
||||
OBSERVATORY_CALCULATION_PROFILE_SCHEMA,
|
||||
LaboratorySetupRegistry,
|
||||
LaboratorySetupRegistryError,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"LABORATORY_SETUP_CATALOG_SCHEMA",
|
||||
@@ -53,3 +61,41 @@ __all__ = [
|
||||
"load_observatory_run_preparation_ledger",
|
||||
"observatory_run_preparation_request_sha256",
|
||||
]
|
||||
|
||||
_EXPORTS = {
|
||||
"is_admitted_observatory_recorded_result": "k1link.observatory.canonical_result",
|
||||
"MAX_RUN_PREPARATION_RECORDS": "k1link.observatory.run_preparations",
|
||||
"MAX_RUN_PREPARATION_STORAGE_BYTES": "k1link.observatory.run_preparations",
|
||||
"OBSERVATORY_RUN_PREPARATION_REQUEST_SCHEMA": "k1link.observatory.run_preparations",
|
||||
"OBSERVATORY_RUN_PREPARATION_SCHEMA": "k1link.observatory.run_preparations",
|
||||
"RUN_PREPARATION_DATABASE_NAME": "k1link.observatory.run_preparations",
|
||||
"RUN_PREPARATION_SQLITE_LOCK_TIMEOUT_SECONDS": "k1link.observatory.run_preparations",
|
||||
"ObservatoryRunPreparation": "k1link.observatory.run_preparations",
|
||||
"ObservatoryRunPreparationCapacityError": "k1link.observatory.run_preparations",
|
||||
"ObservatoryRunPreparationConflictError": "k1link.observatory.run_preparations",
|
||||
"ObservatoryRunPreparationError": "k1link.observatory.run_preparations",
|
||||
"ObservatoryRunPreparationIntegrityError": "k1link.observatory.run_preparations",
|
||||
"ObservatoryRunPreparationIntent": "k1link.observatory.run_preparations",
|
||||
"ObservatoryRunPreparationLedger": "k1link.observatory.run_preparations",
|
||||
"ObservatoryRunPreparationNotFoundError": "k1link.observatory.run_preparations",
|
||||
"load_observatory_run_preparation_ledger": "k1link.observatory.run_preparations",
|
||||
"observatory_run_preparation_request_sha256": "k1link.observatory.run_preparations",
|
||||
"LABORATORY_SETUP_CATALOG_SCHEMA": "k1link.observatory.setups",
|
||||
"LABORATORY_SETUP_REGISTRY_SCHEMA": "k1link.observatory.setups",
|
||||
"OBSERVATORY_CALCULATION_PROFILE_SCHEMA": "k1link.observatory.setups",
|
||||
"LaboratorySetupRegistry": "k1link.observatory.setups",
|
||||
"LaboratorySetupRegistryError": "k1link.observatory.setups",
|
||||
}
|
||||
|
||||
|
||||
def __getattr__(name: str):
|
||||
module = _EXPORTS.get(name)
|
||||
if module is None:
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
value = getattr(import_module(module), name)
|
||||
globals()[name] = value
|
||||
return value
|
||||
|
||||
|
||||
def __dir__():
|
||||
return sorted(set(globals()) | set(__all__))
|
||||
|
||||
@@ -12,7 +12,7 @@ from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Final, cast
|
||||
|
||||
from k1link.observatory.portable_run_definitions import canonical_sha256
|
||||
from k1link.artifacts import canonical_json_sha256 as canonical_sha256
|
||||
|
||||
COMPOSITION_SCHEMA: Final = "missioncore.observatory-ai-composition/v1"
|
||||
MODULE_SCHEMA: Final = "missioncore.observatory-ai-module/v1"
|
||||
@@ -179,6 +179,11 @@ class CompositionSpec:
|
||||
"""Source-independent graph, in deterministic topological execution order."""
|
||||
|
||||
nodes: tuple[CompositionNode, ...]
|
||||
execution_mode: str = "recorded-observation-only"
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.execution_mode not in ("recorded-observation-only", "worker-local-simulation"):
|
||||
raise CompositionError("unsupported composition execution mode")
|
||||
|
||||
@property
|
||||
def source_capabilities(self) -> tuple[str, ...]:
|
||||
@@ -212,7 +217,10 @@ class CompositionSpec:
|
||||
"nodes": [node.as_dict() for node in self.nodes],
|
||||
"source_capabilities": list(self.source_capabilities),
|
||||
"outputs": list(self.outputs),
|
||||
"execution": {"max_parallel_nodes": 1, "mode": "recorded-observation-only"},
|
||||
"execution": {
|
||||
"max_parallel_nodes": 2 if self.execution_mode == "worker-local-simulation" else 1,
|
||||
"mode": self.execution_mode,
|
||||
},
|
||||
}
|
||||
|
||||
@property
|
||||
@@ -286,7 +294,9 @@ class ModuleRegistry:
|
||||
],
|
||||
}
|
||||
|
||||
def compose(self, document: object) -> CompositionSpec:
|
||||
def compose(
|
||||
self, document: object, *, execution_mode: str = "recorded-observation-only"
|
||||
) -> CompositionSpec:
|
||||
root = _object(document, {"schema_version", "selections"})
|
||||
if root["schema_version"] != COMPOSITION_SCHEMA:
|
||||
raise CompositionError("unsupported composition schema")
|
||||
@@ -381,7 +391,7 @@ class ModuleRegistry:
|
||||
for key in ready:
|
||||
ordered.append(pending.pop(key))
|
||||
emitted.add(key)
|
||||
return CompositionSpec(tuple(ordered))
|
||||
return CompositionSpec(tuple(ordered), execution_mode=execution_mode)
|
||||
|
||||
|
||||
def node_input_identity(
|
||||
|
||||
@@ -97,6 +97,11 @@ _AUTHORITY: Final = {
|
||||
}
|
||||
|
||||
_SCHEMA_SQL = """
|
||||
CREATE TABLE IF NOT EXISTS simulation_worker_reservation (
|
||||
singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
|
||||
owner_id TEXT NOT NULL,
|
||||
created_at_utc TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS observatory_recorded_jobs (
|
||||
job_id TEXT PRIMARY KEY,
|
||||
idempotency_key TEXT NOT NULL UNIQUE,
|
||||
@@ -1185,6 +1190,46 @@ class ObservatoryRecordedJobQueue:
|
||||
self._lock = threading.RLock()
|
||||
self._initialize()
|
||||
|
||||
def reserve_simulation(self, owner_id: str) -> None:
|
||||
"""Reserve this Worker's GPU atomically against recorded/live admission.
|
||||
|
||||
A lost heartbeat does not prove GPU release. This reservation survives
|
||||
restarts and is removed only by the exact simulator's release receipt.
|
||||
"""
|
||||
if not re.fullmatch(r"airun-[a-f0-9]{32}", owner_id):
|
||||
raise ValueError("invalid simulation resource owner")
|
||||
with self._transaction() as connection:
|
||||
existing = connection.execute(
|
||||
"SELECT owner_id FROM simulation_worker_reservation"
|
||||
).fetchone()
|
||||
if existing is not None:
|
||||
if existing["owner_id"] == owner_id:
|
||||
return
|
||||
raise ObservatoryRecordedQueueBusyError("Worker занят другим прогоном симуляции.")
|
||||
busy = connection.execute(
|
||||
"SELECT job_id FROM observatory_recorded_jobs "
|
||||
"WHERE state IN ('claimed', 'running', 'paused', 'preemption-pending', "
|
||||
"'reconciliation-required') LIMIT 1"
|
||||
).fetchone()
|
||||
if busy is not None or self._open_live_lease_row(connection) is not None:
|
||||
raise ObservatoryRecordedQueueBusyError("Worker занят задачей AI Inference.")
|
||||
connection.execute(
|
||||
"INSERT INTO simulation_worker_reservation VALUES (1, ?, ?)",
|
||||
(owner_id, self._timestamp()),
|
||||
)
|
||||
|
||||
def release_simulation(self, owner_id: str) -> None:
|
||||
"""Called only after the trusted simulator reports all GPU work stopped."""
|
||||
with self._transaction() as connection:
|
||||
existing = connection.execute(
|
||||
"SELECT owner_id FROM simulation_worker_reservation"
|
||||
).fetchone()
|
||||
if existing is not None and existing["owner_id"] != owner_id:
|
||||
raise ObservatoryRecordedQueueConflictError("simulation resource owner changed")
|
||||
connection.execute(
|
||||
"DELETE FROM simulation_worker_reservation WHERE owner_id = ?", (owner_id,)
|
||||
)
|
||||
|
||||
def resolve_definition(
|
||||
self,
|
||||
setup_id: str,
|
||||
@@ -1444,7 +1489,10 @@ class ObservatoryRecordedJobQueue:
|
||||
label="legacy claim receipt",
|
||||
)
|
||||
row = None
|
||||
if self._open_live_lease_row(connection) is None:
|
||||
simulation = connection.execute(
|
||||
"SELECT owner_id FROM simulation_worker_reservation"
|
||||
).fetchone()
|
||||
if self._open_live_lease_row(connection) is None and simulation is None:
|
||||
active_owner = connection.execute(
|
||||
"SELECT job_id FROM observatory_recorded_jobs "
|
||||
"WHERE state IN ('claimed', 'running', 'preemption-pending', "
|
||||
@@ -2382,6 +2430,10 @@ class ObservatoryRecordedJobQueue:
|
||||
_validate_pattern(lease_id, _LEASE_ID, "live lease id")
|
||||
self.recover_stale_claims()
|
||||
with self._transaction() as connection:
|
||||
if connection.execute("SELECT owner_id FROM simulation_worker_reservation").fetchone():
|
||||
raise ObservatoryRecordedQueueBusyError(
|
||||
"simulation has not released the Worker GPU"
|
||||
)
|
||||
lease = self._get_live_lease(connection, lease_id)
|
||||
if lease.state == "active":
|
||||
return lease
|
||||
|
||||
@@ -1,78 +1,86 @@
|
||||
"""Mission Core qualification and simulation boundaries."""
|
||||
"""Mission Core simulation contracts, with platform services loaded on demand.
|
||||
|
||||
from k1link.simulation.contracts import (
|
||||
AckermannControlSetpoint,
|
||||
AuthorityProfile,
|
||||
CommandAuthorityScope,
|
||||
ControlProfile,
|
||||
ControlSetpoint,
|
||||
DifferentialControlSetpoint,
|
||||
ProviderPin,
|
||||
QualificationArtifact,
|
||||
QualificationEvent,
|
||||
QualificationRun,
|
||||
ReproducibilityTier,
|
||||
RunKind,
|
||||
RunState,
|
||||
SimulationContractError,
|
||||
)
|
||||
from k1link.simulation.orchestrator import (
|
||||
ActiveQualificationRunError,
|
||||
SimulationApplicationService,
|
||||
SimulationOrchestratorError,
|
||||
SimulationWorkerPort,
|
||||
WorkerStartResult,
|
||||
WorkerStopResult,
|
||||
)
|
||||
from k1link.simulation.process_supervisor import (
|
||||
OwnedProcess,
|
||||
PosixProcessSupervisor,
|
||||
ProcessSpec,
|
||||
ProcessStopResult,
|
||||
ProcessSupervisorError,
|
||||
)
|
||||
from k1link.simulation.provider_contract import (
|
||||
PROVIDER_PROFILE_SCHEMA,
|
||||
ProviderRole,
|
||||
SimulationClockDescriptor,
|
||||
SimulationProviderContractError,
|
||||
SimulationProviderDescriptor,
|
||||
SimulationProviderProfile,
|
||||
)
|
||||
from k1link.simulation.run_store import (
|
||||
QualificationRunConflictError,
|
||||
QualificationRunIntegrityError,
|
||||
QualificationRunNotFoundError,
|
||||
QualificationRunStore,
|
||||
QualificationRunStoreError,
|
||||
QualificationRunTransitionError,
|
||||
)
|
||||
from k1link.simulation.s0 import (
|
||||
CheckStatus,
|
||||
DoctorVerdict,
|
||||
RuntimeAcceptance,
|
||||
S0DoctorReport,
|
||||
S0Profile,
|
||||
S0ProfileError,
|
||||
load_s0_profile,
|
||||
run_s0_doctor,
|
||||
)
|
||||
from k1link.simulation.stock_rover import (
|
||||
LIFECYCLE_PROFILE_SCHEMA,
|
||||
StockRoverLifecycleProfile,
|
||||
StockRoverProfileError,
|
||||
StockRoverTargetPaths,
|
||||
load_stock_rover_lifecycle_profile,
|
||||
stock_rover_process_environment,
|
||||
stock_rover_process_specs,
|
||||
)
|
||||
from k1link.simulation.worker import (
|
||||
LocalProcessWorkerAdapter,
|
||||
S0WorkerGuard,
|
||||
SimulationWorldControl,
|
||||
WorkerAdmission,
|
||||
WorkerAdmissionError,
|
||||
)
|
||||
Portable AI compositions do not require the legacy S0/YAML or POSIX process
|
||||
supervisor when loaded by the Windows Worker coordinator.
|
||||
"""
|
||||
|
||||
from importlib import import_module
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from k1link.simulation.contracts import (
|
||||
AckermannControlSetpoint,
|
||||
AuthorityProfile,
|
||||
CommandAuthorityScope,
|
||||
ControlProfile,
|
||||
ControlSetpoint,
|
||||
DifferentialControlSetpoint,
|
||||
ProviderPin,
|
||||
QualificationArtifact,
|
||||
QualificationEvent,
|
||||
QualificationRun,
|
||||
ReproducibilityTier,
|
||||
RunKind,
|
||||
RunState,
|
||||
SimulationContractError,
|
||||
)
|
||||
from k1link.simulation.orchestrator import (
|
||||
ActiveQualificationRunError,
|
||||
SimulationApplicationService,
|
||||
SimulationOrchestratorError,
|
||||
SimulationWorkerPort,
|
||||
WorkerStartResult,
|
||||
WorkerStopResult,
|
||||
)
|
||||
from k1link.simulation.process_supervisor import (
|
||||
OwnedProcess,
|
||||
PosixProcessSupervisor,
|
||||
ProcessSpec,
|
||||
ProcessStopResult,
|
||||
ProcessSupervisorError,
|
||||
)
|
||||
from k1link.simulation.provider_contract import (
|
||||
PROVIDER_PROFILE_SCHEMA,
|
||||
ProviderRole,
|
||||
SimulationClockDescriptor,
|
||||
SimulationProviderContractError,
|
||||
SimulationProviderDescriptor,
|
||||
SimulationProviderProfile,
|
||||
)
|
||||
from k1link.simulation.run_store import (
|
||||
QualificationRunConflictError,
|
||||
QualificationRunIntegrityError,
|
||||
QualificationRunNotFoundError,
|
||||
QualificationRunStore,
|
||||
QualificationRunStoreError,
|
||||
QualificationRunTransitionError,
|
||||
)
|
||||
from k1link.simulation.s0 import (
|
||||
CheckStatus,
|
||||
DoctorVerdict,
|
||||
RuntimeAcceptance,
|
||||
S0DoctorReport,
|
||||
S0Profile,
|
||||
S0ProfileError,
|
||||
load_s0_profile,
|
||||
run_s0_doctor,
|
||||
)
|
||||
from k1link.simulation.stock_rover import (
|
||||
LIFECYCLE_PROFILE_SCHEMA,
|
||||
StockRoverLifecycleProfile,
|
||||
StockRoverProfileError,
|
||||
StockRoverTargetPaths,
|
||||
load_stock_rover_lifecycle_profile,
|
||||
stock_rover_process_environment,
|
||||
stock_rover_process_specs,
|
||||
)
|
||||
from k1link.simulation.worker import (
|
||||
LocalProcessWorkerAdapter,
|
||||
S0WorkerGuard,
|
||||
SimulationWorldControl,
|
||||
WorkerAdmission,
|
||||
WorkerAdmissionError,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"AckermannControlSetpoint",
|
||||
@@ -133,3 +141,76 @@ __all__ = [
|
||||
"stock_rover_process_environment",
|
||||
"stock_rover_process_specs",
|
||||
]
|
||||
|
||||
_EXPORTS = {
|
||||
"AckermannControlSetpoint": "k1link.simulation.contracts",
|
||||
"AuthorityProfile": "k1link.simulation.contracts",
|
||||
"CommandAuthorityScope": "k1link.simulation.contracts",
|
||||
"ControlProfile": "k1link.simulation.contracts",
|
||||
"ControlSetpoint": "k1link.simulation.contracts",
|
||||
"DifferentialControlSetpoint": "k1link.simulation.contracts",
|
||||
"ProviderPin": "k1link.simulation.contracts",
|
||||
"QualificationArtifact": "k1link.simulation.contracts",
|
||||
"QualificationEvent": "k1link.simulation.contracts",
|
||||
"QualificationRun": "k1link.simulation.contracts",
|
||||
"ReproducibilityTier": "k1link.simulation.contracts",
|
||||
"RunKind": "k1link.simulation.contracts",
|
||||
"RunState": "k1link.simulation.contracts",
|
||||
"SimulationContractError": "k1link.simulation.contracts",
|
||||
"ActiveQualificationRunError": "k1link.simulation.orchestrator",
|
||||
"SimulationApplicationService": "k1link.simulation.orchestrator",
|
||||
"SimulationOrchestratorError": "k1link.simulation.orchestrator",
|
||||
"SimulationWorkerPort": "k1link.simulation.orchestrator",
|
||||
"WorkerStartResult": "k1link.simulation.orchestrator",
|
||||
"WorkerStopResult": "k1link.simulation.orchestrator",
|
||||
"OwnedProcess": "k1link.simulation.process_supervisor",
|
||||
"PosixProcessSupervisor": "k1link.simulation.process_supervisor",
|
||||
"ProcessSpec": "k1link.simulation.process_supervisor",
|
||||
"ProcessStopResult": "k1link.simulation.process_supervisor",
|
||||
"ProcessSupervisorError": "k1link.simulation.process_supervisor",
|
||||
"PROVIDER_PROFILE_SCHEMA": "k1link.simulation.provider_contract",
|
||||
"ProviderRole": "k1link.simulation.provider_contract",
|
||||
"SimulationClockDescriptor": "k1link.simulation.provider_contract",
|
||||
"SimulationProviderContractError": "k1link.simulation.provider_contract",
|
||||
"SimulationProviderDescriptor": "k1link.simulation.provider_contract",
|
||||
"SimulationProviderProfile": "k1link.simulation.provider_contract",
|
||||
"QualificationRunConflictError": "k1link.simulation.run_store",
|
||||
"QualificationRunIntegrityError": "k1link.simulation.run_store",
|
||||
"QualificationRunNotFoundError": "k1link.simulation.run_store",
|
||||
"QualificationRunStore": "k1link.simulation.run_store",
|
||||
"QualificationRunStoreError": "k1link.simulation.run_store",
|
||||
"QualificationRunTransitionError": "k1link.simulation.run_store",
|
||||
"CheckStatus": "k1link.simulation.s0",
|
||||
"DoctorVerdict": "k1link.simulation.s0",
|
||||
"RuntimeAcceptance": "k1link.simulation.s0",
|
||||
"S0DoctorReport": "k1link.simulation.s0",
|
||||
"S0Profile": "k1link.simulation.s0",
|
||||
"S0ProfileError": "k1link.simulation.s0",
|
||||
"load_s0_profile": "k1link.simulation.s0",
|
||||
"run_s0_doctor": "k1link.simulation.s0",
|
||||
"LIFECYCLE_PROFILE_SCHEMA": "k1link.simulation.stock_rover",
|
||||
"StockRoverLifecycleProfile": "k1link.simulation.stock_rover",
|
||||
"StockRoverProfileError": "k1link.simulation.stock_rover",
|
||||
"StockRoverTargetPaths": "k1link.simulation.stock_rover",
|
||||
"load_stock_rover_lifecycle_profile": "k1link.simulation.stock_rover",
|
||||
"stock_rover_process_environment": "k1link.simulation.stock_rover",
|
||||
"stock_rover_process_specs": "k1link.simulation.stock_rover",
|
||||
"LocalProcessWorkerAdapter": "k1link.simulation.worker",
|
||||
"S0WorkerGuard": "k1link.simulation.worker",
|
||||
"SimulationWorldControl": "k1link.simulation.worker",
|
||||
"WorkerAdmission": "k1link.simulation.worker",
|
||||
"WorkerAdmissionError": "k1link.simulation.worker",
|
||||
}
|
||||
|
||||
|
||||
def __getattr__(name: str):
|
||||
module = _EXPORTS.get(name)
|
||||
if module is None:
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
value = getattr(import_module(module), name)
|
||||
globals()[name] = value
|
||||
return value
|
||||
|
||||
|
||||
def __dir__():
|
||||
return sorted(set(globals()) | set(__all__))
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Independent, virtual-only AI polygon; never routes assets through the LCC pipeline."""
|
||||
@@ -0,0 +1,194 @@
|
||||
"""Simulation providers use the same immutable typed composition as AI Inference.
|
||||
|
||||
Pinhole camera contracts are intentionally distinct from recorded K1/KB4 ports.
|
||||
Only installed adapters are executable; selection JSON never carries code.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from k1link.observatory.modular_composition import (
|
||||
COMPOSITION_SCHEMA,
|
||||
CompositionError,
|
||||
ModuleRegistry,
|
||||
ModuleSpec,
|
||||
canonical_bytes,
|
||||
)
|
||||
|
||||
|
||||
def digest(value):
|
||||
return hashlib.sha256(canonical_bytes(value)).hexdigest()
|
||||
|
||||
|
||||
def registry(adapter_root: Path) -> ModuleRegistry:
|
||||
profile = json.loads((adapter_root / "models.worker-006.json").read_text())
|
||||
models = {m["id"]: m for m in profile["models"]}
|
||||
implementation = hashlib.sha256(
|
||||
Path(__file__).with_name("inference.py").read_bytes()
|
||||
).hexdigest()
|
||||
ddr, detector = models["ddrnet-goose-pytorch-reference"], models["rf_detr_large"]
|
||||
nav = profile["navigation"]
|
||||
ade = models["segformer-b2-ade150"]
|
||||
return ModuleRegistry(
|
||||
(
|
||||
ModuleSpec(
|
||||
"simulation-ddrnet-goose",
|
||||
"DDRNet · GOOSE 64",
|
||||
"segmentation",
|
||||
ddr["image"].removeprefix("sha256:"),
|
||||
implementation,
|
||||
ddr["checkpoint_sha256"],
|
||||
digest(
|
||||
{
|
||||
"camera": profile["camera"],
|
||||
"preprocess": ddr["preprocess"],
|
||||
"labels": profile["labels_sha256"],
|
||||
}
|
||||
),
|
||||
("source.camera.rgb",),
|
||||
("segmentation.labels", "segmentation.surface"),
|
||||
),
|
||||
ModuleSpec(
|
||||
"simulation-segformer-ade",
|
||||
"SegFormer · природные поверхности",
|
||||
"segmentation",
|
||||
ade["image"].removeprefix("sha256:"),
|
||||
digest(
|
||||
{
|
||||
"client": implementation,
|
||||
"server": hashlib.sha256(
|
||||
(adapter_root / "segformer/server.py").read_bytes()
|
||||
).hexdigest(),
|
||||
}
|
||||
),
|
||||
ade["checkpoint_sha256"],
|
||||
digest(
|
||||
{
|
||||
"camera": profile["camera"],
|
||||
"preprocess": ade["preprocess"],
|
||||
"config": ade["config_sha256"],
|
||||
"processor": ade["processor_sha256"],
|
||||
"output": "ade150-labels-and-bool-surface-candidate-square512-v1",
|
||||
}
|
||||
),
|
||||
("source.camera.rgb",),
|
||||
("segmentation.labels", "segmentation.surface"),
|
||||
),
|
||||
ModuleSpec(
|
||||
"simulation-rf-detr",
|
||||
"RF-DETR · люди и животные",
|
||||
"detection",
|
||||
detector["image"].removeprefix("sha256:"),
|
||||
implementation,
|
||||
detector["checkpoint_sha256"],
|
||||
digest(
|
||||
{
|
||||
"camera": profile["camera"],
|
||||
"preprocess": detector["preprocess"],
|
||||
"output": "normalized-xyxy-risk-boxes-v1",
|
||||
}
|
||||
),
|
||||
("source.camera.rgb",),
|
||||
("detection.boxes",),
|
||||
),
|
||||
ModuleSpec(
|
||||
"simulation-waypoint-mission",
|
||||
"Маршрут · контроль продвижения",
|
||||
"policy",
|
||||
nav["image"].removeprefix("sha256:"),
|
||||
digest(
|
||||
{
|
||||
"policy": hashlib.sha256(
|
||||
Path(__file__).with_name("mission_policy.py").read_bytes()
|
||||
).hexdigest(),
|
||||
"adapter": hashlib.sha256(
|
||||
(adapter_root / "navigation_client.py").read_bytes()
|
||||
).hexdigest(),
|
||||
}
|
||||
),
|
||||
None,
|
||||
digest(
|
||||
{
|
||||
"task": "operator-metric-waypoints-v1",
|
||||
"recovery": "three-observed-reverse-and-replan-attempts-v2",
|
||||
}
|
||||
),
|
||||
(
|
||||
"segmentation.surface",
|
||||
"source.camera.calibration",
|
||||
"source.lidar",
|
||||
"source.pose",
|
||||
"source.simulation-time",
|
||||
),
|
||||
("navigation.goal", "navigation.intent"),
|
||||
state_policy="causal-reset-at-source-start",
|
||||
),
|
||||
ModuleSpec(
|
||||
"simulation-cmu-navigation",
|
||||
"CMU · рельеф и движение",
|
||||
"motion",
|
||||
nav["image"].removeprefix("sha256:"),
|
||||
digest(
|
||||
{
|
||||
p.relative_to(adapter_root).as_posix(): hashlib.sha256(
|
||||
p.read_bytes()
|
||||
).hexdigest()
|
||||
for p in (
|
||||
adapter_root / "navigation/server.py",
|
||||
adapter_root / "navigation/footprint.py",
|
||||
adapter_root / "navigation/terrain_costs.py",
|
||||
adapter_root / "navigation/terrain_connectivity.cpp",
|
||||
adapter_root / "navigation/fastdds.xml",
|
||||
adapter_root / "navigation_client.py",
|
||||
)
|
||||
}
|
||||
),
|
||||
None,
|
||||
digest(
|
||||
{
|
||||
"upstream": nav["upstream_commit"],
|
||||
"footprint": [1, 1],
|
||||
"inputs": "metric-occluded-range-and-rgb",
|
||||
"command": "signed-mps-rps-observed-recovery-v2",
|
||||
}
|
||||
),
|
||||
(
|
||||
"detection.boxes",
|
||||
"navigation.goal",
|
||||
"navigation.intent",
|
||||
"segmentation.surface",
|
||||
"source.camera.calibration",
|
||||
"source.lidar",
|
||||
"source.pose",
|
||||
),
|
||||
("motion.command", "motion.path"),
|
||||
state_policy="causal-reset-at-source-start",
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def compose(adapter_root: Path, selection=None):
|
||||
installed = registry(adapter_root)
|
||||
if selection is None:
|
||||
defaults = json.loads((adapter_root / "models.worker-006.json").read_text())[
|
||||
"default_modules"
|
||||
]
|
||||
selection = {
|
||||
"schema_version": COMPOSITION_SCHEMA,
|
||||
"selections": [
|
||||
{
|
||||
"group": m.group,
|
||||
"module_id": m.module_id,
|
||||
"module_sha256": m.sha256,
|
||||
"parameters": {},
|
||||
}
|
||||
for m in installed.modules
|
||||
if m.module_id in defaults
|
||||
],
|
||||
}
|
||||
result = installed.compose(selection, execution_mode="worker-local-simulation")
|
||||
if "motion.command" not in result.outputs:
|
||||
raise CompositionError("Для движения выберите модуль навигации и его зависимости.")
|
||||
return result
|
||||
@@ -0,0 +1,194 @@
|
||||
"""Versioned scene preparation and camera-driven run contracts."""
|
||||
|
||||
from ipaddress import ip_address, ip_network
|
||||
from typing import Annotated, Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, HttpUrl, field_validator
|
||||
|
||||
|
||||
class Contract(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", allow_inf_nan=False, str_strip_whitespace=True)
|
||||
|
||||
|
||||
class WorldCreate(Contract):
|
||||
name: str = Field(min_length=1, max_length=120)
|
||||
filename: str = Field(pattern=r"^[^/\\\x00-\x1f]{1,200}\.[pP][lL][yY]$")
|
||||
byte_length: int = Field(gt=0, le=8 * 1024**3, strict=True)
|
||||
author: str = Field(min_length=1, max_length=160)
|
||||
license: str = Field(min_length=1, max_length=160)
|
||||
source_url: HttpUrl | None = None
|
||||
|
||||
|
||||
class WorldSettings(Contract):
|
||||
# Source -> metric Z-up world; collision proxy is prepared separately.
|
||||
meters_per_unit: float = Field(default=1, ge=0.0001, le=1000)
|
||||
rotation_degrees: tuple[
|
||||
Annotated[float, Field(ge=-360, le=360)],
|
||||
Annotated[float, Field(ge=-360, le=360)],
|
||||
Annotated[float, Field(ge=-360, le=360)],
|
||||
] = (0, 0, 0)
|
||||
ground_z: float = Field(default=0, ge=-10000, le=10000)
|
||||
spawn_xy: tuple[
|
||||
Annotated[float, Field(ge=-10000, le=10000)], Annotated[float, Field(ge=-10000, le=10000)]
|
||||
] = (0, 0)
|
||||
heading_degrees: float = Field(default=0, ge=-360, le=360)
|
||||
camera_height_m: float = Field(default=0.5, ge=0.1, le=3)
|
||||
max_speed_mps: float = Field(default=0.3, ge=0.05, le=1)
|
||||
prepared: bool = False
|
||||
route_xy: list[
|
||||
tuple[
|
||||
Annotated[float, Field(ge=-10000, le=10000)],
|
||||
Annotated[float, Field(ge=-10000, le=10000)],
|
||||
]
|
||||
] = Field(default_factory=list, max_length=32)
|
||||
|
||||
|
||||
class RunCreate(Contract):
|
||||
world_id: str = Field(pattern=r"^aiworld-[a-f0-9]{32}$")
|
||||
max_steps: int = Field(default=600, ge=1, le=3600, strict=True)
|
||||
clock: Literal["lockstep", "realtime"] = "lockstep"
|
||||
start_paused: bool = False
|
||||
duration_seconds: int = Field(default=1800, ge=10, le=7200, strict=True)
|
||||
composition: dict | None = None
|
||||
|
||||
|
||||
class WorkerWorldCreate(WorldCreate):
|
||||
"""Attestation from the connected Worker after validating its local files."""
|
||||
|
||||
sha256: str = Field(pattern=r"^[a-f0-9]{64}$")
|
||||
collider_sha256: str = Field(pattern=r"^[a-f0-9]{64}$")
|
||||
splat_count: int = Field(ge=1, le=20_000_000, strict=True)
|
||||
settings: WorldSettings
|
||||
|
||||
|
||||
class StreamEndpoint(Contract):
|
||||
server: str
|
||||
signaling_port: Literal[49100] = 49100
|
||||
media_port: Literal[47998] = 47998
|
||||
width: Literal[1280] = 1280
|
||||
height: Literal[720] = 720
|
||||
fps: Literal[30] = 30
|
||||
|
||||
@field_validator("server")
|
||||
@classmethod
|
||||
def private_address(cls, value: str) -> str:
|
||||
address = ip_address(value)
|
||||
if (
|
||||
address.version != 4
|
||||
or not (address.is_private or address in ip_network("100.64.0.0/10"))
|
||||
or address.is_unspecified
|
||||
or address.is_multicast
|
||||
):
|
||||
raise ValueError("Streaming requires a private IPv4 address")
|
||||
return str(address)
|
||||
|
||||
|
||||
class WorkerHello(Contract):
|
||||
worker_id: str = Field(pattern=r"^[a-zA-Z0-9_-]{1,80}$")
|
||||
instance_id: str = Field(pattern=r"^[a-f0-9]{32}$")
|
||||
runtime: Literal["isaac-sim-6.1"]
|
||||
model_ids: list[Annotated[str, Field(min_length=1, max_length=100)]] = Field(
|
||||
min_length=1, max_length=8
|
||||
)
|
||||
runtime_sources: dict[
|
||||
Literal["worker", "scene", "models", "robot"],
|
||||
Annotated[str, Field(pattern=r"^[a-f0-9]{64}$")],
|
||||
] = Field(min_length=4, max_length=4)
|
||||
profile_sha256: str = Field(pattern=r"^[a-f0-9]{64}$")
|
||||
execution_modes: list[Literal["lockstep", "realtime"]] = ["lockstep"]
|
||||
stream: StreamEndpoint | None = None
|
||||
|
||||
|
||||
class Decision(Contract):
|
||||
speed_mps: float = Field(ge=-1, le=1)
|
||||
yaw_rate_rps: float = Field(ge=-1, le=1)
|
||||
reason: Literal[
|
||||
"road",
|
||||
"obstacle",
|
||||
"no-road",
|
||||
"uncertain",
|
||||
"inference-error",
|
||||
"replanning",
|
||||
"stuck",
|
||||
"goal-reached",
|
||||
"unstable",
|
||||
"waiting",
|
||||
]
|
||||
road_fraction: float = Field(ge=0, le=1)
|
||||
obstacle_count: int = Field(ge=0, le=300, strict=True)
|
||||
|
||||
|
||||
class RunSample(Contract):
|
||||
sequence: int = Field(ge=0, le=3600, strict=True)
|
||||
simulation_time_ns: int = Field(ge=0, strict=True)
|
||||
inference_ms: float = Field(ge=0, le=120000)
|
||||
# The snapshot is an observation BEFORE the action below is applied.
|
||||
pose_xy: tuple[float, float]
|
||||
decision: Decision
|
||||
image_jpeg_base64: str = Field(max_length=2 * 1024 * 1024)
|
||||
|
||||
|
||||
class WorkerPoll(Contract):
|
||||
instance_id: str = Field(pattern=r"^[a-f0-9]{32}$")
|
||||
run_id: str | None = Field(default=None, pattern=r"^airun-[a-f0-9]{32}$")
|
||||
|
||||
|
||||
class RunProgress(Contract):
|
||||
phase: Literal["world", "models", "scene"]
|
||||
|
||||
|
||||
class RunApplied(Contract):
|
||||
sequence: int = Field(ge=0, le=3600, strict=True)
|
||||
simulation_time_ns: int = Field(ge=0, strict=True)
|
||||
physics_steps: Literal[6]
|
||||
pose_xy: tuple[float, float]
|
||||
pose_yaw: float | None = None
|
||||
cycle_ms: float | None = Field(default=None, ge=0, le=120000)
|
||||
render_ms: float | None = Field(default=None, ge=0, le=120000)
|
||||
transport_ms: float | None = Field(default=None, ge=0, le=120000)
|
||||
|
||||
|
||||
class WorkerResult(Contract):
|
||||
instance_id: str = Field(pattern=r"^[a-f0-9]{32}$")
|
||||
outcome: Literal["completed", "stopped", "failed"]
|
||||
message: str = Field(default="", max_length=500)
|
||||
resources_released: Literal[True]
|
||||
|
||||
|
||||
class ViewControl(Contract):
|
||||
camera: Literal["follow", "overview", "camera"]
|
||||
|
||||
|
||||
class RealtimeSnapshot(Contract):
|
||||
"""Bounded observation of Worker-owned state; never a physics-step receipt."""
|
||||
|
||||
sequence: int = Field(ge=0, strict=True)
|
||||
control_sequence: int = Field(ge=0, strict=True)
|
||||
state: Literal["ready", "running", "paused", "stopping"]
|
||||
phase: Literal["scene", "models", "running"]
|
||||
simulation_time_ns: int = Field(ge=0, strict=True)
|
||||
wall_elapsed_seconds: float = Field(ge=0)
|
||||
physics_steps: int = Field(ge=0, strict=True)
|
||||
render_frames: int = Field(ge=0, strict=True)
|
||||
sensor_frames: int = Field(ge=0, strict=True)
|
||||
inference_count: int = Field(ge=0, strict=True)
|
||||
dropped_frames: int = Field(ge=0, strict=True)
|
||||
rtf: float = Field(ge=0, le=100)
|
||||
render_fps: float = Field(ge=0, le=1000)
|
||||
sensor_fps: float = Field(ge=0, le=1000)
|
||||
ai_hz: float = Field(ge=0, le=1000)
|
||||
inference_ms: float | None = Field(default=None, ge=0)
|
||||
frame_age_ms: float | None = Field(default=None, ge=0)
|
||||
command_age_ms: float | None = Field(default=None, ge=0)
|
||||
pose_xy: tuple[float, float]
|
||||
pose_yaw: float
|
||||
speed_mps: float
|
||||
applied_speed_mps: float = Field(ge=-1, le=1)
|
||||
applied_yaw_rate_rps: float = Field(ge=-1, le=1)
|
||||
stop_reason: Literal[
|
||||
"none", "paused", "stale-camera", "stale-command", "inference-error", "unstable"
|
||||
]
|
||||
decision: Decision | None = None
|
||||
ai_ready: bool
|
||||
stream_ready: bool
|
||||
camera: Literal["follow", "overview", "camera"]
|
||||
@@ -0,0 +1,158 @@
|
||||
"""Adapters for the pinned reference DDRNet and RF-DETR model contracts.
|
||||
|
||||
The simulation RGB pinhole profile is separate from the device's KB4 profile.
|
||||
No device valid-FOV mask or recorded LiDAR calibration is applied to simulator RGB.
|
||||
"""
|
||||
|
||||
import csv
|
||||
import http.client
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
from k1link.perception.rf_detr_object_detector import (
|
||||
COCO_SPARSE_TO_CONTIGUOUS,
|
||||
RISK_CLASS_IDS,
|
||||
TritonRfDetrHttpInferenceBackend,
|
||||
preprocess_raw_kb4_rf_detr,
|
||||
)
|
||||
|
||||
MODEL_IDS = ["ddrnet-goose-pytorch-reference", "rf_detr_large"]
|
||||
GOOSE_SURFACE_IDS = (3, 5, 7, 9, 11, 18, 21, 23, 24, 31, 40, 50, 62)
|
||||
|
||||
|
||||
def local_endpoint(endpoint: str):
|
||||
parsed = urlsplit(endpoint)
|
||||
if (
|
||||
parsed.scheme != "http"
|
||||
or parsed.hostname not in {"127.0.0.1", "localhost", "::1"}
|
||||
or parsed.path not in {"", "/"}
|
||||
or parsed.query
|
||||
or parsed.fragment
|
||||
or parsed.username
|
||||
):
|
||||
raise ValueError("Use a local Triton endpoint or a loopback tunnel")
|
||||
return parsed
|
||||
|
||||
|
||||
class PillowResizer:
|
||||
def resize(self, image, width, height):
|
||||
return np.asarray(Image.fromarray(image).resize((width, height), Image.Resampling.BILINEAR))
|
||||
|
||||
|
||||
class ModelInference:
|
||||
def __init__(
|
||||
self,
|
||||
endpoint: str,
|
||||
goose_labels: Path,
|
||||
ddrnet_endpoint: str,
|
||||
*,
|
||||
segmenter_id: str = "simulation-ddrnet-goose",
|
||||
):
|
||||
if segmenter_id not in {"simulation-ddrnet-goose", "simulation-segformer-ade"}:
|
||||
raise ValueError("Uninstalled surface provider")
|
||||
self.segmenter_id = segmenter_id
|
||||
parsed = local_endpoint(ddrnet_endpoint)
|
||||
local_endpoint(endpoint)
|
||||
with goose_labels.open(newline="") as stream:
|
||||
labels = {int(row["label_key"]): row["class_name"] for row in csv.DictReader(stream)}
|
||||
if set(labels) != set(range(64)):
|
||||
raise ValueError("Expected the existing GOOSE fine-64 label table")
|
||||
self.road_ids = [
|
||||
i
|
||||
for i, name in labels.items()
|
||||
if name in {"asphalt", "bikeway", "cobble", "sidewalk", "gravel", "soil"}
|
||||
]
|
||||
if len(self.road_ids) != 6:
|
||||
raise ValueError("GOOSE road label mapping is incomplete")
|
||||
self.connection = http.client.HTTPConnection(
|
||||
parsed.hostname, parsed.port or 8000, timeout=10
|
||||
)
|
||||
self.detector = TritonRfDetrHttpInferenceBackend(endpoint, timeout_seconds=10)
|
||||
self.detector_url = local_endpoint(endpoint)
|
||||
|
||||
def ready(self):
|
||||
self.connection.request("GET", "/ready")
|
||||
response = self.connection.getresponse()
|
||||
response.read(65536)
|
||||
if response.status != 200:
|
||||
raise RuntimeError("Selected surface provider is unavailable")
|
||||
connection = http.client.HTTPConnection(
|
||||
self.detector_url.hostname, self.detector_url.port or 8000, timeout=10
|
||||
)
|
||||
try:
|
||||
connection.request("GET", "/v2/models/rf_detr_large/versions/1/ready")
|
||||
response = connection.getresponse()
|
||||
response.read(65536)
|
||||
if response.status != 200:
|
||||
raise RuntimeError("RF-DETR is unavailable")
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
def segment(self, rgb: np.ndarray):
|
||||
return self.surface(rgb)["segmentation.labels"]
|
||||
|
||||
def surface(self, rgb: np.ndarray):
|
||||
if rgb.shape != (600, 800, 3) or rgb.dtype != np.uint8:
|
||||
raise ValueError("Expected an 800x600 RGB simulation camera")
|
||||
self.connection.request(
|
||||
"POST",
|
||||
"/infer",
|
||||
body=np.ascontiguousarray(rgb).tobytes(),
|
||||
headers={"Content-Type": "application/octet-stream"},
|
||||
)
|
||||
response = self.connection.getresponse()
|
||||
ade = self.segmenter_id == "simulation-segformer-ade"
|
||||
expected = 512**2 * (2 if ade else 1)
|
||||
raw = response.read(expected + 1)
|
||||
if response.status != 200 or len(raw) != expected:
|
||||
raise RuntimeError("Surface provider response changed")
|
||||
mask = np.frombuffer(raw[: 512**2], dtype=np.uint8).reshape(512, 512)
|
||||
if np.any(mask >= (150 if ade else 64)):
|
||||
raise RuntimeError("Surface label taxonomy changed")
|
||||
if ade:
|
||||
candidate = np.frombuffer(raw[512**2 :], dtype=np.uint8).reshape(512, 512)
|
||||
if np.any(candidate > 1):
|
||||
raise RuntimeError("Surface candidate raster changed")
|
||||
candidate = candidate.astype(bool)
|
||||
else:
|
||||
candidate = np.isin(mask, GOOSE_SURFACE_IDS)
|
||||
return {"segmentation.labels": mask.copy(), "segmentation.surface": candidate}
|
||||
|
||||
def detect(self, rgb: np.ndarray):
|
||||
if rgb.shape != (600, 800, 3) or rgb.dtype != np.uint8:
|
||||
raise ValueError("Expected an 800x600 RGB simulation camera")
|
||||
bgr = np.ascontiguousarray(rgb[:, :, ::-1])
|
||||
detector_tensor = preprocess_raw_kb4_rf_detr(
|
||||
bgr, np.ones((600, 800), dtype=bool), resizer=PillowResizer()
|
||||
)
|
||||
output = self.detector.infer(detector_tensor)
|
||||
if (
|
||||
output.boxes.shape != (1, 300, 4)
|
||||
or output.logits.shape != (1, 300, 91)
|
||||
or not np.isfinite(output.boxes).all()
|
||||
or not np.isfinite(output.logits).all()
|
||||
):
|
||||
raise RuntimeError("RF-DETR output contract changed")
|
||||
probabilities = 1 / (1 + np.exp(-np.clip(output.logits[0].astype(np.float32), -80, 80)))
|
||||
classes = [
|
||||
key for key, value in COCO_SPARSE_TO_CONTIGUOUS.items() if value in RISK_CLASS_IDS
|
||||
]
|
||||
scores = probabilities[:, classes].max(axis=1)
|
||||
boxes = []
|
||||
for x, y, width, height in output.boxes[0][scores >= 0.25]:
|
||||
if width <= 0 or height <= 0:
|
||||
continue
|
||||
# Unlike the recorded diagnostic filter, never discard a very large near obstacle.
|
||||
box = np.clip([x - width / 2, y - height / 2, x + width / 2, y + height / 2], 0, 1)
|
||||
boxes.append(tuple(float(value) for value in box))
|
||||
return boxes
|
||||
|
||||
def infer(self, rgb: np.ndarray):
|
||||
return np.isin(self.segment(rgb), self.road_ids), self.detect(rgb)
|
||||
|
||||
def close(self):
|
||||
self.connection.close()
|
||||
self.detector.close()
|
||||
@@ -0,0 +1,116 @@
|
||||
"""Bounded waypoint mission and progress recovery, driven only by observations.
|
||||
|
||||
Operator waypoints specify the task. They are not a collision map or a motion
|
||||
trajectory. CMU retains authority to reject every requested local waypoint.
|
||||
"""
|
||||
|
||||
import math
|
||||
|
||||
import numpy as np
|
||||
|
||||
|
||||
def inclination(pose):
|
||||
x, y = pose[3:5]
|
||||
return math.degrees(math.acos(max(-1, min(1, 1 - 2 * (x * x + y * y)))))
|
||||
|
||||
|
||||
class WaypointMission:
|
||||
def __init__(self, route):
|
||||
self.route = [list(p) for p in route]
|
||||
self.index = self.attempts = 0
|
||||
self.anchor = self.anchor_time = None
|
||||
self.last_time = None
|
||||
self.state = "following"
|
||||
self.failed_goals = []
|
||||
self.goal = None
|
||||
self.fault = None
|
||||
self.best_distance = None
|
||||
self.recovery_goal = None
|
||||
self.recovery_started = None
|
||||
|
||||
def resume(self):
|
||||
# Pause freezes the world clock. Preserve the mission cursor and any
|
||||
# latched failure. The paused world has not moved: retain the observed
|
||||
# goal entering the camera blind strip, but revalidate it before motion.
|
||||
self.anchor = self.anchor_time = None
|
||||
|
||||
def update(self, pose, seconds, choose_goal, choose_recovery=None):
|
||||
xy = np.asarray(pose[:2])
|
||||
if self.last_time is not None and seconds < self.last_time:
|
||||
raise ValueError("Mission observations must not rewind")
|
||||
self.last_time = seconds
|
||||
if inclination(pose) >= 30:
|
||||
self.fault = "unstable"
|
||||
if self.fault:
|
||||
return None, self.intent(self.fault)
|
||||
if self.route and np.linalg.norm(xy - self.route[self.index]) <= 0.4:
|
||||
if self.index == len(self.route) - 1:
|
||||
self.fault = "goal-reached"
|
||||
return None, self.intent(self.fault)
|
||||
self.index += 1
|
||||
self.anchor = self.goal = None
|
||||
self.best_distance = None
|
||||
self.recovery_goal = None
|
||||
self.attempts = 0
|
||||
target = self.route[self.index] if self.route else None
|
||||
if self.anchor is None:
|
||||
self.anchor, self.anchor_time = xy.copy(), seconds
|
||||
distance = float(np.linalg.norm(xy - target)) if target is not None else None
|
||||
if self.best_distance is None:
|
||||
self.best_distance = distance
|
||||
progress = self.best_distance - distance if target is not None else 0.0
|
||||
if target is None and self.goal is not None:
|
||||
direction = np.asarray(self.goal[:2]) - self.anchor
|
||||
progress = float(
|
||||
np.dot(xy - self.anchor, direction) / max(np.linalg.norm(direction), 0.1)
|
||||
)
|
||||
if self.recovery_goal is not None:
|
||||
remaining = np.linalg.norm(xy - self.recovery_goal[:2])
|
||||
observed = choose_recovery(self.recovery_goal) if choose_recovery is not None else None
|
||||
if remaining > 0.3 and seconds - self.recovery_started < 6 and observed is not None:
|
||||
return self.recovery_goal, self.intent("reversing")
|
||||
# Recovery is an attempt to escape, never route progress. The next
|
||||
# forward attempt must beat the previous best distance to the task.
|
||||
self.recovery_goal = self.goal = None
|
||||
self.anchor_time = seconds
|
||||
return None, self.intent("replanning")
|
||||
if progress >= 0.1:
|
||||
# A reverse/forward cycle cannot replenish the recovery budget.
|
||||
self.anchor, self.anchor_time = xy.copy(), seconds
|
||||
self.best_distance = distance
|
||||
self.attempts = 0
|
||||
self.failed_goals.clear()
|
||||
stalled = seconds - self.anchor_time >= 8
|
||||
if stalled:
|
||||
self.attempts += 1
|
||||
if self.goal is not None:
|
||||
self.failed_goals.append(self.goal)
|
||||
self.goal = None
|
||||
self.anchor_time = seconds
|
||||
if self.attempts > 3:
|
||||
self.fault = "stuck"
|
||||
return None, self.intent("stuck")
|
||||
self.state = "replanning"
|
||||
if choose_recovery is not None:
|
||||
self.recovery_goal = choose_recovery(None)
|
||||
if self.recovery_goal is not None:
|
||||
self.recovery_started = seconds
|
||||
return self.recovery_goal, self.intent("reversing")
|
||||
observed = choose_goal(target, self.goal, self.failed_goals)
|
||||
if observed is None:
|
||||
# A rejected frame stops motion, not causal memory. Forgetting the
|
||||
# last observed exact waypoint strands it behind the near camera
|
||||
# boundary on the next frame. choose_goal must revalidate visible
|
||||
# support, and live range/collision checks retain final authority.
|
||||
return None, self.intent("no-road")
|
||||
self.goal = observed
|
||||
self.state = "replanning" if self.attempts else "following"
|
||||
return self.goal, self.intent(self.state)
|
||||
|
||||
def intent(self, state):
|
||||
return dict(
|
||||
state=state,
|
||||
waypoint=self.index,
|
||||
waypoint_count=len(self.route),
|
||||
recovery_attempt=self.attempts,
|
||||
)
|
||||
@@ -0,0 +1,79 @@
|
||||
"""Camera-only laboratory road follower. No scene truth or actor coordinates enter here.
|
||||
|
||||
This deliberately small baseline measures model-driven following/braking. It is
|
||||
not a route planner, metric obstacle-distance estimator, or field safety policy.
|
||||
"""
|
||||
|
||||
import numpy as np
|
||||
|
||||
from k1link.simulation.ai_polygon.contracts import Decision
|
||||
|
||||
|
||||
class RoadPolicy:
|
||||
def __init__(self, max_speed_mps: float):
|
||||
if not 0 < max_speed_mps <= 1:
|
||||
raise ValueError("invalid laboratory speed")
|
||||
self.max_speed = max_speed_mps
|
||||
self.clear_frames = 0
|
||||
|
||||
def reset(self):
|
||||
self.clear_frames = 0
|
||||
|
||||
def decide(
|
||||
self, road_mask: np.ndarray, boxes: list[tuple[float, float, float, float]]
|
||||
) -> Decision:
|
||||
if road_mask.shape != (512, 512) or road_mask.dtype != np.bool_:
|
||||
raise ValueError("road mask must be the model's 512x512 boolean raster")
|
||||
# Detector boxes are normalized in the full RGB camera, not the DDRNet crop.
|
||||
if any(
|
||||
not all(np.isfinite(box))
|
||||
or not (0 <= box[0] <= box[2] <= 1 and 0 <= box[1] <= box[3] <= 1)
|
||||
for box in boxes
|
||||
):
|
||||
raise ValueError("invalid observation box")
|
||||
obstacles = sum(x1 < 0.68 and x2 > 0.32 and y2 > 0.55 for x1, _, x2, y2 in boxes)
|
||||
near_road = road_mask[320:500, 100:412]
|
||||
fraction = float(near_road.mean())
|
||||
stop = "obstacle" if obstacles else "no-road" if fraction < 0.35 else None
|
||||
if stop:
|
||||
self.clear_frames = 0
|
||||
return Decision(
|
||||
speed_mps=0,
|
||||
yaw_rate_rps=0,
|
||||
reason=stop,
|
||||
road_fraction=fraction,
|
||||
obstacle_count=obstacles,
|
||||
)
|
||||
self.clear_frames += 1
|
||||
if self.clear_frames < 3:
|
||||
return Decision(
|
||||
speed_mps=0,
|
||||
yaw_rate_rps=0,
|
||||
reason="uncertain",
|
||||
road_fraction=fraction,
|
||||
obstacle_count=0,
|
||||
)
|
||||
# Choose a contiguous visible corridor, rather than averaging two disconnected roads.
|
||||
columns = near_road.mean(axis=0) >= 0.6
|
||||
edges = np.flatnonzero(np.diff(np.r_[False, columns, False].astype(np.int8)))
|
||||
spans = [(a, b) for a, b in zip(edges[::2], edges[1::2], strict=True) if b - a >= 48]
|
||||
if not spans:
|
||||
self.clear_frames = 0
|
||||
return Decision(
|
||||
speed_mps=0,
|
||||
yaw_rate_rps=0,
|
||||
reason="uncertain",
|
||||
road_fraction=fraction,
|
||||
obstacle_count=0,
|
||||
)
|
||||
left, right = min(spans, key=lambda span: abs((span[0] + span[1]) / 2 - 156))
|
||||
center = (left + right) / 2
|
||||
yaw = float(np.clip((156 - center) / 156, -0.6, 0.6))
|
||||
speed = self.max_speed * min(1, fraction / 0.65) * (1 - abs(yaw))
|
||||
return Decision(
|
||||
speed_mps=float(speed),
|
||||
yaw_rate_rps=yaw,
|
||||
reason="road",
|
||||
road_fraction=fraction,
|
||||
obstacle_count=0,
|
||||
)
|
||||
@@ -0,0 +1,412 @@
|
||||
"""Single-worker laboratory lifecycle and durable observation/decision journal."""
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import shutil
|
||||
import stat
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from uuid import uuid4
|
||||
|
||||
from PIL import Image
|
||||
|
||||
from k1link.artifacts import utc_now_iso
|
||||
from k1link.observatory.recorded_jobs import ObservatoryRecordedJobQueue
|
||||
from k1link.simulation.ai_polygon.contracts import (
|
||||
RealtimeSnapshot,
|
||||
RunApplied,
|
||||
RunCreate,
|
||||
RunSample,
|
||||
WorkerHello,
|
||||
)
|
||||
from k1link.simulation.ai_polygon.worlds import WorldStore, write_json
|
||||
|
||||
RUN_ID = re.compile(r"^airun-[a-f0-9]{32}$")
|
||||
TERMINAL = {"completed", "stopped", "failed"}
|
||||
WORKER_LEASE_SECONDS = 20
|
||||
|
||||
|
||||
class RunStore:
|
||||
def __init__(self, worlds: WorldStore, queue: ObservatoryRecordedJobQueue | None = None):
|
||||
self.worlds = worlds
|
||||
self.queue = queue
|
||||
self.root = worlds.root.parent / "runs"
|
||||
self.root.mkdir(mode=0o700, exist_ok=True)
|
||||
self.lock = threading.RLock()
|
||||
self.worker: dict | None = None
|
||||
self.seen = 0.0
|
||||
self.active: str | None = None
|
||||
self.token_path = self.root.parent / "worker.token"
|
||||
if not self.token_path.exists():
|
||||
try:
|
||||
fd = os.open(self.token_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
|
||||
with os.fdopen(fd, "w") as stream:
|
||||
stream.write(secrets.token_urlsafe(48))
|
||||
except FileExistsError:
|
||||
pass
|
||||
metadata = self.token_path.lstat()
|
||||
if not stat.S_ISREG(metadata.st_mode) or metadata.st_mode & 0o077:
|
||||
raise ValueError("AI polygon worker token must be a private regular file")
|
||||
with os.fdopen(os.open(self.token_path, os.O_RDONLY | os.O_NOFOLLOW), "r") as stream:
|
||||
self.token = stream.read(513).strip()
|
||||
if not 32 <= len(self.token) <= 512:
|
||||
raise ValueError("AI polygon worker token is invalid")
|
||||
# Keep realtime ownership uncertain until the same Worker reconciles it.
|
||||
# A Core restart never proves that the remote GPU/process stopped.
|
||||
for row in self.list():
|
||||
if row["state"] not in TERMINAL:
|
||||
if row.get("clock") == "realtime":
|
||||
if self.active is not None:
|
||||
raise RuntimeError("Multiple unreconciled simulation owners")
|
||||
self.active = row["run_id"]
|
||||
row.update(state="disconnected", message="Ожидаем состояние Worker.")
|
||||
else:
|
||||
row.update(state="failed", message="Связь с симуляцией прервана перезапуском.")
|
||||
self._save(row)
|
||||
|
||||
def directory(self, run_id: str) -> Path:
|
||||
if not RUN_ID.fullmatch(run_id):
|
||||
raise FileNotFoundError(run_id)
|
||||
path = self.root / run_id
|
||||
if not path.is_dir() or path.is_symlink():
|
||||
raise FileNotFoundError(run_id)
|
||||
return path
|
||||
|
||||
def get(self, run_id: str) -> dict:
|
||||
return json.loads((self.directory(run_id) / "run.json").read_text())
|
||||
|
||||
def list(self) -> list[dict]:
|
||||
return sorted(
|
||||
[
|
||||
self.get(p.name)
|
||||
for p in self.root.iterdir()
|
||||
if RUN_ID.fullmatch(p.name) and p.is_dir() and not p.is_symlink()
|
||||
],
|
||||
key=lambda item: item["created_at"],
|
||||
reverse=True,
|
||||
)
|
||||
|
||||
def _save(self, doc: dict) -> None:
|
||||
write_json(self.directory(doc["run_id"]) / "run.json", doc)
|
||||
|
||||
def _expire(self) -> None:
|
||||
if self.worker is not None and time.monotonic() - self.seen > WORKER_LEASE_SECONDS:
|
||||
if self.active:
|
||||
row = self.get(self.active)
|
||||
if row.get("clock") == "realtime":
|
||||
row.update(
|
||||
state="disconnected",
|
||||
message="Связь с Worker потеряна. Состояние уточняется.",
|
||||
)
|
||||
else:
|
||||
row.update(state="failed", message="Worker потерял связь. Прогон остановлен.")
|
||||
self.active = None
|
||||
self._save(row)
|
||||
self.worker = None
|
||||
|
||||
def status(self) -> dict:
|
||||
with self.lock:
|
||||
self._expire()
|
||||
return {
|
||||
"available": self.worker is not None,
|
||||
"worker": self.worker,
|
||||
"active_run": self.get(self.active) if self.active else None,
|
||||
}
|
||||
|
||||
def register(self, hello: WorkerHello) -> dict:
|
||||
with self.lock:
|
||||
self._expire()
|
||||
if self.worker and self.worker["instance_id"] != hello.instance_id:
|
||||
raise RuntimeError("Другой Worker уже подключён.")
|
||||
if self.active and self.get(self.active)["worker"] != hello.model_dump():
|
||||
raise RuntimeError("Нельзя менять модели во время прогона.")
|
||||
self.worker = hello.model_dump()
|
||||
self.seen = time.monotonic()
|
||||
return {"registered": True}
|
||||
|
||||
def heartbeat(self, instance_id: str) -> dict:
|
||||
with self.lock:
|
||||
self._require_worker(instance_id)
|
||||
self.seen = time.monotonic()
|
||||
return {"alive": True}
|
||||
|
||||
def _require_worker(self, instance_id: str) -> None:
|
||||
self._expire()
|
||||
if self.worker is None or self.worker["instance_id"] != instance_id:
|
||||
raise RuntimeError("Сессия Worker истекла; требуется переподключение.")
|
||||
|
||||
def start(self, request: RunCreate, request_id: str) -> dict:
|
||||
if not re.fullmatch(r"[a-zA-Z0-9_-]{8,100}", request_id):
|
||||
raise ValueError("Требуется уникальный идентификатор запуска.")
|
||||
with self.lock:
|
||||
self._expire()
|
||||
for old in self.list():
|
||||
if old["request_id"] == request_id:
|
||||
if old["request"] != request.model_dump():
|
||||
raise RuntimeError("Идентификатор запуска уже использован.")
|
||||
return old
|
||||
if self.worker is None:
|
||||
raise RuntimeError("Подключите Worker с симуляцией и моделями inference.")
|
||||
if request.clock not in self.worker.get("execution_modes", ["lockstep"]):
|
||||
raise RuntimeError("Worker не поддерживает этот режим симуляции.")
|
||||
if request.clock == "realtime" and not self.worker.get("stream"):
|
||||
raise RuntimeError("Видеопоток Worker не настроен.")
|
||||
if self.queue is None:
|
||||
raise RuntimeError("Контроль занятости Worker недоступен.")
|
||||
if self.active is not None:
|
||||
raise RuntimeError("Сначала завершите текущий прогон.")
|
||||
world = self.worlds.get(request.world_id)
|
||||
storage = world.get("storage", {})
|
||||
if storage.get("kind") == "worker" and (
|
||||
storage["worker_id"] != self.worker["worker_id"] or request.clock != "realtime"
|
||||
):
|
||||
raise RuntimeError("Локация доступна только на подготовленном Worker.")
|
||||
if world["status"] != "available" or not world["settings"]["prepared"]:
|
||||
raise RuntimeError("Сначала проверьте масштаб, грунт и старт ровера.")
|
||||
run_id = f"airun-{uuid4().hex}"
|
||||
if (
|
||||
request.clock == "lockstep"
|
||||
and shutil.disk_usage(self.root).free < request.max_steps * 1024**2 + 512 * 1024**2
|
||||
):
|
||||
raise ValueError("Недостаточно места для кадров прогона.")
|
||||
(self.root / run_id).mkdir(mode=0o700)
|
||||
if request.clock == "lockstep":
|
||||
(self.root / run_id / "frames").mkdir(mode=0o700)
|
||||
row = {
|
||||
"schema_version": "missioncore.ai-polygon-run/v1",
|
||||
"run_id": run_id,
|
||||
"request_id": request_id,
|
||||
"request": request.model_dump(),
|
||||
"created_at": utc_now_iso(),
|
||||
"world": world,
|
||||
"worker": dict(self.worker),
|
||||
"state": "starting",
|
||||
"control": "pause" if request.start_paused else "play",
|
||||
"control_sequence": 0,
|
||||
"camera": "follow",
|
||||
"telemetry": None,
|
||||
"step_budget": 0,
|
||||
"samples": 0,
|
||||
"applied_steps": 0,
|
||||
"last_applied": None,
|
||||
"last_sample": None,
|
||||
"message": None,
|
||||
"phase": "world",
|
||||
"authority": "virtual-only",
|
||||
"clock": request.clock,
|
||||
"step_ns": 100_000_000,
|
||||
}
|
||||
self._save(row)
|
||||
try:
|
||||
self.queue.reserve_simulation(run_id)
|
||||
except RuntimeError:
|
||||
row.update(state="failed", message="Worker занят другой задачей.")
|
||||
self._save(row)
|
||||
raise
|
||||
self.active = run_id
|
||||
return row
|
||||
|
||||
def control(self, run_id: str, command: str) -> dict:
|
||||
with self.lock:
|
||||
self._expire()
|
||||
row = self.get(run_id)
|
||||
if row["state"] in TERMINAL:
|
||||
return row
|
||||
if command not in {"pause", "play", "step", "stop"}:
|
||||
raise ValueError("Неизвестная команда симуляции.")
|
||||
if command == "step" and row.get("clock") == "realtime":
|
||||
raise RuntimeError("Realtime не допускает пошаговое продвижение времени.")
|
||||
if command == "step" and row["state"] != "paused":
|
||||
raise RuntimeError("Один шаг доступен только на паузе.")
|
||||
if row["control"] == "stop":
|
||||
return row
|
||||
if row["control"] == command:
|
||||
return row
|
||||
row["control_sequence"] = row.get("control_sequence", 0) + 1
|
||||
row["control"] = "pause" if command == "step" else command
|
||||
row["step_budget"] = 1 if command == "step" else 0
|
||||
# Paused is acknowledged by the Worker, not inferred from this request.
|
||||
if command == "stop":
|
||||
row["state"] = "stopping"
|
||||
self._save(row)
|
||||
return row
|
||||
|
||||
def poll(self, instance_id: str, run_id: str | None) -> dict:
|
||||
with self.lock:
|
||||
self._require_worker(instance_id)
|
||||
self.seen = time.monotonic()
|
||||
if self.active is None:
|
||||
return {"run": None, "action": "idle"}
|
||||
row = self.get(self.active)
|
||||
if run_id is None:
|
||||
return {"run": row, "action": "load"}
|
||||
if run_id != self.active:
|
||||
raise RuntimeError("Прогон Worker не совпадает с активным.")
|
||||
action = row["control"]
|
||||
if row.get("clock") == "realtime":
|
||||
# Only actual local snapshots can acknowledge pause/play.
|
||||
return {"run": row, "action": action}
|
||||
if action == "pause":
|
||||
if row["step_budget"]:
|
||||
row["step_budget"] = 0
|
||||
action = "step"
|
||||
else:
|
||||
row["state"] = "paused"
|
||||
elif action == "play" and row["samples"]:
|
||||
row["state"] = "running"
|
||||
self._save(row)
|
||||
return {"run": row, "action": action}
|
||||
|
||||
def progress(self, run_id: str, instance_id: str, phase: str) -> dict:
|
||||
with self.lock:
|
||||
self._require_worker(instance_id)
|
||||
if run_id != self.active:
|
||||
raise RuntimeError("Прогон уже завершён.")
|
||||
row = self.get(run_id)
|
||||
if row["state"] == "starting":
|
||||
row["phase"] = phase
|
||||
self._save(row)
|
||||
return {"control": row["control"]}
|
||||
|
||||
def view(self, run_id: str, camera: str) -> dict:
|
||||
with self.lock:
|
||||
row = self.get(run_id)
|
||||
if run_id != self.active or row.get("clock") != "realtime":
|
||||
raise RuntimeError("Симуляция не запущена.")
|
||||
if camera not in {"follow", "overview", "camera"}:
|
||||
raise ValueError("Неизвестная камера.")
|
||||
row["camera"] = camera
|
||||
self._save(row)
|
||||
return row
|
||||
|
||||
def snapshot(self, run_id: str, instance_id: str, snapshot: RealtimeSnapshot) -> dict:
|
||||
with self.lock:
|
||||
self._require_worker(instance_id)
|
||||
row = self.get(run_id)
|
||||
if self.active != run_id or row.get("clock") != "realtime":
|
||||
raise RuntimeError("Прогон не принадлежит realtime Worker.")
|
||||
if row["worker"]["instance_id"] != instance_id:
|
||||
raise RuntimeError("Прогон принадлежит другому Worker.")
|
||||
old = row.get("telemetry")
|
||||
if old and snapshot.sequence <= old["sequence"]:
|
||||
return {
|
||||
"recorded": old["sequence"]
|
||||
} # Same snapshot may be retried after reconnect.
|
||||
if snapshot.control_sequence > row.get("control_sequence", 0):
|
||||
raise ValueError("Worker подтвердил неизвестную команду.")
|
||||
if old and snapshot.simulation_time_ns < old["simulation_time_ns"]:
|
||||
raise ValueError("Время Worker не может идти назад.")
|
||||
if abs(snapshot.applied_speed_mps) > row["world"]["settings"]["max_speed_mps"]:
|
||||
raise ValueError("Команда превышает скорость прогона.")
|
||||
row.update(
|
||||
telemetry=snapshot.model_dump(),
|
||||
phase=snapshot.phase,
|
||||
samples=snapshot.inference_count,
|
||||
message=None,
|
||||
)
|
||||
if row["control"] != "stop":
|
||||
row["state"] = snapshot.state
|
||||
row["telemetry_received_at"] = utc_now_iso()
|
||||
self._save(row)
|
||||
self.seen = time.monotonic()
|
||||
return {"recorded": snapshot.sequence}
|
||||
|
||||
def sample(self, run_id: str, instance_id: str, sample: RunSample) -> dict:
|
||||
with self.lock:
|
||||
self._require_worker(instance_id)
|
||||
if run_id != self.active:
|
||||
raise RuntimeError("Прогон уже завершён.")
|
||||
row = self.get(run_id)
|
||||
if row.get("clock") != "lockstep":
|
||||
raise RuntimeError("Realtime кадры хранятся только на Worker.")
|
||||
if row["control"] == "stop":
|
||||
raise RuntimeError("Получена команда остановки.")
|
||||
if sample.sequence != row["samples"] or sample.sequence >= row["request"]["max_steps"]:
|
||||
raise RuntimeError("Нарушена последовательность кадров.")
|
||||
if row["samples"] != row["applied_steps"]:
|
||||
raise RuntimeError("Предыдущий шаг физики не подтверждён.")
|
||||
if sample.simulation_time_ns != sample.sequence * row["step_ns"]:
|
||||
raise ValueError("Время кадра не соответствует шагу симуляции.")
|
||||
if abs(sample.decision.speed_mps) > row["world"]["settings"]["max_speed_mps"]:
|
||||
raise ValueError("Команда превышает скорость прогона.")
|
||||
try:
|
||||
image = base64.b64decode(sample.image_jpeg_base64, validate=True)
|
||||
if not 4 <= len(image) <= 1024**2:
|
||||
raise ValueError()
|
||||
with Image.open(io.BytesIO(image)) as decoded:
|
||||
if decoded.format != "JPEG" or decoded.size != (800, 600):
|
||||
raise ValueError()
|
||||
decoded.verify()
|
||||
except Exception as exc:
|
||||
raise ValueError("Неверный кадр камеры.") from exc
|
||||
directory = self.directory(run_id)
|
||||
if shutil.disk_usage(directory).free < len(image) + 512 * 1024**2:
|
||||
raise ValueError("Недостаточно места для кадра прогона.")
|
||||
filename = f"{sample.sequence:06d}.jpg"
|
||||
(directory / "frames" / filename).write_bytes(image)
|
||||
recorded = sample.model_dump(exclude={"image_jpeg_base64"})
|
||||
recorded.update(image_sha256=hashlib.sha256(image).hexdigest(), image=filename)
|
||||
with (directory / "decisions.jsonl").open("a", encoding="utf-8") as stream:
|
||||
stream.write(json.dumps(recorded, allow_nan=False) + "\n")
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
row.update(samples=row["samples"] + 1, last_sample=recorded, phase="running")
|
||||
if row["state"] == "starting":
|
||||
row["state"] = "running"
|
||||
self._save(row)
|
||||
return {"recorded": sample.sequence}
|
||||
|
||||
def applied(self, run_id: str, instance_id: str, receipt: RunApplied) -> dict:
|
||||
with self.lock:
|
||||
self._require_worker(instance_id)
|
||||
if self.active != run_id:
|
||||
raise RuntimeError("Прогон уже завершён.")
|
||||
row = self.get(run_id)
|
||||
if row.get("clock") != "lockstep":
|
||||
raise RuntimeError("Realtime не использует подтверждения физических шагов.")
|
||||
if receipt.sequence != row["applied_steps"] or row["samples"] != receipt.sequence + 1:
|
||||
raise RuntimeError("Шаг не соответствует решению модели.")
|
||||
if receipt.simulation_time_ns != (receipt.sequence + 1) * row["step_ns"]:
|
||||
raise ValueError("Неверное время завершения шага.")
|
||||
recorded = receipt.model_dump()
|
||||
with (self.directory(run_id) / "motion.jsonl").open("a", encoding="utf-8") as stream:
|
||||
stream.write(json.dumps(recorded, allow_nan=False) + "\n")
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
row.update(applied_steps=row["applied_steps"] + 1, last_applied=recorded)
|
||||
self._save(row)
|
||||
return {"applied": receipt.sequence}
|
||||
|
||||
def finish(self, run_id: str, instance_id: str, outcome: str, message: str) -> dict:
|
||||
with self.lock:
|
||||
self._require_worker(instance_id)
|
||||
row = self.get(run_id)
|
||||
if row["worker"]["instance_id"] != instance_id:
|
||||
raise RuntimeError("Прогон принадлежит другому Worker.")
|
||||
if row["state"] in TERMINAL:
|
||||
if self.queue is not None:
|
||||
self.queue.release_simulation(run_id)
|
||||
return row
|
||||
if run_id != self.active:
|
||||
raise RuntimeError("Прогон уже завершён.")
|
||||
if (
|
||||
row.get("clock") == "lockstep"
|
||||
and outcome == "completed"
|
||||
and (
|
||||
row["samples"] != row["request"]["max_steps"]
|
||||
or row["applied_steps"] != row["samples"]
|
||||
)
|
||||
):
|
||||
raise RuntimeError("Прогон не достиг заданного числа шагов.")
|
||||
row.update(state=outcome, message=message or None)
|
||||
self._save(row)
|
||||
if self.queue is not None:
|
||||
self.queue.release_simulation(run_id)
|
||||
self.active = None
|
||||
return row
|
||||
@@ -0,0 +1,24 @@
|
||||
"""Collision identity is independent of episode start, heading and camera."""
|
||||
|
||||
|
||||
def terrain_matches(manifest, world, generator_sha256=None):
|
||||
if manifest.get("source_sha256") != world["sha256"]:
|
||||
return False
|
||||
if generator_sha256 and manifest.get("generator_sha256") != generator_sha256:
|
||||
return False
|
||||
old, new = manifest["settings"], world["settings"]
|
||||
if any(old[key] != new[key] for key in ("meters_per_unit", "rotation_degrees")):
|
||||
return False
|
||||
if manifest.get("generator") == "paired-source":
|
||||
# A supplied full-scene collider is not the generated 30 m tile below.
|
||||
# Source/calibration and collider hashes still bind this asset; actual
|
||||
# support and full-body clearance are checked at the new start by Isaac.
|
||||
return manifest.get("collider_sha256") == world.get("collider_sha256") and bool(
|
||||
manifest.get("collider_sha256")
|
||||
)
|
||||
# The versioned preparer captures a 30x30 m tile and a 12 m vertical band.
|
||||
# Admit starts only within its interior; physical support is checked later.
|
||||
return (
|
||||
all(abs(old["spawn_xy"][i] - new["spawn_xy"][i]) <= 12 for i in (0, 1))
|
||||
and abs(old["ground_z"] - new["ground_z"]) <= 2
|
||||
)
|
||||
@@ -0,0 +1,256 @@
|
||||
"""Durable, resumable Gaussian asset admission, independent of XGRIDS sources."""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from uuid import uuid4
|
||||
|
||||
import numpy as np
|
||||
|
||||
from k1link.artifacts import utc_now_iso
|
||||
from k1link.simulation.ai_polygon.contracts import WorkerWorldCreate, WorldCreate, WorldSettings
|
||||
|
||||
CHUNK_BYTES = 4 * 1024**2
|
||||
WORLD_ID = re.compile(r"^aiworld-[a-f0-9]{32}$")
|
||||
SOURCES = [
|
||||
{
|
||||
"name": "Forest Scan",
|
||||
"author": "draftmode",
|
||||
"license": "CC BY 4.0",
|
||||
"source_url": "https://superspl.at/scene/259c0051",
|
||||
"description": "Лесная тропа и папоротники",
|
||||
},
|
||||
{
|
||||
"name": "Bamboo Trail",
|
||||
"author": "luckysplat",
|
||||
"license": "CC BY 4.0",
|
||||
"source_url": "https://superspl.at/scene/dd49e9a8",
|
||||
"description": "Тропа в бамбуковой роще",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def write_json(path: Path, value: object) -> None:
|
||||
temporary = path.with_name(f".{path.name}-{uuid4().hex}")
|
||||
try:
|
||||
with temporary.open("x", encoding="utf-8") as stream:
|
||||
os.chmod(temporary, 0o600)
|
||||
json.dump(value, stream, ensure_ascii=False, allow_nan=False)
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
temporary.replace(path)
|
||||
finally:
|
||||
temporary.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def inspect_gaussian_ply(path: Path) -> int:
|
||||
"""Admit only standard scalar binary 3DGS; do not label meshes as splats."""
|
||||
with path.open("rb") as stream:
|
||||
header = bytearray()
|
||||
while len(header) < 65536:
|
||||
line = stream.readline(1024)
|
||||
header.extend(line)
|
||||
if line.rstrip() == b"end_header":
|
||||
break
|
||||
if not line:
|
||||
raise ValueError("В PLY отсутствует заголовок Gaussian-сцены.")
|
||||
else:
|
||||
raise ValueError("Заголовок PLY превышает допустимый размер.")
|
||||
try:
|
||||
lines = bytes(header).decode("ascii").splitlines()
|
||||
except UnicodeDecodeError as exc:
|
||||
raise ValueError("Некорректный заголовок PLY.") from exc
|
||||
if lines[:2] != ["ply", "format binary_little_endian 1.0"]:
|
||||
raise ValueError("Экспортируйте стандартный Gaussian PLY (binary little-endian).")
|
||||
elements = [line for line in lines if line.startswith("element ")]
|
||||
if len(elements) != 1 or not elements[0].startswith("element vertex "):
|
||||
raise ValueError("Нужен Gaussian PLY с одним элементом vertex, без меша.")
|
||||
count = int(elements[0].split()[-1])
|
||||
properties = [line.split() for line in lines if line.startswith("property ")]
|
||||
names = [prop[-1] for prop in properties]
|
||||
required = {
|
||||
"x",
|
||||
"y",
|
||||
"z",
|
||||
"opacity",
|
||||
"f_dc_0",
|
||||
"f_dc_1",
|
||||
"f_dc_2",
|
||||
"scale_0",
|
||||
"scale_1",
|
||||
"scale_2",
|
||||
"rot_0",
|
||||
"rot_1",
|
||||
"rot_2",
|
||||
"rot_3",
|
||||
}
|
||||
if (
|
||||
not 1 <= count <= 20_000_000
|
||||
or not required.issubset(names)
|
||||
or len(names) != len(set(names))
|
||||
or not 14 <= len(properties) <= 128
|
||||
or any(len(prop) != 3 or prop[1] not in {"float", "float32"} for prop in properties)
|
||||
):
|
||||
raise ValueError("PLY не содержит поддерживаемые Gaussian-атрибуты.")
|
||||
if path.stat().st_size != len(header) + count * len(properties) * 4:
|
||||
raise ValueError("Размер PLY не соответствует его заголовку.")
|
||||
with path.open("rb") as stream:
|
||||
stream.seek(len(header))
|
||||
for chunk in iter(lambda: stream.read(CHUNK_BYTES), b""):
|
||||
if not np.isfinite(np.frombuffer(chunk, dtype="<f4")).all():
|
||||
raise ValueError("PLY содержит нечисловые или бесконечные атрибуты.")
|
||||
return count
|
||||
|
||||
|
||||
class WorldStore:
|
||||
def __init__(self, root: Path):
|
||||
self.root = root / "ai-polygon" / "worlds"
|
||||
self.root.mkdir(parents=True, exist_ok=True, mode=0o700)
|
||||
self.lock = threading.RLock()
|
||||
|
||||
def directory(self, world_id: str) -> Path:
|
||||
if not WORLD_ID.fullmatch(world_id):
|
||||
raise FileNotFoundError(world_id)
|
||||
path = self.root / world_id
|
||||
if path.is_symlink() or not path.is_dir():
|
||||
raise FileNotFoundError(world_id)
|
||||
return path
|
||||
|
||||
def get(self, world_id: str) -> dict:
|
||||
with self.lock:
|
||||
path = self.directory(world_id)
|
||||
document = json.loads((path / "world.json").read_text())
|
||||
if document["status"] == "uploading":
|
||||
source = path / "source.part"
|
||||
if not source.exists():
|
||||
source = path / "source.ply"
|
||||
document["uploaded_bytes"] = source.stat().st_size
|
||||
return document
|
||||
|
||||
def list(self) -> list[dict]:
|
||||
return sorted(
|
||||
[
|
||||
self.get(p.name)
|
||||
for p in self.root.iterdir()
|
||||
if WORLD_ID.fullmatch(p.name) and p.is_dir() and not p.is_symlink()
|
||||
],
|
||||
key=lambda item: item["created_at"],
|
||||
reverse=True,
|
||||
)
|
||||
|
||||
def create(self, request: WorldCreate) -> dict:
|
||||
with self.lock:
|
||||
if shutil.disk_usage(self.root).free < request.byte_length + 512 * 1024**2:
|
||||
raise ValueError("Недостаточно места для исходника сцены.")
|
||||
world_id = f"aiworld-{uuid4().hex}"
|
||||
path = self.root / world_id
|
||||
path.mkdir(mode=0o700)
|
||||
(path / "source.part").touch(mode=0o600)
|
||||
document = {
|
||||
"schema_version": "missioncore.ai-polygon-world/v1",
|
||||
"world_id": world_id,
|
||||
**request.model_dump(mode="json"),
|
||||
"status": "uploading",
|
||||
"uploaded_bytes": 0,
|
||||
"sha256": None,
|
||||
"splat_count": None,
|
||||
"created_at": utc_now_iso(),
|
||||
"settings": WorldSettings().model_dump(mode="json"),
|
||||
}
|
||||
write_json(path / "world.json", document)
|
||||
return document
|
||||
|
||||
def register_worker_asset(self, request: WorkerWorldCreate, worker_id: str) -> dict:
|
||||
"""Keep only an authenticated asset manifest on the operator machine."""
|
||||
with self.lock:
|
||||
payload = request.model_dump(mode="json")
|
||||
storage = {"kind": "worker", "worker_id": worker_id}
|
||||
for existing in self.list():
|
||||
if existing.get("storage") == storage and existing["sha256"] == request.sha256:
|
||||
if any(existing.get(k) != v for k, v in payload.items() if k != "settings"):
|
||||
raise RuntimeError("Манифест сохранённой локации изменился.")
|
||||
return existing
|
||||
world_id = f"aiworld-{uuid4().hex}"
|
||||
path = self.root / world_id
|
||||
path.mkdir(mode=0o700)
|
||||
document = {
|
||||
"schema_version": "missioncore.ai-polygon-world/v1",
|
||||
"world_id": world_id,
|
||||
**payload,
|
||||
"storage": storage,
|
||||
"status": "available",
|
||||
"uploaded_bytes": 0,
|
||||
"created_at": utc_now_iso(),
|
||||
}
|
||||
write_json(path / "world.json", document)
|
||||
return document
|
||||
|
||||
def append(self, world_id: str, offset: int, payload: bytes) -> dict:
|
||||
with self.lock:
|
||||
doc = self.get(world_id)
|
||||
if doc["status"] != "uploading" or offset != doc["uploaded_bytes"]:
|
||||
raise RuntimeError("Позиция загрузки изменилась. Возобновите передачу.")
|
||||
if not 0 < len(payload) <= CHUNK_BYTES or offset + len(payload) > doc["byte_length"]:
|
||||
raise ValueError("Размер фрагмента загрузки недопустим.")
|
||||
if shutil.disk_usage(self.root).free < len(payload) + 512 * 1024**2:
|
||||
raise ValueError("Недостаточно свободного места.")
|
||||
part = self.directory(world_id) / "source.part"
|
||||
with part.open("r+b") as stream:
|
||||
stream.seek(offset)
|
||||
stream.write(payload)
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
return self.get(world_id)
|
||||
|
||||
def complete(self, world_id: str) -> dict:
|
||||
with self.lock:
|
||||
doc = self.get(world_id)
|
||||
if doc["status"] == "available":
|
||||
return doc
|
||||
if doc["uploaded_bytes"] != doc["byte_length"]:
|
||||
raise RuntimeError("Загрузка сцены ещё не завершена.")
|
||||
directory = self.directory(world_id)
|
||||
source = directory / "source.part"
|
||||
# Recover an interrupted atomic publication without discarding the source.
|
||||
if not source.exists():
|
||||
source = directory / "source.ply"
|
||||
count = inspect_gaussian_ply(source)
|
||||
digest = hashlib.sha256()
|
||||
with source.open("rb") as stream:
|
||||
for chunk in iter(lambda: stream.read(CHUNK_BYTES), b""):
|
||||
digest.update(chunk)
|
||||
source.replace(directory / "source.ply")
|
||||
doc.update(status="available", sha256=digest.hexdigest(), splat_count=count)
|
||||
write_json(directory / "world.json", doc)
|
||||
return doc
|
||||
|
||||
def prefix_hashes(self, world_id: str) -> dict:
|
||||
"""Verify a resumed file against every byte already admitted, in bounded chunks."""
|
||||
with self.lock:
|
||||
doc = self.get(world_id)
|
||||
if doc.get("storage", {}).get("kind") == "worker":
|
||||
raise RuntimeError("Исходник локации хранится на Worker.")
|
||||
directory = self.directory(world_id)
|
||||
source = directory / "source.part"
|
||||
if not source.exists():
|
||||
source = directory / "source.ply"
|
||||
chunks = []
|
||||
with source.open("rb") as stream:
|
||||
for chunk in iter(lambda: stream.read(CHUNK_BYTES), b""):
|
||||
chunks.append(
|
||||
{"byte_length": len(chunk), "sha256": hashlib.sha256(chunk).hexdigest()}
|
||||
)
|
||||
return {"uploaded_bytes": doc["uploaded_bytes"], "chunks": chunks}
|
||||
|
||||
def configure(self, world_id: str, settings: WorldSettings) -> dict:
|
||||
with self.lock:
|
||||
doc = self.get(world_id)
|
||||
if doc["status"] != "available":
|
||||
raise RuntimeError("Сначала завершите импорт сцены.")
|
||||
doc["settings"] = settings.model_dump(mode="json")
|
||||
write_json(self.directory(world_id) / "world.json", doc)
|
||||
return doc
|
||||
@@ -0,0 +1,231 @@
|
||||
"""Control Station and authenticated simulation-worker ports for AI polygon."""
|
||||
|
||||
import secrets
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
from fastapi import APIRouter, Header, HTTPException, Request, Response
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
from k1link.observatory.recorded_jobs import ObservatoryRecordedJobQueue
|
||||
from k1link.simulation.ai_polygon.composition import compose, registry
|
||||
from k1link.simulation.ai_polygon.contracts import (
|
||||
RealtimeSnapshot,
|
||||
RunApplied,
|
||||
RunCreate,
|
||||
RunProgress,
|
||||
RunSample,
|
||||
ViewControl,
|
||||
WorkerHello,
|
||||
WorkerPoll,
|
||||
WorkerResult,
|
||||
WorkerWorldCreate,
|
||||
WorldCreate,
|
||||
WorldSettings,
|
||||
)
|
||||
from k1link.simulation.ai_polygon.runs import RunStore
|
||||
from k1link.simulation.ai_polygon.worlds import CHUNK_BYTES, SOURCES, WorldStore
|
||||
|
||||
|
||||
def build_ai_polygon_router(
|
||||
data_dir: Path, queue: ObservatoryRecordedJobQueue | None = None
|
||||
) -> APIRouter:
|
||||
worlds = WorldStore(data_dir)
|
||||
runs = RunStore(worlds, queue)
|
||||
router = APIRouter(prefix="/api/v1/ai-polygon", tags=["ai-polygon"])
|
||||
adapters = Path(__file__).resolve().parents[3] / "simulation/ai-polygon"
|
||||
|
||||
def invoke(fn, *args):
|
||||
try:
|
||||
return fn(*args)
|
||||
except FileNotFoundError as exc:
|
||||
raise HTTPException(404, "Локация или прогон не найдены.") from exc
|
||||
except ValueError as exc:
|
||||
raise HTTPException(400, str(exc)) from exc
|
||||
except RuntimeError as exc:
|
||||
raise HTTPException(409, str(exc)) from exc
|
||||
|
||||
def authenticate(authorization: str | None) -> None:
|
||||
expected = f"Bearer {runs.token}"
|
||||
if not authorization or not secrets.compare_digest(authorization, expected):
|
||||
raise HTTPException(401, "Worker authentication required")
|
||||
|
||||
@router.get("/catalog")
|
||||
def catalog():
|
||||
return {
|
||||
"schema_version": "missioncore.ai-polygon-catalog/v1",
|
||||
"sources": SOURCES,
|
||||
"worlds": worlds.list(),
|
||||
"runtime": runs.status(),
|
||||
"runs": runs.list()[:30],
|
||||
}
|
||||
|
||||
@router.post("/worlds", status_code=201)
|
||||
def create_world(body: WorldCreate):
|
||||
return invoke(worlds.create, body)
|
||||
|
||||
@router.get("/worlds/{world_id}")
|
||||
def get_world(world_id: str):
|
||||
return invoke(worlds.get, world_id)
|
||||
|
||||
@router.get("/worlds/{world_id}/upload-prefix")
|
||||
def upload_prefix(world_id: str):
|
||||
return invoke(worlds.prefix_hashes, world_id)
|
||||
|
||||
@router.patch("/worlds/{world_id}/source")
|
||||
async def upload(
|
||||
world_id: str, request: Request, upload_offset: int = Header(alias="Upload-Offset", ge=0)
|
||||
):
|
||||
data = bytearray()
|
||||
async for block in request.stream():
|
||||
if len(data) + len(block) > CHUNK_BYTES:
|
||||
raise HTTPException(413, "Фрагмент загрузки превышает 4 МБ.")
|
||||
data.extend(block)
|
||||
return invoke(worlds.append, world_id, upload_offset, bytes(data))
|
||||
|
||||
@router.post("/worlds/{world_id}/complete")
|
||||
def complete(world_id: str):
|
||||
return invoke(worlds.complete, world_id)
|
||||
|
||||
@router.put("/worlds/{world_id}/settings")
|
||||
def settings(world_id: str, body: WorldSettings):
|
||||
return invoke(worlds.configure, world_id, body)
|
||||
|
||||
@router.get("/worlds/{world_id}/source.ply")
|
||||
def source(world_id: str):
|
||||
row = invoke(worlds.get, world_id)
|
||||
if row["status"] != "available":
|
||||
raise HTTPException(409, "Импорт не завершён.")
|
||||
if row.get("storage", {}).get("kind") == "worker":
|
||||
raise HTTPException(409, "Исходник локации хранится на Worker.")
|
||||
return FileResponse(
|
||||
worlds.directory(world_id) / "source.ply",
|
||||
media_type="application/octet-stream",
|
||||
headers={
|
||||
"ETag": f'"{row["sha256"]}"',
|
||||
"Cache-Control": "private, max-age=31536000, immutable",
|
||||
},
|
||||
)
|
||||
|
||||
@router.post("/runs", status_code=201)
|
||||
def start(body: RunCreate, idempotency_key: str = Header(alias="Idempotency-Key")):
|
||||
if body.clock == "realtime":
|
||||
graph = invoke(compose, adapters, body.composition)
|
||||
body = body.model_copy(update={"composition": graph.selection_document()})
|
||||
return invoke(runs.start, body, idempotency_key)
|
||||
|
||||
@router.get("/ai-modules")
|
||||
def modules():
|
||||
installed = invoke(registry, adapters)
|
||||
graph = invoke(compose, adapters)
|
||||
return {
|
||||
"catalog": {**installed.catalog(), "authority": "virtual-only"},
|
||||
"selection": graph.selection_document(),
|
||||
"composition_sha256": graph.sha256,
|
||||
}
|
||||
|
||||
@router.get("/runs/{run_id}")
|
||||
def get_run(run_id: str):
|
||||
runs.status()
|
||||
return invoke(runs.get, run_id)
|
||||
|
||||
@router.post("/runs/{run_id}/{command}")
|
||||
def control(run_id: str, command: Literal["pause", "play", "step", "stop"]):
|
||||
return invoke(runs.control, run_id, command)
|
||||
|
||||
@router.get("/runs/{run_id}/frames/{sequence}.jpg")
|
||||
def frame(run_id: str, sequence: int):
|
||||
row = invoke(runs.get, run_id)
|
||||
if not 0 <= sequence < row["samples"]:
|
||||
raise HTTPException(404, "Кадр не найден.")
|
||||
return FileResponse(
|
||||
runs.directory(run_id) / "frames" / f"{sequence:06d}.jpg",
|
||||
media_type="image/jpeg",
|
||||
headers={"Cache-Control": "private, max-age=31536000, immutable"},
|
||||
)
|
||||
|
||||
@router.put("/runs/{run_id}/view")
|
||||
def view(run_id: str, body: ViewControl):
|
||||
return invoke(runs.view, run_id, body.camera)
|
||||
|
||||
@router.get("/runs/{run_id}/decisions")
|
||||
def decisions(run_id: str):
|
||||
directory = invoke(runs.directory, run_id)
|
||||
path = directory / "decisions.jsonl"
|
||||
if not path.exists():
|
||||
return Response("", media_type="application/x-ndjson")
|
||||
return FileResponse(path, media_type="application/x-ndjson", filename="decisions.jsonl")
|
||||
|
||||
@router.post("/worker/register")
|
||||
def register(body: WorkerHello, authorization: str | None = Header(default=None)):
|
||||
authenticate(authorization)
|
||||
return invoke(runs.register, body)
|
||||
|
||||
@router.post("/worker/worlds", status_code=201)
|
||||
def register_worker_world(
|
||||
body: WorkerWorldCreate,
|
||||
authorization: str | None = Header(default=None),
|
||||
instance_id: str = Header(alias="Worker-Instance"),
|
||||
):
|
||||
authenticate(authorization)
|
||||
with runs.lock:
|
||||
invoke(runs.heartbeat, instance_id)
|
||||
worker = runs.status()["worker"]
|
||||
return invoke(worlds.register_worker_asset, body, worker["worker_id"])
|
||||
|
||||
@router.post("/worker/heartbeat")
|
||||
def heartbeat(body: WorkerPoll, authorization: str | None = Header(default=None)):
|
||||
authenticate(authorization)
|
||||
return invoke(runs.heartbeat, body.instance_id)
|
||||
|
||||
@router.post("/worker/poll")
|
||||
def poll(body: WorkerPoll, authorization: str | None = Header(default=None)):
|
||||
authenticate(authorization)
|
||||
return invoke(runs.poll, body.instance_id, body.run_id)
|
||||
|
||||
@router.post("/worker/runs/{run_id}/progress")
|
||||
def progress(
|
||||
run_id: str,
|
||||
body: RunProgress,
|
||||
instance_id: str = Header(alias="Worker-Instance"),
|
||||
authorization: str | None = Header(default=None),
|
||||
):
|
||||
authenticate(authorization)
|
||||
return invoke(runs.progress, run_id, instance_id, body.phase)
|
||||
|
||||
@router.post("/worker/runs/{run_id}/samples")
|
||||
def sample(
|
||||
run_id: str,
|
||||
body: RunSample,
|
||||
instance_id: str = Header(alias="Worker-Instance"),
|
||||
authorization: str | None = Header(default=None),
|
||||
):
|
||||
authenticate(authorization)
|
||||
return invoke(runs.sample, run_id, instance_id, body)
|
||||
|
||||
@router.post("/worker/runs/{run_id}/snapshot")
|
||||
def snapshot(
|
||||
run_id: str,
|
||||
body: RealtimeSnapshot,
|
||||
instance_id: str = Header(alias="Worker-Instance"),
|
||||
authorization: str | None = Header(default=None),
|
||||
):
|
||||
authenticate(authorization)
|
||||
return invoke(runs.snapshot, run_id, instance_id, body)
|
||||
|
||||
@router.post("/worker/runs/{run_id}/finish")
|
||||
def finish(run_id: str, body: WorkerResult, authorization: str | None = Header(default=None)):
|
||||
authenticate(authorization)
|
||||
return invoke(runs.finish, run_id, body.instance_id, body.outcome, body.message)
|
||||
|
||||
@router.post("/worker/runs/{run_id}/applied")
|
||||
def applied(
|
||||
run_id: str,
|
||||
body: RunApplied,
|
||||
instance_id: str = Header(alias="Worker-Instance"),
|
||||
authorization: str | None = Header(default=None),
|
||||
):
|
||||
authenticate(authorization)
|
||||
return invoke(runs.applied, run_id, instance_id, body)
|
||||
|
||||
return router
|
||||
@@ -238,6 +238,7 @@ from k1link.missions.registration_runs import RegistrationRuns
|
||||
from k1link.web.mission_registration_api import build_mission_registration_router
|
||||
from k1link.web.mission_planner_api import build_mission_planner_router
|
||||
from k1link.web.simulation_projects_api import build_simulation_projects_router
|
||||
from k1link.web.ai_polygon_api import build_ai_polygon_router
|
||||
from k1link.web.simulation_world_provider_api import build_simulation_world_provider_router
|
||||
from k1link.web.system_telemetry_api import build_system_telemetry_router
|
||||
from k1link.web.vegetation_shadow_lab_api import (
|
||||
@@ -1996,6 +1997,10 @@ app.include_router(
|
||||
)
|
||||
)
|
||||
frontend_dist = REPOSITORY_ROOT / "apps" / "control-station" / "dist"
|
||||
try:
|
||||
app.include_router(build_ai_polygon_router(session_store.data_dir, OBSERVATORY_RECORDED_JOB_QUEUE))
|
||||
except (OSError, ValueError):
|
||||
logging.getLogger(__name__).exception("AI polygon could not load its private runtime state")
|
||||
app.include_router(
|
||||
build_viewer_diagnostics_router(
|
||||
expected_ui_build_id=lambda: frontend_build_id(frontend_dist),
|
||||
|
||||
Reference in New Issue
Block a user