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.
- watt_the_hack/__init__.py +1 -0
- watt_the_hack/api/__init__.py +1 -0
- watt_the_hack/api/sandbox.py +147 -0
- watt_the_hack/api/server.py +482 -0
- watt_the_hack/constants.py +11 -0
- watt_the_hack/controllers/__init__.py +11 -0
- watt_the_hack/controllers/parametric.py +40 -0
- watt_the_hack/controllers/rule_based.py +82 -0
- watt_the_hack/data_loaders/__init__.py +0 -0
- watt_the_hack/data_loaders/aemo.py +44 -0
- watt_the_hack/data_loaders/scenarios.py +472 -0
- watt_the_hack/engine/__init__.py +8 -0
- watt_the_hack/engine/base_engine.py +18 -0
- watt_the_hack/engine/engine.py +1584 -0
- watt_the_hack/engine/legacy engines/simple_engine.py +117 -0
- watt_the_hack/metrics/__init__.py +3 -0
- watt_the_hack/metrics/metrics.py +122 -0
- watt_the_hack/playtest.py +1099 -0
- watt_the_hack/scenarios/synthetic/agentic_demo.json +381 -0
- watt_the_hack/scenarios/synthetic/duck_curve.json +1023 -0
- watt_the_hack/simulation/__init__.py +1 -0
- watt_the_hack/simulation/boot.py +96 -0
- watt_the_hack/simulation/runner.py +265 -0
- watt_the_hack/simulation/strategy.py +190 -0
- watt_the_hack-0.2.0.dist-info/METADATA +145 -0
- watt_the_hack-0.2.0.dist-info/RECORD +29 -0
- watt_the_hack-0.2.0.dist-info/WHEEL +5 -0
- watt_the_hack-0.2.0.dist-info/licenses/LICENSE +21 -0
- watt_the_hack-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -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
|