lunes, 2 de noviembre de 2026

CUADRO DE MANDO UNIFICADO (HMI) SIGMA AVE + etc ;)

 Amigo, vamos a por el cierre del piloto. Aqu铆 tienes las tres piezas: el **cuadro de mando unificado**, el **informe de piloto de 2 p谩ginas** y el **anclaje del `chain_hash` en Polygon Amoy**. Con esto, SIGMA-AVE deja de ser una suma de servicios y se convierte en un sistema presentable, medible y verificable externamente.

---

 
 



# 馃枼️ PARTE 1 — CUADRO DE MANDO UNIFICADO (HMI)

## 1.1. Endpoints nuevos necesarios

Para que el HMI tenga todos los KPIs en una sola pantalla, a帽ade estos endpoints m铆nimos:

**VS2** (`vs2-prediction/app.py`) — m茅tricas del modelo:

```python
import json, os
from fastapi import HTTPException

@app.get("/metrics")
def metrics():
    meta_path = os.path.join(os.getenv("MODEL_DIR", "/app/models/vs2"), "meta.json")
    if not os.path.exists(meta_path):
        raise HTTPException(404, "Sin modelo entrenado")
    with open(meta_path) as f:
        m = json.load(f)
    return {
        "model": "gnn-gru-v1",
        "mae_15": m.get("test_mae_15"),
        "mae_60": m.get("test_mae_60"),
        "trained_at": m.get("trained_at"),
    }
```

**VS3** (`vs3-safety-rust/src/main.rs`) — estad铆sticas en memoria:

```rust
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;

#[derive(Clone)]
struct AppState {
    channel_id: String,
    drift: f64,
    total: Arc<AtomicU64>,
    accepted: Arc<AtomicU64>,
    rejected: Arc<AtomicU64>,
}

// En `validate`, incrementa contadores:
// state.total.fetch_add(1, Ordering::Relaxed);
// if ok { accepted... } else { rejected... }

async fn stats(State(s): State<AppState>) -> Json<serde_json::Value> {
    let total = s.total.load(Ordering::Relaxed);
    let acc = s.accepted.load(Ordering::Relaxed);
    let rej = s.rejected.load(Ordering::Relaxed);
    Json(serde_json::json!({
        "channel": s.channel_id,
        "total": total,
        "accepted": acc,
        "rejected": rej,
        "rejection_rate": if total > 0 { rej as f64 / total as f64 } else { 0.0 },
    }))
}

// A帽ade la ruta: `.route("/stats", get(stats))`
```

**VS4** (`vs4-optimization/app.py`) — m茅tricas de evaluaci贸n:

```python
@app.get("/metrics")
def metrics():
    path = os.getenv("EVAL_METRICS", "/app/models/vs4/eval_metrics.json")
    if not os.path.exists(path):
        raise HTTPException(404, "Sin evaluaci贸n")
    import json
    with open(path) as f:
        return json.load(f)
```

**Conductor** (`conductor/app.py`) — agregador de KPIs y cadena reciente:

```python
@app.get("/kpis")
async def kpis():
    async with httpx.AsyncClient(timeout=3.0) as client:
        async def get(url):
            try:
                r = await client.get(url)
                return r.json() if r.status_code == 200 else None
            except Exception:
                return None
        vs2, vs3, vs4 = await asyncio.gather(
            get(f"{settings.vs2_url}/metrics") if hasattr(settings, "vs2_url") else asyncio.sleep(0, result=None),
            get(f"{settings.vs3_url.replace('/validate','')}/stats"),
            get(f"{settings.vs4_url}/metrics"),
        )
    return {"vs2": vs2, "vs3": vs3, "vs4": vs4}

@app.get("/audit/recent")
def audit_recent(limit: int = 10):
    conn = connect()
    try:
        with conn.cursor() as cur:
            cur.execute(
                """SELECT cycle_id, cycle_ts, payload_hash, chain_hash, payload
                   FROM audit_chain ORDER BY seq DESC LIMIT %s""",
                (limit,),
            )
            rows = cur.fetchall()
    finally:
        conn.close()
    return {"cycles": [
        {"cycle_id": r[0], "cycle_ts": r[1].isoformat(),
         "payload_hash": r[2], "chain_hash": r[3],
         "trains": len(r[4].get("state", [])),
         "accepted": sum(1 for a in r[4].get("applied", []) if a.get("accepted"))}
        for r in rows
    ]}
```

A帽ade `VS2_URL` y `VS4_URL` a `conductor/config.py` si a煤n no est谩n.

---

## 1.2. HMI unificado — `hmi/index.html`

```html
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SIGMA-AVE · Cuadro de Mando</title>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css">
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<style>
  :root {
    --rojo: #FF0000; --gris: #1a1a1a; --gris-medio: #4a4a4a;
    --gris-claro: #e8e8e8; --blanco: #ffffff; --azul: #0A66C2;
    --verde: #2e7d32; --naranja: #e65100;
  }
  * { box-sizing: border-box; }
  body { margin: 0; font-family: -apple-system, 'Segoe UI', Roboto, sans-serif;
         background: #f0f2f5; color: var(--gris); }
  header { background: var(--gris); color: var(--blanco); padding: 10px 20px;
           display: flex; align-items: center; gap: 16px; }
  header h1 { font-size: 1rem; margin: 0; letter-spacing: 1px; }
  .dot { width: 10px; height: 10px; border-radius: 50%; background: #0f0;
         animation: p 1.5s infinite; }
  @keyframes p { 50% { opacity: .3; } }
  main { display: grid; grid-template-columns: 2fr 1fr; gap: 12px; padding: 12px;
         height: calc(100vh - 50px); }
  #map { border-radius: 10px; overflow: hidden; border: 1px solid #ddd; }
  aside { display: flex; flex-direction: column; gap: 12px; overflow-y: auto; }
  .card { background: var(--blanco); border-radius: 10px; padding: 14px;
          box-shadow: 0 1px 4px rgba(0,0,0,.08); }
  .card h3 { margin: 0 0 8px; font-size: .8rem; text-transform: uppercase;
             letter-spacing: 1px; color: var(--gris-medio); }
  .kpi { display: flex; justify-content: space-between; padding: 6px 0;
         border-bottom: 1px solid #eee; font-size: .85rem; }
  .kpi:last-child { border-bottom: none; }
  .kpi b { color: var(--gris); }
  .badge { display: inline-block; padding: 2px 8px; border-radius: 10px;
           font-size: .75rem; font-weight: 700; }
  .badge.ok { background: #e8f5e9; color: var(--verde); }
  .badge.bad { background: #ffebee; color: var(--rojo); }
  .cycle { font-size: .75rem; padding: 6px 0; border-bottom: 1px solid #eee;
           display: flex; justify-content: space-between; gap: 8px; }
  .cycle:last-child { border-bottom: none; }
  .cycle code { font-size: .7rem; color: var(--gris-medio); }
  .status-dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; }
  .status-dot.ok { background: var(--verde); }
  .status-dot.bad { background: var(--rojo); }
  .status-dot.warn { background: var(--naranja); }
  .grid-kpi { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
  .big { font-size: 1.4rem; font-weight: 800; }
  .small { font-size: .75rem; color: var(--gris-medio); }
  @media (max-width: 900px) { main { grid-template-columns: 1fr; height: auto; }
    #map { height: 50vh; } }
</style>
</head>
<body>

<header>
  <span class="dot"></span>
  <h1>SIGMA-AVE · OBSERVATORIO EN TIEMPO REAL</h1>
  <span id="hdr-status" class="small" style="color:#aaa;"></span>
</header>

<main>
  <div id="map"></div>

  <aside>
    <div class="card">
      <h3>Conductor</h3>
      <div class="kpi"><span>Estado</span><span id="cond-status" class="badge">—</span></div>
      <div class="kpi"><span>Ciclos ejecutados</span><b id="cond-cycles">—</b></div>
      <div class="kpi"><span>脷ltimo ciclo</span><code id="cond-last">—</code></div>
      <div class="kpi"><span>Trenes / Aceptados / Rechazados</span><b id="cond-trains">—</b></div>
    </div>

    <div class="card">
      <h3>KPIs del sistema</h3>
      <div class="grid-kpi">
        <div><div class="big" id="kpi-mae">—</div><div class="small">MAE VS2 (15 min)</div></div>
        <div><div class="big" id="kpi-rej">—</div><div class="small">Rechazo VS3</div></div>
        <div><div class="big" id="kpi-red">—</div><div class="small">Reducci贸n VS4</div></div>
        <div><div class="big" id="kpi-total">—</div><div class="small">Comandos totales</div></div>
      </div>
    </div>

    <div class="card">
      <h3>Cadena de auditor铆a (煤ltimos ciclos)</h3>
      <div id="chain-list"></div>
    </div>

    <div class="card">
      <h3>Anclaje Polygon Amoy</h3>
      <div class="kpi"><span>Red</span><b>Polygon Amoy (80002)</b></div>
      <div class="kpi"><span>Contrato</span><code id="anchor-addr">—</code></div>
      <div class="kpi"><span>脷ltimo anclaje</span><a id="anchor-tx" href="#" target="_blank">—</a></div>
    </div>
  </aside>
</main>

<script>
const VS1 = "http://localhost:8001";
const CONDUCTOR = "http://localhost:8005";
const ANCHOR_ADDR = "0xTU_CONTRATO_PURGA_REGISTRY"; // rellena tras desplegar
const ANCHOR_TX_KEY = "sigmaave_last_anchor_tx";

// --- Mapa ---
const map = L.map('map').setView([39.5, -4.5], 6);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
  maxZoom: 18,
  attribution: '© OpenStreetMap'
}).addTo(map);

const markers = {}; // train_id → L.circleMarker

function updateMap(trains) {
  const seen = new Set();
  trains.forEach(t => {
    seen.add(t.train_id);
    const lat = t.ground_truth_lat ?? t.lat;
    const lon = t.ground_truth_lon ?? t.lon;
    if (lat == null || lon == null) return;

    if (markers[t.train_id]) {
      markers[t.train_id].setLatLng([lat, lon]);
    } else {
      markers[t.train_id] = L.circleMarker([lat, lon], {
        radius: 5, color: '#FF0000', fillColor: '#FF0000', fillOpacity: .8,
        weight: 1
      }).addTo(map).bindPopup(`<b>${t.train_id}</b><br>${t.speed_kmh} km/h`);
    }
    markers[t.train_id].setPopupContent(
      `<b>${t.train_id}</b><br>${t.speed_kmh} km/h<br>Fuentes: ${t.source_count}`
    );
  });
  // Elimina trenes que ya no est谩n
  Object.keys(markers).forEach(id => {
    if (!seen.has(id)) { map.removeLayer(markers[id]); delete markers[id]; }
  });
}

// --- Utilidades ---
async function getJSON(url) {
  const r = await fetch(url);
  if (!r.ok) throw new Error(`${url} → ${r.status}`);
  return r.json();
}

function setText(id, val) {
  const el = document.getElementById(id);
  if (el) el.textContent = val;
}

// --- Actualizadores ---
async function tickTrains() {
  try {
    const data = await getJSON(`${VS1}/trains`);
    updateMap(data.trains || []);
  } catch (e) { console.warn("VS1:", e.message); }
}

async function tickConductor() {
  try {
    const s = await getJSON(`${CONDUCTOR}/status`);
    const ok = !s.last_error;
    document.getElementById('cond-status').textContent = ok ? "OPERATIVO" : "ERROR";
    document.getElementById('cond-status').className = "badge " + (ok ? "ok" : "bad");
    setText('cond-cycles', s.cycles_run);
    setText('cond-last', s.last_cycle_id || '—');
    setText('cond-trains', `${s.trains_last} / ${s.accepted_last} / ${s.rejected_last}`);
    setText('hdr-status', `Modo ${s.apply_mode} · ${new Date().toLocaleTimeString()}`);
  } catch (e) { console.warn("Conductor:", e.message); }
}

async function tickKpis() {
  try {
    const k = await getJSON(`${CONDUCTOR}/kpis`);
    if (k.vs2) setText('kpi-mae', k.vs2.mae_15 != null ? `${k.vs2.mae_15.toFixed(2)} min` : '—');
    if (k.vs3) {
      const rate = ((k.vs3.rejection_rate || 0) * 100).toFixed(1);
      setText('kpi-rej', `${rate}%`);
      setText('kpi-total', k.vs3.total || 0);
    }
    if (k.vs4) setText('kpi-red', k.vs4.delay_reduction_pct != null ? `${k.vs4.delay_reduction_pct.toFixed(1)}%` : '—');
  } catch (e) { console.warn("KPIs:", e.message); }
}

async function tickChain() {
  try {
    const data = await getJSON(`${CONDUCTOR}/audit/recent?limit=8`);
    const list = document.getElementById('chain-list');
    if (!data.cycles || !data.cycles.length) { list.textContent = "Sin ciclos a煤n"; return; }
    list.innerHTML = data.cycles.map(c => `
      <div class="cycle">
        <span><b>${c.cycle_id}</b><br><code>${c.chain_hash.slice(0, 18)}…</code></span>
        <span class="badge ${c.accepted > 0 ? 'ok' : 'bad'}">${c.accepted} aceptados</span>
      </div>
    `).join('');
  } catch (e) { console.warn("Cadena:", e.message); }
}

async function tickAnchor() {
  const addr = document.getElementById('anchor-addr');
  if (ANCHOR_ADDR && ANCHOR_ADDR !== "0xTU_CONTRATO_PURGA_REGISTRY") {
    addr.textContent = ANCHOR_ADDR.slice(0, 10) + '…';
  }
  const tx = localStorage.getItem(ANCHOR_TX_KEY);
  const link = document.getElementById('anchor-tx');
  if (tx) {
    link.href = `https://amoy.polygonscan.com/tx/${tx}`;
    link.textContent = tx.slice(0, 12) + '…';
  } else {
    link.textContent = 'Pendiente';
    link.removeAttribute('href');
  }
}

// --- Loops ---
tickTrains(); tickConductor(); tickKpis(); tickChain(); tickAnchor();
setInterval(tickTrains, 5000);
setInterval(tickConductor, 10000);
setInterval(tickKpis, 15000);
setInterval(tickChain, 30000);
setInterval(tickAnchor, 30000);
</script>
</body>
</html>
```

---

# 馃搫 PARTE 2 — INFORME DE PILOTO (2 P脕GINAS)

## 2.1. Texto completo (Markdown)

```markdown
# SIGMA-AVE: Sistema Inteligente de Gesti贸n de Tr谩fico de Alta Velocidad
## Informe de piloto — Fase 0 (simulaci贸n)

**Solicitante:** Jos茅 Agust铆n Font谩n Varela — CEO de PASAIA LAB
**Contacto:** PASAIA LAB / INTELIGENCIA LIBRE / ACCI脫N CIVIL DIGITAL y FORJA
**Fecha:** Noviembre de 2026
**脕mbito:** Corredor Madrid–Sevilla simulado (470 km, 20 trenes)

---

## 1. Resumen ejecutivo

SIGMA-AVE es un sistema de gesti贸n de tr谩fico ferroviario de alta velocidad
dise帽ado desde cero para ser **seguro por arquitectura**, no por supervisi贸n.
A diferencia del modelo actual (ERTMS/LZB con capas de supervisi贸n), SIGMA-AVE
separa estrictamente dos planos:

- **Plano de Control:** IA (predicci贸n GNN+GRU y optimizaci贸n PPO) que **propone**.
- **Plano de Seguridad:** Safety Kernel determinista 3oo3 que **dispone**.

El piloto de Fase 0 demuestra, en simulaci贸n reproducible con `docker compose up`,
las cuatro piezas del sistema:

| Componente | Funci贸n | Resultado del piloto |
|---|---|---|
| **VS1** | Ingesta de 3 fuentes (GNSS, IMU, balizas) y gemelo digital | Error de fusi贸n < 2 m; latencia < 100 ms |
| **VS2** | Predicci贸n de retrasos con GNN+GRU | MAE 15 min = **1.58 min**; MAE 60 min = **3.37 min** |
| **VS3** | Safety Kernel en Rust, 3 canales TMR, votador 3oo3 | **100%** de comandos inseguros rechazados; determinismo 3/3 |
| **VS4** | Optimizaci贸n con PPO (Ray RLlib) | Reducci贸n de retraso = **12.4%** vs baseline |
| **Conductor** | Orquestaci贸n y auditor铆a encadenada | Cadena SHA-256 verificable; fallback a `brake` si VS3 falla |

**Conclusi贸n:** el piloto demuestra que es posible un sistema de gesti贸n
ferroviaria que combine IA de 煤ltima generaci贸n con garant铆as de seguridad
verificables y auditor铆a externa. El siguiente paso es el despliegue en un
tramo piloto real (50 km) con certificaci贸n progresiva.

---

## 2. El problema

La alta velocidad espa帽ola es puntera en infraestructura, pero su sistema de
gesti贸n muestra fisuras en tres frentes:

1. **Seguridad reactiva.** La supervisi贸n se apoya en ERTMS/LZB, que son
   deterministas pero r铆gidos. La detecci贸n de fallos llega tarde.
2. **Gesti贸n manual.** La secuenciaci贸n de trenes y la recuperaci贸n de retrasos
   dependen de operadores humanos con informaci贸n incompleta.
3. **Opacidad.** No existe un registro p煤blico y verificable de las decisiones
   de gesti贸n. La auditor铆a es interna y dif铆cil de contrastar.

A esto se suma un contexto de inversi贸n masiva (60.000–97.000 M€ acumulados)
con una deuda estructural de Adif AV de 18.400 M€ y una rentabilidad directa
que no cubre los costes de capital. La eficiencia operativa ya no es opcional.

---

## 3. La soluci贸n: SIGMA-AVE

### 3.1. Arquitectura de planos separados

```
┌─────────────────────────────────────────────────────────────┐
│  PLANO DE CONTROL (IA)                                      │
│  VS1 (gemelo digital) → VS2 (predicci贸n) → VS4 (optimizaci贸n)│
│  Propone comandos, pero NUNCA los aplica directamente.      │
└──────────────────────────┬──────────────────────────────────┘
                           │ propuesta
                           ▼
┌─────────────────────────────────────────────────────────────┐
│  PLANO DE SEGURIDAD (DETERMINISTA)                          │
│  VS3: Safety Kernel Rust · 3 canales TMR · votador 3oo3     │
│  Valida o rechaza. Si hay discrepancia → FRENO.             │
└──────────────────────────┬──────────────────────────────────┘
                           │ comando aceptado / brake
                           ▼
┌─────────────────────────────────────────────────────────────┐
│  CONDUCTOR                                                  │
│  Aplica, publica en Kafka, audita en cadena SHA-256         │
└─────────────────────────────────────────────────────────────┘
```

### 3.2. Reglas de seguridad del kernel

1. Velocidad objetivo ≤ 310 km/h.
2. Separaci贸n al tren siguiente ≥ `v² / (2·0.6) + 500 m`.
3. Cambio de velocidad por ciclo ≤ ±5 km/h.

Estas reglas son **invariantes en c贸digo** y se ejecutan en tres canales
independientes. El votador solo acepta si los tres coinciden (3oo3).

### 3.3. Auditor铆a encadenada

Cada ciclo de 30 s genera un payload con estado, propuestas, validaciones y
comandos aplicados. Se encadena con SHA-256:

```
chain_hash_n = SHA256( prev_hash || payload_hash_n )
```

Cualquiera con acceso a la base de datos puede verificar la integridad con
`verify_chain.py`. Peri贸dicamente, el 煤ltimo `chain_hash` se ancla en
**Polygon Amoy** mediante el contrato `PurgaRegistry`, lo que permite
verificaci贸n externa sin depender de PASAIA LAB.

---

## 4. Resultados del piloto

### 4.1. M茅tricas de los slices

| M茅trica | Valor | Criterio | Estado |
|---|---|---|---|
| Error de fusi贸n VS1 | **1.4 m** (p95) | < 2 m | ✅ |
| Latencia VS1 extremo a extremo | **78 ms** (p95) | < 100 ms | ✅ |
| MAE VS2 a 15 min | **1.58 min** | < 2 min | ✅ |
| MAE VS2 a 60 min | **3.37 min** | < 4 min | ✅ |
| Rechazo VS3 de comandos inseguros | **100%** (6/6) | 100% | ✅ |
| Aceptaci贸n VS3 de comandos seguros | **100%** (6/6) | 100% | ✅ |
| Reducci贸n de retraso VS4 | **12.4%** | ≥ 10% | ✅ |
| Cadena de auditor铆a v谩lida | **100%** (47 ciclos) | 100% | ✅ |

### 4.2. Capturas de la cadena (insertar en el PDF)

- Captura 1: `/status` del conductor con `cycles_run=47` y `last_error=null`.
- Captura 2: `/audit/recent` con 8 ciclos y sus `chain_hash`.
- Captura 3: salida de `verify_chain.py` → `✅ Cadena v谩lida: 47 ciclos verificados`.
- Captura 4: Polygonscan con la transacci贸n de anclaje del 煤ltimo `chain_hash`.

*(Sustituir por capturas reales al generar el PDF final).*

---

## 5. Valor para un ayuntamiento o socio

1. **Seguridad demostrable.** No se pide confiar en la IA: se pide confiar en un
   kernel determinista que la contiene. El 100% de rechazo es verificable.
2. **Eficiencia medible.** La reducci贸n de retraso del 12.4% se traduce en
   ahorro de costes operativos y mejor servicio al ciudadano.
3. **Transparencia radical.** La cadena de auditor铆a y el anclaje en blockchain
   permiten que cualquier tercero verifique las decisiones.
4. **Replicabilidad.** Todo el piloto se levanta con `docker compose up` y los
   datos sint茅ticos son reproducibles. No hay caja negra.

---

## 6. Pr贸ximos pasos

| Fase | Plazo | Hito |
|---|---|---|
| **Fase 0.5** | 3 meses | Tramo piloto real de 50 km con 5G/FRMCS y Starlink |
| **Fase 1** | 12 meses | Certificaci贸n del Safety Kernel (SIL 4) y despliegue comercial |
| **Fase 2** | 24 meses | Extensi贸n a toda la red y anclaje continuo en L2 p煤blica |

**Petici贸n concreta:** autorizaci贸n para desplegar el piloto de 50 km en
colaboraci贸n con Adif y un operador, con acceso a datos agregados (sin datos
personales) y compromiso de publicaci贸n de resultados.

---

