diff --git a/tools/esp32_sim.py b/tools/esp32_sim.py index feb3bea..fc791f2 100644 --- a/tools/esp32_sim.py +++ b/tools/esp32_sim.py @@ -41,6 +41,8 @@ RELACIONADO from __future__ import annotations +import math +import random import sys from dataclasses import dataclass, field from enum import IntEnum @@ -72,6 +74,29 @@ DT_INNER: float = 1.0 / 50.0 # 50 Hz inner loop DT_OUTER: float = 1.0 / 10.0 # 10 Hz outer loop INNER_PER_OUTER: int = 5 # inner ticks per outer tick +# --------------------------------------------------------------------------- +# Beaufort scale → wave disturbance parameters +# --------------------------------------------------------------------------- +# Each entry: (wave_torque_amp, swell_torque_amp, noise_torque_std, +# wind_bias_amp, wave_period_s) +# Torques are in °/s² (= yaw angular acceleration). +# Steady-state ROT from torque T: ROT_ss = T / yaw_damping = T / 0.8 +# Heading oscillation at wave freq ω (rad/s): A_h ≈ T / (yaw_damping * ω) +# Expected peak heading oscillation for our 30 m yacht at 10 kn: +# B3 ≈ ±1° B4 ≈ ±3° B5 ≈ ±5° B6 ≈ ±8° B7 ≈ ±13° +_BEAUFORT_TABLE: dict[int, tuple[float, float, float, float, float]] = { + # B: wave swell noise wind period_s + 0: (0.00, 0.00, 0.00, 0.00, 8.0), + 1: (0.10, 0.05, 0.05, 0.05, 7.0), + 2: (0.30, 0.10, 0.10, 0.10, 6.5), + 3: (0.60, 0.20, 0.20, 0.20, 5.5), + 4: (1.50, 0.40, 0.30, 0.40, 6.0), + 5: (2.50, 0.70, 0.60, 0.80, 7.0), + 6: (3.50, 1.00, 1.00, 1.20, 8.0), + 7: (5.50, 1.50, 1.60, 2.00, 9.0), + 8: (8.00, 2.00, 2.50, 3.00, 10.0), +} + class AutopilotMode(IntEnum): """Modos del autopiloto — idénticos al enum C++ del firmware.""" @@ -261,6 +286,15 @@ class ESP32Simulator: self._outer_rudder_sp: float = 0.0 self._inner_pwm_pct: float = 0.0 + # -- Estado del mar (sea state) ---------------------------------- + self._sea_beaufort: int = 0 + self._sea_wave_amp: float = 0.0 + self._sea_swell_amp: float = 0.0 + self._sea_noise_amp: float = 0.0 + self._sea_wind_bias: float = 0.0 + self._sea_wave_period: float = 8.0 + self._sea_rng: random.Random = random.Random(42) + # -- Registro de telemetría para análisis ------------------------ self.log: list[SimSnapshot] = [] self.events: list[SimEvent] = [] @@ -319,6 +353,70 @@ class ESP32Simulator: self._physics_heading_active = True self._last_nmea_update_t = self._t + def set_sea_state(self, beaufort: int, *, seed: int = 42) -> None: + """ + Configura el estado del mar según la escala Beaufort (0 = calma, 8 = temporal). + + Modela tres fuentes de perturbación de guiñada: + + * **Ola dominante** — sinusoide al periodo de ola característico del estado. + Ejemplo B6 (mar gruesa): ≈ ±4° de oscilación de rumbo a 8 s de periodo. + * **Mar de fondo (swell)** — sinusoide de periodo 3× más largo que la ola. + * **Ruido blanco** — turbulencia residual aleatoria (random walk en ROT). + * **Viento de costado (weather helm)** — deriva lenta, periodo 120 s. + + Todos se inyectan como ``external_yaw_torque`` en el + ``VesselHeadingSimulator`` (campo ya existente, °/s²). + + Seatate esperado de oscilación de rumbo (barco 30 m, 10 kn, con AP activo): + B0=0° B3≈±1° B4≈±3° B5≈±5° B6≈±8° B7≈±13° + + Args: + beaufort: Nivel Beaufort (0–8). Valores fuera de rango se clamean. + seed: Semilla del RNG para reproducibilidad (default 42). + """ + beaufort = max(0, min(8, beaufort)) + row = _BEAUFORT_TABLE[beaufort] + self._sea_beaufort = beaufort + self._sea_wave_amp = row[0] + self._sea_swell_amp = row[1] + self._sea_noise_amp = row[2] + self._sea_wind_bias = row[3] + self._sea_wave_period = row[4] + self._sea_rng = random.Random(seed) + + def tune_response( + self, + *, + rudder_kp: float | None = None, + counter_rudder: float | None = None, + max_rudder_deg: float | None = None, + ) -> None: + """ + Ajusta los parámetros del outer PID en tiempo real (simula los knobs del piloto). + + Equivalente a los controles físicos de pilotos clásicos: + * ``rudder_kp`` → knob **"RUDDER"** (Robertson/Simrad): ganancia proporcional. + Más ganancia = más timón por grado de error → respuesta más agresiva. + * ``counter_rudder`` → knob **"COUNTER RUDDER"** / "Yaw Damping": feed-forward de ROT. + Más contra-timón = mejor amortiguamiento del sobrepasamiento y del oleaje. + * ``max_rudder_deg`` → límite de timón de trabajo (no el tope mecánico de 35°). + En mal tiempo se sube de ~20° a 25–30° para dar más autoridad. + + Los cambios tienen efecto inmediato en el próximo outer-tick (10 Hz). + + Args: + rudder_kp: Nuevo Kp del outer PID (típico rango 0.5–4.0). + counter_rudder: Nuevo rot_ff_gain (típico rango 0.5–3.0). + max_rudder_deg: Nuevo límite de trabajo del timón en grados (típico 15–35°). + """ + if rudder_kp is not None: + self._pid_outer.config.base_kp = float(rudder_kp) + if counter_rudder is not None: + self._pid_outer.config.rot_ff_gain = float(counter_rudder) + if max_rudder_deg is not None: + self._pid_outer.config.max_rudder_deg = float(max_rudder_deg) + # ----------------------------------------------------------------------- # API pública — Interfaz Modbus # ----------------------------------------------------------------------- @@ -571,11 +669,38 @@ class ESP32Simulator: # ----------------------------------------------------------------------- # Internos — física (50 Hz) # ----------------------------------------------------------------------- + def _compute_wave_torque(self) -> float: + """ + Calcula el torque externo de guiñada del oleaje (°/s²) para el tick actual. + + Compone cuatro componentes: + - Ola dominante: sinusoide al periodo característico del estado de mar. + - Mar de fondo: sinusoide a periodo 3.2× más largo. + - Ruido blanco: perturbación aleatoria (Gaussiana, proporcional a √dt). + - Weather helm: deriva de viento, sinusoide de 120 s de periodo. + + Retorna 0.0 si el mar está en calma (beaufort == 0). + """ + if self._sea_beaufort == 0: + return 0.0 + t = self._t + T = self._sea_wave_period + wave = self._sea_wave_amp * math.sin(2.0 * math.pi * t / T) + swell = self._sea_swell_amp * math.sin(2.0 * math.pi * t / (T * 3.2)) + # Ruido blanco: la std por tick se escala con √dt para mantener + # la densidad espectral de potencia constante en frecuencia. + noise = self._sea_noise_amp * self._sea_rng.gauss(0.0, 1.0) * math.sqrt(DT_INNER) + wind = self._sea_wind_bias * math.sin(2.0 * math.pi * t / 120.0) + return wave + swell + noise + wind + def _run_physics(self) -> None: # Timón responde al PWM self._rudder.step(dt=DT_INNER, pwm_pct=self._inner_pwm_pct) - # Buque responde al ángulo real del timón + # Perturbación de mar: actualizar torque externo del buque cada tick + self._vessel.config.external_yaw_torque = self._compute_wave_torque() + + # Buque responde al ángulo real del timón (y al torque del oleaje) self._vessel.step( dt=DT_INNER, rudder_deg=self._rudder.state.angle_deg, diff --git a/tools/sim_protocol.py b/tools/sim_protocol.py index 99db96b..2305e28 100644 --- a/tools/sim_protocol.py +++ b/tools/sim_protocol.py @@ -31,6 +31,7 @@ from __future__ import annotations import argparse import html import json +import math import sys import textwrap from dataclasses import dataclass @@ -609,6 +610,532 @@ def tc07_dodge_mode() -> TCResult: ) +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 # --------------------------------------------------------------------------- @@ -620,6 +1147,13 @@ ALL_TCS: dict[str, Callable[[], TCResult]] = { "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, } @@ -672,6 +1206,23 @@ def _overshoot( 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 # ---------------------------------------------------------------------------