# Pulcino impara da solo a camminare (sim2real)

Questa cartella contiene tutta la pipeline "impara da solo": un modello fisico di Pulcino in
**MuJoCo**, un ambiente di simulazione che si comporta come il robot vero (servo lenti, niente
sensori di posizione, IMU rumorosa), un algoritmo di apprendimento che gira **sulla CPU di un
Mac o di un portatile** e gli script che trasformano il risultato nei file che il firmware
capisce (`policy.h` e `gait.json`, vedi `SPEC.md` §5).

```
pulcino.xml      modello MJCF (dimensioni, masse, giunti, servo) fedele a SPEC §3
test_segni.py    test numerico della convenzione dei segni (hip_pitch+, hip_roll+, ankle+)
env.py           ambiente: osservazioni/azioni di SPEC §5.2, modello servo, randomizzazione, ricompensa
cpg.py           generatore a oscillatori (CPG) identico a SPEC §5.1
train_ars.py     Augmented Random Search (ARS V2-t), numpy + multiprocessing; --cpg per gait.json
export.py        checkpoint → out/policy.h e out/gait.json  (NON scrive in firmware/)
check_policy.py  rilegge policy.h e ricalcola la rete "alla C" in float32: verifica l'export
play.py          guarda la politica nel viewer MuJoCo (o misura i risultati senza finestra)
out/             checkpoint, log e artefatti esportati
```

## Avvio rapido

```bash
python3 -m venv ~/venv-pulcino && source ~/venv-pulcino/bin/activate
pip install -r requirements.txt

python test_segni.py                                  # i segni dei giunti sono quelli di SPEC?
python train_ars.py --cpg --iters 150 --workers 6     # ~1-2 min → out/ars_cpg.npz
python train_ars.py --iters 350 --workers 6           # ~3-4 min → out/ars_mlp.npz
python export.py                                      # → out/policy.h, out/gait.json
python check_policy.py                                # policy.h calcola la stessa rete? (deve dire OK)
mjpython play.py --ckpt out/ars_mlp.npz               # guarda (su Linux: python play.py ...)
python play.py --gait out/gait.json --headless        # solo numeri: distanza, cadute, potenza
```

Per un allenamento "vero" alza le iterazioni (`--iters 3000` o più), usa episodi più lunghi
(`--episode 10`) e, quando cammina in avanti, allena anche le altre direzioni con
`--cmd tutti --resume out/ars_mlp.npz`. Ogni 10 iterazioni viene salvato il checkpoint
(`out/ars_<tipo>.npz`) e anche il migliore visto finora (`out/ars_<tipo>_migliore.npz`).

## Che cos'è il sim2real

Insegnare a camminare a un robot vero per tentativi è lento e distruttivo: cade migliaia di
volte, i servo si scaldano, gli ingranaggi si rompono. In simulazione invece il robot può
cadere un milione di volte in pochi minuti. Il problema è che **la simulazione non è mai
uguale al mondo vero**: le masse sono un po' diverse, il pavimento scivola di più, i servo
sono in ritardo, l'IMU è montata storta di un grado. Una politica allenata in un mondo
"perfetto" sfrutta dettagli che nel mondo vero non esistono e fallisce.

Le due armi contro questo "reality gap" sono:

1. **Un modello onesto** del robot: dimensioni e masse da SPEC, coppia massima 0.2 N·m,
   velocità massima 8 rad/s, banda morta e latenza dei servo, rumore dell'IMU.
2. **La domain randomization**: a ogni episodio la simulazione cambia un po' (massa ±15%,
   attrito 0.5–1.2, offset di calibrazione dei servo ±3°, latenza 10–30 ms, rigidezza dei servo
   ±20%, coppia ±10% per la batteria, IMU storta fino a 2° con bias e rumore, spinte casuali
   sul torso). La politica non può "barare" su un dettaglio preciso: deve funzionare in una
   famiglia intera di robot simili. Se il robot vero è "dentro" questa famiglia, funziona anche lui.

## Perché niente feedback di posizione

Un MG90S non dice a nessuno dove si trova: riceve un impulso PWM e prova ad andarci. Il
potenziometro interno esiste ma non è collegato al microcontrollore. Per questo **la politica
non osserva gli angoli dei giunti**: osserva solo quello che il robot vero conosce davvero
(SPEC §5.2): la gravità e il giroscopio dall'IMU, **le proprie ultime azioni**, il comando
`vx, wz` e un "orologio" di passo (seno e coseno a 1.6 Hz). Se in simulazione le dessimo gli
angoli veri, imparerebbe a usarli e sul robot non avrebbe niente al loro posto.

