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
130 changes: 130 additions & 0 deletions CHANGELOG.md

Large diffs are not rendered by default.

173 changes: 163 additions & 10 deletions README.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion docs/assets/version.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
// The package version shown in the viewer footer. Kept equal to hyperbolic.__version__
// by tests/test_viewer.py; refresh it with: python -m hyperbolic.viewer --write-version
window.HYPERBOLIC_VERSION = "0.9.0";
window.HYPERBOLIC_VERSION = "0.10.0";
2 changes: 1 addition & 1 deletion docs/data/viewer.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion docs/data/viewer.json

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion hyperbolic/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
"""Hyperbolic: a supervisory controller for CityLearn district simulations."""

__version__ = "0.9.0"
__version__ = "0.10.0"
122 changes: 86 additions & 36 deletions hyperbolic/cli.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
"""Command line: hyperbolic baseline | run | replay | compare | serve."""
"""Command line: hyperbolic baseline | run | replay | compare | months | serve."""

from __future__ import annotations

import argparse
import json
import sys
import tempfile
from pathlib import Path

from . import __version__
from . import district as dc
from . import months as mo
from .envelope import Envelope
from .model import (
DEFAULT_BUDGET_USD,
Expand Down Expand Up @@ -42,25 +44,29 @@ def _common(p: argparse.ArgumentParser) -> None:
p.add_argument("--hours", type=int, default=None,
help=f"stop after this many hours (default: the full {dc.EPISODE_HOURS} hour episode)")
p.add_argument("--runs-dir", type=Path, default=Path("runs"), help="where run folders go (default: runs)")
p.add_argument("--month", default=mo.PHASE2, choices=[m.key for m in mo.months()],
help=f"which month of the month set to run (default: {mo.PHASE2}, the reported month)")


def _sim(control: bool):
def _sim(control: bool, schema: Path | None = None):
from .sim import CityLearnSimulator

return CityLearnSimulator(control=control)
return CityLearnSimulator(control=control, schema=schema)


def cmd_baseline(args) -> int:
sim = _sim(control=False)
try:
sim.check_hours(args.hours)
except ValueError as exc:
print(f"error: {exc}", file=sys.stderr)
return 2
out = new_run_dir(args.runs_dir, "baseline", "citylearn")
print(f"baseline, {dc.DATASET} -> {out}")
res = run_session(sim, BaselineController(), RunWriter(out), args.hours,
{"kind": "citylearn", "control": False})
month = mo.month(args.month)
with tempfile.TemporaryDirectory() as tmp:
sim = _sim(control=False, schema=mo.month_schema(month, Path(tmp)))
try:
sim.check_hours(args.hours)
except ValueError as exc:
print(f"error: {exc}", file=sys.stderr)
return 2
out = new_run_dir(args.runs_dir, "baseline", "citylearn", month.dataset)
print(f"baseline, {month.dataset} -> {out}")
res = run_session(sim, BaselineController(), RunWriter(out), args.hours,
{"kind": "citylearn", "control": False, "month": month.key}, dataset=month.dataset)
_print_result(res)
return 0

Expand All @@ -76,28 +82,31 @@ def cmd_run(args) -> int:
print(f"error: {exc}", file=sys.stderr)
return 2
print(f"model key found in {source}")
hours = args.hours or dc.EPISODE_HOURS
try:
sim = _sim(control=True)
sim.check_hours(args.hours)
except ValueError as exc:
print(f"error: {exc}", file=sys.stderr)
return 2
faults = ScriptedSupervisor.default_faults(hours) if args.inject_faults else None
sup = ScriptedSupervisor(args.model, faults=faults) if args.scripted else LiveSupervisor(args.model)
prompt_version, envelope_cfg = controller_settings(args.scripted, args.prompt_version)
ctl = AgentController(
supervisor=sup,
envelope=Envelope(envelope_cfg),
budget=Budget(limit_usd=args.budget),
max_tokens=args.max_tokens,
effort=args.effort,
prompt_version=prompt_version,
)
out = new_run_dir(args.runs_dir, "agent", "citylearn")
print(f"agent, {dc.DATASET}, {sup.kind} replies, {MODELS[args.model]['id']}, "
f"budget ${args.budget:.2f} -> {out}")
res = run_session(sim, ctl, RunWriter(out), args.hours, {"kind": "citylearn", "control": True})
month = mo.month(args.month)
hours = args.hours or mo.hours(month)
with tempfile.TemporaryDirectory() as tmp:
try:
sim = _sim(control=True, schema=mo.month_schema(month, Path(tmp)))
sim.check_hours(args.hours)
except ValueError as exc:
print(f"error: {exc}", file=sys.stderr)
return 2
faults = ScriptedSupervisor.default_faults(hours) if args.inject_faults else None
sup = ScriptedSupervisor(args.model, faults=faults) if args.scripted else LiveSupervisor(args.model)
prompt_version, envelope_cfg = controller_settings(args.scripted, args.prompt_version)
ctl = AgentController(
supervisor=sup,
envelope=Envelope(envelope_cfg),
budget=Budget(limit_usd=args.budget),
max_tokens=args.max_tokens,
effort=args.effort,
prompt_version=prompt_version,
)
out = new_run_dir(args.runs_dir, "agent", "citylearn", month.dataset)
print(f"agent, {month.dataset}, {sup.kind} replies, {MODELS[args.model]['id']}, "
f"budget ${args.budget:.2f} -> {out}")
res = run_session(sim, ctl, RunWriter(out), args.hours,
{"kind": "citylearn", "control": True, "month": month.key}, dataset=month.dataset)
_print_result(res)
return 0

Expand Down Expand Up @@ -150,6 +159,35 @@ def cmd_compare(args) -> int:
return 0


def cmd_months(args) -> int:
import concurrent.futures as cf
import multiprocessing as mp

keys = args.months or [m.key for m in mo.months()]
rows = []
for d in args.run or []:
try:
rows.append(mo.recorded_run(Path(d)))
except (OSError, ValueError, KeyError) as exc:
print(f"error: --run {d}: {exc}", file=sys.stderr)
return 1
with tempfile.TemporaryDirectory() as tmp:
jobs = [(c, k, tmp) for c in args.controllers for k in keys]
print(f"{len(jobs)} CityLearn episodes ({', '.join(args.controllers)} on {', '.join(keys)}), no model calls")
# spawn, not fork: forking after torch has started its threads can deadlock.
with cf.ProcessPoolExecutor(max_workers=args.jobs, mp_context=mp.get_context("spawn")) as pool:
rows = list(pool.map(mo.run_month, jobs)) + rows
for r in rows:
if r["controller"] in mo.CONTROLLERS:
r["controller"] = mo.CONTROLLERS[r["controller"]][0]
rep = mo.report(rows)
print(mo.table(rep))
if args.out:
args.out.write_text(json.dumps(rep, indent=2, sort_keys=True), encoding="utf-8")
print(f"\nreport written to {args.out}")
return 0


def cmd_serve(args) -> int:
from .live import serve

Expand Down Expand Up @@ -217,7 +255,8 @@ def build_parser() -> argparse.ArgumentParser:
help=f"observation and prompt version (default: {PROMPT_VERSION}; with --scripted, "
f"{RULE_PROMPT_VERSION}, the 0.4.0 rule-based controller, while versions 4 and 5 "
"run their strategies literally, version 6 runs the version 5 strategy with the "
"trim hold and version 7 keeps it inside the ranges its observation shows)")
"trim hold, version 7 keeps it inside the ranges its observation shows and version 8 "
"adds extra cooling in a warm room)")
r.set_defaults(func=cmd_run)

