watt-the-hack 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1 @@
1
+ """Headless energy grid simulation watt_the_hack."""
@@ -0,0 +1 @@
1
+ """HTTP API layer for the energy grid simulation."""
@@ -0,0 +1,147 @@
1
+ """Restricted execution of user-provided controller source code."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import builtins
6
+ import math
7
+ import threading
8
+ from typing import Any, Callable
9
+
10
+
11
+ _SAFE_BUILTIN_NAMES = (
12
+ "abs",
13
+ "all",
14
+ "any",
15
+ "bool",
16
+ "dict",
17
+ "divmod",
18
+ "enumerate",
19
+ "filter",
20
+ "float",
21
+ "int",
22
+ "len",
23
+ "list",
24
+ "map",
25
+ "max",
26
+ "min",
27
+ "pow",
28
+ "range",
29
+ "reversed",
30
+ "round",
31
+ "set",
32
+ "sorted",
33
+ "str",
34
+ "sum",
35
+ "tuple",
36
+ "zip",
37
+ )
38
+
39
+
40
+ SAFE_BUILTINS: dict[str, Any] = {
41
+ name: getattr(builtins, name) for name in _SAFE_BUILTIN_NAMES
42
+ }
43
+
44
+
45
+ DISALLOWED_TOKENS = (
46
+ "__import__",
47
+ "__builtins__",
48
+ "__class__",
49
+ "__bases__",
50
+ "__subclasses__",
51
+ "__globals__",
52
+ "open(",
53
+ "exec(",
54
+ "eval(",
55
+ "compile(",
56
+ "globals(",
57
+ "locals(",
58
+ "getattr(",
59
+ "setattr(",
60
+ "delattr(",
61
+ )
62
+
63
+
64
+ class ControllerCompileError(ValueError):
65
+ """Raised when user-provided controller source fails to compile or execute."""
66
+
67
+
68
+ def compile_controller_source(source: str) -> Callable[[dict], dict]:
69
+ """Compile a user-provided controller source string into a callable.
70
+
71
+ The returned callable expects `state` and returns an `action` dict. The
72
+ caller is responsible for catching runtime errors that occur inside the
73
+ compiled controller.
74
+ """
75
+
76
+ if not isinstance(source, str) or not source.strip():
77
+ raise ControllerCompileError("Controller source must be a non-empty string.")
78
+
79
+ if len(source.encode("utf-8")) > 50 * 1024:
80
+ raise ControllerCompileError("Controller source exceeds 50 KB size limit.")
81
+
82
+ for token in DISALLOWED_TOKENS:
83
+ if token in source:
84
+ raise ControllerCompileError(
85
+ f"Disallowed token in controller source: '{token}'."
86
+ )
87
+
88
+ if _contains_import_statement(source):
89
+ raise ControllerCompileError("Imports are not allowed in controller source.")
90
+
91
+ namespace: dict[str, Any] = {"__builtins__": SAFE_BUILTINS}
92
+ try:
93
+ compiled = compile(source, "<user-controller>", "exec")
94
+ exec(compiled, namespace)
95
+ except SyntaxError as exc:
96
+ raise ControllerCompileError(
97
+ f"Syntax error: {exc.msg} at line {exc.lineno}"
98
+ ) from exc
99
+ except Exception as exc:
100
+ raise ControllerCompileError(f"Compilation failed: {exc}") from exc
101
+
102
+ controller = namespace.get("controller")
103
+ if not callable(controller):
104
+ raise ControllerCompileError(
105
+ "Controller source must define a `controller(state)` function."
106
+ )
107
+
108
+ def safe_controller(state: dict) -> dict:
109
+ result = {}
110
+ exc_info = []
111
+
112
+ def worker():
113
+ try:
114
+ action = controller(state)
115
+ if not isinstance(action, dict):
116
+ exc_info.append(ValueError("Controller must return a dict."))
117
+ else:
118
+ result.update(action)
119
+ except Exception as e:
120
+ exc_info.append(e)
121
+
122
+ t = threading.Thread(target=worker, daemon=True)
123
+ t.start()
124
+ t.join(timeout=0.1) # 100ms time limit
125
+
126
+ if t.is_alive():
127
+ raise TimeoutError("Controller execution exceeded 100ms time limit.")
128
+
129
+ if exc_info:
130
+ raise exc_info[0]
131
+
132
+ for k, v in result.items():
133
+ if isinstance(v, (int, float)):
134
+ if math.isnan(v) or math.isinf(v):
135
+ raise ValueError(f"Action value for '{k}' is NaN or infinity.")
136
+
137
+ return result
138
+
139
+ return safe_controller
140
+
141
+
142
+ def _contains_import_statement(source: str) -> bool:
143
+ for raw_line in source.splitlines():
144
+ line = raw_line.strip()
145
+ if line.startswith("import ") or line.startswith("from "):
146
+ return True
147
+ return False
@@ -0,0 +1,482 @@
1
+ """FastAPI HTTP layer for the energy grid simulation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections import defaultdict
6
+ import os
7
+ from typing import Annotated, Any, Literal, Union
8
+
9
+ from fastapi import FastAPI, HTTPException
10
+ from fastapi.middleware.cors import CORSMiddleware
11
+ from pydantic import BaseModel, Field
12
+
13
+ from dotenv import load_dotenv
14
+ load_dotenv()
15
+
16
+ from watt_the_hack.api.sandbox import ControllerCompileError, compile_controller_source
17
+ from watt_the_hack.constants import DEFAULT_STEPS
18
+ from watt_the_hack.controllers.parametric import (
19
+ ParametricControllerParams,
20
+ make_parametric_controller,
21
+ )
22
+
23
+ from watt_the_hack.data_loaders.scenarios import (
24
+ config_overrides as scenario_config_overrides,
25
+ find_scenario_by_id,
26
+ list_scenarios,
27
+ load_scenario,
28
+ public_metadata,
29
+ scoring_config,
30
+ )
31
+ from watt_the_hack.engine.engine import Engine, SimulationConfig
32
+ from watt_the_hack.metrics.metrics import Metrics
33
+ from watt_the_hack.simulation.runner import run_strategy
34
+ from watt_the_hack.simulation.strategy import ResolvedStrategy
35
+
36
+
37
+ class ParametricControllerParamsModel(BaseModel):
38
+ battery_flow_mw: float = 0.0
39
+ emergency_generator: float = 0.0
40
+ curtail_solar: float = 0.0
41
+ fcas_reserve_mw: float = 0.0
42
+ subscribe_ids: bool = False
43
+
44
+
45
+ class SimpleControllerSpec(BaseModel):
46
+ kind: Literal["simple"] = "simple"
47
+ params: ParametricControllerParamsModel = Field(
48
+ default_factory=ParametricControllerParamsModel
49
+ )
50
+
51
+
52
+ class CodeControllerSpec(BaseModel):
53
+ kind: Literal["code"] = "code"
54
+ source: str
55
+
56
+
57
+ ControllerSpec = Annotated[
58
+ Union[SimpleControllerSpec, CodeControllerSpec],
59
+ Field(discriminator="kind"),
60
+ ]
61
+
62
+
63
+ class InitRequest(BaseModel):
64
+ steps: int = DEFAULT_STEPS
65
+ scenario_id: str | None = None # if set, loads scenario instead of defaults
66
+
67
+
68
+ class InitResponse(BaseModel):
69
+ state: dict[str, Any]
70
+ steps: int
71
+ scenario: dict[str, Any] | None = None # spec metadata if a scenario was loaded
72
+
73
+
74
+ class ScenarioSummary(BaseModel):
75
+ id: str
76
+ title: str
77
+ pool: str
78
+ archetype: str
79
+ one_liner: str
80
+ path: str
81
+ mechanics: list[str] = Field(default_factory=list)
82
+
83
+
84
+ class StepRequest(BaseModel):
85
+ state: dict[str, Any]
86
+ controller: ControllerSpec = Field(default_factory=SimpleControllerSpec)
87
+
88
+
89
+ class StepResponse(BaseModel):
90
+ state: dict[str, Any]
91
+ outputs: dict[str, Any]
92
+ controller_error: str | None = None
93
+
94
+
95
+ class RunRequest(BaseModel):
96
+ state: dict[str, Any]
97
+ controller: ControllerSpec = Field(default_factory=SimpleControllerSpec)
98
+ steps: int = DEFAULT_STEPS
99
+ scenario_id: str | None = None # for scoring with the scenario's baselines
100
+ team_id: str | None = None # used for rate limiting judging scenarios
101
+ team_token: str | None = None # secret token to prevent team spoofing
102
+
103
+
104
+ class MetricsSummary(BaseModel):
105
+ renewable_ratio: float
106
+ grid_stability: float
107
+ cost: float
108
+ unmet_demand_total: float = 0.0
109
+ final_score: float
110
+
111
+
112
+ class RunResponse(BaseModel):
113
+ final_state: dict[str, Any]
114
+ states: list[dict[str, Any]]
115
+ outputs: list[dict[str, Any]]
116
+ metrics: MetricsSummary
117
+ controller_error: str | None = None
118
+
119
+
120
+ app = FastAPI(title="Watt The Hack Simulation API", version="0.1.0")
121
+
122
+ app.add_middleware(
123
+ CORSMiddleware,
124
+ allow_origins=["*"],
125
+ allow_credentials=False,
126
+ allow_methods=["*"],
127
+ allow_headers=["*"],
128
+ )
129
+
130
+
131
+ _engine = Engine()
132
+ _submission_counts: dict[str, int] = defaultdict(int)
133
+
134
+ # In production (e.g. on Render), if you want the public to be able to playtest
135
+ # the synthetic/tutorial scenarios before the event, set AUTO_UNLOCK=true in your environment variables.
136
+ # For the actual hackathon where you incrementally release scenarios, set AUTO_UNLOCK=false.
137
+ _auto_unlock_env = os.getenv("WATT_THE_HACK_AUTO_UNLOCK", os.getenv("AUTO_UNLOCK", "true"))
138
+ _auto_unlock = str(_auto_unlock_env).lower() == "true"
139
+
140
+ _unlocked_env = os.getenv("WATT_THE_HACK_UNLOCKED_SCENARIOS")
141
+ if _unlocked_env is not None:
142
+ _unlocked_scenarios: set[str] = {s.strip() for s in _unlocked_env.split(",") if s.strip()}
143
+ else:
144
+ _unlocked_scenarios: set[str] = {"duck_curve"}
145
+
146
+ if _auto_unlock:
147
+ _unlocked_scenarios.update(s["id"] for s in list_scenarios(include_judging=False))
148
+
149
+ # Simple API Authentication for Judging
150
+ # Replace these with your actual hackathon teams and random passwords
151
+ REGISTERED_TEAMS = {"team_alpha": "secret_abc123", "team_beta": "secret_xyz987"}
152
+
153
+ ADMIN_TOKEN = "hackathon_admin_secret"
154
+
155
+
156
+ class ScenarioUnlockRequest(BaseModel):
157
+ scenario_id: str
158
+ admin_token: str
159
+
160
+
161
+ @app.post("/admin/scenarios/unlock")
162
+ def admin_unlock_scenario(req: ScenarioUnlockRequest):
163
+ if req.admin_token != ADMIN_TOKEN:
164
+ raise HTTPException(status_code=401, detail="Unauthorized")
165
+ _unlocked_scenarios.add(req.scenario_id)
166
+ return {"status": "ok", "unlocked": list(_unlocked_scenarios)}
167
+
168
+
169
+ @app.post("/admin/scenarios/lock")
170
+ def admin_lock_scenario(req: ScenarioUnlockRequest):
171
+ if req.admin_token != ADMIN_TOKEN:
172
+ raise HTTPException(status_code=401, detail="Unauthorized")
173
+ _unlocked_scenarios.discard(req.scenario_id)
174
+ return {"status": "ok", "unlocked": list(_unlocked_scenarios)}
175
+
176
+
177
+ @app.get("/admin/scenarios/status")
178
+ def admin_scenarios_status(admin_token: str):
179
+ if admin_token != ADMIN_TOKEN:
180
+ raise HTTPException(status_code=401, detail="Unauthorized")
181
+ all_scenarios = [s["id"] for s in list_scenarios(include_judging=True)]
182
+ return {
183
+ "unlocked": list(_unlocked_scenarios),
184
+ "locked": list(set(all_scenarios) - _unlocked_scenarios),
185
+ }
186
+
187
+
188
+ @app.get("/health")
189
+ def health() -> dict[str, str]:
190
+ return {"status": "ok"}
191
+
192
+
193
+ @app.get("/sim/scenarios", response_model=list[ScenarioSummary])
194
+ def sim_scenarios() -> list[ScenarioSummary]:
195
+ # Only return scenarios that are explicitly unlocked by the admin
196
+ return [
197
+ ScenarioSummary(**s) for s in list_scenarios() if s["id"] in _unlocked_scenarios
198
+ ]
199
+
200
+
201
+ @app.post("/sim/init", response_model=InitResponse)
202
+ def sim_init(req: InitRequest = InitRequest()) -> InitResponse:
203
+ spec_meta: dict[str, Any] | None = None
204
+ if not req.scenario_id:
205
+ raise HTTPException(
206
+ status_code=400,
207
+ detail="scenario_id is required. The default profile has been removed.",
208
+ )
209
+
210
+ if req.scenario_id not in _unlocked_scenarios:
211
+ raise HTTPException(
212
+ status_code=403,
213
+ detail="This scenario is locked or has not been released yet.",
214
+ )
215
+
216
+ path = find_scenario_by_id(req.scenario_id)
217
+ if path is None:
218
+ raise ValueError(f"Unknown scenario_id: {req.scenario_id!r}")
219
+
220
+ spec, state = load_scenario(path)
221
+ spec_meta = public_metadata(spec)
222
+ # Number of steps comes from the loaded profiles, not the request.
223
+ # Read the private series — controllers never see this length directly.
224
+ steps = len(state["_profiles_full"]["demand"])
225
+
226
+ _engine.add_forecast_to_state(state)
227
+
228
+ if _is_judging_scenario(spec_meta.get("pool") if spec_meta else None):
229
+ raise HTTPException(
230
+ status_code=403,
231
+ detail="Judging scenarios cannot be initialized step-by-step. Use /sim/run for full evaluation.",
232
+ )
233
+
234
+ _prepare_state_for_response(state)
235
+ return InitResponse(state=state, steps=steps, scenario=spec_meta)
236
+
237
+
238
+ @app.post("/sim/step", response_model=StepResponse)
239
+ def sim_step(req: StepRequest) -> StepResponse:
240
+ controller_fn, controller_error = _resolve_controller(req.controller)
241
+ controller_state = _state_visible_to_controller(req.state)
242
+ try:
243
+ action = controller_fn(controller_state)
244
+ except Exception as exc:
245
+ action = _fallback_controller()(controller_state)
246
+ controller_error = controller_error or f"Runtime error: {exc}"
247
+
248
+ engine_state = _rehydrate_state_for_engine(req.state)
249
+ new_state, outputs = _engine.step(engine_state, action)
250
+
251
+ scenario_id = req.state.get("scenario_id")
252
+ if scenario_id and _is_judging_scenario_by_id(scenario_id):
253
+ raise HTTPException(
254
+ status_code=403,
255
+ detail="Judging scenarios cannot be stepped. Use /sim/run for full evaluation.",
256
+ )
257
+
258
+ _prepare_state_for_response(new_state)
259
+ return StepResponse(
260
+ state=new_state, outputs=outputs, controller_error=controller_error
261
+ )
262
+
263
+
264
+ @app.post("/sim/run", response_model=RunResponse)
265
+ def sim_run(req: RunRequest) -> RunResponse:
266
+ controller_fn, controller_error = _resolve_controller(req.controller)
267
+ fallback = _fallback_controller()
268
+
269
+ is_judging = False
270
+ scoring = {}
271
+ overrides = {}
272
+ path = None
273
+ scenario_id = req.scenario_id or req.state.get("scenario_id")
274
+ if scenario_id:
275
+ if scenario_id not in _unlocked_scenarios:
276
+ raise HTTPException(
277
+ status_code=403,
278
+ detail="This scenario is locked or has not been released yet.",
279
+ )
280
+ path = find_scenario_by_id(scenario_id)
281
+ if path is not None:
282
+ spec, _ = load_scenario(path)
283
+ scoring = scoring_config(spec)
284
+ overrides = scenario_config_overrides(spec)
285
+ is_judging = spec.get("pool") == "judging"
286
+
287
+ # Build a per-run engine if the scenario has physics overrides, otherwise
288
+ # reuse the shared global instance (no object allocation cost for normal runs).
289
+ run_engine = Engine(config=SimulationConfig(**overrides)) if overrides else _engine
290
+
291
+ dt_hours = getattr(run_engine, "dt_hours", run_engine.config.dt_hours)
292
+ metrics = Metrics(
293
+ dt_hours=dt_hours,
294
+ baselines={**Metrics().baselines, **scoring.get("baselines", {})},
295
+ )
296
+
297
+ if is_judging:
298
+ team = req.team_id
299
+ if not team:
300
+ raise HTTPException(
301
+ status_code=401, detail="team_id is required for judging scenarios."
302
+ )
303
+
304
+ expected_token = REGISTERED_TEAMS.get(team)
305
+ if not expected_token:
306
+ raise HTTPException(
307
+ status_code=401,
308
+ detail=f"Unregistered team_id: '{team}'. Please contact the organizers.",
309
+ )
310
+
311
+ if req.team_token != expected_token:
312
+ raise HTTPException(
313
+ status_code=401, detail="Invalid team_token. Authentication failed."
314
+ )
315
+
316
+ if _submission_counts[team] >= 3:
317
+ raise HTTPException(
318
+ status_code=429,
319
+ detail="Rate limit exceeded: max 3 judging submissions per team.",
320
+ )
321
+ _submission_counts[team] += 1
322
+
323
+ state = _rehydrate_state_for_engine(req.state)
324
+ # The browser may send a controller-visible state with future traces hidden.
325
+ # Rehydrate engine-only scenario fields before physics, then strip them
326
+ # again from every response snapshot.
327
+ if is_judging and path is not None:
328
+ _, full_state = load_scenario(path)
329
+ run_engine.add_forecast_to_state(full_state)
330
+ state = full_state
331
+
332
+ states: list[dict[str, Any]] = []
333
+ outputs_list: list[dict[str, Any]] = []
334
+
335
+ # Browser playground controllers are function-only (no plan/replan), so
336
+ # we wrap controller_fn in a ResolvedStrategy with a fallback shim — the
337
+ # sandbox already swallows participant exceptions, but the shared core
338
+ # also catches them via on_error if anything slips through. Using
339
+ # run_strategy here keeps the playground on the same simulation
340
+ # pipeline as the local CLI and the cloud admin container, so any
341
+ # engine-contract change automatically applies to all three.
342
+ def _safe_step(view: dict) -> dict:
343
+ nonlocal controller_error
344
+ try:
345
+ return controller_fn(view)
346
+ except Exception as exc: # noqa: BLE001
347
+ controller_error = controller_error or f"Runtime error: {exc}"
348
+ return fallback(view)
349
+
350
+ def _capture(_i: int, _view, _action, outputs: dict, post_state: dict) -> None:
351
+ out_state = dict(post_state)
352
+ out_outputs = dict(outputs)
353
+ if is_judging:
354
+ _strip_judging_data(out_state, out_outputs)
355
+ _prepare_state_for_response(out_state)
356
+ states.append(out_state)
357
+ outputs_list.append(out_outputs)
358
+
359
+ strategy = ResolvedStrategy(step=_safe_step, kind="callable", name="browser")
360
+ run_result = run_strategy(
361
+ run_engine,
362
+ state,
363
+ strategy,
364
+ req.steps,
365
+ on_step=_capture,
366
+ metrics=metrics,
367
+ )
368
+ state = run_result["final_state"]
369
+
370
+ final_state = dict(state)
371
+ if is_judging:
372
+ _strip_judging_data(final_state)
373
+ # Security: Do not return step-by-step history for judging scenarios
374
+ states = []
375
+ outputs_list = []
376
+
377
+ _prepare_state_for_response(final_state)
378
+
379
+ return RunResponse(
380
+ final_state=final_state,
381
+ states=states,
382
+ outputs=outputs_list,
383
+ metrics=MetricsSummary(**metrics.summary()),
384
+ controller_error=controller_error,
385
+ )
386
+
387
+
388
+ def _fallback_controller():
389
+ return make_parametric_controller(ParametricControllerParams())
390
+
391
+
392
+ def _resolve_controller(spec: ControllerSpec):
393
+ if isinstance(spec, SimpleControllerSpec):
394
+ params = ParametricControllerParams(
395
+ battery_flow_mw=spec.params.battery_flow_mw,
396
+ emergency_generator=spec.params.emergency_generator,
397
+ curtail_solar=spec.params.curtail_solar,
398
+ fcas_reserve_mw=spec.params.fcas_reserve_mw,
399
+ subscribe_ids=spec.params.subscribe_ids,
400
+ )
401
+ return make_parametric_controller(params), None
402
+
403
+ try:
404
+ return compile_controller_source(spec.source), None
405
+ except ControllerCompileError as exc:
406
+ return _fallback_controller(), str(exc)
407
+
408
+
409
+ def _is_judging_scenario(pool: str | None) -> bool:
410
+ return pool == "judging"
411
+
412
+
413
+ def _is_judging_scenario_by_id(scenario_id: str) -> bool:
414
+ path = find_scenario_by_id(scenario_id)
415
+ if path:
416
+ spec, _ = load_scenario(path)
417
+ return _is_judging_scenario(spec.get("pool"))
418
+ return False
419
+
420
+
421
+ def _prepare_state_for_response(state: dict[str, Any]) -> None:
422
+ """Strip every engine-internal field before serialising to the client.
423
+
424
+ With the engine state now carrying full profiles, events, attack
425
+ windows, and forecast config under `_`-prefixed keys, "stripping"
426
+ reduces to dropping every key starting with `_`. The `Engine.
427
+ controller_view` allowlist is the source of truth for what stays.
428
+ """
429
+ for key in [k for k in state.keys() if k.startswith("_")]:
430
+ state.pop(key, None)
431
+
432
+
433
+ def _state_visible_to_controller(state: dict[str, Any]) -> dict[str, Any]:
434
+ """Return exactly what participant code should see this step."""
435
+ return Engine.controller_view(state)
436
+
437
+
438
+ # Keys that the scenario loader injects into initial_state. The browser
439
+ # round-trips state through HTTP, which strips every `_`-prefixed key, so
440
+ # we re-merge them from disk before handing the state to the engine.
441
+ _REHYDRATE_KEYS: tuple[str, ...] = (
442
+ "_profiles_full",
443
+ "_price_profile_full",
444
+ "_events_full",
445
+ "_forecast_config_full",
446
+ "_attack_windows_full",
447
+ "features",
448
+ )
449
+
450
+
451
+ def _rehydrate_state_for_engine(state: dict[str, Any]) -> dict[str, Any]:
452
+ """Restore engine-internal scenario fields after they round-trip
453
+ through HTTP (which strips them so they never reach controllers).
454
+ """
455
+ scenario_id = state.get("scenario_id")
456
+ if not scenario_id:
457
+ return state
458
+ path = find_scenario_by_id(str(scenario_id))
459
+ if path is None:
460
+ return state
461
+ _, scenario_state = load_scenario(path)
462
+ engine_state = dict(state)
463
+ for key in _REHYDRATE_KEYS:
464
+ if key in scenario_state:
465
+ engine_state[key] = scenario_state[key]
466
+ return engine_state
467
+
468
+
469
+ def _strip_judging_data(
470
+ state: dict[str, Any], outputs: dict[str, Any] | None = None
471
+ ) -> None:
472
+ """Strip scenario data from judging scenarios to prevent probing.
473
+
474
+ Belt-and-braces on top of `_prepare_state_for_response`: removes the
475
+ bounded forecast (still ground truth from the judge's POV) and the
476
+ realised tariff so a judging client can't reverse-engineer the price
477
+ profile by replaying with synthetic actions.
478
+ """
479
+ state.pop("forecast", None)
480
+ if outputs is not None:
481
+ outputs.pop("import_price", None)
482
+ outputs.pop("export_price", None)
@@ -0,0 +1,11 @@
1
+ """Shared timing constants.
2
+
3
+ These two values are used across the backend (engine, runner, server,
4
+ metrics) and must agree. Defined here to avoid duplication.
5
+ """
6
+
7
+ DEFAULT_STEPS: int = 96
8
+ """Number of 15-minute steps in a standard 24-hour simulation."""
9
+
10
+ DT_HOURS: float = 0.25
11
+ """Duration of a single simulation timestep in hours (15 minutes)."""
@@ -0,0 +1,11 @@
1
+ from watt_the_hack.controllers.parametric import (
2
+ ParametricControllerParams,
3
+ make_parametric_controller,
4
+ )
5
+ from watt_the_hack.controllers.rule_based import rule_based_controller
6
+
7
+ __all__ = [
8
+ "ParametricControllerParams",
9
+ "make_parametric_controller",
10
+ "rule_based_controller",
11
+ ]
@@ -0,0 +1,40 @@
1
+ from dataclasses import dataclass
2
+ from typing import Any
3
+
4
+
5
+ @dataclass(slots=True)
6
+ class ParametricControllerParams:
7
+ """Direct action knobs for the dev/debug 'simple' controller.
8
+
9
+ Each field maps 1:1 to a key in the action dict the engine consumes.
10
+ Values are passed through as constants every timestep.
11
+ """
12
+
13
+ battery_flow_mw: float = 0.0 # MW, + discharge / - charge
14
+ emergency_generator: float = 0.0 # MW, clipped to [0, max_emergency_generator_mw]
15
+ curtail_solar: float = 0.0 # MW of solar to disconnect this step
16
+ fcas_reserve_mw: float = 0.0 # MW of inverter capacity held for FCAS revenue
17
+ subscribe_ids: bool = (
18
+ False # whether to subscribe to IDS events (if enabled in cybersecurity scenario)
19
+ )
20
+
21
+
22
+ def make_parametric_controller(params: ParametricControllerParams):
23
+ """Return a controller that emits the parameter values verbatim each step."""
24
+
25
+ battery_flow_mw = float(params.battery_flow_mw)
26
+ emergency_generator = float(params.emergency_generator)
27
+ curtail_solar = max(0.0, float(params.curtail_solar))
28
+ fcas_reserve_mw = max(0.0, float(params.fcas_reserve_mw))
29
+ subscribe_ids = bool(params.subscribe_ids)
30
+
31
+ def controller(_state: dict[str, Any]) -> dict[str, Any]:
32
+ return {
33
+ "battery_flow_mw": battery_flow_mw,
34
+ "emergency_generator": emergency_generator,
35
+ "curtail_solar": curtail_solar,
36
+ "fcas_reserve_mw": fcas_reserve_mw,
37
+ "subscribe_ids": subscribe_ids,
38
+ }
39
+
40
+ return controller