Il modello servo in `env.py` riproduce questa situazione: il comando parte con 10–30 ms di
ritardo, il "setpoint" interno del servo lo insegue al massimo a 8 rad/s, e il servo non
spinge se l'errore è sotto la banda morta (0.017 rad). Nell'MJCF l'attuatore ha coppia
limitata a ±0.2 N·m e un termine `kv = 0.2/8` che imita la forza contro-elettromotrice del
motore (a 8 rad/s la coppia disponibile va a zero).

## Perché ARS (Augmented Random Search)

ARS (Mania, Guy, Recht, 2018) è l'algoritmo di apprendimento per rinforzo più semplice che
funziona davvero sulla locomozione:

1. prendi i parametri θ della politica e N direzioni casuali δ;
2. prova θ + σδ e θ − σδ in simulazione e misura la ricompensa totale;
3. sposta θ verso le direzioni che hanno funzionato meglio (solo le migliori b: "V2-**t**"),
   normalizzando il passo con la dev. std. delle ricompense;
4. normalizza le osservazioni con media e dev. std. calcolate online ("**V2**").

Vantaggi per Pulcino:

- **niente GPU, niente backpropagation, niente PyTorch**: solo numpy e mujoco;
- ogni episodio è indipendente → si parallelizza perfettamente sui core della CPU
  (`--workers`); con 6 core si fanno ~5000 passi di controllo al secondo;
- pochissimi iperparametri (N, b, σ, α) e comportamento robusto;
- le coppie ± usano lo **stesso seed** dell'ambiente (stessa randomizzazione, stesse spinte):
  la differenza di ricompensa misura la direzione, non la fortuna.

Rete: MLP 16 → 32 → 32 → 6 con tanh, esattamente quella di `policy.h`. **Scelta documentata:**
alleniamo direttamente l'MLP, non prima una politica lineare (l'ARS "classico"). Motivo: una
politica lineare non si può scrivere esattamente nel formato di `policy.h` (che ha due strati
nascosti tanh) e servirebbe una conversione approssimata. Per partire tranquilli, l'ultimo
strato è inizializzato a zero: all'inizio la rete comanda la posa `stand` e ARS la "sblocca"
poco a poco.

### Modalità `--cpg`

Con `--cpg` lo stesso algoritmo ottimizza invece i **12 numeri di `gait.json`** (frequenza,
ampiezza/fase/offset dei tre tipi di giunto, guadagni del feedback IMU). È molto più veloce
(12 parametri invece di 1798) e il risultato è leggibile e modificabile a mano; è anche il
ripiego del firmware quando `POLICY_VALID` è 0. L'ottimizzazione parte da una versione
"prudente" (ampiezze ridotte) del gait di esempio di SPEC: con le ampiezze piene il robot
simulato cade in meno di un secondo. In simulazione il comando del CPG sale con una rampa di
1 s all'avvio (consigliata anche nel firmware, per non partire "a scalino").

## La ricompensa (`env.py`, `_ricompensa`)

Per ogni passo a 50 Hz:

| termine | peso | significato |
|---|---|---|
| sopravvivenza | +0.1 | piccola: se è grande, "stare fermi in piedi" diventa la strategia migliore |
| segui `vx` | +0.5 · exp(−(vx−vx*)²/0.08²) | velocità in avanti richiesta (vx=1 → 8 cm/s) |
| progresso | +1.0 · clip(vx/vx*, −1, 1) | dà un segnale anche quando si è ancora lenti |
| segui `wz` | +0.3 · exp(−(ωz−ωz*)²/0.4²) | rotazione richiesta (wz=1 → 0.8 rad/s) |
| clock | +0.3 | piede sinistro in aria nella prima metà del ciclo a 1.6 Hz, destro nella seconda |
| derapata | −0.1 · (vy/0.05)² | niente camminata di traverso |
| dritto | −1.0 · (gx²+gy²) | torso verticale |
| energia | −0.05 · potenza meccanica (W) | batteria e servo ringraziano |
| fluidità | −0.05 · ‖a − a_prec‖² | niente scatti (i servo veri non li seguono) |
| piedi incrociati | −0.5 | in simulazione i piedi non collidono tra loro, sul robot sì |
| caduta | −10 e fine episodio | inclinazione > 60° o torso sotto 8 cm |