rp = sub.add_parser("replay", help="re-run a saved run from its files with zero model or simulator calls")
Expand All @@ -234,6 +273,17 @@ def build_parser() -> argparse.ArgumentParser:
"(repeatable; the viewer data is unchanged)")
c.set_defaults(func=cmd_compare)

mm = sub.add_parser("months", help="run offline controllers on every month of the month set and compare them")
mm.add_argument("--controllers", nargs="+", default=list(mo.CONTROLLERS), choices=list(mo.CONTROLLERS),
help="offline controllers to run (default: all of them)")
mm.add_argument("--months", nargs="+", default=None, choices=[m.key for m in mo.months()],
help="months to run (default: every month available locally)")
mm.add_argument("--run", action="append", metavar="RUN_DIR",
help="also show this finished run at the month it was recorded on (repeatable)")
mm.add_argument("--jobs", type=int, default=None, help="parallel simulations (default: one per CPU)")
mm.add_argument("--out", type=Path, default=None, help="optionally write the report as JSON")
mm.set_defaults(func=cmd_months)

sv = sub.add_parser("serve", help="watch a run live in the browser (local only, 127.0.0.1)")
sv.add_argument("--port", type=int, default=8765, help="port on 127.0.0.1 (default: 8765)")
sv.add_argument("--host", default="127.0.0.1", help="only 127.0.0.1 or localhost are accepted")
Expand Down
8 changes: 4 additions & 4 deletions hyperbolic/live.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,10 +75,10 @@
{"id": "week", "label": "1 week (167 hours)", "hours": 167},
{"id": "month", "label": "Full month (719 hours)", "hours": dc.EPISODE_HOURS},
]
# Typical prompt version 7 request and reply, for a cost estimate before any run is recorded: the
# 0.8.0 live month averaged 5,245 input and 429 output tokens on version 6, version 7 requests are
# 1.7 percent smaller, and replies that send a held trim as a number run a little longer.
TYPICAL_TOKENS = (5160, 450)
# Typical prompt version 8 request and reply, for a cost estimate before any run is recorded: the
# 0.9.0 live month averaged 5,185 input and 245 output tokens on version 7, version 8 requests are
# 12 percent smaller, and replies that send the strategy's cooling as a number may run a little longer.
TYPICAL_TOKENS = (4560, 260)


