Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 14 additions & 5 deletions src/optimizer/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
3 changes: 2 additions & 1 deletion src/optimizer/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
20 changes: 20 additions & 0 deletions tests/test_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import pulp
import pytest

import optimizer.app as app_module
from optimizer.app import app, settings


Expand Down Expand Up @@ -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
Expand Down