## Risultati del training breve incluso (DEMO, non allenata a fondo)

`out/policy.h` e `out/gait.json` sono il risultato di **pochi minuti** di allenamento su un
Mac (6 processi), servono a dimostrare che la pipeline funziona dall'inizio alla fine, **non
sono pronti per il robot**. Sono marcati come demo (commento in `policy.h`, `name` in
`gait.json`). I log completi sono in `out/log_mlp.txt` e `out/log_cpg.txt`.

- **CPG** (`--cpg`, 150 iterazioni, ~1.5 min): ricompensa di valutazione da 68 (2 cadute su 6)
  a 240 (nessuna caduta); cammina in avanti a ~2–3 cm/s con la randomizzazione attiva.
- **MLP** (350 iterazioni, ~3.5 min): ricompensa di valutazione (episodi da 6 s) da 231 (sta
  solo in piedi) a ~290 (picco 323). Su episodi da 10 s (`play.py --headless`) avanza in media
  di ~6 cm/s ma **cade in 3 episodi su 5** e deriva di lato: serve un allenamento molto più lungo.
- `check_policy.py`: differenza massima tra `policy.h` ricalcolato "alla C" in float32 e la
  rete del checkpoint ~2·10⁻⁷ → export identico.

## Dal simulatore al robot vero

1. Monta e **calibra** i servo (`{"cmd":"calib90"}` e `/api/calib`): la randomizzazione
   copre ±3° di errore, non di più.
2. Verifica i segni sul robot con il viewer "joints" del sito o con
   `{"cmd":"servo",...}`: `hip_pitch` positivo deve portare il piede in avanti, `hip_roll`
   positivo verso l'esterno, `ankle_pitch` positivo la punta in su. Se no, correggi `SERVO_DIR[]`.
3. Verifica la **convenzione dell'IMU**: con il robot fermo in piedi la gravità nel frame corpo
   deve essere ≈ (0, 0, −1); inclinandolo in avanti (muso giù) la componente x diventa
   positiva, alzando il fianco sinistro la componente y diventa negativa. Il giroscopio è in
   rad/s nel frame corpo (x avanti, y sinistra, z su).
4. Copia `out/policy.h` in `firmware/pulcino/policy.h` (a mano, quando sei soddisfatto),
   ricompila, poi `{"cmd":"mode","m":"policy"}`. Il firmware deve applicare esattamente:
   normalizzazione e clip ±5, MLP, `q_target = POSE_STAND + a·ACTION_SCALE`,
   filtro `q_cmd = 0.6·q_target + 0.4·q_cmd_prec`, clip ai limiti, a 50 Hz,
   e l'osservazione "ultima azione" è `a` (prima della scala e del filtro).
5. Il `gait.json` si carica con `POST /api/gait` (o dalla web app) e si usa in modalità `cpg`.
6. Prime prove **tenendo il robot con un filo** sopra un tappeto. Se trema: più
   randomizzazione della latenza e più peso alla fluidità; se scivola: attrito più basso
   nell'intervallo di randomizzazione.

## Upgrade: PPO e MuJoCo Playground

ARS è perfetto per iniziare, ma per camminate davvero belle e robuste lo stato dell'arte è
**PPO** con migliaia di ambienti in parallelo:

- **MuJoCo Playground** (MJX/JAX, Google DeepMind) contiene ambienti di locomozione per
  piccoli bipedi (compreso un "Open Duck Mini"-like) e allena in minuti su una GPU. Il nostro
  `pulcino.xml` si può caricare in MJX quasi senza modifiche; la ricompensa e l'osservazione
  di `env.py` vanno riscritte in JAX.
- In alternativa **Stable-Baselines3** (PyTorch) avvolgendo `PulcinoEnv` in un'interfaccia
  Gymnasium (reset/step esistono già): più lento, ma senza GPU dedicata.
- Idee di miglioramento: curriculum (prima avanti, poi girare, poi indietro), osservazioni con
  storia (ultime 3 azioni), "imitation" da un gait CPG buono come punto di partenza della
  rete, randomizzazione anche dell'altezza del terreno.

Qualsiasi algoritmo si usi, il contratto non cambia: stessa osservazione a 16 valori, stessa
rete 16-32-32-6, stesso `policy.h`.