def key_status(env: dict[str, str] | None = None) -> dict:
Expand Down
83 changes: 80 additions & 3 deletions hyperbolic/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,26 @@ class StrategyParamsV7(StrategyParamsV5):
# close to it


@dataclass(frozen=True)
class StrategyParamsV8(StrategyParamsV7):
"""Numbers in the prompt version 8 strategy: version 7's, plus extra cooling in a room that runs
warm. A trim leaves an overcooled room at its setpoint instead of the baseline's 2 C below it, so
the next day's heat (or an outage) can carry it over the top of the band. strategy_plan_v8
executes it and prompt version 8 shows its actions. Chosen on the tuning dataset
(scripts/tune_strategy.py --version 8), never on the reported month: the cheapest of the 36
settings tried that keeps every tuning month at or below the baseline's too-warm share."""

# Added in 0.10.0.
warm_margin_c: float = 1.5 # an occupied room this far above its setpoint is running warm
warm_boost: float = 0.05 # ... and its cooling is raised up to this far above the baseline action;
# 0 is no extra cooling
warm_ramp_c: float = 1.0 # the boost grows from 0 to warm_boost over this many degrees beyond
# warm_margin_c; 0 adds it all at once
night_from_hour: int = 0 # from this hour of the day (1 to 24) until night_until_hour no trim
night_until_hour: int = 6 # starts and a held trim is ended; 0 is no night rule. Searched and not
# shipped: it raised cost and cleared no warm hour


