""" 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.0–6.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 5–6 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"""