From 41907497b91b554e566e6aac532675fcda413fe3 Mon Sep 17 00:00:00 2001 From: Neal006 Date: Sun, 13 Sep 2026 06:27:27 +0530 Subject: [PATCH] feat(WP4): evidence scripts - timer baseline, constrained site, forecast, fit, multi-day MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - prove.py: --policy timer (full power 09-14, else 6 A) prints the LP's delta over a wall-clock timer: CO2 -56.2 %, bill -28.5 %, peak -28.4 %; the timer leaves 31 kWh short and bursts the block ($32.88/day). --block/--feed overrides print block overage $, kWh short and what the full rung (static share) would draw. At feed 80 / block 50: CO2 -23 %, bill +7.4 % (deferred energy lands in the 16-21 h price peak) - the carbon-vs-tariff conflict, reported as measured. --forecast plans on it and scores on data/signal.json, printing mean |forecast - actual|. Every run prints clean-hour share (metrics §3) and stated-vs-actual departures (29/36 later, 5 earlier). - fetch_data.py signal --forecast -> data/signal_forecast.json from /v3/forecast/historical (06:00 issue); signal also stores health_damage ($/MWh) when served. Not run here: no WattTime credentials. - scheduler.impact(..., health=True) adds health_usd (None without an index); impact_detail carries it. One test. - scripts/fit.py: cash tiers | perks only | not a fit from stated slack, tariff spread, demand charge and --saving-rate. Caltech: perks only (slack median 3.8 h; 4 / 15 / 17 = solutions.md Fact 1). - scripts/replay_days.py: range over data/days/.json; names missing days. Only the committed day exists on disk. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017thvbGo7rTVnG2f46cLMzU --- AGENTS.md | 9 ++- noonshift/scheduler.py | 24 +++++--- scripts/fetch_data.py | 56 ++++++++++++++++--- scripts/fit.py | 82 ++++++++++++++++++++++++++++ scripts/prove.py | 121 +++++++++++++++++++++++++++++++++-------- scripts/replay_days.py | 64 ++++++++++++++++++++++ tests/test_impact.py | 9 +++ 7 files changed, 323 insertions(+), 42 deletions(-) create mode 100644 scripts/fit.py create mode 100644 scripts/replay_days.py diff --git a/AGENTS.md b/AGENTS.md index 1bd2bed..50a1464 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,7 +7,7 @@ Hackathon entry: "Noonshift" — a CPO-side, deadline-based EV charging schedule ## Stack & Commands - Python 3.12/3.13 · FastAPI · scipy 1.14 (HiGHS LP) · mobilityhouse/ocpp · asyncpg/Postgres (optional) · Docker Compose. Two Vite front-ends: `web/` (ops dashboard, React+JSX+Tailwind 3, :5173) and `wattwise/` (driver app, React+TS+Tailwind 4, :5174); `npm install && npm run dev` in each, both proxy `/api` and `/ws` to :8000. `npm run build` in each, then `docker compose up` serves them on :3000 / :3001. - `python -m venv .venv && .venv/Scripts/pip install -r requirements-dev.txt` -- `python -m pytest` (69 tests, ~90 s) · `python scripts/prove.py` (slide 1) · `python -m noonshift.test_loop` · `docker compose up -d --build` +- `python -m pytest` (70 tests, ~90 s) · `python scripts/prove.py` (slide 1) · `python -m noonshift.test_loop` · `docker compose up -d --build` - Data: `python -m noonshift.seed gen` (placeholders) · `python scripts/fetch_data.py signal|caiso|sessions --day ...` (real). ## Current State & Focus @@ -33,10 +33,12 @@ Signals (WattTime MOER; CAISO fuel-mix fallback) + tariff table + sessions (dead - `wattwise/src/api/noonshift.ts` — driver app client + hand-copied types; `connectWs()`, `onSimDay()` (ready-by is built on the **sim** day and must be > sim now). - `wattwise/src/context/WattwiseContext.tsx` — driver state: `POST /sessions` on the highest free connector (replayed sessions use c01–c36), `/live` polled every 2 s for the receipt, meter/plan frames for kW/kWh/window; unplug/day_reset moves the session to History. - `noonshift/ocpp_gateway.py` — two profiles per connector: static share at stack 0 (sent as a task after BootNotification, no expiry) + dynamic plan at stack 1 with `valid_to` = now + 15 min, refreshed every 10 sim-min; `Charger.profiles`, `Charger.apply(now)`; self-check `python -m noonshift.ocpp_gateway`. Never `await self.call()` inside an `@after` handler (deadlocks the message loop). `db.py`, `seed.py`, `test_loop.py` — Tirth's. -- `scripts/prove.py` — two real-loop replays (charge-now vs Noonshift), slide-1 table, gate exit code. +- `scripts/prove.py` — real-loop replays: charge-now vs Noonshift (+ `--policy timer` = dumb 09–14 timer; `--block/--feed` overrides; `--forecast ` plans on it and scores on data/signal.json); prints clean-hour share, block overage $, kWh short, stated-vs-actual departures; gate exit code. Timer delta on the real day: CO2 −56 %, bill −28.5 %, peak −28 %. At feed 80/block 50: CO2 −23 % but bill +7 % (deferred energy lands in the 16–21 h price peak). +- `scripts/fit.py ` — pre-sales site fit: `cash tiers | perks only | not a fit` from stated-slack median, tariff spread, demand charge, `--saving-rate` (measured). Caltech: perks only (slack median 3.8 h, 4/15/17). +- `scripts/replay_days.py --days a,b,c` — min/median/max over `data/days/.json` signals; names missing days instead of inventing them. - `scripts/smoke.py` — e2e without a browser: driver REST (`/price`, `/sessions`, `/boost`, `/live`) + `/ws` as the ops app sees it + all four `/demo/*` + ladder drop/restore; plugs 2 extra cars before the demo beats so early-unplug/boost always have a target. - `.docs/pitch/` — `deck.md` (slide list, all sourced), `demo-script.md` (4 min, 7 beats, what the audience sees per beat, failure modes), `hard-questions.md` (answers tied to code + tests). -- `scripts/fetch_data.py` — WattTime / ACN-Data / CAISO fallback → `data/*.json`. +- `scripts/fetch_data.py` — WattTime / ACN-Data / CAISO fallback → `data/*.json`; `signal --forecast` → `data/signal_forecast.json` via `/v3/forecast/historical` (untested: no WattTime creds on this machine); `signal` also stores `health_damage` ($/MWh) when the plan serves it → `impact(..., health=True)["health_usd"]`. - `tests/test_scheduler.py`, `test_impact.py`, `test_perf.py`, `test_day.py` (full day + demo scenarios), `tests/fixtures/baseline_1405.json`. - `.docs/neal-plan.md` — Neal's lane: status vs gates, LP deviations and why, edge-case→test matrix, open items. - `.docs/team-plan.md`, `.docs/noonshift-proposal.md`, `ev-green-charging-ideation.html` — plan and evidence base. @@ -79,6 +81,7 @@ Signals (WattTime MOER; CAISO fuel-mix fallback) + tariff table + sessions (dead - 2026-09-12 — Session data = ACN 2019-04-09 re-dated onto the 2026 signal day; kwh_needed = delivered energy, stated departure = the driver's own input (early leavers kept). ## Changelog +2026-09-13 | WP4 evidence scripts | scripts/prove.py, fetch_data.py, fit.py, replay_days.py, scheduler.impact(health=) | timer baseline is the judge's number (−56 % CO2 over a dumb timer); constrained site shows the carbon-vs-tariff conflict honestly; forecast flag wired, unverified without creds 2026-09-13 | WP3 cars, needs, queues | models.py, sim.py, api.py, data/site.json, tests/test_day.py, scripts/smoke.py | vehicle form → per-car cap + kWh from SoC; observation guard believes the meter; connectors_asap c41/c42; done event + idle_min; Sim.waiting + move-by tightening; a notified done car moves within 10 min when someone waits 2026-09-13 | WP2 fail-safe an inspector accepts: static share under everything | sim.py, api.py, scheduler.py, ocpp_gateway.py, models.py, data/site.json, tests/test_day.py | `full` rung, `apply_limits` fallback and the linprog fallback all return `safe_share_kw`; kill-the-loop test: 6 h with no step() never exceeds headroom and after 15 min never exceeds n × share; `StatusOut.safe_share_kw` 2026-09-13 | WP1 pricing + emergency + first-hour floor | scheduler.py, api.py, models.py, data/site.json, tests/, scripts/smoke.py, .docs/implementation-plan.md | price() = §7b shared savings (never above R); urgency endpoint; slot-0 remaining-duration + time-anchored checkpoints fixed a lost-energy bug that the 3.5 kWh promise exposed diff --git a/noonshift/scheduler.py b/noonshift/scheduler.py index 15d9d65..5fe7445 100644 --- a/noonshift/scheduler.py +++ b/noonshift/scheduler.py @@ -253,8 +253,9 @@ def solve(cars, site, signal, tariff, now) -> dict[str, list[float]]: return solve_lp(cars, site, signal, tariff, now)[0] -def _totals(plan, moer, price, tariff): - """kWh, $ (energy + this day's share of block overage), kg CO2 and peak kW of one {cid: [kW per slot]} dict.""" +def _totals(plan, moer, price, tariff, health=None): + """kWh, $ (energy + this day's share of block overage), kg CO2, peak kW and (with a health-damage index, $/MWh) + health $ of one {cid: [kW per slot]} dict.""" site_kw = np.zeros(HORIZON) for p in plan.values(): a = np.asarray(list(p)[:HORIZON], dtype=float) @@ -267,25 +268,34 @@ def _totals(plan, moer, price, tariff): block = tariff.get("block_kw") if block is not None: usd += max(0.0, peak - block) * tariff.get("block_price", 0.0) * tariff.get("overage_multiplier", 2.0) / DAYS_PER_MONTH - return {"kwh": kwh, "usd": usd, "kgco2": kg, "peak_kw": peak} + out = {"kwh": kwh, "usd": usd, "kgco2": kg, "peak_kw": peak} + if health is not None: + out["health_usd"] = float((site_kw * SLOT_H * health / 1000).sum()) # $/MWh -> $ per kWh-slot + return out def impact_detail(plan, baseline, signal, tariff) -> dict: """Both totals plus the savings. Energy-matched: the baseline is scaled to the plan's kWh, so a plan that delivered less (shortfall) cannot book the undelivered energy as a saving. No signal => kg CO2 is 0, not guessed.""" moer, price = _per_slot(signal.get("moer")), _per_slot(tariff.get("price_per_kwh")) - p, b = _totals(plan, moer, price, tariff), _totals(baseline, moer, price, tariff) + health = _per_slot(signal["health_damage"]) if signal.get("health_damage") else None + p, b = _totals(plan, moer, price, tariff, health), _totals(baseline, moer, price, tariff, health) ratio = p["kwh"] / b["kwh"] if b["kwh"] > 0 else 0.0 return {"plan": p, "baseline": b, "energy_matched": abs(p["kwh"] - b["kwh"]) < 1e-6, "saved_usd": round(b["usd"] * ratio - p["usd"], 4), "saved_kgco2": round(b["kgco2"] * ratio - p["kgco2"], 4) if signal.get("moer") else 0.0, + "health_usd": round(b["health_usd"] * ratio - p["health_usd"], 4) if health is not None else None, "signal_kind": signal.get("kind")} -def impact(plan, baseline, signal, tariff) -> dict[str, float]: - """Savings of `plan` vs `baseline` (both {connector_id: [kW per slot]}), keys exactly as LiveOut expects.""" +def impact(plan, baseline, signal, tariff, *, health=False) -> dict[str, float]: + """Savings of `plan` vs `baseline` (both {connector_id: [kW per slot]}), keys exactly as LiveOut expects. + health=True adds health_usd (metrics.md §4; None when the signal carries no health_damage index).""" d = impact_detail(plan, baseline, signal, tariff) - return {"saved_usd": d["saved_usd"], "saved_kgco2": d["saved_kgco2"]} + out = {"saved_usd": d["saved_usd"], "saved_kgco2": d["saved_kgco2"]} + if health: + out["health_usd"] = d["health_usd"] + return out def tier_of(slack_hours) -> str: diff --git a/scripts/fetch_data.py b/scripts/fetch_data.py index a940963..5ab344b 100644 --- a/scripts/fetch_data.py +++ b/scripts/fetch_data.py @@ -1,6 +1,7 @@ """Download the demo day into data/*.json (same schema seed.py writes) so the demo runs offline. - python scripts/fetch_data.py signal --day 2026-04-14 # WattTime CAISO_NORTH MOER (needs WATTTIME_USER/PASSWORD) + python scripts/fetch_data.py signal --day 2026-04-14 # WattTime CAISO_NORTH MOER (+ health_damage when the plan returns it) + python scripts/fetch_data.py signal --day 2026-04-14 --forecast # the MOER forecast as published at 06:00 that day -> data/signal_forecast.json python scripts/fetch_data.py caiso --day 2026-04-14 # fallback: CAISO fuel mix -> AVERAGE intensity, no auth python scripts/fetch_data.py sessions --day 2019-04-09 --n 40 # ACN-Data Caltech sessions (needs ACN_TOKEN; data spans 2018-04..2021-09), re-dated to --site-day @@ -51,21 +52,57 @@ def to_slots(points, day): return [round(v, 1) for v in out], gaps -def watttime(day): +def _watttime_token(): user, pw = os.environ.get("WATTTIME_USER"), os.environ.get("WATTTIME_PASSWORD") if not (user and pw): sys.exit("set WATTTIME_USER and WATTTIME_PASSWORD (free Basic plan covers CAISO_NORTH)") auth = base64.b64encode(f"{user}:{pw}".encode()).decode() - token = json.loads(get("https://api.watttime.org/login", {"Authorization": f"Basic {auth}"}))["token"] + return json.loads(get("https://api.watttime.org/login", {"Authorization": f"Basic {auth}"}))["token"] + + +def _pt(iso): + return datetime.fromisoformat(iso.replace("Z", "+00:00")).astimezone(PT) + + +def watttime(day, forecast=False): + token = _watttime_token() start = datetime.combine(day, datetime.min.time(), PT).astimezone(timezone.utc) + hdr = {"Authorization": f"Bearer {token}"} + if forecast: + # solutions.md §6: the forecast a product would have planned on at 06:00 that morning, not the realised signal. + # Assumption: WattTime Basic serves /v3/forecast/historical for CAISO_NORTH; if it does not, run this on a live + # day with /v3/forecast and the flag stays wired. + at = start + timedelta(hours=6) + q = urllib.parse.urlencode({"region": "CAISO_NORTH", "signal_type": "co2_moer", "start": at.isoformat(), + "end": (at + timedelta(minutes=5)).isoformat(), "horizon_hours": 24}) + gens = json.loads(get(f"https://api.watttime.org/v3/forecast/historical?{q}", hdr))["data"] + if not gens: + sys.exit("no historical forecast returned for that day") + g = gens[0] + points = [(_pt(p["point_time"]), p["value"] * LBS_PER_MWH_TO_G_PER_KWH) for p in g["forecast"]] + moer, gaps = to_slots(points, day) + json.dump({"kind": "marginal", "region": "CAISO_NORTH", "date": day.isoformat(), "forecast": True, + "generated_at": g.get("generated_at"), "source": "WattTime v3 co2_moer forecast (historical)", "moer": moer}, + open("data/signal_forecast.json", "w")) + print(f"wrote data/signal_forecast.json: forecast generated {g.get('generated_at')}, {len(points)} points, {gaps} gaps filled " + f"(the hours before 06:00 are not in a 06:00 forecast), min {min(moer)} max {max(moer)} g/kWh") + return q = urllib.parse.urlencode({"region": "CAISO_NORTH", "signal_type": "co2_moer", "start": start.isoformat(), "end": (start + timedelta(days=1)).isoformat()}) - data = json.loads(get(f"https://api.watttime.org/v3/historical?{q}", {"Authorization": f"Bearer {token}"}))["data"] - points = [(datetime.fromisoformat(p["point_time"].replace("Z", "+00:00")).astimezone(PT), p["value"] * LBS_PER_MWH_TO_G_PER_KWH) - for p in data] + data = json.loads(get(f"https://api.watttime.org/v3/historical?{q}", hdr))["data"] + points = [(_pt(p["point_time"]), p["value"] * LBS_PER_MWH_TO_G_PER_KWH) for p in data] moer, gaps = to_slots(points, day) - json.dump({"kind": "marginal", "region": "CAISO_NORTH", "date": day.isoformat(), "source": "WattTime v3 co2_moer", - "moer": moer}, open("data/signal.json", "w")) + out = {"kind": "marginal", "region": "CAISO_NORTH", "date": day.isoformat(), "source": "WattTime v3 co2_moer", "moer": moer} + try: # metrics.md §4: the health-damage index ($/MWh) rides along when the plan serves it + q = urllib.parse.urlencode({"region": "CAISO_NORTH", "signal_type": "health_damage", + "start": start.isoformat(), "end": (start + timedelta(days=1)).isoformat()}) + hd = json.loads(get(f"https://api.watttime.org/v3/historical?{q}", hdr))["data"] + out["health_damage"], hd_gaps = to_slots([(_pt(p["point_time"]), p["value"]) for p in hd], day) + out["health_source"] = "WattTime v3 health_damage ($/MWh)" + print(f"health_damage: {len(hd)} points, {hd_gaps} gaps filled, mean {sum(out['health_damage']) / SLOTS:.1f} $/MWh") + except Exception as e: # noqa: BLE001 + print(f"health_damage not stored ({e}); impact.health_usd stays None") + json.dump(out, open("data/signal.json", "w")) print(f"wrote data/signal.json: WattTime CAISO_NORTH MOER for {day}, {len(points)} points, {gaps} gaps filled, " f"min {min(moer)} max {max(moer)} g/kWh") @@ -147,7 +184,8 @@ def sessions(day, n, site_day): ap.add_argument("--day", required=True, help="YYYY-MM-DD of the data to fetch") ap.add_argument("--site-day", default="2026-04-14", help="sessions: re-date onto this day so all files agree") ap.add_argument("--n", type=int, default=40) + ap.add_argument("--forecast", action="store_true", help="signal: fetch the 06:00 forecast for --day instead of the realised MOER") a = ap.parse_args() day = datetime.fromisoformat(a.day).date() - {"signal": lambda: watttime(day), "caiso": lambda: caiso(day), + {"signal": lambda: watttime(day, a.forecast), "caiso": lambda: caiso(day), "sessions": lambda: sessions(day, a.n, datetime.fromisoformat(a.site_day).date())}[a.what]() diff --git a/scripts/fit.py b/scripts/fit.py new file mode 100644 index 0000000..80119b9 --- /dev/null +++ b/scripts/fit.py @@ -0,0 +1,82 @@ +"""Site-fit check before anyone signs (solutions.md §4, business.md §9b): run it on the customer's own session export. + + python scripts/fit.py data/sessions.json # JSON list with arrival, departure, kwh_needed (or kWh) + python scripts/fit.py export.csv --p-max 7 --spread 0.04 # CSV with arrival, departure, kwh columns; tariff spread $/kWh + python scripts/fit.py export.csv --demand-charge # the bill has a demand charge (capacity is worth money) + python scripts/fit.py data/sessions.json --saving-rate 0.006 # what prove.py measured ($/kWh): below 4 c/kWh, cash tiers are unfunded + +Slack per session = dwell - kWh / p_max. Verdict from the median slack and the money on the bill: + cash tiers median slack >= 4 h and (tariff spread >= 4 c/kWh or a demand charge) -> §7b discounts are real money + perks only median slack >= 1.5 h -> non-price perks (§9b) + not a fit median slack < 1.5 h -> nothing to shift +Same arithmetic as metrics.md §6b "site-type check". Slack uses the driver's stated departure when the export has one +(that is the slack the scheduler actually gets), else the real one. Prints the numbers it used, so the verdict can be +argued with. The slack rule is a heuristic: a measured saving rate (prove.py: saved $ / kWh) overrides it. +""" +import argparse +import csv +import json +import statistics +import sys +from datetime import datetime + + +def load(path): + if path.endswith(".json"): + rows = json.load(open(path)) + else: + rows = list(csv.DictReader(open(path, newline=""))) + out = [] + for r in rows: + kwh = next((float(r[k]) for k in ("kwh_needed", "kwh", "kWh", "kWhDelivered", "energy_kwh") if r.get(k) not in (None, "")), None) + arr = r.get("arrival") or r.get("connectionTime") + dep = r.get("user_stated_departure") or r.get("departure") or r.get("disconnectTime") + if kwh is None or not arr or not dep: + continue + a, d = datetime.fromisoformat(str(arr)), datetime.fromisoformat(str(dep)) + out.append(((d - a).total_seconds() / 3600, kwh)) + if not out: + sys.exit("no rows with arrival, departure and kWh") + return out, any(r.get("user_stated_departure") for r in rows) + + +def verdict(sessions, p_max, spread, demand_charge, saving_rate=None): + slack = sorted(dwell - kwh / p_max for dwell, kwh in sessions) + med = statistics.median(slack) + if med < 1.5: + v = "not a fit" + elif med >= 4 and (spread >= 0.04 or demand_charge) and (saving_rate is None or saving_rate >= 0.04): + v = "cash tiers" + else: + v = "perks only" + return v, med, slack + + +def main(argv=None): + ap = argparse.ArgumentParser() + ap.add_argument("path") + ap.add_argument("--p-max", type=float, default=7.0, help="kW per port") + ap.add_argument("--spread", type=float, help="tariff spread $/kWh (max - min); default: from data/tariff.json") + ap.add_argument("--demand-charge", action="store_true", help="the site's bill carries a demand charge") + ap.add_argument("--saving-rate", type=float, help="measured saving $/kWh from prove.py (saved $ / kWh); overrides the slack rule") + a = ap.parse_args(argv) + if a.spread is None: + try: + prices = json.load(open("data/tariff.json"))["price_per_kwh"] + a.spread = max(prices) - min(prices) + except Exception: # noqa: BLE001 + a.spread = 0.0 + sessions, stated = load(a.path) + v, med, slack = verdict(sessions, a.p_max, a.spread, a.demand_charge, a.saving_rate) + n = len(slack) + print(f"{a.path}: {n} sessions, {'stated' if stated else 'actual'} dwell median {statistics.median(d for d, _ in sessions):.1f} h, " + f"kWh median {statistics.median(k for _, k in sessions):.1f}, slack median {med:.1f} h " + f"(< 1 h: {sum(s < 1 for s in slack)}, 1-4 h: {sum(1 <= s < 4 for s in slack)}, >= 4 h: {sum(s >= 4 for s in slack)}); " + f"tariff spread {100 * a.spread:.1f} c/kWh; demand charge {'yes' if a.demand_charge else 'no'}" + + (f"; measured saving {100 * a.saving_rate:.1f} c/kWh" if a.saving_rate is not None else "; measured saving: not given (run prove.py)")) + print(f"verdict: {v}") + return v + + +if __name__ == "__main__": + main() diff --git a/scripts/prove.py b/scripts/prove.py index fac1cad..03974de 100644 --- a/scripts/prove.py +++ b/scripts/prove.py @@ -1,16 +1,21 @@ -"""Slide 1. Replays the simulated day twice through the real control loop (api.step: solver, taper, feed limit), -once under a charge-immediately policy (every deadline = now, the same solve()) and once under Noonshift, and prints -kWh, $ bill, kg CO2 and peak kW for both, the cars that left short, and the h6 gate. +"""Slide 1. Replays the simulated day through the real control loop (api.step: solver, taper, feed limit) under two or +three policies and prints kWh, $ bill, kg CO2 and peak kW for each, the cars that left short, and the h6 gate. - python scripts/prove.py # data/*.json as committed - python scripts/prove.py --w-carbon 0.2 # sweep the carbon weight ($/kg) - python scripts/prove.py --gate 0.15 # gate on CO2 saving (default 15%) + python scripts/prove.py # charge-immediately vs Noonshift, data/*.json as committed + python scripts/prove.py --policy timer # + a dumb timer (full power 09-14, else 6 A): the LP's delta over it + python scripts/prove.py --block 50 --feed 80 # a constrained site: block overage $, kWh short, what the full rung does + python scripts/prove.py --forecast data/signal_forecast.json # plan on the forecast, score on data/signal.json (solutions.md §6) + python scripts/prove.py --w-carbon 0.2 --gate 0.15 # sweep the carbon weight; gate on CO2 saving (default 15 %) + +Also printed every run: renewable-hour share (metrics.md §3), block overage and shortfall per policy, and how the +drivers' stated departures compared with when they actually left (solutions.md Fact 1). """ import argparse import asyncio import json import logging import os +import statistics import sys import time from datetime import timedelta @@ -19,9 +24,10 @@ from noonshift import scheduler # noqa: E402 from noonshift.api import S, step # noqa: E402 -from noonshift.sim import Sim, load_sessions # noqa: E402 +from noonshift.sim import SLOT, Sim, load_sessions, safe_share_kw # noqa: E402 H = scheduler.HORIZON +TIMER_ON = (9, 14) # the dumb timer: full power in these hours, MIN_KW (never pause) outside def absolute(kw_min, start): @@ -35,11 +41,18 @@ def absolute(kw_min, start): async def replay(stats, policy): - """Play the day. policy "noonshift" is the real thing; "asap" forces every deadline to now (charge-immediately).""" + """Play the day. "noonshift" is the real thing; "asap" forces every deadline to now (charge-immediately); + "timer" bypasses the LP with a wall-clock schedule (what a site without a controller typically installs).""" sim = S["sim"] orig = scheduler.solve_lp def spy(cars, site, signal, tariff, now): + if policy == "timer": + t0 = now.replace(minute=now.minute - now.minute % 5, second=0, microsecond=0) + on = [TIMER_ON[0] <= (t0 + k * SLOT).hour < TIMER_ON[1] for k in range(H)] + plan = {c["connector_id"]: [c["p_max_kw"] if o else min(scheduler.MIN_KW, c["p_max_kw"]) for o in on] for c in cars} + stats["solves"] += 1 + return plan, {"shortfall_kwh": {}, "asap": [], "status": "timer", "solve_ms": 0.0} if policy == "asap": cars = [dict(c, departure=now) for c in cars] plan, info = orig(cars, site, signal, tariff, now) @@ -57,7 +70,7 @@ def spy(cars, site, signal, tariff, now): def run_day(policy, site, signal, tariff): - """Returns (metered kW per session on the absolute day, sessions, solver stats).""" + """Returns (metered kW per session on the absolute day, sessions, solver stats). `signal` is what the planner sees.""" S.clear() S.update(site=site, signal=signal, tariff=tariff, plan={}, plan_at=None, baseline={}, impact={}, hist={}, mode="live", ladder={"live": True, "cached": True, "tariff": True, "deadline": True}, live_lost_at=None, @@ -69,40 +82,102 @@ def run_day(policy, site, signal, tariff): return metered, dict(S["sim"].sessions), stats -def main(): +def renewable_share(metered, moer): + """metrics.md §3: energy delivered in slots where the marginal source is renewable (MOER = 0) over all energy.""" + clean = total = 0.0 + for series in metered.values(): + for k, kw in enumerate(series): + total += kw + clean += kw if moer[k] == 0 else 0.0 + return clean / total if total else 0.0 + + +def overage_usd(peak, tariff, block): + return max(0.0, peak - block) * tariff.get("block_price", 0.0) * tariff.get("overage_multiplier", 2.0) / scheduler.DAYS_PER_MONTH + + +def shortfall(sessions): + return sum(max(0.0, s["kwh_needed"] - s["kwh_delivered"]) for s in sessions.values()) + + +def stated_vs_actual(sessions): + """solutions.md Fact 1: real drivers under-state their stay. Minutes = actual departure - stated departure.""" + d = [(s["departure"] - s["user_stated_departure"]).total_seconds() / 60 for s in sessions.values()] + later, earlier = [x for x in d if x > 0.5], [x for x in d if x < -0.5] + return {"n": len(d), "later": len(later), "earlier": len(earlier), "exact": len(d) - len(later) - len(earlier), + "mean_later_min": statistics.mean(later) if later else 0.0, "worst_early_min": min(earlier) if earlier else 0.0} + + +def main(argv=None): ap = argparse.ArgumentParser() ap.add_argument("--w-carbon", type=float, default=scheduler.W_CARBON, help="$/kg CO2 weight in the objective") ap.add_argument("--gate", type=float, default=0.15, help="minimum CO2 saving fraction to pass") - args = ap.parse_args() + ap.add_argument("--policy", choices=["timer"], help="also replay a dumb timer (full power %d-%d) and report the LP's delta over it" % TIMER_ON) + ap.add_argument("--block", type=float, help="override the tariff block kW (site.json + tariff.json)") + ap.add_argument("--feed", type=float, help="override the site feed kW") + ap.add_argument("--forecast", help="signal file the planner sees; data/signal.json still scores the result") + args = ap.parse_args(argv) scheduler.W_CARBON = args.w_carbon logging.basicConfig(level=logging.ERROR) site, signal, tariff = (json.load(open(f"data/{n}.json")) for n in ("site", "signal", "tariff")) + if args.block is not None: + site["block_kw"] = tariff["block_kw"] = args.block + if args.feed is not None: + site["feed_kw"] = args.feed + site.pop("safe_share_kw", None) # recompute for the new feed + plan_signal = json.load(open(args.forecast)) if args.forecast else signal + block = tariff.get("block_kw", site["block_kw"]) t0 = time.perf_counter() - base, base_sessions, base_stats = run_day("asap", site, signal, tariff) - plan, sessions, stats = run_day("noonshift", site, signal, tariff) + runs = {"charge-immediately": run_day("asap", site, plan_signal, tariff)} + if args.policy == "timer": + runs["dumb timer %02d-%02d" % TIMER_ON] = run_day("timer", site, plan_signal, tariff) + runs["noonshift"] = run_day("noonshift", site, plan_signal, tariff) wall = time.perf_counter() - t0 - d = scheduler.impact_detail(plan, base, {"moer": signal["moer"], "kind": signal["kind"]}, tariff) + actual = {"moer": signal["moer"], "kind": signal["kind"], "health_damage": signal.get("health_damage")} + base, base_sessions, base_stats = runs["charge-immediately"] + plan, sessions, stats = runs["noonshift"] + d = scheduler.impact_detail(plan, base, actual, tariff) p, b = d["plan"], d["baseline"] short = lambda ss: [s for s in ss.values() if s["kwh_delivered"] < s["kwh_needed"] - 1e-3] early, base_early = short(sessions), short(base_sessions) pct = lambda a, c: f"{100 * (1 - a / c):5.1f}%" if c else " n/a" co2_saving = d["saved_kgco2"] / (p["kgco2"] + d["saved_kgco2"]) if p["kgco2"] + d["saved_kgco2"] > 0 else 0.0 - fallbacks = stats["fallbacks"] + base_stats["fallbacks"] + fallbacks = sum(r[2]["fallbacks"] for r in runs.values()) print(f"Noonshift prove.py | {signal.get('date', '?')} site {site['id']} {len(sessions)} sessions {site['feed_kw']:.0f} kW feed " - f"block {tariff.get('block_kw', site['block_kw'])} kW | {tariff.get('name', 'tariff')}") + f"block {block} kW | {tariff.get('name', 'tariff')}") print(f"signal: {signal.get('kind')} {signal.get('region', '')} W_CARBON={scheduler.W_CARBON} $/kg " - f"({'AVERAGE intensity, not marginal: label the CO2 number accordingly' if signal.get('kind') == 'average' else 'marginal'})") - print(f"{'':20s}{'kWh':>9s}{'$ bill':>10s}{'kg CO2':>10s}{'peak kW':>10s}") - print(f"{'charge-immediately':20s}{b['kwh']:9.1f}{b['usd']:10.2f}{b['kgco2']:10.1f}{b['peak_kw']:10.1f}") - print(f"{'noonshift':20s}{p['kwh']:9.1f}{p['usd']:10.2f}{p['kgco2']:10.1f}{p['peak_kw']:10.1f}") - print(f"{'saving':20s}{'':9s}{pct(p['usd'], b['usd']):>10s}{pct(p['kgco2'], b['kgco2']):>10s}{pct(p['peak_kw'], b['peak_kw']):>10s}" - f" (energy-matched: {d['energy_matched']}; ${d['saved_usd']:.2f}, {d['saved_kgco2']:.1f} kg)") + f"({'AVERAGE intensity, not marginal: label the CO2 number accordingly' if signal.get('kind') == 'average' else 'marginal'})" + + (f" | planned on {args.forecast} ({plan_signal.get('generated_at', 'forecast')}), scored on data/signal.json: " + f"mean |forecast - actual| {statistics.mean(abs(f - a) for f, a in zip(plan_signal['moer'], signal['moer'])):.0f} g/kWh" + if args.forecast else " | planned and scored on the same realised signal (perfect foresight)")) + print(f"{'':20s}{'kWh':>9s}{'$ bill':>10s}{'kg CO2':>10s}{'peak kW':>10s}{'clean-h %':>11s}{'overage $':>11s}{'short kWh':>11s}") + totals = {} + for name, (metered, ss, _) in runs.items(): + t = scheduler.impact_detail(metered, base, actual, tariff)["plan"] + totals[name] = t + print(f"{name:20s}{t['kwh']:9.1f}{t['usd']:10.2f}{t['kgco2']:10.1f}{t['peak_kw']:10.1f}" + f"{100 * renewable_share(metered, signal['moer']):11.1f}{overage_usd(t['peak_kw'], tariff, block):11.2f}{shortfall(ss):11.1f}") + print(f"{'saving vs charge-now':20s}{'':9s}{pct(p['usd'], b['usd']):>10s}{pct(p['kgco2'], b['kgco2']):>10s}{pct(p['peak_kw'], b['peak_kw']):>10s}" + f" (energy-matched: {d['energy_matched']}; ${d['saved_usd']:.2f}, {d['saved_kgco2']:.1f} kg" + + (f", health ${d['health_usd']:.2f}" if d.get("health_usd") is not None else "") + ")") + if args.policy == "timer": + t = totals["dumb timer %02d-%02d" % TIMER_ON] + print(f"{'saving vs timer':20s}{'':9s}{pct(p['usd'], t['usd']):>10s}{pct(p['kgco2'], t['kgco2']):>10s}{pct(p['peak_kw'], t['peak_kw']):>10s}" + f" (the judge's question: what the LP adds over a wall-clock timer)") + if args.block is not None or args.feed is not None: + share = safe_share_kw(site) + print(f"full rung (static share): {len(site['connectors'])} x {share} kW = {len(site['connectors']) * share:.0f} kW " + f"<= feed - worst building load {site['feed_kw'] - max(site['building_load_kw']):.0f} kW; block overage at that draw " + f"${overage_usd(len(site['connectors']) * share + max(site['building_load_kw']), tariff, block):.2f}/day") print(f"short of full, noonshift: {len(early)}: " + ", ".join(f"#{s['id']} {s['kwh_delivered']:.1f}/{s['kwh_needed']} kWh" for s in early) + f" | charge-immediately: {len(base_early)}: " + ", ".join(f"#{s['id']} {s['kwh_delivered']:.1f}/{s['kwh_needed']}" for s in base_early)) - print(f"solver: {stats['solves']} solves, max {stats['max_ms']:.0f} ms, fallbacks {fallbacks}; both replays {wall:.0f} s") + sv = stated_vs_actual(sessions) + print(f"stated vs actual departure: {sv['later']}/{sv['n']} left later than they said (mean +{sv['mean_later_min']:.0f} min), " + f"{sv['earlier']} earlier (worst {sv['worst_early_min']:.0f} min), {sv['exact']} exact") + print(f"solver: {stats['solves']} solves, max {stats['max_ms']:.0f} ms, fallbacks {fallbacks}; {len(runs)} replays {wall:.0f} s") verdict = "PASS" if co2_saving >= args.gate and fallbacks == 0 else "FAIL" print(f"GATE (CO2 saving >= {100 * args.gate:.0f}%, no fallbacks): {verdict} ({100 * co2_saving:.1f}%)") return 0 if verdict == "PASS" else 1 diff --git a/scripts/replay_days.py b/scripts/replay_days.py new file mode 100644 index 0000000..c175043 --- /dev/null +++ b/scripts/replay_days.py @@ -0,0 +1,64 @@ +"""Replay the same sessions against several days' grid signals and print the range (ROADMAP §5 item 4: n = 1 day today). + + python scripts/replay_days.py --days 2026-04-14,2026-07-15,2026-01-20 + +Each day needs data/days/.json in the data/signal.json schema (fetch with +`python scripts/fetch_data.py signal --day ` and move the file); the committed day is read from data/signal.json. +Days without a file are listed as missing, never invented. The sessions are the committed ones (data/sessions.json); +only the grid changes, which is the question being asked. +""" +import argparse +import json +import os +import statistics +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +import prove # noqa: E402 +from noonshift import scheduler # noqa: E402 + + +def one_day(signal, site, tariff): + base, _, _ = prove.run_day("asap", site, signal, tariff) + plan, _, stats = prove.run_day("noonshift", site, signal, tariff) + d = scheduler.impact_detail(plan, base, {"moer": signal["moer"], "kind": signal["kind"]}, tariff) + p, b = d["plan"], d["baseline"] + pct = lambda a, c: 100 * (1 - a / c) if c else 0.0 + return {"co2": pct(p["kgco2"], b["kgco2"]), "usd": pct(p["usd"], b["usd"]), "peak": pct(p["peak_kw"], b["peak_kw"]), + "clean": 100 * prove.renewable_share(plan, signal["moer"]), "fallbacks": stats["fallbacks"]} + + +def main(argv=None): + ap = argparse.ArgumentParser() + ap.add_argument("--days", required=True, help="comma-separated YYYY-MM-DD") + a = ap.parse_args(argv) + site, committed, tariff = (json.load(open(f"data/{n}.json")) for n in ("site", "signal", "tariff")) + rows, missing = {}, [] + for day in a.days.split(","): + day = day.strip() + path = f"data/days/{day}.json" + if day == committed.get("date"): + signal = committed + elif os.path.exists(path): + signal = json.load(open(path)) + else: + missing.append(day) + continue + rows[day] = one_day(signal, site, tariff) + r = rows[day] + print(f"{day} {signal.get('kind', '?'):8s} CO2 {r['co2']:6.1f}% $ {r['usd']:5.1f}% peak {r['peak']:5.1f}% clean-h {r['clean']:5.1f}% fallbacks {r['fallbacks']}") + if missing: + print(f"missing (no data/days/.json): {', '.join(missing)} -> fetch with scripts/fetch_data.py signal --day ") + if len(rows) >= 2: + for k, label in (("co2", "CO2 saving"), ("usd", "bill saving"), ("peak", "peak saving"), ("clean", "clean-hour share")): + v = [r[k] for r in rows.values()] + print(f"{label:17s} min {min(v):6.1f}% median {statistics.median(v):6.1f}% max {max(v):6.1f}% over {len(v)} days") + elif rows: + print("one day only: the range needs more signal files (see --help)") + return rows + + +if __name__ == "__main__": + main() diff --git a/tests/test_impact.py b/tests/test_impact.py index c6fd877..ded986a 100644 --- a/tests/test_impact.py +++ b/tests/test_impact.py @@ -91,3 +91,12 @@ def test_price_reproduces_the_caltech_day_number(): out = price(5, r=0.25, saving_usd=2.20, kwh=378.9, alpha=0.5) assert out["tier"] == "green" and out["usd_per_kwh"] == pytest.approx(0.247, abs=5e-4) assert 0.25 - out["usd_per_kwh"] == pytest.approx(0.0029, abs=2e-4) + + +def test_health_usd_is_keyword_only_and_none_without_an_index(): + plan, base = {"a": series(7, 12, start=60)}, {"a": series(7, 12)} + assert "health_usd" not in impact(plan, base, sig(), tar()) + assert impact(plan, base, sig(), tar(), health=True)["health_usd"] is None + s = dict(sig(), health_damage=[50.0] * 60 + [0.0] * (H - 60)) # $/MWh: only the first five hours hurt + out = impact(plan, base, s, tar(), health=True) + assert out["health_usd"] == pytest.approx(7 * 12 * (5 / 60) * 50 / 1000, abs=1e-6), "the shifted 7 kWh left the damaging hours"