@dataclass(frozen=True)
class RuleParamsV2:
"""Knobs of the 0.3.0 rule-based policy for prompt version 2 observations, kept as tuned
Expand Down Expand Up @@ -330,7 +350,8 @@ class ScriptedSupervisor:
how the strategy's numbers are chosen offline, and likewise for version 5
(strategy_plan_v5). Version 6 runs the same version 5 strategy, never ends a trim early,
and leaves holding trims to the envelope. Version 7 (strategy_plan_v7) keeps every action
inside the ranges the observation shows and uses the version 7 battery plan. Version 2
inside the ranges the observation shows and uses the version 7 battery plan; version 8
(strategy_plan_v8) adds extra cooling in a warm room. Version 2
observations get the 0.3.0 rule and version 1 the simpler 0.2.0 rule.

Faults are opt-in: on listed steps it injects a bad reply so the envelope and fallback
Expand All @@ -342,7 +363,8 @@ class ScriptedSupervisor:
def __init__(self, model_key: str = DEFAULT_MODEL, faults: dict[int, str] | None = None,
tokens: tuple[int, int] = (0, 0), params: RuleParams | None = None,
params_v2: RuleParamsV2 | None = None, strategy: StrategyParams | None = None,
strategy_v5: StrategyParamsV5 | None = None, strategy_v7: StrategyParamsV7 | None = None) -> None:
strategy_v5: StrategyParamsV5 | None = None, strategy_v7: StrategyParamsV7 | None = None,
strategy_v8: StrategyParamsV8 | None = None) -> None:
self.model_key = model_key
self.faults = dict(faults or {})
self.tokens = tokens
Expand All @@ -351,6 +373,7 @@ def __init__(self, model_key: str = DEFAULT_MODEL, faults: dict[int, str] | None
self.strategy = strategy or StrategyParams()
self.strategy_v5 = strategy_v5 or StrategyParamsV5()
self.strategy_v7 = strategy_v7 or StrategyParamsV7()
self.strategy_v8 = strategy_v8 or StrategyParamsV8()

@staticmethod
def default_faults(hours: int) -> dict[int, str]:
Expand All @@ -371,7 +394,10 @@ def call(self, step: int, request: dict) -> CallResult:
if fault == "malformed":
return CallResult('{"reason": "charge batteries", "Building_1": {"battery": 0.4', *self.tokens,
stop_reason="end_turn", model="scripted")
if "cooling_range" in next(iter(obs["buildings"].values()), {}): # version 7
first = next(iter(obs["buildings"].values()), {})
if "strategy" in first: # version 8
reply = strategy_plan_v8(obs, self.strategy_v8)
elif "cooling_range" in first: # version 7
reply = strategy_plan_v7(obs, self.strategy_v7)
elif "smoothing" in obs:
reply = strategy_plan_v5(obs, self.strategy_v5)
Expand Down Expand Up @@ -832,6 +858,57 @@ def strategy_plan_v7(obs: dict, p: StrategyParamsV7 | None = None) -> dict:
return {"reason": (reason[0].upper() + reason[1:])[:dc.REASON_TARGET_CHARS], **reply}


WARM_NOTE = "cool a warm room a little more"
NIGHT_NOTE = "no cooling trim at night"
NIGHT_END_TRIM = "night: the room cools back to its cushion for tomorrow"


def night_now(hour: int, p: StrategyParamsV8) -> bool:
"""Whether hour of the day (1 to 24) falls in the strategy's no-trim night."""
if p.night_from_hour <= 0:
return False
if p.night_from_hour <= p.night_until_hour:
return p.night_from_hour <= hour < p.night_until_hour
return hour >= p.night_from_hour or hour < p.night_until_hour


def strategy_plan_v8(obs: dict, p: StrategyParamsV8 | None = None) -> dict:
"""The prompt version 8 strategy: version 7's, with no trim at night (none starts, a held one is
ended with end_trim) and an occupied room more than warm_margin_c above its setpoint, with no
trim held, cooled up to warm_boost above baseline_action within cooling_range. Prompt version 8
shows each building's result as strategy."""
p = p or StrategyParamsV8()
reply = strategy_plan_v7(obs, p)
warm = night = False
if night_now(int(obs["hour"]), p):
for b, s in sorted(obs["buildings"].items()):
base = s["baseline_action"]["cooling"]
if s["trim"]["end_trim_ok"]:
reply[b]["cooling"], reply[b]["end_trim"] = None, NIGHT_END_TRIM
night = True
elif s["trim"]["state"] not in ("hold", "ease") and reply[b]["cooling"] is not None and base is not None \
and reply[b]["cooling"] < base:
reply[b]["cooling"] = None
night = True
for b, s in sorted(obs["buildings"].items()):
gap, rng = s["indoor_vs_setpoint_c"], s["cooling_range"]
base = s["baseline_action"]["cooling"]
# In an outage the grid cannot serve extra cooling, and CityLearn counts it as unserved energy.
if (p.warm_boost <= 0 or not s["occupied"] or s["power_outage"] or gap is None or rng is None or base is None
or s["trim"]["state"] in ("hold", "ease") or gap < p.warm_margin_c):
continue
boost = p.warm_boost if p.warm_ramp_c <= 0 else p.warm_boost * min(1.0, (gap - p.warm_margin_c) / p.warm_ramp_c)
# The baseline arrives rounded to 3 decimals: keep 0.001 clear of the deviation limit.
cooling = min(max(math.floor((base + boost - 0.001) * 1000) / 1000, rng[0]), rng[1])
if cooling > base + 0.001:
reply[b]["cooling"] = cooling
warm = True
notes = [*([NIGHT_NOTE] if night else []), *([WARM_NOTE] if warm else [])]
if notes:
reply["reason"] = "; ".join([reply["reason"], *notes])[:dc.REASON_TARGET_CHARS]
return reply


class HoldSupervisor:
"""Answers every hour with null actions, so the envelope applies the baseline action
everywhere: the no-control reference run through the agent path. Used by the live
Expand Down
Loading
Loading