diff --git a/src/optimizer/app.py b/src/optimizer/app.py index 6cf057c8b..9cd310624 100644 --- a/src/optimizer/app.py +++ b/src/optimizer/app.py @@ -46,20 +46,29 @@ def money(value): return None if value is None else round(value, 4) -def dump_slow_request(payload, elapsed): - """Persist requests that exhausted the solver time limit, they are the ones worth replaying. +# money the capped cost stage may leave unproven between its schedule and CBC's bound before +# the request is dumped for replay. The cap keeps requests short of the time limit, so the gap +# is what marks the ones worth replaying at a longer clock. +DUMP_GAP = 1.0 + + +def dump_slow_request(payload, elapsed, gap): + """Persist requests worth replaying: those that exhausted the solver time limit, and those + the cost stage left more than DUMP_GAP of money unproven on. The elapsed time covers model building as well as solving, so a request that only exceeds the limit while building is caught too. That one is equally worth looking at. """ path, limit = settings.dump_slow_requests, settings.time_limit - if not path or limit is None or elapsed < limit: + slow = limit is not None and elapsed >= limit + unproven = gap is not None and gap > DUMP_GAP + if not path or not (slow or unproven): return # one line per request, carrying the same "request" key as test_cases/*.json so a line # can be replayed by the existing harness line = json.dumps({"ts": datetime.now(timezone.utc).isoformat(timespec="seconds"), - "elapsed": round(elapsed, 3), "request": payload}) + "\n" + "elapsed": round(elapsed, 3), "gap": money(gap), "request": payload}) + "\n" try: pathlib.Path(path).parent.mkdir(parents=True, exist_ok=True) with open(path, "a") as f: @@ -287,7 +296,7 @@ def post(self): "steps": optimizer.T, }}), flush=True) - dump_slow_request(data, elapsed) + dump_slow_request(data, elapsed, optimizer.cost_stage_gap) return result except Exception as e: diff --git a/src/optimizer/settings.py b/src/optimizer/settings.py index 39c3ebf1b..34f72ddb1 100644 --- a/src/optimizer/settings.py +++ b/src/optimizer/settings.py @@ -21,4 +21,5 @@ class OptimizerSettings(BaseSettings): time_limit: float | None = Field(default=None, description="Time limit for the optimization process in seconds") log_subject: bool = Field(default=False, description="Log the JWT subject of every request. Off by default, it names accounts in the logs") dump_slow_requests: str | None = Field(default=None, - description="JSON Lines file requests that exhaust the solver time limit are appended to. Unset disables the dump") + description="JSON Lines file requests that exhaust the solver time limit, or leave more than one currency " + "unit unproven in the cost stage, are appended to. Unset disables the dump") diff --git a/tests/test_app.py b/tests/test_app.py index a442e1840..52325c1bb 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -7,6 +7,7 @@ import pulp import pytest +import optimizer.app as app_module from optimizer.app import app, settings @@ -138,6 +139,25 @@ def test_slow_requests_are_dumped(tmp_path, monkeypatch): assert json.loads(lines[1])["elapsed"] > 0 +def test_requests_with_an_open_gap_are_dumped(tmp_path, monkeypatch): + request = json.loads(pathlib.Path('test_cases/026-attenuate-grid-peaks.json').read_text())["request"] + dump = tmp_path / "gap.jsonl" + client = app.test_client() + monkeypatch.setattr(settings, "dump_slow_requests", str(dump)) + # the solver reads its own settings from the environment, so force the split there + monkeypatch.setenv("OPTIMIZER_PROBE_SECONDS", "0") + + # a proven cost stage has no gap to replay for + client.post("/optimize/charge-schedule", json=request) + assert not dump.exists() + + monkeypatch.setattr(app_module, "DUMP_GAP", -1.0) + client.post("/optimize/charge-schedule", json=request) + line = json.loads(dump.read_text()) + assert line["gap"] == 0 + assert line["request"] == request + + def test_every_request_logs_a_solve_line(capsys): # the key names are the Log Analytics contract: the dashboard's KQL queries parse this line, # so renaming one breaks production attribution silently