From 3f0c9e635443914f3c2cd49115835e87ad51ff08 Mon Sep 17 00:00:00 2001 From: Zhirui Liang Date: Sun, 3 May 2026 23:50:01 -0400 Subject: [PATCH 01/13] Add PPO RL controller, scenario library, and evaluation pipeline --- .gitignore | 24 + _zensical.toml | 1 + docs/examples/rl-controller.md | 122 ++ .../examples/voltage-regulation-strategies.md | 4 +- examples/offline/build_scenario_library.py | 1574 +++++++++++++++++ examples/offline/evaluate_controllers.py | 1231 +++++++++++++ examples/offline/sweep_dc_locations.py | 981 ++++++---- examples/offline/systems.py | 1387 ++++++++++++++- examples/offline/train_ppo.py | 845 +++++++++ openg2g/controller/ppo.py | 304 ++++ openg2g/controller/rule_based.py | 19 +- openg2g/grid/opendss.py | 2 +- openg2g/rl/env.py | 914 ++++++++++ paper_plots.mplstyle | 42 + plots.py | 718 ++++++++ scripts/verify_post_merge_migration.sh | 110 ++ 16 files changed, 7929 insertions(+), 349 deletions(-) create mode 100644 docs/examples/rl-controller.md create mode 100644 examples/offline/build_scenario_library.py create mode 100644 examples/offline/evaluate_controllers.py create mode 100644 examples/offline/train_ppo.py create mode 100644 openg2g/controller/ppo.py create mode 100644 openg2g/rl/env.py create mode 100644 paper_plots.mplstyle create mode 100644 plots.py create mode 100755 scripts/verify_post_merge_migration.sh diff --git a/.gitignore b/.gitignore index 34c0afe..ce595c5 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,27 @@ docs/api/ data examples/*/outputs +.claude/ +outputs/ +slurm_*.out +examples/offline/*.sh +/sbatch_*.sh +/launch_*.sh + +# Paper-specific plotting scripts (not part of the upstream pipeline) +examples/offline/plot_4way_multiseed.py +examples/offline/plot_dataset_difficulty_3systems.py +examples/offline/plot_dataset_diversity_ieee13.py +examples/offline/plot_pipeline.py +examples/offline/plot_training_reward_3systems.py +examples/offline/plot_training_reward_breakdown_3systems.py +examples/offline/plot_wv_sweep_combined_ieee13.py +examples/offline/plot_wv_sweep_convergence_ieee13.py +examples/offline/plot_wv_sweep_training_violation_ieee13.py +examples/offline/dump_paper_batch_subfigures.py +examples/offline/check_consistency.py +examples/offline/compare_ppo_runs.py +examples/offline/merge_over_supplement_ieee13.py +examples/offline/migrate_scenario_library.py +examples/offline/filter_scenario_library.py +.git.backup-pre-filter/ diff --git a/_zensical.toml b/_zensical.toml index 7c188cd..42827da 100644 --- a/_zensical.toml +++ b/_zensical.toml @@ -37,6 +37,7 @@ nav = [ { "Examples" = [ { "GPU Flexibility for Voltage Regulation" = "examples/gpu-flexibility.md" }, { "Voltage Regulation Strategies" = "examples/voltage-regulation-strategies.md" }, + { "Reinforcement Learning Controller (PPO)" = "examples/rl-controller.md" }, { "Controller Parameter Sensitivity" = "examples/controller-parameter-sensitivity.md" }, { "Grid Topology and DER Effects" = "examples/grid-topology-effects.md" }, { "Datacenter Sizing and Hosting Capacity" = "examples/hosting-capacity.md" }, diff --git a/docs/examples/rl-controller.md b/docs/examples/rl-controller.md new file mode 100644 index 0000000..47a7223 --- /dev/null +++ b/docs/examples/rl-controller.md @@ -0,0 +1,122 @@ +# Reinforcement Learning Controller (PPO) + +## Research Question + +Can a reinforcement learning policy learn to regulate distribution-feeder voltage by adjusting LLM batch sizes, and how does it compare to model-based approaches like OFO and rule-based control? + +## Overview + +OpenG2G ships a Proximal Policy Optimization (PPO) controller built on top of [stable-baselines3](https://stable-baselines3.readthedocs.io/). Unlike OFO (which needs voltage sensitivity matrices and per-model logistic fits) and rule-based control (which only knows the worst violation magnitude), the PPO policy: + +- Reads a structured observation of the grid + datacenter state at each control tick. +- Outputs a per-model batch-size action (delta or coupled, depending on the action mode). +- Is trained against a per-step reward that combines voltage-violation penalty, throughput bonus, latency penalty, and a switching-cost term. + +The full RL workflow has three stages: + +1. **Build a scenario library** — a pool of pre-screened, randomized PV / TVL / inference-ramp scenarios that the PPO environment will sample from during training. Filtering the library to scenarios where OFO has meaningful headroom keeps the learning signal focused. +2. **Train a PPO policy** — multi-million-step PPO run on the library scenarios, with checkpointing. +3. **Evaluate** — replay held-out scenarios with baseline / rule-based / OFO / PPO controllers and compare voltage and throughput metrics. + +The library code lives in [`openg2g.rl.env`][openg2g.rl.env] (Gymnasium environment, observation/reward configuration, scenario sampling) and [`openg2g.controller.ppo`][openg2g.controller.ppo] (single-site `PPOBatchSizeController` + multi-site `SharedPPOBatchSizeController` for inference). + +## Scripts + +| Script | Purpose | +|--------|---------| +| `build_scenario_library.py` | Generate, screen, and filter a per-system scenario library | +| `train_ppo.py` | PPO training loop using the scenario library; saves model + VecNormalize stats | +| `evaluate_controllers.py` | Compare baseline / OFO / rule-based / PPO on held-out test scenarios | + +## Usage + +### One-time setup: per-spec inference data cache + +The first run of any of these scripts triggers a one-time download + extraction of LLM benchmark data from the HuggingFace `ml-energy-data` repo, populating a per-spec content-addressed cache under `data/specs//`. This step needs roughly **32–64 GB of RAM** during extraction; it will OOM on a typical login node. Run it on a compute node (or via slurm) once, after which all subsequent calls reuse the cache. A turnkey end-to-end smoke is provided in `scripts/verify_post_merge_migration.sh`. + +### Stage 1: Build a scenario library + +```bash +# IEEE 13-bus, 200 candidate scenarios, default screening filter +python examples/offline/build_scenario_library.py \ + --system ieee13 \ + --n-candidates 200 \ + --output-dir examples/offline/outputs/ieee13/scenario_library/n200 +``` + +This produces a `library.pkl` containing accepted scenarios (those where OFO meaningfully reduces voltage violation versus baseline, controlled by `--min-baseline-integral` and `--min-recovery-frac`), per-scenario voltage-envelope plots, and a CSV of metrics. + +### Stage 2: Train PPO + +```bash +python examples/offline/train_ppo.py \ + --system ieee13 \ + --total-timesteps 2000000 \ + --learning-rate 1e-4 \ + --n-steps 3600 \ + --hidden-dims 128 128 \ + --w-voltage 1000 --w-throughput 0 --w-switch 0.01 \ + --scenario-library examples/offline/outputs/ieee13/scenario_library/n200/library.pkl \ + --output-dir examples/offline/outputs/ieee13/ppo +``` + +Key flags (see `train_ppo.py --help` for the full list): + +- `--system`: `ieee13`, `ieee34`, or `ieee123`. +- `--total-timesteps`: cumulative environment steps across all parallel envs. ~2M is enough for IEEE 13; IEEE 34 / IEEE 123 typically need ~5M. +- `--obs-mode`: `full-voltage` (default), `per-zone-summary`, `per-bus-summary`, or `system-summary-only`. Lower-dimensional observations help generalization on larger feeders. +- `--w-voltage / --w-throughput / --w-latency / --w-switch`: reward weights. Setting `w-throughput=0` keeps the policy focused on voltage; the resulting throughput is reported but not optimized. +- `--n-envs`: parallel rollout envs (default 1). Increase for wall-clock speed. +- `--scenario-library`: path to the `.pkl` from Stage 1. Without it, the env synthesizes scenarios on the fly with the `randomize_scenario` defaults. +- `--ofo-baseline`: include OFO action as a reward-shaping baseline (advanced). + +The output directory holds the saved model (`ppo_model.zip`), VecNormalize statistics (`ppo_model_vecnormalize.pkl`), TensorBoard logs (`tb/`), per-checkpoint copies (`checkpoints/`), and a training-progress plot. + +### Stage 3: Evaluate + +```bash +python examples/offline/evaluate_controllers.py \ + --system ieee13 \ + --n-scenarios 50 \ + --ppo-models examples/offline/outputs/ieee13/ppo/ppo_model.zip \ + --ppo-labels champion \ + --include-rule-based \ + --output-dir examples/offline/outputs/ieee13/eval_n50 +``` + +The evaluator filters held-out seeds to keep only scenarios that exhibit a baseline violation and where OFO recovers most of it (controllable by `--min-baseline-integral` and `--min-recovery-frac`), then runs each controller on the same scenario set. + +Outputs: + +- `results.csv` — per-scenario metrics for every controller: violation time, integral violation, worst Vmin/Vmax, mean throughput, p99 latency, mean power, batch-change count. +- `aggregate_*.png` — bar charts comparing voltage / throughput / batch-switching across controllers. +- `scenario_/` — per-scenario voltage envelopes and batch-size traces. + +### Inference: using a trained PPO inside other scripts + +A trained PPO checkpoint can also be loaded inside `build_scenario_library.py --mode ppo`, which runs the policy directly without retraining: + +```bash +python examples/offline/build_scenario_library.py \ + --system ieee13 \ + --mode ppo \ + --ppo-model examples/offline/outputs/ieee13/ppo/ppo_model.zip +``` + +## What to Look For + +- **Voltage**: a well-trained PPO matches or slightly trails OFO on integral violation (pu·s). For IEEE 13 it typically sits within 10–30 % of OFO's voltage performance. +- **Throughput**: PPO usually serves *more* tokens per second than OFO because it's free to choose any feasible batch level rather than following gradient descent toward a fixed setpoint. +- **Switching**: untrained or under-trained policies oscillate a lot (high `batch_chg`); the `--w-switch` term penalizes this. Compare PPO's `Batch Δ` column against OFO's to see whether the policy has learned a smooth control trajectory. +- **Latency**: PPO can violate ITL deadlines if `--w-latency` is set to 0; turn it on if your application is latency-sensitive. + +## Configuration + +PPO-specific settings live in two places: + +- **CLI flags** on `train_ppo.py` (architecture, optimization, reward weights, scenario randomization). +- **Code-level**: `openg2g.rl.env.ObservationConfig` (which signals enter the policy observation), `openg2g.rl.env.RewardConfig` (per-component reward weights and clipping), and the per-system experiment factories in `examples/offline/systems.py` (model deployments, replica schedules, base loads). + +The hardcoded model spec list (`ALL_MODEL_SPECS` in `systems.py`) defines the exact 5 LLM workloads (Llama-3.1-8B / 70B / 405B, Qwen3-30B-A3B, Qwen3-235B-A22B). To use a different mix, edit that tuple and re-run Stage 1 (the `data/specs/` cache is keyed by per-spec hash, so only changed specs regenerate). + +See [Voltage Regulation Strategies](voltage-regulation-strategies.md) for a side-by-side comparison of PPO with the model-based controllers, and [Building Simulators](../guide/building-simulators.md) for the underlying API. diff --git a/docs/examples/voltage-regulation-strategies.md b/docs/examples/voltage-regulation-strategies.md index 46b8957..db358d8 100644 --- a/docs/examples/voltage-regulation-strategies.md +++ b/docs/examples/voltage-regulation-strategies.md @@ -8,11 +8,12 @@ How does datacenter-side batch-size control compare with grid-side regulator tap Distribution feeders traditionally regulate voltage using regulator tap changers: mechanical devices that adjust transformer turns ratios. Datacenter batch-size control offers a complementary demand-side approach: adjusting GPU workload parameters to modulate power consumption in real time. -This analysis compares three control strategies: +This analysis compares four control strategies: 1. **Baseline with tap changes**: Traditional grid-side control only (regulator tap schedule, no batch adjustment) 2. **Rule-based batch control**: Simple proportional controller that reduces batch on undervoltage and increases on overvoltage; no sensitivity matrix or model fits required 3. **OFO batch control**: Primal-dual optimization using voltage sensitivity matrices and logistic curve fits for gradient-based batch adjustment +4. **PPO batch control**: A reinforcement-learning policy (Proximal Policy Optimization) that maps a structured observation of grid + datacenter state to per-model batch-size actions. Requires a separate training run; see [Reinforcement Learning Controller (PPO)](rl-controller.md) for the end-to-end workflow. ## Scripts @@ -23,6 +24,7 @@ This analysis compares three control strategies: | `run_ofo.py --mode ofo-no-tap` | OFO without tap schedule | | `run_ofo.py --mode ofo-tap-change` | OFO with tap schedule | | `analyze_different_controllers.py` | Side-by-side comparison of baseline, rule-based, and OFO | +| `evaluate_controllers.py` | Held-out scenario evaluation that also accepts trained PPO models via `--ppo-models` (see [Reinforcement Learning Controller (PPO)](rl-controller.md)) | ## Usage diff --git a/examples/offline/build_scenario_library.py b/examples/offline/build_scenario_library.py new file mode 100644 index 0000000..d103ab8 --- /dev/null +++ b/examples/offline/build_scenario_library.py @@ -0,0 +1,1574 @@ +"""Build a pre-screened scenario library for PPO training. + +Generates randomized ieee13 scenarios using the same randomization logic as +``train_ppo.py:make_sim_factory``, runs both baseline (no controller) and OFO +on each, and accepts a scenario only if: + + 1) The baseline episode has non-trivial voltage violation (so the policy + has something to learn), and + 2) OFO recovers at least ``--min-recovery-frac`` of the baseline integral + violation (so the violation is within the GPU-flexibility envelope). + +For each accepted scenario the library stores the seed (so the env can +reproduce it) plus the OFO per-second voltage-penalty trace, which the env +will subtract from PPO's voltage cost during training to give a fair, +scenario-difficulty-normalized reward. + +Usage: + python examples/offline/build_scenario_library.py --n-candidates 10 + python examples/offline/build_scenario_library.py --n-candidates 50 \\ + --pv-base-kw 200 --tvl-base-kw 200 --min-recovery-frac 0.8 + +Outputs: + examples/offline/outputs/ieee13/scenario_library// + library.pkl -- list of accepted ScenarioRecord + candidates.csv -- per-candidate stats (accepted + rejected) + scenario_envelopes.png -- voltage envelope per scenario, baseline vs OFO + scenario_summary.png -- bar chart of integral violation, baseline vs OFO + +The .pkl is the artifact training will load via train_ppo.py's +``--scenario-library`` flag (added in the next step). +""" + +from __future__ import annotations + +import csv +import logging +import math +import pickle +import sys +from dataclasses import asdict, dataclass, field +from pathlib import Path + +import matplotlib + +matplotlib.use("Agg") +import matplotlib.pyplot as plt +import numpy as np + +from systems import ( + DT_CTRL, + DT_DC, + DT_GRID, + EXPERIMENTS, + POWER_AUG, + TOTAL_DURATION_S, + V_MAX, + V_MIN, + DCSite, + PVSystemSpec, + SPECS_CACHE_DIR, + ScenarioOpenDSSGrid, + TRAINING_TRACE_PATH, + TimeVaryingLoadSpec, + randomize_scenario, +) + +from openg2g.controller.ofo import LogisticModelStore, OFOConfig +from openg2g.controller.rule_based import RuleBasedBatchSizeController, RuleBasedConfig +from openg2g.controller.tap_schedule import TapScheduleController +from openg2g.coordinator import Coordinator +from openg2g.datacenter.config import DatacenterConfig, InferenceModelSpec, ReplicaSchedule, TrainingRun +from openg2g.datacenter.offline import OfflineDatacenter, OfflineWorkload +from openg2g.datacenter.workloads.inference import InferenceData +from openg2g.datacenter.workloads.training import TrainingTrace +from openg2g.grid.config import TapSchedule +from openg2g.metrics.voltage import VoltageStats, compute_allbus_voltage_stats + +logger = logging.getLogger("scenario_library") + + +def run_simulation( + mode: str, + *, + sys: dict, + dc_sites: dict[str, DCSite], + ofo_config, + inference_data: InferenceData, + training_trace: TrainingTrace, + logistic_models, + pv_systems: list[PVSystemSpec] | None = None, + time_varying_loads: list[TimeVaryingLoadSpec] | None = None, + tap_schedule: TapSchedule | None = None, + rule_based_config: RuleBasedConfig | None = None, + rule_zone_local: bool = False, + ppo_model: str = "", + obs_mode: str = "full-voltage", + training_overlay: dict | None = None, + save_dir: Path, +) -> tuple[VoltageStats, object]: + """Run a simulation with the specified controller mode. + + Modes: + 'baseline': NoopController (no batch control) + 'rule_based': RuleBasedBatchSizeController + 'ofo': OFOBatchSizeController + 'ppo': PPOBatchSizeController / SharedPPOBatchSizeController + + rule_zone_local: when True AND sys defines `zones` AND there is more than + one DC site, each rule-based controller observes only the buses in its + own zone (looked up via the site_id key). Decentralizes credit assignment + in multi-DC topologies (ieee123). No effect for single-site systems. + + Returns (VoltageStats, SimulationLog). + """ + pv_systems = pv_systems or [] + time_varying_loads = time_varying_loads or [] + exclude_buses = tuple(sys["exclude_buses"]) + site_ids = list(dc_sites.keys()) + + training_run = None + if training_overlay is not None: + training_run = TrainingRun( + n_gpus=training_overlay["n_gpus"], + trace=training_trace, + target_peak_W_per_gpu=training_overlay["target_peak_W_per_gpu"], + ).at(t_start=training_overlay["t_start"], t_end=training_overlay["t_end"]) + + datacenters: dict[str, OfflineDatacenter] = {} + controllers: list = [] + site_specs_map: dict[str, tuple[InferenceModelSpec, ...]] = {} + primary_bus = "" + + for site_id, site in dc_sites.items(): + site_specs = tuple(md.spec for md, _ in site.models) + site_specs_map[site_id] = site_specs + site_inference = inference_data.filter_models(site_specs) + + replica_schedules: dict[str, ReplicaSchedule] = { + md.spec.model_label: sched for md, sched in site.models + } + initial_batch_sizes = {md.spec.model_label: md.initial_batch_size for md, _ in site.models} + + dc_config = DatacenterConfig( + gpus_per_server=8, + base_kw_per_phase=site.base_kw_per_phase, + ) + workload_kwargs: dict = { + "inference_data": site_inference, + "replica_schedules": replica_schedules, + "initial_batch_sizes": initial_batch_sizes, + } + if training_run is not None: + workload_kwargs["training"] = training_run + workload = OfflineWorkload(**workload_kwargs) + + dc = OfflineDatacenter( + dc_config, + workload, + name=site_id, + dt_s=DT_DC, + seed=site.seed, + power_augmentation=POWER_AUG, + total_gpu_capacity=site.total_gpu_capacity, + ) + datacenters[site_id] = dc + if not primary_bus: + primary_bus = site.bus + + # Build grid then attach DCs (master's imperative pattern; the old + # dc_loads / dc_bus kwargs are no longer accepted by OpenDSSGrid). + grid = ScenarioOpenDSSGrid( + pv_systems=pv_systems, + time_varying_loads=time_varying_loads, + source_pu=sys["source_pu"], + dss_case_dir=sys["dss_case_dir"], + dss_master_file=sys["dss_master_file"], + dt_s=DT_GRID, + initial_tap_position=sys["initial_taps"], + exclude_buses=exclude_buses, + ) + dc_pf = DatacenterConfig(base_kw_per_phase=0).power_factor + for site_id, dc in datacenters.items(): + site = dc_sites[site_id] + grid.attach_dc(dc, bus=site.bus, connection_type=site.connection_type, power_factor=dc_pf) + + if mode == "baseline": + sched = tap_schedule if tap_schedule is not None else TapSchedule(()) + else: + sched = TapSchedule(()) + controllers.append(TapScheduleController(schedule=sched, dt_s=DT_CTRL)) + + if mode == "ofo": + from openg2g.controller.ofo import OFOBatchSizeController + + for site_id in site_ids: + site_initial_bs = { + md.spec.model_label: md.initial_batch_size for md, _ in dc_sites[site_id].models + } + ofo_ctrl = OFOBatchSizeController( + site_specs_map[site_id], + datacenter=datacenters[site_id], + grid=grid, + models=logistic_models, + config=ofo_config, + dt_s=DT_CTRL, + initial_batch_sizes=site_initial_bs, + ) + controllers.append(ofo_ctrl) + + elif mode == "rule_based" or mode.startswith("rule_based_"): + rb_config = rule_based_config or RuleBasedConfig(v_min=V_MIN, v_max=V_MAX) + zones = sys.get("zones") if rule_zone_local else None + for site_id in site_ids: + site_initial_bs = { + md.spec.model_label: md.initial_batch_size for md, _ in dc_sites[site_id].models + } + zone_buses = None + if zones is not None and len(site_ids) > 1 and site_id in zones: + zone_buses = tuple(zones[site_id]) + rb_ctrl = RuleBasedBatchSizeController( + site_specs_map[site_id], + datacenter=datacenters[site_id], + grid=grid, + config=rb_config, + dt_s=DT_CTRL, + exclude_buses=exclude_buses, + zone_buses=zone_buses, + initial_batch_sizes=site_initial_bs, + ) + controllers.append(rb_ctrl) + + elif mode == "ppo": + from openg2g.controller.ppo import PPOBatchSizeController, SharedPPOBatchSizeController + from openg2g.rl.env import ObservationConfig + + ppo_path = Path(ppo_model).resolve() + + def _find_vecnormalize(model_file: Path) -> Path | None: + mf = Path(model_file) + stem = mf.with_suffix("").name + candidates = [ + mf.parent / f"{stem}_vecnormalize.pkl", + mf.parent / "vecnormalize.pkl", + ] + if stem.startswith("ppo_") and stem.endswith("_steps"): + candidates.append(mf.parent / f"ppo_vecnormalize_{stem[len('ppo_'):]}.pkl") + for parent in [mf.parent, *mf.parent.parents][:4]: + candidates.extend(sorted(parent.glob("ppo_model_*_vecnormalize.pkl"))) + for c in candidates: + if c.is_file(): + return c + return None + + shared_model = None + if ppo_path.is_dir(): + cand = ppo_path / "ppo_model_shared.zip" + if cand.exists(): + shared_model = cand + elif ppo_path.suffix == ".zip": + looks_shared = ( + len(site_ids) > 1 + or "shared" in ppo_path.parts + or ppo_path.stem.startswith("ppo_model_shared") + ) + if looks_shared and ppo_path.exists(): + shared_model = ppo_path + if shared_model is not None and shared_model.exists(): + from stable_baselines3 import PPO as SB3PPO + + sb3 = SB3PPO.load(str(shared_model.with_suffix(""))) + saved_obs_dim = sb3.observation_space.shape[0] + n_models_all = sum(len(dc_sites[sid].models) for sid in site_ids) + zones = sys.get("zones") + + if obs_mode == "per-zone-summary": + n_bus_phases = 0 + zone_summary = {zname: tuple(zbuses) for zname, zbuses in zones.items()} if zones else None + bus_phase_groups = None + elif obs_mode == "system-summary-only": + n_bus_phases = 0 + zone_summary = None + bus_phase_groups = None + elif obs_mode == "per-bus-summary": + from openg2g.rl.env import compute_bus_phase_groups + grid.do_reset() + grid.start() + _v_index = grid.v_index + grid.stop() + bus_phase_groups = compute_bus_phase_groups(_v_index) + n_bus_phases = 2 * len(bus_phase_groups) + zone_summary = {zname: tuple(zbuses) for zname, zbuses in zones.items()} if zones else None + else: # "full-voltage" + n_bus_phases = saved_obs_dim - 3 - 5 * n_models_all + zone_summary = None + bus_phase_groups = None + + site_model_mapping = {sid: [md.spec.model_label for md, _ in dc_sites[sid].models] for sid in site_ids} + all_init_bs = {md.spec.model_label: md.initial_batch_size for sid in site_ids for md, _ in dc_sites[sid].models} + obs_config = ObservationConfig.from_multi_site( + site_specs_map, + {sid: {md.spec.model_label: sched.initial for md, sched in dc_sites[sid].models} for sid in site_ids}, + n_bus_phases=n_bus_phases, + initial_batch_sizes=all_init_bs, + zone_summary=zone_summary, + bus_phase_groups=bus_phase_groups, + v_min=V_MIN, + v_max=V_MAX, + ) + vn_path = _find_vecnormalize(shared_model) + if vn_path is not None: + logger.info("PPO: loading VecNormalize stats from %s", vn_path) + else: + logger.warning( + "PPO: no VecNormalize stats found next to %s — policy will see UNNORMALIZED obs", shared_model + ) + ppo_ctrl = SharedPPOBatchSizeController( + datacenter=next(iter(datacenters.values())), + grid=grid, + model_path=str(shared_model), + obs_config=obs_config, + site_model_mapping=site_model_mapping, + dt_s=DT_CTRL, + vecnormalize_path=str(vn_path) if vn_path is not None else None, + ) + controllers.append(ppo_ctrl) + else: + zones = sys.get("zones") + + for site_id in site_ids: + if ppo_path.is_dir(): + site_model = str(ppo_path / f"ppo_model_{site_id}.zip") + elif ppo_path.suffix == ".zip" and len(site_ids) == 1: + site_model = str(ppo_path) + else: + site_model = str(ppo_path.parent / f"ppo_model_{site_id}.zip") + + from stable_baselines3 import PPO as SB3PPO + + sb3 = SB3PPO.load(str(Path(site_model).with_suffix(""))) + saved_obs_dim = sb3.observation_space.shape[0] + n_models_site = len(dc_sites[site_id].models) + n_bus_phases = saved_obs_dim - 3 - 5 * n_models_site + + zone_buses = None + if zones is not None and site_id in zones: + zone_buses = tuple(zones[site_id]) + + specs = site_specs_map[site_id] + replica_counts = {md.spec.model_label: sched.initial for md, sched in dc_sites[site_id].models} + site_init_bs = {md.spec.model_label: md.initial_batch_size for md, _ in dc_sites[site_id].models} + obs_config = ObservationConfig.from_model_specs( + specs, replica_counts, n_bus_phases=n_bus_phases, initial_batch_sizes=site_init_bs, zone_buses=zone_buses, v_min=V_MIN, v_max=V_MAX + ) + vn_path = _find_vecnormalize(Path(site_model)) + if vn_path is not None: + logger.info("PPO[%s]: loading VecNormalize stats from %s", site_id, vn_path) + else: + logger.warning( + "PPO[%s]: no VecNormalize stats found next to %s — policy will see UNNORMALIZED obs", + site_id, + site_model, + ) + ppo_ctrl = PPOBatchSizeController( + specs, + datacenter=datacenters[site_id], + grid=grid, + model_path=site_model, + obs_config=obs_config, + dt_s=DT_CTRL, + vecnormalize_path=str(vn_path) if vn_path is not None else None, + ) + controllers.append(ppo_ctrl) + + coord = Coordinator( + datacenters=list(datacenters.values()), + grid=grid, + controllers=controllers, + total_duration_s=TOTAL_DURATION_S, + ) + + from openg2g.controller.ppo import SharedPPOBatchSizeController + + for ctrl in controllers: + if isinstance(ctrl, SharedPPOBatchSizeController): + ctrl.attach_datacenters(list(datacenters.values())) + + logger.info("Running %s...", mode) + log = coord.run() + + vstats = compute_allbus_voltage_stats( + log.grid_states, + v_min=V_MIN, + v_max=V_MAX, + exclude_buses=exclude_buses, + ) + logger.info( + " %s: viol=%.1fs integral=%.4f vmin=%.4f vmax=%.4f", + mode, + vstats.violation_time_s, + vstats.integral_violation_pu_s, + vstats.worst_vmin, + vstats.worst_vmax, + ) + + return vstats, log + + +@dataclass +class ScenarioRecord: + """One pre-screened scenario, ready to be replayed at training time. + + ``__module__`` is forced to ``build_scenario_library`` below so pickles + of this class work whether the script is run as ``__main__`` or imported. + Without the override, running ``python build_scenario_library.py`` writes + ``__main__.ScenarioRecord`` into the pickle, which then fails to unpickle + in any other process (e.g. train_ppo.py). See openg2g/rl/env.py:ScenarioLibrary + stub registration. + """ + + seed: int + pv_scale: float + load_scale: float + training_overlay: dict | None + baseline_integral: float + ofo_integral: float + baseline_violation_time_s: float + ofo_violation_time_s: float + recovery_frac: float + # Per-second OFO voltage penalty (sum of squared violation, length = + # total_duration_s). This is what gets subtracted from PPO's per-step + # voltage_pen during training. + ofo_voltage_pen_per_step: np.ndarray = field(repr=False) + baseline_voltage_pen_per_step: np.ndarray = field(repr=False) + # Episode window for truncated training: skip quiet prefix, stop after last violation. + # Computed from baseline_voltage_pen_per_step at build time with configurable buffers. + # env.py reads these via getattr(..., default) so old pickles without these fields degrade + # gracefully to full-episode training. + t_control_start: int = 0 + t_control_end: int = 3600 + bl_undervoltage_time_s: float = 0.0 + bl_overvoltage_time_s: float = 0.0 + # Per-scenario override for inference-ramp synthesis at replay time. + # When None, train_ppo uses the library-wide default from the library config. + randomize_ramps: bool | None = None + # Per-scenario ramp bounds. None falls back to the library-wide defaults in train_ppo. + ramp_frac_min: float | None = None + ramp_frac_max: float | None = None + ramp_start_min: float | None = None + ramp_start_max: float | None = None + ramp_dur_min: float | None = None + ramp_dur_max: float | None = None + # Resolved per-episode state — captured at build time so consumers don't + # need to replay randomize_scenario. Old pickles default to None and must + # be upgraded with examples/offline/migrate_scenario_library.py before use. + resolved_dc_sites: dict | None = None + resolved_pv_systems: tuple | None = None + resolved_tvl: tuple | None = None + + +# Force stable pickle identity regardless of how build_scenario_library.py is +# invoked (as __main__ script vs imported module). See docstring above. +ScenarioRecord.__module__ = "build_scenario_library" +# When run as `python build_scenario_library.py`, the script's globals live in +# sys.modules['__main__'] and there is no entry for 'build_scenario_library'. +# Pickle uses ScenarioRecord.__module__ to find the class at unpickle time, so +# we alias sys.modules so both names resolve to the same module object. +sys.modules.setdefault("build_scenario_library", sys.modules[__name__]) + + +def _per_step_voltage_pen(grid_states, *, v_min: float, v_max: float, exclude_buses: tuple[str, ...]) -> np.ndarray: + """Compute the per-step voltage penalty (sum of squared violation magnitude). + + This matches ``compute_reward`` in ``openg2g/rl/env.py``: at each step the + penalty is ``sum_max(v_min - v, 0)^2 + sum_max(v - v_max, 0)^2`` over all + bus-phases (excluding the substation buses), with NaNs treated as + "not in violation". + """ + drop = {b.lower() for b in exclude_buses} + out = np.zeros(len(grid_states), dtype=np.float64) + for i, gs in enumerate(grid_states): + s = 0.0 + for bus in gs.voltages.buses(): + if bus.lower() in drop: + continue + pv = gs.voltages[bus] + for v in (pv.a, pv.b, pv.c): + if math.isnan(v): + continue + if v < v_min: + s += (v_min - v) ** 2 + elif v > v_max: + s += (v - v_max) ** 2 + out[i] = s + return out + + +def _under_over_voltage_time(grid_states, *, v_min: float, v_max: float, exclude_buses: tuple[str, ...]) -> tuple[float, float]: + """Return (undervoltage_time_s, overvoltage_time_s). + + A step counts as 'undervoltage' if ANY non-excluded bus-phase is below + v_min, and as 'overvoltage' if ANY is above v_max. A step can count for + both (they are not mutually exclusive). + """ + drop = {b.lower() for b in exclude_buses} + under_steps = 0 + over_steps = 0 + for gs in grid_states: + has_under = False + has_over = False + for bus in gs.voltages.buses(): + if bus.lower() in drop: + continue + pv = gs.voltages[bus] + for v in (pv.a, pv.b, pv.c): + if math.isnan(v): + continue + if v < v_min: + has_under = True + if v > v_max: + has_over = True + if has_under and has_over: + break + if has_under: + under_steps += 1 + if has_over: + over_steps += 1 + return float(under_steps), float(over_steps) + + +def _voltage_envelope(grid_states, *, exclude_buses: tuple[str, ...]) -> tuple[np.ndarray, np.ndarray]: + """Return (vmin_t, vmax_t) per step for plotting.""" + drop = {b.lower() for b in exclude_buses} + vmin = np.full(len(grid_states), np.inf) + vmax = np.full(len(grid_states), -np.inf) + for i, gs in enumerate(grid_states): + for bus in gs.voltages.buses(): + if bus.lower() in drop: + continue + pv = gs.voltages[bus] + for v in (pv.a, pv.b, pv.c): + if math.isnan(v): + continue + if v < vmin[i]: + vmin[i] = v + if v > vmax[i]: + vmax[i] = v + return vmin, vmax + + +def _voltage_envelope_by_zone( + grid_states, + *, + zones: dict[str, list[str]], + exclude_buses: tuple[str, ...], +) -> dict[str, tuple[np.ndarray, np.ndarray]]: + """Return {zone_name: (vmin_t, vmax_t)} per step, one array pair per zone.""" + drop = {b.lower() for b in exclude_buses} + zone_sets = {z: {b.lower() for b in buses} for z, buses in zones.items()} + n = len(grid_states) + vmin = {z: np.full(n, np.inf) for z in zones} + vmax = {z: np.full(n, -np.inf) for z in zones} + for i, gs in enumerate(grid_states): + for bus in gs.voltages.buses(): + bl = bus.lower() + if bl in drop: + continue + pv = gs.voltages[bus] + for z, bset in zone_sets.items(): + if bl not in bset: + continue + for v in (pv.a, pv.b, pv.c): + if math.isnan(v): + continue + if v < vmin[z][i]: + vmin[z][i] = v + if v > vmax[z][i]: + vmax[z][i] = v + return {z: (vmin[z], vmax[z]) for z in zones} + + +def _zone_phase_integral( + grid_states, + *, + zones: dict[str, list[str]], + exclude_buses: tuple[str, ...], + v_min: float, + v_max: float, +) -> dict[str, dict[str, float]]: + """Return {zone: {phase: integral_pu_s}} for violation breakdown. + + Integral uses the same linear formula as compute_allbus_voltage_stats: + sum over t of (max(v_min-v,0) + max(v-v_max,0)) * dt. + """ + drop = {b.lower() for b in exclude_buses} + zone_sets = {z: {b.lower() for b in buses} for z, buses in zones.items()} + acc: dict[str, dict[str, float]] = {z: {"a": 0.0, "b": 0.0, "c": 0.0} for z in zones} + times = [gs.time_s for gs in grid_states] + dt = (times[1] - times[0]) if len(times) > 1 else 1.0 + for gs in grid_states: + for bus in gs.voltages.buses(): + bl = bus.lower() + if bl in drop: + continue + pv = gs.voltages[bus] + for z, bset in zone_sets.items(): + if bl not in bset: + continue + for ph in ("a", "b", "c"): + v = getattr(pv, ph, float("nan")) + if math.isnan(v): + continue + viol = max(v_min - v, 0.0) + max(v - v_max, 0.0) + acc[z][ph] += viol * dt + return acc + + +def _build_run_kwargs( + *, + base_exp: dict, + scenario: dict, + ofo_config: OFOConfig, + inference_data: InferenceData, + training_trace: TrainingTrace, + logistic_models: LogisticModelStore, + save_dir: Path, +) -> dict: + """Pack arguments for simulation.run_simulation.""" + return dict( + sys=base_exp["sys"], + dc_sites=scenario["dc_sites"], + ofo_config=ofo_config, + inference_data=inference_data, + training_trace=training_trace, + logistic_models=logistic_models, + pv_systems=scenario["pv_systems"], + time_varying_loads=scenario["tvl"], + tap_schedule=None, # baseline AND ofo run in 'no-tap' mode (matches run_baseline.py --mode no-tap) + rule_based_config=None, + ppo_model="", + training_overlay=scenario["params"]["training_overlay"], + save_dir=save_dir, + ) + + +def _plot_batch_sizes( + records: list[ScenarioRecord], + batch_data: dict[int, dict], + save_path: Path, + *, + max_rows: int = 40, +) -> None: + """Plot batch size over time per accepted scenario, baseline vs OFO, one row per scenario. + + When the library has more than ``max_rows`` scenarios, only the first + ``max_rows`` are shown. A single tall figure of hundreds of rows quickly + exceeds matplotlib's 65535-pixel dimension limit, so we cap here. + """ + n = len(records) + if n == 0: + return + if n > max_rows: + logger.info("_plot_batch_sizes: capping at first %d of %d records", max_rows, n) + records = records[:max_rows] + n = max_rows + + # Collect all (site_id, label) columns from the first scenario. For + # single-DC feeders (ieee13) there's one site; multi-DC feeders + # (ieee34) get one column per (site, model) pair. + first_seed = records[0].seed + ofo_by_site = batch_data[first_seed]["ofo"] + cols_meta: list[tuple[str, str]] = [] + for site_id, sdata in ofo_by_site.items(): + for label in sdata["batch_by_model"].keys(): + cols_meta.append((site_id, label)) + n_cols = len(cols_meta) + + fig, axes = plt.subplots(n, n_cols, figsize=(4 * n_cols, 2.5 * n), sharex=True, squeeze=False) + + for row, rec in enumerate(records): + bd = batch_data[rec.seed] + for col, (site_id, label) in enumerate(cols_meta): + ax = axes[row][col] + bl_site = bd["baseline"][site_id] + ofo_site = bd["ofo"][site_id] + ax.plot(bl_site["time_s"], bl_site["batch_by_model"][label], + color="#888", linewidth=0.7, alpha=0.7, label="baseline") + ax.plot(ofo_site["time_s"], ofo_site["batch_by_model"][label], + color="#2196F3", linewidth=0.7, alpha=0.9, label="OFO") + if row == 0: + short = label.split("/")[-1] if "/" in label else label + title = f"{site_id}:{short}" if len(ofo_by_site) > 1 else short + ax.set_title(title, fontsize=9) + if col == 0: + ax.set_ylabel(f"seed={rec.seed}\nBatch", fontsize=8) + ax.grid(True, alpha=0.2) + if row == 0 and col == 0: + ax.legend(fontsize=7, loc="upper right") + + for col in range(n_cols): + axes[-1][col].set_xlabel("Time (s)") + fig.suptitle("Accepted scenarios — batch size (baseline vs OFO)", fontsize=13, fontweight="bold") + fig.tight_layout() + fig.savefig(save_path, dpi=120, bbox_inches="tight") + plt.close(fig) + + +def _extract_batch_data(log) -> dict: + """Extract time_s and per-model batch sizes from a simulation log. + + Returns {site_id: {"time_s": [...], "batch_by_model": {label: [bs]}}}. + Uses ``log.dc_states_by_site`` (per-site lists) so multi-DC feeders + like ieee34 don't get interleaved timestamps or alternating zeros. + """ + per_site: dict[str, dict] = {} + for site_id, states in log.dc_states_by_site.items(): + time_s = [s.time_s for s in states] + labels: list[str] = [] + if states: + for m in states[0].batch_size_by_model: + if m not in labels: + labels.append(m) + batch_by_model = { + m: [s.batch_size_by_model.get(m, 0) for s in states] for m in labels + } + per_site[site_id] = {"time_s": time_s, "batch_by_model": batch_by_model} + return per_site + + +def _is_always_minimum_batch(ofo_log, min_batch_size: int = 8, warmup_s: float = 10.0) -> bool: + """Return True if every model stays at min_batch_size for the entire episode after warmup_s. + + Skips the first warmup_s seconds to allow for initial transient drops before OFO + has had a chance to act (batch may be at minimum at t=0 before the first control step). + """ + for states in ofo_log.dc_states_by_site.values(): + for s in states: + if s.time_s <= warmup_s: + continue + for bs in s.batch_size_by_model.values(): + if bs > min_batch_size: + return False + return True + + +def _plot_envelopes( + records: list[ScenarioRecord], + envelopes: dict, + save_path: Path, + *, + total_duration_s: int, + zones: dict[str, list[str]] | None = None, + max_rows: int = 40, +) -> None: + """Plot voltage envelope per accepted scenario, baseline vs OFO. + + When ``zones`` is provided (multi-zone feeders like ieee123), each scenario + gets one subplot per zone showing the per-zone vmin/vmax band. Otherwise a + single subplot with the global envelope is used. + + Caps at ``max_rows * 2`` records (global mode) or ``max_rows`` records + (per-zone mode) to stay under matplotlib's 65535-pixel dimension limit. + """ + n = len(records) + if n == 0: + return + + t = np.arange(total_duration_s) + + if zones: + zone_names = list(zones.keys()) + n_zones = len(zone_names) + cap = max_rows + if n > cap: + logger.info("_plot_envelopes: capping at first %d of %d records", cap, n) + records = records[:cap] + n = cap + zone_colors = ["#2196F3", "#4CAF50", "#FF9800", "#9C27B0"] + fig, axes = plt.subplots(n, n_zones, figsize=(5 * n_zones, 3 * n), sharex=True, squeeze=False) + for row, rec in enumerate(records): + env = envelopes[rec.seed] + for col, z in enumerate(zone_names): + ax = axes[row][col] + bl_z = env["baseline_zones"].get(z) + of_z = env["ofo_zones"].get(z) + color = zone_colors[col % len(zone_colors)] + if bl_z is not None: + ax.fill_between(t, bl_z[0], bl_z[1], alpha=0.25, color="#888", label="baseline") + if of_z is not None: + ax.fill_between(t, of_z[0], of_z[1], alpha=0.4, color=color, label="OFO") + ax.axhline(V_MIN, color="red", linestyle="--", linewidth=0.8, alpha=0.6) + ax.axhline(V_MAX, color="red", linestyle="--", linewidth=0.8, alpha=0.6) + ax.grid(True, alpha=0.2) + if row == 0: + ax.set_title(z, fontsize=10, fontweight="bold") + if col == 0: + ax.set_ylabel( + f"seed={rec.seed}\npv×{rec.pv_scale:.2f} ld×{rec.load_scale:.2f}\n" + f"bl={rec.baseline_integral:.1f} ofo={rec.ofo_integral:.1f} " + f"rec={rec.recovery_frac:.0%}", + fontsize=7, + ) + else: + ax.set_ylabel("V (pu)", fontsize=8) + if row == 0 and col == 0: + ax.legend(loc="lower right", fontsize=7) + for col in range(n_zones): + axes[-1][col].set_xlabel("Time (s)", fontsize=8) + fig.suptitle( + "Accepted scenarios — per-zone voltage envelope (baseline vs OFO)", + fontsize=13, fontweight="bold", + ) + else: + cap = max_rows * 2 + if n > cap: + logger.info("_plot_envelopes: capping at first %d of %d records", cap, n) + records = records[:cap] + n = cap + cols = 2 + rows = (n + cols - 1) // cols + fig, axes = plt.subplots(rows, cols, figsize=(7 * cols, 3 * rows), sharex=True) + axes = np.atleast_2d(axes) + + for idx, rec in enumerate(records): + r, c = divmod(idx, cols) + ax = axes[r][c] + bmin, bmax = envelopes[rec.seed]["baseline"] + omin, omax = envelopes[rec.seed]["ofo"] + ax.fill_between(t, bmin, bmax, alpha=0.25, color="#888", label="baseline") + ax.fill_between(t, omin, omax, alpha=0.4, color="#2196F3", label="OFO") + ax.axhline(V_MIN, color="red", linestyle="--", linewidth=1, alpha=0.6) + ax.axhline(V_MAX, color="red", linestyle="--", linewidth=1, alpha=0.6) + ax.set_title( + f"seed={rec.seed} pv×{rec.pv_scale:.2f} load×{rec.load_scale:.2f}\n" + f"int: bl={rec.baseline_integral:.2f} ofo={rec.ofo_integral:.2f} " + f"recov={rec.recovery_frac:.0%}", + fontsize=9, + ) + ax.set_ylabel("V (pu)", fontsize=9) + ax.grid(True, alpha=0.2) + if idx == 0: + ax.legend(loc="lower right", fontsize=8) + + for k in range(n, rows * cols): + r, c = divmod(k, cols) + axes[r][c].axis("off") + + for c in range(cols): + axes[-1][c].set_xlabel("Time (s)") + fig.suptitle("Accepted scenarios — voltage envelope (baseline vs OFO)", fontsize=13, fontweight="bold") + + fig.tight_layout() + fig.savefig(save_path, dpi=120, bbox_inches="tight") + plt.close(fig) + + +def _plot_summary(all_stats: list[dict], save_path: Path) -> None: + """Bar chart of baseline vs OFO integral for every candidate (accepted + rejected).""" + n = len(all_stats) + if n == 0: + return + seeds = [s["seed"] for s in all_stats] + bl = [s["baseline_integral"] for s in all_stats] + of = [s["ofo_integral"] for s in all_stats] + accepted = [s["accepted"] for s in all_stats] + + x = np.arange(n) + w = 0.4 + fig, ax = plt.subplots(figsize=(max(8, 0.7 * n), 5)) + ax.bar(x - w / 2, bl, w, color="#888", label="baseline integral") + ax.bar(x + w / 2, of, w, color="#2196F3", label="OFO integral") + for i, ok in enumerate(accepted): + marker = "✓" if ok else "✗" + color = "green" if ok else "red" + ax.annotate(marker, xy=(i, max(bl[i], of[i])), ha="center", va="bottom", color=color, fontsize=12) + ax.set_xticks(x) + ax.set_xticklabels([str(s) for s in seeds], rotation=45) + ax.set_xlabel("Seed") + ax.set_ylabel("Integral voltage violation (pu·s)") + ax.set_yscale("symlog", linthresh=0.1) + ax.set_title("Candidate scenarios — baseline vs OFO integral violation") + ax.legend() + ax.grid(True, axis="y", alpha=0.2) + fig.tight_layout() + fig.savefig(save_path, dpi=120, bbox_inches="tight") + plt.close(fig) + + +def main( + *, + system: str, + n_candidates: int, + seeds: tuple[int, ...] = (), + min_recovery_frac: float, + max_recovery_frac: float = 1.0, + min_baseline_integral: float, + min_baseline_integral_over: float, + max_baseline_integral: float, + pv_base_kw: float | None, + tvl_base_kw: float | None, + sensitivity_update_interval: int, + ofo_w_throughput: float, + tag: str, + seed_start: int = 0, + use_training_overlay: bool = True, + randomize_ramps: bool = True, + randomization_profile: bool = True, + pv_scale_min: float = 0.5, + pv_scale_max: float = 2.0, + load_scale_min: float = 0.5, + load_scale_max: float = 2.0, + pv_t_shift_max_s: float = 0.0, + tvl_t_shift_max_s: float = 0.0, + pv_warp_min: float = 1.0, + pv_warp_max: float = 1.0, + tvl_warp_min: float = 1.0, + tvl_warp_max: float = 1.0, + overlay_prob: float = 1.0, + overlay_intensity_min: float = 1.0, + overlay_intensity_max: float = 1.0, + overlay_gpu_frac_min: float = 0.85, + overlay_gpu_frac_max: float = 1.0, + n_ramps_per_site_choices: tuple[int, ...] = (1,), + ramp_up_prob: float = 0.0, + ramp_down_frac_min: float = 0.5, + ramp_down_frac_max: float = 0.85, + ramp_up_frac_min: float = 1.05, + ramp_up_frac_max: float = 1.5, + ramp_start_min: float = 500.0, + ramp_start_max: float = 3000.0, + ramp_dur_min: float = 300.0, + ramp_dur_max: float = 800.0, + randomize_pv_profile: bool = False, + pv_shape_choices: tuple[str, ...] = ( + "flat", "rising_falling", "morning_ramp", "afternoon_decline", "midday_dip", + ), + pv_baseline_min: float = 0.75, + pv_baseline_max: float = 0.95, + pv_cloud_count_max: int = 3, + pv_cloud_depth_min: float = 0.30, + pv_cloud_depth_max: float = 0.70, + pv_cloud_width_min: float = 60.0, + pv_cloud_width_max: float = 300.0, + randomize_tvl_profile: bool = False, + tvl_shape_choices: tuple[str, ...] = ("flat", "increasing", "decreasing", "peaked", "valley"), + max_always_min: int = 5, + t_control_start_buffer: int = 200, + t_control_end_buffer: int = 300, + log_level: str, + append_to: Path | None = None, +) -> None: + logging.basicConfig( + level=getattr(logging, log_level), + format="%(levelname)s %(asctime)s [%(name)s:%(lineno)d] %(message)s", + datefmt="%H:%M:%S", + ) + logging.getLogger("openg2g.coordinator").setLevel(logging.WARNING) + logging.getLogger("openg2g.datacenter").setLevel(logging.WARNING) + logging.getLogger("openg2g.grid").setLevel(logging.WARNING) + logging.getLogger("openg2g.controller.ofo").setLevel(logging.WARNING) + logging.getLogger("controller_comparison").setLevel(logging.WARNING) + + if system not in EXPERIMENTS: + raise ValueError(f"Unknown system '{system}'. Available: {list(EXPERIMENTS)}") + out_dir = Path(__file__).resolve().parent / "outputs" / system / "scenario_library" / tag + out_dir.mkdir(parents=True, exist_ok=True) + logger.info("Writing scenario library to %s", out_dir) + logger.info( + "system=%s use_training_overlay=%s randomize_ramps=%s", + system, use_training_overlay, randomize_ramps, + ) + + # ── Load data once (per-spec content-addressed cache under SPECS_CACHE_DIR) ── + logger.info("Loading data...") + base_exp = EXPERIMENTS[system]() + sys_cfg = base_exp["sys"] + exclude_buses = tuple(sys_cfg["exclude_buses"]) + zones: dict[str, list[str]] | None = sys_cfg.get("zones") or None + + # Override OFO sensitivity_update_interval and w_throughput so the + # screening OFO matches the eval OFO exactly. 300 = re-estimate every + # 5 simulated minutes, keeping the H-matrix fresh through the violation + # window. w_throughput override ensures the screening OFO is laser-focused + # on voltage (matching the eval pipeline value of 0.0001). + base_ofo = base_exp["ofo_config"] + ofo_config = base_ofo.model_copy( + update={ + "sensitivity_update_interval": sensitivity_update_interval, + "w_throughput": ofo_w_throughput, + } + ) + logger.info( + "OFO config: sensitivity_update_interval=%d, w_throughput=%g (was %g)", + sensitivity_update_interval, + ofo_w_throughput, + base_ofo.w_throughput, + ) + + # Override the base PV/TVL with caller-provided values, then let + # randomize_scenario apply the [0.5, 2.0] scale factor on top. + # When pv_base_kw/tvl_base_kw is None, keep the per-system defaults + # from the experiment definition (this is the default for ieee34 + # since each bus has a distinct peak kW that we want to preserve). + pv_systems_base = [ + PVSystemSpec(bus=p.bus, bus_kv=p.bus_kv, peak_kw=(pv_base_kw if pv_base_kw is not None else p.peak_kw)) + for p in base_exp["pv_systems"] + ] + tvl_base = [ + TimeVaryingLoadSpec(bus=t.bus, bus_kv=t.bus_kv, peak_kw=(tvl_base_kw if tvl_base_kw is not None else t.peak_kw)) + for t in base_exp["time_varying_loads"] + ] + logger.info( + "PV base total=%.0f kW across %d systems, TVL base total=%.0f kW across %d loads (× scale ∈ [0.5, 2.0] per scenario)", + sum(p.peak_kw for p in pv_systems_base), len(pv_systems_base), + sum(t.peak_kw for t in tvl_base), len(tvl_base), + ) + + # All model specs across the single DC site + all_specs = [] + for site in base_exp["dc_sites"].values(): + for md, _ in site.models: + all_specs.append(md.spec) + all_specs_tuple = tuple(all_specs) + + inference_data = InferenceData.ensure( + SPECS_CACHE_DIR, + all_specs_tuple, + plot=False, + dt_s=float(DT_DC), + ) + training_trace = TrainingTrace.ensure(TRAINING_TRACE_PATH) + logistic_models = LogisticModelStore.ensure( + SPECS_CACHE_DIR, + all_specs_tuple, + plot=False, + ) + + # Training overlay base — same defaults as train_ppo._ieee13_experiment. + # Only used when use_training_overlay is True (ieee13 default). For ieee34 + # the in-distribution scenarios deliberately skip the training overlay so + # PV/TVL scales are the sole source of randomness (overlay is reserved for + # OOD scenarios). + if use_training_overlay: + training_base = { + "trace": training_trace, + "n_gpus": 2400, + "target_peak_W_per_gpu": 400.0, + "t_start": 1000.0, + "t_end": 2000.0, + } + else: + training_base = None + + accepted: list[ScenarioRecord] = [] + envelopes: dict[int, dict] = {} + batch_data: dict[int, dict] = {} + all_stats: list[dict] = [] + n_always_min_accepted: int = 0 + MAX_ALWAYS_MIN = max_always_min + + seed_list = list(seeds) if seeds else [(seed_start + i) * 1000 + 7 for i in range(n_candidates)] + n_total = len(seed_list) + for cand_idx, effective_seed in enumerate(seed_list): + logger.info("=" * 60) + logger.info("Candidate %d/%d (seed=%d)", cand_idx + 1, n_total, effective_seed) + + scenario = randomize_scenario( + seed=effective_seed, + dc_sites_base=base_exp["dc_sites"], + pv_systems_base=pv_systems_base, + tvl_base=tvl_base, + training_base=training_base, + randomize_ramps=randomize_ramps, + randomization_profile=randomization_profile, + pv_scale_min=pv_scale_min, + pv_scale_max=pv_scale_max, + load_scale_min=load_scale_min, + load_scale_max=load_scale_max, + pv_t_shift_max_s=pv_t_shift_max_s, + tvl_t_shift_max_s=tvl_t_shift_max_s, + pv_warp_min=pv_warp_min, + pv_warp_max=pv_warp_max, + tvl_warp_min=tvl_warp_min, + tvl_warp_max=tvl_warp_max, + overlay_prob=overlay_prob, + overlay_intensity_min=overlay_intensity_min, + overlay_intensity_max=overlay_intensity_max, + overlay_gpu_frac_min=overlay_gpu_frac_min, + overlay_gpu_frac_max=overlay_gpu_frac_max, + n_ramps_per_site_choices=tuple(n_ramps_per_site_choices), + ramp_up_prob=ramp_up_prob, + ramp_down_frac_min=ramp_down_frac_min, + ramp_down_frac_max=ramp_down_frac_max, + ramp_up_frac_min=ramp_up_frac_min, + ramp_up_frac_max=ramp_up_frac_max, + ramp_start_min=ramp_start_min, + ramp_start_max=ramp_start_max, + ramp_dur_min=ramp_dur_min, + ramp_dur_max=ramp_dur_max, + randomize_pv_profile=randomize_pv_profile, + pv_shape_choices=tuple(pv_shape_choices), + pv_baseline_min=pv_baseline_min, + pv_baseline_max=pv_baseline_max, + pv_cloud_count_max=pv_cloud_count_max, + pv_cloud_depth_min=pv_cloud_depth_min, + pv_cloud_depth_max=pv_cloud_depth_max, + pv_cloud_width_min=pv_cloud_width_min, + pv_cloud_width_max=pv_cloud_width_max, + randomize_tvl_profile=randomize_tvl_profile, + tvl_shape_choices=tuple(tvl_shape_choices), + ) + + # Build run kwargs once + run_kwargs = _build_run_kwargs( + base_exp=base_exp, + scenario=scenario, + ofo_config=ofo_config, + inference_data=inference_data, + training_trace=training_trace, + logistic_models=logistic_models, + save_dir=out_dir, + ) + + # ── baseline ── + bl_stats, bl_log = run_simulation("baseline", **run_kwargs) + baseline_pen = _per_step_voltage_pen(bl_log.grid_states, v_min=V_MIN, v_max=V_MAX, exclude_buses=exclude_buses) + baseline_int = float(bl_stats.integral_violation_pu_s) + baseline_viol_t = float(bl_stats.violation_time_s) + bl_under_t, bl_over_t = _under_over_voltage_time( + bl_log.grid_states, v_min=V_MIN, v_max=V_MAX, exclude_buses=exclude_buses + ) + + # ── OFO ── + ofo_stats, ofo_log = run_simulation("ofo", **run_kwargs) + ofo_pen = _per_step_voltage_pen(ofo_log.grid_states, v_min=V_MIN, v_max=V_MAX, exclude_buses=exclude_buses) + ofo_int = float(ofo_stats.integral_violation_pu_s) + ofo_viol_t = float(ofo_stats.violation_time_s) + ofo_under_t, ofo_over_t = _under_over_voltage_time( + ofo_log.grid_states, v_min=V_MIN, v_max=V_MAX, exclude_buses=exclude_buses + ) + + recovery = (baseline_int - ofo_int) / baseline_int if baseline_int > 0 else 0.0 + is_pure_overvoltage = bl_over_t > 0 and bl_under_t == 0 + is_mixed = bl_over_t > 0 and bl_under_t > 0 + effective_min_integral = ( + min_baseline_integral_over if (is_pure_overvoltage or is_mixed) else min_baseline_integral + ) + passes = ( + baseline_int >= effective_min_integral + and baseline_int <= max_baseline_integral + and recovery >= min_recovery_frac + and recovery <= max_recovery_frac + ) + + logger.info( + " baseline: int=%.3f viol=%.0fs (under=%.0fs over=%.0fs) | " + "ofo: int=%.3f viol=%.0fs (under=%.0fs over=%.0fs) | recovery=%.0f%% | %s", + baseline_int, + baseline_viol_t, + bl_under_t, + bl_over_t, + ofo_int, + ofo_viol_t, + ofo_under_t, + ofo_over_t, + 100 * recovery, + "ACCEPT" if passes else "reject", + ) + + all_stats.append( + dict( + seed=effective_seed, + pv_scale=scenario["params"]["pv_scale"], + load_scale=scenario["params"]["load_scale"], + training_overlay=scenario["params"]["training_overlay"], + baseline_integral=baseline_int, + ofo_integral=ofo_int, + baseline_violation_time_s=baseline_viol_t, + ofo_violation_time_s=ofo_viol_t, + bl_undervoltage_time_s=bl_under_t, + bl_overvoltage_time_s=bl_over_t, + ofo_undervoltage_time_s=ofo_under_t, + ofo_overvoltage_time_s=ofo_over_t, + recovery_frac=recovery, + accepted=passes, + ) + ) + + # Log per-zone, per-phase baseline integral breakdown for every candidate + # (especially useful for diagnosing rejected scenarios in multi-zone feeders). + if zones: + bl_zone_phase = _zone_phase_integral( + bl_log.grid_states, zones=zones, exclude_buses=exclude_buses, v_min=V_MIN, v_max=V_MAX, + ) + zone_summary = " baseline zone/phase integral: " + " | ".join( + f"{z}: A={bl_zone_phase[z]['a']:.1f} B={bl_zone_phase[z]['b']:.1f} C={bl_zone_phase[z]['c']:.1f}" + for z in zones + ) + logger.info(zone_summary) + + if passes: + always_min = _is_always_minimum_batch(ofo_log) + if always_min: + if n_always_min_accepted >= MAX_ALWAYS_MIN: + logger.info( + " seed=%d: OFO always at min batch — cap reached (%d/%d), skipping", + effective_seed, n_always_min_accepted, MAX_ALWAYS_MIN, + ) + passes = False + else: + n_always_min_accepted += 1 + logger.info( + " seed=%d: OFO always at min batch — accepting (%d/%d)", + effective_seed, n_always_min_accepted, MAX_ALWAYS_MIN, + ) + if passes: + nonzero_steps = np.nonzero(baseline_pen > 0)[0] + if len(nonzero_steps) > 0: + t_first = int(nonzero_steps[0]) + t_last = int(nonzero_steps[-1]) + else: + t_first = 0 + t_last = len(baseline_pen) + t_ctrl_start = max(0, t_first - t_control_start_buffer) + t_ctrl_end = min(len(baseline_pen), t_last + t_control_end_buffer) + logger.info( + " control window: violation [%d, %d] → [%d, %d] (%d steps, saves %d%%)", + t_first, t_last, t_ctrl_start, t_ctrl_end, + t_ctrl_end - t_ctrl_start, + 100 * (len(baseline_pen) - (t_ctrl_end - t_ctrl_start)) // len(baseline_pen), + ) + rec = ScenarioRecord( + seed=effective_seed, + pv_scale=scenario["params"]["pv_scale"], + load_scale=scenario["params"]["load_scale"], + training_overlay=scenario["params"]["training_overlay"], + baseline_integral=baseline_int, + ofo_integral=ofo_int, + baseline_violation_time_s=baseline_viol_t, + ofo_violation_time_s=ofo_viol_t, + recovery_frac=recovery, + bl_undervoltage_time_s=bl_under_t, + bl_overvoltage_time_s=bl_over_t, + ofo_voltage_pen_per_step=ofo_pen, + baseline_voltage_pen_per_step=baseline_pen, + t_control_start=t_ctrl_start, + t_control_end=t_ctrl_end, + resolved_dc_sites=scenario["dc_sites"], + resolved_pv_systems=tuple(scenario["pv_systems"]), + resolved_tvl=tuple(scenario["tvl"]), + ) + accepted.append(rec) + env_entry: dict = { + "baseline": _voltage_envelope(bl_log.grid_states, exclude_buses=exclude_buses), + "ofo": _voltage_envelope(ofo_log.grid_states, exclude_buses=exclude_buses), + } + if zones: + env_entry["baseline_zones"] = _voltage_envelope_by_zone( + bl_log.grid_states, zones=zones, exclude_buses=exclude_buses, + ) + env_entry["ofo_zones"] = _voltage_envelope_by_zone( + ofo_log.grid_states, zones=zones, exclude_buses=exclude_buses, + ) + envelopes[effective_seed] = env_entry + batch_data[effective_seed] = { + "baseline": _extract_batch_data(bl_log), + "ofo": _extract_batch_data(ofo_log), + } + + # ── Save artifacts ── + library_path = out_dir / "library.pkl" + randomize_kwargs = { + "randomize_ramps": randomize_ramps, + "randomization_profile": randomization_profile, + "pv_scale_min": pv_scale_min, + "pv_scale_max": pv_scale_max, + "load_scale_min": load_scale_min, + "load_scale_max": load_scale_max, + "pv_t_shift_max_s": pv_t_shift_max_s, + "tvl_t_shift_max_s": tvl_t_shift_max_s, + "pv_warp_min": pv_warp_min, + "pv_warp_max": pv_warp_max, + "tvl_warp_min": tvl_warp_min, + "tvl_warp_max": tvl_warp_max, + "overlay_prob": overlay_prob, + "overlay_intensity_min": overlay_intensity_min, + "overlay_intensity_max": overlay_intensity_max, + "overlay_gpu_frac_min": overlay_gpu_frac_min, + "overlay_gpu_frac_max": overlay_gpu_frac_max, + "n_ramps_per_site_choices": tuple(n_ramps_per_site_choices), + "ramp_up_prob": ramp_up_prob, + "ramp_down_frac_min": ramp_down_frac_min, + "ramp_down_frac_max": ramp_down_frac_max, + "ramp_up_frac_min": ramp_up_frac_min, + "ramp_up_frac_max": ramp_up_frac_max, + "ramp_start_min": ramp_start_min, + "ramp_start_max": ramp_start_max, + "ramp_dur_min": ramp_dur_min, + "ramp_dur_max": ramp_dur_max, + "randomize_pv_profile": randomize_pv_profile, + "pv_shape_choices": tuple(pv_shape_choices), + "pv_baseline_min": pv_baseline_min, + "pv_baseline_max": pv_baseline_max, + "pv_cloud_count_max": pv_cloud_count_max, + "pv_cloud_depth_min": pv_cloud_depth_min, + "pv_cloud_depth_max": pv_cloud_depth_max, + "pv_cloud_width_min": pv_cloud_width_min, + "pv_cloud_width_max": pv_cloud_width_max, + "randomize_tvl_profile": randomize_tvl_profile, + "tvl_shape_choices": tuple(tvl_shape_choices), + } + + with open(library_path, "wb") as f: + pickle.dump( + { + "scenarios": accepted, + "config": { + "system": system, + "n_candidates": n_total, + "min_recovery_frac": min_recovery_frac, + "max_recovery_frac": max_recovery_frac, + "min_baseline_integral": min_baseline_integral, + "pv_base_kw": pv_base_kw, + "tvl_base_kw": tvl_base_kw, + "use_training_overlay": use_training_overlay, + "randomize_ramps": randomize_ramps, + "randomize_kwargs": randomize_kwargs, + "pv_systems_base": [ + {"bus": p.bus, "bus_kv": p.bus_kv, "peak_kw": p.peak_kw} + for p in pv_systems_base + ], + "tvl_base": [ + {"bus": t.bus, "bus_kv": t.bus_kv, "peak_kw": t.peak_kw} + for t in tvl_base + ], + "v_min": V_MIN, + "v_max": V_MAX, + }, + }, + f, + ) + logger.info("Wrote %d accepted scenarios to %s", len(accepted), library_path) + + # ── Optional: merge into an existing library ── + if append_to is not None: + if not append_to.exists(): + raise FileNotFoundError(f"--append-to target not found: {append_to}") + with open(append_to, "rb") as f: + existing = pickle.load(f) + existing_seeds = {s.seed for s in existing["scenarios"]} + new_scenarios = [s for s in accepted if s.seed not in existing_seeds] + if new_scenarios: + existing["scenarios"] = existing["scenarios"] + new_scenarios + with open(append_to, "wb") as f: + pickle.dump(existing, f) + logger.info( + "Appended %d new scenario(s) to %s (total now: %d)", + len(new_scenarios), append_to, len(existing["scenarios"]), + ) + else: + logger.info("No new scenarios to append — all seeds already present in %s", append_to) + + csv_path = out_dir / "candidates.csv" + with open(csv_path, "w", newline="") as f: + cols = [ + "seed", + "pv_scale", + "load_scale", + "baseline_integral", + "ofo_integral", + "baseline_violation_time_s", + "ofo_violation_time_s", + "bl_undervoltage_time_s", + "bl_overvoltage_time_s", + "ofo_undervoltage_time_s", + "ofo_overvoltage_time_s", + "recovery_frac", + "accepted", + ] + writer = csv.DictWriter(f, fieldnames=cols) + writer.writeheader() + for s in all_stats: + writer.writerow({k: s[k] for k in cols}) + logger.info("Wrote candidate stats to %s", csv_path) + + # Plots + if accepted: + _plot_envelopes( + accepted, envelopes, out_dir / "scenario_envelopes.png", + total_duration_s=len(accepted[0].ofo_voltage_pen_per_step), + zones=zones, + ) + logger.info("Wrote envelope plot to %s", out_dir / "scenario_envelopes.png") + _plot_batch_sizes(accepted, batch_data, out_dir / "scenario_batch_sizes.png") + logger.info("Wrote batch-size plot to %s", out_dir / "scenario_batch_sizes.png") + _plot_summary(all_stats, out_dir / "scenario_summary.png") + logger.info("Wrote summary plot to %s", out_dir / "scenario_summary.png") + + # Headline + n_acc = len(accepted) + accept_rate = n_acc / max(1, n_total) + logger.info("") + logger.info("=" * 60) + logger.info("Library build complete: %d/%d accepted (%.0f%%)", n_acc, n_total, 100 * accept_rate) + if n_acc > 0: + recs = np.array([r.recovery_frac for r in accepted]) + bls = np.array([r.baseline_integral for r in accepted]) + logger.info(" recovery (mean ± std): %.2f ± %.2f", recs.mean(), recs.std()) + logger.info(" baseline integral (mean ± std): %.2f ± %.2f", bls.mean(), bls.std()) + + +if __name__ == "__main__": + from dataclasses import dataclass + from typing import Annotated + + import tyro + + @dataclass + class Args: + system: str = "ieee13" + """Which feeder experiment to use. Valid: ieee13, ieee34, ieee123.""" + n_candidates: int = 20 + """Number of randomized candidate scenarios to evaluate.""" + min_recovery_frac: float = 0.7 + """Reject scenarios where OFO recovers less than this fraction of the baseline integral violation.""" + max_recovery_frac: float = 1.0 + """Reject scenarios where OFO recovers more than this fraction (use with min to select a recovery band, e.g. 0.4-0.6).""" + min_baseline_integral: float = 0.2 + """Reject scenarios where the baseline undervoltage integral is below this threshold (no learning signal).""" + min_baseline_integral_over: float = 0.01 + """Reject pure-overvoltage scenarios where the baseline integral is below this threshold. Lower than min_baseline_integral since OFO recovers overvoltage less aggressively.""" + max_baseline_integral: float = 1e9 + """Reject scenarios where the baseline integral violation exceeds this threshold (saturated, dominate gradients). Default = no upper bound.""" + pv_base_kw: float | None = None + """Base PV peak power per system (kW) before per-scenario scaling. If unset, use the per-bus defaults from the experiment definition (recommended for ieee34 where each PV has a distinct peak_kw).""" + tvl_base_kw: float | None = None + """Base time-varying load peak power per load (kW) before per-scenario scaling. If unset, use the per-bus defaults from the experiment definition.""" + sensitivity_update_interval: int = 300 + """OFO H-matrix re-estimation interval in control steps. 300 = every 5 simulated minutes.""" + ofo_w_throughput: float = 0.0001 + """OFO throughput weight in the primal objective. 0 = pure voltage focus (the screening only cares how much violation OFO can recover).""" + tag: str = "v3" + """Subdirectory under outputs//scenario_library/ to write artifacts to.""" + seed_start: int = 0 + """Starting candidate index. Effective seed = (seed_start + cand_idx) * 1000 + 7. Use to generate non-overlapping scenario sets (e.g., seed-start=400 for eval set when training used 0-349).""" + seeds: tuple[int, ...] = () + """Explicit list of effective seeds to run (overrides seed_start + n_candidates). Use to re-run only known-accepted seeds from a prior build log.""" + use_training_overlay: bool = True + """Whether to add the training overlay (a few-hundred-kW training GPU spike) to in-distribution scenarios. Default True (ieee13 behavior). Set --no-use-training-overlay for ieee34 in-dist libraries — the overlay is reserved for OOD there.""" + randomize_ramps: bool = True + """Whether randomize_scenario synthesizes per-episode inference ramps. Default True (ieee13 behavior). Set --no-randomize-ramps for ieee34 in-dist libraries — ramps are reserved for OOD there.""" + # ── Randomization profile ── + randomization_profile: Annotated[bool, tyro.conf.FlagConversionOff] = True + """True (broad, default) = bidirectional multi-ramps, wider PV/TVL scales, shape randomness, stochastic overlay. False (narrow) = legacy behavior.""" + pv_scale_min: float = 0.5 + pv_scale_max: float = 2.0 + load_scale_min: float = 0.5 + load_scale_max: float = 2.0 + pv_t_shift_max_s: float = 0.0 + """Max abs PV profile peak-time shift in seconds (broad profile only). 0 disables shape randomness on PV.""" + tvl_t_shift_max_s: float = 0.0 + """Max abs TVL profile peak-time shift in seconds (broad profile only).""" + pv_warp_min: float = 1.0 + pv_warp_max: float = 1.0 + tvl_warp_min: float = 1.0 + tvl_warp_max: float = 1.0 + overlay_prob: float = 1.0 + """Probability the training overlay is applied to a candidate (broad profile only).""" + overlay_intensity_min: float = 1.0 + overlay_intensity_max: float = 1.0 + overlay_gpu_frac_min: float = 0.85 + overlay_gpu_frac_max: float = 1.0 + n_ramps_per_site_choices: tuple[int, ...] = (1,) + """Possible numbers of ramps per DC site (broad profile). e.g. (1, 2) gives 50/50 single/double ramps.""" + ramp_up_prob: float = 0.0 + """Probability a sampled ramp goes UP (broad profile). GPU capacity is checked per-site so up-ramps are clamped automatically.""" + ramp_down_frac_min: float = 0.5 + ramp_down_frac_max: float = 0.85 + ramp_up_frac_min: float = 1.05 + ramp_up_frac_max: float = 1.5 + ramp_start_min: float = 500.0 + """Earliest time (s) a ramp can begin (broad mode). Lower this to allow ramps near episode start.""" + ramp_start_max: float = 3000.0 + """Latest time (s) a ramp can begin.""" + ramp_dur_min: float = 300.0 + """Minimum ramp duration (s). Lower for fast/sharp ramps.""" + ramp_dur_max: float = 800.0 + """Maximum ramp duration (s). Higher for slow/gentle ramps.""" + randomize_pv_profile: bool = False + """Replace the analytical pv_profile_kw with a multi-shape random PV profile (broad mode only).""" + pv_shape_choices: tuple[str, ...] = ( + "flat", "rising_falling", "morning_ramp", "afternoon_decline", "midday_dip", + ) + """PV shapes to sample from (broad mode + --randomize-pv-profile). Default covers all 5 transient/flat envelopes.""" + pv_baseline_min: float = 0.75 + pv_baseline_max: float = 0.95 + pv_cloud_count_max: int = 3 + pv_cloud_depth_min: float = 0.30 + pv_cloud_depth_max: float = 0.70 + pv_cloud_width_min: float = 60.0 + pv_cloud_width_max: float = 300.0 + randomize_tvl_profile: bool = False + """Replace the analytical load_profile_kw with one of {flat, increasing, decreasing, peaked, valley} per scenario (broad mode only).""" + tvl_shape_choices: tuple[str, ...] = ("flat", "increasing", "decreasing", "peaked", "valley") + max_always_min: int = 5 + """Max number of 'OFO always at min batch' scenarios to accept. Increase to 10 for training libraries to avoid under-representing easy scenarios.""" + t_control_start_buffer: int = 200 + """Steps of buffer before the first baseline voltage violation (for episode truncation).""" + t_control_end_buffer: int = 300 + """Steps of buffer after the last baseline voltage violation (for episode truncation).""" + log_level: str = "INFO" + append_to: Path | None = None + """If set, merge newly accepted scenarios into this existing library.pkl (deduplicates by seed). The new run is still saved under --tag for diagnostic plots.""" + + args = tyro.cli(Args) + main( + system=args.system, + n_candidates=args.n_candidates, + min_recovery_frac=args.min_recovery_frac, + max_recovery_frac=args.max_recovery_frac, + min_baseline_integral=args.min_baseline_integral, + min_baseline_integral_over=args.min_baseline_integral_over, + max_baseline_integral=args.max_baseline_integral, + pv_base_kw=args.pv_base_kw, + tvl_base_kw=args.tvl_base_kw, + sensitivity_update_interval=args.sensitivity_update_interval, + ofo_w_throughput=args.ofo_w_throughput, + tag=args.tag, + seed_start=args.seed_start, + seeds=args.seeds, + use_training_overlay=args.use_training_overlay, + randomize_ramps=args.randomize_ramps, + randomization_profile=args.randomization_profile, + pv_scale_min=args.pv_scale_min, + pv_scale_max=args.pv_scale_max, + load_scale_min=args.load_scale_min, + load_scale_max=args.load_scale_max, + pv_t_shift_max_s=args.pv_t_shift_max_s, + tvl_t_shift_max_s=args.tvl_t_shift_max_s, + pv_warp_min=args.pv_warp_min, + pv_warp_max=args.pv_warp_max, + tvl_warp_min=args.tvl_warp_min, + tvl_warp_max=args.tvl_warp_max, + overlay_prob=args.overlay_prob, + overlay_intensity_min=args.overlay_intensity_min, + overlay_intensity_max=args.overlay_intensity_max, + overlay_gpu_frac_min=args.overlay_gpu_frac_min, + overlay_gpu_frac_max=args.overlay_gpu_frac_max, + n_ramps_per_site_choices=args.n_ramps_per_site_choices, + ramp_up_prob=args.ramp_up_prob, + ramp_down_frac_min=args.ramp_down_frac_min, + ramp_down_frac_max=args.ramp_down_frac_max, + ramp_up_frac_min=args.ramp_up_frac_min, + ramp_up_frac_max=args.ramp_up_frac_max, + ramp_start_min=args.ramp_start_min, + ramp_start_max=args.ramp_start_max, + ramp_dur_min=args.ramp_dur_min, + ramp_dur_max=args.ramp_dur_max, + randomize_pv_profile=args.randomize_pv_profile, + pv_shape_choices=args.pv_shape_choices, + pv_baseline_min=args.pv_baseline_min, + pv_baseline_max=args.pv_baseline_max, + pv_cloud_count_max=args.pv_cloud_count_max, + pv_cloud_depth_min=args.pv_cloud_depth_min, + pv_cloud_depth_max=args.pv_cloud_depth_max, + pv_cloud_width_min=args.pv_cloud_width_min, + pv_cloud_width_max=args.pv_cloud_width_max, + randomize_tvl_profile=args.randomize_tvl_profile, + tvl_shape_choices=args.tvl_shape_choices, + max_always_min=args.max_always_min, + t_control_start_buffer=args.t_control_start_buffer, + t_control_end_buffer=args.t_control_end_buffer, + log_level=args.log_level, + append_to=args.append_to, + ) diff --git a/examples/offline/evaluate_controllers.py b/examples/offline/evaluate_controllers.py new file mode 100644 index 0000000..cbfb005 --- /dev/null +++ b/examples/offline/evaluate_controllers.py @@ -0,0 +1,1231 @@ +"""Evaluate controllers (baseline, OFO, rule-based, PPO) across scenarios. + +Combines single-run comparison helpers (plotting, display names) with the +multi-scenario evaluation pipeline (scenario generation, CSV export, aggregate +plots). + +Usage: + python examples/offline/evaluate_controllers.py \ + --ppo-models outputs/ieee13/ppo_ablation_a4_full_episode/ppo_model.zip \ + outputs/ieee13/ppo_ablation_a9_low_ent/ppo_model.zip \ + --ppo-labels a4_full_ep a9_low_ent \ + --n-scenarios 10 --seed-start 500 + + # Quick test with 3 scenarios + python examples/offline/evaluate_controllers.py \ + --ppo-models outputs/ieee13/ppo_ablation_a5_2M/ppo_model.zip \ + --n-scenarios 3 +""" + +from __future__ import annotations + +import csv +import logging +import math +from dataclasses import dataclass, field +from pathlib import Path + +import matplotlib + +matplotlib.use("Agg") +import matplotlib.pyplot as plt +import numpy as np +from build_scenario_library import run_simulation +from systems import ( + DT_DC, + EXPERIMENTS, + SPECS_CACHE_DIR, + TRAINING_TRACE_PATH, + V_MAX, + V_MIN, + DCSite, + PVSystemSpec, + TimeVaryingLoadSpec, + deploy, + materialize_scenario, + randomize_scenario, +) + +from openg2g.controller.ofo import LogisticModelStore, OFOConfig +from openg2g.controller.rule_based import RuleBasedConfig +from openg2g.datacenter.config import ModelDeployment +from openg2g.datacenter.workloads.inference import InferenceData +from openg2g.datacenter.workloads.training import TrainingTrace + +logger = logging.getLogger("evaluate_controllers") + + +# ── Display helpers ─────────────────────────────────────────────────────────── + +DISPLAY_NAMES: dict[str, str] = { + "baseline_no_tap": "No Control", + "rule_based": "Droop Control", + "ofo": "OFO Control", +} + + +def _display_order_key(mode: str) -> int: + if mode == "baseline_no_tap": + return 0 + if mode == "rule_based" or mode.startswith("rule_based_s"): + return 1 + if mode.startswith("ppo_"): + return 2 + if mode == "ofo" or mode.startswith("ofo_"): + return 3 + return 99 + + +def _sort_modes(modes: list[str]) -> list[str]: + return sorted(modes, key=_display_order_key) + + +def _display_name(mode: str) -> str: + if mode in DISPLAY_NAMES: + return DISPLAY_NAMES[mode] + if mode.startswith("rule_based_s"): + return "Droop Control" + if mode.startswith("ppo_"): + return "PPO Control" + return mode.replace("_", " ").title() + + +# ── Plotting ────────────────────────────────────────────────────────────────── + +def plot_voltage_comparison( + logs: dict[str, object], + save_dir: Path, + *, + v_min: float = 0.95, + v_max: float = 1.05, + exclude_buses: tuple[str, ...] = (), + scenario_idx: int | None = None, + use_display_names: bool = False, +) -> None: + """Side-by-side voltage envelopes for each controller mode.""" + modes = _sort_modes(list(logs.keys())) + n = len(modes) + fig, axes = plt.subplots(1, n, figsize=(6 * n, 5), sharey=True) + if n == 1: + axes = [axes] + + drop = {b.lower() for b in exclude_buses} + + for ax, mode in zip(axes, modes, strict=False): + log = logs[mode] + time_s = np.array(log.time_s) + + v_min_arr = np.full(len(log.grid_states), np.inf) + v_max_arr = np.full(len(log.grid_states), -np.inf) + + for t_idx, gs in enumerate(log.grid_states): + for bus in gs.voltages.buses(): + if bus.lower() in drop: + continue + pv = gs.voltages[bus] + for v in (pv.a, pv.b, pv.c): + if not math.isnan(v): + v_min_arr[t_idx] = min(v_min_arr[t_idx], v) + v_max_arr[t_idx] = max(v_max_arr[t_idx], v) + + ax.fill_between(time_s, v_min_arr, v_max_arr, alpha=0.3, color="steelblue") + ax.plot(time_s, v_min_arr, color="steelblue", linewidth=0.5, label="Vmin") + ax.plot(time_s, v_max_arr, color="coral", linewidth=0.5, label="Vmax") + ax.axhline(v_min, color="red", linestyle="--", linewidth=1, alpha=0.7) + ax.axhline(v_max, color="red", linestyle="--", linewidth=1, alpha=0.7) + ax.set_xlabel("Time (s)", fontsize=13) + ax.set_title(_display_name(mode) if use_display_names else mode, fontsize=14) + ax.legend(fontsize=12) + ax.tick_params(labelsize=12) + ax.grid(True, alpha=0.2) + + axes[0].set_ylabel("Voltage (pu)", fontsize=13) + fig.suptitle("Voltage Envelope Comparison", fontsize=16, fontweight="bold") + fig.tight_layout() + stem = f"scenario_{scenario_idx:03d}_voltage_comparison" if scenario_idx is not None else "voltage_comparison" + fig.savefig(save_dir / f"{stem}.png", dpi=150, bbox_inches="tight") + plt.close(fig) + logger.info("Saved %s.png", stem) + + +def plot_violation_bars( + results: dict[str, dict], + save_dir: Path, + *, + scenario_idx: int | None = None, + use_display_names: bool = False, +) -> None: + """Four-panel bar chart for a single scenario: + violation time, integral violation, mean throughput, batch size changes. + """ + modes = _sort_modes(list(results.keys())) + if not modes: + return + + cmap = plt.get_cmap("tab10") + colors = [cmap(i % 10) for i in range(len(modes))] + + viol_s = [float(results[m].get("violation_time_s", 0.0)) for m in modes] + integ = [float(results[m].get("integral", 0.0)) for m in modes] + tput = [float(results[m].get("mean_throughput_toks_s", 0.0)) for m in modes] + batch_chg = [float(results[m].get("batch_changes", 0.0)) for m in modes] + labels = [_display_name(m) if use_display_names else m for m in modes] + + fig, axes = plt.subplots(1, 4, figsize=(max(18, 2.0 * len(modes) + 12), 6)) + ax_v, ax_i, ax_t, ax_b = axes + + x = np.arange(len(modes)) + for ax, vals, ylabel, title, fmt in [ + (ax_v, viol_s, "Violation time (s)", "Violation time", "{:.0f}"), + (ax_i, integ, "Integral violation (pu·s)", "Integral violation", "{:.2f}"), + (ax_t, tput, "Throughput (tok/s)", "Mean throughput", "{:.2e}"), + (ax_b, batch_chg, "Batch size changes", "Batch size changes", "{:.0f}"), + ]: + ax.bar(x, vals, color=colors, alpha=0.88, edgecolor="black", linewidth=0.5) + ax.set_xticks(x) + ax.set_xticklabels(labels, rotation=35, ha="right", fontsize=12) + ax.set_ylabel(ylabel, fontsize=13) + ax.set_title(title, fontsize=14) + ax.tick_params(axis="y", labelsize=12) + ax.grid(axis="y", alpha=0.3) + for xi, val in zip(x, vals): + ax.text(xi, val, fmt.format(val), ha="center", va="bottom", fontsize=10) + + fig.suptitle("Per-scenario controller metrics", fontsize=16, fontweight="bold") + fig.tight_layout() + stem = f"scenario_{scenario_idx:03d}_performance_summary" if scenario_idx is not None else "violation_bars" + fig.savefig(save_dir / f"{stem}.png", dpi=150, bbox_inches="tight") + plt.close(fig) + logger.info("Saved %s.png", stem) + + +def plot_batch_comparison( + logs: dict[str, object], + save_dir: Path, + *, + scenario_idx: int | None = None, + use_display_names: bool = False, +) -> None: + """Batch size over time for each controller mode, one subplot per (site, model) pair.""" + modes = _sort_modes(list(logs.keys())) + if not modes: + logger.info("plot_batch_comparison: no controllers to plot") + return + + site_models: list[tuple[str, str]] = [] + for log in logs.values(): + for site_id, states in log.dc_states_by_site.items(): + if not states: + continue + for m in states[0].batch_size_by_model: + pair = (site_id, m) + if pair not in site_models: + site_models.append(pair) + break + + n_rows = len(site_models) + cmap = plt.get_cmap("tab10") + fig, axes = plt.subplots( + n_rows, + 1, + figsize=(13, 4 * n_rows), + sharex=True, + squeeze=False, + ) + + for row, (site_id, model_label) in enumerate(site_models): + ax = axes[row][0] + for i, mode in enumerate(modes): + log = logs[mode] + site_states = log.dc_states_by_site.get(site_id, []) + times = [s.time_s for s in site_states] + batches = [s.batch_size_by_model.get(model_label, 0) for s in site_states] + ax.plot( + times, + batches, + color=cmap(i % 10), + linewidth=1.5, + alpha=0.85, + label=_display_name(mode) if use_display_names else mode, + ) + ax.set_ylabel("Batch Size", fontsize=13) + title = f"{model_label} @ {site_id}" if len(log.dc_states_by_site) > 1 else model_label + ax.set_title(title, fontsize=14) + ax.legend(fontsize=12, loc="upper right") + ax.tick_params(labelsize=12) + ax.grid(True, alpha=0.3) + + axes[-1][0].set_xlabel("Time (s)", fontsize=13) + fig.suptitle("Batch Size Comparison by Model", fontsize=16, fontweight="bold") + fig.tight_layout(rect=[0, 0, 1, 0.95]) + stem = f"scenario_{scenario_idx:03d}_batch_size_comparison" if scenario_idx is not None else "batch_size_comparison" + fig.savefig(save_dir / f"{stem}.png", dpi=150, bbox_inches="tight") + plt.close(fig) + logger.info("Saved %s.png", stem) + + +# ── Metrics helpers ─────────────────────────────────────────────────────────── + +def count_batch_changes(log) -> int: + """Count step-to-step batch-size changes summed across every (site, model) series.""" + total = 0 + states_by_site = getattr(log, "dc_states_by_site", None) or {} + for states in states_by_site.values(): + if len(states) < 2: + continue + labels: set[str] = set() + for s in states: + labels.update(s.batch_size_by_model.keys()) + for lab in labels: + prev = None + for s in states: + bs = s.batch_size_by_model.get(lab, 0) + if prev is not None and bs != prev: + total += 1 + prev = bs + return total + + +def extract_perf_metrics(log, itl_deadlines: dict[str, float] | None = None) -> dict[str, float]: + """Pull throughput / latency / power time-series from a SimulationLog and + return scalar summaries (means, peaks, percentiles). + """ + dc_states = list(log.dc_states) + if not dc_states: + return { + "mean_throughput_toks_s": 0.0, + "peak_throughput_toks_s": 0.0, + "mean_latency_s": 0.0, + "p99_latency_s": 0.0, + "mean_power_kw": 0.0, + "peak_power_kw": 0.0, + "batch_changes": 0, + "itl_violation_rate": 0.0, + } + + labels = set() + for s in dc_states: + labels.update(s.batch_size_by_model.keys()) + labels = sorted(labels) + + tps_total = np.zeros(len(dc_states)) + itl_vals: list[float] = [] + for i, s in enumerate(dc_states): + for lab in labels: + bs = float(s.batch_size_by_model.get(lab, 0) or 0) + replicas = float(s.active_replicas_by_model.get(lab, 0) or 0) + itl = float(s.observed_itl_s_by_model.get(lab, float("nan"))) + if itl > 0 and not math.isnan(itl): + tps_total[i] += bs * replicas / itl + itl_vals.append(itl) + mean_tps = float(np.nanmean(tps_total)) if len(tps_total) else 0.0 + peak_tps = float(np.nanmax(tps_total)) if len(tps_total) else 0.0 + mean_itl = float(np.nanmean(itl_vals)) if itl_vals else 0.0 + p99_itl = float(np.nanpercentile(itl_vals, 99)) if itl_vals else 0.0 + + dc_kw_series: list[float] = [] + for s in dc_states: + p = getattr(s, "power_w", None) + if p is None: + continue + try: + total_w = float(p.a) + float(p.b) + float(p.c) + except Exception: + total_w = 0.0 + dc_kw_series.append(total_w / 1000.0) + mean_kw = float(np.mean(dc_kw_series)) if dc_kw_series else 0.0 + peak_kw = float(np.max(dc_kw_series)) if dc_kw_series else 0.0 + + itl_viol_count = 0 + itl_total_count = 0 + if itl_deadlines: + for s in dc_states: + for label, deadline in itl_deadlines.items(): + itl = s.observed_itl_s_by_model.get(label, float("nan")) + itl = float(itl) + if not math.isnan(itl) and itl > 0: + itl_total_count += 1 + if itl > deadline: + itl_viol_count += 1 + itl_violation_rate = itl_viol_count / itl_total_count if itl_total_count > 0 else 0.0 + + return { + "mean_throughput_toks_s": mean_tps, + "peak_throughput_toks_s": peak_tps, + "mean_latency_s": mean_itl, + "p99_latency_s": p99_itl, + "mean_power_kw": mean_kw, + "peak_power_kw": peak_kw, + "batch_changes": count_batch_changes(log), + "itl_violation_rate": itl_violation_rate, + } + + +# ── Scenario generation ─────────────────────────────────────────────────────── + +def generate_test_scenarios( + exp: dict, + inference_data: InferenceData, + training_trace: TrainingTrace, + logistic_models: LogisticModelStore, + *, + n_scenarios: int, + seed_start: int, + min_baseline_integral: float = 0.2, + min_recovery_frac: float = 0.7, + save_dir: Path, + randomize_ramps: bool = True, +) -> list[dict]: + """Generate randomized scenarios and filter using the library recovery rule. + + A seed is accepted only if: + baseline_no_tap integral >= min_baseline_integral AND + (base - ofo) / base >= min_recovery_frac + """ + dc_sites_base = exp["dc_sites"] + pv_base = exp.get("pv_systems", []) + tvl_base = exp.get("time_varying_loads", []) + training_base = exp.get("training_base") + + sys_cfg = exp["sys"] + ofo_config = exp["ofo_config"] + + accepted: list[dict] = [] + seed = seed_start + attempts = 0 + max_attempts = n_scenarios * 20 + tried: list[str] = [] + + while len(accepted) < n_scenarios and attempts < max_attempts: + effective_seed = seed * 1000 + 7 + sc = randomize_scenario( + seed=effective_seed, + dc_sites_base=dc_sites_base, + pv_systems_base=pv_base, + tvl_base=tvl_base, + training_base=training_base, + randomize_ramps=randomize_ramps, + ) + + training_overlay = sc["params"]["training_overlay"] + + bl_stats, bl_log = run_simulation( + "baseline_no_tap", + sys=sys_cfg, + dc_sites=sc["dc_sites"], + ofo_config=ofo_config, + inference_data=inference_data, + training_trace=training_trace, + logistic_models=logistic_models, + pv_systems=sc["pv_systems"], + time_varying_loads=sc["tvl"], + tap_schedule=exp.get("tap_schedule"), + training_overlay=training_overlay, + save_dir=save_dir, + ) + ofo_stats, ofo_log = run_simulation( + "ofo", + sys=sys_cfg, + dc_sites=sc["dc_sites"], + ofo_config=ofo_config, + inference_data=inference_data, + training_trace=training_trace, + logistic_models=logistic_models, + pv_systems=sc["pv_systems"], + time_varying_loads=sc["tvl"], + tap_schedule=exp.get("tap_schedule"), + training_overlay=training_overlay, + save_dir=save_dir, + ) + base_int = float(bl_stats.integral_violation_pu_s) + ofo_int = float(ofo_stats.integral_violation_pu_s) + recovery = (base_int - ofo_int) / base_int if base_int > 0 else 0.0 + passes = base_int >= min_baseline_integral and recovery >= min_recovery_frac + verdict = "ACCEPT" if passes else "reject" + line = ( + f" seed={effective_seed} base_int={base_int:.3f} ofo_int={ofo_int:.4f} " + f"recovery={100*recovery:.1f}% {verdict}" + ) + logger.info(line) + tried.append(line) + + if passes: + sc["_filter_results"] = { + "baseline_no_tap": {"stats": bl_stats, "log": bl_log}, + "ofo": {"stats": ofo_stats, "log": ofo_log}, + } + accepted.append(sc) + seed += 1 + attempts += 1 + + logger.info( + "Filter complete: %d accepted out of %d attempted seeds (%d-%d)", + len(accepted), attempts, seed_start, seed - 1, + ) + if len(accepted) < n_scenarios: + logger.warning( + "Only accepted %d/%d scenarios — consider lowering min_recovery_frac or expanding seed range", + len(accepted), n_scenarios, + ) + return accepted + + +def load_scenarios_from_library( + library_path: str, + *, + exp: dict, + n_scenarios: int, +) -> list[dict]: + """Load pre-screened scenarios from a ScenarioLibrary pickle. + + Each record carries its resolved ``dc_sites`` / ``pv_systems`` / ``tvl`` + directly, so loading is a pure pickle read — no rng replay, no + distribution mismatch when records from different build-time ranges are + mixed in one library. + """ + from openg2g.rl.env import ScenarioLibrary + + lib = ScenarioLibrary(library_path) + training_base = exp.get("training_base") + + logger.info("Loaded library with %d records from %s", len(lib), library_path) + + n_take = min(n_scenarios, len(lib)) + if n_take < n_scenarios: + logger.warning( + "Library has only %d scenarios — capping n_scenarios from %d to %d", + len(lib), n_scenarios, n_take, + ) + + return [materialize_scenario(rec, training_base=training_base) for rec in lib.scenarios[:n_take]] + + +# ── Evaluation ──────────────────────────────────────────────────────────────── + +def run_scenario( + scenario: dict, + *, + exp: dict, + inference_data: InferenceData, + training_trace: TrainingTrace, + logistic_models: LogisticModelStore, + ppo_models: list[str], + ppo_labels: list[str], + save_dir: Path, + scenario_idx: int, + obs_mode: str = "full-voltage", + ofo_variants: list[tuple[str, "OFOConfig"]] | None = None, + include_rule_based: bool = False, + rule_based_config: "RuleBasedConfig | None" = None, + rule_step_sizes: tuple[float, ...] = (10.0,), + rule_zone_local: bool = False, + no_per_scenario_plots: bool = False, + no_default_ofo: bool = False, + use_display_names: bool = False, +) -> dict: + """Run baseline, OFO (+variants), rule-based, and PPO models on a single scenario.""" + sys_cfg = exp["sys"] + ofo_config = exp["ofo_config"] + exclude_buses = tuple(sys_cfg["exclude_buses"]) + ofo_variants = ofo_variants or [] + + itl_deadlines: dict[str, float] = { + md.spec.model_label: md.spec.itl_deadline_s + for site in exp["dc_sites"].values() + for md, _ in site.models + } + + dc_sites = scenario["dc_sites"] + pv_systems = scenario["pv_systems"] + tvl = scenario["tvl"] + training_overlay = scenario["params"]["training_overlay"] + + sc_save = save_dir / f"scenario_{scenario_idx:03d}" + sc_save.mkdir(parents=True, exist_ok=True) + + results = {} + all_logs: dict[str, object] = {} + + cached = scenario.get("_filter_results", {}) + default_modes = ["baseline_no_tap"] if no_default_ofo else ["baseline_no_tap", "ofo"] + for mode in default_modes: + if mode in cached: + vstats = cached[mode]["stats"] + log = cached[mode]["log"] + else: + vstats, log = run_simulation( + mode, + sys=sys_cfg, + dc_sites=dc_sites, + ofo_config=ofo_config, + inference_data=inference_data, + training_trace=training_trace, + logistic_models=logistic_models, + pv_systems=pv_systems, + time_varying_loads=tvl, + tap_schedule=exp.get("tap_schedule"), + training_overlay=training_overlay, + save_dir=sc_save, + ) + perf = extract_perf_metrics(log, itl_deadlines) + results[mode] = { + "violation_time_s": vstats.violation_time_s, + "integral": vstats.integral_violation_pu_s, + "worst_vmin": vstats.worst_vmin, + "worst_vmax": vstats.worst_vmax, + **perf, + } + all_logs[mode] = log + logger.info( + " scenario %d %s: viol=%.0fs integral=%.4f vmin=%.4f vmax=%.4f " + "tput=%.1f p99_lat=%.3fs power=%.1fkW batch_chg=%d", + scenario_idx, mode, vstats.violation_time_s, + vstats.integral_violation_pu_s, vstats.worst_vmin, vstats.worst_vmax, + perf["mean_throughput_toks_s"], perf["p99_latency_s"], perf["mean_power_kw"], + perf["batch_changes"], + ) + + if include_rule_based: + for step_size in rule_step_sizes: + label = "rule_based" if len(rule_step_sizes) == 1 else f"rule_based_s{step_size:g}" + rb_config = rule_based_config or RuleBasedConfig(v_min=V_MIN, v_max=V_MAX, step_size=step_size) + if rule_based_config is not None: + rb_config = rule_based_config + vstats, log = run_simulation( + label, + sys=sys_cfg, + dc_sites=dc_sites, + ofo_config=ofo_config, + inference_data=inference_data, + training_trace=training_trace, + logistic_models=logistic_models, + pv_systems=pv_systems, + time_varying_loads=tvl, + tap_schedule=exp.get("tap_schedule"), + rule_based_config=rb_config, + rule_zone_local=rule_zone_local, + training_overlay=training_overlay, + save_dir=sc_save, + ) + perf = extract_perf_metrics(log, itl_deadlines) + results[label] = { + "violation_time_s": vstats.violation_time_s, + "integral": vstats.integral_violation_pu_s, + "worst_vmin": vstats.worst_vmin, + "worst_vmax": vstats.worst_vmax, + **perf, + } + all_logs[label] = log + logger.info( + " scenario %d %s: viol=%.0fs integral=%.4f vmin=%.4f vmax=%.4f " + "tput=%.1f p99_lat=%.3fs power=%.1fkW batch_chg=%d", + scenario_idx, label, vstats.violation_time_s, + vstats.integral_violation_pu_s, vstats.worst_vmin, vstats.worst_vmax, + perf["mean_throughput_toks_s"], perf["p99_latency_s"], perf["mean_power_kw"], + perf["batch_changes"], + ) + if rule_based_config is not None: + break + + for variant_label, variant_cfg in ofo_variants: + vstats, log = run_simulation( + "ofo", + sys=sys_cfg, + dc_sites=dc_sites, + ofo_config=variant_cfg, + inference_data=inference_data, + training_trace=training_trace, + logistic_models=logistic_models, + pv_systems=pv_systems, + time_varying_loads=tvl, + tap_schedule=exp.get("tap_schedule"), + training_overlay=training_overlay, + save_dir=sc_save, + ) + perf = extract_perf_metrics(log, itl_deadlines) + mode_key = f"ofo_{variant_label}" + results[mode_key] = { + "violation_time_s": vstats.violation_time_s, + "integral": vstats.integral_violation_pu_s, + "worst_vmin": vstats.worst_vmin, + "worst_vmax": vstats.worst_vmax, + **perf, + } + all_logs[mode_key] = log + logger.info( + " scenario %d %s: viol=%.0fs integral=%.4f vmin=%.4f vmax=%.4f " + "tput=%.1f p99_lat=%.3fs power=%.1fkW batch_chg=%d", + scenario_idx, mode_key, vstats.violation_time_s, + vstats.integral_violation_pu_s, vstats.worst_vmin, vstats.worst_vmax, + perf["mean_throughput_toks_s"], perf["p99_latency_s"], perf["mean_power_kw"], + perf["batch_changes"], + ) + + for ppo_path, label in zip(ppo_models, ppo_labels): + vstats, log = run_simulation( + "ppo", + sys=sys_cfg, + dc_sites=dc_sites, + ofo_config=ofo_config, + inference_data=inference_data, + training_trace=training_trace, + logistic_models=logistic_models, + pv_systems=pv_systems, + time_varying_loads=tvl, + tap_schedule=exp.get("tap_schedule"), + ppo_model=ppo_path, + obs_mode=obs_mode, + training_overlay=training_overlay, + save_dir=sc_save, + ) + perf = extract_perf_metrics(log, itl_deadlines) + results[f"ppo_{label}"] = { + "violation_time_s": vstats.violation_time_s, + "integral": vstats.integral_violation_pu_s, + "worst_vmin": vstats.worst_vmin, + "worst_vmax": vstats.worst_vmax, + **perf, + } + all_logs[f"ppo_{label}"] = log + logger.info( + " scenario %d ppo_%s: viol=%.0fs integral=%.4f vmin=%.4f vmax=%.4f " + "tput=%.1f p99_lat=%.3fs power=%.1fkW batch_chg=%d", + scenario_idx, label, vstats.violation_time_s, + vstats.integral_violation_pu_s, vstats.worst_vmin, vstats.worst_vmax, + perf["mean_throughput_toks_s"], perf["p99_latency_s"], perf["mean_power_kw"], + perf["batch_changes"], + ) + + if not no_per_scenario_plots: + plot_voltage_comparison( + all_logs, sc_save, + v_min=V_MIN, v_max=V_MAX, exclude_buses=exclude_buses, + scenario_idx=scenario_idx, use_display_names=use_display_names, + ) + plot_batch_comparison(all_logs, sc_save, scenario_idx=scenario_idx, use_display_names=use_display_names) + plot_violation_bars(results, sc_save, scenario_idx=scenario_idx, use_display_names=use_display_names) + + return results + + +def plot_aggregate( + all_results: list[dict], + scenario_params: list[dict], + save_dir: Path, + modes: list[str], + *, + system: str = "", + use_display_names: bool = False, +) -> None: + """2×3 aggregate bar chart (means) + per-scenario breakdown + normalized integral + CDF + scatter.""" + n_sc = len(all_results) + prefix = f"{system}_" if system else "" + + colors = ["#999999", "#4CAF50", "#2196F3", "#FF9800", "#E91E63", + "#9C27B0", "#00BCD4", "#795548", "#607D8B"] + + display_labels = [_display_name(m) if use_display_names else m for m in modes] + + metrics = [ + ("violation_time_s", "Mean Violation Time (s)"), + ("integral", "Mean Integral Violation (pu·s)"), + ("batch_changes", "Mean Batch Size Changes"), + ("mean_throughput_toks_s", "Mean Throughput (tok/s)"), + ("mean_power_kw", "Mean Data Center Power (kW)"), + ("itl_violation_rate", "Mean ITL Violation Rate"), + ] + + fig, axes = plt.subplots(2, 3, figsize=(max(15, len(modes) * 3.0), 10)) + x = np.arange(len(modes)) + + for ax, (metric, title) in zip(axes.flat, metrics): + means = [] + for mode in modes: + vals = [r[mode].get(metric, 0) for r in all_results if mode in r] + means.append(np.mean(vals) if vals else 0.0) + + ax.bar(x, means, color=colors[: len(modes)], alpha=0.85) + ax.set_xticks(x) + ax.set_xticklabels(display_labels, rotation=30, ha="right", fontsize=12) + ax.set_ylabel(title, fontsize=13) + ax.set_title(title, fontsize=14, fontweight="bold") + ax.tick_params(axis="y", labelsize=12) + ax.grid(axis="y", alpha=0.3) + + fig.suptitle(f"Aggregate Controller Metrics — {n_sc} Scenarios", fontsize=16, fontweight="bold") + fig.tight_layout(rect=[0, 0, 1, 0.95]) + fname = f"{prefix}controller_evaluation.png" + fig.savefig(save_dir / fname, dpi=150, bbox_inches="tight") + plt.close(fig) + logger.info("Saved %s", fname) + + # ── Per-scenario integral (absolute) ── + fig, ax = plt.subplots(figsize=(max(10, n_sc * 0.8), 6)) + x = np.arange(n_sc) + width = 0.8 / len(modes) + + for i, mode in enumerate(modes): + vals = [r[mode]["integral"] if mode in r else 0 for r in all_results] + ax.bar(x + i * width, vals, width, + label=_display_name(mode) if use_display_names else mode, + color=colors[i % len(colors)], alpha=0.85) + + ax.set_xlabel("Scenario", fontsize=13) + ax.set_ylabel("Integral Violation (pu·s)", fontsize=13) + ax.set_title("Per-Scenario Integral Violation", fontsize=14, fontweight="bold") + ax.set_xticks(x + width * (len(modes) - 1) / 2) + ax.set_xticklabels([f"S{i}" for i in range(n_sc)], fontsize=10) + ax.legend(fontsize=10, loc="upper right") + ax.tick_params(axis="y", labelsize=12) + ax.grid(axis="y", alpha=0.3) + fig.tight_layout() + fname = f"{prefix}scenario_summary.png" + fig.savefig(save_dir / fname, dpi=150, bbox_inches="tight") + plt.close(fig) + logger.info("Saved %s", fname) + + # ── Per-scenario normalized integral (relative to baseline_no_tap) ── + baseline_key = "baseline_no_tap" + if baseline_key in modes: + fig, ax = plt.subplots(figsize=(max(10, n_sc * 0.8), 6)) + x = np.arange(n_sc) + non_baseline = [m for m in modes if m != baseline_key] + width = 0.8 / len(non_baseline) + + for i, mode in enumerate(non_baseline): + norm_vals = [] + for r in all_results: + base = r.get(baseline_key, {}).get("integral", 0.0) + val = r.get(mode, {}).get("integral", 0.0) + norm_vals.append(val / base if base > 0 else 0.0) + ax.bar(x + i * width, norm_vals, width, + label=_display_name(mode) if use_display_names else mode, + color=colors[(modes.index(mode)) % len(colors)], alpha=0.85) + + ax.axhline(1.0, color="black", linestyle="--", linewidth=1, alpha=0.6, label="Baseline") + ax.set_xlabel("Scenario", fontsize=13) + ax.set_ylabel("Normalized Integral (relative to No Control)", fontsize=13) + ax.set_title("Per-Scenario Normalized Integral Violation", fontsize=14, fontweight="bold") + ax.set_xticks(x + width * (len(non_baseline) - 1) / 2) + ax.set_xticklabels([f"S{i}" for i in range(n_sc)], fontsize=10) + ax.legend(fontsize=10, loc="upper right") + ax.tick_params(axis="y", labelsize=12) + ax.grid(axis="y", alpha=0.3) + fig.tight_layout() + fname = f"{prefix}scenario_normalized_integral.png" + fig.savefig(save_dir / fname, dpi=150, bbox_inches="tight") + plt.close(fig) + logger.info("Saved %s", fname) + + # ── CDF of integral violation ── + fig, ax = plt.subplots(figsize=(8, 6)) + for i, mode in enumerate(modes): + vals = sorted([r[mode].get("integral", 0.0) for r in all_results if mode in r]) + if not vals: + continue + cdf = np.arange(1, len(vals) + 1) / len(vals) + ax.plot(vals, cdf, color=colors[i % len(colors)], linewidth=2, + label=_display_name(mode) if use_display_names else mode) + ax.set_xlabel("Integral Violation (pu·s)", fontsize=13) + ax.set_ylabel("Cumulative Fraction", fontsize=13) + ax.set_title("CDF of Integral Violation Across Scenarios", fontsize=14, fontweight="bold") + ax.legend(fontsize=11, loc="lower right") + ax.tick_params(labelsize=12) + ax.grid(True, alpha=0.3) + fig.tight_layout() + fname = f"{prefix}cdf_integral.png" + fig.savefig(save_dir / fname, dpi=150, bbox_inches="tight") + plt.close(fig) + logger.info("Saved %s", fname) + + # ── Throughput vs. voltage violation scatter ── + scatter_data = {} + for i, mode in enumerate(modes): + integrals = [r[mode].get("integral", 0.0) for r in all_results if mode in r] + tputs = [r[mode].get("mean_throughput_toks_s", 0.0) for r in all_results if mode in r] + scatter_data[mode] = (integrals, tputs) + + n_modes = len(modes) + ncols = 2 + nrows = math.ceil(n_modes / ncols) + fig, axes = plt.subplots(nrows, ncols, figsize=(12, 5 * nrows), + sharex=True, sharey=True, squeeze=False) + + for idx, mode in enumerate(modes): + ax = axes[idx // ncols][idx % ncols] + integrals, tputs = scatter_data[mode] + label = _display_name(mode) if use_display_names else mode + + for other_mode, (oi, ot) in scatter_data.items(): + if other_mode != mode: + ax.scatter(oi, ot, color="lightgrey", s=40, alpha=0.6, + edgecolors="none", zorder=1) + + ax.scatter(integrals, tputs, color=colors[idx % len(colors)], + s=80, alpha=0.9, edgecolors="black", linewidths=0.5, zorder=2) + ax.scatter(np.mean(integrals), np.mean(tputs), + color=colors[idx % len(colors)], s=220, marker="*", + edgecolors="black", linewidths=0.8, zorder=3) + + ax.set_title(label, fontsize=14, fontweight="bold") + ax.set_xlabel("Integral Violation (pu·s)", fontsize=12) + ax.set_ylabel("Mean Throughput (tok/s)", fontsize=12) + ax.tick_params(labelsize=11) + ax.yaxis.set_major_formatter(plt.FuncFormatter(lambda v, _: f"{v:.2e}")) + ax.grid(True, alpha=0.3) + + for idx in range(n_modes, nrows * ncols): + axes[idx // ncols][idx % ncols].set_visible(False) + + fig.suptitle("Throughput vs. Voltage Violation by Controller", fontsize=16, fontweight="bold") + fig.tight_layout(rect=[0, 0, 1, 0.95]) + fname = f"{prefix}throughput_vs_violation.png" + fig.savefig(save_dir / fname, dpi=150, bbox_inches="tight") + plt.close(fig) + logger.info("Saved %s", fname) + + logger.info("Saved aggregate figures to %s", save_dir) + + +def main( + *, + ppo_models: tuple[str, ...] = (), + ppo_labels: tuple[str, ...] = (), + system: str = "ieee13", + n_scenarios: int = 10, + seed_start: int = 500, + output_dir: str = "", + obs_mode: str = "full-voltage", + min_baseline_integral: float = 0.2, + min_recovery_frac: float = 0.7, + ofo_w_throughputs: tuple[float, ...] = (), + ofo_w_switches: tuple[float, ...] = (), + ofo_primal_steps: tuple[float, ...] = (), + ofo_extra_variants: tuple[str, ...] = (), + no_default_ofo: bool = False, + include_rule_based: bool = False, + rule_step_sizes: tuple[float, ...] = (10.0,), + rule_zone_local: bool = False, + no_per_scenario_plots: bool = False, + no_aggregate_plots: bool = False, + randomize_ramps: bool = True, + scenario_library: str = "", + use_display_names: bool = False, + log_level: str = "INFO", +) -> None: + logging.basicConfig( + level=getattr(logging, log_level), + format="%(levelname)s %(asctime)s [%(name)s:%(lineno)d] %(message)s", + datefmt="%H:%M:%S", + ) + logging.getLogger("httpx").setLevel(logging.WARNING) + logging.getLogger("openg2g.coordinator").setLevel(logging.WARNING) + logging.getLogger("openg2g.datacenter").setLevel(logging.WARNING) + logging.getLogger("openg2g.grid").setLevel(logging.WARNING) + logging.getLogger("openg2g.controller.ofo").setLevel(logging.WARNING) + + ppo_models_resolved = [str(Path(p).resolve()) for p in ppo_models] + + if not ppo_labels: + ppo_labels = tuple( + Path(p).parent.name.replace("ppo_ablation_", "") for p in ppo_models + ) + + if system not in EXPERIMENTS: + raise ValueError(f"Unknown system {system!r}. Valid: {sorted(EXPERIMENTS)}") + + training_trace = TrainingTrace.ensure(TRAINING_TRACE_PATH) + + exp = EXPERIMENTS[system](training_trace=training_trace) + all_models = [] + for site in exp["dc_sites"].values(): + # site.models is now tuple[(ModelDeployment, ReplicaSchedule), ...] + all_models.extend(md for md, _ in site.models) + all_specs = tuple(m.spec for m in all_models) + + inference_data = InferenceData.ensure( + SPECS_CACHE_DIR, all_specs, plot=False, dt_s=float(DT_DC), + ) + logistic_models = LogisticModelStore.ensure( + SPECS_CACHE_DIR, all_specs, plot=False, + ) + + base_ofo = exp["ofo_config"] + ofo_variants: list[tuple[str, OFOConfig]] = [] + + def _w_tag(w: float) -> str: + if w == 0: + return "w0" + if w >= 1e-3: + return f"w{w:g}".replace(".", "p") + return f"w{w:.0e}".replace("-0", "-") + + def _fmt(v: float) -> str: + return f"{v:g}".replace(".", "p") + + if ofo_w_throughputs or ofo_w_switches or ofo_primal_steps: + w_list = ofo_w_throughputs or (base_ofo.w_throughput,) + s_list = ofo_w_switches or (base_ofo.w_switch,) + p_list = ofo_primal_steps or (base_ofo.primal_step_size,) + for w in w_list: + for s in s_list: + for p in p_list: + overrides: dict = {} + parts: list[str] = [] + if ofo_w_throughputs: + overrides["w_throughput"] = float(w) + parts.append(_w_tag(float(w))) + if ofo_w_switches: + overrides["w_switch"] = float(s) + parts.append(f"ws{_fmt(float(s))}") + if ofo_primal_steps: + overrides["primal_step_size"] = float(p) + parts.append(f"ps{_fmt(float(p))}") + cfg = base_ofo.model_copy(update=overrides) + label = "_".join(parts) if parts else "variant" + ofo_variants.append((label, cfg)) + + for spec in ofo_extra_variants: + overrides: dict = {} + parts: list[str] = [] + for kv in spec.split(","): + k, _, v = kv.strip().partition("=") + if not k: + continue + fv = float(v) + overrides[k] = fv + if k == "w_throughput": + parts.append(_w_tag(fv)) + elif k == "w_switch": + parts.append(f"ws{_fmt(fv)}") + elif k == "primal_step_size": + parts.append(f"ps{_fmt(fv)}") + else: + parts.append(f"{k}{_fmt(fv)}") + cfg = base_ofo.model_copy(update=overrides) + ofo_variants.append(("_".join(parts) if parts else "extra", cfg)) + + if ofo_variants: + logger.info( + "OFO variants (%d): %s", + len(ofo_variants), + [(l, c.w_throughput, c.w_switch, c.primal_step_size) for l, c in ofo_variants], + ) + + save_dir = Path(__file__).resolve().parent / "outputs" / system / ( + output_dir or f"eval_multi_seed{seed_start}_n{n_scenarios}" + ) + save_dir.mkdir(parents=True, exist_ok=True) + + if scenario_library: + test_scenarios = load_scenarios_from_library( + scenario_library, + exp=exp, + n_scenarios=n_scenarios, + ) + else: + test_scenarios = generate_test_scenarios( + exp, + inference_data=inference_data, + training_trace=training_trace, + logistic_models=logistic_models, + n_scenarios=n_scenarios, + seed_start=seed_start, + min_baseline_integral=min_baseline_integral, + min_recovery_frac=min_recovery_frac, + save_dir=save_dir, + randomize_ramps=randomize_ramps, + ) + + all_results = [] + scenario_params = [] + modes_set = set() + + for i, scenario in enumerate(test_scenarios): + params = scenario["params"] + logger.info("") + logger.info("=" * 70) + logger.info( + "SCENARIO %d/%d: seed=%d pv_scale=%.2f load_scale=%.2f", + i + 1, n_scenarios, scenario["seed"], + params["pv_scale"], params["load_scale"], + ) + if params["training_overlay"]: + to = params["training_overlay"] + logger.info( + " training: t=[%.0f, %.0f] n_gpus=%d", + to["t_start"], to["t_end"], to["n_gpus"], + ) + logger.info("=" * 70) + + results = run_scenario( + scenario, + exp=exp, + inference_data=inference_data, + training_trace=training_trace, + logistic_models=logistic_models, + ppo_models=ppo_models_resolved, + ppo_labels=list(ppo_labels), + save_dir=save_dir, + scenario_idx=i, + obs_mode=obs_mode, + ofo_variants=ofo_variants, + include_rule_based=include_rule_based, + rule_step_sizes=rule_step_sizes, + rule_zone_local=rule_zone_local, + no_per_scenario_plots=no_per_scenario_plots, + no_default_ofo=no_default_ofo, + use_display_names=use_display_names, + ) + all_results.append(results) + scenario_params.append(params) + modes_set.update(results.keys()) + + rb_labels = ( + ["rule_based"] if len(rule_step_sizes) == 1 + else [f"rule_based_s{s:g}" for s in rule_step_sizes] + ) + mode_order = ( + ["baseline_no_tap"] + rb_labels + + [f"ppo_{l}" for l in ppo_labels] + + ["ofo"] + [f"ofo_{label}" for label, _ in ofo_variants] + ) + modes = [m for m in mode_order if m in modes_set] + + logger.info("") + logger.info("=" * 90) + logger.info("AGGREGATE RESULTS (%d scenarios)", n_scenarios) + logger.info("=" * 90) + header = ( + f"{'Mode':<20s} {'Viol(s)':>10s} {'±':>8s} {'Integral':>10s} {'±':>8s} " + f"{'Worst Vmin':>12s} {'Worst Vmax':>12s} {'Batch Δ':>10s} {'±':>8s}" + ) + logger.info(header) + logger.info("-" * 104) + + for mode in modes: + viol = [r[mode]["violation_time_s"] for r in all_results if mode in r] + intg = [r[mode]["integral"] for r in all_results if mode in r] + vmin = [r[mode]["worst_vmin"] for r in all_results if mode in r] + vmax = [r[mode]["worst_vmax"] for r in all_results if mode in r] + bchg = [r[mode].get("batch_changes", 0) for r in all_results if mode in r] + if viol: + logger.info( + "%-20s %10.1f %8.1f %10.4f %8.4f %12.4f %12.4f %10.1f %8.1f", + mode, + np.mean(viol), np.std(viol), + np.mean(intg), np.std(intg), + np.mean(vmin), np.mean(vmax), + np.mean(bchg), np.std(bchg), + ) + + extra_cols = [ + "mean_throughput_toks_s", "peak_throughput_toks_s", + "mean_latency_s", "p99_latency_s", + "mean_power_kw", "peak_power_kw", + "batch_changes", + "itl_violation_rate", + ] + csv_path = save_dir / "results.csv" + with open(csv_path, "w", newline="") as f: + writer = csv.writer(f) + writer.writerow([ + "scenario", "seed", "pv_scale", "load_scale", + "mode", "violation_time_s", "integral", "worst_vmin", "worst_vmax", + *extra_cols, + ]) + for i, (results, params) in enumerate(zip(all_results, scenario_params)): + for mode, stats in results.items(): + sc = test_scenarios[i] + writer.writerow([ + i, sc["seed"], params["pv_scale"], params["load_scale"], + mode, + stats["violation_time_s"], stats["integral"], + stats["worst_vmin"], stats["worst_vmax"], + *[stats.get(k, "") for k in extra_cols], + ]) + logger.info("Results CSV: %s", csv_path) + + if not no_aggregate_plots: + plot_aggregate(all_results, scenario_params, save_dir, modes, system=system, use_display_names=use_display_names) + logger.info("All outputs saved to: %s", save_dir) + + +if __name__ == "__main__": + import tyro + + @dataclass + class Args: + ppo_models: tuple[str, ...] = () + """Paths to trained PPO model .zip files. Empty = run only baseline + OFO variants.""" + ppo_labels: tuple[str, ...] = () + """Legend labels (one per model). Defaults to parent dir names.""" + system: str = "ieee13" + """Which feeder experiment to use. Valid: ieee13, ieee34, ieee123.""" + n_scenarios: int = 10 + """Number of held-out scenarios to evaluate on.""" + seed_start: int = 500 + """Starting seed offset for test scenarios (seeds = seed_start*1000+7, ...).""" + output_dir: str = "" + """Output directory name under outputs//. Auto-generated if empty.""" + obs_mode: str = "full-voltage" + """Observation mode used during PPO training: full-voltage, per-zone-summary, per-bus-summary, or system-summary-only.""" + min_baseline_integral: float = 0.2 + """Minimum baseline_no_tap integral (pu*s) for a scenario seed to be accepted.""" + min_recovery_frac: float = 0.7 + """Minimum (base-ofo)/base recovery fraction for a seed to be accepted.""" + ofo_w_throughputs: tuple[float, ...] = () + """Extra OFO variants, given as throughput weight values (e.g. 0.0001 0.00001 0).""" + ofo_w_switches: tuple[float, ...] = () + """Extra OFO variants, given as switching-cost weight values (e.g. 1.0 3.0 10.0).""" + ofo_primal_steps: tuple[float, ...] = () + """Extra OFO variants, given as primal-step-size values (e.g. 0.02 0.05 0.1).""" + ofo_extra_variants: tuple[str, ...] = () + """Explicit OFO variants as 'k=v,k=v' (e.g. 'w_throughput=0,w_switch=1.0').""" + no_default_ofo: bool = False + """Skip emitting the default OFO as 'ofo' in results. Filter/cache path still uses it.""" + include_rule_based: bool = False + """Also evaluate the rule-based controller.""" + rule_step_sizes: tuple[float, ...] = (10.0,) + """Step size(s) for the rule-based controller.""" + rule_zone_local: bool = False + """When True AND sys defines `zones` AND there are >1 DC sites, each rule-based controller observes only buses in its own zone (decentralized credit assignment for ieee123).""" + no_per_scenario_plots: bool = False + """Skip per-scenario voltage and batch plots (saves disk + time).""" + no_aggregate_plots: bool = False + """Skip aggregate comparison and per-scenario integral plots.""" + randomize_ramps: bool = True + """Synthesize per-episode inference ramps. Set --no-randomize-ramps for ieee34.""" + scenario_library: str = "" + """Path to a pre-screened scenario library .pkl (from build_scenario_library.py).""" + use_display_names: bool = False + """Use human-readable display names in all plots.""" + log_level: str = "INFO" + """Logging verbosity.""" + + args = tyro.cli(Args) + main( + ppo_models=args.ppo_models, + ppo_labels=args.ppo_labels, + system=args.system, + n_scenarios=args.n_scenarios, + seed_start=args.seed_start, + output_dir=args.output_dir, + obs_mode=args.obs_mode, + min_baseline_integral=args.min_baseline_integral, + min_recovery_frac=args.min_recovery_frac, + ofo_w_throughputs=args.ofo_w_throughputs, + ofo_w_switches=args.ofo_w_switches, + ofo_primal_steps=args.ofo_primal_steps, + ofo_extra_variants=args.ofo_extra_variants, + no_default_ofo=args.no_default_ofo, + include_rule_based=args.include_rule_based, + rule_step_sizes=args.rule_step_sizes, + rule_zone_local=args.rule_zone_local, + no_per_scenario_plots=args.no_per_scenario_plots, + no_aggregate_plots=args.no_aggregate_plots, + randomize_ramps=args.randomize_ramps, + scenario_library=args.scenario_library, + use_display_names=args.use_display_names, + log_level=args.log_level, + ) diff --git a/examples/offline/sweep_dc_locations.py b/examples/offline/sweep_dc_locations.py index c3d8259..af7e4a9 100644 --- a/examples/offline/sweep_dc_locations.py +++ b/examples/offline/sweep_dc_locations.py @@ -31,19 +31,31 @@ import logging import math import time -from dataclasses import dataclass from fractions import Fraction from pathlib import Path -import matplotlib -import matplotlib.pyplot as plt import numpy as np import pandas as pd -from matplotlib.lines import Line2D - -matplotlib.use("Agg") - -from utils import discover_candidate_buses, extract_all_voltages, find_violations +from systems import ( + DT_CTRL, + DT_DC, + DT_GRID, + POWER_AUG, + SYSTEMS, + TAP_STEP, + TOTAL_DURATION_S, + V_MAX, + V_MIN, + DCSite, + PVSystemSpec, + SPECS_CACHE_DIR, + TRAINING_TRACE_PATH, + TimeVaryingLoadSpec, + all_model_specs, + deploy, + tap, + with_ramp, +) from openg2g.controller.ofo import ( LogisticModelStore, @@ -55,7 +67,6 @@ from openg2g.datacenter.config import ( DatacenterConfig, InferenceModelSpec, - ModelDeployment, PowerAugmentationConfig, ReplicaSchedule, TrainingRun, @@ -64,123 +75,478 @@ from openg2g.datacenter.workloads.inference import InferenceData from openg2g.datacenter.workloads.training import TrainingTrace from openg2g.grid.config import TapPosition, TapSchedule -from openg2g.grid.generator import ConstantGenerator, SyntheticPV -from openg2g.grid.load import SyntheticLoad from openg2g.grid.opendss import OpenDSSGrid -from openg2g.metrics.performance import compute_performance_stats from openg2g.metrics.voltage import compute_allbus_voltage_stats -from plotting import plot_allbus_voltages_per_phase, plot_model_timeseries_4panel -from systems import SYSTEMS, TAP_STEP, tap - -_PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent - -DT_DC = Fraction(1, 10) -DT_GRID = Fraction(1, 10) -DT_CTRL = Fraction(1) -V_MIN, V_MAX = 0.95, 1.05 -TOTAL_DURATION_S = 3600 -POWER_AUG = PowerAugmentationConfig(amplitude_scale_range=(0.98, 1.02), noise_fraction=0.005) - -LLAMA_8B = InferenceModelSpec( - model_label="Llama-3.1-8B", - model_id="meta-llama/Llama-3.1-8B-Instruct", - gpu_model="H100", - task="lm-arena-chat", - precision="bfloat16", - gpus_per_replica=1, - tensor_parallel=1, - itl_deadline_s=0.08, - batch_sizes=(8, 16, 32, 64, 96, 128, 192, 256, 384, 512, 768, 1024), - feasible_batch_sizes=(8, 16, 32, 64, 128, 256, 512), -) -LLAMA_70B = InferenceModelSpec( - model_label="Llama-3.1-70B", - model_id="meta-llama/Llama-3.1-70B-Instruct", - gpu_model="H100", - task="lm-arena-chat", - precision="bfloat16", - gpus_per_replica=4, - tensor_parallel=4, - itl_deadline_s=0.10, - batch_sizes=(8, 16, 32, 64, 96, 128, 192, 256, 384, 512, 768, 1024, 1536, 2048), - feasible_batch_sizes=(8, 16, 32, 64, 128, 256, 512), -) -LLAMA_405B = InferenceModelSpec( - model_label="Llama-3.1-405B", - model_id="meta-llama/Llama-3.1-405B-Instruct-FP8", - gpu_model="H100", - task="lm-arena-chat", - precision="fp8", - gpus_per_replica=8, - tensor_parallel=8, - itl_deadline_s=0.12, - batch_sizes=(8, 16, 32, 64, 96, 128, 192, 256, 384, 512), - feasible_batch_sizes=(8, 16, 32, 64, 128, 256, 512), -) -QWEN_30B = InferenceModelSpec( - model_label="Qwen3-30B-A3B", - model_id="Qwen/Qwen3-30B-A3B-Thinking-2507", - gpu_model="H100", - task="gpqa", - precision="bfloat16", - gpus_per_replica=2, - tensor_parallel=2, - itl_deadline_s=0.06, - batch_sizes=(8, 16, 32, 64, 96, 128, 192, 256, 384, 512), - feasible_batch_sizes=(8, 16, 32, 64, 128, 256, 512), -) -QWEN_235B = InferenceModelSpec( - model_label="Qwen3-235B-A22B", - model_id="Qwen/Qwen3-235B-A22B-Thinking-2507", - gpu_model="H100", - task="gpqa", - precision="bfloat16", - gpus_per_replica=8, - tensor_parallel=8, - itl_deadline_s=0.14, - batch_sizes=(8, 16, 32, 64, 96, 128, 192, 256, 384, 512), - feasible_batch_sizes=(8, 16, 32, 64, 128, 256, 512), -) -ALL_MODEL_SPECS = (LLAMA_8B, LLAMA_70B, LLAMA_405B, QWEN_30B, QWEN_235B) -MODEL_SPECS = {s.model_label: s for s in ALL_MODEL_SPECS} +logger = logging.getLogger("sweep_dc_locations") -SPECS_CACHE_DIR = _PROJECT_ROOT / "data" / "specs" -TRAINING_TRACE_PATH = _PROJECT_ROOT / "data" / "training_trace.csv" +T_TOTAL_S = 3600 +# Backward-compatible aliases (used by other scripts) +PVSystemConfig = PVSystemSpec +TimeVaryingLoadConfig = TimeVaryingLoadSpec -def deploy(label, num_replicas, initial_batch_size=128): - """Shorthand: `deploy("Llama-3.1-8B", 720, 128)` -> `(ModelDeployment, ReplicaSchedule)`.""" - return ( - ModelDeployment(spec=MODEL_SPECS[label], initial_batch_size=initial_batch_size), - ReplicaSchedule(initial=num_replicas), - ) +# ── Profile generation helpers ─────────────────────────────────────────────── -@dataclass -class _DCSiteConfig: - bus: str - base_kw_per_phase: float - total_gpu_capacity: int - models: tuple[tuple[ModelDeployment, ReplicaSchedule], ...] = () - seed: int = 0 - connection_type: str = "wye" - replica_schedules: dict[str, ReplicaSchedule] | None = None +def _smooth_bump(t: float, t_center: float, half_width: float) -> float: + dt = abs(t - t_center) + if dt >= half_width: + return 0.0 + x = dt / half_width + return (1 - x * x) ** 2 -logger = logging.getLogger("sweep_dc_locations") +def _smoothstep(t: float, t_start: float, t_end: float) -> float: + """Cubic Hermite smoothstep: zero-derivative at both endpoints. + + Returns 0 for ``t <= t_start``, 1 for ``t >= t_end``, and a smooth + S-curve in between (3x² - 2x³). This is the standard ease-in/ease-out + function used for physically plausible PV ramps — solar power doesn't + transition linearly because the sun's elevation angle changes smoothly. + """ + if t_end <= t_start: + return 1.0 if t >= t_start else 0.0 + if t <= t_start: + return 0.0 + if t >= t_end: + return 1.0 + x = (t - t_start) / (t_end - t_start) + return x * x * (3.0 - 2.0 * x) + + +def _irregular_fluct(t: float, seed: float = 0.0) -> float: + """Irregular fluctuation via superposition of incommensurate frequencies. + + Returns a value centred around 1.0 with ~+-15% variation. + The use of irrational-ratio periods avoids visible periodicity. + """ + s = seed + f1 = 0.06 * math.sin(2 * math.pi * t / 173.0 + s) + f2 = 0.05 * math.sin(2 * math.pi * t / 97.3 + s * 2.3) + f3 = 0.04 * math.sin(2 * math.pi * t / 251.7 + s * 0.7) + f4 = 0.03 * math.sin(2 * math.pi * t / 41.9 + s * 4.1) + f5 = 0.02 * math.sin(2 * math.pi * t / 317.3 + s * 1.9) + return 1.0 + f1 + f2 + f3 + f4 + f5 + + +def pv_profile_kw(t: float, peak_kw: float, site_idx: int = 0) -> float: + """Solar PV output (kW per phase) with per-site cloud patterns. + + Each site has a distinct profile with different cloud events, trends, and + fluctuation seeds so the curves are visually distinguishable. + """ + T = T_TOTAL_S + if site_idx == 0: + # Site 0: Declining afternoon output with two cloud dips + trend = 0.85 - 0.30 * (t / T) + cloud = 1.0 + cloud -= 0.55 * _smooth_bump(t, 600, 120) + cloud -= 0.40 * _smooth_bump(t, 2100, 180) + fluct = _irregular_fluct(t, seed=0.3) + return max(0.0, peak_kw * trend * max(cloud, 0.05) * fluct) + elif site_idx == 1: + # Site 1: Midday peak with passing cloud bank + ramp = 0.55 + 0.40 * _smooth_bump(t, 1200, 900) + cloud = 1.0 + cloud -= 0.60 * _smooth_bump(t, 1680, 240) + cloud -= 0.25 * _smooth_bump(t, 2400, 150) + fluct = _irregular_fluct(t, seed=2.1) + return max(0.0, peak_kw * ramp * max(cloud, 0.05) * fluct) + else: + # Site 2+: Morning ramp, sustained high, late cloud event + # Seed varies by site_idx so additional PV sites get distinct curves + ramp = 0.30 + 0.65 * min(1.0, t / 900.0) + cloud = 1.0 + cloud -= 0.70 * _smooth_bump(t, 2700, 300) + cloud -= 0.30 * _smooth_bump(t, 1200, 100) + fluct = _irregular_fluct(t, seed=2.0 + site_idx * 3.7) + return max(0.0, peak_kw * ramp * max(cloud, 0.05) * fluct) + + +def load_profile_kw(t: float, peak_kw: float, site_idx: int = 0) -> float: + fluct_period = 130.0 + site_idx * 37 + fluct = 1.0 + 0.06 * math.sin(2 * math.pi * t / fluct_period + site_idx * 1.4) + + if site_idx == 0: + base = 0.15 + 0.85 * _smooth_bump(t, 2280, 1400) + surge = 0.20 * _smooth_bump(t, 2280, 180) + return max(0.0, peak_kw * (base + surge) * fluct) + elif site_idx == 1: + base = 0.10 + base += 0.50 * _smooth_bump(t, 1500, 600) + base += 0.80 * _smooth_bump(t, 2880, 500) + return max(0.0, peak_kw * base * fluct) + elif site_idx == 2: + base = 0.80 - 0.55 * _smooth_bump(t, 1800, 1200) + surge = 0.70 * _smooth_bump(t, 2520, 400) + return max(0.0, peak_kw * (base + surge) * fluct) + elif site_idx == 3: + base = 0.10 + 0.90 * _smooth_bump(t, 3120, 800) + return max(0.0, peak_kw * base * fluct) + else: + base = 0.10 + base += 0.60 * _smooth_bump(t, 1080, 300) + base += 0.75 * _smooth_bump(t, 2100, 350) + base += 0.90 * _smooth_bump(t, 3300, 300) + return max(0.0, peak_kw * base * fluct) + + +# ── Randomized profile generators ──────────────────────────────────────────── +# +# These produce 1-hour episodes (T_TOTAL_S ≈ 3600 s). Unlike the legacy +# pv_profile_kw / load_profile_kw which were shaped like multi-hour curves +# (sunrise→noon→sunset, residential demand peak), these are tuned for the +# 1-hour episode regime where: +# - PV power is roughly constant (sun position barely moves) with +# occasional cloud dips as the dominant variability source. +# - TVL has multiple plausible shapes (flat, increasing, decreasing, +# peaked, valley) depending on the load type. The shape is chosen by +# params["shape"] and the parameters drive its details. +# +# The params dict is generated once per scenario in train_ppo.randomize_scenario +# (broad mode) and stored on PVSystemSpec.profile_params / TVLSpec.profile_params +# so the simulator can replay deterministically. + + +def pv_profile_random(t: float, peak_kw: float, params: dict) -> float: + """Multi-shape PV profile with random cloud events (1-hour episode). + + params["shape"] picks the envelope: + "flat" constant baseline 0.75-0.95 + "rising_falling" smooth bump from low_baseline up to high_baseline and back + "morning_ramp" low → high over a short ramp window, sustained high after + "afternoon_decline" sustained high then ramp down at the end + "midday_dip" high baseline with a substantial mid-episode dip + + Envelope-specific params (all optional, sensible defaults below): + flat: baseline + rising_falling: low_baseline, high_baseline, peak_t, half_width + morning_ramp: low_baseline, high_baseline, ramp_end + afternoon_decline: low_baseline, high_baseline, ramp_start + midday_dip: high_baseline, dip_t, dip_half_width, dip_depth + + Common keys (applied on top of any envelope): + clouds: list of (t_center_s, half_width_s, depth_frac) extra cloud-dip events + noise_amp: amplitude of small irrational-frequency fluctuations (0.0-0.1) + noise_seed: seed for the fluctuation phase + """ + shape = params.get("shape", "flat") + T = float(T_TOTAL_S) + + if shape == "flat": + env = float(params.get("baseline", 0.85)) + elif shape == "rising_falling": + lo = float(params.get("low_baseline", 0.50)) + hi = float(params.get("high_baseline", 0.95)) + peak_t = float(params.get("peak_t", T / 2)) + half_width = float(params.get("half_width", 1200.0)) + env = lo + (hi - lo) * _smooth_bump(t, peak_t, half_width) + elif shape == "morning_ramp": + lo = float(params.get("low_baseline", 0.25)) + hi = float(params.get("high_baseline", 0.75)) + ramp_start = float(params.get("ramp_start", 100.0)) + ramp_end = float(params.get("ramp_end", 1200.0)) + env = lo + (hi - lo) * _smoothstep(t, ramp_start, ramp_end) + elif shape == "afternoon_decline": + lo = float(params.get("low_baseline", 0.25)) + hi = float(params.get("high_baseline", 0.75)) + ramp_start = float(params.get("ramp_start", 2400.0)) + ramp_end = float(params.get("ramp_end", T - 100.0)) + env = hi - (hi - lo) * _smoothstep(t, ramp_start, ramp_end) + elif shape == "midday_dip": + hi = float(params.get("high_baseline", 0.90)) + dip_t = float(params.get("dip_t", T / 2)) + dip_half_width = float(params.get("dip_half_width", 700.0)) + dip_depth = float(params.get("dip_depth", 0.55)) + env = hi - dip_depth * _smooth_bump(t, dip_t, dip_half_width) + else: + env = 0.85 # safe fallback + + # Apply cloud events (independent of envelope shape) + for tc, hw, depth in params.get("clouds", ()): + env -= float(depth) * _smooth_bump(t, float(tc), float(hw)) + + # Floor PV at 15% of nameplate even during heavy clouds — diffuse + # irradiance keeps real PV output above zero in practice. + env = max(0.15, env) + noise_amp = float(params.get("noise_amp", 0.0)) + if noise_amp > 0: + f = _irregular_fluct(t, seed=float(params.get("noise_seed", 0.0))) + env *= 1.0 + (noise_amp / 0.20) * (f - 1.0) + return max(0.0, peak_kw * env) + + +def tvl_profile_random(t: float, peak_kw: float, params: dict) -> float: + """Multi-shape TVL profile (1-hour episode). + + params: + shape: one of "flat", "increasing", "decreasing", "peaked", "valley" + ... shape-specific keys (see body below) ... + noise_amp: amplitude of small fluctuations (0.0-0.08) + noise_seed: seed for the fluctuation phase + """ + shape = params.get("shape", "peaked") + T = float(T_TOTAL_S) + if shape == "flat": + base = float(params.get("level", 0.7)) + elif shape == "increasing": + lo = float(params.get("lo", 0.2)) + hi = float(params.get("hi", 0.9)) + base = lo + (hi - lo) * min(1.0, max(0.0, t / T)) + elif shape == "decreasing": + lo = float(params.get("lo", 0.2)) + hi = float(params.get("hi", 0.9)) + base = hi - (hi - lo) * min(1.0, max(0.0, t / T)) + elif shape == "peaked": + peak_t = float(params.get("peak_t", T / 2)) + peak_w = float(params.get("peak_w", 1400.0)) + baseline = float(params.get("baseline", 0.15)) + amp = float(params.get("amp", 0.85)) + base = baseline + amp * _smooth_bump(t, peak_t, peak_w) + elif shape == "valley": + valley_t = float(params.get("valley_t", T / 2)) + valley_w = float(params.get("valley_w", 1200.0)) + high = float(params.get("high", 0.85)) + depth = float(params.get("depth", 0.55)) + base = high - depth * _smooth_bump(t, valley_t, valley_w) + else: + # Unknown shape -> safe default + base = 0.5 + base = max(0.0, base) + noise_amp = float(params.get("noise_amp", 0.0)) + if noise_amp > 0: + f = _irregular_fluct(t, seed=float(params.get("noise_seed", 0.0))) + base *= 1.0 + (noise_amp / 0.20) * (f - 1.0) + return max(0.0, peak_kw * base) + + +def load_csv_profile(csv_path: Path) -> tuple[np.ndarray, np.ndarray]: + data = np.loadtxt(csv_path, delimiter=",", skiprows=1) + return data[:, 0], data[:, 1] + + +def eval_profile( + t, + *, + peak_kw, + csv_data, + profile_fn, + site_idx, + peak_t_shift_s: float = 0.0, + time_warp: float = 1.0, + profile_kind: str = "default", + profile_params: dict | None = None, +): + """Evaluate a PV/TVL profile at simulated time ``t``. + + Dispatch order: + 1. ``profile_kind="random_flat"`` + ``profile_params`` -> pv_profile_random + 2. ``profile_kind="random_shape"`` + ``profile_params`` -> tvl_profile_random + 3. ``csv_data`` not None -> CSV interpolation + 4. ``profile_fn`` (legacy analytical pv_profile_kw / load_profile_kw) + + The legacy path applies the optional ``peak_t_shift_s`` / ``time_warp`` + transform: ``t_eff = (t - peak_t_shift_s) / time_warp``. The randomized + kinds bake their shape into profile_params and ignore these. + """ + if profile_kind == "random_flat" and profile_params is not None: + return pv_profile_random(t, peak_kw, profile_params) + if profile_kind == "random_shape" and profile_params is not None: + return tvl_profile_random(t, peak_kw, profile_params) + if time_warp <= 0: + time_warp = 1.0 + t_eff = (t - peak_t_shift_s) / time_warp + if csv_data is not None: + return float(np.interp(t_eff, csv_data[0], csv_data[1])) + return profile_fn(t_eff, peak_kw, site_idx) + + +# ── Scenario Grid ──────────────────────────────────────────────────────────── + + +class ScenarioOpenDSSGrid(OpenDSSGrid): + """OpenDSSGrid with PV systems and external loads at arbitrary buses.""" + + def __init__( + self, *, pv_systems=None, time_varying_loads=None, source_pu=None, constant_pv: bool = False, **kwargs + ): + super().__init__(**kwargs) + self._pv_specs = list(pv_systems or []) + self._load_specs = list(time_varying_loads or []) + self._source_pu = source_pu + self._constant_pv = constant_pv + + self._pv_csv = [load_csv_profile(s.csv_path) if s.csv_path else None for s in self._pv_specs] + self._load_csv = [load_csv_profile(s.csv_path) if s.csv_path else None for s in self._load_specs] + self._pv_load_names = [(f"PV_{i}_A", f"PV_{i}_B", f"PV_{i}_C") for i in range(len(self._pv_specs))] + self._ext_load_names = [ + (f"ExtLoad_{i}_A", f"ExtLoad_{i}_B", f"ExtLoad_{i}_C") for i in range(len(self._load_specs)) + ] + + def _init_dss(self) -> None: + super()._init_dss() + from openg2g.grid.opendss import dss + + if self._source_pu is not None: + dss.Text.Command(f"Edit Vsource.source pu={self._source_pu}") + + for i, spec in enumerate(self._pv_specs): + kv_ln = spec.bus_kv / math.sqrt(3.0) + for ph, name in zip((1, 2, 3), self._pv_load_names[i], strict=False): + dss.Text.Command( + f"New Load.{name} bus1={spec.bus}.{ph} phases=1 " + f"conn=wye kV={kv_ln:.6f} kW=0 kvar=0 model=1 vminpu=0.85" + ) + + for i, spec in enumerate(self._load_specs): + kv_ln = spec.bus_kv / math.sqrt(3.0) + for ph, name in zip((1, 2, 3), self._ext_load_names[i], strict=False): + dss.Text.Command( + f"New Load.{name} bus1={spec.bus}.{ph} phases=1 " + f"conn=wye kV={kv_ln:.6f} kW=0 kvar=0 model=1 vminpu=0.85" + ) + + def step(self, clock, power_samples_w, events): + from openg2g.grid.opendss import dss + + for i, spec in enumerate(self._pv_specs): + if self._constant_pv: + kw = spec.peak_kw + else: + kw = eval_profile( + clock.time_s, + peak_kw=spec.peak_kw, + csv_data=self._pv_csv[i], + profile_fn=pv_profile_kw, + site_idx=i, + peak_t_shift_s=getattr(spec, "peak_t_shift_s", 0.0), + time_warp=getattr(spec, "time_warp", 1.0), + profile_kind=getattr(spec, "profile_kind", "default"), + profile_params=getattr(spec, "profile_params", None), + ) + pf = max(min(spec.power_factor, 0.999999), 1e-6) + kvar = kw * math.tan(math.acos(pf)) + for name in self._pv_load_names[i]: + dss.Loads.Name(name) + dss.Loads.kW(-kw) + dss.Loads.kvar(-kvar) + + for i, spec in enumerate(self._load_specs): + kw = eval_profile( + clock.time_s, + peak_kw=spec.peak_kw, + csv_data=self._load_csv[i], + profile_fn=load_profile_kw, + site_idx=i, + peak_t_shift_s=getattr(spec, "peak_t_shift_s", 0.0), + time_warp=getattr(spec, "time_warp", 1.0), + profile_kind=getattr(spec, "profile_kind", "default"), + profile_params=getattr(spec, "profile_params", None), + ) + pf = max(min(spec.power_factor, 0.999999), 1e-6) + kvar = kw * math.tan(math.acos(pf)) + for name in self._ext_load_names[i]: + dss.Loads.Name(name) + dss.Loads.kW(kw) + dss.Loads.kvar(kvar) + + return super().step(clock, power_samples_w, events) + + +# ── Bus discovery ──────────────────────────────────────────────────────────── + + +def discover_candidate_buses( + case_dir: Path, + master_file: str, + target_bus_kv: float, + exclude: set[str], +) -> list[str]: + """Discover all 3-phase buses at the target voltage level.""" + from opendssdirect import dss + + dss.Basic.ClearAll() + master_path = str(Path(case_dir) / master_file) + dss.Text.Command(f'Compile "{master_path}"') + dss.Solution.SolveNoControl() + + target_kv_ln = target_bus_kv / math.sqrt(3.0) + tolerance = 0.05 * target_kv_ln + + bus_phases: dict[str, set[int]] = {} + for name in dss.Circuit.AllNodeNames(): + parts = name.split(".") + bus = parts[0].lower() + phase = int(parts[1]) if len(parts) > 1 else 0 + if bus not in bus_phases: + bus_phases[bus] = set() + bus_phases[bus].add(phase) + + exclude_lower = {b.lower() for b in exclude} + candidates = [] + + for bus_name in dss.Circuit.AllBusNames(): + if bus_name.lower() in exclude_lower: + continue + phases = bus_phases.get(bus_name.lower(), set()) + if not {1, 2, 3}.issubset(phases): + continue + dss.Circuit.SetActiveBus(bus_name) + kv_base = dss.Bus.kVBase() + if abs(kv_base - target_kv_ln) <= tolerance: + candidates.append(bus_name) + + return sorted(candidates) + +# ══════════════════════════════════════════════════════════════════════════════ # 1-D SWEEP (single DC site) +# ══════════════════════════════════════════════════════════════════════════════ MAX_TAP_ITERATIONS = 20 +def extract_all_voltages(grid_states, exclude_buses=("rg60", "sourcebus")): + exclude = {b.lower() for b in exclude_buses} + result = {} + for gs in grid_states: + for bus in gs.voltages.buses(): + if bus.lower() in exclude: + continue + if bus not in result: + result[bus] = {"a": [], "b": [], "c": []} + pv = gs.voltages[bus] + result[bus]["a"].append(pv.a) + result[bus]["b"].append(pv.b) + result[bus]["c"].append(pv.c) + return result + + +def find_violations(voltages_dict, v_min=0.95, v_max=1.05): + violations = [] + for bus, phases in voltages_dict.items(): + for phase_name, values in phases.items(): + arr = np.array(values) + valid = arr[~np.isnan(arr)] + if len(valid) == 0: + continue + vmin = float(np.min(valid)) + vmax = float(np.max(valid)) + if vmin < v_min: + violations.append((bus, phase_name, "under", vmin, v_min - vmin)) + if vmax > v_max: + violations.append((bus, phase_name, "over", vmax, vmax - v_max)) + return violations + + def optimize_taps_for_bus( *, sys: dict, dc_bus: str, + bus_kv: float, base_kw_per_phase: float, connection_type: str, inference_data: InferenceData, @@ -192,7 +558,7 @@ def optimize_taps_for_bus( pv_systems: list | None = None, time_varying_loads: list | None = None, power_augmentation: PowerAugmentationConfig = POWER_AUG, - t_total_s: int = TOTAL_DURATION_S, + t_total_s: int = T_TOTAL_S, t_analysis_start: int = 0, v_min: float = V_MIN, v_max: float = V_MAX, @@ -229,19 +595,16 @@ def optimize_taps_for_bus( total_gpu_capacity=total_gpu_capacity, ) - grid = OpenDSSGrid( + grid = ScenarioOpenDSSGrid( + pv_systems=pv_systems or [], + time_varying_loads=time_varying_loads or [], + source_pu=sys["source_pu"], dss_case_dir=sys["dss_case_dir"], dss_master_file=sys["dss_master_file"], - source_pu=sys["source_pu"], dt_s=dt_coarse, initial_tap_position=tap_pos, - exclude_buses=sys.get("exclude_buses", []), ) grid.attach_dc(dc, bus=dc_bus, connection_type=connection_type, power_factor=dc_config.power_factor) - for bus, gen in pv_systems or []: - grid.attach_generator(gen, bus=bus) - for bus, ld in time_varying_loads or []: - grid.attach_load(ld, bus=bus) ctrl = TapScheduleController(schedule=TapSchedule(()), dt_s=dt_coarse) coord = Coordinator( @@ -304,6 +667,7 @@ def optimize_taps_multiscenario( *, sys: dict, dc_bus: str, + bus_kv: float, base_kw_per_phase: float, connection_type: str, inference_data: InferenceData, @@ -378,6 +742,7 @@ def _round_up(t: int) -> int: optimal = optimize_taps_for_bus( sys=sys, dc_bus=dc_bus, + bus_kv=bus_kv, base_kw_per_phase=base_kw_per_phase, connection_type=connection_type, inference_data=inference_data, @@ -404,6 +769,7 @@ def run_case_1d( *, sys: dict, dc_bus: str, + bus_kv: float, base_kw_per_phase: float, case_name: str, inference_data: InferenceData, @@ -449,19 +815,16 @@ def run_case_1d( total_gpu_capacity=total_gpu_capacity, ) - grid = OpenDSSGrid( + grid = ScenarioOpenDSSGrid( + pv_systems=pv_systems or [], + time_varying_loads=time_varying_loads or [], + source_pu=sys["source_pu"], dss_case_dir=sys["dss_case_dir"], dss_master_file=sys["dss_master_file"], - source_pu=sys["source_pu"], dt_s=dt_grid, initial_tap_position=initial_taps, - exclude_buses=sys.get("exclude_buses", []), ) grid.attach_dc(dc, bus=dc_bus, connection_type=connection_type, power_factor=dc_config.power_factor) - for bus, gen in pv_systems or []: - grid.attach_generator(gen, bus=bus) - for bus, ld in time_varying_loads or []: - grid.attach_load(ld, bus=bus) controllers: list = [] schedule = tap_schedule if tap_schedule else TapSchedule(()) @@ -472,10 +835,10 @@ def run_case_1d( ofo_ctrl = OFOBatchSizeController( all_models, datacenter=dc, + grid=grid, models=logistic_models, config=ofo_config, dt_s=dt_ctrl, - grid=grid, ) controllers.append(ofo_ctrl) @@ -487,12 +850,7 @@ def run_case_1d( ) log = coord.run() - stats = compute_allbus_voltage_stats( - log.grid_states, v_min=v_min, v_max=v_max, exclude_buses=tuple(sys.get("exclude_buses", ())) - ) - pstats = compute_performance_stats( - log.dc_states, itl_deadline_s_by_model={ms.model_label: ms.itl_deadline_s for ms in all_models} - ) + stats = compute_allbus_voltage_stats(log.grid_states, v_min=v_min, v_max=v_max) dc_kW = np.array([(s.power_w.a + s.power_w.b + s.power_w.c) / 3e3 for s in log.dc_states]) avg_power = float(dc_kW.mean()) if dc_kW.size > 0 else 0.0 @@ -503,11 +861,15 @@ def run_case_1d( "worst_vmax": stats.worst_vmax, "integral_violation_pu_s": stats.integral_violation_pu_s, "avg_power_kw_per_phase": avg_power, - "mean_throughput_tps": pstats.mean_throughput_tps, - "itl_deadline_fraction": pstats.itl_deadline_fraction, } if save_dir is not None: + from plot_all_figures import ( + extract_per_model_timeseries, + plot_allbus_voltages_per_phase, + plot_model_timeseries_4panel, + ) + save_dir.mkdir(parents=True, exist_ok=True) time_s = np.array(log.time_s) plot_allbus_voltages_per_phase( @@ -516,11 +878,13 @@ def run_case_1d( save_dir=save_dir, v_min=v_min, v_max=v_max, - title_template=f"DC@{dc_bus} {case_name} -- Voltage (Phase {{label}})", + title_template=f"DC@{dc_bus} {case_name} — Voltage (Phase {{label}})", ) if use_ofo: + per_model = extract_per_model_timeseries(log.dc_states) plot_model_timeseries_4panel( - log.dc_states, + per_model.time_s, + per_model, model_labels=[m.model_label for m in all_models], regime_shading=False, save_path=save_dir / "OFO_results.png", @@ -530,6 +894,8 @@ def run_case_1d( def _plot_bus_comparison(all_rows: list[dict], save_path: Path) -> None: + import matplotlib.pyplot as plt + buses = [r["dc_bus"] for r in all_rows] cases = [ ("baseline_no_tap", "Baseline (no tap)"), @@ -574,7 +940,7 @@ def main_1d( *, sys: dict, system: str, - dc_site: _DCSiteConfig, + dc_site: DCSite, all_models: tuple[InferenceModelSpec, ...], inference_data: InferenceData, training_trace: TrainingTrace, @@ -598,7 +964,7 @@ def main_1d( ) -> None: """1-D sweep: single DC site swept across candidate buses.""" if replica_schedules is None: - replica_schedules = {md.spec.model_label: s for md, s in dc_site.models} + replica_schedules = {md.spec.model_label: sched for md, sched in dc_site.models} site_gpu_capacity = dc_site.total_gpu_capacity dt_dc = DT_DC @@ -620,11 +986,11 @@ def main_1d( candidate_buses = buses logger.info("Using user-specified buses: %s", candidate_buses) else: - logger.info("Discovering candidate 3-phase buses at %.2f kV...", sys["bus_kv"]) + logger.info("Discovering candidate 3-phase buses at %.2f kV...", dc_site.bus_kv) candidate_buses = discover_candidate_buses( sys["dss_case_dir"], sys["dss_master_file"], - sys["bus_kv"], + dc_site.bus_kv, exclude=set(sys["exclude_buses"]), ) logger.info("Found %d candidate buses: %s", len(candidate_buses), candidate_buses) @@ -649,6 +1015,7 @@ def main_1d( taps = optimize_taps_multiscenario( sys=sys, dc_bus=dc_bus, + bus_kv=dc_site.bus_kv, base_kw_per_phase=dc_site.base_kw_per_phase, connection_type=dc_site.connection_type, inference_data=inference_data, @@ -729,12 +1096,12 @@ def main_1d( ("ofo_tap_change", True, True), ] - bus_results: dict[str, str | float] = {"dc_bus": dc_bus} + bus_results = {"dc_bus": dc_bus} for case_name, use_ofo, use_tap_change in cases: case_idx += 1 logger.info("") - logger.info("[%d/%d] Bus %s -- %s", case_idx, total_cases, dc_bus, case_name) + logger.info("[%d/%d] Bus %s — %s", case_idx, total_cases, dc_bus, case_name) case_save_dir = save_dir / f"bus_{dc_bus}" / case_name @@ -742,6 +1109,7 @@ def main_1d( result = run_case_1d( sys=sys, dc_bus=dc_bus, + bus_kv=dc_site.bus_kv, base_kw_per_phase=dc_site.base_kw_per_phase, case_name=case_name, inference_data=inference_data, @@ -777,7 +1145,7 @@ def main_1d( for k, v in result.items(): bus_results[f"{case_name}_{k}"] = v except Exception as e: - logger.error(" Bus %s -- %s FAILED: %s", dc_bus, case_name, e) + logger.error(" Bus %s — %s FAILED: %s", dc_bus, case_name, e) for k in [ "violation_time_s", "worst_vmin", @@ -838,10 +1206,14 @@ def main_1d( logger.info("All outputs saved to: %s", save_dir) +# ══════════════════════════════════════════════════════════════════════════════ # 2-D SWEEP (multiple DC sites) +# ══════════════════════════════════════════════════════════════════════════════ def _plot_heatmaps(df: pd.DataFrame, buses: list[str], save_dir: Path) -> None: + import matplotlib.pyplot as plt + n = len(buses) bus_idx = {b: i for i, b in enumerate(buses)} @@ -893,7 +1265,7 @@ def main_2d( *, sys: dict, system: str, - dc_sites: dict[str, _DCSiteConfig], + dc_sites: dict[str, DCSite], all_models: tuple[InferenceModelSpec, ...], inference_data: InferenceData, training_trace: TrainingTrace, @@ -932,7 +1304,7 @@ def main_2d( site_A = dc_sites[site_A_id] site_B = dc_sites[site_B_id] - target_kv = sys["bus_kv"] + target_kv = site_A.bus_kv logger.info("Discovering candidate 3-phase buses at %.1f kV...", target_kv) candidate_buses = discover_candidate_buses( @@ -970,14 +1342,14 @@ def main_2d( dc_config_A = DatacenterConfig(gpus_per_server=8, base_kw_per_phase=site_A.base_kw_per_phase) dc_config_B = DatacenterConfig(gpus_per_server=8, base_kw_per_phase=site_B.base_kw_per_phase) - rs_A = site_A.replica_schedules or {md.spec.model_label: s for md, s in site_A.models} - rs_B = site_B.replica_schedules or {md.spec.model_label: s for md, s in site_B.models} + schedules_A = {md.spec.model_label: sched for md, sched in site_A.models} + schedules_B = {md.spec.model_label: sched for md, sched in site_B.models} - wl_kwargs_A: dict = {"inference_data": inference_A, "replica_schedules": rs_A} + wl_kwargs_A: dict = {"inference_data": inference_A, "replica_schedules": schedules_A} if training is not None: wl_kwargs_A["training"] = training - wl_kwargs_B: dict = {"inference_data": inference_B, "replica_schedules": rs_B} + wl_kwargs_B: dict = {"inference_data": inference_B, "replica_schedules": schedules_B} if training is not None: wl_kwargs_B["training"] = training @@ -1000,44 +1372,40 @@ def main_2d( total_gpu_capacity=site_B.total_gpu_capacity, ) - grid = OpenDSSGrid( + datacenters = {site_A_id: dc_A, site_B_id: dc_B} + + grid = ScenarioOpenDSSGrid( + pv_systems=pv_systems or [], + time_varying_loads=time_varying_loads or [], + source_pu=sys["source_pu"], dss_case_dir=sys["dss_case_dir"], dss_master_file=sys["dss_master_file"], - source_pu=sys["source_pu"], dt_s=dt_grid, initial_tap_position=initial_taps, exclude_buses=exclude_buses, ) - grid.attach_dc( - dc_A, bus=bus_A, connection_type=site_A.connection_type, power_factor=dc_config_A.power_factor - ) - grid.attach_dc( - dc_B, bus=bus_B, connection_type=site_B.connection_type, power_factor=dc_config_B.power_factor - ) - for bus, gen in pv_systems or []: - grid.attach_generator(gen, bus=bus) - for bus, ld in time_varying_loads or []: - grid.attach_load(ld, bus=bus) + grid.attach_dc(dc_A, bus=bus_A, connection_type=site_A.connection_type, power_factor=dc_config_A.power_factor) + grid.attach_dc(dc_B, bus=bus_B, connection_type=site_B.connection_type, power_factor=dc_config_B.power_factor) ofo_A = OFOBatchSizeController( specs_A, datacenter=dc_A, + grid=grid, models=logistic_models, config=ofo_config, dt_s=dt_ctrl, - grid=grid, ) ofo_B = OFOBatchSizeController( specs_B, datacenter=dc_B, + grid=grid, models=logistic_models, config=ofo_config, dt_s=dt_ctrl, - grid=grid, ) coord = Coordinator( - datacenters=[dc_A, dc_B], + datacenters=list(datacenters.values()), grid=grid, controllers=[ofo_A, ofo_B], total_duration_s=total_duration_s, @@ -1053,10 +1421,6 @@ def main_2d( v_max=v_max, exclude_buses=exclude_buses, ) - pstats = compute_performance_stats( - log.dc_states, - itl_deadline_s_by_model={ms.model_label: ms.itl_deadline_s for ms in all_models}, - ) row = { "bus_A": bus_A, @@ -1065,18 +1429,14 @@ def main_2d( "integral_violation_pu_s": vstats.integral_violation_pu_s, "worst_vmin": vstats.worst_vmin, "worst_vmax": vstats.worst_vmax, - "mean_throughput_tps": pstats.mean_throughput_tps, - "itl_deadline_fraction": pstats.itl_deadline_fraction, "wall_time_s": wall_s, } rows.append(row) logger.info( - " -> viol=%.1fs integral=%.4f pu·s vmin=%.4f thpt=%.1f k tok/s itl_miss=%.2f%% wall=%.1fs", + " -> viol=%.1fs integral=%.4f pu·s vmin=%.4f wall=%.1fs", vstats.violation_time_s, vstats.integral_violation_pu_s, vstats.worst_vmin, - pstats.mean_throughput_tps / 1e3, - pstats.itl_deadline_fraction * 100.0, wall_s, ) @@ -1097,14 +1457,16 @@ def main_2d( logger.info("All outputs in: %s", save_dir) +# ══════════════════════════════════════════════════════════════════════════════ # ZONE-CONSTRAINED SWEEP (N DC sites, each in its own zone) +# ══════════════════════════════════════════════════════════════════════════════ def _run_multi_dc_case( *, bus_map: dict[str, str], site_ids: list[str], - dc_sites: dict[str, _DCSiteConfig], + dc_sites: dict[str, DCSite], site_inference_data: dict[str, InferenceData], sys: dict, training_trace: TrainingTrace, @@ -1141,28 +1503,27 @@ def _run_multi_dc_case( gpus_per_server=8, base_kw_per_phase=site.base_kw_per_phase, ) - rs = site.replica_schedules or {md.spec.model_label: s for md, s in site.models} - wl_kwargs: dict = {"inference_data": site_inference_data[sid]} - + # Schedules combine the (initial, ramps) pair on each (md, sched). + replica_schedules: dict[str, ReplicaSchedule] = { + md.spec.model_label: sched for md, sched in site.models + } if stress_test: # Immediate per-model ramps to fill total GPU capacity - current_gpus = sum(s.initial * md.spec.gpus_per_replica for md, s in site.models) + current_gpus = sum(sched.initial * md.spec.gpus_per_replica for md, sched in site.models) total_cap = site.total_gpu_capacity or current_gpus scale = total_cap / current_gpus if current_gpus > 0 else 1.0 if scale > 1.0: - stress_rs = {} - for md, s in site.models: - abs_target = round(scale * s.initial) - stress_rs[md.spec.model_label] = ReplicaSchedule(initial=s.initial).ramp_to( - abs_target, t_start=0, t_end=1 + replica_schedules = { + md.spec.model_label: ReplicaSchedule(initial=sched.initial).ramp_to( + round(scale * sched.initial), t_start=0, t_end=1, ) - wl_kwargs["replica_schedules"] = stress_rs - else: - wl_kwargs["replica_schedules"] = rs - else: - wl_kwargs["replica_schedules"] = rs - if training is not None: - wl_kwargs["training"] = training + for md, sched in site.models + } + elif training is not None: + pass # training handled below + wl_kwargs: dict = {"inference_data": site_inference_data[sid], "replica_schedules": replica_schedules} + if not stress_test and training is not None: + wl_kwargs["training"] = training dc = OfflineDatacenter( dc_config, @@ -1175,49 +1536,43 @@ def _run_multi_dc_case( ) datacenters[sid] = dc - # Build grid - grid = OpenDSSGrid( + # Build grid then attach DCs (master's imperative pattern). + grid = ScenarioOpenDSSGrid( + pv_systems=pv_systems or [], + time_varying_loads=[] if stress_test else (time_varying_loads or []), + source_pu=sys["source_pu"], + constant_pv=stress_test, dss_case_dir=sys["dss_case_dir"], dss_master_file=sys["dss_master_file"], - source_pu=sys["source_pu"], dt_s=dt_grid, initial_tap_position=initial_taps, exclude_buses=exclude_buses, ) + dc_pf = DatacenterConfig().power_factor for sid in site_ids: grid.attach_dc( datacenters[sid], bus=bus_map[sid], connection_type=dc_sites[sid].connection_type, - power_factor=DatacenterConfig().power_factor, + power_factor=dc_pf, ) - if stress_test: - # Constant PV at peak power, no time-varying loads - for bus, gen in pv_systems or []: - grid.attach_generator(ConstantGenerator(gen._peak_kw), bus=bus) - else: - for bus, gen in pv_systems or []: - grid.attach_generator(gen, bus=bus) - for bus, ld in time_varying_loads or []: - grid.attach_load(ld, bus=bus) # Build OFO controllers - dc_list = list(datacenters.values()) controllers = [] for sid in site_ids: site_specs = tuple(md.spec for md, _ in dc_sites[sid].models) ofo_ctrl = OFOBatchSizeController( site_specs, datacenter=datacenters[sid], + grid=grid, models=logistic_models, config=ofo_config, dt_s=dt_ctrl, - grid=grid, ) controllers.append(ofo_ctrl) coord = Coordinator( - datacenters=dc_list, + datacenters=list(datacenters.values()), grid=grid, controllers=controllers, total_duration_s=duration, @@ -1233,11 +1588,6 @@ def _run_multi_dc_case( v_max=v_max, exclude_buses=exclude_buses, ) - all_site_specs = tuple(ms for sid in site_ids for md, _ in dc_sites[sid].models for ms in (md.spec,)) - pstats = compute_performance_stats( - log.dc_states, - itl_deadline_s_by_model={ms.model_label: ms.itl_deadline_s for ms in all_site_specs}, - ) row: dict = {} for sid in site_ids: @@ -1248,8 +1598,6 @@ def _run_multi_dc_case( "integral_violation_pu_s": vstats.integral_violation_pu_s, "worst_vmin": vstats.worst_vmin, "worst_vmax": vstats.worst_vmax, - "mean_throughput_tps": pstats.mean_throughput_tps, - "itl_deadline_fraction": pstats.itl_deadline_fraction, "wall_time_s": wall_s, } ) @@ -1291,6 +1639,8 @@ def _plot_zoned_summary( suffix: str = "", ) -> None: """Plot bar chart of zone-constrained sweep results.""" + import matplotlib.pyplot as plt + n_show = min(20, len(df)) df_top = df.head(n_show) @@ -1303,13 +1653,13 @@ def _plot_zoned_summary( ax1.bar(x, df_top["violation_time_s"].values, color="steelblue") ax1.set_ylabel("Violation Time (s)") title_tag = f" ({suffix})" if suffix else "" - ax1.set_title(f"{system.upper()} DC Location Sweep{title_tag} -- Violation Time (top {n_show})") + ax1.set_title(f"{system.upper()} DC Location Sweep{title_tag} — Violation Time (top {n_show})") ax1.set_xticks(x) ax1.set_xticklabels(labels, rotation=45, ha="right", fontsize=8) ax2.bar(x, df_top["integral_violation_pu_s"].values, color="coral") ax2.set_ylabel("Integral Violation (pu·s)") - ax2.set_title(f"{system.upper()} DC Location Sweep{title_tag} -- Integral Violation (top {n_show})") + ax2.set_title(f"{system.upper()} DC Location Sweep{title_tag} — Integral Violation (top {n_show})") ax2.set_xticks(x) ax2.set_xticklabels(labels, rotation=45, ha="right", fontsize=8) @@ -1330,6 +1680,8 @@ def _plot_screening_bars( system: str, ) -> None: """Plot per-zone screening results as grouped bar charts.""" + import matplotlib.pyplot as plt + n_zones = len(screening_results) fig, axes = plt.subplots(1, n_zones, figsize=(5 * n_zones, 5), squeeze=False) @@ -1366,6 +1718,8 @@ def _plot_refinement_iteration( Marks the previous best bus (from the prior iteration or Phase 2) with a diamond marker and the new best bus (from this iteration) with a star. """ + import matplotlib.pyplot as plt + n_zones = len(site_ids) fig, axes = plt.subplots(2, n_zones, figsize=(5 * n_zones, 8), squeeze=False) @@ -1411,11 +1765,13 @@ def _plot_refinement_iteration( ) ax.set_ylabel(ylabel) - ax.set_title(f"Zone '{sid}' -- {title_suffix}") + ax.set_title(f"Zone '{sid}' — {title_suffix}") ax.set_xticks(x) ax.set_xticklabels(buses, rotation=45, ha="right", fontsize=9) # Add legend for markers + from matplotlib.lines import Line2D + legend_handles = [ Line2D([0], [0], marker="D", color="w", markerfacecolor="black", markersize=8, label="Previous best"), Line2D( @@ -1432,7 +1788,7 @@ def _plot_refinement_iteration( fig.legend(handles=legend_handles, loc="upper right", fontsize=10, framealpha=0.9) fig.suptitle( - f"{system.upper()} Phase 3 Refinement -- Iteration {iteration}", + f"{system.upper()} Phase 3 Refinement — Iteration {iteration}", fontsize=14, y=1.01, ) @@ -1447,7 +1803,7 @@ def main_zoned( *, sys: dict, system: str, - dc_sites: dict[str, _DCSiteConfig], + dc_sites: dict[str, DCSite], all_models: tuple[InferenceModelSpec, ...], inference_data: InferenceData, training_trace: TrainingTrace, @@ -1472,7 +1828,7 @@ def main_zoned( Three-phase design with different resolution/duration per phase: Phase 1 (Screening): Sweep each zone independently while holding other zones - at their default config buses. Uses coarse resolution (`dt_screening`, + at their default config buses. Uses coarse resolution (``dt_screening``, default 60 s) over the full simulation duration (typically 3600 s) for fast ranking. Keep top-K per zone. Phase 2 (Combination): Cartesian product of top-K per zone. Uses native @@ -1510,7 +1866,8 @@ def main_zoned( site_ids = list(dc_sites.keys()) # Discover all 3-phase candidate buses - target_kv = sys["bus_kv"] + ref_site = next(iter(dc_sites.values())) + target_kv = ref_site.bus_kv logger.info("Discovering candidate 3-phase buses at %.1f kV...", target_kv) all_candidate_buses = set( @@ -1591,10 +1948,10 @@ def main_zoned( # Default bus map from config default_bus_map = {sid: dc_sites[sid].bus for sid in site_ids} - # Phase 1: Screening + # ── Phase 1: Screening ─────────────────────────────────────────────────── logger.info("") logger.info("=" * 80) - logger.info("PHASE 1: SCREENING -- sweep each zone independently") + logger.info("PHASE 1: SCREENING — sweep each zone independently") logger.info("=" * 80) logger.info("Default bus map: %s", default_bus_map) @@ -1670,14 +2027,14 @@ def main_zoned( for sid, buses_list in top_k_per_zone.items(): logger.info(" %s top-%d: %s", sid, len(buses_list), buses_list) - # Phase 2: Combination (1-min stress test: constant PV + full DC) + # ── Phase 2: Combination (1-min stress test: constant PV + full DC) ────── # Duration must yield at least 2 grid steps; ensure >= 2 x dt_grid. min_duration = int(math.ceil(float(dt_grid) * 2)) phase2_duration_s = max(60, min_duration) logger.info("") logger.info("=" * 80) logger.info( - "PHASE 2: COMBINATION -- Cartesian product of top-%d per zone " + "PHASE 2: COMBINATION — Cartesian product of top-%d per zone " "(%ds stress test: constant PV + full DC capacity, no time-varying loads)", top_k, phase2_duration_s, @@ -1763,7 +2120,7 @@ def main_zoned( best_row["worst_vmax"], ) - # Phase 3: Refinement (optional) + # ── Phase 3: Refinement (optional) ─────────────────────────────────────── if not refine: logger.info("") logger.info("Phase 3 (refinement) skipped. Use --refine to enable.") @@ -1773,7 +2130,7 @@ def main_zoned( logger.info("") logger.info("=" * 80) - logger.info("PHASE 3: REFINEMENT -- re-sweep each zone from Phase 2 best") + logger.info("PHASE 3: REFINEMENT — re-sweep each zone from Phase 2 best") logger.info("=" * 80) current_best = dict(best_bus_map) @@ -1910,10 +2267,12 @@ def main_zoned( logger.info("All outputs in: %s", save_dir) +# ══════════════════════════════════════════════════════════════════════════════ # Per-system experiment definitions +# ══════════════════════════════════════════════════════════════════════════════ -def setup_ieee13(sys_const, training_trace): +def _experiment_ieee13(sys_const, training_trace): """IEEE 13-bus: single DC at bus 671 with training overlay.""" models = ( deploy("Llama-3.1-8B", 720), @@ -1923,22 +2282,29 @@ def setup_ieee13(sys_const, training_trace): deploy("Qwen3-235B-A22B", 210), ) - replica_schedules = { - "Llama-3.1-8B": ReplicaSchedule(initial=720).ramp_to(360, t_start=2500, t_end=3000), - "Llama-3.1-70B": ReplicaSchedule(initial=180).ramp_to(90, t_start=2500, t_end=3000), - "Llama-3.1-405B": ReplicaSchedule(initial=90).ramp_to(45, t_start=2500, t_end=3000), - "Qwen3-30B-A3B": ReplicaSchedule(initial=480).ramp_to(240, t_start=2500, t_end=3000), - "Qwen3-235B-A22B": ReplicaSchedule(initial=210).ramp_to(105, t_start=2500, t_end=3000), + # Per-model ramps: target = round(fraction * initial) with fraction=0.2. + # Inject each ramp into its model's ReplicaSchedule via with_ramp() — + # replaces the old single OR-chained InferenceRampSchedule. + ramp_targets = { + "Llama-3.1-8B": 144, + "Llama-3.1-70B": 36, + "Llama-3.1-405B": 18, + "Qwen3-30B-A3B": 96, + "Qwen3-235B-A22B": 42, } + models = tuple( + with_ramp((md, sched), ramp_targets[md.spec.model_label], t_start=2500, t_end=3000) + for md, sched in models + ) dc_sites = { - "default": _DCSiteConfig( + "default": DCSite( bus="671", + bus_kv=sys_const["bus_kv"], base_kw_per_phase=500.0, models=models, seed=0, total_gpu_capacity=7200, - replica_schedules=replica_schedules, ), } @@ -1970,8 +2336,8 @@ def setup_ieee13(sys_const, training_trace): (3300, TapPosition(regulators={"creg1a": tap(10), "creg1b": tap(6), "creg1c": tap(10)})), ] - pv_systems = [("675", SyntheticPV(peak_kw=10.0))] - time_varying_loads = [("680", SyntheticLoad(peak_kw=10.0))] + pv_systems = [PVSystemSpec(bus="675", bus_kv=4.16, peak_kw=10.0)] + time_varying_loads = [TimeVaryingLoadSpec(bus="680", bus_kv=4.16, peak_kw=10.0)] return dict( dc_sites=dc_sites, @@ -1986,7 +2352,7 @@ def setup_ieee13(sys_const, training_trace): ) -def setup_ieee34(sys_const, training_trace): +def _experiment_ieee34(sys_const, training_trace): """IEEE 34-bus: two DC sites (upstream + downstream).""" upstream_models = ( deploy("Llama-3.1-8B", 720), @@ -1999,15 +2365,17 @@ def setup_ieee34(sys_const, training_trace): ) dc_sites = { - "upstream": _DCSiteConfig( + "upstream": DCSite( bus="850", + bus_kv=sys_const["bus_kv"], base_kw_per_phase=120.0, models=upstream_models, seed=0, total_gpu_capacity=520, ), - "downstream": _DCSiteConfig( + "downstream": DCSite( bus="834", + bus_kv=sys_const["bus_kv"], base_kw_per_phase=80.0, models=downstream_models, seed=42, @@ -2029,15 +2397,15 @@ def setup_ieee34(sys_const, training_trace): ) pv_systems = [ - ("848", SyntheticPV(peak_kw=130.0, site_idx=0)), - ("830", SyntheticPV(peak_kw=65.0, site_idx=1)), + PVSystemSpec(bus="848", bus_kv=24.9, peak_kw=130.0), + PVSystemSpec(bus="830", bus_kv=24.9, peak_kw=65.0), ] time_varying_loads = [ - ("860", SyntheticLoad(peak_kw=80.0, site_idx=0)), - ("844", SyntheticLoad(peak_kw=120.0, site_idx=1)), - ("840", SyntheticLoad(peak_kw=60.0, site_idx=2)), - ("858", SyntheticLoad(peak_kw=50.0, site_idx=3)), - ("854", SyntheticLoad(peak_kw=40.0, site_idx=4)), + TimeVaryingLoadSpec(bus="860", bus_kv=24.9, peak_kw=80.0), + TimeVaryingLoadSpec(bus="844", bus_kv=24.9, peak_kw=120.0), + TimeVaryingLoadSpec(bus="840", bus_kv=24.9, peak_kw=60.0), + TimeVaryingLoadSpec(bus="858", bus_kv=24.9, peak_kw=50.0), + TimeVaryingLoadSpec(bus="854", bus_kv=24.9, peak_kw=40.0), ] return dict( @@ -2049,49 +2417,47 @@ def setup_ieee34(sys_const, training_trace): ) -def setup_ieee123(sys_const, training_trace): +def _experiment_ieee123(sys_const, training_trace): """IEEE 123-bus: four DC zones with per-site ramps.""" dc_sites = { - "z1_sw": _DCSiteConfig( + "z1_sw": DCSite( bus="8", + bus_kv=sys_const["bus_kv"], base_kw_per_phase=310.0, - models=(deploy("Llama-3.1-8B", 120),), + # 8B: round(1.5 * 120) = 180 + models=(with_ramp(deploy("Llama-3.1-8B", 120), 180, t_start=500, t_end=1000),), seed=0, total_gpu_capacity=120, - replica_schedules={ - "Llama-3.1-8B": ReplicaSchedule(initial=120).ramp_to(180, t_start=500, t_end=1000), - }, ), - "z2_nw": _DCSiteConfig( + "z2_nw": DCSite( bus="23", + bus_kv=sys_const["bus_kv"], base_kw_per_phase=265.0, - models=(deploy("Qwen3-30B-A3B", 80),), + # 30B: round(1.3 * 80) = 104 + models=(with_ramp(deploy("Qwen3-30B-A3B", 80), 104, t_start=1500, t_end=2500),), seed=17, total_gpu_capacity=160, - replica_schedules={ - "Qwen3-30B-A3B": ReplicaSchedule(initial=80).ramp_to(104, t_start=1500, t_end=2500), - }, ), - "z3_se": _DCSiteConfig( + "z3_se": DCSite( bus="60", + bus_kv=sys_const["bus_kv"], base_kw_per_phase=295.0, - models=(deploy("Llama-3.1-70B", 30), deploy("Llama-3.1-405B", 35)), + # 70B: round(1.5 * 30) = 45, 405B: round(1.5 * 35) = 52 + models=( + with_ramp(deploy("Llama-3.1-70B", 30), 45, t_start=700, t_end=1100), + with_ramp(deploy("Llama-3.1-405B", 35), 52, t_start=700, t_end=1100), + ), seed=34, total_gpu_capacity=400, - replica_schedules={ - "Llama-3.1-70B": ReplicaSchedule(initial=30).ramp_to(45, t_start=700, t_end=1100), - "Llama-3.1-405B": ReplicaSchedule(initial=35).ramp_to(52, t_start=700, t_end=1100), - }, ), - "z4_ne": _DCSiteConfig( + "z4_ne": DCSite( bus="105", + bus_kv=sys_const["bus_kv"], base_kw_per_phase=325.0, - models=(deploy("Qwen3-235B-A22B", 55),), + # 235B: round(0.5 * 55) = 27 + models=(with_ramp(deploy("Qwen3-235B-A22B", 55), 27, t_start=2000, t_end=2500),), seed=51, total_gpu_capacity=440, - replica_schedules={ - "Qwen3-235B-A22B": ReplicaSchedule(initial=55).ramp_to(27, t_start=2000, t_end=2500), - }, ), } @@ -2109,9 +2475,9 @@ def setup_ieee123(sys_const, training_trace): ) pv_systems = [ - ("1", SyntheticPV(peak_kw=333.3, site_idx=0)), - ("48", SyntheticPV(peak_kw=333.3, site_idx=1)), - ("99", SyntheticPV(peak_kw=333.3, site_idx=2)), + PVSystemSpec(bus="1", bus_kv=4.16, peak_kw=333.3), + PVSystemSpec(bus="48", bus_kv=4.16, peak_kw=333.3), + PVSystemSpec(bus="99", bus_kv=4.16, peak_kw=333.3), ] return dict( @@ -2124,14 +2490,16 @@ def setup_ieee123(sys_const, training_trace): ) -SETUPS = { - "ieee13": setup_ieee13, - "ieee34": setup_ieee34, - "ieee123": setup_ieee123, +_EXPERIMENTS = { + "ieee13": _experiment_ieee13, + "ieee34": _experiment_ieee34, + "ieee123": _experiment_ieee123, } +# ══════════════════════════════════════════════════════════════════════════════ # MAIN ENTRY POINT +# ══════════════════════════════════════════════════════════════════════════════ def main( @@ -2146,8 +2514,8 @@ def main( ) -> None: sys_const = SYSTEMS[system]() - # Load data pipeline - all_models = ALL_MODEL_SPECS + # Pre-load data using all model specs (per-spec content-addressed cache). + all_models = all_model_specs() logger.info("Loading data for %s...", system) inference_data = InferenceData.ensure( @@ -2164,12 +2532,12 @@ def main( ) # Build experiment config - setup_fn = SETUPS[system] - config = setup_fn(sys_const, training_trace) + experiment_fn = _EXPERIMENTS[system] + experiment = experiment_fn(sys_const, training_trace) - dc_sites = config["dc_sites"] + dc_sites = experiment["dc_sites"] n_sites = len(dc_sites) - has_zones = config.get("zones") is not None and len(config.get("zones", {})) > 0 + has_zones = experiment.get("zones") is not None and len(experiment.get("zones", {})) > 0 # Collect all model specs from DC sites for the experiment exp_all_models: list[InferenceModelSpec] = [] @@ -2185,7 +2553,7 @@ def main( logger.info( "Config has %d DC site(s) with %d zone(s) -> zone-constrained sweep", n_sites, - len(config["zones"]), + len(experiment["zones"]), ) main_zoned( sys=sys_const, @@ -2194,12 +2562,12 @@ def main( all_models=exp_all_models_tuple, inference_data=inference_data, training_trace=training_trace, - training=config.get("training"), + training=experiment.get("training"), logistic_models=logistic_models, - ofo_config=config["ofo_config"], - zones=config["zones"], - pv_systems=config.get("pv_systems"), - time_varying_loads=config.get("time_varying_loads"), + ofo_config=experiment["ofo_config"], + zones=experiment["zones"], + pv_systems=experiment.get("pv_systems"), + time_varying_loads=experiment.get("time_varying_loads"), dt_override=dt_override, dt_screening=dt_screening, output_dir=output_dir, @@ -2217,15 +2585,15 @@ def main( inference_data=inference_data, training_trace=training_trace, logistic_models=logistic_models, - ofo_config=config["ofo_config"], - training=config.get("training"), - replica_schedules=dc_site.replica_schedules, - tap_schedule_entries=config.get("tap_schedule_entries"), - training_t_start=config.get("training_t_start", 0), - training_t_end=config.get("training_t_end", 0), - ramp_t_end=config.get("ramp_t_end", 0), - pv_systems=config.get("pv_systems"), - time_varying_loads=config.get("time_varying_loads"), + ofo_config=experiment["ofo_config"], + training=experiment.get("training"), + replica_schedules={md.spec.model_label: sched for md, sched in dc_site.models}, + tap_schedule_entries=experiment.get("tap_schedule_entries"), + training_t_start=experiment.get("training_t_start", 0), + training_t_end=experiment.get("training_t_end", 0), + ramp_t_end=experiment.get("ramp_t_end", 0), + pv_systems=experiment.get("pv_systems"), + time_varying_loads=experiment.get("time_varying_loads"), buses=buses, dt_override=dt_override, output_dir=output_dir, @@ -2240,10 +2608,10 @@ def main( inference_data=inference_data, training_trace=training_trace, logistic_models=logistic_models, - ofo_config=config["ofo_config"], - training=config.get("training"), - pv_systems=config.get("pv_systems"), - time_varying_loads=config.get("time_varying_loads"), + ofo_config=experiment["ofo_config"], + training=experiment.get("training"), + pv_systems=experiment.get("pv_systems"), + time_varying_loads=experiment.get("time_varying_loads"), dt_override=dt_override, output_dir=output_dir, ) @@ -2256,29 +2624,22 @@ def main( @dataclass class Args: - """Command-line arguments. - - Attributes: - system: System name (ieee13, ieee34, ieee123). - buses: Comma-separated list of buses to test (overrides auto-discovery, 1-D only). - dt: Override time step for all components (e.g. '60' for 60s resolution). - dt_screening: Coarser time step for Phase 1 screening only (e.g. '60'). - Phases 2/3 use --dt or config. - top_k: Number of top candidates per zone to keep after screening - (zone-constrained mode). - refine: Enable Phase 3 iterative refinement (zone-constrained mode). - output_dir: Override output directory path. - log_level: Logging verbosity (DEBUG, INFO, WARNING). - """ - system: str = "ieee13" + """System name (ieee13, ieee34, ieee123).""" buses: str | None = None + """Comma-separated list of buses to test (overrides auto-discovery, 1-D only).""" dt: str | None = None + """Override time step for all components (e.g. '60' for 60s resolution).""" dt_screening: str | None = None + """Coarser time step for Phase 1 screening only (e.g. '60'). Phases 2/3 use --dt or config.""" top_k: int = 4 + """Number of top candidates per zone to keep after screening (zone-constrained mode).""" refine: bool = False + """Enable Phase 3 iterative refinement (zone-constrained mode).""" output_dir: str | None = None + """Override output directory path.""" log_level: str = "INFO" + """Logging verbosity (DEBUG, INFO, WARNING).""" args = tyro.cli(Args) diff --git a/examples/offline/systems.py b/examples/offline/systems.py index ef56ca0..d383ff6 100644 --- a/examples/offline/systems.py +++ b/examples/offline/systems.py @@ -1,30 +1,240 @@ -"""IEEE test feeder constants. +"""IEEE test feeder constants, shared simulation constants, and data pipeline helpers. Feeder-specific constants (DSS paths, regulator names, initial taps, excluded buses, source voltage, bus voltage levels) are defined here. - -Experiment-specific parameters (datacenter sizing, controller tuning, -workload scenarios, model specs, simulation defaults) belong in -`experiment.py` or inline in each example script. +Shared infrastructure (PV/load profile generators, ScenarioOpenDSSGrid, +experiment configuration factories, scenario randomisation) also lives here +so that application scripts never become implicit libraries. """ from __future__ import annotations +import math +from dataclasses import dataclass +from fractions import Fraction from pathlib import Path -from openg2g.grid.config import TapPosition +import numpy as np + +from openg2g.controller.ofo import OFOConfig +from openg2g.datacenter.config import ( + InferenceModelSpec, + ModelDeployment, + PowerAugmentationConfig, + ReplicaSchedule, + TrainingRun, +) +from openg2g.datacenter.workloads.inference import InferenceData +from openg2g.datacenter.workloads.training import TrainingTrace +from openg2g.grid.config import TapPosition, TapSchedule +from openg2g.grid.opendss import OpenDSSGrid -GRID_DATA_DIR = Path(__file__).resolve().parent.parent.parent / "data" / "grid" +PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent +GRID_DATA_DIR = PROJECT_ROOT / "data" / "grid" -TAP_STEP = 0.00625 # Standard 32-step regulator: +-10% in 32 steps +TAP_STEP = 0.00625 # Standard 32-step regulator: ±10% in 32 steps def tap(steps: int) -> float: - """Convert integer tap step to per-unit ratio. E.g., `tap(14)` -> 1.0875.""" + """Convert integer tap step to per-unit ratio. E.g., ``tap(14)`` → 1.0875.""" return 1.0 + steps * TAP_STEP -# IEEE test feeder constants +# ── Shared simulation constants ────────────────────────────────────────────── + +DT_DC = Fraction(1) +DT_GRID = Fraction(1) +DT_CTRL = Fraction(1) +V_MIN, V_MAX = 0.95, 1.05 +TOTAL_DURATION_S = 3600 +POWER_AUG = PowerAugmentationConfig(amplitude_scale_range=(0.98, 1.02), noise_fraction=0.005) + + +# ── DC site descriptor ─────────────────────────────────────────────────────── + + +@dataclass +class DCSite: + """One datacenter site for simulation setup. + + Attributes: + bus: Distribution bus where the datacenter is connected. + bus_kv: Bus voltage level (kV). + base_kw_per_phase: Constant base load per phase (kW). + total_gpu_capacity: Total physical GPUs installed at this site. + models: (deployment, replica_schedule) pairs at this site. Replica + counts and runtime ramps both live on the ReplicaSchedule + (matching master's unpack_deployments() pattern). + seed: Random seed for layout generation. + connection_type: Grid connection type (``"wye"`` or ``"delta"``). + load_shift_headroom: Fraction of extra server capacity for load shifting. + """ + + bus: str + bus_kv: float + base_kw_per_phase: float + total_gpu_capacity: int + models: tuple[tuple[ModelDeployment, ReplicaSchedule], ...] = () + seed: int = 0 + connection_type: str = "wye" + load_shift_headroom: float = 0.0 + + +# ── Model specs (hardcoded; replaces config.json which master removed) ────── + +LLAMA_8B = InferenceModelSpec( + model_label="Llama-3.1-8B", + model_id="meta-llama/Llama-3.1-8B-Instruct", + gpu_model="H100", + task="lm-arena-chat", + precision="bfloat16", + gpus_per_replica=1, + tensor_parallel=1, + itl_deadline_s=0.08, + batch_sizes=(8, 16, 32, 64, 96, 128, 192, 256, 384, 512, 768, 1024), + feasible_batch_sizes=(8, 16, 32, 64, 128, 256, 512), +) +LLAMA_70B = InferenceModelSpec( + model_label="Llama-3.1-70B", + model_id="meta-llama/Llama-3.1-70B-Instruct", + gpu_model="H100", + task="lm-arena-chat", + precision="bfloat16", + gpus_per_replica=4, + tensor_parallel=4, + itl_deadline_s=0.10, + batch_sizes=(8, 16, 32, 64, 96, 128, 192, 256, 384, 512, 768, 1024, 1536, 2048), + feasible_batch_sizes=(8, 16, 32, 64, 128, 256, 512), +) +LLAMA_405B = InferenceModelSpec( + model_label="Llama-3.1-405B", + model_id="meta-llama/Llama-3.1-405B-Instruct-FP8", + gpu_model="H100", + task="lm-arena-chat", + precision="fp8", + gpus_per_replica=8, + tensor_parallel=8, + itl_deadline_s=0.12, + batch_sizes=(8, 16, 32, 64, 96, 128, 192, 256, 384, 512), + feasible_batch_sizes=(8, 16, 32, 64, 128, 256, 512), +) +QWEN_30B = InferenceModelSpec( + model_label="Qwen3-30B-A3B", + model_id="Qwen/Qwen3-30B-A3B-Thinking-2507", + gpu_model="H100", + task="gpqa", + precision="bfloat16", + gpus_per_replica=2, + tensor_parallel=2, + itl_deadline_s=0.06, + batch_sizes=(8, 16, 32, 64, 96, 128, 192, 256, 384, 512), + feasible_batch_sizes=(8, 16, 32, 64, 128, 256, 512), +) +QWEN_235B = InferenceModelSpec( + model_label="Qwen3-235B-A22B", + model_id="Qwen/Qwen3-235B-A22B-Thinking-2507", + gpu_model="H100", + task="gpqa", + precision="bfloat16", + gpus_per_replica=8, + tensor_parallel=8, + itl_deadline_s=0.14, + batch_sizes=(8, 16, 32, 64, 96, 128, 192, 256, 384, 512), + feasible_batch_sizes=(8, 16, 32, 64, 128, 256, 512), +) +ALL_MODEL_SPECS: tuple[InferenceModelSpec, ...] = (LLAMA_8B, LLAMA_70B, LLAMA_405B, QWEN_30B, QWEN_235B) +MODEL_SPECS: dict[str, InferenceModelSpec] = {s.model_label: s for s in ALL_MODEL_SPECS} + +SPECS_CACHE_DIR = PROJECT_ROOT / "data" / "specs" +TRAINING_TRACE_PATH = PROJECT_ROOT / "data" / "training_trace.csv" + + +def model_spec(label: str) -> InferenceModelSpec: + """Get a single model spec by label.""" + return MODEL_SPECS[label] + + +def all_model_specs() -> tuple[InferenceModelSpec, ...]: + """Return all model specs as a tuple (for data pipeline loading).""" + return ALL_MODEL_SPECS + + +def deploy( + label: str, num_replicas: int, initial_batch_size: int = 128, +) -> tuple[ModelDeployment, ReplicaSchedule]: + """Shorthand: ``deploy("Llama-3.1-8B", 720, 128)`` -> (ModelDeployment, ReplicaSchedule).""" + return ( + ModelDeployment(spec=MODEL_SPECS[label], initial_batch_size=initial_batch_size), + ReplicaSchedule(initial=num_replicas), + ) + + +def with_ramp( + deployment: tuple[ModelDeployment, ReplicaSchedule], + target: int, + *, + t_start: float, + t_end: float, +) -> tuple[ModelDeployment, ReplicaSchedule]: + """Inject a single ramp into a deploy() result. Convenience for ieee*_experiment factories.""" + md, sched = deployment + return (md, sched.ramp_to(target, t_start=t_start, t_end=t_end)) + + +def load_data_sources() -> tuple[InferenceData, None, Path]: + """Compatibility shim — returns (inference_data, None, data_dir). + + On master, the per-spec content-addressed cache replaced the JSON-driven + pipeline, so callers should ideally use ``InferenceData.ensure(SPECS_CACHE_DIR, + ALL_MODEL_SPECS, ...)`` directly. This shim preserves the 3-tuple shape for + in-flight callers while we migrate them. + """ + inference_data = InferenceData.ensure(SPECS_CACHE_DIR, ALL_MODEL_SPECS, plot=False) + return inference_data, None, SPECS_CACHE_DIR + + +# ── PV / time-varying load specs ──────────────────────────────────────────── + + +@dataclass +class PVSystemSpec: + """PV system at a distribution bus (used by ScenarioOpenDSSGrid).""" + + bus: str + bus_kv: float = 4.16 + peak_kw: float = 1000.0 + csv_path: Path | None = None + power_factor: float = 1.0 + # Optional shape randomness applied inside eval_profile: + # t_eff = (t - peak_t_shift_s) / time_warp + # Defaults are no-op so existing callers are unaffected. + peak_t_shift_s: float = 0.0 + time_warp: float = 1.0 + # Optional randomized profile selector. When profile_kind != "default" the + # eval_profile dispatcher uses the matching generator in + # sweep_dc_locations.py and ignores csv_path / profile_fn / time_warp / + # peak_t_shift_s. Valid kinds: "default", "random_flat" (PV). + profile_kind: str = "default" + profile_params: dict | None = None + + +@dataclass +class TimeVaryingLoadSpec: + """Time-varying load at a distribution bus (used by ScenarioOpenDSSGrid).""" + + bus: str + bus_kv: float = 4.16 + peak_kw: float = 500.0 + csv_path: Path | None = None + power_factor: float = 0.96 + peak_t_shift_s: float = 0.0 + time_warp: float = 1.0 + # Valid profile_kind for TVL: "default", "random_shape" (with shape param). + profile_kind: str = "default" + profile_params: dict | None = None + + +# ── IEEE test feeder constants ─────────────────────────────────────────────── def ieee13() -> dict: @@ -41,13 +251,12 @@ def ieee13() -> dict: "creg1c": tap(15), } ), - exclude_buses=["sourcebus", "650", "rg60"], + exclude_buses=("sourcebus", "650", "rg60"), ) def ieee34() -> dict: """IEEE 34-bus (half-line variant) test feeder constants.""" - # fmt: off return dict( dss_case_dir=GRID_DATA_DIR / "ieee34", dss_master_file="IEEE34Bus.dss", @@ -55,28 +264,48 @@ def ieee34() -> dict: source_pu=1.09, initial_taps=TapPosition( regulators={ - "creg1a": tap(12), - "creg1b": tap(8), - "creg1c": tap(10), + "creg1a": tap(11), + "creg1b": tap(6), + "creg1c": tap(8), "creg2a": tap(8), "creg2b": tap(8), "creg2c": tap(8), } ), - exclude_buses=[ - "sourcebus", "800", "802", "806", "808", "810", "812", "814", "888", "890", - ], + exclude_buses=( + "sourcebus", + "800", + "802", + "806", + "808", + "810", + "812", + "814", + "888", + "890", + ), regulator_zones={ "creg1": ["814r", "850", "816", "824", "828", "830", "854"], - "creg2": ["852r", "832", "858", "834", "860", "836", "840", "862", "842", "844", "846", "848"], + "creg2": [ + "852r", + "832", + "858", + "834", + "860", + "836", + "840", + "862", + "842", + "844", + "846", + "848", + ], }, ) - # fmt: on def ieee123() -> dict: """IEEE 123-bus test feeder constants.""" - # fmt: off return dict( dss_case_dir=GRID_DATA_DIR / "ieee123", dss_master_file="IEEE123Bus.dss", @@ -93,30 +322,1120 @@ def ieee123() -> dict: "creg4c": tap(4), } ), - exclude_buses=[ - "sourcebus", "150", "150r", "149", "9r", "25r", "160r", "61s", "610", "300_open", "94_open", - ], + exclude_buses=( + "sourcebus", + "150", + "150r", + "149", + "9r", + "25r", + "160r", + "61s", + "610", + "300_open", + "94_open", + "135", + ), zones={ "z1_sw": [ - "1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", - "12", "13", "14", "15", "16", "17", "18", "19", "20", "34", + "1", + "2", + "3", + "4", + "5", + "6", + "7", + "8", + "9", + "10", + "11", + "12", + "13", + "14", + "15", + "16", + "17", + "18", + "19", + "20", + "34", ], "z2_nw": [ - "21", "22", "23", "24", "25", "26", "27", "28", "29", "30", "31", "32", "33", "35", "36", - "37", "38", "39", "40", "41", "42", "43", "44", "45", "46", "47", "48", "49", "50", "51", + "21", + "22", + "23", + "24", + "25", + "26", + "27", + "28", + "29", + "30", + "31", + "32", + "33", + "35", + "36", + "37", + "38", + "39", + "40", + "41", + "42", + "43", + "44", + "45", + "46", + "47", + "48", + "49", + "50", + "51", ], "z3_se": [ - "52", "53", "54", "55", "56", "57", "58", "59", "60", "61", "62", "63", "64", "65", "66", - "67", "68", "69", "70", "71", "72", "73", "74", "75", "76", "77", "78", "79", "80", "81", - "82", "83", "84", "85", "86", "87", "88", "89", "90", "91", "92", "93", "94", "95", "96", + "52", + "53", + "54", + "55", + "56", + "57", + "58", + "59", + "60", + "61", + "62", + "63", + "64", + "65", + "66", + "67", + "68", + "69", + "70", + "71", + "72", + "73", + "74", + "75", + "76", + "77", + "78", + "79", + "80", + "81", + "82", + "83", + "84", + "85", + "86", + "87", + "88", + "89", + "90", + "91", + "92", + "93", + "94", + "95", + "96", ], "z4_ne": [ - "97", "98", "99", "100", "101", "102", "103", "104", "105", "106", "107", "108", "109", "110", - "111", "112", "113", "114", "115", "116", "117", "118", "119", "120", "121", "122", "123", + "97", + "98", + "99", + "100", + "101", + "102", + "103", + "104", + "105", + "106", + "107", + "108", + "109", + "110", + "111", + "112", + "113", + "114", + "115", + "116", + "117", + "118", + "119", + "120", + "121", + "122", + "123", ], }, ) - # fmt: on SYSTEMS = {"ieee13": ieee13, "ieee34": ieee34, "ieee123": ieee123} + + +# ── Profile helpers ─────────────────────────────────────────────────────────── + + +def _smooth_bump(t: float, t_center: float, half_width: float) -> float: + dt = abs(t - t_center) + if dt >= half_width: + return 0.0 + x = dt / half_width + return (1 - x * x) ** 2 + + +def _smoothstep(t: float, t_start: float, t_end: float) -> float: + """Cubic Hermite smoothstep: zero-derivative at both endpoints.""" + if t_end <= t_start: + return 1.0 if t >= t_start else 0.0 + if t <= t_start: + return 0.0 + if t >= t_end: + return 1.0 + x = (t - t_start) / (t_end - t_start) + return x * x * (3.0 - 2.0 * x) + + +def _irregular_fluct(t: float, seed: float = 0.0) -> float: + """Irregular fluctuation via superposition of incommensurate frequencies.""" + s = seed + f1 = 0.06 * math.sin(2 * math.pi * t / 173.0 + s) + f2 = 0.05 * math.sin(2 * math.pi * t / 97.3 + s * 2.3) + f3 = 0.04 * math.sin(2 * math.pi * t / 251.7 + s * 0.7) + f4 = 0.03 * math.sin(2 * math.pi * t / 41.9 + s * 4.1) + f5 = 0.02 * math.sin(2 * math.pi * t / 317.3 + s * 1.9) + return 1.0 + f1 + f2 + f3 + f4 + f5 + + +def pv_profile_kw(t: float, peak_kw: float, site_idx: int = 0) -> float: + """Solar PV output (kW per phase) with per-site cloud patterns.""" + T = TOTAL_DURATION_S + if site_idx == 0: + trend = 0.85 - 0.30 * (t / T) + cloud = 1.0 + cloud -= 0.55 * _smooth_bump(t, 600, 120) + cloud -= 0.40 * _smooth_bump(t, 2100, 180) + fluct = _irregular_fluct(t, seed=0.3) + return max(0.0, peak_kw * trend * max(cloud, 0.05) * fluct) + elif site_idx == 1: + ramp = 0.55 + 0.40 * _smooth_bump(t, 1200, 900) + cloud = 1.0 + cloud -= 0.60 * _smooth_bump(t, 1680, 240) + cloud -= 0.25 * _smooth_bump(t, 2400, 150) + fluct = _irregular_fluct(t, seed=2.1) + return max(0.0, peak_kw * ramp * max(cloud, 0.05) * fluct) + else: + ramp = 0.30 + 0.65 * min(1.0, t / 900.0) + cloud = 1.0 + cloud -= 0.70 * _smooth_bump(t, 2700, 300) + cloud -= 0.30 * _smooth_bump(t, 1200, 100) + fluct = _irregular_fluct(t, seed=2.0 + site_idx * 3.7) + return max(0.0, peak_kw * ramp * max(cloud, 0.05) * fluct) + + +def load_profile_kw(t: float, peak_kw: float, site_idx: int = 0) -> float: + fluct_period = 130.0 + site_idx * 37 + fluct = 1.0 + 0.06 * math.sin(2 * math.pi * t / fluct_period + site_idx * 1.4) + + if site_idx == 0: + base = 0.15 + 0.85 * _smooth_bump(t, 2280, 1400) + surge = 0.20 * _smooth_bump(t, 2280, 180) + return max(0.0, peak_kw * (base + surge) * fluct) + elif site_idx == 1: + base = 0.10 + base += 0.50 * _smooth_bump(t, 1500, 600) + base += 0.80 * _smooth_bump(t, 2880, 500) + return max(0.0, peak_kw * base * fluct) + elif site_idx == 2: + base = 0.80 - 0.55 * _smooth_bump(t, 1800, 1200) + surge = 0.70 * _smooth_bump(t, 2520, 400) + return max(0.0, peak_kw * (base + surge) * fluct) + elif site_idx == 3: + base = 0.10 + 0.90 * _smooth_bump(t, 3120, 800) + return max(0.0, peak_kw * base * fluct) + else: + base = 0.10 + base += 0.60 * _smooth_bump(t, 1080, 300) + base += 0.75 * _smooth_bump(t, 2100, 350) + base += 0.90 * _smooth_bump(t, 3300, 300) + return max(0.0, peak_kw * base * fluct) + + +def pv_profile_random(t: float, peak_kw: float, params: dict) -> float: + """Multi-shape PV profile with random cloud events (1-hour episode). + + params["shape"] picks the envelope: + "flat" constant baseline 0.75-0.95 + "rising_falling" smooth bump from low_baseline up to high_baseline and back + "morning_ramp" low → high over a short ramp window, sustained high after + "afternoon_decline" sustained high then ramp down at the end + "midday_dip" high baseline with a substantial mid-episode dip + """ + shape = params.get("shape", "flat") + T = float(TOTAL_DURATION_S) + + if shape == "flat": + env = float(params.get("baseline", 0.85)) + elif shape == "rising_falling": + lo = float(params.get("low_baseline", 0.50)) + hi = float(params.get("high_baseline", 0.95)) + peak_t = float(params.get("peak_t", T / 2)) + half_width = float(params.get("half_width", 1200.0)) + env = lo + (hi - lo) * _smooth_bump(t, peak_t, half_width) + elif shape == "morning_ramp": + lo = float(params.get("low_baseline", 0.25)) + hi = float(params.get("high_baseline", 0.75)) + ramp_start = float(params.get("ramp_start", 100.0)) + ramp_end = float(params.get("ramp_end", 1200.0)) + env = lo + (hi - lo) * _smoothstep(t, ramp_start, ramp_end) + elif shape == "afternoon_decline": + lo = float(params.get("low_baseline", 0.25)) + hi = float(params.get("high_baseline", 0.75)) + ramp_start = float(params.get("ramp_start", 2400.0)) + ramp_end = float(params.get("ramp_end", T - 100.0)) + env = hi - (hi - lo) * _smoothstep(t, ramp_start, ramp_end) + elif shape == "midday_dip": + hi = float(params.get("high_baseline", 0.90)) + dip_t = float(params.get("dip_t", T / 2)) + dip_half_width = float(params.get("dip_half_width", 700.0)) + dip_depth = float(params.get("dip_depth", 0.55)) + env = hi - dip_depth * _smooth_bump(t, dip_t, dip_half_width) + else: + env = 0.85 + + for tc, hw, depth in params.get("clouds", ()): + env -= float(depth) * _smooth_bump(t, float(tc), float(hw)) + + env = max(0.15, env) + noise_amp = float(params.get("noise_amp", 0.0)) + if noise_amp > 0: + f = _irregular_fluct(t, seed=float(params.get("noise_seed", 0.0))) + env *= 1.0 + (noise_amp / 0.20) * (f - 1.0) + return max(0.0, peak_kw * env) + + +def tvl_profile_random(t: float, peak_kw: float, params: dict) -> float: + """Multi-shape TVL profile (1-hour episode).""" + shape = params.get("shape", "peaked") + T = float(TOTAL_DURATION_S) + if shape == "flat": + base = float(params.get("level", 0.7)) + elif shape == "increasing": + lo = float(params.get("lo", 0.2)) + hi = float(params.get("hi", 0.9)) + base = lo + (hi - lo) * min(1.0, max(0.0, t / T)) + elif shape == "decreasing": + lo = float(params.get("lo", 0.2)) + hi = float(params.get("hi", 0.9)) + base = hi - (hi - lo) * min(1.0, max(0.0, t / T)) + elif shape == "peaked": + peak_t = float(params.get("peak_t", T / 2)) + peak_w = float(params.get("peak_w", 1400.0)) + baseline = float(params.get("baseline", 0.15)) + amp = float(params.get("amp", 0.85)) + base = baseline + amp * _smooth_bump(t, peak_t, peak_w) + elif shape == "valley": + valley_t = float(params.get("valley_t", T / 2)) + valley_w = float(params.get("valley_w", 1200.0)) + high = float(params.get("high", 0.85)) + depth = float(params.get("depth", 0.55)) + base = high - depth * _smooth_bump(t, valley_t, valley_w) + else: + base = 0.5 + base = max(0.0, base) + noise_amp = float(params.get("noise_amp", 0.0)) + if noise_amp > 0: + f = _irregular_fluct(t, seed=float(params.get("noise_seed", 0.0))) + base *= 1.0 + (noise_amp / 0.20) * (f - 1.0) + return max(0.0, peak_kw * base) + + +def load_csv_profile(csv_path: Path) -> tuple[np.ndarray, np.ndarray]: + data = np.loadtxt(csv_path, delimiter=",", skiprows=1) + return data[:, 0], data[:, 1] + + +def eval_profile( + t, + *, + peak_kw, + csv_data, + profile_fn, + site_idx, + peak_t_shift_s: float = 0.0, + time_warp: float = 1.0, + profile_kind: str = "default", + profile_params: dict | None = None, +): + """Evaluate a PV/TVL profile at simulated time ``t``. + + Dispatch order: + 1. ``profile_kind="random_flat"`` + ``profile_params`` -> pv_profile_random + 2. ``profile_kind="random_shape"`` + ``profile_params`` -> tvl_profile_random + 3. ``csv_data`` not None -> CSV interpolation + 4. ``profile_fn`` (legacy analytical pv_profile_kw / load_profile_kw) + """ + if profile_kind == "random_flat" and profile_params is not None: + return pv_profile_random(t, peak_kw, profile_params) + if profile_kind == "random_shape" and profile_params is not None: + return tvl_profile_random(t, peak_kw, profile_params) + if time_warp <= 0: + time_warp = 1.0 + t_eff = (t - peak_t_shift_s) / time_warp + if csv_data is not None: + return float(np.interp(t_eff, csv_data[0], csv_data[1])) + return profile_fn(t_eff, peak_kw, site_idx) + + +# ── Scenario grid ───────────────────────────────────────────────────────────── + + +class ScenarioOpenDSSGrid(OpenDSSGrid): + """OpenDSSGrid with PV systems and external loads at arbitrary buses.""" + + def __init__( + self, *, pv_systems=None, time_varying_loads=None, source_pu=None, constant_pv: bool = False, **kwargs + ): + super().__init__(**kwargs) + self._pv_specs = list(pv_systems or []) + self._load_specs = list(time_varying_loads or []) + self._source_pu = source_pu + self._constant_pv = constant_pv + + self._pv_csv = [load_csv_profile(s.csv_path) if s.csv_path else None for s in self._pv_specs] + self._load_csv = [load_csv_profile(s.csv_path) if s.csv_path else None for s in self._load_specs] + self._pv_load_names = [(f"PV_{i}_A", f"PV_{i}_B", f"PV_{i}_C") for i in range(len(self._pv_specs))] + self._ext_load_names = [ + (f"ExtLoad_{i}_A", f"ExtLoad_{i}_B", f"ExtLoad_{i}_C") for i in range(len(self._load_specs)) + ] + + def _init_dss(self) -> None: + super()._init_dss() + from openg2g.grid.opendss import dss + + if self._source_pu is not None: + dss.Text.Command(f"Edit Vsource.source pu={self._source_pu}") + + for i, spec in enumerate(self._pv_specs): + kv_ln = spec.bus_kv / math.sqrt(3.0) + for ph, name in zip((1, 2, 3), self._pv_load_names[i], strict=False): + dss.Text.Command( + f"New Load.{name} bus1={spec.bus}.{ph} phases=1 " + f"conn=wye kV={kv_ln:.6f} kW=0 kvar=0 model=1 vminpu=0.85" + ) + + for i, spec in enumerate(self._load_specs): + kv_ln = spec.bus_kv / math.sqrt(3.0) + for ph, name in zip((1, 2, 3), self._ext_load_names[i], strict=False): + dss.Text.Command( + f"New Load.{name} bus1={spec.bus}.{ph} phases=1 " + f"conn=wye kV={kv_ln:.6f} kW=0 kvar=0 model=1 vminpu=0.85" + ) + + def step(self, clock, power_samples_w, events): + from openg2g.grid.opendss import dss + + for i, spec in enumerate(self._pv_specs): + if self._constant_pv: + kw = spec.peak_kw + else: + kw = eval_profile( + clock.time_s, + peak_kw=spec.peak_kw, + csv_data=self._pv_csv[i], + profile_fn=pv_profile_kw, + site_idx=i, + peak_t_shift_s=getattr(spec, "peak_t_shift_s", 0.0), + time_warp=getattr(spec, "time_warp", 1.0), + profile_kind=getattr(spec, "profile_kind", "default"), + profile_params=getattr(spec, "profile_params", None), + ) + pf = max(min(spec.power_factor, 0.999999), 1e-6) + kvar = kw * math.tan(math.acos(pf)) + for name in self._pv_load_names[i]: + dss.Loads.Name(name) + dss.Loads.kW(-kw) + dss.Loads.kvar(-kvar) + + for i, spec in enumerate(self._load_specs): + kw = eval_profile( + clock.time_s, + peak_kw=spec.peak_kw, + csv_data=self._load_csv[i], + profile_fn=load_profile_kw, + site_idx=i, + peak_t_shift_s=getattr(spec, "peak_t_shift_s", 0.0), + time_warp=getattr(spec, "time_warp", 1.0), + profile_kind=getattr(spec, "profile_kind", "default"), + profile_params=getattr(spec, "profile_params", None), + ) + pf = max(min(spec.power_factor, 0.999999), 1e-6) + kvar = kw * math.tan(math.acos(pf)) + for name in self._ext_load_names[i]: + dss.Loads.Name(name) + dss.Loads.kW(kw) + dss.Loads.kvar(kvar) + + return super().step(clock, power_samples_w, events) + + +# ── Scenario randomisation helpers ──────────────────────────────────────────── + + +def _site_inference_gpus(site: DCSite) -> int: + """Sum of GPUs consumed by inference at a site (replicas × gpus_per_replica).""" + return sum(sched.initial * md.spec.gpus_per_replica for md, sched in site.models) + + +def _randomize_ramps( + dc_sites: dict[str, DCSite], + rng: np.random.Generator, + *, + ramp_frac_min: float = 0.15, + ramp_frac_max: float = 0.3, + ramp_start_min: float = 500.0, + ramp_start_max: float = 3000.0, + ramp_dur_min: float = 300.0, + ramp_dur_max: float = 800.0, +) -> dict[str, DCSite]: + """Return a copy of dc_sites with randomized ramp targets and timing.""" + ramp_frac = rng.uniform(ramp_frac_min, ramp_frac_max) + ramp_start = rng.uniform(ramp_start_min, ramp_start_max) + ramp_dur = rng.uniform(ramp_dur_min, ramp_dur_max) + ramp_end = ramp_start + ramp_dur + + new_sites: dict[str, DCSite] = {} + for sid, site in dc_sites.items(): + new_models: list[tuple[ModelDeployment, ReplicaSchedule]] = [] + for md, sched in site.models: + target = max(1, int(ramp_frac * sched.initial)) + new_sched = ReplicaSchedule(initial=sched.initial).ramp_to( + target, t_start=ramp_start, t_end=ramp_end, + ) + new_models.append((md, new_sched)) + new_sites[sid] = DCSite( + bus=site.bus, + bus_kv=site.bus_kv, + base_kw_per_phase=site.base_kw_per_phase, + total_gpu_capacity=site.total_gpu_capacity, + models=tuple(new_models), + seed=int(rng.integers(0, 10000)), + connection_type=site.connection_type, + ) + return new_sites + + +def _randomize_broad_ramps( + dc_sites: dict[str, DCSite], + rng: np.random.Generator, + *, + overlay_gpus_at_first_site: int, + n_ramps_per_site_choices: tuple[int, ...] = (1, 2), + ramp_up_prob: float = 0.5, + ramp_down_frac_min: float = 0.15, + ramp_down_frac_max: float = 0.5, + ramp_up_frac_min: float = 1.05, + ramp_up_frac_max: float = 1.5, + ramp_start_min: float = 500.0, + ramp_start_max: float = 3000.0, + ramp_dur_min: float = 300.0, + ramp_dur_max: float = 800.0, + exclude_window: tuple[float, float] | None = None, +) -> dict[str, DCSite]: + """Bidirectional, multi-ramp generator that respects DC GPU capacity.""" + if exclude_window is not None: + ex_lo, ex_hi = exclude_window + zone1_hi = ex_lo - ramp_dur_max + zone2_lo = ex_hi + zones: list[tuple[float, float]] = [] + if ramp_start_min < zone1_hi: + zones.append((ramp_start_min, min(zone1_hi, ramp_start_max))) + if zone2_lo < ramp_start_max: + zones.append((max(zone2_lo, ramp_start_min), ramp_start_max)) + if not zones: + zones = [(ramp_start_min, ramp_start_max)] + else: + zones = [(ramp_start_min, ramp_start_max)] + + total_width = sum(hi - lo for lo, hi in zones) + + new_sites: dict[str, DCSite] = {} + sites_list = list(dc_sites.items()) + for site_idx, (sid, site) in enumerate(sites_list): + current_gpus = _site_inference_gpus(site) + overlay_here = overlay_gpus_at_first_site if site_idx == 0 else 0 + available_gpus = max(0, site.total_gpu_capacity - overlay_here) + max_feasible_up = available_gpus / current_gpus if current_gpus > 0 else 1.0 + site_ramp_up_max = min(ramp_up_frac_max, max_feasible_up) + can_up_ramp = site_ramp_up_max >= max(1.05, ramp_up_frac_min) + + n_ramps = int(rng.choice(n_ramps_per_site_choices)) + zone_counts = [int(n_ramps * (hi - lo) / total_width) for lo, hi in zones] + remainder = n_ramps - sum(zone_counts) + if remainder > 0: + widest = max(range(len(zones)), key=lambda i: zones[i][1] - zones[i][0]) + zone_counts[widest] += remainder + + # Per-model schedule starts from the model's existing initial count. + model_scheds: dict[str, ReplicaSchedule] = { + md.spec.model_label: ReplicaSchedule(initial=sched.initial) for md, sched in site.models + } + for (z_lo, z_hi), z_count in zip(zones, zone_counts): + if z_count == 0: + continue + band_width = (z_hi - z_lo) / z_count + for bi in range(z_count): + band_lo = z_lo + bi * band_width + band_hi = z_lo + (bi + 1) * band_width + if band_width < ramp_dur_min: + continue + t_dur = float(rng.uniform(ramp_dur_min, min(ramp_dur_max, band_width))) + t_start = float(rng.uniform(band_lo, max(band_lo + 1.0, band_hi - t_dur))) + t_end = t_start + t_dur + + if can_up_ramp and rng.random() < ramp_up_prob: + lo = max(1.05, ramp_up_frac_min) + hi = max(lo + 1e-3, site_ramp_up_max) + frac = float(rng.uniform(lo, hi)) + else: + frac = float(rng.uniform(ramp_down_frac_min, ramp_down_frac_max)) + + for md, sched in site.models: + target = max(1, int(round(frac * sched.initial))) + label = md.spec.model_label + model_scheds[label] = model_scheds[label].ramp_to( + target, t_start=t_start, t_end=t_end, + ) + + new_models = tuple((md, model_scheds[md.spec.model_label]) for md, _ in site.models) + new_sites[sid] = DCSite( + bus=site.bus, + bus_kv=site.bus_kv, + base_kw_per_phase=site.base_kw_per_phase, + total_gpu_capacity=site.total_gpu_capacity, + models=new_models, + seed=int(rng.integers(0, 10000)), + connection_type=site.connection_type, + ) + return new_sites + + +def randomize_scenario( + seed: int, + *, + dc_sites_base: dict[str, DCSite], + pv_systems_base: list[PVSystemSpec], + tvl_base: list[TimeVaryingLoadSpec], + training_base: dict | None, + randomize_ramps: bool = True, + ramp_frac_min: float = 0.15, + ramp_frac_max: float = 0.3, + ramp_start_min: float = 500.0, + ramp_start_max: float = 3000.0, + ramp_dur_min: float = 300.0, + ramp_dur_max: float = 800.0, + randomization_profile: bool = True, + pv_scale_min: float = 0.5, + pv_scale_max: float = 2.0, + load_scale_min: float = 0.5, + load_scale_max: float = 2.0, + pv_t_shift_max_s: float = 0.0, + tvl_t_shift_max_s: float = 0.0, + pv_warp_min: float = 1.0, + pv_warp_max: float = 1.0, + tvl_warp_min: float = 1.0, + tvl_warp_max: float = 1.0, + overlay_prob: float = 1.0, + overlay_intensity_min: float = 1.0, + overlay_intensity_max: float = 1.0, + overlay_gpu_frac_min: float = 0.85, + overlay_gpu_frac_max: float = 1.0, + n_ramps_per_site_choices: tuple[int, ...] = (1,), + ramp_up_prob: float = 0.0, + ramp_down_frac_min: float = 0.15, + ramp_down_frac_max: float = 0.5, + ramp_up_frac_min: float = 1.05, + ramp_up_frac_max: float = 1.5, + randomize_pv_profile: bool = False, + pv_shape_choices: tuple[str, ...] = ( + "flat", "rising_falling", "morning_ramp", "afternoon_decline", "midday_dip", + ), + pv_baseline_min: float = 0.75, + pv_baseline_max: float = 0.95, + pv_cloud_count_max: int = 3, + pv_cloud_depth_min: float = 0.30, + pv_cloud_depth_max: float = 0.70, + pv_cloud_width_min: float = 60.0, + pv_cloud_width_max: float = 300.0, + randomize_tvl_profile: bool = False, + tvl_shape_choices: tuple[str, ...] = ("flat", "increasing", "decreasing", "peaked", "valley"), +) -> dict: + """Build a single randomized episode scenario from a seed.""" + rng = np.random.default_rng(seed=seed) + is_broad = randomization_profile if isinstance(randomization_profile, bool) else (randomization_profile == "broad") + + training_run = None + train_overlay_meta: dict | None = None + overlay_gpus_at_first_site = 0 + if training_base is not None: + if is_broad: + overlay_on = bool(rng.random() < overlay_prob) + else: + overlay_on = True + if overlay_on: + train_dur = float(rng.uniform(500.0, 1200.0)) + if is_broad: + train_start = float(rng.uniform(0.0, max(0.0, float(TOTAL_DURATION_S) - train_dur))) + else: + train_start = float(rng.uniform(500.0, 1500.0)) + gpu_frac = float(rng.uniform(overlay_gpu_frac_min, overlay_gpu_frac_max)) if is_broad else float(rng.uniform(0.85, 1.0)) + train_gpus = int(gpu_frac * training_base["n_gpus"]) + intensity = float(rng.uniform(overlay_intensity_min, overlay_intensity_max)) if is_broad else 1.0 + target_peak = training_base["target_peak_W_per_gpu"] * intensity + training_run = TrainingRun( + n_gpus=train_gpus, + trace=training_base["trace"], + target_peak_W_per_gpu=target_peak, + ).at(t_start=train_start, t_end=train_start + train_dur) + train_overlay_meta = { + "n_gpus": train_gpus, + "target_peak_W_per_gpu": target_peak, + "intensity": intensity, + "t_start": train_start, + "t_end": train_start + train_dur, + } + overlay_gpus_at_first_site = train_gpus + + if randomize_ramps: + if is_broad: + overlay_window: tuple[float, float] | None = None + if train_overlay_meta is not None: + overlay_window = (train_overlay_meta["t_start"], train_overlay_meta["t_end"]) + sites = _randomize_broad_ramps( + dc_sites_base, + rng, + overlay_gpus_at_first_site=overlay_gpus_at_first_site, + n_ramps_per_site_choices=tuple(n_ramps_per_site_choices), + ramp_up_prob=ramp_up_prob, + ramp_down_frac_min=ramp_down_frac_min, + ramp_down_frac_max=ramp_down_frac_max, + ramp_up_frac_min=ramp_up_frac_min, + ramp_up_frac_max=ramp_up_frac_max, + ramp_start_min=ramp_start_min, + ramp_start_max=ramp_start_max, + ramp_dur_min=ramp_dur_min, + ramp_dur_max=ramp_dur_max, + exclude_window=overlay_window, + ) + else: + sites = _randomize_ramps( + dc_sites_base, + rng, + ramp_frac_min=ramp_frac_min, + ramp_frac_max=ramp_frac_max, + ramp_start_min=ramp_start_min, + ramp_start_max=ramp_start_max, + ramp_dur_min=ramp_dur_min, + ramp_dur_max=ramp_dur_max, + ) + else: + sites = dict(dc_sites_base) + + pv_scale = float(rng.uniform(pv_scale_min, pv_scale_max)) if is_broad else float(rng.uniform(0.5, 2.0)) + load_scale = float(rng.uniform(load_scale_min, load_scale_max)) if is_broad else float(rng.uniform(0.5, 2.0)) + pv_t_shift = float(rng.uniform(-pv_t_shift_max_s, pv_t_shift_max_s)) if (is_broad and pv_t_shift_max_s > 0) else 0.0 + tvl_t_shift = float(rng.uniform(-tvl_t_shift_max_s, tvl_t_shift_max_s)) if (is_broad and tvl_t_shift_max_s > 0) else 0.0 + pv_warp = float(rng.uniform(pv_warp_min, pv_warp_max)) if is_broad else 1.0 + tvl_warp = float(rng.uniform(tvl_warp_min, tvl_warp_max)) if is_broad else 1.0 + + def _sample_pv_profile(rng_) -> tuple[str, dict | None]: + n_clouds = int(rng_.integers(0, pv_cloud_count_max + 1)) + clouds = [ + ( + float(rng_.uniform(120.0, 3480.0)), + float(rng_.uniform(pv_cloud_width_min, pv_cloud_width_max)), + float(rng_.uniform(pv_cloud_depth_min, pv_cloud_depth_max)), + ) + for _ in range(n_clouds) + ] + shape = str(rng_.choice(list(pv_shape_choices))) + params: dict = { + "shape": shape, + "clouds": clouds, + "noise_amp": float(rng_.uniform(0.02, 0.06)), + "noise_seed": float(rng_.uniform(0.0, 10.0)), + } + if shape == "flat": + params["baseline"] = float(rng_.uniform(pv_baseline_min, pv_baseline_max)) + elif shape == "rising_falling": + lo = float(rng_.uniform(0.15, 0.55)) + params["low_baseline"] = lo + params["high_baseline"] = float(rng_.uniform(lo + 0.20, 1.00)) + params["peak_t"] = float(rng_.uniform(900.0, 2700.0)) + params["half_width"] = float(rng_.uniform(800.0, 1500.0)) + elif shape == "morning_ramp": + lo = float(rng_.uniform(0.15, 0.45)) + params["low_baseline"] = lo + params["high_baseline"] = float(rng_.uniform(lo + 0.20, 1.00)) + params["ramp_start"] = float(rng_.uniform(0.0, 400.0)) + params["ramp_end"] = float(rng_.uniform(800.0, 1800.0)) + elif shape == "afternoon_decline": + lo = float(rng_.uniform(0.15, 0.45)) + params["low_baseline"] = lo + params["high_baseline"] = float(rng_.uniform(lo + 0.20, 1.00)) + params["ramp_start"] = float(rng_.uniform(1800.0, 2800.0)) + params["ramp_end"] = float(rng_.uniform(3200.0, 3600.0)) + elif shape == "midday_dip": + params["high_baseline"] = float(rng_.uniform(0.30, 1.00)) + params["dip_t"] = float(rng_.uniform(1200.0, 2400.0)) + params["dip_half_width"] = float(rng_.uniform(500.0, 1000.0)) + params["dip_depth"] = float(rng_.uniform(0.20, 0.55)) + return "random_flat", params + + tvl_profile_kind = "default" + tvl_profile_params: dict | None = None + if is_broad and randomize_tvl_profile: + shape = str(rng.choice(list(tvl_shape_choices))) + params: dict = {"shape": shape} + if shape == "flat": + params["level"] = float(rng.uniform(0.5, 0.85)) + elif shape == "increasing": + params["lo"] = float(rng.uniform(0.10, 0.30)) + params["hi"] = float(rng.uniform(0.65, 0.95)) + elif shape == "decreasing": + params["lo"] = float(rng.uniform(0.10, 0.30)) + params["hi"] = float(rng.uniform(0.65, 0.95)) + elif shape == "peaked": + params["peak_t"] = float(rng.uniform(800.0, 2800.0)) + params["peak_w"] = float(rng.uniform(800.0, 1800.0)) + params["baseline"] = float(rng.uniform(0.10, 0.30)) + params["amp"] = float(rng.uniform(0.55, 0.90)) + elif shape == "valley": + params["valley_t"] = float(rng.uniform(800.0, 2800.0)) + params["valley_w"] = float(rng.uniform(800.0, 1500.0)) + params["high"] = float(rng.uniform(0.70, 0.90)) + params["depth"] = float(rng.uniform(0.40, 0.70)) + params["noise_amp"] = float(rng.uniform(0.02, 0.06)) + params["noise_seed"] = float(rng.uniform(0.0, 10.0)) + tvl_profile_kind = "random_shape" + tvl_profile_params = params + + pv_systems_out = [] + for s in pv_systems_base: + if is_broad and randomize_pv_profile: + p_kind, p_params = _sample_pv_profile(rng) + else: + p_kind, p_params = "default", None + pv_systems_out.append(PVSystemSpec( + bus=s.bus, + bus_kv=s.bus_kv, + peak_kw=s.peak_kw * pv_scale, + peak_t_shift_s=pv_t_shift, + time_warp=pv_warp, + profile_kind=p_kind, + profile_params=p_params, + )) + tvl = [ + TimeVaryingLoadSpec( + bus=s.bus, + bus_kv=s.bus_kv, + peak_kw=s.peak_kw * load_scale, + peak_t_shift_s=tvl_t_shift, + time_warp=tvl_warp, + profile_kind=tvl_profile_kind, + profile_params=tvl_profile_params, + ) + for s in tvl_base + ] + + batch_choices = [32, 64, 128] + initial_batch_map: dict[str, int] = {} + new_sites: dict[str, DCSite] = {} + for sid, site in sites.items(): + new_models: list[tuple[ModelDeployment, ReplicaSchedule]] = [] + for md, sched in site.models: + bs = int(rng.choice(batch_choices)) + initial_batch_map[md.spec.model_label] = bs + new_models.append( + (ModelDeployment(spec=md.spec, initial_batch_size=bs), sched), + ) + new_sites[sid] = DCSite( + bus=site.bus, + bus_kv=site.bus_kv, + base_kw_per_phase=site.base_kw_per_phase, + total_gpu_capacity=site.total_gpu_capacity, + models=tuple(new_models), + seed=site.seed, + connection_type=site.connection_type, + ) + + return { + "seed": int(seed), + "dc_sites": new_sites, + "pv_systems": pv_systems_out, + "tvl": tvl, + "training_run": training_run, + "params": { + "pv_scale": pv_scale, + "load_scale": load_scale, + "pv_t_shift_s": pv_t_shift, + "tvl_t_shift_s": tvl_t_shift, + "pv_warp": pv_warp, + "tvl_warp": tvl_warp, + "training_overlay": train_overlay_meta, + "initial_batch_sizes": initial_batch_map, + "randomization_profile": randomization_profile, + "tvl_profile_kind": tvl_profile_kind, + "tvl_profile_params": tvl_profile_params, + }, + } + + +def materialize_scenario(rec, *, training_base: dict | None) -> dict: + """Return the per-episode scenario dict directly from a ``ScenarioRecord``, + without rng replay. + + Mirrors the shape returned by :func:`randomize_scenario` so consumers + (eval pipeline, training make_sim) can swap in transparently. The record + is expected to carry resolved ``dc_sites`` / ``pv_systems`` / ``tvl`` — + libraries built before this field set need to be upgraded with + ``examples/offline/migrate_scenario_library.py``. + """ + if getattr(rec, "resolved_dc_sites", None) is None: + raise RuntimeError( + f"ScenarioRecord(seed={rec.seed}) is missing resolved fields. " + "Run examples/offline/migrate_scenario_library.py on the library." + ) + overlay = getattr(rec, "training_overlay", None) + training_run = None + if overlay is not None and training_base is not None and training_base.get("trace") is not None: + training_run = TrainingRun( + n_gpus=overlay["n_gpus"], + trace=training_base["trace"], + target_peak_W_per_gpu=overlay["target_peak_W_per_gpu"], + ).at(t_start=overlay["t_start"], t_end=overlay["t_end"]) + return { + "seed": int(rec.seed), + "dc_sites": rec.resolved_dc_sites, + "pv_systems": list(rec.resolved_pv_systems), + "tvl": list(rec.resolved_tvl), + "training_run": training_run, + "params": { + "pv_scale": rec.pv_scale, + "load_scale": rec.load_scale, + "training_overlay": overlay, + }, + } + + +# ── Experiment factories (shared by train_ppo, build_scenario_library, evaluate_controllers) ── + + +def ieee13_experiment(training_trace: TrainingTrace | None = None) -> dict: + """IEEE 13-bus: single DC at bus 671 with 5 LLM models.""" + sys = SYSTEMS["ieee13"]() + # Inject ramps into each model's ReplicaSchedule (was a single OR-chained + # InferenceRampSchedule on the old API). + ramp_targets = { + "Llama-3.1-8B": 144, + "Llama-3.1-70B": 36, + "Llama-3.1-405B": 18, + "Qwen3-30B-A3B": 96, + "Qwen3-235B-A22B": 42, + } + base_models = ( + deploy("Llama-3.1-8B", 720), + deploy("Llama-3.1-70B", 180), + deploy("Llama-3.1-405B", 90), + deploy("Qwen3-30B-A3B", 480), + deploy("Qwen3-235B-A22B", 210), + ) + models = tuple( + (md, sched.ramp_to(ramp_targets[md.spec.model_label], t_start=2500, t_end=3000)) + for md, sched in base_models + ) + training_base = ( + { + "trace": training_trace, + "n_gpus": 2400, + "target_peak_W_per_gpu": 400.0, + "t_start": 1000.0, + "t_end": 2000.0, + } + if training_trace is not None + else None + ) + return dict( + sys=sys, + dc_sites={ + "default": DCSite( + bus="671", + bus_kv=sys["bus_kv"], + base_kw_per_phase=500.0, + total_gpu_capacity=7200, + models=models, + seed=0, + ), + }, + pv_systems=[PVSystemSpec(bus="675", bus_kv=4.16, peak_kw=300.0)], + time_varying_loads=[TimeVaryingLoadSpec(bus="680", bus_kv=4.16, peak_kw=300.0)], + training_base=training_base, + ofo_config=OFOConfig( + primal_step_size=0.05, + w_throughput=0.00001, + w_switch=1.0, + voltage_gradient_scale=1e6, + v_min=V_MIN, + v_max=V_MAX, + voltage_dual_step_size=1.0, + latency_dual_step_size=1.0, + sensitivity_update_interval=300, + sensitivity_perturbation_kw=100.0, + ), + tap_schedule=TapSchedule( + ( + (1500, TapPosition(regulators={"creg1a": tap(16), "creg1b": tap(6), "creg1c": tap(17)})), + (3300, TapPosition(regulators={"creg1a": tap(10), "creg1b": tap(6), "creg1c": tap(10)})), + ) + ), + ) + + +def ieee34_experiment(training_trace: TrainingTrace | None = None) -> dict: # noqa: ARG001 + """IEEE 34-bus: two DC sites (upstream/downstream).""" + sys = SYSTEMS["ieee34"]() + return dict( + sys=sys, + dc_sites={ + "upstream": DCSite( + bus="850", + bus_kv=24.9, + base_kw_per_phase=250.0, + models=(deploy("Llama-3.1-8B", 320), deploy("Llama-3.1-70B", 80), deploy("Llama-3.1-405B", 40)), + seed=0, + total_gpu_capacity=1200, + ), + "downstream": DCSite( + bus="834", + bus_kv=24.9, + base_kw_per_phase=300.0, + models=(deploy("Qwen3-30B-A3B", 216), deploy("Qwen3-235B-A22B", 96)), + seed=42, + total_gpu_capacity=1440, + ), + }, + pv_systems=[ + PVSystemSpec(bus="858", bus_kv=24.9, peak_kw=130.0), + PVSystemSpec(bus="852", bus_kv=24.9, peak_kw=65.0), + ], + time_varying_loads=[ + TimeVaryingLoadSpec(bus="860", bus_kv=24.9, peak_kw=80.0), + TimeVaryingLoadSpec(bus="844", bus_kv=24.9, peak_kw=120.0), + TimeVaryingLoadSpec(bus="858", bus_kv=24.9, peak_kw=50.0), + ], + training_base=None, + ofo_config=OFOConfig( + primal_step_size=0.05, + w_throughput=0.0001, + w_switch=1.0, + voltage_gradient_scale=1e6, + voltage_dual_step_size=1.0, + latency_dual_step_size=1.0, + sensitivity_update_interval=300, + sensitivity_perturbation_kw=50.0, + v_min=V_MIN, + v_max=V_MAX, + ), + tap_schedule=TapSchedule( + ( + ( + 1800, + TapPosition( + regulators={ + "creg2a": tap(10), + "creg2b": tap(10), + "creg2c": tap(10), + } + ), + ), + ) + ), + ) + + +def ieee123_experiment(training_trace: TrainingTrace | None = None) -> dict: # noqa: ARG001 + """IEEE 123-bus: four DC sites across zones.""" + sys = SYSTEMS["ieee123"]() + return dict( + sys=sys, + dc_sites={ + "z1_sw": DCSite( + bus="8", + bus_kv=4.16, + base_kw_per_phase=280.0, + models=(with_ramp(deploy("Llama-3.1-8B", 800), 1200, t_start=500, t_end=1000),), + seed=0, + total_gpu_capacity=1200, + ), + "z2_nw": DCSite( + bus="23", + bus_kv=4.16, + base_kw_per_phase=280.0, + models=(with_ramp(deploy("Qwen3-30B-A3B", 460), 600, t_start=1500, t_end=2500),), + seed=17, + total_gpu_capacity=1200, + ), + "z3_se": DCSite( + bus="60", + bus_kv=4.16, + base_kw_per_phase=224.0, + models=( + with_ramp(deploy("Llama-3.1-70B", 64), 96, t_start=700, t_end=1100), + deploy("Llama-3.1-405B", 72), + ), + seed=34, + total_gpu_capacity=960, + ), + "z4_ne": DCSite( + bus="105", + bus_kv=4.16, + base_kw_per_phase=224.0, + models=(with_ramp(deploy("Qwen3-235B-A22B", 96), 56, t_start=2000, t_end=2500),), + seed=51, + total_gpu_capacity=960, + ), + }, + pv_systems=[ + PVSystemSpec(bus="18", bus_kv=4.16, peak_kw=100.0), + PVSystemSpec(bus="48", bus_kv=4.16, peak_kw=250.0), + PVSystemSpec(bus="57", bus_kv=4.16, peak_kw=200.0), + ], + time_varying_loads=[ + TimeVaryingLoadSpec(bus="13", bus_kv=4.16, peak_kw=20.0), + TimeVaryingLoadSpec(bus="86", bus_kv=4.16, peak_kw=20.0), + TimeVaryingLoadSpec(bus="114", bus_kv=4.16, peak_kw=20.0), + ], + training_base=None, + ofo_config=OFOConfig( + primal_step_size=0.05, + w_throughput=0.0001, + w_switch=1.0, + voltage_gradient_scale=1e6, + voltage_dual_step_size=0.3, + latency_dual_step_size=1.0, + sensitivity_update_interval=300, + sensitivity_perturbation_kw=10.0, + v_min=V_MIN, + v_max=V_MAX, + ), + tap_schedule=None, + ) + + +EXPERIMENTS: dict[str, object] = { + "ieee13": ieee13_experiment, + "ieee34": ieee34_experiment, + "ieee123": ieee123_experiment, +} diff --git a/examples/offline/train_ppo.py b/examples/offline/train_ppo.py new file mode 100644 index 0000000..5bb5aa5 --- /dev/null +++ b/examples/offline/train_ppo.py @@ -0,0 +1,845 @@ +"""Train a PPO controller for batch-size voltage regulation. + +Trains one PPO model per datacenter site. For multi-DC systems (ieee34, +ieee123), each site gets its own policy while other sites use fixed +mid-range batch sizes during that site's training. + +Usage: + python train_ppo.py --system ieee13 --total-timesteps 200000 + python train_ppo.py --system ieee13 --obs-mode system-summary-only # summary-only obs + python train_ppo.py --system ieee13 --hidden-dims 256 256 256 --n-envs 8 +""" + +from __future__ import annotations + +import csv +import logging +import sys +from dataclasses import dataclass +from pathlib import Path + +import numpy as np +import tyro +from systems import ( + DT_CTRL, + DT_DC, + DT_GRID, + EXPERIMENTS, + POWER_AUG, + SPECS_CACHE_DIR, + SYSTEMS, + TOTAL_DURATION_S, + TRAINING_TRACE_PATH, + V_MAX, + V_MIN, + DCSite, + PVSystemSpec, + ScenarioOpenDSSGrid, + TimeVaryingLoadSpec, + deploy, + materialize_scenario, +) + +from openg2g.controller.tap_schedule import TapScheduleController +from openg2g.datacenter.config import ( + DatacenterConfig, + InferenceModelSpec, + ModelDeployment, + ReplicaSchedule, + TrainingRun, +) +from openg2g.datacenter.offline import OfflineDatacenter, OfflineWorkload +from openg2g.datacenter.workloads.inference import InferenceData +from openg2g.datacenter.workloads.training import TrainingTrace +from openg2g.grid.config import TapSchedule +from openg2g.rl.env import BatchSizeEnv, ObservationConfig, RewardConfig, SharedBatchSizeEnv, compute_bus_phase_groups, compute_zone_mask, resolve_action_mode + +logger = logging.getLogger(__name__) + +# ── Simulation factory ────────────────────────────────────────────────────── + + +def make_sim_factory( + exp: dict, + inference_data: InferenceData, +): + """Return a callable that builds fresh simulation components. + + Returns ``(make_sim, all_site_specs, all_replica_counts)`` where + ``make_sim()`` produces ``(dict[str, DatacenterBackend], grid, tap_ctrl)``. + + Library scenarios are replayed by reading the resolved fields stored on + each ``ScenarioRecord``; the rng-replay path is gone. Old libraries must + be upgraded via ``examples/offline/migrate_scenario_library.py``. + """ + sys = exp["sys"] + dc_sites: dict[str, DCSite] = exp["dc_sites"] + pv_systems_base = exp.get("pv_systems", []) + tvl_base = exp.get("time_varying_loads", []) + training_base = exp.get("training_base") + + is_single_dc = len(dc_sites) == 1 + if is_single_dc: + orig_sid = next(iter(dc_sites)) + dc_sites = {"_default": dc_sites[orig_sid]} + + all_site_specs: dict[str, tuple[InferenceModelSpec, ...]] = {} + all_replica_counts: dict[str, dict[str, int]] = {} + all_initial_batch_sizes: dict[str, dict[str, int]] = {} + site_inference: dict[str, InferenceData] = {} + for sid, site in dc_sites.items(): + specs = tuple(md.spec for md, _ in site.models) + all_site_specs[sid] = specs + all_replica_counts[sid] = {md.spec.model_label: sched.initial for md, sched in site.models} + all_initial_batch_sizes[sid] = {md.spec.model_label: md.initial_batch_size for md, _ in site.models} + site_inference[sid] = inference_data.filter_models(specs) + + _episode_counter = [0] + + def make_sim(scenario_override=None): + ep = _episode_counter[0] + _episode_counter[0] += 1 + + if scenario_override is not None: + sc = materialize_scenario(scenario_override, training_base=training_base) + sites = sc["dc_sites"] + # Library was built keyed by the experiment's DC site id (e.g. "default"); + # the grid expects "_default" for single-DC. Remap once here. + if is_single_dc and "_default" not in sites: + orig = next(iter(sites)) + sites = {"_default": sites[orig]} + pv_systems = sc["pv_systems"] + tvl = sc["tvl"] + training = sc["training_run"] + else: + sites = dc_sites + pv_systems = pv_systems_base + tvl = tvl_base + if training_base is not None: + training = TrainingRun( + n_gpus=training_base["n_gpus"], + trace=training_base["trace"], + target_peak_W_per_gpu=training_base["target_peak_W_per_gpu"], + ).at(t_start=training_base["t_start"], t_end=training_base["t_end"]) + else: + training = None + + # Build all datacenters. Per-model schedules combine the (initial, + # ramps) pair stored on site.models — replaces the old dict[str, int] + # replica_counts + separate inference_ramps fields. + datacenters: dict[str, OfflineDatacenter] = {} + for sid, site in sites.items(): + dc_config = DatacenterConfig(gpus_per_server=8, base_kw_per_phase=site.base_kw_per_phase) + replica_schedules: dict[str, ReplicaSchedule] = { + md.spec.model_label: sched for md, sched in site.models + } + initial_bs = {md.spec.model_label: md.initial_batch_size for md, _ in site.models} + wl_kwargs: dict = { + "inference_data": site_inference[sid], + "replica_schedules": replica_schedules, + "initial_batch_sizes": initial_bs, + } + if training is not None: + wl_kwargs["training"] = training + workload = OfflineWorkload(**wl_kwargs) + datacenters[sid] = OfflineDatacenter( + dc_config, + workload, + name=sid, + dt_s=DT_DC, + seed=site.seed, + power_augmentation=POWER_AUG, + total_gpu_capacity=site.total_gpu_capacity, + ) + + # Build grid then attach DCs (master's imperative pattern; old + # dc_loads / dc_bus kwargs no longer accepted by OpenDSSGrid). + dc_config_pf = DatacenterConfig(base_kw_per_phase=0).power_factor + exclude = tuple(sys.get("exclude_buses", ())) + grid = ScenarioOpenDSSGrid( + pv_systems=pv_systems, + time_varying_loads=tvl, + source_pu=sys["source_pu"], + dss_case_dir=sys["dss_case_dir"], + dss_master_file=sys["dss_master_file"], + dt_s=DT_GRID, + initial_tap_position=sys["initial_taps"], + exclude_buses=exclude, + ) + for sid, dc in datacenters.items(): + site = sites[sid] + grid.attach_dc( + dc, + bus=site.bus, + connection_type=site.connection_type, + power_factor=dc_config_pf, + ) + + tap_ctrl = TapScheduleController(schedule=TapSchedule(()), dt_s=DT_CTRL) + return datacenters, grid, tap_ctrl + + return make_sim, all_site_specs, all_replica_counts, all_initial_batch_sizes + + +# ── Training metrics callback ─────────────────────────────────────────────── + + +def _new_episode_acc() -> dict: + return { + "voltage": 0.0, + "throughput": 0.0, + "latency": 0.0, + "switch": 0.0, + "safe": 0.0, + "max_under": 0.0, + "max_over": 0.0, + "viol_frac_sum": 0.0, + "n_steps": 0, + } + + +class TrainingMetricsCallback: + """SB3 BaseCallback that aggregates per-episode reward components and voltage stats. + + Writes one CSV row per completed episode and mirrors the same metrics to + the SB3 TensorBoard logger so they show up alongside built-in PPO metrics. + Imported lazily inside ``main`` so SB3 isn't a hard import for tooling that + only wants the experiment definitions. + """ + + def __new__(cls, csv_path: Path): + # Late-bind to BaseCallback so this module is importable without SB3. + from stable_baselines3.common.callbacks import BaseCallback + + class _Impl(BaseCallback): + def __init__(self, csv_path: Path): + super().__init__(verbose=0) + self.csv_path = csv_path + self._per_env: dict[int, dict] = {} + self._ep_count = 0 + self._fp = None + self._writer = None + + def _on_training_start(self) -> None: # noqa: D401 - SB3 hook + self._fp = open(self.csv_path, "w", buffering=1, newline="") + self._writer = csv.writer(self._fp) + self._writer.writerow([ + "episode", + "timestep", + "ep_reward", + "ep_length", + "voltage", + "throughput", + "latency", + "switch", + "safe", + "max_undervoltage", + "max_overvoltage", + "mean_violation_frac", + ]) + + def _on_step(self) -> bool: + infos = self.locals.get("infos", []) or [] + for env_idx, info in enumerate(infos): + acc = self._per_env.setdefault(env_idx, _new_episode_acc()) + rc = info.get("reward_components") + if rc is not None: + acc["voltage"] += rc.get("voltage", 0.0) + acc["throughput"] += rc.get("throughput", 0.0) + acc["latency"] += rc.get("latency", 0.0) + acc["switch"] += rc.get("switch", 0.0) + acc["safe"] += rc.get("safe", 0.0) + vs = info.get("voltage_stats") + if vs is not None: + if vs.get("max_under", 0.0) > acc["max_under"]: + acc["max_under"] = vs["max_under"] + if vs.get("max_over", 0.0) > acc["max_over"]: + acc["max_over"] = vs["max_over"] + acc["viol_frac_sum"] += vs.get("violation_frac", 0.0) + acc["n_steps"] += 1 + + # Monitor wrapper injects an "episode" key on done + ep = info.get("episode") + if ep is not None: + self._ep_count += 1 + n = max(acc["n_steps"], 1) + row = [ + self._ep_count, + self.num_timesteps, + float(ep["r"]), + int(ep["l"]), + acc["voltage"], + acc["throughput"], + acc["latency"], + acc["switch"], + acc["safe"], + acc["max_under"], + acc["max_over"], + acc["viol_frac_sum"] / n, + ] + self._writer.writerow(row) + # Mirror to TB + self.logger.record("custom/voltage_pen", acc["voltage"]) + self.logger.record("custom/throughput_bonus", acc["throughput"]) + self.logger.record("custom/latency_pen", acc["latency"]) + self.logger.record("custom/switch_pen", acc["switch"]) + self.logger.record("custom/safe_bonus", acc["safe"]) + self.logger.record("custom/max_undervoltage", acc["max_under"]) + self.logger.record("custom/max_overvoltage", acc["max_over"]) + self.logger.record("custom/violation_frac", acc["viol_frac_sum"] / n) + self._per_env[env_idx] = _new_episode_acc() + return True + + def _on_training_end(self) -> None: # noqa: D401 - SB3 hook + if self._fp is not None: + self._fp.close() + self._fp = None + + return _Impl(csv_path) + + +# ── Plotting ──────────────────────────────────────────────────────────────── + + +def plot_training_progress(csv_path: Path, output_path: Path, label: str) -> Path | None: + """Read the per-episode metrics CSV and emit a 2x2 PNG dashboard. + + Returns the output path on success, or ``None`` if the CSV is empty. + """ + import matplotlib + + matplotlib.use("Agg") + import matplotlib.pyplot as plt + + rows: list[dict] = [] + with open(csv_path, "r", newline="") as fp: + for r in csv.DictReader(fp): + if any(v is None for v in r.values()): + continue # skip partial rows from interrupted buffered writes + rows.append({k: float(v) if k not in ("episode", "ep_length") else int(float(v)) for k, v in r.items()}) + if not rows: + return None + + eps = np.array([r["episode"] for r in rows]) + ep_reward = np.array([r["ep_reward"] for r in rows]) + voltage = np.array([r["voltage"] for r in rows]) + throughput = np.array([r["throughput"] for r in rows]) + latency = np.array([r["latency"] for r in rows]) + switch = np.array([r["switch"] for r in rows]) + max_under = np.array([r["max_undervoltage"] for r in rows]) + max_over = np.array([r["max_overvoltage"] for r in rows]) + viol_frac = np.array([r["mean_violation_frac"] for r in rows]) + + def smooth(arr: np.ndarray, window: int) -> np.ndarray: + if window <= 1 or len(arr) < 2: + return arr + kernel = np.ones(window) / window + return np.convolve(arr, kernel, mode="same") + + window = max(1, len(rows) // 20) + + fig, axes = plt.subplots(2, 2, figsize=(12, 9)) + + ax = axes[0, 0] + ax.plot(eps, ep_reward, alpha=0.3, label="raw") + ax.plot(eps, smooth(ep_reward, window), label=f"smooth (w={window})", linewidth=2) + ax.set_xlabel("Episode") + ax.set_ylabel("Episode reward") + ax.set_title(f"Learning curve — {label}") + ax.legend() + ax.grid(alpha=0.3) + + ax = axes[0, 1] + ax.plot(eps, smooth(voltage, window), label="voltage", color="C3") + ax.plot(eps, smooth(throughput, window), label="throughput", color="C2") + ax.plot(eps, smooth(latency, window), label="latency", color="C1") + ax.plot(eps, smooth(switch, window), label="switch", color="C0") + ax.set_xlabel("Episode") + ax.set_ylabel("Component reward (per episode)") + ax.set_title("Reward decomposition") + ax.axhline(0, color="k", linewidth=0.5) + ax.legend() + ax.grid(alpha=0.3) + + ax = axes[1, 0] + ax.plot(eps, smooth(max_under, window), label="max undervoltage", color="C0") + ax.plot(eps, smooth(max_over, window), label="max overvoltage", color="C3") + ax.set_xlabel("Episode") + ax.set_ylabel("Worst per-step deviation (pu)") + ax.set_title("Voltage violation magnitude") + ax.legend() + ax.grid(alpha=0.3) + + ax = axes[1, 1] + ax.plot(eps, smooth(viol_frac, window), color="C4") + ax.set_xlabel("Episode") + ax.set_ylabel("Mean fraction of bus-phases violating") + ax.set_title("Violation prevalence") + ax.set_ylim(0, max(0.05, float(viol_frac.max()) * 1.1)) + ax.grid(alpha=0.3) + + fig.suptitle(f"PPO training progress — {label}", fontsize=14) + fig.tight_layout() + fig.savefig(output_path, dpi=110) + plt.close(fig) + return output_path + + +# ── Main ──────────────────────────────────────────────────────────────────── + + +@dataclass +class Args: + system: str = "ieee13" + """System name (ieee13, ieee34, ieee123).""" + total_timesteps: int = 200_000 + """Total environment timesteps for training (per site). Counted across ALL parallel envs.""" + learning_rate: float = 1e-4 + """PPO learning rate (initial value if lr_schedule != 'constant').""" + lr_schedule: str = "constant" + """Learning rate schedule: 'constant' or 'linear' (decays to 0 over training).""" + n_steps: int = 3600 + """Rollout length per environment (one full simulated hour).""" + batch_size: int = 128 + """Minibatch size for PPO updates.""" + n_epochs: int = 10 + """Number of PPO epochs per update.""" + gamma: float = 0.99 + """Discount factor.""" + gae_lambda: float = 0.95 + """GAE lambda.""" + clip_range: float = 0.2 + """PPO clipping range.""" + ent_coef: float = 0.01 + """Entropy coefficient.""" + hidden_dims: tuple[int, ...] = (128, 128) + """Hidden layer widths for the MLP policy/value network. Pass multiple values for a deeper net, e.g. --hidden-dims 256 256 256.""" + w_voltage: float = 1000.0 + """Reward weight for voltage violations.""" + w_throughput: float = 0.0 + """Reward weight for throughput. Default 0 to isolate the voltage-control objective.""" + w_latency: float = 0.0 + """Reward weight for latency violations. Default 0 to isolate the voltage-control objective.""" + w_switch: float = 0.01 + """Reward weight for switching cost (penalizes |log2(batch_t) - log2(batch_{t-1})| summed over models). Without this, randomized-scenario runs converge to a near-uniform action distribution and the deterministic eval policy ends up flipping batch sizes on every step. 0.01 is a gentle prior — much smaller than voltage_pen so it acts as a tie-breaker, not a co-equal objective.""" + w_safe: float = 0.0 + """Small positive reward for staying in the safe voltage range. Each step adds +w_safe * (fraction of bus-phases within [v_min, v_max]). Default 0 (disabled). Recommended: 0.01.""" + switch_mode: str = "magnitude" + """Switch penalty mode: 'magnitude' (original log-ratio), 'binary' (fixed cost per change), or 'cooldown' (decaying cost, recent changes expensive).""" + switch_cooldown_tau: float = 30.0 + """Time constant (steps) for cooldown switch penalty. Only used with --switch-mode cooldown.""" + action_mode: str = "delta" + """Action space mode: 'delta' (per-model {-1,0,+1}, 3^N actions) or + 'coupled' (all models move by the same delta, 13 actions).""" + reward_clip: float = 0.0 + """If > 0, clip per-step reward to [-reward_clip, +inf). Prevents catastrophic scenarios from dominating PPO updates. Recommended: 1.0 (affects ~4% of episodes, leaving normal training signal intact).""" + vec_normalize: bool = True + """Wrap the vec env with SB3 VecNormalize (running obs/reward normalization). Strongly recommended: voltage_pen variance across scenarios is huge and tanks value-function learning without it.""" + obs_mode: str = "full-voltage" + """Voltage observation mode. Choices: + - "full-voltage": all bus-phase raw voltages + per-system summary (3 global scalars). + - "per-bus-summary": per-bus [min,max] phase voltage + per-zone-summary (if zones exist) or per-system summary. + - "per-zone-summary": per-zone summary only (3 scalars/zone, no raw voltages). Requires zones. + - "system-summary-only": 3 global scalars only (no raw voltages, no zone breakdown). + """ + shared: bool = True + """Train one shared PPO for all sites (instead of separate per-site).""" + total_duration_s: int = 3600 + """Episode length in simulated seconds. Lower for fast smoke tests (e.g. 300 = 5 simulated minutes).""" + n_envs: int = 1 + """Number of parallel rollout environments. >1 uses SubprocVecEnv (each subprocess builds its own OpenDSS instance to avoid global-state conflicts).""" + tensorboard: bool = True + """Write TensorBoard logs to /tb. View with `tensorboard --logdir /tb`.""" + plot: bool = True + """Generate matplotlib training-progress plots after each model finishes.""" + output_dir: str = "" + """Output directory (default: outputs//ppo).""" + log_level: str = "INFO" + """Logging verbosity.""" + scenario_library: str = "" + """Path to a scenario library .pkl built by build_scenario_library.py. When set, episodes are sampled from this library.""" + ofo_baseline: bool = False + """Subtract the OFO oracle's per-step voltage penalty from PPO's reward (requires --scenario-library). Disabling gives the raw voltage penalty as reward.""" + truncate_episode: bool = True + """Fast-forward past the initial quiet period and terminate after the last violation (requires --scenario-library with t_control_start/end). Disabling uses full 3600s episodes.""" + seed: int = 42 + """Random seed.""" + init_from: str = "" + """Path to a PPO checkpoint .zip to warm-start from (e.g. ppo_1152000_steps.zip). If a sibling ppo_vecnormalize_.pkl exists and --vec-normalize is set, its stats are loaded too. Hyperparameters stored in the checkpoint (lr, ent_coef, clip_range, …) are preserved; pass CLI flags only to change the env-side reward weights.""" + + +def main() -> None: + args = tyro.cli(Args) + + logging.basicConfig( + level=getattr(logging, args.log_level), + format="%(levelname)s %(asctime)s [%(name)s:%(lineno)d] %(message)s", + datefmt="%H:%M:%S", + ) + logging.getLogger("openg2g.coordinator").setLevel(logging.WARNING) + logging.getLogger("openg2g.datacenter").setLevel(logging.WARNING) + logging.getLogger("openg2g.grid").setLevel(logging.WARNING) + + if args.system not in EXPERIMENTS: + logger.error("Unknown system: %s. Available: %s", args.system, list(EXPERIMENTS.keys())) + sys.exit(1) + + # Load training trace from the canonical fixed path (matches master's + # run_ofo.py pattern; the JSON-driven training_trace_params pipeline is gone). + training_trace = TrainingTrace.ensure(TRAINING_TRACE_PATH) + + exp = EXPERIMENTS[args.system](training_trace) + script_dir = Path(__file__).resolve().parent + output_dir = Path(args.output_dir).resolve() if args.output_dir else script_dir / "outputs" / args.system / "ppo" + output_dir.mkdir(parents=True, exist_ok=True) + + # Collect all model specs across sites + dc_sites: dict[str, DCSite] = exp["dc_sites"] + all_specs: list[InferenceModelSpec] = [] + for site in dc_sites.values(): + all_specs.extend(md.spec for md, _ in site.models) + all_specs_tuple = tuple({s.model_label: s for s in all_specs}.values()) + + # Load data via the per-spec content-addressed cache under SPECS_CACHE_DIR. + # InferenceData.ensure regenerates only specs whose manifest is missing. + logger.info("Loading data for %s...", args.system) + inference_data = InferenceData.ensure( + SPECS_CACHE_DIR, + all_specs_tuple, + plot=False, + dt_s=float(DT_DC), + ) + + from openg2g.controller.ofo import LogisticModelStore + + logistic_models = LogisticModelStore.ensure( + SPECS_CACHE_DIR, + all_specs_tuple, + plot=False, + ) + + scenario_lib = None + if args.scenario_library: + from openg2g.rl.env import ScenarioLibrary + + scenario_lib = ScenarioLibrary(args.scenario_library) + logger.info( + "Loaded scenario library with %d scenarios from %s (ofo_baseline=%s, truncate=%s)", + len(scenario_lib), + args.scenario_library, + args.ofo_baseline, + args.truncate_episode, + ) + + make_sim, all_site_specs, all_replica_counts, all_initial_batch_sizes = make_sim_factory( + exp, inference_data, + ) + + # Probe grid for v_index and n_bus_phases + probe_dcs, probe_grid, _ = make_sim() + for dc in probe_dcs.values(): + dc.do_reset() + dc.start() + probe_grid.do_reset() + probe_grid.start() + v_index = probe_grid.v_index + n_bus_phases_full = len(v_index) + probe_grid.stop() + for dc in probe_dcs.values(): + dc.stop() + + _VALID_OBS_MODES = {"full-voltage", "per-bus-summary", "per-zone-summary", "system-summary-only"} + if args.obs_mode not in _VALID_OBS_MODES: + raise ValueError(f"--obs-mode must be one of {sorted(_VALID_OBS_MODES)}, got {args.obs_mode!r}") + + if args.action_mode not in ("delta", "coupled"): + raise ValueError(f"--action-mode must be 'delta' or 'coupled', got {args.action_mode!r}") + + # Zone info needed early for per-zone-summary validation + zones: dict[str, list[str]] | None = exp.get("sys", {}).get("zones") + + if args.obs_mode == "per-zone-summary" and zones is None: + raise ValueError("--obs-mode per-zone-summary requires the system to have zones defined (e.g. ieee123)") + + if args.obs_mode == "full-voltage": + n_bus_phases = n_bus_phases_full + bus_phase_groups = None + elif args.obs_mode == "per-bus-summary": + bus_phase_groups = compute_bus_phase_groups(v_index) + n_bus_phases = 2 * len(bus_phase_groups) + else: # per-zone-summary or system-summary-only + n_bus_phases = 0 + bus_phase_groups = None + + logger.info("Grid has %d bus-phase pairs across %d buses; obs_mode=%s, n_bus_phases=%d", + n_bus_phases_full, len(set(b for b, _ in v_index)), args.obs_mode, n_bus_phases) + + reward_config = RewardConfig( + w_voltage=args.w_voltage, + w_throughput=args.w_throughput, + w_latency=args.w_latency, + w_switch=args.w_switch, + w_safe=args.w_safe, + v_min=V_MIN, + v_max=V_MAX, + reward_clip=args.reward_clip, + switch_mode=args.switch_mode, + switch_cooldown_tau=args.switch_cooldown_tau, + action_mode=args.action_mode, + ) + + site_ids = list(all_site_specs.keys()) + + from stable_baselines3 import PPO + from stable_baselines3.common.callbacks import CallbackList, CheckpointCallback + from stable_baselines3.common.monitor import Monitor + from stable_baselines3.common.vec_env import DummyVecEnv, SubprocVecEnv, VecNormalize + + def _train_and_save(env_factory, label: str, save_name: str) -> None: + """Build a (possibly vectorized) env from ``env_factory`` and train one PPO model. + + ``env_factory`` is a zero-arg callable returning a fresh ``BatchSizeEnv`` + (or subclass). It is invoked once per parallel environment, wrapped with + ``Monitor``, and stitched into a vec-env. ``SubprocVecEnv`` is used when + ``args.n_envs > 1`` because each rollout needs its own OpenDSS instance + (OpenDSS holds global state, so multiple envs in one process collide). + """ + + def _make_one(): + env = env_factory() + return Monitor(env) + + if args.n_envs > 1: + vec_env = SubprocVecEnv([_make_one for _ in range(args.n_envs)]) + else: + vec_env = DummyVecEnv([_make_one]) + + if args.vec_normalize: + vn_init_ckpt = None + if args.init_from: + _p = Path(args.init_from) + _cand = _p.with_name( + _p.name.replace("ppo_", "ppo_vecnormalize_", 1).replace(".zip", ".pkl") + ) + if _cand.exists(): + vn_init_ckpt = _cand + if vn_init_ckpt is not None: + vec_env = VecNormalize.load(str(vn_init_ckpt), vec_env) + vec_env.training = True + vec_env.norm_reward = True + logger.info("Loaded VecNormalize stats from %s", vn_init_ckpt) + else: + if args.init_from: + logger.warning( + "--init-from set but no VecNormalize sibling pkl found; starting VecNormalize stats fresh." + ) + vec_env = VecNormalize( + vec_env, + norm_obs=True, + norm_reward=True, + clip_obs=10.0, + clip_reward=10.0, + gamma=args.gamma, + ) + + obs_dim = int(vec_env.observation_space.shape[0]) + if hasattr(vec_env.action_space, "nvec"): + n_act = int(len(vec_env.action_space.nvec)) + elif hasattr(vec_env.action_space, "n"): + n_act = int(vec_env.action_space.n) + else: + n_act = int(vec_env.action_space.shape[0]) + logger.info("") + logger.info("=" * 60) + logger.info("Training '%s': obs_dim=%d, n_actions=%d", label, obs_dim, n_act) + logger.info( + " shared=%s, n_envs=%d, hidden_dims=%s, vec_normalize=%s", + args.shared, + args.n_envs, + tuple(args.hidden_dims), + args.vec_normalize, + ) + logger.info( + " reward weights: voltage=%s throughput=%s latency=%s switch=%s safe=%s reward_clip=%s", + args.w_voltage, + args.w_throughput, + args.w_latency, + args.w_switch, + args.w_safe, + args.reward_clip, + ) + logger.info(" ofo_baseline=%s", args.ofo_baseline) + logger.info( + " switch_mode=%s switch_cooldown_tau=%s action_mode=%s", + args.switch_mode, + args.switch_cooldown_tau, + resolve_action_mode(reward_config), + ) + logger.info("=" * 60) + + checkpoint_cb = CheckpointCallback( + save_freq=max(args.n_steps * 10, 1), + save_path=str(output_dir / "checkpoints" / label), + name_prefix="ppo", + save_vecnormalize=args.vec_normalize, + ) + metrics_csv = output_dir / f"metrics_{label}.csv" + metrics_cb = TrainingMetricsCallback(metrics_csv) + callbacks = CallbackList([checkpoint_cb, metrics_cb]) + + tb_log = str(output_dir / "tb") if args.tensorboard else None + + if args.lr_schedule == "linear": + _lr_init = float(args.learning_rate) + lr_arg = lambda progress_remaining: progress_remaining * _lr_init + elif args.lr_schedule == "constant": + lr_arg = args.learning_rate + else: + raise ValueError(f"Unknown --lr-schedule: {args.lr_schedule!r} (expected 'constant' or 'linear')") + + if args.init_from: + model = PPO.load( + args.init_from, + env=vec_env, + device="auto", + tensorboard_log=tb_log, + ) + model.set_env(vec_env) + logger.info( + "Warm-started PPO from %s (num_timesteps=%d).", + args.init_from, getattr(model, "num_timesteps", 0), + ) + else: + model = PPO( + "MlpPolicy", + vec_env, + learning_rate=lr_arg, + n_steps=args.n_steps, + batch_size=args.batch_size, + n_epochs=args.n_epochs, + gamma=args.gamma, + gae_lambda=args.gae_lambda, + clip_range=args.clip_range, + ent_coef=args.ent_coef, + verbose=1, + seed=args.seed, + tensorboard_log=tb_log, + policy_kwargs=dict(net_arch=list(args.hidden_dims)), + ) + model.learn( + total_timesteps=args.total_timesteps, + callback=callbacks, + tb_log_name=label, + reset_num_timesteps=not bool(args.init_from), + ) + model_path = output_dir / save_name + model.save(str(model_path)) + logger.info("Saved '%s' model to %s.zip", label, model_path) + if args.vec_normalize: + # VecNormalize running stats MUST be reloaded at inference time, + # otherwise the policy sees unnormalized obs and acts nonsensically. + vn_path = output_dir / f"{save_name}_vecnormalize.pkl" + vec_env.save(str(vn_path)) + logger.info("Saved VecNormalize stats to %s", vn_path) + vec_env.close() + + if args.plot: + try: + plot_path = plot_training_progress(metrics_csv, output_dir / f"training_progress_{label}.png", label) + if plot_path is not None: + logger.info("Wrote training plot to %s", plot_path) + else: + logger.warning("No metrics rows in %s — skipping plot", metrics_csv) + except Exception as e: # noqa: BLE001 + logger.warning("Plotting failed for '%s': %s", label, e) + + if args.shared and len(site_ids) > 1: + # ── Shared multi-site PPO ── + logger.info("Training SHARED PPO for %d sites: %s", len(site_ids), site_ids) + + site_model_mapping = {sid: [s.model_label for s in all_site_specs[sid]] for sid in site_ids} + all_initial_bs_flat = { + label: bs + for sid in site_ids for label, bs in all_initial_batch_sizes[sid].items() + } + zone_summary = ( + {zname: tuple(zbuses) for zname, zbuses in zones.items()} + if zones is not None and args.obs_mode in ("per-zone-summary", "per-bus-summary") + else None + ) + obs_config = ObservationConfig.from_multi_site( + all_site_specs, all_replica_counts, n_bus_phases=n_bus_phases, + initial_batch_sizes=all_initial_bs_flat, + zone_summary=zone_summary, + bus_phase_groups=bus_phase_groups, + v_min=V_MIN, v_max=V_MAX + ) + + def shared_env_factory(): + return SharedBatchSizeEnv( + make_sim_fn=make_sim, + obs_config=obs_config, + site_model_mapping=site_model_mapping, + reward_config=reward_config, + logistic_models=logistic_models, + dt_ctrl=DT_CTRL, + total_duration_s=args.total_duration_s, + scenario_library=scenario_lib, + ofo_baseline=args.ofo_baseline and scenario_lib is not None, + truncate_episode=args.truncate_episode and scenario_lib is not None, + ) + + _train_and_save(shared_env_factory, "shared", "ppo_model_shared") + + else: + # ── Per-site PPO ── + logger.info("Training %d separate PPO(s): %s", len(site_ids), site_ids) + + for sid in site_ids: + specs = all_site_specs[sid] + replica_counts = all_replica_counts[sid] + + # Zone-local voltage filtering for systems with zone definitions + zone_buses = None + n_bp = n_bus_phases + if zones is not None and sid in zones and args.obs_mode == "full-voltage": + zone_buses = tuple(zones[sid]) + zone_mask = compute_zone_mask(v_index, zone_buses) + n_bp = int(np.sum(zone_mask)) + logger.info("Site '%s': using zone-local obs with %d/%d bus-phases", sid, n_bp, n_bus_phases_full) + + site_initial_bs = all_initial_batch_sizes[sid] + obs_config = ObservationConfig.from_model_specs( + specs, replica_counts, n_bus_phases=n_bp, initial_batch_sizes=site_initial_bs, zone_buses=zone_buses, v_min=V_MIN, v_max=V_MAX + ) + + def site_env_factory(_obs_config=obs_config, _sid=sid, _lib=scenario_lib): + return BatchSizeEnv( + make_sim_fn=make_sim, + obs_config=_obs_config, + agent_site_id=_sid, + reward_config=reward_config, + logistic_models=logistic_models, + dt_ctrl=DT_CTRL, + total_duration_s=args.total_duration_s, + scenario_library=_lib, + ofo_baseline=args.ofo_baseline and _lib is not None, + truncate_episode=args.truncate_episode and _lib is not None, + ) + + _train_and_save(site_env_factory, sid, f"ppo_model_{sid}") + + # For single-site, also save without site suffix for backwards compat + if len(site_ids) == 1: + import shutil + + src = output_dir / f"ppo_model_{site_ids[0]}.zip" + dst = output_dir / "ppo_model.zip" + shutil.copy2(src, dst) + logger.info("Copied to %s", dst) + + logger.info("All done. Models saved to %s", output_dir) + + +if __name__ == "__main__": + main() diff --git a/openg2g/controller/ppo.py b/openg2g/controller/ppo.py new file mode 100644 index 0000000..b64636c --- /dev/null +++ b/openg2g/controller/ppo.py @@ -0,0 +1,304 @@ +"""PPO-trained batch-size controller for voltage regulation. + +Loads a trained stable-baselines3 PPO model and uses it for deterministic +inference within the openg2g Controller interface. +""" + +from __future__ import annotations + +from fractions import Fraction +from pathlib import Path + +import numpy as np + +from openg2g.clock import SimulationClock +from openg2g.controller.base import Controller +from openg2g.datacenter.base import LLMBatchSizeControlledDatacenter, LLMDatacenterState +from openg2g.datacenter.command import DatacenterCommand, SetBatchSize +from openg2g.datacenter.config import InferenceModelSpec +from openg2g.events import EventEmitter +from openg2g.grid.command import GridCommand +from openg2g.grid.opendss import OpenDSSGrid +from openg2g.rl.env import ObservationConfig, build_observation, compute_zone_mask, decode_action + + +def _load_sb3_model(model_path: str | Path): + """Load an SB3 PPO model, handling .zip extension.""" + from stable_baselines3 import PPO + + resolved = Path(model_path).resolve() + load_path = str(resolved.with_suffix("")) if resolved.suffix == ".zip" else str(resolved) + return PPO.load(load_path) + + +def _load_vecnormalize(vecnormalize_path: str | Path, observation_space, action_space): + """Load a saved VecNormalize wrapper, restoring its running obs/reward statistics. + + Returns a VecNormalize whose ``normalize_obs`` reproduces what the policy + saw during training. We attach a 1-env DummyVecEnv whose obs/action spaces + match the trained model so SB3's loader is happy. + """ + import gymnasium as gym + from stable_baselines3.common.vec_env import DummyVecEnv, VecNormalize + + class _DummyEnv(gym.Env): + metadata = {"render_modes": []} + + def __init__(self, obs_space, act_space): + super().__init__() + self.observation_space = obs_space + self.action_space = act_space + self.render_mode = None + + def reset(self, *, seed=None, options=None): + return self.observation_space.sample(), {} + + def step(self, action): + return self.observation_space.sample(), 0.0, False, False, {} + + def close(self): + pass + + venv = DummyVecEnv([lambda: _DummyEnv(observation_space, action_space)]) + vn = VecNormalize.load(str(vecnormalize_path), venv) + vn.training = False + vn.norm_reward = False + return vn + + +def _detect_action_mode(action_space, n_models: int) -> str: + """Infer the action mode from a saved model's action space shape.""" + from gymnasium import spaces as gspaces + + if isinstance(action_space, gspaces.Discrete): + return "coupled" + if isinstance(action_space, gspaces.MultiDiscrete): + nvec = tuple(int(d) for d in action_space.nvec) + if all(d == 3 for d in nvec): + return "delta" + raise ValueError(f"Unrecognised action space {action_space!r} — expected Discrete (coupled) or MultiDiscrete([3]*N) (delta)") + + +class PPOBatchSizeController( + Controller[LLMBatchSizeControlledDatacenter[LLMDatacenterState], OpenDSSGrid], +): + """Batch-size controller using a trained PPO policy (single site). + + Args: + inference_models: Model specifications served in the datacenter. + model_path: Path to saved SB3 PPO model (.zip). + obs_config: Observation space configuration. + dt_s: Control interval (seconds). + site_id: Site identifier for multi-datacenter setups. + vecnormalize_path: Optional path to a saved VecNormalize stats pickle + (``*_vecnormalize.pkl``). If provided, observations are normalized + with the saved running mean/var before being passed to the policy + — this MUST match the wrapper used during training, otherwise the + policy sees out-of-distribution input. + """ + + def __init__( + self, + inference_models: tuple[InferenceModelSpec, ...], + *, + datacenter: LLMBatchSizeControlledDatacenter[LLMDatacenterState], + grid: OpenDSSGrid, + model_path: str | Path, + obs_config: ObservationConfig, + dt_s: Fraction = Fraction(1), + vecnormalize_path: str | Path | None = None, + ) -> None: + self._models = inference_models + self._datacenter = datacenter + self._grid = grid + self._sb3_model = _load_sb3_model(model_path) + self._vecnormalize = ( + _load_vecnormalize(vecnormalize_path, self._sb3_model.observation_space, self._sb3_model.action_space) + if vecnormalize_path is not None + else None + ) + self._obs_config = obs_config + self._dt_s = dt_s + self._feasible = {s.model_label: tuple(s.feasible_batch_sizes) for s in inference_models} + self._prev_batch: dict[str, int] = {} + self._zone_mask: np.ndarray | None = None + self._zone_mask_computed = False + # Detect action mode from model's action space + self._action_mode = _detect_action_mode(self._sb3_model.action_space, len(inference_models)) + n_feasible = min(len(f) for f in self._feasible.values()) + self._coupled_max_shift = n_feasible - 1 + self._init_prev_batch() + + def _init_prev_batch(self) -> None: + self._prev_batch = { + s.model_label: self._obs_config.get_initial_batch(s.model_label) for s in self._models + } + + @property + def dt_s(self) -> Fraction: + return self._dt_s + + def reset(self) -> None: + self._init_prev_batch() + self._zone_mask_computed = False + + def step( + self, + clock: SimulationClock, + events: EventEmitter, + ) -> list[DatacenterCommand | GridCommand]: + datacenter = self._datacenter + grid = self._grid + # Lazily compute zone mask on first step (grid must be started) + if not self._zone_mask_computed: + if self._obs_config.zone_buses is not None: + self._zone_mask = compute_zone_mask(grid.v_index, self._obs_config.zone_buses) + self._zone_mask_computed = True + + obs = build_observation(grid, datacenter, self._obs_config, self._prev_batch, self._zone_mask) + if self._vecnormalize is not None: + obs = self._vecnormalize.normalize_obs(obs) + action, _ = self._sb3_model.predict(obs, deterministic=True) + + batch_sizes = decode_action( + action, + self._action_mode, + self._obs_config.model_labels, + self._feasible, + self._prev_batch, + self._coupled_max_shift, + ) + + self._prev_batch = batch_sizes + events.emit("controller.ppo.step", {"batch_size_by_model": batch_sizes}) + return [SetBatchSize(batch_size_by_model=batch_sizes, target=datacenter)] + + +class SharedPPOBatchSizeController( + Controller[LLMBatchSizeControlledDatacenter[LLMDatacenterState], OpenDSSGrid], +): + """Shared PPO controller that outputs batch sizes for ALL sites jointly. + + Requires the coordinator to have all datacenter sites registered. + Outputs one ``SetBatchSize`` command per site. + + Args: + model_path: Path to saved SB3 PPO model (.zip). + obs_config: Combined observation config (all models from all sites). + site_model_mapping: Maps site_id → list of model labels at that site. + dt_s: Control interval (seconds). + """ + + def __init__( + self, + *, + datacenter: LLMBatchSizeControlledDatacenter[LLMDatacenterState], + grid: OpenDSSGrid, + model_path: str | Path, + obs_config: ObservationConfig, + site_model_mapping: dict[str, list[str]], + dt_s: Fraction = Fraction(1), + vecnormalize_path: str | Path | None = None, + ) -> None: + self._datacenter = datacenter + self._grid = grid + self._sb3_model = _load_sb3_model(model_path) + self._vecnormalize = ( + _load_vecnormalize(vecnormalize_path, self._sb3_model.observation_space, self._sb3_model.action_space) + if vecnormalize_path is not None + else None + ) + self._obs_config = obs_config + self._site_model_mapping = site_model_mapping + self._dt_s = dt_s + self._feasible = dict(obs_config.feasible_batch_sizes) + self._prev_batch: dict[str, int] = {} + self._zone_mask: np.ndarray | None = None + self._zone_mask_computed = False + # Detect action mode from saved model so policies trained with + # Detect action mode so coupled vs delta decodes correctly. + n_models_total = len(self._obs_config.model_labels) + self._action_mode = _detect_action_mode(self._sb3_model.action_space, n_models_total) + n_feasible = min(len(f) for f in self._feasible.values()) + self._coupled_max_shift = n_feasible - 1 + # Optional list of all datacenters (set by attach_datacenters() before + # coord.run()). The Coordinator's step loop only passes ONE DC per + # controller (resolved by _site_id), but a shared policy trained on + # observations from ALL sites needs every DC's batch/itl/replicas/power + # state. When set, step() uses this list and ignores the single DC the + # coordinator hands in. + self._all_datacenters: list = [] + self._init_prev_batch() + + def attach_datacenters(self, datacenters: list) -> None: + """Register all datacenter backends so the shared policy sees the + joint multi-site observation. Call once after the Coordinator is + constructed and before ``coord.run()``.""" + self._all_datacenters = list(datacenters) + + def _init_prev_batch(self) -> None: + self._prev_batch = {label: self._obs_config.get_initial_batch(label) for label in self._obs_config.model_labels} + + @property + def dt_s(self) -> Fraction: + return self._dt_s + + def reset(self) -> None: + self._init_prev_batch() + self._zone_mask_computed = False + + def step( + self, + clock: SimulationClock, + events: EventEmitter, + ) -> list[DatacenterCommand | GridCommand]: + datacenter = self._datacenter + grid = self._grid + if not self._zone_mask_computed: + if self._obs_config.zone_buses is not None: + self._zone_mask = compute_zone_mask(grid.v_index, self._obs_config.zone_buses) + self._zone_mask_computed = True + + # Build obs from all DCs. The Coordinator's step loop hands controllers + # only their own DC, but a shared policy needs the joint state. + # attach_datacenters() must be called once before coord.run() to + # register the full multi-site list; falls back to the single agent + # DC for legacy single-site behaviour. + dc_arg = self._all_datacenters if self._all_datacenters else datacenter + obs = build_observation(grid, dc_arg, self._obs_config, self._prev_batch, self._zone_mask) + if self._vecnormalize is not None: + obs = self._vecnormalize.normalize_obs(obs) + action, _ = self._sb3_model.predict(obs, deterministic=True) + + all_batch = decode_action( + action, + self._action_mode, + self._obs_config.model_labels, + self._feasible, + self._prev_batch, + self._coupled_max_shift, + ) + + self._prev_batch = all_batch + events.emit("controller.ppo.step", {"batch_size_by_model": all_batch}) + + # Emit one SetBatchSize per site. Master's command-routing requires a + # target= DC object per command. SharedPPOBatchSizeController is a + # multi-DC controller; routing each site's command to the right DC + # needs the controller to track per-sid DC references (added below + # only if/when the shared-PPO inference path is exercised). For now + # this raises so callers see a clear migration boundary instead of + # silently routing every site to the same DC. + if len(self._site_model_mapping) > 1: + raise NotImplementedError( + "SharedPPOBatchSizeController multi-DC routing needs migration: " + "the controller must accept a dict[str, DatacenterBackend] in " + "__init__ to route per-site SetBatchSize commands. Single-site " + "shared mode still works." + ) + commands: list[DatacenterCommand | GridCommand] = [] + for _sid, labels in self._site_model_mapping.items(): + site_batch = {label: all_batch[label] for label in labels if label in all_batch} + if site_batch: + commands.append(SetBatchSize(batch_size_by_model=site_batch, target=datacenter)) + return commands diff --git a/openg2g/controller/rule_based.py b/openg2g/controller/rule_based.py index 6e1a388..3c3849b 100644 --- a/openg2g/controller/rule_based.py +++ b/openg2g/controller/rule_based.py @@ -54,7 +54,7 @@ class RuleBasedConfig(BaseModel): step_size: float = 10.0 v_min: float = 0.95 v_max: float = 1.05 - deadband: float = 0.001 + deadband: float = 0.0001 latency_guard: bool = True @@ -77,6 +77,7 @@ def __init__( config: RuleBasedConfig, dt_s: Fraction = Fraction(1), exclude_buses: tuple[str, ...] = (), + zone_buses: tuple[str, ...] | None = None, initial_batch_sizes: dict[str, int] | None = None, ) -> None: model_specs = list(inference_models) @@ -86,6 +87,13 @@ def __init__( self._config = config self._models = model_specs self._exclude_lower = {b.lower() for b in exclude_buses} + # Zone-local observation: when set, only buses in this set contribute + # to the worst-violation scan. Used in multi-DC topologies (ieee123) + # to give each site credit only for the part of the network it can + # actually move. None preserves the original global-scan behavior. + self._zone_lower: set[str] | None = ( + {b.lower() for b in zone_buses} if zone_buses is not None else None + ) self._initial_batch_sizes = initial_batch_sizes or {} # Build per-model feasible batch list (sorted ascending) @@ -102,13 +110,15 @@ def __init__( } logger.info( - "RuleBasedBatchSizeController: %d models, dt=%s s, step_size=%.2f, deadband=%.4f, v=[%.2f, %.2f]", + "RuleBasedBatchSizeController: %d models, dt=%s s, step_size=%.2f, deadband=%.4f, v=[%.2f, %.2f], zone_local=%s (n_zone_buses=%d)", len(model_specs), dt_s, config.step_size, config.deadband, config.v_min, config.v_max, + self._zone_lower is not None, + len(self._zone_lower) if self._zone_lower is not None else 0, ) @property @@ -136,7 +146,10 @@ def step( worst_over = 0.0 # magnitude of worst overvoltage (positive) for bus in voltages.buses(): - if bus.lower() in self._exclude_lower: + blow = bus.lower() + if blow in self._exclude_lower: + continue + if self._zone_lower is not None and blow not in self._zone_lower: continue pv = voltages[bus] for v in (pv.a, pv.b, pv.c): diff --git a/openg2g/grid/opendss.py b/openg2g/grid/opendss.py index e77b049..c64e68c 100644 --- a/openg2g/grid/opendss.py +++ b/openg2g/grid/opendss.py @@ -770,7 +770,7 @@ def _build_phase_to_reg_map(reg_map: dict[str, tuple[str, int]]) -> dict[int, st continue if phase in phase_to_reg: - logger.info( + logger.debug( "Multiple RegControls on phase %s: '%s' and '%s'. " "Phase shorthand (a/b/c) disabled for this phase; use regulator names.", _PHASE_NAME[phase], diff --git a/openg2g/rl/env.py b/openg2g/rl/env.py new file mode 100644 index 0000000..4cdb7fc --- /dev/null +++ b/openg2g/rl/env.py @@ -0,0 +1,914 @@ +"""Gymnasium environment wrapping the openg2g simulation for RL training.""" + +from __future__ import annotations + +import math +import typing +from collections.abc import Callable +from dataclasses import dataclass +from fractions import Fraction +from typing import Any + +import gymnasium +import numpy as np +from gymnasium import spaces + +from openg2g.clock import SimulationClock +from openg2g.common import ThreePhase +from openg2g.controller.ofo import LogisticModelStore +from openg2g.controller.tap_schedule import TapScheduleController +from openg2g.coordinator import SimulationLog +from openg2g.datacenter.base import DatacenterBackend, LLMDatacenterState +from openg2g.datacenter.command import SetBatchSize +from openg2g.datacenter.config import InferenceModelSpec +from openg2g.events import EventEmitter +from openg2g.grid.base import GridBackend + +# ── Observation & reward config ───────────────────────────────────────────── + + +@dataclass(frozen=True) +class ObservationConfig: + """Fixed observation space configuration. + + The observation vector layout is:: + + [0 .. M-1] voltage vector (M bus-phase magnitudes, pu) + + Then, if ``zone_summary`` is set (per-zone voltage summary mode):: + + [M + 3*z + 0] zone z: worst undervoltage magnitude + [M + 3*z + 1] zone z: worst overvoltage magnitude + [M + 3*z + 2] zone z: fraction of bus-phases in violation + [M + 3*n_zones + 5*i] model i features (see below) + + Otherwise (global summary mode):: + + [M] worst undervoltage magnitude + [M+1] worst overvoltage magnitude + [M+2] fraction of bus-phases in violation + [M+3 + 5*i + 0] model i features (see below) + + Per-model features (5 values each):: + + + 0 normalized batch size (log2 scale) [0,1] + + 1 ITL / deadline [0,3] + + 2 active_replicas / max_replicas [0,1] + + 3 total 3-phase power (MW) + + 4 delta batch from prev step (log2 norm) [-1,1] + + Where M = ``n_bus_phases``. Set M = 0 for summary-only observations. + When ``zone_buses`` is set, M is the number of bus-phases within those + buses (subset of the full grid), and violation summaries are computed + over that zone only. + When ``zone_summary`` is set, the 3 global violation scalars are replaced + by 3 scalars per zone. Use with M = 0 for a compact multi-zone observation. + When ``bus_phase_groups`` is set, M must equal ``2 * n_buses`` and the + first M slots contain [min_phase_voltage, max_phase_voltage] per bus + instead of raw per-phase voltages. Each entry in ``bus_phase_groups`` + is a tuple of indices into the full ``v_vec`` for that bus's phases. + """ + + model_labels: tuple[str, ...] + feasible_batch_sizes: dict[str, tuple[int, ...]] + itl_deadlines: dict[str, float] + max_replicas: dict[str, int] + n_bus_phases: int + initial_batch_sizes: dict[str, int] | None = None + zone_buses: tuple[str, ...] | None = None + zone_summary: dict[str, tuple[str, ...]] | None = None + bus_phase_groups: tuple[tuple[int, ...], ...] | None = None + v_min: float = 0.95 + v_max: float = 1.05 + + def get_initial_batch(self, label: str) -> int: + """Return the initial batch size for *label*, falling back to midpoint of feasible sizes.""" + if self.initial_batch_sizes is not None and label in self.initial_batch_sizes: + return self.initial_batch_sizes[label] + fbs = self.feasible_batch_sizes[label] + return fbs[len(fbs) // 2] + + @property + def n_models(self) -> int: + return len(self.model_labels) + + @property + def n_zone_summary_slots(self) -> int: + return len(self.zone_summary) * 3 if self.zone_summary else 0 + + @property + def obs_dim(self) -> int: + if self.zone_summary: + return self.n_bus_phases + self.n_zone_summary_slots + 5 * self.n_models + return self.n_bus_phases + 3 + 5 * self.n_models + + @classmethod + def from_model_specs( + cls, + specs: tuple[InferenceModelSpec, ...], + replica_counts: dict[str, int], + n_bus_phases: int, + initial_batch_sizes: dict[str, int] | None = None, + zone_buses: tuple[str, ...] | None = None, + v_min: float = 0.95, + v_max: float = 1.05, + ) -> ObservationConfig: + return cls( + model_labels=tuple(s.model_label for s in specs), + feasible_batch_sizes={s.model_label: tuple(s.feasible_batch_sizes) for s in specs}, + itl_deadlines={s.model_label: s.itl_deadline_s for s in specs}, + max_replicas={s.model_label: replica_counts.get(s.model_label, 1) for s in specs}, + n_bus_phases=n_bus_phases, + initial_batch_sizes=initial_batch_sizes, + zone_buses=zone_buses, + v_min=v_min, + v_max=v_max, + ) + + @classmethod + def from_multi_site( + cls, + site_specs: dict[str, tuple[InferenceModelSpec, ...]], + site_replica_counts: dict[str, dict[str, int]], + n_bus_phases: int, + initial_batch_sizes: dict[str, int] | None = None, + zone_buses: tuple[str, ...] | None = None, + zone_summary: dict[str, tuple[str, ...]] | None = None, + bus_phase_groups: tuple[tuple[int, ...], ...] | None = None, + v_min: float = 0.95, + v_max: float = 1.05, + ) -> ObservationConfig: + """Build config combining models from ALL sites.""" + all_labels: list[str] = [] + all_feasible: dict[str, tuple[int, ...]] = {} + all_deadlines: dict[str, float] = {} + all_max_rep: dict[str, int] = {} + for sid in site_specs: + for spec in site_specs[sid]: + label = spec.model_label + all_labels.append(label) + all_feasible[label] = tuple(spec.feasible_batch_sizes) + all_deadlines[label] = spec.itl_deadline_s + all_max_rep[label] = site_replica_counts.get(sid, {}).get(label, 1) + return cls( + model_labels=tuple(all_labels), + feasible_batch_sizes=all_feasible, + itl_deadlines=all_deadlines, + max_replicas=all_max_rep, + n_bus_phases=n_bus_phases, + initial_batch_sizes=initial_batch_sizes, + zone_buses=zone_buses, + zone_summary=zone_summary, + bus_phase_groups=bus_phase_groups, + v_min=v_min, + v_max=v_max, + ) + + +@dataclass(frozen=True) +class RewardConfig: + """Reward function weights.""" + + w_voltage: float = 1000.0 + w_throughput: float = 0.01 + w_latency: float = 10.0 + w_switch: float = 0.1 + v_min: float = 0.95 + v_max: float = 1.05 + reward_clip: float = 0.0 + """If > 0, clip per-step reward to [-reward_clip, +inf). Prevents + catastrophic scenarios from dominating PPO updates. Recommended: 1.0.""" + switch_mode: str = "magnitude" + """Switch penalty mode: + - ``"magnitude"``: ``-w_switch * |log2(b_t) - log2(b_{t-1})|`` (original). + - ``"binary"``: ``-w_switch`` per model whenever batch size changes. + - ``"cooldown"``: ``-w_switch * exp(-steps_since_last_change / switch_cooldown_tau)`` + per model whenever batch size changes (recent changes are expensive). + """ + w_safe: float = 0.0 + """Small positive reward for keeping voltages in range. Each step the agent + receives ``+w_safe * (fraction of bus-phases within [v_min, v_max])``. + Default 0 (disabled). Recommended starting value: 0.01.""" + switch_cooldown_tau: float = 30.0 + """Time constant (in steps) for the cooldown switch penalty. + Only used when ``switch_mode="cooldown"``.""" + action_mode: str = "delta" + """Action space mode: ``"delta"`` (per-model {-1,0,+1}, MultiDiscrete [3]*N) + or ``"coupled"`` (single Discrete action shifting all models by the same delta).""" + + +# ── Zone voltage filtering ────────────────────────────────────────────────── + + +def compute_zone_mask(v_index: list[tuple[str, int]], zone_buses: tuple[str, ...]) -> np.ndarray: + """Boolean mask selecting bus-phases belonging to *zone_buses*.""" + bus_set = {b.lower() for b in zone_buses} + return np.array([bus.lower() in bus_set for bus, _ph in v_index], dtype=bool) + + +def compute_bus_phase_groups(v_index: list[tuple[str, int]]) -> tuple[tuple[int, ...], ...]: + """Group v_index positions by bus name. + + Returns one tuple of indices per unique bus, in the order buses first + appear in *v_index*. Used to compute per-bus min/max voltages. + """ + groups: dict[str, list[int]] = {} + for i, (bus, _ph) in enumerate(v_index): + groups.setdefault(bus, []).append(i) + return tuple(tuple(idx) for idx in groups.values()) + + +def resolve_action_mode(cfg: RewardConfig) -> str: + """Return the action mode from the reward config.""" + return cfg.action_mode + + +def decode_action( + action: np.ndarray, + action_mode: str, + model_labels: tuple[str, ...], + feasible_batch_sizes: dict[str, tuple[int, ...]], + prev_batch: dict[str, int], + coupled_max_shift: int = 6, +) -> dict[str, int]: + """Decode a raw action array into batch-size assignments. + + This is the single source of truth for action → batch-size mapping, + shared between the training env and the inference controller. + """ + + def _apply_delta(label: str, delta: int) -> int: + feasible = feasible_batch_sizes[label] + prev_b = prev_batch.get(label, feasible[len(feasible) // 2]) + try: + cur_idx = feasible.index(prev_b) + except ValueError: + cur_idx = len(feasible) // 2 + new_idx = max(0, min(len(feasible) - 1, cur_idx + delta)) + return feasible[new_idx] + + if action_mode == "coupled": + delta = int(action) - coupled_max_shift + return {label: _apply_delta(label, delta) for label in model_labels} + + if action_mode == "delta": + return { + label: _apply_delta(label, int(action[i]) - 1) + for i, label in enumerate(model_labels) + } + + raise ValueError(f"Unknown action_mode: {action_mode!r}") + + +# ── Observation & reward builders ─────────────────────────────────────────── + + +def build_observation( + grid: GridBackend, + datacenter: DatacenterBackend[LLMDatacenterState] | list[DatacenterBackend[LLMDatacenterState]], + obs_config: ObservationConfig, + prev_batch: dict[str, int], + zone_mask: np.ndarray | None = None, + zone_masks: dict[str, np.ndarray] | None = None, +) -> np.ndarray: + """Build a fixed-size observation vector from grid and datacenter state. + + *datacenter* may be a single backend or a list (for shared multi-site). + When a list, per-model features are gathered from all DCs in order. + *zone_mask* filters the voltage vector to the agent's zone (single-zone mode). + *zone_masks* provides per-zone boolean masks for per-zone summary obs + (used when obs_config.zone_summary is set). + """ + obs = np.zeros(obs_config.obs_dim, dtype=np.float32) + M = obs_config.n_bus_phases + + # Voltage vector (optionally filtered by zone) + v_vec_full = grid.voltages_vector() + v_vec = v_vec_full[zone_mask] if zone_mask is not None else v_vec_full + + if obs_config.bus_phase_groups is not None: + # Per-bus min/max: [min_phase, max_phase] for each bus + for k, indices in enumerate(obs_config.bus_phase_groups): + ph_v = v_vec_full[list(indices)] + obs[2 * k] = float(np.min(ph_v)) + obs[2 * k + 1] = float(np.max(ph_v)) + elif M > 0: + n = min(len(v_vec), M) + obs[:n] = v_vec[:n].astype(np.float32) + + v_min_cfg, v_max_cfg = obs_config.v_min, obs_config.v_max + + if obs_config.zone_summary: + # Per-zone violation summary — replaces the 3 global scalars + base = M + for zone_name, zone_bus_list in obs_config.zone_summary.items(): + mask_z = (zone_masks or {}).get(zone_name) + v_z = v_vec_full[mask_z] if mask_z is not None else v_vec_full + under_z = np.maximum(v_min_cfg - v_z, 0.0) + over_z = np.maximum(v_z - v_max_cfg, 0.0) + n_z = len(v_z) + obs[base + 0] = float(np.max(under_z)) if n_z > 0 else 0.0 + obs[base + 1] = float(np.max(over_z)) if n_z > 0 else 0.0 + obs[base + 2] = float(np.count_nonzero(under_z > 0) + np.count_nonzero(over_z > 0)) / max(n_z, 1) + base += 3 + else: + # Global violation summary + under = np.maximum(v_min_cfg - v_vec, 0.0) + over = np.maximum(v_vec - v_max_cfg, 0.0) + n_total = len(v_vec) + obs[M] = float(np.max(under)) if n_total > 0 else 0.0 + obs[M + 1] = float(np.max(over)) if n_total > 0 else 0.0 + obs[M + 2] = float(np.count_nonzero(under > 0) + np.count_nonzero(over > 0)) / max(n_total, 1) + + # Per-model features — gather DC states + dcs = datacenter if isinstance(datacenter, list) else [datacenter] + # Build a merged state dict from all DCs + batch_by_model: dict[str, int] = {} + itl_by_model: dict[str, float] = {} + replicas_by_model: dict[str, int] = {} + total_power_w = 0.0 + for dc in dcs: + st = dc.state + batch_by_model.update(st.batch_size_by_model) + itl_by_model.update(st.observed_itl_s_by_model) + replicas_by_model.update(st.active_replicas_by_model) + total_power_w += st.power_w.a + st.power_w.b + st.power_w.c + + base_offset = M + (obs_config.n_zone_summary_slots if obs_config.zone_summary else 3) + for i, label in enumerate(obs_config.model_labels): + base = base_offset + 5 * i + feasible = obs_config.feasible_batch_sizes[label] + log2_min = math.log2(feasible[0]) + log2_max = math.log2(feasible[-1]) + log2_range = log2_max - log2_min if log2_max > log2_min else 1.0 + + batch = batch_by_model.get(label, feasible[len(feasible) // 2]) + obs[base + 0] = (math.log2(max(batch, 1)) - log2_min) / log2_range + + itl = itl_by_model.get(label, float("nan")) + deadline = obs_config.itl_deadlines[label] + obs[base + 1] = float(np.clip(itl / deadline, 0.0, 3.0)) if not math.isnan(itl) else 0.0 + + replicas = replicas_by_model.get(label, 0) + max_rep = obs_config.max_replicas[label] + obs[base + 2] = replicas / max(max_rep, 1) + + obs[base + 3] = total_power_w / 1e6 # MW + + prev_b = prev_batch.get(label, batch) + if prev_b > 0 and batch > 0: + delta = (math.log2(batch) - math.log2(prev_b)) / log2_range + obs[base + 4] = float(np.clip(delta, -1.0, 1.0)) + + return obs + + +def compute_reward( + grid: GridBackend, + datacenter: DatacenterBackend[LLMDatacenterState] | list[DatacenterBackend[LLMDatacenterState]], + obs_config: ObservationConfig, + reward_config: RewardConfig, + prev_batch: dict[str, int], + curr_batch: dict[str, int], + logistic_models: LogisticModelStore | None = None, + steps_since_change: dict[str, int] | None = None, +) -> tuple[float, dict[str, float], dict[str, float]]: + """Compute per-step scalar reward. + + Returns: + ``(total_reward, reward_components, voltage_stats)``. + + ``reward_components`` is a signed breakdown by source — keys + ``"voltage"``, ``"throughput"``, ``"latency"``, ``"switch"``. + Penalties are negative, bonuses positive; the sum equals + ``total_reward``. + + ``voltage_stats`` reports per-step grid health: ``"max_under"`` + (worst undervoltage magnitude in pu), ``"max_over"`` (worst + overvoltage), and ``"violation_frac"`` (fraction of bus-phases + currently in violation). All computed over the full grid. + """ + # Voltage violation penalty (over ALL buses, not just zone) + v_vec = grid.voltages_vector() + v_min_cfg, v_max_cfg = reward_config.v_min, reward_config.v_max + under = np.maximum(v_min_cfg - v_vec, 0.0) + over = np.maximum(v_vec - v_max_cfg, 0.0) + voltage_penalty = float(np.sum(under**2) + np.sum(over**2)) + voltage_term = -reward_config.w_voltage * voltage_penalty + + n_total = len(v_vec) + n_violating = int(np.count_nonzero(under > 0) + np.count_nonzero(over > 0)) + safe_frac = (n_total - n_violating) / max(n_total, 1) + safe_term = reward_config.w_safe * safe_frac + + voltage_stats = { + "max_under": float(np.max(under)) if n_total > 0 else 0.0, + "max_over": float(np.max(over)) if n_total > 0 else 0.0, + "violation_frac": float(n_violating) / max(n_total, 1), + } + + # Gather DC states + dcs = datacenter if isinstance(datacenter, list) else [datacenter] + itl_by_model: dict[str, float] = {} + for dc in dcs: + itl_by_model.update(dc.state.observed_itl_s_by_model) + + throughput_term = 0.0 + latency_term = 0.0 + switch_term = 0.0 + switch_mode = reward_config.switch_mode + for label in obs_config.model_labels: + feasible = obs_config.feasible_batch_sizes[label] + batch = curr_batch.get(label, feasible[len(feasible) // 2]) + + if logistic_models is not None: + th_fit = logistic_models.throughput(label) + th_max = th_fit.eval(feasible[-1]) + th_max = max(th_max, 1e-9) + throughput_term += reward_config.w_throughput * th_fit.eval(batch) / th_max + else: + log2_max = math.log2(feasible[-1]) + throughput_term += reward_config.w_throughput * math.log2(max(batch, 1)) / log2_max + + itl = itl_by_model.get(label, float("nan")) + deadline = obs_config.itl_deadlines[label] + if not math.isnan(itl) and itl > deadline: + latency_term -= reward_config.w_latency * (itl - deadline) / deadline + + prev_b = prev_batch.get(label, batch) + if prev_b > 0 and batch > 0 and batch != prev_b: + if switch_mode == "magnitude": + switch_term -= reward_config.w_switch * abs(math.log2(batch) - math.log2(prev_b)) + elif switch_mode == "binary": + switch_term -= reward_config.w_switch + elif switch_mode == "cooldown": + ssc = (steps_since_change or {}).get(label, 999) + switch_term -= reward_config.w_switch * math.exp(-ssc / reward_config.switch_cooldown_tau) + + reward = voltage_term + throughput_term + latency_term + switch_term + safe_term + components = { + "voltage": voltage_term, + "throughput": throughput_term, + "latency": latency_term, + "switch": switch_term, + "safe": safe_term, + } + return reward, components, voltage_stats + + +# ── Simulation factory type ───────────────────────────────────────────────── + +SimComponents = tuple[ + dict[str, DatacenterBackend], # datacenters keyed by site_id + GridBackend, # grid + TapScheduleController | None, # tap controller (optional) +] + +MakeSimFn = Callable[..., SimComponents] + + +# ── Scenario library ─────────────────────────────────────────────────────── + + +class ScenarioLibrary: + """Pre-screened scenario bank for PPO training. + + Loaded from the ``library.pkl`` produced by ``build_scenario_library.py``. + Each scenario stores the seed (for deterministic replay via + ``randomize_scenario``), the per-second OFO voltage penalty trace (for + OFO-difference reward), and optionally ``t_control_start`` / + ``t_control_end`` (for episode truncation). + """ + + def __init__(self, path: str) -> None: + import pickle + import sys + import types + from pathlib import Path + + # Register a stub module so pickle can resolve + # build_scenario_library.ScenarioRecord without requiring the + # script to be on sys.path. + if "build_scenario_library" not in sys.modules: + _stub = types.ModuleType("build_scenario_library") + + class _ScenarioRecord: + pass + + _ScenarioRecord.__module__ = "build_scenario_library" + _ScenarioRecord.__qualname__ = "ScenarioRecord" + _stub.ScenarioRecord = _ScenarioRecord + sys.modules["build_scenario_library"] = _stub + + with open(Path(path), "rb") as f: + data = pickle.load(f) + self.scenarios: list = data["scenarios"] + self.config: dict = data.get("config", {}) + if not self.scenarios: + raise ValueError(f"Scenario library at {path} is empty.") + self._rng = np.random.default_rng() + + def __len__(self) -> int: + return len(self.scenarios) + + def sample(self) -> Any: + """Return a random scenario record.""" + idx = int(self._rng.integers(0, len(self.scenarios))) + return self.scenarios[idx] + + +# ── Per-site Gymnasium environment ────────────────────────────────────────── + + +class BatchSizeEnv(gymnasium.Env): + """Gymnasium environment for batch-size voltage regulation. + + Each ``step(action)`` advances the simulation by one control interval. + For multi-DC setups, ``agent_site_id`` specifies which site the RL agent + controls. Other sites run with fixed batch sizes. + + When ``scenario_library`` is provided, each ``reset()`` samples a + pre-screened scenario and replays it. When ``ofo_baseline=True`` the + voltage reward term becomes the per-step difference between PPO's + voltage penalty and the OFO oracle's (stored in the library), so the + agent is rewarded for *improving on OFO*. When ``truncate_episode=True`` + the episode fast-forwards through the initial quiet period (before the + first baseline violation) and terminates after the last violation clears. + """ + + metadata: typing.ClassVar[dict[str, list]] = {"render_modes": []} + + def __init__( + self, + make_sim_fn: MakeSimFn, + obs_config: ObservationConfig, + agent_site_id: str = "_default", + reward_config: RewardConfig | None = None, + logistic_models: LogisticModelStore | None = None, + dt_ctrl: Fraction = Fraction(1), + total_duration_s: int = 3600, + scenario_library: ScenarioLibrary | None = None, + ofo_baseline: bool = False, + truncate_episode: bool = False, + ) -> None: + super().__init__() + self._make_sim = make_sim_fn + self._obs_config = obs_config + self._agent_site_id = agent_site_id + self._reward_config = reward_config or RewardConfig() + self._logistic_models = logistic_models + self._dt_ctrl = dt_ctrl + self._total_duration_s = total_duration_s + self._scenario_library = scenario_library + self._ofo_baseline = ofo_baseline + self._truncate_episode = truncate_episode + + self._action_mode = resolve_action_mode(self._reward_config) + + self.observation_space = spaces.Box(low=-np.inf, high=np.inf, shape=(obs_config.obs_dim,), dtype=np.float32) + n_feasible = min(len(obs_config.feasible_batch_sizes[m]) for m in obs_config.model_labels) + n_models = len(obs_config.model_labels) + self._coupled_max_shift = n_feasible - 1 # e.g. 6 for 7 batch sizes + n_coupled = 2 * self._coupled_max_shift + 1 # e.g. 13 + + if self._action_mode == "coupled": + self.action_space = spaces.Discrete(n_coupled) + elif self._action_mode == "delta": + self.action_space = spaces.MultiDiscrete([3] * n_models) + else: + raise ValueError(f"Unknown action_mode: {self._action_mode!r}") + + self._datacenters: dict[str, DatacenterBackend] = {} + self._grid: GridBackend | None = None + self._tap_ctrl: TapScheduleController | None = None + self._clock: SimulationClock | None = None + self._log: SimulationLog | None = None + self._dc_events: EventEmitter | None = None + self._grid_events: EventEmitter | None = None + self._ctrl_events: EventEmitter | None = None + self._dc_buffers: dict[str, list[ThreePhase]] = {} + self._prev_batch: dict[str, int] = {} + self._steps_since_change: dict[str, int] = {} + self._steps_done: int = 0 + self._max_steps: int = 0 + self._zone_mask: np.ndarray | None = None + self._zone_masks: dict[str, np.ndarray] | None = None + # Per-episode state from the scenario library + self._ofo_voltage_trace: np.ndarray | None = None + self._sim_step_offset: int = 0 # how many sim steps were fast-forwarded + + def _action_to_batch_sizes(self, action: np.ndarray) -> dict[str, int]: + return decode_action( + action, + self._action_mode, + self._obs_config.model_labels, + self._obs_config.feasible_batch_sizes, + self._prev_batch, + self._coupled_max_shift, + ) + + @property + def _agent_dc(self) -> DatacenterBackend: + return self._datacenters[self._agent_site_id] + + def _advance_ticks(self, n_ticks: int) -> None: + """Advance all components by *n_ticks* base ticks.""" + datacenters = self._datacenters + grid = self._grid + tap_ctrl = self._tap_ctrl + clock = self._clock + assert grid is not None and clock is not None + for _ in range(n_ticks): + for sid, dc in datacenters.items(): + if clock.is_due(dc.dt_s): + dc_state = dc.do_step(clock, self._dc_events) + self._dc_buffers[sid].append(dc_state.power_w) + if clock.is_due(grid.dt_s): + # Master's OpenDSSGrid.step() expects power_samples_w keyed + # by the DatacenterBackend object (the same key used in + # grid.attach_dc), not by the site-id string. + power_arg = {datacenters[sid]: list(buf) for sid, buf in self._dc_buffers.items()} + grid.do_step(clock, power_arg, self._grid_events) + for buf in self._dc_buffers.values(): + buf.clear() + if tap_ctrl is not None and clock.is_due(tap_ctrl.dt_s): + # Master's TapScheduleController.step() takes (clock, events). + # The old (clock, datacenter, grid, events) signature is gone. + cmds = tap_ctrl.step(clock, self._ctrl_events) + for cmd in cmds: + grid.apply_control(cmd, self._grid_events) + clock.advance() + + def reset(self, *, seed: int | None = None, options: dict[str, Any] | None = None) -> tuple[np.ndarray, dict]: + super().reset(seed=seed, options=options) + + # If using a scenario library, sample a scenario and stash the OFO + # trace for the difference reward. The make_sim_fn is expected to + # pick up the scenario seed from an external variable set by the + # caller (train_ppo.py manages this via a closure). + self._ofo_voltage_trace = None + self._sim_step_offset = 0 + t_control_start = 0 + t_control_end = self._total_duration_s + + if self._scenario_library is not None: + scenario = self._scenario_library.sample() + if self._ofo_baseline: + self._ofo_voltage_trace = scenario.ofo_voltage_pen_per_step + if self._truncate_episode: + t_control_start = getattr(scenario, "t_control_start", 0) + t_control_end = getattr(scenario, "t_control_end", self._total_duration_s) + # Pass the scenario to make_sim so it uses the library's params + self._current_scenario = scenario + + if self._scenario_library is not None: + datacenters, grid, tap_ctrl = self._make_sim(scenario_override=self._current_scenario) + else: + datacenters, grid, tap_ctrl = self._make_sim() + self._datacenters = datacenters + self._grid = grid + self._tap_ctrl = tap_ctrl + + all_dcs = list(datacenters.values()) + periods = [dc.dt_s for dc in all_dcs] + [grid.dt_s] + if tap_ctrl is not None: + periods.append(tap_ctrl.dt_s) + periods.append(self._dt_ctrl) + tick = periods[0] + for p in periods[1:]: + tick = _gcd_fraction(tick, p) + + self._clock = SimulationClock(tick_s=tick) + self._log = SimulationLog() + self._dc_events = EventEmitter(self._clock, self._log, "datacenter") + self._grid_events = EventEmitter(self._clock, self._log, "grid") + self._ctrl_events = EventEmitter(self._clock, self._log, "controller") + self._dc_buffers = {sid: [] for sid in datacenters} + self._steps_done = 0 + + for dc in all_dcs: + dc.do_reset() + grid.do_reset() + if tap_ctrl is not None: + tap_ctrl.reset() + for dc in all_dcs: + dc.start() + grid.start() + if tap_ctrl is not None: + tap_ctrl.start() + + # Compute zone mask if configured (single-zone voltage vector mode) + zone_buses = self._obs_config.zone_buses + if zone_buses is not None: + self._zone_mask = compute_zone_mask(grid.v_index, zone_buses) + else: + self._zone_mask = None + + # Compute per-zone masks for zone_summary mode + if self._obs_config.zone_summary: + self._zone_masks = { + zname: compute_zone_mask(grid.v_index, tuple(zbuses)) + for zname, zbuses in self._obs_config.zone_summary.items() + } + else: + self._zone_masks = None + + n_ticks_per_ctrl = int(self._dt_ctrl / tick) + + # Fast-forward through the quiet period with fixed initial batch sizes + # (no PPO control). The sim still runs so the grid/DC states evolve. + self._sim_step_offset = t_control_start + for _ in range(t_control_start): + self._advance_ticks(n_ticks_per_ctrl) + + # PPO episode length: from t_control_start to t_control_end + self._max_steps = t_control_end - t_control_start + + # Run one more tick to get the first observation at t_control_start + self._advance_ticks(n_ticks_per_ctrl) + + self._prev_batch = {label: self._obs_config.get_initial_batch(label) for label in self._obs_config.model_labels} + self._steps_since_change = {label: 999 for label in self._obs_config.model_labels} + obs = build_observation(grid, self._agent_dc, self._obs_config, self._prev_batch, self._zone_mask, self._zone_masks) + return obs, {} + + def step(self, action: np.ndarray) -> tuple[np.ndarray, float, bool, bool, dict]: + assert self._grid is not None and self._clock is not None + + batch_sizes = self._action_to_batch_sizes(action) + cmd = SetBatchSize(batch_size_by_model=batch_sizes, target=self._agent_dc) + self._agent_dc.apply_control(cmd, self._dc_events) + + n_ticks_per_ctrl = int(self._dt_ctrl / self._clock.tick_s) + self._advance_ticks(n_ticks_per_ctrl) + self._steps_done += 1 + + reward, reward_components, voltage_stats = compute_reward( + self._grid, + self._agent_dc, + self._obs_config, + self._reward_config, + self._prev_batch, + batch_sizes, + self._logistic_models, + steps_since_change=self._steps_since_change, + ) + + # Update steps-since-change tracking + for label in self._obs_config.model_labels: + if batch_sizes.get(label) != self._prev_batch.get(label): + self._steps_since_change[label] = 0 + else: + self._steps_since_change[label] = self._steps_since_change.get(label, 999) + 1 + + # OFO-difference reward: subtract the OFO oracle's voltage penalty + # at this simulation timestep so the agent is rewarded for beating OFO. + if self._ofo_voltage_trace is not None: + sim_t = self._sim_step_offset + self._steps_done + ofo_pen = float(self._ofo_voltage_trace[sim_t]) if sim_t < len(self._ofo_voltage_trace) else 0.0 + ofo_voltage_baseline = self._reward_config.w_voltage * ofo_pen + reward += ofo_voltage_baseline + reward_components = dict(reward_components) + reward_components["voltage"] += ofo_voltage_baseline + reward_components["ofo_baseline"] = ofo_voltage_baseline + + # Clip reward to prevent catastrophic scenarios from dominating updates. + if self._reward_config.reward_clip > 0: + reward = max(reward, -self._reward_config.reward_clip) + + self._prev_batch = dict(batch_sizes) + obs = build_observation(self._grid, self._agent_dc, self._obs_config, self._prev_batch, self._zone_mask, self._zone_masks) + + truncated = self._steps_done >= self._max_steps + if truncated: + self._stop_components() + info = {"reward_components": reward_components, "voltage_stats": voltage_stats} + return obs, reward, False, truncated, info + + def _stop_components(self) -> None: + if self._tap_ctrl is not None: + self._tap_ctrl.stop() + if self._grid is not None: + self._grid.stop() + for dc in self._datacenters.values(): + dc.stop() + + def close(self) -> None: + self._stop_components() + super().close() + + +# ── Shared multi-site Gymnasium environment ───────────────────────────────── + + +class SharedBatchSizeEnv(BatchSizeEnv): + """Controls ALL datacenter sites jointly with a single policy. + + The observation includes per-model features from all sites. + The action space covers all models across all sites. + ``site_model_mapping`` maps site_id → list of model labels at that site. + """ + + def __init__( + self, + make_sim_fn: MakeSimFn, + obs_config: ObservationConfig, + site_model_mapping: dict[str, list[str]], + reward_config: RewardConfig | None = None, + logistic_models: LogisticModelStore | None = None, + dt_ctrl: Fraction = Fraction(1), + total_duration_s: int = 3600, + scenario_library: ScenarioLibrary | None = None, + ofo_baseline: bool = False, + truncate_episode: bool = False, + ) -> None: + # Use the first site as agent_site_id (unused for shared, but needed by parent) + first_site = next(iter(site_model_mapping)) + super().__init__( + make_sim_fn=make_sim_fn, + obs_config=obs_config, + agent_site_id=first_site, + reward_config=reward_config, + logistic_models=logistic_models, + dt_ctrl=dt_ctrl, + total_duration_s=total_duration_s, + scenario_library=scenario_library, + ofo_baseline=ofo_baseline, + truncate_episode=truncate_episode, + ) + self._site_model_mapping = site_model_mapping + + def _action_to_site_batch_sizes(self, action: np.ndarray) -> dict[str, dict[str, int]]: + """Convert action indices to per-site batch size dicts.""" + flat = super()._action_to_batch_sizes(action) + result: dict[str, dict[str, int]] = {} + for sid, labels in self._site_model_mapping.items(): + result[sid] = {label: flat[label] for label in labels if label in flat} + return result + + @property + def _all_dcs_list(self) -> list[DatacenterBackend]: + return list(self._datacenters.values()) + + def reset(self, *, seed: int | None = None, options: dict[str, Any] | None = None) -> tuple[np.ndarray, dict]: + # Call parent reset (sets up sim, runs initial ticks) + obs, info = super().reset(seed=seed, options=options) + # Rebuild obs using ALL DCs + obs = build_observation(self._grid, self._all_dcs_list, self._obs_config, self._prev_batch, self._zone_mask, self._zone_masks) + return obs, info + + def step(self, action: np.ndarray) -> tuple[np.ndarray, float, bool, bool, dict]: + assert self._grid is not None and self._clock is not None + + # Apply batch sizes to each site's DC + site_batches = self._action_to_site_batch_sizes(action) + all_batch_sizes: dict[str, int] = {} + for sid, batches in site_batches.items(): + if sid in self._datacenters: + dc = self._datacenters[sid] + cmd = SetBatchSize(batch_size_by_model=batches, target=dc) + dc.apply_control(cmd, self._dc_events) + all_batch_sizes.update(batches) + + n_ticks_per_ctrl = int(self._dt_ctrl / self._clock.tick_s) + self._advance_ticks(n_ticks_per_ctrl) + self._steps_done += 1 + + reward, reward_components, voltage_stats = compute_reward( + self._grid, + self._all_dcs_list, + self._obs_config, + self._reward_config, + self._prev_batch, + all_batch_sizes, + self._logistic_models, + steps_since_change=self._steps_since_change, + ) + + # Update steps-since-change tracking + for label in self._obs_config.model_labels: + if all_batch_sizes.get(label) != self._prev_batch.get(label): + self._steps_since_change[label] = 0 + else: + self._steps_since_change[label] = self._steps_since_change.get(label, 999) + 1 + + self._prev_batch = dict(all_batch_sizes) + obs = build_observation(self._grid, self._all_dcs_list, self._obs_config, self._prev_batch, self._zone_mask, self._zone_masks) + + truncated = self._steps_done >= self._max_steps + if truncated: + self._stop_components() + info = {"reward_components": reward_components, "voltage_stats": voltage_stats} + return obs, reward, False, truncated, info + + +# ── Utilities ─────────────────────────────────────────────────────────────── + + +def _gcd_fraction(a: Fraction, b: Fraction) -> Fraction: + """GCD of two Fractions.""" + from math import gcd + + num = gcd(a.numerator * b.denominator, b.numerator * a.denominator) + den = a.denominator * b.denominator + return Fraction(num, den) diff --git a/paper_plots.mplstyle b/paper_plots.mplstyle new file mode 100644 index 0000000..b955a1f --- /dev/null +++ b/paper_plots.mplstyle @@ -0,0 +1,42 @@ + +### Fonts +font.size : 9 +axes.labelsize : 10 +axes.titlesize : 10 +xtick.labelsize : 8.5 +ytick.labelsize : 8.5 +legend.fontsize : 8 + +# San-serif font use Arial +font.family: sans-serif +#font.sans-serif: Helvetica +font.sans-serif: Arial + +### Axes / grid defaults +axes.grid : False +grid.alpha : 0.25 +grid.linewidth : 0.5 +axes.spines.top : False +axes.spines.right : False + +# Serif font use TMR +#font.family: serif +#font.serif: Times New Roman +#mathtext.fontset : stix + + +### Plotting +# lines.linewidth : 2 + +### Saving figures +path.simplify : True + +# SVG export now don't render font as path, but save font as text object. +# This allows for easy integration with MS Office +svg.fonttype : none +# Make SVG generation deterministic +svg.hashsalt : 42 + +# Enforce Type 1 font usage +pdf.fonttype : 42 +ps.fonttype : 42 diff --git a/plots.py b/plots.py new file mode 100644 index 0000000..517e5da --- /dev/null +++ b/plots.py @@ -0,0 +1,718 @@ +"""Cross-variant plots for Section-5 (Model-insights) experiments. + +Paper-grade output: each panel is saved as its own file in PDF, SVG, and +PNG. The paper composes subfloats with `\\subfloat{...}`. No +`set_title` / `suptitle` — captions carry semantics. + +Style from `openg2g-paper/figures/paper_plots.mplstyle` (Arial 9 pt, +Type-1 fonts, deterministic SVG). Every field in it is load-bearing; the +script raises if the file is missing. + +Legends are color-only (no marker/linestyle cycling), horizontal below +the axis, no legend title. Display labels drop precision / parallelism +decorations — those belong in captions, not figure legends. +""" + +from __future__ import annotations + +import logging +import math +from pathlib import Path + +import matplotlib + +matplotlib.use("Agg") +import matplotlib.pyplot as plt +from matplotlib.lines import Line2D +from matplotlib.patches import Patch +import numpy as np +import pandas as pd +import tyro + +import common as mi + +_MPLSTYLE_PATH = Path("/Users/jaywonchung/workspace/openg2g-paper/figures/paper_plots.mplstyle") +if not _MPLSTYLE_PATH.exists(): + raise FileNotFoundError( + f"Paper mplstyle not found at {_MPLSTYLE_PATH}. " + "This file is the paper-wide plot style and is load-bearing; " + "do not regenerate figures without it." + ) +plt.style.use(str(_MPLSTYLE_PATH)) + +logger = logging.getLogger("model_insights.plots") + +OUTDIR = Path(__file__).resolve().parent / "outputs" + +OFO_MODE = "ofo-no-tap" +BASELINE_MODE = "baseline-no-tap" + +COLORS = { + "baseline": "#9A9A9A", + "ofo": "#4C72B0", + "h100": "#4C72B0", + "b200": "#C44E52", + "model_size": "#4C72B0", + "itl_deadline": "#55A868", + "parallelism": "#DD8452", + "hardware": "#C44E52", + "precision": "#8172B3", +} + +EXP_LABELS = { + "model_size": "A. Model size & architecture", + "precision": "B. Weight precision", + "parallelism": "C. Parallelism", + "hardware": "D. GPU hardware generation", + "itl_deadline": "E. DC capacity under SLO", +} + +# Display labels kept minimal — drop #GPUs, precision, parallelism +# suffixes. Those belong in the paper caption, not the figure legend. +DISPLAY_LABELS = { + "Qwen3-8B": "Qwen 3 8B", + "Qwen3-14B": "Qwen 3 14B", + "Qwen3-32B-TP2": "Qwen 3 32B", + "Qwen3-30B-A3B-Instruct-1GPU": "Qwen 3 30B A3B", + "Qwen3-30B-A3B-Instruct-2GPU": "Qwen 3 30B A3B", + "Llama-3.1-8B": "Llama 3.1 8B", + "Llama-3.1-70B": "Llama 3.1 70B", + "Llama-3.1-405B": "Llama 3.1 405B", + "GPT-OSS-120B-H100-2GPU": "GPT-OSS 120B", + "GPT-OSS-120B-B200-1GPU": "GPT-OSS 120B", + "GPT-OSS-120B-B200-2GPU": "GPT-OSS 120B", + "DeepSeek-V3.1-B200-8GPU": "DeepSeek V3.1", + "DeepSeek-R1-B200-8GPU": "DeepSeek R1", + "Qwen3-8B-B200": "Qwen 3 8B", + "Qwen3-14B-B200": "Qwen 3 14B", + "Qwen3-32B-1GPU-H100": "Qwen 3 32B", + "Qwen3-32B-1GPU-B200": "Qwen 3 32B", + "Qwen3-30B-A3B-Instruct-1GPU-B200": "Qwen 3 30B A3B", + "Qwen3-Coder-30B-A3B-B200": "Qwen 3 Coder 30B A3B", + "Qwen3-235B-A22B-Instruct-FP8-4GPU": "Qwen 3 235B A22B", + "Qwen3-235B-A22B-Instruct-FP8-8GPU": "Qwen 3 235B A22B", + "Qwen3-235B-A22B-Instruct-8GPU": "Qwen 3 235B A22B", + "Qwen3-235B-A22B-Instruct-4GPU-B200": "Qwen 3 235B A22B", + "Qwen3-235B-A22B-Instruct-8GPU-B200": "Qwen 3 235B A22B", + "Qwen3-235B-A22B-Thinking-8GPU": "Qwen 3 235B A22B Thinking", + "Qwen3-235B-A22B-Thinking-4GPU-B200": "Qwen 3 235B A22B Thinking", + "Qwen3-235B-A22B-Thinking-8GPU-B200": "Qwen 3 235B A22B Thinking", + "Qwen3-235B-A22B-Thinking-FP8-8GPU": "Qwen 3 235B A22B Thinking", + "Qwen3-235B-A22B-Thinking-FP8-4GPU-B200": "Qwen 3 235B A22B Thinking", + "qwen-8b-1gpu": "Qwen 3 8B", + "qwen-32b-1gpu": "Qwen 3 32B", + "qwen-30b-a3b-instruct-1gpu": "Qwen 3 30B A3B", + "qwen-30b-a3b-instruct": "Qwen 3 30B A3B", + "qwen-235b-a22b-instruct": "Qwen 3 235B A22B", + "qwen-235b-a22b-thinking": "Qwen 3 235B A22B Thinking", + "gpt-oss-120b": "GPT-OSS 120B", +} + + +def _pretty(label: str) -> str: + return DISPLAY_LABELS.get(label, label) + + +def _bar_handle(color: str, label: str, *, alpha: float = 1.0, hatch: str | None = None) -> Patch: + """Legend handle for a bar.""" + return Patch(facecolor=color, alpha=alpha, hatch=hatch, edgecolor="white", label=label) + + +def _line_handle(color: str, label: str) -> Line2D: + """Legend handle for a line-marker plot (Pareto curves).""" + return Line2D( + [0], [0], color=color, marker="o", markersize=5.5, + markeredgecolor="black", markeredgewidth=0.4, lw=1.6, label=label, + ) + + +def _combo_handle(color: str, label: str): + """Composite legend handle — a bar swatch + a line marker, both in + `color`, rendered side-by-side under one label. Use with + `HandlerTuple` so one legend entry covers both the (a) bars and the + (b) Pareto panels at once. + """ + bar = Patch(facecolor=color, edgecolor="white") + line = Line2D([0], [0], color=color, marker="o", markersize=5.5, + markeredgecolor="black", markeredgewidth=0.4, lw=1.6) + return ((bar, line), label) + + +def _legend_above(ax, handles, *, ncol: int) -> None: + """Place a color-only legend directly above the plot axes (inline).""" + if not handles: + return + ax.legend( + handles=handles, ncol=ncol, + loc="lower center", bbox_to_anchor=(0.5, 1.02), + frameon=False, handlelength=1.3, columnspacing=1.1, + handletextpad=0.5, borderaxespad=0, + ) + + +def _save_legend(handles, stem: Path, *, ncol: int, width: float, + tight: bool = True) -> None: + """Save a standalone legend PDF. + + Handles may be plain artists OR `(tuple_of_artists, label)` pairs + (produced by `_combo_handle`) that render as side-by-side glyphs + under one label via `HandlerTuple`. When `tight=False` the full + figsize is preserved (useful for padding a narrow legend so it + renders at larger physical font in LaTeX). + """ + if not handles: + return + from matplotlib.legend_handler import HandlerTuple + + plain_handles, tuple_handles, tuple_labels = [], [], [] + for h in handles: + if isinstance(h, tuple) and len(h) == 2 and isinstance(h[0], tuple): + tuple_handles.append(h[0]) + tuple_labels.append(h[1]) + else: + plain_handles.append(h) + + fig, ax = plt.subplots(figsize=(width, 0.35)) + ax.axis("off") + legend_fontsize = 11 + if tuple_handles: + ax.legend( + handles=tuple_handles + plain_handles, + labels=tuple_labels + [h.get_label() for h in plain_handles], + ncol=ncol, loc="center", frameon=False, fontsize=legend_fontsize, + handlelength=2.6, columnspacing=1.2, handletextpad=0.5, + handler_map={tuple: HandlerTuple(ndivide=None, pad=0.2)}, + ) + else: + ax.legend( + handles=plain_handles, ncol=ncol, loc="center", frameon=False, + fontsize=legend_fontsize, + handlelength=1.2, columnspacing=1.2, handletextpad=0.4, + ) + stem.parent.mkdir(parents=True, exist_ok=True) + if tight: + kw = {"bbox_inches": "tight", "pad_inches": 0.02} + else: + kw = {"bbox_inches": None, "pad_inches": 0.0} + fig.savefig(stem.with_suffix(".pdf"), metadata={"CreationDate": None}, **kw) + fig.savefig(stem.with_suffix(".svg"), metadata={"Date": None}, **kw) + fig.savefig(stem.with_suffix(".png"), dpi=200, metadata={"Creation Time": None}, **kw) + plt.close(fig) + + +def _grouped_bars( + ax, base_vals, ofo_vals, colors, *, + base_errs=None, ofo_errs=None, + ylog: bool = True, ylabel: str = "Integral violation\n(pu·s)", + bar_width: float = 0.38, +) -> None: + """Bars grouped by variant. Each variant has two bars: hatched + (uncoordinated) and solid (coordinated), both colored by variant. + No x tick labels — identity comes from the shared legend + caption.""" + n = len(colors) + xs = np.arange(n) + offset = bar_width / 2 + 0.012 + for i, color in enumerate(colors): + be = base_errs[i] if base_errs is not None else None + oe = ofo_errs[i] if ofo_errs is not None else None + ax.bar(xs[i] - offset, base_vals[i], bar_width, + color=color, hatch="///", edgecolor="white", linewidth=0.6, + yerr=be, capsize=1.8, error_kw={"lw": 0.7}) + ax.bar(xs[i] + offset, ofo_vals[i], bar_width, + color=color, edgecolor="white", linewidth=0.6, + yerr=oe, capsize=1.8, error_kw={"lw": 0.7}) + ax.set_xticks([]) + ax.set_xlim(-0.6, n - 0.4) + ax.set_ylabel(ylabel, fontsize=9) + if ylog: + ax.set_yscale("log") + lo, hi = ax.get_ylim() + ax.set_ylim(lo, hi * 1.2) + ax.grid(True, axis="y", alpha=0.25) + + +# Fixed axes rectangle (in figure fraction). Every (a)/(b) panel uses +# this same position so that — combined with a shared figsize and +# `bbox_inches=None` — the saved PDFs have identical dimensions and +# axes at identical positions. When composed side-by-side in LaTeX, +# x-baselines line up exactly. +_PANEL_FIGSIZE = (2.6, 1.8) +_PANEL_AXES = (0.23, 0.26, 0.74, 0.70) # (left, bottom, width, height) + + +def _make_panel(): + fig, ax = plt.subplots(figsize=_PANEL_FIGSIZE) + ax.set_position(_PANEL_AXES) + return fig, ax + + +def _save(fig: plt.Figure, stem: Path) -> None: + stem.parent.mkdir(parents=True, exist_ok=True) + # No tight_layout — it overrides set_position. bbox_inches=None + # preserves the exact figsize, guaranteeing matched dimensions. + kwargs = {"bbox_inches": None, "pad_inches": 0.0} + fig.savefig(stem.with_suffix(".pdf"), metadata={"CreationDate": None}, **kwargs) + fig.savefig(stem.with_suffix(".svg"), metadata={"Date": None}, **kwargs) + fig.savefig(stem.with_suffix(".png"), dpi=200, metadata={"Creation Time": None}, **kwargs) + plt.close(fig) + + +def _aggregate(df: pd.DataFrame) -> pd.DataFrame: + return df.groupby(["variant", "mode"], as_index=False).agg( + n_seeds=("seed", "nunique"), + integral_mean=("integral_violation_pu_s", "mean"), + integral_std=("integral_violation_pu_s", "std"), + vmin_mean=("worst_vmin", "mean"), + thpt_mean=("mean_throughput_tps", "mean"), + itl_miss_mean=("itl_deadline_fraction", "mean"), + apr_mw=("achievable_power_range_mw", "mean"), + ) + + +def _pareto_from_row(row, logistic_models) -> tuple[np.ndarray, np.ndarray, np.ndarray]: + """Return (batches, power MW, throughput M tok/s) for one variant.""" + variant = row["variant"] + base = variant.split("_dl")[0] + spec = mi.SPECS[base] + fmax = int(row["feasible_batch_max"]) + fmin = int(row["feasible_batch_min"]) + batches = np.array([b for b in sorted(spec.feasible_batch_sizes) if fmin <= b <= fmax], dtype=int) + p_fit = logistic_models.power(base) + t_fit = logistic_models.throughput(base) + n = int(row["num_replicas"]) + p_mw = np.array([float(p_fit.eval(int(b))) * n / 1e6 for b in batches]) + t_mtps = np.array([float(t_fit.eval(int(b))) * n / 1e6 for b in batches]) # tokens/s → M tokens/s + return batches, p_mw, t_mtps + + +def _apply_pareto_axes(ax, with_ylabel: bool = True) -> None: + ax.set_xlabel("Datacenter power (MW)", fontsize=9) + if with_ylabel: + ax.set_ylabel("Token throughput\n(M tok/s)", fontsize=9) + ax.set_ylim(bottom=0) + ax.set_xlim(left=max(0, ax.get_xlim()[0])) + ax.grid(True, alpha=0.3) + + +def _plot_pareto( + ax, entries, logistic_models, color_map, label_map, + *, marker_size: int = 5.5, +) -> None: + """Color-only Pareto curves. Each entry is a dict with variant, + num_replicas, feasible_batch_min, feasible_batch_max.""" + for entry in entries: + batches, p_mw, t_m = _pareto_from_row(entry, logistic_models) + if len(batches) == 0: + continue + key = entry.get("color_key", entry["variant"]) + color = color_map.get(key, COLORS["ofo"]) + label = label_map.get(entry["variant"], _pretty(entry["variant"])) + ax.plot(p_mw, t_m, "-o", color=color, markersize=marker_size, + markeredgecolor="black", markeredgewidth=0.4, + label=label, alpha=0.95, lw=1.6) + + +def _pair_bars( + ax, x, base_vals, ofo_vals, *, ylog: bool, ylabel: str, + base_errs=None, ofo_errs=None, tick_labels=None, rotation: float = 25.0, +) -> None: + width = 0.38 + ax.bar(x - width / 2, base_vals, width, yerr=base_errs, + color=COLORS["baseline"], label="Baseline", capsize=2.5) + ax.bar(x + width / 2, ofo_vals, width, yerr=ofo_errs, + color=COLORS["ofo"], label="OFO", capsize=2.5) + ax.set_xticks(x) + if tick_labels is not None: + ax.set_xticklabels(tick_labels, rotation=rotation, + ha="right" if rotation else "center") + ax.set_ylabel(ylabel) + if ylog: + ax.set_yscale("log") + lo, hi = ax.get_ylim() + ax.set_ylim(lo, hi * 2.5) + ax.grid(True, axis="y", alpha=0.25) + + +# ────────────────────────────────────────────────────────────────────── +# Experiment A — model size & architecture + + +def plot_model_size(df, out, logistic_models, *, suffix: str = "") -> None: + agg = _aggregate(df) + all_variants = df["variant"].drop_duplicates().tolist() + + # Sort variants by power-swing range (least flexible → most flexible). + # This is roughly the ordering of how much coordination can reduce + # the integral violation, so the bar chart reads as a "flexibility + # ladder" from left to right. + def _swing(variant: str) -> float: + row = df[(df["variant"] == variant) & (df["mode"] == OFO_MODE)].iloc[0].to_dict() + _, p_mw, _ = _pareto_from_row(row, logistic_models) + return float(p_mw.max() - p_mw.min()) if len(p_mw) else 0.0 + + variants = sorted(all_variants, key=_swing) + palette = plt.get_cmap("tab10").colors + color_map = {v: palette[i % len(palette)] for i, v in enumerate(variants)} + + def _val(v, mode, col): + row = agg[(agg.variant == v) & (agg["mode"] == mode)] + return float(row[col].iloc[0]) if not row.empty else math.nan + + # (a) integral violation — colored bars, hatched (uncoord.) vs solid (coord.) + fig, ax = _make_panel() + _grouped_bars( + ax, + [_val(v, BASELINE_MODE, "integral_mean") for v in variants], + [_val(v, OFO_MODE, "integral_mean") for v in variants], + [color_map[v] for v in variants], + base_errs=[_val(v, BASELINE_MODE, "integral_std") for v in variants], + ofo_errs=[_val(v, OFO_MODE, "integral_std") for v in variants], + ) + _save(fig, out / f"model_size_a{suffix}") + + # (b) Pareto frontier — same color per variant + entries = [df[(df["variant"] == v) & (df["mode"] == OFO_MODE)].iloc[0].to_dict() for v in variants] + fig, ax = _make_panel() + _plot_pareto(ax, entries, logistic_models, + color_map=color_map, + label_map={v: _pretty(v) for v in variants}) + _apply_pareto_axes(ax) + _save(fig, out / f"model_size_b{suffix}") + + # Shared legend: model colors only (hatch/solid explained in caption). + handles = [_combo_handle(color_map[v], _pretty(v)) for v in variants] + _save_legend(handles, out / f"model_size_legend{suffix}", + ncol=len(variants), width=4.6) + + +# ────────────────────────────────────────────────────────────────────── +# Experiment B — ITL deadline + + +def plot_itl_deadline(df, out, logistic_models, *, suffix: str = "") -> None: + """Exp B — DC capacity under SLO. One panel: for each SLO, show the + grid-feasible replica-count interval [R_min, R_max] under baseline vs + OFO. Gap between baseline's R_max and OFO's R_max is the coordination + capacity unlock. + """ + # Collapse seeds — num_replicas is the binary-search result (fixed per + # SLO × mode × endpoint). integral_mean at the edges is by design + # right at the feasibility threshold. + piv = (df.groupby(["deadline_ms", "mode", "endpoint"])["num_replicas"] + .first().unstack("endpoint").reset_index()) + piv = piv.sort_values("deadline_ms") + + base = piv[piv["mode"] == BASELINE_MODE].set_index("deadline_ms").sort_index() + ofo = piv[piv["mode"] == OFO_MODE].set_index("deadline_ms").sort_index() + xs = base.index.to_numpy(dtype=float) + + fig, ax = plt.subplots(figsize=(2.8, 1.9)) + # Baseline feasible band (shared lower edge with OFO). + ax.fill_between(xs, base["r_min"], base["r_max"], + color=COLORS["baseline"], alpha=0.45, + label="Baseline feasible") + # OFO's additional unlocked region — between baseline's R_max and OFO's R_max. + ax.fill_between(xs, base["r_max"].to_numpy(), ofo["r_max"].to_numpy(), + color=COLORS["ofo"], alpha=0.50, + label="Unlocked by OFO") + # Boundary lines for clarity. + ax.plot(xs, base["r_min"], "-", color="black", lw=0.8, zorder=3) + ax.plot(xs, base["r_max"], "-", color=COLORS["baseline"], lw=1.6, zorder=3) + ax.plot(xs, ofo["r_max"], "-", color=COLORS["ofo"], lw=1.6, zorder=3) + + ax.set_xlabel("ITL deadline (ms)") + ax.set_ylabel("Replicas") + ax.set_ylim(bottom=0) + ax.grid(True, alpha=0.25) + _legend_above( + ax, + [_bar_handle(COLORS["baseline"], "Baseline feasible", alpha=0.45), + _bar_handle(COLORS["ofo"], "Unlocked by OFO", alpha=0.50), + Line2D([0], [0], color="black", lw=1.0, label=r"$R_{\min}$ boundary")], + ncol=3, + ) + _save(fig, out / f"itl_deadline_a{suffix}") + + +# ────────────────────────────────────────────────────────────────────── +# Experiment C — parallelism + + +def plot_parallelism(df, out, logistic_models, *, suffix: str = "") -> None: + agg = _aggregate(df).merge( + df[["variant", "pair", "parallelism_label"]].drop_duplicates(), + on="variant", how="left", + ) + pairs = sorted(agg["pair"].unique()) + for pair in pairs: + sub = agg[agg["pair"] == pair].copy() + variants = ( + sub[["variant", "parallelism_label"]] + .drop_duplicates().sort_values("variant")["variant"].tolist() + ) + labels = [sub[sub["variant"] == v]["parallelism_label"].iloc[0] for v in variants] + x = np.arange(len(variants)) + stem = "parallelism" if len(pairs) == 1 else f"parallelism_{pair}" + + def _v(v, mode, col): + r = sub[(sub["variant"] == v) & (sub["mode"] == mode)] + return float(r[col].iloc[0]) if not r.empty else math.nan + + color_map = {labels[i]: (COLORS["h100"] if i == 0 else COLORS["b200"]) + for i in range(len(variants))} + + # (a) integral violation — colored bars, hatch = baseline vs OFO + fig, ax = _make_panel() + _grouped_bars( + ax, + [_v(v, BASELINE_MODE, "integral_mean") for v in variants], + [_v(v, OFO_MODE, "integral_mean") for v in variants], + [color_map[lbl] for lbl in labels], + base_errs=[_v(v, BASELINE_MODE, "integral_std") for v in variants], + ofo_errs=[_v(v, OFO_MODE, "integral_std") for v in variants], + ) + _save(fig, out / f"{stem}_a{suffix}") + + # (b) Pareto — same colors. No inline legend; standalone legend + # saved below so all experiments share the same font metrics. + entries = [] + for vv, lbl in zip(variants, labels): + r = df[(df["variant"] == vv) & (df["mode"] == OFO_MODE)].iloc[0].to_dict() + r["color_key"] = lbl + entries.append(r) + fig, ax = _make_panel() + _plot_pareto(ax, entries, logistic_models, + color_map=color_map, + label_map={vv: lbl for vv, lbl in zip(variants, labels)}) + _apply_pareto_axes(ax) + _save(fig, out / f"{stem}_b{suffix}") + + # Parallelism main-text panels are Pareto-only (no bars), so the + # legend shows line-marker handles rather than the bar+line combo + # used by Figs A/B/D. Native width is pushed wider with larger + # columnspacing so the LaTeX-side scale factor matches Fig 2's + # (Fig 2 is rendered at \\linewidth / 6.14in ≈ 0.9×; we want the + # same scale here so the rendered font matches). + handles = [_line_handle(color_map[lbl], lbl) for lbl in labels] + _save_legend(handles, out / f"{stem}_legend{suffix}", + ncol=len(labels), width=2.3) + + +# ────────────────────────────────────────────────────────────────────── +# Experiment D — hardware (per-pair + shared legend) + + +_HARDWARE_DROP_PAIRS = {"qwen-30b-a3b-instruct-1gpu"} # information-redundant with MoE findings in Exp A + + +def plot_hardware(df, out, logistic_models, *, suffix: str = "") -> None: + agg = _aggregate(df).merge( + df[["variant", "pair", "hardware"]].drop_duplicates(), + on="variant", how="left", + ) + pairs = [p for p in sorted(agg["pair"].unique()) if p not in _HARDWARE_DROP_PAIRS] + hardware_order = ["H100", "B200"] + + variants_by_pair_hw: dict[tuple[str, str], str] = {} + for _, row in agg.drop_duplicates(["pair", "hardware", "variant"]).iterrows(): + variants_by_pair_hw[(row["pair"], row["hardware"])] = row["variant"] + + def _v(pair_, hw, mode, col): + v = variants_by_pair_hw.get((pair_, hw)) + if v is None: + return math.nan + r = agg[(agg["variant"] == v) & (agg["mode"] == mode)] + return float(r[col].iloc[0]) if not r.empty else math.nan + + # Colors by model (matching Fig 2 style). + palette = plt.get_cmap("tab10").colors + pair_colors = {p: palette[i % len(palette)] for i, p in enumerate(pairs)} + pair_labels = [_pretty(p) for p in pairs] + + # (a) — integral violation. X-axis = hardware. Within each hardware + # group, 4 bars: 2 models × (uncoord, coord). Color = model, hatch = + # uncoord (hatched) / coord (solid). + bar_width = 0.18 + x = np.arange(len(hardware_order)) + fig, ax = _make_panel() + n = len(pairs) + for pi, pair in enumerate(pairs): + color = pair_colors[pair] + for mi, mode in enumerate([BASELINE_MODE, OFO_MODE]): + hatch = "///" if mode == BASELINE_MODE else "" + offset = (pi - (n - 1) / 2) * 2 * bar_width + (mi - 0.5) * bar_width + vals = [_v(pair, hw, mode, "integral_mean") for hw in hardware_order] + ax.bar(x + offset, vals, bar_width, + color=color, hatch=hatch, edgecolor="white", linewidth=0.6) + ax.set_xticks(x) + ax.set_xticklabels(hardware_order) + ax.set_yscale("log") + lo, hi = ax.get_ylim() + ax.set_ylim(lo, hi * 1.5) + ax.set_ylabel("Integral violation\n(pu·s)", fontsize=9) + ax.grid(True, axis="y", alpha=0.2) + _save(fig, out / f"hardware_a{suffix}") + + # (b) — Pareto for both models on BOTH hardware. Color = model + # (shared with (a) and the main legend). Marker fill = hardware: + # open = H100, filled = B200. An inline mini-legend inside the axes + # explains the marker encoding. + fig, ax = _make_panel() + for pair in pairs: + color = pair_colors[pair] + for hw in hardware_order: + variant = variants_by_pair_hw.get((pair, hw)) + if variant is None: + continue + r = df[(df["variant"] == variant) & (df["mode"] == OFO_MODE)].iloc[0].to_dict() + batches, p_mw, t_m = _pareto_from_row(r, logistic_models) + if len(batches) == 0: + continue + if hw == "H100": + ax.plot(p_mw, t_m, "-o", color=color, markersize=5.5, + markerfacecolor="white", markeredgecolor=color, + markeredgewidth=1.3, lw=1.6, alpha=0.95) + else: + ax.plot(p_mw, t_m, "-o", color=color, markersize=5.5, + markeredgecolor="black", markeredgewidth=0.4, + lw=1.6, alpha=0.95) + _apply_pareto_axes(ax) + # Inline hardware-marker key (small, inside the axes so it doesn't + # add to the shared top legend). + hw_key = [ + Line2D([0], [0], marker="o", color="#555", markerfacecolor="white", + markeredgecolor="#555", markeredgewidth=1.3, lw=1.0, label="H100"), + Line2D([0], [0], marker="o", color="#555", markerfacecolor="#555", + markeredgecolor="black", markeredgewidth=0.4, lw=1.0, label="B200"), + ] + ax.legend(handles=hw_key, loc="upper left", frameon=False, + fontsize=8, handlelength=1.2, handletextpad=0.4, + borderaxespad=0.2, labelspacing=0.25) + _save(fig, out / f"hardware_b{suffix}") + + # Shared legend: model colors only. + handles = [_combo_handle(pair_colors[p], _pretty(p)) for p in pairs] + _save_legend(handles, out / f"hardware_legend{suffix}", + ncol=len(pairs), width=2.5) + + +# ────────────────────────────────────────────────────────────────────── +# Experiment E — precision + + +MAIN_PRECISION_PAIR = "qwen-235b-a22b-instruct-h100-8gpu" + + +def plot_precision(df, out, logistic_models, *, suffix: str = "") -> None: + # Main paper shows only the P1 pair; the other two bf16↔FP8 pairs + # (Thinking on H100 8 GPU, Thinking on B200 4 GPU) share the same + # FP8-flattens-power-curve mechanism, differing only on task and + # hardware axes, and live in the appendix. + df = df[df["pair"] == MAIN_PRECISION_PAIR].copy() + agg = _aggregate(df).merge( + df[["variant", "precision_label"]].drop_duplicates(), on="variant", how="left", + ) + variants = agg.drop_duplicates("variant").sort_values("variant")["variant"].tolist() + labels = [agg[agg["variant"] == v]["precision_label"].iloc[0] for v in variants] + + def _v(v, mode, col): + r = agg[(agg["variant"] == v) & (agg["mode"] == mode)] + return float(r[col].iloc[0]) if not r.empty else math.nan + + x = np.arange(len(variants)) + + color_map = {"bf16": COLORS["ofo"], "FP8": COLORS["hardware"]} + + # (a) violation — colored bars, hatch = uncoord., solid = coord. + fig, ax = _make_panel() + _grouped_bars( + ax, + [_v(v, BASELINE_MODE, "integral_mean") for v in variants], + [_v(v, OFO_MODE, "integral_mean") for v in variants], + [color_map[lbl] for lbl in labels], + base_errs=[_v(v, BASELINE_MODE, "integral_std") for v in variants], + ofo_errs=[_v(v, OFO_MODE, "integral_std") for v in variants], + ) + _save(fig, out / f"precision_a{suffix}") + + # (b) Pareto — same colors + entries = [] + for v, lbl in zip(variants, labels): + r = df[(df["variant"] == v) & (df["mode"] == OFO_MODE)].iloc[0].to_dict() + r["color_key"] = lbl + entries.append(r) + fig, ax = _make_panel() + _plot_pareto(ax, entries, logistic_models, + color_map=color_map, + label_map={vv: lbl for vv, lbl in zip(variants, labels)}) + _apply_pareto_axes(ax) + _save(fig, out / f"precision_b{suffix}") + + # Shared legend — colors only; hatch/solid explained in the caption. + # Display "BF16" (not "bf16") per paper style. + _display_prec = lambda l: "BF16" if l == "bf16" else l + handles = [_combo_handle(color_map[lbl], _display_prec(lbl)) for lbl in sorted(set(labels))] + _save_legend(handles, out / f"precision_legend{suffix}", + ncol=len(set(labels)), width=1.8) + + +# ────────────────────────────────────────────────────────────────────── + + +def main( + indir: Path = OUTDIR, + outdir: Path = OUTDIR / "figures", + suffix: str = "", + paper_figure_dir: Path | None = None, +) -> None: + """Generate Section-5 figures. + + Args: + indir: Directory holding per-experiment CSVs. + outdir: Where to write PDF/SVG/PNG per panel. + suffix: GPU suffix appended to every filename (e.g., `_b200`). + paper_figure_dir: If set, copy every generated PDF into this + directory as well. Intended for syncing into the paper repo + (`openg2g-paper/figures`). + """ + import shutil + logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s [%(name)s] %(message)s", datefmt="%H:%M:%S") + outdir.mkdir(parents=True, exist_ok=True) + + shared = mi.load_shared_data() + lm = shared.logistic_models + + dfs: dict[str, pd.DataFrame] = {} + for name in ("model_size", "itl_deadline", "parallelism", "hardware", "precision"): + path = indir / f"{name}{suffix}.csv" + if not path.exists(): + logger.warning("Missing %s — skipping", path) + continue + dfs[name] = pd.read_csv(path) + logger.info("Loaded %s: %d rows", path.name, len(dfs[name])) + + if "model_size" in dfs: + plot_model_size(dfs["model_size"], outdir, lm, suffix=suffix) + if "itl_deadline" in dfs: + plot_itl_deadline(dfs["itl_deadline"], outdir, lm, suffix=suffix) + if "parallelism" in dfs: + plot_parallelism(dfs["parallelism"], outdir, lm, suffix=suffix) + if "hardware" in dfs: + plot_hardware(dfs["hardware"], outdir, lm, suffix=suffix) + if "precision" in dfs: + plot_precision(dfs["precision"], outdir, lm, suffix=suffix) + + logger.info("Wrote figures to %s", outdir) + + if paper_figure_dir is not None: + paper_figure_dir = Path(paper_figure_dir) + paper_figure_dir.mkdir(parents=True, exist_ok=True) + copied = 0 + for pdf in outdir.glob(f"*{suffix}.pdf"): + shutil.copy2(pdf, paper_figure_dir / pdf.name) + copied += 1 + logger.info("Synced %d PDFs into %s", copied, paper_figure_dir) + + +if __name__ == "__main__": + tyro.cli(main) diff --git a/scripts/verify_post_merge_migration.sh b/scripts/verify_post_merge_migration.sh new file mode 100755 index 0000000..f04f191 --- /dev/null +++ b/scripts/verify_post_merge_migration.sh @@ -0,0 +1,110 @@ +#!/bin/bash +# +# Post-merge verification: regenerate the per-spec InferenceData cache under +# data/specs/ and run a 1-step train_ppo to confirm the migrated example +# scripts wire end-to-end. +# +# Why this exists: master removed the JSON-driven config.json + monolithic +# data/offline// pipeline and replaced it with a per-spec +# content-addressed cache (data/specs//_manifest.json). The +# rl_controller-branch examples were migrated to the new APIs in commits +# 4362c2dd / 6d5d3ec1 / da8b6ec1 / f1c5f3ce, but the spec cache hasn't been +# regenerated on most machines. InferenceData.ensure() does the regen +# automatically on first call, but the extraction step needs ~32-64 GB of +# RAM, which exceeds login-node limits. Run this script via sbatch (not on +# the login node) so the regen has enough memory. +# +# Output: a populated data/specs/ tree (one subdir per InferenceModelSpec +# cache_hash) and a confirmation that train_ppo can ingest it. +# +#SBATCH --job-name=verify_post_merge +#SBATCH --account=zhirui0 +#SBATCH --partition=standard +#SBATCH --nodes=1 +#SBATCH --ntasks=1 +#SBATCH --cpus-per-task=8 +#SBATCH --mem=64G +#SBATCH --time=02:00:00 +#SBATCH --output=/scratch/zhirui_root/zhirui0/zhirui/openg2g/slurm_verify_post_merge_%j.out + +set -euo pipefail + +echo "=== environment ===" +date; hostname +echo "SLURM_JOB_ID=${SLURM_JOB_ID:-none}" + +module load python/3.10.4 +source /home/zhirui/venvs/pt_mod/bin/activate + +REPO_ROOT="/gpfs/accounts/zhirui_root/zhirui0/zhirui/openg2g" +cd "${REPO_ROOT}/examples/offline" + +echo +echo "=== step 1: regenerate per-spec InferenceData cache ===" +echo " (downloads from HF Hub, extracts timelines, fits ITL distributions)" +python -u -c " +import sys, logging +logging.basicConfig(level=logging.INFO, format='%(asctime)s %(levelname)s %(name)s: %(message)s', force=True) +sys.path.insert(0, '.') +import systems +from openg2g.controller.ofo import LogisticModelStore +from openg2g.datacenter.workloads.inference import InferenceData +from openg2g.datacenter.workloads.training import TrainingTrace + +print(f'SPECS_CACHE_DIR = {systems.SPECS_CACHE_DIR}', flush=True) +print(f'TRAINING_TRACE_PATH = {systems.TRAINING_TRACE_PATH}', flush=True) +print(f'ALL_MODEL_SPECS = {[s.model_label for s in systems.ALL_MODEL_SPECS]}', flush=True) +print(flush=True) + +inference_data = InferenceData.ensure( + systems.SPECS_CACHE_DIR, systems.ALL_MODEL_SPECS, plot=False, dt_s=1.0, +) +print(f'InferenceData ready: {len(inference_data.models)} models', flush=True) + +training_trace = TrainingTrace.ensure(systems.TRAINING_TRACE_PATH) +print(f'TrainingTrace ready: {systems.TRAINING_TRACE_PATH}', flush=True) + +logistic_models = LogisticModelStore.ensure( + systems.SPECS_CACHE_DIR, systems.ALL_MODEL_SPECS, plot=False, +) +print(f'LogisticModelStore ready: {len(systems.ALL_MODEL_SPECS)} models', flush=True) +" + +echo +echo "=== step 2: 1-step train_ppo on ieee13 ===" +echo " (verifies make_sim_factory, OfflineWorkload.replica_schedules," +echo " grid.attach_dc, Coordinator(datacenters=[...]) all wire end-to-end)" +SMOKE_OUT="/tmp/ppo_smoke_${SLURM_JOB_ID:-$$}" +python -u train_ppo.py --system ieee13 --total-timesteps 1 --output-dir "${SMOKE_OUT}" + +echo +echo "=== step 3: 1-scenario evaluate_controllers on the just-trained model ===" +echo " (verifies the eval pipeline: scenario randomization, baseline/OFO/PPO" +echo " simulation, voltage/throughput metrics)" +EVAL_OUT="/tmp/eval_smoke_${SLURM_JOB_ID:-$$}" +python -u evaluate_controllers.py \ + --system ieee13 \ + --n-scenarios 1 \ + --ppo-models "${SMOKE_OUT}/ppo_model.zip" \ + --ppo-labels ppo_smoke \ + --no-per-scenario-plots \ + --no-aggregate-plots \ + --output-dir "${EVAL_OUT}" + +echo +echo "=== summary ===" +echo "data/specs/ contents:" +ls -la "${REPO_ROOT}/data/specs/" | head +echo +echo "manifest count:" +find "${REPO_ROOT}/data/specs/" -name '_manifest.json' | wc -l +echo +echo "train smoke output:" +ls -la "${SMOKE_OUT}/" 2>&1 | head +echo +echo "eval smoke output:" +ls -la "${EVAL_OUT}" 2>&1 | head -20 + +echo +echo "verification done" +date From 29c14e179b7d5f74e6e72e3f4e93065ff9e30894 Mon Sep 17 00:00:00 2001 From: Zhirui Liang Date: Mon, 4 May 2026 00:05:38 -0400 Subject: [PATCH 02/13] [Clean] Untrack repo-root paper_plots.mplstyle and plots.py --- .gitignore | 2 + paper_plots.mplstyle | 42 --- plots.py | 718 ------------------------------------------- 3 files changed, 2 insertions(+), 760 deletions(-) delete mode 100644 paper_plots.mplstyle delete mode 100644 plots.py diff --git a/.gitignore b/.gitignore index ce595c5..1397062 100644 --- a/.gitignore +++ b/.gitignore @@ -37,3 +37,5 @@ examples/offline/merge_over_supplement_ieee13.py examples/offline/migrate_scenario_library.py examples/offline/filter_scenario_library.py .git.backup-pre-filter/ +/paper_plots.mplstyle +/plots.py diff --git a/paper_plots.mplstyle b/paper_plots.mplstyle deleted file mode 100644 index b955a1f..0000000 --- a/paper_plots.mplstyle +++ /dev/null @@ -1,42 +0,0 @@ - -### Fonts -font.size : 9 -axes.labelsize : 10 -axes.titlesize : 10 -xtick.labelsize : 8.5 -ytick.labelsize : 8.5 -legend.fontsize : 8 - -# San-serif font use Arial -font.family: sans-serif -#font.sans-serif: Helvetica -font.sans-serif: Arial - -### Axes / grid defaults -axes.grid : False -grid.alpha : 0.25 -grid.linewidth : 0.5 -axes.spines.top : False -axes.spines.right : False - -# Serif font use TMR -#font.family: serif -#font.serif: Times New Roman -#mathtext.fontset : stix - - -### Plotting -# lines.linewidth : 2 - -### Saving figures -path.simplify : True - -# SVG export now don't render font as path, but save font as text object. -# This allows for easy integration with MS Office -svg.fonttype : none -# Make SVG generation deterministic -svg.hashsalt : 42 - -# Enforce Type 1 font usage -pdf.fonttype : 42 -ps.fonttype : 42 diff --git a/plots.py b/plots.py deleted file mode 100644 index 517e5da..0000000 --- a/plots.py +++ /dev/null @@ -1,718 +0,0 @@ -"""Cross-variant plots for Section-5 (Model-insights) experiments. - -Paper-grade output: each panel is saved as its own file in PDF, SVG, and -PNG. The paper composes subfloats with `\\subfloat{...}`. No -`set_title` / `suptitle` — captions carry semantics. - -Style from `openg2g-paper/figures/paper_plots.mplstyle` (Arial 9 pt, -Type-1 fonts, deterministic SVG). Every field in it is load-bearing; the -script raises if the file is missing. - -Legends are color-only (no marker/linestyle cycling), horizontal below -the axis, no legend title. Display labels drop precision / parallelism -decorations — those belong in captions, not figure legends. -""" - -from __future__ import annotations - -import logging -import math -from pathlib import Path - -import matplotlib - -matplotlib.use("Agg") -import matplotlib.pyplot as plt -from matplotlib.lines import Line2D -from matplotlib.patches import Patch -import numpy as np -import pandas as pd -import tyro - -import common as mi - -_MPLSTYLE_PATH = Path("/Users/jaywonchung/workspace/openg2g-paper/figures/paper_plots.mplstyle") -if not _MPLSTYLE_PATH.exists(): - raise FileNotFoundError( - f"Paper mplstyle not found at {_MPLSTYLE_PATH}. " - "This file is the paper-wide plot style and is load-bearing; " - "do not regenerate figures without it." - ) -plt.style.use(str(_MPLSTYLE_PATH)) - -logger = logging.getLogger("model_insights.plots") - -OUTDIR = Path(__file__).resolve().parent / "outputs" - -OFO_MODE = "ofo-no-tap" -BASELINE_MODE = "baseline-no-tap" - -COLORS = { - "baseline": "#9A9A9A", - "ofo": "#4C72B0", - "h100": "#4C72B0", - "b200": "#C44E52", - "model_size": "#4C72B0", - "itl_deadline": "#55A868", - "parallelism": "#DD8452", - "hardware": "#C44E52", - "precision": "#8172B3", -} - -EXP_LABELS = { - "model_size": "A. Model size & architecture", - "precision": "B. Weight precision", - "parallelism": "C. Parallelism", - "hardware": "D. GPU hardware generation", - "itl_deadline": "E. DC capacity under SLO", -} - -# Display labels kept minimal — drop #GPUs, precision, parallelism -# suffixes. Those belong in the paper caption, not the figure legend. -DISPLAY_LABELS = { - "Qwen3-8B": "Qwen 3 8B", - "Qwen3-14B": "Qwen 3 14B", - "Qwen3-32B-TP2": "Qwen 3 32B", - "Qwen3-30B-A3B-Instruct-1GPU": "Qwen 3 30B A3B", - "Qwen3-30B-A3B-Instruct-2GPU": "Qwen 3 30B A3B", - "Llama-3.1-8B": "Llama 3.1 8B", - "Llama-3.1-70B": "Llama 3.1 70B", - "Llama-3.1-405B": "Llama 3.1 405B", - "GPT-OSS-120B-H100-2GPU": "GPT-OSS 120B", - "GPT-OSS-120B-B200-1GPU": "GPT-OSS 120B", - "GPT-OSS-120B-B200-2GPU": "GPT-OSS 120B", - "DeepSeek-V3.1-B200-8GPU": "DeepSeek V3.1", - "DeepSeek-R1-B200-8GPU": "DeepSeek R1", - "Qwen3-8B-B200": "Qwen 3 8B", - "Qwen3-14B-B200": "Qwen 3 14B", - "Qwen3-32B-1GPU-H100": "Qwen 3 32B", - "Qwen3-32B-1GPU-B200": "Qwen 3 32B", - "Qwen3-30B-A3B-Instruct-1GPU-B200": "Qwen 3 30B A3B", - "Qwen3-Coder-30B-A3B-B200": "Qwen 3 Coder 30B A3B", - "Qwen3-235B-A22B-Instruct-FP8-4GPU": "Qwen 3 235B A22B", - "Qwen3-235B-A22B-Instruct-FP8-8GPU": "Qwen 3 235B A22B", - "Qwen3-235B-A22B-Instruct-8GPU": "Qwen 3 235B A22B", - "Qwen3-235B-A22B-Instruct-4GPU-B200": "Qwen 3 235B A22B", - "Qwen3-235B-A22B-Instruct-8GPU-B200": "Qwen 3 235B A22B", - "Qwen3-235B-A22B-Thinking-8GPU": "Qwen 3 235B A22B Thinking", - "Qwen3-235B-A22B-Thinking-4GPU-B200": "Qwen 3 235B A22B Thinking", - "Qwen3-235B-A22B-Thinking-8GPU-B200": "Qwen 3 235B A22B Thinking", - "Qwen3-235B-A22B-Thinking-FP8-8GPU": "Qwen 3 235B A22B Thinking", - "Qwen3-235B-A22B-Thinking-FP8-4GPU-B200": "Qwen 3 235B A22B Thinking", - "qwen-8b-1gpu": "Qwen 3 8B", - "qwen-32b-1gpu": "Qwen 3 32B", - "qwen-30b-a3b-instruct-1gpu": "Qwen 3 30B A3B", - "qwen-30b-a3b-instruct": "Qwen 3 30B A3B", - "qwen-235b-a22b-instruct": "Qwen 3 235B A22B", - "qwen-235b-a22b-thinking": "Qwen 3 235B A22B Thinking", - "gpt-oss-120b": "GPT-OSS 120B", -} - - -def _pretty(label: str) -> str: - return DISPLAY_LABELS.get(label, label) - - -def _bar_handle(color: str, label: str, *, alpha: float = 1.0, hatch: str | None = None) -> Patch: - """Legend handle for a bar.""" - return Patch(facecolor=color, alpha=alpha, hatch=hatch, edgecolor="white", label=label) - - -def _line_handle(color: str, label: str) -> Line2D: - """Legend handle for a line-marker plot (Pareto curves).""" - return Line2D( - [0], [0], color=color, marker="o", markersize=5.5, - markeredgecolor="black", markeredgewidth=0.4, lw=1.6, label=label, - ) - - -def _combo_handle(color: str, label: str): - """Composite legend handle — a bar swatch + a line marker, both in - `color`, rendered side-by-side under one label. Use with - `HandlerTuple` so one legend entry covers both the (a) bars and the - (b) Pareto panels at once. - """ - bar = Patch(facecolor=color, edgecolor="white") - line = Line2D([0], [0], color=color, marker="o", markersize=5.5, - markeredgecolor="black", markeredgewidth=0.4, lw=1.6) - return ((bar, line), label) - - -def _legend_above(ax, handles, *, ncol: int) -> None: - """Place a color-only legend directly above the plot axes (inline).""" - if not handles: - return - ax.legend( - handles=handles, ncol=ncol, - loc="lower center", bbox_to_anchor=(0.5, 1.02), - frameon=False, handlelength=1.3, columnspacing=1.1, - handletextpad=0.5, borderaxespad=0, - ) - - -def _save_legend(handles, stem: Path, *, ncol: int, width: float, - tight: bool = True) -> None: - """Save a standalone legend PDF. - - Handles may be plain artists OR `(tuple_of_artists, label)` pairs - (produced by `_combo_handle`) that render as side-by-side glyphs - under one label via `HandlerTuple`. When `tight=False` the full - figsize is preserved (useful for padding a narrow legend so it - renders at larger physical font in LaTeX). - """ - if not handles: - return - from matplotlib.legend_handler import HandlerTuple - - plain_handles, tuple_handles, tuple_labels = [], [], [] - for h in handles: - if isinstance(h, tuple) and len(h) == 2 and isinstance(h[0], tuple): - tuple_handles.append(h[0]) - tuple_labels.append(h[1]) - else: - plain_handles.append(h) - - fig, ax = plt.subplots(figsize=(width, 0.35)) - ax.axis("off") - legend_fontsize = 11 - if tuple_handles: - ax.legend( - handles=tuple_handles + plain_handles, - labels=tuple_labels + [h.get_label() for h in plain_handles], - ncol=ncol, loc="center", frameon=False, fontsize=legend_fontsize, - handlelength=2.6, columnspacing=1.2, handletextpad=0.5, - handler_map={tuple: HandlerTuple(ndivide=None, pad=0.2)}, - ) - else: - ax.legend( - handles=plain_handles, ncol=ncol, loc="center", frameon=False, - fontsize=legend_fontsize, - handlelength=1.2, columnspacing=1.2, handletextpad=0.4, - ) - stem.parent.mkdir(parents=True, exist_ok=True) - if tight: - kw = {"bbox_inches": "tight", "pad_inches": 0.02} - else: - kw = {"bbox_inches": None, "pad_inches": 0.0} - fig.savefig(stem.with_suffix(".pdf"), metadata={"CreationDate": None}, **kw) - fig.savefig(stem.with_suffix(".svg"), metadata={"Date": None}, **kw) - fig.savefig(stem.with_suffix(".png"), dpi=200, metadata={"Creation Time": None}, **kw) - plt.close(fig) - - -def _grouped_bars( - ax, base_vals, ofo_vals, colors, *, - base_errs=None, ofo_errs=None, - ylog: bool = True, ylabel: str = "Integral violation\n(pu·s)", - bar_width: float = 0.38, -) -> None: - """Bars grouped by variant. Each variant has two bars: hatched - (uncoordinated) and solid (coordinated), both colored by variant. - No x tick labels — identity comes from the shared legend + caption.""" - n = len(colors) - xs = np.arange(n) - offset = bar_width / 2 + 0.012 - for i, color in enumerate(colors): - be = base_errs[i] if base_errs is not None else None - oe = ofo_errs[i] if ofo_errs is not None else None - ax.bar(xs[i] - offset, base_vals[i], bar_width, - color=color, hatch="///", edgecolor="white", linewidth=0.6, - yerr=be, capsize=1.8, error_kw={"lw": 0.7}) - ax.bar(xs[i] + offset, ofo_vals[i], bar_width, - color=color, edgecolor="white", linewidth=0.6, - yerr=oe, capsize=1.8, error_kw={"lw": 0.7}) - ax.set_xticks([]) - ax.set_xlim(-0.6, n - 0.4) - ax.set_ylabel(ylabel, fontsize=9) - if ylog: - ax.set_yscale("log") - lo, hi = ax.get_ylim() - ax.set_ylim(lo, hi * 1.2) - ax.grid(True, axis="y", alpha=0.25) - - -# Fixed axes rectangle (in figure fraction). Every (a)/(b) panel uses -# this same position so that — combined with a shared figsize and -# `bbox_inches=None` — the saved PDFs have identical dimensions and -# axes at identical positions. When composed side-by-side in LaTeX, -# x-baselines line up exactly. -_PANEL_FIGSIZE = (2.6, 1.8) -_PANEL_AXES = (0.23, 0.26, 0.74, 0.70) # (left, bottom, width, height) - - -def _make_panel(): - fig, ax = plt.subplots(figsize=_PANEL_FIGSIZE) - ax.set_position(_PANEL_AXES) - return fig, ax - - -def _save(fig: plt.Figure, stem: Path) -> None: - stem.parent.mkdir(parents=True, exist_ok=True) - # No tight_layout — it overrides set_position. bbox_inches=None - # preserves the exact figsize, guaranteeing matched dimensions. - kwargs = {"bbox_inches": None, "pad_inches": 0.0} - fig.savefig(stem.with_suffix(".pdf"), metadata={"CreationDate": None}, **kwargs) - fig.savefig(stem.with_suffix(".svg"), metadata={"Date": None}, **kwargs) - fig.savefig(stem.with_suffix(".png"), dpi=200, metadata={"Creation Time": None}, **kwargs) - plt.close(fig) - - -def _aggregate(df: pd.DataFrame) -> pd.DataFrame: - return df.groupby(["variant", "mode"], as_index=False).agg( - n_seeds=("seed", "nunique"), - integral_mean=("integral_violation_pu_s", "mean"), - integral_std=("integral_violation_pu_s", "std"), - vmin_mean=("worst_vmin", "mean"), - thpt_mean=("mean_throughput_tps", "mean"), - itl_miss_mean=("itl_deadline_fraction", "mean"), - apr_mw=("achievable_power_range_mw", "mean"), - ) - - -def _pareto_from_row(row, logistic_models) -> tuple[np.ndarray, np.ndarray, np.ndarray]: - """Return (batches, power MW, throughput M tok/s) for one variant.""" - variant = row["variant"] - base = variant.split("_dl")[0] - spec = mi.SPECS[base] - fmax = int(row["feasible_batch_max"]) - fmin = int(row["feasible_batch_min"]) - batches = np.array([b for b in sorted(spec.feasible_batch_sizes) if fmin <= b <= fmax], dtype=int) - p_fit = logistic_models.power(base) - t_fit = logistic_models.throughput(base) - n = int(row["num_replicas"]) - p_mw = np.array([float(p_fit.eval(int(b))) * n / 1e6 for b in batches]) - t_mtps = np.array([float(t_fit.eval(int(b))) * n / 1e6 for b in batches]) # tokens/s → M tokens/s - return batches, p_mw, t_mtps - - -def _apply_pareto_axes(ax, with_ylabel: bool = True) -> None: - ax.set_xlabel("Datacenter power (MW)", fontsize=9) - if with_ylabel: - ax.set_ylabel("Token throughput\n(M tok/s)", fontsize=9) - ax.set_ylim(bottom=0) - ax.set_xlim(left=max(0, ax.get_xlim()[0])) - ax.grid(True, alpha=0.3) - - -def _plot_pareto( - ax, entries, logistic_models, color_map, label_map, - *, marker_size: int = 5.5, -) -> None: - """Color-only Pareto curves. Each entry is a dict with variant, - num_replicas, feasible_batch_min, feasible_batch_max.""" - for entry in entries: - batches, p_mw, t_m = _pareto_from_row(entry, logistic_models) - if len(batches) == 0: - continue - key = entry.get("color_key", entry["variant"]) - color = color_map.get(key, COLORS["ofo"]) - label = label_map.get(entry["variant"], _pretty(entry["variant"])) - ax.plot(p_mw, t_m, "-o", color=color, markersize=marker_size, - markeredgecolor="black", markeredgewidth=0.4, - label=label, alpha=0.95, lw=1.6) - - -def _pair_bars( - ax, x, base_vals, ofo_vals, *, ylog: bool, ylabel: str, - base_errs=None, ofo_errs=None, tick_labels=None, rotation: float = 25.0, -) -> None: - width = 0.38 - ax.bar(x - width / 2, base_vals, width, yerr=base_errs, - color=COLORS["baseline"], label="Baseline", capsize=2.5) - ax.bar(x + width / 2, ofo_vals, width, yerr=ofo_errs, - color=COLORS["ofo"], label="OFO", capsize=2.5) - ax.set_xticks(x) - if tick_labels is not None: - ax.set_xticklabels(tick_labels, rotation=rotation, - ha="right" if rotation else "center") - ax.set_ylabel(ylabel) - if ylog: - ax.set_yscale("log") - lo, hi = ax.get_ylim() - ax.set_ylim(lo, hi * 2.5) - ax.grid(True, axis="y", alpha=0.25) - - -# ────────────────────────────────────────────────────────────────────── -# Experiment A — model size & architecture - - -def plot_model_size(df, out, logistic_models, *, suffix: str = "") -> None: - agg = _aggregate(df) - all_variants = df["variant"].drop_duplicates().tolist() - - # Sort variants by power-swing range (least flexible → most flexible). - # This is roughly the ordering of how much coordination can reduce - # the integral violation, so the bar chart reads as a "flexibility - # ladder" from left to right. - def _swing(variant: str) -> float: - row = df[(df["variant"] == variant) & (df["mode"] == OFO_MODE)].iloc[0].to_dict() - _, p_mw, _ = _pareto_from_row(row, logistic_models) - return float(p_mw.max() - p_mw.min()) if len(p_mw) else 0.0 - - variants = sorted(all_variants, key=_swing) - palette = plt.get_cmap("tab10").colors - color_map = {v: palette[i % len(palette)] for i, v in enumerate(variants)} - - def _val(v, mode, col): - row = agg[(agg.variant == v) & (agg["mode"] == mode)] - return float(row[col].iloc[0]) if not row.empty else math.nan - - # (a) integral violation — colored bars, hatched (uncoord.) vs solid (coord.) - fig, ax = _make_panel() - _grouped_bars( - ax, - [_val(v, BASELINE_MODE, "integral_mean") for v in variants], - [_val(v, OFO_MODE, "integral_mean") for v in variants], - [color_map[v] for v in variants], - base_errs=[_val(v, BASELINE_MODE, "integral_std") for v in variants], - ofo_errs=[_val(v, OFO_MODE, "integral_std") for v in variants], - ) - _save(fig, out / f"model_size_a{suffix}") - - # (b) Pareto frontier — same color per variant - entries = [df[(df["variant"] == v) & (df["mode"] == OFO_MODE)].iloc[0].to_dict() for v in variants] - fig, ax = _make_panel() - _plot_pareto(ax, entries, logistic_models, - color_map=color_map, - label_map={v: _pretty(v) for v in variants}) - _apply_pareto_axes(ax) - _save(fig, out / f"model_size_b{suffix}") - - # Shared legend: model colors only (hatch/solid explained in caption). - handles = [_combo_handle(color_map[v], _pretty(v)) for v in variants] - _save_legend(handles, out / f"model_size_legend{suffix}", - ncol=len(variants), width=4.6) - - -# ────────────────────────────────────────────────────────────────────── -# Experiment B — ITL deadline - - -def plot_itl_deadline(df, out, logistic_models, *, suffix: str = "") -> None: - """Exp B — DC capacity under SLO. One panel: for each SLO, show the - grid-feasible replica-count interval [R_min, R_max] under baseline vs - OFO. Gap between baseline's R_max and OFO's R_max is the coordination - capacity unlock. - """ - # Collapse seeds — num_replicas is the binary-search result (fixed per - # SLO × mode × endpoint). integral_mean at the edges is by design - # right at the feasibility threshold. - piv = (df.groupby(["deadline_ms", "mode", "endpoint"])["num_replicas"] - .first().unstack("endpoint").reset_index()) - piv = piv.sort_values("deadline_ms") - - base = piv[piv["mode"] == BASELINE_MODE].set_index("deadline_ms").sort_index() - ofo = piv[piv["mode"] == OFO_MODE].set_index("deadline_ms").sort_index() - xs = base.index.to_numpy(dtype=float) - - fig, ax = plt.subplots(figsize=(2.8, 1.9)) - # Baseline feasible band (shared lower edge with OFO). - ax.fill_between(xs, base["r_min"], base["r_max"], - color=COLORS["baseline"], alpha=0.45, - label="Baseline feasible") - # OFO's additional unlocked region — between baseline's R_max and OFO's R_max. - ax.fill_between(xs, base["r_max"].to_numpy(), ofo["r_max"].to_numpy(), - color=COLORS["ofo"], alpha=0.50, - label="Unlocked by OFO") - # Boundary lines for clarity. - ax.plot(xs, base["r_min"], "-", color="black", lw=0.8, zorder=3) - ax.plot(xs, base["r_max"], "-", color=COLORS["baseline"], lw=1.6, zorder=3) - ax.plot(xs, ofo["r_max"], "-", color=COLORS["ofo"], lw=1.6, zorder=3) - - ax.set_xlabel("ITL deadline (ms)") - ax.set_ylabel("Replicas") - ax.set_ylim(bottom=0) - ax.grid(True, alpha=0.25) - _legend_above( - ax, - [_bar_handle(COLORS["baseline"], "Baseline feasible", alpha=0.45), - _bar_handle(COLORS["ofo"], "Unlocked by OFO", alpha=0.50), - Line2D([0], [0], color="black", lw=1.0, label=r"$R_{\min}$ boundary")], - ncol=3, - ) - _save(fig, out / f"itl_deadline_a{suffix}") - - -# ────────────────────────────────────────────────────────────────────── -# Experiment C — parallelism - - -def plot_parallelism(df, out, logistic_models, *, suffix: str = "") -> None: - agg = _aggregate(df).merge( - df[["variant", "pair", "parallelism_label"]].drop_duplicates(), - on="variant", how="left", - ) - pairs = sorted(agg["pair"].unique()) - for pair in pairs: - sub = agg[agg["pair"] == pair].copy() - variants = ( - sub[["variant", "parallelism_label"]] - .drop_duplicates().sort_values("variant")["variant"].tolist() - ) - labels = [sub[sub["variant"] == v]["parallelism_label"].iloc[0] for v in variants] - x = np.arange(len(variants)) - stem = "parallelism" if len(pairs) == 1 else f"parallelism_{pair}" - - def _v(v, mode, col): - r = sub[(sub["variant"] == v) & (sub["mode"] == mode)] - return float(r[col].iloc[0]) if not r.empty else math.nan - - color_map = {labels[i]: (COLORS["h100"] if i == 0 else COLORS["b200"]) - for i in range(len(variants))} - - # (a) integral violation — colored bars, hatch = baseline vs OFO - fig, ax = _make_panel() - _grouped_bars( - ax, - [_v(v, BASELINE_MODE, "integral_mean") for v in variants], - [_v(v, OFO_MODE, "integral_mean") for v in variants], - [color_map[lbl] for lbl in labels], - base_errs=[_v(v, BASELINE_MODE, "integral_std") for v in variants], - ofo_errs=[_v(v, OFO_MODE, "integral_std") for v in variants], - ) - _save(fig, out / f"{stem}_a{suffix}") - - # (b) Pareto — same colors. No inline legend; standalone legend - # saved below so all experiments share the same font metrics. - entries = [] - for vv, lbl in zip(variants, labels): - r = df[(df["variant"] == vv) & (df["mode"] == OFO_MODE)].iloc[0].to_dict() - r["color_key"] = lbl - entries.append(r) - fig, ax = _make_panel() - _plot_pareto(ax, entries, logistic_models, - color_map=color_map, - label_map={vv: lbl for vv, lbl in zip(variants, labels)}) - _apply_pareto_axes(ax) - _save(fig, out / f"{stem}_b{suffix}") - - # Parallelism main-text panels are Pareto-only (no bars), so the - # legend shows line-marker handles rather than the bar+line combo - # used by Figs A/B/D. Native width is pushed wider with larger - # columnspacing so the LaTeX-side scale factor matches Fig 2's - # (Fig 2 is rendered at \\linewidth / 6.14in ≈ 0.9×; we want the - # same scale here so the rendered font matches). - handles = [_line_handle(color_map[lbl], lbl) for lbl in labels] - _save_legend(handles, out / f"{stem}_legend{suffix}", - ncol=len(labels), width=2.3) - - -# ────────────────────────────────────────────────────────────────────── -# Experiment D — hardware (per-pair + shared legend) - - -_HARDWARE_DROP_PAIRS = {"qwen-30b-a3b-instruct-1gpu"} # information-redundant with MoE findings in Exp A - - -def plot_hardware(df, out, logistic_models, *, suffix: str = "") -> None: - agg = _aggregate(df).merge( - df[["variant", "pair", "hardware"]].drop_duplicates(), - on="variant", how="left", - ) - pairs = [p for p in sorted(agg["pair"].unique()) if p not in _HARDWARE_DROP_PAIRS] - hardware_order = ["H100", "B200"] - - variants_by_pair_hw: dict[tuple[str, str], str] = {} - for _, row in agg.drop_duplicates(["pair", "hardware", "variant"]).iterrows(): - variants_by_pair_hw[(row["pair"], row["hardware"])] = row["variant"] - - def _v(pair_, hw, mode, col): - v = variants_by_pair_hw.get((pair_, hw)) - if v is None: - return math.nan - r = agg[(agg["variant"] == v) & (agg["mode"] == mode)] - return float(r[col].iloc[0]) if not r.empty else math.nan - - # Colors by model (matching Fig 2 style). - palette = plt.get_cmap("tab10").colors - pair_colors = {p: palette[i % len(palette)] for i, p in enumerate(pairs)} - pair_labels = [_pretty(p) for p in pairs] - - # (a) — integral violation. X-axis = hardware. Within each hardware - # group, 4 bars: 2 models × (uncoord, coord). Color = model, hatch = - # uncoord (hatched) / coord (solid). - bar_width = 0.18 - x = np.arange(len(hardware_order)) - fig, ax = _make_panel() - n = len(pairs) - for pi, pair in enumerate(pairs): - color = pair_colors[pair] - for mi, mode in enumerate([BASELINE_MODE, OFO_MODE]): - hatch = "///" if mode == BASELINE_MODE else "" - offset = (pi - (n - 1) / 2) * 2 * bar_width + (mi - 0.5) * bar_width - vals = [_v(pair, hw, mode, "integral_mean") for hw in hardware_order] - ax.bar(x + offset, vals, bar_width, - color=color, hatch=hatch, edgecolor="white", linewidth=0.6) - ax.set_xticks(x) - ax.set_xticklabels(hardware_order) - ax.set_yscale("log") - lo, hi = ax.get_ylim() - ax.set_ylim(lo, hi * 1.5) - ax.set_ylabel("Integral violation\n(pu·s)", fontsize=9) - ax.grid(True, axis="y", alpha=0.2) - _save(fig, out / f"hardware_a{suffix}") - - # (b) — Pareto for both models on BOTH hardware. Color = model - # (shared with (a) and the main legend). Marker fill = hardware: - # open = H100, filled = B200. An inline mini-legend inside the axes - # explains the marker encoding. - fig, ax = _make_panel() - for pair in pairs: - color = pair_colors[pair] - for hw in hardware_order: - variant = variants_by_pair_hw.get((pair, hw)) - if variant is None: - continue - r = df[(df["variant"] == variant) & (df["mode"] == OFO_MODE)].iloc[0].to_dict() - batches, p_mw, t_m = _pareto_from_row(r, logistic_models) - if len(batches) == 0: - continue - if hw == "H100": - ax.plot(p_mw, t_m, "-o", color=color, markersize=5.5, - markerfacecolor="white", markeredgecolor=color, - markeredgewidth=1.3, lw=1.6, alpha=0.95) - else: - ax.plot(p_mw, t_m, "-o", color=color, markersize=5.5, - markeredgecolor="black", markeredgewidth=0.4, - lw=1.6, alpha=0.95) - _apply_pareto_axes(ax) - # Inline hardware-marker key (small, inside the axes so it doesn't - # add to the shared top legend). - hw_key = [ - Line2D([0], [0], marker="o", color="#555", markerfacecolor="white", - markeredgecolor="#555", markeredgewidth=1.3, lw=1.0, label="H100"), - Line2D([0], [0], marker="o", color="#555", markerfacecolor="#555", - markeredgecolor="black", markeredgewidth=0.4, lw=1.0, label="B200"), - ] - ax.legend(handles=hw_key, loc="upper left", frameon=False, - fontsize=8, handlelength=1.2, handletextpad=0.4, - borderaxespad=0.2, labelspacing=0.25) - _save(fig, out / f"hardware_b{suffix}") - - # Shared legend: model colors only. - handles = [_combo_handle(pair_colors[p], _pretty(p)) for p in pairs] - _save_legend(handles, out / f"hardware_legend{suffix}", - ncol=len(pairs), width=2.5) - - -# ────────────────────────────────────────────────────────────────────── -# Experiment E — precision - - -MAIN_PRECISION_PAIR = "qwen-235b-a22b-instruct-h100-8gpu" - - -def plot_precision(df, out, logistic_models, *, suffix: str = "") -> None: - # Main paper shows only the P1 pair; the other two bf16↔FP8 pairs - # (Thinking on H100 8 GPU, Thinking on B200 4 GPU) share the same - # FP8-flattens-power-curve mechanism, differing only on task and - # hardware axes, and live in the appendix. - df = df[df["pair"] == MAIN_PRECISION_PAIR].copy() - agg = _aggregate(df).merge( - df[["variant", "precision_label"]].drop_duplicates(), on="variant", how="left", - ) - variants = agg.drop_duplicates("variant").sort_values("variant")["variant"].tolist() - labels = [agg[agg["variant"] == v]["precision_label"].iloc[0] for v in variants] - - def _v(v, mode, col): - r = agg[(agg["variant"] == v) & (agg["mode"] == mode)] - return float(r[col].iloc[0]) if not r.empty else math.nan - - x = np.arange(len(variants)) - - color_map = {"bf16": COLORS["ofo"], "FP8": COLORS["hardware"]} - - # (a) violation — colored bars, hatch = uncoord., solid = coord. - fig, ax = _make_panel() - _grouped_bars( - ax, - [_v(v, BASELINE_MODE, "integral_mean") for v in variants], - [_v(v, OFO_MODE, "integral_mean") for v in variants], - [color_map[lbl] for lbl in labels], - base_errs=[_v(v, BASELINE_MODE, "integral_std") for v in variants], - ofo_errs=[_v(v, OFO_MODE, "integral_std") for v in variants], - ) - _save(fig, out / f"precision_a{suffix}") - - # (b) Pareto — same colors - entries = [] - for v, lbl in zip(variants, labels): - r = df[(df["variant"] == v) & (df["mode"] == OFO_MODE)].iloc[0].to_dict() - r["color_key"] = lbl - entries.append(r) - fig, ax = _make_panel() - _plot_pareto(ax, entries, logistic_models, - color_map=color_map, - label_map={vv: lbl for vv, lbl in zip(variants, labels)}) - _apply_pareto_axes(ax) - _save(fig, out / f"precision_b{suffix}") - - # Shared legend — colors only; hatch/solid explained in the caption. - # Display "BF16" (not "bf16") per paper style. - _display_prec = lambda l: "BF16" if l == "bf16" else l - handles = [_combo_handle(color_map[lbl], _display_prec(lbl)) for lbl in sorted(set(labels))] - _save_legend(handles, out / f"precision_legend{suffix}", - ncol=len(set(labels)), width=1.8) - - -# ────────────────────────────────────────────────────────────────────── - - -def main( - indir: Path = OUTDIR, - outdir: Path = OUTDIR / "figures", - suffix: str = "", - paper_figure_dir: Path | None = None, -) -> None: - """Generate Section-5 figures. - - Args: - indir: Directory holding per-experiment CSVs. - outdir: Where to write PDF/SVG/PNG per panel. - suffix: GPU suffix appended to every filename (e.g., `_b200`). - paper_figure_dir: If set, copy every generated PDF into this - directory as well. Intended for syncing into the paper repo - (`openg2g-paper/figures`). - """ - import shutil - logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s [%(name)s] %(message)s", datefmt="%H:%M:%S") - outdir.mkdir(parents=True, exist_ok=True) - - shared = mi.load_shared_data() - lm = shared.logistic_models - - dfs: dict[str, pd.DataFrame] = {} - for name in ("model_size", "itl_deadline", "parallelism", "hardware", "precision"): - path = indir / f"{name}{suffix}.csv" - if not path.exists(): - logger.warning("Missing %s — skipping", path) - continue - dfs[name] = pd.read_csv(path) - logger.info("Loaded %s: %d rows", path.name, len(dfs[name])) - - if "model_size" in dfs: - plot_model_size(dfs["model_size"], outdir, lm, suffix=suffix) - if "itl_deadline" in dfs: - plot_itl_deadline(dfs["itl_deadline"], outdir, lm, suffix=suffix) - if "parallelism" in dfs: - plot_parallelism(dfs["parallelism"], outdir, lm, suffix=suffix) - if "hardware" in dfs: - plot_hardware(dfs["hardware"], outdir, lm, suffix=suffix) - if "precision" in dfs: - plot_precision(dfs["precision"], outdir, lm, suffix=suffix) - - logger.info("Wrote figures to %s", outdir) - - if paper_figure_dir is not None: - paper_figure_dir = Path(paper_figure_dir) - paper_figure_dir.mkdir(parents=True, exist_ok=True) - copied = 0 - for pdf in outdir.glob(f"*{suffix}.pdf"): - shutil.copy2(pdf, paper_figure_dir / pdf.name) - copied += 1 - logger.info("Synced %d PDFs into %s", copied, paper_figure_dir) - - -if __name__ == "__main__": - tyro.cli(main) From fa9efb95630b02fc5e056e7bb775624477eb1b3d Mon Sep 17 00:00:00 2001 From: Zhirui Liang Date: Mon, 4 May 2026 00:20:58 -0400 Subject: [PATCH 03/13] [Clean] Untrack verify_post_merge_migration.sh --- scripts/verify_post_merge_migration.sh | 110 ------------------------- 1 file changed, 110 deletions(-) delete mode 100755 scripts/verify_post_merge_migration.sh diff --git a/scripts/verify_post_merge_migration.sh b/scripts/verify_post_merge_migration.sh deleted file mode 100755 index f04f191..0000000 --- a/scripts/verify_post_merge_migration.sh +++ /dev/null @@ -1,110 +0,0 @@ -#!/bin/bash -# -# Post-merge verification: regenerate the per-spec InferenceData cache under -# data/specs/ and run a 1-step train_ppo to confirm the migrated example -# scripts wire end-to-end. -# -# Why this exists: master removed the JSON-driven config.json + monolithic -# data/offline// pipeline and replaced it with a per-spec -# content-addressed cache (data/specs//_manifest.json). The -# rl_controller-branch examples were migrated to the new APIs in commits -# 4362c2dd / 6d5d3ec1 / da8b6ec1 / f1c5f3ce, but the spec cache hasn't been -# regenerated on most machines. InferenceData.ensure() does the regen -# automatically on first call, but the extraction step needs ~32-64 GB of -# RAM, which exceeds login-node limits. Run this script via sbatch (not on -# the login node) so the regen has enough memory. -# -# Output: a populated data/specs/ tree (one subdir per InferenceModelSpec -# cache_hash) and a confirmation that train_ppo can ingest it. -# -#SBATCH --job-name=verify_post_merge -#SBATCH --account=zhirui0 -#SBATCH --partition=standard -#SBATCH --nodes=1 -#SBATCH --ntasks=1 -#SBATCH --cpus-per-task=8 -#SBATCH --mem=64G -#SBATCH --time=02:00:00 -#SBATCH --output=/scratch/zhirui_root/zhirui0/zhirui/openg2g/slurm_verify_post_merge_%j.out - -set -euo pipefail - -echo "=== environment ===" -date; hostname -echo "SLURM_JOB_ID=${SLURM_JOB_ID:-none}" - -module load python/3.10.4 -source /home/zhirui/venvs/pt_mod/bin/activate - -REPO_ROOT="/gpfs/accounts/zhirui_root/zhirui0/zhirui/openg2g" -cd "${REPO_ROOT}/examples/offline" - -echo -echo "=== step 1: regenerate per-spec InferenceData cache ===" -echo " (downloads from HF Hub, extracts timelines, fits ITL distributions)" -python -u -c " -import sys, logging -logging.basicConfig(level=logging.INFO, format='%(asctime)s %(levelname)s %(name)s: %(message)s', force=True) -sys.path.insert(0, '.') -import systems -from openg2g.controller.ofo import LogisticModelStore -from openg2g.datacenter.workloads.inference import InferenceData -from openg2g.datacenter.workloads.training import TrainingTrace - -print(f'SPECS_CACHE_DIR = {systems.SPECS_CACHE_DIR}', flush=True) -print(f'TRAINING_TRACE_PATH = {systems.TRAINING_TRACE_PATH}', flush=True) -print(f'ALL_MODEL_SPECS = {[s.model_label for s in systems.ALL_MODEL_SPECS]}', flush=True) -print(flush=True) - -inference_data = InferenceData.ensure( - systems.SPECS_CACHE_DIR, systems.ALL_MODEL_SPECS, plot=False, dt_s=1.0, -) -print(f'InferenceData ready: {len(inference_data.models)} models', flush=True) - -training_trace = TrainingTrace.ensure(systems.TRAINING_TRACE_PATH) -print(f'TrainingTrace ready: {systems.TRAINING_TRACE_PATH}', flush=True) - -logistic_models = LogisticModelStore.ensure( - systems.SPECS_CACHE_DIR, systems.ALL_MODEL_SPECS, plot=False, -) -print(f'LogisticModelStore ready: {len(systems.ALL_MODEL_SPECS)} models', flush=True) -" - -echo -echo "=== step 2: 1-step train_ppo on ieee13 ===" -echo " (verifies make_sim_factory, OfflineWorkload.replica_schedules," -echo " grid.attach_dc, Coordinator(datacenters=[...]) all wire end-to-end)" -SMOKE_OUT="/tmp/ppo_smoke_${SLURM_JOB_ID:-$$}" -python -u train_ppo.py --system ieee13 --total-timesteps 1 --output-dir "${SMOKE_OUT}" - -echo -echo "=== step 3: 1-scenario evaluate_controllers on the just-trained model ===" -echo " (verifies the eval pipeline: scenario randomization, baseline/OFO/PPO" -echo " simulation, voltage/throughput metrics)" -EVAL_OUT="/tmp/eval_smoke_${SLURM_JOB_ID:-$$}" -python -u evaluate_controllers.py \ - --system ieee13 \ - --n-scenarios 1 \ - --ppo-models "${SMOKE_OUT}/ppo_model.zip" \ - --ppo-labels ppo_smoke \ - --no-per-scenario-plots \ - --no-aggregate-plots \ - --output-dir "${EVAL_OUT}" - -echo -echo "=== summary ===" -echo "data/specs/ contents:" -ls -la "${REPO_ROOT}/data/specs/" | head -echo -echo "manifest count:" -find "${REPO_ROOT}/data/specs/" -name '_manifest.json' | wc -l -echo -echo "train smoke output:" -ls -la "${SMOKE_OUT}/" 2>&1 | head -echo -echo "eval smoke output:" -ls -la "${EVAL_OUT}" 2>&1 | head -20 - -echo -echo "verification done" -date From a9a36bb0d2bdd5e23164246123a4f156b143fdf1 Mon Sep 17 00:00:00 2001 From: Zhirui Liang Date: Mon, 4 May 2026 00:28:06 -0400 Subject: [PATCH 04/13] [Docs] update rl-controller.md --- docs/examples/rl-controller.md | 74 ++++++++++++++++++++-------------- 1 file changed, 44 insertions(+), 30 deletions(-) diff --git a/docs/examples/rl-controller.md b/docs/examples/rl-controller.md index 47a7223..8dde5ff 100644 --- a/docs/examples/rl-controller.md +++ b/docs/examples/rl-controller.md @@ -30,68 +30,82 @@ The library code lives in [`openg2g.rl.env`][openg2g.rl.env] (Gymnasium environm ## Usage -### One-time setup: per-spec inference data cache +The first run of any script below also triggers a one-time download + extraction of LLM benchmark data from the HuggingFace `ml-energy-data` repo, populating a per-spec content-addressed cache under `data/specs//`. Subsequent calls reuse the cache. -The first run of any of these scripts triggers a one-time download + extraction of LLM benchmark data from the HuggingFace `ml-energy-data` repo, populating a per-spec content-addressed cache under `data/specs//`. This step needs roughly **32–64 GB of RAM** during extraction; it will OOM on a typical login node. Run it on a compute node (or via slurm) once, after which all subsequent calls reuse the cache. A turnkey end-to-end smoke is provided in `scripts/verify_post_merge_migration.sh`. +### IEEE 13 — end-to-end example -### Stage 1: Build a scenario library +#### 1. Dataset generation + +**1a. Build training library** + +```bash +python examples/offline/build_scenario_library.py \ + --system ieee13 \ + --n-candidates 500 --seed-start 0 \ + --output-dir outputs/ieee13/scenario_library/train_n500 +``` + +**1b. Build test library** (use a different `--seed-start` so train and test seeds don't overlap) ```bash -# IEEE 13-bus, 200 candidate scenarios, default screening filter python examples/offline/build_scenario_library.py \ --system ieee13 \ - --n-candidates 200 \ - --output-dir examples/offline/outputs/ieee13/scenario_library/n200 + --n-candidates 150 --seed-start 1000 \ + --output-dir outputs/ieee13/scenario_library/test_n150 ``` -This produces a `library.pkl` containing accepted scenarios (those where OFO meaningfully reduces voltage violation versus baseline, controlled by `--min-baseline-integral` and `--min-recovery-frac`), per-scenario voltage-envelope plots, and a CSV of metrics. +Each call writes a `library.pkl` of accepted scenarios (those where OFO meaningfully recovers a baseline voltage violation, controlled by `--min-baseline-integral` and `--min-recovery-frac`), per-scenario voltage-envelope plots, and a CSV of metrics. -### Stage 2: Train PPO +#### 2. PPO training ```bash python examples/offline/train_ppo.py \ --system ieee13 \ --total-timesteps 2000000 \ - --learning-rate 1e-4 \ + --total-duration-s 3600 \ --n-steps 3600 \ - --hidden-dims 128 128 \ - --w-voltage 1000 --w-throughput 0 --w-switch 0.01 \ - --scenario-library examples/offline/outputs/ieee13/scenario_library/n200/library.pkl \ - --output-dir examples/offline/outputs/ieee13/ppo + --hidden-dims 128 128 128 \ + --learning-rate 1e-4 \ + --ent-coef 0.01 \ + --action-mode delta \ + --w-voltage 5000 --w-throughput 0.05 --w-latency 0.01 --w-switch 0.5 \ + --n-envs 8 --seed 1 \ + --scenario-library outputs/ieee13/scenario_library/train_n500/library.pkl \ + --no-ofo-baseline --truncate-episode \ + --output-dir outputs/ieee13/ppo ``` -Key flags (see `train_ppo.py --help` for the full list): +Output: `ppo_model.zip`, `ppo_model_vecnormalize.pkl`, per-checkpoint snapshots under `checkpoints/`, TensorBoard logs in `tb/`, and a training-progress plot. See `train_ppo.py --help` for the full flag set (`--obs-mode`, alternate reward weights, action-mode variants, etc.). -- `--system`: `ieee13`, `ieee34`, or `ieee123`. -- `--total-timesteps`: cumulative environment steps across all parallel envs. ~2M is enough for IEEE 13; IEEE 34 / IEEE 123 typically need ~5M. -- `--obs-mode`: `full-voltage` (default), `per-zone-summary`, `per-bus-summary`, or `system-summary-only`. Lower-dimensional observations help generalization on larger feeders. -- `--w-voltage / --w-throughput / --w-latency / --w-switch`: reward weights. Setting `w-throughput=0` keeps the policy focused on voltage; the resulting throughput is reported but not optimized. -- `--n-envs`: parallel rollout envs (default 1). Increase for wall-clock speed. -- `--scenario-library`: path to the `.pkl` from Stage 1. Without it, the env synthesizes scenarios on the fly with the `randomize_scenario` defaults. -- `--ofo-baseline`: include OFO action as a reward-shaping baseline (advanced). +#### 3. Controller evaluation -The output directory holds the saved model (`ppo_model.zip`), VecNormalize statistics (`ppo_model_vecnormalize.pkl`), TensorBoard logs (`tb/`), per-checkpoint copies (`checkpoints/`), and a training-progress plot. - -### Stage 3: Evaluate +Compares no-coordination baseline, droop (rule-based) control, OFO control, and the trained PPO on the held-out test library: ```bash +MODELS=(outputs/ieee13/ppo/ppo_model.zip) + python examples/offline/evaluate_controllers.py \ --system ieee13 \ + --ppo-models "${MODELS[@]}" \ + --scenario-library outputs/ieee13/scenario_library/test_n150/library.pkl \ --n-scenarios 50 \ - --ppo-models examples/offline/outputs/ieee13/ppo/ppo_model.zip \ - --ppo-labels champion \ + --obs-mode full-voltage \ --include-rule-based \ - --output-dir examples/offline/outputs/ieee13/eval_n50 + --use-display-names \ + --output-dir outputs/ieee13/eval_4ctrl_ieee13 \ + --log-level INFO ``` -The evaluator filters held-out seeds to keep only scenarios that exhibit a baseline violation and where OFO recovers most of it (controllable by `--min-baseline-integral` and `--min-recovery-frac`), then runs each controller on the same scenario set. - Outputs: - `results.csv` — per-scenario metrics for every controller: violation time, integral violation, worst Vmin/Vmax, mean throughput, p99 latency, mean power, batch-change count. - `aggregate_*.png` — bar charts comparing voltage / throughput / batch-switching across controllers. - `scenario_/` — per-scenario voltage envelopes and batch-size traces. +Multiple PPO checkpoints (e.g., from a multi-seed sweep) can be passed in `MODELS=(...)` and labelled via `--ppo-labels`. + +(Repeat the same three stages for `ieee34` / `ieee123` with appropriate `--system`, `--obs-mode`, and `--no-randomize-ramps` flags.) + ### Inference: using a trained PPO inside other scripts A trained PPO checkpoint can also be loaded inside `build_scenario_library.py --mode ppo`, which runs the policy directly without retraining: @@ -100,7 +114,7 @@ A trained PPO checkpoint can also be loaded inside `build_scenario_library.py -- python examples/offline/build_scenario_library.py \ --system ieee13 \ --mode ppo \ - --ppo-model examples/offline/outputs/ieee13/ppo/ppo_model.zip + --ppo-model outputs/ieee13/ppo/ppo_model.zip ``` ## What to Look For From 27bfb3b05db64de0c2a089d91640475c2346097a Mon Sep 17 00:00:00 2001 From: Zhirui Liang Date: Mon, 4 May 2026 00:47:51 -0400 Subject: [PATCH 05/13] [CI] Apply ruff format + fix lint errors --- .gitignore | 1 + examples/offline/build_scenario_library.py | 191 ++++++++----- examples/offline/evaluate_controllers.py | 306 ++++++++++++++------- examples/offline/sweep_dc_locations.py | 60 ++-- examples/offline/systems.py | 62 +++-- examples/offline/train_ppo.py | 152 +++++----- openg2g/controller/ppo.py | 10 +- openg2g/controller/rule_based.py | 6 +- openg2g/rl/env.py | 25 +- 9 files changed, 493 insertions(+), 320 deletions(-) diff --git a/.gitignore b/.gitignore index 1397062..9586fee 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,4 @@ examples/offline/filter_scenario_library.py .git.backup-pre-filter/ /paper_plots.mplstyle /plots.py +scripts/verify_post_merge_migration.sh diff --git a/examples/offline/build_scenario_library.py b/examples/offline/build_scenario_library.py index d103ab8..2eacc18 100644 --- a/examples/offline/build_scenario_library.py +++ b/examples/offline/build_scenario_library.py @@ -37,7 +37,7 @@ import math import pickle import sys -from dataclasses import asdict, dataclass, field +from dataclasses import dataclass, field from pathlib import Path import matplotlib @@ -46,35 +46,35 @@ import matplotlib.pyplot as plt import numpy as np +from openg2g.controller.ofo import LogisticModelStore, OFOConfig +from openg2g.controller.rule_based import RuleBasedBatchSizeController, RuleBasedConfig +from openg2g.controller.tap_schedule import TapScheduleController +from openg2g.coordinator import Coordinator +from openg2g.datacenter.config import DatacenterConfig, InferenceModelSpec, ReplicaSchedule, TrainingRun +from openg2g.datacenter.offline import OfflineDatacenter, OfflineWorkload +from openg2g.datacenter.workloads.inference import InferenceData +from openg2g.datacenter.workloads.training import TrainingTrace +from openg2g.grid.config import TapSchedule +from openg2g.metrics.voltage import VoltageStats, compute_allbus_voltage_stats + from systems import ( DT_CTRL, DT_DC, DT_GRID, EXPERIMENTS, POWER_AUG, + SPECS_CACHE_DIR, TOTAL_DURATION_S, + TRAINING_TRACE_PATH, V_MAX, V_MIN, DCSite, PVSystemSpec, - SPECS_CACHE_DIR, ScenarioOpenDSSGrid, - TRAINING_TRACE_PATH, TimeVaryingLoadSpec, randomize_scenario, ) -from openg2g.controller.ofo import LogisticModelStore, OFOConfig -from openg2g.controller.rule_based import RuleBasedBatchSizeController, RuleBasedConfig -from openg2g.controller.tap_schedule import TapScheduleController -from openg2g.coordinator import Coordinator -from openg2g.datacenter.config import DatacenterConfig, InferenceModelSpec, ReplicaSchedule, TrainingRun -from openg2g.datacenter.offline import OfflineDatacenter, OfflineWorkload -from openg2g.datacenter.workloads.inference import InferenceData -from openg2g.datacenter.workloads.training import TrainingTrace -from openg2g.grid.config import TapSchedule -from openg2g.metrics.voltage import VoltageStats, compute_allbus_voltage_stats - logger = logging.getLogger("scenario_library") @@ -135,9 +135,7 @@ def run_simulation( site_specs_map[site_id] = site_specs site_inference = inference_data.filter_models(site_specs) - replica_schedules: dict[str, ReplicaSchedule] = { - md.spec.model_label: sched for md, sched in site.models - } + replica_schedules: dict[str, ReplicaSchedule] = {md.spec.model_label: sched for md, sched in site.models} initial_batch_sizes = {md.spec.model_label: md.initial_batch_size for md, _ in site.models} dc_config = DatacenterConfig( @@ -193,9 +191,7 @@ def run_simulation( from openg2g.controller.ofo import OFOBatchSizeController for site_id in site_ids: - site_initial_bs = { - md.spec.model_label: md.initial_batch_size for md, _ in dc_sites[site_id].models - } + site_initial_bs = {md.spec.model_label: md.initial_batch_size for md, _ in dc_sites[site_id].models} ofo_ctrl = OFOBatchSizeController( site_specs_map[site_id], datacenter=datacenters[site_id], @@ -211,9 +207,7 @@ def run_simulation( rb_config = rule_based_config or RuleBasedConfig(v_min=V_MIN, v_max=V_MAX) zones = sys.get("zones") if rule_zone_local else None for site_id in site_ids: - site_initial_bs = { - md.spec.model_label: md.initial_batch_size for md, _ in dc_sites[site_id].models - } + site_initial_bs = {md.spec.model_label: md.initial_batch_size for md, _ in dc_sites[site_id].models} zone_buses = None if zones is not None and len(site_ids) > 1 and site_id in zones: zone_buses = tuple(zones[site_id]) @@ -243,7 +237,7 @@ def _find_vecnormalize(model_file: Path) -> Path | None: mf.parent / "vecnormalize.pkl", ] if stem.startswith("ppo_") and stem.endswith("_steps"): - candidates.append(mf.parent / f"ppo_vecnormalize_{stem[len('ppo_'):]}.pkl") + candidates.append(mf.parent / f"ppo_vecnormalize_{stem[len('ppo_') :]}.pkl") for parent in [mf.parent, *mf.parent.parents][:4]: candidates.extend(sorted(parent.glob("ppo_model_*_vecnormalize.pkl"))) for c in candidates: @@ -258,9 +252,7 @@ def _find_vecnormalize(model_file: Path) -> Path | None: shared_model = cand elif ppo_path.suffix == ".zip": looks_shared = ( - len(site_ids) > 1 - or "shared" in ppo_path.parts - or ppo_path.stem.startswith("ppo_model_shared") + len(site_ids) > 1 or "shared" in ppo_path.parts or ppo_path.stem.startswith("ppo_model_shared") ) if looks_shared and ppo_path.exists(): shared_model = ppo_path @@ -282,6 +274,7 @@ def _find_vecnormalize(model_file: Path) -> Path | None: bus_phase_groups = None elif obs_mode == "per-bus-summary": from openg2g.rl.env import compute_bus_phase_groups + grid.do_reset() grid.start() _v_index = grid.v_index @@ -295,7 +288,9 @@ def _find_vecnormalize(model_file: Path) -> Path | None: bus_phase_groups = None site_model_mapping = {sid: [md.spec.model_label for md, _ in dc_sites[sid].models] for sid in site_ids} - all_init_bs = {md.spec.model_label: md.initial_batch_size for sid in site_ids for md, _ in dc_sites[sid].models} + all_init_bs = { + md.spec.model_label: md.initial_batch_size for sid in site_ids for md, _ in dc_sites[sid].models + } obs_config = ObservationConfig.from_multi_site( site_specs_map, {sid: {md.spec.model_label: sched.initial for md, sched in dc_sites[sid].models} for sid in site_ids}, @@ -349,7 +344,13 @@ def _find_vecnormalize(model_file: Path) -> Path | None: replica_counts = {md.spec.model_label: sched.initial for md, sched in dc_sites[site_id].models} site_init_bs = {md.spec.model_label: md.initial_batch_size for md, _ in dc_sites[site_id].models} obs_config = ObservationConfig.from_model_specs( - specs, replica_counts, n_bus_phases=n_bus_phases, initial_batch_sizes=site_init_bs, zone_buses=zone_buses, v_min=V_MIN, v_max=V_MAX + specs, + replica_counts, + n_bus_phases=n_bus_phases, + initial_batch_sizes=site_init_bs, + zone_buses=zone_buses, + v_min=V_MIN, + v_max=V_MAX, ) vn_path = _find_vecnormalize(Path(site_model)) if vn_path is not None: @@ -494,7 +495,9 @@ def _per_step_voltage_pen(grid_states, *, v_min: float, v_max: float, exclude_bu return out -def _under_over_voltage_time(grid_states, *, v_min: float, v_max: float, exclude_buses: tuple[str, ...]) -> tuple[float, float]: +def _under_over_voltage_time( + grid_states, *, v_min: float, v_max: float, exclude_buses: tuple[str, ...] +) -> tuple[float, float]: """Return (undervoltage_time_s, overvoltage_time_s). A step counts as 'undervoltage' if ANY non-excluded bus-phase is below @@ -670,7 +673,7 @@ def _plot_batch_sizes( ofo_by_site = batch_data[first_seed]["ofo"] cols_meta: list[tuple[str, str]] = [] for site_id, sdata in ofo_by_site.items(): - for label in sdata["batch_by_model"].keys(): + for label in sdata["batch_by_model"]: cols_meta.append((site_id, label)) n_cols = len(cols_meta) @@ -682,10 +685,22 @@ def _plot_batch_sizes( ax = axes[row][col] bl_site = bd["baseline"][site_id] ofo_site = bd["ofo"][site_id] - ax.plot(bl_site["time_s"], bl_site["batch_by_model"][label], - color="#888", linewidth=0.7, alpha=0.7, label="baseline") - ax.plot(ofo_site["time_s"], ofo_site["batch_by_model"][label], - color="#2196F3", linewidth=0.7, alpha=0.9, label="OFO") + ax.plot( + bl_site["time_s"], + bl_site["batch_by_model"][label], + color="#888", + linewidth=0.7, + alpha=0.7, + label="baseline", + ) + ax.plot( + ofo_site["time_s"], + ofo_site["batch_by_model"][label], + color="#2196F3", + linewidth=0.7, + alpha=0.9, + label="OFO", + ) if row == 0: short = label.split("/")[-1] if "/" in label else label title = f"{site_id}:{short}" if len(ofo_by_site) > 1 else short @@ -719,9 +734,7 @@ def _extract_batch_data(log) -> dict: for m in states[0].batch_size_by_model: if m not in labels: labels.append(m) - batch_by_model = { - m: [s.batch_size_by_model.get(m, 0) for s in states] for m in labels - } + batch_by_model = {m: [s.batch_size_by_model.get(m, 0) for s in states] for m in labels} per_site[site_id] = {"time_s": time_s, "batch_by_model": batch_by_model} return per_site @@ -807,7 +820,8 @@ def _plot_envelopes( axes[-1][col].set_xlabel("Time (s)", fontsize=8) fig.suptitle( "Accepted scenarios — per-zone voltage envelope (baseline vs OFO)", - fontsize=13, fontweight="bold", + fontsize=13, + fontweight="bold", ) else: cap = max_rows * 2 @@ -931,7 +945,11 @@ def main( ramp_dur_max: float = 800.0, randomize_pv_profile: bool = False, pv_shape_choices: tuple[str, ...] = ( - "flat", "rising_falling", "morning_ramp", "afternoon_decline", "midday_dip", + "flat", + "rising_falling", + "morning_ramp", + "afternoon_decline", + "midday_dip", ), pv_baseline_min: float = 0.75, pv_baseline_max: float = 0.95, @@ -966,7 +984,9 @@ def main( logger.info("Writing scenario library to %s", out_dir) logger.info( "system=%s use_training_overlay=%s randomize_ramps=%s", - system, use_training_overlay, randomize_ramps, + system, + use_training_overlay, + randomize_ramps, ) # ── Load data once (per-spec content-addressed cache under SPECS_CACHE_DIR) ── @@ -1009,9 +1029,11 @@ def main( for t in base_exp["time_varying_loads"] ] logger.info( - "PV base total=%.0f kW across %d systems, TVL base total=%.0f kW across %d loads (× scale ∈ [0.5, 2.0] per scenario)", - sum(p.peak_kw for p in pv_systems_base), len(pv_systems_base), - sum(t.peak_kw for t in tvl_base), len(tvl_base), + "PV base total=%.0f kW across %d systems, TVL base total=%.0f kW across %d loads (× scale ∈ [0.5, 2.0] per scenario)", # noqa: E501 + sum(p.peak_kw for p in pv_systems_base), + len(pv_systems_base), + sum(t.peak_kw for t in tvl_base), + len(tvl_base), ) # All model specs across the single DC site @@ -1189,7 +1211,11 @@ def main( # (especially useful for diagnosing rejected scenarios in multi-zone feeders). if zones: bl_zone_phase = _zone_phase_integral( - bl_log.grid_states, zones=zones, exclude_buses=exclude_buses, v_min=V_MIN, v_max=V_MAX, + bl_log.grid_states, + zones=zones, + exclude_buses=exclude_buses, + v_min=V_MIN, + v_max=V_MAX, ) zone_summary = " baseline zone/phase integral: " + " | ".join( f"{z}: A={bl_zone_phase[z]['a']:.1f} B={bl_zone_phase[z]['b']:.1f} C={bl_zone_phase[z]['c']:.1f}" @@ -1203,14 +1229,18 @@ def main( if n_always_min_accepted >= MAX_ALWAYS_MIN: logger.info( " seed=%d: OFO always at min batch — cap reached (%d/%d), skipping", - effective_seed, n_always_min_accepted, MAX_ALWAYS_MIN, + effective_seed, + n_always_min_accepted, + MAX_ALWAYS_MIN, ) passes = False else: n_always_min_accepted += 1 logger.info( " seed=%d: OFO always at min batch — accepting (%d/%d)", - effective_seed, n_always_min_accepted, MAX_ALWAYS_MIN, + effective_seed, + n_always_min_accepted, + MAX_ALWAYS_MIN, ) if passes: nonzero_steps = np.nonzero(baseline_pen > 0)[0] @@ -1224,7 +1254,10 @@ def main( t_ctrl_end = min(len(baseline_pen), t_last + t_control_end_buffer) logger.info( " control window: violation [%d, %d] → [%d, %d] (%d steps, saves %d%%)", - t_first, t_last, t_ctrl_start, t_ctrl_end, + t_first, + t_last, + t_ctrl_start, + t_ctrl_end, t_ctrl_end - t_ctrl_start, 100 * (len(baseline_pen) - (t_ctrl_end - t_ctrl_start)) // len(baseline_pen), ) @@ -1255,10 +1288,14 @@ def main( } if zones: env_entry["baseline_zones"] = _voltage_envelope_by_zone( - bl_log.grid_states, zones=zones, exclude_buses=exclude_buses, + bl_log.grid_states, + zones=zones, + exclude_buses=exclude_buses, ) env_entry["ofo_zones"] = _voltage_envelope_by_zone( - ofo_log.grid_states, zones=zones, exclude_buses=exclude_buses, + ofo_log.grid_states, + zones=zones, + exclude_buses=exclude_buses, ) envelopes[effective_seed] = env_entry batch_data[effective_seed] = { @@ -1325,13 +1362,9 @@ def main( "randomize_ramps": randomize_ramps, "randomize_kwargs": randomize_kwargs, "pv_systems_base": [ - {"bus": p.bus, "bus_kv": p.bus_kv, "peak_kw": p.peak_kw} - for p in pv_systems_base - ], - "tvl_base": [ - {"bus": t.bus, "bus_kv": t.bus_kv, "peak_kw": t.peak_kw} - for t in tvl_base + {"bus": p.bus, "bus_kv": p.bus_kv, "peak_kw": p.peak_kw} for p in pv_systems_base ], + "tvl_base": [{"bus": t.bus, "bus_kv": t.bus_kv, "peak_kw": t.peak_kw} for t in tvl_base], "v_min": V_MIN, "v_max": V_MAX, }, @@ -1354,7 +1387,9 @@ def main( pickle.dump(existing, f) logger.info( "Appended %d new scenario(s) to %s (total now: %d)", - len(new_scenarios), append_to, len(existing["scenarios"]), + len(new_scenarios), + append_to, + len(existing["scenarios"]), ) else: logger.info("No new scenarios to append — all seeds already present in %s", append_to) @@ -1385,7 +1420,9 @@ def main( # Plots if accepted: _plot_envelopes( - accepted, envelopes, out_dir / "scenario_envelopes.png", + accepted, + envelopes, + out_dir / "scenario_envelopes.png", total_duration_s=len(accepted[0].ofo_voltage_pen_per_step), zones=zones, ) @@ -1423,34 +1460,34 @@ class Args: min_recovery_frac: float = 0.7 """Reject scenarios where OFO recovers less than this fraction of the baseline integral violation.""" max_recovery_frac: float = 1.0 - """Reject scenarios where OFO recovers more than this fraction (use with min to select a recovery band, e.g. 0.4-0.6).""" + """Reject scenarios where OFO recovers more than this fraction (use with min to select a recovery band, e.g. 0.4-0.6).""" # noqa: E501 min_baseline_integral: float = 0.2 """Reject scenarios where the baseline undervoltage integral is below this threshold (no learning signal).""" min_baseline_integral_over: float = 0.01 - """Reject pure-overvoltage scenarios where the baseline integral is below this threshold. Lower than min_baseline_integral since OFO recovers overvoltage less aggressively.""" + """Reject pure-overvoltage scenarios where the baseline integral is below this threshold. Lower than min_baseline_integral since OFO recovers overvoltage less aggressively.""" # noqa: E501 max_baseline_integral: float = 1e9 - """Reject scenarios where the baseline integral violation exceeds this threshold (saturated, dominate gradients). Default = no upper bound.""" + """Reject scenarios where the baseline integral violation exceeds this threshold (saturated, dominate gradients). Default = no upper bound.""" # noqa: E501 pv_base_kw: float | None = None - """Base PV peak power per system (kW) before per-scenario scaling. If unset, use the per-bus defaults from the experiment definition (recommended for ieee34 where each PV has a distinct peak_kw).""" + """Base PV peak power per system (kW) before per-scenario scaling. If unset, use the per-bus defaults from the experiment definition (recommended for ieee34 where each PV has a distinct peak_kw).""" # noqa: E501 tvl_base_kw: float | None = None - """Base time-varying load peak power per load (kW) before per-scenario scaling. If unset, use the per-bus defaults from the experiment definition.""" + """Base time-varying load peak power per load (kW) before per-scenario scaling. If unset, use the per-bus defaults from the experiment definition.""" # noqa: E501 sensitivity_update_interval: int = 300 """OFO H-matrix re-estimation interval in control steps. 300 = every 5 simulated minutes.""" ofo_w_throughput: float = 0.0001 - """OFO throughput weight in the primal objective. 0 = pure voltage focus (the screening only cares how much violation OFO can recover).""" + """OFO throughput weight in the primal objective. 0 = pure voltage focus (the screening only cares how much violation OFO can recover).""" # noqa: E501 tag: str = "v3" """Subdirectory under outputs//scenario_library/ to write artifacts to.""" seed_start: int = 0 - """Starting candidate index. Effective seed = (seed_start + cand_idx) * 1000 + 7. Use to generate non-overlapping scenario sets (e.g., seed-start=400 for eval set when training used 0-349).""" + """Starting candidate index. Effective seed = (seed_start + cand_idx) * 1000 + 7. Use to generate non-overlapping scenario sets (e.g., seed-start=400 for eval set when training used 0-349).""" # noqa: E501 seeds: tuple[int, ...] = () - """Explicit list of effective seeds to run (overrides seed_start + n_candidates). Use to re-run only known-accepted seeds from a prior build log.""" + """Explicit list of effective seeds to run (overrides seed_start + n_candidates). Use to re-run only known-accepted seeds from a prior build log.""" # noqa: E501 use_training_overlay: bool = True - """Whether to add the training overlay (a few-hundred-kW training GPU spike) to in-distribution scenarios. Default True (ieee13 behavior). Set --no-use-training-overlay for ieee34 in-dist libraries — the overlay is reserved for OOD there.""" + """Whether to add the training overlay (a few-hundred-kW training GPU spike) to in-distribution scenarios. Default True (ieee13 behavior). Set --no-use-training-overlay for ieee34 in-dist libraries — the overlay is reserved for OOD there.""" # noqa: E501 randomize_ramps: bool = True - """Whether randomize_scenario synthesizes per-episode inference ramps. Default True (ieee13 behavior). Set --no-randomize-ramps for ieee34 in-dist libraries — ramps are reserved for OOD there.""" + """Whether randomize_scenario synthesizes per-episode inference ramps. Default True (ieee13 behavior). Set --no-randomize-ramps for ieee34 in-dist libraries — ramps are reserved for OOD there.""" # noqa: E501 # ── Randomization profile ── randomization_profile: Annotated[bool, tyro.conf.FlagConversionOff] = True - """True (broad, default) = bidirectional multi-ramps, wider PV/TVL scales, shape randomness, stochastic overlay. False (narrow) = legacy behavior.""" + """True (broad, default) = bidirectional multi-ramps, wider PV/TVL scales, shape randomness, stochastic overlay. False (narrow) = legacy behavior.""" # noqa: E501 pv_scale_min: float = 0.5 pv_scale_max: float = 2.0 load_scale_min: float = 0.5 @@ -1472,7 +1509,7 @@ class Args: n_ramps_per_site_choices: tuple[int, ...] = (1,) """Possible numbers of ramps per DC site (broad profile). e.g. (1, 2) gives 50/50 single/double ramps.""" ramp_up_prob: float = 0.0 - """Probability a sampled ramp goes UP (broad profile). GPU capacity is checked per-site so up-ramps are clamped automatically.""" + """Probability a sampled ramp goes UP (broad profile). GPU capacity is checked per-site so up-ramps are clamped automatically.""" # noqa: E501 ramp_down_frac_min: float = 0.5 ramp_down_frac_max: float = 0.85 ramp_up_frac_min: float = 1.05 @@ -1488,9 +1525,13 @@ class Args: randomize_pv_profile: bool = False """Replace the analytical pv_profile_kw with a multi-shape random PV profile (broad mode only).""" pv_shape_choices: tuple[str, ...] = ( - "flat", "rising_falling", "morning_ramp", "afternoon_decline", "midday_dip", + "flat", + "rising_falling", + "morning_ramp", + "afternoon_decline", + "midday_dip", ) - """PV shapes to sample from (broad mode + --randomize-pv-profile). Default covers all 5 transient/flat envelopes.""" + """PV shapes to sample from (broad mode + --randomize-pv-profile). Default covers all 5 transient/flat envelopes.""" # noqa: E501 pv_baseline_min: float = 0.75 pv_baseline_max: float = 0.95 pv_cloud_count_max: int = 3 @@ -1499,17 +1540,17 @@ class Args: pv_cloud_width_min: float = 60.0 pv_cloud_width_max: float = 300.0 randomize_tvl_profile: bool = False - """Replace the analytical load_profile_kw with one of {flat, increasing, decreasing, peaked, valley} per scenario (broad mode only).""" + """Replace the analytical load_profile_kw with one of {flat, increasing, decreasing, peaked, valley} per scenario (broad mode only).""" # noqa: E501 tvl_shape_choices: tuple[str, ...] = ("flat", "increasing", "decreasing", "peaked", "valley") max_always_min: int = 5 - """Max number of 'OFO always at min batch' scenarios to accept. Increase to 10 for training libraries to avoid under-representing easy scenarios.""" + """Max number of 'OFO always at min batch' scenarios to accept. Increase to 10 for training libraries to avoid under-representing easy scenarios.""" # noqa: E501 t_control_start_buffer: int = 200 """Steps of buffer before the first baseline voltage violation (for episode truncation).""" t_control_end_buffer: int = 300 """Steps of buffer after the last baseline voltage violation (for episode truncation).""" log_level: str = "INFO" append_to: Path | None = None - """If set, merge newly accepted scenarios into this existing library.pkl (deduplicates by seed). The new run is still saved under --tag for diagnostic plots.""" + """If set, merge newly accepted scenarios into this existing library.pkl (deduplicates by seed). The new run is still saved under --tag for diagnostic plots.""" # noqa: E501 args = tyro.cli(Args) main( diff --git a/examples/offline/evaluate_controllers.py b/examples/offline/evaluate_controllers.py index cbfb005..94d8852 100644 --- a/examples/offline/evaluate_controllers.py +++ b/examples/offline/evaluate_controllers.py @@ -22,7 +22,7 @@ import csv import logging import math -from dataclasses import dataclass, field +from dataclasses import dataclass from pathlib import Path import matplotlib @@ -31,6 +31,12 @@ import matplotlib.pyplot as plt import numpy as np from build_scenario_library import run_simulation + +from openg2g.controller.ofo import LogisticModelStore, OFOConfig +from openg2g.controller.rule_based import RuleBasedConfig +from openg2g.datacenter.workloads.inference import InferenceData +from openg2g.datacenter.workloads.training import TrainingTrace + from systems import ( DT_DC, EXPERIMENTS, @@ -38,20 +44,10 @@ TRAINING_TRACE_PATH, V_MAX, V_MIN, - DCSite, - PVSystemSpec, - TimeVaryingLoadSpec, - deploy, materialize_scenario, randomize_scenario, ) -from openg2g.controller.ofo import LogisticModelStore, OFOConfig -from openg2g.controller.rule_based import RuleBasedConfig -from openg2g.datacenter.config import ModelDeployment -from openg2g.datacenter.workloads.inference import InferenceData -from openg2g.datacenter.workloads.training import TrainingTrace - logger = logging.getLogger("evaluate_controllers") @@ -92,6 +88,7 @@ def _display_name(mode: str) -> str: # ── Plotting ────────────────────────────────────────────────────────────────── + def plot_voltage_comparison( logs: dict[str, object], save_dir: Path, @@ -188,7 +185,7 @@ def plot_violation_bars( ax.set_title(title, fontsize=14) ax.tick_params(axis="y", labelsize=12) ax.grid(axis="y", alpha=0.3) - for xi, val in zip(x, vals): + for xi, val in zip(x, vals, strict=False): ax.text(xi, val, fmt.format(val), ha="center", va="bottom", fontsize=10) fig.suptitle("Per-scenario controller metrics", fontsize=16, fontweight="bold") @@ -266,6 +263,7 @@ def plot_batch_comparison( # ── Metrics helpers ─────────────────────────────────────────────────────────── + def count_batch_changes(log) -> int: """Count step-to-step batch-size changes summed across every (site, model) series.""" total = 0 @@ -363,6 +361,7 @@ def extract_perf_metrics(log, itl_deadlines: dict[str, float] | None = None) -> # ── Scenario generation ─────────────────────────────────────────────────────── + def generate_test_scenarios( exp: dict, inference_data: InferenceData, @@ -444,7 +443,7 @@ def generate_test_scenarios( verdict = "ACCEPT" if passes else "reject" line = ( f" seed={effective_seed} base_int={base_int:.3f} ofo_int={ofo_int:.4f} " - f"recovery={100*recovery:.1f}% {verdict}" + f"recovery={100 * recovery:.1f}% {verdict}" ) logger.info(line) tried.append(line) @@ -460,12 +459,16 @@ def generate_test_scenarios( logger.info( "Filter complete: %d accepted out of %d attempted seeds (%d-%d)", - len(accepted), attempts, seed_start, seed - 1, + len(accepted), + attempts, + seed_start, + seed - 1, ) if len(accepted) < n_scenarios: logger.warning( "Only accepted %d/%d scenarios — consider lowering min_recovery_frac or expanding seed range", - len(accepted), n_scenarios, + len(accepted), + n_scenarios, ) return accepted @@ -494,7 +497,9 @@ def load_scenarios_from_library( if n_take < n_scenarios: logger.warning( "Library has only %d scenarios — capping n_scenarios from %d to %d", - len(lib), n_scenarios, n_take, + len(lib), + n_scenarios, + n_take, ) return [materialize_scenario(rec, training_base=training_base) for rec in lib.scenarios[:n_take]] @@ -502,6 +507,7 @@ def load_scenarios_from_library( # ── Evaluation ──────────────────────────────────────────────────────────────── + def run_scenario( scenario: dict, *, @@ -514,9 +520,9 @@ def run_scenario( save_dir: Path, scenario_idx: int, obs_mode: str = "full-voltage", - ofo_variants: list[tuple[str, "OFOConfig"]] | None = None, + ofo_variants: list[tuple[str, OFOConfig]] | None = None, include_rule_based: bool = False, - rule_based_config: "RuleBasedConfig | None" = None, + rule_based_config: RuleBasedConfig | None = None, rule_step_sizes: tuple[float, ...] = (10.0,), rule_zone_local: bool = False, no_per_scenario_plots: bool = False, @@ -530,9 +536,7 @@ def run_scenario( ofo_variants = ofo_variants or [] itl_deadlines: dict[str, float] = { - md.spec.model_label: md.spec.itl_deadline_s - for site in exp["dc_sites"].values() - for md, _ in site.models + md.spec.model_label: md.spec.itl_deadline_s for site in exp["dc_sites"].values() for md, _ in site.models } dc_sites = scenario["dc_sites"] @@ -579,9 +583,15 @@ def run_scenario( logger.info( " scenario %d %s: viol=%.0fs integral=%.4f vmin=%.4f vmax=%.4f " "tput=%.1f p99_lat=%.3fs power=%.1fkW batch_chg=%d", - scenario_idx, mode, vstats.violation_time_s, - vstats.integral_violation_pu_s, vstats.worst_vmin, vstats.worst_vmax, - perf["mean_throughput_toks_s"], perf["p99_latency_s"], perf["mean_power_kw"], + scenario_idx, + mode, + vstats.violation_time_s, + vstats.integral_violation_pu_s, + vstats.worst_vmin, + vstats.worst_vmax, + perf["mean_throughput_toks_s"], + perf["p99_latency_s"], + perf["mean_power_kw"], perf["batch_changes"], ) @@ -619,9 +629,15 @@ def run_scenario( logger.info( " scenario %d %s: viol=%.0fs integral=%.4f vmin=%.4f vmax=%.4f " "tput=%.1f p99_lat=%.3fs power=%.1fkW batch_chg=%d", - scenario_idx, label, vstats.violation_time_s, - vstats.integral_violation_pu_s, vstats.worst_vmin, vstats.worst_vmax, - perf["mean_throughput_toks_s"], perf["p99_latency_s"], perf["mean_power_kw"], + scenario_idx, + label, + vstats.violation_time_s, + vstats.integral_violation_pu_s, + vstats.worst_vmin, + vstats.worst_vmax, + perf["mean_throughput_toks_s"], + perf["p99_latency_s"], + perf["mean_power_kw"], perf["batch_changes"], ) if rule_based_config is not None: @@ -655,13 +671,19 @@ def run_scenario( logger.info( " scenario %d %s: viol=%.0fs integral=%.4f vmin=%.4f vmax=%.4f " "tput=%.1f p99_lat=%.3fs power=%.1fkW batch_chg=%d", - scenario_idx, mode_key, vstats.violation_time_s, - vstats.integral_violation_pu_s, vstats.worst_vmin, vstats.worst_vmax, - perf["mean_throughput_toks_s"], perf["p99_latency_s"], perf["mean_power_kw"], + scenario_idx, + mode_key, + vstats.violation_time_s, + vstats.integral_violation_pu_s, + vstats.worst_vmin, + vstats.worst_vmax, + perf["mean_throughput_toks_s"], + perf["p99_latency_s"], + perf["mean_power_kw"], perf["batch_changes"], ) - for ppo_path, label in zip(ppo_models, ppo_labels): + for ppo_path, label in zip(ppo_models, ppo_labels, strict=False): vstats, log = run_simulation( "ppo", sys=sys_cfg, @@ -690,17 +712,27 @@ def run_scenario( logger.info( " scenario %d ppo_%s: viol=%.0fs integral=%.4f vmin=%.4f vmax=%.4f " "tput=%.1f p99_lat=%.3fs power=%.1fkW batch_chg=%d", - scenario_idx, label, vstats.violation_time_s, - vstats.integral_violation_pu_s, vstats.worst_vmin, vstats.worst_vmax, - perf["mean_throughput_toks_s"], perf["p99_latency_s"], perf["mean_power_kw"], + scenario_idx, + label, + vstats.violation_time_s, + vstats.integral_violation_pu_s, + vstats.worst_vmin, + vstats.worst_vmax, + perf["mean_throughput_toks_s"], + perf["p99_latency_s"], + perf["mean_power_kw"], perf["batch_changes"], ) if not no_per_scenario_plots: plot_voltage_comparison( - all_logs, sc_save, - v_min=V_MIN, v_max=V_MAX, exclude_buses=exclude_buses, - scenario_idx=scenario_idx, use_display_names=use_display_names, + all_logs, + sc_save, + v_min=V_MIN, + v_max=V_MAX, + exclude_buses=exclude_buses, + scenario_idx=scenario_idx, + use_display_names=use_display_names, ) plot_batch_comparison(all_logs, sc_save, scenario_idx=scenario_idx, use_display_names=use_display_names) plot_violation_bars(results, sc_save, scenario_idx=scenario_idx, use_display_names=use_display_names) @@ -721,24 +753,23 @@ def plot_aggregate( n_sc = len(all_results) prefix = f"{system}_" if system else "" - colors = ["#999999", "#4CAF50", "#2196F3", "#FF9800", "#E91E63", - "#9C27B0", "#00BCD4", "#795548", "#607D8B"] + colors = ["#999999", "#4CAF50", "#2196F3", "#FF9800", "#E91E63", "#9C27B0", "#00BCD4", "#795548", "#607D8B"] display_labels = [_display_name(m) if use_display_names else m for m in modes] metrics = [ - ("violation_time_s", "Mean Violation Time (s)"), - ("integral", "Mean Integral Violation (pu·s)"), - ("batch_changes", "Mean Batch Size Changes"), - ("mean_throughput_toks_s", "Mean Throughput (tok/s)"), - ("mean_power_kw", "Mean Data Center Power (kW)"), - ("itl_violation_rate", "Mean ITL Violation Rate"), + ("violation_time_s", "Mean Violation Time (s)"), + ("integral", "Mean Integral Violation (pu·s)"), + ("batch_changes", "Mean Batch Size Changes"), + ("mean_throughput_toks_s", "Mean Throughput (tok/s)"), + ("mean_power_kw", "Mean Data Center Power (kW)"), + ("itl_violation_rate", "Mean ITL Violation Rate"), ] fig, axes = plt.subplots(2, 3, figsize=(max(15, len(modes) * 3.0), 10)) x = np.arange(len(modes)) - for ax, (metric, title) in zip(axes.flat, metrics): + for ax, (metric, title) in zip(axes.flat, metrics, strict=False): means = [] for mode in modes: vals = [r[mode].get(metric, 0) for r in all_results if mode in r] @@ -766,9 +797,14 @@ def plot_aggregate( for i, mode in enumerate(modes): vals = [r[mode]["integral"] if mode in r else 0 for r in all_results] - ax.bar(x + i * width, vals, width, - label=_display_name(mode) if use_display_names else mode, - color=colors[i % len(colors)], alpha=0.85) + ax.bar( + x + i * width, + vals, + width, + label=_display_name(mode) if use_display_names else mode, + color=colors[i % len(colors)], + alpha=0.85, + ) ax.set_xlabel("Scenario", fontsize=13) ax.set_ylabel("Integral Violation (pu·s)", fontsize=13) @@ -798,9 +834,14 @@ def plot_aggregate( base = r.get(baseline_key, {}).get("integral", 0.0) val = r.get(mode, {}).get("integral", 0.0) norm_vals.append(val / base if base > 0 else 0.0) - ax.bar(x + i * width, norm_vals, width, - label=_display_name(mode) if use_display_names else mode, - color=colors[(modes.index(mode)) % len(colors)], alpha=0.85) + ax.bar( + x + i * width, + norm_vals, + width, + label=_display_name(mode) if use_display_names else mode, + color=colors[(modes.index(mode)) % len(colors)], + alpha=0.85, + ) ax.axhline(1.0, color="black", linestyle="--", linewidth=1, alpha=0.6, label="Baseline") ax.set_xlabel("Scenario", fontsize=13) @@ -824,8 +865,13 @@ def plot_aggregate( if not vals: continue cdf = np.arange(1, len(vals) + 1) / len(vals) - ax.plot(vals, cdf, color=colors[i % len(colors)], linewidth=2, - label=_display_name(mode) if use_display_names else mode) + ax.plot( + vals, + cdf, + color=colors[i % len(colors)], + linewidth=2, + label=_display_name(mode) if use_display_names else mode, + ) ax.set_xlabel("Integral Violation (pu·s)", fontsize=13) ax.set_ylabel("Cumulative Fraction", fontsize=13) ax.set_title("CDF of Integral Violation Across Scenarios", fontsize=14, fontweight="bold") @@ -840,7 +886,7 @@ def plot_aggregate( # ── Throughput vs. voltage violation scatter ── scatter_data = {} - for i, mode in enumerate(modes): + for _i, mode in enumerate(modes): integrals = [r[mode].get("integral", 0.0) for r in all_results if mode in r] tputs = [r[mode].get("mean_throughput_toks_s", 0.0) for r in all_results if mode in r] scatter_data[mode] = (integrals, tputs) @@ -848,8 +894,7 @@ def plot_aggregate( n_modes = len(modes) ncols = 2 nrows = math.ceil(n_modes / ncols) - fig, axes = plt.subplots(nrows, ncols, figsize=(12, 5 * nrows), - sharex=True, sharey=True, squeeze=False) + fig, axes = plt.subplots(nrows, ncols, figsize=(12, 5 * nrows), sharex=True, sharey=True, squeeze=False) for idx, mode in enumerate(modes): ax = axes[idx // ncols][idx % ncols] @@ -858,14 +903,28 @@ def plot_aggregate( for other_mode, (oi, ot) in scatter_data.items(): if other_mode != mode: - ax.scatter(oi, ot, color="lightgrey", s=40, alpha=0.6, - edgecolors="none", zorder=1) - - ax.scatter(integrals, tputs, color=colors[idx % len(colors)], - s=80, alpha=0.9, edgecolors="black", linewidths=0.5, zorder=2) - ax.scatter(np.mean(integrals), np.mean(tputs), - color=colors[idx % len(colors)], s=220, marker="*", - edgecolors="black", linewidths=0.8, zorder=3) + ax.scatter(oi, ot, color="lightgrey", s=40, alpha=0.6, edgecolors="none", zorder=1) + + ax.scatter( + integrals, + tputs, + color=colors[idx % len(colors)], + s=80, + alpha=0.9, + edgecolors="black", + linewidths=0.5, + zorder=2, + ) + ax.scatter( + np.mean(integrals), + np.mean(tputs), + color=colors[idx % len(colors)], + s=220, + marker="*", + edgecolors="black", + linewidths=0.8, + zorder=3, + ) ax.set_title(label, fontsize=14, fontweight="bold") ax.set_xlabel("Integral Violation (pu·s)", fontsize=12) @@ -927,9 +986,7 @@ def main( ppo_models_resolved = [str(Path(p).resolve()) for p in ppo_models] if not ppo_labels: - ppo_labels = tuple( - Path(p).parent.name.replace("ppo_ablation_", "") for p in ppo_models - ) + ppo_labels = tuple(Path(p).parent.name.replace("ppo_ablation_", "") for p in ppo_models) if system not in EXPERIMENTS: raise ValueError(f"Unknown system {system!r}. Valid: {sorted(EXPERIMENTS)}") @@ -944,10 +1001,15 @@ def main( all_specs = tuple(m.spec for m in all_models) inference_data = InferenceData.ensure( - SPECS_CACHE_DIR, all_specs, plot=False, dt_s=float(DT_DC), + SPECS_CACHE_DIR, + all_specs, + plot=False, + dt_s=float(DT_DC), ) logistic_models = LogisticModelStore.ensure( - SPECS_CACHE_DIR, all_specs, plot=False, + SPECS_CACHE_DIR, + all_specs, + plot=False, ) base_ofo = exp["ofo_config"] @@ -1009,11 +1071,14 @@ def _fmt(v: float) -> str: logger.info( "OFO variants (%d): %s", len(ofo_variants), - [(l, c.w_throughput, c.w_switch, c.primal_step_size) for l, c in ofo_variants], + [(lbl, c.w_throughput, c.w_switch, c.primal_step_size) for lbl, c in ofo_variants], ) - save_dir = Path(__file__).resolve().parent / "outputs" / system / ( - output_dir or f"eval_multi_seed{seed_start}_n{n_scenarios}" + save_dir = ( + Path(__file__).resolve().parent + / "outputs" + / system + / (output_dir or f"eval_multi_seed{seed_start}_n{n_scenarios}") ) save_dir.mkdir(parents=True, exist_ok=True) @@ -1047,14 +1112,19 @@ def _fmt(v: float) -> str: logger.info("=" * 70) logger.info( "SCENARIO %d/%d: seed=%d pv_scale=%.2f load_scale=%.2f", - i + 1, n_scenarios, scenario["seed"], - params["pv_scale"], params["load_scale"], + i + 1, + n_scenarios, + scenario["seed"], + params["pv_scale"], + params["load_scale"], ) if params["training_overlay"]: to = params["training_overlay"] logger.info( " training: t=[%.0f, %.0f] n_gpus=%d", - to["t_start"], to["t_end"], to["n_gpus"], + to["t_start"], + to["t_end"], + to["n_gpus"], ) logger.info("=" * 70) @@ -1081,14 +1151,13 @@ def _fmt(v: float) -> str: scenario_params.append(params) modes_set.update(results.keys()) - rb_labels = ( - ["rule_based"] if len(rule_step_sizes) == 1 - else [f"rule_based_s{s:g}" for s in rule_step_sizes] - ) + rb_labels = ["rule_based"] if len(rule_step_sizes) == 1 else [f"rule_based_s{s:g}" for s in rule_step_sizes] mode_order = ( - ["baseline_no_tap"] + rb_labels - + [f"ppo_{l}" for l in ppo_labels] - + ["ofo"] + [f"ofo_{label}" for label, _ in ofo_variants] + ["baseline_no_tap"] + + rb_labels + + [f"ppo_{lbl}" for lbl in ppo_labels] + + ["ofo"] + + [f"ofo_{label}" for label, _ in ofo_variants] ) modes = [m for m in mode_order if m in modes_set] @@ -1113,41 +1182,66 @@ def _fmt(v: float) -> str: logger.info( "%-20s %10.1f %8.1f %10.4f %8.4f %12.4f %12.4f %10.1f %8.1f", mode, - np.mean(viol), np.std(viol), - np.mean(intg), np.std(intg), - np.mean(vmin), np.mean(vmax), - np.mean(bchg), np.std(bchg), + np.mean(viol), + np.std(viol), + np.mean(intg), + np.std(intg), + np.mean(vmin), + np.mean(vmax), + np.mean(bchg), + np.std(bchg), ) extra_cols = [ - "mean_throughput_toks_s", "peak_throughput_toks_s", - "mean_latency_s", "p99_latency_s", - "mean_power_kw", "peak_power_kw", + "mean_throughput_toks_s", + "peak_throughput_toks_s", + "mean_latency_s", + "p99_latency_s", + "mean_power_kw", + "peak_power_kw", "batch_changes", "itl_violation_rate", ] csv_path = save_dir / "results.csv" with open(csv_path, "w", newline="") as f: writer = csv.writer(f) - writer.writerow([ - "scenario", "seed", "pv_scale", "load_scale", - "mode", "violation_time_s", "integral", "worst_vmin", "worst_vmax", - *extra_cols, - ]) - for i, (results, params) in enumerate(zip(all_results, scenario_params)): + writer.writerow( + [ + "scenario", + "seed", + "pv_scale", + "load_scale", + "mode", + "violation_time_s", + "integral", + "worst_vmin", + "worst_vmax", + *extra_cols, + ] + ) + for i, (results, params) in enumerate(zip(all_results, scenario_params, strict=False)): for mode, stats in results.items(): sc = test_scenarios[i] - writer.writerow([ - i, sc["seed"], params["pv_scale"], params["load_scale"], - mode, - stats["violation_time_s"], stats["integral"], - stats["worst_vmin"], stats["worst_vmax"], - *[stats.get(k, "") for k in extra_cols], - ]) + writer.writerow( + [ + i, + sc["seed"], + params["pv_scale"], + params["load_scale"], + mode, + stats["violation_time_s"], + stats["integral"], + stats["worst_vmin"], + stats["worst_vmax"], + *[stats.get(k, "") for k in extra_cols], + ] + ) logger.info("Results CSV: %s", csv_path) if not no_aggregate_plots: - plot_aggregate(all_results, scenario_params, save_dir, modes, system=system, use_display_names=use_display_names) + plot_aggregate( + all_results, scenario_params, save_dir, modes, system=system, use_display_names=use_display_names + ) logger.info("All outputs saved to: %s", save_dir) @@ -1169,7 +1263,7 @@ class Args: output_dir: str = "" """Output directory name under outputs//. Auto-generated if empty.""" obs_mode: str = "full-voltage" - """Observation mode used during PPO training: full-voltage, per-zone-summary, per-bus-summary, or system-summary-only.""" + """Observation mode used during PPO training: full-voltage, per-zone-summary, per-bus-summary, or system-summary-only.""" # noqa: E501 min_baseline_integral: float = 0.2 """Minimum baseline_no_tap integral (pu*s) for a scenario seed to be accepted.""" min_recovery_frac: float = 0.7 @@ -1189,7 +1283,7 @@ class Args: rule_step_sizes: tuple[float, ...] = (10.0,) """Step size(s) for the rule-based controller.""" rule_zone_local: bool = False - """When True AND sys defines `zones` AND there are >1 DC sites, each rule-based controller observes only buses in its own zone (decentralized credit assignment for ieee123).""" + """When True AND sys defines `zones` AND there are >1 DC sites, each rule-based controller observes only buses in its own zone (decentralized credit assignment for ieee123).""" # noqa: E501 no_per_scenario_plots: bool = False """Skip per-scenario voltage and batch plots (saves disk + time).""" no_aggregate_plots: bool = False diff --git a/examples/offline/sweep_dc_locations.py b/examples/offline/sweep_dc_locations.py index af7e4a9..fd6a43f 100644 --- a/examples/offline/sweep_dc_locations.py +++ b/examples/offline/sweep_dc_locations.py @@ -36,26 +36,6 @@ import numpy as np import pandas as pd -from systems import ( - DT_CTRL, - DT_DC, - DT_GRID, - POWER_AUG, - SYSTEMS, - TAP_STEP, - TOTAL_DURATION_S, - V_MAX, - V_MIN, - DCSite, - PVSystemSpec, - SPECS_CACHE_DIR, - TRAINING_TRACE_PATH, - TimeVaryingLoadSpec, - all_model_specs, - deploy, - tap, - with_ramp, -) from openg2g.controller.ofo import ( LogisticModelStore, @@ -78,6 +58,27 @@ from openg2g.grid.opendss import OpenDSSGrid from openg2g.metrics.voltage import compute_allbus_voltage_stats +from systems import ( + DT_CTRL, + DT_DC, + DT_GRID, + POWER_AUG, + SPECS_CACHE_DIR, + SYSTEMS, + TAP_STEP, + TOTAL_DURATION_S, + TRAINING_TRACE_PATH, + V_MAX, + V_MIN, + DCSite, + PVSystemSpec, + TimeVaryingLoadSpec, + all_model_specs, + deploy, + tap, + with_ramp, +) + logger = logging.getLogger("sweep_dc_locations") T_TOTAL_S = 3600 @@ -1384,8 +1385,12 @@ def main_2d( initial_tap_position=initial_taps, exclude_buses=exclude_buses, ) - grid.attach_dc(dc_A, bus=bus_A, connection_type=site_A.connection_type, power_factor=dc_config_A.power_factor) - grid.attach_dc(dc_B, bus=bus_B, connection_type=site_B.connection_type, power_factor=dc_config_B.power_factor) + grid.attach_dc( + dc_A, bus=bus_A, connection_type=site_A.connection_type, power_factor=dc_config_A.power_factor + ) + grid.attach_dc( + dc_B, bus=bus_B, connection_type=site_B.connection_type, power_factor=dc_config_B.power_factor + ) ofo_A = OFOBatchSizeController( specs_A, @@ -1504,9 +1509,7 @@ def _run_multi_dc_case( base_kw_per_phase=site.base_kw_per_phase, ) # Schedules combine the (initial, ramps) pair on each (md, sched). - replica_schedules: dict[str, ReplicaSchedule] = { - md.spec.model_label: sched for md, sched in site.models - } + replica_schedules: dict[str, ReplicaSchedule] = {md.spec.model_label: sched for md, sched in site.models} if stress_test: # Immediate per-model ramps to fill total GPU capacity current_gpus = sum(sched.initial * md.spec.gpus_per_replica for md, sched in site.models) @@ -1515,7 +1518,9 @@ def _run_multi_dc_case( if scale > 1.0: replica_schedules = { md.spec.model_label: ReplicaSchedule(initial=sched.initial).ramp_to( - round(scale * sched.initial), t_start=0, t_end=1, + round(scale * sched.initial), + t_start=0, + t_end=1, ) for md, sched in site.models } @@ -2293,8 +2298,7 @@ def _experiment_ieee13(sys_const, training_trace): "Qwen3-235B-A22B": 42, } models = tuple( - with_ramp((md, sched), ramp_targets[md.spec.model_label], t_start=2500, t_end=3000) - for md, sched in models + with_ramp((md, sched), ramp_targets[md.spec.model_label], t_start=2500, t_end=3000) for md, sched in models ) dc_sites = { diff --git a/examples/offline/systems.py b/examples/offline/systems.py index d383ff6..4c98b8a 100644 --- a/examples/offline/systems.py +++ b/examples/offline/systems.py @@ -160,7 +160,9 @@ def all_model_specs() -> tuple[InferenceModelSpec, ...]: def deploy( - label: str, num_replicas: int, initial_batch_size: int = 128, + label: str, + num_replicas: int, + initial_batch_size: int = 128, ) -> tuple[ModelDeployment, ReplicaSchedule]: """Shorthand: ``deploy("Llama-3.1-8B", 720, 128)`` -> (ModelDeployment, ReplicaSchedule).""" return ( @@ -812,7 +814,9 @@ def _randomize_ramps( for md, sched in site.models: target = max(1, int(ramp_frac * sched.initial)) new_sched = ReplicaSchedule(initial=sched.initial).ramp_to( - target, t_start=ramp_start, t_end=ramp_end, + target, + t_start=ramp_start, + t_end=ramp_end, ) new_models.append((md, new_sched)) new_sites[sid] = DCSite( @@ -882,7 +886,7 @@ def _randomize_broad_ramps( model_scheds: dict[str, ReplicaSchedule] = { md.spec.model_label: ReplicaSchedule(initial=sched.initial) for md, sched in site.models } - for (z_lo, z_hi), z_count in zip(zones, zone_counts): + for (z_lo, z_hi), z_count in zip(zones, zone_counts, strict=False): if z_count == 0: continue band_width = (z_hi - z_lo) / z_count @@ -906,7 +910,9 @@ def _randomize_broad_ramps( target = max(1, int(round(frac * sched.initial))) label = md.spec.model_label model_scheds[label] = model_scheds[label].ramp_to( - target, t_start=t_start, t_end=t_end, + target, + t_start=t_start, + t_end=t_end, ) new_models = tuple((md, model_scheds[md.spec.model_label]) for md, _ in site.models) @@ -960,7 +966,11 @@ def randomize_scenario( ramp_up_frac_max: float = 1.5, randomize_pv_profile: bool = False, pv_shape_choices: tuple[str, ...] = ( - "flat", "rising_falling", "morning_ramp", "afternoon_decline", "midday_dip", + "flat", + "rising_falling", + "morning_ramp", + "afternoon_decline", + "midday_dip", ), pv_baseline_min: float = 0.75, pv_baseline_max: float = 0.95, @@ -990,7 +1000,11 @@ def randomize_scenario( train_start = float(rng.uniform(0.0, max(0.0, float(TOTAL_DURATION_S) - train_dur))) else: train_start = float(rng.uniform(500.0, 1500.0)) - gpu_frac = float(rng.uniform(overlay_gpu_frac_min, overlay_gpu_frac_max)) if is_broad else float(rng.uniform(0.85, 1.0)) + gpu_frac = ( + float(rng.uniform(overlay_gpu_frac_min, overlay_gpu_frac_max)) + if is_broad + else float(rng.uniform(0.85, 1.0)) + ) train_gpus = int(gpu_frac * training_base["n_gpus"]) intensity = float(rng.uniform(overlay_intensity_min, overlay_intensity_max)) if is_broad else 1.0 target_peak = training_base["target_peak_W_per_gpu"] * intensity @@ -1046,7 +1060,9 @@ def randomize_scenario( pv_scale = float(rng.uniform(pv_scale_min, pv_scale_max)) if is_broad else float(rng.uniform(0.5, 2.0)) load_scale = float(rng.uniform(load_scale_min, load_scale_max)) if is_broad else float(rng.uniform(0.5, 2.0)) pv_t_shift = float(rng.uniform(-pv_t_shift_max_s, pv_t_shift_max_s)) if (is_broad and pv_t_shift_max_s > 0) else 0.0 - tvl_t_shift = float(rng.uniform(-tvl_t_shift_max_s, tvl_t_shift_max_s)) if (is_broad and tvl_t_shift_max_s > 0) else 0.0 + tvl_t_shift = ( + float(rng.uniform(-tvl_t_shift_max_s, tvl_t_shift_max_s)) if (is_broad and tvl_t_shift_max_s > 0) else 0.0 + ) pv_warp = float(rng.uniform(pv_warp_min, pv_warp_max)) if is_broad else 1.0 tvl_warp = float(rng.uniform(tvl_warp_min, tvl_warp_max)) if is_broad else 1.0 @@ -1101,10 +1117,7 @@ def _sample_pv_profile(rng_) -> tuple[str, dict | None]: params: dict = {"shape": shape} if shape == "flat": params["level"] = float(rng.uniform(0.5, 0.85)) - elif shape == "increasing": - params["lo"] = float(rng.uniform(0.10, 0.30)) - params["hi"] = float(rng.uniform(0.65, 0.95)) - elif shape == "decreasing": + elif shape == "increasing" or shape == "decreasing": params["lo"] = float(rng.uniform(0.10, 0.30)) params["hi"] = float(rng.uniform(0.65, 0.95)) elif shape == "peaked": @@ -1128,15 +1141,17 @@ def _sample_pv_profile(rng_) -> tuple[str, dict | None]: p_kind, p_params = _sample_pv_profile(rng) else: p_kind, p_params = "default", None - pv_systems_out.append(PVSystemSpec( - bus=s.bus, - bus_kv=s.bus_kv, - peak_kw=s.peak_kw * pv_scale, - peak_t_shift_s=pv_t_shift, - time_warp=pv_warp, - profile_kind=p_kind, - profile_params=p_params, - )) + pv_systems_out.append( + PVSystemSpec( + bus=s.bus, + bus_kv=s.bus_kv, + peak_kw=s.peak_kw * pv_scale, + peak_t_shift_s=pv_t_shift, + time_warp=pv_warp, + profile_kind=p_kind, + profile_params=p_params, + ) + ) tvl = [ TimeVaryingLoadSpec( bus=s.bus, @@ -1253,8 +1268,7 @@ def ieee13_experiment(training_trace: TrainingTrace | None = None) -> dict: deploy("Qwen3-235B-A22B", 210), ) models = tuple( - (md, sched.ramp_to(ramp_targets[md.spec.model_label], t_start=2500, t_end=3000)) - for md, sched in base_models + (md, sched.ramp_to(ramp_targets[md.spec.model_label], t_start=2500, t_end=3000)) for md, sched in base_models ) training_base = ( { @@ -1303,7 +1317,7 @@ def ieee13_experiment(training_trace: TrainingTrace | None = None) -> dict: ) -def ieee34_experiment(training_trace: TrainingTrace | None = None) -> dict: # noqa: ARG001 +def ieee34_experiment(training_trace: TrainingTrace | None = None) -> dict: """IEEE 34-bus: two DC sites (upstream/downstream).""" sys = SYSTEMS["ieee34"]() return dict( @@ -1365,7 +1379,7 @@ def ieee34_experiment(training_trace: TrainingTrace | None = None) -> dict: # n ) -def ieee123_experiment(training_trace: TrainingTrace | None = None) -> dict: # noqa: ARG001 +def ieee123_experiment(training_trace: TrainingTrace | None = None) -> dict: """IEEE 123-bus: four DC sites across zones.""" sys = SYSTEMS["ieee123"]() return dict( diff --git a/examples/offline/train_ppo.py b/examples/offline/train_ppo.py index 5bb5aa5..1ee16ad 100644 --- a/examples/offline/train_ppo.py +++ b/examples/offline/train_ppo.py @@ -20,6 +20,28 @@ import numpy as np import tyro + +from openg2g.controller.tap_schedule import TapScheduleController +from openg2g.datacenter.config import ( + DatacenterConfig, + InferenceModelSpec, + ReplicaSchedule, + TrainingRun, +) +from openg2g.datacenter.offline import OfflineDatacenter, OfflineWorkload +from openg2g.datacenter.workloads.inference import InferenceData +from openg2g.datacenter.workloads.training import TrainingTrace +from openg2g.grid.config import TapSchedule +from openg2g.rl.env import ( + BatchSizeEnv, + ObservationConfig, + RewardConfig, + SharedBatchSizeEnv, + compute_bus_phase_groups, + compute_zone_mask, + resolve_action_mode, +) + from systems import ( DT_CTRL, DT_DC, @@ -27,33 +49,14 @@ EXPERIMENTS, POWER_AUG, SPECS_CACHE_DIR, - SYSTEMS, - TOTAL_DURATION_S, TRAINING_TRACE_PATH, V_MAX, V_MIN, DCSite, - PVSystemSpec, ScenarioOpenDSSGrid, - TimeVaryingLoadSpec, - deploy, materialize_scenario, ) -from openg2g.controller.tap_schedule import TapScheduleController -from openg2g.datacenter.config import ( - DatacenterConfig, - InferenceModelSpec, - ModelDeployment, - ReplicaSchedule, - TrainingRun, -) -from openg2g.datacenter.offline import OfflineDatacenter, OfflineWorkload -from openg2g.datacenter.workloads.inference import InferenceData -from openg2g.datacenter.workloads.training import TrainingTrace -from openg2g.grid.config import TapSchedule -from openg2g.rl.env import BatchSizeEnv, ObservationConfig, RewardConfig, SharedBatchSizeEnv, compute_bus_phase_groups, compute_zone_mask, resolve_action_mode - logger = logging.getLogger(__name__) # ── Simulation factory ────────────────────────────────────────────────────── @@ -97,7 +100,6 @@ def make_sim_factory( _episode_counter = [0] def make_sim(scenario_override=None): - ep = _episode_counter[0] _episode_counter[0] += 1 if scenario_override is not None: @@ -130,9 +132,7 @@ def make_sim(scenario_override=None): datacenters: dict[str, OfflineDatacenter] = {} for sid, site in sites.items(): dc_config = DatacenterConfig(gpus_per_server=8, base_kw_per_phase=site.base_kw_per_phase) - replica_schedules: dict[str, ReplicaSchedule] = { - md.spec.model_label: sched for md, sched in site.models - } + replica_schedules: dict[str, ReplicaSchedule] = {md.spec.model_label: sched for md, sched in site.models} initial_bs = {md.spec.model_label: md.initial_batch_size for md, _ in site.models} wl_kwargs: dict = { "inference_data": site_inference[sid], @@ -220,23 +220,25 @@ def __init__(self, csv_path: Path): self._fp = None self._writer = None - def _on_training_start(self) -> None: # noqa: D401 - SB3 hook - self._fp = open(self.csv_path, "w", buffering=1, newline="") + def _on_training_start(self) -> None: + self._fp = open(self.csv_path, "w", buffering=1, newline="") # noqa: SIM115 self._writer = csv.writer(self._fp) - self._writer.writerow([ - "episode", - "timestep", - "ep_reward", - "ep_length", - "voltage", - "throughput", - "latency", - "switch", - "safe", - "max_undervoltage", - "max_overvoltage", - "mean_violation_frac", - ]) + self._writer.writerow( + [ + "episode", + "timestep", + "ep_reward", + "ep_length", + "voltage", + "throughput", + "latency", + "switch", + "safe", + "max_undervoltage", + "max_overvoltage", + "mean_violation_frac", + ] + ) def _on_step(self) -> bool: infos = self.locals.get("infos", []) or [] @@ -290,7 +292,7 @@ def _on_step(self) -> bool: self._per_env[env_idx] = _new_episode_acc() return True - def _on_training_end(self) -> None: # noqa: D401 - SB3 hook + def _on_training_end(self) -> None: if self._fp is not None: self._fp.close() self._fp = None @@ -312,7 +314,7 @@ def plot_training_progress(csv_path: Path, output_path: Path, label: str) -> Pat import matplotlib.pyplot as plt rows: list[dict] = [] - with open(csv_path, "r", newline="") as fp: + with open(csv_path, newline="") as fp: for r in csv.DictReader(fp): if any(v is None for v in r.values()): continue # skip partial rows from interrupted buffered writes @@ -413,7 +415,7 @@ class Args: ent_coef: float = 0.01 """Entropy coefficient.""" hidden_dims: tuple[int, ...] = (128, 128) - """Hidden layer widths for the MLP policy/value network. Pass multiple values for a deeper net, e.g. --hidden-dims 256 256 256.""" + """Hidden layer widths for the MLP policy/value network. Pass multiple values for a deeper net, e.g. --hidden-dims 256 256 256.""" # noqa: E501 w_voltage: float = 1000.0 """Reward weight for voltage violations.""" w_throughput: float = 0.0 @@ -421,20 +423,20 @@ class Args: w_latency: float = 0.0 """Reward weight for latency violations. Default 0 to isolate the voltage-control objective.""" w_switch: float = 0.01 - """Reward weight for switching cost (penalizes |log2(batch_t) - log2(batch_{t-1})| summed over models). Without this, randomized-scenario runs converge to a near-uniform action distribution and the deterministic eval policy ends up flipping batch sizes on every step. 0.01 is a gentle prior — much smaller than voltage_pen so it acts as a tie-breaker, not a co-equal objective.""" + """Reward weight for switching cost (penalizes |log2(batch_t) - log2(batch_{t-1})| summed over models). Without this, randomized-scenario runs converge to a near-uniform action distribution and the deterministic eval policy ends up flipping batch sizes on every step. 0.01 is a gentle prior — much smaller than voltage_pen so it acts as a tie-breaker, not a co-equal objective.""" # noqa: E501 w_safe: float = 0.0 - """Small positive reward for staying in the safe voltage range. Each step adds +w_safe * (fraction of bus-phases within [v_min, v_max]). Default 0 (disabled). Recommended: 0.01.""" + """Small positive reward for staying in the safe voltage range. Each step adds +w_safe * (fraction of bus-phases within [v_min, v_max]). Default 0 (disabled). Recommended: 0.01.""" # noqa: E501 switch_mode: str = "magnitude" - """Switch penalty mode: 'magnitude' (original log-ratio), 'binary' (fixed cost per change), or 'cooldown' (decaying cost, recent changes expensive).""" + """Switch penalty mode: 'magnitude' (original log-ratio), 'binary' (fixed cost per change), or 'cooldown' (decaying cost, recent changes expensive).""" # noqa: E501 switch_cooldown_tau: float = 30.0 """Time constant (steps) for cooldown switch penalty. Only used with --switch-mode cooldown.""" action_mode: str = "delta" """Action space mode: 'delta' (per-model {-1,0,+1}, 3^N actions) or 'coupled' (all models move by the same delta, 13 actions).""" reward_clip: float = 0.0 - """If > 0, clip per-step reward to [-reward_clip, +inf). Prevents catastrophic scenarios from dominating PPO updates. Recommended: 1.0 (affects ~4% of episodes, leaving normal training signal intact).""" + """If > 0, clip per-step reward to [-reward_clip, +inf). Prevents catastrophic scenarios from dominating PPO updates. Recommended: 1.0 (affects ~4% of episodes, leaving normal training signal intact).""" # noqa: E501 vec_normalize: bool = True - """Wrap the vec env with SB3 VecNormalize (running obs/reward normalization). Strongly recommended: voltage_pen variance across scenarios is huge and tanks value-function learning without it.""" + """Wrap the vec env with SB3 VecNormalize (running obs/reward normalization). Strongly recommended: voltage_pen variance across scenarios is huge and tanks value-function learning without it.""" # noqa: E501 obs_mode: str = "full-voltage" """Voltage observation mode. Choices: - "full-voltage": all bus-phase raw voltages + per-system summary (3 global scalars). @@ -447,7 +449,7 @@ class Args: total_duration_s: int = 3600 """Episode length in simulated seconds. Lower for fast smoke tests (e.g. 300 = 5 simulated minutes).""" n_envs: int = 1 - """Number of parallel rollout environments. >1 uses SubprocVecEnv (each subprocess builds its own OpenDSS instance to avoid global-state conflicts).""" + """Number of parallel rollout environments. >1 uses SubprocVecEnv (each subprocess builds its own OpenDSS instance to avoid global-state conflicts).""" # noqa: E501 tensorboard: bool = True """Write TensorBoard logs to /tb. View with `tensorboard --logdir /tb`.""" plot: bool = True @@ -457,15 +459,15 @@ class Args: log_level: str = "INFO" """Logging verbosity.""" scenario_library: str = "" - """Path to a scenario library .pkl built by build_scenario_library.py. When set, episodes are sampled from this library.""" + """Path to a scenario library .pkl built by build_scenario_library.py. When set, episodes are sampled from this library.""" # noqa: E501 ofo_baseline: bool = False - """Subtract the OFO oracle's per-step voltage penalty from PPO's reward (requires --scenario-library). Disabling gives the raw voltage penalty as reward.""" + """Subtract the OFO oracle's per-step voltage penalty from PPO's reward (requires --scenario-library). Disabling gives the raw voltage penalty as reward.""" # noqa: E501 truncate_episode: bool = True - """Fast-forward past the initial quiet period and terminate after the last violation (requires --scenario-library with t_control_start/end). Disabling uses full 3600s episodes.""" + """Fast-forward past the initial quiet period and terminate after the last violation (requires --scenario-library with t_control_start/end). Disabling uses full 3600s episodes.""" # noqa: E501 seed: int = 42 """Random seed.""" init_from: str = "" - """Path to a PPO checkpoint .zip to warm-start from (e.g. ppo_1152000_steps.zip). If a sibling ppo_vecnormalize_.pkl exists and --vec-normalize is set, its stats are loaded too. Hyperparameters stored in the checkpoint (lr, ent_coef, clip_range, …) are preserved; pass CLI flags only to change the env-side reward weights.""" + """Path to a PPO checkpoint .zip to warm-start from (e.g. ppo_1152000_steps.zip). If a sibling ppo_vecnormalize_.pkl exists and --vec-normalize is set, its stats are loaded too. Hyperparameters stored in the checkpoint (lr, ent_coef, clip_range, …) are preserved; pass CLI flags only to change the env-side reward weights.""" # noqa: E501 def main() -> None: @@ -532,7 +534,8 @@ def main() -> None: ) make_sim, all_site_specs, all_replica_counts, all_initial_batch_sizes = make_sim_factory( - exp, inference_data, + exp, + inference_data, ) # Probe grid for v_index and n_bus_phases @@ -571,8 +574,13 @@ def main() -> None: n_bus_phases = 0 bus_phase_groups = None - logger.info("Grid has %d bus-phase pairs across %d buses; obs_mode=%s, n_bus_phases=%d", - n_bus_phases_full, len(set(b for b, _ in v_index)), args.obs_mode, n_bus_phases) + logger.info( + "Grid has %d bus-phase pairs across %d buses; obs_mode=%s, n_bus_phases=%d", + n_bus_phases_full, + len(set(b for b, _ in v_index)), + args.obs_mode, + n_bus_phases, + ) reward_config = RewardConfig( w_voltage=args.w_voltage, @@ -618,9 +626,7 @@ def _make_one(): vn_init_ckpt = None if args.init_from: _p = Path(args.init_from) - _cand = _p.with_name( - _p.name.replace("ppo_", "ppo_vecnormalize_", 1).replace(".zip", ".pkl") - ) + _cand = _p.with_name(_p.name.replace("ppo_", "ppo_vecnormalize_", 1).replace(".zip", ".pkl")) if _cand.exists(): vn_init_ckpt = _cand if vn_init_ckpt is not None: @@ -691,7 +697,10 @@ def _make_one(): if args.lr_schedule == "linear": _lr_init = float(args.learning_rate) - lr_arg = lambda progress_remaining: progress_remaining * _lr_init + + def lr_arg(progress_remaining): + return progress_remaining * _lr_init + elif args.lr_schedule == "constant": lr_arg = args.learning_rate else: @@ -707,7 +716,8 @@ def _make_one(): model.set_env(vec_env) logger.info( "Warm-started PPO from %s (num_timesteps=%d).", - args.init_from, getattr(model, "num_timesteps", 0), + args.init_from, + getattr(model, "num_timesteps", 0), ) else: model = PPO( @@ -750,7 +760,7 @@ def _make_one(): logger.info("Wrote training plot to %s", plot_path) else: logger.warning("No metrics rows in %s — skipping plot", metrics_csv) - except Exception as e: # noqa: BLE001 + except Exception as e: logger.warning("Plotting failed for '%s': %s", label, e) if args.shared and len(site_ids) > 1: @@ -758,21 +768,21 @@ def _make_one(): logger.info("Training SHARED PPO for %d sites: %s", len(site_ids), site_ids) site_model_mapping = {sid: [s.model_label for s in all_site_specs[sid]] for sid in site_ids} - all_initial_bs_flat = { - label: bs - for sid in site_ids for label, bs in all_initial_batch_sizes[sid].items() - } + all_initial_bs_flat = {label: bs for sid in site_ids for label, bs in all_initial_batch_sizes[sid].items()} zone_summary = ( {zname: tuple(zbuses) for zname, zbuses in zones.items()} if zones is not None and args.obs_mode in ("per-zone-summary", "per-bus-summary") else None ) obs_config = ObservationConfig.from_multi_site( - all_site_specs, all_replica_counts, n_bus_phases=n_bus_phases, + all_site_specs, + all_replica_counts, + n_bus_phases=n_bus_phases, initial_batch_sizes=all_initial_bs_flat, zone_summary=zone_summary, bus_phase_groups=bus_phase_groups, - v_min=V_MIN, v_max=V_MAX + v_min=V_MIN, + v_max=V_MAX, ) def shared_env_factory(): @@ -810,7 +820,13 @@ def shared_env_factory(): site_initial_bs = all_initial_batch_sizes[sid] obs_config = ObservationConfig.from_model_specs( - specs, replica_counts, n_bus_phases=n_bp, initial_batch_sizes=site_initial_bs, zone_buses=zone_buses, v_min=V_MIN, v_max=V_MAX + specs, + replica_counts, + n_bus_phases=n_bp, + initial_batch_sizes=site_initial_bs, + zone_buses=zone_buses, + v_min=V_MIN, + v_max=V_MAX, ) def site_env_factory(_obs_config=obs_config, _sid=sid, _lib=scenario_lib): diff --git a/openg2g/controller/ppo.py b/openg2g/controller/ppo.py index b64636c..d85ec6f 100644 --- a/openg2g/controller/ppo.py +++ b/openg2g/controller/ppo.py @@ -42,7 +42,7 @@ def _load_vecnormalize(vecnormalize_path: str | Path, observation_space, action_ from stable_baselines3.common.vec_env import DummyVecEnv, VecNormalize class _DummyEnv(gym.Env): - metadata = {"render_modes": []} + metadata = {"render_modes": []} # noqa: RUF012 def __init__(self, obs_space, act_space): super().__init__() @@ -76,7 +76,9 @@ def _detect_action_mode(action_space, n_models: int) -> str: nvec = tuple(int(d) for d in action_space.nvec) if all(d == 3 for d in nvec): return "delta" - raise ValueError(f"Unrecognised action space {action_space!r} — expected Discrete (coupled) or MultiDiscrete([3]*N) (delta)") + raise ValueError( + f"Unrecognised action space {action_space!r} — expected Discrete (coupled) or MultiDiscrete([3]*N) (delta)" + ) class PPOBatchSizeController( @@ -130,9 +132,7 @@ def __init__( self._init_prev_batch() def _init_prev_batch(self) -> None: - self._prev_batch = { - s.model_label: self._obs_config.get_initial_batch(s.model_label) for s in self._models - } + self._prev_batch = {s.model_label: self._obs_config.get_initial_batch(s.model_label) for s in self._models} @property def dt_s(self) -> Fraction: diff --git a/openg2g/controller/rule_based.py b/openg2g/controller/rule_based.py index 3c3849b..c015d81 100644 --- a/openg2g/controller/rule_based.py +++ b/openg2g/controller/rule_based.py @@ -91,9 +91,7 @@ def __init__( # to the worst-violation scan. Used in multi-DC topologies (ieee123) # to give each site credit only for the part of the network it can # actually move. None preserves the original global-scan behavior. - self._zone_lower: set[str] | None = ( - {b.lower() for b in zone_buses} if zone_buses is not None else None - ) + self._zone_lower: set[str] | None = {b.lower() for b in zone_buses} if zone_buses is not None else None self._initial_batch_sizes = initial_batch_sizes or {} # Build per-model feasible batch list (sorted ascending) @@ -110,7 +108,7 @@ def __init__( } logger.info( - "RuleBasedBatchSizeController: %d models, dt=%s s, step_size=%.2f, deadband=%.4f, v=[%.2f, %.2f], zone_local=%s (n_zone_buses=%d)", + "RuleBasedBatchSizeController: %d models, dt=%s s, step_size=%.2f, deadband=%.4f, v=[%.2f, %.2f], zone_local=%s (n_zone_buses=%d)", # noqa: E501 len(model_specs), dt_s, config.step_size, diff --git a/openg2g/rl/env.py b/openg2g/rl/env.py index 4cdb7fc..5a6d548 100644 --- a/openg2g/rl/env.py +++ b/openg2g/rl/env.py @@ -252,10 +252,7 @@ def _apply_delta(label: str, delta: int) -> int: return {label: _apply_delta(label, delta) for label in model_labels} if action_mode == "delta": - return { - label: _apply_delta(label, int(action[i]) - 1) - for i, label in enumerate(model_labels) - } + return {label: _apply_delta(label, int(action[i]) - 1) for i, label in enumerate(model_labels)} raise ValueError(f"Unknown action_mode: {action_mode!r}") @@ -290,7 +287,7 @@ def build_observation( # Per-bus min/max: [min_phase, max_phase] for each bus for k, indices in enumerate(obs_config.bus_phase_groups): ph_v = v_vec_full[list(indices)] - obs[2 * k] = float(np.min(ph_v)) + obs[2 * k] = float(np.min(ph_v)) obs[2 * k + 1] = float(np.max(ph_v)) elif M > 0: n = min(len(v_vec), M) @@ -301,7 +298,7 @@ def build_observation( if obs_config.zone_summary: # Per-zone violation summary — replaces the 3 global scalars base = M - for zone_name, zone_bus_list in obs_config.zone_summary.items(): + for zone_name, _zone_bus_list in obs_config.zone_summary.items(): mask_z = (zone_masks or {}).get(zone_name) v_z = v_vec_full[mask_z] if mask_z is not None else v_vec_full under_z = np.maximum(v_min_cfg - v_z, 0.0) @@ -729,7 +726,9 @@ def reset(self, *, seed: int | None = None, options: dict[str, Any] | None = Non self._prev_batch = {label: self._obs_config.get_initial_batch(label) for label in self._obs_config.model_labels} self._steps_since_change = {label: 999 for label in self._obs_config.model_labels} - obs = build_observation(grid, self._agent_dc, self._obs_config, self._prev_batch, self._zone_mask, self._zone_masks) + obs = build_observation( + grid, self._agent_dc, self._obs_config, self._prev_batch, self._zone_mask, self._zone_masks + ) return obs, {} def step(self, action: np.ndarray) -> tuple[np.ndarray, float, bool, bool, dict]: @@ -777,7 +776,9 @@ def step(self, action: np.ndarray) -> tuple[np.ndarray, float, bool, bool, dict] reward = max(reward, -self._reward_config.reward_clip) self._prev_batch = dict(batch_sizes) - obs = build_observation(self._grid, self._agent_dc, self._obs_config, self._prev_batch, self._zone_mask, self._zone_masks) + obs = build_observation( + self._grid, self._agent_dc, self._obs_config, self._prev_batch, self._zone_mask, self._zone_masks + ) truncated = self._steps_done >= self._max_steps if truncated: @@ -854,7 +855,9 @@ def reset(self, *, seed: int | None = None, options: dict[str, Any] | None = Non # Call parent reset (sets up sim, runs initial ticks) obs, info = super().reset(seed=seed, options=options) # Rebuild obs using ALL DCs - obs = build_observation(self._grid, self._all_dcs_list, self._obs_config, self._prev_batch, self._zone_mask, self._zone_masks) + obs = build_observation( + self._grid, self._all_dcs_list, self._obs_config, self._prev_batch, self._zone_mask, self._zone_masks + ) return obs, info def step(self, action: np.ndarray) -> tuple[np.ndarray, float, bool, bool, dict]: @@ -893,7 +896,9 @@ def step(self, action: np.ndarray) -> tuple[np.ndarray, float, bool, bool, dict] self._steps_since_change[label] = self._steps_since_change.get(label, 999) + 1 self._prev_batch = dict(all_batch_sizes) - obs = build_observation(self._grid, self._all_dcs_list, self._obs_config, self._prev_batch, self._zone_mask, self._zone_masks) + obs = build_observation( + self._grid, self._all_dcs_list, self._obs_config, self._prev_batch, self._zone_mask, self._zone_masks + ) truncated = self._steps_done >= self._max_steps if truncated: From 7235765ac63df28ae836616395c15ab972e0619b Mon Sep 17 00:00:00 2001 From: Zhirui Liang Date: Mon, 4 May 2026 00:56:48 -0400 Subject: [PATCH 06/13] [CI] Fix ty type-check errors --- examples/offline/sweep_dc_locations.py | 2 +- examples/offline/systems.py | 3 ++- openg2g/rl/env.py | 3 +-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/examples/offline/sweep_dc_locations.py b/examples/offline/sweep_dc_locations.py index fd6a43f..2f94e2a 100644 --- a/examples/offline/sweep_dc_locations.py +++ b/examples/offline/sweep_dc_locations.py @@ -1097,7 +1097,7 @@ def main_1d( ("ofo_tap_change", True, True), ] - bus_results = {"dc_bus": dc_bus} + bus_results: dict[str, str | float] = {"dc_bus": dc_bus} for case_name, use_ofo, use_tap_change in cases: case_idx += 1 diff --git a/examples/offline/systems.py b/examples/offline/systems.py index 4c98b8a..567ffc8 100644 --- a/examples/offline/systems.py +++ b/examples/offline/systems.py @@ -10,6 +10,7 @@ from __future__ import annotations import math +from collections.abc import Callable from dataclasses import dataclass from fractions import Fraction from pathlib import Path @@ -1448,7 +1449,7 @@ def ieee123_experiment(training_trace: TrainingTrace | None = None) -> dict: ) -EXPERIMENTS: dict[str, object] = { +EXPERIMENTS: dict[str, Callable[..., dict]] = { "ieee13": ieee13_experiment, "ieee34": ieee34_experiment, "ieee123": ieee123_experiment, diff --git a/openg2g/rl/env.py b/openg2g/rl/env.py index 5a6d548..6446856 100644 --- a/openg2g/rl/env.py +++ b/openg2g/rl/env.py @@ -3,7 +3,6 @@ from __future__ import annotations import math -import typing from collections.abc import Callable from dataclasses import dataclass from fractions import Fraction @@ -533,7 +532,7 @@ class BatchSizeEnv(gymnasium.Env): first baseline violation) and terminates after the last violation clears. """ - metadata: typing.ClassVar[dict[str, list]] = {"render_modes": []} + metadata = {"render_modes": []} # noqa: RUF012 # gym Env.metadata override; not annotated to avoid ty's invalid-attribute-override def __init__( self, From ca43a56578444754d245cbcd1a9c7adecc0caf43 Mon Sep 17 00:00:00 2001 From: Zhirui Liang Date: Mon, 4 May 2026 01:09:56 -0400 Subject: [PATCH 07/13] [Refactor] sweep_dc_locations.py: deduplicate against systems.py + utils.py --- examples/offline/sweep_dc_locations.py | 459 +------------------------ 1 file changed, 3 insertions(+), 456 deletions(-) diff --git a/examples/offline/sweep_dc_locations.py b/examples/offline/sweep_dc_locations.py index 2f94e2a..ddd595f 100644 --- a/examples/offline/sweep_dc_locations.py +++ b/examples/offline/sweep_dc_locations.py @@ -36,6 +36,7 @@ import numpy as np import pandas as pd +from utils import discover_candidate_buses, extract_all_voltages, find_violations from openg2g.controller.ofo import ( LogisticModelStore, @@ -55,7 +56,6 @@ from openg2g.datacenter.workloads.inference import InferenceData from openg2g.datacenter.workloads.training import TrainingTrace from openg2g.grid.config import TapPosition, TapSchedule -from openg2g.grid.opendss import OpenDSSGrid from openg2g.metrics.voltage import compute_allbus_voltage_stats from systems import ( @@ -72,6 +72,7 @@ V_MIN, DCSite, PVSystemSpec, + ScenarioOpenDSSGrid, TimeVaryingLoadSpec, all_model_specs, deploy, @@ -82,467 +83,13 @@ logger = logging.getLogger("sweep_dc_locations") T_TOTAL_S = 3600 +MAX_TAP_ITERATIONS = 20 # max passes per tap-optimization scenario # Backward-compatible aliases (used by other scripts) PVSystemConfig = PVSystemSpec TimeVaryingLoadConfig = TimeVaryingLoadSpec -# ── Profile generation helpers ─────────────────────────────────────────────── - - -def _smooth_bump(t: float, t_center: float, half_width: float) -> float: - dt = abs(t - t_center) - if dt >= half_width: - return 0.0 - x = dt / half_width - return (1 - x * x) ** 2 - - -def _smoothstep(t: float, t_start: float, t_end: float) -> float: - """Cubic Hermite smoothstep: zero-derivative at both endpoints. - - Returns 0 for ``t <= t_start``, 1 for ``t >= t_end``, and a smooth - S-curve in between (3x² - 2x³). This is the standard ease-in/ease-out - function used for physically plausible PV ramps — solar power doesn't - transition linearly because the sun's elevation angle changes smoothly. - """ - if t_end <= t_start: - return 1.0 if t >= t_start else 0.0 - if t <= t_start: - return 0.0 - if t >= t_end: - return 1.0 - x = (t - t_start) / (t_end - t_start) - return x * x * (3.0 - 2.0 * x) - - -def _irregular_fluct(t: float, seed: float = 0.0) -> float: - """Irregular fluctuation via superposition of incommensurate frequencies. - - Returns a value centred around 1.0 with ~+-15% variation. - The use of irrational-ratio periods avoids visible periodicity. - """ - s = seed - f1 = 0.06 * math.sin(2 * math.pi * t / 173.0 + s) - f2 = 0.05 * math.sin(2 * math.pi * t / 97.3 + s * 2.3) - f3 = 0.04 * math.sin(2 * math.pi * t / 251.7 + s * 0.7) - f4 = 0.03 * math.sin(2 * math.pi * t / 41.9 + s * 4.1) - f5 = 0.02 * math.sin(2 * math.pi * t / 317.3 + s * 1.9) - return 1.0 + f1 + f2 + f3 + f4 + f5 - - -def pv_profile_kw(t: float, peak_kw: float, site_idx: int = 0) -> float: - """Solar PV output (kW per phase) with per-site cloud patterns. - - Each site has a distinct profile with different cloud events, trends, and - fluctuation seeds so the curves are visually distinguishable. - """ - T = T_TOTAL_S - if site_idx == 0: - # Site 0: Declining afternoon output with two cloud dips - trend = 0.85 - 0.30 * (t / T) - cloud = 1.0 - cloud -= 0.55 * _smooth_bump(t, 600, 120) - cloud -= 0.40 * _smooth_bump(t, 2100, 180) - fluct = _irregular_fluct(t, seed=0.3) - return max(0.0, peak_kw * trend * max(cloud, 0.05) * fluct) - elif site_idx == 1: - # Site 1: Midday peak with passing cloud bank - ramp = 0.55 + 0.40 * _smooth_bump(t, 1200, 900) - cloud = 1.0 - cloud -= 0.60 * _smooth_bump(t, 1680, 240) - cloud -= 0.25 * _smooth_bump(t, 2400, 150) - fluct = _irregular_fluct(t, seed=2.1) - return max(0.0, peak_kw * ramp * max(cloud, 0.05) * fluct) - else: - # Site 2+: Morning ramp, sustained high, late cloud event - # Seed varies by site_idx so additional PV sites get distinct curves - ramp = 0.30 + 0.65 * min(1.0, t / 900.0) - cloud = 1.0 - cloud -= 0.70 * _smooth_bump(t, 2700, 300) - cloud -= 0.30 * _smooth_bump(t, 1200, 100) - fluct = _irregular_fluct(t, seed=2.0 + site_idx * 3.7) - return max(0.0, peak_kw * ramp * max(cloud, 0.05) * fluct) - - -def load_profile_kw(t: float, peak_kw: float, site_idx: int = 0) -> float: - fluct_period = 130.0 + site_idx * 37 - fluct = 1.0 + 0.06 * math.sin(2 * math.pi * t / fluct_period + site_idx * 1.4) - - if site_idx == 0: - base = 0.15 + 0.85 * _smooth_bump(t, 2280, 1400) - surge = 0.20 * _smooth_bump(t, 2280, 180) - return max(0.0, peak_kw * (base + surge) * fluct) - elif site_idx == 1: - base = 0.10 - base += 0.50 * _smooth_bump(t, 1500, 600) - base += 0.80 * _smooth_bump(t, 2880, 500) - return max(0.0, peak_kw * base * fluct) - elif site_idx == 2: - base = 0.80 - 0.55 * _smooth_bump(t, 1800, 1200) - surge = 0.70 * _smooth_bump(t, 2520, 400) - return max(0.0, peak_kw * (base + surge) * fluct) - elif site_idx == 3: - base = 0.10 + 0.90 * _smooth_bump(t, 3120, 800) - return max(0.0, peak_kw * base * fluct) - else: - base = 0.10 - base += 0.60 * _smooth_bump(t, 1080, 300) - base += 0.75 * _smooth_bump(t, 2100, 350) - base += 0.90 * _smooth_bump(t, 3300, 300) - return max(0.0, peak_kw * base * fluct) - - -# ── Randomized profile generators ──────────────────────────────────────────── -# -# These produce 1-hour episodes (T_TOTAL_S ≈ 3600 s). Unlike the legacy -# pv_profile_kw / load_profile_kw which were shaped like multi-hour curves -# (sunrise→noon→sunset, residential demand peak), these are tuned for the -# 1-hour episode regime where: -# - PV power is roughly constant (sun position barely moves) with -# occasional cloud dips as the dominant variability source. -# - TVL has multiple plausible shapes (flat, increasing, decreasing, -# peaked, valley) depending on the load type. The shape is chosen by -# params["shape"] and the parameters drive its details. -# -# The params dict is generated once per scenario in train_ppo.randomize_scenario -# (broad mode) and stored on PVSystemSpec.profile_params / TVLSpec.profile_params -# so the simulator can replay deterministically. - - -def pv_profile_random(t: float, peak_kw: float, params: dict) -> float: - """Multi-shape PV profile with random cloud events (1-hour episode). - - params["shape"] picks the envelope: - "flat" constant baseline 0.75-0.95 - "rising_falling" smooth bump from low_baseline up to high_baseline and back - "morning_ramp" low → high over a short ramp window, sustained high after - "afternoon_decline" sustained high then ramp down at the end - "midday_dip" high baseline with a substantial mid-episode dip - - Envelope-specific params (all optional, sensible defaults below): - flat: baseline - rising_falling: low_baseline, high_baseline, peak_t, half_width - morning_ramp: low_baseline, high_baseline, ramp_end - afternoon_decline: low_baseline, high_baseline, ramp_start - midday_dip: high_baseline, dip_t, dip_half_width, dip_depth - - Common keys (applied on top of any envelope): - clouds: list of (t_center_s, half_width_s, depth_frac) extra cloud-dip events - noise_amp: amplitude of small irrational-frequency fluctuations (0.0-0.1) - noise_seed: seed for the fluctuation phase - """ - shape = params.get("shape", "flat") - T = float(T_TOTAL_S) - - if shape == "flat": - env = float(params.get("baseline", 0.85)) - elif shape == "rising_falling": - lo = float(params.get("low_baseline", 0.50)) - hi = float(params.get("high_baseline", 0.95)) - peak_t = float(params.get("peak_t", T / 2)) - half_width = float(params.get("half_width", 1200.0)) - env = lo + (hi - lo) * _smooth_bump(t, peak_t, half_width) - elif shape == "morning_ramp": - lo = float(params.get("low_baseline", 0.25)) - hi = float(params.get("high_baseline", 0.75)) - ramp_start = float(params.get("ramp_start", 100.0)) - ramp_end = float(params.get("ramp_end", 1200.0)) - env = lo + (hi - lo) * _smoothstep(t, ramp_start, ramp_end) - elif shape == "afternoon_decline": - lo = float(params.get("low_baseline", 0.25)) - hi = float(params.get("high_baseline", 0.75)) - ramp_start = float(params.get("ramp_start", 2400.0)) - ramp_end = float(params.get("ramp_end", T - 100.0)) - env = hi - (hi - lo) * _smoothstep(t, ramp_start, ramp_end) - elif shape == "midday_dip": - hi = float(params.get("high_baseline", 0.90)) - dip_t = float(params.get("dip_t", T / 2)) - dip_half_width = float(params.get("dip_half_width", 700.0)) - dip_depth = float(params.get("dip_depth", 0.55)) - env = hi - dip_depth * _smooth_bump(t, dip_t, dip_half_width) - else: - env = 0.85 # safe fallback - - # Apply cloud events (independent of envelope shape) - for tc, hw, depth in params.get("clouds", ()): - env -= float(depth) * _smooth_bump(t, float(tc), float(hw)) - - # Floor PV at 15% of nameplate even during heavy clouds — diffuse - # irradiance keeps real PV output above zero in practice. - env = max(0.15, env) - noise_amp = float(params.get("noise_amp", 0.0)) - if noise_amp > 0: - f = _irregular_fluct(t, seed=float(params.get("noise_seed", 0.0))) - env *= 1.0 + (noise_amp / 0.20) * (f - 1.0) - return max(0.0, peak_kw * env) - - -def tvl_profile_random(t: float, peak_kw: float, params: dict) -> float: - """Multi-shape TVL profile (1-hour episode). - - params: - shape: one of "flat", "increasing", "decreasing", "peaked", "valley" - ... shape-specific keys (see body below) ... - noise_amp: amplitude of small fluctuations (0.0-0.08) - noise_seed: seed for the fluctuation phase - """ - shape = params.get("shape", "peaked") - T = float(T_TOTAL_S) - if shape == "flat": - base = float(params.get("level", 0.7)) - elif shape == "increasing": - lo = float(params.get("lo", 0.2)) - hi = float(params.get("hi", 0.9)) - base = lo + (hi - lo) * min(1.0, max(0.0, t / T)) - elif shape == "decreasing": - lo = float(params.get("lo", 0.2)) - hi = float(params.get("hi", 0.9)) - base = hi - (hi - lo) * min(1.0, max(0.0, t / T)) - elif shape == "peaked": - peak_t = float(params.get("peak_t", T / 2)) - peak_w = float(params.get("peak_w", 1400.0)) - baseline = float(params.get("baseline", 0.15)) - amp = float(params.get("amp", 0.85)) - base = baseline + amp * _smooth_bump(t, peak_t, peak_w) - elif shape == "valley": - valley_t = float(params.get("valley_t", T / 2)) - valley_w = float(params.get("valley_w", 1200.0)) - high = float(params.get("high", 0.85)) - depth = float(params.get("depth", 0.55)) - base = high - depth * _smooth_bump(t, valley_t, valley_w) - else: - # Unknown shape -> safe default - base = 0.5 - base = max(0.0, base) - noise_amp = float(params.get("noise_amp", 0.0)) - if noise_amp > 0: - f = _irregular_fluct(t, seed=float(params.get("noise_seed", 0.0))) - base *= 1.0 + (noise_amp / 0.20) * (f - 1.0) - return max(0.0, peak_kw * base) - - -def load_csv_profile(csv_path: Path) -> tuple[np.ndarray, np.ndarray]: - data = np.loadtxt(csv_path, delimiter=",", skiprows=1) - return data[:, 0], data[:, 1] - - -def eval_profile( - t, - *, - peak_kw, - csv_data, - profile_fn, - site_idx, - peak_t_shift_s: float = 0.0, - time_warp: float = 1.0, - profile_kind: str = "default", - profile_params: dict | None = None, -): - """Evaluate a PV/TVL profile at simulated time ``t``. - - Dispatch order: - 1. ``profile_kind="random_flat"`` + ``profile_params`` -> pv_profile_random - 2. ``profile_kind="random_shape"`` + ``profile_params`` -> tvl_profile_random - 3. ``csv_data`` not None -> CSV interpolation - 4. ``profile_fn`` (legacy analytical pv_profile_kw / load_profile_kw) - - The legacy path applies the optional ``peak_t_shift_s`` / ``time_warp`` - transform: ``t_eff = (t - peak_t_shift_s) / time_warp``. The randomized - kinds bake their shape into profile_params and ignore these. - """ - if profile_kind == "random_flat" and profile_params is not None: - return pv_profile_random(t, peak_kw, profile_params) - if profile_kind == "random_shape" and profile_params is not None: - return tvl_profile_random(t, peak_kw, profile_params) - if time_warp <= 0: - time_warp = 1.0 - t_eff = (t - peak_t_shift_s) / time_warp - if csv_data is not None: - return float(np.interp(t_eff, csv_data[0], csv_data[1])) - return profile_fn(t_eff, peak_kw, site_idx) - - -# ── Scenario Grid ──────────────────────────────────────────────────────────── - - -class ScenarioOpenDSSGrid(OpenDSSGrid): - """OpenDSSGrid with PV systems and external loads at arbitrary buses.""" - - def __init__( - self, *, pv_systems=None, time_varying_loads=None, source_pu=None, constant_pv: bool = False, **kwargs - ): - super().__init__(**kwargs) - self._pv_specs = list(pv_systems or []) - self._load_specs = list(time_varying_loads or []) - self._source_pu = source_pu - self._constant_pv = constant_pv - - self._pv_csv = [load_csv_profile(s.csv_path) if s.csv_path else None for s in self._pv_specs] - self._load_csv = [load_csv_profile(s.csv_path) if s.csv_path else None for s in self._load_specs] - self._pv_load_names = [(f"PV_{i}_A", f"PV_{i}_B", f"PV_{i}_C") for i in range(len(self._pv_specs))] - self._ext_load_names = [ - (f"ExtLoad_{i}_A", f"ExtLoad_{i}_B", f"ExtLoad_{i}_C") for i in range(len(self._load_specs)) - ] - - def _init_dss(self) -> None: - super()._init_dss() - from openg2g.grid.opendss import dss - - if self._source_pu is not None: - dss.Text.Command(f"Edit Vsource.source pu={self._source_pu}") - - for i, spec in enumerate(self._pv_specs): - kv_ln = spec.bus_kv / math.sqrt(3.0) - for ph, name in zip((1, 2, 3), self._pv_load_names[i], strict=False): - dss.Text.Command( - f"New Load.{name} bus1={spec.bus}.{ph} phases=1 " - f"conn=wye kV={kv_ln:.6f} kW=0 kvar=0 model=1 vminpu=0.85" - ) - - for i, spec in enumerate(self._load_specs): - kv_ln = spec.bus_kv / math.sqrt(3.0) - for ph, name in zip((1, 2, 3), self._ext_load_names[i], strict=False): - dss.Text.Command( - f"New Load.{name} bus1={spec.bus}.{ph} phases=1 " - f"conn=wye kV={kv_ln:.6f} kW=0 kvar=0 model=1 vminpu=0.85" - ) - - def step(self, clock, power_samples_w, events): - from openg2g.grid.opendss import dss - - for i, spec in enumerate(self._pv_specs): - if self._constant_pv: - kw = spec.peak_kw - else: - kw = eval_profile( - clock.time_s, - peak_kw=spec.peak_kw, - csv_data=self._pv_csv[i], - profile_fn=pv_profile_kw, - site_idx=i, - peak_t_shift_s=getattr(spec, "peak_t_shift_s", 0.0), - time_warp=getattr(spec, "time_warp", 1.0), - profile_kind=getattr(spec, "profile_kind", "default"), - profile_params=getattr(spec, "profile_params", None), - ) - pf = max(min(spec.power_factor, 0.999999), 1e-6) - kvar = kw * math.tan(math.acos(pf)) - for name in self._pv_load_names[i]: - dss.Loads.Name(name) - dss.Loads.kW(-kw) - dss.Loads.kvar(-kvar) - - for i, spec in enumerate(self._load_specs): - kw = eval_profile( - clock.time_s, - peak_kw=spec.peak_kw, - csv_data=self._load_csv[i], - profile_fn=load_profile_kw, - site_idx=i, - peak_t_shift_s=getattr(spec, "peak_t_shift_s", 0.0), - time_warp=getattr(spec, "time_warp", 1.0), - profile_kind=getattr(spec, "profile_kind", "default"), - profile_params=getattr(spec, "profile_params", None), - ) - pf = max(min(spec.power_factor, 0.999999), 1e-6) - kvar = kw * math.tan(math.acos(pf)) - for name in self._ext_load_names[i]: - dss.Loads.Name(name) - dss.Loads.kW(kw) - dss.Loads.kvar(kvar) - - return super().step(clock, power_samples_w, events) - - -# ── Bus discovery ──────────────────────────────────────────────────────────── - - -def discover_candidate_buses( - case_dir: Path, - master_file: str, - target_bus_kv: float, - exclude: set[str], -) -> list[str]: - """Discover all 3-phase buses at the target voltage level.""" - from opendssdirect import dss - - dss.Basic.ClearAll() - master_path = str(Path(case_dir) / master_file) - dss.Text.Command(f'Compile "{master_path}"') - dss.Solution.SolveNoControl() - - target_kv_ln = target_bus_kv / math.sqrt(3.0) - tolerance = 0.05 * target_kv_ln - - bus_phases: dict[str, set[int]] = {} - for name in dss.Circuit.AllNodeNames(): - parts = name.split(".") - bus = parts[0].lower() - phase = int(parts[1]) if len(parts) > 1 else 0 - if bus not in bus_phases: - bus_phases[bus] = set() - bus_phases[bus].add(phase) - - exclude_lower = {b.lower() for b in exclude} - candidates = [] - - for bus_name in dss.Circuit.AllBusNames(): - if bus_name.lower() in exclude_lower: - continue - phases = bus_phases.get(bus_name.lower(), set()) - if not {1, 2, 3}.issubset(phases): - continue - dss.Circuit.SetActiveBus(bus_name) - kv_base = dss.Bus.kVBase() - if abs(kv_base - target_kv_ln) <= tolerance: - candidates.append(bus_name) - - return sorted(candidates) - - -# ══════════════════════════════════════════════════════════════════════════════ -# 1-D SWEEP (single DC site) -# ══════════════════════════════════════════════════════════════════════════════ - - -MAX_TAP_ITERATIONS = 20 - - -def extract_all_voltages(grid_states, exclude_buses=("rg60", "sourcebus")): - exclude = {b.lower() for b in exclude_buses} - result = {} - for gs in grid_states: - for bus in gs.voltages.buses(): - if bus.lower() in exclude: - continue - if bus not in result: - result[bus] = {"a": [], "b": [], "c": []} - pv = gs.voltages[bus] - result[bus]["a"].append(pv.a) - result[bus]["b"].append(pv.b) - result[bus]["c"].append(pv.c) - return result - - -def find_violations(voltages_dict, v_min=0.95, v_max=1.05): - violations = [] - for bus, phases in voltages_dict.items(): - for phase_name, values in phases.items(): - arr = np.array(values) - valid = arr[~np.isnan(arr)] - if len(valid) == 0: - continue - vmin = float(np.min(valid)) - vmax = float(np.max(valid)) - if vmin < v_min: - violations.append((bus, phase_name, "under", vmin, v_min - vmin)) - if vmax > v_max: - violations.append((bus, phase_name, "over", vmax, vmax - v_max)) - return violations - - def optimize_taps_for_bus( *, sys: dict, From 6237be5498e54b91611238f2e2ea6a0d39951257 Mon Sep 17 00:00:00 2001 From: Zhirui Liang Date: Mon, 4 May 2026 02:16:37 -0400 Subject: [PATCH 08/13] [Revert] sweep_dc_locations.py: restore master's version --- examples/offline/sweep_dc_locations.py | 534 ++++++++++++++----------- 1 file changed, 311 insertions(+), 223 deletions(-) diff --git a/examples/offline/sweep_dc_locations.py b/examples/offline/sweep_dc_locations.py index ddd595f..c3d8259 100644 --- a/examples/offline/sweep_dc_locations.py +++ b/examples/offline/sweep_dc_locations.py @@ -31,11 +31,18 @@ import logging import math import time +from dataclasses import dataclass from fractions import Fraction from pathlib import Path +import matplotlib +import matplotlib.pyplot as plt import numpy as np import pandas as pd +from matplotlib.lines import Line2D + +matplotlib.use("Agg") + from utils import discover_candidate_buses, extract_all_voltages, find_violations from openg2g.controller.ofo import ( @@ -48,6 +55,7 @@ from openg2g.datacenter.config import ( DatacenterConfig, InferenceModelSpec, + ModelDeployment, PowerAugmentationConfig, ReplicaSchedule, TrainingRun, @@ -56,45 +64,123 @@ from openg2g.datacenter.workloads.inference import InferenceData from openg2g.datacenter.workloads.training import TrainingTrace from openg2g.grid.config import TapPosition, TapSchedule +from openg2g.grid.generator import ConstantGenerator, SyntheticPV +from openg2g.grid.load import SyntheticLoad +from openg2g.grid.opendss import OpenDSSGrid +from openg2g.metrics.performance import compute_performance_stats from openg2g.metrics.voltage import compute_allbus_voltage_stats -from systems import ( - DT_CTRL, - DT_DC, - DT_GRID, - POWER_AUG, - SPECS_CACHE_DIR, - SYSTEMS, - TAP_STEP, - TOTAL_DURATION_S, - TRAINING_TRACE_PATH, - V_MAX, - V_MIN, - DCSite, - PVSystemSpec, - ScenarioOpenDSSGrid, - TimeVaryingLoadSpec, - all_model_specs, - deploy, - tap, - with_ramp, +from plotting import plot_allbus_voltages_per_phase, plot_model_timeseries_4panel +from systems import SYSTEMS, TAP_STEP, tap + +_PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent + +DT_DC = Fraction(1, 10) +DT_GRID = Fraction(1, 10) +DT_CTRL = Fraction(1) +V_MIN, V_MAX = 0.95, 1.05 +TOTAL_DURATION_S = 3600 +POWER_AUG = PowerAugmentationConfig(amplitude_scale_range=(0.98, 1.02), noise_fraction=0.005) + +LLAMA_8B = InferenceModelSpec( + model_label="Llama-3.1-8B", + model_id="meta-llama/Llama-3.1-8B-Instruct", + gpu_model="H100", + task="lm-arena-chat", + precision="bfloat16", + gpus_per_replica=1, + tensor_parallel=1, + itl_deadline_s=0.08, + batch_sizes=(8, 16, 32, 64, 96, 128, 192, 256, 384, 512, 768, 1024), + feasible_batch_sizes=(8, 16, 32, 64, 128, 256, 512), +) +LLAMA_70B = InferenceModelSpec( + model_label="Llama-3.1-70B", + model_id="meta-llama/Llama-3.1-70B-Instruct", + gpu_model="H100", + task="lm-arena-chat", + precision="bfloat16", + gpus_per_replica=4, + tensor_parallel=4, + itl_deadline_s=0.10, + batch_sizes=(8, 16, 32, 64, 96, 128, 192, 256, 384, 512, 768, 1024, 1536, 2048), + feasible_batch_sizes=(8, 16, 32, 64, 128, 256, 512), +) +LLAMA_405B = InferenceModelSpec( + model_label="Llama-3.1-405B", + model_id="meta-llama/Llama-3.1-405B-Instruct-FP8", + gpu_model="H100", + task="lm-arena-chat", + precision="fp8", + gpus_per_replica=8, + tensor_parallel=8, + itl_deadline_s=0.12, + batch_sizes=(8, 16, 32, 64, 96, 128, 192, 256, 384, 512), + feasible_batch_sizes=(8, 16, 32, 64, 128, 256, 512), ) +QWEN_30B = InferenceModelSpec( + model_label="Qwen3-30B-A3B", + model_id="Qwen/Qwen3-30B-A3B-Thinking-2507", + gpu_model="H100", + task="gpqa", + precision="bfloat16", + gpus_per_replica=2, + tensor_parallel=2, + itl_deadline_s=0.06, + batch_sizes=(8, 16, 32, 64, 96, 128, 192, 256, 384, 512), + feasible_batch_sizes=(8, 16, 32, 64, 128, 256, 512), +) +QWEN_235B = InferenceModelSpec( + model_label="Qwen3-235B-A22B", + model_id="Qwen/Qwen3-235B-A22B-Thinking-2507", + gpu_model="H100", + task="gpqa", + precision="bfloat16", + gpus_per_replica=8, + tensor_parallel=8, + itl_deadline_s=0.14, + batch_sizes=(8, 16, 32, 64, 96, 128, 192, 256, 384, 512), + feasible_batch_sizes=(8, 16, 32, 64, 128, 256, 512), +) +ALL_MODEL_SPECS = (LLAMA_8B, LLAMA_70B, LLAMA_405B, QWEN_30B, QWEN_235B) +MODEL_SPECS = {s.model_label: s for s in ALL_MODEL_SPECS} + +SPECS_CACHE_DIR = _PROJECT_ROOT / "data" / "specs" +TRAINING_TRACE_PATH = _PROJECT_ROOT / "data" / "training_trace.csv" + + +def deploy(label, num_replicas, initial_batch_size=128): + """Shorthand: `deploy("Llama-3.1-8B", 720, 128)` -> `(ModelDeployment, ReplicaSchedule)`.""" + return ( + ModelDeployment(spec=MODEL_SPECS[label], initial_batch_size=initial_batch_size), + ReplicaSchedule(initial=num_replicas), + ) + + +@dataclass +class _DCSiteConfig: + bus: str + base_kw_per_phase: float + total_gpu_capacity: int + models: tuple[tuple[ModelDeployment, ReplicaSchedule], ...] = () + seed: int = 0 + connection_type: str = "wye" + replica_schedules: dict[str, ReplicaSchedule] | None = None + logger = logging.getLogger("sweep_dc_locations") -T_TOTAL_S = 3600 -MAX_TAP_ITERATIONS = 20 # max passes per tap-optimization scenario -# Backward-compatible aliases (used by other scripts) -PVSystemConfig = PVSystemSpec -TimeVaryingLoadConfig = TimeVaryingLoadSpec +# 1-D SWEEP (single DC site) + + +MAX_TAP_ITERATIONS = 20 def optimize_taps_for_bus( *, sys: dict, dc_bus: str, - bus_kv: float, base_kw_per_phase: float, connection_type: str, inference_data: InferenceData, @@ -106,7 +192,7 @@ def optimize_taps_for_bus( pv_systems: list | None = None, time_varying_loads: list | None = None, power_augmentation: PowerAugmentationConfig = POWER_AUG, - t_total_s: int = T_TOTAL_S, + t_total_s: int = TOTAL_DURATION_S, t_analysis_start: int = 0, v_min: float = V_MIN, v_max: float = V_MAX, @@ -143,16 +229,19 @@ def optimize_taps_for_bus( total_gpu_capacity=total_gpu_capacity, ) - grid = ScenarioOpenDSSGrid( - pv_systems=pv_systems or [], - time_varying_loads=time_varying_loads or [], - source_pu=sys["source_pu"], + grid = OpenDSSGrid( dss_case_dir=sys["dss_case_dir"], dss_master_file=sys["dss_master_file"], + source_pu=sys["source_pu"], dt_s=dt_coarse, initial_tap_position=tap_pos, + exclude_buses=sys.get("exclude_buses", []), ) grid.attach_dc(dc, bus=dc_bus, connection_type=connection_type, power_factor=dc_config.power_factor) + for bus, gen in pv_systems or []: + grid.attach_generator(gen, bus=bus) + for bus, ld in time_varying_loads or []: + grid.attach_load(ld, bus=bus) ctrl = TapScheduleController(schedule=TapSchedule(()), dt_s=dt_coarse) coord = Coordinator( @@ -215,7 +304,6 @@ def optimize_taps_multiscenario( *, sys: dict, dc_bus: str, - bus_kv: float, base_kw_per_phase: float, connection_type: str, inference_data: InferenceData, @@ -290,7 +378,6 @@ def _round_up(t: int) -> int: optimal = optimize_taps_for_bus( sys=sys, dc_bus=dc_bus, - bus_kv=bus_kv, base_kw_per_phase=base_kw_per_phase, connection_type=connection_type, inference_data=inference_data, @@ -317,7 +404,6 @@ def run_case_1d( *, sys: dict, dc_bus: str, - bus_kv: float, base_kw_per_phase: float, case_name: str, inference_data: InferenceData, @@ -363,16 +449,19 @@ def run_case_1d( total_gpu_capacity=total_gpu_capacity, ) - grid = ScenarioOpenDSSGrid( - pv_systems=pv_systems or [], - time_varying_loads=time_varying_loads or [], - source_pu=sys["source_pu"], + grid = OpenDSSGrid( dss_case_dir=sys["dss_case_dir"], dss_master_file=sys["dss_master_file"], + source_pu=sys["source_pu"], dt_s=dt_grid, initial_tap_position=initial_taps, + exclude_buses=sys.get("exclude_buses", []), ) grid.attach_dc(dc, bus=dc_bus, connection_type=connection_type, power_factor=dc_config.power_factor) + for bus, gen in pv_systems or []: + grid.attach_generator(gen, bus=bus) + for bus, ld in time_varying_loads or []: + grid.attach_load(ld, bus=bus) controllers: list = [] schedule = tap_schedule if tap_schedule else TapSchedule(()) @@ -383,10 +472,10 @@ def run_case_1d( ofo_ctrl = OFOBatchSizeController( all_models, datacenter=dc, - grid=grid, models=logistic_models, config=ofo_config, dt_s=dt_ctrl, + grid=grid, ) controllers.append(ofo_ctrl) @@ -398,7 +487,12 @@ def run_case_1d( ) log = coord.run() - stats = compute_allbus_voltage_stats(log.grid_states, v_min=v_min, v_max=v_max) + stats = compute_allbus_voltage_stats( + log.grid_states, v_min=v_min, v_max=v_max, exclude_buses=tuple(sys.get("exclude_buses", ())) + ) + pstats = compute_performance_stats( + log.dc_states, itl_deadline_s_by_model={ms.model_label: ms.itl_deadline_s for ms in all_models} + ) dc_kW = np.array([(s.power_w.a + s.power_w.b + s.power_w.c) / 3e3 for s in log.dc_states]) avg_power = float(dc_kW.mean()) if dc_kW.size > 0 else 0.0 @@ -409,15 +503,11 @@ def run_case_1d( "worst_vmax": stats.worst_vmax, "integral_violation_pu_s": stats.integral_violation_pu_s, "avg_power_kw_per_phase": avg_power, + "mean_throughput_tps": pstats.mean_throughput_tps, + "itl_deadline_fraction": pstats.itl_deadline_fraction, } if save_dir is not None: - from plot_all_figures import ( - extract_per_model_timeseries, - plot_allbus_voltages_per_phase, - plot_model_timeseries_4panel, - ) - save_dir.mkdir(parents=True, exist_ok=True) time_s = np.array(log.time_s) plot_allbus_voltages_per_phase( @@ -426,13 +516,11 @@ def run_case_1d( save_dir=save_dir, v_min=v_min, v_max=v_max, - title_template=f"DC@{dc_bus} {case_name} — Voltage (Phase {{label}})", + title_template=f"DC@{dc_bus} {case_name} -- Voltage (Phase {{label}})", ) if use_ofo: - per_model = extract_per_model_timeseries(log.dc_states) plot_model_timeseries_4panel( - per_model.time_s, - per_model, + log.dc_states, model_labels=[m.model_label for m in all_models], regime_shading=False, save_path=save_dir / "OFO_results.png", @@ -442,8 +530,6 @@ def run_case_1d( def _plot_bus_comparison(all_rows: list[dict], save_path: Path) -> None: - import matplotlib.pyplot as plt - buses = [r["dc_bus"] for r in all_rows] cases = [ ("baseline_no_tap", "Baseline (no tap)"), @@ -488,7 +574,7 @@ def main_1d( *, sys: dict, system: str, - dc_site: DCSite, + dc_site: _DCSiteConfig, all_models: tuple[InferenceModelSpec, ...], inference_data: InferenceData, training_trace: TrainingTrace, @@ -512,7 +598,7 @@ def main_1d( ) -> None: """1-D sweep: single DC site swept across candidate buses.""" if replica_schedules is None: - replica_schedules = {md.spec.model_label: sched for md, sched in dc_site.models} + replica_schedules = {md.spec.model_label: s for md, s in dc_site.models} site_gpu_capacity = dc_site.total_gpu_capacity dt_dc = DT_DC @@ -534,11 +620,11 @@ def main_1d( candidate_buses = buses logger.info("Using user-specified buses: %s", candidate_buses) else: - logger.info("Discovering candidate 3-phase buses at %.2f kV...", dc_site.bus_kv) + logger.info("Discovering candidate 3-phase buses at %.2f kV...", sys["bus_kv"]) candidate_buses = discover_candidate_buses( sys["dss_case_dir"], sys["dss_master_file"], - dc_site.bus_kv, + sys["bus_kv"], exclude=set(sys["exclude_buses"]), ) logger.info("Found %d candidate buses: %s", len(candidate_buses), candidate_buses) @@ -563,7 +649,6 @@ def main_1d( taps = optimize_taps_multiscenario( sys=sys, dc_bus=dc_bus, - bus_kv=dc_site.bus_kv, base_kw_per_phase=dc_site.base_kw_per_phase, connection_type=dc_site.connection_type, inference_data=inference_data, @@ -649,7 +734,7 @@ def main_1d( for case_name, use_ofo, use_tap_change in cases: case_idx += 1 logger.info("") - logger.info("[%d/%d] Bus %s — %s", case_idx, total_cases, dc_bus, case_name) + logger.info("[%d/%d] Bus %s -- %s", case_idx, total_cases, dc_bus, case_name) case_save_dir = save_dir / f"bus_{dc_bus}" / case_name @@ -657,7 +742,6 @@ def main_1d( result = run_case_1d( sys=sys, dc_bus=dc_bus, - bus_kv=dc_site.bus_kv, base_kw_per_phase=dc_site.base_kw_per_phase, case_name=case_name, inference_data=inference_data, @@ -693,7 +777,7 @@ def main_1d( for k, v in result.items(): bus_results[f"{case_name}_{k}"] = v except Exception as e: - logger.error(" Bus %s — %s FAILED: %s", dc_bus, case_name, e) + logger.error(" Bus %s -- %s FAILED: %s", dc_bus, case_name, e) for k in [ "violation_time_s", "worst_vmin", @@ -754,14 +838,10 @@ def main_1d( logger.info("All outputs saved to: %s", save_dir) -# ══════════════════════════════════════════════════════════════════════════════ # 2-D SWEEP (multiple DC sites) -# ══════════════════════════════════════════════════════════════════════════════ def _plot_heatmaps(df: pd.DataFrame, buses: list[str], save_dir: Path) -> None: - import matplotlib.pyplot as plt - n = len(buses) bus_idx = {b: i for i, b in enumerate(buses)} @@ -813,7 +893,7 @@ def main_2d( *, sys: dict, system: str, - dc_sites: dict[str, DCSite], + dc_sites: dict[str, _DCSiteConfig], all_models: tuple[InferenceModelSpec, ...], inference_data: InferenceData, training_trace: TrainingTrace, @@ -852,7 +932,7 @@ def main_2d( site_A = dc_sites[site_A_id] site_B = dc_sites[site_B_id] - target_kv = site_A.bus_kv + target_kv = sys["bus_kv"] logger.info("Discovering candidate 3-phase buses at %.1f kV...", target_kv) candidate_buses = discover_candidate_buses( @@ -890,14 +970,14 @@ def main_2d( dc_config_A = DatacenterConfig(gpus_per_server=8, base_kw_per_phase=site_A.base_kw_per_phase) dc_config_B = DatacenterConfig(gpus_per_server=8, base_kw_per_phase=site_B.base_kw_per_phase) - schedules_A = {md.spec.model_label: sched for md, sched in site_A.models} - schedules_B = {md.spec.model_label: sched for md, sched in site_B.models} + rs_A = site_A.replica_schedules or {md.spec.model_label: s for md, s in site_A.models} + rs_B = site_B.replica_schedules or {md.spec.model_label: s for md, s in site_B.models} - wl_kwargs_A: dict = {"inference_data": inference_A, "replica_schedules": schedules_A} + wl_kwargs_A: dict = {"inference_data": inference_A, "replica_schedules": rs_A} if training is not None: wl_kwargs_A["training"] = training - wl_kwargs_B: dict = {"inference_data": inference_B, "replica_schedules": schedules_B} + wl_kwargs_B: dict = {"inference_data": inference_B, "replica_schedules": rs_B} if training is not None: wl_kwargs_B["training"] = training @@ -920,14 +1000,10 @@ def main_2d( total_gpu_capacity=site_B.total_gpu_capacity, ) - datacenters = {site_A_id: dc_A, site_B_id: dc_B} - - grid = ScenarioOpenDSSGrid( - pv_systems=pv_systems or [], - time_varying_loads=time_varying_loads or [], - source_pu=sys["source_pu"], + grid = OpenDSSGrid( dss_case_dir=sys["dss_case_dir"], dss_master_file=sys["dss_master_file"], + source_pu=sys["source_pu"], dt_s=dt_grid, initial_tap_position=initial_taps, exclude_buses=exclude_buses, @@ -938,26 +1014,30 @@ def main_2d( grid.attach_dc( dc_B, bus=bus_B, connection_type=site_B.connection_type, power_factor=dc_config_B.power_factor ) + for bus, gen in pv_systems or []: + grid.attach_generator(gen, bus=bus) + for bus, ld in time_varying_loads or []: + grid.attach_load(ld, bus=bus) ofo_A = OFOBatchSizeController( specs_A, datacenter=dc_A, - grid=grid, models=logistic_models, config=ofo_config, dt_s=dt_ctrl, + grid=grid, ) ofo_B = OFOBatchSizeController( specs_B, datacenter=dc_B, - grid=grid, models=logistic_models, config=ofo_config, dt_s=dt_ctrl, + grid=grid, ) coord = Coordinator( - datacenters=list(datacenters.values()), + datacenters=[dc_A, dc_B], grid=grid, controllers=[ofo_A, ofo_B], total_duration_s=total_duration_s, @@ -973,6 +1053,10 @@ def main_2d( v_max=v_max, exclude_buses=exclude_buses, ) + pstats = compute_performance_stats( + log.dc_states, + itl_deadline_s_by_model={ms.model_label: ms.itl_deadline_s for ms in all_models}, + ) row = { "bus_A": bus_A, @@ -981,14 +1065,18 @@ def main_2d( "integral_violation_pu_s": vstats.integral_violation_pu_s, "worst_vmin": vstats.worst_vmin, "worst_vmax": vstats.worst_vmax, + "mean_throughput_tps": pstats.mean_throughput_tps, + "itl_deadline_fraction": pstats.itl_deadline_fraction, "wall_time_s": wall_s, } rows.append(row) logger.info( - " -> viol=%.1fs integral=%.4f pu·s vmin=%.4f wall=%.1fs", + " -> viol=%.1fs integral=%.4f pu·s vmin=%.4f thpt=%.1f k tok/s itl_miss=%.2f%% wall=%.1fs", vstats.violation_time_s, vstats.integral_violation_pu_s, vstats.worst_vmin, + pstats.mean_throughput_tps / 1e3, + pstats.itl_deadline_fraction * 100.0, wall_s, ) @@ -1009,16 +1097,14 @@ def main_2d( logger.info("All outputs in: %s", save_dir) -# ══════════════════════════════════════════════════════════════════════════════ # ZONE-CONSTRAINED SWEEP (N DC sites, each in its own zone) -# ══════════════════════════════════════════════════════════════════════════════ def _run_multi_dc_case( *, bus_map: dict[str, str], site_ids: list[str], - dc_sites: dict[str, DCSite], + dc_sites: dict[str, _DCSiteConfig], site_inference_data: dict[str, InferenceData], sys: dict, training_trace: TrainingTrace, @@ -1055,27 +1141,28 @@ def _run_multi_dc_case( gpus_per_server=8, base_kw_per_phase=site.base_kw_per_phase, ) - # Schedules combine the (initial, ramps) pair on each (md, sched). - replica_schedules: dict[str, ReplicaSchedule] = {md.spec.model_label: sched for md, sched in site.models} + rs = site.replica_schedules or {md.spec.model_label: s for md, s in site.models} + wl_kwargs: dict = {"inference_data": site_inference_data[sid]} + if stress_test: # Immediate per-model ramps to fill total GPU capacity - current_gpus = sum(sched.initial * md.spec.gpus_per_replica for md, sched in site.models) + current_gpus = sum(s.initial * md.spec.gpus_per_replica for md, s in site.models) total_cap = site.total_gpu_capacity or current_gpus scale = total_cap / current_gpus if current_gpus > 0 else 1.0 if scale > 1.0: - replica_schedules = { - md.spec.model_label: ReplicaSchedule(initial=sched.initial).ramp_to( - round(scale * sched.initial), - t_start=0, - t_end=1, + stress_rs = {} + for md, s in site.models: + abs_target = round(scale * s.initial) + stress_rs[md.spec.model_label] = ReplicaSchedule(initial=s.initial).ramp_to( + abs_target, t_start=0, t_end=1 ) - for md, sched in site.models - } - elif training is not None: - pass # training handled below - wl_kwargs: dict = {"inference_data": site_inference_data[sid], "replica_schedules": replica_schedules} - if not stress_test and training is not None: - wl_kwargs["training"] = training + wl_kwargs["replica_schedules"] = stress_rs + else: + wl_kwargs["replica_schedules"] = rs + else: + wl_kwargs["replica_schedules"] = rs + if training is not None: + wl_kwargs["training"] = training dc = OfflineDatacenter( dc_config, @@ -1088,43 +1175,49 @@ def _run_multi_dc_case( ) datacenters[sid] = dc - # Build grid then attach DCs (master's imperative pattern). - grid = ScenarioOpenDSSGrid( - pv_systems=pv_systems or [], - time_varying_loads=[] if stress_test else (time_varying_loads or []), - source_pu=sys["source_pu"], - constant_pv=stress_test, + # Build grid + grid = OpenDSSGrid( dss_case_dir=sys["dss_case_dir"], dss_master_file=sys["dss_master_file"], + source_pu=sys["source_pu"], dt_s=dt_grid, initial_tap_position=initial_taps, exclude_buses=exclude_buses, ) - dc_pf = DatacenterConfig().power_factor for sid in site_ids: grid.attach_dc( datacenters[sid], bus=bus_map[sid], connection_type=dc_sites[sid].connection_type, - power_factor=dc_pf, + power_factor=DatacenterConfig().power_factor, ) + if stress_test: + # Constant PV at peak power, no time-varying loads + for bus, gen in pv_systems or []: + grid.attach_generator(ConstantGenerator(gen._peak_kw), bus=bus) + else: + for bus, gen in pv_systems or []: + grid.attach_generator(gen, bus=bus) + for bus, ld in time_varying_loads or []: + grid.attach_load(ld, bus=bus) # Build OFO controllers + dc_list = list(datacenters.values()) controllers = [] for sid in site_ids: site_specs = tuple(md.spec for md, _ in dc_sites[sid].models) ofo_ctrl = OFOBatchSizeController( site_specs, datacenter=datacenters[sid], - grid=grid, models=logistic_models, config=ofo_config, dt_s=dt_ctrl, + grid=grid, ) controllers.append(ofo_ctrl) coord = Coordinator( - datacenters=list(datacenters.values()), + datacenters=dc_list, grid=grid, controllers=controllers, total_duration_s=duration, @@ -1140,6 +1233,11 @@ def _run_multi_dc_case( v_max=v_max, exclude_buses=exclude_buses, ) + all_site_specs = tuple(ms for sid in site_ids for md, _ in dc_sites[sid].models for ms in (md.spec,)) + pstats = compute_performance_stats( + log.dc_states, + itl_deadline_s_by_model={ms.model_label: ms.itl_deadline_s for ms in all_site_specs}, + ) row: dict = {} for sid in site_ids: @@ -1150,6 +1248,8 @@ def _run_multi_dc_case( "integral_violation_pu_s": vstats.integral_violation_pu_s, "worst_vmin": vstats.worst_vmin, "worst_vmax": vstats.worst_vmax, + "mean_throughput_tps": pstats.mean_throughput_tps, + "itl_deadline_fraction": pstats.itl_deadline_fraction, "wall_time_s": wall_s, } ) @@ -1191,8 +1291,6 @@ def _plot_zoned_summary( suffix: str = "", ) -> None: """Plot bar chart of zone-constrained sweep results.""" - import matplotlib.pyplot as plt - n_show = min(20, len(df)) df_top = df.head(n_show) @@ -1205,13 +1303,13 @@ def _plot_zoned_summary( ax1.bar(x, df_top["violation_time_s"].values, color="steelblue") ax1.set_ylabel("Violation Time (s)") title_tag = f" ({suffix})" if suffix else "" - ax1.set_title(f"{system.upper()} DC Location Sweep{title_tag} — Violation Time (top {n_show})") + ax1.set_title(f"{system.upper()} DC Location Sweep{title_tag} -- Violation Time (top {n_show})") ax1.set_xticks(x) ax1.set_xticklabels(labels, rotation=45, ha="right", fontsize=8) ax2.bar(x, df_top["integral_violation_pu_s"].values, color="coral") ax2.set_ylabel("Integral Violation (pu·s)") - ax2.set_title(f"{system.upper()} DC Location Sweep{title_tag} — Integral Violation (top {n_show})") + ax2.set_title(f"{system.upper()} DC Location Sweep{title_tag} -- Integral Violation (top {n_show})") ax2.set_xticks(x) ax2.set_xticklabels(labels, rotation=45, ha="right", fontsize=8) @@ -1232,8 +1330,6 @@ def _plot_screening_bars( system: str, ) -> None: """Plot per-zone screening results as grouped bar charts.""" - import matplotlib.pyplot as plt - n_zones = len(screening_results) fig, axes = plt.subplots(1, n_zones, figsize=(5 * n_zones, 5), squeeze=False) @@ -1270,8 +1366,6 @@ def _plot_refinement_iteration( Marks the previous best bus (from the prior iteration or Phase 2) with a diamond marker and the new best bus (from this iteration) with a star. """ - import matplotlib.pyplot as plt - n_zones = len(site_ids) fig, axes = plt.subplots(2, n_zones, figsize=(5 * n_zones, 8), squeeze=False) @@ -1317,13 +1411,11 @@ def _plot_refinement_iteration( ) ax.set_ylabel(ylabel) - ax.set_title(f"Zone '{sid}' — {title_suffix}") + ax.set_title(f"Zone '{sid}' -- {title_suffix}") ax.set_xticks(x) ax.set_xticklabels(buses, rotation=45, ha="right", fontsize=9) # Add legend for markers - from matplotlib.lines import Line2D - legend_handles = [ Line2D([0], [0], marker="D", color="w", markerfacecolor="black", markersize=8, label="Previous best"), Line2D( @@ -1340,7 +1432,7 @@ def _plot_refinement_iteration( fig.legend(handles=legend_handles, loc="upper right", fontsize=10, framealpha=0.9) fig.suptitle( - f"{system.upper()} Phase 3 Refinement — Iteration {iteration}", + f"{system.upper()} Phase 3 Refinement -- Iteration {iteration}", fontsize=14, y=1.01, ) @@ -1355,7 +1447,7 @@ def main_zoned( *, sys: dict, system: str, - dc_sites: dict[str, DCSite], + dc_sites: dict[str, _DCSiteConfig], all_models: tuple[InferenceModelSpec, ...], inference_data: InferenceData, training_trace: TrainingTrace, @@ -1380,7 +1472,7 @@ def main_zoned( Three-phase design with different resolution/duration per phase: Phase 1 (Screening): Sweep each zone independently while holding other zones - at their default config buses. Uses coarse resolution (``dt_screening``, + at their default config buses. Uses coarse resolution (`dt_screening`, default 60 s) over the full simulation duration (typically 3600 s) for fast ranking. Keep top-K per zone. Phase 2 (Combination): Cartesian product of top-K per zone. Uses native @@ -1418,8 +1510,7 @@ def main_zoned( site_ids = list(dc_sites.keys()) # Discover all 3-phase candidate buses - ref_site = next(iter(dc_sites.values())) - target_kv = ref_site.bus_kv + target_kv = sys["bus_kv"] logger.info("Discovering candidate 3-phase buses at %.1f kV...", target_kv) all_candidate_buses = set( @@ -1500,10 +1591,10 @@ def main_zoned( # Default bus map from config default_bus_map = {sid: dc_sites[sid].bus for sid in site_ids} - # ── Phase 1: Screening ─────────────────────────────────────────────────── + # Phase 1: Screening logger.info("") logger.info("=" * 80) - logger.info("PHASE 1: SCREENING — sweep each zone independently") + logger.info("PHASE 1: SCREENING -- sweep each zone independently") logger.info("=" * 80) logger.info("Default bus map: %s", default_bus_map) @@ -1579,14 +1670,14 @@ def main_zoned( for sid, buses_list in top_k_per_zone.items(): logger.info(" %s top-%d: %s", sid, len(buses_list), buses_list) - # ── Phase 2: Combination (1-min stress test: constant PV + full DC) ────── + # Phase 2: Combination (1-min stress test: constant PV + full DC) # Duration must yield at least 2 grid steps; ensure >= 2 x dt_grid. min_duration = int(math.ceil(float(dt_grid) * 2)) phase2_duration_s = max(60, min_duration) logger.info("") logger.info("=" * 80) logger.info( - "PHASE 2: COMBINATION — Cartesian product of top-%d per zone " + "PHASE 2: COMBINATION -- Cartesian product of top-%d per zone " "(%ds stress test: constant PV + full DC capacity, no time-varying loads)", top_k, phase2_duration_s, @@ -1672,7 +1763,7 @@ def main_zoned( best_row["worst_vmax"], ) - # ── Phase 3: Refinement (optional) ─────────────────────────────────────── + # Phase 3: Refinement (optional) if not refine: logger.info("") logger.info("Phase 3 (refinement) skipped. Use --refine to enable.") @@ -1682,7 +1773,7 @@ def main_zoned( logger.info("") logger.info("=" * 80) - logger.info("PHASE 3: REFINEMENT — re-sweep each zone from Phase 2 best") + logger.info("PHASE 3: REFINEMENT -- re-sweep each zone from Phase 2 best") logger.info("=" * 80) current_best = dict(best_bus_map) @@ -1819,12 +1910,10 @@ def main_zoned( logger.info("All outputs in: %s", save_dir) -# ══════════════════════════════════════════════════════════════════════════════ # Per-system experiment definitions -# ══════════════════════════════════════════════════════════════════════════════ -def _experiment_ieee13(sys_const, training_trace): +def setup_ieee13(sys_const, training_trace): """IEEE 13-bus: single DC at bus 671 with training overlay.""" models = ( deploy("Llama-3.1-8B", 720), @@ -1834,28 +1923,22 @@ def _experiment_ieee13(sys_const, training_trace): deploy("Qwen3-235B-A22B", 210), ) - # Per-model ramps: target = round(fraction * initial) with fraction=0.2. - # Inject each ramp into its model's ReplicaSchedule via with_ramp() — - # replaces the old single OR-chained InferenceRampSchedule. - ramp_targets = { - "Llama-3.1-8B": 144, - "Llama-3.1-70B": 36, - "Llama-3.1-405B": 18, - "Qwen3-30B-A3B": 96, - "Qwen3-235B-A22B": 42, + replica_schedules = { + "Llama-3.1-8B": ReplicaSchedule(initial=720).ramp_to(360, t_start=2500, t_end=3000), + "Llama-3.1-70B": ReplicaSchedule(initial=180).ramp_to(90, t_start=2500, t_end=3000), + "Llama-3.1-405B": ReplicaSchedule(initial=90).ramp_to(45, t_start=2500, t_end=3000), + "Qwen3-30B-A3B": ReplicaSchedule(initial=480).ramp_to(240, t_start=2500, t_end=3000), + "Qwen3-235B-A22B": ReplicaSchedule(initial=210).ramp_to(105, t_start=2500, t_end=3000), } - models = tuple( - with_ramp((md, sched), ramp_targets[md.spec.model_label], t_start=2500, t_end=3000) for md, sched in models - ) dc_sites = { - "default": DCSite( + "default": _DCSiteConfig( bus="671", - bus_kv=sys_const["bus_kv"], base_kw_per_phase=500.0, models=models, seed=0, total_gpu_capacity=7200, + replica_schedules=replica_schedules, ), } @@ -1887,8 +1970,8 @@ def _experiment_ieee13(sys_const, training_trace): (3300, TapPosition(regulators={"creg1a": tap(10), "creg1b": tap(6), "creg1c": tap(10)})), ] - pv_systems = [PVSystemSpec(bus="675", bus_kv=4.16, peak_kw=10.0)] - time_varying_loads = [TimeVaryingLoadSpec(bus="680", bus_kv=4.16, peak_kw=10.0)] + pv_systems = [("675", SyntheticPV(peak_kw=10.0))] + time_varying_loads = [("680", SyntheticLoad(peak_kw=10.0))] return dict( dc_sites=dc_sites, @@ -1903,7 +1986,7 @@ def _experiment_ieee13(sys_const, training_trace): ) -def _experiment_ieee34(sys_const, training_trace): +def setup_ieee34(sys_const, training_trace): """IEEE 34-bus: two DC sites (upstream + downstream).""" upstream_models = ( deploy("Llama-3.1-8B", 720), @@ -1916,17 +1999,15 @@ def _experiment_ieee34(sys_const, training_trace): ) dc_sites = { - "upstream": DCSite( + "upstream": _DCSiteConfig( bus="850", - bus_kv=sys_const["bus_kv"], base_kw_per_phase=120.0, models=upstream_models, seed=0, total_gpu_capacity=520, ), - "downstream": DCSite( + "downstream": _DCSiteConfig( bus="834", - bus_kv=sys_const["bus_kv"], base_kw_per_phase=80.0, models=downstream_models, seed=42, @@ -1948,15 +2029,15 @@ def _experiment_ieee34(sys_const, training_trace): ) pv_systems = [ - PVSystemSpec(bus="848", bus_kv=24.9, peak_kw=130.0), - PVSystemSpec(bus="830", bus_kv=24.9, peak_kw=65.0), + ("848", SyntheticPV(peak_kw=130.0, site_idx=0)), + ("830", SyntheticPV(peak_kw=65.0, site_idx=1)), ] time_varying_loads = [ - TimeVaryingLoadSpec(bus="860", bus_kv=24.9, peak_kw=80.0), - TimeVaryingLoadSpec(bus="844", bus_kv=24.9, peak_kw=120.0), - TimeVaryingLoadSpec(bus="840", bus_kv=24.9, peak_kw=60.0), - TimeVaryingLoadSpec(bus="858", bus_kv=24.9, peak_kw=50.0), - TimeVaryingLoadSpec(bus="854", bus_kv=24.9, peak_kw=40.0), + ("860", SyntheticLoad(peak_kw=80.0, site_idx=0)), + ("844", SyntheticLoad(peak_kw=120.0, site_idx=1)), + ("840", SyntheticLoad(peak_kw=60.0, site_idx=2)), + ("858", SyntheticLoad(peak_kw=50.0, site_idx=3)), + ("854", SyntheticLoad(peak_kw=40.0, site_idx=4)), ] return dict( @@ -1968,47 +2049,49 @@ def _experiment_ieee34(sys_const, training_trace): ) -def _experiment_ieee123(sys_const, training_trace): +def setup_ieee123(sys_const, training_trace): """IEEE 123-bus: four DC zones with per-site ramps.""" dc_sites = { - "z1_sw": DCSite( + "z1_sw": _DCSiteConfig( bus="8", - bus_kv=sys_const["bus_kv"], base_kw_per_phase=310.0, - # 8B: round(1.5 * 120) = 180 - models=(with_ramp(deploy("Llama-3.1-8B", 120), 180, t_start=500, t_end=1000),), + models=(deploy("Llama-3.1-8B", 120),), seed=0, total_gpu_capacity=120, + replica_schedules={ + "Llama-3.1-8B": ReplicaSchedule(initial=120).ramp_to(180, t_start=500, t_end=1000), + }, ), - "z2_nw": DCSite( + "z2_nw": _DCSiteConfig( bus="23", - bus_kv=sys_const["bus_kv"], base_kw_per_phase=265.0, - # 30B: round(1.3 * 80) = 104 - models=(with_ramp(deploy("Qwen3-30B-A3B", 80), 104, t_start=1500, t_end=2500),), + models=(deploy("Qwen3-30B-A3B", 80),), seed=17, total_gpu_capacity=160, + replica_schedules={ + "Qwen3-30B-A3B": ReplicaSchedule(initial=80).ramp_to(104, t_start=1500, t_end=2500), + }, ), - "z3_se": DCSite( + "z3_se": _DCSiteConfig( bus="60", - bus_kv=sys_const["bus_kv"], base_kw_per_phase=295.0, - # 70B: round(1.5 * 30) = 45, 405B: round(1.5 * 35) = 52 - models=( - with_ramp(deploy("Llama-3.1-70B", 30), 45, t_start=700, t_end=1100), - with_ramp(deploy("Llama-3.1-405B", 35), 52, t_start=700, t_end=1100), - ), + models=(deploy("Llama-3.1-70B", 30), deploy("Llama-3.1-405B", 35)), seed=34, total_gpu_capacity=400, + replica_schedules={ + "Llama-3.1-70B": ReplicaSchedule(initial=30).ramp_to(45, t_start=700, t_end=1100), + "Llama-3.1-405B": ReplicaSchedule(initial=35).ramp_to(52, t_start=700, t_end=1100), + }, ), - "z4_ne": DCSite( + "z4_ne": _DCSiteConfig( bus="105", - bus_kv=sys_const["bus_kv"], base_kw_per_phase=325.0, - # 235B: round(0.5 * 55) = 27 - models=(with_ramp(deploy("Qwen3-235B-A22B", 55), 27, t_start=2000, t_end=2500),), + models=(deploy("Qwen3-235B-A22B", 55),), seed=51, total_gpu_capacity=440, + replica_schedules={ + "Qwen3-235B-A22B": ReplicaSchedule(initial=55).ramp_to(27, t_start=2000, t_end=2500), + }, ), } @@ -2026,9 +2109,9 @@ def _experiment_ieee123(sys_const, training_trace): ) pv_systems = [ - PVSystemSpec(bus="1", bus_kv=4.16, peak_kw=333.3), - PVSystemSpec(bus="48", bus_kv=4.16, peak_kw=333.3), - PVSystemSpec(bus="99", bus_kv=4.16, peak_kw=333.3), + ("1", SyntheticPV(peak_kw=333.3, site_idx=0)), + ("48", SyntheticPV(peak_kw=333.3, site_idx=1)), + ("99", SyntheticPV(peak_kw=333.3, site_idx=2)), ] return dict( @@ -2041,16 +2124,14 @@ def _experiment_ieee123(sys_const, training_trace): ) -_EXPERIMENTS = { - "ieee13": _experiment_ieee13, - "ieee34": _experiment_ieee34, - "ieee123": _experiment_ieee123, +SETUPS = { + "ieee13": setup_ieee13, + "ieee34": setup_ieee34, + "ieee123": setup_ieee123, } -# ══════════════════════════════════════════════════════════════════════════════ # MAIN ENTRY POINT -# ══════════════════════════════════════════════════════════════════════════════ def main( @@ -2065,8 +2146,8 @@ def main( ) -> None: sys_const = SYSTEMS[system]() - # Pre-load data using all model specs (per-spec content-addressed cache). - all_models = all_model_specs() + # Load data pipeline + all_models = ALL_MODEL_SPECS logger.info("Loading data for %s...", system) inference_data = InferenceData.ensure( @@ -2083,12 +2164,12 @@ def main( ) # Build experiment config - experiment_fn = _EXPERIMENTS[system] - experiment = experiment_fn(sys_const, training_trace) + setup_fn = SETUPS[system] + config = setup_fn(sys_const, training_trace) - dc_sites = experiment["dc_sites"] + dc_sites = config["dc_sites"] n_sites = len(dc_sites) - has_zones = experiment.get("zones") is not None and len(experiment.get("zones", {})) > 0 + has_zones = config.get("zones") is not None and len(config.get("zones", {})) > 0 # Collect all model specs from DC sites for the experiment exp_all_models: list[InferenceModelSpec] = [] @@ -2104,7 +2185,7 @@ def main( logger.info( "Config has %d DC site(s) with %d zone(s) -> zone-constrained sweep", n_sites, - len(experiment["zones"]), + len(config["zones"]), ) main_zoned( sys=sys_const, @@ -2113,12 +2194,12 @@ def main( all_models=exp_all_models_tuple, inference_data=inference_data, training_trace=training_trace, - training=experiment.get("training"), + training=config.get("training"), logistic_models=logistic_models, - ofo_config=experiment["ofo_config"], - zones=experiment["zones"], - pv_systems=experiment.get("pv_systems"), - time_varying_loads=experiment.get("time_varying_loads"), + ofo_config=config["ofo_config"], + zones=config["zones"], + pv_systems=config.get("pv_systems"), + time_varying_loads=config.get("time_varying_loads"), dt_override=dt_override, dt_screening=dt_screening, output_dir=output_dir, @@ -2136,15 +2217,15 @@ def main( inference_data=inference_data, training_trace=training_trace, logistic_models=logistic_models, - ofo_config=experiment["ofo_config"], - training=experiment.get("training"), - replica_schedules={md.spec.model_label: sched for md, sched in dc_site.models}, - tap_schedule_entries=experiment.get("tap_schedule_entries"), - training_t_start=experiment.get("training_t_start", 0), - training_t_end=experiment.get("training_t_end", 0), - ramp_t_end=experiment.get("ramp_t_end", 0), - pv_systems=experiment.get("pv_systems"), - time_varying_loads=experiment.get("time_varying_loads"), + ofo_config=config["ofo_config"], + training=config.get("training"), + replica_schedules=dc_site.replica_schedules, + tap_schedule_entries=config.get("tap_schedule_entries"), + training_t_start=config.get("training_t_start", 0), + training_t_end=config.get("training_t_end", 0), + ramp_t_end=config.get("ramp_t_end", 0), + pv_systems=config.get("pv_systems"), + time_varying_loads=config.get("time_varying_loads"), buses=buses, dt_override=dt_override, output_dir=output_dir, @@ -2159,10 +2240,10 @@ def main( inference_data=inference_data, training_trace=training_trace, logistic_models=logistic_models, - ofo_config=experiment["ofo_config"], - training=experiment.get("training"), - pv_systems=experiment.get("pv_systems"), - time_varying_loads=experiment.get("time_varying_loads"), + ofo_config=config["ofo_config"], + training=config.get("training"), + pv_systems=config.get("pv_systems"), + time_varying_loads=config.get("time_varying_loads"), dt_override=dt_override, output_dir=output_dir, ) @@ -2175,22 +2256,29 @@ def main( @dataclass class Args: + """Command-line arguments. + + Attributes: + system: System name (ieee13, ieee34, ieee123). + buses: Comma-separated list of buses to test (overrides auto-discovery, 1-D only). + dt: Override time step for all components (e.g. '60' for 60s resolution). + dt_screening: Coarser time step for Phase 1 screening only (e.g. '60'). + Phases 2/3 use --dt or config. + top_k: Number of top candidates per zone to keep after screening + (zone-constrained mode). + refine: Enable Phase 3 iterative refinement (zone-constrained mode). + output_dir: Override output directory path. + log_level: Logging verbosity (DEBUG, INFO, WARNING). + """ + system: str = "ieee13" - """System name (ieee13, ieee34, ieee123).""" buses: str | None = None - """Comma-separated list of buses to test (overrides auto-discovery, 1-D only).""" dt: str | None = None - """Override time step for all components (e.g. '60' for 60s resolution).""" dt_screening: str | None = None - """Coarser time step for Phase 1 screening only (e.g. '60'). Phases 2/3 use --dt or config.""" top_k: int = 4 - """Number of top candidates per zone to keep after screening (zone-constrained mode).""" refine: bool = False - """Enable Phase 3 iterative refinement (zone-constrained mode).""" output_dir: str | None = None - """Override output directory path.""" log_level: str = "INFO" - """Logging verbosity (DEBUG, INFO, WARNING).""" args = tyro.cli(Args) From 1540fea97605a3f7febe39c11af0aa61fe3c00b4 Mon Sep 17 00:00:00 2001 From: Zhirui Liang Date: Mon, 4 May 2026 10:57:10 -0400 Subject: [PATCH 09/13] For ieee13, generate additional overvoltage cases for train/test --- examples/offline/build_scenario_library.py | 188 ++++++++++++++++++++- openg2g/controller/ppo.py | 56 +++--- 2 files changed, 215 insertions(+), 29 deletions(-) diff --git a/examples/offline/build_scenario_library.py b/examples/offline/build_scenario_library.py index 2eacc18..d335682 100644 --- a/examples/offline/build_scenario_library.py +++ b/examples/offline/build_scenario_library.py @@ -383,7 +383,7 @@ def _find_vecnormalize(model_file: Path) -> Path | None: for ctrl in controllers: if isinstance(ctrl, SharedPPOBatchSizeController): - ctrl.attach_datacenters(list(datacenters.values())) + ctrl.attach_datacenters(datacenters) logger.info("Running %s...", mode) log = coord.run() @@ -1445,6 +1445,87 @@ def main( logger.info(" baseline integral (mean ± std): %.2f ± %.2f", bls.mean(), bls.std()) +# ── Optional supplement-phase helpers (used by the CLI when --n-supplement-candidates > 0) ── + + +def _is_pure_overvoltage(rec) -> bool: + """Classify a ScenarioRecord as pure-overvoltage (over > 0, under == 0). + + Uses the saved per-record fields when present (newer builds); falls back to + the load_scale heuristic for old builds where the fields weren't written. + """ + if "bl_overvoltage_time_s" in rec.__dict__: + return rec.bl_overvoltage_time_s > 0 and rec.bl_undervoltage_time_s == 0 + return getattr(rec, "load_scale", 99.0) <= 1.0 # rough proxy: low load implies overvoltage + + +def _merge_supplement_into_base( + base_pkl: Path, + supp_pkl: Path, + swap_undervoltage_for_overvoltage: int = 0, +) -> None: + """Merge pure-overvoltage scenarios from a supplement library into a base library. + + Train mode (swap_undervoltage_for_overvoltage = 0): append ALL pure-overvoltage + scenarios from the supplement to the base. Library size grows. + + Test mode (swap_undervoltage_for_overvoltage > 0): take up to N pure-overvoltage + from the supplement, remove the same number of undervoltage-only scenarios + (highest load_scale first) from the base. Library size unchanged. + """ + if not base_pkl.exists(): + raise FileNotFoundError(f"--n-supplement-candidates set but base library missing: {base_pkl}") + if not supp_pkl.exists(): + raise FileNotFoundError(f"supplement library missing: {supp_pkl}") + + with open(base_pkl, "rb") as f: + base_lib = pickle.load(f) + with open(supp_pkl, "rb") as f: + supp_lib = pickle.load(f) + + pure_over = [r for r in supp_lib["scenarios"] if _is_pure_overvoltage(r)] + base_seeds = {r.seed for r in base_lib["scenarios"]} + pure_over_new = [r for r in pure_over if r.seed not in base_seeds] + logger.info( + "Supplement merge: %d/%d supplement scenarios are pure-overvoltage (%d new vs base)", + len(pure_over), + len(supp_lib["scenarios"]), + len(pure_over_new), + ) + + if swap_undervoltage_for_overvoltage <= 0: + new_scenarios = list(base_lib["scenarios"]) + pure_over_new + else: + n_take = min(swap_undervoltage_for_overvoltage, len(pure_over_new)) + recs = list(base_lib["scenarios"]) + under_only_idx = [ + i + for i, r in enumerate(recs) + if getattr(r, "bl_undervoltage_time_s", None) is not None + and r.bl_undervoltage_time_s > 0 + and getattr(r, "bl_overvoltage_time_s", 0) == 0 + ] + if not under_only_idx: + under_only_idx = [i for i, r in enumerate(recs) if getattr(r, "load_scale", 0) > 2.0] + under_only_idx.sort(key=lambda i: getattr(recs[i], "load_scale", 0), reverse=True) + remove_idx = set(under_only_idx[:n_take]) + if remove_idx: + ls_min = min(getattr(recs[i], "load_scale", 0) for i in remove_idx) + ls_max = max(getattr(recs[i], "load_scale", 0) for i in remove_idx) + logger.info( + " test-set swap: removing %d undervoltage-only scenarios (load_scale %.2f-%.2f)", + len(remove_idx), + ls_min, + ls_max, + ) + new_scenarios = [r for i, r in enumerate(recs) if i not in remove_idx] + pure_over_new[:n_take] + + base_lib["scenarios"] = new_scenarios + with open(base_pkl, "wb") as f: + pickle.dump(base_lib, f) + logger.info("Wrote merged library to %s (%d scenarios total)", base_pkl, len(new_scenarios)) + + if __name__ == "__main__": from dataclasses import dataclass from typing import Annotated @@ -1551,6 +1632,21 @@ class Args: log_level: str = "INFO" append_to: Path | None = None """If set, merge newly accepted scenarios into this existing library.pkl (deduplicates by seed). The new run is still saved under --tag for diagnostic plots.""" # noqa: E501 + # ── Optional supplement phase (paper-balanced library workflow) ── + n_supplement_candidates: int = 0 + """If > 0, after the main build run a second pass with overvoltage-favouring params (high PV, low load) and merge pure-overvoltage scenarios into the base library. Replaces the standalone build_balanced_library_ieee13.py orchestrator.""" # noqa: E501 + supplement_seed_start: int = -1 + """Starting candidate index for the supplement pass. Default -1 means seed_start + 4000 (prevents seed overlap).""" # noqa: E501 + supplement_pv_scale_min: float = 2.0 + supplement_pv_scale_max: float = 5.0 + supplement_load_scale_min: float = 0.5 + supplement_load_scale_max: float = 1.0 + supplement_min_baseline_integral_over: float = 1.0 + """Tighter overvoltage-integral floor for the supplement pass (we want real overvoltage scenarios, not borderline ones).""" # noqa: E501 + supplement_tag: str = "" + """Subdirectory for the supplement build artifacts. Default empty means f'{tag}_over_supp'.""" + swap_undervoltage_for_overvoltage: int = 0 + """If > 0, after the supplement merge swap N undervoltage-only scenarios out of the base for N pure-overvoltage scenarios from the supplement (test-set mode that keeps library size constant). Default 0 = train mode (append all pure-over).""" # noqa: E501 args = tyro.cli(Args) main( @@ -1613,3 +1709,93 @@ class Args: log_level=args.log_level, append_to=args.append_to, ) + + # ── Optional supplement phase: second pass with overvoltage-favouring params + merge ── + if args.n_supplement_candidates > 0: + supp_tag = args.supplement_tag or f"{args.tag}_over_supp" + supp_seed_start = args.supplement_seed_start if args.supplement_seed_start >= 0 else args.seed_start + 4000 + + logger.info("") + logger.info("=" * 60) + logger.info( + "Supplement phase: %d candidates, seed_start=%d, tag=%s", + args.n_supplement_candidates, + supp_seed_start, + supp_tag, + ) + logger.info( + " PV scale [%.2f-%.2f], load scale [%.2f-%.2f] (overvoltage-favouring)", + args.supplement_pv_scale_min, + args.supplement_pv_scale_max, + args.supplement_load_scale_min, + args.supplement_load_scale_max, + ) + logger.info("=" * 60) + + main( + system=args.system, + n_candidates=args.n_supplement_candidates, + min_recovery_frac=args.min_recovery_frac, + max_recovery_frac=args.max_recovery_frac, + min_baseline_integral=args.min_baseline_integral, + min_baseline_integral_over=args.supplement_min_baseline_integral_over, + max_baseline_integral=args.max_baseline_integral, + pv_base_kw=args.pv_base_kw, + tvl_base_kw=args.tvl_base_kw, + sensitivity_update_interval=args.sensitivity_update_interval, + ofo_w_throughput=args.ofo_w_throughput, + tag=supp_tag, + seed_start=supp_seed_start, + seeds=(), + use_training_overlay=args.use_training_overlay, + randomize_ramps=args.randomize_ramps, + randomization_profile=args.randomization_profile, + pv_scale_min=args.supplement_pv_scale_min, + pv_scale_max=args.supplement_pv_scale_max, + load_scale_min=args.supplement_load_scale_min, + load_scale_max=args.supplement_load_scale_max, + pv_t_shift_max_s=args.pv_t_shift_max_s, + tvl_t_shift_max_s=args.tvl_t_shift_max_s, + pv_warp_min=args.pv_warp_min, + pv_warp_max=args.pv_warp_max, + tvl_warp_min=args.tvl_warp_min, + tvl_warp_max=args.tvl_warp_max, + overlay_prob=args.overlay_prob, + overlay_intensity_min=args.overlay_intensity_min, + overlay_intensity_max=args.overlay_intensity_max, + overlay_gpu_frac_min=args.overlay_gpu_frac_min, + overlay_gpu_frac_max=args.overlay_gpu_frac_max, + n_ramps_per_site_choices=args.n_ramps_per_site_choices, + ramp_up_prob=args.ramp_up_prob, + ramp_down_frac_min=args.ramp_down_frac_min, + ramp_down_frac_max=args.ramp_down_frac_max, + ramp_up_frac_min=args.ramp_up_frac_min, + ramp_up_frac_max=args.ramp_up_frac_max, + ramp_start_min=args.ramp_start_min, + ramp_start_max=args.ramp_start_max, + ramp_dur_min=args.ramp_dur_min, + ramp_dur_max=args.ramp_dur_max, + randomize_pv_profile=args.randomize_pv_profile, + pv_shape_choices=args.pv_shape_choices, + pv_baseline_min=args.pv_baseline_min, + pv_baseline_max=args.pv_baseline_max, + pv_cloud_count_max=args.pv_cloud_count_max, + pv_cloud_depth_min=args.pv_cloud_depth_min, + pv_cloud_depth_max=args.pv_cloud_depth_max, + pv_cloud_width_min=args.pv_cloud_width_min, + pv_cloud_width_max=args.pv_cloud_width_max, + randomize_tvl_profile=args.randomize_tvl_profile, + tvl_shape_choices=args.tvl_shape_choices, + max_always_min=args.max_always_min, + t_control_start_buffer=args.t_control_start_buffer, + t_control_end_buffer=args.t_control_end_buffer, + log_level=args.log_level, + append_to=None, # we run a separate merge below (with the pure-over filter) + ) + + scenario_root = Path(__file__).resolve().parent / "outputs" / args.system / "scenario_library" + _merge_supplement_into_base( + base_pkl=scenario_root / args.tag / "library.pkl", + supp_pkl=scenario_root / supp_tag / "library.pkl", + swap_undervoltage_for_overvoltage=args.swap_undervoltage_for_overvoltage, + ) diff --git a/openg2g/controller/ppo.py b/openg2g/controller/ppo.py index d85ec6f..61b7820 100644 --- a/openg2g/controller/ppo.py +++ b/openg2g/controller/ppo.py @@ -221,20 +221,26 @@ def __init__( self._action_mode = _detect_action_mode(self._sb3_model.action_space, n_models_total) n_feasible = min(len(f) for f in self._feasible.values()) self._coupled_max_shift = n_feasible - 1 - # Optional list of all datacenters (set by attach_datacenters() before - # coord.run()). The Coordinator's step loop only passes ONE DC per - # controller (resolved by _site_id), but a shared policy trained on - # observations from ALL sites needs every DC's batch/itl/replicas/power - # state. When set, step() uses this list and ignores the single DC the - # coordinator hands in. + # Per-site datacenter routing (set by attach_datacenters() before + # coord.run()). The shared policy needs every DC's + # batch/itl/replicas/power state to build the joint observation; the + # site-id → DC mapping additionally lets step() route each per-site + # SetBatchSize command back to its specific DC. + self._dcs_by_sid: dict[str, LLMBatchSizeControlledDatacenter[LLMDatacenterState]] = {} self._all_datacenters: list = [] self._init_prev_batch() - def attach_datacenters(self, datacenters: list) -> None: - """Register all datacenter backends so the shared policy sees the - joint multi-site observation. Call once after the Coordinator is - constructed and before ``coord.run()``.""" - self._all_datacenters = list(datacenters) + def attach_datacenters( + self, + datacenters: dict[str, LLMBatchSizeControlledDatacenter[LLMDatacenterState]], + ) -> None: + """Register the site-id → DC mapping so the shared policy can both + observe the joint multi-site state and route per-site + ``SetBatchSize`` commands to the correct DC. Call once after the + Coordinator is constructed and before ``coord.run()``. + """ + self._dcs_by_sid = dict(datacenters) + self._all_datacenters = list(datacenters.values()) def _init_prev_batch(self) -> None: self._prev_batch = {label: self._obs_config.get_initial_batch(label) for label in self._obs_config.model_labels} @@ -282,23 +288,17 @@ def step( self._prev_batch = all_batch events.emit("controller.ppo.step", {"batch_size_by_model": all_batch}) - # Emit one SetBatchSize per site. Master's command-routing requires a - # target= DC object per command. SharedPPOBatchSizeController is a - # multi-DC controller; routing each site's command to the right DC - # needs the controller to track per-sid DC references (added below - # only if/when the shared-PPO inference path is exercised). For now - # this raises so callers see a clear migration boundary instead of - # silently routing every site to the same DC. - if len(self._site_model_mapping) > 1: - raise NotImplementedError( - "SharedPPOBatchSizeController multi-DC routing needs migration: " - "the controller must accept a dict[str, DatacenterBackend] in " - "__init__ to route per-site SetBatchSize commands. Single-site " - "shared mode still works." - ) + # Emit one SetBatchSize per site, routed to the correct DC via the + # sid → DC mapping registered by attach_datacenters(). For multi-site + # use the caller MUST call attach_datacenters() with a dict before + # coord.run(); otherwise we fall back to routing every command to + # self._datacenter (the single agent DC), which is only correct in + # the single-site case. commands: list[DatacenterCommand | GridCommand] = [] - for _sid, labels in self._site_model_mapping.items(): + for sid, labels in self._site_model_mapping.items(): site_batch = {label: all_batch[label] for label in labels if label in all_batch} - if site_batch: - commands.append(SetBatchSize(batch_size_by_model=site_batch, target=datacenter)) + if not site_batch: + continue + target_dc = self._dcs_by_sid.get(sid, datacenter) + commands.append(SetBatchSize(batch_size_by_model=site_batch, target=target_dc)) return commands From b9f40b96809e077e9592278a9c86fdf3a78af6cf Mon Sep 17 00:00:00 2001 From: Zhirui Liang Date: Mon, 4 May 2026 22:43:23 -0400 Subject: [PATCH 10/13] unify output path; revise document; add tensorboard to the rl optional-dependency extra --- docs/examples/rl-controller.md | 61 +++++++++++++++------------------- examples/offline/train_ppo.py | 2 +- pyproject.toml | 2 +- 3 files changed, 29 insertions(+), 36 deletions(-) diff --git a/docs/examples/rl-controller.md b/docs/examples/rl-controller.md index 8dde5ff..ab33446 100644 --- a/docs/examples/rl-controller.md +++ b/docs/examples/rl-controller.md @@ -6,7 +6,7 @@ Can a reinforcement learning policy learn to regulate distribution-feeder voltag ## Overview -OpenG2G ships a Proximal Policy Optimization (PPO) controller built on top of [stable-baselines3](https://stable-baselines3.readthedocs.io/). Unlike OFO (which needs voltage sensitivity matrices and per-model logistic fits) and rule-based control (which only knows the worst violation magnitude), the PPO policy: +Conflux ships a Proximal Policy Optimization (PPO) controller built on top of [stable-baselines3](https://stable-baselines3.readthedocs.io/). Unlike OFO (which needs voltage sensitivity matrices and per-model logistic fits) and rule-based control (which only knows the worst violation magnitude), the PPO policy: - Reads a structured observation of the grid + datacenter state at each control tick. - Outputs a per-model batch-size action (delta or coupled, depending on the action mode). @@ -18,7 +18,7 @@ The full RL workflow has three stages: 2. **Train a PPO policy** — multi-million-step PPO run on the library scenarios, with checkpointing. 3. **Evaluate** — replay held-out scenarios with baseline / rule-based / OFO / PPO controllers and compare voltage and throughput metrics. -The library code lives in [`openg2g.rl.env`][openg2g.rl.env] (Gymnasium environment, observation/reward configuration, scenario sampling) and [`openg2g.controller.ppo`][openg2g.controller.ppo] (single-site `PPOBatchSizeController` + multi-site `SharedPPOBatchSizeController` for inference). +The library code lives in [`conflux.rl.env`][conflux.rl.env] (Gymnasium environment, observation/reward configuration, scenario sampling) and [`conflux.controller.ppo`][conflux.controller.ppo] (single-site `PPOBatchSizeController` + multi-site `SharedPPOBatchSizeController` for inference). ## Scripts @@ -28,9 +28,17 @@ The library code lives in [`openg2g.rl.env`][openg2g.rl.env] (Gymnasium environm | `train_ppo.py` | PPO training loop using the scenario library; saves model + VecNormalize stats | | `evaluate_controllers.py` | Compare baseline / OFO / rule-based / PPO on held-out test scenarios | -## Usage +## Setup + +The RL workflow needs the `[opendss,rl]` extras: + +```bash +pip install "conflux[opendss,rl]" +``` + +> **Path convention.** All commands below are run from the **repo root**. Output-directory flags (`--tag`, `--output-dir`) are a **subdir name only**, joined under `examples/offline/outputs//`. Input-artifact flags (`--scenario-library`, `--ppo-models`) accept arbitrary path strings, resolved against cwd. -The first run of any script below also triggers a one-time download + extraction of LLM benchmark data from the HuggingFace `ml-energy-data` repo, populating a per-spec content-addressed cache under `data/specs//`. Subsequent calls reuse the cache. +## Usage ### IEEE 13 — end-to-end example @@ -42,7 +50,7 @@ The first run of any script below also triggers a one-time download + extraction python examples/offline/build_scenario_library.py \ --system ieee13 \ --n-candidates 500 --seed-start 0 \ - --output-dir outputs/ieee13/scenario_library/train_n500 + --tag train_n500 ``` **1b. Build test library** (use a different `--seed-start` so train and test seeds don't overlap) @@ -51,10 +59,10 @@ python examples/offline/build_scenario_library.py \ python examples/offline/build_scenario_library.py \ --system ieee13 \ --n-candidates 150 --seed-start 1000 \ - --output-dir outputs/ieee13/scenario_library/test_n150 + --tag test_n150 ``` -Each call writes a `library.pkl` of accepted scenarios (those where OFO meaningfully recovers a baseline voltage violation, controlled by `--min-baseline-integral` and `--min-recovery-frac`), per-scenario voltage-envelope plots, and a CSV of metrics. +Each call writes a `library.pkl` of accepted scenarios (those where OFO meaningfully recovers a baseline voltage violation, controlled by `--min-baseline-integral` and `--min-recovery-frac`), per-scenario voltage-envelope plots, and a CSV of metrics. Acceptance rates around 50% are typical, so plan `--n-candidates` accordingly (e.g. request 2× the library size you ultimately want). #### 2. PPO training @@ -70,67 +78,52 @@ python examples/offline/train_ppo.py \ --action-mode delta \ --w-voltage 5000 --w-throughput 0.05 --w-latency 0.01 --w-switch 0.5 \ --n-envs 8 --seed 1 \ - --scenario-library outputs/ieee13/scenario_library/train_n500/library.pkl \ + --scenario-library examples/offline/outputs/ieee13/scenario_library/train_n500/library.pkl \ --no-ofo-baseline --truncate-episode \ - --output-dir outputs/ieee13/ppo + --output-dir ppo ``` -Output: `ppo_model.zip`, `ppo_model_vecnormalize.pkl`, per-checkpoint snapshots under `checkpoints/`, TensorBoard logs in `tb/`, and a training-progress plot. See `train_ppo.py --help` for the full flag set (`--obs-mode`, alternate reward weights, action-mode variants, etc.). +Output: `ppo_model.zip`, `ppo_model_vecnormalize.pkl`, per-checkpoint snapshots under `checkpoints//` (every `n_steps × 10` env steps, with sibling `ppo_vecnormalize__steps.pkl`), TensorBoard logs in `tb/`, and a training-progress plot. See `train_ppo.py --help` for the full flag set (`--obs-mode`, alternate reward weights, action-mode variants, etc.). Pass `--no-tensorboard` to skip TB logging. #### 3. Controller evaluation Compares no-coordination baseline, droop (rule-based) control, OFO control, and the trained PPO on the held-out test library: ```bash -MODELS=(outputs/ieee13/ppo/ppo_model.zip) - python examples/offline/evaluate_controllers.py \ --system ieee13 \ - --ppo-models "${MODELS[@]}" \ - --scenario-library outputs/ieee13/scenario_library/test_n150/library.pkl \ + --ppo-models examples/offline/outputs/ieee13/ppo/ppo_model.zip \ + --scenario-library examples/offline/outputs/ieee13/scenario_library/test_n150/library.pkl \ --n-scenarios 50 \ --obs-mode full-voltage \ --include-rule-based \ --use-display-names \ - --output-dir outputs/ieee13/eval_4ctrl_ieee13 \ + --output-dir eval_4ctrl_ieee13 \ --log-level INFO ``` -Outputs: +Outputs (under `examples/offline/outputs/ieee13/eval_4ctrl_ieee13/`): - `results.csv` — per-scenario metrics for every controller: violation time, integral violation, worst Vmin/Vmax, mean throughput, p99 latency, mean power, batch-change count. - `aggregate_*.png` — bar charts comparing voltage / throughput / batch-switching across controllers. - `scenario_/` — per-scenario voltage envelopes and batch-size traces. -Multiple PPO checkpoints (e.g., from a multi-seed sweep) can be passed in `MODELS=(...)` and labelled via `--ppo-labels`. +Multiple PPO checkpoints (e.g., intermediate snapshots from `checkpoints//ppo__steps.zip` or runs from a multi-seed sweep) can be passed as space-separated arguments to `--ppo-models` and labelled via `--ppo-labels`. -(Repeat the same three stages for `ieee34` / `ieee123` with appropriate `--system`, `--obs-mode`, and `--no-randomize-ramps` flags.) - -### Inference: using a trained PPO inside other scripts - -A trained PPO checkpoint can also be loaded inside `build_scenario_library.py --mode ppo`, which runs the policy directly without retraining: - -```bash -python examples/offline/build_scenario_library.py \ - --system ieee13 \ - --mode ppo \ - --ppo-model outputs/ieee13/ppo/ppo_model.zip -``` +(Repeat the same three stages for `ieee34` / `ieee123` with appropriate `--system` and `--obs-mode` flags.) ## What to Look For -- **Voltage**: a well-trained PPO matches or slightly trails OFO on integral violation (pu·s). For IEEE 13 it typically sits within 10–30 % of OFO's voltage performance. +- **Voltage**: a well-trained PPO matches or slightly trails OFO on integral violation (pu·s). - **Throughput**: PPO usually serves *more* tokens per second than OFO because it's free to choose any feasible batch level rather than following gradient descent toward a fixed setpoint. - **Switching**: untrained or under-trained policies oscillate a lot (high `batch_chg`); the `--w-switch` term penalizes this. Compare PPO's `Batch Δ` column against OFO's to see whether the policy has learned a smooth control trajectory. -- **Latency**: PPO can violate ITL deadlines if `--w-latency` is set to 0; turn it on if your application is latency-sensitive. +- **Latency**: PPO can violate ITL deadlines if `--w-latency` is too small; turn it on if your application is latency-sensitive. ## Configuration PPO-specific settings live in two places: - **CLI flags** on `train_ppo.py` (architecture, optimization, reward weights, scenario randomization). -- **Code-level**: `openg2g.rl.env.ObservationConfig` (which signals enter the policy observation), `openg2g.rl.env.RewardConfig` (per-component reward weights and clipping), and the per-system experiment factories in `examples/offline/systems.py` (model deployments, replica schedules, base loads). - -The hardcoded model spec list (`ALL_MODEL_SPECS` in `systems.py`) defines the exact 5 LLM workloads (Llama-3.1-8B / 70B / 405B, Qwen3-30B-A3B, Qwen3-235B-A22B). To use a different mix, edit that tuple and re-run Stage 1 (the `data/specs/` cache is keyed by per-spec hash, so only changed specs regenerate). +- **Code-level**: `conflux.rl.env.ObservationConfig` (which signals enter the policy observation), `conflux.rl.env.RewardConfig` (per-component reward weights and clipping), and the per-system experiment factories in `examples/offline/systems.py` (model deployments, replica schedules, base loads). See [Voltage Regulation Strategies](voltage-regulation-strategies.md) for a side-by-side comparison of PPO with the model-based controllers, and [Building Simulators](../guide/building-simulators.md) for the underlying API. diff --git a/examples/offline/train_ppo.py b/examples/offline/train_ppo.py index 1ee16ad..8237161 100644 --- a/examples/offline/train_ppo.py +++ b/examples/offline/train_ppo.py @@ -492,7 +492,7 @@ def main() -> None: exp = EXPERIMENTS[args.system](training_trace) script_dir = Path(__file__).resolve().parent - output_dir = Path(args.output_dir).resolve() if args.output_dir else script_dir / "outputs" / args.system / "ppo" + output_dir = script_dir / "outputs" / args.system / (args.output_dir or "ppo") output_dir.mkdir(parents=True, exist_ok=True) # Collect all model specs across sites diff --git a/pyproject.toml b/pyproject.toml index 331990f..1306013 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,7 +25,7 @@ Documentation = "https://gpu2grid.io/openg2g" [project.optional-dependencies] opendss = ["opendssdirect.py"] -rl = ["stable-baselines3>=2.0", "gymnasium"] +rl = ["stable-baselines3>=2.0", "gymnasium", "tensorboard"] [dependency-groups] test = ["pytest"] From 847c111e463716dc6c8914817c8d3e9407cf4f6c Mon Sep 17 00:00:00 2001 From: Jae-Won Chung Date: Sun, 10 May 2026 22:30:32 -0400 Subject: [PATCH 11/13] Refactors --- README.md | 2 + docs/examples/rl-controller.md | 65 +- .../examples/voltage-regulation-strategies.md | 2 +- docs/guide/building-simulators.md | 6 +- docs/guide/data-pipeline.md | 16 +- examples/model_insights/common.py | 10 +- examples/model_insights/plots.py | 30 +- examples/model_insights/systems.py | 122 ++ examples/offline/run_ofo.py | 2 +- examples/offline/systems.py | 1402 +---------------- .../build_library.py} | 632 ++------ .../rl_controller/controller.py | 123 +- {openg2g/rl => examples/rl_controller}/env.py | 561 +++---- .../evaluate.py} | 494 +----- examples/rl_controller/plotting.py | 723 +++++++++ examples/rl_controller/scenarios.py | 1080 +++++++++++++ examples/rl_controller/systems.py | 466 ++++++ .../{offline => rl_controller}/train_ppo.py | 123 +- openg2g/controller/ofo.py | 6 +- openg2g/coordinator.py | 233 ++- openg2g/datacenter/config.py | 2 +- openg2g/datacenter/workloads/inference.py | 8 +- pyproject.toml | 2 +- tests/test_logistic.py | 2 +- tests/test_offline_dc.py | 2 +- tests/test_ofo_internals.py | 2 +- tests/test_ofo_observed_latency.py | 2 +- uv.lock | 769 +++++---- 28 files changed, 3676 insertions(+), 3211 deletions(-) create mode 100644 examples/model_insights/systems.py rename examples/{offline/build_scenario_library.py => rl_controller/build_library.py} (72%) rename openg2g/controller/ppo.py => examples/rl_controller/controller.py (73%) rename {openg2g/rl => examples/rl_controller}/env.py (58%) rename examples/{offline/evaluate_controllers.py => rl_controller/evaluate.py} (62%) create mode 100644 examples/rl_controller/plotting.py create mode 100644 examples/rl_controller/scenarios.py create mode 100644 examples/rl_controller/systems.py rename examples/{offline => rl_controller}/train_ppo.py (89%) diff --git a/README.md b/README.md index 03620ea..b9629ab 100644 --- a/README.md +++ b/README.md @@ -142,6 +142,8 @@ python examples/offline/run_ofo.py --system ieee13 --mode all `--system` selects the IEEE test feeder (ieee13, ieee34, or ieee123). `--mode` selects one of `baseline-no-tap`, `baseline-tap-change`, `ofo-no-tap`, `ofo-tap-change`, or `all`. Benchmark selection now lives directly in each `InferenceModelSpec`, and generated artifacts are cached per spec under `data/specs//`. +A reinforcement-learning (PPO) controller is available as a self-contained example under `examples/rl_controller/`: see [Reinforcement Learning Controller (PPO)](https://gpu2grid.io/openg2g/examples/rl-controller/) for the build / train / evaluate workflow. + ## Documentation Full documentation is available at [https://gpu2grid.io/openg2g](https://gpu2grid.io/openg2g), including: diff --git a/docs/examples/rl-controller.md b/docs/examples/rl-controller.md index ab33446..fdb255d 100644 --- a/docs/examples/rl-controller.md +++ b/docs/examples/rl-controller.md @@ -6,7 +6,7 @@ Can a reinforcement learning policy learn to regulate distribution-feeder voltag ## Overview -Conflux ships a Proximal Policy Optimization (PPO) controller built on top of [stable-baselines3](https://stable-baselines3.readthedocs.io/). Unlike OFO (which needs voltage sensitivity matrices and per-model logistic fits) and rule-based control (which only knows the worst violation magnitude), the PPO policy: +The PPO workflow is built on top of [stable-baselines3](https://stable-baselines3.readthedocs.io/). Unlike OFO (which needs voltage sensitivity matrices and per-model logistic fits) and rule-based control (which only knows the worst violation magnitude), the PPO policy: - Reads a structured observation of the grid + datacenter state at each control tick. - Outputs a per-model batch-size action (delta or coupled, depending on the action mode). @@ -14,40 +14,36 @@ Conflux ships a Proximal Policy Optimization (PPO) controller built on top of [s The full RL workflow has three stages: -1. **Build a scenario library** — a pool of pre-screened, randomized PV / TVL / inference-ramp scenarios that the PPO environment will sample from during training. Filtering the library to scenarios where OFO has meaningful headroom keeps the learning signal focused. -2. **Train a PPO policy** — multi-million-step PPO run on the library scenarios, with checkpointing. -3. **Evaluate** — replay held-out scenarios with baseline / rule-based / OFO / PPO controllers and compare voltage and throughput metrics. - -The library code lives in [`conflux.rl.env`][conflux.rl.env] (Gymnasium environment, observation/reward configuration, scenario sampling) and [`conflux.controller.ppo`][conflux.controller.ppo] (single-site `PPOBatchSizeController` + multi-site `SharedPPOBatchSizeController` for inference). - -## Scripts +1. **Build a scenario library**: a pool of pre-screened, randomized PV / TVL / inference-ramp scenarios that the PPO environment will sample from during training. Filtering the library to scenarios where OFO has meaningful headroom keeps the learning signal focused. +2. **Train a PPO policy**: multi-million-step PPO run on the library scenarios, with checkpointing. +3. **Evaluate**: replay held-out scenarios with baseline / rule-based / OFO / PPO controllers and compare voltage and throughput metrics. | Script | Purpose | |--------|---------| -| `build_scenario_library.py` | Generate, screen, and filter a per-system scenario library | -| `train_ppo.py` | PPO training loop using the scenario library; saves model + VecNormalize stats | -| `evaluate_controllers.py` | Compare baseline / OFO / rule-based / PPO on held-out test scenarios | +| `examples/rl_controller/build_library.py` | Generate, screen, and filter a per-system scenario library. | +| `examples/rl_controller/train_ppo.py` | PPO training loop; saves model + VecNormalize stats. | +| `examples/rl_controller/evaluate.py` | Compare baseline / OFO / rule-based / PPO on held-out test scenarios. | ## Setup The RL workflow needs the `[opendss,rl]` extras: ```bash -pip install "conflux[opendss,rl]" +pip install "openg2g[opendss,rl]" ``` -> **Path convention.** All commands below are run from the **repo root**. Output-directory flags (`--tag`, `--output-dir`) are a **subdir name only**, joined under `examples/offline/outputs//`. Input-artifact flags (`--scenario-library`, `--ppo-models`) accept arbitrary path strings, resolved against cwd. +> **Path convention.** All commands below are run from the **repo root**. Output-directory flags (`--tag`, `--output-dir`) are a **subdir name only**, joined under `examples/rl_controller/outputs//`. Input-artifact flags (`--scenario-library`, `--ppo-models`) accept arbitrary path strings, resolved against cwd. ## Usage -### IEEE 13 — end-to-end example +### IEEE 13: end-to-end example #### 1. Dataset generation **1a. Build training library** ```bash -python examples/offline/build_scenario_library.py \ +python examples/rl_controller/build_library.py \ --system ieee13 \ --n-candidates 500 --seed-start 0 \ --tag train_n500 @@ -56,18 +52,18 @@ python examples/offline/build_scenario_library.py \ **1b. Build test library** (use a different `--seed-start` so train and test seeds don't overlap) ```bash -python examples/offline/build_scenario_library.py \ +python examples/rl_controller/build_library.py \ --system ieee13 \ --n-candidates 150 --seed-start 1000 \ --tag test_n150 ``` -Each call writes a `library.pkl` of accepted scenarios (those where OFO meaningfully recovers a baseline voltage violation, controlled by `--min-baseline-integral` and `--min-recovery-frac`), per-scenario voltage-envelope plots, and a CSV of metrics. Acceptance rates around 50% are typical, so plan `--n-candidates` accordingly (e.g. request 2× the library size you ultimately want). +Each call writes a library directory with `metadata.json`, `traces.npz`, per-scenario voltage-envelope plots, and a `candidates.csv` of metrics. Acceptance rates around 50% are typical, so request roughly 2× the library size you want. #### 2. PPO training ```bash -python examples/offline/train_ppo.py \ +python examples/rl_controller/train_ppo.py \ --system ieee13 \ --total-timesteps 2000000 \ --total-duration-s 3600 \ @@ -78,22 +74,22 @@ python examples/offline/train_ppo.py \ --action-mode delta \ --w-voltage 5000 --w-throughput 0.05 --w-latency 0.01 --w-switch 0.5 \ --n-envs 8 --seed 1 \ - --scenario-library examples/offline/outputs/ieee13/scenario_library/train_n500/library.pkl \ - --no-ofo-baseline --truncate-episode \ + --scenario-library examples/rl_controller/outputs/ieee13/scenario_library/train_n500 \ + --no-ofo-baseline \ --output-dir ppo ``` -Output: `ppo_model.zip`, `ppo_model_vecnormalize.pkl`, per-checkpoint snapshots under `checkpoints//` (every `n_steps × 10` env steps, with sibling `ppo_vecnormalize__steps.pkl`), TensorBoard logs in `tb/`, and a training-progress plot. See `train_ppo.py --help` for the full flag set (`--obs-mode`, alternate reward weights, action-mode variants, etc.). Pass `--no-tensorboard` to skip TB logging. +Output: `ppo_model.zip` (the trained policy) and `ppo_model_vecnormalize.pkl` (the obs-normalization stats, both required at inference), intermediate snapshots every 10 rollouts at `outputs/ieee13/ppo/checkpoints/_default/ppo__steps.zip` (the inner directory is the agent's site id, `_default` for single-DC systems; `` is the total env-transitions count, i.e. `n_steps × n_envs × rollout_index`, so with the defaults you'll see ~7 snapshots over a 2M-step run), TensorBoard logs in `tb/`, and a training-progress plot. See `train_ppo.py --help` for the full flag set; pass `--no-tensorboard` to skip TB logging. #### 3. Controller evaluation Compares no-coordination baseline, droop (rule-based) control, OFO control, and the trained PPO on the held-out test library: ```bash -python examples/offline/evaluate_controllers.py \ +python examples/rl_controller/evaluate.py \ --system ieee13 \ - --ppo-models examples/offline/outputs/ieee13/ppo/ppo_model.zip \ - --scenario-library examples/offline/outputs/ieee13/scenario_library/test_n150/library.pkl \ + --ppo-models examples/rl_controller/outputs/ieee13/ppo/ppo_model.zip \ + --scenario-library examples/rl_controller/outputs/ieee13/scenario_library/test_n150 \ --n-scenarios 50 \ --obs-mode full-voltage \ --include-rule-based \ @@ -102,28 +98,31 @@ python examples/offline/evaluate_controllers.py \ --log-level INFO ``` -Outputs (under `examples/offline/outputs/ieee13/eval_4ctrl_ieee13/`): +Outputs (under `examples/rl_controller/outputs/ieee13/eval_4ctrl_ieee13/`): -- `results.csv` — per-scenario metrics for every controller: violation time, integral violation, worst Vmin/Vmax, mean throughput, p99 latency, mean power, batch-change count. -- `aggregate_*.png` — bar charts comparing voltage / throughput / batch-switching across controllers. -- `scenario_/` — per-scenario voltage envelopes and batch-size traces. +- `results.csv`: per-scenario metrics for every controller: violation time, integral violation, worst Vmin/Vmax, mean throughput, p99 latency, mean power, batch-change count. +- `aggregate_*.png`: bar charts comparing voltage / throughput / batch-switching across controllers. +- `scenario_/`: per-scenario voltage envelopes and batch-size traces. Multiple PPO checkpoints (e.g., intermediate snapshots from `checkpoints//ppo__steps.zip` or runs from a multi-seed sweep) can be passed as space-separated arguments to `--ppo-models` and labelled via `--ppo-labels`. -(Repeat the same three stages for `ieee34` / `ieee123` with appropriate `--system` and `--obs-mode` flags.) +**Other feeders.** Repeat the same three stages for `ieee34` / `ieee123` with the right flags: + +- **`ieee34`**: pass `--no-randomize-ramps` to `build_library.py` (the default per-second ramp randomization tends to swamp the smaller load envelope on this feeder). +- **`ieee123`**: this is the only feeder with named zones, so it's the only one where `--obs-mode per-zone-summary` is valid for `train_ppo.py` / `evaluate.py`. Passing it on `ieee13` / `ieee34` raises a `ValueError`. ## What to Look For -- **Voltage**: a well-trained PPO matches or slightly trails OFO on integral violation (pu·s). +- **Voltage**: a well-trained PPO matches or slightly trails OFO on integral violation (pu·s). - **Throughput**: PPO usually serves *more* tokens per second than OFO because it's free to choose any feasible batch level rather than following gradient descent toward a fixed setpoint. - **Switching**: untrained or under-trained policies oscillate a lot (high `batch_chg`); the `--w-switch` term penalizes this. Compare PPO's `Batch Δ` column against OFO's to see whether the policy has learned a smooth control trajectory. - **Latency**: PPO can violate ITL deadlines if `--w-latency` is too small; turn it on if your application is latency-sensitive. ## Configuration -PPO-specific settings live in two places: +Most knobs are CLI flags on `train_ppo.py` (network architecture, optimizer, reward weights, scenario randomization). For deeper changes: -- **CLI flags** on `train_ppo.py` (architecture, optimization, reward weights, scenario randomization). -- **Code-level**: `conflux.rl.env.ObservationConfig` (which signals enter the policy observation), `conflux.rl.env.RewardConfig` (per-component reward weights and clipping), and the per-system experiment factories in `examples/offline/systems.py` (model deployments, replica schedules, base loads). +- **Observation features and reward shape**: edit `ObservationConfig` / `RewardConfig` / `build_observation` / `compute_reward` in `examples/rl_controller/env.py`. +- **Per-system experiments** (which models are served, replica counts and ramps, base loads, training overlay): edit the `ieee13_experiment` / `ieee34_experiment` / `ieee123_experiment` factories registered under `EXPERIMENTS` in `examples/rl_controller/scenarios.py`. See [Voltage Regulation Strategies](voltage-regulation-strategies.md) for a side-by-side comparison of PPO with the model-based controllers, and [Building Simulators](../guide/building-simulators.md) for the underlying API. diff --git a/docs/examples/voltage-regulation-strategies.md b/docs/examples/voltage-regulation-strategies.md index db358d8..00ab515 100644 --- a/docs/examples/voltage-regulation-strategies.md +++ b/docs/examples/voltage-regulation-strategies.md @@ -24,7 +24,7 @@ This analysis compares four control strategies: | `run_ofo.py --mode ofo-no-tap` | OFO without tap schedule | | `run_ofo.py --mode ofo-tap-change` | OFO with tap schedule | | `analyze_different_controllers.py` | Side-by-side comparison of baseline, rule-based, and OFO | -| `evaluate_controllers.py` | Held-out scenario evaluation that also accepts trained PPO models via `--ppo-models` (see [Reinforcement Learning Controller (PPO)](rl-controller.md)) | +| `examples/rl_controller/evaluate.py` | Held-out scenario evaluation that also accepts trained PPO models via `--ppo-models` (see [Reinforcement Learning Controller (PPO)](rl-controller.md)) | ## Usage diff --git a/docs/guide/building-simulators.md b/docs/guide/building-simulators.md index 4996b3a..9e92139 100644 --- a/docs/guide/building-simulators.md +++ b/docs/guide/building-simulators.md @@ -470,7 +470,7 @@ The following sections describe how the built-in components implement the interf ### `OpenDSSGrid` -[`OpenDSSGrid`][openg2g.grid.opendss.OpenDSSGrid] implements [`GridBackend`][openg2g.grid.base.GridBackend] by wrapping a compiled OpenDSS circuit and running one power-flow solve per step. `start()` compiles the user's DSS file, builds the bus-phase voltage index, and adds native `Storage` elements for any attached storage (storage requires a three-phase bus). Each step writes datacenter, generator, external-load, and storage setpoints into OpenDSS, solves, and returns per-bus, per-phase voltages plus tap positions; each attached storage then receives a [`StorageState`][openg2g.grid.storage.StorageState] readback through `update_state()`. The grid accepts [`SetTaps`][openg2g.grid.command.SetTaps] and [`SetStoragePower`][openg2g.grid.command.SetStoragePower], and exposes `voltages_vector()` and a finite-difference `estimate_sensitivity()` (dV/dP) for controllers that need fine-grained voltage information — used by [`OFOBatchSizeController`](#ofobatchsizecontroller). +[`OpenDSSGrid`][openg2g.grid.opendss.OpenDSSGrid] implements [`GridBackend`][openg2g.grid.base.GridBackend] by wrapping a compiled OpenDSS circuit and running one power-flow solve per step. `start()` compiles the user's DSS file, builds the bus-phase voltage index, and adds native `Storage` elements for any attached storage (storage requires a three-phase bus). Each step writes datacenter, generator, external-load, and storage setpoints into OpenDSS, solves, and returns per-bus, per-phase voltages plus tap positions; each attached storage then receives a [`StorageState`][openg2g.grid.storage.StorageState] readback through `update_state()`. The grid accepts [`SetTaps`][openg2g.grid.command.SetTaps] and [`SetStoragePower`][openg2g.grid.command.SetStoragePower], and exposes `voltages_vector()` and a finite-difference `estimate_sensitivity()` (dV/dP) for controllers that need fine-grained voltage information: used by [`OFOBatchSizeController`](#ofobatchsizecontroller). ### `BatteryStorage` @@ -482,11 +482,11 @@ The following sections describe how the built-in components implement the interf ### `LoadShiftController` -[`LoadShiftController`][openg2g.controller.load_shift.LoadShiftController] implements [`Controller`][openg2g.controller.base.Controller] for cross-site coordination. It holds references to all datacenters and, in a single step, emits paired [`ShiftReplicas`][openg2g.datacenter.command.ShiftReplicas] commands targeting different DCs (`-` at the source, `+` at the destination) — illustrating a controller that spans multiple datacenters and produces a coordinated multi-target action. It also has an ordering relationship with other controllers: its activation rule depends on whether per-site batch controllers are saturated, so it must run after them. +[`LoadShiftController`][openg2g.controller.load_shift.LoadShiftController] implements [`Controller`][openg2g.controller.base.Controller] for cross-site coordination. It holds references to all datacenters and, in a single step, emits paired [`ShiftReplicas`][openg2g.datacenter.command.ShiftReplicas] commands targeting different DCs (`-` at the source, `+` at the destination): illustrating a controller that spans multiple datacenters and produces a coordinated multi-target action. It also has an ordering relationship with other controllers: its activation rule depends on whether per-site batch controllers are saturated, so it must run after them. ### `LocalVoltageStorageDroopController` -[`LocalVoltageStorageDroopController`][openg2g.controller.storage.LocalVoltageStorageDroopController] implements [`Controller`][openg2g.controller.base.Controller] for storage rather than datacenters. It illustrates two patterns the other built-in controllers don't: targeting an [`EnergyStorage`][openg2g.grid.storage.EnergyStorage] (via [`SetStoragePower`][openg2g.grid.command.SetStoragePower]), and reading windowed grid *history* rather than just the current state — each step it consumes the history accumulated since the previous tick, reduces each storage's local samples by the configured `voltage_statistic`, and runs a deadbanded droop curve clipped to the storage rating. In Q-V mode the output drives kvar; in P-V mode it drives kW. +[`LocalVoltageStorageDroopController`][openg2g.controller.storage.LocalVoltageStorageDroopController] implements [`Controller`][openg2g.controller.base.Controller] for storage rather than datacenters. It illustrates two patterns the other built-in controllers don't: targeting an [`EnergyStorage`][openg2g.grid.storage.EnergyStorage] (via [`SetStoragePower`][openg2g.grid.command.SetStoragePower]), and reading windowed grid *history* rather than just the current state: each step it consumes the history accumulated since the previous tick, reduces each storage's local samples by the configured `voltage_statistic`, and runs a deadbanded droop curve clipped to the storage rating. In Q-V mode the output drives kvar; in P-V mode it drives kW. ## Example Analysis Scripts diff --git a/docs/guide/data-pipeline.md b/docs/guide/data-pipeline.md index a064649..a058300 100644 --- a/docs/guide/data-pipeline.md +++ b/docs/guide/data-pipeline.md @@ -87,7 +87,7 @@ For each model and batch size, it: 1. Extracts power timelines from benchmark runs 2. Resamples to a median-duration grid -3. Fits [`ITLMixtureModel`][mlenergy_data.modeling.ITLMixtureModel] distributions per batch size +3. Fits [`ITLMixtureModel`][mlenergy.data.modeling.ITLMixtureModel] distributions per batch size ``` ML.ENERGY Benchmark Dataset mlenergy-data @@ -129,20 +129,20 @@ $$p(x) = \frac{P_{\max}}{1 + \exp(-k_p(x - x_{0,p}))} + p_0, \quad x \triangleq where $P_{\max}$ is the saturation magnitude, $k_p$ controls transition sharpness, $x_{0,p}$ is the characteristic batch size threshold, and $p_0$ is an offset term. Latency and throughput use the same functional form with their own parameters. -OpenG2G uses [`LogisticModel`][mlenergy_data.modeling.LogisticModel] from [`mlenergy-data`](https://ml.energy/data) at both stages: +OpenG2G uses [`LogisticModel`][mlenergy.data.modeling.LogisticModel] from [`mlenergy-data`](https://ml.energy/data) at both stages: -- **Generation**: [`LogisticModel.fit(x, y)`][mlenergy_data.modeling.logistic.LogisticModel.fit] fits the curve to benchmark data -- **Runtime**: [`LogisticModel.eval(batch)`][mlenergy_data.modeling.logistic.LogisticModel.eval] evaluates the curve, and [`LogisticModel.deriv_wrt_x(x)`][mlenergy_data.modeling.logistic.LogisticModel.deriv_wrt_x] computes gradients for the OFO controller +- **Generation**: [`LogisticModel.fit(x, y)`][mlenergy.data.modeling.logistic.LogisticModel.fit] fits the curve to benchmark data +- **Runtime**: [`LogisticModel.eval(batch)`][mlenergy.data.modeling.logistic.LogisticModel.eval] evaluates the curve, and [`LogisticModel.deriv_wrt_x(x)`][mlenergy.data.modeling.logistic.LogisticModel.deriv_wrt_x] computes gradients for the OFO controller ## ITL Mixture Model Historical ITL measurements exhibit heavy-tailed behavior. The generation step captures this using a weighted mixture of two lognormal distributions per batch size. -OpenG2G uses [`ITLMixtureModel`][mlenergy_data.modeling.ITLMixtureModel] from [`mlenergy-data`](https://ml.energy/data) at both stages: +OpenG2G uses [`ITLMixtureModel`][mlenergy.data.modeling.ITLMixtureModel] from [`mlenergy-data`](https://ml.energy/data) at both stages: -- **Generation**: [`ITLMixtureModel.fit(samples)`][mlenergy_data.modeling.latency.ITLMixtureModel.fit] fits the mixture to raw ITL samples -- **Runtime**: [`ITLMixtureModel.sample_avg(n_replicas, rng)`][mlenergy_data.modeling.latency.ITLMixtureModel.sample_avg] draws average latency across replicas +- **Generation**: [`ITLMixtureModel.fit(samples)`][mlenergy.data.modeling.latency.ITLMixtureModel.fit] fits the mixture to raw ITL samples +- **Runtime**: [`ITLMixtureModel.sample_avg(n_replicas, rng)`][mlenergy.data.modeling.latency.ITLMixtureModel.sample_avg] draws average latency across replicas ## Training Trace Generation @@ -164,5 +164,5 @@ To use the dataset: At simulation time, the generated artifacts are consumed by two components: -- **[`OfflineDatacenter`][openg2g.datacenter.offline.OfflineDatacenter]**: Uses [`InferenceData`][openg2g.datacenter.workloads.inference.InferenceData] to replay periodic per-GPU power templates. Latency fits ([`ITLMixtureModel`][mlenergy_data.modeling.ITLMixtureModel]) are sampled at each control interval. +- **[`OfflineDatacenter`][openg2g.datacenter.offline.OfflineDatacenter]**: Uses [`InferenceData`][openg2g.datacenter.workloads.inference.InferenceData] to replay periodic per-GPU power templates. Latency fits ([`ITLMixtureModel`][mlenergy.data.modeling.ITLMixtureModel]) are sampled at each control interval. - **[`OFOBatchSizeController`][openg2g.controller.ofo.OFOBatchSizeController]**: Uses [`LogisticModelStore`][openg2g.controller.ofo.LogisticModelStore] for logistic curve evaluation. Calls `eval()` and `deriv_wrt_x()` at each control step to compute gradients. diff --git a/examples/model_insights/common.py b/examples/model_insights/common.py index 1edac43..68d68b5 100644 --- a/examples/model_insights/common.py +++ b/examples/model_insights/common.py @@ -13,7 +13,6 @@ import csv import logging import math -import sys from dataclasses import dataclass, field from fractions import Fraction from pathlib import Path @@ -44,7 +43,6 @@ from openg2g.metrics.performance import PerformanceStats, compute_performance_stats from openg2g.metrics.voltage import VoltageStats, compute_allbus_voltage_stats -sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "offline")) from systems import SYSTEMS, tap logger = logging.getLogger("model_insights") @@ -314,7 +312,7 @@ def _spec( } -# Data pipeline — per-spec content-addressed cache (see InferenceModelSpec.cache_hash) +# Data pipeline: per-spec content-addressed cache (see InferenceModelSpec.cache_hash) SPECS_CACHE_DIR = _PROJECT_ROOT / "data" / "specs" TRAINING_TRACE_PATH = _PROJECT_ROOT / "data" / "training_trace.csv" @@ -558,7 +556,7 @@ def compute_achievable_power_range( DC power. The logistic is fit to `avg_power_watts`, the per-run average power for - the full `num_gpus` bench configuration — `model.eval(batch)` already + the full `num_gpus` bench configuration: `model.eval(batch)` already covers the whole replica, so do NOT multiply by `gpus_per_replica`. """ total_max_w = 0.0 @@ -589,7 +587,7 @@ def compute_matched_peak_replicas( budget when GPU counts per replica differ. Args: - spec: The model spec — `feasible_batch_sizes` must already be capped + spec: The model spec: `feasible_batch_sizes` must already be capped to batches that meet the SLO; pass the output of `restrict_spec_by_deadline` if an SLO is in play. target_peak_kw: Target peak inference power in kW. @@ -634,7 +632,7 @@ def deploy( """Shorthand for `(ModelDeployment, ReplicaSchedule(initial=...))`. `initial_batch_size=None` (the default) starts the scenario at the - largest feasible batch — i.e., maximum DC power stress, leaving the + largest feasible batch: i.e., maximum DC power stress, leaving the controller full downward range. Args: diff --git a/examples/model_insights/plots.py b/examples/model_insights/plots.py index 7fcf200..145f7a2 100644 --- a/examples/model_insights/plots.py +++ b/examples/model_insights/plots.py @@ -3,7 +3,7 @@ Each panel saves as its own PDF, SVG, and PNG; the paper composes subfloats from these. Style comes from the local `paper_plots.mplstyle`. Legends are color-only and horizontal; display labels drop precision / parallelism -decorations — those live in captions. +decorations: those live in captions. """ from __future__ import annotations @@ -97,7 +97,7 @@ def _line_handle(color: str, label: str) -> Line2D: def _combo_handle(color: str, label: str): - """Composite legend handle — a bar swatch + a line marker, both in + """Composite legend handle: a bar swatch + a line marker, both in `color`, rendered side-by-side under one label. Use with `HandlerTuple` so one legend entry covers both the (a) bars and the (b) Pareto panels at once. @@ -182,7 +182,7 @@ def _grouped_bars( ) -> None: """Bars grouped by variant. Each variant has two bars: hatched (uncoordinated) and solid (coordinated), both colored by variant. - No x tick labels — identity comes from the shared legend + caption.""" + No x tick labels: identity comes from the shared legend + caption.""" n = len(colors) xs = np.arange(n) offset = bar_width / 2 + 0.012 @@ -236,7 +236,7 @@ def _make_panel(): def _save(fig: plt.Figure, stem: Path) -> None: stem.parent.mkdir(parents=True, exist_ok=True) - # No tight_layout — it overrides set_position. bbox_inches=None + # No tight_layout: it overrides set_position. bbox_inches=None # preserves the exact figsize, guaranteeing matched dimensions. kwargs = {"bbox_inches": None, "pad_inches": 0.0} fig.savefig(stem.with_suffix(".pdf"), metadata={"CreationDate": None, "Creator": None, "Producer": None}, **kwargs) @@ -370,7 +370,7 @@ def _val(v, mode, col): row = agg[(agg.variant == v) & (agg["mode"] == mode)] return float(row[col].iloc[0]) if not row.empty else math.nan - # (a) integral violation — colored bars, hatched (uncoord.) vs solid (coord.) + # (a) integral violation: colored bars, hatched (uncoord.) vs solid (coord.) fig, ax = _make_panel() _grouped_bars( ax, @@ -382,7 +382,7 @@ def _val(v, mode, col): ) _save(fig, out / f"model_size_a{suffix}") - # (b) Pareto frontier — same color per variant + # (b) Pareto frontier: same color per variant entries = [df[(df["variant"] == v) & (df["mode"] == OFO_MODE)].iloc[0].to_dict() for v in variants] fig, ax = _make_panel() _plot_pareto(ax, entries, logistic_models, color_map=color_map, label_map={v: _pretty(v) for v in variants}) @@ -422,7 +422,7 @@ def _v(v, mode, col, sub=sub): color_map = {labels[i]: (COLORS["h100"] if i == 0 else COLORS["b200"]) for i in range(len(variants))} - # (a) integral violation — colored bars, hatch = baseline vs OFO + # (a) integral violation: colored bars, hatch = baseline vs OFO fig, ax = _make_panel() _grouped_bars( ax, @@ -434,7 +434,7 @@ def _v(v, mode, col, sub=sub): ) _save(fig, out / f"{stem}_a{suffix}") - # (b) Pareto — same colors. + # (b) Pareto: same colors. entries = [] for vv, lbl in zip(variants, labels, strict=True): r = df[(df["variant"] == vv) & (df["mode"] == OFO_MODE)].iloc[0].to_dict() @@ -488,7 +488,7 @@ def _v(pair_, hw, mode, col): # second), matching the parallelism and precision figures. pair_colors = {p: (COLORS["h100"] if i == 0 else COLORS["b200"]) for i, p in enumerate(pairs)} - # (a) — integral violation. X-axis = hardware. Within each hardware + # (a): integral violation. X-axis = hardware. Within each hardware # group, 4 bars: 2 models × (uncoord, coord). Color = model, hatch = # uncoord (hatched) / coord (solid). bar_width = 0.18 @@ -511,7 +511,7 @@ def _v(pair_, hw, mode, col): ax.grid(True, axis="y", alpha=0.2) _save(fig, out / f"hardware_a{suffix}") - # (b) — Pareto for both models on BOTH hardware. Color = model + # (b): Pareto for both models on BOTH hardware. Color = model # (shared with (a) and the main legend). Marker fill = hardware: # open = H100, filled = B200. An inline mini-legend inside the axes # explains the marker encoding. @@ -620,7 +620,7 @@ def _v(v, mode, col): color_map = {"bf16": COLORS["ofo"], "FP8": COLORS["hardware"]} - # (a) violation — colored bars, hatch = uncoord., solid = coord. + # (a) violation: colored bars, hatch = uncoord., solid = coord. fig, ax = _make_panel() _grouped_bars( ax, @@ -632,7 +632,7 @@ def _v(v, mode, col): ) _save(fig, out / f"precision_a{suffix}") - # (b) Pareto — same colors + # (b) Pareto: same colors entries = [] for v, lbl in zip(variants, labels, strict=True): r = df[(df["variant"] == v) & (df["mode"] == OFO_MODE)].iloc[0].to_dict() @@ -649,7 +649,7 @@ def _v(v, mode, col): _apply_pareto_axes(ax) _save(fig, out / f"precision_b{suffix}") - # Shared legend — colors only; hatch/solid explained in the caption. + # Shared legend: colors only; hatch/solid explained in the caption. # Display "BF16" (not "bf16") per paper style. def _display_prec(label: str) -> str: return "BF16" if label == "bf16" else label @@ -695,7 +695,7 @@ def main( for name, plotter in dual_gpu: path = indir / f"{name}{suffix}.csv" if not path.exists(): - logger.warning("Missing %s — skipping", path) + logger.warning("Missing %s: skipping", path) continue df = pd.read_csv(path) logger.info("Loaded %s: %d rows", path.name, len(df)) @@ -713,7 +713,7 @@ def main( for name, plotter in cross_gpu: path = indir / f"{name}_b200.csv" if not path.exists(): - logger.warning("Missing %s — skipping", path) + logger.warning("Missing %s: skipping", path) continue df = pd.read_csv(path) logger.info("Loaded %s: %d rows", path.name, len(df)) diff --git a/examples/model_insights/systems.py b/examples/model_insights/systems.py new file mode 100644 index 0000000..ef56ca0 --- /dev/null +++ b/examples/model_insights/systems.py @@ -0,0 +1,122 @@ +"""IEEE test feeder constants. + +Feeder-specific constants (DSS paths, regulator names, initial taps, +excluded buses, source voltage, bus voltage levels) are defined here. + +Experiment-specific parameters (datacenter sizing, controller tuning, +workload scenarios, model specs, simulation defaults) belong in +`experiment.py` or inline in each example script. +""" + +from __future__ import annotations + +from pathlib import Path + +from openg2g.grid.config import TapPosition + +GRID_DATA_DIR = Path(__file__).resolve().parent.parent.parent / "data" / "grid" + +TAP_STEP = 0.00625 # Standard 32-step regulator: +-10% in 32 steps + + +def tap(steps: int) -> float: + """Convert integer tap step to per-unit ratio. E.g., `tap(14)` -> 1.0875.""" + return 1.0 + steps * TAP_STEP + + +# IEEE test feeder constants + + +def ieee13() -> dict: + """IEEE 13-bus test feeder constants.""" + return dict( + dss_case_dir=GRID_DATA_DIR / "ieee13", + dss_master_file="IEEE13Bus.dss", + bus_kv=4.16, + source_pu=1.0, + initial_taps=TapPosition( + regulators={ + "creg1a": tap(14), + "creg1b": tap(6), + "creg1c": tap(15), + } + ), + exclude_buses=["sourcebus", "650", "rg60"], + ) + + +def ieee34() -> dict: + """IEEE 34-bus (half-line variant) test feeder constants.""" + # fmt: off + return dict( + dss_case_dir=GRID_DATA_DIR / "ieee34", + dss_master_file="IEEE34Bus.dss", + bus_kv=24.9, + source_pu=1.09, + initial_taps=TapPosition( + regulators={ + "creg1a": tap(12), + "creg1b": tap(8), + "creg1c": tap(10), + "creg2a": tap(8), + "creg2b": tap(8), + "creg2c": tap(8), + } + ), + exclude_buses=[ + "sourcebus", "800", "802", "806", "808", "810", "812", "814", "888", "890", + ], + regulator_zones={ + "creg1": ["814r", "850", "816", "824", "828", "830", "854"], + "creg2": ["852r", "832", "858", "834", "860", "836", "840", "862", "842", "844", "846", "848"], + }, + ) + # fmt: on + + +def ieee123() -> dict: + """IEEE 123-bus test feeder constants.""" + # fmt: off + return dict( + dss_case_dir=GRID_DATA_DIR / "ieee123", + dss_master_file="IEEE123Bus.dss", + bus_kv=4.16, + source_pu=1.0, + initial_taps=TapPosition( + regulators={ + "creg1a": tap(9), + "creg2a": tap(5), + "creg3a": tap(5), + "creg3c": tap(5), + "creg4a": tap(14), + "creg4b": tap(1), + "creg4c": tap(4), + } + ), + exclude_buses=[ + "sourcebus", "150", "150r", "149", "9r", "25r", "160r", "61s", "610", "300_open", "94_open", + ], + zones={ + "z1_sw": [ + "1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", + "12", "13", "14", "15", "16", "17", "18", "19", "20", "34", + ], + "z2_nw": [ + "21", "22", "23", "24", "25", "26", "27", "28", "29", "30", "31", "32", "33", "35", "36", + "37", "38", "39", "40", "41", "42", "43", "44", "45", "46", "47", "48", "49", "50", "51", + ], + "z3_se": [ + "52", "53", "54", "55", "56", "57", "58", "59", "60", "61", "62", "63", "64", "65", "66", + "67", "68", "69", "70", "71", "72", "73", "74", "75", "76", "77", "78", "79", "80", "81", + "82", "83", "84", "85", "86", "87", "88", "89", "90", "91", "92", "93", "94", "95", "96", + ], + "z4_ne": [ + "97", "98", "99", "100", "101", "102", "103", "104", "105", "106", "107", "108", "109", "110", + "111", "112", "113", "114", "115", "116", "117", "118", "119", "120", "121", "122", "123", + ], + }, + ) + # fmt: on + + +SYSTEMS = {"ieee13": ieee13, "ieee34": ieee34, "ieee123": ieee123} diff --git a/examples/offline/run_ofo.py b/examples/offline/run_ofo.py index 089704f..e6e5d14 100644 --- a/examples/offline/run_ofo.py +++ b/examples/offline/run_ofo.py @@ -164,7 +164,7 @@ def unpack_deployments( return models, schedules -# Data pipeline — per-spec content-addressed cache (see InferenceModelSpec.cache_hash) +# Data pipeline: per-spec content-addressed cache (see InferenceModelSpec.cache_hash) SPECS_CACHE_DIR = _PROJECT_ROOT / "data" / "specs" TRAINING_TRACE_PATH = _PROJECT_ROOT / "data" / "training_trace.csv" diff --git a/examples/offline/systems.py b/examples/offline/systems.py index 567ffc8..ef56ca0 100644 --- a/examples/offline/systems.py +++ b/examples/offline/systems.py @@ -1,243 +1,30 @@ -"""IEEE test feeder constants, shared simulation constants, and data pipeline helpers. +"""IEEE test feeder constants. Feeder-specific constants (DSS paths, regulator names, initial taps, excluded buses, source voltage, bus voltage levels) are defined here. -Shared infrastructure (PV/load profile generators, ScenarioOpenDSSGrid, -experiment configuration factories, scenario randomisation) also lives here -so that application scripts never become implicit libraries. + +Experiment-specific parameters (datacenter sizing, controller tuning, +workload scenarios, model specs, simulation defaults) belong in +`experiment.py` or inline in each example script. """ from __future__ import annotations -import math -from collections.abc import Callable -from dataclasses import dataclass -from fractions import Fraction from pathlib import Path -import numpy as np - -from openg2g.controller.ofo import OFOConfig -from openg2g.datacenter.config import ( - InferenceModelSpec, - ModelDeployment, - PowerAugmentationConfig, - ReplicaSchedule, - TrainingRun, -) -from openg2g.datacenter.workloads.inference import InferenceData -from openg2g.datacenter.workloads.training import TrainingTrace -from openg2g.grid.config import TapPosition, TapSchedule -from openg2g.grid.opendss import OpenDSSGrid +from openg2g.grid.config import TapPosition -PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent -GRID_DATA_DIR = PROJECT_ROOT / "data" / "grid" +GRID_DATA_DIR = Path(__file__).resolve().parent.parent.parent / "data" / "grid" -TAP_STEP = 0.00625 # Standard 32-step regulator: ±10% in 32 steps +TAP_STEP = 0.00625 # Standard 32-step regulator: +-10% in 32 steps def tap(steps: int) -> float: - """Convert integer tap step to per-unit ratio. E.g., ``tap(14)`` → 1.0875.""" + """Convert integer tap step to per-unit ratio. E.g., `tap(14)` -> 1.0875.""" return 1.0 + steps * TAP_STEP -# ── Shared simulation constants ────────────────────────────────────────────── - -DT_DC = Fraction(1) -DT_GRID = Fraction(1) -DT_CTRL = Fraction(1) -V_MIN, V_MAX = 0.95, 1.05 -TOTAL_DURATION_S = 3600 -POWER_AUG = PowerAugmentationConfig(amplitude_scale_range=(0.98, 1.02), noise_fraction=0.005) - - -# ── DC site descriptor ─────────────────────────────────────────────────────── - - -@dataclass -class DCSite: - """One datacenter site for simulation setup. - - Attributes: - bus: Distribution bus where the datacenter is connected. - bus_kv: Bus voltage level (kV). - base_kw_per_phase: Constant base load per phase (kW). - total_gpu_capacity: Total physical GPUs installed at this site. - models: (deployment, replica_schedule) pairs at this site. Replica - counts and runtime ramps both live on the ReplicaSchedule - (matching master's unpack_deployments() pattern). - seed: Random seed for layout generation. - connection_type: Grid connection type (``"wye"`` or ``"delta"``). - load_shift_headroom: Fraction of extra server capacity for load shifting. - """ - - bus: str - bus_kv: float - base_kw_per_phase: float - total_gpu_capacity: int - models: tuple[tuple[ModelDeployment, ReplicaSchedule], ...] = () - seed: int = 0 - connection_type: str = "wye" - load_shift_headroom: float = 0.0 - - -# ── Model specs (hardcoded; replaces config.json which master removed) ────── - -LLAMA_8B = InferenceModelSpec( - model_label="Llama-3.1-8B", - model_id="meta-llama/Llama-3.1-8B-Instruct", - gpu_model="H100", - task="lm-arena-chat", - precision="bfloat16", - gpus_per_replica=1, - tensor_parallel=1, - itl_deadline_s=0.08, - batch_sizes=(8, 16, 32, 64, 96, 128, 192, 256, 384, 512, 768, 1024), - feasible_batch_sizes=(8, 16, 32, 64, 128, 256, 512), -) -LLAMA_70B = InferenceModelSpec( - model_label="Llama-3.1-70B", - model_id="meta-llama/Llama-3.1-70B-Instruct", - gpu_model="H100", - task="lm-arena-chat", - precision="bfloat16", - gpus_per_replica=4, - tensor_parallel=4, - itl_deadline_s=0.10, - batch_sizes=(8, 16, 32, 64, 96, 128, 192, 256, 384, 512, 768, 1024, 1536, 2048), - feasible_batch_sizes=(8, 16, 32, 64, 128, 256, 512), -) -LLAMA_405B = InferenceModelSpec( - model_label="Llama-3.1-405B", - model_id="meta-llama/Llama-3.1-405B-Instruct-FP8", - gpu_model="H100", - task="lm-arena-chat", - precision="fp8", - gpus_per_replica=8, - tensor_parallel=8, - itl_deadline_s=0.12, - batch_sizes=(8, 16, 32, 64, 96, 128, 192, 256, 384, 512), - feasible_batch_sizes=(8, 16, 32, 64, 128, 256, 512), -) -QWEN_30B = InferenceModelSpec( - model_label="Qwen3-30B-A3B", - model_id="Qwen/Qwen3-30B-A3B-Thinking-2507", - gpu_model="H100", - task="gpqa", - precision="bfloat16", - gpus_per_replica=2, - tensor_parallel=2, - itl_deadline_s=0.06, - batch_sizes=(8, 16, 32, 64, 96, 128, 192, 256, 384, 512), - feasible_batch_sizes=(8, 16, 32, 64, 128, 256, 512), -) -QWEN_235B = InferenceModelSpec( - model_label="Qwen3-235B-A22B", - model_id="Qwen/Qwen3-235B-A22B-Thinking-2507", - gpu_model="H100", - task="gpqa", - precision="bfloat16", - gpus_per_replica=8, - tensor_parallel=8, - itl_deadline_s=0.14, - batch_sizes=(8, 16, 32, 64, 96, 128, 192, 256, 384, 512), - feasible_batch_sizes=(8, 16, 32, 64, 128, 256, 512), -) -ALL_MODEL_SPECS: tuple[InferenceModelSpec, ...] = (LLAMA_8B, LLAMA_70B, LLAMA_405B, QWEN_30B, QWEN_235B) -MODEL_SPECS: dict[str, InferenceModelSpec] = {s.model_label: s for s in ALL_MODEL_SPECS} - -SPECS_CACHE_DIR = PROJECT_ROOT / "data" / "specs" -TRAINING_TRACE_PATH = PROJECT_ROOT / "data" / "training_trace.csv" - - -def model_spec(label: str) -> InferenceModelSpec: - """Get a single model spec by label.""" - return MODEL_SPECS[label] - - -def all_model_specs() -> tuple[InferenceModelSpec, ...]: - """Return all model specs as a tuple (for data pipeline loading).""" - return ALL_MODEL_SPECS - - -def deploy( - label: str, - num_replicas: int, - initial_batch_size: int = 128, -) -> tuple[ModelDeployment, ReplicaSchedule]: - """Shorthand: ``deploy("Llama-3.1-8B", 720, 128)`` -> (ModelDeployment, ReplicaSchedule).""" - return ( - ModelDeployment(spec=MODEL_SPECS[label], initial_batch_size=initial_batch_size), - ReplicaSchedule(initial=num_replicas), - ) - - -def with_ramp( - deployment: tuple[ModelDeployment, ReplicaSchedule], - target: int, - *, - t_start: float, - t_end: float, -) -> tuple[ModelDeployment, ReplicaSchedule]: - """Inject a single ramp into a deploy() result. Convenience for ieee*_experiment factories.""" - md, sched = deployment - return (md, sched.ramp_to(target, t_start=t_start, t_end=t_end)) - - -def load_data_sources() -> tuple[InferenceData, None, Path]: - """Compatibility shim — returns (inference_data, None, data_dir). - - On master, the per-spec content-addressed cache replaced the JSON-driven - pipeline, so callers should ideally use ``InferenceData.ensure(SPECS_CACHE_DIR, - ALL_MODEL_SPECS, ...)`` directly. This shim preserves the 3-tuple shape for - in-flight callers while we migrate them. - """ - inference_data = InferenceData.ensure(SPECS_CACHE_DIR, ALL_MODEL_SPECS, plot=False) - return inference_data, None, SPECS_CACHE_DIR - - -# ── PV / time-varying load specs ──────────────────────────────────────────── - - -@dataclass -class PVSystemSpec: - """PV system at a distribution bus (used by ScenarioOpenDSSGrid).""" - - bus: str - bus_kv: float = 4.16 - peak_kw: float = 1000.0 - csv_path: Path | None = None - power_factor: float = 1.0 - # Optional shape randomness applied inside eval_profile: - # t_eff = (t - peak_t_shift_s) / time_warp - # Defaults are no-op so existing callers are unaffected. - peak_t_shift_s: float = 0.0 - time_warp: float = 1.0 - # Optional randomized profile selector. When profile_kind != "default" the - # eval_profile dispatcher uses the matching generator in - # sweep_dc_locations.py and ignores csv_path / profile_fn / time_warp / - # peak_t_shift_s. Valid kinds: "default", "random_flat" (PV). - profile_kind: str = "default" - profile_params: dict | None = None - - -@dataclass -class TimeVaryingLoadSpec: - """Time-varying load at a distribution bus (used by ScenarioOpenDSSGrid).""" - - bus: str - bus_kv: float = 4.16 - peak_kw: float = 500.0 - csv_path: Path | None = None - power_factor: float = 0.96 - peak_t_shift_s: float = 0.0 - time_warp: float = 1.0 - # Valid profile_kind for TVL: "default", "random_shape" (with shape param). - profile_kind: str = "default" - profile_params: dict | None = None - - -# ── IEEE test feeder constants ─────────────────────────────────────────────── +# IEEE test feeder constants def ieee13() -> dict: @@ -254,12 +41,13 @@ def ieee13() -> dict: "creg1c": tap(15), } ), - exclude_buses=("sourcebus", "650", "rg60"), + exclude_buses=["sourcebus", "650", "rg60"], ) def ieee34() -> dict: """IEEE 34-bus (half-line variant) test feeder constants.""" + # fmt: off return dict( dss_case_dir=GRID_DATA_DIR / "ieee34", dss_master_file="IEEE34Bus.dss", @@ -267,48 +55,28 @@ def ieee34() -> dict: source_pu=1.09, initial_taps=TapPosition( regulators={ - "creg1a": tap(11), - "creg1b": tap(6), - "creg1c": tap(8), + "creg1a": tap(12), + "creg1b": tap(8), + "creg1c": tap(10), "creg2a": tap(8), "creg2b": tap(8), "creg2c": tap(8), } ), - exclude_buses=( - "sourcebus", - "800", - "802", - "806", - "808", - "810", - "812", - "814", - "888", - "890", - ), + exclude_buses=[ + "sourcebus", "800", "802", "806", "808", "810", "812", "814", "888", "890", + ], regulator_zones={ "creg1": ["814r", "850", "816", "824", "828", "830", "854"], - "creg2": [ - "852r", - "832", - "858", - "834", - "860", - "836", - "840", - "862", - "842", - "844", - "846", - "848", - ], + "creg2": ["852r", "832", "858", "834", "860", "836", "840", "862", "842", "844", "846", "848"], }, ) + # fmt: on def ieee123() -> dict: """IEEE 123-bus test feeder constants.""" + # fmt: off return dict( dss_case_dir=GRID_DATA_DIR / "ieee123", dss_master_file="IEEE123Bus.dss", @@ -325,1132 +93,30 @@ def ieee123() -> dict: "creg4c": tap(4), } ), - exclude_buses=( - "sourcebus", - "150", - "150r", - "149", - "9r", - "25r", - "160r", - "61s", - "610", - "300_open", - "94_open", - "135", - ), + exclude_buses=[ + "sourcebus", "150", "150r", "149", "9r", "25r", "160r", "61s", "610", "300_open", "94_open", + ], zones={ "z1_sw": [ - "1", - "2", - "3", - "4", - "5", - "6", - "7", - "8", - "9", - "10", - "11", - "12", - "13", - "14", - "15", - "16", - "17", - "18", - "19", - "20", - "34", + "1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", + "12", "13", "14", "15", "16", "17", "18", "19", "20", "34", ], "z2_nw": [ - "21", - "22", - "23", - "24", - "25", - "26", - "27", - "28", - "29", - "30", - "31", - "32", - "33", - "35", - "36", - "37", - "38", - "39", - "40", - "41", - "42", - "43", - "44", - "45", - "46", - "47", - "48", - "49", - "50", - "51", + "21", "22", "23", "24", "25", "26", "27", "28", "29", "30", "31", "32", "33", "35", "36", + "37", "38", "39", "40", "41", "42", "43", "44", "45", "46", "47", "48", "49", "50", "51", ], "z3_se": [ - "52", - "53", - "54", - "55", - "56", - "57", - "58", - "59", - "60", - "61", - "62", - "63", - "64", - "65", - "66", - "67", - "68", - "69", - "70", - "71", - "72", - "73", - "74", - "75", - "76", - "77", - "78", - "79", - "80", - "81", - "82", - "83", - "84", - "85", - "86", - "87", - "88", - "89", - "90", - "91", - "92", - "93", - "94", - "95", - "96", + "52", "53", "54", "55", "56", "57", "58", "59", "60", "61", "62", "63", "64", "65", "66", + "67", "68", "69", "70", "71", "72", "73", "74", "75", "76", "77", "78", "79", "80", "81", + "82", "83", "84", "85", "86", "87", "88", "89", "90", "91", "92", "93", "94", "95", "96", ], "z4_ne": [ - "97", - "98", - "99", - "100", - "101", - "102", - "103", - "104", - "105", - "106", - "107", - "108", - "109", - "110", - "111", - "112", - "113", - "114", - "115", - "116", - "117", - "118", - "119", - "120", - "121", - "122", - "123", + "97", "98", "99", "100", "101", "102", "103", "104", "105", "106", "107", "108", "109", "110", + "111", "112", "113", "114", "115", "116", "117", "118", "119", "120", "121", "122", "123", ], }, ) + # fmt: on SYSTEMS = {"ieee13": ieee13, "ieee34": ieee34, "ieee123": ieee123} - - -# ── Profile helpers ─────────────────────────────────────────────────────────── - - -def _smooth_bump(t: float, t_center: float, half_width: float) -> float: - dt = abs(t - t_center) - if dt >= half_width: - return 0.0 - x = dt / half_width - return (1 - x * x) ** 2 - - -def _smoothstep(t: float, t_start: float, t_end: float) -> float: - """Cubic Hermite smoothstep: zero-derivative at both endpoints.""" - if t_end <= t_start: - return 1.0 if t >= t_start else 0.0 - if t <= t_start: - return 0.0 - if t >= t_end: - return 1.0 - x = (t - t_start) / (t_end - t_start) - return x * x * (3.0 - 2.0 * x) - - -def _irregular_fluct(t: float, seed: float = 0.0) -> float: - """Irregular fluctuation via superposition of incommensurate frequencies.""" - s = seed - f1 = 0.06 * math.sin(2 * math.pi * t / 173.0 + s) - f2 = 0.05 * math.sin(2 * math.pi * t / 97.3 + s * 2.3) - f3 = 0.04 * math.sin(2 * math.pi * t / 251.7 + s * 0.7) - f4 = 0.03 * math.sin(2 * math.pi * t / 41.9 + s * 4.1) - f5 = 0.02 * math.sin(2 * math.pi * t / 317.3 + s * 1.9) - return 1.0 + f1 + f2 + f3 + f4 + f5 - - -def pv_profile_kw(t: float, peak_kw: float, site_idx: int = 0) -> float: - """Solar PV output (kW per phase) with per-site cloud patterns.""" - T = TOTAL_DURATION_S - if site_idx == 0: - trend = 0.85 - 0.30 * (t / T) - cloud = 1.0 - cloud -= 0.55 * _smooth_bump(t, 600, 120) - cloud -= 0.40 * _smooth_bump(t, 2100, 180) - fluct = _irregular_fluct(t, seed=0.3) - return max(0.0, peak_kw * trend * max(cloud, 0.05) * fluct) - elif site_idx == 1: - ramp = 0.55 + 0.40 * _smooth_bump(t, 1200, 900) - cloud = 1.0 - cloud -= 0.60 * _smooth_bump(t, 1680, 240) - cloud -= 0.25 * _smooth_bump(t, 2400, 150) - fluct = _irregular_fluct(t, seed=2.1) - return max(0.0, peak_kw * ramp * max(cloud, 0.05) * fluct) - else: - ramp = 0.30 + 0.65 * min(1.0, t / 900.0) - cloud = 1.0 - cloud -= 0.70 * _smooth_bump(t, 2700, 300) - cloud -= 0.30 * _smooth_bump(t, 1200, 100) - fluct = _irregular_fluct(t, seed=2.0 + site_idx * 3.7) - return max(0.0, peak_kw * ramp * max(cloud, 0.05) * fluct) - - -def load_profile_kw(t: float, peak_kw: float, site_idx: int = 0) -> float: - fluct_period = 130.0 + site_idx * 37 - fluct = 1.0 + 0.06 * math.sin(2 * math.pi * t / fluct_period + site_idx * 1.4) - - if site_idx == 0: - base = 0.15 + 0.85 * _smooth_bump(t, 2280, 1400) - surge = 0.20 * _smooth_bump(t, 2280, 180) - return max(0.0, peak_kw * (base + surge) * fluct) - elif site_idx == 1: - base = 0.10 - base += 0.50 * _smooth_bump(t, 1500, 600) - base += 0.80 * _smooth_bump(t, 2880, 500) - return max(0.0, peak_kw * base * fluct) - elif site_idx == 2: - base = 0.80 - 0.55 * _smooth_bump(t, 1800, 1200) - surge = 0.70 * _smooth_bump(t, 2520, 400) - return max(0.0, peak_kw * (base + surge) * fluct) - elif site_idx == 3: - base = 0.10 + 0.90 * _smooth_bump(t, 3120, 800) - return max(0.0, peak_kw * base * fluct) - else: - base = 0.10 - base += 0.60 * _smooth_bump(t, 1080, 300) - base += 0.75 * _smooth_bump(t, 2100, 350) - base += 0.90 * _smooth_bump(t, 3300, 300) - return max(0.0, peak_kw * base * fluct) - - -def pv_profile_random(t: float, peak_kw: float, params: dict) -> float: - """Multi-shape PV profile with random cloud events (1-hour episode). - - params["shape"] picks the envelope: - "flat" constant baseline 0.75-0.95 - "rising_falling" smooth bump from low_baseline up to high_baseline and back - "morning_ramp" low → high over a short ramp window, sustained high after - "afternoon_decline" sustained high then ramp down at the end - "midday_dip" high baseline with a substantial mid-episode dip - """ - shape = params.get("shape", "flat") - T = float(TOTAL_DURATION_S) - - if shape == "flat": - env = float(params.get("baseline", 0.85)) - elif shape == "rising_falling": - lo = float(params.get("low_baseline", 0.50)) - hi = float(params.get("high_baseline", 0.95)) - peak_t = float(params.get("peak_t", T / 2)) - half_width = float(params.get("half_width", 1200.0)) - env = lo + (hi - lo) * _smooth_bump(t, peak_t, half_width) - elif shape == "morning_ramp": - lo = float(params.get("low_baseline", 0.25)) - hi = float(params.get("high_baseline", 0.75)) - ramp_start = float(params.get("ramp_start", 100.0)) - ramp_end = float(params.get("ramp_end", 1200.0)) - env = lo + (hi - lo) * _smoothstep(t, ramp_start, ramp_end) - elif shape == "afternoon_decline": - lo = float(params.get("low_baseline", 0.25)) - hi = float(params.get("high_baseline", 0.75)) - ramp_start = float(params.get("ramp_start", 2400.0)) - ramp_end = float(params.get("ramp_end", T - 100.0)) - env = hi - (hi - lo) * _smoothstep(t, ramp_start, ramp_end) - elif shape == "midday_dip": - hi = float(params.get("high_baseline", 0.90)) - dip_t = float(params.get("dip_t", T / 2)) - dip_half_width = float(params.get("dip_half_width", 700.0)) - dip_depth = float(params.get("dip_depth", 0.55)) - env = hi - dip_depth * _smooth_bump(t, dip_t, dip_half_width) - else: - env = 0.85 - - for tc, hw, depth in params.get("clouds", ()): - env -= float(depth) * _smooth_bump(t, float(tc), float(hw)) - - env = max(0.15, env) - noise_amp = float(params.get("noise_amp", 0.0)) - if noise_amp > 0: - f = _irregular_fluct(t, seed=float(params.get("noise_seed", 0.0))) - env *= 1.0 + (noise_amp / 0.20) * (f - 1.0) - return max(0.0, peak_kw * env) - - -def tvl_profile_random(t: float, peak_kw: float, params: dict) -> float: - """Multi-shape TVL profile (1-hour episode).""" - shape = params.get("shape", "peaked") - T = float(TOTAL_DURATION_S) - if shape == "flat": - base = float(params.get("level", 0.7)) - elif shape == "increasing": - lo = float(params.get("lo", 0.2)) - hi = float(params.get("hi", 0.9)) - base = lo + (hi - lo) * min(1.0, max(0.0, t / T)) - elif shape == "decreasing": - lo = float(params.get("lo", 0.2)) - hi = float(params.get("hi", 0.9)) - base = hi - (hi - lo) * min(1.0, max(0.0, t / T)) - elif shape == "peaked": - peak_t = float(params.get("peak_t", T / 2)) - peak_w = float(params.get("peak_w", 1400.0)) - baseline = float(params.get("baseline", 0.15)) - amp = float(params.get("amp", 0.85)) - base = baseline + amp * _smooth_bump(t, peak_t, peak_w) - elif shape == "valley": - valley_t = float(params.get("valley_t", T / 2)) - valley_w = float(params.get("valley_w", 1200.0)) - high = float(params.get("high", 0.85)) - depth = float(params.get("depth", 0.55)) - base = high - depth * _smooth_bump(t, valley_t, valley_w) - else: - base = 0.5 - base = max(0.0, base) - noise_amp = float(params.get("noise_amp", 0.0)) - if noise_amp > 0: - f = _irregular_fluct(t, seed=float(params.get("noise_seed", 0.0))) - base *= 1.0 + (noise_amp / 0.20) * (f - 1.0) - return max(0.0, peak_kw * base) - - -def load_csv_profile(csv_path: Path) -> tuple[np.ndarray, np.ndarray]: - data = np.loadtxt(csv_path, delimiter=",", skiprows=1) - return data[:, 0], data[:, 1] - - -def eval_profile( - t, - *, - peak_kw, - csv_data, - profile_fn, - site_idx, - peak_t_shift_s: float = 0.0, - time_warp: float = 1.0, - profile_kind: str = "default", - profile_params: dict | None = None, -): - """Evaluate a PV/TVL profile at simulated time ``t``. - - Dispatch order: - 1. ``profile_kind="random_flat"`` + ``profile_params`` -> pv_profile_random - 2. ``profile_kind="random_shape"`` + ``profile_params`` -> tvl_profile_random - 3. ``csv_data`` not None -> CSV interpolation - 4. ``profile_fn`` (legacy analytical pv_profile_kw / load_profile_kw) - """ - if profile_kind == "random_flat" and profile_params is not None: - return pv_profile_random(t, peak_kw, profile_params) - if profile_kind == "random_shape" and profile_params is not None: - return tvl_profile_random(t, peak_kw, profile_params) - if time_warp <= 0: - time_warp = 1.0 - t_eff = (t - peak_t_shift_s) / time_warp - if csv_data is not None: - return float(np.interp(t_eff, csv_data[0], csv_data[1])) - return profile_fn(t_eff, peak_kw, site_idx) - - -# ── Scenario grid ───────────────────────────────────────────────────────────── - - -class ScenarioOpenDSSGrid(OpenDSSGrid): - """OpenDSSGrid with PV systems and external loads at arbitrary buses.""" - - def __init__( - self, *, pv_systems=None, time_varying_loads=None, source_pu=None, constant_pv: bool = False, **kwargs - ): - super().__init__(**kwargs) - self._pv_specs = list(pv_systems or []) - self._load_specs = list(time_varying_loads or []) - self._source_pu = source_pu - self._constant_pv = constant_pv - - self._pv_csv = [load_csv_profile(s.csv_path) if s.csv_path else None for s in self._pv_specs] - self._load_csv = [load_csv_profile(s.csv_path) if s.csv_path else None for s in self._load_specs] - self._pv_load_names = [(f"PV_{i}_A", f"PV_{i}_B", f"PV_{i}_C") for i in range(len(self._pv_specs))] - self._ext_load_names = [ - (f"ExtLoad_{i}_A", f"ExtLoad_{i}_B", f"ExtLoad_{i}_C") for i in range(len(self._load_specs)) - ] - - def _init_dss(self) -> None: - super()._init_dss() - from openg2g.grid.opendss import dss - - if self._source_pu is not None: - dss.Text.Command(f"Edit Vsource.source pu={self._source_pu}") - - for i, spec in enumerate(self._pv_specs): - kv_ln = spec.bus_kv / math.sqrt(3.0) - for ph, name in zip((1, 2, 3), self._pv_load_names[i], strict=False): - dss.Text.Command( - f"New Load.{name} bus1={spec.bus}.{ph} phases=1 " - f"conn=wye kV={kv_ln:.6f} kW=0 kvar=0 model=1 vminpu=0.85" - ) - - for i, spec in enumerate(self._load_specs): - kv_ln = spec.bus_kv / math.sqrt(3.0) - for ph, name in zip((1, 2, 3), self._ext_load_names[i], strict=False): - dss.Text.Command( - f"New Load.{name} bus1={spec.bus}.{ph} phases=1 " - f"conn=wye kV={kv_ln:.6f} kW=0 kvar=0 model=1 vminpu=0.85" - ) - - def step(self, clock, power_samples_w, events): - from openg2g.grid.opendss import dss - - for i, spec in enumerate(self._pv_specs): - if self._constant_pv: - kw = spec.peak_kw - else: - kw = eval_profile( - clock.time_s, - peak_kw=spec.peak_kw, - csv_data=self._pv_csv[i], - profile_fn=pv_profile_kw, - site_idx=i, - peak_t_shift_s=getattr(spec, "peak_t_shift_s", 0.0), - time_warp=getattr(spec, "time_warp", 1.0), - profile_kind=getattr(spec, "profile_kind", "default"), - profile_params=getattr(spec, "profile_params", None), - ) - pf = max(min(spec.power_factor, 0.999999), 1e-6) - kvar = kw * math.tan(math.acos(pf)) - for name in self._pv_load_names[i]: - dss.Loads.Name(name) - dss.Loads.kW(-kw) - dss.Loads.kvar(-kvar) - - for i, spec in enumerate(self._load_specs): - kw = eval_profile( - clock.time_s, - peak_kw=spec.peak_kw, - csv_data=self._load_csv[i], - profile_fn=load_profile_kw, - site_idx=i, - peak_t_shift_s=getattr(spec, "peak_t_shift_s", 0.0), - time_warp=getattr(spec, "time_warp", 1.0), - profile_kind=getattr(spec, "profile_kind", "default"), - profile_params=getattr(spec, "profile_params", None), - ) - pf = max(min(spec.power_factor, 0.999999), 1e-6) - kvar = kw * math.tan(math.acos(pf)) - for name in self._ext_load_names[i]: - dss.Loads.Name(name) - dss.Loads.kW(kw) - dss.Loads.kvar(kvar) - - return super().step(clock, power_samples_w, events) - - -# ── Scenario randomisation helpers ──────────────────────────────────────────── - - -def _site_inference_gpus(site: DCSite) -> int: - """Sum of GPUs consumed by inference at a site (replicas × gpus_per_replica).""" - return sum(sched.initial * md.spec.gpus_per_replica for md, sched in site.models) - - -def _randomize_ramps( - dc_sites: dict[str, DCSite], - rng: np.random.Generator, - *, - ramp_frac_min: float = 0.15, - ramp_frac_max: float = 0.3, - ramp_start_min: float = 500.0, - ramp_start_max: float = 3000.0, - ramp_dur_min: float = 300.0, - ramp_dur_max: float = 800.0, -) -> dict[str, DCSite]: - """Return a copy of dc_sites with randomized ramp targets and timing.""" - ramp_frac = rng.uniform(ramp_frac_min, ramp_frac_max) - ramp_start = rng.uniform(ramp_start_min, ramp_start_max) - ramp_dur = rng.uniform(ramp_dur_min, ramp_dur_max) - ramp_end = ramp_start + ramp_dur - - new_sites: dict[str, DCSite] = {} - for sid, site in dc_sites.items(): - new_models: list[tuple[ModelDeployment, ReplicaSchedule]] = [] - for md, sched in site.models: - target = max(1, int(ramp_frac * sched.initial)) - new_sched = ReplicaSchedule(initial=sched.initial).ramp_to( - target, - t_start=ramp_start, - t_end=ramp_end, - ) - new_models.append((md, new_sched)) - new_sites[sid] = DCSite( - bus=site.bus, - bus_kv=site.bus_kv, - base_kw_per_phase=site.base_kw_per_phase, - total_gpu_capacity=site.total_gpu_capacity, - models=tuple(new_models), - seed=int(rng.integers(0, 10000)), - connection_type=site.connection_type, - ) - return new_sites - - -def _randomize_broad_ramps( - dc_sites: dict[str, DCSite], - rng: np.random.Generator, - *, - overlay_gpus_at_first_site: int, - n_ramps_per_site_choices: tuple[int, ...] = (1, 2), - ramp_up_prob: float = 0.5, - ramp_down_frac_min: float = 0.15, - ramp_down_frac_max: float = 0.5, - ramp_up_frac_min: float = 1.05, - ramp_up_frac_max: float = 1.5, - ramp_start_min: float = 500.0, - ramp_start_max: float = 3000.0, - ramp_dur_min: float = 300.0, - ramp_dur_max: float = 800.0, - exclude_window: tuple[float, float] | None = None, -) -> dict[str, DCSite]: - """Bidirectional, multi-ramp generator that respects DC GPU capacity.""" - if exclude_window is not None: - ex_lo, ex_hi = exclude_window - zone1_hi = ex_lo - ramp_dur_max - zone2_lo = ex_hi - zones: list[tuple[float, float]] = [] - if ramp_start_min < zone1_hi: - zones.append((ramp_start_min, min(zone1_hi, ramp_start_max))) - if zone2_lo < ramp_start_max: - zones.append((max(zone2_lo, ramp_start_min), ramp_start_max)) - if not zones: - zones = [(ramp_start_min, ramp_start_max)] - else: - zones = [(ramp_start_min, ramp_start_max)] - - total_width = sum(hi - lo for lo, hi in zones) - - new_sites: dict[str, DCSite] = {} - sites_list = list(dc_sites.items()) - for site_idx, (sid, site) in enumerate(sites_list): - current_gpus = _site_inference_gpus(site) - overlay_here = overlay_gpus_at_first_site if site_idx == 0 else 0 - available_gpus = max(0, site.total_gpu_capacity - overlay_here) - max_feasible_up = available_gpus / current_gpus if current_gpus > 0 else 1.0 - site_ramp_up_max = min(ramp_up_frac_max, max_feasible_up) - can_up_ramp = site_ramp_up_max >= max(1.05, ramp_up_frac_min) - - n_ramps = int(rng.choice(n_ramps_per_site_choices)) - zone_counts = [int(n_ramps * (hi - lo) / total_width) for lo, hi in zones] - remainder = n_ramps - sum(zone_counts) - if remainder > 0: - widest = max(range(len(zones)), key=lambda i: zones[i][1] - zones[i][0]) - zone_counts[widest] += remainder - - # Per-model schedule starts from the model's existing initial count. - model_scheds: dict[str, ReplicaSchedule] = { - md.spec.model_label: ReplicaSchedule(initial=sched.initial) for md, sched in site.models - } - for (z_lo, z_hi), z_count in zip(zones, zone_counts, strict=False): - if z_count == 0: - continue - band_width = (z_hi - z_lo) / z_count - for bi in range(z_count): - band_lo = z_lo + bi * band_width - band_hi = z_lo + (bi + 1) * band_width - if band_width < ramp_dur_min: - continue - t_dur = float(rng.uniform(ramp_dur_min, min(ramp_dur_max, band_width))) - t_start = float(rng.uniform(band_lo, max(band_lo + 1.0, band_hi - t_dur))) - t_end = t_start + t_dur - - if can_up_ramp and rng.random() < ramp_up_prob: - lo = max(1.05, ramp_up_frac_min) - hi = max(lo + 1e-3, site_ramp_up_max) - frac = float(rng.uniform(lo, hi)) - else: - frac = float(rng.uniform(ramp_down_frac_min, ramp_down_frac_max)) - - for md, sched in site.models: - target = max(1, int(round(frac * sched.initial))) - label = md.spec.model_label - model_scheds[label] = model_scheds[label].ramp_to( - target, - t_start=t_start, - t_end=t_end, - ) - - new_models = tuple((md, model_scheds[md.spec.model_label]) for md, _ in site.models) - new_sites[sid] = DCSite( - bus=site.bus, - bus_kv=site.bus_kv, - base_kw_per_phase=site.base_kw_per_phase, - total_gpu_capacity=site.total_gpu_capacity, - models=new_models, - seed=int(rng.integers(0, 10000)), - connection_type=site.connection_type, - ) - return new_sites - - -def randomize_scenario( - seed: int, - *, - dc_sites_base: dict[str, DCSite], - pv_systems_base: list[PVSystemSpec], - tvl_base: list[TimeVaryingLoadSpec], - training_base: dict | None, - randomize_ramps: bool = True, - ramp_frac_min: float = 0.15, - ramp_frac_max: float = 0.3, - ramp_start_min: float = 500.0, - ramp_start_max: float = 3000.0, - ramp_dur_min: float = 300.0, - ramp_dur_max: float = 800.0, - randomization_profile: bool = True, - pv_scale_min: float = 0.5, - pv_scale_max: float = 2.0, - load_scale_min: float = 0.5, - load_scale_max: float = 2.0, - pv_t_shift_max_s: float = 0.0, - tvl_t_shift_max_s: float = 0.0, - pv_warp_min: float = 1.0, - pv_warp_max: float = 1.0, - tvl_warp_min: float = 1.0, - tvl_warp_max: float = 1.0, - overlay_prob: float = 1.0, - overlay_intensity_min: float = 1.0, - overlay_intensity_max: float = 1.0, - overlay_gpu_frac_min: float = 0.85, - overlay_gpu_frac_max: float = 1.0, - n_ramps_per_site_choices: tuple[int, ...] = (1,), - ramp_up_prob: float = 0.0, - ramp_down_frac_min: float = 0.15, - ramp_down_frac_max: float = 0.5, - ramp_up_frac_min: float = 1.05, - ramp_up_frac_max: float = 1.5, - randomize_pv_profile: bool = False, - pv_shape_choices: tuple[str, ...] = ( - "flat", - "rising_falling", - "morning_ramp", - "afternoon_decline", - "midday_dip", - ), - pv_baseline_min: float = 0.75, - pv_baseline_max: float = 0.95, - pv_cloud_count_max: int = 3, - pv_cloud_depth_min: float = 0.30, - pv_cloud_depth_max: float = 0.70, - pv_cloud_width_min: float = 60.0, - pv_cloud_width_max: float = 300.0, - randomize_tvl_profile: bool = False, - tvl_shape_choices: tuple[str, ...] = ("flat", "increasing", "decreasing", "peaked", "valley"), -) -> dict: - """Build a single randomized episode scenario from a seed.""" - rng = np.random.default_rng(seed=seed) - is_broad = randomization_profile if isinstance(randomization_profile, bool) else (randomization_profile == "broad") - - training_run = None - train_overlay_meta: dict | None = None - overlay_gpus_at_first_site = 0 - if training_base is not None: - if is_broad: - overlay_on = bool(rng.random() < overlay_prob) - else: - overlay_on = True - if overlay_on: - train_dur = float(rng.uniform(500.0, 1200.0)) - if is_broad: - train_start = float(rng.uniform(0.0, max(0.0, float(TOTAL_DURATION_S) - train_dur))) - else: - train_start = float(rng.uniform(500.0, 1500.0)) - gpu_frac = ( - float(rng.uniform(overlay_gpu_frac_min, overlay_gpu_frac_max)) - if is_broad - else float(rng.uniform(0.85, 1.0)) - ) - train_gpus = int(gpu_frac * training_base["n_gpus"]) - intensity = float(rng.uniform(overlay_intensity_min, overlay_intensity_max)) if is_broad else 1.0 - target_peak = training_base["target_peak_W_per_gpu"] * intensity - training_run = TrainingRun( - n_gpus=train_gpus, - trace=training_base["trace"], - target_peak_W_per_gpu=target_peak, - ).at(t_start=train_start, t_end=train_start + train_dur) - train_overlay_meta = { - "n_gpus": train_gpus, - "target_peak_W_per_gpu": target_peak, - "intensity": intensity, - "t_start": train_start, - "t_end": train_start + train_dur, - } - overlay_gpus_at_first_site = train_gpus - - if randomize_ramps: - if is_broad: - overlay_window: tuple[float, float] | None = None - if train_overlay_meta is not None: - overlay_window = (train_overlay_meta["t_start"], train_overlay_meta["t_end"]) - sites = _randomize_broad_ramps( - dc_sites_base, - rng, - overlay_gpus_at_first_site=overlay_gpus_at_first_site, - n_ramps_per_site_choices=tuple(n_ramps_per_site_choices), - ramp_up_prob=ramp_up_prob, - ramp_down_frac_min=ramp_down_frac_min, - ramp_down_frac_max=ramp_down_frac_max, - ramp_up_frac_min=ramp_up_frac_min, - ramp_up_frac_max=ramp_up_frac_max, - ramp_start_min=ramp_start_min, - ramp_start_max=ramp_start_max, - ramp_dur_min=ramp_dur_min, - ramp_dur_max=ramp_dur_max, - exclude_window=overlay_window, - ) - else: - sites = _randomize_ramps( - dc_sites_base, - rng, - ramp_frac_min=ramp_frac_min, - ramp_frac_max=ramp_frac_max, - ramp_start_min=ramp_start_min, - ramp_start_max=ramp_start_max, - ramp_dur_min=ramp_dur_min, - ramp_dur_max=ramp_dur_max, - ) - else: - sites = dict(dc_sites_base) - - pv_scale = float(rng.uniform(pv_scale_min, pv_scale_max)) if is_broad else float(rng.uniform(0.5, 2.0)) - load_scale = float(rng.uniform(load_scale_min, load_scale_max)) if is_broad else float(rng.uniform(0.5, 2.0)) - pv_t_shift = float(rng.uniform(-pv_t_shift_max_s, pv_t_shift_max_s)) if (is_broad and pv_t_shift_max_s > 0) else 0.0 - tvl_t_shift = ( - float(rng.uniform(-tvl_t_shift_max_s, tvl_t_shift_max_s)) if (is_broad and tvl_t_shift_max_s > 0) else 0.0 - ) - pv_warp = float(rng.uniform(pv_warp_min, pv_warp_max)) if is_broad else 1.0 - tvl_warp = float(rng.uniform(tvl_warp_min, tvl_warp_max)) if is_broad else 1.0 - - def _sample_pv_profile(rng_) -> tuple[str, dict | None]: - n_clouds = int(rng_.integers(0, pv_cloud_count_max + 1)) - clouds = [ - ( - float(rng_.uniform(120.0, 3480.0)), - float(rng_.uniform(pv_cloud_width_min, pv_cloud_width_max)), - float(rng_.uniform(pv_cloud_depth_min, pv_cloud_depth_max)), - ) - for _ in range(n_clouds) - ] - shape = str(rng_.choice(list(pv_shape_choices))) - params: dict = { - "shape": shape, - "clouds": clouds, - "noise_amp": float(rng_.uniform(0.02, 0.06)), - "noise_seed": float(rng_.uniform(0.0, 10.0)), - } - if shape == "flat": - params["baseline"] = float(rng_.uniform(pv_baseline_min, pv_baseline_max)) - elif shape == "rising_falling": - lo = float(rng_.uniform(0.15, 0.55)) - params["low_baseline"] = lo - params["high_baseline"] = float(rng_.uniform(lo + 0.20, 1.00)) - params["peak_t"] = float(rng_.uniform(900.0, 2700.0)) - params["half_width"] = float(rng_.uniform(800.0, 1500.0)) - elif shape == "morning_ramp": - lo = float(rng_.uniform(0.15, 0.45)) - params["low_baseline"] = lo - params["high_baseline"] = float(rng_.uniform(lo + 0.20, 1.00)) - params["ramp_start"] = float(rng_.uniform(0.0, 400.0)) - params["ramp_end"] = float(rng_.uniform(800.0, 1800.0)) - elif shape == "afternoon_decline": - lo = float(rng_.uniform(0.15, 0.45)) - params["low_baseline"] = lo - params["high_baseline"] = float(rng_.uniform(lo + 0.20, 1.00)) - params["ramp_start"] = float(rng_.uniform(1800.0, 2800.0)) - params["ramp_end"] = float(rng_.uniform(3200.0, 3600.0)) - elif shape == "midday_dip": - params["high_baseline"] = float(rng_.uniform(0.30, 1.00)) - params["dip_t"] = float(rng_.uniform(1200.0, 2400.0)) - params["dip_half_width"] = float(rng_.uniform(500.0, 1000.0)) - params["dip_depth"] = float(rng_.uniform(0.20, 0.55)) - return "random_flat", params - - tvl_profile_kind = "default" - tvl_profile_params: dict | None = None - if is_broad and randomize_tvl_profile: - shape = str(rng.choice(list(tvl_shape_choices))) - params: dict = {"shape": shape} - if shape == "flat": - params["level"] = float(rng.uniform(0.5, 0.85)) - elif shape == "increasing" or shape == "decreasing": - params["lo"] = float(rng.uniform(0.10, 0.30)) - params["hi"] = float(rng.uniform(0.65, 0.95)) - elif shape == "peaked": - params["peak_t"] = float(rng.uniform(800.0, 2800.0)) - params["peak_w"] = float(rng.uniform(800.0, 1800.0)) - params["baseline"] = float(rng.uniform(0.10, 0.30)) - params["amp"] = float(rng.uniform(0.55, 0.90)) - elif shape == "valley": - params["valley_t"] = float(rng.uniform(800.0, 2800.0)) - params["valley_w"] = float(rng.uniform(800.0, 1500.0)) - params["high"] = float(rng.uniform(0.70, 0.90)) - params["depth"] = float(rng.uniform(0.40, 0.70)) - params["noise_amp"] = float(rng.uniform(0.02, 0.06)) - params["noise_seed"] = float(rng.uniform(0.0, 10.0)) - tvl_profile_kind = "random_shape" - tvl_profile_params = params - - pv_systems_out = [] - for s in pv_systems_base: - if is_broad and randomize_pv_profile: - p_kind, p_params = _sample_pv_profile(rng) - else: - p_kind, p_params = "default", None - pv_systems_out.append( - PVSystemSpec( - bus=s.bus, - bus_kv=s.bus_kv, - peak_kw=s.peak_kw * pv_scale, - peak_t_shift_s=pv_t_shift, - time_warp=pv_warp, - profile_kind=p_kind, - profile_params=p_params, - ) - ) - tvl = [ - TimeVaryingLoadSpec( - bus=s.bus, - bus_kv=s.bus_kv, - peak_kw=s.peak_kw * load_scale, - peak_t_shift_s=tvl_t_shift, - time_warp=tvl_warp, - profile_kind=tvl_profile_kind, - profile_params=tvl_profile_params, - ) - for s in tvl_base - ] - - batch_choices = [32, 64, 128] - initial_batch_map: dict[str, int] = {} - new_sites: dict[str, DCSite] = {} - for sid, site in sites.items(): - new_models: list[tuple[ModelDeployment, ReplicaSchedule]] = [] - for md, sched in site.models: - bs = int(rng.choice(batch_choices)) - initial_batch_map[md.spec.model_label] = bs - new_models.append( - (ModelDeployment(spec=md.spec, initial_batch_size=bs), sched), - ) - new_sites[sid] = DCSite( - bus=site.bus, - bus_kv=site.bus_kv, - base_kw_per_phase=site.base_kw_per_phase, - total_gpu_capacity=site.total_gpu_capacity, - models=tuple(new_models), - seed=site.seed, - connection_type=site.connection_type, - ) - - return { - "seed": int(seed), - "dc_sites": new_sites, - "pv_systems": pv_systems_out, - "tvl": tvl, - "training_run": training_run, - "params": { - "pv_scale": pv_scale, - "load_scale": load_scale, - "pv_t_shift_s": pv_t_shift, - "tvl_t_shift_s": tvl_t_shift, - "pv_warp": pv_warp, - "tvl_warp": tvl_warp, - "training_overlay": train_overlay_meta, - "initial_batch_sizes": initial_batch_map, - "randomization_profile": randomization_profile, - "tvl_profile_kind": tvl_profile_kind, - "tvl_profile_params": tvl_profile_params, - }, - } - - -def materialize_scenario(rec, *, training_base: dict | None) -> dict: - """Return the per-episode scenario dict directly from a ``ScenarioRecord``, - without rng replay. - - Mirrors the shape returned by :func:`randomize_scenario` so consumers - (eval pipeline, training make_sim) can swap in transparently. The record - is expected to carry resolved ``dc_sites`` / ``pv_systems`` / ``tvl`` — - libraries built before this field set need to be upgraded with - ``examples/offline/migrate_scenario_library.py``. - """ - if getattr(rec, "resolved_dc_sites", None) is None: - raise RuntimeError( - f"ScenarioRecord(seed={rec.seed}) is missing resolved fields. " - "Run examples/offline/migrate_scenario_library.py on the library." - ) - overlay = getattr(rec, "training_overlay", None) - training_run = None - if overlay is not None and training_base is not None and training_base.get("trace") is not None: - training_run = TrainingRun( - n_gpus=overlay["n_gpus"], - trace=training_base["trace"], - target_peak_W_per_gpu=overlay["target_peak_W_per_gpu"], - ).at(t_start=overlay["t_start"], t_end=overlay["t_end"]) - return { - "seed": int(rec.seed), - "dc_sites": rec.resolved_dc_sites, - "pv_systems": list(rec.resolved_pv_systems), - "tvl": list(rec.resolved_tvl), - "training_run": training_run, - "params": { - "pv_scale": rec.pv_scale, - "load_scale": rec.load_scale, - "training_overlay": overlay, - }, - } - - -# ── Experiment factories (shared by train_ppo, build_scenario_library, evaluate_controllers) ── - - -def ieee13_experiment(training_trace: TrainingTrace | None = None) -> dict: - """IEEE 13-bus: single DC at bus 671 with 5 LLM models.""" - sys = SYSTEMS["ieee13"]() - # Inject ramps into each model's ReplicaSchedule (was a single OR-chained - # InferenceRampSchedule on the old API). - ramp_targets = { - "Llama-3.1-8B": 144, - "Llama-3.1-70B": 36, - "Llama-3.1-405B": 18, - "Qwen3-30B-A3B": 96, - "Qwen3-235B-A22B": 42, - } - base_models = ( - deploy("Llama-3.1-8B", 720), - deploy("Llama-3.1-70B", 180), - deploy("Llama-3.1-405B", 90), - deploy("Qwen3-30B-A3B", 480), - deploy("Qwen3-235B-A22B", 210), - ) - models = tuple( - (md, sched.ramp_to(ramp_targets[md.spec.model_label], t_start=2500, t_end=3000)) for md, sched in base_models - ) - training_base = ( - { - "trace": training_trace, - "n_gpus": 2400, - "target_peak_W_per_gpu": 400.0, - "t_start": 1000.0, - "t_end": 2000.0, - } - if training_trace is not None - else None - ) - return dict( - sys=sys, - dc_sites={ - "default": DCSite( - bus="671", - bus_kv=sys["bus_kv"], - base_kw_per_phase=500.0, - total_gpu_capacity=7200, - models=models, - seed=0, - ), - }, - pv_systems=[PVSystemSpec(bus="675", bus_kv=4.16, peak_kw=300.0)], - time_varying_loads=[TimeVaryingLoadSpec(bus="680", bus_kv=4.16, peak_kw=300.0)], - training_base=training_base, - ofo_config=OFOConfig( - primal_step_size=0.05, - w_throughput=0.00001, - w_switch=1.0, - voltage_gradient_scale=1e6, - v_min=V_MIN, - v_max=V_MAX, - voltage_dual_step_size=1.0, - latency_dual_step_size=1.0, - sensitivity_update_interval=300, - sensitivity_perturbation_kw=100.0, - ), - tap_schedule=TapSchedule( - ( - (1500, TapPosition(regulators={"creg1a": tap(16), "creg1b": tap(6), "creg1c": tap(17)})), - (3300, TapPosition(regulators={"creg1a": tap(10), "creg1b": tap(6), "creg1c": tap(10)})), - ) - ), - ) - - -def ieee34_experiment(training_trace: TrainingTrace | None = None) -> dict: - """IEEE 34-bus: two DC sites (upstream/downstream).""" - sys = SYSTEMS["ieee34"]() - return dict( - sys=sys, - dc_sites={ - "upstream": DCSite( - bus="850", - bus_kv=24.9, - base_kw_per_phase=250.0, - models=(deploy("Llama-3.1-8B", 320), deploy("Llama-3.1-70B", 80), deploy("Llama-3.1-405B", 40)), - seed=0, - total_gpu_capacity=1200, - ), - "downstream": DCSite( - bus="834", - bus_kv=24.9, - base_kw_per_phase=300.0, - models=(deploy("Qwen3-30B-A3B", 216), deploy("Qwen3-235B-A22B", 96)), - seed=42, - total_gpu_capacity=1440, - ), - }, - pv_systems=[ - PVSystemSpec(bus="858", bus_kv=24.9, peak_kw=130.0), - PVSystemSpec(bus="852", bus_kv=24.9, peak_kw=65.0), - ], - time_varying_loads=[ - TimeVaryingLoadSpec(bus="860", bus_kv=24.9, peak_kw=80.0), - TimeVaryingLoadSpec(bus="844", bus_kv=24.9, peak_kw=120.0), - TimeVaryingLoadSpec(bus="858", bus_kv=24.9, peak_kw=50.0), - ], - training_base=None, - ofo_config=OFOConfig( - primal_step_size=0.05, - w_throughput=0.0001, - w_switch=1.0, - voltage_gradient_scale=1e6, - voltage_dual_step_size=1.0, - latency_dual_step_size=1.0, - sensitivity_update_interval=300, - sensitivity_perturbation_kw=50.0, - v_min=V_MIN, - v_max=V_MAX, - ), - tap_schedule=TapSchedule( - ( - ( - 1800, - TapPosition( - regulators={ - "creg2a": tap(10), - "creg2b": tap(10), - "creg2c": tap(10), - } - ), - ), - ) - ), - ) - - -def ieee123_experiment(training_trace: TrainingTrace | None = None) -> dict: - """IEEE 123-bus: four DC sites across zones.""" - sys = SYSTEMS["ieee123"]() - return dict( - sys=sys, - dc_sites={ - "z1_sw": DCSite( - bus="8", - bus_kv=4.16, - base_kw_per_phase=280.0, - models=(with_ramp(deploy("Llama-3.1-8B", 800), 1200, t_start=500, t_end=1000),), - seed=0, - total_gpu_capacity=1200, - ), - "z2_nw": DCSite( - bus="23", - bus_kv=4.16, - base_kw_per_phase=280.0, - models=(with_ramp(deploy("Qwen3-30B-A3B", 460), 600, t_start=1500, t_end=2500),), - seed=17, - total_gpu_capacity=1200, - ), - "z3_se": DCSite( - bus="60", - bus_kv=4.16, - base_kw_per_phase=224.0, - models=( - with_ramp(deploy("Llama-3.1-70B", 64), 96, t_start=700, t_end=1100), - deploy("Llama-3.1-405B", 72), - ), - seed=34, - total_gpu_capacity=960, - ), - "z4_ne": DCSite( - bus="105", - bus_kv=4.16, - base_kw_per_phase=224.0, - models=(with_ramp(deploy("Qwen3-235B-A22B", 96), 56, t_start=2000, t_end=2500),), - seed=51, - total_gpu_capacity=960, - ), - }, - pv_systems=[ - PVSystemSpec(bus="18", bus_kv=4.16, peak_kw=100.0), - PVSystemSpec(bus="48", bus_kv=4.16, peak_kw=250.0), - PVSystemSpec(bus="57", bus_kv=4.16, peak_kw=200.0), - ], - time_varying_loads=[ - TimeVaryingLoadSpec(bus="13", bus_kv=4.16, peak_kw=20.0), - TimeVaryingLoadSpec(bus="86", bus_kv=4.16, peak_kw=20.0), - TimeVaryingLoadSpec(bus="114", bus_kv=4.16, peak_kw=20.0), - ], - training_base=None, - ofo_config=OFOConfig( - primal_step_size=0.05, - w_throughput=0.0001, - w_switch=1.0, - voltage_gradient_scale=1e6, - voltage_dual_step_size=0.3, - latency_dual_step_size=1.0, - sensitivity_update_interval=300, - sensitivity_perturbation_kw=10.0, - v_min=V_MIN, - v_max=V_MAX, - ), - tap_schedule=None, - ) - - -EXPERIMENTS: dict[str, Callable[..., dict]] = { - "ieee13": ieee13_experiment, - "ieee34": ieee34_experiment, - "ieee123": ieee123_experiment, -} diff --git a/examples/offline/build_scenario_library.py b/examples/rl_controller/build_library.py similarity index 72% rename from examples/offline/build_scenario_library.py rename to examples/rl_controller/build_library.py index d335682..556a34f 100644 --- a/examples/offline/build_scenario_library.py +++ b/examples/rl_controller/build_library.py @@ -1,12 +1,12 @@ """Build a pre-screened scenario library for PPO training. Generates randomized ieee13 scenarios using the same randomization logic as -``train_ppo.py:make_sim_factory``, runs both baseline (no controller) and OFO +`train_ppo.py:make_sim_factory`, runs both baseline (no controller) and OFO on each, and accepts a scenario only if: 1) The baseline episode has non-trivial voltage violation (so the policy has something to learn), and - 2) OFO recovers at least ``--min-recovery-frac`` of the baseline integral + 2) OFO recovers at least `--min-recovery-frac` of the baseline integral violation (so the violation is within the GPU-flexibility envelope). For each accepted scenario the library stores the seed (so the env can @@ -15,19 +15,22 @@ scenario-difficulty-normalized reward. Usage: - python examples/offline/build_scenario_library.py --n-candidates 10 - python examples/offline/build_scenario_library.py --n-candidates 50 \\ - --pv-base-kw 200 --tvl-base-kw 200 --min-recovery-frac 0.8 - -Outputs: - examples/offline/outputs/ieee13/scenario_library// - library.pkl -- list of accepted ScenarioRecord - candidates.csv -- per-candidate stats (accepted + rejected) - scenario_envelopes.png -- voltage envelope per scenario, baseline vs OFO - scenario_summary.png -- bar chart of integral violation, baseline vs OFO - -The .pkl is the artifact training will load via train_ppo.py's -``--scenario-library`` flag (added in the next step). + python examples/rl_controller/build_library.py --system ieee13 \\ + --n-candidates 10 --tag train_n10 + python examples/rl_controller/build_library.py --system ieee13 \\ + --n-candidates 50 --pv-base-kw 200 --tvl-base-kw 200 \\ + --min-recovery-frac 0.8 --tag train_n50 + +Outputs (per `--tag`): + examples/rl_controller/outputs//scenario_library// + metadata.json -- build config + per-scenario scalar fields + traces.npz -- per-scenario voltage penalty arrays + candidates.csv -- per-candidate stats (accepted + rejected) + scenario_envelopes.png -- voltage envelope per scenario, baseline vs OFO + scenario_summary.png -- bar chart of integral violation, baseline vs OFO + +The library directory is the artifact `train_ppo.py` and `evaluate.py` consume +through their `--scenario-library` flag. """ from __future__ import annotations @@ -35,16 +38,23 @@ import csv import logging import math -import pickle -import sys -from dataclasses import dataclass, field +from dataclasses import dataclass from pathlib import Path -import matplotlib - -matplotlib.use("Agg") -import matplotlib.pyplot as plt import numpy as np +from controller import PPOBatchSizeController, SharedPPOBatchSizeController +from env import ObservationConfig, compute_bus_phase_groups +from scenarios import ( + EXPERIMENTS, + DCSite, + PVSystemSpec, + ScenarioOpenDSSGrid, + ScenarioRecord, + TimeVaryingLoadSpec, + load_library_data, + randomize_scenario, + save_library, +) from openg2g.controller.ofo import LogisticModelStore, OFOConfig from openg2g.controller.rule_based import RuleBasedBatchSizeController, RuleBasedConfig @@ -57,22 +67,24 @@ from openg2g.grid.config import TapSchedule from openg2g.metrics.voltage import VoltageStats, compute_allbus_voltage_stats +from plotting import ( + _extract_batch_data, + _plot_batch_sizes, + _plot_envelopes, + _plot_summary, + _voltage_envelope, + _voltage_envelope_by_zone, +) from systems import ( DT_CTRL, DT_DC, DT_GRID, - EXPERIMENTS, POWER_AUG, SPECS_CACHE_DIR, TOTAL_DURATION_S, TRAINING_TRACE_PATH, V_MAX, V_MIN, - DCSite, - PVSystemSpec, - ScenarioOpenDSSGrid, - TimeVaryingLoadSpec, - randomize_scenario, ) logger = logging.getLogger("scenario_library") @@ -164,8 +176,6 @@ def run_simulation( if not primary_bus: primary_bus = site.bus - # Build grid then attach DCs (master's imperative pattern; the old - # dc_loads / dc_bus kwargs are no longer accepted by OpenDSSGrid). grid = ScenarioOpenDSSGrid( pv_systems=pv_systems, time_varying_loads=time_varying_loads, @@ -224,22 +234,27 @@ def run_simulation( controllers.append(rb_ctrl) elif mode == "ppo": - from openg2g.controller.ppo import PPOBatchSizeController, SharedPPOBatchSizeController - from openg2g.rl.env import ObservationConfig - ppo_path = Path(ppo_model).resolve() def _find_vecnormalize(model_file: Path) -> Path | None: + """Look for the VecNormalize sidecar next to a saved PPO model. + + Two conventions: + 1. `_vecnormalize.pkl`: what `train_ppo.py` writes for the + final saved model (e.g. `ppo_model.zip` ↔ `ppo_model_vecnormalize.pkl`). + 2. `_vecnormalize__steps.pkl`: SB3 + `CheckpointCallback`'s sibling for an intermediate checkpoint + `__steps.zip`. + """ mf = Path(model_file) stem = mf.with_suffix("").name - candidates = [ - mf.parent / f"{stem}_vecnormalize.pkl", - mf.parent / "vecnormalize.pkl", - ] - if stem.startswith("ppo_") and stem.endswith("_steps"): - candidates.append(mf.parent / f"ppo_vecnormalize_{stem[len('ppo_') :]}.pkl") - for parent in [mf.parent, *mf.parent.parents][:4]: - candidates.extend(sorted(parent.glob("ppo_model_*_vecnormalize.pkl"))) + candidates = [mf.parent / f"{stem}_vecnormalize.pkl"] + if stem.endswith("_steps"): + # SB3 CheckpointCallback: __steps.zip ↔ _vecnormalize__steps.pkl + parts = stem.rsplit("_", 2) + if len(parts) == 3: + prefix, n, _steps = parts + candidates.append(mf.parent / f"{prefix}_vecnormalize_{n}_steps.pkl") for c in candidates: if c.is_file(): return c @@ -250,12 +265,8 @@ def _find_vecnormalize(model_file: Path) -> Path | None: cand = ppo_path / "ppo_model_shared.zip" if cand.exists(): shared_model = cand - elif ppo_path.suffix == ".zip": - looks_shared = ( - len(site_ids) > 1 or "shared" in ppo_path.parts or ppo_path.stem.startswith("ppo_model_shared") - ) - if looks_shared and ppo_path.exists(): - shared_model = ppo_path + elif ppo_path.suffix == ".zip" and ppo_path.exists() and len(site_ids) > 1: + shared_model = ppo_path if shared_model is not None and shared_model.exists(): from stable_baselines3 import PPO as SB3PPO @@ -273,8 +284,6 @@ def _find_vecnormalize(model_file: Path) -> Path | None: zone_summary = None bus_phase_groups = None elif obs_mode == "per-bus-summary": - from openg2g.rl.env import compute_bus_phase_groups - grid.do_reset() grid.start() _v_index = grid.v_index @@ -306,7 +315,7 @@ def _find_vecnormalize(model_file: Path) -> Path | None: logger.info("PPO: loading VecNormalize stats from %s", vn_path) else: logger.warning( - "PPO: no VecNormalize stats found next to %s — policy will see UNNORMALIZED obs", shared_model + "PPO: no VecNormalize stats found next to %s; policy will see UNNORMALIZED obs", shared_model ) ppo_ctrl = SharedPPOBatchSizeController( datacenter=next(iter(datacenters.values())), @@ -357,7 +366,7 @@ def _find_vecnormalize(model_file: Path) -> Path | None: logger.info("PPO[%s]: loading VecNormalize stats from %s", site_id, vn_path) else: logger.warning( - "PPO[%s]: no VecNormalize stats found next to %s — policy will see UNNORMALIZED obs", + "PPO[%s]: no VecNormalize stats found next to %s; policy will see UNNORMALIZED obs", site_id, site_model, ) @@ -379,8 +388,6 @@ def _find_vecnormalize(model_file: Path) -> Path | None: total_duration_s=TOTAL_DURATION_S, ) - from openg2g.controller.ppo import SharedPPOBatchSizeController - for ctrl in controllers: if isinstance(ctrl, SharedPPOBatchSizeController): ctrl.attach_datacenters(datacenters) @@ -406,73 +413,11 @@ def _find_vecnormalize(model_file: Path) -> Path | None: return vstats, log -@dataclass -class ScenarioRecord: - """One pre-screened scenario, ready to be replayed at training time. - - ``__module__`` is forced to ``build_scenario_library`` below so pickles - of this class work whether the script is run as ``__main__`` or imported. - Without the override, running ``python build_scenario_library.py`` writes - ``__main__.ScenarioRecord`` into the pickle, which then fails to unpickle - in any other process (e.g. train_ppo.py). See openg2g/rl/env.py:ScenarioLibrary - stub registration. - """ - - seed: int - pv_scale: float - load_scale: float - training_overlay: dict | None - baseline_integral: float - ofo_integral: float - baseline_violation_time_s: float - ofo_violation_time_s: float - recovery_frac: float - # Per-second OFO voltage penalty (sum of squared violation, length = - # total_duration_s). This is what gets subtracted from PPO's per-step - # voltage_pen during training. - ofo_voltage_pen_per_step: np.ndarray = field(repr=False) - baseline_voltage_pen_per_step: np.ndarray = field(repr=False) - # Episode window for truncated training: skip quiet prefix, stop after last violation. - # Computed from baseline_voltage_pen_per_step at build time with configurable buffers. - # env.py reads these via getattr(..., default) so old pickles without these fields degrade - # gracefully to full-episode training. - t_control_start: int = 0 - t_control_end: int = 3600 - bl_undervoltage_time_s: float = 0.0 - bl_overvoltage_time_s: float = 0.0 - # Per-scenario override for inference-ramp synthesis at replay time. - # When None, train_ppo uses the library-wide default from the library config. - randomize_ramps: bool | None = None - # Per-scenario ramp bounds. None falls back to the library-wide defaults in train_ppo. - ramp_frac_min: float | None = None - ramp_frac_max: float | None = None - ramp_start_min: float | None = None - ramp_start_max: float | None = None - ramp_dur_min: float | None = None - ramp_dur_max: float | None = None - # Resolved per-episode state — captured at build time so consumers don't - # need to replay randomize_scenario. Old pickles default to None and must - # be upgraded with examples/offline/migrate_scenario_library.py before use. - resolved_dc_sites: dict | None = None - resolved_pv_systems: tuple | None = None - resolved_tvl: tuple | None = None - - -# Force stable pickle identity regardless of how build_scenario_library.py is -# invoked (as __main__ script vs imported module). See docstring above. -ScenarioRecord.__module__ = "build_scenario_library" -# When run as `python build_scenario_library.py`, the script's globals live in -# sys.modules['__main__'] and there is no entry for 'build_scenario_library'. -# Pickle uses ScenarioRecord.__module__ to find the class at unpickle time, so -# we alias sys.modules so both names resolve to the same module object. -sys.modules.setdefault("build_scenario_library", sys.modules[__name__]) - - def _per_step_voltage_pen(grid_states, *, v_min: float, v_max: float, exclude_buses: tuple[str, ...]) -> np.ndarray: """Compute the per-step voltage penalty (sum of squared violation magnitude). - This matches ``compute_reward`` in ``openg2g/rl/env.py``: at each step the - penalty is ``sum_max(v_min - v, 0)^2 + sum_max(v - v_max, 0)^2`` over all + This matches `compute_reward` in `env.py`: at each step the + penalty is `sum_max(v_min - v, 0)^2 + sum_max(v - v_max, 0)^2` over all bus-phases (excluding the substation buses), with NaNs treated as "not in violation". """ @@ -530,57 +475,6 @@ def _under_over_voltage_time( return float(under_steps), float(over_steps) -def _voltage_envelope(grid_states, *, exclude_buses: tuple[str, ...]) -> tuple[np.ndarray, np.ndarray]: - """Return (vmin_t, vmax_t) per step for plotting.""" - drop = {b.lower() for b in exclude_buses} - vmin = np.full(len(grid_states), np.inf) - vmax = np.full(len(grid_states), -np.inf) - for i, gs in enumerate(grid_states): - for bus in gs.voltages.buses(): - if bus.lower() in drop: - continue - pv = gs.voltages[bus] - for v in (pv.a, pv.b, pv.c): - if math.isnan(v): - continue - if v < vmin[i]: - vmin[i] = v - if v > vmax[i]: - vmax[i] = v - return vmin, vmax - - -def _voltage_envelope_by_zone( - grid_states, - *, - zones: dict[str, list[str]], - exclude_buses: tuple[str, ...], -) -> dict[str, tuple[np.ndarray, np.ndarray]]: - """Return {zone_name: (vmin_t, vmax_t)} per step, one array pair per zone.""" - drop = {b.lower() for b in exclude_buses} - zone_sets = {z: {b.lower() for b in buses} for z, buses in zones.items()} - n = len(grid_states) - vmin = {z: np.full(n, np.inf) for z in zones} - vmax = {z: np.full(n, -np.inf) for z in zones} - for i, gs in enumerate(grid_states): - for bus in gs.voltages.buses(): - bl = bus.lower() - if bl in drop: - continue - pv = gs.voltages[bus] - for z, bset in zone_sets.items(): - if bl not in bset: - continue - for v in (pv.a, pv.b, pv.c): - if math.isnan(v): - continue - if v < vmin[z][i]: - vmin[z][i] = v - if v > vmax[z][i]: - vmax[z][i] = v - return {z: (vmin[z], vmax[z]) for z in zones} - - def _zone_phase_integral( grid_states, *, @@ -645,100 +539,6 @@ def _build_run_kwargs( ) -def _plot_batch_sizes( - records: list[ScenarioRecord], - batch_data: dict[int, dict], - save_path: Path, - *, - max_rows: int = 40, -) -> None: - """Plot batch size over time per accepted scenario, baseline vs OFO, one row per scenario. - - When the library has more than ``max_rows`` scenarios, only the first - ``max_rows`` are shown. A single tall figure of hundreds of rows quickly - exceeds matplotlib's 65535-pixel dimension limit, so we cap here. - """ - n = len(records) - if n == 0: - return - if n > max_rows: - logger.info("_plot_batch_sizes: capping at first %d of %d records", max_rows, n) - records = records[:max_rows] - n = max_rows - - # Collect all (site_id, label) columns from the first scenario. For - # single-DC feeders (ieee13) there's one site; multi-DC feeders - # (ieee34) get one column per (site, model) pair. - first_seed = records[0].seed - ofo_by_site = batch_data[first_seed]["ofo"] - cols_meta: list[tuple[str, str]] = [] - for site_id, sdata in ofo_by_site.items(): - for label in sdata["batch_by_model"]: - cols_meta.append((site_id, label)) - n_cols = len(cols_meta) - - fig, axes = plt.subplots(n, n_cols, figsize=(4 * n_cols, 2.5 * n), sharex=True, squeeze=False) - - for row, rec in enumerate(records): - bd = batch_data[rec.seed] - for col, (site_id, label) in enumerate(cols_meta): - ax = axes[row][col] - bl_site = bd["baseline"][site_id] - ofo_site = bd["ofo"][site_id] - ax.plot( - bl_site["time_s"], - bl_site["batch_by_model"][label], - color="#888", - linewidth=0.7, - alpha=0.7, - label="baseline", - ) - ax.plot( - ofo_site["time_s"], - ofo_site["batch_by_model"][label], - color="#2196F3", - linewidth=0.7, - alpha=0.9, - label="OFO", - ) - if row == 0: - short = label.split("/")[-1] if "/" in label else label - title = f"{site_id}:{short}" if len(ofo_by_site) > 1 else short - ax.set_title(title, fontsize=9) - if col == 0: - ax.set_ylabel(f"seed={rec.seed}\nBatch", fontsize=8) - ax.grid(True, alpha=0.2) - if row == 0 and col == 0: - ax.legend(fontsize=7, loc="upper right") - - for col in range(n_cols): - axes[-1][col].set_xlabel("Time (s)") - fig.suptitle("Accepted scenarios — batch size (baseline vs OFO)", fontsize=13, fontweight="bold") - fig.tight_layout() - fig.savefig(save_path, dpi=120, bbox_inches="tight") - plt.close(fig) - - -def _extract_batch_data(log) -> dict: - """Extract time_s and per-model batch sizes from a simulation log. - - Returns {site_id: {"time_s": [...], "batch_by_model": {label: [bs]}}}. - Uses ``log.dc_states_by_site`` (per-site lists) so multi-DC feeders - like ieee34 don't get interleaved timestamps or alternating zeros. - """ - per_site: dict[str, dict] = {} - for site_id, states in log.dc_states_by_site.items(): - time_s = [s.time_s for s in states] - labels: list[str] = [] - if states: - for m in states[0].batch_size_by_model: - if m not in labels: - labels.append(m) - batch_by_model = {m: [s.batch_size_by_model.get(m, 0) for s in states] for m in labels} - per_site[site_id] = {"time_s": time_s, "batch_by_model": batch_by_model} - return per_site - - def _is_always_minimum_batch(ofo_log, min_batch_size: int = 8, warmup_s: float = 10.0) -> bool: """Return True if every model stays at min_batch_size for the entire episode after warmup_s. @@ -755,150 +555,6 @@ def _is_always_minimum_batch(ofo_log, min_batch_size: int = 8, warmup_s: float = return True -def _plot_envelopes( - records: list[ScenarioRecord], - envelopes: dict, - save_path: Path, - *, - total_duration_s: int, - zones: dict[str, list[str]] | None = None, - max_rows: int = 40, -) -> None: - """Plot voltage envelope per accepted scenario, baseline vs OFO. - - When ``zones`` is provided (multi-zone feeders like ieee123), each scenario - gets one subplot per zone showing the per-zone vmin/vmax band. Otherwise a - single subplot with the global envelope is used. - - Caps at ``max_rows * 2`` records (global mode) or ``max_rows`` records - (per-zone mode) to stay under matplotlib's 65535-pixel dimension limit. - """ - n = len(records) - if n == 0: - return - - t = np.arange(total_duration_s) - - if zones: - zone_names = list(zones.keys()) - n_zones = len(zone_names) - cap = max_rows - if n > cap: - logger.info("_plot_envelopes: capping at first %d of %d records", cap, n) - records = records[:cap] - n = cap - zone_colors = ["#2196F3", "#4CAF50", "#FF9800", "#9C27B0"] - fig, axes = plt.subplots(n, n_zones, figsize=(5 * n_zones, 3 * n), sharex=True, squeeze=False) - for row, rec in enumerate(records): - env = envelopes[rec.seed] - for col, z in enumerate(zone_names): - ax = axes[row][col] - bl_z = env["baseline_zones"].get(z) - of_z = env["ofo_zones"].get(z) - color = zone_colors[col % len(zone_colors)] - if bl_z is not None: - ax.fill_between(t, bl_z[0], bl_z[1], alpha=0.25, color="#888", label="baseline") - if of_z is not None: - ax.fill_between(t, of_z[0], of_z[1], alpha=0.4, color=color, label="OFO") - ax.axhline(V_MIN, color="red", linestyle="--", linewidth=0.8, alpha=0.6) - ax.axhline(V_MAX, color="red", linestyle="--", linewidth=0.8, alpha=0.6) - ax.grid(True, alpha=0.2) - if row == 0: - ax.set_title(z, fontsize=10, fontweight="bold") - if col == 0: - ax.set_ylabel( - f"seed={rec.seed}\npv×{rec.pv_scale:.2f} ld×{rec.load_scale:.2f}\n" - f"bl={rec.baseline_integral:.1f} ofo={rec.ofo_integral:.1f} " - f"rec={rec.recovery_frac:.0%}", - fontsize=7, - ) - else: - ax.set_ylabel("V (pu)", fontsize=8) - if row == 0 and col == 0: - ax.legend(loc="lower right", fontsize=7) - for col in range(n_zones): - axes[-1][col].set_xlabel("Time (s)", fontsize=8) - fig.suptitle( - "Accepted scenarios — per-zone voltage envelope (baseline vs OFO)", - fontsize=13, - fontweight="bold", - ) - else: - cap = max_rows * 2 - if n > cap: - logger.info("_plot_envelopes: capping at first %d of %d records", cap, n) - records = records[:cap] - n = cap - cols = 2 - rows = (n + cols - 1) // cols - fig, axes = plt.subplots(rows, cols, figsize=(7 * cols, 3 * rows), sharex=True) - axes = np.atleast_2d(axes) - - for idx, rec in enumerate(records): - r, c = divmod(idx, cols) - ax = axes[r][c] - bmin, bmax = envelopes[rec.seed]["baseline"] - omin, omax = envelopes[rec.seed]["ofo"] - ax.fill_between(t, bmin, bmax, alpha=0.25, color="#888", label="baseline") - ax.fill_between(t, omin, omax, alpha=0.4, color="#2196F3", label="OFO") - ax.axhline(V_MIN, color="red", linestyle="--", linewidth=1, alpha=0.6) - ax.axhline(V_MAX, color="red", linestyle="--", linewidth=1, alpha=0.6) - ax.set_title( - f"seed={rec.seed} pv×{rec.pv_scale:.2f} load×{rec.load_scale:.2f}\n" - f"int: bl={rec.baseline_integral:.2f} ofo={rec.ofo_integral:.2f} " - f"recov={rec.recovery_frac:.0%}", - fontsize=9, - ) - ax.set_ylabel("V (pu)", fontsize=9) - ax.grid(True, alpha=0.2) - if idx == 0: - ax.legend(loc="lower right", fontsize=8) - - for k in range(n, rows * cols): - r, c = divmod(k, cols) - axes[r][c].axis("off") - - for c in range(cols): - axes[-1][c].set_xlabel("Time (s)") - fig.suptitle("Accepted scenarios — voltage envelope (baseline vs OFO)", fontsize=13, fontweight="bold") - - fig.tight_layout() - fig.savefig(save_path, dpi=120, bbox_inches="tight") - plt.close(fig) - - -def _plot_summary(all_stats: list[dict], save_path: Path) -> None: - """Bar chart of baseline vs OFO integral for every candidate (accepted + rejected).""" - n = len(all_stats) - if n == 0: - return - seeds = [s["seed"] for s in all_stats] - bl = [s["baseline_integral"] for s in all_stats] - of = [s["ofo_integral"] for s in all_stats] - accepted = [s["accepted"] for s in all_stats] - - x = np.arange(n) - w = 0.4 - fig, ax = plt.subplots(figsize=(max(8, 0.7 * n), 5)) - ax.bar(x - w / 2, bl, w, color="#888", label="baseline integral") - ax.bar(x + w / 2, of, w, color="#2196F3", label="OFO integral") - for i, ok in enumerate(accepted): - marker = "✓" if ok else "✗" - color = "green" if ok else "red" - ax.annotate(marker, xy=(i, max(bl[i], of[i])), ha="center", va="bottom", color=color, fontsize=12) - ax.set_xticks(x) - ax.set_xticklabels([str(s) for s in seeds], rotation=45) - ax.set_xlabel("Seed") - ax.set_ylabel("Integral voltage violation (pu·s)") - ax.set_yscale("symlog", linthresh=0.1) - ax.set_title("Candidate scenarios — baseline vs OFO integral violation") - ax.legend() - ax.grid(True, axis="y", alpha=0.2) - fig.tight_layout() - fig.savefig(save_path, dpi=120, bbox_inches="tight") - plt.close(fig) - - def main( *, system: str, @@ -1056,7 +712,7 @@ def main( plot=False, ) - # Training overlay base — same defaults as train_ppo._ieee13_experiment. + # Training overlay base: same defaults as train_ppo._ieee13_experiment. # Only used when use_training_overlay is True (ieee13 default). For ieee34 # the in-distribution scenarios deliberately skip the training overlay so # PV/TVL scales are the sole source of randomness (overlay is reserved for @@ -1228,7 +884,7 @@ def main( if always_min: if n_always_min_accepted >= MAX_ALWAYS_MIN: logger.info( - " seed=%d: OFO always at min batch — cap reached (%d/%d), skipping", + " seed=%d: OFO always at min batch: cap reached (%d/%d), skipping", effective_seed, n_always_min_accepted, MAX_ALWAYS_MIN, @@ -1237,7 +893,7 @@ def main( else: n_always_min_accepted += 1 logger.info( - " seed=%d: OFO always at min batch — accepting (%d/%d)", + " seed=%d: OFO always at min batch: accepting (%d/%d)", effective_seed, n_always_min_accepted, MAX_ALWAYS_MIN, @@ -1277,9 +933,6 @@ def main( baseline_voltage_pen_per_step=baseline_pen, t_control_start=t_ctrl_start, t_control_end=t_ctrl_end, - resolved_dc_sites=scenario["dc_sites"], - resolved_pv_systems=tuple(scenario["pv_systems"]), - resolved_tvl=tuple(scenario["tvl"]), ) accepted.append(rec) env_entry: dict = { @@ -1304,7 +957,6 @@ def main( } # ── Save artifacts ── - library_path = out_dir / "library.pkl" randomize_kwargs = { "randomize_ramps": randomize_ramps, "randomization_profile": randomization_profile, @@ -1346,53 +998,55 @@ def main( "tvl_shape_choices": tuple(tvl_shape_choices), } - with open(library_path, "wb") as f: - pickle.dump( + config = { + "system": system, + "n_candidates": n_total, + "min_recovery_frac": min_recovery_frac, + "max_recovery_frac": max_recovery_frac, + "min_baseline_integral": min_baseline_integral, + "pv_base_kw": pv_base_kw, + "tvl_base_kw": tvl_base_kw, + "use_training_overlay": use_training_overlay, + "randomize_ramps": randomize_ramps, + "randomize_kwargs": randomize_kwargs, + "pv_systems_base": [{"bus": p.bus, "bus_kv": p.bus_kv, "peak_kw": p.peak_kw} for p in pv_systems_base], + "tvl_base": [{"bus": t.bus, "bus_kv": t.bus_kv, "peak_kw": t.peak_kw} for t in tvl_base], + "v_min": V_MIN, + "v_max": V_MAX, + # Build-time training-overlay parameters (the trace itself is loaded + # from disk at consumption time and not stored here). + "training_base": ( { - "scenarios": accepted, - "config": { - "system": system, - "n_candidates": n_total, - "min_recovery_frac": min_recovery_frac, - "max_recovery_frac": max_recovery_frac, - "min_baseline_integral": min_baseline_integral, - "pv_base_kw": pv_base_kw, - "tvl_base_kw": tvl_base_kw, - "use_training_overlay": use_training_overlay, - "randomize_ramps": randomize_ramps, - "randomize_kwargs": randomize_kwargs, - "pv_systems_base": [ - {"bus": p.bus, "bus_kv": p.bus_kv, "peak_kw": p.peak_kw} for p in pv_systems_base - ], - "tvl_base": [{"bus": t.bus, "bus_kv": t.bus_kv, "peak_kw": t.peak_kw} for t in tvl_base], - "v_min": V_MIN, - "v_max": V_MAX, - }, - }, - f, - ) - logger.info("Wrote %d accepted scenarios to %s", len(accepted), library_path) + "n_gpus": 2400, + "target_peak_W_per_gpu": 400.0, + "t_start": 1000.0, + "t_end": 2000.0, + } + if use_training_overlay + else None + ), + } + save_library(out_dir, accepted, config) + logger.info("Wrote %d accepted scenarios to %s", len(accepted), out_dir) # ── Optional: merge into an existing library ── if append_to is not None: - if not append_to.exists(): - raise FileNotFoundError(f"--append-to target not found: {append_to}") - with open(append_to, "rb") as f: - existing = pickle.load(f) - existing_seeds = {s.seed for s in existing["scenarios"]} + if not append_to.exists() or not append_to.is_dir(): + raise FileNotFoundError(f"--append-to target not found (must be a library directory): {append_to}") + existing_scenarios, existing_config = load_library_data(append_to) + existing_seeds = {s.seed for s in existing_scenarios} new_scenarios = [s for s in accepted if s.seed not in existing_seeds] if new_scenarios: - existing["scenarios"] = existing["scenarios"] + new_scenarios - with open(append_to, "wb") as f: - pickle.dump(existing, f) + merged = existing_scenarios + new_scenarios + save_library(append_to, merged, existing_config) logger.info( "Appended %d new scenario(s) to %s (total now: %d)", len(new_scenarios), append_to, - len(existing["scenarios"]), + len(merged), ) else: - logger.info("No new scenarios to append — all seeds already present in %s", append_to) + logger.info("No new scenarios to append: all seeds already present in %s", append_to) csv_path = out_dir / "candidates.csv" with open(csv_path, "w", newline="") as f: @@ -1445,23 +1099,14 @@ def main( logger.info(" baseline integral (mean ± std): %.2f ± %.2f", bls.mean(), bls.std()) -# ── Optional supplement-phase helpers (used by the CLI when --n-supplement-candidates > 0) ── - - -def _is_pure_overvoltage(rec) -> bool: - """Classify a ScenarioRecord as pure-overvoltage (over > 0, under == 0). - - Uses the saved per-record fields when present (newer builds); falls back to - the load_scale heuristic for old builds where the fields weren't written. - """ - if "bl_overvoltage_time_s" in rec.__dict__: - return rec.bl_overvoltage_time_s > 0 and rec.bl_undervoltage_time_s == 0 - return getattr(rec, "load_scale", 99.0) <= 1.0 # rough proxy: low load implies overvoltage +def _is_pure_overvoltage(rec: ScenarioRecord) -> bool: + """Classify a ScenarioRecord as pure-overvoltage (over > 0, under == 0).""" + return rec.bl_overvoltage_time_s > 0 and rec.bl_undervoltage_time_s == 0 def _merge_supplement_into_base( - base_pkl: Path, - supp_pkl: Path, + base_dir: Path, + supp_dir: Path, swap_undervoltage_for_overvoltage: int = 0, ) -> None: """Merge pure-overvoltage scenarios from a supplement library into a base library. @@ -1473,57 +1118,46 @@ def _merge_supplement_into_base( from the supplement, remove the same number of undervoltage-only scenarios (highest load_scale first) from the base. Library size unchanged. """ - if not base_pkl.exists(): - raise FileNotFoundError(f"--n-supplement-candidates set but base library missing: {base_pkl}") - if not supp_pkl.exists(): - raise FileNotFoundError(f"supplement library missing: {supp_pkl}") - - with open(base_pkl, "rb") as f: - base_lib = pickle.load(f) - with open(supp_pkl, "rb") as f: - supp_lib = pickle.load(f) - - pure_over = [r for r in supp_lib["scenarios"] if _is_pure_overvoltage(r)] - base_seeds = {r.seed for r in base_lib["scenarios"]} + if not base_dir.is_dir(): + raise FileNotFoundError(f"--n-supplement-candidates set but base library missing: {base_dir}") + if not supp_dir.is_dir(): + raise FileNotFoundError(f"supplement library missing: {supp_dir}") + + base_scenarios, base_config = load_library_data(base_dir) + supp_scenarios, _ = load_library_data(supp_dir) + + pure_over = [r for r in supp_scenarios if _is_pure_overvoltage(r)] + base_seeds = {r.seed for r in base_scenarios} pure_over_new = [r for r in pure_over if r.seed not in base_seeds] logger.info( "Supplement merge: %d/%d supplement scenarios are pure-overvoltage (%d new vs base)", len(pure_over), - len(supp_lib["scenarios"]), + len(supp_scenarios), len(pure_over_new), ) if swap_undervoltage_for_overvoltage <= 0: - new_scenarios = list(base_lib["scenarios"]) + pure_over_new + new_scenarios = list(base_scenarios) + pure_over_new else: n_take = min(swap_undervoltage_for_overvoltage, len(pure_over_new)) - recs = list(base_lib["scenarios"]) under_only_idx = [ - i - for i, r in enumerate(recs) - if getattr(r, "bl_undervoltage_time_s", None) is not None - and r.bl_undervoltage_time_s > 0 - and getattr(r, "bl_overvoltage_time_s", 0) == 0 + i for i, r in enumerate(base_scenarios) if r.bl_undervoltage_time_s > 0 and r.bl_overvoltage_time_s == 0 ] - if not under_only_idx: - under_only_idx = [i for i, r in enumerate(recs) if getattr(r, "load_scale", 0) > 2.0] - under_only_idx.sort(key=lambda i: getattr(recs[i], "load_scale", 0), reverse=True) + under_only_idx.sort(key=lambda i: base_scenarios[i].load_scale, reverse=True) remove_idx = set(under_only_idx[:n_take]) if remove_idx: - ls_min = min(getattr(recs[i], "load_scale", 0) for i in remove_idx) - ls_max = max(getattr(recs[i], "load_scale", 0) for i in remove_idx) + ls_min = min(base_scenarios[i].load_scale for i in remove_idx) + ls_max = max(base_scenarios[i].load_scale for i in remove_idx) logger.info( " test-set swap: removing %d undervoltage-only scenarios (load_scale %.2f-%.2f)", len(remove_idx), ls_min, ls_max, ) - new_scenarios = [r for i, r in enumerate(recs) if i not in remove_idx] + pure_over_new[:n_take] + new_scenarios = [r for i, r in enumerate(base_scenarios) if i not in remove_idx] + pure_over_new[:n_take] - base_lib["scenarios"] = new_scenarios - with open(base_pkl, "wb") as f: - pickle.dump(base_lib, f) - logger.info("Wrote merged library to %s (%d scenarios total)", base_pkl, len(new_scenarios)) + save_library(base_dir, new_scenarios, base_config) + logger.info("Wrote merged library to %s (%d scenarios total)", base_dir, len(new_scenarios)) if __name__ == "__main__": @@ -1563,12 +1197,12 @@ class Args: seeds: tuple[int, ...] = () """Explicit list of effective seeds to run (overrides seed_start + n_candidates). Use to re-run only known-accepted seeds from a prior build log.""" # noqa: E501 use_training_overlay: bool = True - """Whether to add the training overlay (a few-hundred-kW training GPU spike) to in-distribution scenarios. Default True (ieee13 behavior). Set --no-use-training-overlay for ieee34 in-dist libraries — the overlay is reserved for OOD there.""" # noqa: E501 + """Whether to add the training overlay (a few-hundred-kW training GPU spike) to in-distribution scenarios. Default True (ieee13 behavior). Set --no-use-training-overlay for ieee34 in-dist libraries; the overlay is reserved for OOD there.""" # noqa: E501 randomize_ramps: bool = True - """Whether randomize_scenario synthesizes per-episode inference ramps. Default True (ieee13 behavior). Set --no-randomize-ramps for ieee34 in-dist libraries — ramps are reserved for OOD there.""" # noqa: E501 + """Whether randomize_scenario synthesizes per-episode inference ramps. Default True (ieee13 behavior). Set --no-randomize-ramps for ieee34 in-dist libraries; ramps are reserved for OOD there.""" # noqa: E501 # ── Randomization profile ── randomization_profile: Annotated[bool, tyro.conf.FlagConversionOff] = True - """True (broad, default) = bidirectional multi-ramps, wider PV/TVL scales, shape randomness, stochastic overlay. False (narrow) = legacy behavior.""" # noqa: E501 + """True (broad, default) = bidirectional multi-ramps, wider PV/TVL scales, shape randomness, stochastic overlay. False (narrow) = single descending ramp + scalar PV/TVL only.""" # noqa: E501 pv_scale_min: float = 0.5 pv_scale_max: float = 2.0 load_scale_min: float = 0.5 @@ -1631,10 +1265,10 @@ class Args: """Steps of buffer after the last baseline voltage violation (for episode truncation).""" log_level: str = "INFO" append_to: Path | None = None - """If set, merge newly accepted scenarios into this existing library.pkl (deduplicates by seed). The new run is still saved under --tag for diagnostic plots.""" # noqa: E501 + """If set, merge newly accepted scenarios into this existing library directory (deduplicates by seed). The new run is still saved under --tag for diagnostic plots.""" # noqa: E501 # ── Optional supplement phase (paper-balanced library workflow) ── n_supplement_candidates: int = 0 - """If > 0, after the main build run a second pass with overvoltage-favouring params (high PV, low load) and merge pure-overvoltage scenarios into the base library. Replaces the standalone build_balanced_library_ieee13.py orchestrator.""" # noqa: E501 + """If > 0, after the main build run a second pass with overvoltage-favouring params (high PV, low load) and merge pure-overvoltage scenarios into the base library.""" # noqa: E501 supplement_seed_start: int = -1 """Starting candidate index for the supplement pass. Default -1 means seed_start + 4000 (prevents seed overlap).""" # noqa: E501 supplement_pv_scale_min: float = 2.0 @@ -1795,7 +1429,7 @@ class Args: scenario_root = Path(__file__).resolve().parent / "outputs" / args.system / "scenario_library" _merge_supplement_into_base( - base_pkl=scenario_root / args.tag / "library.pkl", - supp_pkl=scenario_root / supp_tag / "library.pkl", + base_dir=scenario_root / args.tag, + supp_dir=scenario_root / supp_tag, swap_undervoltage_for_overvoltage=args.swap_undervoltage_for_overvoltage, ) diff --git a/openg2g/controller/ppo.py b/examples/rl_controller/controller.py similarity index 73% rename from openg2g/controller/ppo.py rename to examples/rl_controller/controller.py index 61b7820..352c6d0 100644 --- a/openg2g/controller/ppo.py +++ b/examples/rl_controller/controller.py @@ -10,6 +10,7 @@ from pathlib import Path import numpy as np +from env import ObservationConfig, build_observation, compute_zone_mask, decode_action from openg2g.clock import SimulationClock from openg2g.controller.base import Controller @@ -19,22 +20,36 @@ from openg2g.events import EventEmitter from openg2g.grid.command import GridCommand from openg2g.grid.opendss import OpenDSSGrid -from openg2g.rl.env import ObservationConfig, build_observation, compute_zone_mask, decode_action -def _load_sb3_model(model_path: str | Path): - """Load an SB3 PPO model, handling .zip extension.""" - from stable_baselines3 import PPO +def load_sb3_policy(model_path: str | Path, *, algorithm: str = "PPO"): + """Load a stable-baselines3 policy by algorithm name. + + `algorithm` is the SB3 class name (`"PPO"`, `"SAC"`, `"DQN"`, + `"A2C"`, `"TD3"`, `"DDPG"`). Resolved by attribute lookup on the + `stable_baselines3` package; mismatches raise an explicit error. + + Trailing `.zip` on `model_path` is stripped because SB3's `load` + appends the extension itself. + """ + import stable_baselines3 as sb3 + + try: + algo_cls = getattr(sb3, algorithm) + except AttributeError as exc: + raise ValueError( + f"Unknown SB3 algorithm {algorithm!r}; expected one of PPO, SAC, DQN, A2C, TD3, DDPG." + ) from exc resolved = Path(model_path).resolve() load_path = str(resolved.with_suffix("")) if resolved.suffix == ".zip" else str(resolved) - return PPO.load(load_path) + return algo_cls.load(load_path) def _load_vecnormalize(vecnormalize_path: str | Path, observation_space, action_space): """Load a saved VecNormalize wrapper, restoring its running obs/reward statistics. - Returns a VecNormalize whose ``normalize_obs`` reproduces what the policy + Returns a VecNormalize whose `normalize_obs` reproduces what the policy saw during training. We attach a 1-env DummyVecEnv whose obs/action spaces match the trained model so SB3's loader is happy. """ @@ -66,7 +81,7 @@ def close(self): return vn -def _detect_action_mode(action_space, n_models: int) -> str: +def _detect_action_mode(action_space) -> str: """Infer the action mode from a saved model's action space shape.""" from gymnasium import spaces as gspaces @@ -77,7 +92,7 @@ def _detect_action_mode(action_space, n_models: int) -> str: if all(d == 3 for d in nvec): return "delta" raise ValueError( - f"Unrecognised action space {action_space!r} — expected Discrete (coupled) or MultiDiscrete([3]*N) (delta)" + f"Unrecognised action space {action_space!r}: expected Discrete (coupled) or MultiDiscrete([3]*N) (delta)" ) @@ -93,9 +108,9 @@ class PPOBatchSizeController( dt_s: Control interval (seconds). site_id: Site identifier for multi-datacenter setups. vecnormalize_path: Optional path to a saved VecNormalize stats pickle - (``*_vecnormalize.pkl``). If provided, observations are normalized - with the saved running mean/var before being passed to the policy - — this MUST match the wrapper used during training, otherwise the + (`*_vecnormalize.pkl`). If provided, observations are normalized + with the saved running mean/var before being passed to the policy; + this MUST match the wrapper used during training, otherwise the policy sees out-of-distribution input. """ @@ -108,12 +123,13 @@ def __init__( model_path: str | Path, obs_config: ObservationConfig, dt_s: Fraction = Fraction(1), + algorithm: str = "PPO", vecnormalize_path: str | Path | None = None, ) -> None: self._models = inference_models self._datacenter = datacenter self._grid = grid - self._sb3_model = _load_sb3_model(model_path) + self._sb3_model = load_sb3_policy(model_path, algorithm=algorithm) self._vecnormalize = ( _load_vecnormalize(vecnormalize_path, self._sb3_model.observation_space, self._sb3_model.action_space) if vecnormalize_path is not None @@ -124,9 +140,10 @@ def __init__( self._feasible = {s.model_label: tuple(s.feasible_batch_sizes) for s in inference_models} self._prev_batch: dict[str, int] = {} self._zone_mask: np.ndarray | None = None - self._zone_mask_computed = False + self._zone_masks: dict[str, np.ndarray] | None = None + self._zone_masks_computed = False # Detect action mode from model's action space - self._action_mode = _detect_action_mode(self._sb3_model.action_space, len(inference_models)) + self._action_mode = _detect_action_mode(self._sb3_model.action_space) n_feasible = min(len(f) for f in self._feasible.values()) self._coupled_max_shift = n_feasible - 1 self._init_prev_batch() @@ -140,7 +157,7 @@ def dt_s(self) -> Fraction: def reset(self) -> None: self._init_prev_batch() - self._zone_mask_computed = False + self._zone_masks_computed = False def step( self, @@ -149,13 +166,21 @@ def step( ) -> list[DatacenterCommand | GridCommand]: datacenter = self._datacenter grid = self._grid - # Lazily compute zone mask on first step (grid must be started) - if not self._zone_mask_computed: + # Grid must be started before v_index is valid, so zone masks are + # computed lazily on the first step. + if not self._zone_masks_computed: if self._obs_config.zone_buses is not None: self._zone_mask = compute_zone_mask(grid.v_index, self._obs_config.zone_buses) - self._zone_mask_computed = True - - obs = build_observation(grid, datacenter, self._obs_config, self._prev_batch, self._zone_mask) + if self._obs_config.zone_summary: + self._zone_masks = { + zname: compute_zone_mask(grid.v_index, tuple(zbuses)) + for zname, zbuses in self._obs_config.zone_summary.items() + } + self._zone_masks_computed = True + + obs = build_observation( + grid, datacenter, self._obs_config, self._prev_batch, self._zone_mask, self._zone_masks + ) if self._vecnormalize is not None: obs = self._vecnormalize.normalize_obs(obs) action, _ = self._sb3_model.predict(obs, deterministic=True) @@ -180,7 +205,7 @@ class SharedPPOBatchSizeController( """Shared PPO controller that outputs batch sizes for ALL sites jointly. Requires the coordinator to have all datacenter sites registered. - Outputs one ``SetBatchSize`` command per site. + Outputs one `SetBatchSize` command per site. Args: model_path: Path to saved SB3 PPO model (.zip). @@ -198,11 +223,12 @@ def __init__( obs_config: ObservationConfig, site_model_mapping: dict[str, list[str]], dt_s: Fraction = Fraction(1), + algorithm: str = "PPO", vecnormalize_path: str | Path | None = None, ) -> None: self._datacenter = datacenter self._grid = grid - self._sb3_model = _load_sb3_model(model_path) + self._sb3_model = load_sb3_policy(model_path, algorithm=algorithm) self._vecnormalize = ( _load_vecnormalize(vecnormalize_path, self._sb3_model.observation_space, self._sb3_model.action_space) if vecnormalize_path is not None @@ -214,11 +240,9 @@ def __init__( self._feasible = dict(obs_config.feasible_batch_sizes) self._prev_batch: dict[str, int] = {} self._zone_mask: np.ndarray | None = None - self._zone_mask_computed = False - # Detect action mode from saved model so policies trained with - # Detect action mode so coupled vs delta decodes correctly. - n_models_total = len(self._obs_config.model_labels) - self._action_mode = _detect_action_mode(self._sb3_model.action_space, n_models_total) + self._zone_masks: dict[str, np.ndarray] | None = None + self._zone_masks_computed = False + self._action_mode = _detect_action_mode(self._sb3_model.action_space) n_feasible = min(len(f) for f in self._feasible.values()) self._coupled_max_shift = n_feasible - 1 # Per-site datacenter routing (set by attach_datacenters() before @@ -236,8 +260,8 @@ def attach_datacenters( ) -> None: """Register the site-id → DC mapping so the shared policy can both observe the joint multi-site state and route per-site - ``SetBatchSize`` commands to the correct DC. Call once after the - Coordinator is constructed and before ``coord.run()``. + `SetBatchSize` commands to the correct DC. Call once after the + Coordinator is constructed and before `coord.run()`. """ self._dcs_by_sid = dict(datacenters) self._all_datacenters = list(datacenters.values()) @@ -251,27 +275,35 @@ def dt_s(self) -> Fraction: def reset(self) -> None: self._init_prev_batch() - self._zone_mask_computed = False + self._zone_masks_computed = False def step( self, clock: SimulationClock, events: EventEmitter, ) -> list[DatacenterCommand | GridCommand]: - datacenter = self._datacenter grid = self._grid - if not self._zone_mask_computed: + if not self._zone_masks_computed: if self._obs_config.zone_buses is not None: self._zone_mask = compute_zone_mask(grid.v_index, self._obs_config.zone_buses) - self._zone_mask_computed = True - - # Build obs from all DCs. The Coordinator's step loop hands controllers - # only their own DC, but a shared policy needs the joint state. - # attach_datacenters() must be called once before coord.run() to - # register the full multi-site list; falls back to the single agent - # DC for legacy single-site behaviour. - dc_arg = self._all_datacenters if self._all_datacenters else datacenter - obs = build_observation(grid, dc_arg, self._obs_config, self._prev_batch, self._zone_mask) + if self._obs_config.zone_summary: + self._zone_masks = { + zname: compute_zone_mask(grid.v_index, tuple(zbuses)) + for zname, zbuses in self._obs_config.zone_summary.items() + } + self._zone_masks_computed = True + + # Shared policies need joint state from every site, but the Coordinator + # hands each controller only its own DC. `attach_datacenters` must be + # called before run(). + if not self._all_datacenters: + raise RuntimeError( + "SharedPPOBatchSizeController.step() called before attach_datacenters(); " + "a shared policy requires the full per-site DC mapping to build its joint observation." + ) + obs = build_observation( + grid, self._all_datacenters, self._obs_config, self._prev_batch, self._zone_mask, self._zone_masks + ) if self._vecnormalize is not None: obs = self._vecnormalize.normalize_obs(obs) action, _ = self._sb3_model.predict(obs, deterministic=True) @@ -288,17 +320,10 @@ def step( self._prev_batch = all_batch events.emit("controller.ppo.step", {"batch_size_by_model": all_batch}) - # Emit one SetBatchSize per site, routed to the correct DC via the - # sid → DC mapping registered by attach_datacenters(). For multi-site - # use the caller MUST call attach_datacenters() with a dict before - # coord.run(); otherwise we fall back to routing every command to - # self._datacenter (the single agent DC), which is only correct in - # the single-site case. commands: list[DatacenterCommand | GridCommand] = [] for sid, labels in self._site_model_mapping.items(): site_batch = {label: all_batch[label] for label in labels if label in all_batch} if not site_batch: continue - target_dc = self._dcs_by_sid.get(sid, datacenter) - commands.append(SetBatchSize(batch_size_by_model=site_batch, target=target_dc)) + commands.append(SetBatchSize(batch_size_by_model=site_batch, target=self._dcs_by_sid[sid])) return commands diff --git a/openg2g/rl/env.py b/examples/rl_controller/env.py similarity index 58% rename from openg2g/rl/env.py rename to examples/rl_controller/env.py index 6446856..f8fdcdc 100644 --- a/openg2g/rl/env.py +++ b/examples/rl_controller/env.py @@ -6,66 +6,78 @@ from collections.abc import Callable from dataclasses import dataclass from fractions import Fraction +from pathlib import Path from typing import Any import gymnasium import numpy as np from gymnasium import spaces +from scenarios import ( + EXPERIMENTS, + PVSystemSpec, + ScenarioRecord, + TimeVaryingLoadSpec, + load_library_data, + materialize_scenario, +) -from openg2g.clock import SimulationClock -from openg2g.common import ThreePhase from openg2g.controller.ofo import LogisticModelStore from openg2g.controller.tap_schedule import TapScheduleController -from openg2g.coordinator import SimulationLog +from openg2g.coordinator import Coordinator from openg2g.datacenter.base import DatacenterBackend, LLMDatacenterState -from openg2g.datacenter.command import SetBatchSize +from openg2g.datacenter.command import DatacenterCommand, SetBatchSize from openg2g.datacenter.config import InferenceModelSpec -from openg2g.events import EventEmitter from openg2g.grid.base import GridBackend - -# ── Observation & reward config ───────────────────────────────────────────── +from openg2g.grid.command import GridCommand @dataclass(frozen=True) class ObservationConfig: """Fixed observation space configuration. - The observation vector layout is:: - - [0 .. M-1] voltage vector (M bus-phase magnitudes, pu) - - Then, if ``zone_summary`` is set (per-zone voltage summary mode):: - - [M + 3*z + 0] zone z: worst undervoltage magnitude - [M + 3*z + 1] zone z: worst overvoltage magnitude - [M + 3*z + 2] zone z: fraction of bus-phases in violation - [M + 3*n_zones + 5*i] model i features (see below) - - Otherwise (global summary mode):: - - [M] worst undervoltage magnitude - [M+1] worst overvoltage magnitude - [M+2] fraction of bus-phases in violation - [M+3 + 5*i + 0] model i features (see below) - - Per-model features (5 values each):: - - + 0 normalized batch size (log2 scale) [0,1] - + 1 ITL / deadline [0,3] - + 2 active_replicas / max_replicas [0,1] - + 3 total 3-phase power (MW) - + 4 delta batch from prev step (log2 norm) [-1,1] - - Where M = ``n_bus_phases``. Set M = 0 for summary-only observations. - When ``zone_buses`` is set, M is the number of bus-phases within those - buses (subset of the full grid), and violation summaries are computed - over that zone only. - When ``zone_summary`` is set, the 3 global violation scalars are replaced - by 3 scalars per zone. Use with M = 0 for a compact multi-zone observation. - When ``bus_phase_groups`` is set, M must equal ``2 * n_buses`` and the - first M slots contain [min_phase_voltage, max_phase_voltage] per bus - instead of raw per-phase voltages. Each entry in ``bus_phase_groups`` - is a tuple of indices into the full ``v_vec`` for that bus's phases. + The observation vector layout is: + + ``` + [0 .. M-1] voltage vector (M bus-phase magnitudes, pu) + ``` + + Then, if `zone_summary` is set (per-zone voltage summary mode): + + ``` + [M + 3*z + 0] zone z: worst undervoltage magnitude + [M + 3*z + 1] zone z: worst overvoltage magnitude + [M + 3*z + 2] zone z: fraction of bus-phases in violation + [M + 3*n_zones + 5*i] model i features (see below) + ``` + + Otherwise (global summary mode): + + ``` + [M] worst undervoltage magnitude + [M+1] worst overvoltage magnitude + [M+2] fraction of bus-phases in violation + [M+3 + 5*i + 0] model i features (see below) + ``` + + Per-model features (5 values each): + + ``` + + 0 normalized batch size (log2 scale) [0,1] + + 1 ITL / deadline [0,3] + + 2 active_replicas / max_replicas [0,1] + + 3 total 3-phase power (MW) + + 4 delta batch from prev step (log2 norm) [-1,1] + ``` + + Where M = `n_bus_phases`. Set M = 0 for summary-only observations. When + `zone_buses` is set, M is the number of bus-phases within those buses + (subset of the full grid), and violation summaries are computed over + that zone only. When `zone_summary` is set, the 3 global violation + scalars are replaced by 3 scalars per zone: use with M = 0 for a + compact multi-zone observation. When `bus_phase_groups` is set, M must + equal `2 * n_buses` and the first M slots contain [min_phase_voltage, + max_phase_voltage] per bus instead of raw per-phase voltages; each + entry is a tuple of indices into the full `v_vec` for that bus's phases. """ model_labels: tuple[str, ...] @@ -179,24 +191,18 @@ class RewardConfig: catastrophic scenarios from dominating PPO updates. Recommended: 1.0.""" switch_mode: str = "magnitude" """Switch penalty mode: - - ``"magnitude"``: ``-w_switch * |log2(b_t) - log2(b_{t-1})|`` (original). - - ``"binary"``: ``-w_switch`` per model whenever batch size changes. - - ``"cooldown"``: ``-w_switch * exp(-steps_since_last_change / switch_cooldown_tau)`` + - `"magnitude"`: `-w_switch * |log2(b_t) - log2(b_{t-1})|` (original). + - `"binary"`: `-w_switch` per model whenever batch size changes. + - `"cooldown"`: `-w_switch * exp(-steps_since_last_change / switch_cooldown_tau)` per model whenever batch size changes (recent changes are expensive). """ w_safe: float = 0.0 """Small positive reward for keeping voltages in range. Each step the agent - receives ``+w_safe * (fraction of bus-phases within [v_min, v_max])``. + receives `+w_safe * (fraction of bus-phases within [v_min, v_max])`. Default 0 (disabled). Recommended starting value: 0.01.""" switch_cooldown_tau: float = 30.0 """Time constant (in steps) for the cooldown switch penalty. - Only used when ``switch_mode="cooldown"``.""" - action_mode: str = "delta" - """Action space mode: ``"delta"`` (per-model {-1,0,+1}, MultiDiscrete [3]*N) - or ``"coupled"`` (single Discrete action shifting all models by the same delta).""" - - -# ── Zone voltage filtering ────────────────────────────────────────────────── + Only used when `switch_mode="cooldown"`.""" def compute_zone_mask(v_index: list[tuple[str, int]], zone_buses: tuple[str, ...]) -> np.ndarray: @@ -217,11 +223,6 @@ def compute_bus_phase_groups(v_index: list[tuple[str, int]]) -> tuple[tuple[int, return tuple(tuple(idx) for idx in groups.values()) -def resolve_action_mode(cfg: RewardConfig) -> str: - """Return the action mode from the reward config.""" - return cfg.action_mode - - def decode_action( action: np.ndarray, action_mode: str, @@ -256,9 +257,6 @@ def _apply_delta(label: str, delta: int) -> int: raise ValueError(f"Unknown action_mode: {action_mode!r}") -# ── Observation & reward builders ─────────────────────────────────────────── - - def build_observation( grid: GridBackend, datacenter: DatacenterBackend[LLMDatacenterState] | list[DatacenterBackend[LLMDatacenterState]], @@ -295,7 +293,7 @@ def build_observation( v_min_cfg, v_max_cfg = obs_config.v_min, obs_config.v_max if obs_config.zone_summary: - # Per-zone violation summary — replaces the 3 global scalars + # Per-zone violation summary: replaces the 3 global scalars base = M for zone_name, _zone_bus_list in obs_config.zone_summary.items(): mask_z = (zone_masks or {}).get(zone_name) @@ -316,7 +314,7 @@ def build_observation( obs[M + 1] = float(np.max(over)) if n_total > 0 else 0.0 obs[M + 2] = float(np.count_nonzero(under > 0) + np.count_nonzero(over > 0)) / max(n_total, 1) - # Per-model features — gather DC states + # Per-model features: gather DC states dcs = datacenter if isinstance(datacenter, list) else [datacenter] # Build a merged state dict from all DCs batch_by_model: dict[str, int] = {} @@ -372,16 +370,16 @@ def compute_reward( """Compute per-step scalar reward. Returns: - ``(total_reward, reward_components, voltage_stats)``. + `(total_reward, reward_components, voltage_stats)`. - ``reward_components`` is a signed breakdown by source — keys - ``"voltage"``, ``"throughput"``, ``"latency"``, ``"switch"``. - Penalties are negative, bonuses positive; the sum equals - ``total_reward``. + `reward_components` is a signed breakdown by source: keys + `"voltage"`, `"throughput"`, `"latency"`, `"switch"`, + `"safe"`. Penalties are negative, bonuses positive; the sum + equals `total_reward`. - ``voltage_stats`` reports per-step grid health: ``"max_under"`` - (worst undervoltage magnitude in pu), ``"max_over"`` (worst - overvoltage), and ``"violation_frac"`` (fraction of bus-phases + `voltage_stats` reports per-step grid health: `"max_under"` + (worst undervoltage magnitude in pu), `"max_over"` (worst + overvoltage), and `"violation_frac"` (fraction of bus-phases currently in violation). All computed over the full grid. """ # Voltage violation penalty (over ALL buses, not just zone) @@ -452,8 +450,6 @@ def compute_reward( return reward, components, voltage_stats -# ── Simulation factory type ───────────────────────────────────────────────── - SimComponents = tuple[ dict[str, DatacenterBackend], # datacenters keyed by site_id GridBackend, # grid @@ -463,45 +459,45 @@ def compute_reward( MakeSimFn = Callable[..., SimComponents] -# ── Scenario library ─────────────────────────────────────────────────────── +class ScenarioLibrary: + """Pre-screened scenario bank for PPO training and evaluation. + Loaded from the directory produced by `build_library.py`: -class ScenarioLibrary: - """Pre-screened scenario bank for PPO training. + - `metadata.json`: build-time config + per-scenario scalar fields. + - `traces.npz`: per-scenario voltage penalty arrays + (`ofo_` / `baseline_`). - Loaded from the ``library.pkl`` produced by ``build_scenario_library.py``. - Each scenario stores the seed (for deterministic replay via - ``randomize_scenario``), the per-second OFO voltage penalty trace (for - OFO-difference reward), and optionally ``t_control_start`` / - ``t_control_end`` (for episode truncation). + `materialize(rec)` replays `randomize_scenario(seed=rec.seed, ...)` to + rebuild the full per-episode scenario configuration on demand. Replay is + bit-identical to the build-time output because `randomize_scenario` is + fully seeded: there is no on-disk cache of the resolved configs to keep + in sync. """ - def __init__(self, path: str) -> None: - import pickle - import sys - import types - from pathlib import Path - - # Register a stub module so pickle can resolve - # build_scenario_library.ScenarioRecord without requiring the - # script to be on sys.path. - if "build_scenario_library" not in sys.modules: - _stub = types.ModuleType("build_scenario_library") - - class _ScenarioRecord: - pass - - _ScenarioRecord.__module__ = "build_scenario_library" - _ScenarioRecord.__qualname__ = "ScenarioRecord" - _stub.ScenarioRecord = _ScenarioRecord - sys.modules["build_scenario_library"] = _stub - - with open(Path(path), "rb") as f: - data = pickle.load(f) - self.scenarios: list = data["scenarios"] - self.config: dict = data.get("config", {}) + def __init__( + self, + path: str, + *, + training_trace=None, + ) -> None: + lib_dir = Path(path) + self.scenarios, self.config = load_library_data(lib_dir) if not self.scenarios: raise ValueError(f"Scenario library at {path} is empty.") + + # Materialization base components, reconstructed from the experiment + # factory + library config. randomize_scenario needs these to replay. + base_exp = EXPERIMENTS[self.config["system"]](training_trace=training_trace) + self._dc_sites_base: dict[str, Any] = base_exp["dc_sites"] + self._pv_systems_base = [PVSystemSpec(**p) for p in self.config["pv_systems_base"]] + self._tvl_base = [TimeVaryingLoadSpec(**t) for t in self.config["tvl_base"]] + overlay_cfg = self.config.get("training_base") + if overlay_cfg is not None and training_trace is not None: + self._training_base: dict | None = {**overlay_cfg, "trace": training_trace} + else: + self._training_base = None + self._rng = np.random.default_rng() def __len__(self) -> int: @@ -512,22 +508,34 @@ def sample(self) -> Any: idx = int(self._rng.integers(0, len(self.scenarios))) return self.scenarios[idx] - -# ── Per-site Gymnasium environment ────────────────────────────────────────── + def materialize(self, rec: ScenarioRecord) -> dict: + """Re-derive the full per-episode scenario dict for `rec`. + + Returns the same shape as `randomize_scenario` (keys `dc_sites`, + `pv_systems`, `tvl`, `training_run`, `params`, ...). + """ + return materialize_scenario( + rec, + dc_sites_base=self._dc_sites_base, + pv_systems_base=self._pv_systems_base, + tvl_base=self._tvl_base, + training_base=self._training_base, + randomize_kwargs=self.config["randomize_kwargs"], + ) class BatchSizeEnv(gymnasium.Env): """Gymnasium environment for batch-size voltage regulation. - Each ``step(action)`` advances the simulation by one control interval. - For multi-DC setups, ``agent_site_id`` specifies which site the RL agent + Each `step(action)` advances the simulation by one control interval. + For multi-DC setups, `agent_site_id` specifies which site the RL agent controls. Other sites run with fixed batch sizes. - When ``scenario_library`` is provided, each ``reset()`` samples a - pre-screened scenario and replays it. When ``ofo_baseline=True`` the + When `scenario_library` is provided, each `reset()` samples a + pre-screened scenario and replays it. When `ofo_baseline=True` the voltage reward term becomes the per-step difference between PPO's voltage penalty and the OFO oracle's (stored in the library), so the - agent is rewarded for *improving on OFO*. When ``truncate_episode=True`` + agent is rewarded for *improving on OFO*. When `truncate_episode=True` the episode fast-forwards through the initial quiet period (before the first baseline violation) and terminates after the last violation clears. """ @@ -540,6 +548,7 @@ def __init__( obs_config: ObservationConfig, agent_site_id: str = "_default", reward_config: RewardConfig | None = None, + action_mode: str = "delta", logistic_models: LogisticModelStore | None = None, dt_ctrl: Fraction = Fraction(1), total_duration_s: int = 3600, @@ -559,13 +568,13 @@ def __init__( self._ofo_baseline = ofo_baseline self._truncate_episode = truncate_episode - self._action_mode = resolve_action_mode(self._reward_config) + self._action_mode = action_mode self.observation_space = spaces.Box(low=-np.inf, high=np.inf, shape=(obs_config.obs_dim,), dtype=np.float32) n_feasible = min(len(obs_config.feasible_batch_sizes[m]) for m in obs_config.model_labels) n_models = len(obs_config.model_labels) - self._coupled_max_shift = n_feasible - 1 # e.g. 6 for 7 batch sizes - n_coupled = 2 * self._coupled_max_shift + 1 # e.g. 13 + self._coupled_max_shift = n_feasible - 1 + n_coupled = 2 * self._coupled_max_shift + 1 if self._action_mode == "coupled": self.action_space = spaces.Discrete(n_coupled) @@ -575,23 +584,15 @@ def __init__( raise ValueError(f"Unknown action_mode: {self._action_mode!r}") self._datacenters: dict[str, DatacenterBackend] = {} - self._grid: GridBackend | None = None - self._tap_ctrl: TapScheduleController | None = None - self._clock: SimulationClock | None = None - self._log: SimulationLog | None = None - self._dc_events: EventEmitter | None = None - self._grid_events: EventEmitter | None = None - self._ctrl_events: EventEmitter | None = None - self._dc_buffers: dict[str, list[ThreePhase]] = {} + self._coord: Coordinator | None = None self._prev_batch: dict[str, int] = {} self._steps_since_change: dict[str, int] = {} self._steps_done: int = 0 self._max_steps: int = 0 self._zone_mask: np.ndarray | None = None self._zone_masks: dict[str, np.ndarray] | None = None - # Per-episode state from the scenario library self._ofo_voltage_trace: np.ndarray | None = None - self._sim_step_offset: int = 0 # how many sim steps were fast-forwarded + self._sim_step_offset: int = 0 # base-tick offset of the first PPO control step def _action_to_batch_sizes(self, action: np.ndarray) -> dict[str, int]: return decode_action( @@ -603,104 +604,73 @@ def _action_to_batch_sizes(self, action: np.ndarray) -> dict[str, int]: self._coupled_max_shift, ) - @property - def _agent_dc(self) -> DatacenterBackend: + def _action_to_commands(self, action: np.ndarray) -> tuple[list[DatacenterCommand | GridCommand], dict[str, int]]: + """Decode an action into the commands to dispatch this step. + + Returns `(commands, applied_batch_sizes)`: the `applied_batch_sizes` + dict is what `compute_reward` and the steps-since-change tracking + consume. Subclasses (e.g. `SharedBatchSizeEnv`) override to dispatch + per-site commands when one policy controls multiple datacenters. + """ + batch_sizes = self._action_to_batch_sizes(action) + commands: list[DatacenterCommand | GridCommand] = [ + SetBatchSize(batch_size_by_model=batch_sizes, target=self._datacenters[self._agent_site_id]) + ] + return commands, batch_sizes + + def _obs_target(self) -> DatacenterBackend | list[DatacenterBackend]: + """Datacenter(s) feeding `build_observation` / `compute_reward`. + + Single-policy default: just the agent's DC. `SharedBatchSizeEnv` + overrides to return all DCs jointly. + """ return self._datacenters[self._agent_site_id] - def _advance_ticks(self, n_ticks: int) -> None: - """Advance all components by *n_ticks* base ticks.""" - datacenters = self._datacenters - grid = self._grid - tap_ctrl = self._tap_ctrl - clock = self._clock - assert grid is not None and clock is not None + def _advance_one_control_interval(self) -> None: + coord = self._coord + if coord is None: + raise RuntimeError("Coordinator not started; call reset() first.") + n_ticks = int(self._dt_ctrl / coord.clock.tick_s) for _ in range(n_ticks): - for sid, dc in datacenters.items(): - if clock.is_due(dc.dt_s): - dc_state = dc.do_step(clock, self._dc_events) - self._dc_buffers[sid].append(dc_state.power_w) - if clock.is_due(grid.dt_s): - # Master's OpenDSSGrid.step() expects power_samples_w keyed - # by the DatacenterBackend object (the same key used in - # grid.attach_dc), not by the site-id string. - power_arg = {datacenters[sid]: list(buf) for sid, buf in self._dc_buffers.items()} - grid.do_step(clock, power_arg, self._grid_events) - for buf in self._dc_buffers.values(): - buf.clear() - if tap_ctrl is not None and clock.is_due(tap_ctrl.dt_s): - # Master's TapScheduleController.step() takes (clock, events). - # The old (clock, datacenter, grid, events) signature is gone. - cmds = tap_ctrl.step(clock, self._ctrl_events) - for cmd in cmds: - grid.apply_control(cmd, self._grid_events) - clock.advance() + coord.step() def reset(self, *, seed: int | None = None, options: dict[str, Any] | None = None) -> tuple[np.ndarray, dict]: super().reset(seed=seed, options=options) - # If using a scenario library, sample a scenario and stash the OFO - # trace for the difference reward. The make_sim_fn is expected to - # pick up the scenario seed from an external variable set by the - # caller (train_ppo.py manages this via a closure). + if self._coord is not None: + self._coord.stop() + self._coord = None + self._ofo_voltage_trace = None self._sim_step_offset = 0 t_control_start = 0 t_control_end = self._total_duration_s if self._scenario_library is not None: - scenario = self._scenario_library.sample() + scenario_record = self._scenario_library.sample() if self._ofo_baseline: - self._ofo_voltage_trace = scenario.ofo_voltage_pen_per_step + self._ofo_voltage_trace = scenario_record.ofo_voltage_pen_per_step if self._truncate_episode: - t_control_start = getattr(scenario, "t_control_start", 0) - t_control_end = getattr(scenario, "t_control_end", self._total_duration_s) - # Pass the scenario to make_sim so it uses the library's params - self._current_scenario = scenario - - if self._scenario_library is not None: - datacenters, grid, tap_ctrl = self._make_sim(scenario_override=self._current_scenario) + t_control_start = scenario_record.t_control_start + t_control_end = scenario_record.t_control_end + scenario_dict = self._scenario_library.materialize(scenario_record) + datacenters, grid, tap_ctrl = self._make_sim(scenario_override=scenario_dict) else: datacenters, grid, tap_ctrl = self._make_sim() self._datacenters = datacenters - self._grid = grid - self._tap_ctrl = tap_ctrl - - all_dcs = list(datacenters.values()) - periods = [dc.dt_s for dc in all_dcs] + [grid.dt_s] - if tap_ctrl is not None: - periods.append(tap_ctrl.dt_s) - periods.append(self._dt_ctrl) - tick = periods[0] - for p in periods[1:]: - tick = _gcd_fraction(tick, p) - - self._clock = SimulationClock(tick_s=tick) - self._log = SimulationLog() - self._dc_events = EventEmitter(self._clock, self._log, "datacenter") - self._grid_events = EventEmitter(self._clock, self._log, "grid") - self._ctrl_events = EventEmitter(self._clock, self._log, "controller") - self._dc_buffers = {sid: [] for sid in datacenters} - self._steps_done = 0 - for dc in all_dcs: - dc.do_reset() - grid.do_reset() - if tap_ctrl is not None: - tap_ctrl.reset() - for dc in all_dcs: - dc.start() - grid.start() - if tap_ctrl is not None: - tap_ctrl.start() - - # Compute zone mask if configured (single-zone voltage vector mode) - zone_buses = self._obs_config.zone_buses - if zone_buses is not None: - self._zone_mask = compute_zone_mask(grid.v_index, zone_buses) - else: - self._zone_mask = None + controllers = [tap_ctrl] if tap_ctrl is not None else [] + self._coord = Coordinator( + datacenters=list(datacenters.values()), + grid=grid, + controllers=controllers, + total_duration_s=self._total_duration_s, + ) + self._coord.reset() + self._coord.start() - # Compute per-zone masks for zone_summary mode + zone_buses = self._obs_config.zone_buses + self._zone_mask = compute_zone_mask(grid.v_index, zone_buses) if zone_buses is not None else None if self._obs_config.zone_summary: self._zone_masks = { zname: compute_zone_mask(grid.v_index, tuple(zbuses)) @@ -709,104 +679,98 @@ def reset(self, *, seed: int | None = None, options: dict[str, Any] | None = Non else: self._zone_masks = None - n_ticks_per_ctrl = int(self._dt_ctrl / tick) - - # Fast-forward through the quiet period with fixed initial batch sizes - # (no PPO control). The sim still runs so the grid/DC states evolve. + # Fast-forward through the quiet pre-violation window with fixed initial + # batch sizes; the sim still runs so grid/DC states evolve. self._sim_step_offset = t_control_start for _ in range(t_control_start): - self._advance_ticks(n_ticks_per_ctrl) - - # PPO episode length: from t_control_start to t_control_end - self._max_steps = t_control_end - t_control_start - - # Run one more tick to get the first observation at t_control_start - self._advance_ticks(n_ticks_per_ctrl) + self._advance_one_control_interval() + + # One more interval so the first observation reflects t_control_start. + # That consumes one tick of the control window, so the agent gets + # `(t_control_end - t_control_start - 1)` steps to act on; without the + # -1, the final step's sim time would be t_control_end and overshoot + # the OFO trace (which covers [0, total_duration_s - 1]). + self._advance_one_control_interval() + self._max_steps = t_control_end - t_control_start - 1 + self._steps_done = 0 self._prev_batch = {label: self._obs_config.get_initial_batch(label) for label in self._obs_config.model_labels} self._steps_since_change = {label: 999 for label in self._obs_config.model_labels} obs = build_observation( - grid, self._agent_dc, self._obs_config, self._prev_batch, self._zone_mask, self._zone_masks + self._coord.grid, self._obs_target(), self._obs_config, self._prev_batch, self._zone_mask, self._zone_masks ) return obs, {} def step(self, action: np.ndarray) -> tuple[np.ndarray, float, bool, bool, dict]: - assert self._grid is not None and self._clock is not None - - batch_sizes = self._action_to_batch_sizes(action) - cmd = SetBatchSize(batch_size_by_model=batch_sizes, target=self._agent_dc) - self._agent_dc.apply_control(cmd, self._dc_events) + coord = self._coord + if coord is None: + raise RuntimeError("Coordinator not started; call reset() first.") - n_ticks_per_ctrl = int(self._dt_ctrl / self._clock.tick_s) - self._advance_ticks(n_ticks_per_ctrl) + commands, applied_batch = self._action_to_commands(action) + coord.dispatch_commands(commands) + self._advance_one_control_interval() self._steps_done += 1 + target = self._obs_target() reward, reward_components, voltage_stats = compute_reward( - self._grid, - self._agent_dc, + coord.grid, + target, self._obs_config, self._reward_config, self._prev_batch, - batch_sizes, + applied_batch, self._logistic_models, steps_since_change=self._steps_since_change, ) - # Update steps-since-change tracking for label in self._obs_config.model_labels: - if batch_sizes.get(label) != self._prev_batch.get(label): + if applied_batch.get(label) != self._prev_batch.get(label): self._steps_since_change[label] = 0 else: self._steps_since_change[label] = self._steps_since_change.get(label, 999) + 1 - # OFO-difference reward: subtract the OFO oracle's voltage penalty - # at this simulation timestep so the agent is rewarded for beating OFO. + # OFO-difference reward: add back the OFO oracle's voltage penalty at + # this simulation timestep so the agent is rewarded for beating OFO. if self._ofo_voltage_trace is not None: sim_t = self._sim_step_offset + self._steps_done - ofo_pen = float(self._ofo_voltage_trace[sim_t]) if sim_t < len(self._ofo_voltage_trace) else 0.0 + if sim_t >= len(self._ofo_voltage_trace): + raise IndexError( + f"OFO trace lookup at sim_t={sim_t} but trace length is " + f"{len(self._ofo_voltage_trace)}; episode is running past the trace. " + "Check t_control_end vs. the scenario library's total_duration_s." + ) + ofo_pen = float(self._ofo_voltage_trace[sim_t]) ofo_voltage_baseline = self._reward_config.w_voltage * ofo_pen reward += ofo_voltage_baseline reward_components = dict(reward_components) reward_components["voltage"] += ofo_voltage_baseline reward_components["ofo_baseline"] = ofo_voltage_baseline - # Clip reward to prevent catastrophic scenarios from dominating updates. if self._reward_config.reward_clip > 0: reward = max(reward, -self._reward_config.reward_clip) - self._prev_batch = dict(batch_sizes) + self._prev_batch = dict(applied_batch) obs = build_observation( - self._grid, self._agent_dc, self._obs_config, self._prev_batch, self._zone_mask, self._zone_masks + coord.grid, target, self._obs_config, self._prev_batch, self._zone_mask, self._zone_masks ) truncated = self._steps_done >= self._max_steps - if truncated: - self._stop_components() info = {"reward_components": reward_components, "voltage_stats": voltage_stats} return obs, reward, False, truncated, info - def _stop_components(self) -> None: - if self._tap_ctrl is not None: - self._tap_ctrl.stop() - if self._grid is not None: - self._grid.stop() - for dc in self._datacenters.values(): - dc.stop() - def close(self) -> None: - self._stop_components() + if self._coord is not None: + self._coord.stop() + self._coord = None super().close() -# ── Shared multi-site Gymnasium environment ───────────────────────────────── - - class SharedBatchSizeEnv(BatchSizeEnv): """Controls ALL datacenter sites jointly with a single policy. - The observation includes per-model features from all sites. - The action space covers all models across all sites. - ``site_model_mapping`` maps site_id → list of model labels at that site. + The observation includes per-model features from all sites; the action + space covers all models across all sites. `site_model_mapping` maps each + site_id to the list of model labels served at that site. """ def __init__( @@ -815,6 +779,7 @@ def __init__( obs_config: ObservationConfig, site_model_mapping: dict[str, list[str]], reward_config: RewardConfig | None = None, + action_mode: str = "delta", logistic_models: LogisticModelStore | None = None, dt_ctrl: Fraction = Fraction(1), total_duration_s: int = 3600, @@ -822,13 +787,11 @@ def __init__( ofo_baseline: bool = False, truncate_episode: bool = False, ) -> None: - # Use the first site as agent_site_id (unused for shared, but needed by parent) - first_site = next(iter(site_model_mapping)) super().__init__( make_sim_fn=make_sim_fn, obs_config=obs_config, - agent_site_id=first_site, reward_config=reward_config, + action_mode=action_mode, logistic_models=logistic_models, dt_ctrl=dt_ctrl, total_duration_s=total_duration_s, @@ -838,81 +801,19 @@ def __init__( ) self._site_model_mapping = site_model_mapping - def _action_to_site_batch_sizes(self, action: np.ndarray) -> dict[str, dict[str, int]]: - """Convert action indices to per-site batch size dicts.""" - flat = super()._action_to_batch_sizes(action) - result: dict[str, dict[str, int]] = {} + def _action_to_commands(self, action: np.ndarray) -> tuple[list[DatacenterCommand | GridCommand], dict[str, int]]: + flat = self._action_to_batch_sizes(action) + commands: list[DatacenterCommand | GridCommand] = [] + applied_batch: dict[str, int] = {} for sid, labels in self._site_model_mapping.items(): - result[sid] = {label: flat[label] for label in labels if label in flat} - return result - - @property - def _all_dcs_list(self) -> list[DatacenterBackend]: + if sid not in self._datacenters: + continue + site_batch = {label: flat[label] for label in labels if label in flat} + if not site_batch: + continue + commands.append(SetBatchSize(batch_size_by_model=site_batch, target=self._datacenters[sid])) + applied_batch.update(site_batch) + return commands, applied_batch + + def _obs_target(self) -> list[DatacenterBackend]: return list(self._datacenters.values()) - - def reset(self, *, seed: int | None = None, options: dict[str, Any] | None = None) -> tuple[np.ndarray, dict]: - # Call parent reset (sets up sim, runs initial ticks) - obs, info = super().reset(seed=seed, options=options) - # Rebuild obs using ALL DCs - obs = build_observation( - self._grid, self._all_dcs_list, self._obs_config, self._prev_batch, self._zone_mask, self._zone_masks - ) - return obs, info - - def step(self, action: np.ndarray) -> tuple[np.ndarray, float, bool, bool, dict]: - assert self._grid is not None and self._clock is not None - - # Apply batch sizes to each site's DC - site_batches = self._action_to_site_batch_sizes(action) - all_batch_sizes: dict[str, int] = {} - for sid, batches in site_batches.items(): - if sid in self._datacenters: - dc = self._datacenters[sid] - cmd = SetBatchSize(batch_size_by_model=batches, target=dc) - dc.apply_control(cmd, self._dc_events) - all_batch_sizes.update(batches) - - n_ticks_per_ctrl = int(self._dt_ctrl / self._clock.tick_s) - self._advance_ticks(n_ticks_per_ctrl) - self._steps_done += 1 - - reward, reward_components, voltage_stats = compute_reward( - self._grid, - self._all_dcs_list, - self._obs_config, - self._reward_config, - self._prev_batch, - all_batch_sizes, - self._logistic_models, - steps_since_change=self._steps_since_change, - ) - - # Update steps-since-change tracking - for label in self._obs_config.model_labels: - if all_batch_sizes.get(label) != self._prev_batch.get(label): - self._steps_since_change[label] = 0 - else: - self._steps_since_change[label] = self._steps_since_change.get(label, 999) + 1 - - self._prev_batch = dict(all_batch_sizes) - obs = build_observation( - self._grid, self._all_dcs_list, self._obs_config, self._prev_batch, self._zone_mask, self._zone_masks - ) - - truncated = self._steps_done >= self._max_steps - if truncated: - self._stop_components() - info = {"reward_components": reward_components, "voltage_stats": voltage_stats} - return obs, reward, False, truncated, info - - -# ── Utilities ─────────────────────────────────────────────────────────────── - - -def _gcd_fraction(a: Fraction, b: Fraction) -> Fraction: - """GCD of two Fractions.""" - from math import gcd - - num = gcd(a.numerator * b.denominator, b.numerator * a.denominator) - den = a.denominator * b.denominator - return Fraction(num, den) diff --git a/examples/offline/evaluate_controllers.py b/examples/rl_controller/evaluate.py similarity index 62% rename from examples/offline/evaluate_controllers.py rename to examples/rl_controller/evaluate.py index 94d8852..adfcd21 100644 --- a/examples/offline/evaluate_controllers.py +++ b/examples/rl_controller/evaluate.py @@ -5,15 +5,15 @@ plots). Usage: - python examples/offline/evaluate_controllers.py \ - --ppo-models outputs/ieee13/ppo_ablation_a4_full_episode/ppo_model.zip \ - outputs/ieee13/ppo_ablation_a9_low_ent/ppo_model.zip \ - --ppo-labels a4_full_ep a9_low_ent \ + python examples/rl_controller/evaluate.py \ + --ppo-models outputs/ieee13/ppo_seed1/ppo_model.zip \ + outputs/ieee13/ppo_seed2/ppo_model.zip \ + --ppo-labels seed1 seed2 \ --n-scenarios 10 --seed-start 500 # Quick test with 3 scenarios - python examples/offline/evaluate_controllers.py \ - --ppo-models outputs/ieee13/ppo_ablation_a5_2M/ppo_model.zip \ + python examples/rl_controller/evaluate.py \ + --ppo-models outputs/ieee13/ppo/ppo_model.zip \ --n-scenarios 3 """ @@ -25,245 +25,36 @@ from dataclasses import dataclass from pathlib import Path -import matplotlib - -matplotlib.use("Agg") -import matplotlib.pyplot as plt import numpy as np -from build_scenario_library import run_simulation +from build_library import run_simulation +from env import ScenarioLibrary +from scenarios import ( + EXPERIMENTS, + randomize_scenario, +) from openg2g.controller.ofo import LogisticModelStore, OFOConfig from openg2g.controller.rule_based import RuleBasedConfig from openg2g.datacenter.workloads.inference import InferenceData from openg2g.datacenter.workloads.training import TrainingTrace +from plotting import ( + plot_aggregate, + plot_batch_comparison, + plot_violation_bars, + plot_voltage_comparison, +) from systems import ( DT_DC, - EXPERIMENTS, SPECS_CACHE_DIR, TRAINING_TRACE_PATH, V_MAX, V_MIN, - materialize_scenario, - randomize_scenario, ) logger = logging.getLogger("evaluate_controllers") -# ── Display helpers ─────────────────────────────────────────────────────────── - -DISPLAY_NAMES: dict[str, str] = { - "baseline_no_tap": "No Control", - "rule_based": "Droop Control", - "ofo": "OFO Control", -} - - -def _display_order_key(mode: str) -> int: - if mode == "baseline_no_tap": - return 0 - if mode == "rule_based" or mode.startswith("rule_based_s"): - return 1 - if mode.startswith("ppo_"): - return 2 - if mode == "ofo" or mode.startswith("ofo_"): - return 3 - return 99 - - -def _sort_modes(modes: list[str]) -> list[str]: - return sorted(modes, key=_display_order_key) - - -def _display_name(mode: str) -> str: - if mode in DISPLAY_NAMES: - return DISPLAY_NAMES[mode] - if mode.startswith("rule_based_s"): - return "Droop Control" - if mode.startswith("ppo_"): - return "PPO Control" - return mode.replace("_", " ").title() - - -# ── Plotting ────────────────────────────────────────────────────────────────── - - -def plot_voltage_comparison( - logs: dict[str, object], - save_dir: Path, - *, - v_min: float = 0.95, - v_max: float = 1.05, - exclude_buses: tuple[str, ...] = (), - scenario_idx: int | None = None, - use_display_names: bool = False, -) -> None: - """Side-by-side voltage envelopes for each controller mode.""" - modes = _sort_modes(list(logs.keys())) - n = len(modes) - fig, axes = plt.subplots(1, n, figsize=(6 * n, 5), sharey=True) - if n == 1: - axes = [axes] - - drop = {b.lower() for b in exclude_buses} - - for ax, mode in zip(axes, modes, strict=False): - log = logs[mode] - time_s = np.array(log.time_s) - - v_min_arr = np.full(len(log.grid_states), np.inf) - v_max_arr = np.full(len(log.grid_states), -np.inf) - - for t_idx, gs in enumerate(log.grid_states): - for bus in gs.voltages.buses(): - if bus.lower() in drop: - continue - pv = gs.voltages[bus] - for v in (pv.a, pv.b, pv.c): - if not math.isnan(v): - v_min_arr[t_idx] = min(v_min_arr[t_idx], v) - v_max_arr[t_idx] = max(v_max_arr[t_idx], v) - - ax.fill_between(time_s, v_min_arr, v_max_arr, alpha=0.3, color="steelblue") - ax.plot(time_s, v_min_arr, color="steelblue", linewidth=0.5, label="Vmin") - ax.plot(time_s, v_max_arr, color="coral", linewidth=0.5, label="Vmax") - ax.axhline(v_min, color="red", linestyle="--", linewidth=1, alpha=0.7) - ax.axhline(v_max, color="red", linestyle="--", linewidth=1, alpha=0.7) - ax.set_xlabel("Time (s)", fontsize=13) - ax.set_title(_display_name(mode) if use_display_names else mode, fontsize=14) - ax.legend(fontsize=12) - ax.tick_params(labelsize=12) - ax.grid(True, alpha=0.2) - - axes[0].set_ylabel("Voltage (pu)", fontsize=13) - fig.suptitle("Voltage Envelope Comparison", fontsize=16, fontweight="bold") - fig.tight_layout() - stem = f"scenario_{scenario_idx:03d}_voltage_comparison" if scenario_idx is not None else "voltage_comparison" - fig.savefig(save_dir / f"{stem}.png", dpi=150, bbox_inches="tight") - plt.close(fig) - logger.info("Saved %s.png", stem) - - -def plot_violation_bars( - results: dict[str, dict], - save_dir: Path, - *, - scenario_idx: int | None = None, - use_display_names: bool = False, -) -> None: - """Four-panel bar chart for a single scenario: - violation time, integral violation, mean throughput, batch size changes. - """ - modes = _sort_modes(list(results.keys())) - if not modes: - return - - cmap = plt.get_cmap("tab10") - colors = [cmap(i % 10) for i in range(len(modes))] - - viol_s = [float(results[m].get("violation_time_s", 0.0)) for m in modes] - integ = [float(results[m].get("integral", 0.0)) for m in modes] - tput = [float(results[m].get("mean_throughput_toks_s", 0.0)) for m in modes] - batch_chg = [float(results[m].get("batch_changes", 0.0)) for m in modes] - labels = [_display_name(m) if use_display_names else m for m in modes] - - fig, axes = plt.subplots(1, 4, figsize=(max(18, 2.0 * len(modes) + 12), 6)) - ax_v, ax_i, ax_t, ax_b = axes - - x = np.arange(len(modes)) - for ax, vals, ylabel, title, fmt in [ - (ax_v, viol_s, "Violation time (s)", "Violation time", "{:.0f}"), - (ax_i, integ, "Integral violation (pu·s)", "Integral violation", "{:.2f}"), - (ax_t, tput, "Throughput (tok/s)", "Mean throughput", "{:.2e}"), - (ax_b, batch_chg, "Batch size changes", "Batch size changes", "{:.0f}"), - ]: - ax.bar(x, vals, color=colors, alpha=0.88, edgecolor="black", linewidth=0.5) - ax.set_xticks(x) - ax.set_xticklabels(labels, rotation=35, ha="right", fontsize=12) - ax.set_ylabel(ylabel, fontsize=13) - ax.set_title(title, fontsize=14) - ax.tick_params(axis="y", labelsize=12) - ax.grid(axis="y", alpha=0.3) - for xi, val in zip(x, vals, strict=False): - ax.text(xi, val, fmt.format(val), ha="center", va="bottom", fontsize=10) - - fig.suptitle("Per-scenario controller metrics", fontsize=16, fontweight="bold") - fig.tight_layout() - stem = f"scenario_{scenario_idx:03d}_performance_summary" if scenario_idx is not None else "violation_bars" - fig.savefig(save_dir / f"{stem}.png", dpi=150, bbox_inches="tight") - plt.close(fig) - logger.info("Saved %s.png", stem) - - -def plot_batch_comparison( - logs: dict[str, object], - save_dir: Path, - *, - scenario_idx: int | None = None, - use_display_names: bool = False, -) -> None: - """Batch size over time for each controller mode, one subplot per (site, model) pair.""" - modes = _sort_modes(list(logs.keys())) - if not modes: - logger.info("plot_batch_comparison: no controllers to plot") - return - - site_models: list[tuple[str, str]] = [] - for log in logs.values(): - for site_id, states in log.dc_states_by_site.items(): - if not states: - continue - for m in states[0].batch_size_by_model: - pair = (site_id, m) - if pair not in site_models: - site_models.append(pair) - break - - n_rows = len(site_models) - cmap = plt.get_cmap("tab10") - fig, axes = plt.subplots( - n_rows, - 1, - figsize=(13, 4 * n_rows), - sharex=True, - squeeze=False, - ) - - for row, (site_id, model_label) in enumerate(site_models): - ax = axes[row][0] - for i, mode in enumerate(modes): - log = logs[mode] - site_states = log.dc_states_by_site.get(site_id, []) - times = [s.time_s for s in site_states] - batches = [s.batch_size_by_model.get(model_label, 0) for s in site_states] - ax.plot( - times, - batches, - color=cmap(i % 10), - linewidth=1.5, - alpha=0.85, - label=_display_name(mode) if use_display_names else mode, - ) - ax.set_ylabel("Batch Size", fontsize=13) - title = f"{model_label} @ {site_id}" if len(log.dc_states_by_site) > 1 else model_label - ax.set_title(title, fontsize=14) - ax.legend(fontsize=12, loc="upper right") - ax.tick_params(labelsize=12) - ax.grid(True, alpha=0.3) - - axes[-1][0].set_xlabel("Time (s)", fontsize=13) - fig.suptitle("Batch Size Comparison by Model", fontsize=16, fontweight="bold") - fig.tight_layout(rect=[0, 0, 1, 0.95]) - stem = f"scenario_{scenario_idx:03d}_batch_size_comparison" if scenario_idx is not None else "batch_size_comparison" - fig.savefig(save_dir / f"{stem}.png", dpi=150, bbox_inches="tight") - plt.close(fig) - logger.info("Saved %s.png", stem) - - -# ── Metrics helpers ─────────────────────────────────────────────────────────── - - def count_batch_changes(log) -> int: """Count step-to-step batch-size changes summed across every (site, model) series.""" total = 0 @@ -359,9 +150,6 @@ def extract_perf_metrics(log, itl_deadlines: dict[str, float] | None = None) -> } -# ── Scenario generation ─────────────────────────────────────────────────────── - - def generate_test_scenarios( exp: dict, inference_data: InferenceData, @@ -466,7 +254,7 @@ def generate_test_scenarios( ) if len(accepted) < n_scenarios: logger.warning( - "Only accepted %d/%d scenarios — consider lowering min_recovery_frac or expanding seed range", + "Only accepted %d/%d scenarios: consider lowering min_recovery_frac or expanding seed range", len(accepted), n_scenarios, ) @@ -476,36 +264,31 @@ def generate_test_scenarios( def load_scenarios_from_library( library_path: str, *, - exp: dict, n_scenarios: int, + training_trace: TrainingTrace, ) -> list[dict]: - """Load pre-screened scenarios from a ScenarioLibrary pickle. + """Load pre-screened scenarios from a `ScenarioLibrary` directory. - Each record carries its resolved ``dc_sites`` / ``pv_systems`` / ``tvl`` - directly, so loading is a pure pickle read — no rng replay, no - distribution mismatch when records from different build-time ranges are - mixed in one library. + Replays `randomize_scenario(seed)` for each record (deterministic, since + the RNG is seeded) to rebuild the per-episode dict. `training_trace` is + needed because libraries built with `--use-training-overlay` reference + a TrainingTrace at materialization time; for libraries without overlay + it can be `None`. """ - from openg2g.rl.env import ScenarioLibrary - - lib = ScenarioLibrary(library_path) - training_base = exp.get("training_base") + lib = ScenarioLibrary(library_path, training_trace=training_trace) logger.info("Loaded library with %d records from %s", len(lib), library_path) n_take = min(n_scenarios, len(lib)) if n_take < n_scenarios: logger.warning( - "Library has only %d scenarios — capping n_scenarios from %d to %d", + "Library has only %d scenarios; capping n_scenarios from %d to %d", len(lib), n_scenarios, n_take, ) - return [materialize_scenario(rec, training_base=training_base) for rec in lib.scenarios[:n_take]] - - -# ── Evaluation ──────────────────────────────────────────────────────────────── + return [lib.materialize(rec) for rec in lib.scenarios[:n_take]] def run_scenario( @@ -522,7 +305,6 @@ def run_scenario( obs_mode: str = "full-voltage", ofo_variants: list[tuple[str, OFOConfig]] | None = None, include_rule_based: bool = False, - rule_based_config: RuleBasedConfig | None = None, rule_step_sizes: tuple[float, ...] = (10.0,), rule_zone_local: bool = False, no_per_scenario_plots: bool = False, @@ -598,9 +380,7 @@ def run_scenario( if include_rule_based: for step_size in rule_step_sizes: label = "rule_based" if len(rule_step_sizes) == 1 else f"rule_based_s{step_size:g}" - rb_config = rule_based_config or RuleBasedConfig(v_min=V_MIN, v_max=V_MAX, step_size=step_size) - if rule_based_config is not None: - rb_config = rule_based_config + rb_config = RuleBasedConfig(v_min=V_MIN, v_max=V_MAX, step_size=step_size) vstats, log = run_simulation( label, sys=sys_cfg, @@ -640,8 +420,6 @@ def run_scenario( perf["mean_power_kw"], perf["batch_changes"], ) - if rule_based_config is not None: - break for variant_label, variant_cfg in ofo_variants: vstats, log = run_simulation( @@ -740,212 +518,6 @@ def run_scenario( return results -def plot_aggregate( - all_results: list[dict], - scenario_params: list[dict], - save_dir: Path, - modes: list[str], - *, - system: str = "", - use_display_names: bool = False, -) -> None: - """2×3 aggregate bar chart (means) + per-scenario breakdown + normalized integral + CDF + scatter.""" - n_sc = len(all_results) - prefix = f"{system}_" if system else "" - - colors = ["#999999", "#4CAF50", "#2196F3", "#FF9800", "#E91E63", "#9C27B0", "#00BCD4", "#795548", "#607D8B"] - - display_labels = [_display_name(m) if use_display_names else m for m in modes] - - metrics = [ - ("violation_time_s", "Mean Violation Time (s)"), - ("integral", "Mean Integral Violation (pu·s)"), - ("batch_changes", "Mean Batch Size Changes"), - ("mean_throughput_toks_s", "Mean Throughput (tok/s)"), - ("mean_power_kw", "Mean Data Center Power (kW)"), - ("itl_violation_rate", "Mean ITL Violation Rate"), - ] - - fig, axes = plt.subplots(2, 3, figsize=(max(15, len(modes) * 3.0), 10)) - x = np.arange(len(modes)) - - for ax, (metric, title) in zip(axes.flat, metrics, strict=False): - means = [] - for mode in modes: - vals = [r[mode].get(metric, 0) for r in all_results if mode in r] - means.append(np.mean(vals) if vals else 0.0) - - ax.bar(x, means, color=colors[: len(modes)], alpha=0.85) - ax.set_xticks(x) - ax.set_xticklabels(display_labels, rotation=30, ha="right", fontsize=12) - ax.set_ylabel(title, fontsize=13) - ax.set_title(title, fontsize=14, fontweight="bold") - ax.tick_params(axis="y", labelsize=12) - ax.grid(axis="y", alpha=0.3) - - fig.suptitle(f"Aggregate Controller Metrics — {n_sc} Scenarios", fontsize=16, fontweight="bold") - fig.tight_layout(rect=[0, 0, 1, 0.95]) - fname = f"{prefix}controller_evaluation.png" - fig.savefig(save_dir / fname, dpi=150, bbox_inches="tight") - plt.close(fig) - logger.info("Saved %s", fname) - - # ── Per-scenario integral (absolute) ── - fig, ax = plt.subplots(figsize=(max(10, n_sc * 0.8), 6)) - x = np.arange(n_sc) - width = 0.8 / len(modes) - - for i, mode in enumerate(modes): - vals = [r[mode]["integral"] if mode in r else 0 for r in all_results] - ax.bar( - x + i * width, - vals, - width, - label=_display_name(mode) if use_display_names else mode, - color=colors[i % len(colors)], - alpha=0.85, - ) - - ax.set_xlabel("Scenario", fontsize=13) - ax.set_ylabel("Integral Violation (pu·s)", fontsize=13) - ax.set_title("Per-Scenario Integral Violation", fontsize=14, fontweight="bold") - ax.set_xticks(x + width * (len(modes) - 1) / 2) - ax.set_xticklabels([f"S{i}" for i in range(n_sc)], fontsize=10) - ax.legend(fontsize=10, loc="upper right") - ax.tick_params(axis="y", labelsize=12) - ax.grid(axis="y", alpha=0.3) - fig.tight_layout() - fname = f"{prefix}scenario_summary.png" - fig.savefig(save_dir / fname, dpi=150, bbox_inches="tight") - plt.close(fig) - logger.info("Saved %s", fname) - - # ── Per-scenario normalized integral (relative to baseline_no_tap) ── - baseline_key = "baseline_no_tap" - if baseline_key in modes: - fig, ax = plt.subplots(figsize=(max(10, n_sc * 0.8), 6)) - x = np.arange(n_sc) - non_baseline = [m for m in modes if m != baseline_key] - width = 0.8 / len(non_baseline) - - for i, mode in enumerate(non_baseline): - norm_vals = [] - for r in all_results: - base = r.get(baseline_key, {}).get("integral", 0.0) - val = r.get(mode, {}).get("integral", 0.0) - norm_vals.append(val / base if base > 0 else 0.0) - ax.bar( - x + i * width, - norm_vals, - width, - label=_display_name(mode) if use_display_names else mode, - color=colors[(modes.index(mode)) % len(colors)], - alpha=0.85, - ) - - ax.axhline(1.0, color="black", linestyle="--", linewidth=1, alpha=0.6, label="Baseline") - ax.set_xlabel("Scenario", fontsize=13) - ax.set_ylabel("Normalized Integral (relative to No Control)", fontsize=13) - ax.set_title("Per-Scenario Normalized Integral Violation", fontsize=14, fontweight="bold") - ax.set_xticks(x + width * (len(non_baseline) - 1) / 2) - ax.set_xticklabels([f"S{i}" for i in range(n_sc)], fontsize=10) - ax.legend(fontsize=10, loc="upper right") - ax.tick_params(axis="y", labelsize=12) - ax.grid(axis="y", alpha=0.3) - fig.tight_layout() - fname = f"{prefix}scenario_normalized_integral.png" - fig.savefig(save_dir / fname, dpi=150, bbox_inches="tight") - plt.close(fig) - logger.info("Saved %s", fname) - - # ── CDF of integral violation ── - fig, ax = plt.subplots(figsize=(8, 6)) - for i, mode in enumerate(modes): - vals = sorted([r[mode].get("integral", 0.0) for r in all_results if mode in r]) - if not vals: - continue - cdf = np.arange(1, len(vals) + 1) / len(vals) - ax.plot( - vals, - cdf, - color=colors[i % len(colors)], - linewidth=2, - label=_display_name(mode) if use_display_names else mode, - ) - ax.set_xlabel("Integral Violation (pu·s)", fontsize=13) - ax.set_ylabel("Cumulative Fraction", fontsize=13) - ax.set_title("CDF of Integral Violation Across Scenarios", fontsize=14, fontweight="bold") - ax.legend(fontsize=11, loc="lower right") - ax.tick_params(labelsize=12) - ax.grid(True, alpha=0.3) - fig.tight_layout() - fname = f"{prefix}cdf_integral.png" - fig.savefig(save_dir / fname, dpi=150, bbox_inches="tight") - plt.close(fig) - logger.info("Saved %s", fname) - - # ── Throughput vs. voltage violation scatter ── - scatter_data = {} - for _i, mode in enumerate(modes): - integrals = [r[mode].get("integral", 0.0) for r in all_results if mode in r] - tputs = [r[mode].get("mean_throughput_toks_s", 0.0) for r in all_results if mode in r] - scatter_data[mode] = (integrals, tputs) - - n_modes = len(modes) - ncols = 2 - nrows = math.ceil(n_modes / ncols) - fig, axes = plt.subplots(nrows, ncols, figsize=(12, 5 * nrows), sharex=True, sharey=True, squeeze=False) - - for idx, mode in enumerate(modes): - ax = axes[idx // ncols][idx % ncols] - integrals, tputs = scatter_data[mode] - label = _display_name(mode) if use_display_names else mode - - for other_mode, (oi, ot) in scatter_data.items(): - if other_mode != mode: - ax.scatter(oi, ot, color="lightgrey", s=40, alpha=0.6, edgecolors="none", zorder=1) - - ax.scatter( - integrals, - tputs, - color=colors[idx % len(colors)], - s=80, - alpha=0.9, - edgecolors="black", - linewidths=0.5, - zorder=2, - ) - ax.scatter( - np.mean(integrals), - np.mean(tputs), - color=colors[idx % len(colors)], - s=220, - marker="*", - edgecolors="black", - linewidths=0.8, - zorder=3, - ) - - ax.set_title(label, fontsize=14, fontweight="bold") - ax.set_xlabel("Integral Violation (pu·s)", fontsize=12) - ax.set_ylabel("Mean Throughput (tok/s)", fontsize=12) - ax.tick_params(labelsize=11) - ax.yaxis.set_major_formatter(plt.FuncFormatter(lambda v, _: f"{v:.2e}")) - ax.grid(True, alpha=0.3) - - for idx in range(n_modes, nrows * ncols): - axes[idx // ncols][idx % ncols].set_visible(False) - - fig.suptitle("Throughput vs. Voltage Violation by Controller", fontsize=16, fontweight="bold") - fig.tight_layout(rect=[0, 0, 1, 0.95]) - fname = f"{prefix}throughput_vs_violation.png" - fig.savefig(save_dir / fname, dpi=150, bbox_inches="tight") - plt.close(fig) - logger.info("Saved %s", fname) - - logger.info("Saved aggregate figures to %s", save_dir) - - def main( *, ppo_models: tuple[str, ...] = (), @@ -986,7 +558,7 @@ def main( ppo_models_resolved = [str(Path(p).resolve()) for p in ppo_models] if not ppo_labels: - ppo_labels = tuple(Path(p).parent.name.replace("ppo_ablation_", "") for p in ppo_models) + ppo_labels = tuple(str(i) for i in range(len(ppo_models))) if system not in EXPERIMENTS: raise ValueError(f"Unknown system {system!r}. Valid: {sorted(EXPERIMENTS)}") @@ -1085,8 +657,8 @@ def _fmt(v: float) -> str: if scenario_library: test_scenarios = load_scenarios_from_library( scenario_library, - exp=exp, n_scenarios=n_scenarios, + training_trace=training_trace, ) else: test_scenarios = generate_test_scenarios( @@ -1291,7 +863,7 @@ class Args: randomize_ramps: bool = True """Synthesize per-episode inference ramps. Set --no-randomize-ramps for ieee34.""" scenario_library: str = "" - """Path to a pre-screened scenario library .pkl (from build_scenario_library.py).""" + """Path to a pre-screened scenario library directory (from build_library.py).""" use_display_names: bool = False """Use human-readable display names in all plots.""" log_level: str = "INFO" diff --git a/examples/rl_controller/plotting.py b/examples/rl_controller/plotting.py new file mode 100644 index 0000000..56d7171 --- /dev/null +++ b/examples/rl_controller/plotting.py @@ -0,0 +1,723 @@ +"""Plotting helpers for the RL pipeline. + +Used by `build_library.py` (per-scenario voltage envelopes, batch traces, +acceptance summary across the library) and `evaluate.py` (per-scenario +controller comparison plots and aggregate roll-up across the test set). +""" + +from __future__ import annotations + +import logging +import math +from pathlib import Path + +import matplotlib + +matplotlib.use("Agg") +import matplotlib.pyplot as plt +import numpy as np +from scenarios import ScenarioRecord + +from systems import V_MAX, V_MIN + +logger = logging.getLogger(__name__) + + +DISPLAY_NAMES: dict[str, str] = { + "baseline_no_tap": "No Control", + "rule_based": "Droop Control", + "ofo": "OFO Control", +} + + +def _display_order_key(mode: str) -> int: + if mode == "baseline_no_tap": + return 0 + if mode == "rule_based" or mode.startswith("rule_based_s"): + return 1 + if mode.startswith("ppo_"): + return 2 + if mode == "ofo" or mode.startswith("ofo_"): + return 3 + return 99 + + +def _sort_modes(modes: list[str]) -> list[str]: + return sorted(modes, key=_display_order_key) + + +def _display_name(mode: str) -> str: + if mode in DISPLAY_NAMES: + return DISPLAY_NAMES[mode] + if mode.startswith("rule_based_s"): + return "Droop Control" + if mode.startswith("ppo_"): + return "PPO Control" + return mode.replace("_", " ").title() + + +def _voltage_envelope(grid_states, *, exclude_buses: tuple[str, ...]) -> tuple[np.ndarray, np.ndarray]: + """Return (vmin_t, vmax_t) per step for plotting.""" + drop = {b.lower() for b in exclude_buses} + vmin = np.full(len(grid_states), np.inf) + vmax = np.full(len(grid_states), -np.inf) + for i, gs in enumerate(grid_states): + for bus in gs.voltages.buses(): + if bus.lower() in drop: + continue + pv = gs.voltages[bus] + for v in (pv.a, pv.b, pv.c): + if math.isnan(v): + continue + if v < vmin[i]: + vmin[i] = v + if v > vmax[i]: + vmax[i] = v + return vmin, vmax + + +def _voltage_envelope_by_zone( + grid_states, + *, + zones: dict[str, list[str]], + exclude_buses: tuple[str, ...], +) -> dict[str, tuple[np.ndarray, np.ndarray]]: + """Return {zone_name: (vmin_t, vmax_t)} per step, one array pair per zone.""" + drop = {b.lower() for b in exclude_buses} + zone_sets = {z: {b.lower() for b in buses} for z, buses in zones.items()} + n = len(grid_states) + vmin = {z: np.full(n, np.inf) for z in zones} + vmax = {z: np.full(n, -np.inf) for z in zones} + for i, gs in enumerate(grid_states): + for bus in gs.voltages.buses(): + bl = bus.lower() + if bl in drop: + continue + pv = gs.voltages[bus] + for z, bset in zone_sets.items(): + if bl not in bset: + continue + for v in (pv.a, pv.b, pv.c): + if math.isnan(v): + continue + if v < vmin[z][i]: + vmin[z][i] = v + if v > vmax[z][i]: + vmax[z][i] = v + return {z: (vmin[z], vmax[z]) for z in zones} + + +def _plot_batch_sizes( + records: list[ScenarioRecord], + batch_data: dict[int, dict], + save_path: Path, + *, + max_rows: int = 40, +) -> None: + """Plot batch size over time per accepted scenario, baseline vs OFO, one row per scenario. + + When the library has more than `max_rows` scenarios, only the first + `max_rows` are shown. A single tall figure of hundreds of rows quickly + exceeds matplotlib's 65535-pixel dimension limit, so we cap here. + """ + n = len(records) + if n == 0: + return + if n > max_rows: + logger.info("_plot_batch_sizes: capping at first %d of %d records", max_rows, n) + records = records[:max_rows] + n = max_rows + + # Collect all (site_id, label) columns from the first scenario. For + # single-DC feeders (ieee13) there's one site; multi-DC feeders + # (ieee34) get one column per (site, model) pair. + first_seed = records[0].seed + ofo_by_site = batch_data[first_seed]["ofo"] + cols_meta: list[tuple[str, str]] = [] + for site_id, sdata in ofo_by_site.items(): + for label in sdata["batch_by_model"]: + cols_meta.append((site_id, label)) + n_cols = len(cols_meta) + + fig, axes = plt.subplots(n, n_cols, figsize=(4 * n_cols, 2.5 * n), sharex=True, squeeze=False) + + for row, rec in enumerate(records): + bd = batch_data[rec.seed] + for col, (site_id, label) in enumerate(cols_meta): + ax = axes[row][col] + bl_site = bd["baseline"][site_id] + ofo_site = bd["ofo"][site_id] + ax.plot( + bl_site["time_s"], + bl_site["batch_by_model"][label], + color="#888", + linewidth=0.7, + alpha=0.7, + label="baseline", + ) + ax.plot( + ofo_site["time_s"], + ofo_site["batch_by_model"][label], + color="#2196F3", + linewidth=0.7, + alpha=0.9, + label="OFO", + ) + if row == 0: + short = label.split("/")[-1] if "/" in label else label + title = f"{site_id}:{short}" if len(ofo_by_site) > 1 else short + ax.set_title(title, fontsize=9) + if col == 0: + ax.set_ylabel(f"seed={rec.seed}\nBatch", fontsize=8) + ax.grid(True, alpha=0.2) + if row == 0 and col == 0: + ax.legend(fontsize=7, loc="upper right") + + for col in range(n_cols): + axes[-1][col].set_xlabel("Time (s)") + fig.suptitle("Accepted scenarios: batch size (baseline vs OFO)", fontsize=13, fontweight="bold") + fig.tight_layout() + fig.savefig(save_path, dpi=120, bbox_inches="tight") + plt.close(fig) + + +def _extract_batch_data(log) -> dict: + """Extract time_s and per-model batch sizes from a simulation log. + + Returns {site_id: {"time_s": [...], "batch_by_model": {label: [bs]}}}. + Uses `log.dc_states_by_site` (per-site lists) so multi-DC feeders + like ieee34 don't get interleaved timestamps or alternating zeros. + """ + per_site: dict[str, dict] = {} + for site_id, states in log.dc_states_by_site.items(): + time_s = [s.time_s for s in states] + labels: list[str] = [] + if states: + for m in states[0].batch_size_by_model: + if m not in labels: + labels.append(m) + batch_by_model = {m: [s.batch_size_by_model.get(m, 0) for s in states] for m in labels} + per_site[site_id] = {"time_s": time_s, "batch_by_model": batch_by_model} + return per_site + + +def _plot_envelopes( + records: list[ScenarioRecord], + envelopes: dict, + save_path: Path, + *, + total_duration_s: int, + zones: dict[str, list[str]] | None = None, + max_rows: int = 40, +) -> None: + """Plot voltage envelope per accepted scenario, baseline vs OFO. + + When `zones` is provided (multi-zone feeders like ieee123), each scenario + gets one subplot per zone showing the per-zone vmin/vmax band. Otherwise a + single subplot with the global envelope is used. + + Caps at `max_rows * 2` records (global mode) or `max_rows` records + (per-zone mode) to stay under matplotlib's 65535-pixel dimension limit. + """ + n = len(records) + if n == 0: + return + + t = np.arange(total_duration_s) + + if zones: + zone_names = list(zones.keys()) + n_zones = len(zone_names) + cap = max_rows + if n > cap: + logger.info("_plot_envelopes: capping at first %d of %d records", cap, n) + records = records[:cap] + n = cap + zone_colors = ["#2196F3", "#4CAF50", "#FF9800", "#9C27B0"] + fig, axes = plt.subplots(n, n_zones, figsize=(5 * n_zones, 3 * n), sharex=True, squeeze=False) + for row, rec in enumerate(records): + env = envelopes[rec.seed] + for col, z in enumerate(zone_names): + ax = axes[row][col] + bl_z = env["baseline_zones"].get(z) + of_z = env["ofo_zones"].get(z) + color = zone_colors[col % len(zone_colors)] + if bl_z is not None: + ax.fill_between(t, bl_z[0], bl_z[1], alpha=0.25, color="#888", label="baseline") + if of_z is not None: + ax.fill_between(t, of_z[0], of_z[1], alpha=0.4, color=color, label="OFO") + ax.axhline(V_MIN, color="red", linestyle="--", linewidth=0.8, alpha=0.6) + ax.axhline(V_MAX, color="red", linestyle="--", linewidth=0.8, alpha=0.6) + ax.grid(True, alpha=0.2) + if row == 0: + ax.set_title(z, fontsize=10, fontweight="bold") + if col == 0: + ax.set_ylabel( + f"seed={rec.seed}\npv×{rec.pv_scale:.2f} ld×{rec.load_scale:.2f}\n" + f"bl={rec.baseline_integral:.1f} ofo={rec.ofo_integral:.1f} " + f"rec={rec.recovery_frac:.0%}", + fontsize=7, + ) + else: + ax.set_ylabel("V (pu)", fontsize=8) + if row == 0 and col == 0: + ax.legend(loc="lower right", fontsize=7) + for col in range(n_zones): + axes[-1][col].set_xlabel("Time (s)", fontsize=8) + fig.suptitle( + "Accepted scenarios: per-zone voltage envelope (baseline vs OFO)", + fontsize=13, + fontweight="bold", + ) + else: + cap = max_rows * 2 + if n > cap: + logger.info("_plot_envelopes: capping at first %d of %d records", cap, n) + records = records[:cap] + n = cap + cols = 2 + rows = (n + cols - 1) // cols + fig, axes = plt.subplots(rows, cols, figsize=(7 * cols, 3 * rows), sharex=True) + axes = np.atleast_2d(axes) + + for idx, rec in enumerate(records): + r, c = divmod(idx, cols) + ax = axes[r][c] + bmin, bmax = envelopes[rec.seed]["baseline"] + omin, omax = envelopes[rec.seed]["ofo"] + ax.fill_between(t, bmin, bmax, alpha=0.25, color="#888", label="baseline") + ax.fill_between(t, omin, omax, alpha=0.4, color="#2196F3", label="OFO") + ax.axhline(V_MIN, color="red", linestyle="--", linewidth=1, alpha=0.6) + ax.axhline(V_MAX, color="red", linestyle="--", linewidth=1, alpha=0.6) + ax.set_title( + f"seed={rec.seed} pv×{rec.pv_scale:.2f} load×{rec.load_scale:.2f}\n" + f"int: bl={rec.baseline_integral:.2f} ofo={rec.ofo_integral:.2f} " + f"recov={rec.recovery_frac:.0%}", + fontsize=9, + ) + ax.set_ylabel("V (pu)", fontsize=9) + ax.grid(True, alpha=0.2) + if idx == 0: + ax.legend(loc="lower right", fontsize=8) + + for k in range(n, rows * cols): + r, c = divmod(k, cols) + axes[r][c].axis("off") + + for c in range(cols): + axes[-1][c].set_xlabel("Time (s)") + fig.suptitle("Accepted scenarios: voltage envelope (baseline vs OFO)", fontsize=13, fontweight="bold") + + fig.tight_layout() + fig.savefig(save_path, dpi=120, bbox_inches="tight") + plt.close(fig) + + +def _plot_summary(all_stats: list[dict], save_path: Path) -> None: + """Bar chart of baseline vs OFO integral for every candidate (accepted + rejected).""" + n = len(all_stats) + if n == 0: + return + seeds = [s["seed"] for s in all_stats] + bl = [s["baseline_integral"] for s in all_stats] + of = [s["ofo_integral"] for s in all_stats] + accepted = [s["accepted"] for s in all_stats] + + x = np.arange(n) + w = 0.4 + fig, ax = plt.subplots(figsize=(max(8, 0.7 * n), 5)) + ax.bar(x - w / 2, bl, w, color="#888", label="baseline integral") + ax.bar(x + w / 2, of, w, color="#2196F3", label="OFO integral") + for i, ok in enumerate(accepted): + marker = "✓" if ok else "✗" + color = "green" if ok else "red" + ax.annotate(marker, xy=(i, max(bl[i], of[i])), ha="center", va="bottom", color=color, fontsize=12) + ax.set_xticks(x) + ax.set_xticklabels([str(s) for s in seeds], rotation=45) + ax.set_xlabel("Seed") + ax.set_ylabel("Integral voltage violation (pu·s)") + ax.set_yscale("symlog", linthresh=0.1) + ax.set_title("Candidate scenarios: baseline vs OFO integral violation") + ax.legend() + ax.grid(True, axis="y", alpha=0.2) + fig.tight_layout() + fig.savefig(save_path, dpi=120, bbox_inches="tight") + plt.close(fig) + + +def plot_voltage_comparison( + logs: dict[str, object], + save_dir: Path, + *, + v_min: float = 0.95, + v_max: float = 1.05, + exclude_buses: tuple[str, ...] = (), + scenario_idx: int | None = None, + use_display_names: bool = False, +) -> None: + """Side-by-side voltage envelopes for each controller mode.""" + modes = _sort_modes(list(logs.keys())) + n = len(modes) + fig, axes = plt.subplots(1, n, figsize=(6 * n, 5), sharey=True) + if n == 1: + axes = [axes] + + drop = {b.lower() for b in exclude_buses} + + for ax, mode in zip(axes, modes, strict=False): + log = logs[mode] + time_s = np.array(log.time_s) + + v_min_arr = np.full(len(log.grid_states), np.inf) + v_max_arr = np.full(len(log.grid_states), -np.inf) + + for t_idx, gs in enumerate(log.grid_states): + for bus in gs.voltages.buses(): + if bus.lower() in drop: + continue + pv = gs.voltages[bus] + for v in (pv.a, pv.b, pv.c): + if not math.isnan(v): + v_min_arr[t_idx] = min(v_min_arr[t_idx], v) + v_max_arr[t_idx] = max(v_max_arr[t_idx], v) + + ax.fill_between(time_s, v_min_arr, v_max_arr, alpha=0.3, color="steelblue") + ax.plot(time_s, v_min_arr, color="steelblue", linewidth=0.5, label="Vmin") + ax.plot(time_s, v_max_arr, color="coral", linewidth=0.5, label="Vmax") + ax.axhline(v_min, color="red", linestyle="--", linewidth=1, alpha=0.7) + ax.axhline(v_max, color="red", linestyle="--", linewidth=1, alpha=0.7) + ax.set_xlabel("Time (s)", fontsize=13) + ax.set_title(_display_name(mode) if use_display_names else mode, fontsize=14) + ax.legend(fontsize=12) + ax.tick_params(labelsize=12) + ax.grid(True, alpha=0.2) + + axes[0].set_ylabel("Voltage (pu)", fontsize=13) + fig.suptitle("Voltage Envelope Comparison", fontsize=16, fontweight="bold") + fig.tight_layout() + stem = f"scenario_{scenario_idx:03d}_voltage_comparison" if scenario_idx is not None else "voltage_comparison" + fig.savefig(save_dir / f"{stem}.png", dpi=150, bbox_inches="tight") + plt.close(fig) + logger.info("Saved %s.png", stem) + + +def plot_violation_bars( + results: dict[str, dict], + save_dir: Path, + *, + scenario_idx: int | None = None, + use_display_names: bool = False, +) -> None: + """Four-panel bar chart for a single scenario: + violation time, integral violation, mean throughput, batch size changes. + """ + modes = _sort_modes(list(results.keys())) + if not modes: + return + + cmap = plt.get_cmap("tab10") + colors = [cmap(i % 10) for i in range(len(modes))] + + viol_s = [float(results[m].get("violation_time_s", 0.0)) for m in modes] + integ = [float(results[m].get("integral", 0.0)) for m in modes] + tput = [float(results[m].get("mean_throughput_toks_s", 0.0)) for m in modes] + batch_chg = [float(results[m].get("batch_changes", 0.0)) for m in modes] + labels = [_display_name(m) if use_display_names else m for m in modes] + + fig, axes = plt.subplots(1, 4, figsize=(max(18, 2.0 * len(modes) + 12), 6)) + ax_v, ax_i, ax_t, ax_b = axes + + x = np.arange(len(modes)) + for ax, vals, ylabel, title, fmt in [ + (ax_v, viol_s, "Violation time (s)", "Violation time", "{:.0f}"), + (ax_i, integ, "Integral violation (pu·s)", "Integral violation", "{:.2f}"), + (ax_t, tput, "Throughput (tok/s)", "Mean throughput", "{:.2e}"), + (ax_b, batch_chg, "Batch size changes", "Batch size changes", "{:.0f}"), + ]: + ax.bar(x, vals, color=colors, alpha=0.88, edgecolor="black", linewidth=0.5) + ax.set_xticks(x) + ax.set_xticklabels(labels, rotation=35, ha="right", fontsize=12) + ax.set_ylabel(ylabel, fontsize=13) + ax.set_title(title, fontsize=14) + ax.tick_params(axis="y", labelsize=12) + ax.grid(axis="y", alpha=0.3) + for xi, val in zip(x, vals, strict=False): + ax.text(xi, val, fmt.format(val), ha="center", va="bottom", fontsize=10) + + fig.suptitle("Per-scenario controller metrics", fontsize=16, fontweight="bold") + fig.tight_layout() + stem = f"scenario_{scenario_idx:03d}_performance_summary" if scenario_idx is not None else "violation_bars" + fig.savefig(save_dir / f"{stem}.png", dpi=150, bbox_inches="tight") + plt.close(fig) + logger.info("Saved %s.png", stem) + + +def plot_batch_comparison( + logs: dict[str, object], + save_dir: Path, + *, + scenario_idx: int | None = None, + use_display_names: bool = False, +) -> None: + """Batch size over time for each controller mode, one subplot per (site, model) pair.""" + modes = _sort_modes(list(logs.keys())) + if not modes: + logger.info("plot_batch_comparison: no controllers to plot") + return + + site_models: list[tuple[str, str]] = [] + for log in logs.values(): + for site_id, states in log.dc_states_by_site.items(): + if not states: + continue + for m in states[0].batch_size_by_model: + pair = (site_id, m) + if pair not in site_models: + site_models.append(pair) + break + + n_rows = len(site_models) + cmap = plt.get_cmap("tab10") + fig, axes = plt.subplots( + n_rows, + 1, + figsize=(13, 4 * n_rows), + sharex=True, + squeeze=False, + ) + + for row, (site_id, model_label) in enumerate(site_models): + ax = axes[row][0] + for i, mode in enumerate(modes): + log = logs[mode] + site_states = log.dc_states_by_site.get(site_id, []) + times = [s.time_s for s in site_states] + batches = [s.batch_size_by_model.get(model_label, 0) for s in site_states] + ax.plot( + times, + batches, + color=cmap(i % 10), + linewidth=1.5, + alpha=0.85, + label=_display_name(mode) if use_display_names else mode, + ) + ax.set_ylabel("Batch Size", fontsize=13) + title = f"{model_label} @ {site_id}" if len(log.dc_states_by_site) > 1 else model_label + ax.set_title(title, fontsize=14) + ax.legend(fontsize=12, loc="upper right") + ax.tick_params(labelsize=12) + ax.grid(True, alpha=0.3) + + axes[-1][0].set_xlabel("Time (s)", fontsize=13) + fig.suptitle("Batch Size Comparison by Model", fontsize=16, fontweight="bold") + fig.tight_layout(rect=[0, 0, 1, 0.95]) + stem = f"scenario_{scenario_idx:03d}_batch_size_comparison" if scenario_idx is not None else "batch_size_comparison" + fig.savefig(save_dir / f"{stem}.png", dpi=150, bbox_inches="tight") + plt.close(fig) + logger.info("Saved %s.png", stem) + + +def plot_aggregate( + all_results: list[dict], + scenario_params: list[dict], + save_dir: Path, + modes: list[str], + *, + system: str = "", + use_display_names: bool = False, +) -> None: + """2×3 aggregate bar chart (means) + per-scenario breakdown + normalized integral + CDF + scatter.""" + n_sc = len(all_results) + prefix = f"{system}_" if system else "" + + colors = ["#999999", "#4CAF50", "#2196F3", "#FF9800", "#E91E63", "#9C27B0", "#00BCD4", "#795548", "#607D8B"] + + display_labels = [_display_name(m) if use_display_names else m for m in modes] + + metrics = [ + ("violation_time_s", "Mean Violation Time (s)"), + ("integral", "Mean Integral Violation (pu·s)"), + ("batch_changes", "Mean Batch Size Changes"), + ("mean_throughput_toks_s", "Mean Throughput (tok/s)"), + ("mean_power_kw", "Mean Data Center Power (kW)"), + ("itl_violation_rate", "Mean ITL Violation Rate"), + ] + + fig, axes = plt.subplots(2, 3, figsize=(max(15, len(modes) * 3.0), 10)) + x = np.arange(len(modes)) + + for ax, (metric, title) in zip(axes.flat, metrics, strict=False): + means = [] + for mode in modes: + vals = [r[mode].get(metric, 0) for r in all_results if mode in r] + means.append(np.mean(vals) if vals else 0.0) + + ax.bar(x, means, color=colors[: len(modes)], alpha=0.85) + ax.set_xticks(x) + ax.set_xticklabels(display_labels, rotation=30, ha="right", fontsize=12) + ax.set_ylabel(title, fontsize=13) + ax.set_title(title, fontsize=14, fontweight="bold") + ax.tick_params(axis="y", labelsize=12) + ax.grid(axis="y", alpha=0.3) + + fig.suptitle(f"Aggregate Controller Metrics: {n_sc} Scenarios", fontsize=16, fontweight="bold") + fig.tight_layout(rect=[0, 0, 1, 0.95]) + fname = f"{prefix}controller_evaluation.png" + fig.savefig(save_dir / fname, dpi=150, bbox_inches="tight") + plt.close(fig) + logger.info("Saved %s", fname) + + # ── Per-scenario integral (absolute) ── + fig, ax = plt.subplots(figsize=(max(10, n_sc * 0.8), 6)) + x = np.arange(n_sc) + width = 0.8 / len(modes) + + for i, mode in enumerate(modes): + vals = [r[mode]["integral"] if mode in r else 0 for r in all_results] + ax.bar( + x + i * width, + vals, + width, + label=_display_name(mode) if use_display_names else mode, + color=colors[i % len(colors)], + alpha=0.85, + ) + + ax.set_xlabel("Scenario", fontsize=13) + ax.set_ylabel("Integral Violation (pu·s)", fontsize=13) + ax.set_title("Per-Scenario Integral Violation", fontsize=14, fontweight="bold") + ax.set_xticks(x + width * (len(modes) - 1) / 2) + ax.set_xticklabels([f"S{i}" for i in range(n_sc)], fontsize=10) + ax.legend(fontsize=10, loc="upper right") + ax.tick_params(axis="y", labelsize=12) + ax.grid(axis="y", alpha=0.3) + fig.tight_layout() + fname = f"{prefix}scenario_summary.png" + fig.savefig(save_dir / fname, dpi=150, bbox_inches="tight") + plt.close(fig) + logger.info("Saved %s", fname) + + # ── Per-scenario normalized integral (relative to baseline_no_tap) ── + baseline_key = "baseline_no_tap" + if baseline_key in modes: + fig, ax = plt.subplots(figsize=(max(10, n_sc * 0.8), 6)) + x = np.arange(n_sc) + non_baseline = [m for m in modes if m != baseline_key] + width = 0.8 / len(non_baseline) + + for i, mode in enumerate(non_baseline): + norm_vals = [] + for r in all_results: + base = r.get(baseline_key, {}).get("integral", 0.0) + val = r.get(mode, {}).get("integral", 0.0) + norm_vals.append(val / base if base > 0 else 0.0) + ax.bar( + x + i * width, + norm_vals, + width, + label=_display_name(mode) if use_display_names else mode, + color=colors[(modes.index(mode)) % len(colors)], + alpha=0.85, + ) + + ax.axhline(1.0, color="black", linestyle="--", linewidth=1, alpha=0.6, label="Baseline") + ax.set_xlabel("Scenario", fontsize=13) + ax.set_ylabel("Normalized Integral (relative to No Control)", fontsize=13) + ax.set_title("Per-Scenario Normalized Integral Violation", fontsize=14, fontweight="bold") + ax.set_xticks(x + width * (len(non_baseline) - 1) / 2) + ax.set_xticklabels([f"S{i}" for i in range(n_sc)], fontsize=10) + ax.legend(fontsize=10, loc="upper right") + ax.tick_params(axis="y", labelsize=12) + ax.grid(axis="y", alpha=0.3) + fig.tight_layout() + fname = f"{prefix}scenario_normalized_integral.png" + fig.savefig(save_dir / fname, dpi=150, bbox_inches="tight") + plt.close(fig) + logger.info("Saved %s", fname) + + # ── CDF of integral violation ── + fig, ax = plt.subplots(figsize=(8, 6)) + for i, mode in enumerate(modes): + vals = sorted([r[mode].get("integral", 0.0) for r in all_results if mode in r]) + if not vals: + continue + cdf = np.arange(1, len(vals) + 1) / len(vals) + ax.plot( + vals, + cdf, + color=colors[i % len(colors)], + linewidth=2, + label=_display_name(mode) if use_display_names else mode, + ) + ax.set_xlabel("Integral Violation (pu·s)", fontsize=13) + ax.set_ylabel("Cumulative Fraction", fontsize=13) + ax.set_title("CDF of Integral Violation Across Scenarios", fontsize=14, fontweight="bold") + ax.legend(fontsize=11, loc="lower right") + ax.tick_params(labelsize=12) + ax.grid(True, alpha=0.3) + fig.tight_layout() + fname = f"{prefix}cdf_integral.png" + fig.savefig(save_dir / fname, dpi=150, bbox_inches="tight") + plt.close(fig) + logger.info("Saved %s", fname) + + # ── Throughput vs. voltage violation scatter ── + scatter_data = {} + for _i, mode in enumerate(modes): + integrals = [r[mode].get("integral", 0.0) for r in all_results if mode in r] + tputs = [r[mode].get("mean_throughput_toks_s", 0.0) for r in all_results if mode in r] + scatter_data[mode] = (integrals, tputs) + + n_modes = len(modes) + ncols = 2 + nrows = math.ceil(n_modes / ncols) + fig, axes = plt.subplots(nrows, ncols, figsize=(12, 5 * nrows), sharex=True, sharey=True, squeeze=False) + + for idx, mode in enumerate(modes): + ax = axes[idx // ncols][idx % ncols] + integrals, tputs = scatter_data[mode] + label = _display_name(mode) if use_display_names else mode + + for other_mode, (oi, ot) in scatter_data.items(): + if other_mode != mode: + ax.scatter(oi, ot, color="lightgrey", s=40, alpha=0.6, edgecolors="none", zorder=1) + + ax.scatter( + integrals, + tputs, + color=colors[idx % len(colors)], + s=80, + alpha=0.9, + edgecolors="black", + linewidths=0.5, + zorder=2, + ) + ax.scatter( + np.mean(integrals), + np.mean(tputs), + color=colors[idx % len(colors)], + s=220, + marker="*", + edgecolors="black", + linewidths=0.8, + zorder=3, + ) + + ax.set_title(label, fontsize=14, fontweight="bold") + ax.set_xlabel("Integral Violation (pu·s)", fontsize=12) + ax.set_ylabel("Mean Throughput (tok/s)", fontsize=12) + ax.tick_params(labelsize=11) + ax.yaxis.set_major_formatter(plt.FuncFormatter(lambda v, _: f"{v:.2e}")) + ax.grid(True, alpha=0.3) + + for idx in range(n_modes, nrows * ncols): + axes[idx // ncols][idx % ncols].set_visible(False) + + fig.suptitle("Throughput vs. Voltage Violation by Controller", fontsize=16, fontweight="bold") + fig.tight_layout(rect=[0, 0, 1, 0.95]) + fname = f"{prefix}throughput_vs_violation.png" + fig.savefig(save_dir / fname, dpi=150, bbox_inches="tight") + plt.close(fig) + logger.info("Saved %s", fname) + + logger.info("Saved aggregate figures to %s", save_dir) diff --git a/examples/rl_controller/scenarios.py b/examples/rl_controller/scenarios.py new file mode 100644 index 0000000..16e945c --- /dev/null +++ b/examples/rl_controller/scenarios.py @@ -0,0 +1,1080 @@ +"""RL scenario generation, randomization, and experiment factories. + +What lives here: + +- `DCSite`, `PVSystemSpec`, `TimeVaryingLoadSpec`: dataclasses describing the + per-site simulation components that are randomized across scenarios. +- `ScenarioRecord`: scalar snapshot of one randomized scenario, produced by + `build_library.py` and replayed by `train_ppo.py` and `evaluate.py`. +- `save_library` / `load_library_data`: serialize a list of `ScenarioRecord` + to a `metadata.json` + `traces.npz` pair (no pickle). +- `ScenarioOpenDSSGrid`: `OpenDSSGrid` subclass that injects PV systems and + time-varying loads at arbitrary buses. +- `pv_profile_random` / `tvl_profile_random` / `eval_profile`: parameterized + PV/TVL profile generators used at runtime to materialize ScenarioRecord + parameters into instantaneous power values. +- `randomize_scenario` / `materialize_scenario`: build a ScenarioRecord from + a seed and turn it back into the concrete simulation configs each episode + needs. +- `EXPERIMENTS` / `ieee*_experiment`: per-feeder factories that combine the + shared feeder definitions from `examples/offline/systems.py` with the + model deployments and DC layouts this study uses. + +Shared simulation constants and feeder factories (ieee13/ieee34/ieee123, +DT_*, V_MIN/V_MAX, model specs, ...) come from the local `systems.py`, +which is a vendored copy of `examples/offline/systems.py` so the example +is self-contained. +""" + +from __future__ import annotations + +import math +from collections.abc import Callable +from dataclasses import dataclass, field +from pathlib import Path + +import numpy as np + +from openg2g.controller.ofo import OFOConfig +from openg2g.datacenter.config import ( + ModelDeployment, + ReplicaSchedule, + TrainingRun, +) +from openg2g.datacenter.workloads.training import TrainingTrace +from openg2g.grid.config import TapPosition, TapSchedule +from openg2g.grid.opendss import OpenDSSGrid + +from systems import ( + SYSTEMS, + TOTAL_DURATION_S, + V_MAX, + V_MIN, + _irregular_fluct, + _smooth_bump, + _smoothstep, + deploy, + load_profile_kw, + pv_profile_kw, + tap, + with_ramp, +) + + +@dataclass +class DCSite: + """One datacenter site for simulation setup. + + Attributes: + bus: Distribution bus where the datacenter is connected. + bus_kv: Bus voltage level (kV). + base_kw_per_phase: Constant base load per phase (kW). + total_gpu_capacity: Total physical GPUs installed at this site. + models: (deployment, replica_schedule) pairs at this site. Replica + counts and runtime ramps both live on the ReplicaSchedule + (matching master's unpack_deployments() pattern). + seed: Random seed for layout generation. + connection_type: Grid connection type (`"wye"` or `"delta"`). + load_shift_headroom: Fraction of extra server capacity for load shifting. + """ + + bus: str + bus_kv: float + base_kw_per_phase: float + total_gpu_capacity: int + models: tuple[tuple[ModelDeployment, ReplicaSchedule], ...] = () + seed: int = 0 + connection_type: str = "wye" + load_shift_headroom: float = 0.0 + + +@dataclass +class PVSystemSpec: + """PV system at a distribution bus (used by ScenarioOpenDSSGrid).""" + + bus: str + bus_kv: float = 4.16 + peak_kw: float = 1000.0 + csv_path: Path | None = None + power_factor: float = 1.0 + peak_t_shift_s: float = 0.0 + time_warp: float = 1.0 + profile_kind: str = "default" + profile_params: dict | None = None + + +@dataclass +class TimeVaryingLoadSpec: + """Time-varying load at a distribution bus (used by ScenarioOpenDSSGrid).""" + + bus: str + bus_kv: float = 4.16 + peak_kw: float = 500.0 + csv_path: Path | None = None + power_factor: float = 0.96 + peak_t_shift_s: float = 0.0 + time_warp: float = 1.0 + profile_kind: str = "default" + profile_params: dict | None = None + + +@dataclass +class ScenarioRecord: + """One randomized scenario, ready to be replayed at training time. + + Built by `build_library.py` after baseline + OFO screening, and consumed + by `train_ppo.py` (sampled per episode) and `evaluate.py` (replayed + deterministically). `ofo_voltage_pen_per_step` is the per-second OFO + voltage penalty trace and is what gets subtracted from PPO's per-step + voltage_pen during training. + """ + + seed: int + pv_scale: float + load_scale: float + training_overlay: dict | None + baseline_integral: float + ofo_integral: float + baseline_violation_time_s: float + ofo_violation_time_s: float + recovery_frac: float + ofo_voltage_pen_per_step: np.ndarray = field(repr=False) + baseline_voltage_pen_per_step: np.ndarray = field(repr=False) + t_control_start: int = 0 + t_control_end: int = 3600 + bl_undervoltage_time_s: float = 0.0 + bl_overvoltage_time_s: float = 0.0 + randomize_ramps: bool | None = None + ramp_frac_min: float | None = None + ramp_frac_max: float | None = None + ramp_start_min: float | None = None + ramp_start_max: float | None = None + ramp_dur_min: float | None = None + ramp_dur_max: float | None = None + + +def pv_profile_random(t: float, peak_kw: float, params: dict) -> float: + """Multi-shape PV profile with random cloud events (1-hour episode). + + params["shape"] picks the envelope: + "flat" constant baseline 0.75-0.95 + "rising_falling" smooth bump from low_baseline up to high_baseline and back + "morning_ramp" low → high over a short ramp window, sustained high after + "afternoon_decline" sustained high then ramp down at the end + "midday_dip" high baseline with a substantial mid-episode dip + """ + shape = params.get("shape", "flat") + T = float(TOTAL_DURATION_S) + + if shape == "flat": + env = float(params.get("baseline", 0.85)) + elif shape == "rising_falling": + lo = float(params.get("low_baseline", 0.50)) + hi = float(params.get("high_baseline", 0.95)) + peak_t = float(params.get("peak_t", T / 2)) + half_width = float(params.get("half_width", 1200.0)) + env = lo + (hi - lo) * _smooth_bump(t, peak_t, half_width) + elif shape == "morning_ramp": + lo = float(params.get("low_baseline", 0.25)) + hi = float(params.get("high_baseline", 0.75)) + ramp_start = float(params.get("ramp_start", 100.0)) + ramp_end = float(params.get("ramp_end", 1200.0)) + env = lo + (hi - lo) * _smoothstep(t, ramp_start, ramp_end) + elif shape == "afternoon_decline": + lo = float(params.get("low_baseline", 0.25)) + hi = float(params.get("high_baseline", 0.75)) + ramp_start = float(params.get("ramp_start", 2400.0)) + ramp_end = float(params.get("ramp_end", T - 100.0)) + env = hi - (hi - lo) * _smoothstep(t, ramp_start, ramp_end) + elif shape == "midday_dip": + hi = float(params.get("high_baseline", 0.90)) + dip_t = float(params.get("dip_t", T / 2)) + dip_half_width = float(params.get("dip_half_width", 700.0)) + dip_depth = float(params.get("dip_depth", 0.55)) + env = hi - dip_depth * _smooth_bump(t, dip_t, dip_half_width) + else: + env = 0.85 + + for tc, hw, depth in params.get("clouds", ()): + env -= float(depth) * _smooth_bump(t, float(tc), float(hw)) + + env = max(0.15, env) + noise_amp = float(params.get("noise_amp", 0.0)) + if noise_amp > 0: + f = _irregular_fluct(t, seed=float(params.get("noise_seed", 0.0))) + env *= 1.0 + (noise_amp / 0.20) * (f - 1.0) + return max(0.0, peak_kw * env) + + +def tvl_profile_random(t: float, peak_kw: float, params: dict) -> float: + """Multi-shape TVL profile (1-hour episode).""" + shape = params.get("shape", "peaked") + T = float(TOTAL_DURATION_S) + if shape == "flat": + base = float(params.get("level", 0.7)) + elif shape == "increasing": + lo = float(params.get("lo", 0.2)) + hi = float(params.get("hi", 0.9)) + base = lo + (hi - lo) * min(1.0, max(0.0, t / T)) + elif shape == "decreasing": + lo = float(params.get("lo", 0.2)) + hi = float(params.get("hi", 0.9)) + base = hi - (hi - lo) * min(1.0, max(0.0, t / T)) + elif shape == "peaked": + peak_t = float(params.get("peak_t", T / 2)) + peak_w = float(params.get("peak_w", 1400.0)) + baseline = float(params.get("baseline", 0.15)) + amp = float(params.get("amp", 0.85)) + base = baseline + amp * _smooth_bump(t, peak_t, peak_w) + elif shape == "valley": + valley_t = float(params.get("valley_t", T / 2)) + valley_w = float(params.get("valley_w", 1200.0)) + high = float(params.get("high", 0.85)) + depth = float(params.get("depth", 0.55)) + base = high - depth * _smooth_bump(t, valley_t, valley_w) + else: + base = 0.5 + base = max(0.0, base) + noise_amp = float(params.get("noise_amp", 0.0)) + if noise_amp > 0: + f = _irregular_fluct(t, seed=float(params.get("noise_seed", 0.0))) + base *= 1.0 + (noise_amp / 0.20) * (f - 1.0) + return max(0.0, peak_kw * base) + + +def load_csv_profile(csv_path: Path) -> tuple[np.ndarray, np.ndarray]: + data = np.loadtxt(csv_path, delimiter=",", skiprows=1) + return data[:, 0], data[:, 1] + + +def eval_profile( + t, + *, + peak_kw, + csv_data, + profile_fn, + site_idx, + peak_t_shift_s: float = 0.0, + time_warp: float = 1.0, + profile_kind: str = "default", + profile_params: dict | None = None, +): + """Evaluate a PV/TVL profile at simulated time `t`. + + Dispatch order: + 1. `profile_kind="random_flat"` + `profile_params` -> pv_profile_random + 2. `profile_kind="random_shape"` + `profile_params` -> tvl_profile_random + 3. `csv_data` not None -> CSV interpolation + 4. `profile_fn` (analytical pv_profile_kw / load_profile_kw) + """ + if profile_kind == "random_flat" and profile_params is not None: + return pv_profile_random(t, peak_kw, profile_params) + if profile_kind == "random_shape" and profile_params is not None: + return tvl_profile_random(t, peak_kw, profile_params) + if time_warp <= 0: + time_warp = 1.0 + t_eff = (t - peak_t_shift_s) / time_warp + if csv_data is not None: + return float(np.interp(t_eff, csv_data[0], csv_data[1])) + return profile_fn(t_eff, peak_kw, site_idx) + + +class ScenarioOpenDSSGrid(OpenDSSGrid): + """OpenDSSGrid with PV systems and external loads at arbitrary buses.""" + + def __init__( + self, *, pv_systems=None, time_varying_loads=None, source_pu=None, constant_pv: bool = False, **kwargs + ): + super().__init__(**kwargs) + self._pv_specs = list(pv_systems or []) + self._load_specs = list(time_varying_loads or []) + self._source_pu = source_pu + self._constant_pv = constant_pv + + self._pv_csv = [load_csv_profile(s.csv_path) if s.csv_path else None for s in self._pv_specs] + self._load_csv = [load_csv_profile(s.csv_path) if s.csv_path else None for s in self._load_specs] + self._pv_load_names = [(f"PV_{i}_A", f"PV_{i}_B", f"PV_{i}_C") for i in range(len(self._pv_specs))] + self._ext_load_names = [ + (f"ExtLoad_{i}_A", f"ExtLoad_{i}_B", f"ExtLoad_{i}_C") for i in range(len(self._load_specs)) + ] + + def _init_dss(self) -> None: + super()._init_dss() + from openg2g.grid.opendss import dss + + if self._source_pu is not None: + dss.Text.Command(f"Edit Vsource.source pu={self._source_pu}") + + for i, spec in enumerate(self._pv_specs): + kv_ln = spec.bus_kv / math.sqrt(3.0) + for ph, name in zip((1, 2, 3), self._pv_load_names[i], strict=False): + dss.Text.Command( + f"New Load.{name} bus1={spec.bus}.{ph} phases=1 " + f"conn=wye kV={kv_ln:.6f} kW=0 kvar=0 model=1 vminpu=0.85" + ) + + for i, spec in enumerate(self._load_specs): + kv_ln = spec.bus_kv / math.sqrt(3.0) + for ph, name in zip((1, 2, 3), self._ext_load_names[i], strict=False): + dss.Text.Command( + f"New Load.{name} bus1={spec.bus}.{ph} phases=1 " + f"conn=wye kV={kv_ln:.6f} kW=0 kvar=0 model=1 vminpu=0.85" + ) + + def step(self, clock, power_samples_w, events): + from openg2g.grid.opendss import dss + + for i, spec in enumerate(self._pv_specs): + if self._constant_pv: + kw = spec.peak_kw + else: + kw = eval_profile( + clock.time_s, + peak_kw=spec.peak_kw, + csv_data=self._pv_csv[i], + profile_fn=pv_profile_kw, + site_idx=i, + peak_t_shift_s=getattr(spec, "peak_t_shift_s", 0.0), + time_warp=getattr(spec, "time_warp", 1.0), + profile_kind=getattr(spec, "profile_kind", "default"), + profile_params=getattr(spec, "profile_params", None), + ) + pf = max(min(spec.power_factor, 0.999999), 1e-6) + kvar = kw * math.tan(math.acos(pf)) + for name in self._pv_load_names[i]: + dss.Loads.Name(name) + dss.Loads.kW(-kw) + dss.Loads.kvar(-kvar) + + for i, spec in enumerate(self._load_specs): + kw = eval_profile( + clock.time_s, + peak_kw=spec.peak_kw, + csv_data=self._load_csv[i], + profile_fn=load_profile_kw, + site_idx=i, + peak_t_shift_s=getattr(spec, "peak_t_shift_s", 0.0), + time_warp=getattr(spec, "time_warp", 1.0), + profile_kind=getattr(spec, "profile_kind", "default"), + profile_params=getattr(spec, "profile_params", None), + ) + pf = max(min(spec.power_factor, 0.999999), 1e-6) + kvar = kw * math.tan(math.acos(pf)) + for name in self._ext_load_names[i]: + dss.Loads.Name(name) + dss.Loads.kW(kw) + dss.Loads.kvar(kvar) + + return super().step(clock, power_samples_w, events) + + +def _site_inference_gpus(site: DCSite) -> int: + """Sum of GPUs consumed by inference at a site (replicas × gpus_per_replica).""" + return sum(sched.initial * md.spec.gpus_per_replica for md, sched in site.models) + + +def _randomize_ramps( + dc_sites: dict[str, DCSite], + rng: np.random.Generator, + *, + ramp_frac_min: float = 0.15, + ramp_frac_max: float = 0.3, + ramp_start_min: float = 500.0, + ramp_start_max: float = 3000.0, + ramp_dur_min: float = 300.0, + ramp_dur_max: float = 800.0, +) -> dict[str, DCSite]: + """Return a copy of dc_sites with randomized ramp targets and timing.""" + ramp_frac = rng.uniform(ramp_frac_min, ramp_frac_max) + ramp_start = rng.uniform(ramp_start_min, ramp_start_max) + ramp_dur = rng.uniform(ramp_dur_min, ramp_dur_max) + ramp_end = ramp_start + ramp_dur + + new_sites: dict[str, DCSite] = {} + for sid, site in dc_sites.items(): + new_models: list[tuple[ModelDeployment, ReplicaSchedule]] = [] + for md, sched in site.models: + target = max(1, int(ramp_frac * sched.initial)) + new_sched = ReplicaSchedule(initial=sched.initial).ramp_to( + target, + t_start=ramp_start, + t_end=ramp_end, + ) + new_models.append((md, new_sched)) + new_sites[sid] = DCSite( + bus=site.bus, + bus_kv=site.bus_kv, + base_kw_per_phase=site.base_kw_per_phase, + total_gpu_capacity=site.total_gpu_capacity, + models=tuple(new_models), + seed=int(rng.integers(0, 10000)), + connection_type=site.connection_type, + ) + return new_sites + + +def _randomize_broad_ramps( + dc_sites: dict[str, DCSite], + rng: np.random.Generator, + *, + overlay_gpus_at_first_site: int, + n_ramps_per_site_choices: tuple[int, ...] = (1, 2), + ramp_up_prob: float = 0.5, + ramp_down_frac_min: float = 0.15, + ramp_down_frac_max: float = 0.5, + ramp_up_frac_min: float = 1.05, + ramp_up_frac_max: float = 1.5, + ramp_start_min: float = 500.0, + ramp_start_max: float = 3000.0, + ramp_dur_min: float = 300.0, + ramp_dur_max: float = 800.0, + exclude_window: tuple[float, float] | None = None, +) -> dict[str, DCSite]: + """Bidirectional, multi-ramp generator that respects DC GPU capacity.""" + if exclude_window is not None: + ex_lo, ex_hi = exclude_window + zone1_hi = ex_lo - ramp_dur_max + zone2_lo = ex_hi + zones: list[tuple[float, float]] = [] + if ramp_start_min < zone1_hi: + zones.append((ramp_start_min, min(zone1_hi, ramp_start_max))) + if zone2_lo < ramp_start_max: + zones.append((max(zone2_lo, ramp_start_min), ramp_start_max)) + if not zones: + zones = [(ramp_start_min, ramp_start_max)] + else: + zones = [(ramp_start_min, ramp_start_max)] + + total_width = sum(hi - lo for lo, hi in zones) + + new_sites: dict[str, DCSite] = {} + sites_list = list(dc_sites.items()) + for site_idx, (sid, site) in enumerate(sites_list): + current_gpus = _site_inference_gpus(site) + overlay_here = overlay_gpus_at_first_site if site_idx == 0 else 0 + available_gpus = max(0, site.total_gpu_capacity - overlay_here) + max_feasible_up = available_gpus / current_gpus if current_gpus > 0 else 1.0 + site_ramp_up_max = min(ramp_up_frac_max, max_feasible_up) + can_up_ramp = site_ramp_up_max >= max(1.05, ramp_up_frac_min) + + n_ramps = int(rng.choice(n_ramps_per_site_choices)) + zone_counts = [int(n_ramps * (hi - lo) / total_width) for lo, hi in zones] + remainder = n_ramps - sum(zone_counts) + if remainder > 0: + widest = max(range(len(zones)), key=lambda i: zones[i][1] - zones[i][0]) + zone_counts[widest] += remainder + + # Per-model schedule starts from the model's existing initial count. + model_scheds: dict[str, ReplicaSchedule] = { + md.spec.model_label: ReplicaSchedule(initial=sched.initial) for md, sched in site.models + } + for (z_lo, z_hi), z_count in zip(zones, zone_counts, strict=False): + if z_count == 0: + continue + band_width = (z_hi - z_lo) / z_count + for bi in range(z_count): + band_lo = z_lo + bi * band_width + band_hi = z_lo + (bi + 1) * band_width + if band_width < ramp_dur_min: + continue + t_dur = float(rng.uniform(ramp_dur_min, min(ramp_dur_max, band_width))) + t_start = float(rng.uniform(band_lo, max(band_lo + 1.0, band_hi - t_dur))) + t_end = t_start + t_dur + + if can_up_ramp and rng.random() < ramp_up_prob: + lo = max(1.05, ramp_up_frac_min) + hi = max(lo + 1e-3, site_ramp_up_max) + frac = float(rng.uniform(lo, hi)) + else: + frac = float(rng.uniform(ramp_down_frac_min, ramp_down_frac_max)) + + for md, sched in site.models: + target = max(1, int(round(frac * sched.initial))) + label = md.spec.model_label + model_scheds[label] = model_scheds[label].ramp_to( + target, + t_start=t_start, + t_end=t_end, + ) + + new_models = tuple((md, model_scheds[md.spec.model_label]) for md, _ in site.models) + new_sites[sid] = DCSite( + bus=site.bus, + bus_kv=site.bus_kv, + base_kw_per_phase=site.base_kw_per_phase, + total_gpu_capacity=site.total_gpu_capacity, + models=new_models, + seed=int(rng.integers(0, 10000)), + connection_type=site.connection_type, + ) + return new_sites + + +def randomize_scenario( + seed: int, + *, + dc_sites_base: dict[str, DCSite], + pv_systems_base: list[PVSystemSpec], + tvl_base: list[TimeVaryingLoadSpec], + training_base: dict | None, + randomize_ramps: bool = True, + ramp_frac_min: float = 0.15, + ramp_frac_max: float = 0.3, + ramp_start_min: float = 500.0, + ramp_start_max: float = 3000.0, + ramp_dur_min: float = 300.0, + ramp_dur_max: float = 800.0, + randomization_profile: bool = True, + pv_scale_min: float = 0.5, + pv_scale_max: float = 2.0, + load_scale_min: float = 0.5, + load_scale_max: float = 2.0, + pv_t_shift_max_s: float = 0.0, + tvl_t_shift_max_s: float = 0.0, + pv_warp_min: float = 1.0, + pv_warp_max: float = 1.0, + tvl_warp_min: float = 1.0, + tvl_warp_max: float = 1.0, + overlay_prob: float = 1.0, + overlay_intensity_min: float = 1.0, + overlay_intensity_max: float = 1.0, + overlay_gpu_frac_min: float = 0.85, + overlay_gpu_frac_max: float = 1.0, + n_ramps_per_site_choices: tuple[int, ...] = (1,), + ramp_up_prob: float = 0.0, + ramp_down_frac_min: float = 0.15, + ramp_down_frac_max: float = 0.5, + ramp_up_frac_min: float = 1.05, + ramp_up_frac_max: float = 1.5, + randomize_pv_profile: bool = False, + pv_shape_choices: tuple[str, ...] = ( + "flat", + "rising_falling", + "morning_ramp", + "afternoon_decline", + "midday_dip", + ), + pv_baseline_min: float = 0.75, + pv_baseline_max: float = 0.95, + pv_cloud_count_max: int = 3, + pv_cloud_depth_min: float = 0.30, + pv_cloud_depth_max: float = 0.70, + pv_cloud_width_min: float = 60.0, + pv_cloud_width_max: float = 300.0, + randomize_tvl_profile: bool = False, + tvl_shape_choices: tuple[str, ...] = ("flat", "increasing", "decreasing", "peaked", "valley"), +) -> dict: + """Build a single randomized episode scenario from a seed.""" + rng = np.random.default_rng(seed=seed) + is_broad = randomization_profile if isinstance(randomization_profile, bool) else (randomization_profile == "broad") + + training_run = None + train_overlay_meta: dict | None = None + overlay_gpus_at_first_site = 0 + if training_base is not None: + if is_broad: + overlay_on = bool(rng.random() < overlay_prob) + else: + overlay_on = True + if overlay_on: + train_dur = float(rng.uniform(500.0, 1200.0)) + if is_broad: + train_start = float(rng.uniform(0.0, max(0.0, float(TOTAL_DURATION_S) - train_dur))) + else: + train_start = float(rng.uniform(500.0, 1500.0)) + gpu_frac = ( + float(rng.uniform(overlay_gpu_frac_min, overlay_gpu_frac_max)) + if is_broad + else float(rng.uniform(0.85, 1.0)) + ) + train_gpus = int(gpu_frac * training_base["n_gpus"]) + intensity = float(rng.uniform(overlay_intensity_min, overlay_intensity_max)) if is_broad else 1.0 + target_peak = training_base["target_peak_W_per_gpu"] * intensity + training_run = TrainingRun( + n_gpus=train_gpus, + trace=training_base["trace"], + target_peak_W_per_gpu=target_peak, + ).at(t_start=train_start, t_end=train_start + train_dur) + train_overlay_meta = { + "n_gpus": train_gpus, + "target_peak_W_per_gpu": target_peak, + "intensity": intensity, + "t_start": train_start, + "t_end": train_start + train_dur, + } + overlay_gpus_at_first_site = train_gpus + + if randomize_ramps: + if is_broad: + overlay_window: tuple[float, float] | None = None + if train_overlay_meta is not None: + overlay_window = (train_overlay_meta["t_start"], train_overlay_meta["t_end"]) + sites = _randomize_broad_ramps( + dc_sites_base, + rng, + overlay_gpus_at_first_site=overlay_gpus_at_first_site, + n_ramps_per_site_choices=tuple(n_ramps_per_site_choices), + ramp_up_prob=ramp_up_prob, + ramp_down_frac_min=ramp_down_frac_min, + ramp_down_frac_max=ramp_down_frac_max, + ramp_up_frac_min=ramp_up_frac_min, + ramp_up_frac_max=ramp_up_frac_max, + ramp_start_min=ramp_start_min, + ramp_start_max=ramp_start_max, + ramp_dur_min=ramp_dur_min, + ramp_dur_max=ramp_dur_max, + exclude_window=overlay_window, + ) + else: + sites = _randomize_ramps( + dc_sites_base, + rng, + ramp_frac_min=ramp_frac_min, + ramp_frac_max=ramp_frac_max, + ramp_start_min=ramp_start_min, + ramp_start_max=ramp_start_max, + ramp_dur_min=ramp_dur_min, + ramp_dur_max=ramp_dur_max, + ) + else: + sites = dict(dc_sites_base) + + pv_scale = float(rng.uniform(pv_scale_min, pv_scale_max)) if is_broad else float(rng.uniform(0.5, 2.0)) + load_scale = float(rng.uniform(load_scale_min, load_scale_max)) if is_broad else float(rng.uniform(0.5, 2.0)) + pv_t_shift = float(rng.uniform(-pv_t_shift_max_s, pv_t_shift_max_s)) if (is_broad and pv_t_shift_max_s > 0) else 0.0 + tvl_t_shift = ( + float(rng.uniform(-tvl_t_shift_max_s, tvl_t_shift_max_s)) if (is_broad and tvl_t_shift_max_s > 0) else 0.0 + ) + pv_warp = float(rng.uniform(pv_warp_min, pv_warp_max)) if is_broad else 1.0 + tvl_warp = float(rng.uniform(tvl_warp_min, tvl_warp_max)) if is_broad else 1.0 + + def _sample_pv_profile(rng_) -> tuple[str, dict | None]: + n_clouds = int(rng_.integers(0, pv_cloud_count_max + 1)) + clouds = [ + ( + float(rng_.uniform(120.0, 3480.0)), + float(rng_.uniform(pv_cloud_width_min, pv_cloud_width_max)), + float(rng_.uniform(pv_cloud_depth_min, pv_cloud_depth_max)), + ) + for _ in range(n_clouds) + ] + shape = str(rng_.choice(list(pv_shape_choices))) + params: dict = { + "shape": shape, + "clouds": clouds, + "noise_amp": float(rng_.uniform(0.02, 0.06)), + "noise_seed": float(rng_.uniform(0.0, 10.0)), + } + if shape == "flat": + params["baseline"] = float(rng_.uniform(pv_baseline_min, pv_baseline_max)) + elif shape == "rising_falling": + lo = float(rng_.uniform(0.15, 0.55)) + params["low_baseline"] = lo + params["high_baseline"] = float(rng_.uniform(lo + 0.20, 1.00)) + params["peak_t"] = float(rng_.uniform(900.0, 2700.0)) + params["half_width"] = float(rng_.uniform(800.0, 1500.0)) + elif shape == "morning_ramp": + lo = float(rng_.uniform(0.15, 0.45)) + params["low_baseline"] = lo + params["high_baseline"] = float(rng_.uniform(lo + 0.20, 1.00)) + params["ramp_start"] = float(rng_.uniform(0.0, 400.0)) + params["ramp_end"] = float(rng_.uniform(800.0, 1800.0)) + elif shape == "afternoon_decline": + lo = float(rng_.uniform(0.15, 0.45)) + params["low_baseline"] = lo + params["high_baseline"] = float(rng_.uniform(lo + 0.20, 1.00)) + params["ramp_start"] = float(rng_.uniform(1800.0, 2800.0)) + params["ramp_end"] = float(rng_.uniform(3200.0, 3600.0)) + elif shape == "midday_dip": + params["high_baseline"] = float(rng_.uniform(0.30, 1.00)) + params["dip_t"] = float(rng_.uniform(1200.0, 2400.0)) + params["dip_half_width"] = float(rng_.uniform(500.0, 1000.0)) + params["dip_depth"] = float(rng_.uniform(0.20, 0.55)) + return "random_flat", params + + tvl_profile_kind = "default" + tvl_profile_params: dict | None = None + if is_broad and randomize_tvl_profile: + shape = str(rng.choice(list(tvl_shape_choices))) + params: dict = {"shape": shape} + if shape == "flat": + params["level"] = float(rng.uniform(0.5, 0.85)) + elif shape == "increasing" or shape == "decreasing": + params["lo"] = float(rng.uniform(0.10, 0.30)) + params["hi"] = float(rng.uniform(0.65, 0.95)) + elif shape == "peaked": + params["peak_t"] = float(rng.uniform(800.0, 2800.0)) + params["peak_w"] = float(rng.uniform(800.0, 1800.0)) + params["baseline"] = float(rng.uniform(0.10, 0.30)) + params["amp"] = float(rng.uniform(0.55, 0.90)) + elif shape == "valley": + params["valley_t"] = float(rng.uniform(800.0, 2800.0)) + params["valley_w"] = float(rng.uniform(800.0, 1500.0)) + params["high"] = float(rng.uniform(0.70, 0.90)) + params["depth"] = float(rng.uniform(0.40, 0.70)) + params["noise_amp"] = float(rng.uniform(0.02, 0.06)) + params["noise_seed"] = float(rng.uniform(0.0, 10.0)) + tvl_profile_kind = "random_shape" + tvl_profile_params = params + + pv_systems_out = [] + for s in pv_systems_base: + if is_broad and randomize_pv_profile: + p_kind, p_params = _sample_pv_profile(rng) + else: + p_kind, p_params = "default", None + pv_systems_out.append( + PVSystemSpec( + bus=s.bus, + bus_kv=s.bus_kv, + peak_kw=s.peak_kw * pv_scale, + peak_t_shift_s=pv_t_shift, + time_warp=pv_warp, + profile_kind=p_kind, + profile_params=p_params, + ) + ) + tvl = [ + TimeVaryingLoadSpec( + bus=s.bus, + bus_kv=s.bus_kv, + peak_kw=s.peak_kw * load_scale, + peak_t_shift_s=tvl_t_shift, + time_warp=tvl_warp, + profile_kind=tvl_profile_kind, + profile_params=tvl_profile_params, + ) + for s in tvl_base + ] + + batch_choices = [32, 64, 128] + initial_batch_map: dict[str, int] = {} + new_sites: dict[str, DCSite] = {} + for sid, site in sites.items(): + new_models: list[tuple[ModelDeployment, ReplicaSchedule]] = [] + for md, sched in site.models: + bs = int(rng.choice(batch_choices)) + initial_batch_map[md.spec.model_label] = bs + new_models.append( + (ModelDeployment(spec=md.spec, initial_batch_size=bs), sched), + ) + new_sites[sid] = DCSite( + bus=site.bus, + bus_kv=site.bus_kv, + base_kw_per_phase=site.base_kw_per_phase, + total_gpu_capacity=site.total_gpu_capacity, + models=tuple(new_models), + seed=site.seed, + connection_type=site.connection_type, + ) + + return { + "seed": int(seed), + "dc_sites": new_sites, + "pv_systems": pv_systems_out, + "tvl": tvl, + "training_run": training_run, + "params": { + "pv_scale": pv_scale, + "load_scale": load_scale, + "pv_t_shift_s": pv_t_shift, + "tvl_t_shift_s": tvl_t_shift, + "pv_warp": pv_warp, + "tvl_warp": tvl_warp, + "training_overlay": train_overlay_meta, + "initial_batch_sizes": initial_batch_map, + "randomization_profile": randomization_profile, + "tvl_profile_kind": tvl_profile_kind, + "tvl_profile_params": tvl_profile_params, + }, + } + + +def save_library( + library_dir: Path, + scenarios: list[ScenarioRecord], + config: dict, +) -> None: + """Save a scenario library to `library_dir` as `metadata.json` + `traces.npz`. + + The two files together capture everything needed to replay a library: + `metadata.json` is human-inspectable and stores the build config plus + every `ScenarioRecord`'s scalar fields; `traces.npz` stores the per-step + voltage penalty arrays as numpy arrays (`ofo_`, `baseline_`). + """ + import json + + library_dir.mkdir(parents=True, exist_ok=True) + metadata: dict = { + "config": config, + "scenarios": [], + } + traces: dict[str, np.ndarray] = {} + for i, rec in enumerate(scenarios): + scalar_fields: dict = {} + for f in ScenarioRecord.__dataclass_fields__: + if f in {"ofo_voltage_pen_per_step", "baseline_voltage_pen_per_step"}: + continue + scalar_fields[f] = getattr(rec, f) + metadata["scenarios"].append(scalar_fields) + traces[f"ofo_{i}"] = rec.ofo_voltage_pen_per_step + traces[f"baseline_{i}"] = rec.baseline_voltage_pen_per_step + (library_dir / "metadata.json").write_text(json.dumps(metadata, indent=2)) + np.savez(library_dir / "traces.npz", **traces) + + +def load_library_data(library_dir: Path) -> tuple[list[ScenarioRecord], dict]: + """Read a library directory written by `save_library`. + + Returns `(scenarios, config)`. Does not construct materialization base + components: `ScenarioLibrary` does that on top. + """ + import json + + metadata = json.loads((library_dir / "metadata.json").read_text()) + traces = np.load(library_dir / "traces.npz") + scenarios: list[ScenarioRecord] = [] + for i, fields in enumerate(metadata["scenarios"]): + scenarios.append( + ScenarioRecord( + ofo_voltage_pen_per_step=traces[f"ofo_{i}"], + baseline_voltage_pen_per_step=traces[f"baseline_{i}"], + **fields, + ) + ) + return scenarios, metadata["config"] + + +def materialize_scenario( + rec: ScenarioRecord, + *, + dc_sites_base: dict[str, DCSite], + pv_systems_base: list[PVSystemSpec], + tvl_base: list[TimeVaryingLoadSpec], + training_base: dict | None, + randomize_kwargs: dict, +) -> dict: + """Replay `randomize_scenario` from a record's seed to obtain the same + per-episode scenario dict the build pipeline saw. + + This is `randomize_scenario(seed=rec.seed, ...)` with the + `*_base` configurations and `randomize_kwargs` that the library was built + with: the RNG is seeded so the replay is bit-identical to the build-time + output (including `dc_sites`, `pv_systems`, `tvl`, and the chosen scalar + parameters). `ScenarioLibrary.materialize` wires the base components in + automatically; call this directly only when constructing a scenario + outside a library (e.g. evaluate.py's seed-driven test set). + """ + return randomize_scenario( + seed=rec.seed, + dc_sites_base=dc_sites_base, + pv_systems_base=pv_systems_base, + tvl_base=tvl_base, + training_base=training_base, + **randomize_kwargs, + ) + + +def ieee13_experiment(training_trace: TrainingTrace | None = None) -> dict: + """IEEE 13-bus: single DC at bus 671 with 5 LLM models.""" + sys = SYSTEMS["ieee13"]() + ramp_targets = { + "Llama-3.1-8B": 144, + "Llama-3.1-70B": 36, + "Llama-3.1-405B": 18, + "Qwen3-30B-A3B": 96, + "Qwen3-235B-A22B": 42, + } + base_models = ( + deploy("Llama-3.1-8B", 720), + deploy("Llama-3.1-70B", 180), + deploy("Llama-3.1-405B", 90), + deploy("Qwen3-30B-A3B", 480), + deploy("Qwen3-235B-A22B", 210), + ) + models = tuple( + (md, sched.ramp_to(ramp_targets[md.spec.model_label], t_start=2500, t_end=3000)) for md, sched in base_models + ) + training_base = ( + { + "trace": training_trace, + "n_gpus": 2400, + "target_peak_W_per_gpu": 400.0, + "t_start": 1000.0, + "t_end": 2000.0, + } + if training_trace is not None + else None + ) + return dict( + sys=sys, + dc_sites={ + "default": DCSite( + bus="671", + bus_kv=sys["bus_kv"], + base_kw_per_phase=500.0, + total_gpu_capacity=7200, + models=models, + seed=0, + ), + }, + pv_systems=[PVSystemSpec(bus="675", bus_kv=4.16, peak_kw=300.0)], + time_varying_loads=[TimeVaryingLoadSpec(bus="680", bus_kv=4.16, peak_kw=300.0)], + training_base=training_base, + ofo_config=OFOConfig( + primal_step_size=0.05, + w_throughput=0.00001, + w_switch=1.0, + voltage_gradient_scale=1e6, + v_min=V_MIN, + v_max=V_MAX, + voltage_dual_step_size=1.0, + latency_dual_step_size=1.0, + sensitivity_update_interval=300, + sensitivity_perturbation_kw=100.0, + ), + tap_schedule=TapSchedule( + ( + (1500, TapPosition(regulators={"creg1a": tap(16), "creg1b": tap(6), "creg1c": tap(17)})), + (3300, TapPosition(regulators={"creg1a": tap(10), "creg1b": tap(6), "creg1c": tap(10)})), + ) + ), + ) + + +def ieee34_experiment(training_trace: TrainingTrace | None = None) -> dict: + """IEEE 34-bus: two DC sites (upstream/downstream).""" + sys = SYSTEMS["ieee34"]() + return dict( + sys=sys, + dc_sites={ + "upstream": DCSite( + bus="850", + bus_kv=24.9, + base_kw_per_phase=250.0, + models=(deploy("Llama-3.1-8B", 320), deploy("Llama-3.1-70B", 80), deploy("Llama-3.1-405B", 40)), + seed=0, + total_gpu_capacity=1200, + ), + "downstream": DCSite( + bus="834", + bus_kv=24.9, + base_kw_per_phase=300.0, + models=(deploy("Qwen3-30B-A3B", 216), deploy("Qwen3-235B-A22B", 96)), + seed=42, + total_gpu_capacity=1440, + ), + }, + pv_systems=[ + PVSystemSpec(bus="858", bus_kv=24.9, peak_kw=130.0), + PVSystemSpec(bus="852", bus_kv=24.9, peak_kw=65.0), + ], + time_varying_loads=[ + TimeVaryingLoadSpec(bus="860", bus_kv=24.9, peak_kw=80.0), + TimeVaryingLoadSpec(bus="844", bus_kv=24.9, peak_kw=120.0), + TimeVaryingLoadSpec(bus="858", bus_kv=24.9, peak_kw=50.0), + ], + training_base=None, + ofo_config=OFOConfig( + primal_step_size=0.05, + w_throughput=0.0001, + w_switch=1.0, + voltage_gradient_scale=1e6, + voltage_dual_step_size=1.0, + latency_dual_step_size=1.0, + sensitivity_update_interval=300, + sensitivity_perturbation_kw=50.0, + v_min=V_MIN, + v_max=V_MAX, + ), + tap_schedule=TapSchedule( + ( + ( + 1800, + TapPosition( + regulators={ + "creg2a": tap(10), + "creg2b": tap(10), + "creg2c": tap(10), + } + ), + ), + ) + ), + ) + + +def ieee123_experiment(training_trace: TrainingTrace | None = None) -> dict: + """IEEE 123-bus: four DC sites across zones.""" + sys = SYSTEMS["ieee123"]() + return dict( + sys=sys, + dc_sites={ + "z1_sw": DCSite( + bus="8", + bus_kv=4.16, + base_kw_per_phase=280.0, + models=(with_ramp(deploy("Llama-3.1-8B", 800), 1200, t_start=500, t_end=1000),), + seed=0, + total_gpu_capacity=1200, + ), + "z2_nw": DCSite( + bus="23", + bus_kv=4.16, + base_kw_per_phase=280.0, + models=(with_ramp(deploy("Qwen3-30B-A3B", 460), 600, t_start=1500, t_end=2500),), + seed=17, + total_gpu_capacity=1200, + ), + "z3_se": DCSite( + bus="60", + bus_kv=4.16, + base_kw_per_phase=224.0, + models=( + with_ramp(deploy("Llama-3.1-70B", 64), 96, t_start=700, t_end=1100), + deploy("Llama-3.1-405B", 72), + ), + seed=34, + total_gpu_capacity=960, + ), + "z4_ne": DCSite( + bus="105", + bus_kv=4.16, + base_kw_per_phase=224.0, + models=(with_ramp(deploy("Qwen3-235B-A22B", 96), 56, t_start=2000, t_end=2500),), + seed=51, + total_gpu_capacity=960, + ), + }, + pv_systems=[ + PVSystemSpec(bus="18", bus_kv=4.16, peak_kw=100.0), + PVSystemSpec(bus="48", bus_kv=4.16, peak_kw=250.0), + PVSystemSpec(bus="57", bus_kv=4.16, peak_kw=200.0), + ], + time_varying_loads=[ + TimeVaryingLoadSpec(bus="13", bus_kv=4.16, peak_kw=20.0), + TimeVaryingLoadSpec(bus="86", bus_kv=4.16, peak_kw=20.0), + TimeVaryingLoadSpec(bus="114", bus_kv=4.16, peak_kw=20.0), + ], + training_base=None, + ofo_config=OFOConfig( + primal_step_size=0.05, + w_throughput=0.0001, + w_switch=1.0, + voltage_gradient_scale=1e6, + voltage_dual_step_size=0.3, + latency_dual_step_size=1.0, + sensitivity_update_interval=300, + sensitivity_perturbation_kw=10.0, + v_min=V_MIN, + v_max=V_MAX, + ), + tap_schedule=None, + ) + + +EXPERIMENTS: dict[str, Callable[..., dict]] = { + "ieee13": ieee13_experiment, + "ieee34": ieee34_experiment, + "ieee123": ieee123_experiment, +} diff --git a/examples/rl_controller/systems.py b/examples/rl_controller/systems.py new file mode 100644 index 0000000..862226a --- /dev/null +++ b/examples/rl_controller/systems.py @@ -0,0 +1,466 @@ +"""IEEE test feeder constants, shared simulation constants, model-spec presets. + +Feeder definitions (`ieee13`, `ieee34`, `ieee123`), the `SYSTEMS` lookup +dict, the regulator-tap helper (`tap`, `TAP_STEP`), shared simulation +constants (`DT_*`, `V_MIN`/`V_MAX`, `TOTAL_DURATION_S`, `POWER_AUG`), +hardcoded `InferenceModelSpec` presets (`LLAMA_*`, `QWEN_*`, +`MODEL_SPECS`), and the `deploy` / `with_ramp` deployment shortcuts. The +RL-specific scenario randomization, experiment factories, and +`ScenarioOpenDSSGrid` live in `scenarios.py`. +""" + +from __future__ import annotations + +import math +from fractions import Fraction +from pathlib import Path + +from openg2g.datacenter.config import ( + InferenceModelSpec, + ModelDeployment, + PowerAugmentationConfig, + ReplicaSchedule, +) +from openg2g.grid.config import TapPosition + +PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent +GRID_DATA_DIR = PROJECT_ROOT / "data" / "grid" + +TAP_STEP = 0.00625 # Standard 32-step regulator: ±10% in 32 steps + + +def tap(steps: int) -> float: + """Convert integer tap step to per-unit ratio. E.g., ``tap(14)`` → 1.0875.""" + return 1.0 + steps * TAP_STEP + + +# ── Shared simulation constants ────────────────────────────────────────────── + +DT_DC = Fraction(1) +DT_GRID = Fraction(1) +DT_CTRL = Fraction(1) +V_MIN, V_MAX = 0.95, 1.05 +TOTAL_DURATION_S = 3600 +POWER_AUG = PowerAugmentationConfig(amplitude_scale_range=(0.98, 1.02), noise_fraction=0.005) + + +# ── Model specs (hardcoded; replaces config.json which master removed) ────── + +LLAMA_8B = InferenceModelSpec( + model_label="Llama-3.1-8B", + model_id="meta-llama/Llama-3.1-8B-Instruct", + gpu_model="H100", + task="lm-arena-chat", + precision="bfloat16", + gpus_per_replica=1, + tensor_parallel=1, + itl_deadline_s=0.08, + batch_sizes=(8, 16, 32, 64, 96, 128, 192, 256, 384, 512, 768, 1024), + feasible_batch_sizes=(8, 16, 32, 64, 128, 256, 512), +) +LLAMA_70B = InferenceModelSpec( + model_label="Llama-3.1-70B", + model_id="meta-llama/Llama-3.1-70B-Instruct", + gpu_model="H100", + task="lm-arena-chat", + precision="bfloat16", + gpus_per_replica=4, + tensor_parallel=4, + itl_deadline_s=0.10, + batch_sizes=(8, 16, 32, 64, 96, 128, 192, 256, 384, 512, 768, 1024, 1536, 2048), + feasible_batch_sizes=(8, 16, 32, 64, 128, 256, 512), +) +LLAMA_405B = InferenceModelSpec( + model_label="Llama-3.1-405B", + model_id="meta-llama/Llama-3.1-405B-Instruct-FP8", + gpu_model="H100", + task="lm-arena-chat", + precision="fp8", + gpus_per_replica=8, + tensor_parallel=8, + itl_deadline_s=0.12, + batch_sizes=(8, 16, 32, 64, 96, 128, 192, 256, 384, 512), + feasible_batch_sizes=(8, 16, 32, 64, 128, 256, 512), +) +QWEN_30B = InferenceModelSpec( + model_label="Qwen3-30B-A3B", + model_id="Qwen/Qwen3-30B-A3B-Thinking-2507", + gpu_model="H100", + task="gpqa", + precision="bfloat16", + gpus_per_replica=2, + tensor_parallel=2, + itl_deadline_s=0.06, + batch_sizes=(8, 16, 32, 64, 96, 128, 192, 256, 384, 512), + feasible_batch_sizes=(8, 16, 32, 64, 128, 256, 512), +) +QWEN_235B = InferenceModelSpec( + model_label="Qwen3-235B-A22B", + model_id="Qwen/Qwen3-235B-A22B-Thinking-2507", + gpu_model="H100", + task="gpqa", + precision="bfloat16", + gpus_per_replica=8, + tensor_parallel=8, + itl_deadline_s=0.14, + batch_sizes=(8, 16, 32, 64, 96, 128, 192, 256, 384, 512), + feasible_batch_sizes=(8, 16, 32, 64, 128, 256, 512), +) +ALL_MODEL_SPECS: tuple[InferenceModelSpec, ...] = (LLAMA_8B, LLAMA_70B, LLAMA_405B, QWEN_30B, QWEN_235B) +MODEL_SPECS: dict[str, InferenceModelSpec] = {s.model_label: s for s in ALL_MODEL_SPECS} + +SPECS_CACHE_DIR = PROJECT_ROOT / "data" / "specs" +TRAINING_TRACE_PATH = PROJECT_ROOT / "data" / "training_trace.csv" + + +def deploy( + label: str, + num_replicas: int, + initial_batch_size: int = 128, +) -> tuple[ModelDeployment, ReplicaSchedule]: + """Shorthand: ``deploy("Llama-3.1-8B", 720, 128)`` -> (ModelDeployment, ReplicaSchedule).""" + return ( + ModelDeployment(spec=MODEL_SPECS[label], initial_batch_size=initial_batch_size), + ReplicaSchedule(initial=num_replicas), + ) + + +def with_ramp( + deployment: tuple[ModelDeployment, ReplicaSchedule], + target: int, + *, + t_start: float, + t_end: float, +) -> tuple[ModelDeployment, ReplicaSchedule]: + """Inject a single ramp into a deploy() result. Convenience for ieee*_experiment factories.""" + md, sched = deployment + return (md, sched.ramp_to(target, t_start=t_start, t_end=t_end)) + + +# ── IEEE test feeder constants ─────────────────────────────────────────────── + + +def ieee13() -> dict: + """IEEE 13-bus test feeder constants.""" + return dict( + dss_case_dir=GRID_DATA_DIR / "ieee13", + dss_master_file="IEEE13Bus.dss", + bus_kv=4.16, + source_pu=1.0, + initial_taps=TapPosition( + regulators={ + "creg1a": tap(14), + "creg1b": tap(6), + "creg1c": tap(15), + } + ), + exclude_buses=("sourcebus", "650", "rg60"), + ) + + +def ieee34() -> dict: + """IEEE 34-bus (half-line variant) test feeder constants.""" + return dict( + dss_case_dir=GRID_DATA_DIR / "ieee34", + dss_master_file="IEEE34Bus.dss", + bus_kv=24.9, + source_pu=1.09, + initial_taps=TapPosition( + regulators={ + "creg1a": tap(11), + "creg1b": tap(6), + "creg1c": tap(8), + "creg2a": tap(8), + "creg2b": tap(8), + "creg2c": tap(8), + } + ), + exclude_buses=( + "sourcebus", + "800", + "802", + "806", + "808", + "810", + "812", + "814", + "888", + "890", + ), + regulator_zones={ + "creg1": ["814r", "850", "816", "824", "828", "830", "854"], + "creg2": [ + "852r", + "832", + "858", + "834", + "860", + "836", + "840", + "862", + "842", + "844", + "846", + "848", + ], + }, + ) + + +def ieee123() -> dict: + """IEEE 123-bus test feeder constants.""" + return dict( + dss_case_dir=GRID_DATA_DIR / "ieee123", + dss_master_file="IEEE123Bus.dss", + bus_kv=4.16, + source_pu=1.0, + initial_taps=TapPosition( + regulators={ + "creg1a": tap(9), + "creg2a": tap(5), + "creg3a": tap(5), + "creg3c": tap(5), + "creg4a": tap(14), + "creg4b": tap(1), + "creg4c": tap(4), + } + ), + exclude_buses=( + "sourcebus", + "150", + "150r", + "149", + "9r", + "25r", + "160r", + "61s", + "610", + "300_open", + "94_open", + "135", + ), + zones={ + "z1_sw": [ + "1", + "2", + "3", + "4", + "5", + "6", + "7", + "8", + "9", + "10", + "11", + "12", + "13", + "14", + "15", + "16", + "17", + "18", + "19", + "20", + "34", + ], + "z2_nw": [ + "21", + "22", + "23", + "24", + "25", + "26", + "27", + "28", + "29", + "30", + "31", + "32", + "33", + "35", + "36", + "37", + "38", + "39", + "40", + "41", + "42", + "43", + "44", + "45", + "46", + "47", + "48", + "49", + "50", + "51", + ], + "z3_se": [ + "52", + "53", + "54", + "55", + "56", + "57", + "58", + "59", + "60", + "61", + "62", + "63", + "64", + "65", + "66", + "67", + "68", + "69", + "70", + "71", + "72", + "73", + "74", + "75", + "76", + "77", + "78", + "79", + "80", + "81", + "82", + "83", + "84", + "85", + "86", + "87", + "88", + "89", + "90", + "91", + "92", + "93", + "94", + "95", + "96", + ], + "z4_ne": [ + "97", + "98", + "99", + "100", + "101", + "102", + "103", + "104", + "105", + "106", + "107", + "108", + "109", + "110", + "111", + "112", + "113", + "114", + "115", + "116", + "117", + "118", + "119", + "120", + "121", + "122", + "123", + ], + }, + ) + + +SYSTEMS = {"ieee13": ieee13, "ieee34": ieee34, "ieee123": ieee123} + + +# ── Profile helpers ─────────────────────────────────────────────────────────── + + +def _smooth_bump(t: float, t_center: float, half_width: float) -> float: + dt = abs(t - t_center) + if dt >= half_width: + return 0.0 + x = dt / half_width + return (1 - x * x) ** 2 + + +def _smoothstep(t: float, t_start: float, t_end: float) -> float: + """Cubic Hermite smoothstep: zero-derivative at both endpoints.""" + if t_end <= t_start: + return 1.0 if t >= t_start else 0.0 + if t <= t_start: + return 0.0 + if t >= t_end: + return 1.0 + x = (t - t_start) / (t_end - t_start) + return x * x * (3.0 - 2.0 * x) + + +def _irregular_fluct(t: float, seed: float = 0.0) -> float: + """Irregular fluctuation via superposition of incommensurate frequencies.""" + s = seed + f1 = 0.06 * math.sin(2 * math.pi * t / 173.0 + s) + f2 = 0.05 * math.sin(2 * math.pi * t / 97.3 + s * 2.3) + f3 = 0.04 * math.sin(2 * math.pi * t / 251.7 + s * 0.7) + f4 = 0.03 * math.sin(2 * math.pi * t / 41.9 + s * 4.1) + f5 = 0.02 * math.sin(2 * math.pi * t / 317.3 + s * 1.9) + return 1.0 + f1 + f2 + f3 + f4 + f5 + + +def pv_profile_kw(t: float, peak_kw: float, site_idx: int = 0) -> float: + """Solar PV output (kW per phase) with per-site cloud patterns.""" + T = TOTAL_DURATION_S + if site_idx == 0: + trend = 0.85 - 0.30 * (t / T) + cloud = 1.0 + cloud -= 0.55 * _smooth_bump(t, 600, 120) + cloud -= 0.40 * _smooth_bump(t, 2100, 180) + fluct = _irregular_fluct(t, seed=0.3) + return max(0.0, peak_kw * trend * max(cloud, 0.05) * fluct) + elif site_idx == 1: + ramp = 0.55 + 0.40 * _smooth_bump(t, 1200, 900) + cloud = 1.0 + cloud -= 0.60 * _smooth_bump(t, 1680, 240) + cloud -= 0.25 * _smooth_bump(t, 2400, 150) + fluct = _irregular_fluct(t, seed=2.1) + return max(0.0, peak_kw * ramp * max(cloud, 0.05) * fluct) + else: + ramp = 0.30 + 0.65 * min(1.0, t / 900.0) + cloud = 1.0 + cloud -= 0.70 * _smooth_bump(t, 2700, 300) + cloud -= 0.30 * _smooth_bump(t, 1200, 100) + fluct = _irregular_fluct(t, seed=2.0 + site_idx * 3.7) + return max(0.0, peak_kw * ramp * max(cloud, 0.05) * fluct) + + +def load_profile_kw(t: float, peak_kw: float, site_idx: int = 0) -> float: + fluct_period = 130.0 + site_idx * 37 + fluct = 1.0 + 0.06 * math.sin(2 * math.pi * t / fluct_period + site_idx * 1.4) + + if site_idx == 0: + base = 0.15 + 0.85 * _smooth_bump(t, 2280, 1400) + surge = 0.20 * _smooth_bump(t, 2280, 180) + return max(0.0, peak_kw * (base + surge) * fluct) + elif site_idx == 1: + base = 0.10 + base += 0.50 * _smooth_bump(t, 1500, 600) + base += 0.80 * _smooth_bump(t, 2880, 500) + return max(0.0, peak_kw * base * fluct) + elif site_idx == 2: + base = 0.80 - 0.55 * _smooth_bump(t, 1800, 1200) + surge = 0.70 * _smooth_bump(t, 2520, 400) + return max(0.0, peak_kw * (base + surge) * fluct) + elif site_idx == 3: + base = 0.10 + 0.90 * _smooth_bump(t, 3120, 800) + return max(0.0, peak_kw * base * fluct) + else: + base = 0.10 + base += 0.60 * _smooth_bump(t, 1080, 300) + base += 0.75 * _smooth_bump(t, 2100, 350) + base += 0.90 * _smooth_bump(t, 3300, 300) + return max(0.0, peak_kw * base * fluct) diff --git a/examples/offline/train_ppo.py b/examples/rl_controller/train_ppo.py similarity index 89% rename from examples/offline/train_ppo.py rename to examples/rl_controller/train_ppo.py index 8237161..f8d93c8 100644 --- a/examples/offline/train_ppo.py +++ b/examples/rl_controller/train_ppo.py @@ -5,9 +5,13 @@ mid-range batch sizes during that site's training. Usage: - python train_ppo.py --system ieee13 --total-timesteps 200000 - python train_ppo.py --system ieee13 --obs-mode system-summary-only # summary-only obs - python train_ppo.py --system ieee13 --hidden-dims 256 256 256 --n-envs 8 + python examples/rl_controller/train_ppo.py --system ieee13 \\ + --scenario-library examples/rl_controller/outputs/ieee13/scenario_library/train_n500 \\ + --total-timesteps 2000000 + python examples/rl_controller/train_ppo.py --system ieee13 \\ + --scenario-library .../train_n500 --obs-mode system-summary-only + python examples/rl_controller/train_ppo.py --system ieee13 \\ + --scenario-library .../train_n500 --hidden-dims 256 256 256 --n-envs 8 """ from __future__ import annotations @@ -20,6 +24,20 @@ import numpy as np import tyro +from env import ( + BatchSizeEnv, + ObservationConfig, + RewardConfig, + ScenarioLibrary, + SharedBatchSizeEnv, + compute_bus_phase_groups, + compute_zone_mask, +) +from scenarios import ( + EXPERIMENTS, + DCSite, + ScenarioOpenDSSGrid, +) from openg2g.controller.tap_schedule import TapScheduleController from openg2g.datacenter.config import ( @@ -32,35 +50,20 @@ from openg2g.datacenter.workloads.inference import InferenceData from openg2g.datacenter.workloads.training import TrainingTrace from openg2g.grid.config import TapSchedule -from openg2g.rl.env import ( - BatchSizeEnv, - ObservationConfig, - RewardConfig, - SharedBatchSizeEnv, - compute_bus_phase_groups, - compute_zone_mask, - resolve_action_mode, -) from systems import ( DT_CTRL, DT_DC, DT_GRID, - EXPERIMENTS, POWER_AUG, SPECS_CACHE_DIR, TRAINING_TRACE_PATH, V_MAX, V_MIN, - DCSite, - ScenarioOpenDSSGrid, - materialize_scenario, ) logger = logging.getLogger(__name__) -# ── Simulation factory ────────────────────────────────────────────────────── - def make_sim_factory( exp: dict, @@ -68,12 +71,12 @@ def make_sim_factory( ): """Return a callable that builds fresh simulation components. - Returns ``(make_sim, all_site_specs, all_replica_counts)`` where - ``make_sim()`` produces ``(dict[str, DatacenterBackend], grid, tap_ctrl)``. - - Library scenarios are replayed by reading the resolved fields stored on - each ``ScenarioRecord``; the rng-replay path is gone. Old libraries must - be upgraded via ``examples/offline/migrate_scenario_library.py``. + Returns `(make_sim, all_site_specs, all_replica_counts, all_initial_batch_sizes)` + where `make_sim(scenario_override=None)` produces + `(dict[str, DatacenterBackend], grid, tap_ctrl)`. When the env is sampling + from a `ScenarioLibrary`, it passes the already-materialized scenario dict + as `scenario_override`; otherwise `make_sim()` falls back to the + experiment's defaults. """ sys = exp["sys"] dc_sites: dict[str, DCSite] = exp["dc_sites"] @@ -99,20 +102,19 @@ def make_sim_factory( _episode_counter = [0] - def make_sim(scenario_override=None): + def make_sim(scenario_override: dict | None = None): _episode_counter[0] += 1 if scenario_override is not None: - sc = materialize_scenario(scenario_override, training_base=training_base) - sites = sc["dc_sites"] + sites = scenario_override["dc_sites"] # Library was built keyed by the experiment's DC site id (e.g. "default"); # the grid expects "_default" for single-DC. Remap once here. if is_single_dc and "_default" not in sites: orig = next(iter(sites)) sites = {"_default": sites[orig]} - pv_systems = sc["pv_systems"] - tvl = sc["tvl"] - training = sc["training_run"] + pv_systems = scenario_override["pv_systems"] + tvl = scenario_override["tvl"] + training = scenario_override["training_run"] else: sites = dc_sites pv_systems = pv_systems_base @@ -126,9 +128,6 @@ def make_sim(scenario_override=None): else: training = None - # Build all datacenters. Per-model schedules combine the (initial, - # ramps) pair stored on site.models — replaces the old dict[str, int] - # replica_counts + separate inference_ramps fields. datacenters: dict[str, OfflineDatacenter] = {} for sid, site in sites.items(): dc_config = DatacenterConfig(gpus_per_server=8, base_kw_per_phase=site.base_kw_per_phase) @@ -152,8 +151,6 @@ def make_sim(scenario_override=None): total_gpu_capacity=site.total_gpu_capacity, ) - # Build grid then attach DCs (master's imperative pattern; old - # dc_loads / dc_bus kwargs no longer accepted by OpenDSSGrid). dc_config_pf = DatacenterConfig(base_kw_per_phase=0).power_factor exclude = tuple(sys.get("exclude_buses", ())) grid = ScenarioOpenDSSGrid( @@ -181,9 +178,6 @@ def make_sim(scenario_override=None): return make_sim, all_site_specs, all_replica_counts, all_initial_batch_sizes -# ── Training metrics callback ─────────────────────────────────────────────── - - def _new_episode_acc() -> dict: return { "voltage": 0.0, @@ -203,7 +197,7 @@ class TrainingMetricsCallback: Writes one CSV row per completed episode and mirrors the same metrics to the SB3 TensorBoard logger so they show up alongside built-in PPO metrics. - Imported lazily inside ``main`` so SB3 isn't a hard import for tooling that + Imported lazily inside `main` so SB3 isn't a hard import for tooling that only wants the experiment definitions. """ @@ -300,13 +294,10 @@ def _on_training_end(self) -> None: return _Impl(csv_path) -# ── Plotting ──────────────────────────────────────────────────────────────── - - def plot_training_progress(csv_path: Path, output_path: Path, label: str) -> Path | None: """Read the per-episode metrics CSV and emit a 2x2 PNG dashboard. - Returns the output path on success, or ``None`` if the CSV is empty. + Returns the output path on success, or `None` if the CSV is empty. """ import matplotlib @@ -347,7 +338,7 @@ def smooth(arr: np.ndarray, window: int) -> np.ndarray: ax.plot(eps, smooth(ep_reward, window), label=f"smooth (w={window})", linewidth=2) ax.set_xlabel("Episode") ax.set_ylabel("Episode reward") - ax.set_title(f"Learning curve — {label}") + ax.set_title(f"Learning curve: {label}") ax.legend() ax.grid(alpha=0.3) @@ -380,16 +371,13 @@ def smooth(arr: np.ndarray, window: int) -> np.ndarray: ax.set_ylim(0, max(0.05, float(viol_frac.max()) * 1.1)) ax.grid(alpha=0.3) - fig.suptitle(f"PPO training progress — {label}", fontsize=14) + fig.suptitle(f"PPO training progress: {label}", fontsize=14) fig.tight_layout() fig.savefig(output_path, dpi=110) plt.close(fig) return output_path -# ── Main ──────────────────────────────────────────────────────────────────── - - @dataclass class Args: system: str = "ieee13" @@ -402,6 +390,8 @@ class Args: """Learning rate schedule: 'constant' or 'linear' (decays to 0 over training).""" n_steps: int = 3600 """Rollout length per environment (one full simulated hour).""" + checkpoint_interval_rollouts: int = 10 + """Checkpoint cadence in rollouts. With the defaults (n_steps=3600, n_envs=8), 10 rollouts ≈ 288000 env transitions, so a 2M-step run produces ~7 snapshots.""" # noqa: E501 batch_size: int = 128 """Minibatch size for PPO updates.""" n_epochs: int = 10 @@ -423,7 +413,7 @@ class Args: w_latency: float = 0.0 """Reward weight for latency violations. Default 0 to isolate the voltage-control objective.""" w_switch: float = 0.01 - """Reward weight for switching cost (penalizes |log2(batch_t) - log2(batch_{t-1})| summed over models). Without this, randomized-scenario runs converge to a near-uniform action distribution and the deterministic eval policy ends up flipping batch sizes on every step. 0.01 is a gentle prior — much smaller than voltage_pen so it acts as a tie-breaker, not a co-equal objective.""" # noqa: E501 + """Reward weight for switching cost (penalizes |log2(batch_t) - log2(batch_{t-1})| summed over models). Without this, randomized-scenario runs converge to a near-uniform action distribution and the deterministic eval policy ends up flipping batch sizes on every step. 0.01 is a gentle prior: much smaller than voltage_pen so it acts as a tie-breaker, not a co-equal objective.""" # noqa: E501 w_safe: float = 0.0 """Small positive reward for staying in the safe voltage range. Each step adds +w_safe * (fraction of bus-phases within [v_min, v_max]). Default 0 (disabled). Recommended: 0.01.""" # noqa: E501 switch_mode: str = "magnitude" @@ -459,7 +449,7 @@ class Args: log_level: str = "INFO" """Logging verbosity.""" scenario_library: str = "" - """Path to a scenario library .pkl built by build_scenario_library.py. When set, episodes are sampled from this library.""" # noqa: E501 + """Path to a scenario library directory built by build_library.py (containing metadata.json + traces.npz). When set, episodes are sampled from this library.""" # noqa: E501 ofo_baseline: bool = False """Subtract the OFO oracle's per-step voltage penalty from PPO's reward (requires --scenario-library). Disabling gives the raw voltage penalty as reward.""" # noqa: E501 truncate_episode: bool = True @@ -486,8 +476,6 @@ def main() -> None: logger.error("Unknown system: %s. Available: %s", args.system, list(EXPERIMENTS.keys())) sys.exit(1) - # Load training trace from the canonical fixed path (matches master's - # run_ofo.py pattern; the JSON-driven training_trace_params pipeline is gone). training_trace = TrainingTrace.ensure(TRAINING_TRACE_PATH) exp = EXPERIMENTS[args.system](training_trace) @@ -522,8 +510,6 @@ def main() -> None: scenario_lib = None if args.scenario_library: - from openg2g.rl.env import ScenarioLibrary - scenario_lib = ScenarioLibrary(args.scenario_library) logger.info( "Loaded scenario library with %d scenarios from %s (ofo_baseline=%s, truncate=%s)", @@ -593,7 +579,6 @@ def main() -> None: reward_clip=args.reward_clip, switch_mode=args.switch_mode, switch_cooldown_tau=args.switch_cooldown_tau, - action_mode=args.action_mode, ) site_ids = list(all_site_specs.keys()) @@ -604,12 +589,12 @@ def main() -> None: from stable_baselines3.common.vec_env import DummyVecEnv, SubprocVecEnv, VecNormalize def _train_and_save(env_factory, label: str, save_name: str) -> None: - """Build a (possibly vectorized) env from ``env_factory`` and train one PPO model. + """Build a (possibly vectorized) env from `env_factory` and train one PPO model. - ``env_factory`` is a zero-arg callable returning a fresh ``BatchSizeEnv`` + `env_factory` is a zero-arg callable returning a fresh `BatchSizeEnv` (or subclass). It is invoked once per parallel environment, wrapped with - ``Monitor``, and stitched into a vec-env. ``SubprocVecEnv`` is used when - ``args.n_envs > 1`` because each rollout needs its own OpenDSS instance + `Monitor`, and stitched into a vec-env. `SubprocVecEnv` is used when + `args.n_envs > 1` because each rollout needs its own OpenDSS instance (OpenDSS holds global state, so multiple envs in one process collide). """ @@ -679,12 +664,12 @@ def _make_one(): " switch_mode=%s switch_cooldown_tau=%s action_mode=%s", args.switch_mode, args.switch_cooldown_tau, - resolve_action_mode(reward_config), + args.action_mode, ) logger.info("=" * 60) checkpoint_cb = CheckpointCallback( - save_freq=max(args.n_steps * 10, 1), + save_freq=max(args.n_steps * args.checkpoint_interval_rollouts, 1), save_path=str(output_dir / "checkpoints" / label), name_prefix="ppo", save_vecnormalize=args.vec_normalize, @@ -759,7 +744,7 @@ def lr_arg(progress_remaining): if plot_path is not None: logger.info("Wrote training plot to %s", plot_path) else: - logger.warning("No metrics rows in %s — skipping plot", metrics_csv) + logger.warning("No metrics rows in %s: skipping plot", metrics_csv) except Exception as e: logger.warning("Plotting failed for '%s': %s", label, e) @@ -791,6 +776,7 @@ def shared_env_factory(): obs_config=obs_config, site_model_mapping=site_model_mapping, reward_config=reward_config, + action_mode=args.action_mode, logistic_models=logistic_models, dt_ctrl=DT_CTRL, total_duration_s=args.total_duration_s, @@ -835,6 +821,7 @@ def site_env_factory(_obs_config=obs_config, _sid=sid, _lib=scenario_lib): obs_config=_obs_config, agent_site_id=_sid, reward_config=reward_config, + action_mode=args.action_mode, logistic_models=logistic_models, dt_ctrl=DT_CTRL, total_duration_s=args.total_duration_s, @@ -845,14 +832,16 @@ def site_env_factory(_obs_config=obs_config, _sid=sid, _lib=scenario_lib): _train_and_save(site_env_factory, sid, f"ppo_model_{sid}") - # For single-site, also save without site suffix for backwards compat + # Single-site runs alias the per-site output to the canonical ppo_model.zip + # path that the docs + evaluate.py default to. if len(site_ids) == 1: import shutil - src = output_dir / f"ppo_model_{site_ids[0]}.zip" - dst = output_dir / "ppo_model.zip" - shutil.copy2(src, dst) - logger.info("Copied to %s", dst) + for suffix in (".zip", "_vecnormalize.pkl"): + src = output_dir / f"ppo_model_{site_ids[0]}{suffix}" + dst = output_dir / f"ppo_model{suffix}" + if src.exists(): + shutil.copy2(src, dst) logger.info("All done. Models saved to %s", output_dir) diff --git a/openg2g/controller/ofo.py b/openg2g/controller/ofo.py index 38304c7..721ff02 100644 --- a/openg2g/controller/ofo.py +++ b/openg2g/controller/ofo.py @@ -14,8 +14,8 @@ from typing import Any import numpy as np -from mlenergy_data.modeling import LogisticModel -from mlenergy_data.records import LLMRuns +from mlenergy.data.modeling import LogisticModel +from mlenergy.data.records import LLMRuns from pydantic import BaseModel, ConfigDict from openg2g.clock import SimulationClock @@ -39,7 +39,7 @@ def _warn_if_fit_suspicious( ) -> None: """Emit `logger.warning` when the fit has suspicious parameters or shape. - Fits are not rejected — the caller can inspect the emitted warnings + Fits are not rejected: the caller can inspect the emitted warnings and decide whether to exclude batches via `fit_exclude_batch_sizes` or drop the spec. """ diff --git a/openg2g/coordinator.py b/openg2g/coordinator.py index 160cef1..0e81f67 100644 --- a/openg2g/coordinator.py +++ b/openg2g/coordinator.py @@ -4,7 +4,7 @@ import logging import warnings -from collections.abc import Sequence +from collections.abc import Iterable, Iterator, Sequence from dataclasses import dataclass, field from fractions import Fraction from typing import Any, Generic @@ -62,6 +62,53 @@ def emit(self, event: SimEvent) -> None: self.events.append(event) +@dataclass(frozen=True) +class TickOutput(Generic[DCStateT, GridStateT]): + """Outputs produced by one base tick of [`Coordinator.step`][..Coordinator.step]. + + Empty-or-`None` fields mean the corresponding component did not step on + this tick. The base tick is the GCD of all component periods, so on any + given tick only the components whose `dt_s` is currently due will appear. + + Attributes: + t_s: Simulation time at the start of this tick (seconds). + dc_states: Datacenter states produced this tick, keyed by DC name. + Empty if no DC was due. + grid_state: Grid state produced this tick, or `None` if the grid was + not due. + commands: Commands emitted by controllers and dispatched during this + tick (in the order they fired). + sim_events: `SimEvent`s emitted by all components during this tick, + in chronological order. + """ + + t_s: float + dc_states: dict[str, DCStateT] + grid_state: GridStateT | None + commands: list[DatacenterCommand | GridCommand] + sim_events: list[SimEvent] + + +class _EventSink: + """Per-run sink that EventEmitter writes into. + + Drained at the end of each [`Coordinator.step`][..Coordinator.step] so + the resulting `TickOutput.sim_events` reflects only events emitted + during that single tick. + """ + + def __init__(self) -> None: + self._events: list[SimEvent] = [] + + def emit(self, event: SimEvent) -> None: + self._events.append(event) + + def drain(self) -> list[SimEvent]: + out = self._events + self._events = [] + return out + + def _gcd_fraction(a: Fraction, b: Fraction) -> Fraction: """GCD of two positive Fractions using Euclidean algorithm.""" a, b = abs(a), abs(b) @@ -140,6 +187,13 @@ def __init__( self._validate_controller_compatibility() + # Per-run event scaffolding. Initialised by start(), torn down by stop(). + self._sink: _EventSink | None = None + self._dc_events: EventEmitter | None = None + self._grid_events: EventEmitter | None = None + self._controller_events: EventEmitter | None = None + self._dc_buffers: dict[DatacenterBackend[DCStateT], list[ThreePhase]] = {} + def reset(self) -> None: """Reset coordinator and all sub-components for a fresh run.""" self.clock.reset() @@ -150,12 +204,21 @@ def reset(self) -> None: ctrl.reset() def start(self) -> None: - """Acquire resources on all sub-components.""" + """Acquire resources on all sub-components and prepare event routing. + + Must be called before [`step`][..Coordinator.step] or + [`dispatch_commands`][..Coordinator.dispatch_commands]. + """ for dc in self._datacenters: dc.start() self.grid.start() for ctrl in self.controllers: ctrl.start() + self._sink = _EventSink() + self._dc_events = EventEmitter(self.clock, self._sink, "datacenter") + self._grid_events = EventEmitter(self.clock, self._sink, "grid") + self._controller_events = EventEmitter(self.clock, self._sink, "controller") + self._dc_buffers = {dc: [] for dc in self._datacenters} def stop(self) -> None: """Release resources on all sub-components (LIFO order).""" @@ -164,6 +227,11 @@ def stop(self) -> None: self.grid.stop() for dc in self._datacenters: dc.stop() + self._sink = None + self._dc_events = None + self._grid_events = None + self._controller_events = None + self._dc_buffers = {} def _validate_controller_compatibility(self) -> None: for ctrl in self.controllers: @@ -192,19 +260,101 @@ def _validate_controller_compatibility(self) -> None: got = type(self.grid).__name__ raise TypeError(f"{ctrl.__class__.__name__} ({sig}) requires grid type {expected}, got {got}.") - def run(self) -> SimulationLog[DCStateT, GridStateT]: - """Run the full simulation and return the log.""" - log: SimulationLog[DCStateT, GridStateT] = SimulationLog() - dc_events = EventEmitter(self.clock, log, "datacenter") - grid_events = EventEmitter(self.clock, log, "grid") - controller_events = EventEmitter(self.clock, log, "controller") + def step(self) -> TickOutput[DCStateT, GridStateT]: + """Advance the simulation by one base tick and return its outputs. + + The caller is responsible for the surrounding lifecycle: invoke + [`reset`][..Coordinator.reset] and [`start`][..Coordinator.start] + before the first call, and [`stop`][..Coordinator.stop] after the + last. Iterating with `run_iter()` (`for tick in coord.run_iter()`) handles this + automatically; [`run`][..Coordinator.run] is a thin wrapper that + accumulates every `TickOutput` into a `SimulationLog`. + + Each tick: + + 1. Step every datacenter whose `dt_s` is due. + 2. Step the grid if its `dt_s` is due, passing the per-DC power + sub-trace accumulated since the previous grid step. + 3. Step every controller whose `dt_s` is due, in registration order, + and dispatch its commands immediately via + [`dispatch_commands`][..Coordinator.dispatch_commands]. + 4. Drain `SimEvent`s emitted during the tick into the returned + `TickOutput`, then advance the clock. + """ + sink = self._sink + dc_events = self._dc_events + grid_events = self._grid_events + controller_events = self._controller_events + if sink is None or dc_events is None or grid_events is None or controller_events is None: + raise RuntimeError("Coordinator must be started before step() can be called.") + + dc_states_this_tick: dict[str, DCStateT] = {} + grid_state_this_tick: GridStateT | None = None + commands_this_tick: list[DatacenterCommand | GridCommand] = [] - self.reset() - self.start() + for dc in self._datacenters: + if self.clock.is_due(dc.dt_s): + dc_state = dc.do_step(self.clock, dc_events) + self._dc_buffers[dc].append(dc_state.power_w) + dc_states_this_tick[dc.name] = dc_state + + if self.clock.is_due(self.grid.dt_s): + power_arg = {dc: list(buf) for dc, buf in self._dc_buffers.items()} + grid_state_this_tick = self.grid.do_step(self.clock, power_arg, grid_events) + for buf in self._dc_buffers.values(): + buf.clear() - # Per-DC power buffers - dc_buffers: dict[DatacenterBackend, list[ThreePhase]] = {dc: [] for dc in self._datacenters} + for ctrl in self.controllers: + if self.clock.is_due(ctrl.dt_s): + cmds = ctrl.step(self.clock, controller_events) + self.dispatch_commands(cmds) + commands_this_tick.extend(cmds) + + t_s = float(self.clock.time_s) + sim_events = sink.drain() + self.clock.advance() + + return TickOutput( + t_s=t_s, + dc_states=dc_states_this_tick, + grid_state=grid_state_this_tick, + commands=commands_this_tick, + sim_events=sim_events, + ) + def dispatch_commands( + self, + commands: Iterable[DatacenterCommand | GridCommand], + ) -> None: + """Route commands to the grid or to their target datacenter. + + Uses the same dispatch rules as the in-loop controller block: a + `DatacenterCommand` is delivered to `command.target` (which must be + set), and a `GridCommand` goes to the coordinator's grid. External + drivers (e.g. an RL training environment) call this between + [`step`][..Coordinator.step] invocations to inject control actions. + """ + if self._dc_events is None or self._grid_events is None: + raise RuntimeError("Coordinator must be started before dispatch_commands() can be called.") + for command in commands: + if isinstance(command, DatacenterCommand): + command.target.apply_control(command, self._dc_events) + elif isinstance(command, GridCommand): + self.grid.apply_control(command, self._grid_events) + else: + raise ValueError(f"Unsupported command type: {type(command).__name__}") + + def run_iter(self) -> Iterator[TickOutput[DCStateT, GridStateT]]: + """Yield one `TickOutput` per base tick until `total_duration_s`. + + Manages the [`reset`][..Coordinator.reset] / + [`start`][..Coordinator.start] / [`stop`][..Coordinator.stop] + lifecycle automatically, including on early termination (e.g. when + the consumer breaks out of the loop). For fully external drivers + that need per-tick control (e.g. an RL Gym env, an interactive + viewer), use [`step`][..Coordinator.step] directly and manage the + lifecycle by hand. + """ ratio = Fraction(self.total_duration_s) / self.clock.tick_s if ratio.denominator != 1: raise ValueError( @@ -212,54 +362,33 @@ def run(self) -> SimulationLog[DCStateT, GridStateT]: ) n_ticks = int(ratio) + self.reset() + self.start() + try: + for _ in range(n_ticks): + yield self.step() + finally: + self.stop() + + def run(self) -> SimulationLog[DCStateT, GridStateT]: + """Run the full simulation end-to-end and return the accumulated log.""" + log: SimulationLog[DCStateT, GridStateT] = SimulationLog() logger.info( - "Starting simulation: %d s, tick=%s s, %d ticks, %d DC site(s), dt_grid=%s s, %d controller(s)", + "Starting simulation: %d s, tick=%s s, %d DC site(s), dt_grid=%s s, %d controller(s)", self.total_duration_s, self.clock.tick_s, - n_ticks, len(self._datacenters), self.grid.dt_s, len(self.controllers), ) - try: - for _ in range(n_ticks): - # 1. Datacenter steps (if due) - for dc in self._datacenters: - if self.clock.is_due(dc.dt_s): - dc_state = dc.do_step(self.clock, dc_events) - dc_buffers[dc].append(dc_state.power_w) - log.record_datacenter(dc_state, dc_name=dc.name) - - # 2. Grid step (if due). Pass full sub-trace since last grid step. - if self.clock.is_due(self.grid.dt_s): - power_arg = {dc: list(buf) for dc, buf in dc_buffers.items()} - grid_state = self.grid.do_step(self.clock, power_arg, grid_events) - for buf in dc_buffers.values(): - buf.clear() - log.record_grid(grid_state) - - # 3. Controllers (if due). In order, actions applied immediately. - for ctrl in self.controllers: - if self.clock.is_due(ctrl.dt_s): - commands = ctrl.step(self.clock, controller_events) - for command in commands: - if isinstance(command, DatacenterCommand): - if command.target is None: - raise ValueError( - f"{type(ctrl).__name__} returned {type(command).__name__} " - f"without target. Controllers must set command.target." - ) - command.target.apply_control(command, dc_events) - elif isinstance(command, GridCommand): - self.grid.apply_control(command, grid_events) - else: - raise ValueError(f"Unsupported command type: {type(command).__name__}") - log.record_commands(commands) - - self.clock.advance() - finally: - self.stop() + for tick in self.run_iter(): + for dc_name, dc_state in tick.dc_states.items(): + log.record_datacenter(dc_state, dc_name=dc_name) + if tick.grid_state is not None: + log.record_grid(tick.grid_state) + log.record_commands(tick.commands) + log.events.extend(tick.sim_events) logger.info( "Simulation complete: %d grid steps, %d DC steps, %d commands", diff --git a/openg2g/datacenter/config.py b/openg2g/datacenter/config.py index afb035f..eaefa0c 100644 --- a/openg2g/datacenter/config.py +++ b/openg2g/datacenter/config.py @@ -39,7 +39,7 @@ class InferenceModelSpec(BaseModel): task: Benchmark task name (e.g. `"lm-arena-chat"`, `"gpqa"`, `"sourcegraph-fim"`). precision: Weight precision (e.g. `"bfloat16"`, `"fp8"`, `"mxfp4"`). - Informational — the underlying precision is already encoded in + Informational: the underlying precision is already encoded in the `model_id` of the HuggingFace checkpoint. gpus_per_replica: GPUs allocated to each replica. tensor_parallel: Tensor-parallel degree. For metadata / cache diff --git a/openg2g/datacenter/workloads/inference.py b/openg2g/datacenter/workloads/inference.py index b51ee73..e090092 100644 --- a/openg2g/datacenter/workloads/inference.py +++ b/openg2g/datacenter/workloads/inference.py @@ -14,8 +14,8 @@ import numpy as np import pandas as pd -from mlenergy_data.modeling import ITLMixtureModel -from mlenergy_data.records import LLMRuns +from mlenergy.data.modeling import ITLMixtureModel +from mlenergy.data.records import LLMRuns from pydantic import BaseModel, ConfigDict import openg2g @@ -659,7 +659,7 @@ def ensure( """Load per-spec caches under `base_dir`, generating missing ones. Any spec whose `base_dir//_manifest.json` is missing triggers a - targeted regeneration (v3 lookup + fit) for just that spec — the + targeted regeneration (v3 lookup + fit) for just that spec: the already-cached specs are untouched. The manifest is the last file `save()` writes, so its presence is the completion marker; checking it avoids loading partially-written caches from an interrupted run. @@ -742,7 +742,7 @@ def _write_inference_manifest( """Write per-spec `_manifest.json` files under `base_dir//`. Each file records the full spec fields, openg2g/mlenergy-data - versions, the content hash, and the UTC timestamp — enough for any + versions, the content hash, and the UTC timestamp: enough for any reader to recover exactly which benchmark query produced this dir. """ now = datetime.datetime.now(datetime.timezone.utc).isoformat(timespec="seconds") diff --git a/pyproject.toml b/pyproject.toml index 1306013..07342b6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,7 +15,7 @@ dependencies = [ "pydantic>=2.0", "aiohttp", "zeus>=0.15.0", - "mlenergy-data", + "mlenergy[data]>=0.1.0", ] [project.urls] diff --git a/tests/test_logistic.py b/tests/test_logistic.py index f1251ab..f032cb3 100644 --- a/tests/test_logistic.py +++ b/tests/test_logistic.py @@ -4,7 +4,7 @@ import math -from mlenergy_data.modeling import LogisticModel +from mlenergy.data.modeling import LogisticModel def test_eval_at_midpoint(): diff --git a/tests/test_offline_dc.py b/tests/test_offline_dc.py index f1b1f4c..b8b5160 100644 --- a/tests/test_offline_dc.py +++ b/tests/test_offline_dc.py @@ -6,7 +6,7 @@ import numpy as np import pytest -from mlenergy_data.modeling import ITLMixtureModel +from mlenergy.data.modeling import ITLMixtureModel from openg2g.clock import SimulationClock from openg2g.coordinator import SimulationLog diff --git a/tests/test_ofo_internals.py b/tests/test_ofo_internals.py index 59045de..d24b773 100644 --- a/tests/test_ofo_internals.py +++ b/tests/test_ofo_internals.py @@ -9,7 +9,7 @@ import numpy as np import pytest -from mlenergy_data.modeling import LogisticModel +from mlenergy.data.modeling import LogisticModel from openg2g.controller.ofo import ( OFOConfig, diff --git a/tests/test_ofo_observed_latency.py b/tests/test_ofo_observed_latency.py index 2178dfa..783b4db 100644 --- a/tests/test_ofo_observed_latency.py +++ b/tests/test_ofo_observed_latency.py @@ -3,7 +3,7 @@ from fractions import Fraction import numpy as np -from mlenergy_data.modeling import LogisticModel +from mlenergy.data.modeling import LogisticModel from openg2g.clock import SimulationClock from openg2g.common import ThreePhase diff --git a/uv.lock b/uv.lock index f63a01f..e48ec34 100644 --- a/uv.lock +++ b/uv.lock @@ -11,6 +11,15 @@ resolution-markers = [ "python_full_version < '3.11'", ] +[[package]] +name = "absl-py" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/64/c7/8de93764ad66968d19329a7e0c147a2bb3c7054c554d4a119111b8f9440f/absl_py-2.4.0.tar.gz", hash = "sha256:8c6af82722b35cf71e0f4d1d47dcaebfff286e27110a99fc359349b247dfb5d4", size = 116543, upload-time = "2026-01-28T10:17:05.322Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/a6/907a406bb7d359e6a63f99c313846d9eec4f7e6f7437809e03aa00fa3074/absl_py-2.4.0-py3-none-any.whl", hash = "sha256:88476fd881ca8aab94ffa78b7b6c632a782ab3ba1cd19c9bd423abc4fb4cd28d", size = 135750, upload-time = "2026-01-28T10:17:04.19Z" }, +] + [[package]] name = "aiohappyeyeballs" version = "2.6.1" @@ -1019,6 +1028,67 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/11/8c/c9138d881c79aa0ea9ed83cbd58d5ca75624378b38cee225dcf5c42cc91f/griffelib-2.0.2-py3-none-any.whl", hash = "sha256:925c857658fb1ba40c0772c37acbc2ab650bd794d9c1b9726922e36ea4117ea1", size = 142357, upload-time = "2026-03-27T11:34:46.275Z" }, ] +[[package]] +name = "grpcio" +version = "1.80.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b7/48/af6173dbca4454f4637a4678b67f52ca7e0c1ed7d5894d89d434fecede05/grpcio-1.80.0.tar.gz", hash = "sha256:29aca15edd0688c22ba01d7cc01cb000d72b2033f4a3c72a81a19b56fd143257", size = 12978905, upload-time = "2026-03-30T08:49:10.502Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/cd/bb7b7e54084a344c03d68144450da7ddd5564e51a298ae1662de65f48e2d/grpcio-1.80.0-cp310-cp310-linux_armv7l.whl", hash = "sha256:886457a7768e408cdce226ad1ca67d2958917d306523a0e21e1a2fdaa75c9c9c", size = 6050363, upload-time = "2026-03-30T08:46:20.894Z" }, + { url = "https://files.pythonhosted.org/packages/16/02/1417f5c3460dea65f7a2e3c14e8b31e77f7ffb730e9bfadd89eda7a9f477/grpcio-1.80.0-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:7b641fc3f1dc647bfd80bd713addc68f6d145956f64677e56d9ebafc0bd72388", size = 12026037, upload-time = "2026-03-30T08:46:25.144Z" }, + { url = "https://files.pythonhosted.org/packages/43/98/c910254eedf2cae368d78336a2de0678e66a7317d27c02522392f949b5c6/grpcio-1.80.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:33eb763f18f006dc7fee1e69831d38d23f5eccd15b2e0f92a13ee1d9242e5e02", size = 6602306, upload-time = "2026-03-30T08:46:27.593Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f8/88ca4e78c077b2b2113d95da1e1ab43efd43d723c9a0397d26529c2c1a56/grpcio-1.80.0-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:52d143637e3872633fc7dd7c3c6a1c84e396b359f3a72e215f8bf69fd82084fc", size = 7301535, upload-time = "2026-03-30T08:46:29.556Z" }, + { url = "https://files.pythonhosted.org/packages/f9/96/f28660fe2fe0f153288bf4a04e4910b7309d442395135c88ed4f5b3b8b40/grpcio-1.80.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c51bf8ac4575af2e0678bccfb07e47321fc7acb5049b4482832c5c195e04e13a", size = 6808669, upload-time = "2026-03-30T08:46:31.984Z" }, + { url = "https://files.pythonhosted.org/packages/47/eb/3f68a5e955779c00aeef23850e019c1c1d0e032d90633ba49c01ad5a96e0/grpcio-1.80.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:50a9871536d71c4fba24ee856abc03a87764570f0c457dd8db0b4018f379fed9", size = 7409489, upload-time = "2026-03-30T08:46:34.684Z" }, + { url = "https://files.pythonhosted.org/packages/5b/a7/d2f681a4bfb881be40659a309771f3bdfbfdb1190619442816c3f0ffc079/grpcio-1.80.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:a72d84ad0514db063e21887fbacd1fd7acb4d494a564cae22227cd45c7fbf199", size = 8423167, upload-time = "2026-03-30T08:46:36.833Z" }, + { url = "https://files.pythonhosted.org/packages/97/8a/29b4589c204959aa35ce5708400a05bba72181807c45c47b3ec000c39333/grpcio-1.80.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f7691a6788ad9196872f95716df5bc643ebba13c97140b7a5ee5c8e75d1dea81", size = 7846761, upload-time = "2026-03-30T08:46:40.091Z" }, + { url = "https://files.pythonhosted.org/packages/6b/d2/ed143e097230ee121ac5848f6ff14372dba91289b10b536d54fb1b7cbae7/grpcio-1.80.0-cp310-cp310-win32.whl", hash = "sha256:46c2390b59d67f84e882694d489f5b45707c657832d7934859ceb8c33f467069", size = 4156534, upload-time = "2026-03-30T08:46:42.026Z" }, + { url = "https://files.pythonhosted.org/packages/d5/c9/df8279bb49b29409995e95efa85b72973d62f8aeff89abee58c91f393710/grpcio-1.80.0-cp310-cp310-win_amd64.whl", hash = "sha256:dc053420fc75749c961e2a4c906398d7c15725d36ccc04ae6d16093167223b58", size = 4889869, upload-time = "2026-03-30T08:46:44.219Z" }, + { url = "https://files.pythonhosted.org/packages/5d/db/1d56e5f5823257b291962d6c0ce106146c6447f405b60b234c4f222a7cde/grpcio-1.80.0-cp311-cp311-linux_armv7l.whl", hash = "sha256:dfab85db094068ff42e2a3563f60ab3dddcc9d6488a35abf0132daec13209c8a", size = 6055009, upload-time = "2026-03-30T08:46:46.265Z" }, + { url = "https://files.pythonhosted.org/packages/6e/18/c83f3cad64c5ca63bca7e91e5e46b0d026afc5af9d0a9972472ceba294b3/grpcio-1.80.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:5c07e82e822e1161354e32da2662f741a4944ea955f9f580ec8fb409dd6f6060", size = 12035295, upload-time = "2026-03-30T08:46:49.099Z" }, + { url = "https://files.pythonhosted.org/packages/0f/8e/e14966b435be2dda99fbe89db9525ea436edc79780431a1c2875a3582644/grpcio-1.80.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ba0915d51fd4ced2db5ff719f84e270afe0e2d4c45a7bdb1e8d036e4502928c2", size = 6610297, upload-time = "2026-03-30T08:46:52.123Z" }, + { url = "https://files.pythonhosted.org/packages/cc/26/d5eb38f42ce0e3fdc8174ea4d52036ef8d58cc4426cb800f2610f625dd75/grpcio-1.80.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:3cb8130ba457d2aa09fa6b7c3ed6b6e4e6a2685fce63cb803d479576c4d80e21", size = 7300208, upload-time = "2026-03-30T08:46:54.859Z" }, + { url = "https://files.pythonhosted.org/packages/25/51/bd267c989f85a17a5b3eea65a6feb4ff672af41ca614e5a0279cc0ea381c/grpcio-1.80.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:09e5e478b3d14afd23f12e49e8b44c8684ac3c5f08561c43a5b9691c54d136ab", size = 6813442, upload-time = "2026-03-30T08:46:57.056Z" }, + { url = "https://files.pythonhosted.org/packages/9e/d9/d80eef735b19e9169e30164bbf889b46f9df9127598a83d174eb13a48b26/grpcio-1.80.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:00168469238b022500e486c1c33916acf2f2a9b2c022202cf8a1885d2e3073c1", size = 7414743, upload-time = "2026-03-30T08:46:59.682Z" }, + { url = "https://files.pythonhosted.org/packages/de/f2/567f5bd5054398ed6b0509b9a30900376dcf2786bd936812098808b49d8d/grpcio-1.80.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8502122a3cc1714038e39a0b071acb1207ca7844208d5ea0d091317555ee7106", size = 8426046, upload-time = "2026-03-30T08:47:02.474Z" }, + { url = "https://files.pythonhosted.org/packages/62/29/73ef0141b4732ff5eacd68430ff2512a65c004696997f70476a83e548e7e/grpcio-1.80.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ce1794f4ea6cc3ca29463f42d665c32ba1b964b48958a66497917fe9069f26e6", size = 7851641, upload-time = "2026-03-30T08:47:05.462Z" }, + { url = "https://files.pythonhosted.org/packages/46/69/abbfa360eb229a8623bab5f5a4f8105e445bd38ce81a89514ba55d281ad0/grpcio-1.80.0-cp311-cp311-win32.whl", hash = "sha256:51b4a7189b0bef2aa30adce3c78f09c83526cf3dddb24c6a96555e3b97340440", size = 4154368, upload-time = "2026-03-30T08:47:08.027Z" }, + { url = "https://files.pythonhosted.org/packages/6f/d4/ae92206d01183b08613e846076115f5ac5991bae358d2a749fa864da5699/grpcio-1.80.0-cp311-cp311-win_amd64.whl", hash = "sha256:02e64bb0bb2da14d947a49e6f120a75e947250aebe65f9629b62bb1f5c14e6e9", size = 4894235, upload-time = "2026-03-30T08:47:10.839Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e8/a2b749265eb3415abc94f2e619bbd9e9707bebdda787e61c593004ec927a/grpcio-1.80.0-cp312-cp312-linux_armv7l.whl", hash = "sha256:c624cc9f1008361014378c9d776de7182b11fe8b2e5a81bc69f23a295f2a1ad0", size = 6015616, upload-time = "2026-03-30T08:47:13.428Z" }, + { url = "https://files.pythonhosted.org/packages/3e/97/b1282161a15d699d1e90c360df18d19165a045ce1c343c7f313f5e8a0b77/grpcio-1.80.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:f49eddcac43c3bf350c0385366a58f36bed8cc2c0ec35ef7b74b49e56552c0c2", size = 12014204, upload-time = "2026-03-30T08:47:15.873Z" }, + { url = "https://files.pythonhosted.org/packages/6e/5e/d319c6e997b50c155ac5a8cb12f5173d5b42677510e886d250d50264949d/grpcio-1.80.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d334591df610ab94714048e0d5b4f3dd5ad1bee74dfec11eee344220077a79de", size = 6563866, upload-time = "2026-03-30T08:47:18.588Z" }, + { url = "https://files.pythonhosted.org/packages/ae/f6/fdd975a2cb4d78eb67769a7b3b3830970bfa2e919f1decf724ae4445f42c/grpcio-1.80.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:0cb517eb1d0d0aaf1d87af7cc5b801d686557c1d88b2619f5e31fab3c2315921", size = 7273060, upload-time = "2026-03-30T08:47:21.113Z" }, + { url = "https://files.pythonhosted.org/packages/db/f0/a3deb5feba60d9538a962913e37bd2e69a195f1c3376a3dd44fe0427e996/grpcio-1.80.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4e78c4ac0d97dc2e569b2f4bcbbb447491167cb358d1a389fc4af71ab6f70411", size = 6782121, upload-time = "2026-03-30T08:47:23.827Z" }, + { url = "https://files.pythonhosted.org/packages/ca/84/36c6dcfddc093e108141f757c407902a05085e0c328007cb090d56646cdf/grpcio-1.80.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2ed770b4c06984f3b47eb0517b1c69ad0b84ef3f40128f51448433be904634cd", size = 7383811, upload-time = "2026-03-30T08:47:26.517Z" }, + { url = "https://files.pythonhosted.org/packages/7c/ef/f3a77e3dc5b471a0ec86c564c98d6adfa3510d38f8ee99010410858d591e/grpcio-1.80.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:256507e2f524092f1473071a05e65a5b10d84b82e3ff24c5b571513cfaa61e2f", size = 8393860, upload-time = "2026-03-30T08:47:29.439Z" }, + { url = "https://files.pythonhosted.org/packages/9b/8d/9d4d27ed7f33d109c50d6b5ce578a9914aa68edab75d65869a17e630a8d1/grpcio-1.80.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:9a6284a5d907c37db53350645567c522be314bac859a64a7a5ca63b77bb7958f", size = 7830132, upload-time = "2026-03-30T08:47:33.254Z" }, + { url = "https://files.pythonhosted.org/packages/14/e4/9990b41c6d7a44e1e9dee8ac11d7a9802ba1378b40d77468a7761d1ad288/grpcio-1.80.0-cp312-cp312-win32.whl", hash = "sha256:c71309cfce2f22be26aa4a847357c502db6c621f1a49825ae98aa0907595b193", size = 4140904, upload-time = "2026-03-30T08:47:35.319Z" }, + { url = "https://files.pythonhosted.org/packages/2f/2c/296f6138caca1f4b92a31ace4ae1b87dab692fc16a7a3417af3bb3c805bf/grpcio-1.80.0-cp312-cp312-win_amd64.whl", hash = "sha256:9fe648599c0e37594c4809d81a9e77bd138cc82eb8baa71b6a86af65426723ff", size = 4880944, upload-time = "2026-03-30T08:47:37.831Z" }, + { url = "https://files.pythonhosted.org/packages/2f/3a/7c3c25789e3f069e581dc342e03613c5b1cb012c4e8c7d9d5cf960a75856/grpcio-1.80.0-cp313-cp313-linux_armv7l.whl", hash = "sha256:e9e408fc016dffd20661f0126c53d8a31c2821b5c13c5d67a0f5ed5de93319ad", size = 6017243, upload-time = "2026-03-30T08:47:40.075Z" }, + { url = "https://files.pythonhosted.org/packages/04/19/21a9806eb8240e174fd1ab0cd5b9aa948bb0e05c2f2f55f9d5d7405e6d08/grpcio-1.80.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:92d787312e613754d4d8b9ca6d3297e69994a7912a32fa38c4c4e01c272974b0", size = 12010840, upload-time = "2026-03-30T08:47:43.11Z" }, + { url = "https://files.pythonhosted.org/packages/18/3a/23347d35f76f639e807fb7a36fad3068aed100996849a33809591f26eca6/grpcio-1.80.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8ac393b58aa16991a2f1144ec578084d544038c12242da3a215966b512904d0f", size = 6567644, upload-time = "2026-03-30T08:47:46.806Z" }, + { url = "https://files.pythonhosted.org/packages/ff/40/96e07ecb604a6a67ae6ab151e3e35b132875d98bc68ec65f3e5ab3e781d7/grpcio-1.80.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:68e5851ac4b9afe07e7f84483803ad167852570d65326b34d54ca560bfa53fb6", size = 7277830, upload-time = "2026-03-30T08:47:49.643Z" }, + { url = "https://files.pythonhosted.org/packages/9b/e2/da1506ecea1f34a5e365964644b35edef53803052b763ca214ba3870c856/grpcio-1.80.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:873ff5d17d68992ef6605330127425d2fc4e77e612fa3c3e0ed4e668685e3140", size = 6783216, upload-time = "2026-03-30T08:47:52.817Z" }, + { url = "https://files.pythonhosted.org/packages/44/83/3b20ff58d0c3b7f6caaa3af9a4174d4023701df40a3f39f7f1c8e7c48f9d/grpcio-1.80.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2bea16af2750fd0a899bf1abd9022244418b55d1f37da2202249ba4ba673838d", size = 7385866, upload-time = "2026-03-30T08:47:55.687Z" }, + { url = "https://files.pythonhosted.org/packages/47/45/55c507599c5520416de5eefecc927d6a0d7af55e91cfffb2e410607e5744/grpcio-1.80.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ba0db34f7e1d803a878284cd70e4c63cb6ae2510ba51937bf8f45ba997cefcf7", size = 8391602, upload-time = "2026-03-30T08:47:58.303Z" }, + { url = "https://files.pythonhosted.org/packages/10/bb/dd06f4c24c01db9cf11341b547d0a016b2c90ed7dbbb086a5710df7dd1d7/grpcio-1.80.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8eb613f02d34721f1acf3626dfdb3545bd3c8505b0e52bf8b5710a28d02e8aa7", size = 7826752, upload-time = "2026-03-30T08:48:01.311Z" }, + { url = "https://files.pythonhosted.org/packages/f9/1e/9d67992ba23371fd63d4527096eb8c6b76d74d52b500df992a3343fd7251/grpcio-1.80.0-cp313-cp313-win32.whl", hash = "sha256:93b6f823810720912fd131f561f91f5fed0fda372b6b7028a2681b8194d5d294", size = 4142310, upload-time = "2026-03-30T08:48:04.594Z" }, + { url = "https://files.pythonhosted.org/packages/cf/e6/283326a27da9e2c3038bc93eeea36fb118ce0b2d03922a9cda6688f53c5b/grpcio-1.80.0-cp313-cp313-win_amd64.whl", hash = "sha256:e172cf795a3ba5246d3529e4d34c53db70e888fa582a8ffebd2e6e48bc0cba50", size = 4882833, upload-time = "2026-03-30T08:48:07.363Z" }, + { url = "https://files.pythonhosted.org/packages/c5/6d/e65307ce20f5a09244ba9e9d8476e99fb039de7154f37fb85f26978b59c3/grpcio-1.80.0-cp314-cp314-linux_armv7l.whl", hash = "sha256:3d4147a97c8344d065d01bbf8b6acec2cf86fb0400d40696c8bdad34a64ffc0e", size = 6017376, upload-time = "2026-03-30T08:48:10.005Z" }, + { url = "https://files.pythonhosted.org/packages/69/10/9cef5d9650c72625a699c549940f0abb3c4bfdb5ed45a5ce431f92f31806/grpcio-1.80.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:d8e11f167935b3eb089ac9038e1a063e6d7dbe995c0bb4a661e614583352e76f", size = 12018133, upload-time = "2026-03-30T08:48:12.927Z" }, + { url = "https://files.pythonhosted.org/packages/04/82/983aabaad82ba26113caceeb9091706a0696b25da004fe3defb5b346e15b/grpcio-1.80.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f14b618fc30de822681ee986cfdcc2d9327229dc4c98aed16896761cacd468b9", size = 6574748, upload-time = "2026-03-30T08:48:16.386Z" }, + { url = "https://files.pythonhosted.org/packages/07/d7/031666ef155aa0bf399ed7e19439656c38bbd143779ae0861b038ce82abd/grpcio-1.80.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:4ed39fbdcf9b87370f6e8df4e39ca7b38b3e5e9d1b0013c7b6be9639d6578d14", size = 7277711, upload-time = "2026-03-30T08:48:19.627Z" }, + { url = "https://files.pythonhosted.org/packages/e8/43/f437a78f7f4f1d311804189e8f11fb311a01049b2e08557c1068d470cb2e/grpcio-1.80.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2dcc70e9f0ba987526e8e8603a610fb4f460e42899e74e7a518bf3c68fe1bf05", size = 6785372, upload-time = "2026-03-30T08:48:22.373Z" }, + { url = "https://files.pythonhosted.org/packages/93/3d/f6558e9c6296cb4227faa5c43c54a34c68d32654b829f53288313d16a86e/grpcio-1.80.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:448c884b668b868562b1bda833c5fce6272d26e1926ec46747cda05741d302c1", size = 7395268, upload-time = "2026-03-30T08:48:25.638Z" }, + { url = "https://files.pythonhosted.org/packages/06/21/0fdd77e84720b08843c371a2efa6f2e19dbebf56adc72df73d891f5506f0/grpcio-1.80.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a1dc80fe55685b4a543555e6eef975303b36c8db1023b1599b094b92aa77965f", size = 8392000, upload-time = "2026-03-30T08:48:28.974Z" }, + { url = "https://files.pythonhosted.org/packages/f5/68/67f4947ed55d2e69f2cc199ab9fd85e0a0034d813bbeef84df6d2ba4d4b7/grpcio-1.80.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:31b9ac4ad1aa28ffee5503821fafd09e4da0a261ce1c1281c6c8da0423c83b6e", size = 7828477, upload-time = "2026-03-30T08:48:32.054Z" }, + { url = "https://files.pythonhosted.org/packages/44/b6/8d4096691b2e385e8271911a0de4f35f0a6c7d05aff7098e296c3de86939/grpcio-1.80.0-cp314-cp314-win32.whl", hash = "sha256:367ce30ba67d05e0592470428f0ec1c31714cab9ef19b8f2e37be1f4c7d32fae", size = 4218563, upload-time = "2026-03-30T08:48:34.538Z" }, + { url = "https://files.pythonhosted.org/packages/e5/8c/bbe6baf2557262834f2070cf668515fa308b2d38a4bbf771f8f7872a7036/grpcio-1.80.0-cp314-cp314-win_amd64.whl", hash = "sha256:3b01e1f5464c583d2f567b2e46ff0d516ef979978f72091fd81f5ab7fa6e2e7f", size = 5019457, upload-time = "2026-03-30T08:48:37.308Z" }, +] + [[package]] name = "gymnasium" version = "1.2.3" @@ -1046,34 +1116,34 @@ wheels = [ [[package]] name = "hf-xet" -version = "1.4.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/53/92/ec9ad04d0b5728dca387a45af7bc98fbb0d73b2118759f5f6038b61a57e8/hf_xet-1.4.3.tar.gz", hash = "sha256:8ddedb73c8c08928c793df2f3401ec26f95be7f7e516a7bee2fbb546f6676113", size = 670477, upload-time = "2026-03-31T22:40:07.874Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/72/43/724d307b34e353da0abd476e02f72f735cdd2bc86082dee1b32ea0bfee1d/hf_xet-1.4.3-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:7551659ba4f1e1074e9623996f28c3873682530aee0a846b7f2f066239228144", size = 3800935, upload-time = "2026-03-31T22:39:49.618Z" }, - { url = "https://files.pythonhosted.org/packages/2b/d2/8bee5996b699262edb87dbb54118d287c0e1b2fc78af7cdc41857ba5e3c4/hf_xet-1.4.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:bee693ada985e7045997f05f081d0e12c4c08bd7626dc397f8a7c487e6c04f7f", size = 3558942, upload-time = "2026-03-31T22:39:47.938Z" }, - { url = "https://files.pythonhosted.org/packages/c3/a1/e993d09cbe251196fb60812b09a58901c468127b7259d2bf0f68bf6088eb/hf_xet-1.4.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:21644b404bb0100fe3857892f752c4d09642586fd988e61501c95bbf44b393a3", size = 4207657, upload-time = "2026-03-31T22:39:39.69Z" }, - { url = "https://files.pythonhosted.org/packages/64/44/9eb6d21e5c34c63e5e399803a6932fa983cabdf47c0ecbcfe7ea97684b8c/hf_xet-1.4.3-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:987f09cfe418237812896a6736b81b1af02a3a6dcb4b4944425c4c4fca7a7cf8", size = 3986765, upload-time = "2026-03-31T22:39:37.936Z" }, - { url = "https://files.pythonhosted.org/packages/ea/7b/8ad6f16fdb82f5f7284a34b5ec48645bd575bdcd2f6f0d1644775909c486/hf_xet-1.4.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:60cf7fc43a99da0a853345cf86d23738c03983ee5249613a6305d3e57a5dca74", size = 4188162, upload-time = "2026-03-31T22:39:58.382Z" }, - { url = "https://files.pythonhosted.org/packages/1b/c4/39d6e136cbeea9ca5a23aad4b33024319222adbdc059ebcda5fc7d9d5ff4/hf_xet-1.4.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:2815a49a7a59f3e2edf0cf113ae88e8cb2ca2a221bf353fb60c609584f4884d4", size = 4424525, upload-time = "2026-03-31T22:40:00.225Z" }, - { url = "https://files.pythonhosted.org/packages/46/f2/adc32dae6bdbc367853118b9878139ac869419a4ae7ba07185dc31251b76/hf_xet-1.4.3-cp313-cp313t-win_amd64.whl", hash = "sha256:42ee323265f1e6a81b0e11094564fb7f7e0ec75b5105ffd91ae63f403a11931b", size = 3671610, upload-time = "2026-03-31T22:40:10.42Z" }, - { url = "https://files.pythonhosted.org/packages/e2/19/25d897dcc3f81953e0c2cde9ec186c7a0fee413eb0c9a7a9130d87d94d3a/hf_xet-1.4.3-cp313-cp313t-win_arm64.whl", hash = "sha256:27c976ba60079fb8217f485b9c5c7fcd21c90b0367753805f87cb9f3cdc4418a", size = 3528529, upload-time = "2026-03-31T22:40:09.106Z" }, - { url = "https://files.pythonhosted.org/packages/ec/36/3e8f85ca9fe09b8de2b2e10c63b3b3353d7dda88a0b3d426dffbe7b8313b/hf_xet-1.4.3-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:5251d5ece3a81815bae9abab41cf7ddb7bcb8f56411bce0827f4a3071c92fdc6", size = 3801019, upload-time = "2026-03-31T22:39:56.651Z" }, - { url = "https://files.pythonhosted.org/packages/b5/9c/defb6cb1de28bccb7bd8d95f6e60f72a3d3fa4cb3d0329c26fb9a488bfe7/hf_xet-1.4.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1feb0f3abeacee143367c326a128a2e2b60868ec12a36c225afb1d6c5a05e6d2", size = 3558746, upload-time = "2026-03-31T22:39:54.766Z" }, - { url = "https://files.pythonhosted.org/packages/c1/bd/8d001191893178ff8e826e46ad5299446e62b93cd164e17b0ffea08832ec/hf_xet-1.4.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8b301fc150290ca90b4fccd079829b84bb4786747584ae08b94b4577d82fb791", size = 4207692, upload-time = "2026-03-31T22:39:46.246Z" }, - { url = "https://files.pythonhosted.org/packages/ce/48/6790b402803250e9936435613d3a78b9aaeee7973439f0918848dde58309/hf_xet-1.4.3-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:d972fbe95ddc0d3c0fc49b31a8a69f47db35c1e3699bf316421705741aab6653", size = 3986281, upload-time = "2026-03-31T22:39:44.648Z" }, - { url = "https://files.pythonhosted.org/packages/51/56/ea62552fe53db652a9099eda600b032d75554d0e86c12a73824bfedef88b/hf_xet-1.4.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c5b48db1ee344a805a1b9bd2cda9b6b65fe77ed3787bd6e87ad5521141d317cd", size = 4187414, upload-time = "2026-03-31T22:40:04.951Z" }, - { url = "https://files.pythonhosted.org/packages/7d/f5/bc1456d4638061bea997e6d2db60a1a613d7b200e0755965ec312dc1ef79/hf_xet-1.4.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:22bdc1f5fb8b15bf2831440b91d1c9bbceeb7e10c81a12e8d75889996a5c9da8", size = 4424368, upload-time = "2026-03-31T22:40:06.347Z" }, - { url = "https://files.pythonhosted.org/packages/e4/76/ab597bae87e1f06d18d3ecb8ed7f0d3c9a37037fc32ce76233d369273c64/hf_xet-1.4.3-cp314-cp314t-win_amd64.whl", hash = "sha256:0392c79b7cf48418cd61478c1a925246cf10639f4cd9d94368d8ca1e8df9ea07", size = 3672280, upload-time = "2026-03-31T22:40:16.401Z" }, - { url = "https://files.pythonhosted.org/packages/62/05/2e462d34e23a09a74d73785dbed71cc5dbad82a72eee2ad60a72a554155d/hf_xet-1.4.3-cp314-cp314t-win_arm64.whl", hash = "sha256:681c92a07796325778a79d76c67011764ecc9042a8c3579332b61b63ae512075", size = 3528945, upload-time = "2026-03-31T22:40:14.995Z" }, - { url = "https://files.pythonhosted.org/packages/ac/9f/9c23e4a447b8f83120798f9279d0297a4d1360bdbf59ef49ebec78fe2545/hf_xet-1.4.3-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:d0da85329eaf196e03e90b84c2d0aca53bd4573d097a75f99609e80775f98025", size = 3805048, upload-time = "2026-03-31T22:39:53.105Z" }, - { url = "https://files.pythonhosted.org/packages/0b/f8/7aacb8e5f4a7899d39c787b5984e912e6c18b11be136ef13947d7a66d265/hf_xet-1.4.3-cp37-abi3-macosx_11_0_arm64.whl", hash = "sha256:e23717ce4186b265f69afa66e6f0069fe7efbf331546f5c313d00e123dc84583", size = 3562178, upload-time = "2026-03-31T22:39:51.295Z" }, - { url = "https://files.pythonhosted.org/packages/df/9a/a24b26dc8a65f0ecc0fe5be981a19e61e7ca963b85e062c083f3a9100529/hf_xet-1.4.3-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc360b70c815bf340ed56c7b8c63aacf11762a4b099b2fe2c9bd6d6068668c08", size = 4212320, upload-time = "2026-03-31T22:39:42.922Z" }, - { url = "https://files.pythonhosted.org/packages/53/60/46d493db155d2ee2801b71fb1b0fd67696359047fdd8caee2c914cc50c79/hf_xet-1.4.3-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:39f2d2e9654cd9b4319885733993807aab6de9dfbd34c42f0b78338d6617421f", size = 3991546, upload-time = "2026-03-31T22:39:41.335Z" }, - { url = "https://files.pythonhosted.org/packages/bc/f5/067363e1c96c6b17256910830d1b54099d06287e10f4ec6ec4e7e08371fc/hf_xet-1.4.3-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:49ad8a8cead2b56051aa84d7fce3e1335efe68df3cf6c058f22a65513885baac", size = 4193200, upload-time = "2026-03-31T22:40:01.936Z" }, - { url = "https://files.pythonhosted.org/packages/42/4b/53951592882d9c23080c7644542fda34a3813104e9e11fa1a7d82d419cb8/hf_xet-1.4.3-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:7716d62015477a70ea272d2d68cd7cad140f61c52ee452e133e139abfe2c17ba", size = 4429392, upload-time = "2026-03-31T22:40:03.492Z" }, - { url = "https://files.pythonhosted.org/packages/8a/21/75a6c175b4e79662ad8e62f46a40ce341d8d6b206b06b4320d07d55b188c/hf_xet-1.4.3-cp37-abi3-win_amd64.whl", hash = "sha256:6b591fcad34e272a5b02607485e4f2a1334aebf1bc6d16ce8eb1eb8978ac2021", size = 3677359, upload-time = "2026-03-31T22:40:13.619Z" }, - { url = "https://files.pythonhosted.org/packages/8a/7c/44314ecd0e89f8b2b51c9d9e5e7a60a9c1c82024ac471d415860557d3cd8/hf_xet-1.4.3-cp37-abi3-win_arm64.whl", hash = "sha256:7c2c7e20bcfcc946dc67187c203463f5e932e395845d098cc2a93f5b67ca0b47", size = 3533664, upload-time = "2026-03-31T22:40:12.152Z" }, +version = "1.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/74/d8/5c06fc76461418326a7decf8367480c35be11a41fd938633929c60a9ec6b/hf_xet-1.5.0.tar.gz", hash = "sha256:e0fb0a34d9f406eed88233e829a67ec016bec5af19e480eac65a233ea289a948", size = 837196, upload-time = "2026-05-06T06:18:15.583Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/68/9b/6912c99070915a4f28119e3c5b52a9abd1eec0ad5cb293b8c967a0c6f5a2/hf_xet-1.5.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:7d70fe2ce97b9db73b9c9b9c81fe3693640aec83416a966c446afea54acfae3c", size = 4023383, upload-time = "2026-05-06T06:17:53.947Z" }, + { url = "https://files.pythonhosted.org/packages/0f/6d/9563cfde59b5d8128a9c7ec972a087f4c782e4f7bac5a85234edfd5d5e49/hf_xet-1.5.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:73a0dae8c71de3b0633a45c73f4a4a5ed09e94b43441d82981a781d4f12baa42", size = 3792751, upload-time = "2026-05-06T06:17:51.791Z" }, + { url = "https://files.pythonhosted.org/packages/07/a5/ed5a0cf35b49a0571af5a8f53416dad1877a718c021c9937c3a53cb45781/hf_xet-1.5.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a60290ec57e9b71767fba7c3645ddafdd0759974b540441510c629c6db6db24a", size = 4456058, upload-time = "2026-05-06T06:17:40.735Z" }, + { url = "https://files.pythonhosted.org/packages/60/fb/3ae8bf2a7a37a4197d0195d7247fd25b3952e15cb8a599e285dfaa6f52b3/hf_xet-1.5.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:e5de0f6deada0dada870bb376a11bcd1f08abf3a968a6d118f33e72d1b1eb480", size = 4250783, upload-time = "2026-05-06T06:17:38.412Z" }, + { url = "https://files.pythonhosted.org/packages/a2/9b/8bae40d4d91525085137196e84eb0ed49cf65b5e96e5c3ecdadd8bd0fac2/hf_xet-1.5.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c799d49f1a5544a0ef7591c0ee75e0d6b93d6f56dc7a4979f59f7518d2872216", size = 4445594, upload-time = "2026-05-06T06:18:04.219Z" }, + { url = "https://files.pythonhosted.org/packages/13/59/c74efbbd4e8728172b2cc72a2bc014d2947a4b7bdced932fbd3f5da1a4e5/hf_xet-1.5.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:2baea1b0b989e5c152fe81425f7745ddc8901280ba3d97c98d8cdece7b706c60", size = 4663995, upload-time = "2026-05-06T06:18:06.1Z" }, + { url = "https://files.pythonhosted.org/packages/73/32/8e1e0410af64cda9b139d1dcebdc993a8ff9c8c7c0e2696ae356d75ccc0d/hf_xet-1.5.0-cp313-cp313t-win_amd64.whl", hash = "sha256:526345b3ed45f374f6317349df489167606736c876241ba984105afe7fd4839d", size = 3966608, upload-time = "2026-05-06T06:18:19.74Z" }, + { url = "https://files.pythonhosted.org/packages/fc/34/a8febc8f4edbea8b3e21b02ebc8b628679b84ba7e45cde624a7736b51500/hf_xet-1.5.0-cp313-cp313t-win_arm64.whl", hash = "sha256:786d28e2eb8315d5035544b9d137b4a842d600c434bb91bf7d0d953cce906ad4", size = 3796946, upload-time = "2026-05-06T06:18:17.568Z" }, + { url = "https://files.pythonhosted.org/packages/2a/20/8fc8996afe5815fa1a6be8e9e5c02f24500f409d599e905800d498a4e14d/hf_xet-1.5.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:872d5601e6deea30d15865ede55d29eac6daf5a534ab417b99b6ef6b076dd96c", size = 4023495, upload-time = "2026-05-06T06:18:01.94Z" }, + { url = "https://files.pythonhosted.org/packages/32/6a/93d84463c00cecb561a7508aa6303e35ee2894294eac14245526924415fe/hf_xet-1.5.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:9929561f5abf4581c8ea79587881dfef6b8abb2a0d8a51915936fc2a614f4e73", size = 3792731, upload-time = "2026-05-06T06:18:00.021Z" }, + { url = "https://files.pythonhosted.org/packages/9d/5a/8ec8e0c863b382d00b3c2e2af6ded6b06371be617144a625903a6d562f4b/hf_xet-1.5.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f7b7bbae318e583a86fb21e5a4a175d6721d628a2874f4bd022d0e660c32a682", size = 4456738, upload-time = "2026-05-06T06:17:49.574Z" }, + { url = "https://files.pythonhosted.org/packages/c5/ca/f7effa1a67717da2bcc6b6c28f71c6ca648c77acaec4e2c32f40cbe16d85/hf_xet-1.5.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:cf7b2dc6f31a4ea754bb50f74cde482dcf5d366d184076d8530b9872787f3761", size = 4251622, upload-time = "2026-05-06T06:17:47.096Z" }, + { url = "https://files.pythonhosted.org/packages/65/f2/19247dba3e231cf77dec59ddfb878f00057635ff773d099c9b59d37812c3/hf_xet-1.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8dbcbab554c9ef158ef2c991545c3e970ddd8cc7acdcd0a78c5a41095dab4ded", size = 4445667, upload-time = "2026-05-06T06:18:11.983Z" }, + { url = "https://files.pythonhosted.org/packages/7f/64/6f116801a3bcfb6f59f5c251f48cadc47ea54026441c4a385079286a94fa/hf_xet-1.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5906bf7718d3636dc13402914736abe723492cb730f744834f5f5b67d3a12702", size = 4664619, upload-time = "2026-05-06T06:18:13.771Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e8/069542d37946ed08669b127e1496fa99e78196d71de8d41eda5e9f1b7a58/hf_xet-1.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:5f3dc2248fc01cc0a00cd392ab497f1ca373fcbc7e3f2da1f452480b384e839e", size = 3966802, upload-time = "2026-05-06T06:18:28.162Z" }, + { url = "https://files.pythonhosted.org/packages/f9/91/fc6fdec27b14d04e88c386ac0a0129732b53fa23f7c4a78f4b83a039c567/hf_xet-1.5.0-cp314-cp314t-win_arm64.whl", hash = "sha256:b285cea1b5bab46b758772716ba8d6854a1a0310fed1c249d678a8b38601e5a0", size = 3797168, upload-time = "2026-05-06T06:18:26.287Z" }, + { url = "https://files.pythonhosted.org/packages/3d/fb/69ff198a82cae7eb1a69fb84d93b3a3e4816564d76817fe541ddc96874eb/hf_xet-1.5.0-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:dad0dc84e941b8ba3c860659fe1fdc35c049d47cce293f003287757e971a8f56", size = 4030814, upload-time = "2026-05-06T06:17:57.933Z" }, + { url = "https://files.pythonhosted.org/packages/9b/ff/edcc2b40162bef3ff78e14ab637e5f3b89243d6aee72f5949d3bb6a5af83/hf_xet-1.5.0-cp37-abi3-macosx_11_0_arm64.whl", hash = "sha256:fd6e5a9b0fdac4ed03ed45ef79254a655b1aaab514a02202617fbf643f5fdf7a", size = 3798444, upload-time = "2026-05-06T06:17:55.79Z" }, + { url = "https://files.pythonhosted.org/packages/49/4d/103f76b04310e5e57656696cc184690d20c466af0bca3ca88f8c8ea5d4f3/hf_xet-1.5.0-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3531b1823a0e6d77d80f9ed15ca0e00f0d115094f8ac033d5cae88f4564cc949", size = 4465986, upload-time = "2026-05-06T06:17:44.886Z" }, + { url = "https://files.pythonhosted.org/packages/c4/a2/546f47f464737b3edbab6f8ddb57f2599b93d2cbb66f06abb475ccb48651/hf_xet-1.5.0-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:9a0ee58cd18d5ea799f7ed11290bbccbe56bdd8b1d97ca74b9cc49a3945d7a3b", size = 4259865, upload-time = "2026-05-06T06:17:42.639Z" }, + { url = "https://files.pythonhosted.org/packages/95/7f/1be593c1f28613be2e196473481cd81bfc5910795e30a34e8f744f6cac4f/hf_xet-1.5.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1e60df5a42e9bed8628b6416af2cba4cba57ae9f02de226a06b020d98e1aab18", size = 4459835, upload-time = "2026-05-06T06:18:08.026Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b2/703569fc881f3284487e68cda7b42179978480da3c438042a6bbbb4a671c/hf_xet-1.5.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:4b35549ce62601b84da4ff9b24d970032ace3d4430f52d91bcbb26c901d6c690", size = 4672414, upload-time = "2026-05-06T06:18:09.864Z" }, + { url = "https://files.pythonhosted.org/packages/af/37/1b6def445c567286b50aa3b33828158e135b1be44938dde59f11382a500c/hf_xet-1.5.0-cp37-abi3-win_amd64.whl", hash = "sha256:2806c7c17b4d23f8d88f7c4814f838c3b6150773fe339c20af23e1cfaf2797e4", size = 3977238, upload-time = "2026-05-06T06:18:23.621Z" }, + { url = "https://files.pythonhosted.org/packages/62/94/3b66b148778ee100dcfd69c2ca22b57b41b44d3063ceec934f209e9184ce/hf_xet-1.5.0-cp37-abi3-win_arm64.whl", hash = "sha256:b6c9df403040248c76d808d3e047d64db2d923bae593eb244c41e425cf6cd7be", size = 3806916, upload-time = "2026-05-06T06:18:21.7Z" }, ] [[package]] @@ -1106,7 +1176,7 @@ wheels = [ [[package]] name = "huggingface-hub" -version = "1.12.0" +version = "1.14.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "filelock" }, @@ -1119,9 +1189,9 @@ dependencies = [ { name = "typer" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/56/52/1b54cb569509c725a32c1315261ac9fd0e6b91bbbf74d86fca10d3376164/huggingface_hub-1.12.0.tar.gz", hash = "sha256:7c3fe85e24b652334e5d456d7a812cd9a071e75630fac4365d9165ab5e4a34b6", size = 763091, upload-time = "2026-04-24T13:32:08.674Z" } +sdist = { url = "https://files.pythonhosted.org/packages/39/40/43109e943fd718b0ccd0cd61eb4f1c347df22bf81f5874c6f22adf44bcff/huggingface_hub-1.14.0.tar.gz", hash = "sha256:d6d2c9cd6be1d02ae9ec6672d5587d10a427f377db688e82528f426a041622c2", size = 782365, upload-time = "2026-05-06T14:14:34.278Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7e/2b/ef03ddb96bd1123503c2bd6932001020292deea649e9bf4caa2cb65a85bf/huggingface_hub-1.12.0-py3-none-any.whl", hash = "sha256:d74939969585ee35748bd66de09baf84099d461bda7287cd9043bfb99b0e424d", size = 646806, upload-time = "2026-04-24T13:32:06.717Z" }, + { url = "https://files.pythonhosted.org/packages/89/a5/33b49ba7bea7c41bb37f74ec0f8beea0831e052330196633fe2c77516ea6/huggingface_hub-1.14.0-py3-none-any.whl", hash = "sha256:efe075535c62e130b30e836b138e13785f6f043d1f0539e0a39aa411a99e90b8", size = 661479, upload-time = "2026-05-06T14:14:32.029Z" }, ] [[package]] @@ -1401,14 +1471,14 @@ wheels = [ [[package]] name = "markdown-it-py" -version = "4.0.0" +version = "4.2.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "mdurl" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" } +sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, + { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, ] [[package]] @@ -1678,9 +1748,23 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/32/28/79f0f8de97cce916d5ae88a7bee1ad724855e83e6019c0b4d5b3fabc80f3/mkdocstrings_python-2.0.3-py3-none-any.whl", hash = "sha256:0b83513478bdfd803ff05aa43e9b1fca9dd22bcd9471f09ca6257f009bc5ee12", size = 104779, upload-time = "2026-02-20T10:38:34.517Z" }, ] +[[package]] +name = "mlenergy" +version = "0.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/93/702d897dbc13f659348b4434f5523f0c7f4cee0924504a3616a02db0cf3a/mlenergy-0.1.0.tar.gz", hash = "sha256:3ee1c29f0c27447bfb7b5da95d28b917329fc8852122354d79252a932448842e", size = 1964, upload-time = "2026-05-10T02:55:46.651Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/59/2f6d93759890ea9e4f12ae1bd699f3e8694b374ffaa8284a9ae9d28689eb/mlenergy-0.1.0-py3-none-any.whl", hash = "sha256:1924077fed8962588483077b774052889487ba347ae8329678df88969a3e47d9", size = 1827, upload-time = "2026-05-10T02:55:45.61Z" }, +] + +[package.optional-dependencies] +data = [ + { name = "mlenergy-data" }, +] + [[package]] name = "mlenergy-data" -version = "0.3.2" +version = "0.4.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "huggingface-hub" }, @@ -1691,9 +1775,9 @@ dependencies = [ { name = "pyarrow" }, { name = "pyyaml" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ac/55/131b85ce36e2c9ce91612aea93b5912a4730eeac5f5f157647b9040b9f4a/mlenergy_data-0.3.2.tar.gz", hash = "sha256:613f89c508c8a2c328962f015a3c09db8f8fd52f78356e606b4dc5ff75102365", size = 35503, upload-time = "2026-03-23T21:38:21.542Z" } +sdist = { url = "https://files.pythonhosted.org/packages/37/d6/1958e8445023dfdc748018af1bf349d4f8a3c09d1c61316accc8fdacc12d/mlenergy_data-0.4.0.tar.gz", hash = "sha256:94119d4884d19348af340ecb8d36e75270e37b72ea1d606e9976aa6a3293ce4c", size = 37477, upload-time = "2026-05-07T02:10:30.823Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/24/3e/52e2de434bb6603d4159f945267def38c7b6e5898d4b887226cd35bf7e60/mlenergy_data-0.3.2-py3-none-any.whl", hash = "sha256:56d71c9e6504b8a59eda3a282ab79f68db4b19a374b3b09872b70d3778c84ebe", size = 27503, upload-time = "2026-03-23T21:38:20.677Z" }, + { url = "https://files.pythonhosted.org/packages/da/d8/4396dcb42dbfc9018f4c397df9e3304c7cbbc62f67afd6f6fe956cc1f889/mlenergy_data-0.4.0-py3-none-any.whl", hash = "sha256:5c793a9b625091ffcd2c6a071458e3bdf76d2461913868d2d8a48bb45907a71f", size = 28378, upload-time = "2026-05-07T02:10:29.593Z" }, ] [[package]] @@ -2207,7 +2291,7 @@ wheels = [ [[package]] name = "openai" -version = "2.33.0" +version = "2.36.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -2219,9 +2303,9 @@ dependencies = [ { name = "tqdm" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f0/ee/d056c82f63c05f06baac0cffb4a90952d8274f90c49dfe244f20497b9bbd/openai-2.33.0.tar.gz", hash = "sha256:f850c435e2a4685bba3295bd54912dd26315d9c1b7733068186134d6e0599f9a", size = 693254, upload-time = "2026-04-28T14:04:42.428Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f4/a1/4d5e84cf51720fc1526cc49e10ac1961abcccb55b0efb3d970db1e9a2728/openai-2.36.0.tar.gz", hash = "sha256:139dea0edd2f1b30c33d46ae1a6929e03906254140318e4608e98fe8c566f2e7", size = 753003, upload-time = "2026-05-07T17:33:17.075Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7d/32/37734d769bc8b42e4938785313cc05aade6cb0fa72479d3220a0d61a4e78/openai-2.33.0-py3-none-any.whl", hash = "sha256:03ac37d70e8c9e3a8124214e3afa785e2cbc12e627fbd98177a086ef2fd87ad5", size = 1162695, upload-time = "2026-04-28T14:04:40.482Z" }, + { url = "https://files.pythonhosted.org/packages/9d/1c/5d43735b2553baae2a5e899dcbcd0670a86930d993184d72ca909bf11c9b/openai-2.36.0-py3-none-any.whl", hash = "sha256:143f6194b548dbc2c921af1f1b03b9f14c85fed8a75b5b516f5bcc11a2a50c63", size = 1302361, upload-time = "2026-05-07T17:33:15.063Z" }, ] [[package]] @@ -2241,7 +2325,7 @@ name = "openg2g" source = { editable = "." } dependencies = [ { name = "aiohttp" }, - { name = "mlenergy-data" }, + { name = "mlenergy", extra = ["data"] }, { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "numpy", version = "2.4.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "pandas", version = "2.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, @@ -2257,6 +2341,7 @@ opendss = [ rl = [ { name = "gymnasium" }, { name = "stable-baselines3" }, + { name = "tensorboard" }, ] [package.dev-dependencies] @@ -2299,12 +2384,13 @@ test = [ requires-dist = [ { name = "aiohttp" }, { name = "gymnasium", marker = "extra == 'rl'" }, - { name = "mlenergy-data" }, + { name = "mlenergy", extras = ["data"], specifier = ">=0.1.0" }, { name = "numpy" }, { name = "opendssdirect-py", marker = "extra == 'opendss'" }, { name = "pandas" }, { name = "pydantic", specifier = ">=2.0" }, { name = "stable-baselines3", marker = "extra == 'rl'", specifier = ">=2.0" }, + { name = "tensorboard", marker = "extra == 'rl'" }, { name = "zeus", specifier = ">=0.15.0" }, ] provides-extras = ["opendss", "rl"] @@ -2613,116 +2699,145 @@ wheels = [ [[package]] name = "propcache" -version = "0.4.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/9e/da/e9fc233cf63743258bff22b3dfa7ea5baef7b5bc324af47a0ad89b8ffc6f/propcache-0.4.1.tar.gz", hash = "sha256:f48107a8c637e80362555f37ecf49abe20370e557cc4ab374f04ec4423c97c3d", size = 46442, upload-time = "2025-10-08T19:49:02.291Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3c/0e/934b541323035566a9af292dba85a195f7b78179114f2c6ebb24551118a9/propcache-0.4.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7c2d1fa3201efaf55d730400d945b5b3ab6e672e100ba0f9a409d950ab25d7db", size = 79534, upload-time = "2025-10-08T19:46:02.083Z" }, - { url = "https://files.pythonhosted.org/packages/a1/6b/db0d03d96726d995dc7171286c6ba9d8d14251f37433890f88368951a44e/propcache-0.4.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:1eb2994229cc8ce7fe9b3db88f5465f5fd8651672840b2e426b88cdb1a30aac8", size = 45526, upload-time = "2025-10-08T19:46:03.884Z" }, - { url = "https://files.pythonhosted.org/packages/e4/c3/82728404aea669e1600f304f2609cde9e665c18df5a11cdd57ed73c1dceb/propcache-0.4.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:66c1f011f45a3b33d7bcb22daed4b29c0c9e2224758b6be00686731e1b46f925", size = 47263, upload-time = "2025-10-08T19:46:05.405Z" }, - { url = "https://files.pythonhosted.org/packages/df/1b/39313ddad2bf9187a1432654c38249bab4562ef535ef07f5eb6eb04d0b1b/propcache-0.4.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9a52009f2adffe195d0b605c25ec929d26b36ef986ba85244891dee3b294df21", size = 201012, upload-time = "2025-10-08T19:46:07.165Z" }, - { url = "https://files.pythonhosted.org/packages/5b/01/f1d0b57d136f294a142acf97f4ed58c8e5b974c21e543000968357115011/propcache-0.4.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5d4e2366a9c7b837555cf02fb9be2e3167d333aff716332ef1b7c3a142ec40c5", size = 209491, upload-time = "2025-10-08T19:46:08.909Z" }, - { url = "https://files.pythonhosted.org/packages/a1/c8/038d909c61c5bb039070b3fb02ad5cccdb1dde0d714792e251cdb17c9c05/propcache-0.4.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9d2b6caef873b4f09e26ea7e33d65f42b944837563a47a94719cc3544319a0db", size = 215319, upload-time = "2025-10-08T19:46:10.7Z" }, - { url = "https://files.pythonhosted.org/packages/08/57/8c87e93142b2c1fa2408e45695205a7ba05fb5db458c0bf5c06ba0e09ea6/propcache-0.4.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2b16ec437a8c8a965ecf95739448dd938b5c7f56e67ea009f4300d8df05f32b7", size = 196856, upload-time = "2025-10-08T19:46:12.003Z" }, - { url = "https://files.pythonhosted.org/packages/42/df/5615fec76aa561987a534759b3686008a288e73107faa49a8ae5795a9f7a/propcache-0.4.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:296f4c8ed03ca7476813fe666c9ea97869a8d7aec972618671b33a38a5182ef4", size = 193241, upload-time = "2025-10-08T19:46:13.495Z" }, - { url = "https://files.pythonhosted.org/packages/d5/21/62949eb3a7a54afe8327011c90aca7e03547787a88fb8bd9726806482fea/propcache-0.4.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:1f0978529a418ebd1f49dad413a2b68af33f85d5c5ca5c6ca2a3bed375a7ac60", size = 190552, upload-time = "2025-10-08T19:46:14.938Z" }, - { url = "https://files.pythonhosted.org/packages/30/ee/ab4d727dd70806e5b4de96a798ae7ac6e4d42516f030ee60522474b6b332/propcache-0.4.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:fd138803047fb4c062b1c1dd95462f5209456bfab55c734458f15d11da288f8f", size = 200113, upload-time = "2025-10-08T19:46:16.695Z" }, - { url = "https://files.pythonhosted.org/packages/8a/0b/38b46208e6711b016aa8966a3ac793eee0d05c7159d8342aa27fc0bc365e/propcache-0.4.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:8c9b3cbe4584636d72ff556d9036e0c9317fa27b3ac1f0f558e7e84d1c9c5900", size = 200778, upload-time = "2025-10-08T19:46:18.023Z" }, - { url = "https://files.pythonhosted.org/packages/cf/81/5abec54355ed344476bee711e9f04815d4b00a311ab0535599204eecc257/propcache-0.4.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f93243fdc5657247533273ac4f86ae106cc6445a0efacb9a1bfe982fcfefd90c", size = 193047, upload-time = "2025-10-08T19:46:19.449Z" }, - { url = "https://files.pythonhosted.org/packages/ec/b6/1f237c04e32063cb034acd5f6ef34ef3a394f75502e72703545631ab1ef6/propcache-0.4.1-cp310-cp310-win32.whl", hash = "sha256:a0ee98db9c5f80785b266eb805016e36058ac72c51a064040f2bc43b61101cdb", size = 38093, upload-time = "2025-10-08T19:46:20.643Z" }, - { url = "https://files.pythonhosted.org/packages/a6/67/354aac4e0603a15f76439caf0427781bcd6797f370377f75a642133bc954/propcache-0.4.1-cp310-cp310-win_amd64.whl", hash = "sha256:1cdb7988c4e5ac7f6d175a28a9aa0c94cb6f2ebe52756a3c0cda98d2809a9e37", size = 41638, upload-time = "2025-10-08T19:46:21.935Z" }, - { url = "https://files.pythonhosted.org/packages/e0/e1/74e55b9fd1a4c209ff1a9a824bf6c8b3d1fc5a1ac3eabe23462637466785/propcache-0.4.1-cp310-cp310-win_arm64.whl", hash = "sha256:d82ad62b19645419fe79dd63b3f9253e15b30e955c0170e5cebc350c1844e581", size = 38229, upload-time = "2025-10-08T19:46:23.368Z" }, - { url = "https://files.pythonhosted.org/packages/8c/d4/4e2c9aaf7ac2242b9358f98dccd8f90f2605402f5afeff6c578682c2c491/propcache-0.4.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:60a8fda9644b7dfd5dece8c61d8a85e271cb958075bfc4e01083c148b61a7caf", size = 80208, upload-time = "2025-10-08T19:46:24.597Z" }, - { url = "https://files.pythonhosted.org/packages/c2/21/d7b68e911f9c8e18e4ae43bdbc1e1e9bbd971f8866eb81608947b6f585ff/propcache-0.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c30b53e7e6bda1d547cabb47c825f3843a0a1a42b0496087bb58d8fedf9f41b5", size = 45777, upload-time = "2025-10-08T19:46:25.733Z" }, - { url = "https://files.pythonhosted.org/packages/d3/1d/11605e99ac8ea9435651ee71ab4cb4bf03f0949586246476a25aadfec54a/propcache-0.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6918ecbd897443087a3b7cd978d56546a812517dcaaca51b49526720571fa93e", size = 47647, upload-time = "2025-10-08T19:46:27.304Z" }, - { url = "https://files.pythonhosted.org/packages/58/1a/3c62c127a8466c9c843bccb503d40a273e5cc69838805f322e2826509e0d/propcache-0.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3d902a36df4e5989763425a8ab9e98cd8ad5c52c823b34ee7ef307fd50582566", size = 214929, upload-time = "2025-10-08T19:46:28.62Z" }, - { url = "https://files.pythonhosted.org/packages/56/b9/8fa98f850960b367c4b8fe0592e7fc341daa7a9462e925228f10a60cf74f/propcache-0.4.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a9695397f85973bb40427dedddf70d8dc4a44b22f1650dd4af9eedf443d45165", size = 221778, upload-time = "2025-10-08T19:46:30.358Z" }, - { url = "https://files.pythonhosted.org/packages/46/a6/0ab4f660eb59649d14b3d3d65c439421cf2f87fe5dd68591cbe3c1e78a89/propcache-0.4.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2bb07ffd7eaad486576430c89f9b215f9e4be68c4866a96e97db9e97fead85dc", size = 228144, upload-time = "2025-10-08T19:46:32.607Z" }, - { url = "https://files.pythonhosted.org/packages/52/6a/57f43e054fb3d3a56ac9fc532bc684fc6169a26c75c353e65425b3e56eef/propcache-0.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fd6f30fdcf9ae2a70abd34da54f18da086160e4d7d9251f81f3da0ff84fc5a48", size = 210030, upload-time = "2025-10-08T19:46:33.969Z" }, - { url = "https://files.pythonhosted.org/packages/40/e2/27e6feebb5f6b8408fa29f5efbb765cd54c153ac77314d27e457a3e993b7/propcache-0.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:fc38cba02d1acba4e2869eef1a57a43dfbd3d49a59bf90dda7444ec2be6a5570", size = 208252, upload-time = "2025-10-08T19:46:35.309Z" }, - { url = "https://files.pythonhosted.org/packages/9e/f8/91c27b22ccda1dbc7967f921c42825564fa5336a01ecd72eb78a9f4f53c2/propcache-0.4.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:67fad6162281e80e882fb3ec355398cf72864a54069d060321f6cd0ade95fe85", size = 202064, upload-time = "2025-10-08T19:46:36.993Z" }, - { url = "https://files.pythonhosted.org/packages/f2/26/7f00bd6bd1adba5aafe5f4a66390f243acab58eab24ff1a08bebb2ef9d40/propcache-0.4.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f10207adf04d08bec185bae14d9606a1444715bc99180f9331c9c02093e1959e", size = 212429, upload-time = "2025-10-08T19:46:38.398Z" }, - { url = "https://files.pythonhosted.org/packages/84/89/fd108ba7815c1117ddca79c228f3f8a15fc82a73bca8b142eb5de13b2785/propcache-0.4.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:e9b0d8d0845bbc4cfcdcbcdbf5086886bc8157aa963c31c777ceff7846c77757", size = 216727, upload-time = "2025-10-08T19:46:39.732Z" }, - { url = "https://files.pythonhosted.org/packages/79/37/3ec3f7e3173e73f1d600495d8b545b53802cbf35506e5732dd8578db3724/propcache-0.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:981333cb2f4c1896a12f4ab92a9cc8f09ea664e9b7dbdc4eff74627af3a11c0f", size = 205097, upload-time = "2025-10-08T19:46:41.025Z" }, - { url = "https://files.pythonhosted.org/packages/61/b0/b2631c19793f869d35f47d5a3a56fb19e9160d3c119f15ac7344fc3ccae7/propcache-0.4.1-cp311-cp311-win32.whl", hash = "sha256:f1d2f90aeec838a52f1c1a32fe9a619fefd5e411721a9117fbf82aea638fe8a1", size = 38084, upload-time = "2025-10-08T19:46:42.693Z" }, - { url = "https://files.pythonhosted.org/packages/f4/78/6cce448e2098e9f3bfc91bb877f06aa24b6ccace872e39c53b2f707c4648/propcache-0.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:364426a62660f3f699949ac8c621aad6977be7126c5807ce48c0aeb8e7333ea6", size = 41637, upload-time = "2025-10-08T19:46:43.778Z" }, - { url = "https://files.pythonhosted.org/packages/9c/e9/754f180cccd7f51a39913782c74717c581b9cc8177ad0e949f4d51812383/propcache-0.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:e53f3a38d3510c11953f3e6a33f205c6d1b001129f972805ca9b42fc308bc239", size = 38064, upload-time = "2025-10-08T19:46:44.872Z" }, - { url = "https://files.pythonhosted.org/packages/a2/0f/f17b1b2b221d5ca28b4b876e8bb046ac40466513960646bda8e1853cdfa2/propcache-0.4.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e153e9cd40cc8945138822807139367f256f89c6810c2634a4f6902b52d3b4e2", size = 80061, upload-time = "2025-10-08T19:46:46.075Z" }, - { url = "https://files.pythonhosted.org/packages/76/47/8ccf75935f51448ba9a16a71b783eb7ef6b9ee60f5d14c7f8a8a79fbeed7/propcache-0.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:cd547953428f7abb73c5ad82cbb32109566204260d98e41e5dfdc682eb7f8403", size = 46037, upload-time = "2025-10-08T19:46:47.23Z" }, - { url = "https://files.pythonhosted.org/packages/0a/b6/5c9a0e42df4d00bfb4a3cbbe5cf9f54260300c88a0e9af1f47ca5ce17ac0/propcache-0.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f048da1b4f243fc44f205dfd320933a951b8d89e0afd4c7cacc762a8b9165207", size = 47324, upload-time = "2025-10-08T19:46:48.384Z" }, - { url = "https://files.pythonhosted.org/packages/9e/d3/6c7ee328b39a81ee877c962469f1e795f9db87f925251efeb0545e0020d0/propcache-0.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ec17c65562a827bba85e3872ead335f95405ea1674860d96483a02f5c698fa72", size = 225505, upload-time = "2025-10-08T19:46:50.055Z" }, - { url = "https://files.pythonhosted.org/packages/01/5d/1c53f4563490b1d06a684742cc6076ef944bc6457df6051b7d1a877c057b/propcache-0.4.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:405aac25c6394ef275dee4c709be43745d36674b223ba4eb7144bf4d691b7367", size = 230242, upload-time = "2025-10-08T19:46:51.815Z" }, - { url = "https://files.pythonhosted.org/packages/20/e1/ce4620633b0e2422207c3cb774a0ee61cac13abc6217763a7b9e2e3f4a12/propcache-0.4.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0013cb6f8dde4b2a2f66903b8ba740bdfe378c943c4377a200551ceb27f379e4", size = 238474, upload-time = "2025-10-08T19:46:53.208Z" }, - { url = "https://files.pythonhosted.org/packages/46/4b/3aae6835b8e5f44ea6a68348ad90f78134047b503765087be2f9912140ea/propcache-0.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15932ab57837c3368b024473a525e25d316d8353016e7cc0e5ba9eb343fbb1cf", size = 221575, upload-time = "2025-10-08T19:46:54.511Z" }, - { url = "https://files.pythonhosted.org/packages/6e/a5/8a5e8678bcc9d3a1a15b9a29165640d64762d424a16af543f00629c87338/propcache-0.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:031dce78b9dc099f4c29785d9cf5577a3faf9ebf74ecbd3c856a7b92768c3df3", size = 216736, upload-time = "2025-10-08T19:46:56.212Z" }, - { url = "https://files.pythonhosted.org/packages/f1/63/b7b215eddeac83ca1c6b934f89d09a625aa9ee4ba158338854c87210cc36/propcache-0.4.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:ab08df6c9a035bee56e31af99be621526bd237bea9f32def431c656b29e41778", size = 213019, upload-time = "2025-10-08T19:46:57.595Z" }, - { url = "https://files.pythonhosted.org/packages/57/74/f580099a58c8af587cac7ba19ee7cb418506342fbbe2d4a4401661cca886/propcache-0.4.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4d7af63f9f93fe593afbf104c21b3b15868efb2c21d07d8732c0c4287e66b6a6", size = 220376, upload-time = "2025-10-08T19:46:59.067Z" }, - { url = "https://files.pythonhosted.org/packages/c4/ee/542f1313aff7eaf19c2bb758c5d0560d2683dac001a1c96d0774af799843/propcache-0.4.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:cfc27c945f422e8b5071b6e93169679e4eb5bf73bbcbf1ba3ae3a83d2f78ebd9", size = 226988, upload-time = "2025-10-08T19:47:00.544Z" }, - { url = "https://files.pythonhosted.org/packages/8f/18/9c6b015dd9c6930f6ce2229e1f02fb35298b847f2087ea2b436a5bfa7287/propcache-0.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:35c3277624a080cc6ec6f847cbbbb5b49affa3598c4535a0a4682a697aaa5c75", size = 215615, upload-time = "2025-10-08T19:47:01.968Z" }, - { url = "https://files.pythonhosted.org/packages/80/9e/e7b85720b98c45a45e1fca6a177024934dc9bc5f4d5dd04207f216fc33ed/propcache-0.4.1-cp312-cp312-win32.whl", hash = "sha256:671538c2262dadb5ba6395e26c1731e1d52534bfe9ae56d0b5573ce539266aa8", size = 38066, upload-time = "2025-10-08T19:47:03.503Z" }, - { url = "https://files.pythonhosted.org/packages/54/09/d19cff2a5aaac632ec8fc03737b223597b1e347416934c1b3a7df079784c/propcache-0.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:cb2d222e72399fcf5890d1d5cc1060857b9b236adff2792ff48ca2dfd46c81db", size = 41655, upload-time = "2025-10-08T19:47:04.973Z" }, - { url = "https://files.pythonhosted.org/packages/68/ab/6b5c191bb5de08036a8c697b265d4ca76148efb10fa162f14af14fb5f076/propcache-0.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:204483131fb222bdaaeeea9f9e6c6ed0cac32731f75dfc1d4a567fc1926477c1", size = 37789, upload-time = "2025-10-08T19:47:06.077Z" }, - { url = "https://files.pythonhosted.org/packages/bf/df/6d9c1b6ac12b003837dde8a10231a7344512186e87b36e855bef32241942/propcache-0.4.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:43eedf29202c08550aac1d14e0ee619b0430aaef78f85864c1a892294fbc28cf", size = 77750, upload-time = "2025-10-08T19:47:07.648Z" }, - { url = "https://files.pythonhosted.org/packages/8b/e8/677a0025e8a2acf07d3418a2e7ba529c9c33caf09d3c1f25513023c1db56/propcache-0.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d62cdfcfd89ccb8de04e0eda998535c406bf5e060ffd56be6c586cbcc05b3311", size = 44780, upload-time = "2025-10-08T19:47:08.851Z" }, - { url = "https://files.pythonhosted.org/packages/89/a4/92380f7ca60f99ebae761936bc48a72a639e8a47b29050615eef757cb2a7/propcache-0.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cae65ad55793da34db5f54e4029b89d3b9b9490d8abe1b4c7ab5d4b8ec7ebf74", size = 46308, upload-time = "2025-10-08T19:47:09.982Z" }, - { url = "https://files.pythonhosted.org/packages/2d/48/c5ac64dee5262044348d1d78a5f85dd1a57464a60d30daee946699963eb3/propcache-0.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:333ddb9031d2704a301ee3e506dc46b1fe5f294ec198ed6435ad5b6a085facfe", size = 208182, upload-time = "2025-10-08T19:47:11.319Z" }, - { url = "https://files.pythonhosted.org/packages/c6/0c/cd762dd011a9287389a6a3eb43aa30207bde253610cca06824aeabfe9653/propcache-0.4.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:fd0858c20f078a32cf55f7e81473d96dcf3b93fd2ccdb3d40fdf54b8573df3af", size = 211215, upload-time = "2025-10-08T19:47:13.146Z" }, - { url = "https://files.pythonhosted.org/packages/30/3e/49861e90233ba36890ae0ca4c660e95df565b2cd15d4a68556ab5865974e/propcache-0.4.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:678ae89ebc632c5c204c794f8dab2837c5f159aeb59e6ed0539500400577298c", size = 218112, upload-time = "2025-10-08T19:47:14.913Z" }, - { url = "https://files.pythonhosted.org/packages/f1/8b/544bc867e24e1bd48f3118cecd3b05c694e160a168478fa28770f22fd094/propcache-0.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d472aeb4fbf9865e0c6d622d7f4d54a4e101a89715d8904282bb5f9a2f476c3f", size = 204442, upload-time = "2025-10-08T19:47:16.277Z" }, - { url = "https://files.pythonhosted.org/packages/50/a6/4282772fd016a76d3e5c0df58380a5ea64900afd836cec2c2f662d1b9bb3/propcache-0.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4d3df5fa7e36b3225954fba85589da77a0fe6a53e3976de39caf04a0db4c36f1", size = 199398, upload-time = "2025-10-08T19:47:17.962Z" }, - { url = "https://files.pythonhosted.org/packages/3e/ec/d8a7cd406ee1ddb705db2139f8a10a8a427100347bd698e7014351c7af09/propcache-0.4.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:ee17f18d2498f2673e432faaa71698032b0127ebf23ae5974eeaf806c279df24", size = 196920, upload-time = "2025-10-08T19:47:19.355Z" }, - { url = "https://files.pythonhosted.org/packages/f6/6c/f38ab64af3764f431e359f8baf9e0a21013e24329e8b85d2da32e8ed07ca/propcache-0.4.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:580e97762b950f993ae618e167e7be9256b8353c2dcd8b99ec100eb50f5286aa", size = 203748, upload-time = "2025-10-08T19:47:21.338Z" }, - { url = "https://files.pythonhosted.org/packages/d6/e3/fa846bd70f6534d647886621388f0a265254d30e3ce47e5c8e6e27dbf153/propcache-0.4.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:501d20b891688eb8e7aa903021f0b72d5a55db40ffaab27edefd1027caaafa61", size = 205877, upload-time = "2025-10-08T19:47:23.059Z" }, - { url = "https://files.pythonhosted.org/packages/e2/39/8163fc6f3133fea7b5f2827e8eba2029a0277ab2c5beee6c1db7b10fc23d/propcache-0.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a0bd56e5b100aef69bd8562b74b46254e7c8812918d3baa700c8a8009b0af66", size = 199437, upload-time = "2025-10-08T19:47:24.445Z" }, - { url = "https://files.pythonhosted.org/packages/93/89/caa9089970ca49c7c01662bd0eeedfe85494e863e8043565aeb6472ce8fe/propcache-0.4.1-cp313-cp313-win32.whl", hash = "sha256:bcc9aaa5d80322bc2fb24bb7accb4a30f81e90ab8d6ba187aec0744bc302ad81", size = 37586, upload-time = "2025-10-08T19:47:25.736Z" }, - { url = "https://files.pythonhosted.org/packages/f5/ab/f76ec3c3627c883215b5c8080debb4394ef5a7a29be811f786415fc1e6fd/propcache-0.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:381914df18634f5494334d201e98245c0596067504b9372d8cf93f4bb23e025e", size = 40790, upload-time = "2025-10-08T19:47:26.847Z" }, - { url = "https://files.pythonhosted.org/packages/59/1b/e71ae98235f8e2ba5004d8cb19765a74877abf189bc53fc0c80d799e56c3/propcache-0.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:8873eb4460fd55333ea49b7d189749ecf6e55bf85080f11b1c4530ed3034cba1", size = 37158, upload-time = "2025-10-08T19:47:27.961Z" }, - { url = "https://files.pythonhosted.org/packages/83/ce/a31bbdfc24ee0dcbba458c8175ed26089cf109a55bbe7b7640ed2470cfe9/propcache-0.4.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:92d1935ee1f8d7442da9c0c4fa7ac20d07e94064184811b685f5c4fada64553b", size = 81451, upload-time = "2025-10-08T19:47:29.445Z" }, - { url = "https://files.pythonhosted.org/packages/25/9c/442a45a470a68456e710d96cacd3573ef26a1d0a60067e6a7d5e655621ed/propcache-0.4.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:473c61b39e1460d386479b9b2f337da492042447c9b685f28be4f74d3529e566", size = 46374, upload-time = "2025-10-08T19:47:30.579Z" }, - { url = "https://files.pythonhosted.org/packages/f4/bf/b1d5e21dbc3b2e889ea4327044fb16312a736d97640fb8b6aa3f9c7b3b65/propcache-0.4.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:c0ef0aaafc66fbd87842a3fe3902fd889825646bc21149eafe47be6072725835", size = 48396, upload-time = "2025-10-08T19:47:31.79Z" }, - { url = "https://files.pythonhosted.org/packages/f4/04/5b4c54a103d480e978d3c8a76073502b18db0c4bc17ab91b3cb5092ad949/propcache-0.4.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f95393b4d66bfae908c3ca8d169d5f79cd65636ae15b5e7a4f6e67af675adb0e", size = 275950, upload-time = "2025-10-08T19:47:33.481Z" }, - { url = "https://files.pythonhosted.org/packages/b4/c1/86f846827fb969c4b78b0af79bba1d1ea2156492e1b83dea8b8a6ae27395/propcache-0.4.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c07fda85708bc48578467e85099645167a955ba093be0a2dcba962195676e859", size = 273856, upload-time = "2025-10-08T19:47:34.906Z" }, - { url = "https://files.pythonhosted.org/packages/36/1d/fc272a63c8d3bbad6878c336c7a7dea15e8f2d23a544bda43205dfa83ada/propcache-0.4.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:af223b406d6d000830c6f65f1e6431783fc3f713ba3e6cc8c024d5ee96170a4b", size = 280420, upload-time = "2025-10-08T19:47:36.338Z" }, - { url = "https://files.pythonhosted.org/packages/07/0c/01f2219d39f7e53d52e5173bcb09c976609ba30209912a0680adfb8c593a/propcache-0.4.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a78372c932c90ee474559c5ddfffd718238e8673c340dc21fe45c5b8b54559a0", size = 263254, upload-time = "2025-10-08T19:47:37.692Z" }, - { url = "https://files.pythonhosted.org/packages/2d/18/cd28081658ce597898f0c4d174d4d0f3c5b6d4dc27ffafeef835c95eb359/propcache-0.4.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:564d9f0d4d9509e1a870c920a89b2fec951b44bf5ba7d537a9e7c1ccec2c18af", size = 261205, upload-time = "2025-10-08T19:47:39.659Z" }, - { url = "https://files.pythonhosted.org/packages/7a/71/1f9e22eb8b8316701c2a19fa1f388c8a3185082607da8e406a803c9b954e/propcache-0.4.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:17612831fda0138059cc5546f4d12a2aacfb9e47068c06af35c400ba58ba7393", size = 247873, upload-time = "2025-10-08T19:47:41.084Z" }, - { url = "https://files.pythonhosted.org/packages/4a/65/3d4b61f36af2b4eddba9def857959f1016a51066b4f1ce348e0cf7881f58/propcache-0.4.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:41a89040cb10bd345b3c1a873b2bf36413d48da1def52f268a055f7398514874", size = 262739, upload-time = "2025-10-08T19:47:42.51Z" }, - { url = "https://files.pythonhosted.org/packages/2a/42/26746ab087faa77c1c68079b228810436ccd9a5ce9ac85e2b7307195fd06/propcache-0.4.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:e35b88984e7fa64aacecea39236cee32dd9bd8c55f57ba8a75cf2399553f9bd7", size = 263514, upload-time = "2025-10-08T19:47:43.927Z" }, - { url = "https://files.pythonhosted.org/packages/94/13/630690fe201f5502d2403dd3cfd451ed8858fe3c738ee88d095ad2ff407b/propcache-0.4.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6f8b465489f927b0df505cbe26ffbeed4d6d8a2bbc61ce90eb074ff129ef0ab1", size = 257781, upload-time = "2025-10-08T19:47:45.448Z" }, - { url = "https://files.pythonhosted.org/packages/92/f7/1d4ec5841505f423469efbfc381d64b7b467438cd5a4bbcbb063f3b73d27/propcache-0.4.1-cp313-cp313t-win32.whl", hash = "sha256:2ad890caa1d928c7c2965b48f3a3815c853180831d0e5503d35cf00c472f4717", size = 41396, upload-time = "2025-10-08T19:47:47.202Z" }, - { url = "https://files.pythonhosted.org/packages/48/f0/615c30622316496d2cbbc29f5985f7777d3ada70f23370608c1d3e081c1f/propcache-0.4.1-cp313-cp313t-win_amd64.whl", hash = "sha256:f7ee0e597f495cf415bcbd3da3caa3bd7e816b74d0d52b8145954c5e6fd3ff37", size = 44897, upload-time = "2025-10-08T19:47:48.336Z" }, - { url = "https://files.pythonhosted.org/packages/fd/ca/6002e46eccbe0e33dcd4069ef32f7f1c9e243736e07adca37ae8c4830ec3/propcache-0.4.1-cp313-cp313t-win_arm64.whl", hash = "sha256:929d7cbe1f01bb7baffb33dc14eb5691c95831450a26354cd210a8155170c93a", size = 39789, upload-time = "2025-10-08T19:47:49.876Z" }, - { url = "https://files.pythonhosted.org/packages/8e/5c/bca52d654a896f831b8256683457ceddd490ec18d9ec50e97dfd8fc726a8/propcache-0.4.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3f7124c9d820ba5548d431afb4632301acf965db49e666aa21c305cbe8c6de12", size = 78152, upload-time = "2025-10-08T19:47:51.051Z" }, - { url = "https://files.pythonhosted.org/packages/65/9b/03b04e7d82a5f54fb16113d839f5ea1ede58a61e90edf515f6577c66fa8f/propcache-0.4.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:c0d4b719b7da33599dfe3b22d3db1ef789210a0597bc650b7cee9c77c2be8c5c", size = 44869, upload-time = "2025-10-08T19:47:52.594Z" }, - { url = "https://files.pythonhosted.org/packages/b2/fa/89a8ef0468d5833a23fff277b143d0573897cf75bd56670a6d28126c7d68/propcache-0.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9f302f4783709a78240ebc311b793f123328716a60911d667e0c036bc5dcbded", size = 46596, upload-time = "2025-10-08T19:47:54.073Z" }, - { url = "https://files.pythonhosted.org/packages/86/bd/47816020d337f4a746edc42fe8d53669965138f39ee117414c7d7a340cfe/propcache-0.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c80ee5802e3fb9ea37938e7eecc307fb984837091d5fd262bb37238b1ae97641", size = 206981, upload-time = "2025-10-08T19:47:55.715Z" }, - { url = "https://files.pythonhosted.org/packages/df/f6/c5fa1357cc9748510ee55f37173eb31bfde6d94e98ccd9e6f033f2fc06e1/propcache-0.4.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ed5a841e8bb29a55fb8159ed526b26adc5bdd7e8bd7bf793ce647cb08656cdf4", size = 211490, upload-time = "2025-10-08T19:47:57.499Z" }, - { url = "https://files.pythonhosted.org/packages/80/1e/e5889652a7c4a3846683401a48f0f2e5083ce0ec1a8a5221d8058fbd1adf/propcache-0.4.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:55c72fd6ea2da4c318e74ffdf93c4fe4e926051133657459131a95c846d16d44", size = 215371, upload-time = "2025-10-08T19:47:59.317Z" }, - { url = "https://files.pythonhosted.org/packages/b2/f2/889ad4b2408f72fe1a4f6a19491177b30ea7bf1a0fd5f17050ca08cfc882/propcache-0.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8326e144341460402713f91df60ade3c999d601e7eb5ff8f6f7862d54de0610d", size = 201424, upload-time = "2025-10-08T19:48:00.67Z" }, - { url = "https://files.pythonhosted.org/packages/27/73/033d63069b57b0812c8bd19f311faebeceb6ba31b8f32b73432d12a0b826/propcache-0.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:060b16ae65bc098da7f6d25bf359f1f31f688384858204fe5d652979e0015e5b", size = 197566, upload-time = "2025-10-08T19:48:02.604Z" }, - { url = "https://files.pythonhosted.org/packages/dc/89/ce24f3dc182630b4e07aa6d15f0ff4b14ed4b9955fae95a0b54c58d66c05/propcache-0.4.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:89eb3fa9524f7bec9de6e83cf3faed9d79bffa560672c118a96a171a6f55831e", size = 193130, upload-time = "2025-10-08T19:48:04.499Z" }, - { url = "https://files.pythonhosted.org/packages/a9/24/ef0d5fd1a811fb5c609278d0209c9f10c35f20581fcc16f818da959fc5b4/propcache-0.4.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:dee69d7015dc235f526fe80a9c90d65eb0039103fe565776250881731f06349f", size = 202625, upload-time = "2025-10-08T19:48:06.213Z" }, - { url = "https://files.pythonhosted.org/packages/f5/02/98ec20ff5546f68d673df2f7a69e8c0d076b5abd05ca882dc7ee3a83653d/propcache-0.4.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:5558992a00dfd54ccbc64a32726a3357ec93825a418a401f5cc67df0ac5d9e49", size = 204209, upload-time = "2025-10-08T19:48:08.432Z" }, - { url = "https://files.pythonhosted.org/packages/a0/87/492694f76759b15f0467a2a93ab68d32859672b646aa8a04ce4864e7932d/propcache-0.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c9b822a577f560fbd9554812526831712c1436d2c046cedee4c3796d3543b144", size = 197797, upload-time = "2025-10-08T19:48:09.968Z" }, - { url = "https://files.pythonhosted.org/packages/ee/36/66367de3575db1d2d3f3d177432bd14ee577a39d3f5d1b3d5df8afe3b6e2/propcache-0.4.1-cp314-cp314-win32.whl", hash = "sha256:ab4c29b49d560fe48b696cdcb127dd36e0bc2472548f3bf56cc5cb3da2b2984f", size = 38140, upload-time = "2025-10-08T19:48:11.232Z" }, - { url = "https://files.pythonhosted.org/packages/0c/2a/a758b47de253636e1b8aef181c0b4f4f204bf0dd964914fb2af90a95b49b/propcache-0.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:5a103c3eb905fcea0ab98be99c3a9a5ab2de60228aa5aceedc614c0281cf6153", size = 41257, upload-time = "2025-10-08T19:48:12.707Z" }, - { url = "https://files.pythonhosted.org/packages/34/5e/63bd5896c3fec12edcbd6f12508d4890d23c265df28c74b175e1ef9f4f3b/propcache-0.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:74c1fb26515153e482e00177a1ad654721bf9207da8a494a0c05e797ad27b992", size = 38097, upload-time = "2025-10-08T19:48:13.923Z" }, - { url = "https://files.pythonhosted.org/packages/99/85/9ff785d787ccf9bbb3f3106f79884a130951436f58392000231b4c737c80/propcache-0.4.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:824e908bce90fb2743bd6b59db36eb4f45cd350a39637c9f73b1c1ea66f5b75f", size = 81455, upload-time = "2025-10-08T19:48:15.16Z" }, - { url = "https://files.pythonhosted.org/packages/90/85/2431c10c8e7ddb1445c1f7c4b54d886e8ad20e3c6307e7218f05922cad67/propcache-0.4.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:c2b5e7db5328427c57c8e8831abda175421b709672f6cfc3d630c3b7e2146393", size = 46372, upload-time = "2025-10-08T19:48:16.424Z" }, - { url = "https://files.pythonhosted.org/packages/01/20/b0972d902472da9bcb683fa595099911f4d2e86e5683bcc45de60dd05dc3/propcache-0.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6f6ff873ed40292cd4969ef5310179afd5db59fdf055897e282485043fc80ad0", size = 48411, upload-time = "2025-10-08T19:48:17.577Z" }, - { url = "https://files.pythonhosted.org/packages/e2/e3/7dc89f4f21e8f99bad3d5ddb3a3389afcf9da4ac69e3deb2dcdc96e74169/propcache-0.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:49a2dc67c154db2c1463013594c458881a069fcf98940e61a0569016a583020a", size = 275712, upload-time = "2025-10-08T19:48:18.901Z" }, - { url = "https://files.pythonhosted.org/packages/20/67/89800c8352489b21a8047c773067644e3897f02ecbbd610f4d46b7f08612/propcache-0.4.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:005f08e6a0529984491e37d8dbc3dd86f84bd78a8ceb5fa9a021f4c48d4984be", size = 273557, upload-time = "2025-10-08T19:48:20.762Z" }, - { url = "https://files.pythonhosted.org/packages/e2/a1/b52b055c766a54ce6d9c16d9aca0cad8059acd9637cdf8aa0222f4a026ef/propcache-0.4.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5c3310452e0d31390da9035c348633b43d7e7feb2e37be252be6da45abd1abcc", size = 280015, upload-time = "2025-10-08T19:48:22.592Z" }, - { url = "https://files.pythonhosted.org/packages/48/c8/33cee30bd890672c63743049f3c9e4be087e6780906bfc3ec58528be59c1/propcache-0.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4c3c70630930447f9ef1caac7728c8ad1c56bc5015338b20fed0d08ea2480b3a", size = 262880, upload-time = "2025-10-08T19:48:23.947Z" }, - { url = "https://files.pythonhosted.org/packages/0c/b1/8f08a143b204b418285c88b83d00edbd61afbc2c6415ffafc8905da7038b/propcache-0.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8e57061305815dfc910a3634dcf584f08168a8836e6999983569f51a8544cd89", size = 260938, upload-time = "2025-10-08T19:48:25.656Z" }, - { url = "https://files.pythonhosted.org/packages/cf/12/96e4664c82ca2f31e1c8dff86afb867348979eb78d3cb8546a680287a1e9/propcache-0.4.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:521a463429ef54143092c11a77e04056dd00636f72e8c45b70aaa3140d639726", size = 247641, upload-time = "2025-10-08T19:48:27.207Z" }, - { url = "https://files.pythonhosted.org/packages/18/ed/e7a9cfca28133386ba52278136d42209d3125db08d0a6395f0cba0c0285c/propcache-0.4.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:120c964da3fdc75e3731aa392527136d4ad35868cc556fd09bb6d09172d9a367", size = 262510, upload-time = "2025-10-08T19:48:28.65Z" }, - { url = "https://files.pythonhosted.org/packages/f5/76/16d8bf65e8845dd62b4e2b57444ab81f07f40caa5652b8969b87ddcf2ef6/propcache-0.4.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:d8f353eb14ee3441ee844ade4277d560cdd68288838673273b978e3d6d2c8f36", size = 263161, upload-time = "2025-10-08T19:48:30.133Z" }, - { url = "https://files.pythonhosted.org/packages/e7/70/c99e9edb5d91d5ad8a49fa3c1e8285ba64f1476782fed10ab251ff413ba1/propcache-0.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ab2943be7c652f09638800905ee1bab2c544e537edb57d527997a24c13dc1455", size = 257393, upload-time = "2025-10-08T19:48:31.567Z" }, - { url = "https://files.pythonhosted.org/packages/08/02/87b25304249a35c0915d236575bc3574a323f60b47939a2262b77632a3ee/propcache-0.4.1-cp314-cp314t-win32.whl", hash = "sha256:05674a162469f31358c30bcaa8883cb7829fa3110bf9c0991fe27d7896c42d85", size = 42546, upload-time = "2025-10-08T19:48:32.872Z" }, - { url = "https://files.pythonhosted.org/packages/cb/ef/3c6ecf8b317aa982f309835e8f96987466123c6e596646d4e6a1dfcd080f/propcache-0.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:990f6b3e2a27d683cb7602ed6c86f15ee6b43b1194736f9baaeb93d0016633b1", size = 46259, upload-time = "2025-10-08T19:48:34.226Z" }, - { url = "https://files.pythonhosted.org/packages/c4/2d/346e946d4951f37eca1e4f55be0f0174c52cd70720f84029b02f296f4a38/propcache-0.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ecef2343af4cc68e05131e45024ba34f6095821988a9d0a02aa7c73fcc448aa9", size = 40428, upload-time = "2025-10-08T19:48:35.441Z" }, - { url = "https://files.pythonhosted.org/packages/5b/5a/bc7b4a4ef808fa59a816c17b20c4bef6884daebbdf627ff2a161da67da19/propcache-0.4.1-py3-none-any.whl", hash = "sha256:af2a6052aeb6cf17d3e46ee169099044fd8224cbaf75c76a2ef596e8163e2237", size = 13305, upload-time = "2025-10-08T19:49:00.792Z" }, +version = "0.5.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ec/44/c87281c333769159c50594f22610f77398a47ccbfbbf23074e744e86f87c/propcache-0.5.2.tar.gz", hash = "sha256:01c4fc7480cd0598bb4b57022df55b9ca296da7fc5a8760bd8451a7e63a7d427", size = 50208, upload-time = "2026-05-08T21:02:12.199Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5b/56/030b7b4719d53085722893e0009dffb9236aa10bca1b12121bdc5626ef16/propcache-0.5.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d5a81be28596d6559f6131ef33e10200de6e17643b3c74ce03f9eb103be6ae8b", size = 93417, upload-time = "2026-05-08T20:59:15.597Z" }, + { url = "https://files.pythonhosted.org/packages/1a/55/1140a8e067b8ec093a18a4ae7bb0045d9db65da38a08618ddc5e2f1994aa/propcache-0.5.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:29cbaac5ea0212663e6845e04b5e188d5a6ae6dd919810ac835bf1d3b42c3f4c", size = 53847, upload-time = "2026-05-08T20:59:17.096Z" }, + { url = "https://files.pythonhosted.org/packages/20/42/0e7443c90310498561addf346e7d57fe3c6ba1914e1ba938b5464c7bbfd2/propcache-0.5.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:6bf3be92233808fcd338eba0fb4d0b59ec5772af4f4ecfcec450d1bfc0f8b5eb", size = 53512, upload-time = "2026-05-08T20:59:18.64Z" }, + { url = "https://files.pythonhosted.org/packages/b7/db/cf51a71bab2009517d1a7f0ee07657e3bd446c4d69f67e6966cf17bcf956/propcache-0.5.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2f8ea531c794b9d6274acd4e8d2c2ebcac590a4361d27482edd3010b79f1325e", size = 58068, upload-time = "2026-05-08T20:59:20.683Z" }, + { url = "https://files.pythonhosted.org/packages/b7/43/39b6bdee9699fa1e1641c519feeb64a67e2a9f93bb465c70776b37a7333f/propcache-0.5.2-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:decfca4c79dd53ebab484b00cc4b6717d8c369f86e74aa4ca395a64ac651495e", size = 61020, upload-time = "2026-05-08T20:59:22.112Z" }, + { url = "https://files.pythonhosted.org/packages/26/0b/843726fbb0a29a8c5684fdb25971823638399f31e52e9d1f06a02dc9aa6b/propcache-0.5.2-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4621064bbf28fa77ff64dd5d94367c04684c67d3a5bf1dff25f0cd0d98a38f3b", size = 62732, upload-time = "2026-05-08T20:59:23.805Z" }, + { url = "https://files.pythonhosted.org/packages/39/6e/899fed76dc1942b8a64193a4f059d7f1a2c7ef65085e8a9366ed8ec0d199/propcache-0.5.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b96db7141a592cbc968daf1feea83a118e6ab378af4abbc72b248c895414c22d", size = 60140, upload-time = "2026-05-08T20:59:25.389Z" }, + { url = "https://files.pythonhosted.org/packages/ab/09/3da4be9b5b879219ad234aa535b3dd4a080ed1ad48d3a73ca07a9e798f22/propcache-0.5.2-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1ca071adabaab6e9219924bbe00af821f1ee7de113a9eca1cdc292de3d120f4d", size = 60400, upload-time = "2026-05-08T20:59:27.238Z" }, + { url = "https://files.pythonhosted.org/packages/60/2f/09b72b874a9aa0044faf52a69807a6ed618e267ceaa9ec4a63195fa5b504/propcache-0.5.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:e4294d04a94dcab1b3bccd8b66d962dcad411a1d19414b2a41d1445f1de32ad0", size = 58155, upload-time = "2026-05-08T20:59:28.48Z" }, + { url = "https://files.pythonhosted.org/packages/8a/37/97489848c54c95578045473954f10956d619ce6a09e7ac137b71cdcb698b/propcache-0.5.2-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:a0e399a2eccb91ed18721f86aa85757727400b6865c89e88934781deb9c8498b", size = 57037, upload-time = "2026-05-08T20:59:30.146Z" }, + { url = "https://files.pythonhosted.org/packages/22/db/6c695285ccfc49012743ee9c98212b8c5dd0aed7b63cfd816d4a0f7a1601/propcache-0.5.2-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:823581fd5cb08b12a48bfa11fe962a7916766b6170c17b028fbdf762b85eb9bf", size = 61103, upload-time = "2026-05-08T20:59:31.626Z" }, + { url = "https://files.pythonhosted.org/packages/98/a9/1e500401ca593b0bdb6bf75a70bc2d723835fd53360edff6af70692c7546/propcache-0.5.2-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:949c91d1a990cf3b2e8188dfcfb25005e0b834a06c63fa4ef9f360878ce21ecf", size = 60394, upload-time = "2026-05-08T20:59:32.829Z" }, + { url = "https://files.pythonhosted.org/packages/1f/87/f638b6e375eae0f30a1a2325d8b34fd85fdc785bb9960cf805f3bf1ec69a/propcache-0.5.2-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:cc1177027eda740fdb152706bd215a3f124e3eea15afc39f2cb9fe351b50619e", size = 63084, upload-time = "2026-05-08T20:59:35.964Z" }, + { url = "https://files.pythonhosted.org/packages/f6/18/884573f5d97b6d9eba68de759a82c901b7e39d7904d30f7b8d58d42d2a12/propcache-0.5.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b05d643f944a8c3c4bd86d65ffd87bf3264b617f87791940302bc474d2ff5274", size = 60999, upload-time = "2026-05-08T20:59:38.481Z" }, + { url = "https://files.pythonhosted.org/packages/8f/1a/c3915eb059ceec9e758a56e4cfd955292bc0f201be2176a46b76d94b303a/propcache-0.5.2-cp310-cp310-win32.whl", hash = "sha256:8114f28879e0904748e831c3a7774261bd9e75f49be089f389a76f959dcd13fe", size = 39036, upload-time = "2026-05-08T20:59:40.323Z" }, + { url = "https://files.pythonhosted.org/packages/5b/02/1dfd5607501a602d19c1c449d2d193b7d1c611f9246b4059026a1189a80e/propcache-0.5.2-cp310-cp310-win_amd64.whl", hash = "sha256:5fcb98e7598b1ee0addab320d90f65b530297a867dbfe9de52ea838077e16e3d", size = 42190, upload-time = "2026-05-08T20:59:42.232Z" }, + { url = "https://files.pythonhosted.org/packages/57/93/f71588ad08b3e6f4b555b5ef215808a3c02b042d0151ad82fa6f15be677a/propcache-0.5.2-cp310-cp310-win_arm64.whl", hash = "sha256:04dc2390d9edbbaef7461f33322555976ffddf0b650a038649d026358714e6c5", size = 38545, upload-time = "2026-05-08T20:59:44.087Z" }, + { url = "https://files.pythonhosted.org/packages/e7/f1/8a8cc1c2c7e7934ab77e0163414f736fadbc0f5e8dd9673b952355ac175b/propcache-0.5.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:74b70780220e2dd89175ca24b81b68b67c83db499ae611e7f2313cb329801c78", size = 90744, upload-time = "2026-05-08T20:59:45.799Z" }, + { url = "https://files.pythonhosted.org/packages/c2/f4/651b1225e976bd1a2ba5cfba0c29d096581c2636b437e3a9a7ab6276270a/propcache-0.5.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a4840ab0ae0216d952f4b53dc6d0b992bfc2bedbfe360bdd9b548bc184c08959", size = 52033, upload-time = "2026-05-08T20:59:47.408Z" }, + { url = "https://files.pythonhosted.org/packages/15/a8/8ede85d6aa1f79fc7dc2f8fd2c8d65920b8272c3892903c8a1affde48cfb/propcache-0.5.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c6844ba6364fb12f403928a82cfd295ab103a2b315c77c747b2dbe4a41894ea7", size = 52754, upload-time = "2026-05-08T20:59:49.202Z" }, + { url = "https://files.pythonhosted.org/packages/7d/fe/b3551b41bbc2f5b5bb088fc6920567cd43101253e68fbaa261339eb96fe1/propcache-0.5.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2293949b855ce597f2826452d17c2d545fb5622379c4ea6fdf525e9b8e8a2511", size = 57573, upload-time = "2026-05-08T20:59:50.778Z" }, + { url = "https://files.pythonhosted.org/packages/83/27/ab851ebd1b7172e3e161f5f8d39e315d54a91bea246f01f4d872d3376aef/propcache-0.5.2-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0fd59b5af35f74da48d905dcbad55449ba13be91823cb05a9bd590bbf5b61660", size = 60645, upload-time = "2026-05-08T20:59:52.227Z" }, + { url = "https://files.pythonhosted.org/packages/95/7d/466b3d18022e9897cbda9c735c493c5bd747d7a4c6f5ea1480b4cec434b6/propcache-0.5.2-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29f9309a2e42b0d273be006fdb4be2d6c39a47f6f57d8fb1cf9f81481df81b66", size = 61563, upload-time = "2026-05-08T20:59:53.866Z" }, + { url = "https://files.pythonhosted.org/packages/27/1b/16ab7f2cf2041da2f60d156ba64c2484eadf9168075b4ff43c3ef60045af/propcache-0.5.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5aaa2b923c1944ac8febd6609cb373540a5563e7cbcb0fd770f75dace2eb817b", size = 58888, upload-time = "2026-05-08T20:59:55.457Z" }, + { url = "https://files.pythonhosted.org/packages/0a/67/bb777ffd907633563bf35fd859c4ce97b0512c32f4633cf5d1eb7c33512b/propcache-0.5.2-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:66ea454f095ddf5b6b14f56c064c0941c4788be11e18d2464cf643bf7203ff67", size = 59253, upload-time = "2026-05-08T20:59:57.075Z" }, + { url = "https://files.pythonhosted.org/packages/b9/42/64f8d90b73fd9cdc1499b48057ff6d9cd2a98a25734c9bb62ecf07e87061/propcache-0.5.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:95f1e3f4760d404b13c9976c0229b2b49a3c8e2c62a9ce92efdd2b11ada75e3f", size = 57558, upload-time = "2026-05-08T20:59:58.602Z" }, + { url = "https://files.pythonhosted.org/packages/eb/02/dba5bc03c9041f2092ea55a449caf5dfe68352c6654511b29ba0654ddb69/propcache-0.5.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:85341b12b9d55bad0bded24cac341bb34289469e03a11f3f583ea1cc1db0326c", size = 55007, upload-time = "2026-05-08T20:59:59.837Z" }, + { url = "https://files.pythonhosted.org/packages/14/c0/43f649c7aa2a77a3b100d84e9dea3a483120ecb608bfe36ce49eaff517fe/propcache-0.5.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:26a4dca084132874e639895c3135dfad5eb20bae209f62d1aeb31b03e601c3c0", size = 60355, upload-time = "2026-05-08T21:00:01.144Z" }, + { url = "https://files.pythonhosted.org/packages/83/c0/435dafd27f1cb4a495381dae60e25883ccfe4020bb72818e8184c1678092/propcache-0.5.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:3b199b9b2b3d6a7edf3183ba8a9a137a22b97f7df525feb5ae1eccf026d2a9c6", size = 59057, upload-time = "2026-05-08T21:00:02.401Z" }, + { url = "https://files.pythonhosted.org/packages/53/ae/6e292df9135d659944e96cb3389258e4a663e5b2b5f6c217ef0ddc8d2f73/propcache-0.5.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:e59bc9e66329185b93dab73f210f1a37f81cb40f321501db8017c9aea15dba27", size = 61938, upload-time = "2026-05-08T21:00:03.638Z" }, + { url = "https://files.pythonhosted.org/packages/0b/42/314ebc50d8159055411fd6b0bda322ff510e4b1f7d2e4927940ad0f6af20/propcache-0.5.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:552ffadf6ad409844bc5919c42a0a83d88314cedddaea0e41e80a8b8fffe881f", size = 59731, upload-time = "2026-05-08T21:00:04.881Z" }, + { url = "https://files.pythonhosted.org/packages/b8/9b/2da6dee38871c3c8772fabc2758325a5c9077d6d18c597737dc04dd884cd/propcache-0.5.2-cp311-cp311-win32.whl", hash = "sha256:cd416c1de191973c52ff1a12a57446bfc7642797b282d7caf2162d7d1b8aa9a0", size = 38966, upload-time = "2026-05-08T21:00:06.511Z" }, + { url = "https://files.pythonhosted.org/packages/42/4e/f17363fb58c0afe05b067361cb6d86ed2d29de6506779a27547c4d183075/propcache-0.5.2-cp311-cp311-win_amd64.whl", hash = "sha256:44e488ef40dbb452700b2b1f8188934121f6648f52c295055662d2191959ff82", size = 42135, upload-time = "2026-05-08T21:00:08.088Z" }, + { url = "https://files.pythonhosted.org/packages/c6/eb/6af6685077d22e8b33358d3c548e3282706a0b3cd85044ffba4e5dd08e3b/propcache-0.5.2-cp311-cp311-win_arm64.whl", hash = "sha256:54adaa85a22078d1e306304a40984dc5be99d599bf3dc0a24dc98f7daeab89ab", size = 38381, upload-time = "2026-05-08T21:00:09.692Z" }, + { url = "https://files.pythonhosted.org/packages/4a/cb/e27bc2b2737a0bb49962b275efa051e8f1c35a936df7d5139b6b658b7dc9/propcache-0.5.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:806719138ecd720339a12410fb9614ac9b2b2d3a5fdf8235d56981c36f4039ba", size = 95887, upload-time = "2026-05-08T21:00:11.277Z" }, + { url = "https://files.pythonhosted.org/packages/e6/13/b8ae04c59392f8d11c6cd9fb4011d1dc7c86b81225c770280300e259ffe1/propcache-0.5.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:db2b80ea58eab4f86b2beec3cc8b39e8ff9276ac20e96b7cce43c8ae84cd6b5a", size = 54654, upload-time = "2026-05-08T21:00:12.604Z" }, + { url = "https://files.pythonhosted.org/packages/2c/7d/49777a3e20b55863d4794384a38acd460c04157b0a00f8602b0d508b8431/propcache-0.5.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e5cbfac9f61484f7e9f3597775500cd3ebe8274e9b050c38f9525c77c97520bf", size = 55190, upload-time = "2026-05-08T21:00:13.935Z" }, + { url = "https://files.pythonhosted.org/packages/44/c7/085d0cd63062e84044e3f05797749c3f8e3938ff3aeb0eb2f69d43fafc91/propcache-0.5.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5dbc581d2814337da56222fab8dc5f161cd798a434e49bac27930aaef798e144", size = 59995, upload-time = "2026-05-08T21:00:15.526Z" }, + { url = "https://files.pythonhosted.org/packages/9c/42/32cf8e3009e92b2645cf1e944f701e8ea4e924dffde1ee26db860bcbf7e4/propcache-0.5.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:857187f381f88c8e2fa2fe56ab94879d011b883d5a2ee5a1b60a8cd2a06846d9", size = 63422, upload-time = "2026-05-08T21:00:16.824Z" }, + { url = "https://files.pythonhosted.org/packages/9e/1b/f112433f99fc979431b87a39ef169e3f8df070d99a72792c56d6937ac48b/propcache-0.5.2-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:178b4a2cdaac1818e2bf1c5a99b94383fa73ea5382e032a48dec07dc5668dc42", size = 64342, upload-time = "2026-05-08T21:00:18.362Z" }, + { url = "https://files.pythonhosted.org/packages/14/15/5574111ae50dd6e879456888c0eadd4c5a869959775854e18e18a6b345f3/propcache-0.5.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f328175a2cde1f0ff2c4ed8ce968b9dcfb55f3a7153f39e2957ed994da13476", size = 61639, upload-time = "2026-05-08T21:00:19.692Z" }, + { url = "https://files.pythonhosted.org/packages/cc/da/4d775080b1490c0ae604acda868bd71aabe3a89ed16f2aa4339eb8a283e7/propcache-0.5.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5671d09a36b06d0fd4a3da0fccbcae360e9b1570924171a15e9e0997f0249fba", size = 61588, upload-time = "2026-05-08T21:00:21.155Z" }, + { url = "https://files.pythonhosted.org/packages/04/ac/f076982cbe2195ee9cf32de5a1e46951d9fb399fc207f390562dd0fd8fb2/propcache-0.5.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:80168e2ebe4d3ec6599d10ad8f520304ae1cad9b6c5a95372aef1b66b7bfb53a", size = 60029, upload-time = "2026-05-08T21:00:22.713Z" }, + { url = "https://files.pythonhosted.org/packages/70/60/189be62e0dd898dce3b331e1b8c7a543cd3a405ac0c81fe8ee8a9d5d77e1/propcache-0.5.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:45f11346f884bc47444f6e6647131055844134c3175b629f84952e2b5cd62b64", size = 56774, upload-time = "2026-05-08T21:00:24.001Z" }, + { url = "https://files.pythonhosted.org/packages/ea/9e/93377b9c7939c1ffae98f878dee955efadfd638078bc86dbc21f9d52f651/propcache-0.5.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8e778ebd44ef4f66ed60a0416b06b489687db264a9c0b3620362f26489492913", size = 63532, upload-time = "2026-05-08T21:00:25.545Z" }, + { url = "https://files.pythonhosted.org/packages/14/f9/590ef6cfb9b8028d516d287812ece32bb0bc5f11fbb9c8bf6b2e6313fec8/propcache-0.5.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:c0cb9ed24c8964e172768d455a38254c2dd8a552905729ce006cad3d3dda59b1", size = 61592, upload-time = "2026-05-08T21:00:27.186Z" }, + { url = "https://files.pythonhosted.org/packages/b4/5e/70958b3034c297a630bba2f17ca7abc2d5f39a803ad7e370ab79d1ecd022/propcache-0.5.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:1d1ad32d9d4355e2be65574fd0bfd3677e7066b009cd5b9b2dee8aa6a6393b33", size = 64788, upload-time = "2026-05-08T21:00:28.8Z" }, + { url = "https://files.pythonhosted.org/packages/12/fd/77fe5936d8c3086ca9048f7f415f122ed82e53884a9ec193646b42deef06/propcache-0.5.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c80f4ba3e8f00189165999a742ee526ebeccedf6c3f7beb0c7df821e9772435a", size = 62514, upload-time = "2026-05-08T21:00:30.098Z" }, + { url = "https://files.pythonhosted.org/packages/cf/74/66bd798b5b3be70aa1b391f5cc9d6a0a5532d7fd3b19ec0b213e72e6ad9d/propcache-0.5.2-cp312-cp312-win32.whl", hash = "sha256:8c7972d8f193740d9175f0998ab38717e6cd322d5935c5b0fef8c0d323fd9031", size = 39018, upload-time = "2026-05-08T21:00:31.622Z" }, + { url = "https://files.pythonhosted.org/packages/61/7c/5c0d34aa3024694d6dcb9271cdbdd08c4e47c1c0ad95ec7e7bc74cdea145/propcache-0.5.2-cp312-cp312-win_amd64.whl", hash = "sha256:d9ee8826a7d47863a08ac44e1a5f611a462eefc3a194b492da242128bec75b42", size = 42322, upload-time = "2026-05-08T21:00:32.918Z" }, + { url = "https://files.pythonhosted.org/packages/4d/91/875812f1a3feb20ceba818ef39fbe4d92f1081e04ac815c822496d0d038b/propcache-0.5.2-cp312-cp312-win_arm64.whl", hash = "sha256:2800a4a8ead6b28cccd1ec54b59346f0def7922ee1c7598e8499c733cfbb7c84", size = 38172, upload-time = "2026-05-08T21:00:35.124Z" }, + { url = "https://files.pythonhosted.org/packages/c5/09/f049e45385503fe67db75a6b6186a7b9f0c3930366dc960522c312a825b1/propcache-0.5.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:099aaf4b4d1a02265b92a977edf00b5c4f63b3b17ac6de39b0d637c9cac0188a", size = 94457, upload-time = "2026-05-08T21:00:36.355Z" }, + { url = "https://files.pythonhosted.org/packages/6b/65/83d1d05655baf63113731bd5a1008435e14f8d1e5a06cbe4ec5b23ad7a31/propcache-0.5.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:68ce1c44c7a813a7f71ea04315a8c7b330b63db99d059a797a4651bb6f69f117", size = 53835, upload-time = "2026-05-08T21:00:38.072Z" }, + { url = "https://files.pythonhosted.org/packages/a9/12/a6ba6482bb5ea3260c000c9b20881c95fa11c6b30173715668259f844ed7/propcache-0.5.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fc299c129490f55f254cd90be0deca4764e36e9a7c08b4aa588479a3bbed3098", size = 54545, upload-time = "2026-05-08T21:00:39.319Z" }, + { url = "https://files.pythonhosted.org/packages/a9/19/7fa086f5764c59ec8a8e157cd93aa8497acc00aba9dcdec56bfffb32602d/propcache-0.5.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a6ae2198be502c10f09b2516e7b5d019816924bc3183a43ce792a7bd6625e6f4", size = 59886, upload-time = "2026-05-08T21:00:40.621Z" }, + { url = "https://files.pythonhosted.org/packages/a1/e4/5d7663dc8235956c8f5281698a3af1d351d8820341ddd890f59d9a9127f2/propcache-0.5.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6041d31504dc1779d700e1edcfb08eea334b357620b06681a4eabb57a74e574e", size = 63261, upload-time = "2026-05-08T21:00:41.775Z" }, + { url = "https://files.pythonhosted.org/packages/4a/4a/15a03adee24d6350da4292caeac44c34c033d2afe5e87eb370f38854560f/propcache-0.5.2-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f7eabc04151c78a9f4d5bbb5f1faf571e4defeb4b585e0fe95b60ff2dbe4d3d7", size = 64184, upload-time = "2026-05-08T21:00:43.018Z" }, + { url = "https://files.pythonhosted.org/packages/8b/c6/979176efdaa3d239e36d503d5af63a0a773b36662ed8f52e5b6a6d9fd40e/propcache-0.5.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4db0ba63d693afd40d249bd93f842b5f144f8fcbb83de05660373bcf30517b1d", size = 61534, upload-time = "2026-05-08T21:00:44.507Z" }, + { url = "https://files.pythonhosted.org/packages/c8/22/63e8cd1bae4c2d2be6493b6b7d10566ddafad88137cfbc99964a1119853c/propcache-0.5.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1dbcf7675229b35d31abb6547d8ebc8c27a830ac3f9a794edff6254873ec7c0a", size = 61500, upload-time = "2026-05-08T21:00:45.796Z" }, + { url = "https://files.pythonhosted.org/packages/60/5a/28e5d9acbac1cc9ccb67045e8c1b943aa8d79fdf39c93bd73cacd68008ea/propcache-0.5.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d310c013aad2c72f1c3f2f8dd3279d460a858c551f97aeb8c63e4693cca7b4d2", size = 59994, upload-time = "2026-05-08T21:00:47.093Z" }, + { url = "https://files.pythonhosted.org/packages/f3/40/db650677f554a95b9c01a7c9d93d629e93a15562f5deb4573c9ee136fed2/propcache-0.5.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:06187263ddad280d05b4d8a8b3bb7d164cbebd469236544a42e6d9b28ac6a4fa", size = 56884, upload-time = "2026-05-08T21:00:48.376Z" }, + { url = "https://files.pythonhosted.org/packages/80/45/70b39b89516ff8b96bf732fa6fded8cef20f293cb1508690101c3c07ec51/propcache-0.5.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3115559b8effafd63b142ea5ed53d63a16ea6469cbc63dce4ee194b42db5d853", size = 63464, upload-time = "2026-05-08T21:00:49.954Z" }, + { url = "https://files.pythonhosted.org/packages/f9/e2/fa59d3a89eac5534293124af4f1d0d0ada091ce4a0ab4610ce03fd2bdd8d/propcache-0.5.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c60462af8e6dc30c35407c7237ea908d777b22862bbee27bc4699c0d8bcdc45a", size = 61588, upload-time = "2026-05-08T21:00:51.281Z" }, + { url = "https://files.pythonhosted.org/packages/0b/97/efb547a55c4bc7381cfb202d6a2239ac621045277bc1ea5dfd3a7f0516c0/propcache-0.5.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:40314bca9ac559716fe374094fc81c11dcc34b64fd6c585360f5775690505704", size = 64667, upload-time = "2026-05-08T21:00:52.602Z" }, + { url = "https://files.pythonhosted.org/packages/92/56/f5c7d9b4b7595d5127da38974d791b2153f3d1eae6c674af3583ace92ad3/propcache-0.5.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:cfa21e036ce1e1db2be04ba3b85d2df1bb1702fa01932d984c5464c665228ff4", size = 62463, upload-time = "2026-05-08T21:00:54.303Z" }, + { url = "https://files.pythonhosted.org/packages/bd/3b/484a3a65fc9f9f60c41dcd17b428bace5389544e2c680994534a20755066/propcache-0.5.2-cp313-cp313-win32.whl", hash = "sha256:f156a3529f38063b6dbaf356e15602a7f95f8055b1295a438433a6386f10463d", size = 38621, upload-time = "2026-05-08T21:00:55.808Z" }, + { url = "https://files.pythonhosted.org/packages/1c/fd/3f0f10dba4dabad3bf53102be007abf55481067952bde0fdddff439e7c61/propcache-0.5.2-cp313-cp313-win_amd64.whl", hash = "sha256:dfed59d0a5aeb01e242e66ff0300bc4a265a7c05f612d30016f0b60b1017d757", size = 41649, upload-time = "2026-05-08T21:00:57.061Z" }, + { url = "https://files.pythonhosted.org/packages/90/ec/6ce619cc32bb500a482f811f9cd509368b4e58e638d13f2c68f370d6b475/propcache-0.5.2-cp313-cp313-win_arm64.whl", hash = "sha256:ba338430e87ceb9c8f0cf754de38a9860560261e56c00376debd628698a7364f", size = 37636, upload-time = "2026-05-08T21:00:58.646Z" }, + { url = "https://files.pythonhosted.org/packages/1b/82/c1d268bbbf2ef981c5bf0fbbe746db617c66e3bcefe431a1aa8943fbe23a/propcache-0.5.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:a592f5f3da71c8691c788c13cb6734b6d17663d2e1cb8caddf0673d01ef8847d", size = 98872, upload-time = "2026-05-08T21:00:59.889Z" }, + { url = "https://files.pythonhosted.org/packages/f4/d4/52c871e73e864e6b34c0e2d58ac1ec5ccd149497ddc7ad2137ae98323a35/propcache-0.5.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:6a997d0489e9668a384fcfd5061b857aa5361de73191cac204d04b889cfbbafa", size = 56257, upload-time = "2026-05-08T21:01:01.195Z" }, + { url = "https://files.pythonhosted.org/packages/67/f0/9b90ca2a210b3d09bcfcd96ecd0f55545c091535abce2a45de2775cfd357/propcache-0.5.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:10734b5484ea113152ee25a91dccedf81631791805d2c9ccb054958e51842c94", size = 56696, upload-time = "2026-05-08T21:01:02.941Z" }, + { url = "https://files.pythonhosted.org/packages/9d/0e/6e9d4ba07c8e56e21ddec1e75f12148142b21ca83a51871babce095334f4/propcache-0.5.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cafca7e56c12bb02ae16d283742bef25a61122e9dab2b5b3f2ccbe589ce32164", size = 62378, upload-time = "2026-05-08T21:01:04.475Z" }, + { url = "https://files.pythonhosted.org/packages/65/19/c10badaa463dde8a27ce884f8ee2ec37e6035b7c9f5ff0c8f74f06f08dac/propcache-0.5.2-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f064f8d2b59177878b7615df1735cd8fe3462ed6be8c7b217d17a276489c2b7f", size = 65283, upload-time = "2026-05-08T21:01:05.959Z" }, + { url = "https://files.pythonhosted.org/packages/b0/b6/93bea99ca80e19cef6512a8580e5b7857bbe09422d9daa7fd4ef5723306c/propcache-0.5.2-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f78abfa8dfc32376fd1aacf597b2f2fbbe0ea751419aee718af5d4f82537ef8c", size = 66616, upload-time = "2026-05-08T21:01:07.228Z" }, + { url = "https://files.pythonhosted.org/packages/83/e4/5c7462e50625f051f37fb38b8224f7639f667184bbd34424ec83819bb1b7/propcache-0.5.2-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f7467da8a9822bf1a55336f877340c5bcbd3c482afc43a99771169f74a26dedc", size = 63773, upload-time = "2026-05-08T21:01:08.514Z" }, + { url = "https://files.pythonhosted.org/packages/ca/b6/99238894047b13c823be25027e736626cd414a52a5e30d2c3347c2733529/propcache-0.5.2-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a6ddc6ac9e25de626c1f129c1b467d7ecd33ce2237d3fd0c4e429feef0a7ee1f", size = 63664, upload-time = "2026-05-08T21:01:09.874Z" }, + { url = "https://files.pythonhosted.org/packages/85/1e/a3a1a63116a2b8edb415a8bb9a6f0c34bd03830b1e18e8ce2904e1dc1cf4/propcache-0.5.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2f22cbbac9e26a8e864c0985ff1268d5d939d53d9d9411a9824279097e03a2cb", size = 62643, upload-time = "2026-05-08T21:01:11.132Z" }, + { url = "https://files.pythonhosted.org/packages/e4/03/893cf147de2fc6543c5eaa07ad833170e7e2a2385725bbebe8c0503723bb/propcache-0.5.2-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:fc76378c62a0f04d0cd82fbb1a2cd2d7e28fcb40d5873f28a6c44e388aaa2751", size = 59595, upload-time = "2026-05-08T21:01:12.387Z" }, + { url = "https://files.pythonhosted.org/packages/86/3b/04c1a2e12c57766568ba75ba72b3bf2042818d4c1425fab6fc07155c7cff/propcache-0.5.2-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:acd2c8edba48e31e58a363b8cf4e5c7db3b04b3f9e371f601df30d9b0d244836", size = 65711, upload-time = "2026-05-08T21:01:13.676Z" }, + { url = "https://files.pythonhosted.org/packages/1c/34/80f8d0099f8d6bacc4de1624c85672681c8cd1149ca2da0e38fd120b817f/propcache-0.5.2-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:452b5065457eb9991ec5eb38ff41d6cd4c991c9ac7c531c4d5849ae473a9a13f", size = 64247, upload-time = "2026-05-08T21:01:14.936Z" }, + { url = "https://files.pythonhosted.org/packages/f3/1a/8b08f3a5f1037e9e370c55883ceeeee0f6dd0416fb2d2d67b8bfc91f2a79/propcache-0.5.2-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:3430bb2bfe1331885c427745a751e774ee679fd4344f80b97bf879815fe8fa55", size = 67102, upload-time = "2026-05-08T21:01:16.281Z" }, + { url = "https://files.pythonhosted.org/packages/34/68/8bdb7bb7756d76e005490649d10e4a8369e610c74d619f71e1aedf889e9c/propcache-0.5.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:cef6cea3922890dd6c9654971001fa797b526c16ab5e1e46c05fd6f877be7568", size = 64964, upload-time = "2026-05-08T21:01:17.57Z" }, + { url = "https://files.pythonhosted.org/packages/0a/aa/50fb0b5d3968b61a510926ff8b8465f1d6e976b3ab74496d7a4b9fc42515/propcache-0.5.2-cp313-cp313t-win32.whl", hash = "sha256:72d61e16dd78228b58c5d47be830ff3da7e5f139abdf0aef9d86cde1c5cf2191", size = 42546, upload-time = "2026-05-08T21:01:18.946Z" }, + { url = "https://files.pythonhosted.org/packages/ae/4c/0ddbae64321bd4a95bcbfc19307238016b5b1fee645c84626c8d539e5b74/propcache-0.5.2-cp313-cp313t-win_amd64.whl", hash = "sha256:0958834041a0166d343b8d2cedcd8bcbaeb4fdbe0cf08320c5379f143c3be6e7", size = 46330, upload-time = "2026-05-08T21:01:20.162Z" }, + { url = "https://files.pythonhosted.org/packages/00/d9/9cddc8efb78d8af264c5ec9f6d10b62f57c515feda8d321595f56010fb23/propcache-0.5.2-cp313-cp313t-win_arm64.whl", hash = "sha256:6de8bd93ddde9b992cf2b2e0d796d501a19026b5b9fd87356d7d0779531a8d96", size = 40521, upload-time = "2026-05-08T21:01:21.399Z" }, + { url = "https://files.pythonhosted.org/packages/e2/ea/23ee535d90ce8bcc465a3028eb3cc0ce3bd1005f4bb27710b30587de798d/propcache-0.5.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:46088abff4cba581dea21ae0467a480526cb25aa5f3c269e909f800328bc3999", size = 94662, upload-time = "2026-05-08T21:01:22.683Z" }, + { url = "https://files.pythonhosted.org/packages/b5/06/c5a52f419b5d8972f8d46a7577476090d8e3263ff589ce40b5ca4968d5be/propcache-0.5.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fc88b26f08d634f7bc819a7852e5214f5802641ab8d9fd5326892292eee1993e", size = 53928, upload-time = "2026-05-08T21:01:23.986Z" }, + { url = "https://files.pythonhosted.org/packages/63/b1/4260d67d6bd85e58a66b72d54ce15d5de789b6f3870cc6bedf8ff9667401/propcache-0.5.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:97797ebb098e670a2f92dd66f32897e30d7615b14e7f59711de23e30a9072539", size = 54650, upload-time = "2026-05-08T21:01:25.305Z" }, + { url = "https://files.pythonhosted.org/packages/70/06/2f46c318e3307cd7a6a7481def374ce838c0fe20084b39dd54b0879d0e99/propcache-0.5.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ba57fffe4ac99c5d30076161b5866336d97600769bad35cc68f7774b15298a4e", size = 59912, upload-time = "2026-05-08T21:01:26.545Z" }, + { url = "https://files.pythonhosted.org/packages/4c/29/fe1aebec2ce57ab985a9c382bded1124431f85078113aa222c5d278430d4/propcache-0.5.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:583c19759d9eec1e5b69e2fbef36a7d9c326041be9746cb822d335c8cedc2979", size = 63300, upload-time = "2026-05-08T21:01:27.937Z" }, + { url = "https://files.pythonhosted.org/packages/b4/18/2334b26768b6c82be8c69e83671b767d5ef426aa09b0cba6c2ea47816774/propcache-0.5.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d0326e2e5e1f3163fa306c834e48e8d490e5fae607a097a40c0648109b47ba80", size = 64208, upload-time = "2026-05-08T21:01:29.484Z" }, + { url = "https://files.pythonhosted.org/packages/2b/76/7f1bfd6afff4c5e38e36a3c6d68eb5f4b7311ea80baf693db78d95b603c4/propcache-0.5.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e00820e192c8dbebcafb383ebbf99030895f09905e7a0eb2e0340a0bcc2bc825", size = 61633, upload-time = "2026-05-08T21:01:31.068Z" }, + { url = "https://files.pythonhosted.org/packages/c4/46/b3ff8aba2b4953a3e50de2cf72f1b5748b8eca93b15f3dc2c84339084c09/propcache-0.5.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c66afea89b1e43725731d2004732a046fe6fe955d51f952c3e95a7314a284a39", size = 61724, upload-time = "2026-05-08T21:01:32.374Z" }, + { url = "https://files.pythonhosted.org/packages/c5/01/814cfcafbcff954f94c01cf30e097ddc88a076b5440fbcf4570753437d40/propcache-0.5.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d4dc37dec6c6cdad0b57881a5658fd14fbf53e333b1a86cf86559f190e1d9ec4", size = 60069, upload-time = "2026-05-08T21:01:33.67Z" }, + { url = "https://files.pythonhosted.org/packages/da/68/5c6f7622d510cc666a300687e06fd060c1a43361c0c9b20d284f06d8096a/propcache-0.5.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:5570dbcc97571c15f68068e529c92715a12f8d54030e272d264b377e22bd17a5", size = 57099, upload-time = "2026-05-08T21:01:34.915Z" }, + { url = "https://files.pythonhosted.org/packages/55/27/9cb0b4c679124085327957d42521c99dba04c88c90c3e55a6f0b633ebccc/propcache-0.5.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f814362777a9f841adddb200ecdf8f5cb1e5a3c4b7a86378edbd6ccb26edd702", size = 63391, upload-time = "2026-05-08T21:01:36.231Z" }, + { url = "https://files.pythonhosted.org/packages/f0/9d/7258aaa5bdf60fc6f27591eef6fe52768cb0beda7140be477c8b12c9794a/propcache-0.5.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:196913dea116aeb5a2ba95af4ddcb7ea85559ae07d8eee8751688310d09168c3", size = 61626, upload-time = "2026-05-08T21:01:37.545Z" }, + { url = "https://files.pythonhosted.org/packages/8e/0d/41c602003e8a9b16fe1e7eadf62c7bfba9d5474370b24200bf48b315f45f/propcache-0.5.2-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:6e7b8719005dd1175be4ab1cd25e9b98659a5e0347331506ec6760d2773a7fb5", size = 64781, upload-time = "2026-05-08T21:01:38.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f3/38e66b1856e9bd079deea015bc4a55f7767c0e4db2f7dcf69e7e680ba4ce/propcache-0.5.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:51f96d685ab16e88cab128cd37a52c5da540809c8b879fa047731bfcb4ad35a4", size = 62570, upload-time = "2026-05-08T21:01:40.415Z" }, + { url = "https://files.pythonhosted.org/packages/95/ca/bbfe9b910ce57dde8bb4876b4520fc02a4e89497c10de26be936758a3aaa/propcache-0.5.2-cp314-cp314-win32.whl", hash = "sha256:cc6fc3cc62e8501d3ed62894425040d2728ecddb1ed072737a5c70bd537aa9f0", size = 39436, upload-time = "2026-05-08T21:01:41.654Z" }, + { url = "https://files.pythonhosted.org/packages/61/d2/45c9defbaa1ea297035d9d4cce9e8f80daafbf19319c6007f157c6256ea9/propcache-0.5.2-cp314-cp314-win_amd64.whl", hash = "sha256:81e3a30b0bb60caa22033dd0f8a3618d1d67356212514f62c57db75cb0ef410c", size = 42373, upload-time = "2026-05-08T21:01:43.041Z" }, + { url = "https://files.pythonhosted.org/packages/44/68/9ea5103f41d5217d7d6ec24db90018e23aebec070c3f9a6e54d12b841fd8/propcache-0.5.2-cp314-cp314-win_arm64.whl", hash = "sha256:0d2c9bf8528f135dbb805ce027567e09164f7efa51a2be07458a2c0420f292d0", size = 38554, upload-time = "2026-05-08T21:01:44.336Z" }, + { url = "https://files.pythonhosted.org/packages/8a/81/fadf555f42d3b762eea8a53950b0489fdc0aa9da5f8ed9e10ce0a4e01b48/propcache-0.5.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:4bc8ff1feffc6a61c7002ffe84634c41b822e104990ae009f44a0834430070bb", size = 99395, upload-time = "2026-05-08T21:01:45.883Z" }, + { url = "https://files.pythonhosted.org/packages/f5/c9/c61e134a686949cf7971af3a390148b1156f7be81c73bc0cd12c873e2d48/propcache-0.5.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:79aa3ff0a9b566633b642fa9caf7e21ed1c13d6feca718187873f199e1514078", size = 56653, upload-time = "2026-05-08T21:01:47.307Z" }, + { url = "https://files.pythonhosted.org/packages/cb/73/daf935ea7048ddd7ec8eec5345b4a40b619d2d178b3c0a0900796bc3c794/propcache-0.5.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1b31822f4474c4036bae62de9402710051d431a606d6a0f907fec79935a071aa", size = 56914, upload-time = "2026-05-08T21:01:48.573Z" }, + { url = "https://files.pythonhosted.org/packages/79/9f/aba959b435ea18617edd7cf0a7ad0b9c574b8fc7e3d2cd55fb59cb255d33/propcache-0.5.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:13fef48778b5a2a756523fdb781326b028ca75e32858b04f2cdd19f394564917", size = 62567, upload-time = "2026-05-08T21:01:49.903Z" }, + { url = "https://files.pythonhosted.org/packages/6c/a1/859942de9a791ff42f6141736f5b37749b8f53e65edfa49638c67dd67e6a/propcache-0.5.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8b73ab70f1a3351fbc71f663b3e645af6dd0329100c353081cf69c37433fc6fe", size = 65542, upload-time = "2026-05-08T21:01:51.204Z" }, + { url = "https://files.pythonhosted.org/packages/b5/61/315bc0fd6c0fc7f80a528b8afd209e5fc4a875ea79571b91b8f50f442907/propcache-0.5.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5538d2c13d93e4698af7e092b57bc7298fd35d1d58e656ae18f23ee0d0378e03", size = 66845, upload-time = "2026-05-08T21:01:52.539Z" }, + { url = "https://files.pythonhosted.org/packages/47/f7/9f8122e3132e8e354ac41975ef8f1099be7d5a16bc7ae562734e993665c0/propcache-0.5.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cd645f03898405cabe694fb8bc35241e3a9c332ec85627584fe3de201452b335", size = 63985, upload-time = "2026-05-08T21:01:53.847Z" }, + { url = "https://files.pythonhosted.org/packages/c8/54/c317819ec157cbf6f35df9df9657a6f82daf34d5faf15948b2f639c2192e/propcache-0.5.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a473b3440261e0c60706e732b2ed2f517857344fc21bf48fdfe211e2d98eb285", size = 63999, upload-time = "2026-05-08T21:01:55.179Z" }, + { url = "https://files.pythonhosted.org/packages/5a/56/387e3f7dfce0a9233df41fb888aa1c30222cb4bbbf09537c02dd9bd85fe2/propcache-0.5.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7afa37062e6650640e932e4cc9297d81f9f42d9944029cc386b8247dea4da837", size = 62779, upload-time = "2026-05-08T21:01:57.489Z" }, + { url = "https://files.pythonhosted.org/packages/a1/9c/596784cb5824ed61ee960d3f8655a3f0993e107c6e98ab6c818b7fb92ccb/propcache-0.5.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:8a90efd5777e996e42d568db9ac740b944d691e565cbfd31b2f7832f9184b2b8", size = 59796, upload-time = "2026-05-08T21:01:58.736Z" }, + { url = "https://files.pythonhosted.org/packages/c2/3d/1a6cfa1726a48542c1e8784a0761421476a5b68e09b7f36bf95eb954aaba/propcache-0.5.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:f19bb891234d72535764d703bfed1153cc34f4214d5bd7150aee1eec9e8f4366", size = 66023, upload-time = "2026-05-08T21:02:00.228Z" }, + { url = "https://files.pythonhosted.org/packages/e4/0e/05fd6990369477076e4e280bcb970de760fddf0161a46e988bc95f7940ec/propcache-0.5.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:32775082acd2d807ee3db715c7770d38767b817870acfa08c29e057f3c4d5b56", size = 64448, upload-time = "2026-05-08T21:02:01.888Z" }, + { url = "https://files.pythonhosted.org/packages/cd/86/5f8da315a4309c62c10c0b2516b17492d5d3bbe1bb862b96604db67e2a37/propcache-0.5.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9282fb1a3bccd038da9f768b927b24a0c753e466c086b7c4f3c6982851eefb2d", size = 67329, upload-time = "2026-05-08T21:02:03.484Z" }, + { url = "https://files.pythonhosted.org/packages/da/d3/3368efe79ab21f0cdf86ef49895811c9cc933131d4cde1f28a624e22e712/propcache-0.5.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cc49723e2f60d6b32a0f0b08a3fd6d13203c07f1cd9566cfce0f12a917c967a2", size = 65172, upload-time = "2026-05-08T21:02:04.745Z" }, + { url = "https://files.pythonhosted.org/packages/d5/07/127e8b0bacfb325396196f9d976a22453049b89b9b2b08477cc3145faa44/propcache-0.5.2-cp314-cp314t-win32.whl", hash = "sha256:2d7aa89ebca5acc98cba9d1472d976e394782f587bad6661003602a619fd1821", size = 43813, upload-time = "2026-05-08T21:02:06.025Z" }, + { url = "https://files.pythonhosted.org/packages/88/fb/46dad6c0ae49ed230ab1b16c890c2b6314e2403e6c412976f4a72d64a527/propcache-0.5.2-cp314-cp314t-win_amd64.whl", hash = "sha256:d447bb0b3054be5818458fbb171208b1d9ff11eba14e18ca18b90cbb45767370", size = 47764, upload-time = "2026-05-08T21:02:07.353Z" }, + { url = "https://files.pythonhosted.org/packages/e7/c4/a47d0a63aa309d10d59ede6e9d4cff03a344a79d1f0f4cd0cd74997b53e0/propcache-0.5.2-cp314-cp314t-win_arm64.whl", hash = "sha256:fe67a3d11cd9b4efabfa45c3d00ffba2b26811442a73a581a94b67c2b5faccf6", size = 41140, upload-time = "2026-05-08T21:02:09.065Z" }, + { url = "https://files.pythonhosted.org/packages/3a/ed/1cdcab6ba3d6ab7feca11fc14f0eeea80755bb53ef4e892079f31b10a25f/propcache-0.5.2-py3-none-any.whl", hash = "sha256:be1ddfcbb376e3de5d2e2db1d58d6d67463e6b4f9f040c000de8e300295465fe", size = 14036, upload-time = "2026-05-08T21:02:10.673Z" }, +] + +[[package]] +name = "protobuf" +version = "7.34.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6b/6b/a0e95cad1ad7cc3f2c6821fcab91671bd5b78bd42afb357bb4765f29bc41/protobuf-7.34.1.tar.gz", hash = "sha256:9ce42245e704cc5027be797c1db1eb93184d44d1cdd71811fb2d9b25ad541280", size = 454708, upload-time = "2026-03-20T17:34:47.036Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/11/3325d41e6ee15bf1125654301211247b042563bcc898784351252549a8ad/protobuf-7.34.1-cp310-abi3-macosx_10_9_universal2.whl", hash = "sha256:d8b2cc79c4d8f62b293ad9b11ec3aebce9af481fa73e64556969f7345ebf9fc7", size = 429247, upload-time = "2026-03-20T17:34:37.024Z" }, + { url = "https://files.pythonhosted.org/packages/eb/9d/aa69df2724ff63efa6f72307b483ce0827f4347cc6d6df24b59e26659fef/protobuf-7.34.1-cp310-abi3-manylinux2014_aarch64.whl", hash = "sha256:5185e0e948d07abe94bb76ec9b8416b604cfe5da6f871d67aad30cbf24c3110b", size = 325753, upload-time = "2026-03-20T17:34:38.751Z" }, + { url = "https://files.pythonhosted.org/packages/92/e8/d174c91fd48e50101943f042b09af9029064810b734e4160bbe282fa1caa/protobuf-7.34.1-cp310-abi3-manylinux2014_s390x.whl", hash = "sha256:403b093a6e28a960372b44e5eb081775c9b056e816a8029c61231743d63f881a", size = 340198, upload-time = "2026-03-20T17:34:39.871Z" }, + { url = "https://files.pythonhosted.org/packages/53/1b/3b431694a4dc6d37b9f653f0c64b0a0d9ec074ee810710c0c3da21d67ba7/protobuf-7.34.1-cp310-abi3-manylinux2014_x86_64.whl", hash = "sha256:8ff40ce8cd688f7265326b38d5a1bed9bfdf5e6723d49961432f83e21d5713e4", size = 324267, upload-time = "2026-03-20T17:34:41.1Z" }, + { url = "https://files.pythonhosted.org/packages/85/29/64de04a0ac142fb685fd09999bc3d337943fb386f3a0ec57f92fd8203f97/protobuf-7.34.1-cp310-abi3-win32.whl", hash = "sha256:34b84ce27680df7cca9f231043ada0daa55d0c44a2ddfaa58ec1d0d89d8bf60a", size = 426628, upload-time = "2026-03-20T17:34:42.536Z" }, + { url = "https://files.pythonhosted.org/packages/4d/87/cb5e585192a22b8bd457df5a2c16a75ea0db9674c3a0a39fc9347d84e075/protobuf-7.34.1-cp310-abi3-win_amd64.whl", hash = "sha256:e97b55646e6ce5cbb0954a8c28cd39a5869b59090dfaa7df4598a7fba869468c", size = 437901, upload-time = "2026-03-20T17:34:44.112Z" }, + { url = "https://files.pythonhosted.org/packages/88/95/608f665226bca68b736b79e457fded9a2a38c4f4379a4a7614303d9db3bc/protobuf-7.34.1-py3-none-any.whl", hash = "sha256:bb3812cd53aefea2b028ef42bd780f5b96407247f20c6ef7c679807e9d188f11", size = 170715, upload-time = "2026-03-20T17:34:45.384Z" }, ] [[package]] @@ -2793,7 +2908,7 @@ wheels = [ [[package]] name = "pydantic" -version = "2.13.3" +version = "2.13.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-types" }, @@ -2801,125 +2916,125 @@ dependencies = [ { name = "typing-extensions" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d9/e4/40d09941a2cebcb20609b86a559817d5b9291c49dd6f8c87e5feffbe703a/pydantic-2.13.3.tar.gz", hash = "sha256:af09e9d1d09f4e7fe37145c1f577e1d61ceb9a41924bf0094a36506285d0a84d", size = 844068, upload-time = "2026-04-20T14:46:43.632Z" } +sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f3/0a/fd7d723f8f8153418fb40cf9c940e82004fce7e987026b08a68a36dd3fe7/pydantic-2.13.3-py3-none-any.whl", hash = "sha256:6db14ac8dfc9a1e57f87ea2c0de670c251240f43cb0c30a5130e9720dc612927", size = 471981, upload-time = "2026-04-20T14:46:41.402Z" }, + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, ] [[package]] name = "pydantic-core" -version = "2.46.3" +version = "2.46.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/2a/ef/f7abb56c49382a246fd2ce9c799691e3c3e7175ec74b14d99e798bcddb1a/pydantic_core-2.46.3.tar.gz", hash = "sha256:41c178f65b8c29807239d47e6050262eb6bf84eb695e41101e62e38df4a5bc2c", size = 471412, upload-time = "2026-04-20T14:40:56.672Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/22/98/b50eb9a411e87483b5c65dba4fa430a06bac4234d3403a40e5a9905ebcd0/pydantic_core-2.46.3-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:1da3786b8018e60349680720158cc19161cc3b4bdd815beb0a321cd5ce1ad5b1", size = 2108971, upload-time = "2026-04-20T14:43:51.945Z" }, - { url = "https://files.pythonhosted.org/packages/08/4b/f364b9d161718ff2217160a4b5d41ce38de60aed91c3689ebffa1c939d23/pydantic_core-2.46.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:cc0988cb29d21bf4a9d5cf2ef970b5c0e38d8d8e107a493278c05dc6c1dda69f", size = 1949588, upload-time = "2026-04-20T14:44:10.386Z" }, - { url = "https://files.pythonhosted.org/packages/8f/8b/30bd03ee83b2f5e29f5ba8e647ab3c456bf56f2ec72fdbcc0215484a0854/pydantic_core-2.46.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:27f9067c3bfadd04c55484b89c0d267981b2f3512850f6f66e1e74204a4e4ce3", size = 1975986, upload-time = "2026-04-20T14:43:57.106Z" }, - { url = "https://files.pythonhosted.org/packages/3c/54/13ccf954d84ec275d5d023d5786e4aa48840bc9f161f2838dc98e1153518/pydantic_core-2.46.3-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a642ac886ecf6402d9882d10c405dcf4b902abeb2972cd5fb4a48c83cd59279a", size = 2055830, upload-time = "2026-04-20T14:44:15.499Z" }, - { url = "https://files.pythonhosted.org/packages/be/0e/65f38125e660fdbd72aa858e7dfae893645cfa0e7b13d333e174a367cd23/pydantic_core-2.46.3-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:79f561438481f28681584b89e2effb22855e2179880314bcddbf5968e935e807", size = 2222340, upload-time = "2026-04-20T14:41:51.353Z" }, - { url = "https://files.pythonhosted.org/packages/d1/88/f3ab7739efe0e7e80777dbb84c59eb98518e3f57ea433206194c2e425272/pydantic_core-2.46.3-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:57a973eae4665352a47cf1a99b4ee864620f2fe663a217d7a8da68a1f3a5bfda", size = 2280727, upload-time = "2026-04-20T14:41:30.461Z" }, - { url = "https://files.pythonhosted.org/packages/2a/6d/c228219080817bec4982f9531cadb18da6aaa770fdeb114f49c237ac2c9f/pydantic_core-2.46.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:83d002b97072a53ea150d63e0a3adfae5670cef5aa8a6e490240e482d3b22e57", size = 2092158, upload-time = "2026-04-20T14:44:07.305Z" }, - { url = "https://files.pythonhosted.org/packages/0f/b1/525a16711e7c6d61635fac3b0bd54600b5c5d9f60c6fc5aaab26b64a2297/pydantic_core-2.46.3-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:b40ddd51e7c44b28cfaef746c9d3c506d658885e0a46f9eeef2ee815cbf8e045", size = 2116626, upload-time = "2026-04-20T14:42:34.118Z" }, - { url = "https://files.pythonhosted.org/packages/ef/7c/17d30673351439a6951bf54f564cf2443ab00ae264ec9df00e2efd710eb5/pydantic_core-2.46.3-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ac5ec7fb9b87f04ee839af2d53bcadea57ded7d229719f56c0ed895bff987943", size = 2160691, upload-time = "2026-04-20T14:41:14.023Z" }, - { url = "https://files.pythonhosted.org/packages/86/66/af8adbcbc0886ead7f1a116606a534d75a307e71e6e08226000d51b880d2/pydantic_core-2.46.3-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:a3b11c812f61b3129c4905781a2601dfdfdea5fe1e6c1cfb696b55d14e9c054f", size = 2182543, upload-time = "2026-04-20T14:40:48.886Z" }, - { url = "https://files.pythonhosted.org/packages/b0/37/6de71e0f54c54a4190010f57deb749e1ddf75c568ada3b1320b70067f121/pydantic_core-2.46.3-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:1108da631e602e5b3c38d6d04fe5bb3bfa54349e6918e3ca6cf570b2e2b2f9d4", size = 2324513, upload-time = "2026-04-20T14:42:36.121Z" }, - { url = "https://files.pythonhosted.org/packages/51/b1/9fc74ce94f603d5ef59ff258ca9c2c8fb902fb548d340a96f77f4d1c3b7f/pydantic_core-2.46.3-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:de885175515bcfa98ae618c1df7a072f13d179f81376c8007112af20567fd08a", size = 2361853, upload-time = "2026-04-20T14:43:24.886Z" }, - { url = "https://files.pythonhosted.org/packages/40/d0/4c652fc592db35f100279ee751d5a145aca1b9a7984b9684ba7c1b5b0535/pydantic_core-2.46.3-cp310-cp310-win32.whl", hash = "sha256:d11058e3201527d41bc6b545c79187c9e4bf85e15a236a6007f0e991518882b7", size = 1980465, upload-time = "2026-04-20T14:44:46.239Z" }, - { url = "https://files.pythonhosted.org/packages/27/b8/a920453c38afbe1f355e1ea0b0d94a0a3e0b0879d32d793108755fa171d5/pydantic_core-2.46.3-cp310-cp310-win_amd64.whl", hash = "sha256:3612edf65c8ea67ac13616c4d23af12faef1ae435a8a93e5934c2a0cbbdd1fd6", size = 2073884, upload-time = "2026-04-20T14:43:01.201Z" }, - { url = "https://files.pythonhosted.org/packages/22/a2/1ba90a83e85a3f94c796b184f3efde9c72f2830dcda493eea8d59ba78e6d/pydantic_core-2.46.3-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:ab124d49d0459b2373ecf54118a45c28a1e6d4192a533fbc915e70f556feb8e5", size = 2106740, upload-time = "2026-04-20T14:41:20.932Z" }, - { url = "https://files.pythonhosted.org/packages/b6/f6/99ae893c89a0b9d3daec9f95487aa676709aa83f67643b3f0abaf4ab628a/pydantic_core-2.46.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:cca67d52a5c7a16aed2b3999e719c4bcf644074eac304a5d3d62dd70ae7d4b2c", size = 1948293, upload-time = "2026-04-20T14:43:42.115Z" }, - { url = "https://files.pythonhosted.org/packages/3e/b8/2e8e636dc9e3f16c2e16bf0849e24be82c5ee82c603c65fc0326666328fc/pydantic_core-2.46.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5c024e08c0ba23e6fd68c771a521e9d6a792f2ebb0fa734296b36394dc30390e", size = 1973222, upload-time = "2026-04-20T14:41:57.841Z" }, - { url = "https://files.pythonhosted.org/packages/34/36/0e730beec4d83c5306f417afbd82ff237d9a21e83c5edf675f31ed84c1fe/pydantic_core-2.46.3-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6645ce7eec4928e29a1e3b3d5c946621d105d3e79f0c9cddf07c2a9770949287", size = 2053852, upload-time = "2026-04-20T14:40:43.077Z" }, - { url = "https://files.pythonhosted.org/packages/4b/f0/3071131f47e39136a17814576e0fada9168569f7f8c0e6ac4d1ede6a4958/pydantic_core-2.46.3-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a712c7118e6c5ea96562f7b488435172abb94a3c53c22c9efc1412264a45cbbe", size = 2221134, upload-time = "2026-04-20T14:43:03.349Z" }, - { url = "https://files.pythonhosted.org/packages/2f/a9/a2dc023eec5aa4b02a467874bad32e2446957d2adcab14e107eab502e978/pydantic_core-2.46.3-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:69a868ef3ff206343579021c40faf3b1edc64b1cc508ff243a28b0a514ccb050", size = 2279785, upload-time = "2026-04-20T14:41:19.285Z" }, - { url = "https://files.pythonhosted.org/packages/0a/44/93f489d16fb63fbd41c670441536541f6e8cfa1e5a69f40bc9c5d30d8c90/pydantic_core-2.46.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cc7e8c32db809aa0f6ea1d6869ebc8518a65d5150fdfad8bcae6a49ae32a22e2", size = 2089404, upload-time = "2026-04-20T14:43:10.108Z" }, - { url = "https://files.pythonhosted.org/packages/2a/78/8692e3aa72b2d004f7a5d937f1dfdc8552ba26caf0bec75f342c40f00dec/pydantic_core-2.46.3-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:3481bd1341dc85779ee506bc8e1196a277ace359d89d28588a9468c3ecbe63fa", size = 2114898, upload-time = "2026-04-20T14:44:51.475Z" }, - { url = "https://files.pythonhosted.org/packages/6a/62/e83133f2e7832532060175cebf1f13748f4c7e7e7165cdd1f611f174494b/pydantic_core-2.46.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8690eba565c6d68ffd3a8655525cbdd5246510b44a637ee2c6c03a7ebfe64d3c", size = 2157856, upload-time = "2026-04-20T14:43:46.64Z" }, - { url = "https://files.pythonhosted.org/packages/6d/ec/6a500e3ad7718ee50583fae79c8651f5d37e3abce1fa9ae177ae65842c53/pydantic_core-2.46.3-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4de88889d7e88d50d40ee5b39d5dac0bcaef9ba91f7e536ac064e6b2834ecccf", size = 2180168, upload-time = "2026-04-20T14:42:00.302Z" }, - { url = "https://files.pythonhosted.org/packages/d8/53/8267811054b1aa7fc1dc7ded93812372ef79a839f5e23558136a6afbfde1/pydantic_core-2.46.3-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:e480080975c1ef7f780b8f99ed72337e7cc5efea2e518a20a692e8e7b278eb8b", size = 2322885, upload-time = "2026-04-20T14:41:05.253Z" }, - { url = "https://files.pythonhosted.org/packages/c8/c1/1c0acdb3aa0856ddc4ecc55214578f896f2de16f400cf51627eb3c26c1c4/pydantic_core-2.46.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:de3a5c376f8cd94da9a1b8fd3dd1c16c7a7b216ed31dc8ce9fd7a22bf13b836e", size = 2360328, upload-time = "2026-04-20T14:41:43.991Z" }, - { url = "https://files.pythonhosted.org/packages/f0/d0/ef39cd0f4a926814f360e71c1adeab48ad214d9727e4deb48eedfb5bce1a/pydantic_core-2.46.3-cp311-cp311-win32.whl", hash = "sha256:fc331a5314ffddd5385b9ee9d0d2fee0b13c27e0e02dad71b1ae5d6561f51eeb", size = 1979464, upload-time = "2026-04-20T14:43:12.215Z" }, - { url = "https://files.pythonhosted.org/packages/18/9c/f41951b0d858e343f1cf09398b2a7b3014013799744f2c4a8ad6a3eec4f2/pydantic_core-2.46.3-cp311-cp311-win_amd64.whl", hash = "sha256:b5b9c6cf08a8a5e502698f5e153056d12c34b8fb30317e0c5fd06f45162a6346", size = 2070837, upload-time = "2026-04-20T14:41:47.707Z" }, - { url = "https://files.pythonhosted.org/packages/9f/1e/264a17cd582f6ed50950d4d03dd5fefd84e570e238afe1cb3e25cf238769/pydantic_core-2.46.3-cp311-cp311-win_arm64.whl", hash = "sha256:5dfd51cf457482f04ec49491811a2b8fd5b843b64b11eecd2d7a1ee596ea78a6", size = 2053647, upload-time = "2026-04-20T14:42:27.535Z" }, - { url = "https://files.pythonhosted.org/packages/4b/cb/5b47425556ecc1f3fe18ed2a0083188aa46e1dd812b06e406475b3a5d536/pydantic_core-2.46.3-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:b11b59b3eee90a80a36701ddb4576d9ae31f93f05cb9e277ceaa09e6bf074a67", size = 2101946, upload-time = "2026-04-20T14:40:52.581Z" }, - { url = "https://files.pythonhosted.org/packages/a1/4f/2fb62c2267cae99b815bbf4a7b9283812c88ca3153ef29f7707200f1d4e5/pydantic_core-2.46.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:af8653713055ea18a3abc1537fe2ebc42f5b0bbb768d1eb79fd74eb47c0ac089", size = 1951612, upload-time = "2026-04-20T14:42:42.996Z" }, - { url = "https://files.pythonhosted.org/packages/50/6e/b7348fd30d6556d132cddd5bd79f37f96f2601fe0608afac4f5fb01ec0b3/pydantic_core-2.46.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:75a519dab6d63c514f3a81053e5266c549679e4aa88f6ec57f2b7b854aceb1b0", size = 1977027, upload-time = "2026-04-20T14:42:02.001Z" }, - { url = "https://files.pythonhosted.org/packages/82/11/31d60ee2b45540d3fb0b29302a393dbc01cd771c473f5b5147bcd353e593/pydantic_core-2.46.3-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a6cd87cb1575b1ad05ba98894c5b5c96411ef678fa2f6ed2576607095b8d9789", size = 2063008, upload-time = "2026-04-20T14:44:17.952Z" }, - { url = "https://files.pythonhosted.org/packages/8a/db/3a9d1957181b59258f44a2300ab0f0be9d1e12d662a4f57bb31250455c52/pydantic_core-2.46.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f80a55484b8d843c8ada81ebf70a682f3f00a3d40e378c06cf17ecb44d280d7d", size = 2233082, upload-time = "2026-04-20T14:40:57.934Z" }, - { url = "https://files.pythonhosted.org/packages/9c/e1/3277c38792aeb5cfb18c2f0c5785a221d9ff4e149abbe1184d53d5f72273/pydantic_core-2.46.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3861f1731b90c50a3266316b9044f5c9b405eecb8e299b0a7120596334e4fe9c", size = 2304615, upload-time = "2026-04-20T14:42:12.584Z" }, - { url = "https://files.pythonhosted.org/packages/5e/d5/e3d9717c9eba10855325650afd2a9cba8e607321697f18953af9d562da2f/pydantic_core-2.46.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fb528e295ed31570ac3dcc9bfdd6e0150bc11ce6168ac87a8082055cf1a67395", size = 2094380, upload-time = "2026-04-20T14:43:05.522Z" }, - { url = "https://files.pythonhosted.org/packages/a1/20/abac35dedcbfd66c6f0b03e4e3564511771d6c9b7ede10a362d03e110d9b/pydantic_core-2.46.3-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:367508faa4973b992b271ba1494acaab36eb7e8739d1e47be5035fb1ea225396", size = 2135429, upload-time = "2026-04-20T14:41:55.549Z" }, - { url = "https://files.pythonhosted.org/packages/6c/a5/41bfd1df69afad71b5cf0535055bccc73022715ad362edbc124bc1e021d7/pydantic_core-2.46.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5ad3c826fe523e4becf4fe39baa44286cff85ef137c729a2c5e269afbfd0905d", size = 2174582, upload-time = "2026-04-20T14:41:45.96Z" }, - { url = "https://files.pythonhosted.org/packages/79/65/38d86ea056b29b2b10734eb23329b7a7672ca604df4f2b6e9c02d4ee22fe/pydantic_core-2.46.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:ec638c5d194ef8af27db69f16c954a09797c0dc25015ad6123eb2c73a4d271ca", size = 2187533, upload-time = "2026-04-20T14:40:55.367Z" }, - { url = "https://files.pythonhosted.org/packages/b6/55/a1129141678a2026badc539ad1dee0a71d06f54c2f06a4bd68c030ac781b/pydantic_core-2.46.3-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:28ed528c45446062ee66edb1d33df5d88828ae167de76e773a3c7f64bd14e976", size = 2332985, upload-time = "2026-04-20T14:44:13.05Z" }, - { url = "https://files.pythonhosted.org/packages/d7/60/cb26f4077719f709e54819f4e8e1d43f4091f94e285eb6bd21e1190a7b7c/pydantic_core-2.46.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:aed19d0c783886d5bd86d80ae5030006b45e28464218747dcf83dabfdd092c7b", size = 2373670, upload-time = "2026-04-20T14:41:53.421Z" }, - { url = "https://files.pythonhosted.org/packages/6b/7e/c3f21882bdf1d8d086876f81b5e296206c69c6082551d776895de7801fa0/pydantic_core-2.46.3-cp312-cp312-win32.whl", hash = "sha256:06d5d8820cbbdb4147578c1fe7ffcd5b83f34508cb9f9ab76e807be7db6ff0a4", size = 1966722, upload-time = "2026-04-20T14:44:30.588Z" }, - { url = "https://files.pythonhosted.org/packages/57/be/6b5e757b859013ebfbd7adba02f23b428f37c86dcbf78b5bb0b4ffd36e99/pydantic_core-2.46.3-cp312-cp312-win_amd64.whl", hash = "sha256:c3212fda0ee959c1dd04c60b601ec31097aaa893573a3a1abd0a47bcac2968c1", size = 2072970, upload-time = "2026-04-20T14:42:54.248Z" }, - { url = "https://files.pythonhosted.org/packages/bf/f8/a989b21cc75e9a32d24192ef700eea606521221a89faa40c919ce884f2b1/pydantic_core-2.46.3-cp312-cp312-win_arm64.whl", hash = "sha256:f1f8338dd7a7f31761f1f1a3c47503a9a3b34eea3c8b01fa6ee96408affb5e72", size = 2035963, upload-time = "2026-04-20T14:44:20.4Z" }, - { url = "https://files.pythonhosted.org/packages/9b/3c/9b5e8eb9821936d065439c3b0fb1490ffa64163bfe7e1595985a47896073/pydantic_core-2.46.3-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:12bc98de041458b80c86c56b24df1d23832f3e166cbaff011f25d187f5c62c37", size = 2102109, upload-time = "2026-04-20T14:41:24.219Z" }, - { url = "https://files.pythonhosted.org/packages/91/97/1c41d1f5a19f241d8069f1e249853bcce378cdb76eec8ab636d7bc426280/pydantic_core-2.46.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:85348b8f89d2c3508b65b16c3c33a4da22b8215138d8b996912bb1532868885f", size = 1951820, upload-time = "2026-04-20T14:42:14.236Z" }, - { url = "https://files.pythonhosted.org/packages/30/b4/d03a7ae14571bc2b6b3c7b122441154720619afe9a336fa3a95434df5e2f/pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1105677a6df914b1fb71a81b96c8cce7726857e1717d86001f29be06a25ee6f8", size = 1977785, upload-time = "2026-04-20T14:42:31.648Z" }, - { url = "https://files.pythonhosted.org/packages/ae/0c/4086f808834b59e3c8f1aa26df8f4b6d998cdcf354a143d18ef41529d1fe/pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:87082cd65669a33adeba5470769e9704c7cf026cc30afb9cc77fd865578ebaad", size = 2062761, upload-time = "2026-04-20T14:40:37.093Z" }, - { url = "https://files.pythonhosted.org/packages/fa/71/a649be5a5064c2df0db06e0a512c2281134ed2fcc981f52a657936a7527c/pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:60e5f66e12c4f5212d08522963380eaaeac5ebd795826cfd19b2dfb0c7a52b9c", size = 2232989, upload-time = "2026-04-20T14:42:59.254Z" }, - { url = "https://files.pythonhosted.org/packages/a2/84/7756e75763e810b3a710f4724441d1ecc5883b94aacb07ca71c5fb5cfb69/pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b6cdf19bf84128d5e7c37e8a73a0c5c10d51103a650ac585d42dd6ae233f2b7f", size = 2303975, upload-time = "2026-04-20T14:41:32.287Z" }, - { url = "https://files.pythonhosted.org/packages/6c/35/68a762e0c1e31f35fa0dac733cbd9f5b118042853698de9509c8e5bf128b/pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:031bb17f4885a43773c8c763089499f242aee2ea85cf17154168775dccdecf35", size = 2095325, upload-time = "2026-04-20T14:42:47.685Z" }, - { url = "https://files.pythonhosted.org/packages/77/bf/1bf8c9a8e91836c926eae5e3e51dce009bf495a60ca56060689d3df3f340/pydantic_core-2.46.3-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:bcf2a8b2982a6673693eae7348ef3d8cf3979c1d63b54fca7c397a635cc68687", size = 2133368, upload-time = "2026-04-20T14:41:22.766Z" }, - { url = "https://files.pythonhosted.org/packages/e5/50/87d818d6bab915984995157ceb2380f5aac4e563dddbed6b56f0ed057aba/pydantic_core-2.46.3-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:28e8cf2f52d72ced402a137145923a762cbb5081e48b34312f7a0c8f55928ec3", size = 2173908, upload-time = "2026-04-20T14:42:52.044Z" }, - { url = "https://files.pythonhosted.org/packages/91/88/a311fb306d0bd6185db41fa14ae888fb81d0baf648a761ae760d30819d33/pydantic_core-2.46.3-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:17eaface65d9fc5abb940003020309c1bf7a211f5f608d7870297c367e6f9022", size = 2186422, upload-time = "2026-04-20T14:43:29.55Z" }, - { url = "https://files.pythonhosted.org/packages/8f/79/28fd0d81508525ab2054fef7c77a638c8b5b0afcbbaeee493cf7c3fef7e1/pydantic_core-2.46.3-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:93fd339f23408a07e98950a89644f92c54d8729719a40b30c0a30bb9ebc55d23", size = 2332709, upload-time = "2026-04-20T14:42:16.134Z" }, - { url = "https://files.pythonhosted.org/packages/b3/21/795bf5fe5c0f379308b8ef19c50dedab2e7711dbc8d0c2acf08f1c7daa05/pydantic_core-2.46.3-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:23cbdb3aaa74dfe0837975dbf69b469753bbde8eacace524519ffdb6b6e89eb7", size = 2372428, upload-time = "2026-04-20T14:41:10.974Z" }, - { url = "https://files.pythonhosted.org/packages/45/b3/ed14c659cbe7605e3ef063077680a64680aec81eb1a04763a05190d49b7f/pydantic_core-2.46.3-cp313-cp313-win32.whl", hash = "sha256:610eda2e3838f401105e6326ca304f5da1e15393ae25dacae5c5c63f2c275b13", size = 1965601, upload-time = "2026-04-20T14:41:42.128Z" }, - { url = "https://files.pythonhosted.org/packages/ef/bb/adb70d9a762ddd002d723fbf1bd492244d37da41e3af7b74ad212609027e/pydantic_core-2.46.3-cp313-cp313-win_amd64.whl", hash = "sha256:68cc7866ed863db34351294187f9b729964c371ba33e31c26f478471c52e1ed0", size = 2071517, upload-time = "2026-04-20T14:43:36.096Z" }, - { url = "https://files.pythonhosted.org/packages/52/eb/66faefabebfe68bd7788339c9c9127231e680b11906368c67ce112fdb47f/pydantic_core-2.46.3-cp313-cp313-win_arm64.whl", hash = "sha256:f64b5537ac62b231572879cd08ec05600308636a5d63bcbdb15063a466977bec", size = 2035802, upload-time = "2026-04-20T14:43:38.507Z" }, - { url = "https://files.pythonhosted.org/packages/7f/db/a7bcb4940183fda36022cd18ba8dd12f2dff40740ec7b58ce7457befa416/pydantic_core-2.46.3-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:afa3aa644f74e290cdede48a7b0bee37d1c35e71b05105f6b340d484af536d9b", size = 2097614, upload-time = "2026-04-20T14:44:38.374Z" }, - { url = "https://files.pythonhosted.org/packages/24/35/e4066358a22e3e99519db370494c7528f5a2aa1367370e80e27e20283543/pydantic_core-2.46.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ced3310e51aa425f7f77da8bbbb5212616655bedbe82c70944320bc1dbe5e018", size = 1951896, upload-time = "2026-04-20T14:40:53.996Z" }, - { url = "https://files.pythonhosted.org/packages/87/92/37cf4049d1636996e4b888c05a501f40a43ff218983a551d57f9d5e14f0d/pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e29908922ce9da1a30b4da490bd1d3d82c01dcfdf864d2a74aacee674d0bfa34", size = 1979314, upload-time = "2026-04-20T14:41:49.446Z" }, - { url = "https://files.pythonhosted.org/packages/d8/36/9ff4d676dfbdfb2d591cf43f3d90ded01e15b1404fd101180ed2d62a2fd3/pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0c9ff69140423eea8ed2d5477df3ba037f671f5e897d206d921bc9fdc39613e7", size = 2056133, upload-time = "2026-04-20T14:42:23.574Z" }, - { url = "https://files.pythonhosted.org/packages/bc/f0/405b442a4d7ba855b06eec8b2bf9c617d43b8432d099dfdc7bf999293495/pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b675ab0a0d5b1c8fdb81195dc5bcefea3f3c240871cdd7ff9a2de8aa50772eb2", size = 2228726, upload-time = "2026-04-20T14:44:22.816Z" }, - { url = "https://files.pythonhosted.org/packages/e7/f8/65cd92dd5a0bd89ba277a98ecbfaf6fc36bbd3300973c7a4b826d6ab1391/pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0087084960f209a9a4af50ecd1fb063d9ad3658c07bb81a7a53f452dacbfb2ba", size = 2301214, upload-time = "2026-04-20T14:44:48.792Z" }, - { url = "https://files.pythonhosted.org/packages/fd/86/ef96a4c6e79e7a2d0410826a68fbc0eccc0fd44aa733be199d5fcac3bb87/pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ed42e6cc8e1b0e2b9b96e2276bad70ae625d10d6d524aed0c93de974ae029f9f", size = 2099927, upload-time = "2026-04-20T14:41:40.196Z" }, - { url = "https://files.pythonhosted.org/packages/6d/53/269caf30e0096e0a8a8f929d1982a27b3879872cca2d917d17c2f9fdf4fe/pydantic_core-2.46.3-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:f1771ce258afb3e4201e67d154edbbae712a76a6081079fe247c2f53c6322c22", size = 2128789, upload-time = "2026-04-20T14:41:15.868Z" }, - { url = "https://files.pythonhosted.org/packages/00/b0/1a6d9b6a587e118482910c244a1c5acf4d192604174132efd12bf0ac486f/pydantic_core-2.46.3-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a7610b6a5242a6c736d8ad47fd5fff87fcfe8f833b281b1c409c3d6835d9227f", size = 2173815, upload-time = "2026-04-20T14:44:25.152Z" }, - { url = "https://files.pythonhosted.org/packages/87/56/e7e00d4041a7e62b5a40815590114db3b535bf3ca0bf4dca9f16cef25246/pydantic_core-2.46.3-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:ff5e7783bcc5476e1db448bf268f11cb257b1c276d3e89f00b5727be86dd0127", size = 2181608, upload-time = "2026-04-20T14:41:28.933Z" }, - { url = "https://files.pythonhosted.org/packages/e8/22/4bd23c3d41f7c185d60808a1de83c76cf5aeabf792f6c636a55c3b1ec7f9/pydantic_core-2.46.3-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:9d2e32edcc143bc01e95300671915d9ca052d4f745aa0a49c48d4803f8a85f2c", size = 2326968, upload-time = "2026-04-20T14:42:03.962Z" }, - { url = "https://files.pythonhosted.org/packages/24/ac/66cd45129e3915e5ade3b292cb3bc7fd537f58f8f8dbdaba6170f7cabb74/pydantic_core-2.46.3-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:6e42d83d1c6b87fa56b521479cff237e626a292f3b31b6345c15a99121b454c1", size = 2369842, upload-time = "2026-04-20T14:41:35.52Z" }, - { url = "https://files.pythonhosted.org/packages/a2/51/dd4248abb84113615473aa20d5545b7c4cd73c8644003b5259686f93996c/pydantic_core-2.46.3-cp314-cp314-win32.whl", hash = "sha256:07bc6d2a28c3adb4f7c6ae46aa4f2d2929af127f587ed44057af50bf1ce0f505", size = 1959661, upload-time = "2026-04-20T14:41:00.042Z" }, - { url = "https://files.pythonhosted.org/packages/20/eb/59980e5f1ae54a3b86372bd9f0fa373ea2d402e8cdcd3459334430f91e91/pydantic_core-2.46.3-cp314-cp314-win_amd64.whl", hash = "sha256:8940562319bc621da30714617e6a7eaa6b98c84e8c685bcdc02d7ed5e7c7c44e", size = 2071686, upload-time = "2026-04-20T14:43:16.471Z" }, - { url = "https://files.pythonhosted.org/packages/8c/db/1cf77e5247047dfee34bc01fa9bca134854f528c8eb053e144298893d370/pydantic_core-2.46.3-cp314-cp314-win_arm64.whl", hash = "sha256:5dcbbcf4d22210ced8f837c96db941bdb078f419543472aca5d9a0bb7cddc7df", size = 2026907, upload-time = "2026-04-20T14:43:31.732Z" }, - { url = "https://files.pythonhosted.org/packages/57/c0/b3df9f6a543276eadba0a48487b082ca1f201745329d97dbfa287034a230/pydantic_core-2.46.3-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:d0fe3dce1e836e418f912c1ad91c73357d03e556a4d286f441bf34fed2dbeecf", size = 2095047, upload-time = "2026-04-20T14:42:37.982Z" }, - { url = "https://files.pythonhosted.org/packages/66/57/886a938073b97556c168fd99e1a7305bb363cd30a6d2c76086bf0587b32a/pydantic_core-2.46.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:9ce92e58abc722dac1bf835a6798a60b294e48eb0e625ec9fd994b932ac5feee", size = 1934329, upload-time = "2026-04-20T14:43:49.655Z" }, - { url = "https://files.pythonhosted.org/packages/0b/7c/b42eaa5c34b13b07ecb51da21761297a9b8eb43044c864a035999998f328/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a03e6467f0f5ab796a486146d1b887b2dc5e5f9b3288898c1b1c3ad974e53e4a", size = 1974847, upload-time = "2026-04-20T14:42:10.737Z" }, - { url = "https://files.pythonhosted.org/packages/e6/9b/92b42db6543e7de4f99ae977101a2967b63122d4b6cf7773812da2d7d5b5/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2798b6ba041b9d70acfb9071a2ea13c8456dd1e6a5555798e41ba7b0790e329c", size = 2041742, upload-time = "2026-04-20T14:40:44.262Z" }, - { url = "https://files.pythonhosted.org/packages/0f/19/46fbe1efabb5aa2834b43b9454e70f9a83ad9c338c1291e48bdc4fecf167/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9be3e221bdc6d69abf294dcf7aff6af19c31a5cdcc8f0aa3b14be29df4bd03b1", size = 2236235, upload-time = "2026-04-20T14:41:27.307Z" }, - { url = "https://files.pythonhosted.org/packages/77/da/b3f95bc009ad60ec53120f5d16c6faa8cabdbe8a20d83849a1f2b8728148/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f13936129ce841f2a5ddf6f126fea3c43cd128807b5a59588c37cf10178c2e64", size = 2282633, upload-time = "2026-04-20T14:44:33.271Z" }, - { url = "https://files.pythonhosted.org/packages/cc/6e/401336117722e28f32fb8220df676769d28ebdf08f2f4469646d404c43a3/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:28b5f2ef03416facccb1c6ef744c69793175fd27e44ef15669201601cf423acb", size = 2109679, upload-time = "2026-04-20T14:44:41.065Z" }, - { url = "https://files.pythonhosted.org/packages/fc/53/b289f9bc8756a32fe718c46f55afaeaf8d489ee18d1a1e7be1db73f42cc4/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:830d1247d77ad23852314f069e9d7ddafeec5f684baf9d7e7065ed46a049c4e6", size = 2108342, upload-time = "2026-04-20T14:42:50.144Z" }, - { url = "https://files.pythonhosted.org/packages/10/5b/8292fc7c1f9111f1b2b7c1b0dcf1179edcd014fc3ea4517499f50b829d71/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0793c90c1a3c74966e7975eaef3ed30ebdff3260a0f815a62a22adc17e4c01c", size = 2157208, upload-time = "2026-04-20T14:42:08.133Z" }, - { url = "https://files.pythonhosted.org/packages/2b/9e/f80044e9ec07580f057a89fc131f78dda7a58751ddf52bbe05eaf31db50f/pydantic_core-2.46.3-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:d2d0aead851b66f5245ec0c4fb2612ef457f8bbafefdf65a2bf9d6bac6140f47", size = 2167237, upload-time = "2026-04-20T14:42:25.412Z" }, - { url = "https://files.pythonhosted.org/packages/f8/84/6781a1b037f3b96be9227edbd1101f6d3946746056231bf4ac48cdff1a8d/pydantic_core-2.46.3-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:2f40e4246676beb31c5ce77c38a55ca4e465c6b38d11ea1bd935420568e0b1ab", size = 2312540, upload-time = "2026-04-20T14:40:40.313Z" }, - { url = "https://files.pythonhosted.org/packages/3e/db/19c0839feeb728e7df03255581f198dfdf1c2aeb1e174a8420b63c5252e5/pydantic_core-2.46.3-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:cf489cf8986c543939aeee17a09c04d6ffb43bfef8ca16fcbcc5cfdcbed24dba", size = 2369556, upload-time = "2026-04-20T14:41:09.427Z" }, - { url = "https://files.pythonhosted.org/packages/e0/15/3228774cb7cd45f5f721ddf1b2242747f4eb834d0c491f0c02d606f09fed/pydantic_core-2.46.3-cp314-cp314t-win32.whl", hash = "sha256:ffe0883b56cfc05798bf994164d2b2ff03efe2d22022a2bb080f3b626176dd56", size = 1949756, upload-time = "2026-04-20T14:41:25.717Z" }, - { url = "https://files.pythonhosted.org/packages/b8/2a/c79cf53fd91e5a87e30d481809f52f9a60dd221e39de66455cf04deaad37/pydantic_core-2.46.3-cp314-cp314t-win_amd64.whl", hash = "sha256:706d9d0ce9cf4593d07270d8e9f53b161f90c57d315aeec4fb4fd7a8b10240d8", size = 2051305, upload-time = "2026-04-20T14:43:18.627Z" }, - { url = "https://files.pythonhosted.org/packages/0b/db/d8182a7f1d9343a032265aae186eb063fe26ca4c40f256b21e8da4498e89/pydantic_core-2.46.3-cp314-cp314t-win_arm64.whl", hash = "sha256:77706aeb41df6a76568434701e0917da10692da28cb69d5fb6919ce5fdb07374", size = 2026310, upload-time = "2026-04-20T14:41:01.778Z" }, - { url = "https://files.pythonhosted.org/packages/66/7f/03dbad45cd3aa9083fbc93c210ae8b005af67e4136a14186950a747c6874/pydantic_core-2.46.3-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:9715525891ed524a0a1eb6d053c74d4d4ad5017677fb00af0b7c2644a31bae46", size = 2105683, upload-time = "2026-04-20T14:42:19.779Z" }, - { url = "https://files.pythonhosted.org/packages/26/22/4dc186ac8ea6b257e9855031f51b62a9637beac4d68ac06bee02f046f836/pydantic_core-2.46.3-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:9d2f400712a99a013aff420ef1eb9be077f8189a36c1e3ef87660b4e1088a874", size = 1940052, upload-time = "2026-04-20T14:43:59.274Z" }, - { url = "https://files.pythonhosted.org/packages/0d/ca/d376391a5aff1f2e8188960d7873543608130a870961c2b6b5236627c116/pydantic_core-2.46.3-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bd2aab0e2e9dc2daf36bd2686c982535d5e7b1d930a1344a7bb6e82baab42a76", size = 1988172, upload-time = "2026-04-20T14:41:17.469Z" }, - { url = "https://files.pythonhosted.org/packages/0e/6b/523b9f85c23788755d6ab949329de692a2e3a584bc6beb67fef5e035aa9d/pydantic_core-2.46.3-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4e9d76736da5f362fabfeea6a69b13b7f2be405c6d6966f06b2f6bfff7e64531", size = 2128596, upload-time = "2026-04-20T14:40:41.707Z" }, - { url = "https://files.pythonhosted.org/packages/34/42/f426db557e8ab2791bc7562052299944a118655496fbff99914e564c0a94/pydantic_core-2.46.3-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:b12dd51f1187c2eb489af8e20f880362db98e954b54ab792fa5d92e8bcc6b803", size = 2091877, upload-time = "2026-04-20T14:43:27.091Z" }, - { url = "https://files.pythonhosted.org/packages/5c/4f/86a832a9d14df58e663bfdf4627dc00d3317c2bd583c4fb23390b0f04b8e/pydantic_core-2.46.3-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:f00a0961b125f1a47af7bcc17f00782e12f4cd056f83416006b30111d941dfa3", size = 1932428, upload-time = "2026-04-20T14:40:45.781Z" }, - { url = "https://files.pythonhosted.org/packages/11/1a/fe857968954d93fb78e0d4b6df5c988c74c4aaa67181c60be7cfe327c0ca/pydantic_core-2.46.3-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:57697d7c056aca4bbb680200f96563e841a6386ac1129370a0102592f4dddff5", size = 1997550, upload-time = "2026-04-20T14:44:02.425Z" }, - { url = "https://files.pythonhosted.org/packages/17/eb/9d89ad2d9b0ba8cd65393d434471621b98912abb10fbe1df08e480ba57b5/pydantic_core-2.46.3-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd35aa21299def8db7ef4fe5c4ff862941a9a158ca7b63d61e66fe67d30416b4", size = 2137657, upload-time = "2026-04-20T14:42:45.149Z" }, - { url = "https://files.pythonhosted.org/packages/1f/da/99d40830684f81dec901cac521b5b91c095394cc1084b9433393cde1c2df/pydantic_core-2.46.3-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:13afdd885f3d71280cf286b13b310ee0f7ccfefd1dbbb661514a474b726e2f25", size = 2107973, upload-time = "2026-04-20T14:42:06.175Z" }, - { url = "https://files.pythonhosted.org/packages/99/a5/87024121818d75bbb2a98ddbaf638e40e7a18b5e0f5492c9ca4b1b316107/pydantic_core-2.46.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:f91c0aff3e3ee0928edd1232c57f643a7a003e6edf1860bc3afcdc749cb513f3", size = 1947191, upload-time = "2026-04-20T14:43:14.319Z" }, - { url = "https://files.pythonhosted.org/packages/60/62/0c1acfe10945b83a6a59d19fbaa92f48825381509e5701b855c08f13db76/pydantic_core-2.46.3-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6529d1d128321a58d30afcc97b49e98836542f68dd41b33c2e972bb9e5290536", size = 2123791, upload-time = "2026-04-20T14:43:22.766Z" }, - { url = "https://files.pythonhosted.org/packages/75/3e/3b2393b4c8f44285561dc30b00cf307a56a2eff7c483a824db3b8221ca51/pydantic_core-2.46.3-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:975c267cff4f7e7272eacbe50f6cc03ca9a3da4c4fbd66fffd89c94c1e311aa1", size = 2153197, upload-time = "2026-04-20T14:44:27.932Z" }, - { url = "https://files.pythonhosted.org/packages/ba/75/5af02fb35505051eee727c061f2881c555ab4f8ddb2d42da715a42c9731b/pydantic_core-2.46.3-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:2b8e4f2bbdf71415c544b4b1138b8060db7b6611bc927e8064c769f64bed651c", size = 2181073, upload-time = "2026-04-20T14:43:20.729Z" }, - { url = "https://files.pythonhosted.org/packages/10/92/7e0e1bd9ca3c68305db037560ca2876f89b2647deb2f8b6319005de37505/pydantic_core-2.46.3-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:e61ea8e9fff9606d09178f577ff8ccdd7206ff73d6552bcec18e1033c4254b85", size = 2315886, upload-time = "2026-04-20T14:44:04.826Z" }, - { url = "https://files.pythonhosted.org/packages/b8/d8/101655f27eaf3e44558ead736b2795d12500598beed4683f279396fa186e/pydantic_core-2.46.3-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:b504bda01bafc69b6d3c7a0c7f039dcf60f47fab70e06fe23f57b5c75bdc82b8", size = 2360528, upload-time = "2026-04-20T14:40:47.431Z" }, - { url = "https://files.pythonhosted.org/packages/07/0f/1c34a74c8d07136f0d729ffe5e1fdab04fbdaa7684f61a92f92511a84a15/pydantic_core-2.46.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:b00b76f7142fc60c762ce579bd29c8fa44aaa56592dd3c54fab3928d0d4ca6ff", size = 2184144, upload-time = "2026-04-20T14:42:57Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/08/f1ba952f1c8ae5581c70fa9c6da89f247b83e3dd8c09c035d5d7931fc23d/pydantic_core-2.46.4-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:a396dcc17e5a0b164dbe026896245a4fa9ff402edca1dff0be3d53a517f74de4", size = 2113146, upload-time = "2026-05-06T13:37:36.537Z" }, + { url = "https://files.pythonhosted.org/packages/56/c6/65f646c7ff09bd257f660434adb45c4dfcbbcebcc030562fecf6f5bf887d/pydantic_core-2.46.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:da4b951fe36dc7c3a1ccb4e3cd1747c3542b8c9ceede8fc86cae054e764485f5", size = 1949769, upload-time = "2026-05-06T13:37:46.365Z" }, + { url = "https://files.pythonhosted.org/packages/64/ba/bfb1d928fd5b49e1258935ff104ae356e9fd89384a55bf9f847e9193ad40/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bb63e0198ca18aad131c089b9204c23079c3afa95487e561f4c522d519e55aba", size = 1974958, upload-time = "2026-05-06T13:37:28.611Z" }, + { url = "https://files.pythonhosted.org/packages/4e/74/76223bfb117b64af743c9b6670d1364516f5c0604f96b48f3272f6af6cc6/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f47286a97f0bc9b8859519809077b91b2cefe4ae47fcbf5e466a009c1c5d742b", size = 2042118, upload-time = "2026-05-06T13:36:55.216Z" }, + { url = "https://files.pythonhosted.org/packages/cb/7b/848732968bc8f48f3187542f08358b9d842db564147b256669426ebb1652/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:905a0ed8ea6f2d61c1738835f99b699348d7857379083e5fc497fa0c967a407c", size = 2222876, upload-time = "2026-05-06T13:38:25.455Z" }, + { url = "https://files.pythonhosted.org/packages/b5/2f/e90b63ee2e14bd8d3db8f705a6d75d64e6ee1b7c2c8833747ce706e1e0ce/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ea793e075b70290d89d8142074262885d3f7da19634845135751bd6344f73b50", size = 2286703, upload-time = "2026-05-06T13:37:53.304Z" }, + { url = "https://files.pythonhosted.org/packages/ba/1e/acc4d70f88a0a277e4a1fa77ebb985ceabaf900430f875bf9338e11c9420/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:395aebd9183f9d112f569aeb5b2214d1a10a33bec8456447f7fbdfa51d38d4cd", size = 2092042, upload-time = "2026-05-06T13:38:46.981Z" }, + { url = "https://files.pythonhosted.org/packages/a9/da/0a422b57bf8504102bf3c4ccea9c41bab5a5cee6a54650acf8faf67f5a24/pydantic_core-2.46.4-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:b078afbc25f3a1436c7a1d2cd3e322497ee99615ba97c563566fdf46aff1ee01", size = 2117231, upload-time = "2026-05-06T13:39:23.146Z" }, + { url = "https://files.pythonhosted.org/packages/bd/2a/2ac13c3af305843e23c5078c53d135656b3f05a2fd78cb7bbbb12e97b473/pydantic_core-2.46.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f747929cf940cddb5b3668a390056ddd5ba2e5010615ea2dcf4f9c4f3ab8791d", size = 2168388, upload-time = "2026-05-06T13:40:08.06Z" }, + { url = "https://files.pythonhosted.org/packages/72/04/2beacf7e1607e93eefe4aed1b4709f079b905fb77530179d4f7c71745f22/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:daa27d92c36f24388fe3ad306b174781c747627f134452e4f128ea00ce1fe8c4", size = 2184769, upload-time = "2026-05-06T13:38:13.901Z" }, + { url = "https://files.pythonhosted.org/packages/9e/29/d2b9fd9f539133548eaf622c06a4ce176cb46ac59f32d0359c4abc0de047/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:19e51f073cd3df251856a8a4189fbdf1de4012c3ebacfb1884f94f1eb406079f", size = 2319312, upload-time = "2026-05-06T13:39:08.24Z" }, + { url = "https://files.pythonhosted.org/packages/7c/af/0f7a5b85fec6075bea96e3ef9187de38fccced0de92c1e7feda8d5cc7bb9/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c1747f85cee84c26985853c6f3d9bd3e75da5212912443fa111c113b9c246f39", size = 2361817, upload-time = "2026-05-06T13:38:43.2Z" }, + { url = "https://files.pythonhosted.org/packages/25/a4/73363fec545fd3ec025490bdda2743c56d0dd5b6266b1a53bbe9e4265375/pydantic_core-2.46.4-cp310-cp310-win32.whl", hash = "sha256:2f84c03c8607173d16b5a854ec68a2f9079ae03237a54fb506d13af47e1d018d", size = 1987085, upload-time = "2026-05-06T13:39:25.497Z" }, + { url = "https://files.pythonhosted.org/packages/01/aa/62f082da2c91fac1c234bc9ee0066257ce83f0604abd72e4c9d5991f2d84/pydantic_core-2.46.4-cp310-cp310-win_amd64.whl", hash = "sha256:8358a950c8909158e3df31538a7e4edc2d7265a7c54b47f0864d9e5bae9dcebf", size = 2074311, upload-time = "2026-05-06T13:39:59.922Z" }, + { url = "https://files.pythonhosted.org/packages/5c/fa/6d7708d2cfc1a832acb6aeb0cd16e801902df8a0f583bb3b4b527fde022e/pydantic_core-2.46.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0e96592440881c74a213e5ad528e2b24d3d4f940de2766bed9010ab1d9e51594", size = 2111872, upload-time = "2026-05-06T13:40:27.596Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6f/aa064a3e74b5745afbdf250594f38e7ead05e2d651bcb35994b9417a0d4d/pydantic_core-2.46.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0d65b8c354be7fb5f720c3caa8bc940bc2d20ce749c8e06135f07f8ed95dd7c", size = 1948255, upload-time = "2026-05-06T13:39:12.574Z" }, + { url = "https://files.pythonhosted.org/packages/43/3a/41114a9f7569b84b4d84e7a018c57c56347dac30c0d4a872946ec4e36c46/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826", size = 1972827, upload-time = "2026-05-06T13:38:19.841Z" }, + { url = "https://files.pythonhosted.org/packages/ef/25/1ab42e8048fe551934d9884e8d64daa7e990ad386f310a15981aeb6a5b08/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9037063db01f09b09e237c282b6792bd4da634b5402c4e7f0c61effed7701a04", size = 2041051, upload-time = "2026-05-06T13:38:10.447Z" }, + { url = "https://files.pythonhosted.org/packages/94/c2/1a934597ddf08da410385b3b7aae91956a5a76c635effef456074fad7e88/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fc010ab034c8c7452522748bf937df58020d256ccae0874463d1f4d01758af8e", size = 2221314, upload-time = "2026-05-06T13:40:13.089Z" }, + { url = "https://files.pythonhosted.org/packages/02/6d/9e8ad178c9c4df27ad3c8f25d1fe2a7ab0d2ba0559fad4aee5d3d1f16771/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c5dac79fa1614d1e06ca695109c6105923bd9c7d1d6c918d4e637b7e6b32fd3", size = 2285146, upload-time = "2026-05-06T13:38:59.224Z" }, + { url = "https://files.pythonhosted.org/packages/80/50/540cd3aeefc041beb111125c4bff779831a2111fc6b15a9138cda277d32c/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9fa868638bf362d3d138ea55829cefb3d5f4b0d7f142234382a15e2485dbec4", size = 2089685, upload-time = "2026-05-06T13:38:17.762Z" }, + { url = "https://files.pythonhosted.org/packages/6b/a4/b440ad35f05f6a38f89fa0f149accb3f0e02be94ca5e15f3c449a61b4bc9/pydantic_core-2.46.4-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:17299feefe090f2caa5b8e37222bb5f663e4935a8bfa6931d4102e5df1a9f398", size = 2115420, upload-time = "2026-05-06T13:37:58.195Z" }, + { url = "https://files.pythonhosted.org/packages/99/61/de4f55db8dfd57bfdfa9a12ec90fe1b57c4f41062f7ca86f08586b3e0ac0/pydantic_core-2.46.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4c63ebc82684aa89d9a3bcbd13d515b3be44250dc68dd3bd81526c1cb31286c3", size = 2165122, upload-time = "2026-05-06T13:37:01.167Z" }, + { url = "https://files.pythonhosted.org/packages/f7/52/7c529d7bdb2d1068bd52f51fe32572c8301f9a4febf1948f10639f1436f5/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:aaa2a54443eff1950ba5ddc6b6ccda0d9c84a364276a62f969bdf2a390650848", size = 2182573, upload-time = "2026-05-06T13:38:45.04Z" }, + { url = "https://files.pythonhosted.org/packages/37/b3/7c40325848ba78247f2812dcf9c7274e38cd801820ca6dd9fe63bcfb0eb4/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:18e5ceec2ab67e6d5f1a9085e5a24c9c4e2ac4545730bfe668680bca05e555f3", size = 2317139, upload-time = "2026-05-06T13:37:15.539Z" }, + { url = "https://files.pythonhosted.org/packages/d9/37/f913f81a657c865b75da6c0dbed79876073c2a43b5bd9edbe8da785e4d49/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a0f62d0a58f4e7da165457e995725421e0064f2255d8eccebc49f41bbc23b109", size = 2360433, upload-time = "2026-05-06T13:37:30.099Z" }, + { url = "https://files.pythonhosted.org/packages/c4/67/6acaa1be2567f9256b056d8477158cac7240813956ce86e49deae8e173b4/pydantic_core-2.46.4-cp311-cp311-win32.whl", hash = "sha256:041bde0a48fd37cf71cab1c9d56d3e8625a3793fef1f7dd232b3ff37e978ecda", size = 1985513, upload-time = "2026-05-06T13:38:15.669Z" }, + { url = "https://files.pythonhosted.org/packages/aa/e6/c505f83dfeda9a2e5c995cfd872949e4d05e12f7feb3dca72f633daefa94/pydantic_core-2.46.4-cp311-cp311-win_amd64.whl", hash = "sha256:6f2eeda33a839975441c86a4119e1383c50b47faf0cbb5176985565c6bb02c33", size = 2071114, upload-time = "2026-05-06T13:40:35.416Z" }, + { url = "https://files.pythonhosted.org/packages/0f/da/7a263a96d965d9d0df5e8de8a475f33495451117035b09acb110288c381f/pydantic_core-2.46.4-cp311-cp311-win_arm64.whl", hash = "sha256:14f4c5d6db102bd796a627bbb3a17b4cf4574b9ae861d8b7c9a9661c6dd3362d", size = 2044298, upload-time = "2026-05-06T13:38:29.754Z" }, + { url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" }, + { url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" }, + { url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" }, + { url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" }, + { url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" }, + { url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" }, + { url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" }, + { url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" }, + { url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" }, + { url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" }, + { url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" }, + { url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" }, + { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" }, + { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" }, + { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" }, + { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" }, + { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" }, + { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" }, + { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" }, + { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" }, + { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" }, + { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" }, + { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" }, + { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" }, + { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" }, + { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" }, + { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" }, + { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" }, + { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" }, + { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" }, + { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" }, + { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" }, + { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" }, + { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" }, + { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" }, + { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" }, + { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" }, + { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" }, + { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" }, + { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" }, + { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" }, + { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, + { url = "https://files.pythonhosted.org/packages/ee/a4/73995fd4ebbb46ba0ee51e6fa049b8f02c40daebb762208feda8a6b7894d/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:14d4edf427bdcf950a8a02d7cb44a08614388dd6e1bdcbf4f67504fa7887da9c", size = 2111589, upload-time = "2026-05-06T13:37:10.817Z" }, + { url = "https://files.pythonhosted.org/packages/fb/7f/f37d3a5e8bfcc2e403f5c57a730f2d815693fb42119e8ea48b3789335af1/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0ce40cd7b21210e99342afafbd4d0f76d784eb5b1d60f3bdc566be4983c6c73b", size = 1944552, upload-time = "2026-05-06T13:36:56.717Z" }, + { url = "https://files.pythonhosted.org/packages/15/3c/d7eb777b3ff43e8433a4efb39a17aa8fd98a4ee8561a24a67ef5db07b2d6/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90884113d8b48f760e9587002789ddd741e76ab9f89518cd1e43b1f1a52ec44b", size = 1982984, upload-time = "2026-05-06T13:39:06.207Z" }, + { url = "https://files.pythonhosted.org/packages/63/87/70b9f40170a81afd55ca26c9b2acb25c20d64bcfbf888fafecb3ba077d4c/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66ce7632c22d837c95301830e111ad0128a32b8207533b60896a96c4915192ea", size = 2138417, upload-time = "2026-05-06T13:39:45.476Z" }, + { url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" }, + { url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" }, + { url = "https://files.pythonhosted.org/packages/11/cb/428de0385b6c8d44b716feba566abfacfbd23ee3c4439faa789a1456242f/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:0c563b08bca408dc7f65f700633d8442fffb2421fc47b8101377e9fd65051ff0", size = 2112782, upload-time = "2026-05-06T13:37:04.016Z" }, + { url = "https://files.pythonhosted.org/packages/0b/b5/6a17bdadd0fc1f170adfd05a20d37c832f52b117b4d9131da1f41bb097ce/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:db06ffe51636ffe9ca531fe9023dd64bdd794be8754cb5df57c5498ae5b518a7", size = 1952146, upload-time = "2026-05-06T13:39:43.092Z" }, + { url = "https://files.pythonhosted.org/packages/2a/dc/03734d80e362cd43ef65428e9de77c730ce7f2f11c60d2b1e1b39f0fbf99/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:133878133d271ade3d41d1bfb2a45ec38dbdbda40bc065921c6b04e4630127e2", size = 2134492, upload-time = "2026-05-06T13:36:58.124Z" }, + { url = "https://files.pythonhosted.org/packages/de/df/5e5ffc085ed07cc22d298134d3d911c63e91f6a0eb91fe646750a3209910/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9bc519fbf2b7578398853d815009ae5e4d4603d12f4e3f91da8c06852d3da3e9", size = 2156604, upload-time = "2026-05-06T13:37:49.88Z" }, + { url = "https://files.pythonhosted.org/packages/81/44/6e112a4253e56f5705467cbab7ab5e91ee7398ba3d56d358635958893d3e/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:c7a7bd4e39e8e4c12c39cd480356842b6a8a06e41b23a55a5e3e191718838ddf", size = 2183828, upload-time = "2026-05-06T13:37:43.053Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ad/5565071e937d8e752842ac241463944c9eb14c87e2d269f2658a5bd05e98/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:d396ec2b979760aaf3218e76c24e65bd0aca24983298653b3a9d7a45f9e47b30", size = 2310000, upload-time = "2026-05-06T13:37:56.694Z" }, + { url = "https://files.pythonhosted.org/packages/4f/c3/66883a5cec183e7fba4d024b4cbbe61851a63750ef606b0afecc46d1f2bf/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:86e1a4418c6cd97d60c95c71164158eaf7324fae7b0923264016baa993eba6fc", size = 2361286, upload-time = "2026-05-06T13:40:05.667Z" }, + { url = "https://files.pythonhosted.org/packages/4b/2d/69abac8f838090bbecd5df894befb2c2619e7996a98ddb949db9f3b93225/pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983", size = 2193071, upload-time = "2026-05-06T13:38:08.682Z" }, ] [[package]] @@ -2985,11 +3100,11 @@ wheels = [ [[package]] name = "pytz" -version = "2026.1.post1" +version = "2026.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/56/db/b8721d71d945e6a8ac63c0fc900b2067181dbb50805958d4d4661cf7d277/pytz-2026.1.post1.tar.gz", hash = "sha256:3378dde6a0c3d26719182142c56e60c7f9af7e968076f31aae569d72a0358ee1", size = 321088, upload-time = "2026-03-03T07:47:50.683Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ff/46/dd499ec9038423421951e4fad73051febaa13d2df82b4064f87af8b8c0c3/pytz-2026.2.tar.gz", hash = "sha256:0e60b47b29f21574376f218fe21abc009894a2321ea16c6754f3cad6eb7cdd6a", size = 320861, upload-time = "2026-05-04T01:35:29.667Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/10/99/781fe0c827be2742bcc775efefccb3b048a3a9c6ce9aec0cbf4a101677e5/pytz-2026.1.post1-py2.py3-none-any.whl", hash = "sha256:f2fd16142fda348286a75e1a524be810bb05d444e5a081f37f7affc635035f7a", size = 510489, upload-time = "2026-03-03T07:47:49.167Z" }, + { url = "https://files.pythonhosted.org/packages/ec/dd/96da98f892250475bdf2328112d7468abdd4acc7b902b6af23f4ed958ea0/pytz-2026.2-py2.py3-none-any.whl", hash = "sha256:04156e608bee23d3792fd45c94ae47fae1036688e75032eea2e3bf0323d1f126", size = 510141, upload-time = "2026-05-04T01:35:27.408Z" }, ] [[package]] @@ -3431,6 +3546,37 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a2/09/77d55d46fd61b4a135c444fc97158ef34a095e5681d0a6c10b75bf356191/sympy-1.14.0-py3-none-any.whl", hash = "sha256:e091cc3e99d2141a0ba2847328f5479b05d94a6635cb96148ccb3f34671bd8f5", size = 6299353, upload-time = "2025-04-27T18:04:59.103Z" }, ] +[[package]] +name = "tensorboard" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "absl-py" }, + { name = "grpcio" }, + { name = "markdown" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.4.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "packaging" }, + { name = "pillow" }, + { name = "protobuf" }, + { name = "setuptools" }, + { name = "tensorboard-data-server" }, + { name = "werkzeug" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/9c/d9/a5db55f88f258ac669a92858b70a714bbbd5acd993820b41ec4a96a4d77f/tensorboard-2.20.0-py3-none-any.whl", hash = "sha256:9dc9f978cb84c0723acf9a345d96c184f0293d18f166bb8d59ee098e6cfaaba6", size = 5525680, upload-time = "2025-07-17T19:20:49.638Z" }, +] + +[[package]] +name = "tensorboard-data-server" +version = "0.7.2" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7a/13/e503968fefabd4c6b2650af21e110aa8466fe21432cd7c43a84577a89438/tensorboard_data_server-0.7.2-py3-none-any.whl", hash = "sha256:7e0610d205889588983836ec05dc098e80f97b7e7bbff7e994ebb78f578d0ddb", size = 2356, upload-time = "2023-10-23T21:23:32.16Z" }, + { url = "https://files.pythonhosted.org/packages/b7/85/dabeaf902892922777492e1d253bb7e1264cadce3cea932f7ff599e53fea/tensorboard_data_server-0.7.2-py3-none-macosx_10_9_x86_64.whl", hash = "sha256:9fe5d24221b29625dbc7328b0436ca7fc1c23de4acf4d272f1180856e32f9f60", size = 4823598, upload-time = "2023-10-23T21:23:33.714Z" }, + { url = "https://files.pythonhosted.org/packages/73/c6/825dab04195756cf8ff2e12698f22513b3db2f64925bdd41671bfb33aaa5/tensorboard_data_server-0.7.2-py3-none-manylinux_2_31_x86_64.whl", hash = "sha256:ef687163c24185ae9754ed5650eb5bc4d84ff257aabdc33f0cc6f74d8ba54530", size = 6590363, upload-time = "2023-10-23T21:23:35.583Z" }, +] + [[package]] name = "threadpoolctl" version = "3.6.0" @@ -3590,26 +3736,26 @@ wheels = [ [[package]] name = "ty" -version = "0.0.33" +version = "0.0.34" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/84/44/9478c50c266826c1bf30d1692e589755bffa8f1c0a3eb7af8a346c255991/ty-0.0.33.tar.gz", hash = "sha256:46d63bda07403322cb6c28ccfdd5536be916e13df725c29f7ccd0a21f06bd9e8", size = 5559373, upload-time = "2026-04-28T10:45:13.18Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c4/69/e24eefe2c35c0fdbdec9b60e162727af669bb76d64d993d982eb67b24c38/ty-0.0.34.tar.gz", hash = "sha256:a6efe66b0f13c03a65e6c72ec9abfe2792e2fd063c74fa67e2c4930e29d661be", size = 5585933, upload-time = "2026-05-01T23:06:46.388Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e9/24/e287388c63a19191be26b32ff4dbd06029834068150ebe2532939bc4c851/ty-0.0.33-py3-none-linux_armv6l.whl", hash = "sha256:94d0a9d2234261a8911396d59e506b5923fe0971dbda43b9dcea287936887fcc", size = 11021308, upload-time = "2026-04-28T10:45:43.34Z" }, - { url = "https://files.pythonhosted.org/packages/00/ca/ba1eed819895bd239fba8ee35dfcd5fcb266c203b0914a17a59579096bb5/ty-0.0.33-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e4a2b5ba078f90de342f56b5f7979bb77c9b9b1d8625a041352ffc6ee93c4073", size = 10777272, upload-time = "2026-04-28T10:45:32.905Z" }, - { url = "https://files.pythonhosted.org/packages/25/a8/c3131d37b44b3fea1d6654a1c929a0cd0873822f77a90482b8ec28f6fbbd/ty-0.0.33-py3-none-macosx_11_0_arm64.whl", hash = "sha256:84ff5707825e9af9668d2bcf66975f93e520a63b524ab494e3a8265735be2563", size = 10201078, upload-time = "2026-04-28T10:45:23.374Z" }, - { url = "https://files.pythonhosted.org/packages/7b/db/d8e37ff0045810cc65e1ff36aa0da0a2253c05659787ac987df8a16c7897/ty-0.0.33-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e375285736f57886868e7af0b11c7b0ec5b6543fa15e7ad2a714fed9f077d4e0", size = 10732347, upload-time = "2026-04-28T10:45:21.444Z" }, - { url = "https://files.pythonhosted.org/packages/e0/1a/20e83a412506a918e4684fc67b567cf7cc13b105470b3428cb23c3d5aa13/ty-0.0.33-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5680f6350c3b4e46b8bff6d7bb132366ea239463d6cad4892725d06046e65464", size = 10808238, upload-time = "2026-04-28T10:45:38.565Z" }, - { url = "https://files.pythonhosted.org/packages/5d/4b/d0a39f4464dc6cb4cc2c159473ce216bd1846bfb684c0323a3cb36dce5c6/ty-0.0.33-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c5535538bad8d0f7e62bcdff02197cdb30e41451d80b35d27e17d128f2e1dc5d", size = 11288348, upload-time = "2026-04-28T10:45:08.419Z" }, - { url = "https://files.pythonhosted.org/packages/35/7e/f1745e0f9583363d7a83d9a4990fc244f76ecc30840ddad83dc16a33c52d/ty-0.0.33-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:da196c42bbbc069e1e21e3e52107c061aa9660352dae57a41930690b56e2c02d", size = 11789907, upload-time = "2026-04-28T10:45:19.064Z" }, - { url = "https://files.pythonhosted.org/packages/a5/71/25f39f46a12d662859d45bc648555d0661044eb43db6b5648c9947487da9/ty-0.0.33-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9281672921ef6d4460e03146b5e6c18cb1a3e3a3b8a1a88f6f33226d05a469b7", size = 11500774, upload-time = "2026-04-28T10:45:48.012Z" }, - { url = "https://files.pythonhosted.org/packages/94/ec/136959ecbb7c71cb90537f5aea441c73f4ab24612868a6ecdc9d7444d32d/ty-0.0.33-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:82c1b8f303f82da64e878108e764be3ecbcd7c9903ac0a7f7031614ed00b97ab", size = 11360314, upload-time = "2026-04-28T10:45:05.402Z" }, - { url = "https://files.pythonhosted.org/packages/cf/95/32809575c222f00beed498cb728e9290a0f5009f930025381bb7253b2206/ty-0.0.33-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:efe3af412c9ff67bce5fa37d0a2b0d8555c24072b145a5bac6c79637f1c83abe", size = 10707785, upload-time = "2026-04-28T10:45:10.836Z" }, - { url = "https://files.pythonhosted.org/packages/13/89/c8e9531f7aa4a093359e15fa32c8e1277fbbe90d16894d7c6032d29f4b34/ty-0.0.33-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:aeec29c91ea768601747da546c3efc20b72c2fb1bd52bcc786a5c6eeff51d27b", size = 10834987, upload-time = "2026-04-28T10:45:40.738Z" }, - { url = "https://files.pythonhosted.org/packages/31/16/9835fbcf5338af1a1917bd28fdb8a7193c210b83f243aa286fa9f79cb3ad/ty-0.0.33-py3-none-musllinux_1_2_i686.whl", hash = "sha256:a535977c52bbb5f7e96b8b70a6ad375ad077f4a9ff2492508ea3816a2b403819", size = 10968968, upload-time = "2026-04-28T10:45:30.26Z" }, - { url = "https://files.pythonhosted.org/packages/36/69/64c76aabc1bc70c7f24b686cd93c3407f8ea430905e395f59bf9603ef571/ty-0.0.33-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:1d732facf39fcb221ba279d469c5040d37883e964f123b1563888efd34818180", size = 11458077, upload-time = "2026-04-28T10:45:45.971Z" }, - { url = "https://files.pythonhosted.org/packages/91/84/fae27b0c4718776a298690d31ca4cc1995f2e3e1c63a7b59e84c41498e9a/ty-0.0.33-py3-none-win32.whl", hash = "sha256:d90960b574428dc252f85e8598ec5fcb7f619794196b2fc95a90da075ed4681c", size = 10345364, upload-time = "2026-04-28T10:45:16.836Z" }, - { url = "https://files.pythonhosted.org/packages/3c/a0/a2938b23ae3e1a09a2d7c189e2ac5f7113676bae4e0e23948b568e18e5f8/ty-0.0.33-py3-none-win_amd64.whl", hash = "sha256:c1c3aec62c44de610c6e95f0a4e97ac3dbc07934bfdbf1fd90d758c9ff72f48e", size = 11342470, upload-time = "2026-04-28T10:45:26.455Z" }, - { url = "https://files.pythonhosted.org/packages/ab/62/7fb948aace38d2f6329261bb33c035a8484549c74f1db28649c7a4c6fed9/ty-0.0.33-py3-none-win_arm64.whl", hash = "sha256:0d44f99ba1b441e55e2aa301b2ac0a21112784931b46a5f66f4ea9efe5620d97", size = 10742673, upload-time = "2026-04-28T10:45:35.555Z" }, + { url = "https://files.pythonhosted.org/packages/83/7b/8b85003d6639ef17a97dcbb31f4511cfe78f1c81a964470db100c8c883e7/ty-0.0.34-py3-none-linux_armv6l.whl", hash = "sha256:9ecc3d14f07a95a6ceb88e07f8e62358dbd37325d3d5bd56da7217ff1fef7fb8", size = 11067094, upload-time = "2026-05-01T23:06:21.133Z" }, + { url = "https://files.pythonhosted.org/packages/d7/25/b0098f65b020b015c40567c763fc66fffbec88b2ba6f584bca1e92f05ebb/ty-0.0.34-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:0dccffd8a9d02321cd2dee3249df205e26d62694e741f4eeca36b157fd8b419f", size = 10840909, upload-time = "2026-05-01T23:06:18.409Z" }, + { url = "https://files.pythonhosted.org/packages/e4/55/5e4adcf7d2a1006b844903b27cb81244a9b748d850433a46a6c21776c401/ty-0.0.34-py3-none-macosx_11_0_arm64.whl", hash = "sha256:b0ea47a2998e167ab3b21d2f4b5309a9cf33c297809f6d7e3e753252223174d0", size = 10279378, upload-time = "2026-05-01T23:06:37.962Z" }, + { url = "https://files.pythonhosted.org/packages/4d/91/f537dca0db8fe2558e8ab04d8941d687b384fcc1df5eb9023b2db75ac26c/ty-0.0.34-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b37da00b41a118a459ae56d8947e70651073fb33ebfbceb820e4a10b22d5023", size = 10817423, upload-time = "2026-05-01T23:06:26.247Z" }, + { url = "https://files.pythonhosted.org/packages/2c/c4/55a3ad1da2815af1009bdc1b8c90dc11a364cd314e4b48c5128ba9d38859/ty-0.0.34-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:81cbbb93c2342fe3de43e625d3a9eb149633e9f485e816ebf6395d08685355d8", size = 10851826, upload-time = "2026-05-01T23:06:24.198Z" }, + { url = "https://files.pythonhosted.org/packages/ce/8c/9c7606af22d73fb43ea4369472d9c66ece11231be73b0efe8e3c61655559/ty-0.0.34-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4c5b4dea1594a021289e172582df9cde7089dce14b276fc650e7b212b1772e12", size = 11356318, upload-time = "2026-05-01T23:06:51.139Z" }, + { url = "https://files.pythonhosted.org/packages/20/54/bb423f663721ab4138b216425c6b55eaefd3a068243b24d6d8fe988f4e13/ty-0.0.34-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:030fb00aa2d2a5b5ae9d9183d574e0c82dae80566700a7490c43669d8ece40cd", size = 11902968, upload-time = "2026-05-01T23:06:35.82Z" }, + { url = "https://files.pythonhosted.org/packages/b6/22/01122b21ab6b534a2f618c6bbe5f1f7f49fd56f4b2ec8887cd6d40d08fb3/ty-0.0.34-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5ae9555e24e36c63a8218e037a5a63f15579eb6aa94f41017e57cd41d335cfb5", size = 11548860, upload-time = "2026-05-01T23:06:42.155Z" }, + { url = "https://files.pythonhosted.org/packages/d1/50/86008b1392ec64bed1957bbcc7aaa43b466b50dfc91bb131841c21d7c5c3/ty-0.0.34-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:99eb23df9ed129fc26d1ab00d6f0b8dfe5253b09c2ac6abdb11523fa70d67f10", size = 11457097, upload-time = "2026-05-01T23:06:53.477Z" }, + { url = "https://files.pythonhosted.org/packages/92/3e/4558b2296963ba99c58d8409c57d7db4f3061b656c3613cb21c02c1ef4c2/ty-0.0.34-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:85de45382016eceae69e104815eb2cfa200787df104002e262a86cbd43ed2c02", size = 10798192, upload-time = "2026-05-01T23:06:40.004Z" }, + { url = "https://files.pythonhosted.org/packages/76/bf/650d24402be2ef678528d60caac1d9477a40fc37e3792ecef07834fd7a4a/ty-0.0.34-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:14cb575fb8fa5131f5129d100cfe23c1575d23faf5dfc5158432749a3e38c9b5", size = 10890390, upload-time = "2026-05-01T23:06:33.076Z" }, + { url = "https://files.pythonhosted.org/packages/5c/ef/ccd2ca13906079f7935fd7e067661b24233017f57d987d51d6a121d85bb5/ty-0.0.34-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c6fc0b69d8450e6910ba9db34572b959b81329a97ae273c391f70e9fb6c1aade", size = 11031564, upload-time = "2026-05-01T23:06:55.812Z" }, + { url = "https://files.pythonhosted.org/packages/ba/2d/d27b72005b6f43599e3bcabab0d7135ac0c230b7a307bb99f9eea02c1cda/ty-0.0.34-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:30dfcec2f0fde3993f4f912ed0e057dcbebc8615299f610a4c2ddb7b5a3e1e06", size = 11553430, upload-time = "2026-05-01T23:06:31.096Z" }, + { url = "https://files.pythonhosted.org/packages/a7/12/20812e1ad930b8d4af70eebf19ad23cff6e31efcfa613ef884531fcdbaa1/ty-0.0.34-py3-none-win32.whl", hash = "sha256:97b77ddf007271b812a313a8f0a14929bc5590958433e1fb83ef585676f53342", size = 10436048, upload-time = "2026-05-01T23:06:49.108Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6a/afa095c5987868fbda27c0f731146ac8e3d07b357adfa83daccaee5b1a16/ty-0.0.34-py3-none-win_amd64.whl", hash = "sha256:1f543968accb952705134028d1fda8656882787dbbc667ad4d6c3ba23791d604", size = 11462526, upload-time = "2026-05-01T23:06:28.514Z" }, + { url = "https://files.pythonhosted.org/packages/63/8f/bf041a06260d77662c0605e56dacfe90b786bf824cbe1aed238d15fe5e84/ty-0.0.34-py3-none-win_arm64.whl", hash = "sha256:ea09108cbcb16b6b06d7596312b433bf49681e78d30e4dc7fb3c1b248a95e09a", size = 10846945, upload-time = "2026-05-01T23:06:44.428Z" }, ] [[package]] @@ -3626,7 +3772,7 @@ wheels = [ [[package]] name = "typer" -version = "0.25.0" +version = "0.25.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-doc" }, @@ -3634,9 +3780,9 @@ dependencies = [ { name = "rich" }, { name = "shellingham" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/7b/27/ede8cec7596e0041ba7e7b80b47d132562f56ff454313a16f6084e555c9f/typer-0.25.0.tar.gz", hash = "sha256:123eaf9f19bb40fd268310e12a542c0c6b4fab9c98d9d23342a01ff95e3ce930", size = 120150, upload-time = "2026-04-26T08:46:14.767Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e4/51/9aed62104cea109b820bbd6c14245af756112017d309da813ef107d42e7e/typer-0.25.1.tar.gz", hash = "sha256:9616eb8853a09ffeabab1698952f33c6f29ffdbceb4eaeecf571880e8d7664cc", size = 122276, upload-time = "2026-04-30T19:32:16.964Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9a/72/193d4e586ec5a4db834a36bbeb47641a62f951f114ffd0fe5b1b46e8d56f/typer-0.25.0-py3-none-any.whl", hash = "sha256:ac01b48823d3db9a83c9e164338057eadbb1c9957a2a6b4eeb486669c560b5dc", size = 55993, upload-time = "2026-04-26T08:46:15.889Z" }, + { url = "https://files.pythonhosted.org/packages/3f/f9/2b3ff4e56e5fa7debfaf9eb135d0da96f3e9a1d5b27222223c7296336e5f/typer-0.25.1-py3-none-any.whl", hash = "sha256:75caa44ed46a03fb2dab8808753ffacdbfea88495e74c85a28c5eefcf5f39c89", size = 58409, upload-time = "2026-04-30T19:32:18.271Z" }, ] [[package]] @@ -3685,11 +3831,11 @@ wheels = [ [[package]] name = "urllib3" -version = "2.6.3" +version = "2.7.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, ] [[package]] @@ -3724,6 +3870,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067, upload-time = "2024-11-01T14:07:11.845Z" }, ] +[[package]] +name = "werkzeug" +version = "3.1.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/dd/b2/381be8cfdee792dd117872481b6e378f85c957dd7c5bca38897b08f765fd/werkzeug-3.1.8.tar.gz", hash = "sha256:9bad61a4268dac112f1c5cd4630a56ede601b6ed420300677a869083d70a4c44", size = 875852, upload-time = "2026-04-02T18:49:14.268Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/93/8c/2e650f2afeb7ee576912636c23ddb621c91ac6a98e66dc8d29c3c69446e1/werkzeug-3.1.8-py3-none-any.whl", hash = "sha256:63a77fb8892bf28ebc3178683445222aa500e48ebad5ec77b0ad80f8726b1f50", size = 226459, upload-time = "2026-04-02T18:49:12.72Z" }, +] + [[package]] name = "xxhash" version = "3.7.0" @@ -4023,31 +4181,32 @@ wheels = [ [[package]] name = "zensical" -version = "0.0.37" +version = "0.0.41" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, { name = "deepmerge" }, + { name = "jinja2" }, { name = "markdown" }, { name = "pygments" }, { name = "pymdown-extensions" }, { name = "pyyaml" }, { name = "tomli" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/7c/57/f499eb86c487953866ab7ff364096b92d9a61fd68d38ca73f740bc42b78e/zensical-0.0.37.tar.gz", hash = "sha256:e43a59e939fbddb50d218aa5b643c24d0e7259155e9e36fb791e5f865af588d5", size = 3903829, upload-time = "2026-04-27T07:59:14.885Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/66/fb/63175f1d6785616541a29b30a3a749a04a1c334d431bbae2635399612705/zensical-0.0.37-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:f75668ec86f4da8302862a272a6f02a32b7384e5518842960de592e0e7b522c7", size = 12509324, upload-time = "2026-04-27T07:58:41.711Z" }, - { url = "https://files.pythonhosted.org/packages/34/19/6c981a60ad0758b4b8601589c5016b5bf8724b066346c933af9600e3a3c0/zensical-0.0.37-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:d853c285ff942883a6f93e665e12af2f4d83b682bbff16db02ce7f058f70d7a4", size = 12383773, upload-time = "2026-04-27T07:58:44.745Z" }, - { url = "https://files.pythonhosted.org/packages/11/b0/5fd6d066ca8d9fea9b4ccafddc4b26f938c584864c648d6be29e5515787b/zensical-0.0.37-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bfd5d4afa750c785a2671f586b6b82f18c906c066990ab374f76ef7d359221ce", size = 12779684, upload-time = "2026-04-27T07:58:47.451Z" }, - { url = "https://files.pythonhosted.org/packages/ed/9f/30daa249ab326d059311c51e802957a1e0bdd0505ae7d8d30c19fb00672c/zensical-0.0.37-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:48c3e7e33b15d2602de4670171ab119e2e7500b34aa7a1eb1d8abb26a3725240", size = 12726437, upload-time = "2026-04-27T07:58:50.028Z" }, - { url = "https://files.pythonhosted.org/packages/4e/3c/9c1a1de28fa807b81746f58eef5f29cf353873fb7fcc2b6af51645fd4569/zensical-0.0.37-cp310-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:649c39cec8b3805b0e4abb8484f1ef85582792cea2f7376b33aa1263fe87ad95", size = 13073605, upload-time = "2026-04-27T07:58:52.882Z" }, - { url = "https://files.pythonhosted.org/packages/06/31/de06d2481581891839f9e2a1395fe32bfb9c9eb98cd1d635c560fb759ac6/zensical-0.0.37-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e4e23ef4245b07bde6d6d8dd2ab902f34f6d3cd459fd136f6d0345cb87ccbd6c", size = 12804476, upload-time = "2026-04-27T07:58:55.478Z" }, - { url = "https://files.pythonhosted.org/packages/54/d0/5bbc27820d6cd622ddd2de5df3743a619003dcec01d51365e06ad623d984/zensical-0.0.37-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:21c6b629bc46062f8cb53e5ad575720df384e4b7db34770f3aeb3f097c3eb4cc", size = 12954884, upload-time = "2026-04-27T07:58:58.108Z" }, - { url = "https://files.pythonhosted.org/packages/df/9f/e5c7624ccfe792254f22f8dbc7e2e4c30bf3ed14ad830e838b0009b42a58/zensical-0.0.37-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:a5f95ebe531264b36cacabf208f3ea7cb7e9d874de857bb6bd3328cdabcf9257", size = 12997404, upload-time = "2026-04-27T07:59:00.488Z" }, - { url = "https://files.pythonhosted.org/packages/d1/7a/70fe375ce974cdfd4eb42df53340c5b2e790f6a1d20a224f6ab5283c0a32/zensical-0.0.37-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:96375cd6338c2db731ce671e41afc108194552f5ceb75edd14a54eea48adfb89", size = 13139582, upload-time = "2026-04-27T07:59:03.452Z" }, - { url = "https://files.pythonhosted.org/packages/07/49/0a7b27c89d52d0116ac466845f88495acb0b925405527df0079b2ad56508/zensical-0.0.37-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:7be0976869317a2e1672f426853183c32a8193f211347d560d6037358a52fe7b", size = 13082714, upload-time = "2026-04-27T07:59:06.375Z" }, - { url = "https://files.pythonhosted.org/packages/ea/25/81c3f1de09abf571a7d6fdab3c836e37cc5a093596939d8d3af745251ae7/zensical-0.0.37-cp310-abi3-win32.whl", hash = "sha256:e1295f63230621ccb01ac7cd6be24f0ff079a12e95d9bdde631a13331c7ba67f", size = 12093854, upload-time = "2026-04-27T07:59:09.244Z" }, - { url = "https://files.pythonhosted.org/packages/a0/77/419377bee6b91746c5c3de19712eeba6c367c0b14b77c6b9c94e6bf2c3a2/zensical-0.0.37-cp310-abi3-win_amd64.whl", hash = "sha256:c015451bd9af60ee0fb01b95d0b17b751a0790fec58c3e0efd11ee4286ad2724", size = 12317524, upload-time = "2026-04-27T07:59:11.929Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/89/d6/b3e931233e53a2377ef5915cc6e786845c3263306874a469af8fb569ef9c/zensical-0.0.41.tar.gz", hash = "sha256:6c3c90301123749dfc26a210d6c080f0691253c7c765ad308a10b4518369a6fe", size = 3927788, upload-time = "2026-05-09T14:35:29.005Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/72/08/ee18207c9b4e3ada74a0f4adf253bea90da39ae43772761cd91072e3a1fc/zensical-0.0.41-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:f06a0015dcfdf7aeca73f4998a401db65db0ae2dd72da9629a7be8f9a4d0b7b6", size = 12701539, upload-time = "2026-05-09T14:34:48.6Z" }, + { url = "https://files.pythonhosted.org/packages/4c/93/d4635fbbce8171cf71dd64285d9f6d5773a2b624b928f1dd8acaf1ee9f9f/zensical-0.0.41-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:4e524ce68c9ff082ffaded9f742407097cf51bab692b7bc18d3c174b966174fe", size = 12560038, upload-time = "2026-05-09T14:34:51.666Z" }, + { url = "https://files.pythonhosted.org/packages/f2/4a/1730a30377bbb0914ed740e0e289d379b0552673b6cf912aefe7a205440c/zensical-0.0.41-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a4afe35331cd2394c408cd362458936479cc0ed4fb272478498e4794aafc7414", size = 12942926, upload-time = "2026-05-09T14:34:54.393Z" }, + { url = "https://files.pythonhosted.org/packages/32/e3/d9a0416ef4edc043ce9f404a66f1934f102bcb645b103abb26b180ba5680/zensical-0.0.41-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:15a850285050f03aeb3b67ce7d99943093059fe8d32fc7731fa9f27be45c64cc", size = 12912711, upload-time = "2026-05-09T14:34:57.174Z" }, + { url = "https://files.pythonhosted.org/packages/68/d0/775852783bef835425306a2fcd8236ef14fd19160e1b4261e192bf2d9f54/zensical-0.0.41-cp310-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:35052e9dbefabe3a71c4836cfc4afa6c9469e5eeddc2a3ee750803ae3fe777dc", size = 13275869, upload-time = "2026-05-09T14:34:59.93Z" }, + { url = "https://files.pythonhosted.org/packages/c3/95/554273cc09a270ced0213d3e0aac8b3fc2b472fc2b26771d56fc8fd55047/zensical-0.0.41-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a47f459205fb55f64dcb6c65e9f3c2fa00a2b4306c5ef1b71b9a50c45007071d", size = 12980177, upload-time = "2026-05-09T14:35:02.81Z" }, + { url = "https://files.pythonhosted.org/packages/ec/b5/d74d5040b3121db5c72b0134f0455641b90b1277fb1330a8e5e0029ca8d3/zensical-0.0.41-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:aa3b3b3a4e6f75f6bb3c1aca1fad7a96cebf54cbd4e31122f6876503b8801666", size = 13119629, upload-time = "2026-05-09T14:35:07.105Z" }, + { url = "https://files.pythonhosted.org/packages/62/9a/93527acd7750092d7fca2e6c43fe2b8f1e85e1c96a1002baf6a08201c6f7/zensical-0.0.41-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:565133fd48b2ce939698c174c0c1c6470407a8fb6a90a2bb0eeec97cd4344444", size = 13182183, upload-time = "2026-05-09T14:35:10.105Z" }, + { url = "https://files.pythonhosted.org/packages/b2/7e/d77e4c809bfcbad40db85a6a7beeda2ee5c964232e0186783c3a837a7d0b/zensical-0.0.41-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:cec0a2b05eaaace0c7424bab3f2884da03ade212cac4ba4487c58691ec13ec65", size = 13330444, upload-time = "2026-05-09T14:35:13.245Z" }, + { url = "https://files.pythonhosted.org/packages/fd/e8/ecbb7e34bff88aa892c676b8b2e2ddf425f94d66cbb84b80016095191b77/zensical-0.0.41-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:1736f0cb7686628cc6f53952d208423f20b542f0c16b0c2ddd7e702bf6e41fdd", size = 13263093, upload-time = "2026-05-09T14:35:20.826Z" }, + { url = "https://files.pythonhosted.org/packages/c1/6f/48b2f81ce708d19bb807d94716f2772ec4b74389b6d29024669fc470df08/zensical-0.0.41-cp310-abi3-win32.whl", hash = "sha256:34a78645c68fba152faacb66516c895283166154f8b15b61440a6c21c84f0974", size = 12253644, upload-time = "2026-05-09T14:35:23.598Z" }, + { url = "https://files.pythonhosted.org/packages/a0/92/5cf943133f61b996965743deeaff467f278135521f58d83ca68d2601ded3/zensical-0.0.41-cp310-abi3-win_amd64.whl", hash = "sha256:00d80cd573152e0efb655143bbdfe8788eb4b33167a802639fdb1b1800b724ac", size = 12483190, upload-time = "2026-05-09T14:35:26.43Z" }, ] [[package]] From ac88b169c8fdf2a683f7a46a9cb9ed0b3a0e803f Mon Sep 17 00:00:00 2001 From: Jae-Won Chung Date: Sun, 10 May 2026 22:35:38 -0400 Subject: [PATCH 12/13] Fix fmt --- examples/rl_controller/controller.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/examples/rl_controller/controller.py b/examples/rl_controller/controller.py index 352c6d0..18baa1b 100644 --- a/examples/rl_controller/controller.py +++ b/examples/rl_controller/controller.py @@ -178,9 +178,7 @@ def step( } self._zone_masks_computed = True - obs = build_observation( - grid, datacenter, self._obs_config, self._prev_batch, self._zone_mask, self._zone_masks - ) + obs = build_observation(grid, datacenter, self._obs_config, self._prev_batch, self._zone_mask, self._zone_masks) if self._vecnormalize is not None: obs = self._vecnormalize.normalize_obs(obs) action, _ = self._sb3_model.predict(obs, deterministic=True) From 06c3600e22f19ce9b5894d94afcd983901a05070 Mon Sep 17 00:00:00 2001 From: Jae-Won Chung Date: Sun, 10 May 2026 22:40:17 -0400 Subject: [PATCH 13/13] Remove personal stuff in .gitignore --- .gitignore | 27 +-------------------------- 1 file changed, 1 insertion(+), 26 deletions(-) diff --git a/.gitignore b/.gitignore index 9586fee..0679a9a 100644 --- a/.gitignore +++ b/.gitignore @@ -13,30 +13,5 @@ docs/api/ data examples/*/outputs -.claude/ -outputs/ -slurm_*.out -examples/offline/*.sh -/sbatch_*.sh -/launch_*.sh -# Paper-specific plotting scripts (not part of the upstream pipeline) -examples/offline/plot_4way_multiseed.py -examples/offline/plot_dataset_difficulty_3systems.py -examples/offline/plot_dataset_diversity_ieee13.py -examples/offline/plot_pipeline.py -examples/offline/plot_training_reward_3systems.py -examples/offline/plot_training_reward_breakdown_3systems.py -examples/offline/plot_wv_sweep_combined_ieee13.py -examples/offline/plot_wv_sweep_convergence_ieee13.py -examples/offline/plot_wv_sweep_training_violation_ieee13.py -examples/offline/dump_paper_batch_subfigures.py -examples/offline/check_consistency.py -examples/offline/compare_ppo_runs.py -examples/offline/merge_over_supplement_ieee13.py -examples/offline/migrate_scenario_library.py -examples/offline/filter_scenario_library.py -.git.backup-pre-filter/ -/paper_plots.mplstyle -/plots.py -scripts/verify_post_merge_migration.sh +.claude/