Decisiones de diseño¶
Registro de las decisiones ya tomadas. El agente de desarrollo no debe revertir ninguna sin
discutirlo. A diferencia de radar_emulator, aquí casi todas provienen directamente del
documento de plan de proyecto (Project Plan), no de una sesión de diseño de
este repo — se citan con la sección de origen.
D-01 · El RCP es cliente Modbus, único maestro¶
Decisión. El HAL-simulador del RCP implementa un cliente Modbus TCP. Nunca un servidor.
Por qué. Es la contraparte fijada del lado servidor en radar_emulator
(docs/interfaces/modbus.md: "El emulador actúa como servidor Modbus TCP. El controlador es
el cliente y el único maestro"). No es una decisión de este repo, es la mitad que falta de una
decisión ya tomada en el otro.
D-02 · ORPG feed vive en el RCP, no en el DSP¶
Decisión. El RCP archiva la observación volumétrica y alimenta ORPG. El DSP solo entrega momentos al RCP.
Por qué. Plan, nota de revisión punto 1: el RCP es el nodo que gestiona el archivo de
observaciones; el DSP es headless. Consecuencia entre proyectos: el plan del DSP debe
actualizarse para quitar su contrato DSP↔ORPG — no es tarea de este repo, pero bloquea si no
ocurre en paralelo (ver pendientes).
D-03 · Enlace RCP↔DSP es 1 GbE¶
Decisión. Fuera de la FPGA, 1 Gb Ethernet basta. El camino de alta tasa (ADC/DDC/decimación, 10GbE dentro del DRX) queda interno a la FPGA/DRX, fuera de alcance del RCP.
Por qué. Plan, nota de revisión punto 2.
D-04 · Solo se archiva la observación volumétrica, formato NEXRAD Level-II¶
Decisión. El RCP archiva base data (Z/V/W + dual-pol, por volumen), no productos derivados. Level-II es el formato primario de archivo/salida.
Por qué. Plan, nota de revisión punto 3. La generación de producto es responsabilidad exclusiva de ORPG.
D-05 · La emulación RDA se implementa completa, no parcial¶
Decisión. RCP↔ORPG (ICD 2620002) es un entregable crítico de Stage 1 completo: máquina de estados RDA, Msg 31/1, Msg 2 (status), Msg 11/12 (loopback), Msg 6 (control), Msg 13/15 (clutter/bypass), VCP, servidor TCP con login. No un encoder parcial.
Por qué. Plan, nota de revisión punto 4: el sistema depende 100% de ORPG para producto, así que esta interfaz no puede quedar a medias.
D-06 · HAL con dos adaptadores intercambiables, nada más arriba distingue cuál¶
Decisión. Una interfaz HAL abstracta; adaptador real (Modbus/Profibus vía SBC) y adaptador
simulador (contra radar_emulator) la implementan por igual.
Por qué. Plan §4.3: valida en simulador ahora, comisiona en hardware después. El adaptador simulador es, en la práctica, el cliente Modbus + receptor UDP descritos en contexto.md.
D-07 · Single operator, arbitraje de control colapsado a passive/active¶
Decisión. Se descarta el protocolo RCL heredado y el arbitraje de cuatro niveles de Ravis. Un solo toggle passive (monitor) / active (control).
Por qué. Plan §1, §4.3: un solo operador, red air-gapped, sin requisito de multi-operador ni control-authority arbitration.
D-08 · Stack: Python 3.12 + FastAPI + Pydantic v2 + asyncio; Vue 3 + TS + Vite en frontend¶
Decisión. Backend según plan §5: FastAPI/Uvicorn, Pydantic v2 para contratos, asyncio para orquestación soft-real-time, pymodbus para el cliente Modbus, NumPy/SciPy para momentos y codificación Level-II, SQLite para metadata de scan/status/evento. Frontend: Vue 3 + TypeScript + Vite, Pinia + Vue Router, Tailwind, PixiJS (PPI/RHI) + uPlot (ASCOPE).
Por qué. Plan §5, tabla completa. Preferencia del equipo ya fijada, no decisión de este repo.
Resuelto (2026-08-19), primer intento: componentes = PrimeVue. El plan (§5) marcaba esto "decisión necesaria" entre PrimeVue y shadcn-vue (Reka UI). Se eligió PrimeVue por los widgets batteries-included (DataTable, Dialog, Knob, Gauge) que se necesitan para controles de antena y BITE en Fase 2, frente al costo de construirlos a mano sobre un set headless.
Revertido el mismo día, al scaffoldear la MMI: primevue@5.0.1 (la última versión al momento
de instalar) mostró en dev un banner "Invalid PrimeUI License" — PrimeVue se relicenció bajo
"PrimeUI" a partir de v5: ya no es MIT libre sin condiciones, requiere license key incluso en el
tier gratuito ("Community"), con límites de tamaño de organización (< $1M USD de ingresos
anuales, < 5 developers, < 10 empleados, < $3M de capital externo) y renovación anual de
elegibilidad. Esto no se supo hasta después de tomar la decisión — la elección original no pesó
licenciamiento porque hasta v4 PrimeVue era MIT sin condiciones.
Resuelto definitivamente: componentes = shadcn-vue (Reka UI). Sin licencia de terceros que gestionar (Reka UI es MIT); el costo es construir a mano los widgets no estándar (knob, gauge) que Fase 2 va a necesitar para controles de antena/BITE — se acepta ese costo a cambio de no depender de una license key en un sistema air-gapped de shelf-life largo. Ver pendientes.md.
D-09 · Empaquetado offline, air-gapped¶
Decisión. Docker Compose (bundle de imágenes offline) como opción primaria; PyInstaller como alternativa. Sin acceso a paquetes en el target de despliegue.
Por qué. Plan §5, §12: entorno de desarrollo/CI puede tener acceso a paquetes (o un mirror interno), el target de despliegue no.
Resuelto (2026-08-19): vendorizado de wheels Python vía uv con lockfile (uv.lock) —
resolución/instalación en CI, wheels cacheados en capa Docker. Ver
pendientes.md.
D-10 · core/contracts/mmi.py se extiende con DspStreamStatus, no con momentos por WS¶
Decisión (2026-08-19). Al conectar el stub de stream DSP
(spike-fase0/dsp_moment_stream_spike.py) al gateway (Fase 1), el contrato RCP↔MMI ya congelado
se amplía con un campo dsp: DspStreamStatus | None en SystemStatusSnapshot (contadores +
último volumen/elevación/status), no con un tipo de mensaje WS que lleve momentos completos.
Por qué. mmi.py documenta explícitamente que las vistas PPI/RHI/ASCOPE (que sí necesitarían
momentos en vivo) son de Fase 2/3 — resolver la forma de ese streaming ahora sería inventarla sin
acuerdo del equipo, justo lo que el contrato existe para evitar. Un campo de estado resumido no
choca con eso: no es la vista PPI, es visibilidad de "¿está llegando el stream, hasta dónde
llegó?" para el operador/diagnóstico.
Nota: es una ampliación de un contrato marcado como congelado (AGENTS.md), no una reinterpretación de algo ya decidido — se registra aquí para que quede trazable. Ver pendientes.md.
D-11 · PEND-RCP-07 (Rutinas 2–6) se acepta como suficientemente confirmado por silencio¶
Decisión (2026-08-20). El usuario indica que el product expert no ha objetado nada de
docs/operacion/rutinas-control.md para las Rutinas 2–6 (solo la Rutina 1 tuvo feedback
explícito, absorbido como PEND-27/PEND-28 en radar_emulator). Con esto, PEND-RCP-07 se cierra
como "aceptado", se sigue con otro trabajo de Fase 2/3.
Por qué. Decisión operativa del usuario para no bloquear el avance, no una confirmación técnica nueva del experto.
Salvedad importante: esto es inferencia por ausencia de objeción, no una revisión línea por línea confirmada como la que sí ocurrió para la Rutina 1 (PEND-RCP-06). Si en algún momento el product expert señala algo concreto sobre Rutinas 2–6, tiene prioridad sobre esta decisión sin necesidad de discutirla primero (a diferencia del resto de decisiones de este documento).
D-12 · Los seis endpoints POST /api/control/* pasan a un patrón de job asíncrono¶
Decisión (2026-08-20). Los seis endpoints de ejecución de rutinas de control
(docs/alcance/pendientes.md, sección "Rutinas de control cableadas al gateway + MMI") dejan de
bloquear la respuesta HTTP hasta que la rutina termina. POST /api/control/* responde 202 de
inmediato con {job_id, routine, status: "running"} (ControlJobAccepted) y arranca la rutina en
un asyncio.create_task de fondo; GET /api/control/jobs/{job_id} expone
ControlJobStatusResponse (status: running|done, result: RoutineResult|None,
error: str|None). La MMI (useGateway.ts: runControlJob) sondea ese GET cada 400 ms hasta
done — cada vista sigue viendo la misma forma "await, obtengo el RoutineResult final" que ya
tenía antes de este cambio, solo que ahora la promesa puede tardar de verdad (hasta timeout_s,
minutos en algunos casos) sin dejar la conexión HTTP original colgada ni bloqueando el hilo del
navegador.
Por qué. El usuario preguntó explícitamente cómo manejar el estado de estos endpoints en procesos largos — el diseño anterior (200 bloqueante) no daba ninguna señal de progreso ni sobrevivía a una recarga de página a mitad de camino. Se evaluó también emitir el progreso por el canal WS ya existente (mismo patrón que BITE), pero se descartó: ningún consumidor lo iba a usar todavía (la MMI ya resuelve el caso con polling simple), así que hubiera sido construir para un requisito hipotético — el mismo error que este repo ya evita en otros lados (YAGNI).
error vs. result con outcome en failed/interrupted: son cosas distintas a propósito.
Un RoutineResult con outcome != success es un resultado legítimo de la rutina (una
precondición no se cumplió, la guarda de seguridad interrumpió el movimiento). error es para
cuando la corrutina lanza una excepción de infraestructura no prevista (ej. el HAL se desconecta
a mitad de camino) — no debe confundirse con un fallo operativo normal.
Historial acotado: app.state.control_jobs guarda como máximo 50 jobs
(CONTROL_JOB_HISTORY_LIMIT), descartando el más viejo al superar el tope — mismo criterio que
MAX_LOG en useGateway.ts, evita crecimiento sin límite en una sesión larga con muchos clics.
Sin resolver, fuera de alcance de esta decisión: cancelación de un job en curso (ver
docs/alcance/pendientes.md).
D-13 · Gate de "modo mantenimiento" para acciones de calibración, aparte del toggle passive/active¶
Decisión (2026-08-27). Las acciones que escriben calibración — commit del workflow manual de
single-point/TX power calibration, y ediciones de la carpeta de constantes de la ecuación del
radar (docs/referencia/project-plan.md §4.5, DRX/RSP Control & Calibration, sub-vistas 2 y 6) —
quedan detrás de un segundo gate explícito, modo mantenimiento, además del toggle activo ya
existente (D-07). Sin modo mantenimiento activado, esos endpoints rechazan la escritura tanto en
cliente como en servidor — mismo criterio que ya usa la guarda de seguridad de parámetros para
límites de antena (rechazo en los dos lados, no solo en la UI).
Alcance del gate — explícitamente acotado: solo cubre las dos sub-vistas de arriba. No cubre TX/RX adjustment (setpoints operativos del día a día, con lectura en vivo inmediata) ni Trigger/timing setup — esas siguen detrás de nada más que el toggle activo, igual que el resto de los controles. Zero Check queda exento por diseño: es muestreo automático de piso de ruido en boot + intervalo de fondo, sin acción de operador — no hay nada que gatear.
Mecánica: entrar a modo mantenimiento es en sí una acción de operador logueada (mismo criterio que el log de actividad de calibración ya descrito en el plan). Es de alcance de sesión: se limpia solo al ceder control activo (volver a passive) o al desconectarse de la sesión — no persiste "desbloqueado" entre sesiones ni queda como estado olvidado.
Por qué. Cierra PEND-RCP-11: una calibración mal aplicada no da señal visible inmediata (a diferencia de mover la antena) — degrada silenciosamente la precisión de reflectividad de todas las observaciones siguientes hasta que alguien note el drift. Un gate de acción, no de operador: no reabre D-07 ni reinstala el arbitraje de cuatro niveles de Ravis — sigue habiendo un solo operador con control activo; el gate es sobre qué tipo de acción ese mismo operador puede ejecutar en un momento dado, no sobre quién tiene autoridad. Decisión operativa del usuario, no del product expert — igual que D-11, queda sujeta a revisión si el product expert señala algo distinto al llegar a Fase 3.
D-14 · StatusMessage por WS para que A6 (SD/RD) detecte stale de verdad¶
Decisión (2026-09-19). GET /api/status (hal_connected, dsp) solo se pedía una vez al
montar cada vista — sin refresco, los indicadores SD/RD del inventario de UI
(docs/diseno/inventario-ui.md, A6) quedaban congelados en lo que vieron al arrancar y nunca
podían mostrar el estado stale ("hay conexión pero el dato no se refresca hace 5s") que el
propio documento marca como el más peligroso de los cinco. Se agrega StatusMessage (nuevo tipo
del sobre WS discriminado, core/contracts/mmi.py), emitido cada WS_STATUS_PERIOD_S (1 s,
src/adapters/gateway/app.py) con hal_connected y dsp — mismo criterio D-10 que
ControlJobStatusResponse.result: se amplía el contrato ya congelado para una capacidad nueva y
necesaria, no se reinterpreta nada ya fijado. HeartbeatMessage no se toca ni se fusiona con
este mensaje nuevo: sigue siendo el keep-alive documentado en docs/implementacion/fases.md.
DspStreamStatus gana last_radial_at_wall (MomentStreamReceiver.last_radial_at, nuevo):
connected solo refleja el socket TCP del emisor, no si sigue mandando radiales de verdad — sin
este timestamp, un emisor conectado pero congelado seguía pintando RD en verde.
Por qué no polling desde la MMI. Se evaluó (y se descartó) que useGateway.ts reintentara
GET /api/status cada ~2s en vez de tocar el contrato WS: es menos código, pero el resultado
depende de la cadencia de sondeo del cliente, no de un dato real del servidor, y el resto del
estado en vivo de la app (AntennaMessage, BiteEventMessage) ya viaja por WS — meter un segundo
mecanismo de refresco (polling HTTP) para un solo indicador hubiera sido inconsistente sin
necesidad.
Cómo se deriva stale del lado de la MMI (useGateway.ts): sin comparar relojes de pared
entre máquinas (AGENTS.md, "dos clocks") — todo se mide contra Date.now() del propio navegador
en el momento de recibir cada dato, nunca contra at_wall/last_radial_at_wall del servidor.
SD stale si no llega un StatusMessage nuevo en >5s (el mensaje dejar de llegar es la señal
de canal de estado caído, no hace falta un campo aparte); RD stale si dsp.connected es cierto
pero radials_received no cambió en >5s. fault (rojo) sigue siendo distinto de stale (gris):
sin conexión vs. conectado-sin-datos, tal como exige la tabla de A6 del inventario.