|
| 1 | +# ADR 0070 — Scenario-Scheduled Device Commands: `commands`-Block + ScenarioCommandEngine (Provisional) |
| 2 | + |
| 3 | +**Status:** Provisional — Owner traegt die Empfehlung mit (S0, full-Mechanismus- |
| 4 | +Mandat; [`ADR 0006`](0006-adr-lifecycle-superseding-and-process-corrections.md) §2); die |
| 5 | +Validierung laeuft ueber S1..S3 des |
| 6 | +[`in-progress`-Slice-Plans](../planning/in-progress/scenario-scheduled-device-commands.md). |
| 7 | +`Accepted` folgt bei der Implementierungs-Wellen-Closure (gates gruen); bis dahin |
| 8 | +bleibt der Entwurf schaerfbar — offener Platzierungs-Punkt (§2.1 top-level vs. |
| 9 | +nested) ist begruendet entschieden, aber noch **kein** immutable Beschluss. |
| 10 | +**Datum:** 2026-06-18 |
| 11 | +**Status geaendert am:** 2026-06-18 — `Proposed → Provisional` (S0; Owner- |
| 12 | +Mittragung des Voll-Mechanismus statt der schlanken Agents-Deckung). |
| 13 | +**Bezug:** |
| 14 | + |
| 15 | +- [`ADR 0022`](0022-fault-injection-protocol.md) + [`ADR 0059`](0059-generic-scenario-fault-engine.md) — |
| 16 | + die tick-genaue **Fault-Planung** (`faults`-Block + `ScenarioFaultEngine`) ist |
| 17 | + das strukturelle Template; diese ADR zieht den analogen Command-Pfad ein |
| 18 | + (kein Supersedes, Pattern [`ADR 0011`](0011-schaerfung-ohne-abloesung.md)). |
| 19 | +- [`ADR 0013`](0013-device-model-protocol.md) §2.3 — `apply_command`-Vertrag + |
| 20 | + Command-Reihenfolge aus der Scenario-Source; hier um eine **scenario-getriebene** |
| 21 | + Quelle erweitert (bisher nur Agents/Inline). |
| 22 | +- [`ADR 0027`](0027-rule-based-agent-scenario-pattern.md) — der bestehende |
| 23 | + Agents/Rules-Command-Pfad; bewusste Abgrenzung (§3). |
| 24 | +- [`ADR 0021`](0021-scenario-loader-and-tick-loop-event-wiring.md) — Loader- + |
| 25 | + TickLoop-Event-Verdrahtung (optionale Top-Level-Bloecke, pin-neutral default); |
| 26 | + das `commands`-Schema + die Engine-Naht setzen genau hier an. |
| 27 | +- [`ADR 0051`](0051-fault-engine-location-and-naming.md) — Placement-/Naming- |
| 28 | + Konvention der Scenario-Engines (Vorbild fuer `ScenarioCommandEngine`). |
| 29 | +- [Trigger 046](../planning/open/046-command-driven-integration-e2e.md) — |
| 30 | + der aufloesende Trigger (Command-E2E der SOLLTE-Geraete). |
| 31 | + |
| 32 | +--- |
| 33 | + |
| 34 | +## 1. Kontext |
| 35 | + |
| 36 | +Jedes SOLLTE-Geraet ([`GG-DEV-015`](../../../spec/lastenheft.md#gg-dev-015)..018) |
| 37 | +implementiert `apply_command` ([`ADR 0013`](0013-device-model-protocol.md)), aber |
| 38 | +die vier Szenario-Smokes fahren **idle**: der `devices`-Layer kennt **keinen** |
| 39 | +scenario-scheduled-Command-Mechanismus. Faults werden tick-genau geplant |
| 40 | +(`faults`-Block → [`ScenarioFaultEngine`](0059-generic-scenario-fault-engine.md) → |
| 41 | +`TickLoop` Schritt A2), Commands nicht. Commands erreichen `apply_command` heute |
| 42 | +nur ueber zwei Quellen: |
| 43 | + |
| 44 | +- **Agents** (`rule_based`, [`ADR 0027`](0027-rule-based-agent-scenario-pattern.md)): |
| 45 | + `agents.<name>.rules[*].action` → `_pending_agent_commands` → `TickLoop`-Schritt |
| 46 | + A0a (`_apply_pending_agent_commands`) → `device.apply_command`. |
| 47 | +- **Inline Load-Overlay** (`load_events`/`load_profiles`) — direkte Calls, keine |
| 48 | + `Command`-Objekte. |
| 49 | + |
| 50 | +Die generische Command-Routing-Mechanik ist damit gedeckt; es fehlt der |
| 51 | +**geraetespezifische, scenario-deklarierte** E2E-Pfad „Command kommt via Szenario |
| 52 | +rein → SOLLTE-Geraet reagiert sichtbar im Snapshot". |
| 53 | + |
| 54 | +## 2. Decision |
| 55 | + |
| 56 | +### 2.1 Schema — optionaler Top-Level-`commands`-Block |
| 57 | + |
| 58 | +Ein optionaler Top-Level-`commands`-Block, **strukturell analog zu `faults`** |
| 59 | +(beide Top-Level, beide zielen auf ein `target`-Device): |
| 60 | + |
| 61 | +```yaml |
| 62 | +commands: |
| 63 | + - simulation_time: 5000 # ms; zugestellt am Tick, dessen Span simulation_time enthaelt |
| 64 | + target: "ev-1" # device_id |
| 65 | + type: "set_charge_power" |
| 66 | + payload: { value: "20" } # Decimal-als-String (Variante A — canonical_json verbietet float) |
| 67 | +``` |
| 68 | +
|
| 69 | +**Punkt-in-der-Zeit** (kein `duration_ms` wie bei Faults): ein Command wird genau |
| 70 | +**einmal** am geplanten Tick zugestellt (kein Fenster, kein Recovery). |
| 71 | + |
| 72 | +**Top-level statt nested im `devices`-Block** (begruendete Wahl): spiegelt `faults` |
| 73 | +1:1 → einheitlicher Loader-/Validator-/`canonical_json`-/Hash-Pfad, und die |
| 74 | +Geraete-Definition bleibt rein deklarativ (keine eingebettete Zeitreihe). Die |
| 75 | +Trigger-046-Formulierung „im `devices`-Block" meint *an die Geraete gerichtete* |
| 76 | +Commands, nicht eine YAML-Verschachtelung. (Nested-per-Device als Alternative in |
| 77 | +§3 verworfen.) |
| 78 | + |
| 79 | +### 2.2 Domain — `ScenarioCommand` + `Scenario.commands` |
| 80 | + |
| 81 | +`ScenarioCommand` (frozen dataclass, analog `ScenarioFault`): |
| 82 | +`simulation_time: int`, `target: str`, `type: str`, `payload: Mapping[str, object]`. |
| 83 | +NEU optionales Feld `Scenario.commands: tuple[ScenarioCommand, ...] = ()` (Default |
| 84 | +leer, analog `agents`/`load_events` — **pin-neutral** fuer alle Bestands-Szenarien). |
| 85 | +Loader: `_build_commands`/`_build_command` analog `_build_faults`/`_build_fault`. |
| 86 | +Validator-Strang fuer den `commands`-Block analog dem `faults`-Strang. |
| 87 | + |
| 88 | +### 2.3 ScenarioCommandEngine + TickLoop-Naht |
| 89 | + |
| 90 | +`ScenarioCommandEngine` ([`ADR 0051`](0051-fault-engine-location-and-naming.md)- |
| 91 | +Naming) haelt die geplanten Commands und liefert pro Tick die **faelligen** |
| 92 | +`Command`-Objekte: `due_commands(context) -> Sequence[Command]` — jene, deren |
| 93 | +`simulation_time` in den aktuellen Tick-Span faellt. Jeder `ScenarioCommand` → |
| 94 | +ein [`Command`](../../../src/grid_gym/hexagon/core/domain/command.py) |
| 95 | +(`command_id="scenario-cmd-<i>"`, `target_device_id=target`, `type`, `payload`, |
| 96 | +`validation_status="scenario"`). |
| 97 | + |
| 98 | +`TickLoop`-Naht: eine neue Vor-Tick-Stufe (analog Fault-Schritt A2) speist die |
| 99 | +faelligen Commands in den **bestehenden** Apply-Pfad |
| 100 | +(`_device_by_id[target].apply_command`, wie `_apply_pending_agent_commands`). |
| 101 | +**Determinismus-Reihenfolge** (festzulegen, [`ADR 0013`](0013-device-model-protocol.md) §2.3): |
| 102 | +scenario-Commands werden in Scenario-Source-Reihenfolge zugestellt und laufen |
| 103 | +**vor** den Agent-Commands desselben Ticks (scenario-Commands sind externe |
| 104 | +geplante Inputs wie Faults; Agents reagieren auf den dadurch entstandenen |
| 105 | +Zustand). Nicht-existentes `target` → typisierter Loader-/Validierungs-Fehler |
| 106 | +(kein Silent-Drop). |
| 107 | + |
| 108 | +### 2.4 Canonical / Hash / Determinismus |
| 109 | + |
| 110 | +`commands` ist Teil von `Scenario` → automatisch in |
| 111 | +`canonical_json(asdict(scenario))` → **`scenario_hash`**. Payload-Werte sind |
| 112 | +Decimal-als-String (Variante A; `canonical_json` verbietet `float`). Determinismus |
| 113 | +([`GG-SIM-001`](../../../spec/lastenheft.md#gg-sim-001)/004, |
| 114 | +[`GG-MVP-002`](../../../spec/lastenheft.md#gg-mvp-002)-Replay): gleicher |
| 115 | +`scenario_hash` + Seed → identischer Command-Zeitplan → byte-identische Telemetrie. |
| 116 | +**Pin-neutral**: Szenario ohne `commands` → leeres Tupel → Hash unveraendert → |
| 117 | +Bestands-Pins (inkl. die vier SOLLTE-Idle-Smokes) bit-genau gueltig. |
| 118 | + |
| 119 | +### 2.5 E2E-Scope (S3, schliesst Trigger 046) |
| 120 | + |
| 121 | +Je SOLLTE-Geraet ein **nicht-idle** Integration-E2E (Command via `commands`-Block |
| 122 | +geplant → Snapshot-Assertion): |
| 123 | + |
| 124 | +- **EV-Charger**: `set_charge_power` → `power_kw` springt am geplanten Tick (+ SoC |
| 125 | + bewegt sich); `set_plug_state` optional. |
| 126 | +- **Transformer**: `set_power_kw` → `primary_power_kw`. |
| 127 | +- **Diesel**: `set_power_kw` (>=0) → `power_kw`/`running`. |
| 128 | +- **Wind-Turbine**: nimmt **keine** Commands ([`ADR 0057`](0057-wind-turbine-device-pattern.md) |
| 129 | + §2.1 → `apply_command` = `CommandResult.IGNORED`). E2E belegt: Command wird via |
| 130 | + Zeitplan zugestellt, Wind **ignoriert** ihn (`power_kw` bleibt wettergetrieben) — |
| 131 | + positiver Beleg der `IGNORED`-Semantik. |
| 132 | + |
| 133 | +## 3. Verworfene Alternativen |
| 134 | + |
| 135 | +- **Nested `commands:` pro Device-Definition** — bricht die `faults`-Analogie, |
| 136 | + verteilt Zeitplan-Logik in die Geraete-Bloecke, erschwert Validator/Canonical |
| 137 | + (heterogene Device-Schemata). Verworfen zugunsten Top-Level (§2.1). |
| 138 | +- **Nur Agents/Rules-Deckung** (schlanker Pfad) — deckt die `apply_command`- |
| 139 | + Integrations-Luecke, liefert aber **nicht** den scenario-deklarierten |
| 140 | + `devices`-Command-Mechanismus, den Trigger 046 als Closure-Kriterium fordert. |
| 141 | + Vom Owner zugunsten des Voll-Mechanismus verworfen. |
| 142 | + |
| 143 | +## 4. Konsequenzen |
| 144 | + |
| 145 | +- NEU: `commands`-Schema + `ScenarioCommand` + Loader + `ScenarioCommandEngine` + |
| 146 | + `TickLoop`-Naht + 4 SOLLTE-E2E (Slice-Plan S1..S3). |
| 147 | +- `scenario_hash` deckt jetzt `commands` ab (additive, default-leere Erweiterung). |
| 148 | +- Out-of-Scope: bedingte/konditionale Commands (nur tick-geplant), Command-Result- |
| 149 | + Rueckkopplung ins Szenario, Runtime-API-Command-Injektion (Agents/API-Pfad), |
| 150 | + Recovery/Fenster-Semantik (Punkt-in-der-Zeit). |
0 commit comments