diff --git a/.gitignore b/.gitignore index 34c0afe..0679a9a 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,5 @@ docs/api/ data examples/*/outputs + +.claude/ diff --git a/README.md b/README.md index c137bc1..8a17b97 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/_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..fdb255d --- /dev/null +++ b/docs/examples/rl-controller.md @@ -0,0 +1,128 @@ +# 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 + +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). +- 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. + +| Script | Purpose | +|--------|---------| +| `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 "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/rl_controller/outputs//`. Input-artifact flags (`--scenario-library`, `--ppo-models`) accept arbitrary path strings, resolved against cwd. + +## Usage + +### IEEE 13: end-to-end example + +#### 1. Dataset generation + +**1a. Build training library** + +```bash +python examples/rl_controller/build_library.py \ + --system ieee13 \ + --n-candidates 500 --seed-start 0 \ + --tag train_n500 +``` + +**1b. Build test library** (use a different `--seed-start` so train and test seeds don't overlap) + +```bash +python examples/rl_controller/build_library.py \ + --system ieee13 \ + --n-candidates 150 --seed-start 1000 \ + --tag test_n150 +``` + +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/rl_controller/train_ppo.py \ + --system ieee13 \ + --total-timesteps 2000000 \ + --total-duration-s 3600 \ + --n-steps 3600 \ + --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 examples/rl_controller/outputs/ieee13/scenario_library/train_n500 \ + --no-ofo-baseline \ + --output-dir ppo +``` + +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/rl_controller/evaluate.py \ + --system ieee13 \ + --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 \ + --use-display-names \ + --output-dir eval_4ctrl_ieee13 \ + --log-level INFO +``` + +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. + +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`. + +**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). +- **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 + +Most knobs are CLI flags on `train_ppo.py` (network architecture, optimizer, reward weights, scenario randomization). For deeper changes: + +- **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 46b8957..00ab515 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 | +| `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/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 a13d390..6efb59d 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 @@ -123,7 +123,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. @@ -208,7 +208,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 @@ -262,7 +262,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) @@ -402,7 +402,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, @@ -414,7 +414,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}) @@ -455,7 +455,7 @@ def _v(v, mode, col, sub=sub): pair_palette = PARALLELISM_PAIR_COLORS.get(pair, (COLORS["h100"], COLORS["b200"])) color_map = {labels[i]: pair_palette[i] 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, @@ -467,7 +467,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() @@ -523,7 +523,7 @@ def _v(pair_, hw, mode, col): p: MODEL_COLORS[_pretty(next(v for (pp, _), v in variants_by_pair_hw.items() if pp == p))] for p in 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 @@ -546,7 +546,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. @@ -655,7 +655,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, @@ -667,7 +667,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() @@ -684,7 +684,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 @@ -733,7 +733,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)) @@ -751,7 +751,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/rl_controller/build_library.py b/examples/rl_controller/build_library.py new file mode 100644 index 0000000..556a34f --- /dev/null +++ b/examples/rl_controller/build_library.py @@ -0,0 +1,1435 @@ +"""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/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 + +import csv +import logging +import math +from dataclasses import dataclass +from pathlib import Path + +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 +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 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, + POWER_AUG, + SPECS_CACHE_DIR, + TOTAL_DURATION_S, + TRAINING_TRACE_PATH, + V_MAX, + V_MIN, +) + +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 + + 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": + 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"] + 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 + 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" 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 + + 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": + 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, + ) + + for ctrl in controllers: + if isinstance(ctrl, SharedPPOBatchSizeController): + ctrl.attach_datacenters(datacenters) + + 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 + + +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 `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 _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 _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 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)", # 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 + 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, + ) + 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 ── + 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), + } + + 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": ( + { + "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() 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: + 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(merged), + ) + 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()) + + +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_dir: Path, + supp_dir: 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_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_scenarios), + len(pure_over_new), + ) + + if swap_undervoltage_for_overvoltage <= 0: + new_scenarios = list(base_scenarios) + pure_over_new + else: + n_take = min(swap_undervoltage_for_overvoltage, len(pure_over_new)) + under_only_idx = [ + i for i, r in enumerate(base_scenarios) if r.bl_undervoltage_time_s > 0 and r.bl_overvoltage_time_s == 0 + ] + 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(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(base_scenarios) if i not in remove_idx] + pure_over_new[:n_take] + + 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__": + 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).""" # 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.""" # noqa: E501 + max_baseline_integral: float = 1e9 + """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).""" # 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.""" # 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).""" # 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).""" # 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.""" # 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 + 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 + # ── 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) = 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 + 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.""" # noqa: E501 + 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.""" # noqa: E501 + 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).""" # 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.""" # 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 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.""" # 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( + 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, + ) + + # ── 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_dir=scenario_root / args.tag, + supp_dir=scenario_root / supp_tag, + swap_undervoltage_for_overvoltage=args.swap_undervoltage_for_overvoltage, + ) diff --git a/examples/rl_controller/controller.py b/examples/rl_controller/controller.py new file mode 100644 index 0000000..18baa1b --- /dev/null +++ b/examples/rl_controller/controller.py @@ -0,0 +1,327 @@ +"""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 env import ObservationConfig, build_observation, compute_zone_mask, decode_action + +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 + + +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 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 + 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": []} # noqa: RUF012 + + 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) -> 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), + 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_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 + 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_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) + 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_masks_computed = False + + def step( + self, + clock: SimulationClock, + events: EventEmitter, + ) -> list[DatacenterCommand | GridCommand]: + datacenter = self._datacenter + grid = self._grid + # 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) + 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) + + 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), + algorithm: str = "PPO", + vecnormalize_path: str | Path | None = None, + ) -> None: + self._datacenter = datacenter + self._grid = grid + 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 + 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_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 + # 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: 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} + + @property + def dt_s(self) -> Fraction: + return self._dt_s + + def reset(self) -> None: + self._init_prev_batch() + self._zone_masks_computed = False + + def step( + self, + clock: SimulationClock, + events: EventEmitter, + ) -> list[DatacenterCommand | GridCommand]: + grid = self._grid + 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) + 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) + + 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}) + + 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 + commands.append(SetBatchSize(batch_size_by_model=site_batch, target=self._dcs_by_sid[sid])) + return commands diff --git a/examples/rl_controller/env.py b/examples/rl_controller/env.py new file mode 100644 index 0000000..f8fdcdc --- /dev/null +++ b/examples/rl_controller/env.py @@ -0,0 +1,819 @@ +"""Gymnasium environment wrapping the openg2g simulation for RL training.""" + +from __future__ import annotations + +import math +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.controller.ofo import LogisticModelStore +from openg2g.controller.tap_schedule import TapScheduleController +from openg2g.coordinator import Coordinator +from openg2g.datacenter.base import DatacenterBackend, LLMDatacenterState +from openg2g.datacenter.command import DatacenterCommand, SetBatchSize +from openg2g.datacenter.config import InferenceModelSpec +from openg2g.grid.base import GridBackend +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 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"`.""" + + +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 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}") + + +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"`, + `"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 + 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 + + +SimComponents = tuple[ + dict[str, DatacenterBackend], # datacenters keyed by site_id + GridBackend, # grid + TapScheduleController | None, # tap controller (optional) +] + +MakeSimFn = Callable[..., SimComponents] + + +class ScenarioLibrary: + """Pre-screened scenario bank for PPO training and evaluation. + + Loaded from the directory produced by `build_library.py`: + + - `metadata.json`: build-time config + per-scenario scalar fields. + - `traces.npz`: per-scenario voltage penalty arrays + (`ofo_` / `baseline_`). + + `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, + *, + 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: + 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] + + 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 + 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 = {"render_modes": []} # noqa: RUF012 # gym Env.metadata override; not annotated to avoid ty's invalid-attribute-override + + def __init__( + self, + make_sim_fn: MakeSimFn, + 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, + 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 = 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 + n_coupled = 2 * self._coupled_max_shift + 1 + + 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._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 + self._ofo_voltage_trace: np.ndarray | None = None + 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( + action, + self._action_mode, + self._obs_config.model_labels, + self._obs_config.feasible_batch_sizes, + self._prev_batch, + self._coupled_max_shift, + ) + + 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_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): + 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 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_record = self._scenario_library.sample() + if self._ofo_baseline: + self._ofo_voltage_trace = scenario_record.ofo_voltage_pen_per_step + if self._truncate_episode: + 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 + + 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() + + 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)) + for zname, zbuses in self._obs_config.zone_summary.items() + } + else: + self._zone_masks = None + + # 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_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( + 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]: + coord = self._coord + if coord is None: + raise RuntimeError("Coordinator not started; call reset() first.") + + 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( + coord.grid, + target, + self._obs_config, + self._reward_config, + self._prev_batch, + applied_batch, + self._logistic_models, + steps_since_change=self._steps_since_change, + ) + + for label in self._obs_config.model_labels: + 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: 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 + 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 + + if self._reward_config.reward_clip > 0: + reward = max(reward, -self._reward_config.reward_clip) + + self._prev_batch = dict(applied_batch) + obs = build_observation( + coord.grid, target, self._obs_config, self._prev_batch, self._zone_mask, self._zone_masks + ) + + truncated = self._steps_done >= self._max_steps + info = {"reward_components": reward_components, "voltage_stats": voltage_stats} + return obs, reward, False, truncated, info + + def close(self) -> None: + if self._coord is not None: + self._coord.stop() + self._coord = None + super().close() + + +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 each + site_id to the list of model labels served 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, + action_mode: str = "delta", + 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__( + make_sim_fn=make_sim_fn, + obs_config=obs_config, + reward_config=reward_config, + action_mode=action_mode, + 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_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(): + 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()) diff --git a/examples/rl_controller/evaluate.py b/examples/rl_controller/evaluate.py new file mode 100644 index 0000000..adfcd21 --- /dev/null +++ b/examples/rl_controller/evaluate.py @@ -0,0 +1,897 @@ +"""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/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/rl_controller/evaluate.py \ + --ppo-models outputs/ieee13/ppo/ppo_model.zip \ + --n-scenarios 3 +""" + +from __future__ import annotations + +import csv +import logging +import math +from dataclasses import dataclass +from pathlib import Path + +import numpy as np +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, + SPECS_CACHE_DIR, + TRAINING_TRACE_PATH, + V_MAX, + V_MIN, +) + +logger = logging.getLogger("evaluate_controllers") + + +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, + } + + +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, + *, + n_scenarios: int, + training_trace: TrainingTrace, +) -> list[dict]: + """Load pre-screened scenarios from a `ScenarioLibrary` directory. + + 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`. + """ + 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", + len(lib), + n_scenarios, + n_take, + ) + + return [lib.materialize(rec) for rec in lib.scenarios[:n_take]] + + +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_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 = RuleBasedConfig(v_min=V_MIN, v_max=V_MAX, step_size=step_size) + 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"], + ) + + 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, strict=False): + 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 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(str(i) for i in range(len(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), + [(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.mkdir(parents=True, exist_ok=True) + + if scenario_library: + test_scenarios = load_scenarios_from_library( + scenario_library, + n_scenarios=n_scenarios, + training_trace=training_trace, + ) + 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_{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] + + 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, 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], + ] + ) + 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.""" # 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 + """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).""" # noqa: E501 + 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 directory (from build_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/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/rl_controller/train_ppo.py b/examples/rl_controller/train_ppo.py new file mode 100644 index 0000000..f8d93c8 --- /dev/null +++ b/examples/rl_controller/train_ppo.py @@ -0,0 +1,850 @@ +"""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 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 + +import csv +import logging +import sys +from dataclasses import dataclass +from pathlib import Path + +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 ( + 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 systems import ( + DT_CTRL, + DT_DC, + DT_GRID, + POWER_AUG, + SPECS_CACHE_DIR, + TRAINING_TRACE_PATH, + V_MAX, + V_MIN, +) + +logger = logging.getLogger(__name__) + + +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, 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"] + 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: dict | None = None): + _episode_counter[0] += 1 + + if scenario_override is not None: + 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 = scenario_override["pv_systems"] + tvl = scenario_override["tvl"] + training = scenario_override["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 + + 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, + ) + + 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 + + +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: + 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", + ] + ) + + 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: + if self._fp is not None: + self._fp.close() + self._fp = None + + return _Impl(csv_path) + + +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, 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 + + +@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).""" + 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 + """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.""" # noqa: E501 + 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.""" # 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" + """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).""" # 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.""" # noqa: E501 + 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).""" # noqa: E501 + 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 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 + """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.""" # noqa: E501 + + +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) + + training_trace = TrainingTrace.ensure(TRAINING_TRACE_PATH) + + exp = EXPERIMENTS[args.system](training_trace) + script_dir = Path(__file__).resolve().parent + 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 + 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: + 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, + ) + + 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, + args.action_mode, + ) + logger.info("=" * 60) + + checkpoint_cb = CheckpointCallback( + 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, + ) + 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) + + def lr_arg(progress_remaining): + return 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: + 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, + action_mode=args.action_mode, + 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, + action_mode=args.action_mode, + 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}") + + # 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 + + 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) + + +if __name__ == "__main__": + main() diff --git a/openg2g/controller/ofo.py b/openg2g/controller/ofo.py index 871713d..721ff02 100644 --- a/openg2g/controller/ofo.py +++ b/openg2g/controller/ofo.py @@ -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/controller/rule_based.py b/openg2g/controller/rule_based.py index 6e1a388..c015d81 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,11 @@ 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 +108,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)", # noqa: E501 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 +144,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/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 4bee278..e090092 100644 --- a/openg2g/datacenter/workloads/inference.py +++ b/openg2g/datacenter/workloads/inference.py @@ -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/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/pyproject.toml b/pyproject.toml index ab20202..07342b6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,7 +15,7 @@ dependencies = [ "pydantic>=2.0", "aiohttp", "zeus>=0.15.0", - "mlenergy-data>=0.4.0", + "mlenergy[data]>=0.1.0", ] [project.urls] @@ -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"] diff --git a/uv.lock b/uv.lock index 1df5ddc..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,6 +1748,20 @@ 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.4.0" @@ -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", specifier = ">=0.4.0" }, + { 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]]