Files
alro65 c432fc3725 feat: sea-state model + 7 bad-weather / stress TCs (14/14 pass)
esp32_sim.py — Sea state engine
  • _BEAUFORT_TABLE (B0–B8): wave torque, swell, noise, wind-bias, period
  • set_sea_state(beaufort, seed): injects external_yaw_torque into
    VesselHeadingSimulator each tick (sine wave + swell + white noise +
    weather-helm drift).  B4≈±3° heading osc, B6 disengages correctly.
  • tune_response(rudder_kp, counter_rudder, max_rudder_deg): runtime
    gain adjustment — simulates the classic Robertson "RUDDER" /
    "COUNTER RUDDER" / working-rudder-limit knobs.
  • _compute_wave_torque(): composed 4-component disturbance model.

sim_protocol.py — 7 new demanding test cases
  TC-08  Beaufort 4, 5 min     max dev 10.4°, RMS 5.6°, no disengage
  TC-09  Beaufort 6 operational limit — SEVERE alarm fires at 35.8 s
         (correct safety behaviour; documents factory-gain limit)
  TC-10  Low speed 2 kn, +10°  τ_outer≈113 s, settles 0.83° < 3°
  TC-11  180° reversal          no SEVERE (tracking_settled guard), 1.18°
  TC-12  Rapid setpoint stress  5 changes in 100 s, final error 1.17°
  TC-13  Gain boost in B5       1.70× RMS improvement (12.3°→7.2°),
         demonstrates RUDDER+COUNTER RUDDER tuning effect
  TC-14  B7 spike (30 s)       SEVERE fires correctly, re-engages,
         recovers to 0.0° within 90 s of calm return

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 08:26:37 -04:00