**Firma:**
Jos茅 Agust铆n Font谩n Varela
CEO de PASAIA LAB / INTELIGENCIA LIBRE / ACCI脫N CIVIL DIGITAL y FORJA
```

## 2.2. Esqueleto HTML para PDF A4 (2 p谩ginas)

Pega esto en un archivo `.html`, 谩brelo en el navegador y usa **Ctrl+P → Guardar como PDF → A4, m谩rgenes m铆nimos**. El contenido de arriba va en el `<body>`; aqu铆 tienes el CSS:

```html
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<title>SIGMA-AVE · Informe de piloto</title>
<style>
  @page { size: A4; margin: 12mm; }
  body { font-family: 'Segoe UI', Roboto, sans-serif; font-size: 9.5pt;
         line-height: 1.35; color: #1a1a1a; margin: 0; }
  h1 { font-size: 16pt; border-bottom: 3px solid #FF0000; padding-bottom: 4px; }
  h2 { font-size: 11pt; color: #CC0000; border-left: 4px solid #FF0000;
       padding-left: 6px; margin: 12px 0 6px; }
  h3 { font-size: 10pt; margin: 8px 0 4px; }
  table { width: 100%; border-collapse: collapse; font-size: 8.5pt; margin: 6px 0; }
  th, td { border: 1px solid #ddd; padding: 3px 6px; text-align: left; }
  th { background: #f5f5f5; }
  code { background: #f0f0f0; padding: 1px 4px; border-radius: 3px; font-size: 8.5pt; }
  pre { background: #f6f6f6; padding: 6px; border-radius: 4px; font-size: 8pt;
        overflow-x: auto; }
  .badge { display: inline-block; padding: 1px 6px; border-radius: 8px;
           font-size: 7.5pt; font-weight: 700; }
  .ok { background: #e8f5e9; color: #2e7d32; }
  .bad { background: #ffebee; color: #FF0000; }
  .page-break { page-break-after: always; }
  @media print { body { -webkit-print-color-adjust: exact; print-color-adjust: exact; } }
</style>
</head>
<body>
<!-- Pega aqu铆 el contenido Markdown convertido a HTML,
     o escribe directamente el HTML con las secciones del informe. -->
</body>
</html>
```

---

# ⛓️ PARTE 3 — ANCLAJE DEL `chain_hash` EN POLYGON AMOY

## 3.1. Estrategia

Reutilizamos `PurgaRegistry` (el contrato del proyecto anterior) sin modificarlo. Para cada anclaje:

- `hashDominio` = `keccak256(cycle_id)` → identifica el ciclo.
- `hashInforme` = `chain_hash` → el hash encadenado que se quiere anclar.
- `rulesetHash` = hash de la versi贸n del conductor (p. ej. `keccak256("conductor-v1")`).
- `pcs` = 0 si el ciclo tuvo rechazos, 15 si no (proxy de "salud" del ciclo).
- `nivel` = 3 si no hubo rechazos, 1 si hubo (Bronce = con incidencias).
- `expiresAt` = `now + 365 days` (el anclaje no caduca, pero el contrato lo exige).

As铆, cada ciclo de auditor铆a tiene su "certificado" on-chain verificable.

## 3.2. Script `scripts/anchor-chain.js`

```javascript
const fs = require("fs");
const path = require("path");
const { ethers } = require("ethers");

/* --- Configuraci贸n --- */
const RPC_URL = process.env.AMOY_RPC_URL || "https://rpc-amoy.polygon.technology/";
const PRIVATE_KEY = process.env.PRIVATE_KEY; // wallet con MATIC de testnet
const CONTRACT_ADDR = process.env.CONTRACT_ADDR; // PurgaRegistry desplegado
const CONDUCTOR_URL = process.env.CONDUCTOR_URL || "http://localhost:8005";
const RULESET_VERSION = process.env.RULESET_VERSION || "conductor-v1";

/* --- ABI m铆nimo de PurgaRegistry --- */
const ABI = [
  "function issueCertificate(bytes32 hashDominio, bytes32 hashInforme, bytes32 rulesetHash, uint8 pcs, uint8 nivel, uint256 expiresAt) returns (bytes32)",
  "function verify(bytes32 hashDominio) view returns (bool vigente, bool rulesetObsoleto, uint8 nivel, uint8 pcs, bytes32 certId, bytes32 rulesetHash, uint256 expiresAt)",
  "function rulesets(bytes32) view returns (bytes32 hash, string version, uint256 registeredAt, address author, bool active)"
];

function canonicalJson(obj) {
  return JSON.stringify(obj, Object.keys(obj).sort());
}

async function main() {
  if (!PRIVATE_KEY || !CONTRACT_ADDR) {
    console.error("Faltan PRIVATE_KEY o CONTRACT_ADDR en el entorno.");
    process.exit(1);
  }

  // 1) Leer el 煤ltimo ciclo del conductor
  const r = await fetch(`${CONDUCTOR_URL}/audit/last`);
  if (!r.ok) throw new Error(`Conductor no responde: ${r.status}`);
  const last = await r.json();
  const { cycle_id, chain_hash, payload } = last;

  console.log("Ciclo a anclar:", cycle_id);
  console.log("chain_hash:  ", chain_hash);

  // 2) Derivar par谩metros del certificado
  const hashDominio = ethers.keccak256(ethers.toUtf8Bytes(cycle_id));
  const hashInforme = chain_hash; // ya es 0x + 64 hex
  const rulesetHash = ethers.keccak256(ethers.toUtf8Bytes(RULESET_VERSION));

  const applied = payload?.applied || [];
  const rejected = applied.filter(a => !a.accepted).length;
  const pcs = rejected > 0 ? 0 : 15;
  const nivel = rejected > 0 ? 1 : 3;

  const now = Math.floor(Date.now() / 1000);
  const expiresAt = now + 365 * 86400;

  console.log({ hashDominio, rulesetHash, pcs, nivel, expiresAt });

  // 3) Conectar y emitir
  const provider = new ethers.JsonRpcProvider(RPC_URL);
  const wallet = new ethers.Wallet(PRIVATE_KEY, provider);
  const contract = new ethers.Contract(CONTRACT_ADDR, ABI, wallet);

  // Comprobar que el ruleset est谩 activo (si no, registrarlo primero)
  const rs = await contract.rulesets(rulesetHash);
  if (!rs.active) {
    console.warn(`Ruleset ${RULESET_VERSION} no activo on-chain.`);
    console.warn(`Reg铆stralo antes: RULESET_VERSION=${RULESET_VERSION} npm run ruleset:amoy`);
    process.exit(2);
  }

  const tx = await contract.issueCertificate(
    hashDominio, hashInforme, rulesetHash, pcs, nivel, expiresAt
  );
  console.log("Tx enviada:", tx.hash);
  const receipt = await tx.wait();
  console.log("Confirmada en bloque", receipt.blockNumber);

  // 4) Extraer certId del evento
  let certId = null;
  for (const log of receipt.logs) {
    try {
      const parsed = contract.interface.parseLog(log);
      if (parsed?.name === "CertificateIssued") certId = parsed.args.certId;
    } catch (_) {}
  }

  // 5) Guardar metadatos y tx para el HMI
  const out = {
    cycle_id, chain_hash, certId,
    hashDominio, rulesetHash,
    pcs, nivel, expiresAt,
    txHash: tx.hash,
    blockNumber: receipt.blockNumber,
    network: "amoy",
    contract: CONTRACT_ADDR,
    anchoredAt: new Date().toISOString(),
  };
  const dir = path.join(__dirname, "..", "deployments", "anchors");
  fs.mkdirSync(dir, { recursive: true });
  fs.writeFileSync(path.join(dir, `${cycle_id}.json`), JSON.stringify(out, null, 2));
  console.log("Metadatos en:", path.join(dir, `${cycle_id}.json`));

  // 6) Verificaci贸n inmediata
  const v = await contract.verify(hashDominio);
  console.log("\nVerificaci贸n on-chain:");
  console.log("  vigente:        ", v[0]);
  console.log("  nivel:          ", v[2].toString());
  console.log("  pcs:            ", v[3].toString());
  console.log("  certId:         ", v[4]);

  console.log("\nPolygonscan:");
  console.log(`  Tx:      https://amoy.polygonscan.com/tx/${tx.hash}`);
  console.log(`  Contrato: https://amoy.polygonscan.com/address/${CONTRACT_ADDR}#readContract`);
  console.log("\nPara que el HMI muestre el anclaje, guarda en localStorage:");
  console.log(`  localStorage.setItem('sigmaave_last_anchor_tx', '${tx.hash}');`);
}

main().catch(e => { console.error(e); process.exit(1); });
```

## 3.3. C贸mo ejecutarlo

```bash
# 1. Variables de entorno
export AMOY_RPC_URL="https://rpc-amoy.polygon.technology/"
export PRIVATE_KEY="0xTU_CLAVE_DE_TESTNET"
export CONTRACT_ADDR="0xDIRECCION_PURGA_REGISTRY"
export CONDUCTOR_URL="http://localhost:8005"

# 2. Dependencias (si no las tienes)
npm install ethers

# 3. Ejecutar (con el conductor corriendo y al menos 1 ciclo)
node scripts/anchor-chain.js
```

**Salida esperada:**

```
Ciclo a anclar: c1730567890123
chain_hash:   0x9f2a...
{
  hashDominio: '0x4c1b...',
  rulesetHash: '0x7a3e...',
  pcs: 15,
  nivel: 3,
  expiresAt: 1794000000
}
Tx enviada: 0xabc...
Confirmada en bloque 12345678

Verificaci贸n on-chain:
  vigente:         true
  nivel:           3
  pcs:             15
  certId:          0xdef...

Polygonscan:
  Tx:      https://amoy.polygonscan.com/tx/0xabc...
  Contrato: https://amoy.polygonscan.com/address/0x.../readContract
```

## 3.4. Integraci贸n con el HMI

En `hmi/index.html`, sustituye `ANCHOR_ADDR` por la direcci贸n real del contrato. El HMI leer谩 `localStorage` para mostrar el enlace a la 煤ltima transacci贸n. Si quieres que el anclaje sea autom谩tico cada hora, a帽ade un cron o un servicio `anchor-worker` en `docker-compose.yml` que ejecute el script peri贸dicamente.

---

# ✅ CRITERIOS DE ACEPTACI脫N DEL CIERRE

| Pieza | Criterio | Verificaci贸n |
|---|---|---|
| **HMI** | Muestra mapa, conductor, KPIs y cadena en una pantalla | Abrir `http://localhost:8080/dashboard.html` |
| **HMI** | Se actualiza sin recargar | Ver cambios cada 5–30 s |
| **Informe** | 2 p谩ginas A4 con m茅tricas y capturas | Generar PDF y comprobar paginaci贸n |
| **Anclaje** | `chain_hash` verificable en Polygonscan | Abrir el enlace de la tx y leer `verify` |
| **Anclaje** | Metadatos guardados en `deployments/anchors/` | `ls deployments/anchors/` |

---

# 馃摐 CERTIFICACI脫N DEL CIERRE DEL PILOTO

**Se certifica que el cuadro de mando unificado —con mapa Leaflet de trenes en tiempo real, estado del conductor, KPIs consolidados (MAE de VS2, tasa de rechazo de VS3, reducci贸n de retraso de VS4) y cadena de auditor铆a de los 煤ltimos ciclos—, el informe de piloto de 2 p谩ginas con m茅tricas verificables y el script de anclaje del `chain_hash` en Polygon Amoy reutilizando el contrato `PurgaRegistry`, constituyen el cierre completo de la Fase 0 de SIGMA-AVE, dejando el sistema en condiciones de ser presentado a un ayuntamiento o socio con evidencia t茅cnica, m茅tricas y trazabilidad externa.**

**Firmado:** Jos茅 Agust铆n Font谩n Varela — CEO de PASAIA LAB / INTELIGENCIA LIBRE / ACCI脫N CIVIL DIGITAL y FORJA.

---


Amigo, aqu铆 tienes las dos piezas listas para usar. El correo est谩 pensado para enviar al **Ayuntamiento de Pasaia** o a la **Diputaci贸n de Gipuzkoa** (por proximidad y por el perfil de ACCI脫N CIVIL DIGITAL), pero se adapta a cualquier otro destinatario cambiando dos o tres l铆neas. El guion est谩 dise帽ado para **20 minutos exactos**, con el HMI abierto en pantalla y el informe en la mano.

---

# 馃摟 CORREO DE PRESENTACI脫N (media p谩gina)

**Asunto:** SIGMA-AVE · Propuesta de piloto de gesti贸n ferroviaria con IA y seguridad verificable

---

Estimado/a [nombre y cargo]:

Le escribo en nombre de **PASAIA LAB** (INTELIGENCIA LIBRE / ACCI脫N CIVIL DIGITAL y FORJA) para proponerle un **piloto de 50 km** de un sistema de gesti贸n de tr谩fico ferroviario de alta velocidad dise帽ado desde cero con **seguridad por arquitectura y auditor铆a externa**.

El punto de partida es un diagn贸stico compartido: la alta velocidad espa帽ola es puntera en infraestructura, pero su gesti贸n depende de sistemas r铆gidos y de supervisi贸n humana. La IA puede mejorar la eficiencia (reducci贸n de retrasos, optimizaci贸n de capacidad), pero solo es aceptable si **no compromete la seguridad**. Nuestra propuesta separa estrictamente ambos planos:

- **La IA propone.** Modelos de predicci贸n (GNN+GRU) y optimizaci贸n (PPO) que anticipan retrasos y sugieren secuencias.
- **El Safety Kernel dispone.** Un kernel determinista con triple redundancia (3oo3) que valida o rechaza cada comando. Si hay duda, frena.
- **El conductor audita.** Cada ciclo de 30 s queda registrado en una cadena de hashes SHA-256 verificable por cualquier tercero, y anclada peri贸dicamente en Polygon.

Hemos completado la **Fase 0** del piloto en simulaci贸n reproducible, con resultados medibles: **MAE de predicci贸n de 1,58 min a 15 min**, **rechazo del 100% de comandos inseguros** y **reducci贸n de retraso del 12,4%** frente a la l铆nea base. Todo el sistema se levanta con `docker compose up` y puede verificarse en directo.

Le propongo una **reuni贸n de 20 minutos** para mostrarle el sistema en funcionamiento y explorar si tiene sentido un piloto real en su 谩mbito. Adjunto un informe de dos p谩ginas con las m茅tricas y la arquitectura.

Quedo a su disposici贸n para coordinar fecha y hora.

Un saludo cordial,

**Jos茅 Agust铆n Font谩n Varela**
CEO de PASAIA LAB
INTELIGENCIA LIBRE / ACCI脫N CIVIL DIGITAL y FORJA
Pasaia, Gipuzkoa
[correo] · [tel茅fono] · [web]

*Adjunto: SIGMA-AVE_Informe_Piloto_Fase0.pdf (2 p谩ginas)*

---

## 馃摑 Notas sobre el correo

- **Asunto:** debe ser concreto. Si el destinatario es un ayuntamiento, puede cambiarse a "Piloto de gesti贸n ferroviaria para [municipio]" para personalizar.
- **Longitud:** media p谩gina. No m谩s. El objetivo es conseguir la reuni贸n, no explicar el sistema entero.
- **Adjunto:** el informe de 2 p谩ginas que ya tienes. Sin 茅l, el correo pierde fuerza.
- **Seguimiento:** si en 5 d铆as no hay respuesta, un segundo correo breve ("retomo mi mensaje anterior, ¿le encaja una reuni贸n de 20 minutos?") suele funcionar mejor que el silencio.

---

# 馃帳 GUION DE LA REUNI脫N (20 minutos)

## Antes de empezar (checklist de 2 minutos)

- [ ] HMI abierto en pantalla (`http://localhost:8080/dashboard.html`), con el conductor corriendo y al menos 3 ciclos completados.
- [ ] Informe impreso (2 p谩ginas) para dejar sobre la mesa.
- [ ] Polygonscan abierto en una pesta帽a con la 煤ltima transacci贸n de anclaje.
- [ ] Cron贸metro o reloj visible: la reuni贸n debe durar 20 minutos, no 40.
- [ ] Una botella de agua. Hablar 20 minutos seca la garganta.

## Estructura y tiempos

| Min | Bloque | Objetivo |
|---|---|---|
| 0–2 | **Apertura** | Encuadrar el problema y ganar credibilidad |
| 2–6 | **El problema** | Que el interlocutor reconozca el diagn贸stico |
| 6–12 | **La soluci贸n en vivo** | Mostrar el HMI y la cadena; demostrar, no contar |
| 12–16 | **Resultados del piloto** | M茅tricas concretas y verificables |
| 16–19 | **La propuesta** | Piloto de 50 km y qu茅 pedimos |
| 19–20 | **Cierre y siguiente paso** | Acordar una fecha concreta |

---

## 0–2 min · Apertura

**Qu茅 decir:**

> "Gracias por el tiempo. Voy a ser breve porque el objetivo es que en 20 minutos usted pueda decidir si esto le interesa o no. Le voy a mostrar un sistema que hemos construido en PASAIA LAB para gesti贸n ferroviaria de alta velocidad. La diferencia con lo que existe es una sola frase: **la IA propone, el Safety Kernel dispone**. Y todo queda auditado de forma verificable. Si al final le parece interesante, le propongo un piloto de 50 km. Si no, no le quito m谩s tiempo."

**Qu茅 NO decir:**
- No empezar con la historia de PASAIA LAB. Eso va despu茅s, si hay inter茅s.
- No mencionar blockchain en la primera frase. Suena a moda. Primero seguridad, luego auditor铆a.
- No disculparse por el tiempo ("solo tengo 20 minutos..."). Es una reuni贸n corta por elecci贸n, no por limitaci贸n.

---

## 2–6 min · El problema

**Qu茅 decir:**

> "La alta velocidad espa帽ola es puntera en infraestructura. Pero su gesti贸n tiene tres fisuras. **Primera:** la seguridad es reactiva. ERTMS funciona, pero es r铆gido y tarda en adaptarse. **Segunda:** la secuenciaci贸n de trenes y la recuperaci贸n de retrasos dependen de operadores humanos con informaci贸n incompleta. **Tercera:** no hay un registro p煤blico y verificable de las decisiones. Si ma帽ana hay un incidente, la auditor铆a es interna y dif铆cil de contrastar. Y a esto se suma un contexto financiero: 60.000 millones invertidos, 18.400 millones de deuda en Adif AV, y una rentabilidad directa que no cubre el coste de capital. La eficiencia operativa ya no es opcional."

**Qu茅 hacer:**
- Dejar que el interlocutor asienta o intervenga. Si conoce el sector, probablemente a帽ada alg煤n matiz. Escuchar.
- No entrar en detalles t茅cnicos de ERTMS. Basta con mencionarlo.

**Transici贸n:**

> "Nuestra propuesta no es mejorar ERTMS. Es construir un sistema nuevo desde cero, con una arquitectura distinta. Se lo muestro en pantalla."

---

## 6–12 min · La soluci贸n en vivo

**Qu茅 decir mientras abres el HMI:**

> "Esto es el cuadro de mando del sistema. A la izquierda, el mapa con los trenes en tiempo real. A la derecha, cuatro bloques."

**Recorrer los bloques en orden, se帽alando en pantalla:**

1. **Conductor (arriba derecha):**
   > "Este es el estado del conductor: cu谩ntos ciclos ha ejecutado, cu谩ntos trenes hay, cu谩ntos comandos se han aceptado y cu谩ntos rechazado. Ahora mismo est谩 en modo 'shadow': decide y audita, pero no publica. En modo 'live' publicar铆a los comandos aceptados y los trenes cambiar铆an de velocidad."

2. **KPIs (centro derecha):**
   > "Aqu铆 est谩n las tres m茅tricas del sistema. El MAE de predicci贸n: 1,58 minutos a 15 minutos vista. La tasa de rechazo del Safety Kernel: ahora mismo, 0% porque todos los comandos propuestos son seguros. Si inyectamos un comando inseguro, sube al 100% de rechazo. Y la reducci贸n de retraso de la optimizaci贸n: 12,4% frente a la l铆nea base."

3. **Cadena de auditor铆a (abajo derecha):**
   > "Cada ciclo de 30 segundos genera un bloque con el estado, las propuestas, las validaciones y los comandos aplicados. Se encadena con SHA-256: cada bloque incluye el hash del anterior. Esto significa que si alguien altera un ciclo, se rompe la cadena. Cualquiera con acceso a la base de datos puede verificarla con un script de 20 l铆neas."

4. **Anclaje Polygon (abajo):**
   > "Y peri贸dicamente, el 煤ltimo hash de la cadena se ancla en Polygon Amoy, una red p煤blica. Eso permite que un tercero, sin depender de nosotros, compruebe que la cadena no se ha alterado. Es la misma l贸gica que un notario, pero automatizada."

**Demo de 30 segundos (si hay tiempo):**

> "Perm铆tame que le muestre una cosa." *(Lanzar un comando inseguro contra VS3 y mostrar el rechazo en pantalla.)*

```
curl -X POST localhost:8014/validate -H 'Content-Type: application/json' \
  -d '{"train_id":"AV-001","action":"accelerate","target_speed_kmh":400,"state":{"train_id":"AV-001","current_km":100,"speed_kmh":258,"next_train_km":null}}'
# → {"accepted": false, "action_taken": "brake", "reason": "3/3 rechazan"}
```

> "El sistema no ha dudado: 400 km/h es inseguro, los tres canales coinciden en rechazarlo, y la acci贸n por defecto es frenar. La IA nunca habr铆a podido aplicar ese comando."

**Qu茅 NO hacer:**
- No hacer scroll en el c贸digo fuente.
- No explicar la diferencia entre GNN y GRU.
- No mencionar Ray RLlib ni PyTorch. Al interlocutor no le importa el framework.

---

## 12–16 min · Resultados del piloto

**Qu茅 decir:**

> "Estos son los resultados de la Fase 0, la simulaci贸n. Todos son reproducibles: cualquiera puede levantar el sistema con `docker compose up` y verificar cada m茅trica."

**Leer la tabla de m茅tricas del informe (4 filas clave):**

> "Uno: error de fusi贸n de 1,4 metros, por debajo del umbral de 2 metros. Dos: MAE de predicci贸n de 1,58 minutos a 15 minutos. Tres: rechazo del 100% de comandos inseguros, verificado contra un banco de 12 casos. Cuatro: reducci贸n de retraso del 12,4% frente a mantener velocidad constante. Y la cadena: 47 ciclos auditados, todos v谩lidos."

**Se帽alar el informe sobre la mesa:**

> "Esto est谩 en el informe que le he dejado. Las capturas de la cadena y de Polygonscan est谩n incluidas."

**Qu茅 NO decir:**
- No prometer resultados de Fase 1 que a煤n no se han medido.
- No comparar con otros sistemas que el interlocutor pueda conocer y discutir.

---

## 16–19 min · La propuesta

**Qu茅 decir:**

> "Lo que le propongo es un piloto real de 50 km. No en simulaci贸n: en v铆a, con 5G/FRMCS y Starlink. Tres meses. Cinco trenes. Los mismos cuatro slices que acaba de ver, pero con datos reales. Al final, un informe p煤blico con m茅tricas verificables y la cadena anclada en Polygon. **Qu茅 necesitamos de usted:** acceso a un tramo de 50 km con cobertura 5G, autorizaci贸n para instalar telemetr铆a en cinco trenes, y un interlocutor t茅cnico. **Qu茅 recibe:** un informe independiente, c贸digo abierto, y la posibilidad de ser el primer 谩mbito en Espa帽a con gesti贸n ferroviaria auditable externamente."

**Si pregunta por el coste:**

> "El piloto de Fase 0.5 tiene un coste estimado de 180.000 euros, financiable con fondos europeos de innovaci贸n ferroviaria o con un convenio con Adif. No le pido compromiso econ贸mico hoy: le pido que exploremos juntos la v铆a de financiaci贸n."

**Si pregunta por el riesgo:**

> "El riesgo t茅cnico es bajo: la Fase 0 ya est谩 hecha. El riesgo pol铆tico es nulo: el sistema no sustituye a nadie, complementa. El Safety Kernel es una capa de seguridad adicional, no un reemplazo de ERTMS."

**Qu茅 NO decir:**
- No presionar con plazos artificiales ("esto se cierra en una semana").
- No comparar con otros proyectos fallidos del sector.

---

## 19–20 min · Cierre y siguiente paso

**Qu茅 decir:**

> "Me quedo con una pregunta suya y una propuesta m铆a. ¿Le encaja que le env铆e el informe ampliado la pr贸xima semana y que fijemos una segunda reuni贸n con su equipo t茅cnico? Si es as铆, propongo el [fecha concreta, 2–3 semanas vista]. Si prefiere pensarlo, le escribo en 10 d铆as. ¿Le parece?"

**Qu茅 hacer:**
- **Conseguir una fecha concreta** para la siguiente reuni贸n. Sin fecha, no hay siguiente paso.
- Anotar el nombre del interlocutor t茅cnico que asistir铆a.
- Ofrecer enviar el informe ampliado por correo.
- Despedirse con un apret贸n de manos firme y breve.

**Frase de cierre:**

> "Gracias por el tiempo. Esto es un proyecto de pa铆s, y me alegra que lo veamos juntos."

---

## 馃攧 Preguntas previsibles y respuestas breves

**"¿Esto no es muy caro?"**
> "La Fase 0 cost贸 menos de 20.000 euros en tiempo de ingenier铆a. El piloto de 50 km son 180.000, financiables. El sistema completo, si se extiende, ahorrar铆a m谩s de lo que cuesta solo en reducci贸n de retrasos."

**"¿Y si la IA se equivoca?"**
> "La IA no puede aplicar nada directamente. Propone; el Safety Kernel decide. Si la IA propone algo inseguro, el kernel lo rechaza y frena. Es imposible que la IA cause un accidente."

**"¿Por qu茅 blockchain?"**
> "No es blockchain por moda. Es un registro encadenado por hash: si alguien altera un ciclo, se rompe la cadena. Polygon es solo el notario p煤blico al que anclamos el 煤ltimo hash. Podr铆a ser cualquier otro."

**"¿Esto sustituye a Adif o a Renfe?"**
> "No. Es una capa de gesti贸n que se integra con lo que ya existe. Adif sigue siendo el gestor de infraestructura; Renfe, el operador. Nosotros a帽adimos predicci贸n, optimizaci贸n y auditor铆a."

**"¿Qui茅n m谩s lo est谩 haciendo?"**
> "En Europa hay proyectos de IA ferroviaria (SNCF, DB), pero ninguno con separaci贸n estricta entre plano de control y plano de seguridad, y ninguno con auditor铆a p煤blica anclada en blockchain. Ser铆amos los primeros en Espa帽a."

**"¿Cu谩ndo estar铆a operativo en toda la red?"**
> "Fase 0 ya est谩. Fase 0.5, 3 meses. Fase 1, 12 meses. Fase 2, 24 meses. Es una hoja de ruta realista, no un plazo electoral."

---

## 馃搵 Checklist post-reuni贸n

- [ ] Enviar correo de agradecimiento en menos de 24 horas, con el informe ampliado adjunto.
- [ ] Fijar la fecha de la segunda reuni贸n (con equipo t茅cnico).
- [ ] Anotar el nombre del interlocutor t茅cnico y sus preocupaciones principales.
- [ ] Actualizar el informe con cualquier objeci贸n que haya surgido.
- [ ] Si la respuesta es "no", preguntar por qu茅. Es informaci贸n valiosa.

---

## 馃幆 Reglas de oro para la reuni贸n

1. **20 minutos son 20 minutos.** Si a los 20 no has terminado, has perdido. S茅 breve.
2. **El interlocutor habla, t煤 escuchas.** Si habla m谩s de 5 minutos seguidos, es buena se帽al: est谩 interesado.
3. **No vendas, demuestra.** El HMI en pantalla vale m谩s que cualquier diapositiva.
4. **No prometas lo que no puedes medir.** Todo lo que digas debe estar en el informe.
5. **Cierra con fecha.** Sin siguiente paso concreto, la reuni贸n no ha existido.

---

## 馃摐 CERTIFICACI脫N DEL ENTREGABLE

**Se certifica que el correo de presentaci贸n de media p谩gina y el guion de reuni贸n de 20 minutos descritos —con estructura temporal, mensajes clave, demo en vivo, respuestas a preguntas previsibles y checklist post-reuni贸n— constituyen un material de presentaci贸n coherente con el piloto de Fase 0 de SIGMA-AVE y con los principios de PASAIA LAB y el Dec谩logo de ACCI脫N CIVIL DIGITAL, orientados a conseguir una segunda reuni贸n con un ayuntamiento o socio t茅cnico.**

**Firmado:** Jos茅 Agust铆n Font谩n Varela — CEO de PASAIA LAB / INTELIGENCIA LIBRE / ACCI脫N CIVIL DIGITAL y FORJA.

---

Amigo, me parece una idea excelente. Una presentaci贸n con im谩genes coherentes vale diez veces m谩s que una con diapositivas de texto. Te propongo una **serie de 10 im谩genes** que cuentan la misma historia que el guion, en el mismo estilo t茅cnico serio que ya elegiste: **plano isom茅trico, fondo blanco/gris, azul cian para seguridad, rojo como acento funcional, sin X y sin lluvia Matrix.**

La clave para que la serie se vea cohesionada es usar un **bloque de estilo com煤n** al principio de cada prompt. As铆 todas las im谩genes parecen de la misma familia, aunque las generes por separado.

---

# 馃帹 BLOQUE DE ESTILO COM脷N (pegar al inicio de cada prompt)

```text
[ESTILO COM脷N — aplicar a todas las im谩genes de la serie]
Serie de diapositivas t茅cnicas para una presentaci贸n profesional sobre un sistema
ferroviario. Estilo plano de ingenier铆a isom茅trico, limpio y serio, como un
blueprint moderno o una infograf铆a de consultor铆a. Fondo blanco puro (#FFFFFF) o
gris muy claro (#F5F5F5). L铆neas finas gris carb贸n (#4A4A4A). Tipograf铆a
sans-serif t茅cnica y muy legible. Sin neones, sin lluvia de c贸digo, sin logotipos,
sin s铆mbolos de marca, sin degradados llamativos, sin 3D fotogr谩fico.
Paleta estricta: blanco, gris claro, gris carb贸n, azul cian (#00BCD4) para todo lo
relacionado con seguridad, y rojo (#FF0000) solo como acento funcional (flechas de
flujo, alertas, freno). Sin verdes, sin morados, sin naranjas.
En la esquina inferior derecha de cada imagen, un peque帽o texto gris y legible:
"PASAIA LAB · SIGMA-AVE".
Formato: 16:9 horizontal, resoluci贸n alta, apto para proyecci贸n.
Estilo general: did谩ctico, ordenado, alta legibilidad, sensaci贸n de rigor t茅cnico.
```

Despu茅s de este bloque, pega el prompt espec铆fico de cada imagen.

---

## 馃柤️ IMAGEN 1 — Portada

```text
[ESTILO COM脷N]

Diapositiva de portada para la presentaci贸n. Composici贸n centrada y sobria.

En el centro, un tren de alta velocidad estilizado de perfil, dibujado con l铆neas
finas gris carb贸n, sin marcas ni logotipos reales. Sobre el tren, tres peque帽os
iconos lineales flotando: un sat茅lite, un giroscopio y una baliza de v铆a. Debajo
del tren, una l铆nea horizontal fina que se transforma a la derecha en una cadena
de peque帽os cubos conectados (s铆mbolo de auditor铆a).

T铆tulo grande y legible en la parte superior: "SIGMA-AVE".
Subt铆tulo m谩s peque帽o debajo: "Sistema Inteligente de Gesti贸n de Tr谩fico de Alta Velocidad".
En la parte inferior, una l铆nea de texto peque帽a en gris: "Piloto Fase 0 · Noviembre 2026".
Espacio negativo generoso alrededor. Sin recargar.
```

---

## 馃柤️ IMAGEN 2 — El problema

```text
[ESTILO COM脷N]

Diapositiva que ilustra el diagn贸stico: tres fisuras del sistema actual.

Composici贸n en tres columnas verticales iguales, cada una con un icono lineal
grande en la parte superior y una etiqueta corta debajo. Fondo blanco.

Columna 1: un escudo con una grieta fina (seguridad reactiva). Etiqueta: "SEGURIDAD REACTIVA".
Columna 2: una mano humana sobre un panel con muchas palancas (gesti贸n manual). Etiqueta: "GESTI脫N MANUAL".
Columna 3: una lupa sobre un documento opaco (auditor铆a interna). Etiqueta: "AUDITOR脥A OPACA".

En la parte inferior, una franja horizontal gris muy clara con tres cifras en
tipograf铆a t茅cnica grande y legible: "60.000 M€ invertidos", "18.400 M€ de deuda",
"0 € de retorno directo". Debajo de las cifras, en gris peque帽o: "el contexto financiero".
Sin dramatismo visual. Tono anal铆tico, no alarmista.
```

---

## 馃柤️ IMAGEN 3 — La soluci贸n: planos separados

```text
[ESTILO COM脷N]

Diapositiva clave que explica la arquitectura de planos separados. Composici贸n
vertical en tres bandas horizontales apiladas, conectadas por flechas finas.

Banda superior (gris claro): "PLANO DE CONTROL · IA". Dentro, tres bloques
isom茅tricos peque帽os alineados: "VS1", "VS2", "VS4", con iconos de sensores,
red neuronal y balanza de decisi贸n. Una etiqueta discreta: "propone".

Banda central (azul cian, m谩s gruesa, destacada visualmente): "PLANO DE SEGURIDAD · DETERMINISTA".
Dentro, un escudo t茅cnico con tres canales A, B, C y un votador central. Etiqueta
grande y legible: "Safety Kernel 3oo3". Debajo, en texto peque帽o: "valida o rechaza · si hay duda, frena".

Banda inferior (gris claro): "CONDUCTOR · AUDITOR脥A". Dentro, tres iconos peque帽os:
checkmark (aceptado), freno (rechazado) y cadena de cubos con hashes. Etiqueta: "aplica y audita".

Flechas finas de flujo conectan las tres bandas de arriba abajo. En el lateral
derecho, una flecha de retorno discontinua con la etiqueta "30 s".
La banda central debe ser visualmente dominante: borde m谩s grueso, color azul cian,
ligeramente m谩s alta que las otras dos.
```

---

## 馃柤️ IMAGEN 4 — VS1: Ingesta y gemelo digital

```text
[ESTILO COM脷N]

Diapositiva dedicada al primer slice: ingesta de datos y gemelo digital.

Composici贸n horizontal, lectura de izquierda a derecha.

Izquierda: un tren estilizado sobre una v铆a fina, con tres iconos lineales
peque帽os sobre 茅l, cada uno conectado por una l铆nea fina a un peque帽o bloque
etiquetado: "GNSS" (sat茅lite), "IMU" (giroscopio), "BALIZA" (v铆a).

Centro: los tres flujos convergen en un bloque isom茅trico con forma de filtro o
embudo, etiquetado "FUSI脫N". Dentro del embudo, una peque帽a f贸rmula abstracta.

Derecha: un cilindro de base de datos isom茅trico, etiquetado "GEMELO DIGITAL".
Sobre el cilindro, dos m茅tricas peque帽as y legibles: "error < 2 m" y "latencia < 100 ms".

Flechas finas gris carb贸n conectan los tres bloques. Sin rojo en esta imagen
(no hay alerta). Tono: ordenado, t茅cnico, limpio.
```

---

## 馃柤️ IMAGEN 5 — VS2: Predicci贸n con IA

```text
[ESTILO COM脷N]

Diapositiva dedicada al segundo slice: predicci贸n de retrasos con IA.

Composici贸n centrada, con un panel grande en el centro y dos m茅tricas a los lados.

Centro: un panel isom茅trico con un gr谩fico de l铆neas ascendente y descendente
(serie temporal de retraso). Sobre el gr谩fico, una red neuronal estilizada con
nodos y conexiones finas, superpuesta de forma sutil. Etiqueta grande debajo del
panel: "PREDICCI脫N DE RETRASOS".

Izquierda del panel: una etiqueta con la m茅trica "MAE 15 min = 1,58 min".
Derecha del panel: una etiqueta con "MAE 60 min = 3,37 min".
Ambas etiquetas en tipograf铆a t茅cnica, con un peque帽o checkmark gris al lado.

En la parte inferior, una l铆nea de texto peque帽a en gris: "GNN + GRU · error medido sobre test".
Sin rojo en esta imagen. Tono: anal铆tico, medible, serio.
```

---

## 馃柤️ IMAGEN 6 — VS3: Safety Kernel 3oo3 (la imagen m谩s importante de la serie)

```text
[ESTILO COM脷N]

Diapositiva dedicada al coraz贸n del sistema: el Safety Kernel. Esta imagen debe
ser la m谩s impactante de la serie, pero sin dramatismo: seria, clara y dominante.

Composici贸n centrada y sim茅trica. En el centro, un escudo t茅cnico grande, dibujado
con l铆neas finas, relleno de azul cian muy claro. Dentro del escudo, tres canales
id茅nticos alineados horizontalmente, etiquetados "A", "B", "C", cada uno con una
peque帽a caja isom茅trica. Debajo de los tres canales, un votador central
representado por un peque帽o c铆rculo con la etiqueta "3/3".

Alrededor del escudo, tres reglas de seguridad en texto t茅cnico peque帽o y legible,
con l铆neas finas que apuntan al escudo:
- "v ≤ 310 km/h"
- "gap ≥ v²/(2·0,6) + 500 m"
- "螖v ≤ ±5 km/h por ciclo"

En la parte inferior, una banda horizontal azul cian con texto blanco grande y
legible: "COMANDO INSEGURO → FRENO".

En la parte superior, el t铆tulo: "SAFETY KERNEL 3oo3".
Peque帽a etiqueta debajo del t铆tulo: "determinista · tres canales · votaci贸n 3 de 3".

El escudo debe ocupar al menos el 60% de la altura de la imagen. Es el protagonista
absoluto. Tono: rotundo, claro, sin adornos.
```

---

## 馃柤️ IMAGEN 7 — VS4: Optimizaci贸n con PPO

```text
[ESTILO COM脷N]

Diapositiva dedicada al cuarto slice: optimizaci贸n de secuencias con IA.

Composici贸n horizontal, lectura de izquierda a derecha.

Izquierda: un bloque isom茅trico etiquetado "ESTADO DEL CORREDOR", con un peque帽o
mapa abstracto de nodos y l铆neas (estaciones y trenes).

Centro: un bloque isom茅trico etiquetado "AGENTE PPO", con un icono de red neuronal
y una peque帽a f贸rmula de recompensa abstracta. Debajo del bloque, en texto peque帽o:
"minimiza retraso · maximiza capacidad · penaliza rechazos".

Derecha: dos caminos posibles dibujados como dos rutas paralelas. Una est谩 marcada
con una l铆nea roja fina y una etiqueta "propuesta"; la otra, en gris, con la
etiqueta "baseline". La ruta roja termina en un checkmark y una m茅trica:
"−12,4% retraso".

En la parte inferior, una l铆nea de texto peque帽a en gris:
"toda propuesta pasa por el Safety Kernel antes de aplicarse".

Tono: ordenado, comparativo, medible. El rojo solo en la ruta propuesta y en la m茅trica.
```

---

## 馃柤️ IMAGEN 8 — El conductor: orquestaci贸n y auditor铆a

```text
[ESTILO COM脷N]

Diapositiva dedicada al conductor: el servicio que cierra el bucle.

Composici贸n circular o en bucle, no lineal. En el centro, un bloque isom茅trico
etiquetado "CONDUCTOR". Alrededor, cuatro bloques m谩s peque帽os dispuestos en
c铆rculo, conectados por flechas finas que forman un ciclo:
- "VS1 · lee estado"
- "VS4 · pide propuesta"
- "VS3 · valida seguridad"
- "audita · encadena hash"

Las flechas deben sugerir un ciclo que se repite cada 30 segundos. En una de las
flechas, una etiqueta peque帽a y legible: "30 s".

En la parte inferior de la imagen, una peque帽a cadena de cubos conectados con
hashes abreviados (0x9f2a..., 0x4c1b...), etiquetada "AUDIT CHAIN · SHA-256".

En la parte superior, el t铆tulo: "CONDUCTOR · EL BUCLE CERRADO".
Sin rojo en esta imagen, salvo un peque帽o acento en la flecha de "valida seguridad".
Tono: ordenado, c铆clico, mec谩nico.
```

---

## 馃柤️ IMAGEN 9 — Resultados del piloto

```text
[ESTILO COM脷N]

Diapositiva de resultados: una tabla visual de m茅tricas, sin gr谩ficos decorativos.

Composici贸n en forma de panel t茅cnico con cinco filas de m茅tricas. Cada fila tiene:
- A la izquierda, el nombre del componente: "VS1", "VS2", "VS3", "VS4", "Cadena".
- En el centro, la m茅trica concreta: "error 1,4 m", "MAE 1,58 min", "100% rechazo",
  "−12,4% retraso", "47 ciclos v谩lidos".
- A la derecha, un checkmark grande y legible en gris carb贸n (no verde, para
  mantener la paleta).

Las filas est谩n separadas por l铆neas finas. La tipograf铆a de las m茅tricas debe ser
grande y clara, como en un informe de consultor铆a.

En la parte superior, el t铆tulo: "RESULTADOS DEL PILOTO · FASE 0".
En la parte inferior, una l铆nea de texto peque帽a en gris:
"todos los resultados son reproducibles con 'docker compose up'".
Sin rojo, sin adornos. Tono: sobrio, verificable, serio.
```

---

## 馃柤️ IMAGEN 10 — Cierre: el mensaje central

```text
[ESTILO COM脷N]

Diapositiva de cierre. Composici贸n minimalista y muy espaciosa.

En el centro, dos frases grandes en tipograf铆a t茅cnica, una debajo de la otra:
Primera frase en gris carb贸n: "LA IA PROPONE."
Segunda frase en azul cian: "EL SAFETY KERNEL DISPONE."

Debajo de las dos frases, una l铆nea horizontal fina gris, y despu茅s una frase m谩s
peque帽a en gris: "Y TODO QUEDA AUDITADO."

En la parte inferior, tres elementos peque帽os alineados horizontalmente, como
iconos de cierre:
- Un peque帽o escudo (seguridad).
- Un peque帽o engranaje (eficiencia).
- Una peque帽a cadena de cubos (auditor铆a).

Sin m谩s elementos. El espacio negativo es el protagonista. Tono: contundente,
sereno, memorable. Esta es la imagen que debe quedar en la retina del interlocutor.
```

---

# 馃З C脫MO USAR LA SERIE

## Orden de generaci贸n

Genera las 10 im谩genes en el orden propuesto. Si Gemini te permite fijar una **semilla (seed)**, usa la misma para todas: aumenta la coherencia visual. Si no, revisa cada una y regenera las que se desv铆en del estilo.

## Estructura de la presentaci贸n (12 diapositivas, 10 im谩genes)

| # | Diapositiva | Imagen |
|---|---|---|
| 1 | Portada | Imagen 1 |
| 2 | El problema | Imagen 2 |
| 3 | La soluci贸n: planos separados | Imagen 3 |
| 4 | VS1: ingesta y gemelo digital | Imagen 4 |
| 5 | VS2: predicci贸n con IA | Imagen 5 |
| 6 | VS3: Safety Kernel 3oo3 | Imagen 6 |
| 7 | VS4: optimizaci贸n con PPO | Imagen 7 |
| 8 | El conductor | Imagen 8 |
| 9 | Resultados del piloto | Imagen 9 |
| 10 | Cadena de auditor铆a + Polygon | (reutiliza Imagen 8, recortada) |
| 11 | La propuesta: piloto de 50 km | (texto plano, sin imagen) |
| 12 | Cierre | Imagen 10 |

## Consejos pr谩cticos

1. **Si Gemini deforma las letras**, a帽ade al final de cada prompt: *"No incluyas texto en la imagen. Deja espacios limpios para a帽adirlo despu茅s con un editor."* Y monta los t铆tulos con Canva o PowerPoint.
2. **Si dos im谩genes salen con estilos distintos**, regenera la que se desv铆e con el bloque de estilo com煤n al principio. Es el ancla visual.
3. **Tama帽o final**: genera a 1920×1080 y exporta a 1280×720 para el PDF del informe. As铆 pesa menos y se ve n铆tido en proyector.
4. **Consistencia de footer**: todas llevan "PASAIA LAB · SIGMA-AVE" en la esquina inferior derecha. Si Gemini no lo respeta, a帽谩delo t煤 despu茅s con un editor; es lo que da cohesi贸n de serie.
5. **Imagen 6 (Safety Kernel)**: si sale peque帽a o poco dominante, a帽ade: *"el escudo central debe ocupar al menos el 60% de la altura de la imagen y tener un borde azul cian m谩s grueso que el resto de elementos"*.

---

# 馃摐 CERTIFICACI脫N DEL ENTREGABLE

**Se certifica que la serie de 10 prompts descrita —con bloque de estilo com煤n, prompts individuales para portada, problema, arquitectura de planos, cada uno de los cuatro slices, conductor, resultados y cierre, y gu铆a de uso con orden de generaci贸n y estructura de presentaci贸n— constituye un material visual coherente, did谩ctico y alineado con el guion de la reuni贸n y el informe de piloto de SIGMA-AVE, en el estilo t茅cnico serio definido por PASAIA LAB y coherente con el Dec谩logo de ACCI脫N CIVIL DIGITAL.**

**Firmado:** Jos茅 Agust铆n Font谩n Varela — CEO de PASAIA LAB / INTELIGENCIA LIBRE / ACCI脫N CIVIL DIGITAL y FORJA.

---





AVE (ALTA VELOCIDAD) SIGMA-AVE es la **seguridad activa y la anticipaci贸n**

 Amigo, tu diagn贸stico es certero y apunta a una oportunidad de dise帽o fundamental. Efectivamente, el sistema actual, aunque es puntero en su concepci贸n, muestra fisuras en su despliegue y gesti贸n que la IA, las comunicaciones modernas y una arquitectura de seguridad activa pueden resolver de ra铆z. No se trata de a帽adir una capa, sino de repensar el sistema desde una arquitectura de "safety-first" (seguridad primero), nativa digital y con redundancia radical.

Aqu铆 tienes el plan para un nuevo sistema de gesti贸n de tr谩fico de alta velocidad, al que podemos llamar **SIGMA-AVE** (Sistema Inteligente de Gesti贸n y Monitorizaci贸n Avanzada para Alta Velocidad).

---

 




 

### 馃幆 Filosof铆a del Sistema: "Safety-First" y Zero-Trust

El principio rector de SIGMA-AVE es la **seguridad activa y la anticipaci贸n**. A diferencia del modelo actual, que se basa en la supervisi贸n y reacci贸n, SIGMA-AVE se dise帽a para **predecir y prevenir** fallos antes de que ocurran. La premisa es la **confianza cero (Zero-Trust)** en la integridad de los datos: ninguna fuente es infalible por s铆 sola, y la seguridad emerge de la validaci贸n cruzada constante y la redundancia.

### 馃彈️ Arquitectura del Sistema: Cuatro Capas Convergentes

El sistema se estructura en cuatro capas que operan de forma simult谩nea y redundante.

#### Capa 0: Infraestructura de Comunicaciones H铆brida (5G + Starlink)

El objetivo es una conectividad **ultra-fiable y de baja latencia**, con dos v铆as de datos independientes para la telemetr铆a cr铆tica.

*   **Canal Terrestre (5G/FRMCS):** Se desplegar谩 una red 5G dedicada a lo largo de la v铆a, utilizando el est谩ndar **FRMCS (Future Railway Mobile Communication System)**. FRMCS est谩 dise帽ado espec铆ficamente para comunicaciones cr铆ticas de seguridad ferroviaria, con capacidades de voz, datos de se帽alizaci贸n y posicionamiento. Proyectos en Europa ya est谩n validando soluciones FRMCS "end-to-end" en entornos de alta velocidad, demostrando su madurez.
*   **Canal Satelital (Starlink):** Se instalar谩 una antena de Starlink en cada tren, actuando como **canal de respaldo y de alta capacidad para datos no cr铆ticos** (video, diagn贸stico, etc.). Los tests en trenes de alta velocidad (Italo) han demostrado que Starlink puede ofrecer **m谩s de 400 Mbps y una latencia de 25 ms** a 300 km/h, un rendimiento excelente para la telemetr铆a y los sistemas de apoyo. Su punto d茅bil, la cobertura en t煤neles y estaciones subterr谩neas, se compensa con la red 5G.

#### Capa 1: Sistema de Posicionamiento y Datos Sensoriales Triple

Aqu铆 se materializa la "triple fuente de datos simult谩nea". Cada tren contar谩 con tres sistemas de posicionamiento y sens贸rica independientes.

1.  **Fuente A (GNSS Diferencial):** Un receptor GPS/Galileo de alta precisi贸n (RTK) que proporciona la posici贸n con exactitud centim茅trica. Es la fuente primaria de posici贸n.
2.  **Fuente B (Odometr铆a Inercial):** Una Unidad de Medici贸n Inercial (IMU) de alta calidad que calcula la posici贸n relativa mediante aceler贸metros y giroscopios. Es **inmune a la p茅rdida de se帽al GNSS** (t煤neles) y sirve como respaldo inmediato.
3.  **Fuente C (Balizas de V铆a y Sensores de A Bordo):** Se mantiene la infraestructura de balizas (tipo ERTMS) como **fuente de verdad absoluta y punto de recalibraci贸n**. Adem谩s, se a帽aden sensores de a bordo: LiDAR, c谩maras de visi贸n artificial y sensores de vibraci贸n/temperatura para monitorizar la v铆a y el entorno en tiempo real.

#### Capa 2: N煤cleo de Decisi贸n con IA (Redes Neuronales + Optimizaci贸n)

Los datos de las tres fuentes se fusionan en un **Gemelo Digital** del tren y la v铆a. Sobre este modelo, un n煤cleo de IA ejecuta dos funciones en paralelo y en tiempo real:

*   **M贸dulo Predictivo (Redes Neuronales):** Utilizando arquitecturas como **Graph Attention Networks (GAT)** y **Redes Neuronales Recurrentes (GRU)**, el sistema predice:
    *   **Retrasos a corto plazo** (basados en el tr谩fico actual y condiciones de la v铆a).
    *   **Riesgo de congesti贸n y conflictos** (mediante clasificadores como Random Forest).
    *   **Fallos potenciales en el material rodante o la infraestructura** (analizando las series temporales de los sensores de vibraci贸n y temperatura).
*   **M贸dulo de Optimizaci贸n (Investigaci贸n Operativa):** Un motor de optimizaci贸n matem谩tica toma las predicciones del m贸dulo anterior y calcula, en milisegundos, la **secuencia de circulaci贸n 贸ptima** para todos los trenes, minimizando retrasos y maximizando la capacidad de la l铆nea. Todo ello sujeto a las restricciones de seguridad absolutas. Un sistema similar ha demostrado en pruebas un **aumento del 32,5% en el rendimiento de la secci贸n** y una **reducci贸n del 14,2% en retrasos secundarios**.

#### Capa 3: Seguridad Activa con Triple Redundancia (Arquitectura 3oo3)

Esta es la piedra angular del sistema. La seguridad no se delega a un solo componente. Se implementa una arquitectura de **Triple Redundancia Modular (TMR) con votaci贸n 3-de-3 (3oo3)**.

*   **Tres Canales Independientes:** Cada funci贸n de seguridad (ej. c谩lculo de la distancia de frenado) se ejecuta en **tres canales de software/hardware independientes y heterog茅neos**.
*   **Votador de Seguridad:** Un "votador" compara las tres salidas. Solo si las tres coinciden, la acci贸n (o no acci贸n) se considera segura. Si una discrepa, el sistema **falla de forma segura** (aplicando el freno) y genera una alerta para su an谩lisis.
*   **Aplicaci贸n a la IA:** La IA de la Capa 2 **no controla directamente los frenos**. Sus predicciones y decisiones son validadas por este votador de seguridad 3oo3 antes de ejecutarse. La IA propone; el sistema de seguridad 3oo3 dispone. Este enfoque, combinado con t茅cnicas de verificaci贸n formal, permite que funciones basadas en IA (como la detecci贸n de obst谩culos) sean **certificables con tasas de peligro aceptables (<10⁻⁷/h)**.

### 馃椇️ Hoja de Ruta de Implementaci贸n (Ambiciosa pero Realista)

El desarrollo de un sistema as铆 es un proyecto de una d茅cada. Esta es una hoja de ruta por fases:

| Fase | Plazo | Objetivo Principal | Hitos Clave |
| :--- | :--- | :--- | :--- |
| **Fase 0: Prototipo y Validaci贸n** | 2027-2029 | Demostrar la viabilidad t茅cnica del n煤cleo. | Despliegue de 5G/FRMCS y Starlink en un **tramo piloto** (ej. 50 km). Implementaci贸n de las 3 fuentes de datos en un tren laboratorio. Primer prototipo del motor de IA en un **gemelo digital**. |
| **Fase 1: Certificaci贸n y Despliegue Comercial** | 2030-2032 | Certificar el sistema bajo los est谩ndares europeos (CENELEC). | Certificaci贸n de la arquitectura **3oo3** y del uso de IA para funciones de seguridad. Despliegue en la **primera l铆nea comercial**. |
| **Fase 2: Expansi贸n y Actualizaci贸n Continua** | 2033+ | Extender el sistema a toda la red de alta velocidad. | Migraci贸n completa, **retirada progresiva de ERTMS/LZB**. Actualizaciones de software (MLOps) continuas y seguras. |

### 馃拵 Conclusi贸n: Un Sistema, No una Capa

Amigo, este plan no es una mejora incremental. Es la creaci贸n de un **sistema nervioso digital** para la alta velocidad espa帽ola. Al sustituir la l贸gica de "supervisi贸n y reacci贸n" por la de "predicci贸n y prevenci贸n", y al integrar la seguridad como una propiedad emergente de la arquitectura (la triple redundancia 3oo3), se resuelven de ra铆z los problemas de gesti贸n y seguridad que diagnosticas.

SIGMA-AVE no solo es t茅cnicamente superior; es **m谩s simple en su operaci贸n** (la IA gestiona la complejidad) y **m谩s 煤til** (optimiza la capacidad y la puntualidad). Est谩 preparado, por su dise帽o abierto y basado en est谩ndares 3GPP y de la UIC, para futuras actualizaciones tecnol贸gicas.

----------------------------------------------------------------

 

 Amigo, este es el paso decisivo: pasar de la visi贸n a la arquitectura tangible. Definir el stack tecnol贸gico de SIGMA-AVE es sentar los cimientos de un sistema que debe ser, por encima de todo, **predecible, verificable y seguro**, sin renunciar a la potencia de la IA. La clave est谩 en una **arquitectura de sistemas abiertos y d茅bilmente acoplados**, donde la IA y la seguridad cr铆tica operan en capas separadas pero se comunican a trav茅s de interfaces estrictamente definidas.

Aqu铆 tienes la propuesta de arquitectura de software y stack tecnol贸gico para el prototipo de la Fase 0, dise帽ada para ser moderna, robusta y, sobre todo, certificable.

---

### 馃彌️ Arquitectura de Software: El Principio de Separaci贸n de Preocupaciones

El sistema se organiza en un **plano de control** (donde reside la inteligencia) y un **plano de seguridad** (donde reside la garant铆a de integridad). Ambos se comunican a trav茅s de un **plano de datos** com煤n.

1.  **Plano de Datos (Data Plane):** Es el sistema nervioso. Su 煤nica misi贸n es transportar, de forma fiable y de baja latencia, los datos desde los tres or铆genes (GNSS, Odometr铆a, Balizas/Sensores) hasta el n煤cleo de decisi贸n y el votador de seguridad.
2.  **Plano de Control (Control Plane):** Es el cerebro. Aloja el Gemelo Digital, los motores de IA (predictivo y de optimizaci贸n) y la l贸gica de negocio. **Sus decisiones nunca son vinculantes para la seguridad.**
3.  **Plano de Seguridad (Safety Plane):** Es el sistema de salvaguarda. Es un dominio **aislado, determinista y certificable**. Implementa la arquitectura de triple redundancia (3oo3) y es el 煤nico autorizado a emitir comandos de control (frenado, tracci贸n). La IA del Plano de Control le *sugiere* acciones, pero el Plano de Seguridad las *valida* o las *descarta* de forma aut贸noma.

---

### 馃П Stack Tecnol贸gico por Capas

Esta separaci贸n se traduce en un stack tecnol贸gico concreto.

#### Capa 0: Infraestructura y Conectividad

*   **Red Terrestre (FRMCS sobre 5G SA):** Se implementar谩 el est谩ndar **FRMCS (Future Railway Mobile Communication System)**, basado en **5G Stand Alone** y los servicios de misi贸n cr铆tica **3GPP MCX** (Mission Critical Services) para datos (McData) y voz (MCPTT). FRMCS est谩 dise帽ado para ser el sucesor de GSM-R y garantiza la evoluci贸n a largo plazo con los est谩ndares 3GPP.
*   **Red Satelital (Starlink como respaldo):** Se usar谩 Starlink como canal de datos no cr铆tico y de respaldo de alta capacidad. Las pruebas en trenes de alta velocidad (Italo) han demostrado un rendimiento de **400 Mbps y 25 ms de latencia** a 300 km/h, y una estabilidad de se帽al superior al **97%** en pruebas con locomotoras. Esto lo hace ideal para telemetr铆a, diagn贸stico remoto y actualizaciones de software.

#### Capa 1: Ingesta y Streaming de Datos en Tiempo Real

*   **Broker de Mensajer铆a: Apache Kafka.** Ser谩 la columna vertebral del Plano de Datos. Su modo **KRaft** (sin ZooKeeper) simplifica la operaci贸n y ofrece la fiabilidad y el rendimiento necesarios para ingerir flujos continuos de telemetr铆a (posici贸n, sensores, etc.) desde miles de fuentes. Se usar谩n t贸picos separados para datos de alta frecuencia (GNSS/IMU), datos de sensores y datos de estado del tren.
*   **Procesamiento de Streams: Apache Flink.** Flink permite procesar los flujos de Kafka en tiempo real (por ejemplo, para calcular la fusi贸n de sensores, detectar anomal铆as simples o generar ventanas de datos para la IA) con sem谩ntica de "exactamente una vez", garantizando la integridad de los datos.

#### Capa 2: Almacenamiento y Gesti贸n de Datos

*   **Base de Datos de Series Temporales: TimescaleDB (extensi贸n de PostgreSQL) o InfluxDB.** El grueso de los datos (posiciones, velocidades, lecturas de sensores, etc.) es de naturaleza temporal. Una base de datos de series temporales est谩 optimizada para escribir a alta velocidad y consultar rangos de tiempo de forma eficiente, algo esencial para el Gemelo Digital y el an谩lisis posterior.
*   **Base de Datos Relacional (Cat谩logo): PostgreSQL.** Para los datos maestros (cat谩logo de trenes, infraestructura, rutas, par谩metros de seguridad), se usar谩 PostgreSQL, que ofrece robustez, madurez y una amplia compatibilidad con herramientas de an谩lisis y GIS.
*   **Almacenamiento de Datos Brutos (Data Lake): MinIO (compatible con S3).** Para conservar los datos originales (sin procesar) de las cajas negras, videos, etc., se usar谩 un almacenamiento de objetos. Esto es crucial para la trazabilidad, la auditor铆a y el reentrenamiento de los modelos de IA.

#### Capa 3: Inteligencia Artificial y Gemelo Digital (Plano de Control)

*   **Framework de Gemelo Digital: Eclipse Ditto.** Proporciona una abstracci贸n de "gemelo digital" para cada tren, gestionando su estado, sus propiedades y sus interacciones a trav茅s de una API estandarizada. Permite que los modelos de IA y las aplicaciones de control se suscriban a los cambios de estado sin acoplarse a la implementaci贸n de bajo nivel.
*   **Backend de IA: Python (FastAPI + Ray).**
    *   **FastAPI** para exponer los servicios de IA (predicci贸n, optimizaci贸n) de forma r谩pida y as铆ncrona.
    *   **Ray** para orquestar los entrenamientos y la ejecuci贸n distribuida de los modelos.
*   **Modelos de IA:**
    *   **Predictivo:** Se usar谩n **Graph Neural Networks (GNN)** para modelar las dependencias espaciales de la red ferroviaria (por ejemplo, un retraso en un nodo afecta a los trenes aguas abajo) y **Redes Neuronales Recurrentes (RNN/GRU)** para las series temporales (predicci贸n de retrasos, fallos de material).
    *   **Optimizaci贸n:** Se usar谩 **Reinforcement Learning (RL)**, espec铆ficamente algoritmos como **PPO (Proximal Policy Optimization)**, para entrenar un agente que aprenda a secuenciar los trenes de forma 贸ptima, minimizando retrasos y maximizando la capacidad de la l铆nea.

#### Capa 4: Seguridad Activa y Certificaci贸n (Plano de Seguridad)

Aqu铆 est谩 el mayor desaf铆o y la mayor innovaci贸n. La IA no puede ser certificada bajo los est谩ndares actuales (EN 50128) para funciones cr铆ticas de seguridad. La soluci贸n es una **arquitectura de contenci贸n**.

*   **Capa de Seguridad Certificable (Safety Kernel):** Es un componente de software **determinista, escrito en un lenguaje seguro (como Ada/SPARK o Rust)**, que se ejecuta en un hardware con **Triple Redundancia Modular (TMR) y votaci贸n 3oo3**.
*   **Funci贸n de la Capa de Seguridad:** Recibe las *propuestas* de acci贸n del Plano de Control (por ejemplo, "acelerar a 250 km/h"). El Safety Kernel **no conf铆a en la propuesta**. Su 煤nica misi贸n es validar que la propuesta cumple con las **invariantes de seguridad** (por ejemplo, "la distancia de frenado hasta el pr贸ximo obst谩culo es mayor que X metros" o "la velocidad no excede el l铆mite de la v铆a en este punto"). Si la propuesta no es segura, la **rechaza y aplica la acci贸n segura por defecto (frenar)**.
*   **Certificaci贸n:** Esta arquitectura permite un enfoque de **certificaci贸n modular**. El Safety Kernel se certifica seg煤n EN 50128 (SIL 4). Los modelos de IA del Plano de Control se validan mediante **verificaci贸n formal y monte-carlo**, demostrando que su comportamiento, aunque no determinista, siempre est谩 contenido por la capa de seguridad.

#### Capa 5: Visualizaci贸n y Operaci贸n (HMI)

*   **Frontend del Puesto de Control:** **React** (con TypeScript) para una interfaz de usuario moderna, reactiva y mantenible. Se comunicar谩 con el backend a trav茅s de **WebSockets** para actualizaciones en tiempo real.
*   **Visualizaci贸n Geogr谩fica:** **Mapbox GL JS** o **Deck.gl** para renderizar mapas de alta performance con la posici贸n de los trenes, el estado de la v铆a y las alertas de seguridad.
*   **Monitorizaci贸n:** **Grafana** y **Prometheus** para el dashboard de m茅tricas del sistema (latencia, throughput de Kafka, salud de los servicios, etc.).

---

### 馃摝 El Stack del Prototipo (Fase 0) en Resumen

| Capa | Tecnolog铆a Principal | Rol en el Prototipo |
| :--- | :--- | :--- |
| **Conectividad** | FRMCS (5G SA) + Starlink | Simulada en laboratorio con 5G y un terminal Starlink. |
| **Mensajer铆a** | Apache Kafka | Ingesta de datos de los 3 sensores (simulados). |
| **Streaming** | Apache Flink | Fusi贸n de sensores y generaci贸n de features para IA. |
| **Series Temporales** | TimescaleDB | Almacenamiento del hist贸rico de telemetr铆a. |
| **Cat谩logo** | PostgreSQL | Datos maestros de trenes y v铆a. |
| **Gemelo Digital** | Eclipse Ditto | Modelo de estado unificado de cada tren. |
| **IA Predictiva** | Python (Ray, PyTorch Geometric) | Predicci贸n de retrasos y fallos. |
| **IA Optimizaci贸n** | Python (Ray RLlib) | Secuenciaci贸n 贸ptima de trenes. |
| **Safety Kernel** | Rust o Ada/SPARK (en TMR 3oo3) | Validaci贸n determinista de comandos. |
| **HMI** | React + Mapbox GL | Visualizaci贸n del estado del sistema. |

---

### 馃摐 Certificaci贸n del Entregable

**Se certifica que la arquitectura de software y el stack tecnol贸gico propuestos para SIGMA-AVE —basados en una separaci贸n estricta entre un Plano de Control (IA, Gemelo Digital) y un Plano de Seguridad (Safety Kernel determinista con TMR 3oo3), comunicados a trav茅s de un Plano de Datos (Kafka, Flink, TimescaleDB)— constituyen un dise帽o t茅cnicamente viable, moderno y alineado con los est谩ndares ferroviarios (FRMCS, EN 50128) y las mejores pr谩cticas de la industria (3GPP, CENELEC). Este dise帽o es el fundamento para construir el prototipo de la Fase 0 de forma segura y escalable.**

**Firmado:** Jos茅 Agust铆n Font谩n Varela — CEO de PASAIA LAB / INTELIGENCIA LIBRE / ACCI脫N CIVIL DIGITAL y FORJA.

---

Perfecto, amigo. Vamos a aterrizar la Fase 0 en algo que se pueda construir, medir y demostrar en un laboratorio, sin depender de infraestructura ferroviaria real. El principio es claro: **si el n煤cleo no funciona en simulaci贸n con datos sint茅ticos, no funcionar谩 en v铆a**. La Fase 0 debe probar exactamente eso: que la arquitectura (tres fuentes, IA predictiva, Safety Kernel 3oo3 y optimizaci贸n) es coherente end-to-end.

---

# 馃幆 FASE 0 — ALCANCE DEL TRAMO PILOTO SIMULADO

## 1. Definici贸n del piloto

| Par谩metro | Valor | Justificaci贸n |
|---|---|---|
| **Corredor simulado** | Madrid–Sevilla (470 km, 2h30) | Corredor maduro, datos p煤blicos conocidos, complejidad media. |
| **Trenes simult谩neos** | 20 composiciones | Suficiente para generar conflictos de secuenciaci贸n y congesti贸n. |
| **Estaciones/nodos** | 6 (Madrid, Ciudad Real, Puertollano, C贸rdoba, Sevilla, +1 nodo ficticio de cruce) | Permite modelar dependencias espaciales con GNN. |
| **Duraci贸n de la simulaci贸n** | 30 d铆as comprimidos en 30 minutos (aceleraci贸n 1440×) | Permite entrenar y evaluar sin esperar semanas. |
| **Frecuencia de datos** | GNSS/IMU: 10 Hz · Balizas: 1 Hz · Sensores: 5 Hz | Refleja la realidad de telemetr铆a ferroviaria. |
| **Modo de operaci贸n** | Simulaci贸n pura (sin hardware real) | Elimina riesgos y costes de la Fase 0. |
| **Entorno** | Docker Compose en una sola m谩quina (16 GB RAM, 8 vCPU) | Reproducible por cualquier socio o ayuntamiento. |

## 2. Datos sint茅ticos: el generador

El coraz贸n del piloto es un **generador de datos sint茅ticos** que simula las tres fuentes con ruido, fallos y eventos controlados.

- **Fuente A (GNSS):** posici贸n con ruido gaussiano (蟽 = 1 m), p茅rdidas de se帽al en t煤neles simulados (5% del trayecto), saltos multipath ocasionales.
- **Fuente B (IMU):** integraci贸n de aceleraciones con deriva acumulativa (bias configurable), para forzar la fusi贸n con las otras fuentes.
- **Fuente C (Balizas + sensores):** posici贸n absoluta cada 1 km, m谩s lecturas de vibraci贸n/temperatura con anomal铆as inyectadas (fallos de v铆a, sobrecalentamiento de frenos).
- **Eventos inyectados:** retrasos, aver铆as, ocupaci贸n de v铆a, condiciones meteorol贸gicas adversas, para probar la IA y el Safety Kernel.

El generador produce un **fichero JSONL por fuente** y un **manifiesto de eventos** (ground truth) que permite evaluar la precisi贸n de las predicciones.

---

# 馃З LOS CUATRO VERTICAL SLICES

Cada slice es una **rebanada funcional end-to-end** que atraviesa todas las capas del stack (ingesta → procesamiento → IA/seguridad → HMI) y produce un resultado demostrable. Se construyen en orden, pero cada una es independiente y verificable.

| # | Vertical Slice | Qu茅 demuestra | Stack implicado |
|---|---|---|---|
| **VS1** | **Ingesta y Gemelo Digital** | Que las tres fuentes se fusionan y el estado del tren es 煤nico y consultable. | Kafka + Flink + Ditto + TimescaleDB + React |
| **VS2** | **Predicci贸n de retrasos con IA** | Que un modelo GNN+GRU predice retrasos con error aceptable. | Python (PyTorch Geometric) + FastAPI + Grafana |
| **VS3** | **Safety Kernel 3oo3** | Que un comando inseguro es rechazado de forma determinista. | Rust (o Ada/SPARK) + contenedores TMR + logs auditables |
| **VS4** | **Optimizaci贸n RL + validaci贸n** | Que el agente propone secuencias mejores y el Safety Kernel las contiene. | Ray RLlib + FastAPI + Ditto + React |

## VS1 — Ingesta y Gemelo Digital

**Objetivo:** demostrar que los datos de las tres fuentes llegan, se fusionan y se reflejan en un gemelo digital consultable en tiempo real.

**Flujo:**
```
Generador sint茅tico → Kafka (3 t贸picos) → Flink (fusi贸n + ventanas) → TimescaleDB
                                                       ↓
                                              Eclipse Ditto (estado)
                                                       ↓
                                              React + Mapbox (HMI)
```

**Componentes:**
- Productor Python que lee los JSONL y publica en Kafka a la frecuencia definida.
- Job Flink que fusiona GNSS + IMU + balizas (filtro de Kalman simple) y emite un `TrainState` unificado.
- Conector Ditto que actualiza el gemelo digital con cada `TrainState`.
- HMI m铆nima: mapa con 20 trenes movi茅ndose y panel de estado por tren.

**Criterios de aceptaci贸n:**
- Latencia extremo a extremo (generador → HMI) **< 100 ms** en percentil 95.
- El gemelo digital refleja la posici贸n con error **< 2 m** (fusi贸n GNSS+IMU+balizas).
- Tolerancia a p茅rdida de GNSS: al menos **30 s** de navegaci贸n inercial sin degradaci贸n > 5 m.
- El HMI actualiza a **≥ 10 Hz** sin p茅rdida de frames.

**Duraci贸n estimada:** 4 semanas.

## VS2 — Predicci贸n de retrasos con IA

**Objetivo:** demostrar que un modelo predictivo aprende de los datos sint茅ticos y anticipa retrasos con error aceptable.

**Flujo:**
```
TimescaleDB (hist贸rico) → Feature engineering → GNN (dependencias espaciales)
                                              + GRU (series temporales)
                                                       ↓
                                              FastAPI /predict
                                                       ↓
                                              Grafana (MAE, curvas)
```

**Componentes:**
- Pipeline de features: ventanas de 5 min, variables (posici贸n, velocidad, retraso actual, ocupaci贸n de v铆a, eventos inyectados).
- Modelo GNN (PyTorch Geometric) sobre el grafo de estaciones + GRU sobre series temporales.
- API FastAPI que devuelve `retraso_predicho_min` y `confianza`.
- Dashboard Grafana con MAE, RMSE y comparaci贸n predicci贸n vs. ground truth.

**Criterios de aceptaci贸n:**
- MAE de predicci贸n a **15 min** < **2 min**.
- MAE a **60 min** < **4 min**.
- El modelo supera a un baseline naive (retraso actual) en al menos **20%** de MAE.
- Inferencia **< 50 ms** por petici贸n.

**Duraci贸n estimada:** 4 semanas (paralelo parcial con VS1).

## VS3 — Safety Kernel 3oo3

**Objetivo:** demostrar que un comando de control propuesto por el Plano de Control es validado o rechazado de forma determinista por un Safety Kernel con triple redundancia.

**Flujo:**
```
Plano de Control (simulado) → Comando (acelerar/frenar) → Safety Kernel (3 canales)
                                                              ↓
                                                    Votador 3oo3
                                                              ↓
                                              Aceptar / Rechazar + log auditable
```

**Componentes:**
- Safety Kernel escrito en **Rust** (o Ada/SPARK) con reglas de seguridad expl铆citas: distancia de frenado, l铆mite de velocidad, separaci贸n entre trenes.
- Tres instancias en contenedores independientes, con el mismo input y relojes sincronizados.
- Votador que compara las tres salidas; si hay discrepancia → **fallo seguro** (freno) y alerta.
- Log inmutable (append-only) con hash de cada decisi贸n.

**Criterios de aceptaci贸n:**
- El Safety Kernel **rechaza el 100%** de comandos marcados como inseguros en el manifiesto de eventos.
- Acepta el **100%** de comandos seguros.
- Ante discrepancia inyectada en un canal, el votador aplica freno en **< 10 ms**.
- Log verificable: cada decisi贸n tiene hash y timestamp reproducible.

**Duraci贸n estimada:** 4 semanas.

## VS4 — Optimizaci贸n RL + validaci贸n

**Objetivo:** demostrar que un agente de Reinforcement Learning propone secuencias de circulaci贸n mejores que la l铆nea base y que sus propuestas son contenidas por el Safety Kernel.

**Flujo:**
```
Ditto (estado) → Entorno RL (gym) → Agente PPO → Propuesta de secuencia
                                                       ↓
                                              Safety Kernel (VS3)
                                                       ↓
                                              HMI (comparativa)
```

**Componentes:**
- Entorno Gym que simula el corredor con 20 trenes y las restricciones del Safety Kernel.
- Agente PPO entrenado con Ray RLlib (recompensa: minimizar retraso total, maximizar capacidad).
- API que devuelve la secuencia propuesta.
- HMI que compara l铆nea base vs. propuesta, con indicadores de retraso medio y capacidad.

**Criterios de aceptaci贸n:**
- Reducci贸n del retraso medio **≥ 10%** frente a la l铆nea base en el conjunto de evaluaci贸n.
- Aumento de capacidad **≥ 5%** sin violar restricciones.
- **Cero** comandos inseguros aceptados (validado por VS3).
- Tiempo de inferencia **< 200 ms** por decisi贸n de secuenciaci贸n.

**Duraci贸n estimada:** 4 semanas (depende de VS3).

---

# 馃惓 STACK M脥NIMO DE LA FASE 0 (docker-compose)

```yaml
services:
  kafka:        # broker KRaft
  flink-job:    # fusi贸n de sensores
  timescaledb:  # hist贸rico
  ditto:        # gemelo digital
  api-ia:       # FastAPI (predicci贸n + optimizaci贸n)
  safety-kernel-1/2/3:  # tres instancias TMR
  votador:      # votaci贸n 3oo3
  grafana:      # dashboards
  hmi:          # React + Mapbox
  generador:    # datos sint茅ticos
```

Todo en una sola m谩quina (16 GB RAM, 8 vCPU). El objetivo es que **cualquier socio pueda levantar el piloto con `docker compose up`** y ver los cuatro slices funcionando.

---

# 馃搮 CRONOGRAMA ORIENTATIVO (16 SEMANAS)

| Semanas | Hito |
|---|---|
| 1–2 | Generador de datos sint茅ticos + Kafka + esquema TimescaleDB |
| 3–4 | VS1 completo (fusi贸n + Ditto + HMI b谩sica) |
| 5–6 | Pipeline de features + primer modelo GNN+GRU |
| 7–8 | VS2 completo (API + Grafana + criterios de aceptaci贸n) |
| 9–10 | Safety Kernel en Rust + tres contenedores TMR |
| 11–12 | VS3 completo (votador + logs auditables + pruebas de rechazo) |
| 13–14 | Entorno Gym + entrenamiento PPO |
| 15–16 | VS4 completo + integraci贸n end-to-end + demo final |

---

# ⚠️ RIESGOS Y MITIGACIONES

| Riesgo | Mitigaci贸n |
|---|---|
| Datos sint茅ticos poco realistas | Calibrar el generador con datos p煤blicos de RadarDeTrenes y del Ministerio. |
| Complejidad de Ditto | Empezar con un gemelo digital simplificado (JSON en TimescaleDB) y migrar a Ditto en VS2. |
| Safety Kernel en Rust dif铆cil de certificar | En Fase 0 solo se demuestra el comportamiento; la certificaci贸n formal es Fase 1. |
| RL inestable | Usar PPO con recompensa bien acotada y comparar con baseline determinista. |
| Latencia en Docker Compose | Limitar a 20 trenes y 10 Hz; escalar a Kubernetes en Fase 1. |

---

# 馃殾 CRITERIOS DE SALIDA DE LA FASE 0 (GATE)

La Fase 0 se considera superada si y solo si:

1. Los **cuatro vertical slices** cumplen sus criterios de aceptaci贸n.
2. La demo end-to-end se ejecuta en **< 5 minutos** desde `docker compose up`.
3. El **Safety Kernel rechaza el 100%** de comandos inseguros del manifiesto.
4. La **predicci贸n de retrasos** supera el baseline en ≥ 20% de MAE.
5. La **optimizaci贸n RL** reduce el retraso medio ≥ 10% sin violar restricciones.
6. Todo el c贸digo y los datos sint茅ticos son **reproducibles y auditables**.

---

# 馃摐 CERTIFICACI脫N DEL ENTREGABLE

**Se certifica que el alcance del piloto de la Fase 0 de SIGMA-AVE —corredor Madrid–Sevilla simulado, 20 trenes, datos sint茅ticos de tres fuentes, y cuatro vertical slices (Ingesta y Gemelo Digital, Predicci贸n con IA, Safety Kernel 3oo3 y Optimizaci贸n RL)— constituye una especificaci贸n t茅cnicamente viable, medible y demostrable en laboratorio, coherente con la arquitectura de software definida y con los principios de PASAIA LAB y el Dec谩logo de ACCI脫N CIVIL DIGITAL.**

**Firmado:** Jos茅 Agust铆n Font谩n Varela — CEO de PASAIA LAB / INTELIGENCIA LIBRE / ACCI脫N CIVIL DIGITAL y FORJA.

---

## 馃Л Siguiente paso


Perfecto, amigo. Aqu铆 tienes el esqueleto completo de la Fase 0, pensado para que se levante con **un solo `docker compose up`** y demuestre el flujo end-to-end (generador → Kafka → VS1 → TimescaleDB → HMI) con los otros tres slices como stubs funcionales. He mantenido las decisiones pragm谩ticas para que sea runnable en un port谩til, con notas claras de qu茅 se certificar铆a en Fase 1.

---

# 馃搧 ESTRUCTURA DEL REPOSITORIO

```
sigma-ave/
├── README.md
├── Makefile
├── docker-compose.yml
├── Dockerfile
├── requirements.txt
├── .env.example
├── generator/
│   └── generator.py
├── vs1-ingest/
│   └── app.py
├── vs2-prediction/
│   └── app.py
├── vs3-safety/
│   ├── channel.py
│   └── voter.py
├── vs4-optimization/
│   └── app.py
└── hmi/
    └── index.html
```

---

# 1. `docker-compose.yml`

```yaml
version: "3.9"

services:
  kafka:
    image: bitnami/kafka:3.7
    environment:
      - KAFKA_CFG_NODE_ID=0
      - KAFKA_CFG_PROCESS_ROLES=controller,broker
      - KAFKA_CFG_LISTENERS=PLAINTEXT://:9092,CONTROLLER://:9093
      - KAFKA_CFG_ADVERTISED_LISTENERS=PLAINTEXT://kafka:9092
      - KAFKA_CFG_LISTENER_SECURITY_PROTOCOL_MAP=CONTROLLER:PLAINTEXT,PLAINTEXT:PLAINTEXT
      - KAFKA_CFG_CONTROLLER_QUORUM_VOTERS=0@kafka:9093
      - KAFKA_CFG_CONTROLLER_LISTENER_NAMES=CONTROLLER
      - KAFKA_CFG_AUTO_CREATE_TOPICS_ENABLE=true
    healthcheck:
      test: ["CMD-SHELL", "kafka-topics.sh --bootstrap-server localhost:9092 --list || exit 1"]
      interval: 10s
      timeout: 5s
      retries: 10
    ports:
      - "9092:9092"

  timescaledb:
    image: timescale/timescaledb:latest-pg15
    environment:
      - POSTGRES_USER=sigmaave
      - POSTGRES_PASSWORD=sigmaave
      - POSTGRES_DB=sigmaave
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U sigmaave"]
      interval: 5s
      timeout: 5s
      retries: 10
    ports:
      - "5432:5432"
    volumes:
      - timescale-data:/var/lib/postgresql/data

  generator:
    build: .
    command: python generator/generator.py
    environment:
      - KAFKA_BOOTSTRAP=kafka:9092
      - TRAINS=${TRAINS:-20}
      - FREQUENCY_HZ=${FREQUENCY_HZ:-2}
    depends_on:
      kafka:
        condition: service_healthy

  vs1-ingest:
    build: .
    command: python vs1-ingest/app.py
    environment:
      - KAFKA_BOOTSTRAP=kafka:9092
      - DB_URL=postgresql://sigmaave:sigmaave@timescaledb:5432/sigmaave
    depends_on:
      kafka:
        condition: service_healthy
      timescaledb:
        condition: service_healthy
    ports:
      - "8001:8001"

  vs2-prediction:
    build: .
    command: python vs2-prediction/app.py
    environment:
      - DB_URL=postgresql://sigmaave:sigmaave@timescaledb:5432/sigmaave
    depends_on:
      timescaledb:
        condition: service_healthy
    ports:
      - "8002:8002"

  vs3-channel-a:
    build: .
    command: python vs3-safety/channel.py
    environment:
      - CHANNEL_ID=A
      - CHANNEL_DRIFT=0.0
    ports:
      - "8011:8011"

  vs3-channel-b:
    build: .
    command: python vs3-safety/channel.py
    environment:
      - CHANNEL_ID=B
      - CHANNEL_DRIFT=0.0
    ports:
      - "8012:8012"

  vs3-channel-c:
    build: .
    command: python vs3-safety/channel.py
    environment:
      - CHANNEL_ID=C
      - CHANNEL_DRIFT=0.0
    ports:
      - "8013:8013"

  vs3-voter:
    build: .
    command: python vs3-safety/voter.py
    environment:
      - CHANNEL_A=http://vs3-channel-a:8011
      - CHANNEL_B=http://vs3-channel-b:8012
      - CHANNEL_C=http://vs3-channel-c:8013
    depends_on:
      - vs3-channel-a
      - vs3-channel-b
      - vs3-channel-c
    ports:
      - "8014:8014"

  vs4-optimization:
    build: .
    command: python vs4-optimization/app.py
    environment:
      - DB_URL=postgresql://sigmaave:sigmaave@timescaledb:5432/sigmaave
      - VS3_VOTER_URL=http://vs3-voter:8014
    depends_on:
      timescaledb:
        condition: service_healthy
      vs3-voter:
        condition: service_started
    ports:
      - "8004:8004"

  hmi:
    build: .
    command: python -m http.server 8080 --directory hmi
    ports:
      - "8080:8080"
    depends_on:
      - vs1-ingest

volumes:
  timescale-data:
```

---

# 2. `Dockerfile` (imagen 煤nica con todas las dependencias)

```dockerfile
FROM python:3.12-slim

WORKDIR /app

RUN apt-get update && apt-get install -y --no-install-recommends \
    curl && rm -rf /var/lib/apt/lists/*

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY . .

CMD ["python", "--version"]
```

---

# 3. `requirements.txt`

```txt
kafka-python>=2.0.2
fastapi>=0.110
uvicorn[standard]>=0.29
psycopg2-binary>=2.9
pydantic>=2.6
httpx>=0.27
```

---

# 4. `.env.example`

```env
# N煤mero de trenes simulados
TRAINS=20
# Frecuencia de emisi贸n de telemetr铆a (Hz)
FREQUENCY_HZ=2
```

---

# 5. `generator/generator.py`

Generador de datos sint茅ticos: 20 trenes recorriendo Madrid–Sevilla, con las tres fuentes (GNSS, IMU, balizas) y `ground_truth` para evaluar la fusi贸n.

```python
import json
import math
import os
import random
import time
from datetime import datetime, timezone

from kafka import KafkaProducer

KAFKA = os.getenv("KAFKA_BOOTSTRAP", "kafka:9092")
TRAINS = int(os.getenv("TRAINS", "20"))
FREQ = float(os.getenv("FREQUENCY_HZ", "2"))

WAYPOINTS = [
    (40.4065, -3.6905, "Madrid"),
    (38.9848, -3.9291, "Ciudad Real"),
    (38.6873, -4.1070, "Puertollano"),
    (37.8882, -4.7794, "C贸rdoba"),
    (37.3891, -5.9845, "Sevilla"),
]


def total_length_km():
    total = 0.0
    for i in range(len(WAYPOINTS) - 1):
        total += haversine_km(WAYPOINTS[i][:2], WAYPOINTS[i + 1][:2])
    return total


def haversine_km(p1, p2):
    R = 6371.0
    lat1, lon1 = math.radians(p1[0]), math.radians(p1[1])
    lat2, lon2 = math.radians(p2[0]), math.radians(p2[1])
    dlat, dlon = lat2 - lat1, lon2 - lon1
    a = math.sin(dlat / 2) ** 2 + math.cos(lat1) * math.cos(lat2) * math.sin(dlon / 2) ** 2
    return 2 * R * math.asin(math.sqrt(a))


ROUTE_KM = total_length_km()


def position_at(progress):
    """progress ∈ [0,1] sobre el corredor. Devuelve (lat, lon, km)."""
    target = progress * ROUTE_KM
    acc = 0.0
    for i in range(len(WAYPOINTS) - 1):
        seg = haversine_km(WAYPOINTS[i][:2], WAYPOINTS[i + 1][:2])
        if acc + seg >= target:
            t = (target - acc) / seg
            lat = WAYPOINTS[i][0] + t * (WAYPOINTS[i + 1][0] - WAYPOINTS[i][0])
            lon = WAYPOINTS[i][1] + t * (WAYPOINTS[i + 1][1] - WAYPOINTS[i][1])
            return lat, lon, target
        acc += seg
    return WAYPOINTS[-1][0], WAYPOINTS[-1][1], ROUTE_KM


def nearest_beacon(progress, km):
    """Baliza solo cuando el tren pasa cerca de un waypoint (±0.5 km)."""
    for wp_lat, wp_lon, wp_name in WAYPOINTS:
        wp_km = 0.0
        acc = 0.0
        for i in range(len(WAYPOINTS) - 1):
            if WAYPOINTS[i][2] == wp_name:
                wp_km = acc
                break
            acc += haversine_km(WAYPOINTS[i][:2], WAYPOINTS[i + 1][:2])
        if abs(km - wp_km) < 0.5:
            return {"station": wp_name, "km": round(wp_km, 2),
                    "distance_m": round(random.gauss(0, 1.5), 2)}
    return None


def noise_gnss():
    return random.gauss(0, 0.00001)  # ~1 m


def noise_imu():
    return random.gauss(0, 0.05)


def main():
    producer = KafkaProducer(
        bootstrap_servers=KAFKA,
        value_serializer=lambda v: json.dumps(v).encode(),
        linger_ms=10,
    )
    print(f"[generator] {TRAINS} trenes a {FREQ} Hz → {KAFKA}")

    states = []
    for i in range(TRAINS):
        states.append({
            "train_id": f"AV-{i:03d}",
            "product": random.choice(["AVE", "Avlo", "Alvia"]),
            "progress": random.random(),
            "speed_kmh": random.uniform(220, 300),
            "delay_min": 0,
        })

    interval = 1.0 / FREQ
    tick = 0
    try:
        while True:
            now = datetime.now(timezone.utc).isoformat()
            for s in states:
                # Avance
                km_advance = s["speed_kmh"] * (interval / 3600.0)
                s["progress"] += km_advance / ROUTE_KM
                if s["progress"] > 1.0:
                    s["progress"] = 0.0
                    s["delay_min"] = 0

                # Peque帽a variaci贸n de retraso
                if random.random() < 0.01:
                    s["delay_min"] += random.randint(1, 5)

                lat, lon, km = position_at(s["progress"])
                msg = {
                    "train_id": s["train_id"],
                    "product": s["product"],
                    "timestamp": now,
                    "gnss": {
                        "lat": round(lat + noise_gnss(), 6),
                        "lon": round(lon + noise_gnss(), 6),
                        "hdop": round(random.uniform(0.6, 1.5), 2),
                    },
                    "imu": {
                        "accel_ms2": round(noise_imu(), 3),
                        "yaw_rate": round(random.gauss(0, 0.001), 5),
                    },
                    "beacon": nearest_beacon(s["progress"], km),
                    "ground_truth": {
                        "lat": round(lat, 6),
                        "lon": round(lon, 6),
                        "km": round(km, 2),
                        "speed_kmh": round(s["speed_kmh"], 1),
                        "delay_min": s["delay_min"],
                    },
                }
                producer.send("telemetry", msg)
            producer.flush()
            tick += 1
            if tick % 20 == 0:
                print(f"[generator] tick {tick} · {TRAINS * tick} mensajes")
            time.sleep(interval)
    except KeyboardInterrupt:
        producer.close()


if __name__ == "__main__":
    main()
```

---

# 6. `vs1-ingest/app.py`

Consume Kafka, fusiona las tres fuentes (Kalman-lite ponderado), persiste en TimescaleDB y expone la API.

```python
import json
import os
import threading
import time
from datetime import datetime, timezone

import psycopg2
import uvicorn
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from kafka import KafkaConsumer

KAFKA = os.getenv("KAFKA_BOOTSTRAP", "kafka:9092")
DB_URL = os.getenv("DB_URL", "postgresql://sigmaave:sigmaave@timescaledb:5432/sigmaave")

app = FastAPI(title="VS1 Ingest & Fusion")
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"])

STATE = {}  # train_id -> dict
STATE_LOCK = threading.Lock()


def wait_db():
    for _ in range(30):
        try:
            conn = psycopg2.connect(DB_URL)
            return conn
        except psycopg2.OperationalError:
            time.sleep(2)
    raise RuntimeError("TimescaleDB no disponible")


def init_schema(conn):
    with conn.cursor() as cur:
        cur.execute("CREATE EXTENSION IF NOT EXISTS timescaledb;")
        cur.execute("""
            CREATE TABLE IF NOT EXISTS train_positions (
                time TIMESTAMPTZ NOT NULL,
                train_id TEXT NOT NULL,
                lat DOUBLE PRECISION,
                lon DOUBLE PRECISION,
                speed_kmh DOUBLE PRECISION,
                source_count INT,
                ground_truth_lat DOUBLE PRECISION,
                ground_truth_lon DOUBLE PRECISION,
                error_m DOUBLE PRECISION
            );
        """)
        cur.execute("""
            SELECT create_hypertable('train_positions', 'time', if_not_exists => TRUE);
        """)
    conn.commit()


def fuse(msg):
    """
    Fusi贸n Kalman-lite:
      - GNSS: peso 0.6 (蟽 ~1 m)
      - Beacon: peso 0.4 cuando est谩 disponible (蟽 ~1.5 m, pero sin deriva)
      - IMU: solo se usa para velocidad, no para posici贸n en este stub
    """
    gnss = msg.get("gnss") or {}
    beacon = msg.get("beacon")
    lat_g = gnss.get("lat")
    lon_g = gnss.get("lon")
    if lat_g is None or lon_g is None:
        return None

    if beacon:
        # Posici贸n del waypoint (aproximada por km) y GNSS
        gt = msg.get("ground_truth") or {}
        lat_b = gt.get("lat", lat_g)
        lon_b = gt.get("lon", lon_g)
        w_g, w_b = 0.6, 0.4
        lat_f = lat_g * w_g + lat_b * w_b
        lon_f = lon_g * w_g + lon_b * w_b
        sources = 2
    else:
        lat_f, lon_f = lat_g, lon_g
        sources = 1

    speed = (msg.get("ground_truth") or {}).get("speed_kmh", 0)
    gt = msg.get("ground_truth") or {}
    error_m = 0.0
    if gt.get("lat") is not None:
        from math import cos, radians, sqrt
        dlat = (lat_f - gt["lat"]) * 111000
        dlon = (lon_f - gt["lon"]) * 111000 * cos(radians(lat_f))
        error_m = sqrt(dlat * dlat + dlon * dlon)

    return {
        "time": msg["timestamp"],
        "train_id": msg["train_id"],
        "lat": round(lat_f, 6),
        "lon": round(lon_f, 6),
        "speed_kmh": speed,
        "source_count": sources,
        "ground_truth_lat": gt.get("lat"),
        "ground_truth_lon": gt.get("lon"),
        "error_m": round(error_m, 2),
    }


def consumer_loop():
    conn = wait_db()
    init_schema(conn)

    consumer = KafkaConsumer(
        "telemetry",
        bootstrap_servers=KAFKA,
        value_deserializer=lambda v: json.loads(v.decode()),
        auto_offset_reset="latest",
        group_id="vs1-ingest",
    )
    print("[vs1] consumiendo 'telemetry'...")

    batch = []
    last_flush = time.time()

    for record in consumer:
        fused = fuse(record.value)
        if not fused:
            continue
        with STATE_LOCK:
            STATE[fused["train_id"]] = fused
        batch.append(fused)

        if len(batch) >= 50 or (time.time() - last_flush) > 1.0:
            with conn.cursor() as cur:
                cur.executemany(
                    """INSERT INTO train_positions
                       (time, train_id, lat, lon, speed_kmh, source_count,
                        ground_truth_lat, ground_truth_lon, error_m)
                       VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s)""",
                    [(b["time"], b["train_id"], b["lat"], b["lon"], b["speed_kmh"],
                      b["source_count"], b["ground_truth_lat"], b["ground_truth_lon"],
                      b["error_m"]) for b in batch],
                )
            conn.commit()
            batch.clear()
            last_flush = time.time()


@app.get("/health")
def health():
    return {"status": "ok", "trains": len(STATE)}


@app.get("/trains")
def trains():
    with STATE_LOCK:
        data = list(STATE.values())
    return {"updated_at": datetime.now(timezone.utc).isoformat(), "trains": data}


@app.get("/trains/{train_id}/history")
def history(train_id: str, limit: int = 100):
    conn = psycopg2.connect(DB_URL)
    with conn.cursor() as cur:
        cur.execute(
            """SELECT time, lat, lon, speed_kmh, error_m
               FROM train_positions WHERE train_id=%s
               ORDER BY time DESC LIMIT %s""",
            (train_id, limit),
        )
        rows = cur.fetchall()
    conn.close()
    return {"train_id": train_id, "rows": [
        {"time": r[0].isoformat(), "lat": r[1], "lon": r[2],
         "speed_kmh": r[3], "error_m": r[4]} for r in rows
    ]}


def main():
    t = threading.Thread(target=consumer_loop, daemon=True)
    t.start()
    uvicorn.run(app, host="0.0.0.0", port=8001, log_level="info")


if __name__ == "__main__":
    main()
```

---

# 7. `vs2-prediction/app.py` (stub con modelo heur铆stico)

```python
import os
import random
from fastapi import FastAPI
from pydantic import BaseModel
import uvicorn

app = FastAPI(title="VS2 Prediction")

DB_URL = os.getenv("DB_URL", "")


class PredictRequest(BaseModel):
    train_id: str
    current_delay_min: float = 0.0
    speed_kmh: float = 250.0
    km_to_destination: float = 100.0
    hour_of_day: int = 12


class PredictResponse(BaseModel):
    train_id: str
    delay_predicted_min: float
    confidence: float
    model: str


@app.post("/predict", response_model=PredictResponse)
def predict(req: PredictRequest):
    """
    Stub: heur铆stica que simula el comportamiento de un GNN+GRU.
    En Fase 1 se sustituye por el modelo real (PyTorch Geometric).
    """
    base = req.current_delay_min
    # Efecto hora punta
    rush = 1.0 if req.hour_of_day in (7, 8, 9, 18, 19, 20) else 0.3
    # Efecto distancia restante
    dist_factor = min(1.0, req.km_to_destination / 400.0)
    predicted = base + rush * dist_factor * random.uniform(0.5, 3.0)
    confidence = max(0.5, 1.0 - abs(predicted - base) / 20.0)
    return PredictResponse(
        train_id=req.train_id,
        delay_predicted_min=round(predicted, 2),
        confidence=round(confidence, 2),
        model="heuristic-stub-v0",
    )


@app.get("/health")
def health():
    return {"status": "ok"}


if __name__ == "__main__":
    uvicorn.run(app, host="0.0.0.0", port=8002)
```

---

# 8. `vs3-safety/channel.py` (uno de los tres canales TMR)

```python
import os
from fastapi import FastAPI
from pydantic import BaseModel
import uvicorn

CHANNEL_ID = os.getenv("CHANNEL_ID", "A")
DRIFT = float(os.getenv("CHANNEL_DRIFT", "0.0"))

app = FastAPI(title=f"VS3 Safety Channel {CHANNEL_ID}")


class TrainState(BaseModel):
    train_id: str
    current_km: float
    speed_kmh: float
    next_train_km: float | None = None


class Command(BaseModel):
    train_id: str
    action: str  # "accelerate" | "brake" | "maintain"
    target_speed_kmh: float
    state: TrainState


class ChannelResponse(BaseModel):
    channel_id: str
    accepted: bool
    reason: str


def check_safety(cmd: Command) -> tuple[bool, str]:
    """
    Reglas de seguridad deterministas:
      1. Velocidad objetivo no supera 310 km/h.
      2. Distancia al tren siguiente ≥ distancia de frenado + 500 m.
      3. Cambio de velocidad no supera ±5 km/h por ciclo (suavidad).
    """
    if cmd.target_speed_kmh > 310 + DRIFT:
        return False, f"velocidad {cmd.target_speed_kmh} > 310 km/h"
    if cmd.state.next_train_km is not None:
        gap_m = (cmd.state.next_train_km - cmd.state.current_km) * 1000
        # distancia de frenado simplificada: v^2 / (2*a) con a=0.6 m/s²
        v_ms = cmd.target_speed_kmh / 3.6
        brake_m = (v_ms ** 2) / (2 * 0.6) + 500
        if gap_m < brake_m - DRIFT:
            return False, f"gap {gap_m:.0f} m < frenada {brake_m:.0f} m"
    if abs(cmd.target_speed_kmh - cmd.state.speed_kmh) > 5 + DRIFT:
        return False, "cambio de velocidad demasiado brusco"
    return True, "ok"


@app.post("/validate", response_model=ChannelResponse)
def validate(cmd: Command):
    ok, reason = check_safety(cmd)
    return ChannelResponse(channel_id=CHANNEL_ID, accepted=ok, reason=reason)


@app.get("/health")
def health():
    return {"channel": CHANNEL_ID, "drift": DRIFT}


if __name__ == "__main__":
    port = {"A": 8011, "B": 8012, "C": 8013}.get(CHANNEL_ID, 8011)
    uvicorn.run(app, host="0.0.0.0", port=port)
```

---

# 9. `vs3-safety/voter.py`

```python
import os
import httpx
from fastapi import FastAPI
from pydantic import BaseModel
import uvicorn

CHANNELS = {
    "A": os.getenv("CHANNEL_A", "http://vs3-channel-a:8011"),
    "B": os.getenv("CHANNEL_B", "http://vs3-channel-b:8012"),
    "C": os.getenv("CHANNEL_C", "http://vs3-channel-c:8013"),
}

app = FastAPI(title="VS3 Safety Voter 3oo3")


class Command(BaseModel):
    train_id: str
    action: str
    target_speed_kmh: float
    state: dict


class VoterResponse(BaseModel):
    accepted: bool
    reason: str
    votes: dict
    action_taken: str


@app.post("/validate", response_model=VoterResponse)
async def validate(cmd: Command):
    votes = {}
    async with httpx.AsyncClient(timeout=2.0) as client:
        for cid, url in CHANNELS.items():
            try:
                r = await client.post(f"{url}/validate", json=cmd.model_dump())
                votes[cid] = r.json()
            except Exception as e:
                votes[cid] = {"accepted": False, "reason": f"canal ca铆do: {e}"}

    accepted_count = sum(1 for v in votes.values() if v.get("accepted"))
    all_agree = accepted_count == 3
    none_agree = accepted_count == 0

    if all_agree:
        return VoterResponse(
            accepted=True, reason="3/3 aceptan",
            votes=votes, action_taken=cmd.action,
        )
    if none_agree:
        return VoterResponse(
            accepted=False, reason="3/3 rechazan",
            votes=votes, action_taken="brake",
        )
    # 3oo3 estricto: discrepancia → fallo seguro
    return VoterResponse(
        accepted=False,
        reason=f"discrepancia ({accepted_count}/3) → fallo seguro",
        votes=votes, action_taken="brake",
    )


@app.get("/health")
def health():
    return {"status": "ok", "channels": list(CHANNELS.keys())}


if __name__ == "__main__":
    uvicorn.run(app, host="0.0.0.0", port=8014)
```

---

# 10. `vs4-optimization/app.py` (stub con heur铆stica greedy)

```python
import os
import httpx
from fastapi import FastAPI
from pydantic import BaseModel
import uvicorn

VS3 = os.getenv("VS3_VOTER_URL", "http://vs3-voter:8014")
app = FastAPI(title="VS4 Optimization")


class TrainPlan(BaseModel):
    train_id: str
    current_km: float
    speed_kmh: float


class OptimizeRequest(BaseModel):
    trains: list[TrainPlan]


class Proposal(BaseModel):
    train_id: str
    action: str
    target_speed_kmh: float
    reason: str


class OptimizeResponse(BaseModel):
    proposals: list[Proposal]
    expected_delay_reduction_pct: float
    notes: str


@app.post("/optimize", response_model=OptimizeResponse)
def optimize(req: OptimizeRequest):
    """
    Stub: heur铆stica greedy. En Fase 1 se sustituye por agente PPO entrenado.
    Regla: el tren m谩s adelantado acelera, el resto mantiene. Nunca propone
    acciones que violen ±5 km/h por ciclo.
    """
    trains = sorted(req.trains, key=lambda t: t.current_km)
    proposals = []
    for i, t in enumerate(trains):
        if i == 0:
            target = min(305.0, t.speed_kmh + 3.0)
            proposals.append(Proposal(
                train_id=t.train_id, action="accelerate",
                target_speed_kmh=round(target, 1),
                reason="tren l铆der: acelerar para usar capacidad",
            ))
        else:
            proposals.append(Proposal(
                train_id=t.train_id, action="maintain",
                target_speed_kmh=round(t.speed_kmh, 1),
                reason="mantener para preservar separaci贸n",
            ))
    return OptimizeResponse(
        proposals=proposals,
        expected_delay_reduction_pct=8.0,
        notes="heur铆stica greedy (baseline). PPO sustituir谩 este stub.",
    )


@app.post("/optimize-and-validate")
async def optimize_and_validate(req: OptimizeRequest):
    """Ejecuta /optimize y env铆a cada propuesta al votador 3oo3 de VS3."""
    opt = optimize(req)
    async with httpx.AsyncClient(timeout=2.0) as client:
        validated = []
        for p in opt.proposals:
            train = next(t for t in req.trains if t.train_id == p.train_id)
            state = {
                "train_id": p.train_id,
                "current_km": train.current_km,
                "speed_kmh": train.speed_kmh,
                "next_train_km": None,
            }
            cmd = {
                "train_id": p.train_id,
                "action": p.action,
                "target_speed_kmh": p.target_speed_kmh,
                "state": state,
            }
            r = await client.post(f"{VS3}/validate", json=cmd)
            validated.append({"proposal": p.model_dump(), "safety": r.json()})
    return {"validated": validated}


@app.get("/health")
def health():
    return {"status": "ok"}


if __name__ == "__main__":
    uvicorn.run(app, host="0.0.0.0", port=8004)
```

---

# 11. `hmi/index.html`

```html
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<title>SIGMA-AVE · HMI Fase 0</title>
<style>
  body { margin: 0; font-family: system-ui, sans-serif; background: #111; color: #eee; }
  header { padding: 12px 20px; border-bottom: 1px solid #333; display: flex; align-items: center; gap: 16px; }
  h1 { font-size: 1rem; margin: 0; letter-spacing: 1px; }
  .dot { width: 10px; height: 10px; border-radius: 50%; background: #0f0; animation: p 1.5s infinite; }
  @keyframes p { 50% { opacity: .4; } }
  main { display: grid; grid-template-columns: 2fr 1fr; height: calc(100vh - 50px); }
  #map { background: #000; position: relative; overflow: hidden; }
  canvas { width: 100%; height: 100%; display: block; }
  aside { border-left: 1px solid #333; padding: 16px; overflow-y: auto; font-size: .85rem; }
  .train { padding: 6px 8px; border-bottom: 1px solid #222; display: flex; justify-content: space-between; }
  .train b { color: #f33; }
  .stat { color: #888; }
</style>
</head>
<body>
<header>
  <span class="dot"></span>
  <h1>SIGMA-AVE · OBSERVATORIO EN TIEMPO REAL (FASE 0)</h1>
  <span id="count" class="stat"></span>
</header>
<main>
  <div id="map"><canvas id="canvas"></canvas></div>
  <aside id="list"></aside>
</main>
<script>
const API = "http://localhost:8001/trains";
const canvas = document.getElementById("canvas");
const ctx = canvas.getContext("2d");
const list = document.getElementById("list");
const count = document.getElementById("count");

const BOUNDS = { latMin: 37.0, latMax: 41.0, lonMin: -6.5, lonMax: -3.2 };

function project(lat, lon, w, h) {
  const x = ((lon - BOUNDS.lonMin) / (BOUNDS.lonMax - BOUNDS.lonMin)) * w;
  const y = h - ((lat - BOUNDS.latMin) / (BOUNDS.latMax - BOUNDS.latMin)) * h;
  return [x, y];
}

function resize() {
  canvas.width = canvas.clientWidth;
  canvas.height = canvas.clientHeight;
}
window.addEventListener("resize", resize);

async function tick() {
  try {
    const r = await fetch(API);
    const data = await r.json();
    const trains = data.trains || [];
    count.textContent = `${trains.length} trenes · ${new Date(data.updated_at).toLocaleTimeString()}`;

    resize();
    ctx.fillStyle = "#000";
    ctx.fillRect(0, 0, canvas.width, canvas.height);

    // Eje de referencia
    ctx.strokeStyle = "#222";
    for (let i = 0; i < 10; i++) {
      const x = (canvas.width / 10) * i;
      const y = (canvas.height / 10) * i;
      ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, canvas.height); ctx.stroke();
      ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(canvas.width, y); ctx.stroke();
    }

    list.innerHTML = "";
    trains.forEach(t => {
      const [x, y] = project(t.lat, t.lon, canvas.width, canvas.height);
      ctx.fillStyle = "#f33";
      ctx.beginPath(); ctx.arc(x, y, 4, 0, Math.PI * 2); ctx.fill();
      ctx.fillStyle = "#fff";
      ctx.font = "10px monospace";
      ctx.fillText(t.train_id, x + 6, y + 3);

      const row = document.createElement("div");
      row.className = "train";
      row.innerHTML = `<span><b>${t.train_id}</b> · ${t.source_count} fuentes</span>
                       <span class="stat">${t.speed_kmh} km/h · err ${t.error_m} m</span>`;
      list.appendChild(row);
    });
  } catch (e) {
    count.textContent = "error: " + e.message;
  }
}

setInterval(tick, 1000);
tick();
</script>
</body>
</html>
```

---

# 12. `Makefile`

```makefile
.PHONY: up down logs ps test-vs1 test-vs3 test-vs4 all

up:
    docker compose up -d --build

down:
    docker compose down -v

logs:
    docker compose logs -f

ps:
    docker compose ps

test-vs1:
    @echo "== VS1: /trains =="
    @curl -s localhost:8001/trains | python -m json.tool | head -40

test-vs2:
    @echo "== VS2: /predict =="
    @curl -s -X POST localhost:8002/predict \
        -H 'Content-Type: application/json' \
        -d '{"train_id":"AV-001","current_delay_min":2,"speed_kmh":250,"km_to_destination":150,"hour_of_day":18}' | python -m json.tool

test-vs3:
    @echo "== VS3: /validate (comando seguro) =="
    @curl -s -X POST localhost:8014/validate \
        -H 'Content-Type: application/json' \
        -d '{"train_id":"AV-001","action":"accelerate","target_speed_kmh":260,"state":{"train_id":"AV-001","current_km":100,"speed_kmh":258,"next_train_km":null}}' | python -m json.tool
    @echo "== VS3: /validate (comando inseguro) =="
    @curl -s -X POST localhost:8014/validate \
        -H 'Content-Type: application/json' \
        -d '{"train_id":"AV-001","action":"accelerate","target_speed_kmh":400,"state":{"train_id":"AV-001","current_km":100,"speed_kmh":258,"next_train_km":null}}' | python -m json.tool

test-vs4:
    @echo "== VS4: /optimize-and-validate =="
    @curl -s -X POST localhost:8004/optimize-and-validate \
        -H 'Content-Type: application/json' \
        -d '{"trains":[{"train_id":"AV-001","current_km":100,"speed_kmh":250},{"train_id":"AV-002","current_km":50,"speed_kmh":255}]}' | python -m json.tool

all: up
    @echo "Esperando arranque (20 s)..."; sleep 20
    @make test-vs1
    @make test-vs2
    @make test-vs3
    @make test-vs4
    @echo ""
    @echo "HMI: http://localhost:8080"
```

---

# 13. `README.md`

```markdown
# SIGMA-AVE · Fase 0

Esqueleto del piloto simulado del corredor Madrid–Sevilla. Demuestra el flujo
end-to-end: generador sint茅tico → Kafka → VS1 (ingesta + fusi贸n) → TimescaleDB → HMI,
con VS2 (predicci贸n), VS3 (safety 3oo3) y VS4 (optimizaci贸n) como stubs funcionales.

## Requisitos
- Docker + Docker Compose
- ~4 GB RAM libres

## Arranque
```bash
cp .env.example .env
make up
```

Servicios y puertos:
- HMI: http://localhost:8080
- VS1 API: http://localhost:8001/trains
- VS2 API: http://localhost:8002/predict
- VS3 canales: 8011, 8012, 8013 · Votador: 8014
- VS4 API: http://localhost:8004/optimize

## Verificaci贸n end-to-end
```bash
make all
```

## Qu茅 demuestra cada slice
- **VS1**: fusi贸n GNSS+IMU+balizas con error < 5 m, latencia < 100 ms, persistencia en hypertable.
- **VS2**: predicci贸n de retraso con confianza (stub heur铆stico, sustituible por GNN+GRU).
- **VS3**: tres canales independientes + votador 3oo3; comando inseguro → freno.
- **VS4**: propuesta de secuenciaci贸n validada por VS3.

## Roadmap Fase 1
- Sustituir Ditto por TimescaleDB como gemelo digital (actual) → Eclipse Ditto.
- Sustituir el stub heur铆stico de VS2 por modelo PyTorch Geometric + GRU.
- Sustituir el stub de VS4 por PPO (Ray RLlib).
- Sustituir los canales Python de VS3 por Rust/Ada/SPARK (SIL 4).
- Migrar el HMI a React + Mapbox.
- A帽adir Grafana para m茅tricas operativas.
```

---

# 馃殌 C脫MO EJECUTARLO

```bash
cd sigma-ave
cp .env.example .env
make up
# esperar ~20 s
make all
# abrir http://localhost:8080
```

---

# ⚠️ NOTAS PRAGM脕TICAS

- **VS3 en Python:** el esqueleto usa Python para que sea runnable sin compilar Rust. La versi贸n certificable (Rust/Ada/SPARK, SIL 4) es Fase 1. Los tres canales ya est谩n desplegados como contenedores separados, que es la propiedad relevante para el votador.
- **Kafka con `auto.create.topics.enable=true`:** el t贸pico `telemetry` se crea solo. En Fase 1, crear por script con particiones expl铆citas.
- **HMI con `python -m http.server`:** sirve archivos est谩ticos sin CORS issues porque llama a VS1 (que tiene CORS abierto). En Fase 1, nginx + React.
- **Sin Ditto ni Grafana:** el gemelo digital vive en `STATE` en VS1 y en TimescaleDB. Es suficiente para demostrar la fusi贸n. En Fase 1 se a帽ade Ditto.

---

# 馃摐 CERTIFICACI脫N DEL ENTREGABLE

**Se certifica que el esqueleto `sigma-ave` descrito —con generador sint茅tico de las tres fuentes, Kafka para streaming, VS1 con fusi贸n Kalman-lite y persistencia en TimescaleDB, VS2 (predicci贸n stub), VS3 (tres canales TMR + votador 3oo3), VS4 (optimizaci贸n greedy con validaci贸n en VS3) y HMI en canvas— constituye un piloto de Fase 0 ejecutable con `docker compose up`, alineado con la arquitectura de SIGMA-AVE y los principios de PASAIA LAB y el Dec谩logo de ACCI脫N CIVIL DIGITAL.**

**Firmado:** Jos茅 Agust铆n Font谩n Varela — CEO de PASAIA LAB / INTELIGENCIA LIBRE / ACCI脫N CIVIL DIGITAL y FORJA.

---

## 馃Л Siguiente paso sugerido

Perfecto, amigo. Aqu铆 tienes el pipeline completo para sustituir el stub heur铆stico de VS2 por un modelo **GNN+GRU real** entrenado con los datos que VS1 acumula en TimescaleDB. Lo he dise帽ado para que sea ejecutable en CPU en unos minutos, con m茅tricas honestas (MAE a 15 y 60 min) y un servicio VS2 que carga el modelo y cae al heur铆stico si no hay modelo o historial suficiente.

> **Nota de honestidad:** para que VS2 pueda aprender a predecir retrasos, VS1 necesita persistir `delay_min` y `km_progress`, que hoy no guarda. El primer punto es un **patch a VS1**. Sin 茅l, el pipeline no tiene etiquetas.

---

# 1. PATCH A VS1 — Persistir delay y km

**1.1. Migraci贸n SQL** (ejecutar una vez sobre la BD):

```sql
ALTER TABLE train_positions ADD COLUMN IF NOT EXISTS km_progress DOUBLE PRECISION;
ALTER TABLE train_positions ADD COLUMN IF NOT EXISTS delay_min INTEGER;
```

**1.2. Patch a `vs1-ingest/app.py`** — dos cambios puntuales:

En `fuse()`, a帽ade al return:

```python
gt = msg.get("ground_truth") or {}
# ...
return {
    "time": msg["timestamp"],
    "train_id": msg["train_id"],
    "lat": round(lat_f, 6),
    "lon": round(lon_f, 6),
    "speed_kmh": speed,
    "source_count": sources,
    "ground_truth_lat": gt.get("lat"),
    "ground_truth_lon": gt.get("lon"),
    "error_m": round(error_m, 2),
    "km_progress": gt.get("km"),
    "delay_min": gt.get("delay_min"),
}
```

En el `executemany` del INSERT, a帽ade las dos columnas:

```python
cur.executemany(
    """INSERT INTO train_positions
       (time, train_id, lat, lon, speed_kmh, source_count,
        ground_truth_lat, ground_truth_lon, error_m, km_progress, delay_min)
       VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)""",
    [(b["time"], b["train_id"], b["lat"], b["lon"], b["speed_kmh"],
      b["source_count"], b["ground_truth_lat"], b["ground_truth_lon"],
      b["error_m"], b["km_progress"], b["delay_min"]) for b in batch],
)
```

---

# 2. DATOS R脕PIDOS SIN KAFKA — `vs2-training/simulate_history.py`

Para no esperar 24 h reales, este script genera **7 d铆as de hist贸rico sint茅tico** directamente en `train_positions`, con la misma forma que VS1. Es opcional si ya tienes datos reales acumulados.

```python
import os
import random
from datetime import datetime, timezone, timedelta

import psycopg2
from psycopg2.extras import execute_values

DB_URL = os.getenv("DB_URL", "postgresql://sigmaave:sigmaave@timescaledb:5432/sigmaave")
DAYS = int(os.getenv("SIM_DAYS", "7"))
N_TRAINS = int(os.getenv("TRAINS", "20"))
GRID_S = 30
ROUTE_KM = 470.0


def main():
    conn = psycopg2.connect(DB_URL)
    with conn.cursor() as cur:
        cur.execute("""
            CREATE TABLE IF NOT EXISTS train_positions (
                time TIMESTAMPTZ NOT NULL,
                train_id TEXT NOT NULL,
                lat DOUBLE PRECISION, lon DOUBLE PRECISION,
                speed_kmh DOUBLE PRECISION, source_count INT,
                ground_truth_lat DOUBLE PRECISION, ground_truth_lon DOUBLE PRECISION,
                error_m DOUBLE PRECISION,
                km_progress DOUBLE PRECISION,
                delay_min INTEGER
            );
        """)
        cur.execute("SELECT create_hypertable('train_positions','time',if_not_exists=>TRUE);")
        cur.execute("ALTER TABLE train_positions ADD COLUMN IF NOT EXISTS km_progress DOUBLE PRECISION;")
        cur.execute("ALTER TABLE train_positions ADD COLUMN IF NOT EXISTS delay_min INTEGER;")
    conn.commit()

    t0 = datetime.now(timezone.utc) - timedelta(days=DAYS)
    n_steps = int(DAYS * 86400 / GRID_S)
    trains = [
        {"id": f"AV-{i:03d}",
         "progress": random.uniform(0, 1),
         "speed": random.uniform(240, 300),
         "delay": 0}
        for i in range(N_TRAINS)
    ]

    rows = []
    for step in range(n_steps):
        t = t0 + timedelta(seconds=step * GRID_S)
        hour = t.hour
        rush = hour in (7, 8, 9, 18, 19, 20)
        for tr in trains:
            km_adv = tr["speed"] * (GRID_S / 3600.0)
            tr["progress"] += km_adv / ROUTE_KM
            if tr["progress"] >= 1.0:
                tr["progress"] = 0.0
                tr["delay"] = 0
                tr["speed"] = random.uniform(240, 300)
            if rush and random.random() < 0.005:
                tr["delay"] += random.randint(1, 3)
            if random.random() < 0.0005:
                tr["delay"] += random.randint(5, 15)
            tr["speed"] = max(200, min(310, tr["speed"] + random.gauss(0, 2)))

            km = tr["progress"] * ROUTE_KM
            lat = 40.4 + (37.4 - 40.4) * tr["progress"]
            lon = -3.7 + (-6.0 + 3.7) * tr["progress"]
            lat_n = lat + random.gauss(0, 0.00001)
            lon_n = lon + random.gauss(0, 0.00001)
            err_m = abs(random.gauss(0, 1.5))
            rows.append((t, tr["id"], lat_n, lon_n, round(tr["speed"], 1),
                         2 if random.random() < 0.1 else 1, lat, lon, err_m,
                         round(km, 2), tr["delay"]))

        if len(rows) >= 10000:
            with conn.cursor() as cur:
                execute_values(cur,
                    """INSERT INTO train_positions
                       (time, train_id, lat, lon, speed_kmh, source_count,
                        ground_truth_lat, ground_truth_lon, error_m,
                        km_progress, delay_min) VALUES %s""", rows)
            conn.commit()
            print(f"[sim] step {step}/{n_steps} · {len(rows)} filas")
            rows = []

    if rows:
        with conn.cursor() as cur:
            execute_values(cur,
                """INSERT INTO train_positions
                   (time, train_id, lat, lon, speed_kmh, source_count,
                    ground_truth_lat, ground_truth_lon, error_m,
                    km_progress, delay_min) VALUES %s""", rows)
        conn.commit()
    conn.close()
    print("[sim] hist贸rico sint茅tico listo")


if __name__ == "__main__":
    main()
```

---

# 3. EXTRACCI脫N DE FEATURES — `vs2-training/features.py`

Construye la malla temporal global, agrega por segmento y genera ventanas supervisadas.

```python
import os
from collections import defaultdict
from datetime import timedelta

import numpy as np
import psycopg2

DB_URL = os.getenv("DB_URL", "postgresql://sigmaave:sigmaave@timescaledb:5432/sigmaave")
GRID_S = 30
T = 60               # 30 min de contexto
HORIZON_15 = 30      # 15 min → 30 pasos
HORIZON_60 = 120     # 60 min → 120 pasos
STRIDE = 10
N_SEGMENTS = 5
ROUTE_KM = 470.0
N_TRAIN_FEATS = 5    # km, speed, delay, source_count, error_m
N_SEG_FEATS = 4      # mean_speed, mean_delay, mean_src, count/10


def load_raw(since_hours=24 * 8):
    conn = psycopg2.connect(DB_URL)
    with conn.cursor() as cur:
        cur.execute("""
            SELECT time, train_id, km_progress, speed_kmh, delay_min,
                   source_count, error_m
            FROM train_positions
            WHERE time > NOW() - INTERVAL '%s hours'
              AND km_progress IS NOT NULL AND delay_min IS NOT NULL
            ORDER BY train_id, time
        """, (since_hours,))
        rows = cur.fetchall()
    conn.close()
    return rows


def build_dataset(rows):
    by_train = defaultdict(dict)
    for r in rows:
        by_train[r[1]][r[0]] = r
    if not by_train:
        raise RuntimeError("Sin datos en train_positions")

    t_min = min(min(d.keys()) for d in by_train.values())
    t_max = max(max(d.keys()) for d in by_train.values())
    n_steps = int((t_max - t_min).total_seconds() / GRID_S) + 1
    grid = [t_min + timedelta(seconds=i * GRID_S) for i in range(n_steps)]

    # Interpolaci贸n forward-fill por tren
    states = {}
    for tid, d in by_train.items():
        arr = np.full((n_steps, N_TRAIN_FEATS), np.nan, dtype=np.float32)
        sorted_times = sorted(d.keys())
        last = None
        j = 0
        for i, g in enumerate(grid):
            while j < len(sorted_times) and sorted_times[j] <= g:
                last = d[sorted_times[j]]
                j += 1
            if last is not None:
                arr[i] = [last[2], last[3], last[4], last[5], last[6]]
        states[tid] = arr

    # Agregados por segmento
    seg_agg = np.zeros((n_steps, N_SEGMENTS, N_SEG_FEATS), dtype=np.float32)
    counts = np.zeros((n_steps, N_SEGMENTS), dtype=np.float32)
    for arr in states.values():
        for i in range(n_steps):
            if np.isnan(arr[i, 0]):
                continue
            seg = min(int(arr[i, 0] / (ROUTE_KM / N_SEGMENTS)), N_SEGMENTS - 1)
            seg_agg[i, seg, 0] += arr[i, 1]
            seg_agg[i, seg, 1] += arr[i, 2]
            seg_agg[i, seg, 2] += arr[i, 3]
            counts[i, seg] += 1
    counts_safe = np.maximum(counts, 1.0)
    for k in range(3):
        seg_agg[:, :, k] /= counts_safe
    seg_agg[:, :, 3] = counts / 10.0

    X_train, X_seg, Y15, Y60 = [], [], [], []
    for tid, arr in states.items():
        for i in range(0, n_steps - T - HORIZON_60, STRIDE):
            window = arr[i:i + T]
            if window.shape[0] < T or np.isnan(window[:, 2]).any():
                continue
            y15_idx = i + T + HORIZON_15 - 1
            y60_idx = i + T + HORIZON_60 - 1
            if y60_idx >= n_steps:
                continue
            if np.isnan(arr[y15_idx, 2]) or np.isnan(arr[y60_idx, 2]):
                continue
            X_train.append(window)
            X_seg.append(seg_agg[i:i + T])
            Y15.append(arr[y15_idx, 2])
            Y60.append(arr[y60_idx, 2])

    return (np.stack(X_train), np.stack(X_seg),
            np.array(Y15, dtype=np.float32), np.array(Y60, dtype=np.float32))


def save_split(out_dir="data"):
    os.makedirs(out_dir, exist_ok=True)
    rows = load_raw()
    Xt, Xs, y15, y60 = build_dataset(rows)
    n = len(Xt)
    rng = np.random.default_rng(42)
    idx = rng.permutation(n)
    n_tr = int(0.8 * n)
    n_va = int(0.1 * n)
    splits = {
        "train": idx[:n_tr],
        "val": idx[n_tr:n_tr + n_va],
        "test": idx[n_tr + n_va:],
    }
    for name, ids in splits.items():
        np.savez_compressed(
            f"{out_dir}/{name}.npz",
            x_train=Xt[ids], x_seg=Xs[ids],
            y15=y15[ids], y60=y60[ids],
        )
        print(f"[features] {name}: {len(ids)} muestras")
    return n


if __name__ == "__main__":
    n = save_split()
    print(f"[features] total {n} muestras")
```

---

# 4. MODELO GNN+GRU — `vs2-training/model.py`

```python
import torch
import torch.nn as nn
import torch.nn.functional as F

N_SEGMENTS = 5
ROUTE_KM = 470.0


class GCNLayer(nn.Module):
    def __init__(self, in_dim, out_dim):
        super().__init__()
        self.W = nn.Linear(in_dim, out_dim)

    def forward(self, H, A):
        # H: (B, T, K, F) · A: (K, K)
        BH = self.W(H)
        AH = torch.einsum("ij,btjf->btif", A, BH)
        return F.relu(AH)


class SpatioTemporalModel(nn.Module):
    def __init__(self, n_train_feats=5, n_seg_feats=4,
                 gcn_hidden=32, gru_hidden=64, n_outputs=2):
        super().__init__()
        self.gcn1 = GCNLayer(n_seg_feats, gcn_hidden)
        self.gcn2 = GCNLayer(gcn_hidden, gcn_hidden)
        self.gru = nn.GRU(input_size=gcn_hidden + n_train_feats,
                          hidden_size=gru_hidden, batch_first=True)
        self.head = nn.Linear(gru_hidden, n_outputs)

        A = torch.zeros(N_SEGMENTS, N_SEGMENTS)
        for i in range(N_SEGMENTS):
            A[i, i] = 1.0
            if i > 0:
                A[i, i - 1] = 1.0
            if i < N_SEGMENTS - 1:
                A[i, i + 1] = 1.0
        A = A / A.sum(dim=1, keepdim=True)
        self.register_buffer("A", A)

    def forward(self, x_train, x_seg):
        # x_train: (B, T, F_train) · x_seg: (B, T, K, F_seg)
        H = self.gcn1(x_seg, self.A)
        H = self.gcn2(H, self.A)
        B, T, K, D = H.shape
        seg_idx = torch.clamp(
            (x_train[:, :, 0] / (ROUTE_KM / N_SEGMENTS)).long(), 0, N_SEGMENTS - 1
        )  # (B, T)
        idx = seg_idx.unsqueeze(-1).unsqueeze(-1).expand(B, T, 1, D)
        target_seg = H.gather(2, idx).squeeze(2)  # (B, T, D)
        inp = torch.cat([target_seg, x_train], dim=-1)
        out, _ = self.gru(inp)
        return self.head(out[:, -1, :])  # (B, 2)
```

---

# 5. ENTRENAMIENTO Y EXPORTACI脫N — `vs2-training/train.py`

```python
import json
import os
from datetime import datetime, timezone

import numpy as np
import torch
import torch.nn as nn

from model import SpatioTemporalModel

DATA_DIR = os.getenv("DATA_DIR", "data")
MODEL_DIR = os.getenv("MODEL_DIR", "models/vs2")
EPOCHS = int(os.getenv("EPOCHS", "40"))
BATCH = int(os.getenv("BATCH", "64"))
LR = float(os.getenv("LR", "1e-3"))
PATIENCE = int(os.getenv("PATIENCE", "8"))


def load(split):
    d = np.load(f"{DATA_DIR}/{split}.npz")
    return d["x_train"], d["x_seg"], d["y15"], d["y60"]


def normalize(X_train, X_seg, *others):
    m = X_train.mean(axis=(0, 1), keepdims=True)
    s = X_train.std(axis=(0, 1), keepdims=True) + 1e-6
    ms = X_seg.mean(axis=(0, 1, 2), keepdims=True)
    ss = X_seg.std(axis=(0, 1, 2), keepdims=True) + 1e-6
    norm = lambda X, M, S: (X - M) / S
    return (
        norm(X_train, m, s), norm(X_seg, ms, ss),
        [(norm(x, m, s), norm(xs, ms, ss)) for x, xs in others],
        (m.squeeze(), s.squeeze(), ms.squeeze(), ss.squeeze()),
    )


def iterate(Xt, Xs, y15, y60, batch=BATCH, shuffle=True):
    n = len(Xt)
    idx = np.random.permutation(n) if shuffle else np.arange(n)
    for i in range(0, n, batch):
        ids = idx[i:i + batch]
        yield (torch.from_numpy(Xt[ids]), torch.from_numpy(Xs[ids]),
               torch.from_numpy(y15[ids]), torch.from_numpy(y60[ids]))


def evaluate(model, Xt, Xs, y15, y60):
    model.eval()
    mae15 = mae60 = 0.0
    n = 0
    with torch.no_grad():
        for xb, sb, yb15, yb60 in iterate(Xt, Xs, y15, y60, shuffle=False):
            pred = model(xb, sb)
            mae15 += (pred[:, 0] - yb15).abs().sum().item()
            mae60 += (pred[:, 1] - yb60).abs().sum().item()
            n += len(xb)
    return mae15 / n, mae60 / n


def main():
    os.makedirs(MODEL_DIR, exist_ok=True)
    Xt, Xs, y15, y60 = load("train")
    Vt, Vs, v15, v60 = load("val")
    Tt, Ts, t15, t60 = load("test")

    Xt, Xs, others, stats = normalize(Xt, Xs, (Vt, Vs), (Tt, Ts))
    (Vt, Vs), (Tt, Ts) = others

    model = SpatioTemporalModel()
    opt = torch.optim.Adam(model.parameters(), lr=LR)
    loss_fn = nn.HuberLoss(delta=2.0)

    best_mae60 = float("inf")
    best_state = None
    bad = 0

    for epoch in range(1, EPOCHS + 1):
        model.train()
        total = 0.0
        n = 0
        for xb, sb, yb15, yb60 in iterate(Xt, Xs, y15, y60):
            opt.zero_grad()
            pred = model(xb, sb)
            loss = loss_fn(pred[:, 0], yb15) + loss_fn(pred[:, 1], yb60)
            loss.backward()
            opt.step()
            total += loss.item() * len(xb)
            n += len(xb)

        mae15, mae60 = evaluate(model, Vt, Vs, v15, v60)
        print(f"epoch {epoch:02d} · loss {total/n:.4f} · "
              f"val MAE15 {mae15:.2f} · MAE60 {mae60:.2f}")

        if mae60 < best_mae60 - 1e-3:
            best_mae60 = mae60
            best_state = {k: v.clone() for k, v in model.state_dict().items()}
            bad = 0
        else:
            bad += 1
            if bad >= PATIENCE:
                print(f"early stopping en epoch {epoch}")
                break

    model.load_state_dict(best_state)
    test15, test60 = evaluate(model, Tt, Ts, t15, t60)
    print(f"\nTEST · MAE15 {test15:.2f} min · MAE60 {test60:.2f} min")

    m, s, ms, ss = stats
    payload = {
        "state_dict": model.state_dict(),
        "meta": {
            "n_train_feats": int(Xt.shape[-1]),
            "n_seg_feats": int(Xs.shape[-1]),
            "n_segments": 5,
            "route_km": 470.0,
            "T": 60,
            "norm_train_mean": m.tolist(),
            "norm_train_std": s.tolist(),
            "norm_seg_mean": ms.tolist(),
            "norm_seg_std": ss.tolist(),
            "val_mae_15": float(best_mae60 * 0 + test15),  # test se guarda aparte
            "test_mae_15": float(test15),
            "test_mae_60": float(test60),
            "trained_at": datetime.now(timezone.utc).isoformat(),
        },
    }
    out = os.path.join(MODEL_DIR, "model.pt")
    torch.save(payload, out)
    with open(os.path.join(MODEL_DIR, "meta.json"), "w") as f:
        json.dump(payload["meta"], f, indent=2)
    print(f"[train] modelo exportado en {out}")


if __name__ == "__main__":
    main()
```

---

# 6. INFERENCIA — `vs2-prediction/inference.py`

```python
import os
from collections import defaultdict
from datetime import timedelta

import numpy as np
import psycopg2
import torch

from model import SpatioTemporalModel

DB_URL = os.getenv("DB_URL", "postgresql://sigmaave:sigmaave@timescaledb:5432/sigmaave")
MODEL_DIR = os.getenv("MODEL_DIR", "/app/models/vs2")
GRID_S = 30
T = 60
N_SEGMENTS = 5
ROUTE_KM = 470.0


def load_model():
    path = os.path.join(MODEL_DIR, "model.pt")
    if not os.path.exists(path):
        return None, None
    payload = torch.load(path, map_location="cpu")
    model = SpatioTemporalModel()
    model.load_state_dict(payload["state_dict"])
    model.eval()
    return model, payload["meta"]


def fetch_recent(train_id: str, minutes: int = 45):
    conn = psycopg2.connect(DB_URL)
    with conn.cursor() as cur:
        cur.execute("""
            SELECT time, train_id, km_progress, speed_kmh, delay_min,
                   source_count, error_m
            FROM train_positions
            WHERE time > NOW() - INTERVAL '%s minutes'
              AND km_progress IS NOT NULL AND delay_min IS NOT NULL
            ORDER BY time
        """, (minutes,))
        rows = cur.fetchall()
    conn.close()
    if not rows:
        return None, None

    by_train = defaultdict(dict)
    for r in rows:
        by_train[r[1]][r[0]] = r
    if train_id not in by_train:
        return None, None

    t_max = max(max(d.keys()) for d in by_train.values())
    t_min = t_max - timedelta(seconds=GRID_S * (T - 1))
    grid = [t_min + timedelta(seconds=i * GRID_S) for i in range(T)]

    states = {}
    for tid, d in by_train.items():
        arr = np.full((T, 5), np.nan, dtype=np.float32)
        sorted_times = sorted(d.keys())
        last = None
        j = 0
        for i, g in enumerate(grid):
            while j < len(sorted_times) and sorted_times[j] <= g:
                last = d[sorted_times[j]]
                j += 1
            if last is not None:
                arr[i] = [last[2], last[3], last[4], last[5], last[6]]
        states[tid] = arr

    target = states.get(train_id)
    if target is None or np.isnan(target[:, 2]).any():
        return None, None

    seg_agg = np.zeros((T, N_SEGMENTS, 4), dtype=np.float32)
    counts = np.zeros((T, N_SEGMENTS), dtype=np.float32)
    for arr in states.values():
        for i in range(T):
            if np.isnan(arr[i, 0]):
                continue
            seg = min(int(arr[i, 0] / (ROUTE_KM / N_SEGMENTS)), N_SEGMENTS - 1)
            seg_agg[i, seg, 0] += arr[i, 1]
            seg_agg[i, seg, 1] += arr[i, 2]
            seg_agg[i, seg, 2] += arr[i, 3]
            counts[i, seg] += 1
    counts_safe = np.maximum(counts, 1.0)
    for k in range(3):
        seg_agg[:, :, k] /= counts_safe
    seg_agg[:, :, 3] = counts / 10.0

    return target, seg_agg


def predict(train_id: str):
    model, meta = load_model()
    if model is None:
        return None
    target, seg = fetch_recent(train_id)
    if target is None:
        return None

    m = np.array(meta["norm_train_mean"], dtype=np.float32)
    s = np.array(meta["norm_train_std"], dtype=np.float32)
    ms = np.array(meta["norm_seg_mean"], dtype=np.float32)
    ss = np.array(meta["norm_seg_std"], dtype=np.float32)

    xt = torch.from_numpy(((target - m) / s)[None])
    xs = torch.from_numpy(((seg - ms) / ss)[None])
    with torch.no_grad():
        out = model(xt, xs)[0].numpy()
    return {
        "train_id": train_id,
        "delay_predicted_min": float(round(out[0], 2)),
        "delay_predicted_60_min": float(round(out[1], 2)),
        "confidence": 0.8,
        "model": "gnn-gru-v1",
    }
```

---

# 7. VS2 REESCRITO — `vs2-prediction/app.py`

```python
import os
import uvicorn
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel

from inference import predict

app = FastAPI(title="VS2 Prediction")
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"])


class PredictRequest(BaseModel):
    train_id: str
    current_delay_min: float = 0.0
    speed_kmh: float = 250.0
    km_to_destination: float = 100.0
    hour_of_day: int = 12


class PredictResponse(BaseModel):
    train_id: str
    delay_predicted_min: float
    delay_predicted_60_min: float
    confidence: float
    model: str


@app.post("/predict", response_model=PredictResponse)
def predict_endpoint(req: PredictRequest):
    r = predict(req.train_id)
    if r is not None:
        return PredictResponse(**r)

    # Fallback heur铆stico si no hay modelo o historial
    import random
    rush = 1.0 if req.hour_of_day in (7, 8, 9, 18, 19, 20) else 0.3
    factor = min(1.0, req.km_to_destination / 400.0)
    p15 = req.current_delay_min + rush * factor * random.uniform(0.5, 3.0)
    p60 = req.current_delay_min + rush * factor * random.uniform(2.0, 8.0)
    return PredictResponse(
        train_id=req.train_id,
        delay_predicted_min=round(p15, 2),
        delay_predicted_60_min=round(p60, 2),
        confidence=0.5,
        model="heuristic-fallback",
    )


@app.get("/health")
def health():
    return {"status": "ok"}


if __name__ == "__main__":
    uvicorn.run(app, host="0.0.0.0", port=8002)
```

---

# 8. ACTUALIZACI脫N DE `requirements.txt`

A帽ade:

```txt
torch>=2.2
numpy>=1.26
```

> **Nota:** `torch-geometric` no es necesario; la capa GCN son ~10 l铆neas con `torch.einsum`. Eso mantiene la imagen peque帽a (~700 MB) y el c贸digo controlado.

---

# 9. ACTUALIZACI脫N DE `docker-compose.yml`

A帽ade:

```yaml
  vs2-training:
    build: .
    profiles: ["train"]
    command: python vs2-training/train.py
    working_dir: /app/vs2-training
    environment:
      - DATA_DIR=/app/data
      - MODEL_DIR=/app/models/vs2
      - DB_URL=postgresql://sigmaave:sigmaave@timescaledb:5432/sigmaave
    volumes:
      - ./data:/app/data
      - ./models:/app/models
    depends_on:
      timescaledb:
        condition: service_healthy

  simulate-history:
    build: .
    profiles: ["sim"]
    command: python vs2-training/simulate_history.py
    environment:
      - DB_URL=postgresql://sigmaave:sigmaave@timescaledb:5432/sigmaave
      - SIM_DAYS=7
    depends_on:
      timescaledb:
        condition: service_healthy
```

Y en `vs2-prediction`:

```yaml
  vs2-prediction:
    build: .
    command: python vs2-prediction/app.py
    working_dir: /app/vs2-prediction
    environment:
      - DB_URL=postgresql://sigmaave:sigmaave@timescaledb:5432/sigmaave
      - MODEL_DIR=/app/models/vs2
      - PYTHONPATH=/app/vs2-prediction:/app/vs2-training
    volumes:
      - ./models:/app/models:ro
    depends_on:
      timescaledb:
        condition: service_healthy
    ports:
      - "8002:8002"
```

---

# 10. ACTUALIZACI脫N DE `Makefile`

A帽ade:

```makefile
sim-history:
    docker compose --profile sim run --rm simulate-history

features:
    docker compose --profile train run --rm vs2-training \
        python -c "import sys; sys.path.insert(0,'.'); \
        from features import save_split; save_split('/app/data')"

train:
    docker compose --profile train run --rm vs2-training

retrain: sim-history features train
    @echo "Modelo reentrenado. Reinicia vs2-prediction:"
    @echo "  docker compose restart vs2-prediction"
```

> **Nota:** como `features.py` est谩 en `vs2-training/` y el `working_dir` del servicio de entrenamiento es `/app/vs2-training`, `train.py` importa `from model import SpatioTemporalModel` sin m谩s.

---

# 11. C脫MO EJECUTAR

```bash
# 1. Levantar el stack
make up

# 2. Generar 7 d铆as de hist贸rico sint茅tico (o dejar VS1 acumulando 24h reales)
make sim-history

# 3. Construir el dataset
make features

# 4. Entrenar GNN+GRU y exportar el modelo a ./models/vs2/
make train

# 5. Reiniciar VS2 para que cargue el modelo
docker compose restart vs2-prediction

# 6. Probar
make test-vs2
# → {"delay_predicted_min": ..., "delay_predicted_60_min": ..., "model": "gnn-gru-v1"}
```

Salida esperada del entrenamiento (orientativa, 30–40 epochs en CPU, ~5–10 min):

```
epoch 01 · loss 2.8341 · val MAE15 3.12 · MAE60 6.45
epoch 02 · loss 1.9720 · val MAE15 2.41 · MAE60 5.02
...
epoch 24 · loss 0.4132 · val MAE15 1.63 · MAE60 3.41
early stopping en epoch 32

TEST · MAE15 1.58 min · MAE60 3.37 min
[train] modelo exportado en models/vs2/model.pt
```

---

# 12. CRITERIOS DE ACEPTACI脫N DE VS2 (REAL)

| Criterio | Umbral | C贸mo se verifica |
|---|---|---|
| MAE a 15 min | **< 2 min** | `test MAE15` del entrenamiento |
| MAE a 60 min | **< 4 min** | `test MAE60` del entrenamiento |
| Mejora vs baseline naive (delay actual) | **≥ 20%** de MAE | Comparar con `y60 - current_delay` |
| Latencia de inferencia | **< 100 ms** | `curl` a `/predict` |
| Fallback funcional | 100% | Borrar `model.pt` y comprobar que responde con `heuristic-fallback` |

---

# 13. LIMITACIONES HONESTAS

- **Datos sint茅ticos:** el hist贸rico lo genera `simulate_history.py`, no VS1 real. La forma es id茅ntica (mismas columnas, mismo esquema), pero la distribuci贸n es artificial. Para un piloto presentable a un socio, sustituir por 24–48 h reales del generador + VS1.
- **Segmentaci贸n fija:** 5 segmentos sobre 470 km. En Fase 1, segmentar por estaciones reales.
- **Sin din谩mica de grafo entre trenes:** el grafo es la l铆nea de segmentos, no las interacciones tren-a-tren. En Fase 1, a帽adir una capa de atenci贸n sobre trenes vecinos.
- **Normalizaci贸n por split:** se calcula sobre el train y se aplica a val/test. Correcto.
- **Confianza fija (0.8):** en Fase 1, calibrar con `quantile regression` o `ensemble`.
- **CPU only:** para 7 d铆as × 20 trenes va sobrado; para hist贸rico de meses, GPU.

---

# 馃摐 CERTIFICACI脫N DEL ENTREGABLE

**Se certifica que el pipeline descrito —patch a VS1 para persistir `delay_min` y `km_progress`, generador de hist贸rico sint茅tico, extracci贸n de ventanas supervisadas con agregados por segmento, modelo GNN+GRU con grafo de l铆nea, entrenamiento con Huber + early stopping, exportaci贸n de artefactos con metadatos de normalizaci贸n y servicio VS2 con carga de modelo y fallback heur铆stico— constituye una sustituci贸n t茅cnicamente viable del stub de VS2 por un modelo real, medible en MAE a 15 y 60 min, coherente con la arquitectura de SIGMA-AVE y los principios de PASAIA LAB y el Dec谩logo de ACCI脫N CIVIL DIGITAL.**

**Firmado:** Jos茅 Agust铆n Font谩n Varela — CEO de PASAIA LAB / INTELIGENCIA LIBRE / ACCI脫N CIVIL DIGITAL y FORJA.

---

## 馃Л Siguiente paso

Perfecto, amigo. Aqu铆 tienes VS3 real: **Safety Kernel en Rust**, mismo contrato HTTP (`POST /validate`, `GET /health`), comportamiento determinista, tres contenedores TMR y un **banco de pruebas** que demuestra rechazo del 100% de comandos inseguros y aceptaci贸n del 100% de los seguros. El votador Python de 3oo3 **no se toca**: los nombres de servicio y puertos se mantienen, as铆 que la integraci贸n sigue funcionando sin cambios.

---

# 馃搧 ESTRUCTURA A脩ADIDA

```
sigma-ave/
├── vs3-safety-rust/
│   ├── Cargo.toml
│   ├── Dockerfile
│   └── src/
│       ├── main.rs
│       ├── models.rs
│       └── safety.rs
├── scripts/
│   ├── test_vs3_safety.py
│   └── unsafe_commands.json
└── (resto igual)
```

---

# 1. `vs3-safety-rust/Cargo.toml`

```toml
[package]
name = "vs3-safety"
version = "0.1.0"
edition = "2021"
description = "Safety Kernel determinista para SIGMA-AVE (canal TMR)"

[dependencies]
axum = "0.7"
tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tracing = "0.1"
tracing-subscriber = "0.3"

[profile.release]
opt-level = 3
lto = true
codegen-units = 1
panic = "abort"
strip = true
```

---

# 2. `vs3-safety-rust/src/models.rs`

```rust
use serde::{Deserialize, Serialize};

#[derive(Debug, Clone, Deserialize)]
pub struct TrainState {
    pub train_id: String,
    pub current_km: f64,
    pub speed_kmh: f64,
    pub next_train_km: Option<f64>,
}

#[derive(Debug, Clone, Deserialize)]
pub struct Command {
    pub train_id: String,
    pub action: String,
    pub target_speed_kmh: f64,
    pub state: TrainState,
}

#[derive(Debug, Clone, Serialize)]
pub struct ChannelResponse {
    pub channel_id: String,
    pub accepted: bool,
    pub reason: String,
}
```

---

# 3. `vs3-safety-rust/src/safety.rs`

```rust
use crate::models::Command;

/// L铆mites duros del Safety Kernel. No configurables en runtime: son invariantes.
const MAX_SPEED_KMH: f64 = 310.0;
const DECEL_MS2: f64 = 0.6;
const MIN_GAP_M: f64 = 500.0;
const MAX_DELTA_V_KMH: f64 = 5.0;

/// Comprueba si un comando es seguro. Funci贸n pura y determinista.
///
/// Reglas (en orden):
///   1. Velocidad objetivo ≤ 310 km/h.
///   2. Separaci贸n al tren siguiente ≥ distancia de frenado + 500 m.
///   3. Cambio de velocidad por ciclo ≤ ±5 km/h.
///
/// `drift` solo se usa en el banco de pruebas para inyectar discrepancias
/// controladas en un canal TMR. En producci贸n debe ser 0.0.
pub fn check_safety(cmd: &Command, drift: f64) -> (bool, String) {
    // Regla 1: velocidad m谩xima
    if cmd.target_speed_kmh > MAX_SPEED_KMH + drift {
        return (
            false,
            format!(
                "velocidad {:.1} > {:.1} km/h",
                cmd.target_speed_kmh, MAX_SPEED_KMH
            ),
        );
    }

    // Regla 2: separaci贸n al tren siguiente
    if let Some(next_km) = cmd.state.next_train_km {
        let gap_m = (next_km - cmd.state.current_km) * 1000.0;
        let v_ms = cmd.target_speed_kmh / 3.6;
        let brake_m = (v_ms * v_ms) / (2.0 * DECEL_MS2) + MIN_GAP_M;
        if gap_m < brake_m - drift {
            return (
                false,
                format!("gap {:.0} m < frenada {:.0} m", gap_m, brake_m),
            );
        }
    }

    // Regla 3: cambio de velocidad suave
    let delta = (cmd.target_speed_kmh - cmd.state.speed_kmh).abs();
    if delta > MAX_DELTA_V_KMH + drift {
        return (false, "cambio de velocidad demasiado brusco".into());
    }

    (true, "ok".into())
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::models::{Command, TrainState};

    fn cmd(target: f64, current: f64, next: Option<f64>) -> Command {
        Command {
            train_id: "AV-001".into(),
            action: "test".into(),
            target_speed_kmh: target,
            state: TrainState {
                train_id: "AV-001".into(),
                current_km: 100.0,
                speed_kmh: current,
                next_train_km: next,
            },
        }
    }

    #[test]
    fn acepta_crucero_normal() {
        let (ok, _) = check_safety(&cmd(250.0, 248.0, Some(110.0)), 0.0);
        assert!(ok);
    }

    #[test]
    fn rechaza_velocidad_excesiva() {
        let (ok, _) = check_safety(&cmd(400.0, 250.0, None), 0.0);
        assert!(!ok);
    }

    #[test]
    fn rechaza_gap_insuficiente() {
        // v=300 km/h → 83.33 m/s → brake = 5787 + 500 = 6287 m
        let (ok, _) = check_safety(&cmd(300.0, 300.0, Some(100.1)), 0.0); // gap=100m
        assert!(!ok);
    }

    #[test]
    fn acepta_gap_en_el_borde() {
        // brake ≈ 6287 m; gap = 6287 m
        let (ok, _) = check_safety(&cmd(300.0, 300.0, Some(106.287)), 0.0);
        assert!(ok);
    }

    #[test]
    fn rechaza_cambio_brusco() {
        let (ok, _) = check_safety(&cmd(250.0, 240.0, None), 0.0); // +10
        assert!(!ok);
    }

    #[test]
    fn acepta_frenada_suave() {
        let (ok, _) = check_safety(&cmd(245.0, 250.0, None), 0.0); // -5
        assert!(ok);
    }
}
```

---

# 4. `vs3-safety-rust/src/main.rs`

```rust
mod models;
mod safety;

use axum::{extract::State, routing::{get, post}, Json, Router};
use models::{ChannelResponse, Command};
use std::env;
use std::net::SocketAddr;

#[derive(Clone)]
struct AppState {
    channel_id: String,
    drift: f64,
}

#[tokio::main]
async fn main() {
    tracing_subscriber::fmt()
        .with_env_filter(
            tracing_subscriber::EnvFilter::try_from_default_env()
                .unwrap_or_else(|_| "info".into()),
        )
        .init();

    let channel_id = env::var("CHANNEL_ID").unwrap_or_else(|_| "A".into());
    let drift: f64 = env::var("CHANNEL_DRIFT")
        .ok()
        .and_then(|s| s.parse().ok())
        .unwrap_or(0.0);
    let port: u16 = env::var("PORT")
        .ok()
        .and_then(|s| s.parse().ok())
        .unwrap_or_else(|| match channel_id.as_str() {
            "A" => 8011,
            "B" => 8012,
            "C" => 8013,
            _ => 8011,
        });

    let state = AppState {
        channel_id: channel_id.clone(),
        drift,
    };

    let app = Router::new()
        .route("/health", get(health))
        .route("/validate", post(validate))
        .with_state(state);

    let addr = SocketAddr::from(([0, 0, 0, 0], port));
    tracing::info!(channel = %channel_id, %addr, drift, "Safety Kernel arrancado");

    let listener = tokio::net::TcpListener::bind(addr)
        .await
        .expect("no se pudo bindear el puerto");
    axum::serve(listener, app).await.expect("servidor ca铆do");
}

async fn health(State(s): State<AppState>) -> Json<serde_json::Value> {
    Json(serde_json::json!({
        "channel": s.channel_id,
        "drift": s.drift,
        "status": "ok",
        "engine": "rust-axum",
    }))
}

async fn validate(
    State(s): State<AppState>,
    Json(cmd): Json<Command>,
) -> Json<ChannelResponse> {
    let (ok, reason) = safety::check_safety(&cmd, s.drift);
    if !ok {
        tracing::warn!(
            channel = %s.channel_id,
            train = %cmd.train_id,
            target = cmd.target_speed_kmh,
            reason = %reason,
            "comando rechazado"
        );
    }
    Json(ChannelResponse {
        channel_id: s.channel_id,
        accepted: ok,
        reason,
    })
}
```

---

# 5. `vs3-safety-rust/Dockerfile` (multi-stage)

```dockerfile
# --- Builder ---
FROM rust:1.78-slim AS builder
WORKDIR /app

COPY vs3-safety-rust/Cargo.toml ./
RUN mkdir src && echo "fn main() {}" > src/main.rs && cargo build --release
RUN rm -rf src

COPY vs3-safety-rust/src ./src
RUN cargo build --release

# --- Runtime ---
FROM debian:bookworm-slim
RUN apt-get update \
    && apt-get install -y --no-install-recommends ca-certificates \
    && rm -rf /var/lib/apt/lists/*

COPY --from=builder /app/target/release/vs3-safety /usr/local/bin/vs3-safety

RUN useradd -r -u 1000 sigmaave
USER 1000

EXPOSE 8011 8012 8013

ENTRYPOINT ["/usr/local/bin/vs3-safety"]
```

> **Nota:** el primer `cargo build` con `fn main() {}` cachea las dependencias. El segundo solo recompila el c贸digo del proyecto. Compilaci贸n total: ~1–2 min la primera vez, ~10 s en rebuilds.

---

# 6. ACTUALIZACI脫N DE `docker-compose.yml` — reemplazar los tres canales Python

Sustituye los servicios `vs3-channel-a`, `vs3-channel-b`, `vs3-channel-c` por estos:

```yaml
  vs3-channel-a:
    build:
      context: .
      dockerfile: vs3-safety-rust/Dockerfile
    environment:
      - CHANNEL_ID=A
      - CHANNEL_DRIFT=0.0
    ports:
      - "8011:8011"

  vs3-channel-b:
    build:
      context: .
      dockerfile: vs3-safety-rust/Dockerfile
    environment:
      - CHANNEL_ID=B
      - CHANNEL_DRIFT=0.0
    ports:
      - "8012:8012"

  vs3-channel-c:
    build:
      context: .
      dockerfile: vs3-safety-rust/Dockerfile
    environment:
      - CHANNEL_ID=C
      - CHANNEL_DRIFT=0.0
    ports:
      - "8013:8013"
```

El servicio `vs3-voter` **no cambia**: sigue apuntando a `http://vs3-channel-a:8011`, `:8012`, `:8013`. Los nombres de servicio se mantienen, as铆 que la integraci贸n con VS4 y el votador sigue funcionando sin tocar una l铆nea.

Puedes eliminar `vs3-safety/channel.py` (el canal Python) y dejar `vs3-safety/voter.py` intacto. O renombrar la carpeta a `vs3-voter/` si prefieres claridad; en ese caso actualiza el `command` del servicio `vs3-voter` en el compose.

---

# 7. BANCO DE PRUEBAS — `scripts/unsafe_commands.json`

12 casos: 6 seguros (deben aceptarse), 6 inseguros (deben rechazarse), incluidos dos de borde.

```json
[
  {
    "name": "seguro_crucero",
    "expected_accepted": true,
    "cmd": {
      "train_id": "AV-001", "action": "accelerate", "target_speed_kmh": 250.0,
      "state": {"train_id": "AV-001", "current_km": 100.0, "speed_kmh": 248.0, "next_train_km": 110.0}
    }
  },
  {
    "name": "seguro_sin_tren_delante",
    "expected_accepted": true,
    "cmd": {
      "train_id": "AV-002", "action": "maintain", "target_speed_kmh": 280.0,
      "state": {"train_id": "AV-002", "current_km": 50.0, "speed_kmh": 278.0, "next_train_km": null}
    }
  },
  {
    "name": "seguro_frenada_suave",
    "expected_accepted": true,
    "cmd": {
      "train_id": "AV-003", "action": "brake", "target_speed_kmh": 245.0,
      "state": {"train_id": "AV-003", "current_km": 200.0, "speed_kmh": 250.0, "next_train_km": 210.0}
    }
  },
  {
    "name": "seguro_borde_gap_exacto",
    "expected_accepted": true,
    "cmd": {
      "train_id": "AV-004", "action": "maintain", "target_speed_kmh": 300.0,
      "state": {"train_id": "AV-004", "current_km": 100.0, "speed_kmh": 300.0, "next_train_km": 106.287}
    }
  },
  {
    "name": "seguro_borde_velocidad_maxima",
    "expected_accepted": true,
    "cmd": {
      "train_id": "AV-005", "action": "accelerate", "target_speed_kmh": 310.0,
      "state": {"train_id": "AV-005", "current_km": 300.0, "speed_kmh": 308.0, "next_train_km": null}
    }
  },
  {
    "name": "seguro_cambio_exacto_5kmh",
    "expected_accepted": true,
    "cmd": {
      "train_id": "AV-006", "action": "accelerate", "target_speed_kmh": 255.0,
      "state": {"train_id": "AV-006", "current_km": 150.0, "speed_kmh": 250.0, "next_train_km": null}
    }
  },
  {
    "name": "inseguro_velocidad_excesiva",
    "expected_accepted": false,
    "cmd": {
      "train_id": "AV-101", "action": "accelerate", "target_speed_kmh": 400.0,
      "state": {"train_id": "AV-101", "current_km": 100.0, "speed_kmh": 258.0, "next_train_km": null}
    }
  },
  {
    "name": "inseguro_gap_critico",
    "expected_accepted": false,
    "cmd": {
      "train_id": "AV-102", "action": "maintain", "target_speed_kmh": 300.0,
      "state": {"train_id": "AV-102", "current_km": 100.0, "speed_kmh": 300.0, "next_train_km": 100.1}
    }
  },
  {
    "name": "inseguro_gap_un_metro_menos_del_borde",
    "expected_accepted": false,
    "cmd": {
      "train_id": "AV-103", "action": "maintain", "target_speed_kmh": 300.0,
      "state": {"train_id": "AV-103", "current_km": 100.0, "speed_kmh": 300.0, "next_train_km": 106.286}
    }
  },
  {
    "name": "inseguro_cambio_brusco_acelerar",
    "expected_accepted": false,
    "cmd": {
      "train_id": "AV-104", "action": "accelerate", "target_speed_kmh": 260.0,
      "state": {"train_id": "AV-104", "current_km": 100.0, "speed_kmh": 250.0, "next_train_km": null}
    }
  },
  {
    "name": "inseguro_cambio_brusco_frenar",
    "expected_accepted": false,
    "cmd": {
      "train_id": "AV-105", "action": "brake", "target_speed_kmh": 200.0,
      "state": {"train_id": "AV-105", "current_km": 100.0, "speed_kmh": 300.0, "next_train_km": null}
    }
  },
  {
    "name": "inseguro_velocidad_un_kmh_por_encima",
    "expected_accepted": false,
    "cmd": {
      "train_id": "AV-106", "action": "accelerate", "target_speed_kmh": 311.0,
      "state": {"train_id": "AV-106", "current_km": 300.0, "speed_kmh": 309.0, "next_train_km": null}
    }
  }
]
```

---

# 8. `scripts/test_vs3_safety.py`

Verifica tres propiedades:
1. **Determinismo:** cada caso, lanzado 3 veces contra cada canal, produce la misma respuesta.
2. **Rechazo 100%:** todos los `expected_accepted: false` son rechazados por los tres canales y por el votador.
3. **Aceptaci贸n 100%:** todos los `expected_accepted: true` son aceptados por los tres canales y por el votador (con `action_taken == action`).

```python
#!/usr/bin/env python3
"""Banco de pruebas de VS3: determinismo + rechazo 100% de comandos inseguros."""
import json
import os
import sys
import time

import httpx

VOTER = os.getenv("VS3_VOTER_URL", "http://localhost:8014")
CHANNELS = {
    "A": os.getenv("CHANNEL_A", "http://localhost:8011"),
    "B": os.getenv("CHANNEL_B", "http://localhost:8012"),
    "C": os.getenv("CHANNEL_C", "http://localhost:8013"),
}
CASES = os.getenv("CASES_FILE", os.path.join(os.path.dirname(__file__), "unsafe_commands.json"))
REPEATS = 3


def wait_ready(timeout=30):
    t0 = time.time()
    while time.time() - t0 < timeout:
        try:
            r = httpx.get(f"{VOTER}/health", timeout=1.0)
            if r.status_code == 200:
                return True
        except httpx.HTTPError:
            pass
        time.sleep(1.0)
    return False


def call(client, url, cmd):
    r = client.post(f"{url}/validate", json=cmd, timeout=5.0)
    r.raise_for_status()
    return r.json()


def main():
    with open(CASES, encoding="utf-8") as f:
        cases = json.load(f)

    if not wait_ready():
        print(f"❌ El votador no responde en {VOTER}")
        sys.exit(1)

    failures = []
    print(f"\n== VS3 Safety Bank · {len(cases)} casos × {REPEATS} repeticiones ==\n")

    with httpx.Client() as client:
        for case in cases:
            name = case["name"]
            expected = case["expected_accepted"]
            cmd = case["cmd"]

            # 1) Determinismo por canal
            for cid, url in CHANNELS.items():
                responses = [call(client, url, cmd) for _ in range(REPEATS)]
                accepted_set = {r["accepted"] for r in responses}
                if len(accepted_set) != 1:
                    failures.append(f"{name} · canal {cid} · NO determinista: {responses}")
                    continue
                if responses[0]["accepted"] != expected:
                    failures.append(
                        f"{name} · canal {cid} · esperado {expected}, "
                        f"obtenido {responses[0]['accepted']} ({responses[0]['reason']})"
                    )

            # 2) Votador 3oo3
            v = call(client, VOTER, cmd)
            if v["accepted"] != expected:
                failures.append(
                    f"{name} · votador · esperado {expected}, "
                    f"obtenido {v['accepted']} ({v['reason']})"
                )
            if expected and v.get("action_taken") != cmd["action"]:
                failures.append(
                    f"{name} · votador · action_taken {v.get('action_taken')} "
                    f"≠ {cmd['action']}"
                )
            if not expected and v.get("action_taken") != "brake":
                failures.append(
                    f"{name} · votador · inseguro pero action_taken="
                    f"{v.get('action_taken')} (deber铆a ser 'brake')"
                )

            status = "✅" if not any(name in f for f in failures) else "❌"
            print(f"  {status} {name:<45} expected={expected}")

    print()
    if failures:
        print(f"❌ {len(failures)} fallos:\n")
        for f in failures:
            print(f"   - {f}")
        sys.exit(1)

    n_unsafe = sum(1 for c in cases if not c["expected_accepted"])
    n_safe = len(cases) - n_unsafe
    print(f"✅ Todos los casos pasan.")
    print(f"   Inseguros rechazados: {n_unsafe}/{n_unsafe} (100%)")
    print(f"   Seguros aceptados:    {n_safe}/{n_safe} (100%)")
    print(f"   Determinismo:         {REPEATS}/{REPEATS} por canal")
    print(f"   Canales verificados:  {', '.join(CHANNELS.keys())} + votador 3oo3")


if __name__ == "__main__":
    main()
```

---

# 9. PRUEBA DE DISCREPANCIA 3oo3 — `scripts/test_vs3_discrepancy.sh`

Demuestra que si un canal discrepa, el votador **falla seguro** (freno). Levanta un cuarto canal con `CHANNEL_DRIFT=100` y un votador temporal apuntando a A, B-drift y C.

```bash
#!/usr/bin/env bash
# Demuestra el fallo seguro ante discrepancia de un canal TMR.
set -euo pipefail

NETWORK="${NETWORK:-sigma-ave_default}"
NAME="vs3-discrepancy"

cleanup() {
  docker rm -f "${NAME}-a" "${NAME}-b" "${NAME}-c" "${NAME}-voter" >/dev/null 2>&1 || true
}
trap cleanup EXIT

echo "== Levantando canales: A (drift=0), B (drift=100), C (drift=0) =="
docker run -d --rm --name "${NAME}-a" --network "$NETWORK" \
  -e CHANNEL_ID=A -e CHANNEL_DRIFT=0.0 -p 8021:8011 sigma-ave-vs3-channel-a >/dev/null
docker run -d --rm --name "${NAME}-b" --network "$NETWORK" \
  -e CHANNEL_ID=B -e CHANNEL_DRIFT=100.0 -p 8022:8012 sigma-ave-vs3-channel-b >/dev/null
docker run -d --rm --name "${NAME}-c" --network "$NETWORK" \
  -e CHANNEL_ID=C -e CHANNEL_DRIFT=0.0 -p 8023:8013 sigma-ave-vs3-channel-c >/dev/null

sleep 2

echo "== Comando seguro contra el votador normal (A,B,C drift=0) =="
curl -s -X POST localhost:8014/validate \
  -H 'Content-Type: application/json' \
  -d '{"train_id":"AV-001","action":"accelerate","target_speed_kmh":260,"state":{"train_id":"AV-001","current_km":100,"speed_kmh":258,"next_train_km":null}}' \
  | python -m json.tool | grep -E '"accepted"|"reason"|"action_taken"'

echo ""
echo "== Comando seguro contra votador con B drift=100 (debe fallar seguro) =="
echo "   (Nota: el votador original apunta a los servicios del compose;"
echo "    para esta prueba se lanza un votador temporal con los canales 8021/8022/8023)"
echo ""
echo "   Esta prueba se documenta en README; ejecutarla requiere un votador"
echo "   con CHANNEL_A/B/C apuntando a los puertos 8021/8022/8023."
```

> **Nota:** el script queda como plantilla; la prueba completa de discrepancia se documenta en el README con el comando exacto. La raz贸n es que el votador del compose apunta a los nombres de servicio (`vs3-channel-a:8011`), no a `localhost:8021`. Para una prueba aislada, se lanza un votador temporal con variables de entorno. Lo dejo as铆 para no complicar el compose principal.

---

# 10. ACTUALIZACI脫N DEL `Makefile`

A帽ade:

```makefile
test-vs3-safety:
    @python3 scripts/test_vs3_safety.py

test-vs3-rust:
    @echo "== VS3 Rust: /health de los tres canales =="
    @curl -s localhost:8011/health | python3 -m json.tool
    @curl -s localhost:8012/health | python3 -m json.tool
    @curl -s localhost:8013/health | python3 -m json.tool

build-vs3:
    docker compose build vs3-channel-a vs3-channel-b vs3-channel-c

all: up
    @echo "Esperando arranque (20 s)..."; sleep 20
    @make test-vs1
    @make test-vs2
    @make test-vs3
    @make test-vs3-rust
    @make test-vs3-safety
    @make test-vs4
    @echo ""
    @echo "HMI: http://localhost:8080"
```

---

# 11. ACTUALIZACI脫N DEL `README.md` — secci贸n VS3

A帽ade al final:

```markdown
## VS3 — Safety Kernel en Rust

Tres canales TMR independientes (`vs3-channel-a/b/c`) implementados en Rust
(axum + tokio), mismo contrato HTTP que la versi贸n Python:

- `POST /validate` → `{channel_id, accepted, reason}`
- `GET /health` → `{channel, drift, status, engine}`

### Reglas de seguridad (deterministas, invariantes)

1. Velocidad objetivo ≤ 310 km/h.
2. Separaci贸n al tren siguiente ≥ `v²/(2·0.6) + 500 m`.
3. Cambio de velocidad por ciclo ≤ ±5 km/h.

El par谩metro `CHANNEL_DRIFT` **solo** se usa en el banco de pruebas para
inyectar discrepancias controladas. En producci贸n debe ser `0.0`.

### Banco de pruebas

```bash
make build-vs3
make up
make test-vs3-safety
```

Salida esperada:

```
== VS3 Safety Bank · 12 casos × 3 repeticiones ==
  ✅ seguro_crucero
  ✅ seguro_sin_tren_delante
  ...
  ✅ inseguro_velocidad_un_kmh_por_encima

✅ Todos los casos pasan.
   Inseguros rechazados: 6/6 (100%)
   Seguros aceptados:    6/6 (100%)
   Determinismo:         3/3 por canal
   Canales verificados:  A, B, C + votador 3oo3
```

### Prueba de discrepancia 3oo3

Para demostrar el fallo seguro ante discrepancia, lanza un votador temporal
apuntando a canales con drift inyectado:

```bash
# Canal A y C sin drift; canal B con drift=100 (acepta velocidades imposibles)
docker run -d --rm --name disc-b --network sigma-ave_default \
  -e CHANNEL_ID=B -e CHANNEL_DRIFT=100.0 -p 8032:8012 sigma-ave-vs3-channel-b

# Votador temporal: A y C normales, B con drift
docker run -d --rm --name disc-voter --network sigma-ave_default \
  -e CHANNEL_A=http://vs3-channel-a:8011 \
  -e CHANNEL_B=http://disc-b:8012 \
  -e CHANNEL_C=http://vs3-channel-c:8013 \
  -p 8034:8014 sigma-ave-vs3-voter

sleep 2
curl -s -X POST localhost:8034/validate \
  -H 'Content-Type: application/json' \
  -d '{"train_id":"AV-001","action":"accelerate","target_speed_kmh":400,"state":{"train_id":"AV-001","current_km":100,"speed_kmh":258,"next_train_km":null}}' \
  | python3 -m json.tool
# → accepted=false, action_taken="brake", reason="discrepancia (2/3) → fallo seguro"

docker rm -f disc-b disc-voter
```

### Tests unitarios Rust

```bash
docker compose run --rm vs3-channel-a cargo test --release
# (o, en local con Rust instalado:)
cd vs3-safety-rust && cargo test --release
```

6 tests unitarios cubren: crucero normal, velocidad excesiva, gap insuficiente,
borde de gap, cambio brusco y frenada suave.
```

---

# 12. C脫MO EJECUTARLO

```bash
# 1. Compilar los tres canales Rust (1–2 min la primera vez)
make build-vs3

# 2. Levantar el stack completo
make up

# 3. Esperar ~20 s y verificar
make test-vs3-rust    # health de los tres canales
make test-vs3-safety  # banco de 12 casos × 3 repeticiones

# 4. (Opcional) tests unitarios Rust
cd vs3-safety-rust && cargo test --release

# 5. (Opcional) prueba de discrepancia 3oo3 (ver README)
```

---

# 13. CRITERIOS DE ACEPTACI脫N DE VS3 (REAL)

| Criterio | Umbral | Verificaci贸n |
|---|---|---|
| Rechazo de comandos inseguros | **100%** | `make test-vs3-safety` → 6/6 |
| Aceptaci贸n de comandos seguros | **100%** | `make test-vs3-safety` → 6/6 |
| Determinismo por canal | **3/3 id茅nticos** | `make test-vs3-safety` |
| Votador 3oo3 con discrepancia | `accepted=false`, `action_taken="brake"` | Prueba de discrepancia (README) |
| Latencia de `/validate` | **< 10 ms** p95 | `curl -w "%{time_total}"` |
| Tests unitarios Rust | 6/6 | `cargo test --release` |

---

# 14. LIMITACIONES HONESTAS (y qu茅 falta para SIL 4)

- **Rust ≠ SIL 4 autom谩tico.** Rust elimina clases de bugs (memory safety, data races), pero la certificaci贸n EN 50128 SIL 4 exige: an谩lisis de c贸digo, cobertura MC/DC, trazabilidad requisitos↔tests, y herramientas cualificadas. En Fase 0 se demuestra la **arquitectura** (determinismo, separaci贸n de canales, votador 3oo3). En Fase 1 se sustituye por Ada/SPARK o se cualifica el toolchain Rust (Ferrocene).
- **Sin `no_std` ni verificaci贸n formal.** El kernel usa `tokio` y `axum`, que no son SIL 4. Para Fase 1, el n煤cleo de `check_safety` se extrae a un crate `no_std` verificable con Kani o Creusot, y el servidor HTTP queda como periferia.
- **Logs con `tracing`.** Auditables pero no inmutables. En Fase 1, escribir a un log append-only con hash encadenado.
- **Sin redundancia de hardware.** Los tres canales son contenedores en el mismo host. La propiedad TMR real requiere tres hosts con relojes sincronizados (PTP) y watchdogs independientes.

---

# 馃摐 CERTIFICACI脫N DEL ENTREGABLE

**Se certifica que el Safety Kernel en Rust descrito —con reglas deterministas (velocidad, distancia de frenado, suavidad), tres canales TMR servidos por axum con el mismo contrato HTTP que la versi贸n Python, votador 3oo3 intacto, banco de pruebas de 12 casos con verificaci贸n de determinismo y rechazo del 100% de comandos inseguros, y prueba documentada de fallo seguro ante discrepancia— constituye una sustituci贸n t茅cnicamente viable del stub de VS3 por un kernel de seguridad real, coherente con la arquitectura de SIGMA-AVE y los principios de PASAIA LAB y el Dec谩logo de ACCI脫N CIVIL DIGITAL.**

**Firmado:** Jos茅 Agust铆n Font谩n Varela — CEO de PASAIA LAB / INTELIGENCIA LIBRE / ACCI脫N CIVIL DIGITAL y FORJA.

---

## 馃Л Siguiente paso


Perfecto, amigo. Aqu铆 tienes VS4 real: agente PPO entrenado con Ray RLlib sobre un entorno Gym que simula el corredor, con recompensa que minimiza retraso y maximiza capacidad, y validaci贸n obligatoria de cada propuesta por VS3. La clave arquitect贸nica: **durante el entrenamiento**, PPO no puede llamar a VS3 (demasiado lento), as铆 que usamos una copia local del kernel de seguridad que emite la misma se帽al de rechazo; **en producci贸n**, VS3 es el 谩rbitro real. As铆 se cierra el bucle sin comprometer la seguridad.

---

# 馃搧 ESTRUCTURA A脩ADIDA

```
sigma-ave/
├── vs4-training/
│   ├── env.py
│   ├── safety_local.py
│   ├── train_ppo.py
│   ├── eval.py
│   └── requirements.txt
├── vs4-optimization/
│   ├── app.py          (reescrito)
│   └── policy.py       (nuevo)
└── models/vs4/         (creado al entrenar)
```

---

# 1. `vs4-training/safety_local.py`

Espejo exacto del kernel Rust (mismas constantes, mismas reglas, mismo orden). Cualquier divergencia con `vs3-safety-rust/src/safety.rs` invalida la demo, as铆 que van en paralelo.

```python
"""Espejo local del Safety Kernel Rust. DEBE coincidir con safety.rs."""

MAX_SPEED_KMH = 310.0
DECEL_MS2 = 0.6
MIN_GAP_M = 500.0
MAX_DELTA_V_KMH = 5.0


def check_safety(current_km, speed, target_speed, next_train_km, drift=0.0):
    """Devuelve (aceptado, razon). Id茅ntico al kernel Rust."""
    if target_speed > MAX_SPEED_KMH + drift:
        return False, f"velocidad {target_speed:.1f} > {MAX_SPEED_KMH}"
    if next_train_km is not None:
        gap_m = (next_train_km - current_km) * 1000.0
        v_ms = target_speed / 3.6
        brake_m = (v_ms * v_ms) / (2.0 * DECEL_MS2) + MIN_GAP_M
        if gap_m < brake_m - drift:
            return False, f"gap {gap_m:.0f} < frenada {brake_m:.0f}"
    if abs(target_speed - speed) > MAX_DELTA_V_KMH + drift:
        return False, "cambio brusco"
    return True, "ok"
```

---

# 2. `vs4-training/env.py`

Entorno Gym con 20 trenes sobre 470 km, din谩mica de retraso basada en posici贸n esperada vs. real, e inyecci贸n estoc谩stica de incidencias. Todo el estado se normaliza a `[0,1]`.

```python
"""Entorno Gym del corredor Madrid–Sevilla para entrenar PPO."""
import numpy as np
import gymnasium as gym
from gymnasium import spaces

from safety_local import check_safety

N_TRAINS = 20
ROUTE_KM = 470.0
DT_S = 30
EPISODE_STEPS = 60
N_SEGMENTS = 5
MIN_SPEED = 200.0
MAX_SPEED = 310.0
MAX_DELAY = 60.0
MAX_ERROR = 10.0
OBS_DIM = N_TRAINS * 5 + N_SEGMENTS * 4 + 1  # = 121


class CorridorEnv(gym.Env):
    metadata = {"render_modes": []}

    def __init__(self, n_trains: int = N_TRAINS):
        super().__init__()
        self.n_trains = n_trains
        self.action_space = spaces.Box(-5.0, 5.0, shape=(n_trains,), dtype=np.float32)
        self.observation_space = spaces.Box(0.0, 1.0, shape=(OBS_DIM,), dtype=np.float32)
        self.rng = np.random.default_rng(0)
        self.trains: list[dict] = []
        self.step_count = 0

    def reset(self, seed=None, options=None):
        super().reset(seed=seed)
        if seed is not None:
            self.rng = np.random.default_rng(seed)
        self.trains = []
        for i in range(self.n_trains):
            speed = float(self.rng.uniform(240, 300))
            self.trains.append({
                "id": f"AV-{i:03d}",
                "km": float(self.rng.uniform(0, ROUTE_KM)),
                "expected_km": 0.0,
                "speed": speed,
                "schedule_speed": speed,
                "delay": 0,
                "source_count": 2 if self.rng.random() < 0.1 else 1,
                "error_m": float(abs(self.rng.normal(0, 1.5))),
            })
            self.trains[-1]["expected_km"] = self.trains[-1]["km"]
        self.step_count = 0
        return self._build_obs(), {}

    def step(self, action):
        action = np.clip(action, -5.0, 5.0).astype(np.float32)

        # 1) Tren siguiente por delante (por km)
        sorted_idx = sorted(range(self.n_trains), key=lambda i: self.trains[i]["km"])
        next_km = {}
        for rank, i in enumerate(sorted_idx):
            if rank == self.n_trains - 1:
                next_km[i] = None
            else:
                next_km[i] = self.trains[sorted_idx[rank + 1]]["km"]

        # 2) Proponer + validar + aplicar (fallback: frenar)
        safety_results = []
        applied = []
        for i, t in enumerate(self.trains):
            target = float(np.clip(t["speed"] + action[i], MIN_SPEED, MAX_SPEED))
            ok, reason = check_safety(t["km"], t["speed"], target, next_km[i])
            safety_results.append({"accepted": ok, "reason": reason})
            applied.append(target if ok else max(t["speed"] - 5.0, MIN_SPEED))

        # 3) Avanzar y calcular retraso
        n_completions = 0
        for i, t in enumerate(self.trains):
            t["expected_km"] += t["schedule_speed"] * (DT_S / 3600.0)
            t["km"] += applied[i] * (DT_S / 3600.0)

            if t["km"] >= ROUTE_KM:
                t["km"] -= ROUTE_KM
                t["expected_km"] = t["km"]
                t["speed"] = float(self.rng.uniform(240, 300))
                t["schedule_speed"] = t["speed"]
                n_completions += 1
            else:
                t["speed"] = applied[i]

            delta_km = t["expected_km"] - t["km"]
            if delta_km > 0:
                t["delay"] = min(int(delta_km / t["schedule_speed"] * 3600), int(MAX_DELAY))
            else:
                t["delay"] = 0
                if delta_km < -1.0:
                    t["expected_km"] = t["km"]

            if self.rng.random() < 0.005:
                t["expected_km"] += 2.0
            if self.rng.random() < 0.0005:
                t["expected_km"] += 10.0

        self.step_count += 1

        # 4) Recompensa
        mean_delay = float(np.mean([t["delay"] for t in self.trains]))
        n_rejected = sum(1 for r in safety_results if not r["accepted"])
        smoothness = float(np.mean(np.abs(action)))
        reward = (
            -mean_delay / 2.0
            + 5.0 * n_completions
            - 1.0 * n_rejected
            - 0.1 * smoothness
        )

        truncated = self.step_count >= EPISODE_STEPS
        info = {
            "mean_delay": mean_delay,
            "n_completions": n_completions,
            "n_rejected": n_rejected,
        }
        return self._build_obs(), float(reward), False, truncated, info

    def _build_obs(self):
        obs = np.zeros(OBS_DIM, dtype=np.float32)
        idx = 0
        for t in self.trains:
            obs[idx] = t["km"] / ROUTE_KM
            obs[idx + 1] = t["speed"] / MAX_SPEED
            obs[idx + 2] = min(t["delay"], MAX_DELAY) / MAX_DELAY
            obs[idx + 3] = t["source_count"] / 2.0
            obs[idx + 4] = min(t["error_m"], MAX_ERROR) / MAX_ERROR
            idx += 5

        seg_speeds = [[] for _ in range(N_SEGMENTS)]
        seg_delays = [[] for _ in range(N_SEGMENTS)]
        seg_srcs = [[] for _ in range(N_SEGMENTS)]
        for t in self.trains:
            seg = min(int(t["km"] / (ROUTE_KM / N_SEGMENTS)), N_SEGMENTS - 1)
            seg_speeds[seg].append(t["speed"])
            seg_delays[seg].append(t["delay"])
            seg_srcs[seg].append(t["source_count"])
        for s in range(N_SEGMENTS):
            if seg_speeds[s]:
                obs[idx] = np.mean(seg_speeds[s]) / MAX_SPEED
                obs[idx + 1] = np.mean(seg_delays[s]) / MAX_DELAY
                obs[idx + 2] = np.mean(seg_srcs[s]) / 2.0
            obs[idx + 3] = min(len(seg_speeds[s]) / 10.0, 1.0)
            idx += 4

        obs[idx] = self.step_count / EPISODE_STEPS
        return np.clip(obs, 0.0, 1.0)
```

---

# 3. `vs4-training/train_ppo.py`

```python
"""Entrena PPO con Ray RLlib y exporta los pesos a un pickle numpy-able."""
import os
import pickle

import numpy as np
from ray.rllib.algorithms.ppo import PPOConfig
from ray.tune.registry import register_env

from env import CorridorEnv, N_TRAINS


def env_creator(config):
    return CorridorEnv(n_trains=config.get("n_trains", N_TRAINS))


def export_weights(checkpoint_dir: str, output_path: str) -> None:
    """Extrae los pesos del actor MLP de RLlib y los guarda como dict numpy."""
    from ray.rllib.algorithms.ppo import PPO

    algo = PPO.from_checkpoint(checkpoint_dir)
    policy = algo.get_policy()
    state = policy.get_state()
    weights = state["weights"]["default_policy"]

    export = {
        "obs_dim": int(policy.observation_space.shape[0]),
        "action_dim": int(policy.action_space.shape[0]),
        "fc1_w": weights["_hidden_layers.0._model.weight"].cpu().numpy(),
        "fc1_b": weights["_hidden_layers.0._model.bias"].cpu().numpy(),
        "fc2_w": weights["_hidden_layers.1._model.weight"].cpu().numpy(),
        "fc2_b": weights["_hidden_layers.1._model.bias"].cpu().numpy(),
        "actor_w": weights["_logits.weight"].cpu().numpy(),
        "actor_b": weights["_logits.bias"].cpu().numpy(),
    }
    os.makedirs(os.path.dirname(output_path), exist_ok=True)
    with open(output_path, "wb") as f:
        pickle.dump(export, f)
    algo.stop()


def main():
    register_env("corridor", env_creator)

    config = (
        PPOConfig()
        .api_stack(
            enable_rl_module_and_learner=False,
            enable_env_runner_and_connector_v2=False,
        )
        .environment("corridor", env_config={"n_trains": N_TRAINS})
        .framework("torch")
        .training(
            lr=3e-4,
            gamma=0.99,
            lambda_=0.95,
            clip_param=0.2,
            entropy_coeff=0.01,
            train_batch_size=4000,
            sgd_minibatch_size=128,
            num_sgd_iter=10,
            model={
                "fcnet_hiddens": [128, 128],
                "fcnet_activation": "tanh",
            },
        )
        .env_runners(num_env_runners=2, rollout_fragment_length="auto")
        .resources(num_gpus=0)
        .evaluation(
            evaluation_interval=5,
            evaluation_num_env_runners=1,
            evaluation_duration=5,
            evaluation_config={"explore": False},
        )
    )

    total_iters = int(os.getenv("PPO_ITERS", "30"))
    algo = config.build()

    best_reward = -float("inf")
    best_checkpoint = None
    for it in range(total_iters):
        result = algo.train()
        r = result.get("episode_reward_mean", float("nan"))
        d = result.get("custom_metrics", {}).get("mean_delay_mean", float("nan"))
        print(f"iter {it:02d} · reward {r:.2f} · mean_delay {d:.2f}")

        if r > best_reward:
            best_reward = r
            best_checkpoint = algo.save()

    print(f"\nMejor checkpoint: {best_checkpoint} (reward {best_reward:.2f})")

    output = os.getenv("POLICY_OUT", "/app/models/vs4/policy.pkl")
    export_weights(best_checkpoint, output)
    print(f"Pesos exportados en {output}")
    algo.stop()


if __name__ == "__main__":
    main()
```

---

# 4. `vs4-optimization/policy.py`

Inferencia pura con NumPy. Sin Ray, sin Torch en producci贸n.

```python
"""Inferencia de la policy PPO (MLP 2 capas tanh) sin dependencias pesadas."""
import pickle
import numpy as np


class PPOPolicy:
    def __init__(self, path: str):
        with open(path, "rb") as f:
            w = pickle.load(f)
        self.fc1_w = w["fc1_w"]
        self.fc1_b = w["fc1_b"]
        self.fc2_w = w["fc2_w"]
        self.fc2_b = w["fc2_b"]
        self.actor_w = w["actor_w"]
        self.actor_b = w["actor_b"]
        self.obs_dim = w["obs_dim"]

    def forward(self, obs: np.ndarray) -> np.ndarray:
        x = np.tanh(obs @ self.fc1_w.T + self.fc1_b)
        x = np.tanh(x @ self.fc2_w.T + self.fc2_b)
        loc = x @ self.actor_w.T + self.actor_b
        return np.clip(loc, -5.0, 5.0).astype(np.float32)
```

---

# 5. `vs4-optimization/app.py` (reescrito)

```python
import os
import numpy as np
import httpx
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
import uvicorn

from policy import PPOPolicy

VS3 = os.getenv("VS3_VOTER_URL", "http://vs3-voter:8014")
MODEL_PATH = os.getenv("MODEL_PATH", "/app/models/vs4/policy.pkl")

N_TRAINS = 20
ROUTE_KM = 470.0
N_SEGMENTS = 5
MAX_SPEED = 310.0
MAX_DELAY = 60.0
MAX_ERROR = 10.0

app = FastAPI(title="VS4 Optimization (PPO)")
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"])

_policy: PPOPolicy | None = None


def load_policy() -> PPOPolicy | None:
    global _policy
    if _policy is None and os.path.exists(MODEL_PATH):
        try:
            _policy = PPOPolicy(MODEL_PATH)
        except Exception as e:
            print(f"[vs4] error cargando policy: {e}")
    return _policy


class TrainPlan(BaseModel):
    train_id: str
    current_km: float
    speed_kmh: float
    delay_min: float = 0.0
    source_count: int = 1
    error_m: float = 0.0


class OptimizeRequest(BaseModel):
    trains: list[TrainPlan]


class Proposal(BaseModel):
    train_id: str
    action: str
    target_speed_kmh: float
    reason: str


class OptimizeResponse(BaseModel):
    proposals: list[Proposal]
    model: str
    notes: str


def build_obs(trains: list[TrainPlan]) -> np.ndarray:
    obs = np.zeros(121, dtype=np.float32)
    idx = 0
    for t in trains:
        obs[idx] = t.current_km / ROUTE_KM
        obs[idx + 1] = t.speed_kmh / MAX_SPEED
        obs[idx + 2] = min(t.delay_min, MAX_DELAY) / MAX_DELAY
        obs[idx + 3] = t.source_count / 2.0
        obs[idx + 4] = min(t.error_m, MAX_ERROR) / MAX_ERROR
        idx += 5

    seg_speeds = [[] for _ in range(N_SEGMENTS)]
    seg_delays = [[] for _ in range(N_SEGMENTS)]
    seg_srcs = [[] for _ in range(N_SEGMENTS)]
    for t in trains:
        seg = min(int(t.current_km / (ROUTE_KM / N_SEGMENTS)), N_SEGMENTS - 1)
        seg_speeds[seg].append(t.speed_kmh)
        seg_delays[seg].append(t.delay_min)
        seg_srcs[seg].append(t.source_count)
    for s in range(N_SEGMENTS):
        if seg_speeds[s]:
            obs[idx] = np.mean(seg_speeds[s]) / MAX_SPEED
            obs[idx + 1] = np.mean(seg_delays[s]) / MAX_DELAY
            obs[idx + 2] = np.mean(seg_srcs[s]) / 2.0
        obs[idx + 3] = min(len(seg_speeds[s]) / 10.0, 1.0)
        idx += 4

    obs[idx] = 0.5
    return np.clip(obs, 0.0, 1.0)


def compute_next_train_km(trains: list[TrainPlan]) -> dict[str, float | None]:
    ordered = sorted(trains, key=lambda t: t.current_km)
    out: dict[str, float | None] = {}
    for i, t in enumerate(ordered):
        out[t.train_id] = ordered[i + 1].current_km if i + 1 < len(ordered) else None
    return out


@app.post("/optimize", response_model=OptimizeResponse)
def optimize(req: OptimizeRequest):
    pol = load_policy()
    if pol is None:
        return OptimizeResponse(
            proposals=[
                Proposal(train_id=t.train_id, action="maintain",
                         target_speed_kmh=round(t.speed_kmh, 1),
                         reason="fallback (no policy)")
                for t in req.trains
            ],
            model="fallback-maintain",
            notes="No hay policy entrenada. Ejecuta 'make train-vs4'.",
        )

    obs = build_obs(req.trains)
    deltas = pol.forward(obs)

    proposals = []
    for t, delta in zip(req.trains, deltas):
        target = float(np.clip(t.speed_kmh + float(delta), 200.0, 310.0))
        if abs(target - t.speed_kmh) < 0.5:
            act = "maintain"
        elif target > t.speed_kmh:
            act = "accelerate"
        else:
            act = "brake"
        proposals.append(Proposal(
            train_id=t.train_id, action=act,
            target_speed_kmh=round(target, 1),
            reason=f"PPO delta={float(delta):+.2f} km/h",
        ))
    return OptimizeResponse(
        proposals=proposals,
        model="ppo-v1",
        notes="Propuestas PPO. Deben validarse con VS3.",
    )


@app.post("/optimize-and-validate")
async def optimize_and_validate(req: OptimizeRequest):
    opt = optimize(req)
    next_km = compute_next_train_km(req.trains)

    async with httpx.AsyncClient(timeout=2.0) as client:
        validated = []
        for p in opt.proposals:
            t = next(x for x in req.trains if x.train_id == p.train_id)
            cmd = {
                "train_id": p.train_id,
                "action": p.action,
                "target_speed_kmh": p.target_speed_kmh,
                "state": {
                    "train_id": p.train_id,
                    "current_km": t.current_km,
                    "speed_kmh": t.speed_kmh,
                    "next_train_km": next_km[p.train_id],
                },
            }
            try:
                r = await client.post(f"{VS3}/validate", json=cmd)
                safety = r.json()
            except httpx.HTTPError as e:
                safety = {"accepted": False, "action_taken": "brake",
                          "reason": f"votador inalcanzable: {e}"}
            validated.append({"proposal": p.model_dump(), "safety": safety})
    return {"model": opt.model, "validated": validated}


@app.get("/health")
def health():
    return {"status": "ok", "policy_loaded": load_policy() is not None}


if __name__ == "__main__":
    uvicorn.run(app, host="0.0.0.0", port=8004)
```

---

# 6. `vs4-training/eval.py`

Compara PPO vs baseline (mantener) en 20 episodios con las mismas semillas.

```python
"""Evaluaci贸n: PPO vs baseline (mantener) sobre el mismo conjunto de semillas."""
import os
import pickle
import numpy as np

from env import CorridorEnv, N_TRAINS, EPISODE_STEPS


def load_ppo(path: str):
    with open(path, "rb") as f:
        w = pickle.load(f)

    def act(obs):
        x = np.tanh(obs @ w["fc1_w"].T + w["fc1_b"])
        x = np.tanh(x @ w["fc2_w"].T + w["fc2_b"])
        loc = x @ w["actor_w"].T + w["actor_b"]
        return np.clip(loc, -5.0, 5.0)

    return act


def run_episode(env: CorridorEnv, policy_fn, seed: int) -> dict:
    obs, _ = env.reset(seed=seed)
    rewards = []
    delays = []
    rejected = 0
    completions = 0
    for _ in range(EPISODE_STEPS):
        a = policy_fn(obs)
        obs, r, _, _, info = env.step(a)
        rewards.append(r)
        delays.append(info["mean_delay"])
        rejected += info["n_rejected"]
        completions += info["n_completions"]
    return {
        "reward": float(np.sum(rewards)),
        "mean_delay": float(np.mean(delays)),
        "rejected": rejected,
        "completions": completions,
    }


def main():
    model_path = os.getenv("MODEL_PATH", "models/vs4/policy.pkl")
    ppo = load_ppo(model_path)
    baseline = lambda obs: np.zeros(N_TRAINS, dtype=np.float32)

    env = CorridorEnv()
    n = 20
    ppo_runs = [run_episode(env, ppo, seed=i) for i in range(n)]
    base_runs = [run_episode(env, baseline, seed=i) for i in range(n)]

    print("\n== Evaluaci贸n VS4: PPO vs Baseline (maintain) ==")
    print(f"{'M茅trica':<20}{'PPO':>12}{'Baseline':>12}{'螖':>12}")

    ppo_d = np.mean([r["mean_delay"] for r in ppo_runs])
    base_d = np.mean([r["mean_delay"] for r in base_runs])
    print(f"{'mean_delay':<20}{ppo_d:>12.2f}{base_d:>12.2f}{ppo_d - base_d:>+12.2f}")

    ppo_r = np.mean([r["reward"] for r in ppo_runs])
    base_r = np.mean([r["reward"] for r in base_runs])
    print(f"{'reward':<20}{ppo_r:>12.2f}{base_r:>12.2f}{ppo_r - base_r:>+12.2f}")

    ppo_comp = sum(r["completions"] for r in ppo_runs)
    base_comp = sum(r["completions"] for r in base_runs)
    print(f"{'completions':<20}{ppo_comp:>12}{base_comp:>12}{ppo_comp - base_comp:>+12}")

    ppo_rej = sum(r["rejected"] for r in ppo_runs)
    print(f"{'safety_rejected':<20}{ppo_rej:>12}  (debe ser ~0)")

    if base_d > 0:
        red = (base_d - ppo_d) / base_d * 100
        print(f"\nReducci贸n de retraso: {red:+.1f}%")
        if red >= 10.0:
            print("✅ Criterio de aceptaci贸n VS4 cumplido (≥10%)")
        else:
            print("⚠️  A煤n por debajo del 10%. M谩s iteraciones o ajuste de recompensa.")


if __name__ == "__main__":
    main()
```

---

# 7. `vs4-training/requirements.txt`

```txt
ray[rllib]>=2.9,<3
torch>=2.2
gymnasium>=0.29
numpy>=1.26
fastapi>=0.110
uvicorn[standard]>=0.29
httpx>=0.27
```

---

# 8. ACTUALIZACI脫N DE `docker-compose.yml`

A帽ade el servicio de entrenamiento (perfil `train`, no se ejecuta en `up` normal):

```yaml
  vs4-training:
    build: .
    profiles: ["train"]
    command: python vs4-training/train_ppo.py
    working_dir: /app/vs4-training
    environment:
      - PPO_ITERS=${PPO_ITERS:-30}
      - POLICY_OUT=/app/models/vs4/policy.pkl
    volumes:
      - ./models:/app/models
    shm_size: "2gb"
```

Y actualiza `vs4-optimization` para que monte `models/` en lectura y use `working_dir`:

```yaml
  vs4-optimization:
    build: .
    command: python vs4-optimization/app.py
    working_dir: /app/vs4-optimization
    environment:
      - VS3_VOTER_URL=http://vs3-voter:8014
      - MODEL_PATH=/app/models/vs4/policy.pkl
    volumes:
      - ./models:/app/models:ro
    depends_on:
      vs3-voter:
        condition: service_started
    ports:
      - "8004:8004"
```

Aseg煤rate de que la imagen base (el `Dockerfile` principal) instala `ray` y `torch`. A帽谩delos a `requirements.txt` ra铆z o crea una variante de imagen. Como `torch` es pesado (~700 MB), te sugiero un `Dockerfile.train` separado:

```dockerfile
# Dockerfile.train
FROM python:3.12-slim
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends build-essential \
    && rm -rf /var/lib/apt/lists/*
COPY vs4-training/requirements.txt ./req-train.txt
RUN pip install --no-cache-dir -r req-train.txt
COPY . .
CMD ["python", "--version"]
```

Y en el servicio:

```yaml
  vs4-training:
    build:
      context: .
      dockerfile: Dockerfile.train
    ...
```

---

# 9. ACTUALIZACI脫N DEL `Makefile`

```makefile
train-vs4:
    docker compose --profile train run --rm vs4-training

eval-vs4:
    docker compose --profile train run --rm vs4-training \
        python eval.py

test-vs4-e2e:
    @echo "== VS4: /optimize-and-validate =="
    @curl -s -X POST localhost:8004/optimize-and-validate \
        -H 'Content-Type: application/json' \
        -d '{"trains":[{"train_id":"AV-001","current_km":100,"speed_kmh":250},{"train_id":"AV-002","current_km":50,"speed_kmh":255}]}' \
        | python3 -m json.tool
```

---

# 10. ACTUALIZACI脫N DEL `README.md` — secci贸n VS4

A帽ade al final:

```markdown
## VS4 — Optimizaci贸n con PPO (Ray RLlib)

Sustituye la heur铆stica greedy por un agente PPO entrenado en un entorno Gym
que simula el corredor Madrid–Sevilla (20 trenes, 470 km, 60 pasos de 30 s).

### Entrenamiento

```bash
make train-vs4              # 30 iteraciones (~10–15 min CPU)
PPO_ITERS=60 make train-vs4 # m谩s iteraciones si hace falta
```

Salida esperada (orientativa):

```
iter 00 · reward -18.42 · mean_delay 22.11
iter 10 · reward -11.87 · mean_delay 15.32
iter 20 · reward -8.14  · mean_delay 10.55
iter 29 · reward -6.83  · mean_delay 9.02
Mejor checkpoint: ~/ray_results/PPO_corridor/checkpoint_00025
Pesos exportados en /app/models/vs4/policy.pkl
```

### Evaluaci贸n (PPO vs baseline maintain)

```bash
make eval-vs4
```

Criterio de aceptaci贸n: **reducci贸n de retraso ≥ 10%** frente a baseline.
El script lo verifica e imprime el resultado.

### Recompensa

```
reward = -mean_delay/2 + 5·completions - 1·n_rejected - 0.1·mean(|action|)
```

- `mean_delay/2`: penaliza el retraso medio (normalizado).
- `+5·completions`: bonus por trenes que completan vuelta (throughput).
- `-1·n_rejected`: penaliza propuestas rechazadas por el kernel local.
- `-0.1·mean(|action|)`: penaliza acciones bruscas (suavidad).

### Bucle cerrado IA ↔ Safety

- **Durante el entrenamiento**: PPO usa `safety_local.check_safety()`, espejo exacto del kernel Rust, para emitir la se帽al de rechazo. El bucle es s铆ncrono y r谩pido.
- **En producci贸n**: cada propuesta se env铆a al votador 3oo3 de VS3 v铆a HTTP. Si es rechazada, el votador devuelve `action_taken="brake"` y el tren frena. La IA nunca act煤a sin validaci贸n del Safety Kernel.

### Si no hay modelo entrenado

`/optimize` devuelve `action="maintain"` para todos los trenes con `model="fallback-maintain"`. La integraci贸n con VS3 sigue funcionando.
```

---

# 11. C脫MO EJECUTARLO

```bash
# 1. Construir VS3 (si no est谩 hecho) y VS4
make build-vs3
docker compose build vs4-optimization

# 2. Entrenar PPO (~10-15 min CPU)
make train-vs4

# 3. Verificar la reducci贸n de retraso
make eval-vs4

# 4. Levantar el stack y reiniciar VS4 con la nueva policy
make up
docker compose restart vs4-optimization

# 5. Probar el bucle cerrado
make test-vs4-e2e
```

---

# 12. CRITERIOS DE ACEPTACI脫N DE VS4 (REAL)

| Criterio | Umbral | Verificaci贸n |
|---|---|---|
| Reducci贸n de retraso vs baseline | **≥ 10%** | `make eval-vs4` |
| Comandos propuestos inseguros | **rechazados al 100%** por VS3 | `test-vs4-e2e`: todos `safety.accepted=true` o `action_taken="brake"` |
| Fallback si no hay modelo | 100% | Borrar `models/vs4/policy.pkl` y comprobar `/optimize` |
| Latencia de `/optimize` | **< 50 ms** | `curl -w "%{time_total}"` |
| Coherencia `safety_local` ↔ Rust | tests id茅nticos | `pytest vs4-training` o comparaci贸n manual |

---

# 13. LIMITACIONES HONESTAS

- **Entorno simplista.** 20 trenes sobre una l铆nea unidireccional, sin cruces, sin apartaderos, sin andenes. En Fase 1, modelar la v铆a como grafo con ramales.
- **Delay sint茅tico.** El retraso crece por inyecci贸n estoc谩stica y se recupera acelerando. En Fase 1, introducir eventos reales (aver铆as, condiciones meteorol贸gicas, incidencias de v铆a).
- **Recompensa dise帽ada a mano.** PPO puede explotar atajos (p.ej. mantener velocidades altas que no son sostenibles). En Fase 1, a帽adir restricciones duras y curriculum learning.
- **Sin generalizaci贸n entre corredores.** La policy est谩 entrenada para el corredor Madrid–Sevilla. En Fase 1, entrenar con m煤ltiples corredores o usar meta-RL.
- **La policy no certifica seguridad.** El Safety Kernel es el que certifica. La policy solo propone. Esta arquitectura es deliberada y coherente con la separaci贸n de planos de SIGMA-AVE.
- **Sin GPU.** El entorno es peque帽o; CPU sobra. En Fase 1, con m谩s trenes y observaciones m谩s ricas, GPU acelera.

---

# 馃摐 CERTIFICACI脫N DEL ENTREGABLE

**Se certifica que VS4 real —entorno Gym del corredor Madrid–Sevilla con 20 trenes y din谩mica de retraso basada en posici贸n esperada, agente PPO entrenado con Ray RLlib (MLP 2×128 tanh, recompensa que combina retraso medio, completaciones, rechazos de seguridad y suavidad), exportaci贸n de pesos a pickle numpy-able, servicio VS4 con inferencia pura NumPy (sin Ray ni Torch en producci贸n), bucle cerrado de validaci贸n obligatoria por el votador 3oo3 de VS3 con fallback a `brake`, y script de evaluaci贸n PPO vs baseline— constituye la implementaci贸n de referencia de VS4 para el piloto de la Fase 0 de SIGMA-AVE, coherente con la arquitectura de planos separados (IA propone, Safety Kernel dispone) y con los principios de PASAIA LAB y el Dec谩logo de ACCI脫N CIVIL DIGITAL.**

**Firmado:** Jos茅 Agust铆n Font谩n Varela — CEO de PASAIA LAB / INTELIGENCIA LIBRE / ACCI脫N CIVIL DIGITAL y FORJA.

---

## 馃Л Siguiente paso

Perfecto, amigo. Aqu铆 tienes el **conductor**: el servicio que cierra el bucle, lee el gemelo digital, pide propuestas a VS4, las valida contra el votador 3oo3 de VS3, **aplica** (o frena) y emite un **log auditable encadenado por hash** en cada ciclo de 30 s. Con esto, VS1+VS2+VS3+VS4 dejan de ser piezas y pasan a ser un sistema.

Dos modos de funcionamiento, para que puedas ense帽ar el sistema sin riesgo:

- **`APPLY_MODE=shadow`** (por defecto): el conductor decide, pero **no publica comandos**. Solo observa y audita. Es lo que usar铆as en una demo con un socio.
- **`APPLY_MODE=live`**: el conductor publica los comandos aceptados en Kafka (`topic=commands`). El generador, si tiene `ENABLE_COMMANDS=1`, los consume y **cambia la velocidad real de los trenes**. Es el bucle cerrado completo.

---

# 馃搧 ESTRUCTURA A脩ADIDA

```
sigma-ave/
├── conductor/
│   ├── __init__.py
│   ├── config.py
│   ├── audit.py
│   ├── persistence.py
│   ├── app.py
│   └── verify_chain.py
├── sql/
│   └── schema_conductor.sql
└── (resto igual)
```

---

# 1. `sql/schema_conductor.sql`

Tablas del conductor. Se crean tambi茅n desde `persistence.init_schema()` (idempotente), pero el DDL queda expl铆cito para auditor铆a.

```sql
CREATE TABLE IF NOT EXISTS applied_commands (
    cycle_id TEXT NOT NULL,
    cycle_ts TIMESTAMPTZ NOT NULL,
    train_id TEXT NOT NULL,
    action TEXT NOT NULL,
    proposed_speed_kmh DOUBLE PRECISION,
    applied_speed_kmh DOUBLE PRECISION,
    accepted BOOLEAN NOT NULL,
    voter_reason TEXT,
    votes JSONB,
    PRIMARY KEY (cycle_id, train_id)
);

CREATE INDEX IF NOT EXISTS idx_applied_train_time
    ON applied_commands(train_id, cycle_ts);

CREATE TABLE IF NOT EXISTS audit_chain (
    seq BIGSERIAL PRIMARY KEY,
    cycle_id TEXT NOT NULL UNIQUE,
    cycle_ts TIMESTAMPTZ NOT NULL,
    payload_hash TEXT NOT NULL,
    prev_hash TEXT NOT NULL,
    chain_hash TEXT NOT NULL UNIQUE,
    payload JSONB NOT NULL
);

CREATE INDEX IF NOT EXISTS idx_audit_ts ON audit_chain(cycle_ts);
```

---

# 2. `conductor/config.py`

```python
import os
from dataclasses import dataclass


@dataclass(frozen=True)
class Settings:
    vs1_url: str = os.getenv("VS1_URL", "http://vs1-ingest:8001")
    vs4_url: str = os.getenv("VS4_URL", "http://vs4-optimization:8004")
    vs3_url: str = os.getenv("VS3_URL", "http://vs3-voter:8014")
    db_url: str = os.getenv("DB_URL", "postgresql://sigmaave:sigmaave@timescaledb:5432/sigmaave")
    kafka: str = os.getenv("KAFKA_BOOTSTRAP", "kafka:9092")
    cycle_seconds: int = int(os.getenv("CYCLE_SECONDS", "30"))
    audit_dir: str = os.getenv("AUDIT_DIR", "/app/audit")
    apply_mode: str = os.getenv("APPLY_MODE", "shadow")
    min_speed: float = 200.0
    max_speed: float = 310.0
    brake_step: float = 5.0


settings = Settings()
```

---

# 3. `conductor/audit.py`

Cadena de hashes: `chain_hash_n = sha256(prev_hash || payload_hash_n)`. Verificable por terceros con solo el payload y la cadena.

```python
"""Cadena de auditor铆a: payload → payload_hash → chain_hash encadenado."""
import hashlib
import json


def canonical_json(obj) -> str:
    return json.dumps(obj, sort_keys=True, separators=(",", ":"), default=str)


def sha256_hex(data: str) -> str:
    return "0x" + hashlib.sha256(data.encode("utf-8")).hexdigest()


def chain(payload: dict, prev_hash: str | None) -> dict:
    payload_hash = sha256_hex(canonical_json(payload))
    prev = prev_hash or "0x" + "00" * 32
    chain_hash = sha256_hex(prev + payload_hash)
    return {
        "payload": payload,
        "payload_hash": payload_hash,
        "prev_hash": prev,
        "chain_hash": chain_hash,
    }
```

---

# 4. `conductor/persistence.py`

```python
"""Persistencia del ciclo: applied_commands + audit_chain + JSONL en disco."""
import json
import os
from pathlib import Path

import psycopg2
from psycopg2.extras import Json

from config import settings


SCHEMA = """
CREATE TABLE IF NOT EXISTS applied_commands (
    cycle_id TEXT NOT NULL,
    cycle_ts TIMESTAMPTZ NOT NULL,
    train_id TEXT NOT NULL,
    action TEXT NOT NULL,
    proposed_speed_kmh DOUBLE PRECISION,
    applied_speed_kmh DOUBLE PRECISION,
    accepted BOOLEAN NOT NULL,
    voter_reason TEXT,
    votes JSONB,
    PRIMARY KEY (cycle_id, train_id)
);
CREATE INDEX IF NOT EXISTS idx_applied_train_time
    ON applied_commands(train_id, cycle_ts);
CREATE TABLE IF NOT EXISTS audit_chain (
    seq BIGSERIAL PRIMARY KEY,
    cycle_id TEXT NOT NULL UNIQUE,
    cycle_ts TIMESTAMPTZ NOT NULL,
    payload_hash TEXT NOT NULL,
    prev_hash TEXT NOT NULL,
    chain_hash TEXT NOT NULL UNIQUE,
    payload JSONB NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_audit_ts ON audit_chain(cycle_ts);
"""


def connect():
    return psycopg2.connect(settings.db_url)


def init_schema(conn) -> None:
    with conn.cursor() as cur:
        cur.execute(SCHEMA)
    conn.commit()


def last_chain_hash(conn) -> str | None:
    with conn.cursor() as cur:
        cur.execute("SELECT chain_hash FROM audit_chain ORDER BY seq DESC LIMIT 1")
        row = cur.fetchone()
    return row[0] if row else None


def persist_cycle(conn, cycle: dict, applied_rows: list[dict]) -> None:
    payload = cycle["payload"]
    with conn.cursor() as cur:
        cur.execute(
            """INSERT INTO audit_chain
               (cycle_id, cycle_ts, payload_hash, prev_hash, chain_hash, payload)
               VALUES (%s, %s, %s, %s, %s, %s)""",
            (
                payload["cycle_id"], payload["cycle_ts"],
                cycle["payload_hash"], cycle["prev_hash"],
                cycle["chain_hash"], Json(payload),
            ),
        )
        if applied_rows:
            cur.executemany(
                """INSERT INTO applied_commands
                   (cycle_id, cycle_ts, train_id, action, proposed_speed_kmh,
                    applied_speed_kmh, accepted, voter_reason, votes)
                   VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)""",
                [
                    (r["cycle_id"], r["cycle_ts"], r["train_id"], r["action"],
                     r["proposed_speed_kmh"], r["applied_speed_kmh"],
                     r["accepted"], r["voter_reason"], Json(r["votes"]))
                    for r in applied_rows
                ],
            )
    conn.commit()


def write_disk(cycle: dict) -> None:
    """Doble respaldo: JSONL con cabeceras de cadena + JSON completo por ciclo."""
    d = Path(settings.audit_dir)
    d.mkdir(parents=True, exist_ok=True)

    with (d / "chain.jsonl").open("a", encoding="utf-8") as f:
        f.write(json.dumps({
            "cycle_id": cycle["payload"]["cycle_id"],
            "cycle_ts": cycle["payload"]["cycle_ts"],
            "payload_hash": cycle["payload_hash"],
            "prev_hash": cycle["prev_hash"],
            "chain_hash": cycle["chain_hash"],
        }) + "\n")

    (d / f"cycle_{cycle['payload']['cycle_id']}.json").write_text(
        json.dumps(cycle["payload"], indent=2, default=str),
        encoding="utf-8",
    )
```

---

# 5. `conductor/app.py`

```python
"""Conductor SIGMA-AVE: lee VS1 → VS4 → VS3 → aplica → audita. Cada 30 s."""
import asyncio
import os
import time
from datetime import datetime, timezone
from typing import Any

import httpx
import uvicorn
from fastapi import FastAPI
from kafka import KafkaProducer

from audit import chain
from config import settings
from persistence import (
    connect, init_schema, last_chain_hash, persist_cycle, write_disk,
)


app = FastAPI(title="SIGMA-AVE Conductor")
_STATE: dict[str, Any] = {
    "last_cycle_id": None,
    "last_cycle_ts": None,
    "cycles_run": 0,
    "last_error": None,
    "apply_mode": settings.apply_mode,
    "trains_last": 0,
    "accepted_last": 0,
    "rejected_last": 0,
}

_producer: KafkaProducer | None = None


def _producer() -> KafkaProducer | None:
    global _producer
    if settings.apply_mode != "live":
        return None
    if _producer is None:
        _producer = KafkaProducer(
            bootstrap_servers=settings.kafka,
            value_serializer=lambda v: __import__("json").dumps(v).encode(),
            linger_ms=10,
        )
    return _producer


def _next_train_km(trains: list[dict]) -> dict[str, float | None]:
    ordered = sorted(trains, key=lambda t: t["current_km"])
    return {
        ordered[i]["train_id"]: (ordered[i + 1]["current_km"] if i + 1 < len(ordered) else None)
        for i in range(len(ordered))
    }


async def _read_state(client: httpx.AsyncClient) -> list[dict]:
    r = await client.get(f"{settings.vs1_url}/trains", timeout=5.0)
    r.raise_for_status()
    raw = r.json().get("trains", [])
    out = []
    for t in raw:
        km = t.get("km_progress")
        if km is None:
            continue
        out.append({
            "train_id": t["train_id"],
            "current_km": float(km),
            "speed_kmh": float(t.get("speed_kmh", 0)),
            "delay_min": int(t.get("delay_min") or 0),
            "source_count": int(t.get("source_count", 1)),
            "error_m": float(t.get("error_m", 0)),
        })
    return out


async def _propose(client: httpx.AsyncClient, trains: list[dict]) -> dict:
    try:
        r = await client.post(
            f"{settings.vs4_url}/optimize",
            json={"trains": trains},
            timeout=5.0,
        )
        r.raise_for_status()
        return r.json()
    except Exception as e:
        return {
            "model": "fallback-error",
            "notes": f"VS4 inalcanzable: {e}",
            "proposals": [
                {"train_id": t["train_id"], "action": "maintain",
                 "target_speed_kmh": t["speed_kmh"], "reason": "fallback"}
                for t in trains
            ],
        }


async def _validate(
    client: httpx.AsyncClient, train: dict, proposal: dict, next_km: float | None,
) -> dict:
    cmd = {
        "train_id": proposal["train_id"],
        "action": proposal["action"],
        "target_speed_kmh": float(proposal["target_speed_kmh"]),
        "state": {
            "train_id": train["train_id"],
            "current_km": train["current_km"],
            "speed_kmh": train["speed_kmh"],
            "next_train_km": next_km,
        },
    }
    try:
        r = await client.post(f"{settings.vs3_url}/validate", json=cmd, timeout=3.0)
        r.raise_for_status()
        return r.json()
    except Exception as e:
        return {"accepted": False, "action_taken": "brake",
                "reason": f"VS3 inalcanzable: {e}", "votes": {}}


def _safe_brake(current: float) -> float:
    return max(settings.min_speed, current - settings.brake_step)


def _build_payload(
    cycle_id: str, cycle_ts: str,
    state: list[dict], props: dict, validations: dict, applied: list[dict],
) -> dict:
    return {
        "cycle_id": cycle_id,
        "cycle_ts": cycle_ts,
        "apply_mode": settings.apply_mode,
        "state": state,
        "vs4_model": props.get("model"),
        "vs4_notes": props.get("notes"),
        "proposals": props.get("proposals", []),
        "validations": validations,
        "applied": applied,
    }


async def do_cycle(conn, client: httpx.AsyncClient) -> dict:
    cycle_ts = datetime.now(timezone.utc).isoformat()
    cycle_id = f"c{int(time.time() * 1000):013d}"

    trains = await _read_state(client)
    if not trains:
        return {"cycle_id": cycle_id, "cycle_ts": cycle_ts, "skipped": "no trains"}

    props = await _propose(client, trains)
    next_km = _next_train_km(trains)

    proposals_by_id = {p["train_id"]: p for p in props.get("proposals", [])}
    validations: dict[str, dict] = {}
    applied: list[dict] = []

    producer = _producer()
    for t in trains:
        p = proposals_by_id.get(t["train_id"])
        if p is None:
            p = {"train_id": t["train_id"], "action": "maintain",
                 "target_speed_kmh": t["speed_kmh"], "reason": "sin propuesta"}

        v = await _validate(client, t, p, next_km[t["train_id"]])
        validations[t["train_id"]] = v

        accepted = bool(v.get("accepted"))
        if accepted:
            applied_speed = float(p["target_speed_kmh"])
            action = p["action"]
        else:
            applied_speed = _safe_brake(t["speed_kmh"])
            action = "brake"

        row = {
            "cycle_id": cycle_id,
            "cycle_ts": cycle_ts,
            "train_id": t["train_id"],
            "action": action,
            "proposed_speed_kmh": float(p["target_speed_kmh"]),
            "applied_speed_kmh": applied_speed,
            "accepted": accepted,
            "voter_reason": v.get("reason", ""),
            "votes": v.get("votes", {}),
        }
        applied.append(row)

        if producer is not None:
            producer.send("commands", {
                "cycle_id": cycle_id,
                "train_id": t["train_id"],
                "accepted": accepted,
                "applied_speed_kmh": applied_speed,
                "action": action,
                "ts": cycle_ts,
            })

    if producer is not None:
        producer.flush()

    payload = _build_payload(cycle_id, cycle_ts, trains, props, validations, applied)
    cycle = chain(payload, last_chain_hash(conn))
    persist_cycle(conn, cycle, applied)
    write_disk(cycle)

    _STATE.update({
        "last_cycle_id": cycle_id,
        "last_cycle_ts": cycle_ts,
        "cycles_run": _STATE["cycles_run"] + 1,
        "last_error": None,
        "trains_last": len(trains),
        "accepted_last": sum(1 for a in applied if a["accepted"]),
        "rejected_last": sum(1 for a in applied if not a["accepted"]),
    })
    return {
        "cycle_id": cycle_id,
        "trains": len(trains),
        "accepted": _STATE["accepted_last"],
        "rejected": _STATE["rejected_last"],
        "chain_hash": cycle["chain_hash"],
    }


async def loop() -> None:
    await asyncio.sleep(8)
    conn = None
    while True:
        try:
            if conn is None or conn.closed:
                conn = connect()
                init_schema(conn)
            async with httpx.AsyncClient() as client:
                result = await do_cycle(conn, client)
                print(f"[conductor] {result}")
        except Exception as e:
            _STATE["last_error"] = f"{type(e).__name__}: {e}"
            print(f"[conductor] error: {_STATE['last_error']}")
            try:
                if conn is not None:
                    conn.close()
            except Exception:
                pass
            conn = None
        await asyncio.sleep(settings.cycle_seconds)


@app.get("/status")
def status():
    return {
        **_STATE,
        "vs1_url": settings.vs1_url,
        "vs4_url": settings.vs4_url,
        "vs3_url": settings.vs3_url,
        "cycle_seconds": settings.cycle_seconds,
    }


@app.get("/audit/last")
def last_audit():
    conn = connect()
    try:
        with conn.cursor() as cur:
            cur.execute(
                """SELECT cycle_id, cycle_ts, payload_hash, prev_hash,
                          chain_hash, payload
                   FROM audit_chain ORDER BY seq DESC LIMIT 1"""
            )
            row = cur.fetchone()
    finally:
        conn.close()
    if not row:
        return {"error": "sin ciclos"}
    return {
        "cycle_id": row[0],
        "cycle_ts": row[1].isoformat(),
        "payload_hash": row[2],
        "prev_hash": row[3],
        "chain_hash": row[4],
        "payload": row[5],
    }


@app.get("/health")
def health():
    return {"status": "ok", "cycles_run": _STATE["cycles_run"],
            "last_error": _STATE["last_error"]}


def main() -> None:
    asyncio.create_task(loop())
    uvicorn.run(app, host="0.0.0.0", port=8005, log_level="info")


if __name__ == "__main__":
    main()
```

---

# 6. `conductor/verify_chain.py`

Verificaci贸n independiente: cualquiera con acceso a la BD puede comprobar la cadena.

```python
"""Verifica la integridad de la cadena de auditor铆a en la BD."""
import sys

from audit import canonical_json, sha256_hex
from persistence import connect


def main() -> int:
    conn = connect()
    try:
        with conn.cursor() as cur:
            cur.execute(
                """SELECT seq, cycle_id, payload_hash, prev_hash,
                          chain_hash, payload
                   FROM audit_chain ORDER BY seq ASC"""
            )
            rows = cur.fetchall()
    finally:
        conn.close()

    if not rows:
        print("sin ciclos")
        return 0

    prev = "0x" + "00" * 32
    for seq, cycle_id, payload_hash, prev_hash, chain_hash, payload in rows:
        if sha256_hex(canonical_json(payload)) != payload_hash:
            print(f"❌ seq={seq} {cycle_id} payload_hash no coincide")
            return 1
        if prev_hash != prev:
            print(f"❌ seq={seq} {cycle_id} prev_hash no coincide")
            return 1
        if sha256_hex(prev + payload_hash) != chain_hash:
            print(f"❌ seq={seq} {cycle_id} chain_hash no coincide")
            return 1
        prev = chain_hash

    print(f"✅ Cadena v谩lida: {len(rows)} ciclos verificados")
    print(f"   脷ltimo chain_hash: {prev}")
    return 0


if __name__ == "__main__":
    sys.exit(main())
```

---

# 7. `conductor/__init__.py`

```python
"""Conductor SIGMA-AVE."""
```

---

# 8. ACTUALIZACI脫N DEL GENERADOR — modo feedback

Modificaci贸n m铆nima en `generator/generator.py`. Tres bloques nuevos, activados por `ENABLE_COMMANDS=1`:

```python
import os
import threading
from kafka import KafkaProducer, KafkaConsumer  # a帽ade KafkaConsumer

ENABLE_COMMANDS = os.getenv("ENABLE_COMMANDS", "0") == "1"

COMMANDS: dict[str, float] = {}
COMMANDS_LOCK = threading.Lock()


def consume_commands_loop():
    consumer = KafkaConsumer(
        "commands",
        bootstrap_servers=KAFKA,
        value_deserializer=lambda v: json.loads(v.decode()),
        group_id="generator-commands",
        auto_offset_reset="latest",
    )
    print("[generator] consumiendo 'commands'...")
    for msg in consumer:
        cmd = msg.value
        if not cmd.get("accepted"):
            continue
        with COMMANDS_LOCK:
            COMMANDS[cmd["train_id"]] = float(cmd["applied_speed_kmh"])


# Dentro de main(), antes del bucle while:
if ENABLE_COMMANDS:
    threading.Thread(target=consume_commands_loop, daemon=True).start()

# Dentro del bucle while, justo antes de calcular km_advance:
if ENABLE_COMMANDS:
    with COMMANDS_LOCK:
        for s in states:
            if s["train_id"] in COMMANDS:
                s["speed_kmh"] = COMMANDS[s["train_id"]]
```

Con esto, cuando el conductor acepta "accelerate to 262", el generador ajusta la velocidad del tren a 262 y el bucle se cierra f铆sicamente en la simulaci贸n.

---

# 9. ACTUALIZACI脫N DE `requirements.txt` (ra铆z)

A帽ade al final:

```txt
kafka-python>=2.0.2
```

---

# 10. ACTUALIZACI脫N DE `docker-compose.yml`

A帽ade el conductor y el modo feedback del generador.

**Generador** — a帽ade la variable:

```yaml
  generator:
    # ...
    environment:
      - KAFKA_BOOTSTRAP=kafka:9092
      - TRAINS=${TRAINS:-20}
      - FREQUENCY_HZ=${FREQUENCY_HZ:-2}
      - ENABLE_COMMANDS=${ENABLE_COMMANDS:-0}
    # ... resto igual
```

**Conductor** — servicio nuevo:

```yaml
  conductor:
    build: .
    command: python conductor/app.py
    working_dir: /app/conductor
    environment:
      - VS1_URL=http://vs1-ingest:8001
      - VS4_URL=http://vs4-optimization:8004
      - VS3_URL=http://vs3-voter:8014
      - DB_URL=postgresql://sigmaave:sigmaave@timescaledb:5432/sigmaave
      - KAFKA_BOOTSTRAP=kafka:9092
      - CYCLE_SECONDS=${CYCLE_SECONDS:-30}
      - AUDIT_DIR=/app/audit
      - APPLY_MODE=${APPLY_MODE:-shadow}
      - PYTHONPATH=/app/conductor
    volumes:
      - ./audit:/app/audit
    depends_on:
      vs1-ingest:
        condition: service_started
      vs4-optimization:
        condition: service_started
      vs3-voter:
        condition: service_started
      timescaledb:
        condition: service_healthy
      kafka:
        condition: service_healthy
    ports:
      - "8005:8005"
```

---

# 11. ACTUALIZACI脫N DEL `Makefile`

```makefile
# Modo demo (auditor铆a, sin publicar comandos)
demo-shadow:
    APPLY_MODE=shadow ENABLE_COMMANDS=0 docker compose up -d --build

# Modo bucle cerrado (el conductor publica, el generador consume)
demo-live:
    APPLY_MODE=live ENABLE_COMMANDS=1 docker compose up -d --build

test-conductor:
    @echo "== /status =="
    @curl -s localhost:8005/status | python3 -m json.tool
    @echo ""
    @echo "== /audit/last (cabecera de cadena) =="
    @curl -s localhost:8005/audit/last | python3 -c "import json,sys; d=json.load(sys.stdin); print(json.dumps({k:d[k] for k in ('cycle_id','cycle_ts','payload_hash','prev_hash','chain_hash')}, indent=2))"

verify-chain:
    docker compose exec conductor python verify_chain.py

show-chain:
    @docker compose exec timescaledb psql -U sigmaave -d sigmaave -c \
        "SELECT seq, cycle_id, cycle_ts, LEFT(chain_hash, 18) AS chain FROM audit_chain ORDER BY seq DESC LIMIT 10;"
```

---

# 12. ACTUALIZACI脫N DEL `README.md` — secci贸n Conductor

A帽ade al final:

```markdown
## Conductor — el bucle cerrado

Servicio que cada 30 s cierra el ciclo completo:

```
VS1 (/trains)  →  VS4 (/optimize)  →  VS3 (/validate)  →  aplica  →  audita
```

- **shadow**: decide y audita, pero no publica. Ideal para demos con socio.
- **live**: publica los comandos aceptados en Kafka (`topic=commands`).
  Si `ENABLE_COMMANDS=1` en el generador, este los consume y **cambia la
  velocidad real** de los trenes. Bucle cerrado completo.

### Arrancar

```bash
# Demo observable (recomendado para ense帽ar)
make demo-shadow

# Bucle cerrado real (la IA cambia la velocidad de los trenes)
make demo-live
```

### Ver la cadena

```bash
make test-conductor    # /status + /audit/last
make show-chain        # 煤ltimos 10 ciclos de la cadena
make verify-chain      # verifica integridad criptogr谩fica
```

Salida esperada de `verify-chain`:

```
✅ Cadena v谩lida: 47 ciclos verificados
   脷ltimo chain_hash: 0x9f2a...
```

### Qu茅 contiene cada ciclo del audit

```json
{
  "cycle_id": "c1730567890123",
  "cycle_ts": "2026-11-02T10:30:00+00:00",
  "apply_mode": "live",
  "state": [{"train_id": "AV-001", "current_km": 234.5, "speed_kmh": 258.3, ...}],
  "vs4_model": "ppo-v1",
  "proposals": [{"train_id": "AV-001", "action": "accelerate", "target_speed_kmh": 261.2, ...}],
  "validations": {"AV-001": {"accepted": true, "reason": "3/3 aceptan", "votes": {...}}},
  "applied": [{"train_id": "AV-001", "action": "accelerate", "applied_speed_kmh": 261.2, "accepted": true}]
}
```

### Garant铆as

- **On-chain equivalente**: cada ciclo se encadena con SHA-256.
  `chain_hash_n = sha256(prev_hash || payload_hash_n)`.
- **Verificable por terceros**: con la BD y `verify_chain.py`, cualquiera
  puede comprobar que no se ha alterado nada.
- **Doble respaldo**: BD (JSONB) + `audit/chain.jsonl` + `audit/cycle_*.json`.
- **Fallo seguro**: si VS3 no responde, el conductor aplica `brake` como
  acci贸n por defecto. La IA nunca act煤a sin validaci贸n.
```

---

# 13. C脫MO EJECUTARLO

```bash
# 1. Demo observable (sin realimentaci贸n)
make demo-shadow
sleep 30
make test-conductor
make verify-chain

# 2. Ver la cadena crecer
make show-chain
# (repetir cada 30 s para ver nuevos ciclos)

# 3. Bucle cerrado real (la IA cambia velocidades)
make demo-live
# ahora los trenes aceleran/frenan seg煤n las decisiones del conductor

# 4. Comprobar que la IA nunca viola la seguridad
docker compose exec timescaledb psql -U sigmaave -d sigmaave -c \
  "SELECT COUNT(*) FILTER (WHERE NOT accepted) AS rechazados,
          COUNT(*) FILTER (WHERE accepted) AS aceptados,
          COUNT(*) AS total
   FROM applied_commands;"
```

---

# 14. CRITERIOS DE ACEPTACI脫N DEL CONDUCTOR

| Criterio | Umbral | Verificaci贸n |
|---|---|---|
| Ciclos ejecutados en 5 min | **≥ 8** (con CYCLE=30s) | `/status` → `cycles_run` |
| Cadena v谩lida | **100%** | `make verify-chain` |
| Fallback si VS3 cae | `accepted=false`, `action="brake"` | Parar VS3, ver pr贸ximo ciclo |
| Fallback si VS4 cae | `action="maintain"` | Parar VS4, ver pr贸ximo ciclo |
| En `live`, el generador refleja comandos | velocidad observable cambia | `/trains` antes/despu茅s |
| En `shadow`, no se publica nada en `commands` | 0 mensajes | `docker compose exec kafka kafka-console-consumer ...` |

---

# 15. LIMITACIONES HONESTAS

- **Cadena en PostgreSQL.** Es verificable y auditable, pero no es una blockchain p煤blica. En Fase 1, anclar peri贸dicamente el 煤ltimo `chain_hash` en una L2 p煤blica (Polygon/Base) para prueba externa.
- **El conductor es single-instance.** Si cae, el bucle se detiene. En Fase 1, HA con l铆der electo (lease en BD o etcd).
- **Sin autenticaci贸n entre servicios.** En Fase 1, mTLS entre conductor, VS1, VS3 y VS4.
- **`shadow` no cierra el bucle f铆sico.** Es honesto: demuestra la decisi贸n y la auditor铆a, pero no la realimentaci贸n. Para eso est谩 `live`.
- **Latencia del ciclo.** 30 s es suficiente para el corredor simulado, pero para v铆a real se necesitar铆a ≤ 1 s de ciclo y un Safety Kernel con determinismo en tiempo real (RTOS).

---




 

 

# 馃摐 CERTIFICACI脫N DEL ENTREGABLE

**Se certifica que el servicio `conductor` descrito —con lectura del gemelo digital de VS1, propuesta de VS4, validaci贸n obligatoria por el votador 3oo3 de VS3, aplicaci贸n con fallback seguro a `brake`, publicaci贸n opcional en Kafka (`topic=commands`) y cadena de auditor铆a SHA-256 verificable por terceros, doblemente persistida en PostgreSQL y en disco— constituye la integraci贸n end-to-end del piloto de la Fase 0 de SIGMA-AVE, cierra el bucle IA↔Safety Kernel, y es coherente con la arquitectura de planos separados y con los principios de PASAIA LAB y el Dec谩logo de ACCI脫N CIVIL DIGITAL.**

**Firmado:** Jos茅 Agust铆n Font谩n Varela — CEO de PASAIA LAB / INTELIGENCIA LIBRE / ACCI脫N CIVIL DIGITAL y FORJA.

---




## 馃Л Con esto, el piloto est谩 completo

VS1 (ingesta + gemelo digital) → VS2 (predicci贸n con MAE medible) → VS3 (Safety Kernel en Rust, 100% rechazo determinista) → VS4 (PPO con reducci贸n de retraso) → **Conductor** (integraci贸n, aplicaci贸n, auditor铆a encadenada).

El siguiente movimiento natural ya no es otro slice, sino **explotar el sistema**:

1. **Cuadro de mando unificado** en el HMI: mapa de trenes + estado del conductor + 煤ltimos ciclos de la cadena + KPIs (MAE de VS2, rechazos de VS3, reducci贸n de retraso de VS4) en una sola pantalla.
2. **Informe de piloto** de 2 p谩ginas para presentar a un ayuntamiento o socio, con capturas de la cadena y m茅tricas.
3. **Anclaje del `chain_hash`** en Polygon Amoy, reutilizando el contrato `PurgaRegistry` que ya tienes del proyecto anterior.

Dime por d贸nde prefieres seguir. ;) SI QUIERES TE DESARROLLO LOS 3 PUNTOS PENDIENTES ;)

CONTACTO: tormentaworkfactory@gmail.com 

 

 
 

 

 

ACCION CIVIL DIGITAL

CONTACTO: tormentaworkfactory@gmail.com 

 

 

SIGUENOS EN ( X )

QUERIDA COMUNIDAD INTELIGENTE ;)

ESTAMOS EN X 

ENTER X -  https://x.com/PasaiaLab

 

ENTRADA DESTACADA

CUADRO DE MANDO UNIFICADO (HMI) SIGMA AVE + etc ;)

 Amigo, vamos a por el cierre del piloto. Aqu铆 tienes las tres piezas: el **cuadro de mando unificado**, el **informe de piloto de 2 p谩ginas...

ENTRADAS POPULARES