## Deviazioni e scelte non scritte in SPEC

- **Filtro passa-basso**: SPEC dice "α = 0.6" senza la formula; qui α è il peso della
  **nuova** azione: `q_cmd = 0.6·q_target + 0.4·q_cmd_prec`. Il firmware deve fare lo stesso.
- **Convenzione roll/pitch dell'IMU per il feedback del CPG**: roll > 0 = fianco sinistro in su,
  pitch > 0 = muso in giù (regola della mano destra su x e y). `roll = atan2(−g_y, −g_z)`,
  `pitch = atan2(g_x, −g_z)` dalla gravità nel frame corpo.
- **Collisioni**: i piedi non collidono tra loro (a giunti a zero si toccano esattamente sul
  bordo interno); c'è una penalità nella ricompensa per i piedi incrociati. Gambe e staffe
  non hanno collisioni, solo piedi e torso col pavimento.
- **Scala dei comandi** (non in SPEC): `vx = 1` ↔ 8 cm/s, `wz = 1` ↔ 0.8 rad/s.
- Il timestep è 4 ms (5 passi di fisica per passo di controllo), integratore `implicitfast`.


## Training persistente sul Mac mini

Il servizio `net.pulcino.training` esegue continuamente il curriculum A→B in
MuJoCo/CPG, con un solo processo monothread e priorità bassa. Il worker limita
cooperativamente il proprio tempo CPU al 50% quando il rendering è attivo (75%
nella configurazione precedente senza video), lasciando margine per il supervisore:
budget complessivo medio sotto un core, non pinning a un core fisico. Nessuna GPU.
Blocchi di 10 iterazioni, 8 direzioni, senza pause prefissate né limite di iterazioni.
Il budget cooperativo limita il carico anche durante l’esecuzione continua.
La Palestra monitora il server; non avvia né salva un training nel browser.

Gli otto livelli sono: piano, terreno casuale, ostacoli, terreno e ostacoli;
poi le stesse quattro condizioni su un piano inclinato casualmente fino a ±3°.
Rilievi di 0,5–5 mm, ostacoli fisici di 5 cm e perturbazioni/domain randomization.
A→B misura 18 cm; successo solo con piedi entro 3 cm da B, appoggio a terra,
senza caduta e prima di 20 secondi simulati. Il navigatore geometrico locale
produce comandi avanti/gira e considera gli ostacoli; il CPG apprende il controllo
delle gambe. Non apprende navigazione visiva o riconoscimento degli oggetti.

Dopo ogni blocco, il migliore viene verificato su 10 semi distinti e separati
(dal dominio dei semi di training). Solo 10 successi consecutivi con la stessa
politica permettono l'avanzamento. Fallimenti azzerano la serie. Nessun salto
basato sul reward. Il livello seguente eredita i parametri, ma usa un checkpoint
nuovo con punteggi/RNG propri. Ogni qualifica conserva checkpoint e prove.
Dopo l'ottavo livello continua il perfezionamento dell'ultimo, mantenendo la qualifica.
Non è garantito che l'algoritmo raggiunga tutti i livelli: la Palestra mostra anche
fallimenti e stalli, senza promozioni automatiche a tempo.

```sh
uv venv --python 3.12 training/.venv
uv pip install --python training/.venv/bin/python -r training/requirements.txt
training/.venv/bin/python training/service-install.py
```

I checkpoint latest e migliore sono in `training/runtime/curriculum/livello-N/`, fuori
 dalla build pubblica e conservati dopo logout/reboot. Ogni iterazione è salvata
con scrittura atomica, RNG, normalizzazione, storico e migliore ricompensa.
Dopo un crash si perde al massimo l'iterazione non ancora completata. LaunchAgent
riavvia il servizio in caso di errore e al login dell'utente; non impedisce lo sleep
 del Mac. Se il Mac dorme, il training riprende al risveglio. Un guasto al disco
richiede comunque un backup del sistema: due checkpoint non sono un backup esterno.

Per fermare il servizio conservando i progressi:

```sh
launchctl bootout gui/$(id -u)/net.pulcino.training
```

Per ripartire: riesegui `service-install.py`. Il percorso del Python virtualenv è
fissato nel LaunchAgent. La sola chiusura della pagina o dell'app Codex non ferma
il training. Non esistono comandi pubblici per avviare job o cambiare il budget.