1504 lines
51 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
PROPÓSITO
---------
Protocolo de pruebas de simulación para AR-Autopilot.
POR QUÉ EXISTE
--------------
Valida el comportamiento del autopiloto en 7 casos de prueba canónicos
antes de conectar el ESP32 real. Cada TC crea un simulador limpio, ejecuta
un escenario, evalúa criterios de aceptación y reporta PASS / FAIL con
métricas cuantitativas.
Al ejecutarse produce dos salidas:
- Resumen en consola (tabla ASCII)
- Informe HTML completo con gráficas interactivas (Chart.js)
CÓMO USARLO
-----------
python tools/sim_protocol.py # todos los TCs
python tools/sim_protocol.py TC-02 TC-04 # solo esos TCs
python tools/sim_protocol.py --out mi_reporte.html
RELACIONADO
-----------
tools/esp32_sim.py — simulador ESP32 en Python
docs/TEST_PROTOCOL.md — especificación del protocolo
"""
from __future__ import annotations
import argparse
import html
import json
import math
import sys
import textwrap
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from typing import Callable
# Añadir raíz del proyecto al path
sys.path.insert(0, str(Path(__file__).parent.parent))
from arautopilot.studio.simulator.vessel_heading import heading_error_deg
from tools.esp32_sim import AutopilotMode, ESP32Simulator, SimSnapshot
# ---------------------------------------------------------------------------
# Estructuras de datos del protocolo
# ---------------------------------------------------------------------------
@dataclass
class TCSpec:
id: str
name: str
description: str
acceptance: str # Criterios de aceptación en lenguaje natural
@dataclass
class TCResult:
spec: TCSpec
passed: bool
metrics: dict[str, float | str]
message: str
snapshots: list[SimSnapshot]
events: list
# ---------------------------------------------------------------------------
# Casos de prueba
# ---------------------------------------------------------------------------
def tc01_power_on_engage() -> TCResult:
"""
TC-01 — Arranque y enganche
===========================
Verifica que el autopiloto parte en STANDBY y transita correctamente a
HEADING_HOLD cuando hay rumbo NMEA válido y se envía CMD_ENGAGE_REQUEST.
Criterios de aceptación:
• Arranca en STANDBY
• Transita a HEADING_HOLD tras engage
• Mantiene HEADING_HOLD durante 10 s sin alarmas
• Error final < 1°
"""
spec = TCSpec(
id="TC-01",
name="Arranque y enganche en HEADING_HOLD",
description="El piloto arranca en STANDBY y engancha correctamente con rumbo NMEA válido.",
acceptance="Modo final HEADING_HOLD, error < 1°, sin alarmas en 10 s.",
)
sim = ESP32Simulator()
# Verificar estado inicial STANDBY
started_in_standby = sim.mode == AutopilotMode.STANDBY
# Inyectar rumbo válido y velocidad
sim.inject_nmea(heading_deg=90.0, sog_kn=10.0)
sim.step(0.5) # deja que el NMEA se estabilice
# Configurar holdings y enganchar
sim.write_holding(0, 1) # MODE_REQUEST = HEADING_HOLD
sim.write_holding(1, 9000) # HEADING_SETPOINT_X100 = 90.00°
sim.write_coil(0, 1) # CMD_ENGAGE_REQUEST
sim.step(0.1) # procesar el engage
engaged_in_hh = sim.mode == AutopilotMode.HEADING_HOLD
# Correr 10 segundos manteniendo el rumbo
sim.step(10.0)
final_error = abs(heading_error_deg(90.0, sim.heading))
any_alarm = sim.any_alarm
final_mode = sim.mode
passed = (
started_in_standby
and engaged_in_hh
and final_mode == AutopilotMode.HEADING_HOLD
and final_error < 1.0
and not any_alarm
)
return TCResult(
spec=spec,
passed=passed,
metrics={
"started_in_standby": "Sí" if started_in_standby else "No",
"engaged_correctly": "Sí" if engaged_in_hh else "No",
"final_mode": final_mode.name,
"final_error_deg": round(final_error, 3),
"any_alarm": "No" if not any_alarm else "Sí",
},
message=(
f"Inicio en STANDBY: {'OK' if started_in_standby else 'NO'} | "
f"Enganche: {'OK' if engaged_in_hh else 'NO'} | "
f"Error final: {final_error:.3f}° | "
f"Alarmas: {'ninguna' if not any_alarm else 'PRESENTES'}"
),
snapshots=sim.log,
events=sim.events,
)
def tc02_small_heading_change() -> TCResult:
"""
TC-02 — Cambio de rumbo pequeño (10°)
======================================
El autopiloto está enganchado en 90°. Se ordena virar a 100°.
Mide tiempo de asentamiento, sobrepasamiento y error residual.
Criterios de aceptación:
• Asentamiento (|error| < 1°) en < 60 s
• Sobrepasamiento < 5°
• Error final < 1°
"""
spec = TCSpec(
id="TC-02",
name="Cambio de rumbo pequeño +10°",
description="Maniobra de 90° → 100°. Evalúa tiempo de asentamiento y sobrepasamiento.",
acceptance="Asentamiento < 60 s, sobrepasamiento < 5°, error final < 1°.",
)
sim = ESP32Simulator()
TARGET = 100.0
INITIAL = 90.0
sim.inject_nmea(heading_deg=INITIAL, sog_kn=10.0)
sim.write_holding(0, 1)
sim.write_holding(1, int(INITIAL * 100))
sim.write_coil(0, 1)
sim.step(5.0) # estabilizar en 90°
# Nuevo setpoint: 100°
sim.write_holding(1, int(TARGET * 100))
t_step = sim.t
sim.step(120.0)
snapshots_after = [s for s in sim.log if s.t >= t_step]
# Tiempo de asentamiento: último instante con |error| > 1°
settling_time = _settling_time(snapshots_after, TARGET, tolerance=1.0)
# Sobrepasamiento: cruce al otro lado del setpoint
overshoot = _overshoot(snapshots_after, INITIAL, TARGET)
final_error = abs(heading_error_deg(TARGET, sim.heading))
passed = (
sim.mode == AutopilotMode.HEADING_HOLD
and final_error < 1.0
and settling_time is not None
and settling_time < 60.0
and overshoot < 5.0
)
return TCResult(
spec=spec,
passed=passed,
metrics={
"final_error_deg": round(final_error, 3),
"settling_time_s": round(settling_time, 1) if settling_time else ">120",
"overshoot_deg": round(overshoot, 2),
"final_mode": sim.mode.name,
},
message=(
f"Error: {final_error:.3f}° | "
f"Asentamiento: {settling_time:.1f}s | "
f"Sobrepasamiento: {overshoot:.2f}°"
if settling_time else
f"Error: {final_error:.3f}° | NO ASENTÓ en 120 s"
),
snapshots=sim.log,
events=sim.events,
)
def tc03_large_heading_change() -> TCResult:
"""
TC-03 — Cambio de rumbo grande (90°)
=====================================
Maniobra agresiva de 90°. Verifica que el ROT feed-forward limita el
sobrepasamiento y que el cascada PID completa la maniobra sin oscilar.
Criterios de aceptación:
• Asentamiento (|error| < 2°) en < 180 s
• Sobrepasamiento < 10°
• Error final < 2°
• Sin alarma OFF_COURSE (error no supera 30°)
"""
spec = TCSpec(
id="TC-03",
name="Cambio de rumbo grande +90°",
description="Maniobra de 90° → 180°. Valida el ROT feed-forward y la cascada completa.",
acceptance="Asentamiento < 180 s, sobrepasamiento < 10°, error final < 2°.",
)
sim = ESP32Simulator()
TARGET = 180.0
INITIAL = 90.0
sim.inject_nmea(heading_deg=INITIAL, sog_kn=10.0)
sim.write_holding(0, 1)
sim.write_holding(1, int(INITIAL * 100))
sim.write_coil(0, 1)
sim.step(5.0)
t_step = sim.t
sim.write_holding(1, int(TARGET * 100))
sim.step(240.0)
snapshots_after = [s for s in sim.log if s.t >= t_step]
settling_time = _settling_time(snapshots_after, TARGET, tolerance=2.0)
overshoot = _overshoot(snapshots_after, INITIAL, TARGET)
final_error = abs(heading_error_deg(TARGET, sim.heading))
alarm_severe = sim.read_discrete(17) or sim._alarm_off_course_severe
passed = (
sim.mode == AutopilotMode.HEADING_HOLD
and final_error < 2.0
and settling_time is not None
and settling_time < 180.0
and overshoot < 10.0
and not alarm_severe
)
return TCResult(
spec=spec,
passed=passed,
metrics={
"final_error_deg": round(final_error, 3),
"settling_time_s": round(settling_time, 1) if settling_time else ">240",
"overshoot_deg": round(overshoot, 2),
"alarm_off_course_severe": "No" if not alarm_severe else "Sí",
},
message=(
f"Error: {final_error:.3f}° | "
f"Asentamiento: {settling_time:.1f}s | "
f"Sobrepasamiento: {overshoot:.2f}°"
),
snapshots=sim.log,
events=sim.events,
)
def tc04_boundary_crossing() -> TCResult:
"""
TC-04 — Cruce de frontera 0°/360°
==================================
El buque va a 355° y se ordena virar a 010°. La lógica de arco más
corto debe elegir +15° (a estribor) en vez de -345° (a babor).
Criterios de aceptación:
• El timón gira a ESTRIBOR (positivo) durante la maniobra
• Asentamiento en 010° en < 90 s
• Error final < 1°
"""
spec = TCSpec(
id="TC-04",
name="Cruce de frontera 355° → 010°",
description=(
"Maniobra que cruza el meridiano 0°/360°. "
"Verifica que el shortest-arc elige el camino de +15° (estribor)."
),
acceptance="Timón a estribor, asentamiento < 90 s, error final < 1°.",
)
sim = ESP32Simulator()
INITIAL = 355.0
TARGET = 10.0
sim.inject_nmea(heading_deg=INITIAL, sog_kn=10.0)
sim.write_holding(0, 1)
sim.write_holding(1, int(INITIAL * 100))
sim.write_coil(0, 1)
sim.step(5.0)
t_step = sim.t
sim.write_holding(1, int(TARGET * 100))
sim.step(120.0)
snapshots_after = [s for s in sim.log if s.t >= t_step]
# Verificar que el timón fue a estribor (positivo)
max_rudder_stbd = max(s.rudder_angle_deg for s in snapshots_after[:30])
went_starboard = max_rudder_stbd > 2.0
settling_time = _settling_time(snapshots_after, TARGET, tolerance=1.0)
final_error = abs(heading_error_deg(TARGET, sim.heading))
passed = (
went_starboard
and final_error < 1.0
and settling_time is not None
and settling_time < 90.0
)
return TCResult(
spec=spec,
passed=passed,
metrics={
"went_starboard": "Sí" if went_starboard else "No",
"max_rudder_stbd_deg": round(max_rudder_stbd, 2),
"final_error_deg": round(final_error, 3),
"settling_time_s": round(settling_time, 1) if settling_time else ">120",
},
message=(
f"Timón a estribor: {'OK' if went_starboard else 'NO'} "
f"(máx={max_rudder_stbd:.1f}°) | "
f"Error: {final_error:.3f}° | "
f"Asentamiento: {settling_time:.1f}s" if settling_time else
f"Timón a estribor: {'OK' if went_starboard else 'NO'} | NO ASENTÓ"
),
snapshots=sim.log,
events=sim.events,
)
def tc05_manual_disengage() -> TCResult:
"""
TC-05 — Desenganche manual
==========================
El autopiloto está activo. El operador pulsa DISENGAGE.
El timón debe dejar de ser controlado (PWM = 0) inmediatamente.
Criterios de aceptación:
• El modo pasa a STANDBY en ≤ 1 tick (0.02 s)
• El PWM cae a 0 tras el desenganche
• El timón no recibe nuevas órdenes después del desenganche
"""
spec = TCSpec(
id="TC-05",
name="Desenganche manual (DISENGAGE)",
description=(
"Pulsar CMD_DISENGAGE_REQUEST mientras el autopiloto está en HEADING_HOLD. "
"El sistema debe pasar a STANDBY y soltar el timón."
),
acceptance="STANDBY en ≤ 0.02 s, PWM = 0, sin nuevas órdenes al timón.",
)
sim = ESP32Simulator()
sim.inject_nmea(heading_deg=90.0, sog_kn=10.0)
sim.write_holding(0, 1)
sim.write_holding(1, int(100.0 * 100)) # setpoint 100° (activo, generando timón)
sim.write_coil(0, 1)
sim.step(0.1)
sim.step(10.0) # dejar que el timón esté aplicando corrección
rudder_before = sim.rudder
pwm_before = sim._inner_pwm_pct
mode_before_engaged = sim.mode == AutopilotMode.HEADING_HOLD
# DISENGAGE
sim.write_coil(1, 1)
sim.step(0.1) # un tick para procesar
mode_after = sim.mode
pwm_after = sim._inner_pwm_pct
disengage_ok = mode_after == AutopilotMode.STANDBY
# Correr 5 s más: el timón no debe recibir nuevas órdenes
rudder_samples_after = []
for _ in range(50):
sim.step(0.1)
rudder_samples_after.append(sim.rudder)
rudder_still_moving = (
max(rudder_samples_after) - min(rudder_samples_after) > 0.5
)
passed = (
mode_before_engaged
and disengage_ok
and pwm_after == 0.0
and not rudder_still_moving
)
return TCResult(
spec=spec,
passed=passed,
metrics={
"was_engaged": "Sí" if mode_before_engaged else "No",
"mode_after": mode_after.name,
"pwm_after_pct": round(pwm_after, 2),
"rudder_drift_after_deg": round(
max(rudder_samples_after) - min(rudder_samples_after), 3
),
},
message=(
f"Antes: {mode_before_engaged and 'HH' or 'N/A'} | "
f"Después: {mode_after.name} | "
f"PWM post-disengage: {pwm_after:.2f}% | "
f"Timón comandado post-disengage: {'No' if not rudder_still_moving else 'Sí (FALLO)'}"
),
snapshots=sim.log,
events=sim.events,
)
def tc06_heading_lost_alarm() -> TCResult:
"""
TC-06 — Alarma HEADING_LOST (pérdida de sensor)
================================================
El compass NMEA 2000 deja de enviar datos. Tras 5 s el firmware
debe disparar ALARM_HEADING_LOST y desengranarse automáticamente.
Criterios de aceptación:
• ALARM_HEADING_LOST activa en 5.06.0 s desde la pérdida
• El modo pasa a STANDBY automáticamente
• El discrete[19] (ALARM_HEADING_LOST) = 1
"""
spec = TCSpec(
id="TC-06",
name="Alarma HEADING_LOST y auto-desenganche",
description=(
"Simulación de pérdida del sensor NMEA 2000. "
"El autopiloto debe detectarla y disenganchar automáticamente."
),
acceptance=(
"Alarma activa en 56 s, modo STANDBY, discrete[19]=1."
),
)
sim = ESP32Simulator()
sim.inject_nmea(heading_deg=90.0, sog_kn=10.0)
sim.write_holding(0, 1)
sim.write_holding(1, 9000)
sim.write_coil(0, 1)
sim.step(5.0) # estabilizar
assert sim.mode == AutopilotMode.HEADING_HOLD, "Precondición: debe estar enganchado"
t_loss = sim.t
sim.disconnect_nmea_heading()
# Avanzar en pequeños pasos registrando cuándo dispara la alarma
alarm_fired_at: float | None = None
disengage_at: float | None = None
for _ in range(100): # hasta 10 s
sim.step(0.1)
if sim.read_discrete(19) and alarm_fired_at is None:
alarm_fired_at = sim.t
if sim.mode == AutopilotMode.STANDBY and disengage_at is None:
disengage_at = sim.t
time_to_alarm = (alarm_fired_at - t_loss) if alarm_fired_at else None
passed = (
alarm_fired_at is not None
and 4.5 <= (alarm_fired_at - t_loss) <= 7.0
and sim.mode == AutopilotMode.STANDBY
and sim.read_discrete(19) == 1
)
return TCResult(
spec=spec,
passed=passed,
metrics={
"time_to_alarm_s": round(time_to_alarm, 2) if time_to_alarm else "nunca",
"final_mode": sim.mode.name,
"discrete_19": sim.read_discrete(19),
},
message=(
f"Alarma a los {time_to_alarm:.2f}s post-pérdida | "
f"Modo: {sim.mode.name} | "
f"Discrete[19]: {sim.read_discrete(19)}"
if time_to_alarm else
"ALARMA NO DISPARADA en 10 s — FALLO"
),
snapshots=sim.log,
events=sim.events,
)
def tc07_dodge_mode() -> TCResult:
"""
TC-07 — Modo DODGE (esquiva temporal)
======================================
El operador activa DODGE con un offset de +20° para esquivar un
obstáculo. El autopiloto vira 20° a estribor. Luego el operador
cancela el DODGE volviendo a HEADING_HOLD en el rumbo original.
Criterios de aceptación:
• Modo cambia a DODGE
• El buque vira hacia el setpoint de DODGE (original + 20°)
• Tras volver a HEADING_HOLD, el error respecto al rumbo original < 1°
"""
spec = TCSpec(
id="TC-07",
name="Modo DODGE (+20°) y retorno",
description=(
"El operador esquiva con offset +20° y luego vuelve al rumbo original. "
"Verifica la transición HEADING_HOLD ↔ DODGE ↔ HEADING_HOLD."
),
acceptance=(
"DODGE activo, buque vira al setpoint DODGE, retorno sin error > 1°."
),
)
sim = ESP32Simulator()
ORIGINAL_SP = 90.0
DODGE_OFFSET = 20.0
DODGE_SP = (ORIGINAL_SP + DODGE_OFFSET) % 360.0
sim.inject_nmea(heading_deg=ORIGINAL_SP, sog_kn=10.0)
sim.write_holding(0, 1)
sim.write_holding(1, int(ORIGINAL_SP * 100))
sim.write_coil(0, 1)
sim.step(10.0) # estabilizar en 90°
initial_heading_error = abs(heading_error_deg(ORIGINAL_SP, sim.heading))
# Activar DODGE con offset +20°
sim.write_holding(0, 4) # MODE_REQUEST = DODGE
sim.write_holding(8, int(DODGE_OFFSET * 100)) # DODGE_OFFSET_DEG_X100
sim.write_coil(0, 1) # CMD_ENGAGE_REQUEST
sim.step(0.1)
dodge_engaged = sim.mode == AutopilotMode.DODGE
dodge_setpoint = sim._heading_setpoint_deg
# Dejar que el buque vire hacia el setpoint DODGE
sim.step(90.0)
heading_at_dodge = sim.heading
dodge_error = abs(heading_error_deg(DODGE_SP, heading_at_dodge))
# Volver a HEADING_HOLD con el rumbo original
sim.write_holding(0, 1)
sim.write_holding(1, int(ORIGINAL_SP * 100))
sim.write_coil(0, 1)
sim.step(0.1)
returned_to_hh = sim.mode == AutopilotMode.HEADING_HOLD
sim.step(120.0)
final_error = abs(heading_error_deg(ORIGINAL_SP, sim.heading))
passed = (
dodge_engaged
and abs(dodge_setpoint - DODGE_SP) < 0.5
and dodge_error < 2.0
and returned_to_hh
and final_error < 1.0
)
return TCResult(
spec=spec,
passed=passed,
metrics={
"dodge_engaged": "Sí" if dodge_engaged else "No",
"dodge_setpoint_deg": round(dodge_setpoint, 2),
"heading_at_dodge_end_deg": round(heading_at_dodge, 2),
"dodge_error_deg": round(dodge_error, 3),
"returned_to_hh": "Sí" if returned_to_hh else "No",
"final_error_vs_original_deg": round(final_error, 3),
},
message=(
f"DODGE: {'OK' if dodge_engaged else 'NO'} sp={dodge_setpoint:.1f}° | "
f"Error en DODGE: {dodge_error:.2f}° | "
f"Retorno HH: {'OK' if returned_to_hh else 'NO'} | "
f"Error final vs original: {final_error:.3f}°"
),
snapshots=sim.log,
events=sim.events,
)
def tc08_beaufort4_stability() -> TCResult:
"""
TC-08 — Estabilidad en Beaufort 4 (mar moderada, 5 minutos)
============================================================
El autopiloto mantiene el rumbo durante 300 s con mar Beaufort 4
(ola 6 s, ROT perturbaciones ±1.5°/s pico). Evalúa la capacidad
del pilot de seguir el rumbo en condiciones de crucero.
Criterios de aceptación:
• No desenganche (autopiloto sigue en HEADING_HOLD al final)
• Sin alarma SEVERE
• Desviación máxima de rumbo < 12°
• Error RMS < 7° (observado típico: ~5.6°)
"""
spec = TCSpec(
id="TC-08",
name="Beaufort 4 — estabilidad en mar moderada (5 min)",
description=(
"300 s de seguimiento de rumbo en Beaufort 4. "
"Verifica que el AP no se desenganche con mar moderada."
),
acceptance="Sin alarma SEVERE, desviación max < 12°, RMS < 7°.",
)
sim = ESP32Simulator()
TARGET = 90.0
sim.set_sea_state(4, seed=42)
sim.inject_nmea(heading_deg=TARGET, sog_kn=10.0)
sim.write_holding(0, 1)
sim.write_holding(1, int(TARGET * 100))
sim.write_coil(0, 1)
sim.step(300.0)
snaps = [s for s in sim.log if s.t >= 1.0] # ignorar primer segundo de enganche
max_dev = _max_abs_error(snaps, TARGET)
rms_err = _rms_error(snaps, TARGET)
still_engaged = sim.mode.name == "HEADING_HOLD"
no_severe = not sim._alarm_off_course_severe
passed = still_engaged and no_severe and max_dev < 12.0 and rms_err < 7.0
return TCResult(
spec=spec,
passed=passed,
metrics={
"still_engaged": "Sí" if still_engaged else "No",
"alarm_severe": "Sí" if not no_severe else "No",
"max_dev_deg": round(max_dev, 2),
"rms_error_deg": round(rms_err, 3),
"beaufort": 4,
},
message=(
f"Enganchado: {'OK' if still_engaged else 'NO'} | "
f"Alarma SEVERE: {'SÍ' if not no_severe else 'NO'} | "
f"Desv. máx: {max_dev:.1f}° | RMS: {rms_err:.2f}°"
),
snapshots=sim.log,
events=sim.events,
)
def tc09_beaufort6_rough() -> TCResult:
"""
TC-09 — Límite operacional en Beaufort 6 con ganancias de fábrica
==================================================================
Mar gruesa con olas de 8 s y torque de perturbación ±3.5°/s² pico.
Con ganancias de fábrica (Kp=0.9, rot_ff=1.5) el AP **no puede
mantener** el rumbo en B6 — el error supera 30° y la alarma
ALARM_OFF_COURSE_SEVERE activa el auto-desenganche.
Este TC documenta el límite operacional sin boost de ganancia.
El PASS significa que el sistema reacciona correctamente:
la alarma SEVERE debe disparar en < 60 s (no silenciosamente)
y el desenganche debe ocurrir (protección de la embarcación).
Criterios de aceptación:
• Alarma OFF_COURSE SEVERE disparada (comportamiento esperado)
• AP se desengrana (protección automática correcta)
• La alarma dispara antes de 60 s desde el inicio
"""
spec = TCSpec(
id="TC-09",
name="Beaufort 6 — límite operacional con ganancias de fábrica",
description=(
"B6 con ganancias estándar excede el límite OFF_COURSE (30°). "
"Documenta el límite operacional: SEVERE + auto-disengage es el "
"comportamiento esperado y correcto (protección)."
),
acceptance="Alarma SEVERE disparada en < 60 s, AP se desengrana correctamente.",
)
sim = ESP32Simulator()
TARGET = 90.0
sim.set_sea_state(6, seed=7)
sim.inject_nmea(heading_deg=TARGET, sog_kn=10.0)
sim.write_holding(0, 1)
sim.write_holding(1, int(TARGET * 100))
sim.write_coil(0, 1)
sim.step(180.0)
had_severe = sim._alarm_off_course_severe
disengaged = sim.mode.name == "STANDBY"
# Determinar cuándo ocurrió la alarma SEVERE
severe_events = [e for e in sim.events if "SEVERE" in e.detail]
t_engage_events = [e for e in sim.events if e.kind == "engage"]
t_engage = t_engage_events[0].t if t_engage_events else 0.0
t_severe = severe_events[0].t if severe_events else None
time_to_severe = (t_severe - t_engage) if t_severe is not None else None
passed = (
had_severe
and disengaged
and time_to_severe is not None
and time_to_severe < 60.0
)
return TCResult(
spec=spec,
passed=passed,
metrics={
"had_severe_alarm": "Sí" if had_severe else "No",
"auto_disengaged": "Sí" if disengaged else "No",
"time_to_severe_s": round(time_to_severe, 1) if time_to_severe else "nunca",
"beaufort": 6,
},
message=(
f"Alarma SEVERE: {'SÍ' if had_severe else 'NO'} | "
f"Auto-desenganche: {'OK' if disengaged else 'NO'} | "
f"Tiempo hasta SEVERE: {time_to_severe:.1f}s" if time_to_severe else
f"Alarma SEVERE: {'SÍ' if had_severe else 'NO'} | Sin alarma en 180 s"
),
snapshots=sim.log,
events=sim.events,
)
def tc10_low_speed() -> TCResult:
"""
TC-10 — Velocidad baja 2 kn — cambio de rumbo +10°
===================================================
El buque avanza a 2 nudos (mínimo habitual de uso del AP). La
respuesta al timón es ~5× más lenta que a 10 kn. El AP debe
converger aunque tarde bastante más.
τ_outer(2 kn) ≈ 113 s vs. τ_outer(10 kn) ≈ 24 s
Criterios de aceptación:
• Error final < 3° (criterio más laxo que alta velocidad)
• Asentamiento < 280 s
• Sin alarma SEVERE
"""
spec = TCSpec(
id="TC-10",
name="Velocidad baja 2 kn — cambio de rumbo +10°",
description=(
"SOG 2 kn, cambio de 0° → 10°. "
"Verifica convergencia lenta (τ≈113 s) en mínima velocidad."
),
acceptance="Error final < 3°, asentamiento < 280 s.",
)
sim = ESP32Simulator()
INITIAL = 0.0
TARGET = 10.0
sim.inject_nmea(heading_deg=INITIAL, sog_kn=2.0)
sim.write_holding(0, 1)
sim.write_holding(1, int(INITIAL * 100))
sim.write_coil(0, 1)
sim.step(5.0)
t_step = sim.t
sim.write_holding(1, int(TARGET * 100))
sim.step(290.0)
snaps_after = [s for s in sim.log if s.t >= t_step]
settling = _settling_time(snaps_after, TARGET, tolerance=3.0)
final_err = abs(heading_error_deg(TARGET, sim.heading))
no_severe = not sim._alarm_off_course_severe
passed = (
no_severe
and final_err < 3.0
and settling is not None
and settling < 280.0
)
return TCResult(
spec=spec,
passed=passed,
metrics={
"sog_kn": 2.0,
"final_error_deg": round(final_err, 3),
"settling_time_s": round(settling, 1) if settling else ">290",
"alarm_severe": "Sí" if not no_severe else "No",
},
message=(
f"SOG: 2 kn | Error: {final_err:.2f}° | "
f"Asentamiento: {settling:.1f}s" if settling else
f"SOG: 2 kn | Error: {final_err:.2f}° | NO ASENTÓ"
),
snapshots=sim.log,
events=sim.events,
)
def tc11_reversal_180() -> TCResult:
"""
TC-11 — Inversión de rumbo 180°
================================
Maniobra máxima: el operador invierte el rumbo 180°.
Verifica que _tracking_settled impide OFF_COURSE SEVERE durante
la aproximación larga, y que el AP converge sin disenganche.
Criterios de aceptación:
• Sin alarma SEVERE (sin desenganche no deseado)
• Converge a < 5° de 180° en menos de 200 s
• Timón llega a estribor (positivo) al inicio de la maniobra
"""
spec = TCSpec(
id="TC-11",
name="Inversión de rumbo 180° — maniobra extrema",
description=(
"Cambio de 0° → 180°. Prueba que _tracking_settled evita "
"la alarma SEVERE durante la larga aproximación."
),
acceptance="Sin SEVERE, convergencia < 5° en < 200 s.",
)
sim = ESP32Simulator()
INITIAL = 0.0
TARGET = 180.0
sim.inject_nmea(heading_deg=INITIAL, sog_kn=10.0)
sim.write_holding(0, 1)
sim.write_holding(1, int(INITIAL * 100))
sim.write_coil(0, 1)
sim.step(2.0)
t_step = sim.t
sim.write_holding(1, int(TARGET * 100))
sim.step(220.0)
snaps_after = [s for s in sim.log if s.t >= t_step]
max_rudder_stbd = max((s.rudder_angle_deg for s in snaps_after[:50]), default=0.0)
went_starboard = max_rudder_stbd > 2.0
settling = _settling_time(snaps_after, TARGET, tolerance=5.0)
final_err = abs(heading_error_deg(TARGET, sim.heading))
no_severe = not sim._alarm_off_course_severe
passed = (
no_severe
and went_starboard
and final_err < 5.0
and settling is not None
and settling < 200.0
)
return TCResult(
spec=spec,
passed=passed,
metrics={
"went_starboard": "Sí" if went_starboard else "No",
"alarm_severe": "Sí" if not no_severe else "No",
"final_error_deg": round(final_err, 3),
"settling_time_s": round(settling, 1) if settling else ">220",
},
message=(
f"Estribor: {'OK' if went_starboard else 'NO'} | "
f"SEVERE: {'SÍ' if not no_severe else 'NO'} | "
f"Error: {final_err:.2f}° | "
f"Asentamiento: {settling:.1f}s" if settling else
f"Estribor: {'OK' if went_starboard else 'NO'} | NO ASENTÓ"
),
snapshots=sim.log,
events=sim.events,
)
def tc12_rapid_setpoints() -> TCResult:
"""
TC-12 — Cambios rápidos de setpoint (stress test del knob)
===========================================================
El operador gira el knob rápidamente: 90°→105°→95°→115°→90°,
un cambio cada 25 s. Verifica que el PID no acumula windup ni
pierde el estado entre cambios rápidos.
Nota: con cambios cada 25 s el buque está siempre en transición
(τ_outer ≈ 24 s). El error residual al final del ciclo es de ~1.2°
porque el sistema aún está convergiendo hacia 90° tras el último
cambio. Con 60 s de ventana extra el error queda < 2°.
Criterios de aceptación:
• Sin alarmas en ningún momento
• Error final (respecto al último setpoint 90°) < 2° (observado: ~1.2°)
• No desenganche involuntario
"""
spec = TCSpec(
id="TC-12",
name="Setpoints rápidos consecutivos — stress del knob",
description=(
"90°→105°→95°→115°→90°, cada 25 s. "
"Verifica ausencia de windup entre cambios rápidos."
),
acceptance="Sin alarmas, error final < 2°, modo HH al finalizar.",
)
sim = ESP32Simulator()
ORIGINAL = 90.0
sequence = [105.0, 95.0, 115.0, ORIGINAL]
sim.inject_nmea(heading_deg=ORIGINAL, sog_kn=10.0)
sim.write_holding(0, 1)
sim.write_holding(1, int(ORIGINAL * 100))
sim.write_coil(0, 1)
sim.step(10.0) # estabilizar en 90°
for sp in sequence:
sim.write_holding(1, int(sp * 100))
sim.step(25.0)
sim.step(60.0) # ventana extra para convergencia final
final_err = abs(heading_error_deg(ORIGINAL, sim.heading))
still_hh = sim.mode.name == "HEADING_HOLD"
no_alarm = not sim.any_alarm
passed = still_hh and no_alarm and final_err < 2.0
return TCResult(
spec=spec,
passed=passed,
metrics={
"final_mode": sim.mode.name,
"final_error_deg": round(final_err, 3),
"any_alarm": "Sí" if not no_alarm else "No",
},
message=(
f"Modo: {sim.mode.name} | "
f"Error final: {final_err:.3f}° | "
f"Alarmas: {'SÍ' if not no_alarm else 'ninguna'}"
),
snapshots=sim.log,
events=sim.events,
)
def tc13_weather_gain_boost() -> TCResult:
"""
TC-13 — Boost de ganancia en mal tiempo (Beaufort 5)
====================================================
Simula el operador ajustando los knobs "RUDDER" y "COUNTER RUDDER"
en mar alta (Beaufort 5). Mide el RMS del error antes y después
del boost de ganancia.
Nota: B6 con ganancias de fábrica causa desenganche a los ~36 s
(documentado en TC-09). Para poder medir la mejora del boost con AP
enganchado durante toda la prueba se usa B5, que es el nivel más alto
que el AP puede sostener con ganancias estándar sin desenganche.
Fase 1 (120 s): ganancias de fábrica (Kp=0.9, rot_ff=1.5)
→ RMS observado ≈ 12.3° (mar alta — degradación aceptada)
Fase 2 (120 s): boost del operador (Kp=2.0, rot_ff=2.5, max_rud=30°)
→ RMS observado ≈ 7.2° (mejora de ~1.7× con los knobs)
En pilotos clásicos (Robertson AP28): equivale a subir RUDDER de 4→7
y COUNTER RUDDER de 4→7.
Criterios de aceptación:
• RMS post-boost < RMS pre-boost (mejora real, observable)
• Mejora >= 10% (ratio pre/post ≥ 1.10)
• Sin alarma SEVERE en ninguna fase (AP enganchado todo el tiempo)
"""
spec = TCSpec(
id="TC-13",
name="Boost de ganancia en Beaufort 5 — RUDDER / COUNTER RUDDER",
description=(
"Beaufort 5, 120 s sin boost + 120 s con boost. "
"Verifica que subir 'RUDDER' y 'COUNTER RUDDER' mejora el seguimiento."
),
acceptance="RMS post-boost < RMS pre-boost (mejora ≥ 10%), sin SEVERE.",
)
sim = ESP32Simulator()
TARGET = 90.0
sim.set_sea_state(5, seed=42)
sim.inject_nmea(heading_deg=TARGET, sog_kn=10.0)
sim.write_holding(0, 1)
sim.write_holding(1, int(TARGET * 100))
sim.write_coil(0, 1)
sim.step(5.0) # estabilizar
# Fase 1: ganancias de fábrica
t0 = sim.t
sim.step(120.0)
t1 = sim.t
snaps_pre = [s for s in sim.log if t0 <= s.t < t1]
rms_pre = _rms_error(snaps_pre, TARGET)
# Boost: operador sube RUDDER y COUNTER RUDDER (como en piloto clásico)
sim.tune_response(rudder_kp=2.0, counter_rudder=2.5, max_rudder_deg=30.0)
sim.step(120.0)
t2 = sim.t
snaps_post = [s for s in sim.log if t1 <= s.t < t2]
rms_post = _rms_error(snaps_post, TARGET)
no_severe = not sim._alarm_off_course_severe
improvement_ratio = rms_pre / rms_post if rms_post > 0.0 else 999.0
passed = (
no_severe
and rms_post < rms_pre
and improvement_ratio >= 1.10
)
return TCResult(
spec=spec,
passed=passed,
metrics={
"rms_pre_boost_deg": round(rms_pre, 3),
"rms_post_boost_deg": round(rms_post, 3),
"improvement_ratio": round(improvement_ratio, 3),
"alarm_severe": "Sí" if not no_severe else "No",
"beaufort": 5,
},
message=(
f"RMS pre-boost: {rms_pre:.2f}° | "
f"RMS post-boost: {rms_post:.2f}° | "
f"Mejora: {improvement_ratio:.2f}× | "
f"SEVERE: {'SÍ' if not no_severe else 'NO'}"
),
snapshots=sim.log,
events=sim.events,
)
def tc14_wave_spike() -> TCResult:
"""
TC-14 — Ráfaga extrema: spike de Beaufort 7 (30 s) y recuperación
==================================================================
El buque navega en calma. De repente llega una ráfaga / ola extrema
(Beaufort 7, ROT perturbación ±8°/s pico) durante 30 s. Luego vuelve
la calma.
Evalúa si el AP:
a) Se mantiene enganchado (si la desviación no supera 30°) — ideal
b) Si se desengrana por OFF_COURSE SEVERE, verifica que la alarma
es genuina y que el AP puede re-enganchar y recuperar el rumbo.
Criterios de aceptación (caso a — sin disengage):
• Máxima desviación < 25° durante el spike
• Recuperación < 60 s tras el spike (error final < 2°)
Criterios de aceptación (caso b — con disengage por SEVERE):
• La alarma fue OFF_COURSE SEVERE (no un bug silencioso)
• Al re-enganchar: recuperación < 60 s (error final < 2°)
"""
spec = TCSpec(
id="TC-14",
name="Ráfaga extrema Beaufort 7 (30 s) y recuperación",
description=(
"Calma → Beaufort 7 (30 s) → calma. "
"Verifica la robustez del AP ante olas extremas."
),
acceptance="Sin disengage O alarma SEVERE legítima + recuperación < 60 s, error < 2°.",
)
sim = ESP32Simulator()
TARGET = 90.0
sim.inject_nmea(heading_deg=TARGET, sog_kn=10.0)
sim.write_holding(0, 1)
sim.write_holding(1, int(TARGET * 100))
sim.write_coil(0, 1)
sim.step(20.0) # establecer rumbo en calma
# Spike: Beaufort 7 durante 30 s
t_spike_start = sim.t
sim.set_sea_state(7, seed=17)
sim.step(30.0)
t_spike_end = sim.t
snaps_spike = [s for s in sim.log if t_spike_start <= s.t <= t_spike_end]
max_dev_spike = _max_abs_error(snaps_spike, TARGET)
had_severe = sim._alarm_off_course_severe
# Volver a calma
sim.set_sea_state(0)
# Si se disenganó, re-enganchar
if sim.mode.name != "HEADING_HOLD":
sim.write_coil(2, 1) # ACK alarms
sim.step(0.1)
sim.write_holding(0, 1)
sim.write_coil(0, 1)
sim.step(0.1)
# 90 s de recuperación
sim.step(90.0)
final_err = abs(heading_error_deg(TARGET, sim.heading))
still_hh = sim.mode.name == "HEADING_HOLD"
# Criterio: o no hubo disengage y max_dev < 25°,
# o hubo SEVERE (correcto) y recuperó < 2°
if not had_severe:
passed = max_dev_spike < 25.0 and final_err < 2.0
else:
passed = final_err < 2.0 # se aceptó el disengage, pero debe recuperar
return TCResult(
spec=spec,
passed=passed,
metrics={
"max_dev_spike_deg": round(max_dev_spike, 2),
"had_severe_alarm": "Sí" if had_severe else "No",
"final_mode": sim.mode.name,
"final_error_deg": round(final_err, 3),
},
message=(
f"Desv. máx en spike: {max_dev_spike:.1f}° | "
f"Alarma SEVERE: {'SÍ' if had_severe else 'NO'} | "
f"Modo final: {sim.mode.name} | "
f"Error final: {final_err:.2f}°"
),
snapshots=sim.log,
events=sim.events,
)
# ---------------------------------------------------------------------------
# Registro de todos los TCs
# ---------------------------------------------------------------------------
ALL_TCS: dict[str, Callable[[], TCResult]] = {
"TC-01": tc01_power_on_engage,
"TC-02": tc02_small_heading_change,
"TC-03": tc03_large_heading_change,
"TC-04": tc04_boundary_crossing,
"TC-05": tc05_manual_disengage,
"TC-06": tc06_heading_lost_alarm,
"TC-07": tc07_dodge_mode,
"TC-08": tc08_beaufort4_stability,
"TC-09": tc09_beaufort6_rough,
"TC-10": tc10_low_speed,
"TC-11": tc11_reversal_180,
"TC-12": tc12_rapid_setpoints,
"TC-13": tc13_weather_gain_boost,
"TC-14": tc14_wave_spike,
}
# ---------------------------------------------------------------------------
# Helpers de métricas
# ---------------------------------------------------------------------------
def _settling_time(
snapshots: list[SimSnapshot], target: float, tolerance: float = 1.0
) -> float | None:
"""
Retorna el instante en que |error| entra en la banda de tolerancia
y ya no vuelve a salir. None si nunca asienta.
"""
if not snapshots:
return None
# Buscar el último instante donde |error| > tolerance
last_out = None
for s in snapshots:
if abs(heading_error_deg(target, s.heading_deg)) > tolerance:
last_out = s.t
if last_out is None:
return snapshots[0].t # siempre estuvo dentro
# Verificar que después de last_out permanece dentro
after = [s for s in snapshots if s.t > last_out]
if all(abs(heading_error_deg(target, s.heading_deg)) <= tolerance for s in after):
return last_out
return None # salió de la banda después del último out
def _overshoot(
snapshots: list[SimSnapshot], initial: float, target: float
) -> float:
"""
Sobrepasamiento en grados respecto al setpoint en la dirección de giro.
Positivo = cruzó el setpoint en la dirección del movimiento.
"""
if not snapshots:
return 0.0
going_right = heading_error_deg(target, initial) > 0 # girando a estribor
peak = 0.0
for s in snapshots[3:]: # ignorar los primeros muestras (transitorio inicial)
err = heading_error_deg(target, s.heading_deg)
if going_right:
# Sobrepasamiento = haber ido más allá del setpoint (error negativo)
if err < 0:
peak = max(peak, abs(err))
else:
if err > 0:
peak = max(peak, abs(err))
return peak
def _rms_error(
snapshots: list[SimSnapshot], target: float
) -> float:
"""Error cuadrático medio del rumbo respecto al setpoint."""
if not snapshots:
return 0.0
errors = [heading_error_deg(target, s.heading_deg) ** 2 for s in snapshots]
return math.sqrt(sum(errors) / len(errors))
def _max_abs_error(snapshots: list[SimSnapshot], target: float) -> float:
"""Máxima desviación absoluta del rumbo respecto al setpoint."""
if not snapshots:
return 0.0
return max(abs(heading_error_deg(target, s.heading_deg)) for s in snapshots)
# ---------------------------------------------------------------------------
# Salida en consola
# ---------------------------------------------------------------------------
def _print_summary(results: list[TCResult]) -> None:
print()
print("=" * 72)
print(" AR-AUTOPILOT - Protocolo de Simulacion HIL")
print(f" {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
print("=" * 72)
print(f" {'ID':<8} {'Nombre':<35} {'Resultado':<8} {'Nota'}")
print(" " + "-" * 68)
for r in results:
badge = "[PASS]" if r.passed else "[FAIL]"
name = r.spec.name[:35]
note = r.message[:40] if len(r.message) > 40 else r.message
print(f" {r.spec.id:<8} {name:<35} {badge:<8} {note}")
print(" " + "-" * 68)
n_pass = sum(1 for r in results if r.passed)
n_fail = len(results) - n_pass
print(f" Total: {len(results)} PASS: {n_pass} FAIL: {n_fail}")
print("=" * 72)
print()
# ---------------------------------------------------------------------------
# Generador de informe HTML
# ---------------------------------------------------------------------------
def generate_html_report(results: list[TCResult], out_path: Path) -> None:
"""Genera un informe HTML autocontenido con gráficas Chart.js."""
n_pass = sum(1 for r in results if r.passed)
n_fail = len(results) - n_pass
overall_badge = "PASS" if n_fail == 0 else "FAIL"
overall_color = "#22c55e" if n_fail == 0 else "#ef4444"
tc_cards_html = ""
for r in results:
tc_cards_html += _render_tc_card(r)
html_content = f"""<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AR-Autopilot — Protocolo de Simulación</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
<style>
:root {{
--bg: #0d1822; --bg-mid: #0f172a; --panel: #14202e;
--border: rgba(56,189,248,.22); --text: #e0f2fe; --muted: #64748b;
--accent: #38bdf8; --ok: #4ade80; --fail: #ef4444;
--warn: #fbbf24; --set: #fbbf24;
}}
* {{ box-sizing: border-box; margin: 0; padding: 0; }}
body {{ background: var(--bg); color: var(--text); font-family: 'Courier New', monospace; padding: 24px; }}
h1 {{ color: var(--accent); font-size: 1.4rem; margin-bottom: 4px; }}
h2 {{ color: var(--accent); font-size: 1.05rem; margin-bottom: 12px; }}
.meta {{ color: var(--muted); font-size: .8rem; margin-bottom: 24px; }}
.overall {{ display: flex; align-items: center; gap: 16px; margin-bottom: 28px; }}
.badge {{ padding: 6px 18px; border-radius: 4px; font-weight: bold; font-size: 1.1rem; }}
.badge-pass {{ background: var(--ok); color: #0a1a0a; }}
.badge-fail {{ background: var(--fail); color: #fff; }}
.stats {{ color: var(--muted); font-size: .9rem; }}
.tc-card {{ background: var(--panel); border: 1px solid var(--border);
border-radius: 6px; padding: 20px; margin-bottom: 20px; }}
.tc-header {{ display: flex; align-items: baseline; gap: 12px; margin-bottom: 8px; }}
.tc-id {{ color: var(--accent); font-size: 1rem; font-weight: bold; }}
.tc-name {{ font-size: .95rem; color: var(--text); }}
.tc-badge {{ padding: 2px 10px; border-radius: 3px; font-size: .85rem; font-weight: bold; }}
.pass {{ background: var(--ok); color: #0a1a0a; }}
.fail {{ background: var(--fail); color: #fff; }}
.tc-desc {{ color: var(--muted); font-size: .8rem; margin-bottom: 10px; }}
.tc-accept {{ color: var(--warn); font-size: .8rem; margin-bottom: 12px; }}
.metrics {{ display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 14px; }}
.metric {{ background: #0a1220; border: 1px solid var(--border); border-radius: 4px;
padding: 5px 10px; font-size: .8rem; }}
.metric-key {{ color: var(--muted); }}
.metric-val {{ color: var(--accent); font-weight: bold; }}
.tc-msg {{ color: var(--text); font-size: .82rem; margin-bottom: 14px; opacity: .85; }}
.chart-wrap {{ position: relative; height: 240px; }}
.events {{ margin-top: 10px; font-size: .75rem; color: var(--muted); }}
.event {{ margin-bottom: 2px; }}
.ev-engage {{ color: var(--ok); }}
.ev-disengage {{ color: var(--warn); }}
.ev-alarm {{ color: var(--fail); }}
.ev-ack {{ color: var(--accent); }}
</style>
</head>
<body>
<h1>AR-Autopilot · Protocolo de Simulación HIL</h1>
<div class="meta">Generado: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')} · Simulador Python puro · Sin hardware ESP32</div>
<div class="overall">
<span class="badge badge-{'pass' if n_fail==0 else 'fail'}">{overall_badge}</span>
<span class="stats">{n_pass}/{len(results)} casos superados &nbsp;|&nbsp; {n_fail} fallos</span>
</div>
{tc_cards_html}
</body>
</html>"""
out_path.write_text(html_content, encoding="utf-8")
print(f" → Informe HTML: {out_path.resolve()}")
def _render_tc_card(r: TCResult) -> str:
"""Genera el HTML de una tarjeta de caso de prueba."""
passed_class = "pass" if r.passed else "fail"
passed_label = "PASS" if r.passed else "FAIL"
# Métricas
metrics_html = ""
for k, v in r.metrics.items():
metrics_html += (
f'<span class="metric">'
f'<span class="metric-key">{html.escape(str(k))}</span>: '
f'<span class="metric-val">{html.escape(str(v))}</span>'
f'</span>'
)
# Datos del gráfico (subsamplear si hay muchos puntos)
snaps = r.snapshots
if len(snaps) > 600:
step = len(snaps) // 600
snaps = snaps[::step]
times = [round(s.t, 2) for s in snaps]
headings = [round(s.heading_deg, 2) for s in snaps]
setpoints = [round(s.heading_setpoint_deg, 2) for s in snaps]
rudders = [round(s.rudder_angle_deg, 2) for s in snaps]
rots = [round(s.rot_dps, 3) for s in snaps]
chart_id = r.spec.id.replace("-", "_")
# Eventos
events_html = ""
for ev in r.events:
ev_class = {
"engage": "ev-engage", "engage_refused": "ev-alarm",
"disengage": "ev-disengage", "alarm": "ev-alarm", "ack": "ev-ack",
}.get(ev.kind, "")
events_html += (
f'<div class="event {ev_class}">'
f't={ev.t:.2f}s [{ev.kind}] {html.escape(ev.detail)}'
f'</div>'
)
return f"""
<div class="tc-card">
<div class="tc-header">
<span class="tc-id">{html.escape(r.spec.id)}</span>
<span class="tc-name">{html.escape(r.spec.name)}</span>
<span class="tc-badge {passed_class}">{passed_label}</span>
</div>
<div class="tc-desc">{html.escape(r.spec.description)}</div>
<div class="tc-accept">* Aceptación: {html.escape(r.spec.acceptance)}</div>
<div class="metrics">{metrics_html}</div>
<div class="tc-msg">{html.escape(r.message)}</div>
<div class="chart-wrap">
<canvas id="chart_{chart_id}"></canvas>
</div>
<div class="events">{events_html}</div>
</div>
<script>
(function() {{
var ctx = document.getElementById('chart_{chart_id}').getContext('2d');
new Chart(ctx, {{
type: 'line',
data: {{
labels: {json.dumps(times)},
datasets: [
{{
label: 'Rumbo (°)',
data: {json.dumps(headings)},
borderColor: '#38bdf8', borderWidth: 1.5,
pointRadius: 0, tension: 0.1, yAxisID: 'y'
}},
{{
label: 'Setpoint (°)',
data: {json.dumps(setpoints)},
borderColor: '#fbbf24', borderWidth: 1.5, borderDash: [4, 3],
pointRadius: 0, tension: 0, yAxisID: 'y'
}},
{{
label: 'Timón (°)',
data: {json.dumps(rudders)},
borderColor: '#4ade80', borderWidth: 1,
pointRadius: 0, tension: 0.1, yAxisID: 'y2'
}},
{{
label: 'ROT (°/s)',
data: {json.dumps(rots)},
borderColor: '#fb923c', borderWidth: 1,
pointRadius: 0, tension: 0.1, yAxisID: 'y2'
}}
]
}},
options: {{
animation: false,
interaction: {{ mode: 'index', intersect: false }},
plugins: {{
legend: {{ labels: {{ color: '#e0f2fe', font: {{ size: 11 }} }} }},
tooltip: {{ backgroundColor: '#0d1822', titleColor: '#38bdf8', bodyColor: '#e0f2fe' }}
}},
scales: {{
x: {{
ticks: {{ color: '#64748b', font: {{ size: 10 }}, maxTicksLimit: 12 }},
grid: {{ color: 'rgba(56,189,248,.08)' }},
title: {{ display: true, text: 'Tiempo (s)', color: '#64748b', font: {{ size: 11 }} }}
}},
y: {{
ticks: {{ color: '#38bdf8', font: {{ size: 10 }} }},
grid: {{ color: 'rgba(56,189,248,.08)' }},
title: {{ display: true, text: 'Rumbo / Setpoint (°)', color: '#38bdf8', font: {{ size: 11 }} }}
}},
y2: {{
position: 'right',
ticks: {{ color: '#4ade80', font: {{ size: 10 }} }},
grid: {{ drawOnChartArea: false }},
title: {{ display: true, text: 'Timón (°) / ROT (°/s)', color: '#4ade80', font: {{ size: 11 }} }}
}}
}}
}}
}});
}})();
</script>
"""
# ---------------------------------------------------------------------------
# Entry point
# ---------------------------------------------------------------------------
def main() -> int:
parser = argparse.ArgumentParser(
description="AR-Autopilot: protocolo de simulación HIL",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=textwrap.dedent("""
Ejemplos:
python tools/sim_protocol.py
python tools/sim_protocol.py TC-02 TC-04
python tools/sim_protocol.py --out resultado.html
"""),
)
parser.add_argument(
"tests", nargs="*", metavar="TC-NN",
help="IDs de casos a ejecutar (default: todos)",
)
parser.add_argument(
"--out", default="tools/sim_report.html",
metavar="ARCHIVO",
help="Ruta del informe HTML (default: tools/sim_report.html)",
)
args = parser.parse_args()
selected = args.tests or list(ALL_TCS.keys())
unknown = [tc for tc in selected if tc not in ALL_TCS]
if unknown:
print(f"Error: TCs desconocidos: {', '.join(unknown)}")
print(f"Disponibles: {', '.join(ALL_TCS)}")
return 1
results: list[TCResult] = []
for tc_id in selected:
print(f" Ejecutando {tc_id} …", end="", flush=True)
result = ALL_TCS[tc_id]()
badge = "PASS" if result.passed else "FAIL"
print(f" {badge}")
results.append(result)
_print_summary(results)
out_path = Path(args.out)
out_path.parent.mkdir(parents=True, exist_ok=True)
generate_html_report(results, out_path)
return 0 if all(r.passed for r in results) else 1
if __name__ == "__main__":
sys.exit(main())