c432fc3725
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>
1504 lines
51 KiB
Python
1504 lines
51 KiB
Python
"""
|
||
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"""<!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 | {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())
|