La Palestra legge `training-status.json` ogni due secondi: livello, serie di successi,
prove, CPU/RAM, curva reward e percorso reale delle verifiche MuJoCo. Inoltre e permette di scaricare/provare
`server-gait.json`. Sono copie dei progressi/export; i checkpoint .npz e i log
restano locali. Il punteggio MuJoCo non è confrontabile con il punteggio Rapier.
Cambiare l'obiettivo di un checkpoint è rifiutato: usa un job distinto.

### Benchmark storico a due worker (configurazione precedente)

Mac mini Mac16,10, 10 core logici, 16 GB RAM. Prova di 25 iterazioni CPG/fermo,
16 direzioni, due worker, episodi 6 s: 19,22 s; CPU aggregata 142,2% di un core,
cioè 14,2% del server; picco RSS aggregata 182,9 MB (include memoria condivisa
 conteggiata da più processi). Con pause da 30 s il carico medio stimato è circa
5,6%, se la durata dei blocchi resta simile. Stima iniziale per 1000 iterazioni:
circa 30–40 minuti, variabile con durata degli episodi e altri carichi.
Watt e consumo elettrico non sono stati misurati: non si ricavano da CPU%.

```sh
training/.venv/bin/python training/benchmark.py
training/.venv/bin/python training/test_checkpoint.py
training/.venv/bin/python training/test_curriculum.py
```

La suite verifica che training continuo e spezzato diano gli stessi parametri/RNG
e che un salvataggio fallito lasci intatto il precedente. Il CPG ora rispetta
`--cmd fermo` (in precedenza forzava avanti anche in quel caso).


## Rendering 3D e streaming RTMP/HLS

`render_live.py` cattura il modello/stato MuJoCo effettivo del worker, durante
allenamento e verifiche, al massimo 4 fps a 480×360. Camera fissa, A/B colorati,
banana visiva di 18 cm. I fotogrammi vengono passati singolarmente a FFmpeg via image2pipe, evitando
il loop di un’immagine memorizzata. Non è una ricostruzione Rapier: anche le cadute sono reali
nel mondo MuJoCo. Il training è accelerato; i fotogrammi sono campionati in tempo
reale del server, non tutti i passi di fisica. Nelle pause il frame resta fermo.

`stream-service.py` pubblica con FFmpeg e H.264 VideoToolbox hardware a
`rtmp://127.0.0.1:11935/live/pulcino`. Un secondo FFmpeg riceve quell'RTMP e
rimuxa senza ricodifica in HLS. Non è un server RTMP multiutente: il ricevitore
locale ha un singolo publisher; gli spettatori usano HLS. RTMP non è esposto dal
Funnel. L'uscita pubblica HTTPS è `live/index.m3u8` sotto il sito.

```sh
# Richiede FFmpeg sul Mac, incluso h264_videotoolbox.
training/.venv/bin/python training/stream-install.py
npm run check:stream
```

Safari usa HLS nativo; Chromium/Firefox usano hls.js 1.7.3. La Palestra include
il player e il pulsante di riconnessione. Segmenti da 2 s, playlist di 6 segmenti:
ritardo di alcuni secondi. Segmenti vecchi eliminati; non viene archiviato un filmato
continuo. `site/live/` è generato e ignorato da Git/build, poi il servizio ne copia
atomicamente l'uscita in `dist/live/`. Checkpoint/frame grezzi restano privati in
`training/runtime/`. Il workflow GitHub Pages non avvia il rendering.

Worker limitato cooperativamente al 50% di un core per lasciare margine al video;
telemetria CPU/RAM include training, relay e FFmpeg. Campione di 10 s del sistema
attivo: 27% di un core e 223,3 MB RSS aggregata. Il carico varia e usa anche GPU/
VideoToolbox: non è una misura in watt né un'affinità rigida a un core fisico.

Il servizio `net.pulcino.stream` riparte al login e dopo errori; stop con
`launchctl bootout gui/$(id -u)/net.pulcino.stream`. Il training resta indipendente
dal browser. Rendering senza sessione grafica/logged out non è stato collaudato.

Riferimenti: [MuJoCo Renderer](https://mujoco.readthedocs.io/en/latest/python.html),
[FFmpeg protocolli](https://ffmpeg.org/ffmpeg-protocols.html),
[hls.js e HLS nativo](https://github.com/video-dev/hls.js).
