SPEC — Simulation / System Clock¶
- Estado: congelado en Fase 0
- ADR principal: ADR-0002
1. Responsabilidades¶
- Proveer una única fuente de tiempo dentro de la simulación.
- Permitir step síncrono determinista.
- Coordinar callbacks periódicos de múltiples consumidores (sensores, control, telemetría).
- Proveer aleatoriedad reproducible via
RandomSource.
No es responsabilidad del Clock:
- Conocer el contenido de los mensajes.
- Persistir nada a disco.
- Coordinar entrega ordenada (eso lo hace el bus consumiendo timestamps del clock).
2. Interfaces¶
class SimClock(Protocol):
def now_ns(self) -> int: ...
def step_ns(self) -> int: ... # tamaño del paso del backend
def advance(self, dt_ns: int) -> None: ... # solo backend de sim
def schedule(self, at_ns: int, cb: Callable[[], None]) -> Handle: ...
def schedule_periodic(self, period_ns: int, cb: Callable[[], None],
phase_ns: int = 0) -> Handle: ...
def random_source(self) -> RandomSource: ...
class SystemClock(Protocol): # hardware o sim free-running
def now_ns(self) -> int: ...
def random_source(self) -> RandomSource: ...
@dataclass(frozen=True)
class Handle:
cancel: Callable[[], None]
3. Contratos vinculantes¶
- Unidad atómica entera en nanosegundos. No se aceptan
floatparanow_ns(),advance(),at_ns,period_ns. La conversión a segundos es solo para visualización. - Monotonía.
clock.now_ns()nunca retrocede. advance()solo enSimClock. En hardware el tiempo avanza solo.- Determinismo. En
SimClock, dada la misma seed y el mismo conjunto deschedule_periodiccon los mismos parámetros, el orden y los timestamps de los callbacks son idénticos. schedule_periodic(period, cb, phase)disparacbent = phase, phase+period, phase+2·period, ...siempre quet ≤ now_ns(). La acumulación se hace enint, no enfloat.- Cancelación segura.
handle.cancel()puede llamarse en cualquier momento sin lanzar; double-cancel es idempotente.
4. Aleatoriedad¶
class RandomSource(Protocol):
seed: int
label: str # ruta jerárquica, ej. "/imu_noise"
def child(self, label: str) -> "RandomSource": ...
def uniform(self, a: float, b: float) -> float: ...
def normal(self, mu: float, sigma: float) -> float: ...
def integers(self, low: int, high: int) -> int: ...
def numpy_rng(self) -> np.random.Generator: ...
Reglas:
- Única raíz. El
ScenarioSpec.seedes la única semilla declarada. - Subgeneradores deterministas.
child("imu_noise")deriva de la raíz vía hash determinista de la etiqueta. Mismo árbol de etiquetas + misma raíz = mismos números. - Prohibido
random.*ynp.random.*globales. Linter custom (entools/lint_random.py) detecta usos en el código del proyecto. - Etiquetas jerárquicas. Convención:
/<modulo>/<sub>/<...>. Ej:/sensors/imu0/noise,/sensors/cam_front/dropout.
5. Arquitectura del scheduler¶
Implementación recomendada: time wheel o min-heap. Para Fase 1 un min-heap es suficiente; al crecer la carga puede sustituirse sin cambiar la API.
Pseudo-flujo de advance(dt_ns):
target = now + dt
while heap.peek().at_ns <= target:
item = heap.pop()
now = item.at_ns
item.cb()
if item.periodic:
heap.push(at_ns=item.at_ns + item.period_ns, ...)
now = target
Reglas:
- Si un callback programa nuevos eventos cuyo
at_ns ≤ target, se procesan dentro del mismoadvance(). - Si un callback lanza excepción, se captura, se publica
Event(SCHEDULER_CALLBACK_FAILED)y el scheduler continúa.
6. Orden total¶
Cada llamada a bus.publish() adquiere un sequence global atómico. La tupla (stamp_sim_ns, sequence) es el orden total. Subscribers reciben en este orden, no en orden de inscripción.
Para callbacks del scheduler ejecutados con el mismo at_ns, el orden lo define el orden de inserción en el heap (FIFO para empates). Esto debe documentarse en la implementación y testearse.
7. Replay¶
ReplayClock implementa SimClock leyendo timestamps del MCAP:
advance(dt_ns)reproduce todos los mensajes y eventos del MCAP cuyo stamp cae en(now, now+dt], en el mismo orden total.- Los callbacks de control no se ejecutan automáticamente en replay (se pueden activar opcionalmente).
RandomSourceen replay no produce números (no se necesita), o se reproduce del log si fue persistido.
8. Casos de uso¶
8.1 Loop principal de Fase 1¶
clock = backend.clock # SimClock determinista
clock.schedule_periodic(5_000_000, controller.tick) # 200 Hz
clock.schedule_periodic(20_000_000, telemetry.flush_tick) # 50 Hz
while clock.now_ns() < END_NS:
backend.step(1_000_000) # 1 ms
8.2 Aleatoriedad limpia¶
rng = clock.random_source().child("/sensors/imu0/noise")
accel_noise = rng.normal(0.0, 0.05, size=3)
8.3 Hardware¶
clock = backend.clock # SystemClock
def tick():
process_sensors()
publish_command()
schedule_periodic_thread(period_ns=10_000_000, cb=tick, clock=clock)
(En hardware el "schedule" es un thread loop con time.sleep_ns() calibrado, no el time-wheel determinista.)
9. Errores comunes a evitar¶
- Usar
time.time()otime.monotonic()dentro del backend de sim. Violación de contrato. - Acumular
dtenfloat. Drift garantizado. - Pedir un
childcon etiqueta dinámica (ej:f"/run_{uuid}"). Rompe determinismo. - Programar callbacks que lanzan sin manejar. El scheduler los aísla pero el bug queda invisible.
- Confiar en que dos
schedule_periodiccon mismo periodo y fase distinta no se intercalen "raramente". El orden está fijado; testearlo.
10. Evolución futura¶
- En hardware real:
SystemClockpuede leer del reloj de PX4 vía MAVLink para alinear con el firmware. - Soporte PTP / hardware timestamping en cámaras (Fase 9+).
- Modo "wall-clock-throttle" en sim para correr a velocidad real (no determinista, solo para demos).