diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 0234e23b..33e3064d 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -30,6 +30,10 @@ jobs: run: npm run build working-directory: viewer + - name: Check viewer + run: npm run lint && npm test + working-directory: viewer + - name: Install uv uses: astral-sh/setup-uv@v5 with: @@ -38,6 +42,29 @@ jobs: - name: Build package run: uv build + - name: Install built package for browser validation + run: | + uv venv .venv + uv pip install dist/*.whl + .venv/bin/python viewer/tests/prepare.py + + - name: Generate the published examples from scratch + run: | + uv pip install -r examples/requirements.txt + .venv/bin/python examples/build_gallery.py --output results/gallery + .venv/bin/python viewer/export_docs.py --source results/gallery --output results/viewer-release/gallery + + - name: Install browser + run: npx playwright install --with-deps chromium + working-directory: viewer + + - name: Verify local viewer and portable report + run: npm run test:browser + working-directory: viewer + env: + PLAYWRIGHT_CHANNEL: chromium + VIEWER_GALLERY_URL: http://127.0.0.1:8722/gallery/index.html + - name: Upload dist uses: actions/upload-artifact@v4 with: diff --git a/.github/workflows/lint-and-unit-test.yml b/.github/workflows/lint-and-unit-test.yml index fd8b5541..6216d3e5 100644 --- a/.github/workflows/lint-and-unit-test.yml +++ b/.github/workflows/lint-and-unit-test.yml @@ -8,7 +8,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: [3.9, 3.13] + python-version: ["3.9", "3.10", "3.11", "3.12", "3.13", "3.14", "3.15"] steps: - uses: actions/checkout@v4 @@ -29,6 +29,10 @@ jobs: run: npm run build working-directory: viewer + - name: Test viewer data format + run: npm test + working-directory: viewer + # Python setup - name: Install uv and set python version to ${{ matrix.python-version }} uses: astral-sh/setup-uv@v5 @@ -40,7 +44,7 @@ jobs: run: uv pip install hatch-vcs - name: Install dependencies - run: uv pip install -e ".[dev,sil]" + run: uv pip install -e ".[dev,live,mosaik]" # type checking - name: Run type checker @@ -53,3 +57,10 @@ jobs: # unit-testing - name: Run tests run: make test + + - name: Check reproducible examples with pinned dependencies + if: matrix.python-version == '3.13' + run: >- + uv run --extra dev --extra live --extra mosaik + --with-requirements examples/requirements.txt + pytest tests/test_examples.py tests/test_infrastructure_study.py tests/test_microgrid_comparison.py diff --git a/.gitignore b/.gitignore index 5e1aac01..3d291608 100644 --- a/.gitignore +++ b/.gitignore @@ -90,3 +90,8 @@ crash.log # Sphinx _build results.csv + +# Browser validation outputs +viewer/test-results/ +viewer/playwright-report/ +/test-results/ diff --git a/.readthedocs.yml b/.readthedocs.yml index 97b96c60..82814b1f 100644 --- a/.readthedocs.yml +++ b/.readthedocs.yml @@ -8,10 +8,8 @@ build: jobs: post_create_environment: - pip install uv - pre_install: - - cd viewer && npm ci && npx vite build post_install: - - uv pip install -e ".[docs,sil]" + - uv pip install -e ".[docs,live]" pre_build: - make docs-viewer diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d49ad9d5..e47b17df 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -5,7 +5,7 @@ Install dependencies (requires [uv](https://docs.astral.sh/uv/)): ```bash -uv pip install -e ".[dev,sil,docs]" +uv pip install -e ".[dev,live,docs]" ``` ## Viewer @@ -61,4 +61,4 @@ make docs-viewer ## Release 1. Create a GitHub release (tag triggers the `publish.yml` workflow) -2. The workflow builds the viewer, bundles it into the Python package, and publishes to PyPI \ No newline at end of file +2. The workflow builds the viewer, bundles it into the Python package, and publishes to PyPI diff --git a/Makefile b/Makefile index 98ed1c16..8fbe58e6 100644 --- a/Makefile +++ b/Makefile @@ -1,28 +1,25 @@ .PHONY: docs docs-viewer serve-docs typecheck lint test check +CHECK_RUN = uv run --extra dev --extra live --extra mosaik + check: typecheck lint test typecheck: - uv run mypy vessim + $(CHECK_RUN) mypy vessim lint: - uv run ruff check vessim + $(CHECK_RUN) ruff check vessim test: - uv run pytest + $(CHECK_RUN) pytest docs: docs-viewer - uv run mkdocs build + uv run --extra docs mkdocs build serve-docs: docs-viewer - uv run mkdocs serve --watch docs + uv run --extra docs mkdocs serve --watch docs docs-viewer: - cd viewer && [ -d node_modules ] || npm ci - cd viewer && VITE_STATIC_BASE="./" npx vite build --outDir dist - cp -r viewer/dist/. docs/viewer/ - uv run python examples/basic_example.py - mkdir -p docs/viewer/results - cp results/basic_example/metadata.yaml docs/viewer/results/ - cp results/basic_example/timeseries.csv docs/viewer/results/ - echo '{"mode":"single","experiments":[{"name":"","status":"completed"}]}' > docs/viewer/experiments.json + npm --prefix viewer ci + npm --prefix viewer run build + uv run --with-requirements examples/requirements.txt python viewer/export_docs.py diff --git a/README.md b/README.md index f5f11be4..75889656 100644 --- a/README.md +++ b/README.md @@ -2,80 +2,96 @@ Vessim Logo

-Vessim is a **co-simulation testbed for microgrids**\* +Vessim is a Python library for simulating datacenter energy systems. -Vessim lets you model how loads, (renewable) generation, energy storage, and the public grid interact, and test control and dispatch strategies against them, in pure simulation or with **real software and hardware** in the loop. +Vessim models the interaction between datacenter demand and energy infrastructure, helping you understand how design and control decisions affect cost, carbon emissions, and resilience. -*\*By "microgrid" we mean any local energy system that combines loads, generation, and storage behind a grid connection. Grids can scale from small battery-constrained systems to a GW-scale datacenter campus.* +[Documentation](https://vessim.readthedocs.io/en/latest/) · [Getting started](https://vessim.readthedocs.io/en/latest/getting_started/) · [Explore example results](https://vessim.readthedocs.io/en/latest/vessim_viewer/) -Check out the [website and documentation](https://vessim.readthedocs.io/en/latest/)! +- **Simulation studies:** Compare infrastructure and control strategies across demand, weather, and operating scenarios. + Explore design choices through parameter sweeps, optimization, or probabilistic simulations. +- **Live modeling:** Connect real applications to a simulated energy system through REST APIs. + Test how a scheduler or controller responds to changing power availability. +- **Digital twins:** Model an energy system alongside the real installation using live measurements. + Explore possible futures, such as prolonged low renewable generation or a grid outage. -## What can I do with Vessim? +## Getting started -Vessim helps you understand and optimize how loads, energy sources, and storage interact. -It was originally designed for energy-aware and carbon-aware data centers, but has also been used for other use cases such as battery price arbitrage and demand response for cooling warehouses. - -- **Control and dispatch strategies**: Test battery charging/discharging, load-shifting, and price- or carbon-driven dispatch. -- **Demand response and power outages**: Simulate demand response signals or power outages to understand your system's flexibility and test mitigation strategies. -- **Microgrid composition**: Experiment with adding solar panels, wind turbines, or batteries to see how they would affect your energy costs and carbon emissions. -- **Energy- and carbon-aware computing**: Develop applications that adapt their energy consumption to the carbon intensity and price of electricity. - -Vessim can simulate multiple distributed microgrids in parallel and easily integrates historical datasets and new simulators. -Vessim’s software-in-the-loop capabilities let you run real systems against simulated microgrids. Connect live data sources like Prometheus and interact through REST APIs. +Install Vessim with Python 3.9 to 3.15: +```console +python -m pip install vessim +``` -## Simple Example +This example models three days of datacenter demand, solar generation, and battery operation. +Surplus solar power charges the battery, which supplies demand when solar generation falls. +The grid supplies any remaining deficit and receives surplus power once the battery is full. -The scenario below simulates a microgrid with a computing system drawing 700W, a solar panel, and a 1.5 kWh battery. +Download [solar_example.csv](https://raw.githubusercontent.com/dos-group/vessim/main/datasets/solar_example.csv) into your working directory, then run: ```python import vessim as vs -environment = vs.Environment(sim_start="2022-06-09", step_size=300) +# Scale the solar profile to a 2.4 kW installation. +solar = vs.Trace.from_csv("solar_example.csv", column="Berlin", scale=2400) -environment.add_microgrid( - name="datacenter", +env = vs.Environment(sim_start="2026-01-01", step_size=5 * 60) +env.add_microgrid( + name="site", actors=[ - vs.Actor(name="server", signal=vs.StaticSignal(value=700), consumer=True), - vs.Actor(name="solar_panel", signal=vs.Trace.from_csv( - "datasets/solar_example.csv", column="Berlin", scale=5000 - )), - ], - dispatchables=[ - vs.SimpleBattery(name="battery", capacity=1500, initial_soc=0.8, min_soc=0.3) + vs.Consumer("load", signal=700), + vs.Producer("solar", signal=solar), ], + dispatchables=[vs.SimpleBattery("battery", capacity=1000), vs.GridConnection()], ) +recorder = env.add_controller(vs.Recorder("results/basic_example")) +env.run(until=3 * 24 * 3600) + +metrics = recorder.metrics["microgrids"]["site"] +print(f"Grid import: {metrics['grid_import'] / 1000:.2f} kWh") +print(f"Grid export: {metrics['grid_export'] / 1000:.2f} kWh") +``` -environment.add_controller(vs.CsvLogger("results/my_experiment")) -environment.run(until=24 * 3600) +```text +Grid import: 26.71 kWh +Grid export: 11.08 kWh ``` -Check out the [Getting Started walkthrough](https://vessim.readthedocs.io/en/latest/getting_started/) and [`examples/`](examples/) for software-in-the-loop simulations. +[Explore this example in your browser](https://vessim.readthedocs.io/en/latest/viewer/index.html?study=getting-started#results), or open your saved results locally: +```console +python -m vessim view results/basic_example +``` -## Installation +The [Getting Started walkthrough](https://vessim.readthedocs.io/en/latest/getting_started/) explains how the components work together. +To use your own data, see [Actors and Signals](https://vessim.readthedocs.io/en/latest/concepts/signals/). -You can install the [latest release](https://pypi.org/project/vessim/) of Vessim -via [pip](https://pip.pypa.io/en/stable/quickstart/): +## Explore further -``` -pip install vessim -``` +| Example | What you can explore | +| --- | --- | +| [Exploring microgrid configurations](examples/infrastructure_study.py) | Compare wind, solar, and battery configurations over a year. [View results](https://vessim.readthedocs.io/en/latest/viewer/index.html?study=infrastructure-year#results) | +| [Optimization](https://vessim.readthedocs.io/en/latest/concepts/optimization/) | Search for designs with different cost and emissions trade-offs. [View results](https://vessim.readthedocs.io/en/latest/viewer/index.html?study=optuna-search#results) | +| [Dispatch strategies](examples/strategy_comparison.py) | Compare control policies across demand scenarios. [View results](https://vessim.readthedocs.io/en/latest/viewer/index.html?study=dispatch-comparison#results) | +| [Demand response](examples/demand_response.py) | Adjust workload concurrency under a flexible grid connection agreement | +| [Custom metrics](examples/custom_metrics.py) | Calculate electricity costs, emissions, and your own study objectives | -If you require software-in-the-loop capabilities, install the `sil` extension: +[Run the examples locally](examples/README.md) with a shared setup and reproducible commands. -``` -pip install vessim[sil] -``` +For [live measurements and REST control](https://vessim.readthedocs.io/en/latest/concepts/live/), install `pip install 'vessim[live]'` and use `vs.Environment.live()`. +To [connect external simulators](https://vessim.readthedocs.io/en/latest/concepts/coupling/) through Mosaik, install `pip install 'vessim[mosaik]'`. ## Publications -If you use Vessim in your research, please cite our paper: +If you use Vessim in your research, please cite: + +Philipp Wiesner, Ilja Behnke, Paul Kilian, Marvin Steinke, and Odej Kao. +[**Vessim: A Testbed for Carbon-Aware Applications and Systems.**](https://dl.acm.org/doi/pdf/10.1145/3727200.3727210) +*ACM SIGENERGY Energy Informatics Review 4 (5)*, 2024. -- Philipp Wiesner, Ilja Behnke, Paul Kilian, Marvin Steinke, and Odej Kao. "[Vessim: A Testbed for Carbon-Aware Applications and Systems.](https://dl.acm.org/doi/pdf/10.1145/3727200.3727210)" _ACM SIGENERGY Energy Informatics Review 4 (5)_. 2024. +More papers are listed in the [documentation](https://vessim.readthedocs.io/en/latest/publications/)! -For details in Vessim's software-in-the-loop simulation methodology, refer to: -- Philipp Wiesner, Marvin Steinke, Henrik Nickel, Yazan Kitana, and Odej Kao. "[Software-in-the-Loop Simulation for Developing and Testing Carbon-Aware Applications.](https://doi.org/10.1002/spe.3275)" _Software: Practice and Experience, 53 (12)_. 2023. +## About -For more related papers and concrete use cases, please refer to the [documentation](https://vessim.readthedocs.io/en/latest/publications). +Vessim was originally developed at the research group for [Distributed and Operating Systems](https://distributedsystems.berlin/) at TU Berlin. It is now maintained at the [Machine Learning Systems Lab](https://mlsys.cst.cam.ac.uk/) at the University of Cambridge. diff --git a/benchmarks/README.md b/benchmarks/README.md new file mode 100644 index 00000000..8cc7c737 --- /dev/null +++ b/benchmarks/README.md @@ -0,0 +1,82 @@ +# Benchmarks + +These scripts measure simulation speed, recorder overhead, and viewer indexing. +Run them from a checkout installed with `pip install -e .`. +Install `pip install -e '.[mosaik]'` only when comparing backends. +Write reports under `results/` to keep generated measurements out of the repository. + +## Backend workloads + +```console +python benchmarks/run.py --backend native --output results/benchmarks/native.json +python benchmarks/run.py --backend mosaik --output results/benchmarks/mosaik.json +``` + +Six workloads cover static inputs, traces, CSV recording, multiple microgrids, mixed sampling intervals, and controller feedback. +Reports contain median setup and execution times, individual timing samples, peak traced Python memory, and trajectory checksums. +Each workload retains its trajectory in memory with metrics disabled, and the CSV case additionally writes it to disk. +Repeated runs must produce the same checksum. +Imports and trace loading are outside the timed workloads, except for lazy backend initialization on first use. +Memory measurements run separately from timing measurements. + +Use `--steps` and `--repeats` to change the workload size. +To profile all six workloads separately: + +```console +python benchmarks/run.py --output results/benchmarks/native.json --profile results/benchmarks/profiles +python -m pstats results/benchmarks/profiles/traces.prof +``` + +The fixed `input.csv` is also used to compare native and Mosaik trajectories in `tests/test_backends.py`. +The tests compare every timestamp and recorded field, allowing floating-point roundoff in numeric values. +Separate behavioral tests check power balance, battery state, sampling, and controller ordering against expected results. +Historical timing reports remain available in Git history. + +## Study throughput and memory + +```console +python benchmarks/studies.py --runs 1000 --output results/benchmarks/studies-native.json +``` + +This measures sequential scenarios with a shared trace and metrics-only recording, including model construction and cleanup. +It also compares Python allocation peaks for no recorder, metrics only, and full in-memory trajectories at 1,000 and 10,000 steps. +Use `--backend mosaik` for a backend comparison, or `--runs`, `--steps`, and `--memory-steps` for shorter checks. +Traced allocations are not total process memory. + +## Annual workloads and parallel execution + +```console +python benchmarks/native_study.py --quiet --output results/benchmarks/annual +``` + +This uses the infrastructure example's synthetic inputs with three reference configurations. +It measures the cost of observations, built-in metrics, custom metrics, and recording to memory or disk over a simulated year. +It also includes five-minute simulations, profiles, and throughput at different worker counts. +Reports separate input preparation, model setup, and simulation time. +Parallel measurements include process startup and writing experiment records, and check that results agree across worker counts. + +For a short throughput check: + +```console +python benchmarks/native_study.py --quiet --throughput-only --jobs 6 --workers 1 2 --output results/benchmarks/parallel +``` + +## Viewer + +```console +node viewer/benchmark.mjs 10000 +``` + +This measures indexing synthetic configurations in JavaScript, excluding browser rendering and network requests. +For browser checks with 10,000 recorded summaries, use the existing viewer test fixtures: + +```console +npm --prefix viewer ci +npm --prefix viewer run build +python viewer/tests/prepare.py +python -m vessim view results/viewer-release/input/large +``` + +Both viewer datasets are synthetic fixtures, not simulation results. +Compare timings on the same machine with the same dependencies and workload settings. +Profiling adds overhead, so profile timings should not be compared with ordinary execution times. diff --git a/benchmarks/input.csv b/benchmarks/input.csv new file mode 100644 index 00000000..d7536f8b --- /dev/null +++ b/benchmarks/input.csv @@ -0,0 +1,1002 @@ +,solar,load,carbon +0,0.0,700.0,300.0 +60,5.235971141695885,699.9444495882868,301.6665895072445 +120,10.471842598048722,699.7778600701122,303.33271608367534 +180,15.707514685613328,699.5004165278026,304.9979169270678 +240,20.942887724740213,699.112427203418,306.6617294923393 +300,26.177862041473343,698.6143231562925,308.323691620031 +360,31.41233796944778,698.0066577841242,309.9833416646828 +420,36.64621585178719,697.2901062081451,311.6402186230649 +480,41.879396043001165,696.4654645230564,313.2938622622314 +540,47.11177891088233,695.5336489125606,314.9438132473599 +600,52.3432648384032,694.4956946314737,316.5896132693415 +660,57.57375422561273,693.3527548555489,318.23080517208587 +720,62.80314749153259,692.1060994002885,319.8669330795061 +780,68.031345076053,690.7571133101685,321.4975425221483 +840,73.25824744182825,689.307295319843,323.1221805634299 +900,78.48375507617168,687.7582561890373,324.7403959254523 +960,83.70776849295036,686.111716912981,326.3517391143535 +1020,88.93018823447906,684.369506810368,327.95576254516413 +1080,94.15091487341394,682.5335614909678,329.55202066613396 +1140,99.36984901464521,680.6059207051483,331.14007008249325 +1200,104.5868912971898,678.5887260776948,332.7194696796152 +1260,109.80194239608286,676.4842187284488,334.28978074554516 +1320,115.01490302426878,674.2947367824045,335.8505670928617 +1380,120.22567393449152,672.0227127720323,337.40139517983766 +1440,125.43415592118414,669.6706709347166,338.9418342308651 +1500,130.64024982235748,667.2412244083057,340.47145635611247 +1560,135.84385652148805,664.7370723278952,341.98983667038056 +1620,141.04487694940516,662.1609968270665,343.49655341112305 +1680,146.24321208617698,659.5158599469128,344.9911880556 +1740,151.4387629629957,656.8046004562889,346.47332543713173 +1800,156.6314306640619,654.030230586814,347.9425538604203 +1860,161.82111632846758,651.195832686258,349.39846521590624 +1920,167.00772115207852,648.3045557940277,350.8406550931301 +1980,172.19114638941514,645.3596121425577,352.2687228930659 +2040,177.3712933555327,642.3642735884945,353.6822719393961 +2100,182.54806342790002,639.3218679776357,355.08090958869695 +2160,187.72135804827704,636.2357754476674,356.4642473395035 +2220,192.89107872459132,633.1094246728017,357.8319009402243 +2280,198.05712703281318,629.9462890544928,359.1834904958753 +2340,203.21940461882954,626.7498828624588,360.51864057360393 +2400,208.3778132003164,623.523757330299,361.8369803069737 +2460,213.5322545686101,620.2714967100444,363.13814349897996 +2520,218.68263059057696,616.9967142900241,364.4217687237691 +2580,223.82884321048155,613.7030483804722,365.68749942703244 +2640,228.97079445185378,610.3941582713351,366.9349840250466 +2700,234.1083864193539,607.0737201667703,368.1638760023334 +2760,239.2415213006366,603.7454231008534,369.373834007912 +2820,244.370101368213,600.4129648390306,370.5645219501165 +2880,249.49402898131117,597.0800477698712,371.7356090899523 +2940,254.61320658773548,593.750374791683,372.88677013296615 +3000,259.72753672572344,590.4276451985625,374.01768531960374 +3060,264.8369220258013,587.1155505704476,375.1280405140293 +3120,269.941265212638,583.8177706717428,376.217527291384 +3180,275.040469106897,580.5379693630705,377.28584302345723 +3240,280.1344366270865,577.2797905306913,378.33269096274836 +3300,285.22307079140774,574.0468540381166,379.3577803248942 +3360,290.3062747196013,570.8427517044107,380.3608263694411 +3420,295.38395163479163,567.6710433136496,381.3415504789374 +3480,300.4560048653297,564.5352526599722,382.2996802363253 +3540,305.5223378466331,561.4388636326156,383.2349495006107 +3600,310.5828541230249,558.3853163452858,384.1470984807896 +3660,315.63745734956973,555.3780033141625,385.0358738080104 +3720,320.68605129390824,552.4202656887865,385.90102860595255 +3780,325.7285398380891,549.5153895400142,386.7423225594017 +3840,330.764826980399,546.6666022091666,387.55952198100255 +3900,335.7948168371905,543.8770687224259,388.35239987617035 +3960,340.8184136447071,541.1498882744654,389.12073600614355 +4020,345.83552176090717,538.4880907852194,389.86431694915973 +4080,350.84604566728405,535.8946335336162,390.5829361597381 +4140,355.8498899706852,533.3723978720176,391.2763940260521 +4200,360.84695940512773,530.9241860250123,391.9444979253755 +4260,365.83715883361293,528.5527179761218,392.58706227758773 +4320,370.8203932499369,526.2606284458755,393.2039085967226 +4380,375.79656778049946,524.0504639646151,393.79486554054677 +4440,380.76558768611056,521.9246800432785,394.3597689581533 +4500,385.7273583637939,519.8856384453067,394.8984619355586 +4560,390.68178534858805,517.9356045627047,395.4107948392883 +4620,395.6287743153447,516.0767448991734,395.8966253579412 +4680,400.56823108052515,514.3111246631053,396.3558185417193 +4740,405.5000616039923,512.6407054731212,396.7882468399131 +4800,410.42417199080245,511.06734317869586,397.19379013633124 +4860,415.34046849299153,509.5927857982939,397.5723357826659 +4920,420.24885751136094,508.2186715773068,397.92377862978253 +4980,425.149245597259,506.9465271679489,398.24802105692794 +5040,430.0415394543603,505.7777659331342,398.544972998846 +5100,434.9256459404419,504.7136863762184,398.81455197079566 +5160,439.8014720691568,503.75547069835125,399.05668309146256 +5220,444.6689250118035,502.90418348504096,399.27129910375885 +5280,449.5279120990944,502.1607705233913,399.45834039350564 +5340,454.37834082291954,501.52605775132406,399.61775500599174 +5400,459.22011883810774,501.00075033995546,399.74949866040544 +5460,464.05315396418473,500.585431910145,399.85353476213425 +5520,468.87735418712845,500.2805638840881,399.92983441292995 +5580,473.6926276611207,500.08648497267205,399.9783764189357 +5640,478.49888271029545,500.0034107991655,399.99914729657314 +5700,483.29602783048466,500.03143365965934,399.9921412762875 +5760,488.0839716909602,500.17052242052466,399.9573603041505 +5820,492.86262313617266,500.420522553003,399.8948140413197 +5880,497.63189118748676,500.7811563048886,399.80451986135483 +5940,502.39168504491374,501.2520230091135,399.6865028453919 +6000,507.1419140888393,501.8325995288921,399.5407957751765 +6060,511.88248788174997,502.52224083893026,399.3674391239579 +6120,516.6133161699541,503.3201807420539,399.1664810452469 +6180,521.3343088853004,504.22553272046014,398.9379773594396 +6240,526.0453761468929,505.23729092064616,398.68199153831307 +6300,530.7464282628016,506.3543312709204,398.3985946873937 +6360,535.4373757317705,507.57541273025566,398.08786552620677 +6420,540.118129244922,508.899178667096,397.7498903664101 +6480,544.7885996874562,510.32415836658527,397.3847630878195 +6540,549.448698140348,511.8487686645438,396.9925851123312 +6600,554.0983358820407,513.4713157063763,396.57346537574995 +6660,558.7374243901337,515.1899968289592,396.12752029753 +6720,563.365875343069,517.0029025634128,395.65487374843667 +6780,567.983600621812,518.9080187565385,395.15565701613895 +6840,572.5905123115301,520.9032288085583,394.63000876874145 +6900,577.1865227032652,522.9863160246755,394.07807501626627 +6960,581.7715442956045,525.1549660778401,393.50000907009576 +7020,586.345489796346,527.406769579986,392.89597150038696 +7080,590.9082721241605,529.7392247588803,392.26613009146956 +7140,595.4598044102498,532.1497402376131,391.6106597952408 +7200,599.9999999999999,534.6356379136388,390.9297426825682 +7260,604.5287724546315,537.1941559341699,390.2235678927159 +7320,609.046035552845,539.8224517646178,389.492331580807 +7380,613.5517032924625,542.5176053466731,388.7362368633376 +7440,618.045689892065,545.2766223425134,387.9554937617562 +7500,622.5279097926256,548.0964374615378,387.15031914412657 +7560,626.9982776591386,550.97391786593,386.32093666488737 +7620,631.4567083822435,553.9058666512669,385.4675767027278 +7680,635.9031170798459,556.8890263983054,384.5904762965946 +7740,640.3374190987338,559.9200827920024,383.6898790798498 +7800,644.7595300161887,562.9956683037462,382.7660352125961 +7860,649.1693656415927,566.1123659327078,381.8192013121896 +7920,653.5668420180324,569.2667130021581,380.84964038195903 +7980,657.9518754238966,572.4552050065288,379.8576217381507 +8040,662.3243823744698,575.6742995049483,378.84342093512043 +8100,666.6842796235226,578.920420056922,377.8073196887921 +8160,671.0314841648961,582.1899601957873,376.74960579840547 +8220,675.3659132340822,585.4792874355277,375.67057306657335 +8280,679.6874843097994,588.7847473064945,374.570521217672 +8340,683.9961151155636,592.1026674155532,373.4497558145862 +8400,688.2917236212553,595.429361526142,372.30858817383245 +8460,692.5742280446804,598.761133653711,371.1473352790844 +8520,696.8435468531277,602.0942821719918,369.96631969312386 +8580,701.0995987649213,605.4251039255347,368.76586946824176 +8640,705.3423027509677,608.7498983439447,367.5463180551151 +8700,709.5715780362988,612.0649715532454,366.3080042101836 +8760,713.7873441016095,615.3666404798045,365.051271901553 +8820,717.9895206847907,618.6512369422576,363.77647021345035 +8880,722.1780277824579,621.9151117268885,362.4839532492589 +8940,726.3527856514733,625.1546386419361,361.1740800331578 +9000,730.5137148104648,628.3662185463227,359.8472144103956 +9060,734.6607360413392,631.5462833483292,358.5037249462249 +9120,738.79377039079,634.6912999697755,357.14398482352476 +9180,742.9127391718008,637.7977742712981,355.7683717391417 +9240,747.0175639651434,640.8622549343684,354.3772677989763 +9300,751.1081666208709,643.8813372957361,352.97105941184486 +9360,755.184469259805,646.8516671300378,351.5501371821464 +9420,759.2463942750193,649.7699443763689,350.11489580136384 +9480,763.2938643333167,652.6329268046792,348.6657339384298 +9540,767.3268023767016,655.4374336179161,347.20305412898824 +9600,771.3451316238471,658.180348985917,345.7272626635812 +9660,775.3487755715566,660.8586255071224,344.23876947479306 +9720,779.3376579962204,663.4692875942635,342.73798802338297 +9780,783.3117029552669,666.0094347802644,341.2253351834376 +9840,787.2708347886087,668.4762449406854,339.701231126575 +9900,791.2149781200826,670.866977429126,338.1660992052332 +9960,795.144057858885,673.1789761221055,336.620365835075 +10020,799.0579992010012,675.4096723700383,335.064460376542 +10080,802.9567276306299,677.5565878510249,333.4988150155905 +10140,806.8401689216016,679.6173373242879,331.9238646436425 +10200,810.7082491387922,681.5896312801933,330.3400467367856 +10260,814.5608946395301,683.471278483916,328.74780123425444 +10320,818.3980320749981,685.2601884099186,327.14757041622823 +10380,822.219588391631,686.954373564543,325.5397987809782 +10440,826.0254908325047,688.5519516941318,323.92493292139824 +10500,829.8156669387233,690.0511478762286,322.3034214009548 +10560,833.5900445507967,691.45029649153,320.67571462908836 +10620,837.3485518100161,692.7478430744036,319.0422647361027 +10680,841.0911171598211,693.9423460399113,317.40352544757627 +10740,844.817669347162,695.0324782854227,315.7599519583298 +10800,848.528137423857,696.0170286650366,314.1120008059867 +10860,852.2224507479424,696.8949033351741,312.46012974415964 +10920,855.9005389850179,697.6651269698482,310.80479761529983 +10980,859.562332109585,698.3268438442584,309.1464642232437 +11040,863.2077604063813,698.8793187855086,307.4855902054927 +11100,866.8367544717066,699.3219379893898,305.82263690526133 +11160,870.4492452147451,699.6542097023217,304.1580662433291 +11220,874.0451638588804,699.8757647676955,302.4923405897321 +11280,877.6244419430045,699.9863570360089,300.8259226353304 +11340,881.1870113228225,699.9858636383415,299.1592752632851 +11400,884.7328041721489,699.8742851228619,297.4928614204831 +11460,888.2617529841986,699.6517454542197,295.82714398894336 +11520,891.773790572873,699.3184918758193,294.162585657242 +11580,895.2688500740395,698.8748946351301,292.4996487919903 +11640,898.7468649468025,698.3214465723395,290.83879530940254 +11700,902.2077689747729,697.6587625728023,289.1804865469892 +11760,905.6514962673263,696.8875788838989,287.52518313541015 +11820,909.0779812608596,696.0087522970575,285.87334487052505 +11880,912.4871587200372,695.0232591958529,284.22543058567516 +11940,915.8789637390347,693.9321944712352,282.5818980242324 +12000,919.2533317427736,692.7367703050975,280.94320371245146 +12060,922.6101984881515,691.438314823532,279.30980283266 +12120,925.949500065264,690.0382706212703,277.68214909682195 +12180,929.2711728986227,688.5381931589495,276.06069462050914 +12240,932.575153748365,686.9397490349825,274.44588979731685 +12300,935.8613797114581,685.2447141339528,272.8381831737574 +12360,939.1297882228965,683.454971653593,271.23802132466625 +12420,942.3803170568938,681.5725100125356,269.6458487291571 +12480,945.6129043280663,679.599420641165,268.06210764715763 +12540,948.8274884926121,677.5378956580195,266.4872379965628 +12600,952.0240083494822,675.3902254343304,264.921677231038 +12660,955.2024030415465,673.158796049401,263.36586021850724 +12720,958.3626120567513,670.8460866396517,261.82021912035884 +12780,961.5045752292727,668.4546666442807,260.28518327140404 +12840,964.6282327406608,665.9871929505939,258.7611790606193 +12900,967.733525120979,663.4464069421833,257.2486298127085 +12960,970.820393249937,660.8351314532255,255.74795567051476 +13020,973.8887783580146,658.1562676322906,254.25957347831712 +13080,976.9386220275829,655.4127917191423,252.78389666604323 +13140,979.9698661940147,652.6077517381106,251.32133513443006 +13200,982.9824531467901,649.7442641117117,249.87229514116552 +13260,985.9763255305965,646.8255101982774,248.43717918804157 +13320,988.9514263464189,643.854732757439,247.01638590915064 +13380,991.9076989526263,640.835232347396,245.61030996015694 +13440,994.84508706605,637.7703636579668,244.21934190867177 +13500,997.7635347630543,634.6635317835025,242.84386812576562 +13560,1000.6629864806017,631.5181884397982,241.48427067864498 +13620,1003.5433870173114,628.3378281292082,240.1409272245252 +13680,1006.4046815345088,625.1259842582256,238.81421090572812 +13740,1009.2468155572709,621.886225211838,237.50449024603367 +13800,1012.0697349754628,618.6221503890231,236.2121290483148 +13860,1014.8733860447679,615.3373862037864,234.93748629348326 +13920,1017.6577153877112,612.0355820561853,233.68091604077497 +13980,1020.4226699946754,608.7204062778155,232.44276732940267 +14040,1023.1681972249106,605.395542056265,231.2233840816026 +14100,1025.8942448075359,602.0646833430616,230.02310500710308 +14160,1028.6007608425346,598.731530749663,228.84226350904035 +14220,1031.2876938017432,595.3997874360463,227.6811875913488 +14280,1033.9549925298309,592.0731549964676,226.54019976765102 +14340,1036.6026062452747,588.7553293469596,225.41961697167264 +14400,1039.2304845413264,585.4499966191387,224.31975046920718 +14460,1041.8385773869707,582.1608290648824,223.24090577165543 +14520,1044.4268351278797,578.8914809764275,222.1833825511631 +14580,1046.9952084873564,575.6455846264209,221.1474745573805 +14640,1049.5436485672749,572.426746232435,220.13346953586722 +14700,1052.0721068490093,569.2385419504303,219.14164914816473 +14760,1054.5805351943584,566.0845139016166,218.17228889355897 +14820,1057.0688858464628,562.9681662371272,217.2256580325543 +14880,1059.5371114307122,559.892961244878,216.30201951208085 +14940,1061.9851649556501,556.8623155029379,215.40162989245533 +15000,1064.412999813866,553.8795960836811,214.524739276116 +15060,1066.8205697828837,550.9481168129428,213.67159123815117 +15120,1069.2078290260415,548.0711345883315,212.8424227586412 +15180,1071.5747320933642,545.2518457607883,212.0374641568322 +15240,1073.92123392243,542.4933825834158,211.25693902715994 +15300,1076.2472898392261,539.7988097315176,210.50106417714164 +15360,1078.5528555590004,537.1711208977202,209.7700495671532 +15420,1080.8378871871055,534.6132354659549,209.06409825210807 +15480,1083.1023412198326,532.1279952679988,208.38340632505452 +15540,1085.3461745452416,529.7181614261758,207.72816286270654 +15600,1087.5693444439798,527.3864112857275,207.0985498729238 +15660,1089.7718085900974,525.1353354402602,206.49474224415508 +15720,1091.9535250518518,522.967434853574,205.91690769685925 +15780,1094.1144522925063,520.8851180810718,205.36520673691726 +15840,1096.254549171121,518.8906985938344,204.83979261104838 +15900,1098.3737749433365,516.9863922083346,204.34081126424252 +15960,1100.4720892621488,515.1743146246472,203.86840129922086 +16020,1102.549452178678,513.4564790758888,203.42269393793612 +16080,1104.6058241429284,511.8347940914992,203.00381298512275 +16140,1106.6411660045421,510.3110613768494,202.6118747939076 +16200,1108.655439013544,508.8869738115323,202.2469882334903 +16260,1110.6486048210797,507.56411356855915,201.9092546589025 +16320,1112.6206254801448,506.3439503565528,201.59876788285442 +16380,1114.571463446309,505.2278397868888,201.31561414967635 +16440,1116.5010815784294,504.21702186760047,201.05987211136232 +16500,1118.409443139358,503.3126196257195,200.83161280572307 +16560,1120.296511796642,502.51563785958365,200.63089963665357 +16620,1122.1622516232142,501.8269620224977,200.45778835652123 +16680,1124.0066270980772,501.24735723898726,200.31232705067976 +16740,1125.829603106981,500.7774674547396,200.19455612411207 +16800,1127.63114494309,500.41781472117424,200.10450829020718 +16860,1129.4112183076447,500.16879861543947,200.04220856167325 +16920,1131.169789310614,500.03069579647934,200.00767424358992 +16980,1132.906824471341,500.0036596976629,200.0009149286012 +17040,1134.62229071918,500.08772035631847,200.02193249425113 +17100,1136.3161553941268,500.28278438036216,200.0707211024622 +17160,1137.9883862474392,500.58863505205704,200.14726720115692 +17220,1139.6389514422524,501.00493256878843,200.25154952802234 +17280,1141.2678195541841,501.5312144205873,200.38353911641593 +17340,1142.8749595719341,502.1668959039828,200.54319930341182 +17400,1144.4603408978724,502.91127077161246,200.73048573998472 +17460,1146.023933348624,503.76351201686896,200.94534640332867 +17520,1147.5657071556425,504.7226727927113,201.18772161130767 +17580,1149.0856329657772,505.7876874636196,201.4575440390335 +17640,1150.5836818418315,506.9573727895247,201.75473873756675 +17700,1152.0598252631144,508.23042924039805,202.07922315473547 +17760,1153.5140351259827,509.6054424400402,202.4309071580657 +17820,1154.9462837443768,511.0808847374639,202.8096930598179 +17880,1156.3565438503476,512.6551169041277,203.21547564412163 +17940,1157.7447885945758,514.3263899551318,203.6481421962015 +18000,1159.110991546882,516.0928470923548,204.10757253368615 +18060,1160.4551266967317,517.9525257673716,204.5936390399916 +18120,1161.7771684537292,519.9033598618615,205.10620669976922 +18180,1163.0770916481054,521.9431819830817,205.64513313640936 +18240,1164.3548715311958,524.069725871859,206.21026865158927 +18300,1165.6104837759137,526.2806289204224,206.80145626685533 +18360,1166.843904477212,528.57343479728,207.41853176722674 +18420,1168.0551101525382,530.9455961762229,208.06132374681016 +18480,1169.2440777422823,533.3944775664263,208.72965365641102 +18540,1170.4107846102152,535.9173582405007,209.42333585312957 +18600,1171.5552085439201,538.5114352572432,210.14217765192703 +18660,1172.6773277552147,541.1738265757278,210.88597937914815 +18720,1173.7771208805668,543.9015742572772,211.6545344279847 +18780,1174.8545669815005,546.6916477517557,212.44762931586482 +18840,1175.9096455449956,549.5409472645372,213.26504374375264 +18900,1176.9423364838765,552.4463072004007,214.1065506573408 +18960,1177.9526201371968,555.4044996805343,214.97191631011972 +19020,1178.9404772706116,558.4122381287339,215.86090032830572 +19080,1179.9058890767453,561.466180922817,216.77325577760988 +19140,1180.848837175549,564.5629351071925,217.70872923182935 +19200,1181.7693036146495,567.699060162462,218.66706084324198 +19260,1182.6672708696933,570.8710718278654,219.64798441478445 +19320,1183.5427218446778,574.0754459723233,220.65122747399408 +19380,1184.3956398722783,577.3086225097759,221.67651134869433 +19440,1185.2260087141653,580.5670093544666,222.72355124440128 +19500,1186.0338125613127,583.8469864117808,223.792056323432 +19560,1186.8190360343,587.1449096001996,224.8817297856905 +19620,1187.5816641836043,590.4571148999049,225.99226895111056 +19680,1188.3216824898843,593.779922423535,227.1233653437314 +19740,1189.0390768642587,597.1096405045704,228.2747047773836 +19800,1189.7338336485725,600.442569798805,229.4459674429608 +19860,1190.4059396156579,603.7750073943491,230.63682799725387 +19920,1191.0553819695863,607.1032509255936,231.84695565332154 +19980,1191.6821483459116,610.4236026865697,233.0760142723738 +20040,1192.286226811905,613.7323737391304,234.3236624571415 +20100,1192.8676058667836,617.0258880113909,235.5895536467068 +20160,1193.426274441928,620.3004863818751,236.87333621276784 +20220,1193.9622219010942,623.5525307448295,238.1746535573109 +20280,1194.4754380406146,626.7784080521888,239.4931442116634 +20340,1194.965913089594,629.9745343277015,240.82844193689905 +20400,1195.4336377100947,633.137358648758,242.1801758255691 +20460,1195.8786029973137,636.2633670914939,243.54797040472943 +20520,1196.3008004797537,639.349086634789,244.93144574023626 +20580,1196.700222119383,642.3910890188223,246.330217542281 +20640,1197.076860311789,645.3859945538962,247.74389727213492 +20700,1197.4307078863242,648.3304758753006,249.17209225007417 +20760,1197.7617581062402,651.2212616400416,250.61440576445477 +20820,1198.0700046688185,654.0551401613312,252.0704371819079 +20880,1198.3554417054886,656.8289629767974,253.53978205862427 +20940,1198.6180637819407,659.5396483464516,255.02203225269713 +21000,1198.8578658982294,662.1841846765279,256.51677603749226 +21060,1199.0748434888674,664.7596338653876,258.023598216014 +21120,1199.2689924229148,667.263134567775,259.54208023623494 +21180,1199.4403090040555,669.6919053737954,261.07180030735776 +21240,1199.5887899706688,672.0432478990839,262.6123335169764 +21300,1199.714432495891,674.3145497827335,264.16325194910496 +21360,1199.8172341876696,676.5032875896488,265.7241248030403 +21420,1199.8971930888083,678.6070296141039,267.29451851302593 +21480,1199.9543076770055,680.6234385813868,268.87399686868457 +21540,1199.9885768648815,682.5502742445312,270.46212113618446 +21600,1200.0,684.3853958732492,272.0584501801074 +21660,1199.9885768648815,686.1267646323005,273.6625405859834 +21720,1199.9543076770055,687.7724458466549,275.27394678345826 +21780,1199.8971930888083,689.3206111509322,276.8922211700608 +21840,1199.8172341876696,690.7695405207315,278.5169142355335 +21900,1199.714432495891,692.1176241835919,280.1475746866938 +21960,1199.5887899706688,693.3633644074637,281.7837495727904 +22020,1199.4403090040555,694.5053771647003,283.42498441132 +22080,1199.2689924229148,695.5423936697248,285.07082331426943 +22140,1199.0748434888674,696.4732617886609,286.7208091147483 +22200,1198.8578658982294,697.2969473193638,288.37448349397704 +22260,1198.6180637819407,698.012535140426,290.03138710859446 +22320,1198.3554417054886,698.6192302278864,291.69105971825036 +22380,1198.0700046688185,699.1163585385068,293.3530403134471 +22440,1197.7617581062402,699.5033677586408,295.01686724359473 +22500,1197.4307078863242,699.779827917858,296.6820783452443 +22560,1197.076860311789,699.9454318666452,298.34821107046383 +22620,1196.700222119383,699.9999956176516,300.0148026153206 +22680,1196.3008004797537,699.9434585501004,301.681390048435 +22740,1195.8786029973137,699.7758834771395,303.3475104395694 +22800,1195.4336377100947,699.4974565760544,305.01270098821726 +22860,1194.965913089594,699.1084871814253,306.67649915215554 +22920,1194.4754380406146,698.6094074414523,308.3384427759258 +22980,1193.9622219010942,698.0007718378358,309.9980702192077 +23040,1193.4262744419282,697.2832565697436,311.6549204850494 +23100,1192.8676058667836,696.4576588025463,313.3085333479195 +23160,1192.286226811905,695.5248957821617,314.9584494815443 +23220,1191.6821483459116,694.4860038159861,316.6042105864957 +23280,1191.0553819695865,693.3421371215483,318.24535951749345 +23340,1190.4059396156579,692.0945665441646,319.881440410387 +23400,1189.7338336485725,690.7446781450196,321.5119988087815 +23460,1189.0390768642587,689.29397166124,323.1365817902732 +23520,1188.3216824898843,687.7440588396746,324.75473809225764 +23580,1187.5816641836043,686.0966616462307,326.36601823727784 +23640,1186.8190360343,684.3536103527553,327.96997465787626 +23700,1186.0338125613127,682.5168415035881,329.5661618209161 +23760,1185.2260087141651,680.5883957640451,331.1541363513378 +23820,1184.3956398722783,678.5704156532222,332.73345715531576 +23880,1183.5427218446778,676.4651431636389,334.30368554278175 +23940,1182.6672708696933,674.2749172703669,335.86438534928004 +24000,1181.7693036146495,672.0021713324084,337.415123057122 +24060,1180.848837175549,669.6494303892147,338.95546791580443 +24120,1179.9058890767456,667.2193083553468,340.48499206165985 +24180,1178.9404772706116,664.7145051163947,342.00327063670477 +24240,1177.9526201371968,662.137803529382,343.5098819066531 +24300,1176.9423364838765,659.4920663309892,345.0044073780618 +24360,1175.9096455449956,656.7802329570293,346.48643191457626 +24420,1174.8545669815005,654.0053162767108,347.9555438522436 +24480,1173.7771208805668,651.1703992453149,349.4113351138608 +24540,1172.6773277552147,648.2786314790069,350.85340132232704 +24600,1171.5552085439201,645.3332257555868,352.28134191296795 +24660,1170.4107846102154,642.3374544450666,353.6947602448011 +24720,1169.2440777422823,639.29464587404,355.0932637107115 +24780,1168.0551101525382,636.208180627885,356.4764638465065 +24840,1166.843904477212,633.0814877949047,357.84397643882005 +24900,1165.6104837759137,629.9180411565826,359.19542163183627 +24960,1164.3548715311958,626.7213553281827,360.53042403280244 +25020,1163.0770916481054,623.4949818539823,361.8486128163024 +25080,1161.7771684537292,620.2425052614784,363.14962182726106 +25140,1160.4551266967317,616.9675390789469,364.4330896826519 +25200,1159.110991546882,613.6737218207834,365.69865987187893 +25260,1157.7447885945755,610.364712945084,366.9459808558049 +25320,1156.3565438503476,607.0441887879568,368.1747061643991 +25380,1154.9462837443768,603.7158384790827,369.3844944929764 +25440,1153.5140351259827,600.3833598430609,370.57500979700217 +25500,1152.0598252631144,597.0504552910969,371.74592138543596 +25560,1150.5836818418315,593.7208277075919,372.8969040125876 +25620,1149.0856329657772,590.3981763362079,374.0276379684615 +25680,1147.5657071556427,587.0861926699783,375.1378091675628 +25740,1146.023933348624,583.7885563500282,376.22710923614113 +25800,1144.4603408978724,580.5089310774624,377.2952355978483 +25860,1142.8749595719341,577.2509605429643,378.3418915577853 +25920,1141.2678195541844,574.0182643786244,379.3667863849153 +25980,1139.6389514422524,570.8144341365004,380.36963539282095 +26040,1137.9883862474394,567.6430292983723,381.3501600187817 +26100,1136.3161553941268,564.5075733211295,382.30808790115054 +26160,1134.6222907191802,561.4115497221809,383.24315295500855 +26220,1132.9068244713412,558.3583982092384,384.1550954460755 +26280,1131.169789310614,555.3515108587734,385.04366206285647 +26340,1129.4112183076447,552.3942283473917,385.90860598700465 +26400,1127.63114494309,549.4898362403145,386.74968696188057 +26460,1125.8296031069808,546.6415613410883,387.5666713592882 +26520,1124.0066270980772,543.8525681065795,388.35933224437036 +26580,1122.1622516232142,541.1259551312369,389.12744943864465 +26640,1120.296511796642,538.4647517045279,389.8708095811627 +26700,1118.4094431393582,535.8719144453727,390.58920618777586 +26760,1116.5010815784294,533.3503240173162,391.2824397084904 +26820,1114.571463446309,530.9027819280874,391.95031758289707 +26880,1112.620625480145,528.5320074171012,392.5926542936584 +26940,1110.6486048210797,526.2406344343608,393.2092714180406 +27000,1108.655439013544,524.0312087141178,393.7999976774739 +27060,1106.6411660045424,521.9061849465405,394.364668985129 +27120,1104.6058241429284,519.8679240505327,394.90312849149586 +27180,1102.5494521786782,517.9186905507331,395.4152266279515 +27240,1100.4720892621488,516.0606500616109,395.9008211483064 +27300,1098.3737749433367,514.2958668814489,396.3597771683162 +27360,1096.2545491711212,512.626301698892,396.7919672031486 +27420,1094.1144522925065,511.0538094146056,397.1972712027952 +27480,1091.953525051852,509.5801370804658,397.57557658541765 +27540,1089.7718085900979,508.20692195857066,397.92677826862 +27600,1087.56934444398,506.93568970222793,398.2507786986373 +27660,1085.3461745452416,505.7678526609419,398.54748787743347 +27720,1083.1023412198326,504.70470831128193,398.8168233877 +27780,1080.8378871871055,503.74743781537467,399.05871041574926 +27840,1078.5528555590004,502.8971047086234,399.2730817722953 +27900,1076.2472898392261,502.1546537181116,399.4598779111176 +27960,1073.92123392243,501.5209097130033,399.61904694560087 +28020,1071.5747320933642,500.99657678810655,399.75054466314793 +28080,1069.2078290260415,500.5822374816185,399.8543345374605 +28140,1066.8205697828837,500.27835212792036,399.9303877386853 +28200,1064.4129998138662,500.0852583461423,399.97868314142215 +28260,1061.9851649556504,500.003170665066,399.99920733059184 +28320,1059.5371114307125,500.032180284781,399.99195460516285 +28380,1057.0688858464628,500.17225497536094,399.95692697973436 +28440,1054.5805351943584,500.4232391126711,399.8941341839772 +28500,1052.0721068490093,500.7848538512684,399.8035936599308 +28560,1049.543648567275,501.25669743420053,399.6853305571586 +28620,1046.9952084873566,501.83824563936156,399.539377725762 +28680,1044.4268351278797,502.52885236190696,399.3657757072557 +28740,1041.8385773869709,503.32775033208134,399.16457272330643 +28800,1039.2304845413264,504.2340519676615,398.9358246623382 +28860,1036.602606245275,505.2467503600677,398.67959506400814 +28920,1033.954992529831,506.36472039304687,398.3959551015572 +28980,1031.2876938017434,507.58671999268705,398.08498356203995 +29040,1028.6007608425348,508.91139150737047,397.74676682444016 +29100,1025.8942448075359,510.3372632161369,397.3813988356768 +29160,1023.1681972249107,511.86275096377653,396.9889810845086 +29220,1020.4226699946754,513.4861599208409,396.56962257334317 +29280,1017.6577153877113,515.2056864666108,396.12343978795946 +29340,1014.8733860447678,517.0194201929351,395.65055666515093 +29400,1012.0697349754628,518.9253460267088,395.1511045583001 +29460,1009.246815557271,520.921346468637,394.6252222008918 +29520,1006.4046815345089,523.0052039457928,394.0730556679773 +29580,1003.5433870173116,525.1746032753605,393.4947583355986 +29640,1000.6629864806018,527.427134236821,392.8904908381851 +29700,997.7635347630544,529.7602942497286,392.26042102393404 +29760,994.84508706605,532.1714911540971,391.6047239081869 +29820,991.9076989526263,534.6580460903109,390.9235816248153 +29880,988.951426346419,537.2171964753614,390.21718337562936 +29940,985.9763255305965,539.8460990720977,389.4857253778228 +30000,982.9824531467905,542.5418331480881,388.7294108094694 +30060,979.9698661940148,545.3014037205764,387.9484497530865 +30120,976.938622027583,548.1217448839341,387.14305913727895 +30180,973.8887783580149,550.9997232159046,386.3134626764838 +30240,970.820393249937,553.9321412588638,385.45989080882805 +30300,967.7335251209793,556.9157410722188,384.58258063211986 +30360,964.6282327406609,559.9472078520101,383.68177583798933 +30420,961.5045752292727,563.0231736136827,382.7577266441984 +30480,958.3626120567512,566.1402209339477,381.81068972513685 +30540,955.2024030415466,569.2948867475651,380.84092814052633 +30600,952.0240083494822,572.4836661948403,379.848711262349 +30660,948.8274884926118,575.7030165155497,378.8343147000246 +30720,945.6129043280664,578.9493609849778,377.7980202238534 +30780,942.3803170568938,582.2190928876885,376.7401156867487 +30840,939.1297882228969,585.5085795246132,375.6608949442798 +30900,935.8613797114581,588.8141662490125,374.5606577730466 +30960,932.5751537483652,592.132180526816,373.43970978741135 +31020,929.2711728986228,595.4589360168402,372.2983623546065 +31080,925.9495000652639,598.7907366663417,371.1369325082472 +31140,922.6101984881515,602.1238808173646,369.9557428602668 +31200,919.2533317427736,605.4546653193105,368.7551215113062 +31260,915.8789637390348,608.7793896431718,367.5354019595753 +31320,912.4871587200372,612.0943599928474,366.2969230082183 +31380,909.0779812608598,615.3958934089832,365.0400286712025 +31440,905.6514962673266,618.6803218607674,363.76506807776207 +31500,902.2077689747729,621.943996321146,362.47239537541924 +31560,898.7468649468028,625.1832908209191,361.16236963161293 +31620,895.2688500740395,628.394606477224,359.8353547339598 +31680,891.7737905728734,631.5743754919243,358.49171928917616 +31740,888.2617529841983,634.7190651154611,357.13183652069085 +31800,884.7328041721489,637.8251815717691,355.75608416497295 +31860,881.1870113228225,640.889273939888,354.36484436660885 +31920,877.6244419430047,643.907937987967,352.95850357215215 +31980,874.0451638588803,646.8778199553915,351.53745242278114 +32040,870.449245214745,649.7956202788415,350.10208564578846 +32100,866.8367544717067,652.6580972581313,348.65280194493874 +32160,863.2077604063813,655.462070657767,347.1900038897186 +32220,859.5623321095852,658.2044252402122,345.7140978035156 +32280,855.9005389850179,660.8821142269454,344.22549365075224 +32340,852.2224507479426,663.4921626834528,342.7246049230108 +32400,848.5281374238571,666.031670824408,341.21184852417565 +32460,844.8176693471619,668.4978172353551,339.6876446546308 +32520,841.0911171598212,670.8878620073245,338.1524166945395 +32580,837.3485518100163,673.1991497808947,336.6065910862411 +32640,833.590044550797,675.4291126963169,335.05059721579863 +32700,829.8156669387234,677.5752732464307,333.4848672937263 +32760,826.025490832505,679.6352470291923,331.9098362349352 +32820,822.2195883916312,681.6067453967671,330.325941537925 +32880,818.3980320749984,683.4875779982344,328.7336231632608 +32940,814.5608946395304,685.2756552130872,327.1333234113633 +33000,810.7082491387924,686.968990472816,325.5254867996518 +33060,806.8401689216021,688.5657024680041,323.9105599390686 +33120,802.9567276306296,690.0640172384768,322.2889914100248 +33180,799.0579992010012,691.462270144187,320.66123163779577 +33240,795.1440578588848,692.7589077146423,319.0277327674078 +33300,791.2149781200827,693.9524893748256,317.38894853804334 +33360,787.2708347886087,695.0416890456839,315.7453341570067 +33420,783.3117029552668,696.0252966174141,314.09734617328 +33480,779.3376579962205,696.902219293905,312.4454423507062 +33540,775.3487755715566,697.6714828068418,310.79008154083687 +33600,771.3451316238474,698.3322324981277,309.13172355547493 +33660,767.3268023767017,698.8837342694146,307.47082903895347 +33720,763.2938643333171,699.3253753976935,305.8078593401805 +33780,759.2463942750194,699.6566652160344,304.14327638449106 +33840,755.1844692598048,699.877235658721,302.4775425453358 +33900,751.1081666208711,699.9868416701754,300.81112051584864 +33960,747.0175639651436,699.9853614772162,299.1444731803224 +34020,742.912739171801,699.8727967243501,297.47806348563415 +34080,738.7937703907901,699.6492724719444,295.8123543126503 +34140,734.6607360413395,699.3150370572841,294.1478083476534 +34200,730.5137148104651,698.870461818667,292.48488795381905 +34260,726.3527856514734,698.3160406828438,290.82405504278665 +34320,722.1780277824583,697.652389616262,289.16577094635255 +34380,717.989520684791,696.880245940721,287.51049628832476 +34440,713.7873441016095,696.0004675142026,285.85869085657646 +34500,709.5715780362987,695.0140317777825,284.2108134753284 +34560,705.3423027509679,693.922034669687,282.56732187770206 +34620,701.0995987649213,692.7256894076947,280.92867257857324 +34680,696.8435468531278,691.4263251412428,279.2953207477667 +34740,692.5742280446804,690.0253854747305,277.6677200836216 +34800,688.2917236212551,688.5244268636632,276.04632268696844 +34860,683.9961151155638,686.9251168854166,274.43157893554695 +34920,679.6874843097994,685.2292323865464,272.82393735890577 +34980,675.3659132340824,683.4386575086946,271.223844513812 +35040,671.0314841648963,681.5553815952951,269.6317448602122 +35100,666.6842796235226,679.5814969813944,268.0480806377726 +35160,662.3243823744699,677.5191966690525,266.473291743039 +35220,657.9518754238966,675.3707718908979,264.9078156072452 +35280,653.5668420180327,673.1386095645497,263.3520870748072 +35340,649.1693656415929,670.8251896407329,261.8065382825364 +35400,644.7595300161889,668.4330823480317,260.27159853960376 +35460,640.337419098734,665.9649453373462,258.74769420829074 +35520,635.903117079846,663.4235207292179,257.235248585557 +35580,631.4567083822437,660.8116320673138,255.73468178546196 +35640,626.9982776591387,658.1321811814436,254.24641062246786 +35700,622.5279097926261,655.3881449636054,252.77084849566182 +35760,618.0456898920653,652.5825720606318,251.30840527392428 +35820,613.5517032924625,649.7185794871206,249.85948718208027 +35880,609.046035552845,646.7993491624028,248.42449668806074 +35940,604.5287724546317,643.8281243754078,247.00383239110957 +36000,599.9999999999999,640.8082061813392,245.59788891106302 +36060,595.4598044102496,637.7429497341776,244.20705677873661 +36120,590.9082721241606,634.6357605590762,242.8317223274464 +36180,586.3454897963459,631.4900907687934,241.47226758569636 +36240,581.7715442956046,628.3094352283691,240.12907017106264 +36300,577.1865227032652,625.0973276722985,238.80250318530116 +36360,572.5905123115303,621.8573367785262,237.49293511071178 +36420,567.9836006218121,618.5930622036137,236.20072970778335 +36480,563.3658753430689,615.308130583493,234.92624591415256 +36540,558.7374243901339,612.0061915042427,233.6698377449002 +36600,554.0983358820407,608.6909134473693,232.43185419421712 +36660,549.4486981403484,605.3659797140922,231.21263913846246 +36720,544.7885996874562,602.0350843331682,230.01253124064579 +36780,540.1181292449224,598.7019279567925,228.83186385635508 +36840,535.4373757317708,595.3702137491456,227.6709649411613 +36900,530.7464282628016,592.043643272146,226.53015695952047 +36960,526.0453761468932,588.7259123729858,225.40975679520335 +37020,521.3343088853006,585.4207070780133,224.3100756632738 +37080,516.6133161699546,582.1316994975266,223.23141902364176 +37140,511.8824878817498,578.8625437460307,222.17408649621603 +37200,507.14191408883937,575.616871882484,221.13837177767758 +37260,502.3916850449136,572.3982898750533,220.12456255989997 +37320,497.63189118748704,569.2103735948508,219.1329404500358 +37380,492.86262313617266,566.0566648431147,218.16378089229522 +37440,488.08397169096,562.9406674162359,217.21735309143463 +37500,483.2960278304848,559.8658432130142,216.2939199379801 +37560,478.4988827102954,556.8356083884578,215.39373793520298 +37620,473.69262766112087,553.8533295584091,214.51705712787174 +37680,468.8773541871285,550.9223200592045,213.66412103279555 +37740,464.05315396418507,548.0458362665329,212.83516657118332 +37800,459.22011883810785,545.2270739775731,212.030424002833 +37860,454.37834082291954,542.469164860439,211.25011686217243 +37920,449.5279120990947,539.7751729748693,210.49446189616793 +37980,444.66892501180354,537.1480913680311,209.76366900411762 +38040,439.8014720691571,534.5908387492227,209.05794117934835 +38100,434.9256459404421,532.1062562471598,208.37747445282895 +38160,430.0415394543608,529.6971042534614,207.72245783871935 +38220,425.14924559725927,527.3660593558297,207.09307328186713 +38280,420.24885751136105,525.115711364341,206.48949560726908 +38340,415.34046849299193,522.9485604341431,205.91189247150922 +38400,410.4241719908027,520.867014287765,205.3604243161892 +38460,405.5000616039929,518.8733855401147,204.83524432336168 +38520,400.56823108052487,516.9698891291475,204.33649837298123 +38580,395.6287743153448,515.1586398550494,203.86432500238254 +38640,390.6817853485879,513.4416500306785,203.41885536779927 +38700,385.7273583637941,511.8208272458676,203.00021320793215 +38760,380.76558768611056,510.29797224807703,202.6085148095782 +38820,375.7965677804993,508.87477694174913,202.24386897532952 +38880,370.820393249937,507.55282250858784,201.90637699335085 +38940,365.83715883361293,506.333577650854,201.596132609245 +39000,360.846959405128,505.21839695962393,201.31322200001256 +39060,355.8498899706852,504.20851940982845,201.0577237501149 +39120,350.84604566728444,503.30506698374074,200.82970882964514 +39180,345.83552176090734,502.5090434244458,200.62924057461515 +39240,340.8184136447071,501.8213331206723,200.45637466936228 +39300,335.79481683719075,501.24270012423085,200.31115913108198 +39360,330.76482698039905,500.77378730114486,200.1936342964901 +39420,325.7285398380895,500.4151156174221,200.10383281061831 +39480,320.6860512939084,500.1670835602564,200.04177961774639 +39540,315.6374573495703,500.0299666953058,200.00749195447338 +39600,310.58285412302524,500.0039173605363,200.00097934492965 +39660,305.5223378466332,500.08896449697323,200.02224359813158 +39720,300.4560048653301,500.2850136165478,200.0712788074789 +39780,295.38395163479186,500.59184690707383,200.14807135239545 +39840,290.3062747196013,501.0091234742389,200.25259990211248 +39900,285.2230707914075,501.5363797203411,200.38483542159383 +39960,280.13443662708664,502.17302985934924,200.5447411796011 +40020,275.0404691068969,502.91836656771676,200.73227275889627 +40080,269.94126521263826,503.7715617702231,200.94737806857992 +40140,264.83692202580136,504.7316675599732,201.1899973585602 +40200,259.72753672572327,505.79761725152844,201.4600632361499 +40260,254.6132065877357,506.9682265660046,201.7575006847863 +40320,249.49402898131117,508.24219494681387,202.08222708486826 +40380,244.37010136821326,509.6181070045953,202.43415223670613 +40440,239.2415213006367,511.0944340897227,202.81317838557604 +40500,234.10838641935433,512.6695359906485,203.21920024887385 +40560,228.97079445185398,514.3416627561905,203.65210504535935 +40620,223.82884321048155,516.1089566397436,204.11177252648366 +40680,218.68263059057728,517.9694541632512,204.5980750097911 +40740,213.53225456861023,519.921088298644,205.11087741438539 +40800,208.37781320031687,521.9616907643283,205.65003729845154 +40860,203.21940461882977,524.088994434161,206.21540489882176 +40920,198.05712703281318,526.3006358562488,206.80682317257578 +40980,192.89107872459167,528.5941578787601,207.42412784066252 +41040,187.72135804827718,530.9670123798428,208.06714743353243 +41100,182.5480634279005,533.416563098607,208.73570333876643 +41160,177.371293355533,535.9400885640343,209.4296098506897 +41220,172.19114638941522,538.534785118553,210.14867422195513 +41280,167.0077211520784,541.197770032926,210.89269671708328 +41340,161.8211163284678,543.9260847089852,211.66147066794275 +41400,156.6314306640619,546.7166979666603,212.45478253115715 +41460,151.43876296299547,549.5665094116431,213.272411947421 +41520,146.24321208617707,552.472352879952,214.11413180270924 +41580,141.04487694940505,555.4309999555667,214.97970829136335 +41640,135.84385652148828,558.4391635572233,215.86890098103535 +41700,130.64024982235748,561.493501590391,216.78146287947348 +41760,125.43415592118448,564.5906206603642,217.71714050312912 +41820,120.22567393449167,567.727079842354,218.67567394756782 +41880,115.01490302426869,570.8993945043825,219.6567969596626 +41940,109.80194239608309,574.1040401787388,220.66023701155228 +42000,104.58689129718984,577.3374564776906,221.685715376341 +42060,99.36984901464557,580.5960510491052,222.7329472055214 +42120,94.15091487341408,583.8762035675811,223.80164160809667 +42180,88.93018823447954,587.1742697566614,224.89150173138242 +42240,83.70776849295063,590.4865854376521,226.00222484346307 +42300,78.48375507617175,593.8094706005579,227.133502417283 +42360,73.25824744182863,597.139233492601,228.28502021634566 +42420,68.03134507605319,600.4721747197897,229.45645838199988 +42480,62.8031474915331,603.8045913569771,230.64749152228777 +42540,57.5737542256125,607.1327810618385,231.85778880232843 +42600,52.343264838403286,610.4530461882073,233.0870140362154 +42660,47.11177891088221,613.7616978941876,234.3348257803985 +42720,41.87939604300137,617.0550602404903,235.60087742852792 +42780,36.64621585178719,620.3294742744297,236.88481730773083 +42840,31.412337969447574,623.5813020950523,238.18628877629666 +42900,26.17786204147346,626.8069308948723,239.50493032274127 +42960,20.942887724740128,630.002776973733,240.8403756662253 +43020,15.707514685613562,633.165289720325,242.1922538582955 +43080,10.47184259804875,636.2909555569462,243.56018938592467 +43140,5.235971141696239,639.376301843114,244.94380227581732 +43200,1.4695761589768238e-13,642.4179007336998,246.34270819995652 +43260,0.0,645.41237298729,247.75651858235807 +43320,0.0,648.3563917205538,249.18484070700643 +43380,0.0,651.2466861044371,250.62727782694026 +43440,0.0,654.0800449980793,252.08342927445628 +43500,0.0,656.8533205164197,253.5528905724047 +43560,0.0,659.5634315275208,255.03525354653985 +43620,0.0,662.2073670757343,256.5301064389009 +43680,0.0,664.7821897268944,258.03703402218474 +43740,0.0,667.2850388318346,259.555617715086 +43800,0.0,669.7131337045898,261.0854356985651 +43860,0.0,672.0637767117631,262.6260630330193 +43920,0.0,674.3343562696173,264.1770717763171 +43980,0.0,676.5223497455676,265.73803110266977 +44040,0.0,678.6253262608451,267.3085074223011 +44100,0.0,680.6409493912255,268.88806450188724 +44160,0.0,682.5669797628134,270.4762635857293 +44220,0.0,684.4012775400064,272.072663517627 +44280,0.0,686.1418048028703,273.6768208634199 +44340,0.0,687.7866278112838,275.2882900341597 +44400,0.0,689.333919153341,276.9066234098832 +44460,0.0,690.7819597756195,278.53137146394715 +44520,0.0,692.129140893065,280.16208288789505 +44580,0.0,693.3739657763626,281.79830471681646 +44640,0.0,694.5150514148171,283.43958245516905 +44700,0.0,695.5511300528877,285.085460203023 +44760,0.0,696.4810505986766,286.73548078269823 +44820,0.0,697.3037799028,288.38918586575414 +44880,0.0,698.0184039062264,290.04611610030133 +44940,0.0,698.6241286558037,291.705811238595 +45000,0.0,699.1202811863474,293.36781026487995 +45060,0.0,699.5063102683106,295.03165152344644 +45120,0.0,699.7817870202045,296.6968728468654 +45180,0.0,699.9464053850895,298.36301168436586 +45240,0.0,699.9999824706067,300.02960523031686 +45300,0.0,699.942458752173,301.6961905527833 +45360,0.0,699.773898139113,303.36230472211366 +45420,0.0,699.494487903656,305.0274849395298 +45480,0.0,699.1045384728743,306.6912686656781 +45540,0.0,698.6044830837964,308.3531937491108 +45600,0.0,697.994877302077,310.01279855465725 +45660,0.0,697.2763984047596,311.66962209165416 +45720,0.0,696.449844627815,313.3232041419942 +45780,0.0,695.5161342792954,314.97308538796295 +45840,0.0,694.4763047190867,316.6188075398226 +45900,0.0,693.3315112063922,318.2599134631134 +45960,0.0,692.0830256162312,319.8959473056309 +46020,0.0,690.7322350263748,321.5264546240494 +46080,0.0,689.280640176291,323.1509825101539 +46140,0.0,687.7298537998112,324.76907971664355 +46200,0.0,686.0815988333688,326.3802967824769 +46260,0.0,684.3377065018043,327.98418615771743 +46320,0.0,682.5001142838572,329.5803023278522 +46380,0.0,680.5708637596133,331.16820193754097 +46440,0.0,678.5520983422907,332.7474439137693 +46500,0.0,676.4460608968913,334.3175895883647 +46560,0.0,674.2550912483582,335.87820281984716 +46620,0.0,671.9816235820126,337.42885011457565 +46680,0.0,669.6281837391514,338.9691007471616 +46740,0.0,667.1973864108195,340.4985268801104 +46800,0.0,664.691932232864,342.0167036826641 +46860,0.0,662.1146047855075,343.5232094488059 +46920,0.0,659.4682675007664,345.0176257143986 +46980,0.0,656.7558604811544,346.49953737342213 +47040,0.0,653.9803972332054,347.9685327932774 +47100,0.0,651.1449613194412,349.42420392912703 +47160,0.0,648.2527029325105,350.8661464372374 +47220,0.0,645.3068353952959,352.2939597872948 +47280,0.0,642.3106315908891,353.7072473736604 +47340,0.0,639.2674203263875,355.10561662553664 +47400,0.0,636.1805826345656,356.4886791160116 +47460,0.0,633.0535480175157,357.8560506699534 +47520,0.0,629.8897906364472,359.2073514707223 +47580,0.0,626.6928254518608,360.54220616567386 +47640,0.0,623.466204318402,361.86024397042013 +47700,0.0,620.2135120387182,363.16109877182384 +47760,0.0,616.9383623807161,364.44440922969346 +47820,0.0,613.6443940626343,365.7098188771532 +47880,0.0,610.335266710397,366.95697621966025 +47940,0.0,607.0146567917417,368.18553483263815 +48000,0.0,603.6862535316297,369.39515345770565 +48060,0.0,600.353754813491,370.58549609746643 +48120,0.0,597.0208630708412,371.7562321088416 +48180,0.0,593.6912811738509,372.9070362949109 +48240,0.0,590.368708315424,374.03758899524485 +48300,0.0,587.0568359013654,375.1475761746959 +48360,0.0,583.7593434491976,376.2366895106295 +48420,0.0,580.4798945001878,377.30462647856604 +48480,0.0,577.2221325491216,378.35109043621486 +48540,0.0,573.9896769963527,379.3757907058718 +48600,0.0,570.7861191266164,380.3784426551621 +48660,0.0,567.6150181190851,381.3587677761023 +48720,0.0,564.4798970930904,382.3164937624622 +48780,0.0,561.3842391939068,383.251354585404 +48840,0.0,558.3314837229514,384.16309056737657 +48900,0.0,555.3250223166889,385.05144845424775 +48960,0.0,552.3681951784985,385.9161814856496 +49020,0.0,549.4642873676772,386.7570494635228 +49080,0.0,546.6165251497151,387.5738188188356 +49140,0.0,543.828072411885,388.3662626764635 +49200,0.0,541.10202714814,389.134160918208 +49260,0.0,538.4414180172145,389.87730024393966 +49320,0.0,535.8492009777617,390.5954742308462 +49380,0.0,533.3282560042569,391.28848339077143 +49440,0.0,530.8813838873243,391.9561352256269 +49500,0.0,528.5113031220335,392.59824428086273 +49560,0.0,526.2206468876309,393.21463219698103 +49620,0.0,524.0119601220549,393.80512775907965 +49680,0.0,521.8876966944886,394.3695669444105 +49740,0.0,519.8502166790921,394.90779296794017 +49800,0.0,517.9017837329395,395.41965632590086 +49860,0.0,516.044562581078,395.9050148373176 +49920,0.0,514.2806166114999,396.3637336835026 +49980,0.0,512.6119055827025,396.7956854455036 +50040,0.0,511.04028344637914,397.2007501394976 +50100,0.0,509.56749628766477,397.5788152501185 +50160,0.0,508.1951803852196,397.9297757617111 +50220,0.0,506.92486039331163,398.253534187501 +50280,0.0,505.75794764791175,398.55000059667384 +50340,0.0,504.695738598689,398.81909263935563 +50400,0.0,503.73941336864334,399.06073556948706 +50460,0.0,502.8900344429801,399.2748622655856 +50520,0.0,502.1485454886798,399.4614132493904 +50580,0.0,501.51577030607615,399.62033670238316 +50640,0.0,500.99241191360755,399.75158848018225 +50700,0.0,500.5790517667574,399.85513212480436 +50760,0.0,500.2761491120526,399.93093887479176 +50820,0.0,500.0840404768368,399.97898767320146 +50880,0.0,500.00293929538543,399.9992651734538 +50940,0.0,500.032935671779,399.99176574304033 +51000,0.0,500.17399627979637,399.9564914650879 +51060,0.0,500.4259643999404,399.8934521377802 +51120,0.0,500.7885600935549,399.80266527163616 +51180,0.0,501.2613805138386,399.684156084646 +51240,0.0,501.84390035341113,399.5379574952663 +51300,0.0,502.5354724279342,399.36411011327624 +51360,0.0,503.33532839513845,399.1626622284975 +51420,0.0,504.2425796084579,398.93366979738045 +51480,0.0,505.25621810432426,398.67719642746135 +51540,0.0,506.37511772202174,398.3933133596939 +51600,0.0,507.5980353548622,398.0820994486603 +51660,0.0,508.92361233128526,397.7436411406683 +51720,0.0,510.3503759243554,397.37803244973804 +51780,0.0,511.87674098797197,396.98537493148854 +51840,0.0,513.5010117179811,396.56577765492773 +51900,0.0,515.2213835362269,396.1193571721568 +51960,0.0,517.0359450954535,395.64623748599485 +52020,0.0,518.9426804028267,395.1465500155348 +52080,0.0,520.93947105972,394.62043355963874 +52140,0.0,523.0240986152706,394.06803425838376 +52200,0.0,525.1942470311,393.4895055524683 +52260,0.0,527.447505254448,392.8850081405907 +52320,0.0,529.7813698968743,392.2547099348119 +52380,0.0,532.1932480155441,391.5987860139139 +52440,0.0,534.680459994007,390.9174185747684 +52500,0.0,537.2402425192752,390.2107968817272 +52560,0.0,539.8697516518845,389.4791172140504 +52620,0.0,542.5660659855371,388.72258281138545 +52680,0.0,545.3261898928057,387.941403817313 +52740,0.0,548.147056853302,387.1357972209751 +52800,0.0,551.0255328606049,386.30598679680196 +52860,0.0,553.9584199041686,385.4522030423533 +52920,0.0,556.942459522337,384.5746831142934 +52980,0.0,559.9743364225235,383.6736707625154 +53040,0.0,563.050682164526,382.74941626243475 +53100,0.0,566.1680789028944,381.80217634546943 +53160,0.0,569.3230631841827,380.8322141277279 +53220,0.0,572.5121297948751,379.83979903692295 +53280,0.0,575.731735655708,378.82520673753163 +53340,0.0,578.9783037580584,377.7887190542246 +53400,0.0,582.248227138032,376.73062389358245 +53460,0.0,585.5378728838276,375.6512151641242 +53520,0.0,588.8435861719347,374.55079269466734 +53580,0.0,592.1616943276694,373.42966215104445 +53640,0.0,595.4885109055489,372.2881349511976 +53700,0.0,598.8203397849608,371.1265281786758 +53760,0.0,602.1534792765856,369.9451644945576 +53820,0.0,605.4842262350014,368.74437204782606 +53880,0.0,608.808880172912,367.52448438421726 +53940,0.0,612.1237473724154,366.2858403535719 +54000,0.0,615.4251449887583,365.02878401571166 +54060,0.0,618.7094051420041,363.7536645448698 +54120,0.0,621.9728789920786,362.46083613270054 +54180,0.0,625.2119407926627,361.1506578898941 +54240,0.0,628.4229919194241,359.82349374642706 +54300,0.0,631.6024648681209,358.479712350472 +54360,0.0,634.746827218126,357.1196869659989 +54420,0.0,637.8525855569775,355.7437953690913 +54480,0.0,640.9162893615868,354.35241974301294 +54540,0.0,643.9345348318001,352.9459465720464 +54600,0.0,646.9039686720465,351.52476653414044 +54660,0.0,649.8212918168789,350.08927439238914 +54720,0.0,652.6832630962609,348.63986888538 +54780,0.0,655.4867028365354,347.1769526164337 +54840,0.0,658.228496393066,345.7009319417744 +54900,0.0,660.9055976106356,344.21221685765397 +54960,0.0,663.515032207747,342.71122088646786 +55020,0.0,666.0539010810759,341.19836096189033 +55080,0.0,668.5193835263987,339.6740573130612 +55140,0.0,670.9087403724168,338.1387333478599 +55200,0.0,673.2193170240002,336.5928155352931 +55260,0.0,675.4485464114626,335.0367332870356 +55320,0.0,677.5939518425989,333.47091883815045 +55380,0.0,679.6531497543089,331.89580712702775 +55440,0.0,681.623852360757,330.3118356745702 +55500,0.0,683.5038701951199,328.7194444626633 +55560,0.0,685.2911145421034,327.11907581195993 +55620,0.0,686.9835997585209,325.5111742590174 +55680,0.0,688.5794454793611,323.896186432817 +55740,0.0,690.0768787068871,322.27456093070407 +55800,0.0,691.4742357804531,320.6467481937797 +55860,0.0,692.7699642248442,319.01320038178187 +55920,0.0,693.962624475089,317.3743712474883 +55980,0.0,695.0508914758292,315.730716010676 +56040,0.0,696.0335561534656,314.0826912316758 +56100,0.0,696.90952675945,312.43075468455146 +56160,0.0,697.6778300832261,310.7753652299444 +56220,0.0,698.3376125334748,309.11698268761427 +56280,0.0,698.888141086461,307.4560677087153 +56340,0.0,699.3288041004294,305.7930816478395 +56400,0.0,699.6591119951424,304.1284864348666 +56460,0.0,699.8786977958097,302.4627444466522 +56520,0.0,699.9873175407982,300.79631837859375 +56580,0.0,699.9848505526772,299.1296711161058 +56640,0.0,699.8712995722906,297.4632656060451 +56700,0.0,699.6467907557125,295.7975647281159 +56760,0.0,699.311573534087,294.1330311662962 +56820,0.0,698.8660203365075,292.47012728031706 +56880,0.0,698.3106261762453,290.80931497723185 +56940,0.0,697.6460081007858,289.15105558311274 +57000,0.0,696.8729045062834,287.4958097149063 +57060,0.0,695.9921743171975,285.8440371524886 +57120,0.0,695.0047960320204,284.1961967109503 +57180,0.0,693.9118666361574,282.552746113152 +57240,0.0,692.7146003831664,280.91414186258106 +57300,0.0,691.4143274457134,279.280839116549 +57360,0.0,690.0124924377386,277.6532915597605 +57420,0.0,688.510652809479,276.03195127829446 +57480,0.0,686.9104771171279,274.41726863402573 +57540,0.0,685.2137431690562,272.809692139529 +57600,0.0,683.422336050651,271.2096683334935 +57660,0.0,681.5382460299724,269.61764165668865 +57720,0.0,679.5635663465466,268.0340543285097 +57780,0.0,677.5004908857629,266.45934622414387 +57840,0.0,675.3513117414458,264.8939547523833 +57900,0.0,673.1184166693214,263.338314734127 +57960,0.0,670.8042864342005,261.7928582815991 +58020,0.0,668.4114920538308,260.25801467832207 +58080,0.0,665.9426919424709,258.734210259872 +58140,0.0,663.4006289573736,257.22186829545586 +58200,0.0,660.7881273514465,255.72140887033922 +58260,0.0,658.1080896354872,254.23324876915947 +58320,0.0,655.3634933534655,252.7578013601534 +58380,0.0,652.5573877744506,251.29547648033633 +58440,0.0,649.6928905048462,249.8466803216608 +58500,0.0,646.7731840247073,248.41181531818907 +58560,0.0,643.8015121519743,246.99128003430678 +58620,0.0,640.7811764385666,245.58546905401397 +58680,0.0,637.7155325023334,244.19477287132207 +58740,0.0,634.6079862989304,242.81957778178375 +58800,0.0,631.4619903377782,241.46026577519254 +58860,0.0,628.2810398462935,240.1172144294763 +58920,0.0,625.068668886667,238.79079680581805 +58980,0.0,621.8284464294852,237.48138134502858 +59040,0.0,618.5639723885791,236.18933176520525 +59100,0.0,615.2788736214881,234.91500696070102 +59160,0.0,611.9767998999939,233.65876090243486 +59220,0.0,608.6614198551907,232.4209425395673 +59280,0.0,605.3364169016081,231.20189570257335 +59340,0.0,602.0054851449056,230.00195900773565 +59400,0.0,598.6723252776941,228.8214657630877 +59460,0.0,595.3406404680314,227.6607438758289 +59520,0.0,592.0141322451746,226.52011576124204 +59580,0.0,588.6964963871517,225.3998982531363 +59640,0.0,585.3914188147188,224.30040251583856 +59700,0.0,582.1025714962735,223.2219339577616 +59760,0.0,578.8336083682675,222.16479214657016 +59820,0.0,575.5881612756546,221.12927072597037 +59880,0.0,572.3698359368778,220.1156573341427 +59940,0.0,569.1822079378893,219.12423352384513 +60000,0.0,566.028818759643,218.1552746842055 diff --git a/benchmarks/native_study.py b/benchmarks/native_study.py new file mode 100644 index 00000000..31aba37e --- /dev/null +++ b/benchmarks/native_study.py @@ -0,0 +1,269 @@ +"""Benchmark annual simulation throughput and recorder overhead. + +Run with .venv/bin/python benchmarks/native_study.py. +Writes raw timings and profiles under results/benchmark-native. +No simulation models or engine methods are replaced. +""" + +from __future__ import annotations + +import argparse +from concurrent.futures import ProcessPoolExecutor +import cProfile +import io +import json +import multiprocessing +import os +from pathlib import Path +import platform +import pstats +import statistics +import sys +import tempfile +import time + +import numpy as np +import pandas as pd +from loguru import logger + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "examples")) + +import vessim as vs # noqa: E402 +from infrastructure_study import inputs # noqa: E402 + +CONFIGS = [ + dict(wind_kw=0, solar_kw=0, battery_kwh=0, battery_c_rate=0.5), + dict(wind_kw=1500, solar_kw=2000, battery_kwh=4000, battery_c_rate=0.25), + dict(wind_kw=5000, solar_kw=6000, battery_kwh=12000, battery_c_rate=1.0), +] + + +class Observer(vs.Controller): + """Request normal detached state observations without recording metrics.""" + + def step(self, now, microgrid_states): + pass + + +def run_one(config, seed, mode="metrics", step_seconds=3600, output=None): + started = time.perf_counter() + frame = inputs(seed=seed, step_seconds=step_seconds) + hours = (frame.index.to_numpy() / 3600) % 24 + frame["price"] = np.where((hours >= 17) & (hours < 21), 0.30, 0.15) + frame["export_price"] = 0.04 + input_done = time.perf_counter() + battery = ( + vs.SimpleBattery("battery", config["battery_kwh"] * 1000, c_rate=config["battery_c_rate"]) + if config["battery_kwh"] + else None + ) + with vs.Environment("2026-01-01", step_size=step_seconds, backend="native") as env: + env.add_microgrid( + name="site", + actors=[ + vs.Producer("wind", vs.Trace(frame.wind_cf * config["wind_kw"] * 1000)), + vs.Producer("solar", vs.Trace(frame.solar_cf * config["solar_kw"] * 1000)), + vs.Consumer("datacenter", vs.Trace(frame.load_kw * 1000)), + ], + dispatchables=([battery] if battery is not None else []) + [vs.GridConnection()], + grid_signals={ + key: vs.Trace(frame[key]) for key in ("carbon", "price", "export_price") + }, + ) + recorder = None + if mode == "observer": + env.add_controller(Observer()) + elif mode != "none": + custom = ( + { + "grid_emissions": vs.GridImportEmissions("site", "carbon"), + "electricity_cost": vs.ElectricityImportCost("site", "price"), + "export_revenue": vs.ElectricityExportRevenue("site", "export_price"), + } + if mode != "physical" + else {} + ) + recorder = env.add_controller( + vs.Recorder( + output, + timeseries=mode in ("trajectory_memory", "trajectory_csv"), + custom_metrics=custom, + parameters={**config, "seed": seed}, + ) + ) + setup_done = time.perf_counter() + result = env.run(365 * 86400) + run_done = time.perf_counter() + assert result.steps == 365 * 86400 // step_seconds + checksum = recorder.metrics["microgrids"]["site"] if recorder is not None else None + return { + "config": config, + "seed": seed, + "mode": mode, + "step_seconds": step_seconds, + "input_seconds": input_done - started, + "setup_seconds": setup_done - input_done, + "run_seconds": run_done - setup_done, + "total_seconds": time.perf_counter() - started, + "steps": result.steps, + "metrics": checksum, + } + + +def worker(task): + config, seed, output = task + return run_one(config, seed, output=output) + + +def configure_logging(quiet): + if quiet: + logger.disable("vessim") + + +def summary(rows): + return { + name: { + "median": statistics.median(row[name] for row in rows), + "min": min(row[name] for row in rows), + "max": max(row[name] for row in rows), + } + for name in ("input_seconds", "setup_seconds", "run_seconds", "total_seconds") + } + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--output", type=Path, default=ROOT / "results/benchmark-native") + parser.add_argument("--repeats", type=int, default=6) + parser.add_argument("--jobs", type=int, default=100) + parser.add_argument("--workers", nargs="+", type=int, default=[1, 2, 4, 8]) + parser.add_argument("--quiet", action="store_true", help="Disable Vessim log messages") + parser.add_argument("--throughput-only", action="store_true") + args = parser.parse_args() + if args.repeats < 1 or args.jobs < 1 or any(w < 1 for w in args.workers): + parser.error("repeats, jobs and workers must be positive") + configure_logging(args.quiet) + args.output.mkdir(parents=True, exist_ok=True) + report = { + "system": { + "python": sys.version, + "platform": platform.platform(), + "machine": platform.machine(), + "logical_cpus": os.cpu_count(), + "numpy": np.__version__, + "pandas": pd.__version__, + }, + "scope": "365 days, three actors, battery where configured, grid, six traces", + "input_source": "examples/infrastructure_study.py:inputs", + "logging": "disabled" if args.quiet else "default", + "ablations": {}, + "parallel": [], + } + + def save(): + (args.output / "timings.json").write_text(json.dumps(report, indent=2) + "\n") + + # Warm imports and allocation paths before collecting unprofiled timings. + run_one(CONFIGS[1], 9999) + with tempfile.TemporaryDirectory(prefix="native-benchmark-", dir=args.output) as scratch: + modes = [ + "none", + "observer", + "physical", + "metrics", + "metrics_disk", + "trajectory_memory", + "trajectory_csv", + ] + rows = {mode: [] for mode in modes} + # Rotate execution order so later modes do not always get a warmer CPU. + for repeat in range(0 if args.throughput_only else args.repeats): + order = modes[repeat % len(modes) :] + modes[: repeat % len(modes)] + for mode in order: + output = ( + Path(scratch) / f"{mode}-{repeat}" + if mode in ("metrics_disk", "trajectory_csv") + else None + ) + rows[mode].append( + run_one(CONFIGS[repeat % len(CONFIGS)], 10000 + repeat, mode, output=output) + ) + print(f"Ablations {repeat + 1}/{args.repeats}", flush=True) + for mode in modes: + if not rows[mode]: + continue + report["ablations"][mode] = {"summary": summary(rows[mode]), "runs": rows[mode]} + print(mode, json.dumps(summary(rows[mode])), flush=True) + save() + + if not args.throughput_only: + fine = [run_one(CONFIGS[1], 10000 + i, step_seconds=300) for i in range(3)] + report["five_minute"] = {"summary": summary(fine), "runs": fine} + save() + print("Five-minute", json.dumps(summary(fine)), flush=True) + + for mode in (() if args.throughput_only else ("none", "metrics", "trajectory_csv")): + profiler = cProfile.Profile() + for i in range(3): + profiler.runcall( + run_one, + CONFIGS[1], + 10000 + i, + mode, + output=( + Path(scratch) / f"profile-{mode}-{i}" if mode == "trajectory_csv" else None + ), + ) + profiler.dump_stats(str(args.output / f"{mode}.prof")) + stream = io.StringIO() + stats = pstats.Stats(profiler, stream=stream) + stats.strip_dirs().sort_stats("cumulative").print_stats(50) + stats.sort_stats("tottime").print_stats(40) + (args.output / f"{mode}-profile.txt").write_text(stream.getvalue()) + print(f"Profiled {mode}", flush=True) + + configs = CONFIGS + report["parallel_configuration_source"] = "three reference configurations" + reference = None + for workers in args.workers: + tasks = [ + (configs[i % len(configs)], 10000 + i, Path(scratch) / f"parallel-{workers}-{i}") + for i in range(args.jobs) + ] + started = time.perf_counter() + if workers == 1: + results = list(map(worker, tasks)) + else: + with ProcessPoolExecutor( + max_workers=workers, + mp_context=multiprocessing.get_context("spawn"), + initializer=configure_logging, + initargs=(args.quiet,), + ) as pool: + results = list(pool.map(worker, tasks, chunksize=1)) + elapsed = time.perf_counter() - started + metrics = [r["metrics"] for r in results] + if reference is None: + reference = metrics + assert metrics == reference, "Parallel execution changed results" + item = { + "workers": workers, + "runs": args.jobs, + "wall_seconds": elapsed, + "runs_per_second": args.jobs / elapsed, + "includes_pool_startup": True, + "includes_recorded_json": True, + "timings": results, + } + report["parallel"].append(item) + save() + print( + "Throughput", + json.dumps({k: v for k, v in item.items() if k != "timings"}), + flush=True, + ) + + +if __name__ == "__main__": + main() diff --git a/benchmarks/run.py b/benchmarks/run.py new file mode 100644 index 00000000..ab4aa999 --- /dev/null +++ b/benchmarks/run.py @@ -0,0 +1,164 @@ +"""Reproducible backend comparison using public Vessim APIs. + +Run from the repository: python benchmarks/run.py --backend native --output result.json +""" + +from __future__ import annotations + +import argparse +import cProfile +import gc +import hashlib +import importlib.metadata +import json +import platform +import statistics +import sys +import tempfile +import time +import tracemalloc +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +import numpy as np +import pandas as pd +from loguru import logger +import vessim as vs + +CASES = ("static", "traces", "csv", "many_grids", "mixed_rates", "feedback") + + +def environment_info(): + versions = {} + for package in ("vessim", "numpy", "pandas", "mosaik"): + try: + versions[package] = importlib.metadata.version(package) + except importlib.metadata.PackageNotFoundError: + versions[package] = None + return {"python": platform.python_version(), "platform": platform.platform(), **versions} + + +class Feedback(vs.Controller): + def start(self, environment): + self.grids = environment.microgrids + + def step(self, now, microgrid_states): + for grid in self.grids.values(): + grid.policy.charge_power = 100 if now.minute % 2 else -100 + + +def build(backend, case, traces, directory): + env = vs.Environment(sim_start="2026-01-01", step_size=60, backend=backend) + for i in range(10 if case == "many_grids" else 1): + traced = case != "static" + env.add_microgrid( + name=f"grid{i}", + actors=[ + vs.Producer( + "solar", + traces["solar"] if traced else 900, + step_size=180 if case == "mixed_rates" else None, + ), + vs.Consumer("load", traces["load"] if traced else 600), + ], + dispatchables=[ + vs.SimpleBattery("battery", 1500, initial_soc=0.5, min_soc=0.1), + vs.GridConnection(), + ], + grid_signals={"carbon": traces["carbon"]} if traced else None, + ) + if case == "feedback": + env.add_controller(Feedback()) + memory = vs.Recorder(metrics=False) + env.add_controller(memory) + if case == "csv": + env.add_controller(vs.Recorder(directory, metrics=False)) + return env, memory + + +def digest(memory): + rows = [(str(t), value) for t, value in memory.log.items()] + return hashlib.sha256(json.dumps(rows, sort_keys=True).encode()).hexdigest() + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--backend", choices=["native", "mosaik"], default="native") + parser.add_argument("--steps", type=int, default=1000) + parser.add_argument("--repeats", type=int, default=3) + parser.add_argument("--output", type=Path, required=True) + parser.add_argument("--profile", type=Path, help="Directory for per-workload cProfile files") + args = parser.parse_args() + if args.steps < 1 or args.repeats < 1: + parser.error("steps and repeats must be positive") + logger.remove() + results = { + "backend": args.backend, + **environment_info(), + "cases": {}, + } + with tempfile.TemporaryDirectory() as tmp: + directory = Path(tmp) + if args.steps == 1000: + (directory / "input.csv").write_bytes( + Path(__file__).with_name("input.csv").read_bytes() + ) + else: + offsets = np.arange(args.steps + 1) * 60 + pd.DataFrame( + { + "solar": 1200 * np.maximum(0, np.sin(offsets / 86400 * 2 * np.pi)), + "load": 600 + 100 * np.cos(offsets / 1800), + "carbon": 300 + 100 * np.sin(offsets / 3600), + }, + index=offsets, + ).to_csv(directory / "input.csv") + t0 = time.perf_counter() + traces = { + c: vs.Trace.from_csv(directory / "input.csv", column=c) + for c in ("solar", "load", "carbon") + } + results["trace_load_seconds"] = time.perf_counter() - t0 + for case in CASES: + steps = max(1, args.steps // 5) if case == "many_grids" else args.steps + timings, setups = [], [] + checksums = set() + for repeat in range(args.repeats): + gc.collect() + t0 = time.perf_counter() + env, memory = build(args.backend, case, traces, directory / f"{case}-{repeat}") + setups.append(time.perf_counter() - t0) + t0 = time.perf_counter() + env.run(until=steps * 60, print_progress=False) + timings.append(time.perf_counter() - t0) + checksum = digest(memory) + checksums.add(checksum) + if len(checksums) != 1: + raise RuntimeError(f"Repeated {case} runs produced different trajectories") + gc.collect() + tracemalloc.start() + env, memory = build(args.backend, case, traces, directory / f"{case}-memory") + env.run(until=steps * 60, print_progress=False) + _, peak = tracemalloc.get_traced_memory() + tracemalloc.stop() + results["cases"][case] = { + "steps": steps, + "run_seconds": statistics.median(timings), + "setup_seconds": statistics.median(setups), + "runs": timings, + "peak_traced_bytes": peak, + "trajectory_sha256": checksum, + } + print(case, results["cases"][case], flush=True) + if args.profile: + args.profile.mkdir(parents=True, exist_ok=True) + env, _ = build(args.backend, case, traces, directory / f"{case}-profile") + profiler = cProfile.Profile() + profiler.runcall(env.run, until=steps * 60, print_progress=False) + profiler.dump_stats(str(args.profile / f"{case}.prof")) + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(json.dumps(results, indent=2) + "\n") + + +if __name__ == "__main__": + main() diff --git a/benchmarks/studies.py b/benchmarks/studies.py new file mode 100644 index 00000000..9d71f595 --- /dev/null +++ b/benchmarks/studies.py @@ -0,0 +1,79 @@ +"""Measure multi-run throughput and bounded versus trajectory result retention.""" + +from __future__ import annotations + +import argparse +import gc +import json +import sys +import time +import tracemalloc +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +import pandas as pd +from loguru import logger +import vessim as vs +from benchmarks.run import environment_info + + +def scenario(trace, steps, backend, output): + env = vs.Environment("2026-01-01", step_size=60, backend=backend) + env.add_microgrid( + [vs.Consumer("load", signal=trace)], + [vs.SimpleBattery("battery", 1500, initial_soc=0.5), vs.GridConnection()], + name="site", + ) + if output == "metrics": + env.add_controller(vs.Recorder(timeseries=False)) + elif output == "trajectory": + env.add_controller(vs.Recorder()) + env.run(steps * 60) + return env + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--backend", choices=["native", "mosaik"], default="native") + parser.add_argument("--runs", type=int, default=100) + parser.add_argument("--steps", type=int, default=288) + parser.add_argument("--memory-steps", nargs="+", type=int, default=[1000, 10000]) + parser.add_argument("--output", type=Path, required=True) + args = parser.parse_args() + if args.runs < 1 or args.steps < 1 or any(n < 1 for n in args.memory_steps): + parser.error("runs, steps, and memory-steps must be positive") + logger.remove() + data = pd.Series([500, 700, 600], index=[0, 3600, 7200]) + horizon = max(args.steps, *args.memory_steps) * 60 + if horizon > data.index[-1]: + data.loc[horizon] = data.iloc[-1] + trace = vs.Trace(data) + result = { + **environment_info(), + "backend": args.backend, + "runs": args.runs, + "steps_per_run": args.steps, + } + scenario(trace, 1, args.backend, "metrics") # Optional import warm-up. + t0 = time.perf_counter() + for _ in range(args.runs): + scenario(trace, args.steps, args.backend, "metrics") + result["total_seconds"] = time.perf_counter() - t0 + result["scenarios_per_second"] = args.runs / result["total_seconds"] + result["memory"] = [] + for output in ("none", "metrics", "trajectory"): + for steps in args.memory_steps: + gc.collect() + tracemalloc.start() + env = scenario(trace, steps, args.backend, output) + _, peak = tracemalloc.get_traced_memory() + tracemalloc.stop() + result["memory"].append({"output": output, "steps": steps, "peak_traced_bytes": peak}) + del env + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(json.dumps(result, indent=2) + "\n") + print(json.dumps(result, indent=2)) + + +if __name__ == "__main__": + main() diff --git a/datasets/replay_example.csv b/datasets/replay_example.csv new file mode 100644 index 00000000..d0ad05c3 --- /dev/null +++ b/datasets/replay_example.csv @@ -0,0 +1,8 @@ +seconds,load,solar +0,700,0 +3600,700,1200 +7200,700,2400 +10800,700,1200 +14400,700,0 +18000,700,0 +21600,700,0 diff --git a/docs/about.md b/docs/about.md index b4910aa2..78c85df4 100644 --- a/docs/about.md +++ b/docs/about.md @@ -1,21 +1,21 @@ # About Vessim -Research on microgrids and energy management, and in particular on energy- and carbon-aware computing where Vessim originated, often relies on simulation, as physical testbeds are rare and costly. -Vessim addresses this gap by simplifying and unifying simulation-based evaluations and enabling continuous testing of emerging applications. +Vessim is developed by [Philipp Wiesner](https://philippwiesner.org) at the [University of Cambridge](https://www.cam.ac.uk/) in the [Machine Learning Systems Lab](https://mlsys.cst.cam.ac.uk/). +For feedback, ideas, or questions, feel free to post on [GitHub Discussions](https://github.com/dos-group/vessim/discussions). +If you’re interested in collaborating or want advice on using Vessim in a commercial project, drop me an email at _pw560 at cam.ac.uk_. -Vessim is developed by [Philipp Wiesner](https://philippwiesner.org) at the [Distributed and Operating Systems (DOS)](https://www.dos.tu-berlin.de) group at TU Berlin. -Active development for Vessim began in March 2023 with initial funding from the [German Federal Ministry of Education and Research (BMBF)](https://www.bmbf.de/) as part of the Software Campus project [SYNERGY](https://softwarecampus.de/en/projekt/synergy-synergies-of-distributed-artificial-intelligence-and-renewable-energy-generation/). -We thank everyone who has contributed to Vessim over the past years, especially Marvin Steinke, Paul Kilian, and Amanda Malkowski. +Development of Vessim began in March 2023 at the [Distributed and Operating Systems (DOS)](https://www.dos.tu-berlin.de) group at TU Berlin, with initial funding from the [German Federal Ministry of Education and Research (BMBF)](https://www.bmbf.de/) as part of the Software Campus project [SYNERGY](https://softwarecampus.de/en/projekt/synergy-synergies-of-distributed-artificial-intelligence-and-renewable-energy-generation/). + +We thank everyone who has contributed to Vessim over the past years, especially Marvin Steinke, Paul Kilian, Amanda Malkowski, and Julius Irion. ## Roadmap We are currently working on the following aspects and features: -- Datasets: We are working on better integrating exiting data sources like electricity prices, carbon intensity data, or weather forecasts into Vessim. +- Probabilistic modelling: Better support for Monte Carlo simulation runs with inputs sampled from probability distributions. +- Datasets: We are working on better integrating existing data sources like electricity prices, carbon intensity data, or weather forecasts into Vessim. - System Advisor Model (SAM): We are working on integrating NREL's [SAM](https://sam.nrel.gov/) as a subsystem in Vessim, allowing for better simulation of solar arrays, wind farms, and other types of renewable energy generators. - Battery degradation: We are working on integrating NREL's [BLAST-Lite](https://github.com/NREL/BLAST-Lite) for modeling battery lifetime and degradation -- Improved modeling: We are working on more sophisticated modeling of e.g. demand response signals, time-varying PUE, ... -- Calibration: We are working on a methodology for calibrating Vessim simulations on real hardware testbeds. ## License diff --git a/docs/api_reference/actor.md b/docs/api_reference/actor.md index 58cf32fc..b00adf50 100644 --- a/docs/api_reference/actor.md +++ b/docs/api_reference/actor.md @@ -1,3 +1,14 @@ -# Actor +# Actors + +[`Consumer`](#vessim.Consumer) models demand, [`Producer`](#vessim.Producer) models generation, and [`Actor`](#vessim.Actor) supports signed inputs that can change direction. +See [Actors and signals](../concepts/signals.md) for examples. + +::: vessim.Consumer + options: + inherited_members: [signal] + +::: vessim.Producer + options: + inherited_members: [signal] ::: vessim.Actor diff --git a/docs/api_reference/cli.md b/docs/api_reference/cli.md deleted file mode 100644 index b0f01fee..00000000 --- a/docs/api_reference/cli.md +++ /dev/null @@ -1,34 +0,0 @@ -# Command-Line Interface - -Vessim ships with a small CLI for inspecting experiment results in the browser. - -## `vessim view` - -Launches the experiment viewer on a results directory written by [`CsvLogger`](controller.md). - -``` -usage: vessim view [-h] [-p PORT] [--no-browser] directory - -positional arguments: - directory Path to a results directory - -options: - -p PORT, --port PORT Port to serve on (default: 8710) - --no-browser Do not open the browser automatically -``` - -### Single-experiment mode - -Pass a directory that contains a `metadata.yaml` and `timeseries.csv` directly. The viewer opens that single experiment: - -```console -vessim view results/basic_example -``` - -### Multi-experiment mode - -Pass a parent directory that contains several experiment subdirectories. The viewer adds a sidebar from which you can browse runs: - -```console -vessim view results/ -``` diff --git a/docs/api_reference/controller.md b/docs/api_reference/controller.md index 255be86c..c8ae7509 100644 --- a/docs/api_reference/controller.md +++ b/docs/api_reference/controller.md @@ -1,10 +1,20 @@ -# Controller +# Controllers -Controllers run at every simulation step. +Controllers run after dispatch at each simulation step. They can observe the state of all microgrids and modify their components. See the [Controllers](../concepts/controllers.md) concept page for usage examples. ::: vessim.Controller -::: vessim.CsvLogger -::: vessim.MemoryLogger + ::: vessim.Api + +### REST API commands + +Install `vessim[live]` to use `Api`. +Its running server exposes interactive endpoint documentation at `http://localhost:8700/docs` with the default port. + +API commands are acknowledged when queued. +Changes appear in observations after the command is applied and the affected component is sampled again. +Assigning an actor's `signal` through the API replaces its input with a static signal. + +For recording and custom metric APIs, see [Recording and metrics](recorder.md). diff --git a/docs/api_reference/dispatch_policy.md b/docs/api_reference/dispatch_policy.md index ffa9c9d3..23fea286 100644 --- a/docs/api_reference/dispatch_policy.md +++ b/docs/api_reference/dispatch_policy.md @@ -1,11 +1,10 @@ -# Dispatch Policy +# Dispatch policy -A dispatch policy receives the microgrid's current power delta and allocates it -across [Dispatchables](dispatchable.md), returning the remainder to be exchanged -with the public grid. Subclass [`DispatchPolicy`](#vessim.DispatchPolicy) to -implement custom strategies. See the -[Dispatchables and Dispatch Policies](../concepts/dispatchables.md) concept page -for usage examples. +A dispatch policy allocates the microgrid's power surplus or deficit across [dispatchables](dispatchable.md). +Each `apply()` call assigns feasible resource setpoints and returns `None`. +The microgrid validates bounds and power balance. + +See [Dispatchables and policies](../concepts/dispatchables.md) for examples. -::: vessim.DispatchPolicy ::: vessim.DefaultDispatchPolicy +::: vessim.DispatchPolicy diff --git a/docs/api_reference/dispatchable.md b/docs/api_reference/dispatchable.md index 769cf0a9..e219cd89 100644 --- a/docs/api_reference/dispatchable.md +++ b/docs/api_reference/dispatchable.md @@ -1,12 +1,21 @@ -# Dispatchable +# Dispatchables -Controllable energy resources whose power is allocated by a -[Dispatch Policy](dispatch_policy.md). Subclass [`Dispatchable`](#vessim.Dispatchable) -to model any device with a controllable power setpoint. See the -[Dispatchables and Dispatch Policies](../concepts/dispatchables.md) concept page for -usage examples and a guide to writing custom dispatchables. +A [dispatch policy](dispatch_policy.md) assigns power to these resources. +Use the built-in grid and battery models, or subclass `Dispatchable` for a custom resource. +See [Dispatchables and policies](../concepts/dispatchables.md) for examples. -::: vessim.Dispatchable -::: vessim.Storage +Positive power means absorption or export. +Negative power means supply or import. +Battery observations include `current_power` in W, `charge_level` in Wh, and `soc` between 0 and 1. + +::: vessim.GridConnection + options: + inherited_members: [set_power] ::: vessim.SimpleBattery + options: + inherited_members: [set_power] ::: vessim.ClcBattery + options: + inherited_members: [set_power] +::: vessim.Dispatchable +::: vessim.Storage diff --git a/docs/api_reference/environment.md b/docs/api_reference/environment.md index 6defb6f2..7991590b 100644 --- a/docs/api_reference/environment.md +++ b/docs/api_reference/environment.md @@ -1 +1,32 @@ +# Environment and RunResult + +Create and run simulations with `Environment`. +Its `run()` method returns a `RunResult` containing the final state. +See [Getting Started](../getting_started.md#run-your-first-simulation) for a walkthrough or [Connect External Simulators](../concepts/coupling.md) for Mosaik. + ::: vessim.Environment + +::: vessim.RunResult + +## Time and final state + +Actor sampling intervals must be whole multiples of the environment's `step_size`. +Inputs are held between samples. +Controller changes to an actor's input take effect at its next sample, while changes to policy parameters affect the next dispatch. + +Ticks occur strictly before `until`. +The final integration interval is shortened as needed to reach that horizon. +`RunResult.states` contains the final resource state, while `last_time` and recorded observations refer to the last tick. +Energy metrics include the final interval. + +## Mosaik connections + +With `backend="mosaik"`, `env.world` exposes the Mosaik world. +Call `env.prepare()` before connecting to microgrids through `env.entities[name]`, then use `env.run()` to execute and clean up the coupled simulation. + +The microgrid input `power` is in W, with generation positive and consumption negative. +Multiple external inputs are summed with local actor power. +A microgrid fed entirely by external simulators may have no actors, but still needs dispatchables to balance its power. + +Available outputs are `p_delta`, `p_grid`, `actor_states`, `dispatch_states`, `policy_state`, and `grid_signals`. +Vessim uses integer-second scheduling with Mosaik's `time_resolution=1.0`. diff --git a/docs/api_reference/microgrid.md b/docs/api_reference/microgrid.md index 9b937a2c..fd10e2a2 100644 --- a/docs/api_reference/microgrid.md +++ b/docs/api_reference/microgrid.md @@ -1,4 +1,22 @@ # Microgrid +Create microgrids through [`Environment.add_microgrid()`](environment.md#vessim.Environment.add_microgrid). +Controllers receive their observations as `MicrogridState` dictionaries. + +## Power balance and units + +The sum of actor and external simulator power is `p_delta`, the surplus or deficit allocated across dispatchables. +A grid connection must be explicitly configured. +Unmet demand or surplus that cannot be absorbed raises an error. + +| Quantity | Positive | Negative | +| --- | --- | --- | +| Actor power and `p_delta` | Generation or surplus | Consumption or deficit | +| Dispatchable power | Absorbing power | Supplying power | +| Grid exchange, `p_grid` | Export | Import | + +Power is in W, energy and battery capacity in Wh, and state of charge ranges from 0 to 1. +Multiple microgrids share simulation time but have independent power balances unless explicitly coupled. + ::: vessim.Microgrid ::: vessim.MicrogridState diff --git a/docs/api_reference/recorder.md b/docs/api_reference/recorder.md new file mode 100644 index 00000000..30015872 --- /dev/null +++ b/docs/api_reference/recorder.md @@ -0,0 +1,52 @@ +# Recording and metrics + +See [Results and metrics](../concepts/results.md) for recording choices and custom calculations, or [Simulation studies](../concepts/studies.md) for comparing runs. + +::: vessim.Recorder +::: vessim.ExperimentResult +::: vessim.Metric +::: vessim.ElectricityImportCost +::: vessim.ElectricityExportRevenue +::: vessim.GridImportEmissions + +## Standard metrics + +| Scope | Metrics | Unit | +| --- | --- | --- | +| Actor and microgrid | `energy_produced`, `energy_consumed` | Wh | +| Microgrid | `grid_import`, `grid_export` | Wh | +| Microgrid | `peak_grid_import`, `peak_grid_export` | W | + +Actor production and consumption are accumulated separately from sampled power. +Microgrid production and consumption sum the actors, excluding dispatchables and external simulator inputs. +Grid import and export integrate the net exchange of all grid connections. +Peak values are sampled maxima, not rolling billing demand. + +Energy totals cover the full [simulation duration](environment.md#time-and-final-state), including a shortened final interval. +For incomplete runs, check `execution["status"]` and the [partial-recording semantics](#partial-recordings). + +## Saved records + +An experiment uses `format="vessim.experiment"` and `schema_version=1`. +Use `ExperimentResult.load()` and `save()` to read and write validated records. +`to_dict()` provides a detached dictionary for other tools. + +`timeseries` is a relative CSV path or null. +`timeseries_path` resolves it from the original record location. +Saving a loaded record elsewhere copies the CSV beside the new JSON. +Saving in-memory observations does not persist a trajectory unless the recorder was configured with an output directory. + +Keep JSON and CSV together when moving recordings. +For embedded `vessim.study` records, CSV paths are relative to the study file. +Prefer opening a collection of experiment directories in the viewer when each run has its own CSV. + +CSV columns are fixed by the first observation. +Nested state fields use dot-separated paths, escaping literal `.` as `%2E` and `%` as `%25` within names. +Changing the state schema during a run raises an error. + +## Partial recordings + +`Recorder` accumulates metrics for an interval when its starting observation is recorded. +The interval is clipped to the requested simulation horizon. +If a run fails or is interrupted, the last accumulated interval can extend beyond the point execution reached, particularly in live mode. +Check `execution["status"]` before using such totals in a comparison. diff --git a/docs/api_reference/signal.md b/docs/api_reference/signal.md index 9b7ad670..b043047b 100644 --- a/docs/api_reference/signal.md +++ b/docs/api_reference/signal.md @@ -1,18 +1,35 @@ -# Signal +# Signals -A [`Signal`](#vessim.Signal) provides a time-varying value to actors and grid -signals. See the [Signals and Datasets](../concepts/signals.md) concept page for -usage examples. +A `Signal` provides numeric input to actors or contextual data such as electricity prices. +See [Actors and signals](../concepts/signals.md) for usage examples. -::: vessim.Signal ::: vessim.StaticSignal ::: vessim.Trace +::: vessim.Signal + +## Custom signal lifecycle + +The environment calls `start()` before a signal is first sampled and `finalize()` when the environment closes, including after a failure. +Shared signals are started and finalized once per environment. +Replaced signals remain owned until cleanup. + +A signal may be read by several components at the same simulation time. +Stateful implementations should handle repeated reads consistently. +Create fresh stateful signals for each run. +Immutable traces can be reused, but live signals must not be shared between environments. + +## Live signals -## Software-in-the-Loop signals +These signals require the `live` extra (`pip install 'vessim[live]'`). +See the [Live simulation](../concepts/live.md) page for setup and usage. -These signals require the `sil` extra (`pip install vessim[sil]`). See the -[Software-in-the-Loop](../concepts/sil.md) concept page for the full picture. +Live signals require `Environment.live()` and poll in the background. +They return zero until the first reading and retain the last value if polling fails. +There is no automatic freshness check. +Custom `_fetch_current_value()` implementations should use bounded request timeouts. -::: vessim.SilSignal +::: vessim.LiveSignal + options: + members: [at, start, finalize, _fetch_current_value] ::: vessim.PrometheusSignal ::: vessim.WatttimeSignal diff --git a/docs/assets/optuna-search-results.png b/docs/assets/optuna-search-results.png new file mode 100644 index 00000000..6778ad9c Binary files /dev/null and b/docs/assets/optuna-search-results.png differ diff --git a/docs/concepts/controllers.md b/docs/concepts/controllers.md index 2a18d5fd..0439a8c1 100644 --- a/docs/concepts/controllers.md +++ b/docs/concepts/controllers.md @@ -1,84 +1,64 @@ # Controllers -A [`Controller`](../api_reference/controller.md) is executed at every simulation step. -It can observe the state of all microgrids, log it, and modify components of the running simulation, for example by changing parameters of a [`DispatchPolicy`](../api_reference/dispatch_policy.md) on the fly. +Controllers let you observe and influence a running simulation. +Use them to record results, connect an application, or adjust how a datacenter operates. +A controller can access every microgrid in its environment. -Vessim ships with two built-in loggers ([`CsvLogger`](../api_reference/controller.md#vessim.CsvLogger) and [`MemoryLogger`](../api_reference/controller.md#vessim.MemoryLogger)), an [`Api`](../api_reference/controller.md#vessim.Api) controller for software-in-the-loop experiment, and you can write your own by subclassing `Controller`. +Register controllers with `env.add_controller()` before running the simulation. +You can combine several, for example to record results while testing a control strategy. -## Logging with CsvLogger +## Recorder -[`CsvLogger`](../api_reference/controller.md#vessim.CsvLogger) is the recommended way to record an experiment. It writes the static configuration and the full timeseries to a directory: +[`vs.Recorder`](../api_reference/recorder.md#vessim.Recorder) collects time series and calculates metrics such as grid imports and exports: ```python -environment.add_controller(vs.CsvLogger("results/my_experiment")) +recorder = env.add_controller(vs.Recorder("results/experiment")) ``` -After the run, the directory contains: +See [Results and metrics](results.md) to inspect recordings and add your own calculations. -``` -results/my_experiment/ - metadata.yaml # static experiment configuration - timeseries.csv # power flows, battery state, grid signals at every step -``` - -These two files are exactly what the [experiment viewer](../getting_started.md#7-exploring-the-results) consumes. Open them in your browser with `vessim view results/my_experiment`. - -!!! tip "MemoryLogger" - For interactive exploration in a notebook, use [`vs.MemoryLogger()`](../api_reference/controller.md#vessim.MemoryLogger) instead. It keeps the simulation state in memory and exposes `.to_df()` for direct DataFrame analysis. - -## Writing a custom controller - -Subclass [`Controller`](../api_reference/controller.md) and implement `step`. Optionally override `start` to grab references to the environment before the simulation begins: - -```python -import vessim as vs +## Api -class MyController(vs.Controller): - def start(self, environment): - # Called once before the simulation starts - self.microgrids = environment.microgrids +[`vs.Api`](../api_reference/controller.md#vessim.Api) lets an external application read simulation state and send commands through a REST API. +For example, a job scheduler can observe available solar power and adjust datacenter demand. - def step(self, now, microgrid_states): - # Called at every simulation step - ... -``` - -The `step` method receives: - -- `now` — the current simulation time as a `datetime`. -- `microgrid_states` — a `dict[str, MicrogridState]` keyed by microgrid name. Each [`MicrogridState`](../api_reference/microgrid.md#vessim.MicrogridState) exposes `p_delta`, `p_grid`, `actor_states`, `dispatch_states`, `policy_state`, and `grid_signals`. - -## Example: a scheduled charger +Use it with `vs.Environment.live()` to test the application against a simulation running in real time. +[Live simulation](live.md#connect-an-application) walks through the setup and requests. -Cheap and clean grid hours are typically at night. Let's force-charge the battery between midnight and 6 AM by mutating the `charge_power` of the dispatch policy at runtime: +## Custom controllers -```python -import vessim as vs - -class ScheduledCharger(vs.Controller): - """Force-charges storage at 500 W between 00:00 and 06:00.""" +Subclass [`vs.Controller`](../api_reference/controller.md#vessim.Controller) and implement `step()` to respond to observations. +Vessim calls it after each dispatch. +Changes to model inputs or parameters affect subsequent steps. - def __init__(self, microgrid_name: str): - self.microgrid_name = microgrid_name +### Demand response - def start(self, environment): - mg = environment.microgrids[self.microgrid_name] - assert isinstance(mg.policy, vs.DefaultDispatchPolicy) - self.policy = mg.policy - - def step(self, now, microgrid_states): - self.policy.charge_power = 500.0 if now.hour < 6 else 0.0 -``` +Consider a datacenter with a flexible connection agreement. +In this scenario, the grid operator can temporarily lower its import limit with advance notice. +The datacenter responds by pausing flexible workloads until more power is available. -Add it to the environment alongside the logger: +The controller passes the announced limit to a datacenter simulator, whose `set_power_budget()` method adjusts workload concurrency. +Its power consumption feeds a `vs.Consumer` through a [custom signal](signals.md#custom-signals). ```python -environment.add_controller(ScheduledCharger("datacenter")) -environment.add_controller(vs.CsvLogger("results/scheduled")) +class DemandResponse(vs.Controller): + def __init__(self, datacenter, grid): + self.datacenter = datacenter + self.grid = grid + + def step(self, now, states): + limit = states["site"]["grid_signals"]["next_import_limit"] + self.datacenter.set_power_budget(limit) + self.grid.max_import = limit ``` -The same pattern works for any policy parameter: switch between policies, raise/lower a battery's `min_soc`, or react to grid signals exposed via `microgrid_states[name]["grid_signals"]`. +Here, each notice applies to the next simulation step, when both the reduced workload and the new connection limit take effect. +When the limit rises again, the simulator resumes more workloads. +`set_power_budget()` belongs to your datacenter model, so you decide how it responds and which workloads can wait. -## Going further: real-time control +A controller changes how the system operates. +The [dispatch policy](dispatchables.md#dispatch-policies) then allocates resource power to serve the resulting demand. +Use a policy to choose battery or generator power, and a controller to adjust workload or coordinate several sites. -Custom controllers can also expose the simulation to the outside world. The built-in [`Api`](../api_reference/controller.md#vessim.Api) controller starts a REST server that lets external programs query the state and send control commands while the simulation runs. See [Software-in-the-Loop](sil.md) for the full picture. +The [complete example](https://github.com/dos-group/vessim/blob/main/examples/demand_response.py) connects a simple datacenter model and runs through a temporary restriction. +See the [Controller API](../api_reference/controller.md) for initialization and cleanup hooks. diff --git a/docs/concepts/coupling.md b/docs/concepts/coupling.md new file mode 100644 index 00000000..ec14cc6e --- /dev/null +++ b/docs/concepts/coupling.md @@ -0,0 +1,55 @@ +# Connect External Simulators + +Vessim can connect domain-specific simulators written in any programming language, including simulators running on other computers, through its Mosaik backend. +[Mosaik](https://mosaik.offis.de/) is a co-simulation framework that coordinates simulation time and exchanges data between models. +Each simulator implements Mosaik's interface so it can participate in a shared simulation. + +For example, a datacenter simulator can model job scheduling and server power while Vessim models its electricity supply. +You can reuse that simulator and explore how its workloads interact with batteries, renewable generation, and grid constraints. + +## Connect a datacenter model + +Install the backend: + +```console +python -m pip install 'vessim[mosaik]' +``` + +The [complete example](https://github.com/dos-group/vessim/blob/main/examples/external_simulator_coupling.py) provides a small datacenter simulator whose demand increases as jobs arrive. +Download it as `external_simulator_coupling.py`. +With that file alongside your script, the connection looks like this: + +```python +import vessim as vs + +with vs.Environment( + "2026-01-01", + step_size=60, + backend="mosaik", + sim_config={ + "Datacenter": {"python": "external_simulator_coupling:DatacenterSimulator"}, + }, +) as env: + datacenter = env.world.start("Datacenter").Datacenter() + env.add_microgrid(name="site", actors=[], dispatchables=[vs.GridConnection()]) + recorder = env.add_controller(vs.Recorder()) + + env.prepare() + env.world.connect(datacenter, env.entities["site"], "power") + env.run(180) +``` + +Here, the external simulator supplies the microgrid's demand. +`env.prepare()` makes the microgrid available to Mosaik, and `connect()` links the datacenter's power output to it. +When the environment runs, Mosaik advances both simulators and passes the demand to Vessim at each step. +Inspect the recording as usual with `recorder.to_df()` or [calculate metrics](results.md). + +## Connect your own simulator + +Wrap your model with the [Mosaik simulator API](https://mosaik.readthedocs.io/en/3.6.0/mosaik-api/), or use an existing Mosaik-compatible simulator. +The example loads a Python class locally. +Mosaik can also start a separate process or connect to a simulator already running on another computer through its [simulator configuration](https://mosaik.readthedocs.io/en/3.6.0/simmanager.html). + +You can also send Vessim observations back to another simulator, for example to let a scheduler respond to battery charge. +See Mosaik's [feedback connections](https://mosaik.readthedocs.io/en/3.6.0/scenario-definition.html#how-to-achieve-cyclic-data-flows) and the [Vessim connection reference](../api_reference/environment.md#mosaik-connections) for the interface details. +Use `vs.Environment.live(backend="mosaik", ...)` when the coupled simulation should run in real time. diff --git a/docs/concepts/dispatchables.md b/docs/concepts/dispatchables.md index ee2d3fd0..d39992f7 100644 --- a/docs/concepts/dispatchables.md +++ b/docs/concepts/dispatchables.md @@ -1,170 +1,162 @@ -# Dispatchables and Dispatch Policies +# Dispatchables and policies -A [`Dispatchable`](../api_reference/dispatchable.md) is a controllable energy resource (a battery, diesel/gas generator, or electrolyzer) whose power setpoint is decided each step by a [`DispatchPolicy`](../api_reference/dispatch_policy.md). -Unlike actors (whose power comes from a signal), a dispatchable's power is allocated by the policy from the microgrid's current power delta. +Dispatchables are resources whose power is chosen by a dispatch policy, such as batteries, generators, and grid connections. +Each microgrid can contain any combination of them. +At each step, the policy decides how to use these resources to handle the actors' combined power surplus or deficit. -Each `Dispatchable` reports a *feasible range* `(min_power, max_power)` for the upcoming timestep. Negative values represent discharging/generation; positive values represent charging/consumption. +## Dispatchables -## Battery models +A dispatchable can: -### SimpleBattery +- **Absorb surplus power**, for example a dump load or a mechanism for accounting for curtailed generation. +- **Supply missing power**, for example a diesel or gas generator. +- **Do both**, for example a battery or a grid connection. -[`SimpleBattery`](../api_reference/dispatchable.md#vessim.SimpleBattery) is an ideal, capacity-based model which is fast and easy to parameterize. It is a sensible default for simple simulations: +Each resource defines its operating constraints. +Batteries have capacity and charge-rate limits, grid connections can limit power and ramp rates, and a generator model may include startup delays or minimum output. -```python -import vessim as vs - -battery = vs.SimpleBattery( - name="battery", - capacity=5000, # Wh (total energy capacity - initial_soc=0.8, # 80 % charged at simulation start - min_soc=0.2, # never discharge below 20 % SoC - c_rate=0.5, # optional: cap charge/discharge rate at 0.5C -) -``` +### Batteries -`c_rate` is the maximum charge/discharge rate as a fraction of capacity per hour. A 1 kWh battery with `c_rate=0.5` can charge or discharge at most 500 W. If omitted, the only limit is the available energy. - -### ClcBattery - -[`ClcBattery`](../api_reference/dispatchable.md#vessim.ClcBattery) implements the C-L-C model for lithium-ion batteries ([Kazhamiaka et al., 2019](https://doi.org/10.1186/s42162-019-0070-6)). It captures realistic degradation of charge/discharge limits as a function of current and is appropriate for high-fidelity studies. The default parameterization models a pack of LGM50 21700 Li-ion cells: +[`vs.SimpleBattery`](../api_reference/dispatchable.md#vessim.SimpleBattery) stores surplus energy and supplies it later. +This creates a 1 kWh battery, initially half full, with a charge and discharge limit of 500 W: ```python -battery = vs.ClcBattery( - name="battery", - number_of_cells=100, - initial_soc=0.5, - min_soc=0.1, -) +import vessim as vs + +battery = vs.SimpleBattery("battery", capacity=1000, initial_soc=0.5, c_rate=0.5) ``` -!!! note - `ClcBattery` should not be used with large simulation step sizes. For coarse-grained simulations (15 min or more), prefer `SimpleBattery`. +The simple model assumes ideal charging and discharging. +[`vs.ClcBattery`](../api_reference/dispatchable.md#vessim.ClcBattery) adds conversion losses and current-dependent limits for more detailed battery simulations. -## Multiple dispatchables +### Grid connections -[`add_microgrid`](../api_reference/environment.md#vessim.Environment.add_microgrid) accepts a list of dispatchables. The default policy serves them in order, so the first one is used until it is exhausted before the next one is touched: +[`vs.GridConnection`](../api_reference/dispatchable.md#vessim.GridConnection) imports power to meet demand and exports surplus. +For example, limit imports to 5 kW and exports to 2 kW: ```python -environment.add_microgrid( - name="datacenter", - actors=[...], - dispatchables=[ - vs.SimpleBattery(name="primary", capacity=10000, initial_soc=0.9), - vs.SimpleBattery(name="backup", capacity=5000, initial_soc=0.5), - ], -) +grid = vs.GridConnection("grid", max_import=5000, max_export=2000) ``` -## Dispatch policies - -A [`DispatchPolicy`](../api_reference/dispatch_policy.md) receives the microgrid's power delta and allocates it across dispatchables. It returns the power that needs to be exchanged with the public grid. +Optional `ramp_up` and `ramp_down` constrain changes in power, measured in W per simulation step. +A [controller](controllers.md) can set `grid.connected = False` to simulate an outage and `True` to reconnect. +Omit the grid connection to model an islanded system. -### DefaultDispatchPolicy +### Custom dispatchables -[`DefaultDispatchPolicy`](../api_reference/dispatch_policy.md#vessim.DefaultDispatchPolicy) is used when no `policy` argument is given to `add_microgrid`. It supports three modes: +Subclass [`vs.Dispatchable`](../api_reference/dispatchable.md#vessim.Dispatchable) to model other resources, such as diesel or gas generators. +A custom resource can also account for curtailed generation as an absorption of surplus power. -Grid-connected (default): allocate the delta in priority order, exchange the remainder with the grid. +This example models a running gas turbine with minimum and maximum output and a ramp limit. +The illustrative turbine supplies between 1 and 5 MW and can change its output by up to 0.5 MW per simulation step: ```python -policy = vs.DefaultDispatchPolicy(mode="grid-connected") -``` +class GasTurbine(vs.Dispatchable): + def __init__(self, name, min_output, max_output, ramp_limit): + super().__init__(name) + self.min_output = min_output + self.max_output = max_output + self.ramp_limit = ramp_limit + self.output = min_output + self.current_power = -min_output + + def feasible_range(self, duration): + lower = max(self.min_output, self.output - self.ramp_limit) + upper = min(self.max_output, self.output + self.ramp_limit) + return -upper, -lower # Generation uses negative dispatchable power. + + def step(self, duration): + self.output = -self.current_power + + def config(self): + return { + "min_output": self.min_output, + "max_output": self.max_output, + "ramp_limit": self.ramp_limit, + } + + def state(self): + return {"current_power": self.current_power} -Islanded: fully disconnect the microgrid from the grid. If dispatchables cannot cover a deficit, a `RuntimeError` is raised. Use this to verify a self-sufficient design. -```python -policy = vs.DefaultDispatchPolicy(mode="islanded") +turbine = GasTurbine("gas_turbine", min_output=1e6, max_output=5e6, ramp_limit=0.5e6) +dispatchables = [turbine, vs.GridConnection()] ``` -Fixed charge power: force storage to charge or discharge at a constant rate, balancing the difference with the grid. Useful for scheduled charging strategies; only works in grid-connected mode. +`feasible_range()` combines the output and ramp constraints. +`step()` remembers the dispatched output for the next step. +`config()` and `state()` expose model parameters and observations for recording. -```python -policy = vs.DefaultDispatchPolicy(charge_power=200.0) # charge at 200 W -policy = vs.DefaultDispatchPolicy(charge_power=-500.0) # discharge at 500 W -``` +The turbine starts at its minimum output and stays running. +If demand falls below what it can supply, the grid exports the excess. +Startup, shutdown, and fuel consumption would require extending the model. + +## Dispatch policies -## Custom dispatchables +A policy receives the actors' net power, called `p_delta`, and the microgrid's set of dispatchables. +A positive delta is surplus generation, and a negative delta is unmet demand. +The policy assigns power to the resources within their operating constraints. -To model hardware that does not fit a battery model, subclass [`Dispatchable`](../api_reference/dispatchable.md) and implement four methods: +**After dispatch, the remaining power imbalance must be zero.** +If a surplus or deficit remains, the dispatch is infeasible. +Vessim raises an error and records the run as failed. -| Method | Description | -|---|---| -| `feasible_range(duration)` | Return `(min_power, max_power)` achievable for the timestep. | -| `step(duration)` | Advance internal state after `current_power` has been set. | -| `config()` | Return static parameters (logged once to `metadata.yaml`). | -| `state()` | Return dynamic state (logged each step to `timeseries.csv`). | +### Default policy -A diesel generator is stateless and only generates (negative power): +[`vs.DefaultDispatchPolicy`](../api_reference/dispatch_policy.md#vessim.DefaultDispatchPolicy) uses resources in list order while respecting their constraints. +For example: ```python -class DieselGenerator(vs.Dispatchable): - def __init__(self, name: str, max_power_w: float): - super().__init__(name) - self.max_power_w = max_power_w +dispatchables = [battery, grid] +``` - def feasible_range(self, duration: int) -> tuple[float, float]: - return (-self.max_power_w, 0.0) # negative = generation +This uses the battery first and exchanges the remaining power with the grid. +Reversing the order prioritizes grid exchange. +Add a generator or curtailment resource to the list when the system needs those options. - def step(self, duration: int) -> None: - pass # no internal state +The default policy is used automatically. +To request a fixed battery charging power, pass `policy=vs.DefaultDispatchPolicy(charge_power=200)` to `env.add_microgrid()`. +The policy balances this target using available generation and the other resources. - def config(self) -> dict: - return {"max_power_w": self.max_power_w} +### Custom policies - def state(self) -> dict: - return {"current_power": self.current_power} -``` +Choosing when to charge, generate, import, or export is often the main control problem in an energy system. +Policies can range from a simple priority order to forecast-based optimization or reinforcement learning. +Vessim lets you test your own strategies across demand, weather, and price scenarios through [simulation studies](studies.md), including probabilistic inputs. -Combined with a battery, the default policy will exhaust the battery first and only call on the generator for the remaining deficit: +For the gas turbine and grid connection above, prioritize the cheaper source. +This policy compares the current electricity price with an assumed turbine cost per kWh, then lets the default policy allocate power in that order: ```python -environment.add_microgrid( - name="datacenter", - actors=[...], - dispatchables=[ - vs.SimpleBattery(name="battery", capacity=10000, initial_soc=0.9), - DieselGenerator(name="generator", max_power_w=5000), - ], -) +class CostBasedPolicy(vs.DefaultDispatchPolicy): + def __init__(self, turbine_cost): + super().__init__() + self.turbine_cost = turbine_cost + + def apply(self, p_delta, duration, dispatchables, grid_signals=None): + grid_price = grid_signals["price"] + ordered = sorted( + dispatchables, + key=lambda resource: ( + grid_price if isinstance(resource, vs.GridConnection) + else self.turbine_cost + ), + ) + super().apply(p_delta, duration, ordered, grid_signals) + + def state(self): + return {"turbine_cost": self.turbine_cost} ``` -## Custom dispatch policies +Pass `policy=CostBasedPolicy(turbine_cost=0.15)` and `grid_signals={"price": 0.10}` to `env.add_microgrid()`, using EUR/kWh for both. +The grid is preferred at this price. +If the price rises above 0.15, the turbine is preferred. +Use a price trace to vary it during the run. -For advanced energy management, subclass [`DispatchPolicy`](../api_reference/dispatch_policy.md) and implement `apply`. The microgrid's grid signals are passed in directly, so the policy can be carbon- or price-aware without extra wiring: +The turbine's minimum output and ramp limits still apply, so the grid supplies any remaining demand or receives excess generation. +This policy covers a turbine and a grid connection. +To add other resource types, define their costs or priority in the ordering rule. -```python -class GreenChargePolicy(vs.DispatchPolicy): - """Only charge batteries when grid carbon intensity is low.""" +For a different allocation algorithm, implement [`vs.DispatchPolicy.apply()`](../api_reference/dispatch_policy.md#vessim.DispatchPolicy.apply) and assign resource power through `set_power()`, ensuring the final balance is zero. - def __init__(self, carbon_threshold: float): - self.carbon_threshold = carbon_threshold - - def apply(self, p_delta, duration, dispatchables, grid_signals=None): - remaining = p_delta - carbon = (grid_signals or {}).get("carbon_intensity") - - for d in dispatchables: - lo, hi = d.feasible_range(duration) - if remaining > 0: - # Excess: only charge if grid is clean - if carbon is not None and carbon > self.carbon_threshold: - d.set_power(0.0, duration) - continue - allocated = min(hi, remaining) - else: - # Deficit: always discharge - allocated = max(lo, remaining) - d.set_power(allocated, duration) - remaining -= allocated - - return remaining # remainder goes to the grid - - -environment.add_microgrid( - name="datacenter", - actors=[...], - dispatchables=[vs.SimpleBattery(name="battery", capacity=5000, initial_soc=0.8)], - policy=GreenChargePolicy(carbon_threshold=200), - grid_signals={"carbon_intensity": vs.Trace.from_csv("datasets/watttime_example.csv")}, -) -``` +The [strategy comparison example](https://github.com/dos-group/vessim/blob/main/examples/strategy_comparison.py) compares self-consumption, peak-shaving, and carbon-aware policies. diff --git a/docs/concepts/live.md b/docs/concepts/live.md new file mode 100644 index 00000000..a6bd54fe --- /dev/null +++ b/docs/concepts/live.md @@ -0,0 +1,95 @@ +# Live simulation + +Test an application against a simulated energy system before connecting it to real equipment. +A job scheduler, for example, can observe solar generation and battery charge, start more work when power is available, and scale back when it is scarce. + +`vs.Environment.live()` runs alongside real time. +Your application reads state and sends commands through the REST API while Vessim simulates the response of the energy system. + +## Connect an application + +Install the live integration: + +```console +python -m pip install 'vessim[live]' +``` + +Start with a datacenter drawing 700 W and solar producing 1000 W. +The surplus charges its battery. +Save this as `live_example.py` and run `python live_example.py`: + +```python +import vessim as vs + + +def main(): + with vs.Environment.live(step_size=1) as env: + env.add_microgrid( + name="site", + actors=[ + vs.Consumer("datacenter", signal=700), + vs.Producer("solar", signal=1000), + ], + dispatchables=[ + vs.SimpleBattery("battery", capacity=1000, initial_soc=0.5), + vs.GridConnection(), + ], + ) + env.add_controller(vs.Api(broker_port=8700)) + env.run() + + +if __name__ == "__main__": + main() +``` + +In another terminal, read the latest observation: + +```console +curl http://localhost:8700/microgrids/site +``` + +The response includes actor power, grid exchange, and battery state of charge. +Now represent the scheduler starting more work by raising datacenter demand to 1200 W: + +```console +curl -X PUT 'http://localhost:8700/microgrids/site/actors/datacenter/signal?value=1200' +``` + +Demand now exceeds solar generation, so the battery starts discharging. +Read the state again to observe the change. +Your application can make the same requests as part of its control loop. +The running API lists available requests at `http://localhost:8700/docs`. + +Use Ctrl+C to end the simulation. +See [`vs.Api`](../api_reference/controller.md#vessim.Api) for configuration options. + +## Use live measurements + +You can also combine real equipment with simulated energy infrastructure. +For example, feed measured server demand into Vessim using [`vs.PrometheusSignal`](../api_reference/signal.md#vessim.PrometheusSignal): + +```python +power = vs.PrometheusSignal( + url="http://localhost:9090", + query="server_power_watts", +) +datacenter = vs.Consumer("datacenter", signal=power) +``` + +Use this consumer in your live environment and replace the query with a power metric from your monitoring setup. +For other data sources, implement a [custom live signal](signals.md#live-signals). + +The [server-monitoring example](https://github.com/dos-group/vessim/blob/main/examples/live_server_monitoring.py) includes a Prometheus setup and optional Grafana dashboards. +Use `Api(export_prometheus=True)` to expose simulation measurements for monitoring. + +## Build a digital twin + +Live measurements can keep a digital twin aligned with an operating energy system. +Your application can update demand, battery charge, and other model parameters as conditions change. + +To explore possible futures, create separate [simulation studies](studies.md) initialized from the observed state. +For example, start a study from the current battery charge and compare how long it could support demand during a grid outage. +Your application supplies the observed state and forecast inputs to those simulations. + +See [controllers](controllers.md) for model updates and [results and metrics](results.md) for recording live experiments. diff --git a/docs/concepts/optimization.md b/docs/concepts/optimization.md new file mode 100644 index 00000000..38c63bbe --- /dev/null +++ b/docs/concepts/optimization.md @@ -0,0 +1,79 @@ +# Optimization + +Simulation studies can span more configurations than you can reasonably test. +Often, you want to find designs that are cost-effective, low-carbon, or resilient, and understand the trade-offs between those goals. + +Vessim lets you evaluate candidate designs with your own demand profiles, energy infrastructure, and control strategies. +Connect those simulations to an optimizer such as [Optuna](https://optuna.org/) to search for promising designs using the [metrics](results.md) that matter to your study. + +## Size a battery + +Suppose your datacenter has solar generation and you are choosing a battery. +More storage can reduce grid imports by saving surplus solar power for later, but it also costs more. +Rather than choosing a single weighting between these goals, we can search for designs that offer different trade-offs. + +This example uses a synthetic solar profile and searches battery capacities between 0.5 and 5 kWh. +It minimizes both battery purchase cost and grid imports over the six-hour scenario. +Install Optuna with `pip install optuna`. + +```python +import optuna +import pandas as pd +import vessim as vs + +solar = vs.Trace(pd.Series( + [0, 1200, 2400, 1200, 0, 0, 0], + index=[hour * 3600 for hour in range(7)], +)) + + +def objective(trial): + capacity = trial.suggest_float("battery_capacity_wh", 500, 5000) + with vs.Environment("2026-01-01", step_size=1800) as env: + env.add_microgrid( + name="site", + actors=[vs.Consumer("load", 700), vs.Producer("solar", solar)], + dispatchables=[vs.SimpleBattery("battery", capacity), vs.GridConnection()], + ) + recorder = env.add_controller(vs.Recorder(timeseries=False)) + env.run(6 * 3600) + + battery_cost = capacity / 1000 * 300 # Illustrative purchase cost in EUR/kWh + grid_import = recorder.metrics["microgrids"]["site"]["grid_import"] + return battery_cost, grid_import + + +study = optuna.create_study( + directions=["minimize", "minimize"], + sampler=optuna.samplers.NSGAIISampler(seed=42), +) +study.optimize(objective, n_trials=20) + +for trial in study.best_trials: + print(trial.params, trial.values) +``` + +Optuna proposes a capacity, Vessim simulates it, and the objective function returns its cost and grid imports. +`study.best_trials` contains the Pareto front among the evaluated designs. +None of these designs improves one objective without worsening the other. +You can choose a candidate based on your budget or import target. + +## Explore your design choices + +The same approach can search renewable capacity, generator sizing, or dispatch policy parameters. +Use cost and emissions metrics as objectives, or assess resilience by simulating outages. +To account for uncertain demand or weather, evaluate each candidate across several scenarios and compare its average or worst-case performance. + +The [complete infrastructure example](https://github.com/dos-group/vessim/blob/main/examples/infrastructure_study.py) searches wind, solar, and battery sizes over a full year and records each candidate's results. +Its `--financial` mode compares annual cost with grid-import emissions using illustrative inputs. +After following the [example setup instructions](https://github.com/dos-group/vessim/blob/main/examples/README.md), run: + +```console +python examples/infrastructure_study.py --financial --trials 100 --output results/infrastructure-search +python -m vessim view results/infrastructure-search +``` + +Explore the [annual optimization results](../viewer/index.html?study=optuna-search#results) from 100 equipment configurations in the viewer. + +Use [Optuna Dashboard](https://optuna-dashboard.readthedocs.io/en/stable/) to explore the search and its trade-offs, then inspect saved runs in the [Vessim viewer](../vessim_viewer.md) to understand why a design performs well. +See Optuna's [multi-objective tutorial](https://optuna.readthedocs.io/en/stable/tutorial/20_recipes/002_multi_objective.html) for more on configuring the search. diff --git a/docs/concepts/results.md b/docs/concepts/results.md new file mode 100644 index 00000000..71d78f42 --- /dev/null +++ b/docs/concepts/results.md @@ -0,0 +1,107 @@ +# Results and metrics + +How much electricity did your datacenter import, and when did the battery help? +Record a simulation to inspect its time series and calculate metrics you can compare across runs. + +## Recorder + +Add a [`vs.Recorder`](../api_reference/recorder.md#vessim.Recorder) to your environment before running it: + +```python +recorder = env.add_controller(vs.Recorder("results/experiment")) +env.run(3600) +``` + +This saves the run's configuration and metrics in `experiment.json`, alongside its time series in `timeseries.csv`. +Give each run its own output directory. +Omit the directory to keep results in memory instead. + +After the run, use pandas to explore how your system behaved: + +```python +frame = recorder.to_df() +site = frame.xs("site", level="microgrid") +print(site.head()) +``` + +The columns include actor power, battery state of charge, and grid exchange. +You can plot these in a notebook or use the CSV in your own analysis tools. +To explore saved runs in the viewer: + +```sh +python -m vessim view results/experiment +``` + +Try the [example results](../viewer/index.html?study=getting-started#results) and [time-series view](../viewer/index.html?study=getting-started#timeseries), or see [Vessim Viewer](../vessim_viewer.md) to open and share your own recordings. + +## ExperimentResult + +[`vs.ExperimentResult`](../api_reference/recorder.md#vessim.ExperimentResult) keeps a run's configuration, parameters, and metrics together. +Access it through `recorder.result`, or load a saved run to continue your analysis: + +```python +result = vs.ExperimentResult.load("results/experiment") +print(result.metrics["microgrids"]["site"]["grid_import"]) +``` + +You can also attach information calculated outside the simulation, such as an equipment cost, and save it with the result: + +```python +result.add_metric("capex", 12000, "EUR") +result.save("results/experiment/experiment.json") +``` + +## Metrics + +The recorder calculates energy consumption, generation, grid imports and exports in Wh, plus peak grid import and export in W. +Read them from `recorder.metrics` or a saved result as shown above. +See the [metric definitions](../api_reference/recorder.md#standard-metrics) for the full list. + +For larger studies, `Recorder(timeseries=False)` calculates metrics without storing time series. +[Simulation studies](studies.md) shows how to compare configurations across many runs. + +### Costs and emissions + +To evaluate electricity costs or emissions, supply the corresponding price and carbon-intensity signals and add metric calculators to your recorder: + +```python +env.add_microgrid( + name="site", + actors=[vs.Consumer("load", signal=700)], + dispatchables=[vs.GridConnection()], + grid_signals={ + "price": 0.30, # EUR/kWh + "carbon": 0.25, # kgCO2e/kWh + }, +) +recorder = env.add_controller(vs.Recorder(custom_metrics={ + "cost": vs.ElectricityImportCost("site", "price", currency="EUR"), + "emissions": vs.GridImportEmissions("site", "carbon"), +})) +``` + +These calculations combine imported electricity with your signals, which can also be [traces](signals.md#trace-signals) that vary over time. +After the run, read `cost` and `emissions` alongside the standard metrics for `site`. +[`vs.ElectricityExportRevenue`](../api_reference/recorder.md#vessim.ElectricityExportRevenue) can account for electricity sales using a separate export-price signal. + +### Custom metrics + +Subclass [`vs.Metric`](../api_reference/recorder.md#vessim.Metric) to calculate something specific to your study. +For example, count the hours when grid import exceeds 1 kW: + +```python +class HighImportHours(vs.Metric): + def __init__(self): + super().__init__("h", microgrid="site") + + def step(self, now, states, duration): + imported_power = -states["site"]["p_grid"] + if imported_power > 1000: + self.value += duration / 3600 +``` + +Vessim calls `step()` for each recorded interval, with its duration in seconds. +Register the calculation as `custom_metrics={"high_import_hours": HighImportHours()}` on your recorder. +Metrics can describe the whole run, a microgrid, or an individual actor. + +The [custom-metrics example](https://github.com/dos-group/vessim/blob/main/examples/custom_metrics.py) combines electricity costs, emissions, and equipment costs into study objectives. diff --git a/docs/concepts/signals.md b/docs/concepts/signals.md index d7df6196..4ee5bb48 100644 --- a/docs/concepts/signals.md +++ b/docs/concepts/signals.md @@ -1,101 +1,92 @@ -# Signals and Datasets +# Actors and signals -A [`Signal`](../api_reference/signal.md) provides a time-varying value to the simulation: the power consumed by a server, the power produced by a solar panel, the carbon intensity of the public grid. Every [`Actor`](../api_reference/actor.md) and every entry in a microgrid's `grid_signals` is backed by a `Signal`. +[`vs.Consumer`](../api_reference/actor.md#vessim.Consumer) represents electrical loads, such as computing equipment or cooling. +[`vs.Producer`](../api_reference/actor.md#vessim.Producer) represents generation, such as solar panels or wind turbines. +Both are subclasses of [`vs.Actor`](../api_reference/actor.md#vessim.Actor), which also supports prosumers that consume and produce power. -Vessim ships three signal types: [`StaticSignal`](../api_reference/signal.md#vessim.StaticSignal) for constant values, [`Trace`](../api_reference/signal.md#vessim.Trace) for replaying historical data, and [`SilSignal`](../api_reference/signal.md#vessim.SilSignal) for live data sources. -You can easily write your own, only take care that `signal.at(elapsed)` returns the value at the elapsed time the simulation has accumulated since `sim_start`. +Add these components to your microgrid's `actors` list. +Their inputs can be static, follow a recorded trace, or come from live measurements. -## StaticSignal +## Static signals -[`StaticSignal`](../api_reference/signal.md#vessim.StaticSignal) always returns the same value. It is useful for baseline loads, fixed production, or as a placeholder during testing: +Actors with static inputs are the simplest form of actor. +Pass a number to model fixed demand or generation: ```python import vessim as vs -signal = vs.StaticSignal(value=42) +load = vs.Consumer("load", signal=700) +solar = vs.Producer("solar", signal=1000) ``` -You can update a `StaticSignal` at runtime via `set_value()`. This allows a [`Controller`](../api_reference/controller.md) to toggle loads or follow a schedule. +A [controller](controllers.md#demand-response) can change demand with `load.signal = 500`, which takes effect at the next actor update. -## Trace +## Trace signals -[`Trace`](../api_reference/signal.md#vessim.Trace) replays a time series row-by-row. Its index is an *offset* in seconds since the trace's start (row 0 at `0`); as the simulation advances, `Trace` returns the value at the matching offset. - -### From in-memory data - -`Trace` accepts a pandas `Series` or `DataFrame` with either a numeric index (interpreted as seconds) or a `TimedeltaIndex`: +Use [`vs.Trace`](../api_reference/signal.md#vessim.Trace) to describe demand or generation over time. +Create a trace from a pandas Series or DataFrame: ```python import pandas as pd -import vessim as vs - -trace = vs.Trace(pd.Series([50, 200, 50], index=[0, 3600, 7200])) -trace = vs.Trace(pd.Series( - [50, 200, 50], - index=pd.to_timedelta(["0s", "1h", "2h"]), -)) +profile = pd.Series([0, 1000, 0], index=[0, 3600, 7200]) +solar = vs.Producer("solar", signal=vs.Trace(profile)) ``` -Sampling does not need to be uniform. The index must start at 0. + -### Loading from a CSV +You can also load a trace from a CSV file. +For example, save this hourly profile as `input.csv`, with elapsed seconds in the first column: -[`Trace.from_csv`](../api_reference/signal.md#vessim.Trace.from_csv) reads a CSV whose first column is the offset in seconds and remaining columns hold values: - -```text -time,trace_a,trace_b -0,0.10,0.35 -3600,0.22,0.47 -7200,0.38,0.41 +```csv +seconds,load,solar +0,700,0 +3600,700,1200 +7200,700,2400 +10800,700,1200 +14400,700,0 +18000,700,0 +21600,700,0 ``` +Select the column for each actor: + ```python -load = vs.Trace.from_csv( - "datasets/load_example.csv", - column="trace_a", # required if the CSV has multiple value columns -) +inputs = vs.Trace.from_csv("input.csv") +load = vs.Consumer("load", signal=inputs.select("load")) +solar = vs.Producer("solar", signal=inputs.select("solar")) ``` -`scale=` multiplies all values (handy for normalized data), and missing cells (per column) are dropped. - -### Datetime data and `anchor` +The trace holds each value until the next timestamp. +Use a profile covering your simulation period. +See the [Trace reference](../api_reference/signal.md#vessim.Trace) for calendar timestamps, scaling, and data requirements. -Calendar-stamped data is also supported. Pass an `anchor` to mark which row should sit at offset=0; earlier rows are dropped. This works both for in-memory data and for CSVs with a datetime first column: +The [CSV example](https://github.com/dos-group/vessim/blob/main/examples/replay_csv.py) runs this profile: -```text -datetime,Berlin,Stockholm,Mumbai -2022-06-08 00:05:00,0.0,0.0,0.0 -2022-06-08 00:10:00,0.0,0.0,0.0 -2022-06-08 00:15:00,0.0,0.0,0.0 +```console +python examples/replay_csv.py input.csv --hours 6 ``` -```python -solar = vs.Trace.from_csv( - "datasets/solcast.csv", - anchor="2022-06-09 00:00:00", - column="Berlin", - scale=5000, -) -``` - -## Public data sources +## Live signals -Vessim's CSV schema is intentionally minimal so you can produce it from any source. Common providers for simulation data include: +Live signals read measurements from a running system. +For example, [`vs.PrometheusSignal`](../api_reference/signal.md#vessim.PrometheusSignal) can supply measured server power from Prometheus. +See [live measurements](live.md#use-live-measurements) for setup and an example. -- Carbon intensity: [Electricity Maps](https://www.electricitymaps.com/) or [WattTime](https://watttime.org/). -- Weather and solar: [Open-Meteo](https://open-meteo.com/) (free historical archive), [Solcast](https://solcast.com/) (commercial irradiance and PV forecasts), or [NREL NSRDB](https://nsrdb.nrel.gov/) (high-resolution US data). For an example for a public live station feed, see [HTW Berlin](https://wetter.htw-berlin.de/). -- Electricity prices: [ENTSO-E Transparency Platform](https://transparency.entsoe.eu/) (European day-ahead and balancing markets), [EIA Open Data](https://www.eia.gov/opendata/) (US wholesale and retail), [Nord Pool](https://www.nordpoolgroup.com/) (Nordic and Baltic spot markets), or [Electricity Maps](https://www.electricitymaps.com/) (price data alongside carbon intensity). +## Custom signals -For real-time data instead of historical traces, see the [Software-in-the-Loop signals](#software-in-the-loop-signals) below. +To calculate an input yourself, implement [`vs.Signal.at()`](../api_reference/signal.md#vessim.Signal.at) as a function of elapsed simulation time in seconds: -## Software-in-the-Loop signals - -For real-time simulations, [Software-in-the-Loop (SiL)](sil.md) signals poll data from external sources in the background: +```python +class DemandProfile(vs.Signal): + def at(self, elapsed): + return 700 if elapsed < 3600 else 1200 -- [`PrometheusSignal`](../api_reference/signal.md#vessim.PrometheusSignal): pull live metrics (e.g. server power) from a Prometheus server. -- [`WatttimeSignal`](../api_reference/signal.md#vessim.WatttimeSignal): fetch live grid carbon intensity from the WattTime API. +load = vs.Consumer("load", signal=DemandProfile()) +``` -SiL signals are time-independent: they ignore the `elapsed` argument and always return their most recent cached value. Use them with [`Environment.live`](../api_reference/environment.md#vessim.Environment.live) rather than `Environment(...)`. See [Software-in-the-Loop](sil.md). +Signals can also supply electricity prices or carbon intensity to policies, controllers, and metrics. +Add them through the microgrid's `grid_signals`, for example `grid_signals={"price": price_trace}`. +See [costs and emissions](results.md#costs-and-emissions) for a complete use case. -You can subclass [`SilSignal`](../api_reference/signal.md#vessim.SilSignal) to wrap any other live data source. +For a component with its own behavior, such as a datacenter with a job queue, implement a [custom actor](../api_reference/actor.md#vessim.Actor.sample). diff --git a/docs/concepts/sil.md b/docs/concepts/sil.md deleted file mode 100644 index 56dc4bc6..00000000 --- a/docs/concepts/sil.md +++ /dev/null @@ -1,180 +0,0 @@ -# Software-in-the-Loop - -Software-in-the-Loop (SiL) simulation lets Vessim interact with real systems while the simulation is running. Use it to: - -- Test real applications — validate energy-aware schedulers, autoscalers, or routing logic against simulated energy scenarios. -- Use real data sources — pull live metrics from sensors, monitoring systems, or APIs into the simulation. - -SiL extras are not installed by default. Enable them with: - -```console -pip install vessim[sil] -``` - -## Architecture - -Vessim's SiL has two communication directions: - -1. Real-time data → Vessim: [`SilSignal`](../api_reference/signal.md#vessim.SilSignal) (and subclasses like [`PrometheusSignal`](../api_reference/signal.md#vessim.PrometheusSignal) or [`WatttimeSignal`](../api_reference/signal.md#vessim.WatttimeSignal)) fetches data from external sources in a background thread and feeds it into the simulation. -2. Vessim → external world: the [`Api`](../api_reference/controller.md#vessim.Api) controller exposes the simulation state via a REST API. External programs can read it and send back control commands. Feel free to implement other types of APIs. - -## Real-time signals - -A [`SilSignal`](../api_reference/signal.md#vessim.SilSignal) polls its data source on a background thread, so the simulation never blocks on a network request. - -### PrometheusSignal - -[`PrometheusSignal`](../api_reference/signal.md#vessim.PrometheusSignal) pulls a value from a [Prometheus](https://prometheus.io/) query. This is typical for using real server metrics as the load of a simulated actor: - -```python -import vessim as vs - -power_signal = vs.PrometheusSignal( - url="http://localhost:9090", - query='(1 - avg(rate(node_cpu_seconds_total{mode="idle"}[1m]))) * 1000', -) - -server = vs.Actor(name="server", signal=power_signal, consumer=True) -``` - -### WatttimeSignal - -[`WatttimeSignal`](../api_reference/signal.md#vessim.WatttimeSignal) fetches live grid carbon intensity from the [WattTime API](https://watttime.org/): - -```python -carbon = vs.WatttimeSignal( - username="my_user", - password="my_password", - region="CAISO_NORTH", - update_interval=300, -) -``` - -### Custom SiL signals - -Subclass [`SilSignal`](../api_reference/signal.md#vessim.SilSignal) and implement `_fetch_current_value`: - -```python -class MySensorSignal(vs.SilSignal): - def _fetch_current_value(self) -> float: - # response = requests.get("http://192.168.1.50/power") - # return float(response.text) - return 42.0 -``` - -## The Api controller - -[`Api`](../api_reference/controller.md#vessim.Api) starts a small FastAPI server that bridges the simulation and the outside world: - -```python -environment.add_controller(vs.Api(export_prometheus=True, broker_port=8700)) -``` - -Once the simulation is running, the following endpoints are available at `http://localhost:8700`: - -| Method & path | Purpose | -|---|---| -| `GET /` | List of microgrids and their actors. | -| `GET /` | Current state of a microgrid (power flows, battery SoC, …). | -| `PUT /` | Send a control command (e.g. mutate a dispatchable property). | -| `GET /metrics` | Prometheus-format metrics (when `export_prometheus=True`). | - -Read the state: - -```bash -curl http://localhost:8700/datacenter -``` - -```json -{ - "microgrid": "datacenter", - "time": "2023-01-01T00:00:00", - "p_delta": -500.0, - "p_grid": -500.0, - "dispatch": { "battery": { "soc": 0.8 } }, - "actors": { "server": { "power": -500.0 } } -} -``` - -Send a control command: - -```bash -curl -X PUT http://localhost:8700/datacenter \ - -H "Content-Type: application/json" \ - -d '{"dispatchable": {"name": "battery", "property": "min_soc", "value": 0.5}}' -``` - -When `export_prometheus=True`, the `/metrics` endpoint can be scraped by Prometheus and visualized in Grafana. See [`examples/sil/`](https://github.com/dos-group/vessim/tree/main/examples/sil) for a ready-made compose file. - -## Walkthrough: real CPU load to simulated microgrid - -This example wires up a real Prometheus + node_exporter stack so that the actual CPU usage of the host drives the simulated server's power draw. The full source is at [`examples/sil_example.py`](https://github.com/dos-group/vessim/blob/main/examples/sil_example.py). - -```python -import vessim as vs - - -def main(): - # Live mode: sim_start is captured when run() is called, simulation - # advances at 1× wall-clock, and Trace data replays starting from "now". - environment = vs.Environment.live(step_size=1) - - # Server load is driven by real host CPU usage scraped via Prometheus - server = vs.Actor( - name="server", - consumer=True, - signal=vs.PrometheusSignal( - url="http://localhost:9090", - query='(1 - avg(rate(node_cpu_seconds_total{mode="idle"}[1m]))) * 1000', - ), - ) - - solar = vs.Actor( - name="solar", - signal=vs.Trace.from_csv( - "datasets/solar_example.csv", - column="Berlin", - scale=2000, - ), - ) - - battery = vs.SimpleBattery(name="battery", capacity=20, initial_soc=0.5) - - environment.add_microgrid( - name="your_computer", - actors=[server, solar], - dispatchables=[battery], - ) - - environment.add_controller(vs.Api(export_prometheus=True)) - environment.run() - - -if __name__ == "__main__": - main() -``` - -### Running it - -1. Install the SiL extras: - ```console - pip install 'vessim[sil]' - ``` -2. Start Prometheus + node_exporter: - ```console - docker compose -f examples/sil/docker-compose.yml up -d - ``` - Wait ~15 s for the first scrapes to land. -3. Run the simulation: - ```console - python examples/sil_example.py - ``` -4. Hit the API in another terminal: - ```console - curl http://localhost:8700/your_computer - ``` - Drive your CPU up (e.g. `yes > /dev/null`) and watch the server power follow. -5. Optionally start Grafana to visualize the live `/metrics` endpoint: - ```console - docker compose -f examples/sil/docker-compose.grafana.yml up -d - ``` diff --git a/docs/concepts/studies.md b/docs/concepts/studies.md new file mode 100644 index 00000000..f140c038 --- /dev/null +++ b/docs/concepts/studies.md @@ -0,0 +1,60 @@ +# Simulation studies + +Define scenarios in Python and compare their [recorded metrics](results.md) and time series in the [viewer](../vessim_viewer.md). +These three examples use synthetic inputs. Follow the [setup instructions](https://github.com/dos-group/vessim/blob/main/examples/README.md) to run them locally. + +## Exploring microgrid configurations + +Adding wind, solar, and battery storage can reduce a datacenter's grid imports, but manufacturing the equipment also causes emissions. +We compare eight setups over a full year to see how equipment choices affect grid dependence and emissions. + +The setups include grid-only supply, solar, wind, a wind–solar mix, and four combinations with storage, all facing the same demand and weather. +Compare their energy and emissions metrics, then overlay time series to see when batteries charge and grid supply is needed. + +[Explore the results](../viewer/index.html?study=infrastructure-year#results) or run the [infrastructure example](https://github.com/dos-group/vessim/blob/main/examples/infrastructure_study.py): + +```console +python examples/infrastructure_study.py +python -m vessim view results/infrastructure-study +``` + +## Optimization with Optuna + +Design spaces can be very large, while we often only need configurations that perform well against specific metrics. +Optuna uses results from previous simulations to guide the search toward promising designs without evaluating every possible combination. + +Here, we optimize wind, solar, and battery sizing to minimize annual cost and grid-import emissions. +Optuna selects 100 candidates, Vessim simulates each for a year, and the viewer shows their cost–emissions trade-offs and time series. + +
+ + ![Scatter plot of annual cost and export revenue for configurations evaluated during the Optuna search](../assets/optuna-search-results.png) + +
Annual cost and export revenue for the evaluated configurations, shown in the Vessim viewer.
+
+ +[Explore the results](../viewer/index.html?study=optuna-search#results) or run: + +```console +python examples/infrastructure_study.py --financial --trials 100 --output results/infrastructure-search +python -m vessim view results/infrastructure-search +``` + +See [Optimization](optimization.md) for defining an objective function and using Optuna Dashboard to inspect the search. + +## Probabilistic studies + +Demand, weather, and electricity prices are uncertain. +Monte Carlo sampling lets us evaluate configurations across possible realizations of these inputs and examine how their outcomes vary. + +This example samples demand and compares three battery strategies: self-consumption, peak shaving, and carbon-aware charging. +Each faces the same five daily demand samples. Compare individual runs or aggregate them to see the variation in costs, emissions, and peak grid imports. + +[Explore the results](../viewer/index.html?study=dispatch-comparison#results) or run the [dispatch example](https://github.com/dos-group/vessim/blob/main/examples/strategy_comparison.py): + +```console +python examples/strategy_comparison.py +python -m vessim view results/strategy-comparison +``` + +We are working on better functionality for planning, executing, and visualizing probabilistic studies. diff --git a/docs/getting_started.md b/docs/getting_started.md index 1008b861..29c9ed8d 100644 --- a/docs/getting_started.md +++ b/docs/getting_started.md @@ -1,162 +1,127 @@ -# Getting Started +# Getting started -This walkthrough builds a complete Vessim simulation step by step. +## Install -The final code matches [`examples/basic_example.py`](https://github.com/dos-group/vessim/blob/main/examples/basic_example.py) in the repository, so you can follow along by running that file directly. +Install Vessim with Python 3.9 to 3.15: -## 1. The Environment - -The [`Environment`](api_reference/environment.md) manages simulation time and the synchronization between all components. We start by creating one with a step size of 5 minutes (300 seconds): - -```python -import vessim as vs - -environment = vs.Environment(sim_start="2026-05-03", step_size=300) -``` - -Vessim has two modes: - -- *simulated* (used here) uses only simulated actors and runs as fast as possible (see [Discrete-event simulation](https://en.wikipedia.org/wiki/Discrete-event_simulation) on Wikipedia). The `sim_start` parameter labels the output (e.g. CSV timestamps). -- *live* (via [`Environment.live`](api_reference/environment.md#vessim.Environment.live)) advances at wall-clock speed and can connect to real systems, see [Software-in-the-Loop](concepts/sil.md). Here, `sim_start` is optional and defaults to `datetime.now()`. - -## 2. Actors - -Actors are the energy consumers and producers in your microgrid. -Each actor wraps a [`Signal`](concepts/signals.md) that provides its power value at any point in time. - -We start with a server that constantly draws 700 W. -By Vessim convention, production yields positives and consumption negative values. -Passing `consumer=True` flips the sign of the singal: - -```python -server = vs.Actor(name="server", signal=vs.StaticSignal(value=700), consumer=True) +```console +python -m pip install vessim ``` -Next, we add a solar panel. We point it at a CSV from the repository's `datasets/` directory, pick the Berlin column, and scale the normalized data to a 5 kW peak: +Optional integrations: -```python -solar = vs.Actor( - name="solar_panel", - signal=vs.Trace.from_csv( - "datasets/solar_example.csv", - column="Berlin", - scale=5000, - ), -) -``` +| Install | Use | +| --- | --- | +| `pip install 'vessim[live]'` | [Live measurements and REST APIs](concepts/live.md) | +| `pip install 'vessim[mosaik]'` | [Connect external simulators](concepts/coupling.md) | -A [`Trace`](api_reference/signal.md#vessim.Trace) replays a CSV row-by-row, indexed by an offset in seconds since the trace's start. See [Signals and Datasets](concepts/signals.md) for all available signal types, the CSV schema, and how to fetch more data. +## How Vessim works -At every step, Vessim sums the actors' powers into the microgrid's *power delta*. Again, positive means surplus, negative means deficit. +Vessim models local energy systems, called *microgrids*. +Each combines electrical loads, such as computing equipment and cooling, with generation, storage, and grid connections. +An [environment](#run-your-first-simulation) advances one or more microgrids on a shared clock, for example to model geographically distributed datacenters. -## 3. Dispatchables (optional) +![Actors, dispatchables, and controllers in simulated and live microgrids](assets/vessim_overview.png) -Dispatchables are controllable resources whose power is decided at each step by a [`DispatchPolicy`](api_reference/dispatch_policy.md). The most common dispatchable is a battery. Vessim's [`SimpleBattery`](api_reference/dispatchable.md#vessim.SimpleBattery) is an ideal capacity-based model: +- **[Actors](concepts/signals.md)** (red) provide demand and generation. + [`vs.Consumer`](api_reference/actor.md#vessim.Consumer) and [`vs.Producer`](api_reference/actor.md#vessim.Producer) use static inputs or a [signal](concepts/signals.md), such as a load trace or live measurement. + Their combined power gives the surplus or deficit to balance at each step. +- **[Dispatchables](concepts/dispatchables.md)** (gray) supply or absorb power within their operating limits. + A [dispatch policy](concepts/dispatchables.md#dispatch-policies) allocates the surplus or deficit across batteries, generators, and grid connections. + Policies can use electricity prices or carbon intensity to guide those decisions. +- **[Controllers](concepts/controllers.md)** (yellow) observe all microgrids after dispatch. + They can change demand or policy parameters for subsequent steps, [record results](concepts/results.md), or let external applications monitor and control the system through a [REST API](concepts/live.md#connect-an-application). -```python -battery = vs.SimpleBattery(name="battery", capacity=1500, initial_soc=0.8, min_soc=0.3) -``` +## Run your first simulation -Without a custom policy, Vessim uses the [`DefaultDispatchPolicy`](api_reference/dispatch_policy.md#vessim.DefaultDispatchPolicy) which charges on surplus, discharges on deficit, and exchanges any leftover power with the public grid. See [Dispatchables and Dispatch Policies](concepts/dispatchables.md) for islanded mode, custom policies, and how to model anything beyond batteries. +The following steps build a three-day experiment with a datacenter load, solar generation, and battery storage. +The complete script is available as [basic_example.py](https://github.com/dos-group/vessim/blob/main/examples/basic_example.py). -## 4. Grid Signals (optional) +### 1. Choose how to run -A microgrid can also carry *grid signals*: time-varying contextual data such as local electricity price or carbon intensity. -They don't consume or produce power, but custom dispatch policies and controllers can use them to make decisions. +| Create an environment with | To | +| --- | --- | +| `vs.Environment(...)` | Run as fast as computation allows for [simulation studies](concepts/studies.md) | +| `vs.Environment.live(...)` | Run alongside wall-clock time for [live applications and measurements](concepts/live.md) | -We add carbon intensity from a WattTime CAISO-North trace. -This CSV is datetime-indexed, so we pass an `anchor=` to mark which row maps to our simulation start with offset=0: +Both modes use the same component models. +Here we use simulation mode and update the system every five minutes: ```python -grid_signals = { - "carbon_intensity": vs.Trace.from_csv( - "datasets/watttime_example.csv", - anchor="2023-06-08 00:00:00", - ), -} -``` - -## 5. Putting the microgrid together - -[`add_microgrid`](api_reference/environment.md#vessim.Environment.add_microgrid) combines actors, dispatchables, and grid signals into one simulated system. -You can call it multiple times to simulate several geo-distributed microgrids in parallel. +import vessim as vs -```python -environment.add_microgrid( - name="datacenter", - actors=[server, solar], - dispatchables=[battery], - grid_signals=grid_signals, -) +env = vs.Environment(sim_start="2026-01-01", step_size=5 * 60) ``` -## 6. Logging and running +### 2. Assemble the energy system -The last ingredient is a [`Controller`](api_reference/controller.md). -Controllers observe the simulation at every step and can react to it. -We use the built-in [`CsvLogger`](api_reference/controller.md#vessim.CsvLogger), which writes the experiment configuration and full timeseries to a directory: +Add demand, generation, and the resources that supply or store power to a microgrid. +Download [solar_example.csv](https://raw.githubusercontent.com/dos-group/vessim/main/datasets/solar_example.csv) into your working directory. +Its first column contains elapsed seconds and the remaining columns contain normalized solar profiles for different cities. +Here we select Berlin and scale its profile to a 2.4 kW installation, alongside a constant 700 W load, a 1 kWh battery, and the public grid: ```python -environment.add_controller(vs.CsvLogger("results/basic_example")) - -environment.run(until=24 * 3600) # 24 hours -``` +# Scale the normalized solar profile to a 2.4 kW installation. +solar = vs.Trace.from_csv("solar_example.csv", column="Berlin", scale=2400) -After the run, `results/basic_example/` contains: - -``` -results/basic_example/ - metadata.yaml # static experiment configuration - timeseries.csv # power flows, battery state, grid signals at every step +env.add_microgrid( + name="site", + actors=[ + vs.Consumer("load", signal=700), + vs.Producer("solar", signal=solar), + ], + dispatchables=[vs.SimpleBattery("battery", capacity=1000), vs.GridConnection()], +) ``` -Custom controllers can do much more than logging: they can mutate the dispatch policy at runtime or expose the simulation as a REST API. See [Controllers](concepts/controllers.md) and [Software-in-the-Loop](concepts/sil.md). +The default policy uses the battery before exchanging power with the grid. +Choose different [inputs](concepts/signals.md) or [resources and policies](concepts/dispatchables.md) to represent your system. +Call `add_microgrid()` again to model another site. -## 7. Exploring the results +### 3. Add recording or control -Vessim ships with a browser-based experiment viewer. Launch it on your results directory: +Register a `Recorder` to save results: -```console -vessim view results/basic_example +```python +recorder = env.add_controller(vs.Recorder("results/basic_example")) ``` -This opens an interactive dashboard with charts for power flows, battery state of charge, and grid signals. +Add [custom controllers](concepts/controllers.md) to adjust demand or resource operation as the simulation progresses. +Controllers can observe all sites in the environment. -You can also [open the live demo of this exact run](viewer/index.html){target="_blank"}, served as a static page from the documentation site. +### 4. Run and inspect -## The complete example +Run the model for three simulated days: ```python -import vessim as vs +run = env.run(until=3 * 24 * 3600) +metrics = recorder.metrics["microgrids"]["site"] +print(f"Grid import: {metrics['grid_import'] / 1000:.2f} kWh") +print(f"Grid export: {metrics['grid_export'] / 1000:.2f} kWh") +``` -environment = vs.Environment(sim_start="2022-06-09", step_size=300) +```text +Grid import: 26.71 kWh +Grid export: 11.08 kWh +``` -environment.add_microgrid( - name="datacenter", - actors=[ - vs.Actor(name="server", signal=vs.StaticSignal(value=700), consumer=True), - vs.Actor(name="solar_panel", signal=vs.Trace.from_csv( - "datasets/solar_example.csv", column="Berlin", scale=5000 - )), - ], - dispatchables=[ - vs.SimpleBattery(name="battery", capacity=1500, initial_soc=0.8, min_soc=0.3) - ], - grid_signals={ - "carbon_intensity": vs.Trace.from_csv( - "datasets/watttime_example.csv", anchor="2023-06-08 00:00:00" - ), - }, -) +Open the saved results: -environment.add_controller(vs.CsvLogger("results/basic_example")) -environment.run(until=24 * 3600) +```console +python -m vessim view results/basic_example ``` +[Explore this example in the viewer](viewer/index.html?study=getting-started#results), or open [Time series](viewer/index.html?study=getting-started#timeseries) and select the basic example to inspect its power flows. -## Next steps +## Where next -- [Signals and Datasets](concepts/signals.md) — replay historical data, build constant or custom signals. -- [Dispatchables and Dispatch Policies](concepts/dispatchables.md) — battery models, custom dispatchables, custom policies. -- [Controllers](concepts/controllers.md) — observe and modify a running simulation. -- [Software-in-the-Loop](concepts/sil.md) — connect Vessim to live data sources and real applications. +| To… | Read | +| --- | --- | +| Use your own demand and generation data | [Load a CSV](concepts/signals.md#load-a-csv) | +| Model resource limits and outages | [Dispatchables and policies](concepts/dispatchables.md#grid-connections) | +| Implement control strategies or demand response | [Controllers](concepts/controllers.md) | +| Calculate costs, emissions, or custom metrics | [Results and metrics](concepts/results.md#costs-and-emissions) | +| Compare configurations and uncertain scenarios | [Simulation studies](concepts/studies.md) and [optimization](concepts/optimization.md) | +| Test an application against a simulated energy system | [Live simulation](concepts/live.md#connect-an-application) | +| Model an existing energy system using live measurements | [Digital-twin workflows](concepts/live.md#build-a-digital-twin) | +| Connect another simulator | [Connect external simulators](concepts/coupling.md) | diff --git a/docs/index.md b/docs/index.md index a129da49..b19ef88f 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,67 +1,92 @@ +--- +hide: + - toc +--- + #
Vessim Logo
-Vessim is a **co-simulation testbed for microgrids**\* +Vessim is a Python library for simulating datacenter energy systems. + +Vessim models the interaction between datacenter demand and energy infrastructure, helping you understand how design and control decisions affect cost, carbon emissions, and resilience. + +
+ +
+ +### Simulation studies + +Evaluate configurations and control strategies across variations in operating conditions. +Use parameter sweeps, what-if scenarios, and probabilistic inputs to explore design choices and assess their sensitivity to uncertainty. + +[Explore example results](viewer/index.html?study=optuna-search&view=%7B%22v%22%3A1%2C%22pareto%22%3Atrue%7D#results){ .md-button } +
+ +
+ +### Live modeling -Vessim lets you model how loads, (renewable) generation, energy storage, and the public grid interact, and test control and dispatch strategies against them, in pure simulation or with **real software and hardware** in the loop. +Connect real applications to Vessim as they would connect to a physical energy system. +Through REST APIs and software-in-the-loop simulation, applications can observe and control simulated components in real time. -*\*By "microgrid" we mean any local energy system that combines loads, generation, and storage behind a grid connection. Grids can scale from small battery-constrained systems to a GW-scale datacenter campus.* +
+ +
+### Digital twins -## What can I do with Vessim? +Model your energy system alongside the real installation, using live measurements and updating parameters as conditions change. +Explore future scenarios such as prolonged low renewable generation or a complete grid outage. -Vessim helps you understand and optimize how loads, energy sources, and storage interact. -It was originally designed for energy-aware and carbon-aware data centers, but has also been used for other use cases such as battery price arbitrage and demand response for cooling warehouses. +
+ +
-- **Control and dispatch strategies**: test battery charging/discharging, load-shifting, and price- or carbon-driven dispatch. -- **Demand response and power outages**: simulate demand response signals or power outages to understand your system's flexibility and test mitigation strategies. -- **Microgrid composition**: experiment with adding solar panels, wind turbines, or batteries to see how they would affect your energy costs and carbon emissions. -- **Energy- and carbon-aware computing**: develop applications that adapt their energy consumption to the carbon intensity and price of electricity. +
-Vessim can simulate multiple distributed microgrids in parallel and easily integrates historical datasets and new simulators. -Vessim's [software-in-the-loop](concepts/sil.md) capabilities let you run real systems against simulated microgrids. +## News +
-## How Vessim Works + -Vessim simulates local energy systems, called *microgrids*, that combine loads (such as computing equipment, cooling, or charging) with energy sources, dispatchable resources like batteries, and a connection to the public grid. +Vessim 0.16.0 is out! +See the [release notes](https://github.com/dos-group/vessim/releases) for what’s new. -![Vessim Overview](assets/vessim_overview.png) +
-Vessim is built on [Mosaik](https://mosaik.offis.de), a general-purpose co-simulation framework. -Each hexagon in the diagram is a Mosaik component that can be simulated or run as real software (software-in-the-loop). -A microgrid is composed of three kinds of building blocks: +
-- [Actors](concepts/signals.md) (red): exogenous consumers and producers whose power is determined by a [`Signal`](api_reference/signal.md). Their sum at each step is the microgrid's *power delta*. -- [Dispatchables](concepts/dispatchables.md) (gray): controllable resources like batteries or generators. A [`DispatchPolicy`](api_reference/dispatch_policy.md) decides how to distribute the power delta across them; any remainder is exchanged with the public grid. -- [Controllers](concepts/controllers.md) (yellow): observe the simulation each step. Built-in loggers record the state, and custom controllers can adjust dispatch parameters or expose the simulation via APIs. + +Philipp Wiesner gave a keynote on [“Co-Simulating Computing and Energy Systems”](https://tdis.gitlab.io/tdis26/) at the TDIS workshop at [EuroSys'26](https://2026.eurosys.org/) in Edinburgh. -## Try it now +
-Head to [Getting Started](getting_started.md) for a step-by-step walkthrough that builds a complete microgrid in a few lines of code. +
-You can also explore a finished simulation in your browser: [open the Experiment Viewer beta](viewer/index.html){target="_blank"} to see the interactive dashboard for the Getting Started run. + +Together with [Exalsius](https://www.exalsius.ai/) and [Deep Science Ventures](https://www.deepscienceventures.com/), we presented a prototype for [distributed LLM pretraining during renewable curtailment windows](https://arxiv.org/abs/2602.22760). +Vessim was used to model geographically distributed energy systems. -## Installation +
-Install the [latest release](https://pypi.org/project/vessim/) via [pip](https://pip.pypa.io/en/stable/getting-started/): +
-```console -pip install vessim -``` +
-For [software-in-the-loop](concepts/sil.md) capabilities, install the `sil` extra: +Vessim is developed at the [University of Cambridge](https://www.cam.ac.uk/). -```console -pip install vessim[sil] -``` + -Please reach out via [GitHub Discussions](https://github.com/dos-group/vessim/discussions) in case of questions. +
diff --git a/docs/publications.md b/docs/publications.md index 13f5b244..61acf1a7 100644 --- a/docs/publications.md +++ b/docs/publications.md @@ -1,12 +1,13 @@ # Publications -Vessim started as a research project in early 2023 and has since been used in various research publications on carbon-aware applications and systems. - If you use Vessim in your research, please cite our paper: !!! abstract "Paper" - Philipp Wiesner, Ilja Behnke, Paul Kilian, Marvin Steinke, and Odej Kao. "[Vessim: A Testbed for Carbon-Aware Applications and Systems.](https://dl.acm.org/doi/pdf/10.1145/3727200.3727210)" _ACM SIGENERGY Energy Informatics Review 4 (5)_. 2024. + Philipp Wiesner, Ilja Behnke, Paul Kilian, Marvin Steinke, and Odej Kao. + "[Vessim: A Testbed for Carbon-Aware Applications and Systems.](https://dl.acm.org/doi/pdf/10.1145/3727200.3727210)" + _ACM SIGENERGY Energy Informatics Review 4 (5)_. + 2024. ``` bibtex @article{wiesner2024vessim, @@ -21,30 +22,28 @@ If you use Vessim in your research, please cite our paper: ``` -For details in Vessim's software-in-the-loop simulation methodology, refer to our journal paper: - -!!! abstract "Paper" - - Philipp Wiesner, Marvin Steinke, Henrik Nickel, Yazan Kitana, and Odej Kao. "[Software-in-the-Loop Simulation for Developing and Testing Carbon-Aware Applications](https://onlinelibrary.wiley.com/doi/10.1002/spe.3275)" _Software: Practice and Experience, 53 (12)_. 2023. - - ``` bibtex - @article{wiesner2023sil, - author = {Wiesner, Philipp and Steinke, Marvin and Nickel, Henrik and Kitana, Yazan and Kao, Odej}, - title = {Software-in-the-loop simulation for developing and testing carbon-aware applications}, - journal = {Software: Practice and Experience}, - year = {2023}, - volume = {53}, - number = {12}, - pages = {2362-2376}, - doi = {https://doi.org/10.1002/spe.3275} - } - ``` - - ## Publications using or building on Vessim -1. Philipp Wiesner, Soeren Becker, Brett Cornick, Dominik Scheinert, Alexander Acker and Odej Kao. "[Distributed LLM Pretraining During Renewable Curtailment Windows: A Feasibility Study](https://arxiv.org/pdf/2602.22760)". Technical Report. 2026. -1. Julius Irion, Philipp Wiesner, Jonathan Bader, and Odej Kao. "[Optimizing Microgrid Composition for Sustainable Data Centers](https://dl.acm.org/doi/full/10.1145/3731599.3767562)". Sustainable Supercomputing Workshop at SC. 2025. -1. Miray Özcan, Philipp Wiesner, Philipp Weiß, and Odej Kao. "[Quantifying the Energy Consumption and Carbon Emissions of LLM Inference via Simulations](https://arxiv.org/pdf/2507.11417)". Workshop on Performance and Energy Efficiency in Concurrent and Distributed Systems (PECS) at Euro-PAR. 2025. -1. Paul Kilian, Philipp Wiesner, and Odej Kao. "[Choosing the Right Battery Model for Data Center Simulations](https://arxiv.org/pdf/2506.17739)". 1st International Workshop on Low Carbon Computing (LOCO). 2024. -1. Philipp Wiesner, Ramin Khalili, Dennis Grinwald, Pratik Agrawal, Lauritz Thamsen, and Odej Kao. "[FedZero: Leveraging Renewable Excess Energy in Federated Learning](https://dl.acm.org/doi/10.1145/3632775.3639589)". 15th ACM International Conference on Future and Sustainable Energy Systems (e-Energy). 2024. +1. Philipp Wiesner, Soeren Becker, Brett Cornick, Dominik Scheinert, Alexander Acker and Odej Kao. + "[Distributed LLM Pretraining During Renewable Curtailment Windows: A Feasibility Study](https://arxiv.org/pdf/2602.22760)". + Technical Report. + 2026. +1. Julius Irion, Philipp Wiesner, Jonathan Bader, and Odej Kao. + "[Optimizing Microgrid Composition for Sustainable Data Centers](https://dl.acm.org/doi/full/10.1145/3731599.3767562)". + _Sustainable Supercomputing Workshop at SC._ + 2025. +1. Miray Özcan, Philipp Wiesner, Philipp Weiß, and Odej Kao. + "[Quantifying the Energy Consumption and Carbon Emissions of LLM Inference via Simulations](https://arxiv.org/pdf/2507.11417)". + _Workshop on Performance and Energy Efficiency in Concurrent and Distributed Systems (PECS) at Euro-PAR._ + 2025. +1. Paul Kilian, Philipp Wiesner, and Odej Kao. + "[Choosing the Right Battery Model for Data Center Simulations](https://arxiv.org/pdf/2506.17739)". + _International Workshop on Low Carbon Computing (LOCO)._ + 2024. +1. Philipp Wiesner, Ramin Khalili, Dennis Grinwald, Pratik Agrawal, Lauritz Thamsen, and Odej Kao. + "[FedZero: Leveraging Renewable Excess Energy in Federated Learning](https://dl.acm.org/doi/10.1145/3632775.3639589)". + _ACM International Conference on Future and Sustainable Energy Systems (e-Energy)._ + 2024. +1. Philipp Wiesner, Marvin Steinke, Henrik Nickel, Yazan Kitana, and Odej Kao. + "[Software-in-the-Loop Simulation for Developing and Testing Carbon-Aware Applications](https://onlinelibrary.wiley.com/doi/10.1002/spe.3275)" _Software: Practice and Experience, 53 (12)_. + 2023. diff --git a/docs/stylesheets/extra.css b/docs/stylesheets/extra.css index 3940d1ff..dae2c8cd 100644 --- a/docs/stylesheets/extra.css +++ b/docs/stylesheets/extra.css @@ -2,7 +2,134 @@ background-color: #2c4a3c; } +.md-typeset .vessim-figure p { + margin: 0; +} + +.md-typeset .vessim-figure img { + display: block; +} + +.md-typeset .vessim-figure figcaption { + margin: 0.25rem auto 0; +} + .md-header .md-header__title { margin-left: 0; -} \ No newline at end of file +} + +.vessim-use-cases { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 0.8rem; + margin: 1.4rem 0 1.2rem; +} + +.vessim-use-case { + padding: 1rem; + border: 1px solid var(--md-default-fg-color--lightest); + border-radius: 0.35rem; +} + +.md-typeset .vessim-use-case h3 { + margin: 0 0 0.6rem; + font-size: 0.9rem; + font-weight: 600; +} + +.md-typeset .vessim-use-case p { + margin: 0; +} + +.md-typeset .vessim-use-case .md-button { + margin-top: 0.6rem; +} + +.md-typeset .vessim-actions p { + display: flex; + flex-wrap: wrap; + gap: 0.6rem; + margin: 0; +} + +.vessim-news { + margin-top: 2.4rem; +} + +.md-typeset .vessim-news h2 { + margin: 0 0 0.6rem; + font-size: 1.1rem; + font-weight: 600; +} + +.vessim-news-item { + display: grid; + grid-template-columns: 6.5rem minmax(0, 1fr); + gap: 1.2rem; + padding: 0.9rem 0; + border-top: 1px solid var(--md-default-fg-color--lightest); +} + +.md-typeset .vessim-news-item p { + margin: 0; +} + +.vessim-news-item time { + color: var(--md-default-fg-color--light); + font-size: 0.75rem; + font-weight: 500; +} + +.vessim-project { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1.2rem 2rem; + margin-top: 1.6rem; + padding-top: 1.2rem; + border-top: 1px solid var(--md-default-fg-color--lightest); + color: var(--md-default-fg-color--light); + font-size: 0.7rem; +} + +.md-typeset .vessim-project p { + max-width: 25rem; + margin: 0; +} + +.md-typeset .vessim-project-links ul { + display: flex; + flex-wrap: wrap; + gap: 0.4rem 1rem; + margin: 0; + padding: 0; + list-style: none; +} + +.md-typeset .vessim-project-links li { + margin: 0; + white-space: nowrap; +} + +@media (max-width: 700px) { + .vessim-use-cases { + grid-template-columns: 1fr; + } + + .vessim-news-item { + grid-template-columns: 1fr; + gap: 0.3rem; + } + + .vessim-project { + flex-direction: column; + } +} + +@media (max-width: 480px) { + .md-typeset .vessim-actions .md-button { + width: 100%; + text-align: center; + } +} diff --git a/docs/vessim_viewer.md b/docs/vessim_viewer.md new file mode 100644 index 00000000..ad6f2886 --- /dev/null +++ b/docs/vessim_viewer.md @@ -0,0 +1,30 @@ +# Vessim Viewer + +Explore simulation studies in your browser, from individual runs to comparisons across configurations and uncertain inputs. + +- [Results](viewer/index.html?study=optuna-search#results) compares recorded parameters and metrics in tables and plots. +- [Time series](viewer/index.html?study=getting-started#timeseries) shows power flows and component states. + Choose runs or configurations to overlay their recordings. + +## Explore the examples + +| Example | What it shows | +| --- | --- | +| [Getting started](viewer/index.html?study=getting-started#results) | Three days of demand, solar generation, and battery operation | +| [Exploring microgrid configurations](viewer/index.html?study=infrastructure-year#results) | Wind, solar, and storage configurations over a year | +| [Optimization](viewer/index.html?study=optuna-search#results) | Cost and emissions trade-offs across 100 equipment configurations | +| [Probabilistic sampling](viewer/index.html?study=dispatch-comparison#results) | Dispatch strategies evaluated across different demand realizations | + +## Open and share your results + +Open a directory of [recorded runs](concepts/results.md), or export an interactive report for static web hosting: + +```console +python -m vessim view results/study +python -m vessim view results/study --export report +``` + +The local viewer opens at `http://localhost:8710`. +Use `--port` to change the port or `--no-browser` to open it yourself. +Export to an empty directory and serve it over HTTP. +Recipients do not need Vessim installed. diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 00000000..5bbc5152 --- /dev/null +++ b/examples/README.md @@ -0,0 +1,97 @@ +# Examples + +Run these commands from a checkout of Vessim with Python 3.12 or newer. +Create an environment and install the versions used for the published simulations: + +```console +python -m venv .venv +source .venv/bin/activate +pip install -e . -r examples/requirements.txt +``` + +To open the viewer from a source checkout, build its assets once with Node.js 22.12 or newer: + +```console +npm --prefix viewer ci +npm --prefix viewer run build +``` + +## Run an example + +Each script runs on its own and saves results under `results/`. +Use `--output PATH` to choose a different directory. +Outputs must be empty so a new run cannot overwrite or mix with an earlier study. +Open any saved run or study with `python -m vessim view PATH`. + +| Example | Command | Results | +| --- | --- | --- | +| Getting started | `python examples/basic_example.py` | `results/basic_example` | +| Your own CSV | `python examples/replay_csv.py` | `results/replay_csv` | +| Battery sizing | `python examples/battery_sizing_study.py --workers 4` | `results/battery-sizing` | +| Exploring microgrid configurations | `python examples/infrastructure_study.py` | `results/infrastructure-study` | +| Annual optimization | `python examples/infrastructure_study.py --financial --trials 100 --output results/infrastructure-search` | `results/infrastructure-search` | +| Probabilistic dispatch | `python examples/strategy_comparison.py` | `results/strategy-comparison` | +| Demand response | `python examples/demand_response.py` | `results/demand_response` | +| Custom metrics | `python examples/custom_metrics.py` | `results/custom_metrics` | + +CSV replay uses a bundled sample by default. +Pass your own file as its first argument. +Study examples record time series by default, and accept `--no-timeseries` for smaller summary-only outputs. +The custom-metrics example saves summaries only. + +Stochastic examples use `--seed 42` by default. +Capacity and policy comparisons reuse the same demand scenarios across configurations. +Optimization runs sequentially from a fresh seeded sampler, with `--trials` specifying the total number of evaluations. +The same commands and dependency versions reproduce the inputs, configurations, and metrics. +Execution timestamps and runtimes will differ. +Synthetic demand, weather, prices, and equipment costs are illustrative model assumptions. + +## Rebuild the published examples + +```console +python examples/build_gallery.py --output results/gallery +python -m vessim view results/gallery +``` + +This runs the same commands as the individual examples and produces four studies, including 100 annual optimization trials. +Use `--trials` to change the search size or `--seed` to generate another realization. +No previous results are required. + +## Connect an external simulator + +```console +pip install -e '.[mosaik]' +python examples/external_simulator_coupling.py +``` + +The included datacenter simulator runs for three simulated minutes. +Add `--live` to run it at wall-clock speed. + +## Use live measurements + +```console +pip install -e '.[live]' +docker compose -f examples/live/docker-compose.yml up -d +python examples/live_server_monitoring.py --until 300 +``` + +Allow at least a minute for Prometheus to collect CPU samples before starting Vessim. +The example estimates power from CPU utilization and exposes the simulation at `http://localhost:8700`. +It does not measure electrical power. +On Docker Desktop, node_exporter observes the Linux VM hosting the containers. +Use `--prometheus-url` to connect your own monitoring system instead. + +For the optional Grafana dashboard at `http://localhost:3001`: + +```console +docker compose -f examples/live/docker-compose.grafana.yml up -d +``` + +Stop the services when finished: + +```console +docker compose -f examples/live/docker-compose.grafana.yml down +docker compose -f examples/live/docker-compose.yml down +``` + +The monitoring images are pinned to [Prometheus 3.15.0](https://github.com/prometheus/prometheus/releases/tag/v3.15.0), [node_exporter 1.12.1](https://github.com/prometheus/node_exporter/releases/tag/v1.12.1), and [Grafana 13.2.4](https://github.com/grafana/grafana/releases/tag/v13.2.4). diff --git a/examples/_common.py b/examples/_common.py new file mode 100644 index 00000000..65c4e693 --- /dev/null +++ b/examples/_common.py @@ -0,0 +1,37 @@ +"""Small file helpers shared by the runnable examples.""" + +import json +from pathlib import Path + + +def fresh_output(path: Path) -> Path: + """Keep each invocation separate from previous results.""" + if path.exists() and (not path.is_dir() or any(path.iterdir())): + raise FileExistsError(f"Output is not empty: {path}. Choose a fresh --output directory.") + path.mkdir(parents=True, exist_ok=True) + return path + + +def write_study(output, study_id, name, groups, *, run_parameters=(), description=""): + """Write explicit configuration membership alongside canonical run records. + + Each group is (configuration ID, display name, run IDs). Runs live directly + below the output directory. No results or parameters are copied into the index. + """ + document = { + "format": "vessim.study", + "schema_version": 2, + "study_id": study_id, + "name": name, + "description": description, + "run_parameters": list(run_parameters), + "configurations": [ + { + "configuration_id": identity, + "name": label, + "runs": [{"path": f"{run_id}/experiment.json"} for run_id in runs], + } + for identity, label, runs in groups + ], + } + (output / "study.json").write_text(json.dumps(document, indent=2, allow_nan=False) + "\n") diff --git a/examples/basic_example.py b/examples/basic_example.py index 84a18e29..203682d5 100644 --- a/examples/basic_example.py +++ b/examples/basic_example.py @@ -1,60 +1,62 @@ -"""This example accompanies the "Getting Started" walkthrough. +"""Three days of constant demand and Berlin solar generation from a bundled CSV. -https://vessim.readthedocs.io/en/latest/getting_started/ +Run: python examples/basic_example.py +View: python -m vessim view results/basic_example +Walkthrough: https://vessim.readthedocs.io/en/latest/getting_started/ """ -import os + +import argparse +from pathlib import Path + +from _common import fresh_output, write_study import vessim as vs -DATASETS = f"{os.path.dirname(os.path.dirname(os.path.abspath(__file__)))}/datasets" - -# The Environment manages simulation time and the synchronization between simulators. -# We step every 5 minutes (300 s). -environment = vs.Environment(sim_start="2026-05-05", step_size=300) - -# A microgrid combines actors (consumers/producers), optional dispatchables (batteries, -# diesel/gas generators, ...), and optional grid signals (electricity price, carbon -# intensity, curtailment, demand response, ...). -# Call add_microgrid() multiple times to simulate geo-distributed sites in parallel. -environment.add_microgrid( - name="datacenter", - actors=[ - # Actors represent energy consumers or producers. - # Vessim convention: consumption is negative (here done by consumer=True), and - # production is positive. - vs.Actor(name="server", signal=vs.StaticSignal(value=700), consumer=True), - - # Trace replays a time series. The CSV is offset-indexed (first column = - # seconds since the trace start). Solar data is normalized 0..1; we - # scale it to a 5 kW peak. - vs.Actor( - name="solar_panel", - signal=vs.Trace.from_csv( - f"{DATASETS}/solar_example.csv", - column="Berlin", - scale=5000, - ), + +def run(output): + # Scale the normalized solar profile to a 2.4 kW installation. + solar = vs.Trace.from_csv( + Path(__file__).resolve().parents[1] / "datasets/solar_example.csv", + column="Berlin", + scale=2400, + ) + + env = vs.Environment(sim_start="2026-01-01", step_size=5 * 60) + env.add_microgrid( + name="site", + actors=[ + vs.Consumer("load", signal=700), + vs.Producer("solar", signal=solar), + ], + dispatchables=[vs.SimpleBattery("battery", capacity=1000), vs.GridConnection()], + ) + recorder = env.add_controller(vs.Recorder(output, run_id="basic-example")) + env.run(until=3 * 24 * 3600) + + metrics = recorder.metrics["microgrids"]["site"] + print(f"Grid import: {metrics['grid_import'] / 1000:.2f} kWh") + print(f"Grid export: {metrics['grid_export'] / 1000:.2f} kWh") + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--output", type=Path, default=Path("results/basic_example")) + args = parser.parse_args() + run(fresh_output(args.output)) + write_study( + args.output, + "getting-started", + "Getting started", + [("basic-example", "Basic example", ["."])], + description=( + "A small microgrid supplies steady demand with solar power, a battery, " + "and a grid connection. " + "Three days of solar data show the battery charging during sunny periods and " + "supplying demand when generation falls. " + "Open the time series to follow how power flows through the system." ), - ], - # Dispatchables are controllable resources. The default DispatchPolicy charges on - # surplus, discharges on deficit, and exchanges any remainder with the public grid. - dispatchables=[ - vs.SimpleBattery(name="battery", capacity=1500, initial_soc=0.8, min_soc=0.3) - ], - # Grid signals provide contextual information that custom policies and controllers - # can react to. They are also plotted by the experiment viewer. The watttime CSV - # is datetime-indexed, so we pass anchor= to mark which row maps to offset=0. - grid_signals={ - "carbon_intensity": vs.Trace.from_csv( - f"{DATASETS}/watttime_example.csv", - anchor="2023-06-08 00:00:00", - ) - }, -) - -# CsvLogger writes metadata.yaml + timeseries.csv to the output directory. View the -# results in your browser with: `vessim view results/basic_example` -environment.add_controller(vs.CsvLogger(outdir="results/basic_example")) - -# Run the simulation for 24 hours (24 * 3600 seconds). -environment.run(until=24 * 3600) + ) + + +if __name__ == "__main__": + main() diff --git a/examples/battery_sizing_study.py b/examples/battery_sizing_study.py new file mode 100644 index 00000000..55b9fdf9 --- /dev/null +++ b/examples/battery_sizing_study.py @@ -0,0 +1,113 @@ +"""Compare battery capacities against the same seeded demand scenarios. + +python examples/battery_sizing_study.py --scenarios 10 --workers 4 +python -m vessim view results/battery-sizing +""" + +from __future__ import annotations + +import argparse +from concurrent.futures import ProcessPoolExecutor +import multiprocessing +from pathlib import Path + +import numpy as np +import pandas as pd +import vessim as vs +from _common import fresh_output, write_study + +CAPACITIES = (1000, 2000, 4000) +SOLAR: vs.Trace + + +def initialize_worker(): + global SOLAR + seconds = np.arange(0, 86400, 300) + watts = 5000 * np.maximum(0, np.sin((seconds / 3600 - 6) * np.pi / 12)) + SOLAR = vs.Trace(pd.Series(watts, index=seconds)) + + +def run_scenario(task): + capacity, seed, output, timeseries = task + rng = np.random.default_rng(seed) + seconds = np.arange(0, 86400, 300) + load = vs.Trace(pd.Series(rng.uniform(500, 1500, len(seconds)), index=seconds)) + run_id = f"battery-{capacity}-s{seed}" + with vs.Environment("2026-01-01", step_size=300) as env: + env.add_microgrid( + name="site", + actors=[vs.Producer("solar", SOLAR), vs.Consumer("load", load)], + dispatchables=[ + vs.SimpleBattery("battery", capacity, initial_soc=0.5), + vs.GridConnection(), + ], + ) + recorder = env.add_controller( + vs.Recorder( + output / run_id, + timeseries=timeseries, + run_id=run_id, + parameters={"capacity_wh": capacity, "seed": seed}, + ) + ) + env.run(24 * 3600) + result = recorder.result + result.add_metric( + "battery_capex", capacity / 1000 * 400, "EUR", assumptions={"battery_eur_per_kwh": 400} + ) + result.save(output / run_id / "experiment.json") + return run_id + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--scenarios", type=int, default=5) + parser.add_argument("--workers", type=int, default=1) + parser.add_argument("--seed", type=int, default=42) + parser.add_argument("--output", type=Path, default=Path("results/battery-sizing")) + parser.add_argument("--no-timeseries", action="store_true") + args = parser.parse_args() + if args.scenarios <= 0 or args.workers <= 0 or args.seed < 0: + parser.error("scenarios and workers must be positive, seed must be nonnegative") + fresh_output(args.output) + seeds = range(args.seed, args.seed + args.scenarios) + tasks = [ + (capacity, seed, args.output, not args.no_timeseries) + for capacity in CAPACITIES + for seed in seeds + ] + if args.workers == 1: + initialize_worker() + list(map(run_scenario, tasks)) + else: + with ProcessPoolExecutor( + max_workers=args.workers, + initializer=initialize_worker, + mp_context=multiprocessing.get_context("spawn"), + ) as pool: + list(pool.map(run_scenario, tasks)) + write_study( + args.output, + "battery-sizing", + "Battery sizing", + [ + ( + f"battery-{capacity}", + f"{capacity / 1000:g} kWh", + [f"battery-{capacity}-s{seed}" for seed in seeds], + ) + for capacity in CAPACITIES + ], + run_parameters=["seed"], + description=( + "Three battery capacities supply the same demand scenarios " + "alongside solar generation. " + "Compare how additional storage changes grid imports and how consistent those savings " + "are across different daily demand profiles." + ), + ) + print(f"Saved {len(tasks)} runs. View: python -m vessim view {args.output}") + + +if __name__ == "__main__": + main() diff --git a/examples/build_gallery.py b/examples/build_gallery.py new file mode 100644 index 00000000..115de4b1 --- /dev/null +++ b/examples/build_gallery.py @@ -0,0 +1,52 @@ +"""Run the four published examples using their normal command-line interfaces. + +python examples/build_gallery.py --output results/gallery +python -m vessim view results/gallery +""" + +import argparse +from pathlib import Path +import subprocess +import sys + +from _common import fresh_output + +EXAMPLES = Path(__file__).resolve().parent + + +def build(output: Path, trials: int = 100, seed: int = 42): + if trials < 1 or seed < 0: + raise ValueError("trials must be positive and seed must be nonnegative") + # Check optional tooling before starting the simulations. + import optuna # noqa: F401 + + fresh_output(output) + commands = [ + ("basic_example.py", "getting-started", []), + ("infrastructure_study.py", "infrastructure-year", ["--seed", str(seed)]), + ( + "infrastructure_study.py", + "optuna-search", + ["--seed", str(seed), "--trials", str(trials), "--financial"], + ), + ("strategy_comparison.py", "dispatch-comparison", ["--seed", str(seed)]), + ] + for script, name, options in commands: + print(f"Running {name}", flush=True) + subprocess.run( + [sys.executable, str(EXAMPLES / script), "--output", str(output / name), *options], + check=True, + ) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--output", type=Path, default=Path("results/gallery")) + parser.add_argument("--trials", type=int, default=100) + parser.add_argument("--seed", type=int, default=42) + args = parser.parse_args() + build(args.output, args.trials, args.seed) + + +if __name__ == "__main__": + main() diff --git a/examples/custom_metrics.py b/examples/custom_metrics.py new file mode 100644 index 00000000..824a51f9 --- /dev/null +++ b/examples/custom_metrics.py @@ -0,0 +1,114 @@ +"""Evaluate energy, cost, and emissions with custom optimization objectives. + +python examples/custom_metrics.py +All prices and equipment figures below are illustrative assumptions. +""" + +import numpy as np +import pandas as pd + +import argparse +from pathlib import Path +from _common import fresh_output + +import vessim as vs + + +class HighImportDuration(vs.Metric): + """Count hours above a chosen grid-import threshold.""" + + def __init__(self, microgrid, threshold_w): + super().__init__( + "h", + microgrid=microgrid, + description="Time above the grid-import threshold", + assumptions={"threshold_w": threshold_w}, + ) + self.threshold_w = threshold_w + + def step(self, now, states, duration): + if -states[self.microgrid]["p_grid"] > self.threshold_w: + self.value += duration / 3600 + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--output", type=Path, default=Path("results/custom_metrics")) + args = parser.parse_args() + fresh_output(args.output) + seconds = np.arange(0, 86401, 300) + solar = vs.Trace( + pd.Series( + 5000 * np.maximum(0, np.sin((seconds / 3600 - 6) * np.pi / 12)), + index=seconds, + ) + ) + capacity_wh = 1500 + with vs.Environment("2026-01-01", step_size=300) as env: + env.add_microgrid( + name="site", + actors=[ + vs.Producer("solar", signal=solar), + vs.Consumer("server", signal=700), + ], + dispatchables=[ + vs.SimpleBattery("battery", capacity_wh, initial_soc=0.5), + vs.GridConnection(), + ], + grid_signals={ + "buy_price": 0.30, # EUR/kWh + "sell_price": 0.08, # EUR/kWh + "carbon": 0.25, # kgCO2e/kWh + }, + ) + recorder = env.add_controller( + vs.Recorder( + args.output, + run_id="custom-metrics", + timeseries=False, + parameters={"battery_capacity_wh": capacity_wh}, + custom_metrics={ + "electricity_import_cost": vs.ElectricityImportCost("site", "buy_price"), + "electricity_export_revenue": vs.ElectricityExportRevenue( + "site", "sell_price" + ), + "grid_import_emissions": vs.GridImportEmissions("site", "carbon"), + "high_import_duration": HighImportDuration("site", 500), + }, + ) + ) + env.run(24 * 3600) + + result = recorder.result + result.add_metric( + "battery_capex", capacity_wh / 1000 * 400, "EUR", assumptions={"battery_eur_per_kwh": 400} + ) + result.add_metric( + "battery_embodied_emissions", + capacity_wh / 1000 * 100, + "kgCO2e", + assumptions={"battery_kgco2e_per_kwh": 100}, + ) + result.add_metric("area", 25, "m2", microgrid="site", actor="solar") + grid = result.metrics["microgrids"]["site"] + result.add_metric( + "net_electricity_cost", + grid["electricity_import_cost"] - grid["electricity_export_revenue"], + "EUR", + microgrid="site", + description="Electricity import cost less export revenue", + ) + result.save(args.output / "experiment.json") + # Pass plain numbers to an optimizer. Weighting and constraints stay in the caller. + objectives = [ + result.metrics["run"]["battery_capex"], + grid["net_electricity_cost"], + grid["grid_import_emissions"], + ] + area_constraint = result.metrics["actors"]["site"]["solar"]["area"] - 30 + print("Objectives:", objectives) + print("Area constraint (feasible when <= 0):", area_constraint) + + +if __name__ == "__main__": + main() diff --git a/examples/demand_response.py b/examples/demand_response.py new file mode 100644 index 00000000..cd8c8a85 --- /dev/null +++ b/examples/demand_response.py @@ -0,0 +1,78 @@ +"""Respond to a temporary import limit under a flexible connection agreement. + +python examples/demand_response.py + +Limits are announced one simulation step before they take effect. The toy +simulator adjusts concurrent jobs, assuming enough flexible work is available. +It does not model job completion, deadlines, or the cost of pausing work. +""" + +import pandas as pd + +import argparse +from pathlib import Path +from _common import fresh_output + +import vessim as vs + + +class DatacenterSimulator: + """Model a 400 W base load and up to six concurrent 100 W jobs.""" + + def __init__(self): + self.active_jobs = 6 + + def set_power_budget(self, watts): + if watts < 400: + raise ValueError("The power budget cannot cover the base load.") + self.active_jobs = min(6, int((watts - 400) // 100)) + + def power(self): + return 400 + 100 * self.active_jobs + + +class DatacenterPower(vs.Signal): + def __init__(self, datacenter): + self.datacenter = datacenter + + def at(self, elapsed=None): + return self.datacenter.power() + + +class DemandResponse(vs.Controller): + def __init__(self, datacenter, grid): + self.datacenter = datacenter + self.grid = grid + + def step(self, now, states): + limit = states["site"]["grid_signals"]["next_import_limit"] + self.datacenter.set_power_budget(limit) + self.grid.max_import = limit + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--output", type=Path, default=Path("results/demand_response")) + args = parser.parse_args() + fresh_output(args.output) + datacenter = DatacenterSimulator() + grid = vs.GridConnection(max_import=1000) + notices = vs.Trace(pd.Series([700, 700, 1000, 1000], index=[0, 60, 120, 180])) + with vs.Environment("2026-01-01", step_size=60) as env: + env.add_microgrid( + name="site", + actors=[vs.Consumer("datacenter", signal=DatacenterPower(datacenter))], + dispatchables=[grid], + grid_signals={"next_import_limit": notices}, + ) + env.add_controller(DemandResponse(datacenter, grid)) + recorder = env.add_controller(vs.Recorder(args.output, run_id="demand-response")) + env.run(240) + + for (now, _), row in recorder.to_df().iterrows(): + demand = -row["actor_states.datacenter.power"] + print(f"{now:%H:%M:%S}: {demand:.0f} W") + + +if __name__ == "__main__": + main() diff --git a/examples/external_simulator_coupling.py b/examples/external_simulator_coupling.py new file mode 100644 index 00000000..6209d141 --- /dev/null +++ b/examples/external_simulator_coupling.py @@ -0,0 +1,72 @@ +"""Connect a small datacenter load simulator to a Vessim microgrid. + +Install vessim[mosaik], then run: + python examples/external_simulator_coupling.py + python examples/external_simulator_coupling.py --live +""" + +from __future__ import annotations + +import argparse + +import mosaik_api_v3 +from pathlib import Path +from _common import fresh_output + +import vessim as vs + + +class DatacenterSimulator(mosaik_api_v3.Simulator): + def __init__(self): + super().__init__( + { + "type": "time-based", + "models": { + "Datacenter": {"public": True, "params": [], "attrs": ["power"]}, + }, + } + ) + self.power = 0 + + def init(self, sid, time_resolution=1.0, **params): + return self.meta + + def create(self, num, model): + return [{"eid": str(i), "type": model} for i in range(num)] + + def step(self, time, inputs, max_advance): + # More jobs arrive each minute, increasing demand from 1 kW to 2 kW. + self.power = -(1000 + 500 * (time // 60)) + return time + 60 + + def get_data(self, outputs): + return {eid: {"power": self.power} for eid in outputs} + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--live", action="store_true") + parser.add_argument("--output", type=Path, default=Path("results/external_simulator_coupling")) + args = parser.parse_args() + fresh_output(args.output) + create = vs.Environment.live if args.live else vs.Environment + with create( + sim_start="2026-01-01", + backend="mosaik", + step_size=60, + sim_config={ + "Datacenter": {"python": "external_simulator_coupling:DatacenterSimulator"}, + }, + ) as env: + datacenter = env.world.start("Datacenter").Datacenter() + env.add_microgrid(actors=[], name="site", dispatchables=[vs.GridConnection()]) + recorder = env.add_controller(vs.Recorder(args.output, run_id="external-simulator")) + env.prepare() + env.world.connect(datacenter, env.entities["site"], "power") + result = env.run(until=180) + print(result.states["site"]) + print(recorder.result.metrics) + + +if __name__ == "__main__": + main() diff --git a/examples/infrastructure_study.py b/examples/infrastructure_study.py new file mode 100644 index 00000000..0cc8ae4d --- /dev/null +++ b/examples/infrastructure_study.py @@ -0,0 +1,356 @@ +"""Compare wind, solar, and battery sizes over a configurable simulation horizon. + +python examples/infrastructure_study.py +python examples/infrastructure_study.py --trials 24 --output results/infrastructure-search +python -m vessim view results/infrastructure-study + +Inspired by dos-group/vessim-opt, not a reproduction of its models or results. +All traces and embodied-emissions coefficients are illustrative synthetic inputs. +Optuna is optional; without --trials this evaluates eight named configurations. +Use --financial with --trials to minimize annual +cost and grid emissions. Financial runs require a full 365-day horizon. +""" + +from __future__ import annotations + +import argparse +import math +from pathlib import Path +import time + +import numpy as np +import pandas as pd + +from _common import fresh_output, write_study + +import vessim as vs + +MODEL_VERSION = 1 +START = "2026-01-01" +# kgCO2e/kW for generation; kgCO2e/kWh for storage. Demonstration coefficients, +# not measured equipment inventories, annualized emissions, or lifecycle factors. +EMBODIED = {"wind_kw": 400, "solar_kw": 600, "battery_kwh": 100} +# Illustrative installed EUR/kW and EUR/kWh, with straight-line annual allocation. +CAPEX = {"wind_kw": 1300, "solar_kw": 700, "battery_kwh": 300} +LIFETIMES = {"wind_kw": 20, "solar_kw": 25, "battery_kwh": 10} +SETUPS = { + "grid-only": (0, 0, 0), + "solar": (0, 3000, 0), + "wind": (3000, 0, 0), + "mixed": (1500, 2000, 0), + "mixed-storage": (1500, 2000, 4000), + "wind-storage": (3000, 0, 4000), + "solar-storage": (0, 5000, 8000), + "high-renewables": (3000, 4000, 8000), +} + + +def inputs(days: int = 365, step_seconds: int = 3600, seed: int = 42) -> pd.DataFrame: + """Generate shared synthetic load, capacity factors, and grid carbon intensity. + + Weather is generated hourly before sampling, so changing step size retains + the same underlying realization. Finer steps hold hourly inputs constant. + This is a demonstration time series, not a calibrated weather/demand model. + """ + if days <= 0 or step_seconds <= 0 or 3600 % step_seconds or seed < 0: + raise ValueError("days must be positive, step_seconds must divide 3600, seed >= 0") + hours = np.arange(days * 24) + day, hour = hours / 24, hours % 24 + season = np.cos(2 * np.pi * (day - 172) / 365) + cloud_rng, wind_rng, load_rng = [ + np.random.default_rng(s) for s in np.random.SeedSequence(seed).spawn(3) + ] + cloud = np.repeat(cloud_rng.uniform(0.25, 1, days), 24) + daylight = 12 + 4 * season + solar = np.maximum(0, np.sin(np.pi * (hour - (12 - daylight / 2)) / daylight)) + solar *= (0.75 + 0.2 * season) * cloud + # Persistent wind variation; generate warm-up values before the first sample. + noise = wind_rng.normal(size=len(hours) + 48) + smooth = np.convolve(noise, np.ones(24) / np.sqrt(24), mode="valid")[: len(hours)] + wind = np.clip(0.38 - 0.10 * season + 0.20 * smooth, 0, 0.95) + load = 1000 + 100 * np.sin(2 * np.pi * (hour - 8) / 24) + load += 70 * season + load_rng.uniform(-70, 70, len(hours)) + carbon = np.clip(0.40 - 0.20 * wind - 0.12 * solar, 0.08, 0.55) + hourly = pd.DataFrame( + {"load_kw": load, "solar_cf": solar, "wind_cf": wind, "carbon": carbon}, + index=hours * 3600, + ) + return hourly.reindex(np.arange(0, days * 86400, step_seconds), method="ffill") + + +def run_scenario( + output: Path, + run_id: str, + wind_kw: float, + solar_kw: float, + battery_kwh: float, + *, + days: int = 365, + step_seconds: int = 3600, + seed: int = 42, + timeseries: bool = True, + financial: bool = False, + battery_c_rate: float = 0.5, +) -> vs.ExperimentResult: + """Record one configuration with a common input realization and explicit assumptions.""" + sizes = {"wind_kw": wind_kw, "solar_kw": solar_kw, "battery_kwh": battery_kwh} + if any(not math.isfinite(value) or value < 0 for value in sizes.values()): + raise ValueError("Equipment sizes must be finite and nonnegative") + if not math.isfinite(battery_c_rate) or battery_c_rate <= 0: + raise ValueError("Battery C-rate must be finite and positive") + if financial and days != 365: + raise ValueError("Annual financial metrics require a 365-day simulation") + if (output / run_id).exists(): + raise FileExistsError(f"Preserving existing run: {output / run_id}") + frame = inputs(days, step_seconds, seed) + + def trace(values): + return vs.Trace(values) + + dispatchables = [] + if battery_kwh: + dispatchables.append( + vs.SimpleBattery("battery", battery_kwh * 1000, initial_soc=0, c_rate=battery_c_rate) + ) + dispatchables.append(vs.GridConnection()) + emissions = vs.GridImportEmissions("Microgrid 1", "carbon") + emissions.assumptions.update( + { + "source": "synthetic hourly carbon intensity; not a regional forecast", + "input_model_version": MODEL_VERSION, + "export_credit": False, + "battery": "lossless, starts empty, C-rate limited; no terminal energy credit", + } + ) + grid_signals = {"carbon": trace(frame.carbon)} + metrics = {"grid_emissions": emissions} + if financial: + hours = (frame.index.to_numpy() / 3600) % 24 + grid_signals.update( + { + "price": trace( + pd.Series( + np.where((hours >= 17) & (hours < 21), 0.30, 0.15), index=frame.index + ) + ), + "export_price": trace(pd.Series(0.04, index=frame.index)), + } + ) + metrics.update( + { + "electricity_cost": vs.ElectricityImportCost("Microgrid 1", "price"), + "export_revenue": vs.ElectricityExportRevenue("Microgrid 1", "export_price"), + } + ) + with vs.Environment(START, step_size=step_seconds, name="Infrastructure sizing") as env: + env.add_microgrid( + [ + vs.Producer("wind", signal=trace(frame.wind_cf * wind_kw * 1000)), + vs.Producer("solar", signal=trace(frame.solar_cf * solar_kw * 1000)), + vs.Consumer("datacenter", signal=trace(frame.load_kw * 1000)), + ], + dispatchables=dispatchables, + name="Microgrid 1", + grid_signals=grid_signals, + ) + recorder = env.add_controller( + vs.Recorder( + output / run_id, + run_id=run_id, + timeseries=timeseries, + parameters={ + **sizes, + "days": days, + "step_seconds": step_seconds, + "seed": seed, + "input_model_version": MODEL_VERSION, + "battery_c_rate": battery_c_rate, + }, + custom_metrics=metrics, + ) + ) + env.run(days * 86400) + result = recorder.result + result.add_metric( + "initial_embodied_emissions", + sum(sizes[key] * factor for key, factor in EMBODIED.items()), + "kgCO2e", + description="Illustrative upfront emissions of installed wind, solar, and storage", + assumptions={ + "source": "synthetic demonstration coefficients; not a lifecycle assessment", + "wind_kgco2e_per_kw": EMBODIED["wind_kw"], + "solar_kgco2e_per_kw": EMBODIED["solar_kw"], + "battery_kgco2e_per_kwh": EMBODIED["battery_kwh"], + "excludes": "existing datacenter, grid infrastructure, replacements, end of life", + "annualized": False, + }, + ) + if financial: + assumptions = { + "source": "illustrative synthetic costs, not a financial forecast", + "installed_costs": CAPEX, + "lifetimes_years": LIFETIMES, + "allocation": "straight-line annual capital allocation; no discounting", + "import_eur_per_kwh": {"17:00-21:00": 0.30, "otherwise": 0.15}, + "export_eur_per_kwh": 0.04, + "excludes": ( + "financing, maintenance, demand charges, taxes, " + "equipment replacement scheduling" + ), + } + capital = sum(sizes[k] * CAPEX[k] for k in sizes) + annual_capital = sum(sizes[k] * CAPEX[k] / LIFETIMES[k] for k in sizes) + grid = result.metrics["microgrids"]["Microgrid 1"] + for name, value, description in ( + ("equipment_capex", capital, "Upfront equipment investment"), + ( + "annualized_equipment_cost", + annual_capital, + "One year of straight-line equipment cost allocation", + ), + ( + "annual_cost", + annual_capital + grid["electricity_cost"] - grid["export_revenue"], + "Annual import cost minus export revenue plus equipment allocation", + ), + ): + result.add_metric(name, value, "EUR", description=description, assumptions=assumptions) + result.save(output / run_id / "experiment.json") + return result + + +def objective_values(result: vs.ExperimentResult, financial: bool = False) -> tuple[float, float]: + """Return recorded objectives, rejecting unsuccessful or undefined evidence.""" + if result.execution["status"] != "completed": + raise ValueError(f"Run {result.run_id} did not complete") + values = ( + result.metrics["run"]["annual_cost" if financial else "initial_embodied_emissions"], + result.metrics["microgrids"]["Microgrid 1"]["grid_emissions"], + ) + if any(value is None or not math.isfinite(value) for value in values): + raise ValueError("Objectives must be finite recorded metrics") + return values + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--output", type=Path, default=Path("results/infrastructure-study")) + parser.add_argument("--days", type=int, default=365) + parser.add_argument("--step-seconds", type=int, default=3600) + parser.add_argument("--seed", type=int, default=42) + parser.add_argument( + "--trials", type=int, default=0, help="Optuna trials; 0 = eight fixed setups" + ) + parser.add_argument("--no-timeseries", action="store_true") + parser.add_argument("--financial", action="store_true", help="Annual cost/emissions search") + args = parser.parse_args() + if args.financial and args.days != 365: + parser.error("Annual financial metrics require a 365-day simulation") + if args.trials < 0: + parser.error("trials must be nonnegative") + try: + inputs(args.days, args.step_seconds, args.seed) + except ValueError as exc: + parser.error(str(exc)) + settings = { + "model_version": MODEL_VERSION, + "days": args.days, + "step_seconds": args.step_seconds, + "seed": args.seed, + "timeseries": not args.no_timeseries, + **({"financial": True} if args.financial else {}), + } + fresh_output(args.output) + scenario_args = {k: v for k, v in settings.items() if k != "model_version"} + started = time.perf_counter() + if args.trials: + try: + import optuna + except ImportError as exc: + raise SystemExit( + "Install optional tooling: pip install optuna optuna-dashboard" + ) from exc + storage = f"sqlite:///{(args.output / 'optuna.db').resolve()}" + study = optuna.create_study( + study_name=( + "infrastructure-cost-emissions" if args.financial else "infrastructure-emissions" + ), + storage=storage, + directions=["minimize", "minimize"], + sampler=optuna.samplers.NSGAIISampler(seed=args.seed, population_size=8), + ) + study.set_metric_names( + [ + "Annual cost (EUR)" if args.financial else "Upfront equipment emissions (kgCO2e)", + "Grid-import emissions (kgCO2e)", + ] + ) + + def objective(trial): + sizes = { + "wind_kw": trial.suggest_int("wind_kw", 0, 5000, step=500), + "solar_kw": trial.suggest_int("solar_kw", 0, 6000, step=500), + "battery_kwh": trial.suggest_int("battery_kwh", 0, 12000, step=1000), + } + if args.financial: + sizes["battery_c_rate"] = trial.suggest_categorical( + "battery_c_rate", [0.25, 0.5, 1.0] + ) + run_id = f"trial-{trial.number:05d}" + trial.set_user_attr("vessim_run_id", run_id) + trial.set_user_attr("vessim_experiment", f"{run_id}/experiment.json") + result = run_scenario(args.output, run_id, **sizes, **scenario_args) + return objective_values(result, financial=args.financial) + + study.optimize(objective, n_trials=args.trials) + print(f"Pareto trial numbers: {[trial.number for trial in study.best_trials]}") + print(f"Optuna Dashboard: optuna-dashboard {storage}") + groups = [ + ( + trial.user_attrs["vessim_run_id"], + f"Trial {trial.number}", + [trial.user_attrs["vessim_run_id"]], + ) + for trial in study.trials + ] + else: + groups = [(name, name.replace("-", " ").capitalize(), [name]) for name in SETUPS] + for name, (wind, solar, battery) in SETUPS.items(): + result = run_scenario(args.output, name, wind, solar, battery, **scenario_args) + upfront, operational = objective_values(result) + print(f"{name}: upfront {upfront:,.0f}; operating {operational:,.0f} kgCO2e") + if args.trials: + objectives = ( + "annual electricity and equipment costs against grid emissions" + if args.financial + else "upfront equipment emissions against grid emissions" + ) + description = ( + f"Based on the \"Exploring Microgrid Configurations\" study, this example we explores {args.trials} wind, solar, and battery configurations using [Optuna](https://optuna.org/)." + "You can weigh {objectives}. " + f"\nEach candidate faces the same {args.days} days of synthetic demand and weather. " + "You can select the Pareto front to highlight configurations where improving one objective comes at the expense of the other." + ) + else: + description = ( + "We explore eight microgrid configurations that supply the same datacenter " + f"over {args.days} days of synthetic demand and weather. " + "\nYou can compare how wind, solar, and battery sizing affect grid dependence and the balance " + "between upfront equipment emissions and emissions from grid electricity. " + "\nThe concept is based on our paper " + "[Optimizing Microgrid Composition for Sustainable Data Centers](https://arxiv.org/abs/2508.04284)." + ) + write_study( + args.output, + "optuna-search" if args.trials else "infrastructure-year", + "Optuna search" if args.trials else "Exploring microgrid configurations", + groups, + run_parameters=["seed"], + description=description, + ) + print(f"Elapsed: {time.perf_counter() - started:.2f}s") + print(f"View recorded results: python -m vessim view {args.output}") + + +if __name__ == "__main__": + main() diff --git a/examples/sil/docker-compose.grafana.yml b/examples/live/docker-compose.grafana.yml similarity index 75% rename from examples/sil/docker-compose.grafana.yml rename to examples/live/docker-compose.grafana.yml index f95dd1b2..a671b7ca 100644 --- a/examples/sil/docker-compose.grafana.yml +++ b/examples/live/docker-compose.grafana.yml @@ -1,13 +1,12 @@ services: grafana: - container_name: grafana - image: grafana/grafana:latest + image: grafana/grafana:13.2.4 ports: - "3001:3000" environment: - GF_AUTH_ANONYMOUS_ENABLED=true - GF_AUTH_ANONYMOUS_ORG_ROLE=Admin - - GF_DASHBOARDS_DEFAULT_HOME_DASHBOARD_PATH=/etc/grafana/provisioning/dashboards/vessim-energy-dashboard_deprecated.json + - GF_DASHBOARDS_DEFAULT_HOME_DASHBOARD_PATH=/etc/grafana/provisioning/dashboards/vessim-energy-dashboard.json volumes: - grafana_data:/var/lib/grafana - ./grafana-provisioning:/etc/grafana/provisioning diff --git a/examples/sil/docker-compose.yml b/examples/live/docker-compose.yml similarity index 72% rename from examples/sil/docker-compose.yml rename to examples/live/docker-compose.yml index fcc55102..924973cd 100644 --- a/examples/sil/docker-compose.yml +++ b/examples/live/docker-compose.yml @@ -1,7 +1,6 @@ services: prometheus: - container_name: prometheus - image: prom/prometheus:latest + image: prom/prometheus:v3.15.0 ports: - "9090:9090" volumes: @@ -12,8 +11,7 @@ services: restart: unless-stopped node-exporter: - container_name: node-exporter - image: prom/node-exporter:latest + image: prom/node-exporter:v1.12.1 ports: - "9100:9100" restart: unless-stopped diff --git a/examples/sil/grafana-provisioning/dashboards/dashboards.yaml b/examples/live/grafana-provisioning/dashboards/dashboards.yaml similarity index 100% rename from examples/sil/grafana-provisioning/dashboards/dashboards.yaml rename to examples/live/grafana-provisioning/dashboards/dashboards.yaml diff --git a/examples/sil/grafana-provisioning/dashboards/vessim-energy-dashboard.json b/examples/live/grafana-provisioning/dashboards/vessim-energy-dashboard.json similarity index 100% rename from examples/sil/grafana-provisioning/dashboards/vessim-energy-dashboard.json rename to examples/live/grafana-provisioning/dashboards/vessim-energy-dashboard.json diff --git a/examples/sil/grafana-provisioning/datasources/datasources.yml b/examples/live/grafana-provisioning/datasources/datasources.yml similarity index 100% rename from examples/sil/grafana-provisioning/datasources/datasources.yml rename to examples/live/grafana-provisioning/datasources/datasources.yml diff --git a/examples/sil/prometheus.yml b/examples/live/prometheus.yml similarity index 100% rename from examples/sil/prometheus.yml rename to examples/live/prometheus.yml diff --git a/examples/live_server_monitoring.py b/examples/live_server_monitoring.py new file mode 100644 index 00000000..d61ce2b6 --- /dev/null +++ b/examples/live_server_monitoring.py @@ -0,0 +1,72 @@ +"""Monitor a real server alongside simulated solar generation and a battery. + +Estimates server power from node_exporter CPU utilization in Prometheus. +This linear 0–1000 W proxy is not a measurement of electrical power. + +1. pip install 'vessim[live]' +2. docker compose -f examples/live/docker-compose.yml up -d +3. Wait at least one minute for Prometheus to collect initial node_exporter scrapes +4. python examples/live_server_monitoring.py +5. API available at http://localhost:8700 + +Grafana visualization (run in a second terminal): + - docker compose -f examples/live/docker-compose.grafana.yml up -d +""" + +import argparse +from pathlib import Path + +import vessim as vs + +DATASETS = Path(__file__).resolve().parents[1] / "datasets" + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--prometheus-url", default="http://localhost:9090") + parser.add_argument("--broker-port", type=int, default=8700) + parser.add_argument("--until", type=float, help="Optional duration in seconds") + parser.add_argument("--backend", choices=["native", "mosaik"], default="native") + args = parser.parse_args() + # Live mode: sim_start is captured when run() is called and the simulation + # advances at 1× wall-clock. Trace data is queried by elapsed time since + # sim_start, so the solar trace starts replaying from "now". + with vs.Environment.live(step_size=1, backend=args.backend) as environment: + + # Estimated server load driven by CPU usage via Prometheus + node_exporter. + # The query returns CPU utilization (0.0-1.0), scaled to 0-1000W. + server = vs.Consumer( + name="server", + signal=vs.PrometheusSignal( + url=args.prometheus_url, + query='(1 - avg(rate(node_cpu_seconds_total{mode="idle"}[1m]))) * 1000', + ), + ) + + # Solar simulation based on historical trace data, scaled to 2 kW peak. + solar = vs.Producer( + name="solar", + signal=vs.Trace.from_csv( + DATASETS / "solar_example.csv", + column="Berlin", + scale=2000, + ), + ) + + battery = vs.SimpleBattery(name="battery", capacity=20, initial_soc=0.5) + + environment.add_microgrid( + name="your_computer", + actors=[server, solar], + dispatchables=[battery, vs.GridConnection()], + ) + + # The API controller exposes a REST API and a /metrics endpoint for Prometheus. + environment.add_controller(vs.Api(export_prometheus=True, broker_port=args.broker_port)) + + # Use curl or other tools to interact with the API while this call runs. + environment.run(until=args.until) + + +if __name__ == "__main__": + main() diff --git a/examples/microgrid_comparison.py b/examples/microgrid_comparison.py new file mode 100644 index 00000000..0319c0be --- /dev/null +++ b/examples/microgrid_comparison.py @@ -0,0 +1,138 @@ +"""Compare three independent microgrids under three shared equipment choices. + +Each site has distinct synthetic weather, demand, and carbon intensity. There is +no electrical connection or workload migration between sites. All configurations +reuse the same site-specific input realizations. Run-level totals sum recorded +energy/emissions; coincident peak demand is accumulated from simultaneous states. + +python examples/microgrid_comparison.py --output results/multiple-microgrids +""" + +import argparse +from pathlib import Path + +import vessim as vs +from infrastructure_study import inputs +from _common import fresh_output, write_study + +SETUPS = {"grid-only": (0, 0), "renewables": (1500, 0), "with-storage": (1500, 3000)} + + +class CoincidentImportPeak(vs.Metric): + """Record the maximum simultaneous aggregate grid-import power.""" + + def __init__(self): + super().__init__( + "W", + description="Maximum simultaneous sum of imported power across all microgrids", + assumptions={"export_offset": False, "sampling": "hourly held states"}, + ) + + def step(self, now, states, duration): + self.value = max(self.value, sum(max(0, -state["p_grid"]) for state in states.values())) + + +def run_scenario( + output: Path, + run_id: str, + solar_kw: float, + battery_kwh: float, + days: int = 7, + seed: int = 42, + timeseries: bool = True, +) -> vs.ExperimentResult: + """Execute one three-microgrid configuration, preserving existing output.""" + if (output / run_id).exists(): + raise FileExistsError(f"Preserving existing run: {output / run_id}") + with vs.Environment("2026-01-01", step_size=3600, name="Multiple microgrids") as env: + metrics = {"coincident_import_peak": CoincidentImportPeak(), "grid_emissions": []} + for index, scale in enumerate((0.7, 1.0, 1.3), start=1): + name = f"Microgrid {index}" + frame = inputs(days=days, seed=seed + index - 1) + dispatchables = [] + if battery_kwh: + dispatchables.append( + vs.SimpleBattery("battery", battery_kwh * 1000, initial_soc=0, c_rate=0.5) + ) + dispatchables.append(vs.GridConnection()) + env.add_microgrid( + [ + vs.Consumer("datacenter", signal=vs.Trace(frame.load_kw * scale * 1000)), + vs.Producer("solar", signal=vs.Trace(frame.solar_cf * solar_kw * 1000)), + vs.Producer("wind", signal=vs.Trace(frame.wind_cf * solar_kw * 1000)), + ], + dispatchables=dispatchables, + name=name, + grid_signals={"carbon": vs.Trace(frame.carbon)}, + ) + metrics["grid_emissions"].append(vs.GridImportEmissions(name, "carbon")) + recorder = env.add_controller( + vs.Recorder( + output / run_id, + run_id=run_id, + timeseries=timeseries, + parameters={ + "solar_kw_per_microgrid": solar_kw, + "wind_kw_per_microgrid": solar_kw, + "battery_kwh_per_microgrid": battery_kwh, + "days": days, + "seed": seed, + "step_seconds": 3600, + }, + custom_metrics=metrics, + ) + ) + env.run(days * 86400) + result = recorder.result + emissions = sum(grid["grid_emissions"] for grid in result.metrics["microgrids"].values()) + result.add_metric( + "total_grid_emissions", + emissions, + "kgCO2e", + description="Sum of grid-import emissions across three microgrids", + assumptions={"source": "synthetic hourly intensities", "export_credit": False}, + ) + result.add_metric( + "total_grid_import", + sum(grid["grid_import"] for grid in result.metrics["microgrids"].values()), + "Wh", + description="Sum of imported electricity across three microgrids", + assumptions={"export_offset": False}, + ) + result.save(output / run_id / "experiment.json") + return result + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--output", type=Path, default=Path("results/multiple-microgrids")) + parser.add_argument("--seed", type=int, default=42) + parser.add_argument("--days", type=int, default=7) + parser.add_argument("--no-timeseries", action="store_true") + args = parser.parse_args() + if args.seed < 0 or args.days < 1: + parser.error("seed must be nonnegative and days must be positive") + fresh_output(args.output) + for name, (solar, battery) in SETUPS.items(): + run_scenario( + args.output, + name, + solar, + battery, + days=args.days, + seed=args.seed, + timeseries=not args.no_timeseries, + ) + write_study( + args.output, + "multiple-microgrids", + "Multiple microgrids", + [(name, name.replace("-", " ").capitalize(), [name]) for name in SETUPS], + run_parameters=["seed"], + description="Equipment choices for three sites with distinct demand and weather.", + ) + print(f"View: python -m vessim view {args.output}") + + +if __name__ == "__main__": + main() diff --git a/examples/replay_csv.py b/examples/replay_csv.py new file mode 100644 index 00000000..3ebbeda1 --- /dev/null +++ b/examples/replay_csv.py @@ -0,0 +1,55 @@ +"""Replay your own load and solar measurements from a CSV. + +Run: python replay_csv.py input.csv --hours 6 +The first column is elapsed seconds. Load and solar are nonnegative watts. +See the signals guide for a complete sample CSV. +""" + +import argparse +from pathlib import Path + +from _common import fresh_output + +import vessim as vs + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "input", + nargs="?", + type=Path, + default=Path(__file__).resolve().parents[1] / "datasets/replay_example.csv", + ) + parser.add_argument("--hours", type=float, default=6) + parser.add_argument("--capacity-wh", type=float, default=1000) + parser.add_argument("--output", type=Path, default=Path("results/replay_csv")) + args = parser.parse_args() + + if args.hours <= 0 or args.capacity_wh <= 0: + parser.error("hours and capacity must be positive") + traces = vs.Trace.from_csv(args.input) + fresh_output(args.output) + with vs.Environment("2026-01-01", step_size=60) as env: + env.add_microgrid( + name="site", + actors=[ + vs.Consumer("load", signal=traces.select("load")), + vs.Producer("solar", signal=traces.select("solar")), + ], + dispatchables=[ + vs.SimpleBattery("battery", capacity=args.capacity_wh), + vs.GridConnection(), + ], + ) + recorder = env.add_controller(vs.Recorder(args.output, run_id="csv-replay")) + env.run(until=args.hours * 3600) + + metrics = recorder.metrics["microgrids"]["site"] + print(f"Grid import: {metrics['grid_import'] / 1000:.2f} kWh") + print(f"Grid export: {metrics['grid_export'] / 1000:.2f} kWh") + print(f"View results: python -m vessim view {args.output}") + + +if __name__ == "__main__": + main() diff --git a/examples/requirements.txt b/examples/requirements.txt new file mode 100644 index 00000000..83ad3b59 --- /dev/null +++ b/examples/requirements.txt @@ -0,0 +1,4 @@ +# Versions used for the published simulations (Python 3.12 or newer). +numpy==2.5.4 +pandas==3.0.6 +optuna==5.0.0 diff --git a/examples/sil_example.py b/examples/sil_example.py deleted file mode 100644 index f829a9d3..00000000 --- a/examples/sil_example.py +++ /dev/null @@ -1,65 +0,0 @@ -"""Software-in-the-Loop example with real Prometheus metrics. - -Uses node_exporter CPU metrics from Prometheus as the server power consumption -signal, demonstrating how vessim connects to real monitoring infrastructure. - -1. pip install 'vessim[sil]' -2. docker compose -f examples/sil/docker-compose.yml up -d -3. Wait ~15s for Prometheus to collect initial node_exporter scrapes -4. python examples/sil_example.py -5. API available at http://localhost:8700 - -Grafana visualization (run in a second terminal): - - docker compose -f examples/sil/docker-compose.grafana.yml up -d -""" -import os - -import vessim as vs - -DATASETS = f"{os.path.dirname(os.path.dirname(os.path.abspath(__file__)))}/datasets" - - -def main(): - # Live mode: sim_start is captured when run() is called and the simulation - # advances at 1× wall-clock. Trace data is queried by elapsed time since - # sim_start, so the solar trace starts replaying from "now". - environment = vs.Environment.live(step_size=1) - - # Server load driven by actual host CPU usage via Prometheus + node_exporter. - # The query returns CPU utilization (0.0-1.0), scaled to 0-1000W. - server = vs.Actor( - name="server", - consumer=True, - signal=vs.PrometheusSignal( - url="http://localhost:9090", - query='(1 - avg(rate(node_cpu_seconds_total{mode="idle"}[1m]))) * 1000', - ), - ) - - # Solar simulation based on historical trace data, scaled to 2 kW peak. - solar = vs.Actor( - name="solar", - signal=vs.Trace.from_csv( - f"{DATASETS}/solar_example.csv", - column="Berlin", - scale=2000, - ), - ) - - battery = vs.SimpleBattery(name="battery", capacity=20, initial_soc=0.5) - - environment.add_microgrid( - name="your_computer", - actors=[server, solar], - dispatchables=[battery], - ) - - # The API controller exposes a REST API and a /metrics endpoint for Prometheus. - environment.add_controller(vs.Api(export_prometheus=True)) - - environment.run() - # You can now use 'curl' or other tools to interact with the API. - - -if __name__ == "__main__": - main() diff --git a/examples/strategy_comparison.py b/examples/strategy_comparison.py new file mode 100644 index 00000000..2f47d1c7 --- /dev/null +++ b/examples/strategy_comparison.py @@ -0,0 +1,161 @@ +"""Compare three illustrative Python policies under the same load and solar traces. + +python examples/strategy_comparison.py --output results/strategy-comparison +python -m vessim view results/strategy-comparison + +Synthetic one-day traces and equipment costs demonstrate the viewer, not an +investment or emissions forecast. Each policy receives identical inputs per seed. +""" + +from __future__ import annotations + +import argparse +from pathlib import Path + +import numpy as np +import pandas as pd + +from _common import fresh_output, write_study + +import vessim as vs + + +class StudyPolicy(vs.DefaultDispatchPolicy): + """Illustrative dispatch policies with deliberately different behavior.""" + + def __init__(self, strategy: str): + super().__init__() + self.strategy = strategy + + def apply(self, p_delta, duration, dispatchables, grid_signals=None): + signals = grid_signals or {} + if self.strategy == "carbon_aware": + self.charge_power = 350 if signals.get("carbon", 1) < 0.15 else 0 + super().apply(p_delta, duration, dispatchables, grid_signals) + + def state(self): + return {**super().state(), "strategy": self.strategy} + + +def run_scenario( + output: Path, + run_id: str, + capacity_wh: float, + strategy: str, + seed: int = 42, + timeseries: bool = True, +) -> vs.ExperimentResult: + """Execute one fresh simulation and return its canonical experiment record.""" + if strategy not in ("self_consumption", "peak_shaving", "carbon_aware"): + raise ValueError(f"Unknown strategy: {strategy}") + if (output / run_id).exists(): + raise FileExistsError(f"Preserving existing run: {output / run_id}") + rng = np.random.default_rng(seed) + seconds = np.arange(0, 86400, 300) + hours = seconds / 3600 + solar = np.maximum(0, np.sin((hours - 6) * np.pi / 12)) * 3200 + load = 850 + 550 * np.exp(-(((hours - 19) / 3) ** 2)) + rng.uniform(0, 180, len(hours)) + carbon = 0.25 + 0.15 * np.cos((hours - 20) * np.pi / 12) + price = np.where((hours >= 17) & (hours < 21), 0.40, 0.18) + + def trace(values): + return vs.Trace(pd.Series(values, index=seconds)) + + with vs.Environment("2026-01-01", step_size=300, name="Campus energy study") as env: + env.add_microgrid( + [ + vs.Producer("solar", signal=trace(solar)), + vs.Consumer("servers", signal=trace(load)), + ], + dispatchables=( + # Use up to 1 kW from the grid before discharging storage. + # The overflow connection keeps demand served if storage runs out. + [ + vs.GridConnection("base_import", max_import=1000, max_export=0), + vs.SimpleBattery("battery", capacity_wh, initial_soc=0.5), + vs.GridConnection("overflow"), + ] + if strategy == "peak_shaving" + else [ + vs.SimpleBattery("battery", capacity_wh, initial_soc=0.5), + vs.GridConnection(), + ] + ), + policy=StudyPolicy(strategy), + name="campus", + grid_signals={"carbon": trace(carbon), "price": trace(price)}, + ) + recorder = env.add_controller( + vs.Recorder( + output / run_id, + run_id=run_id, + timeseries=timeseries, + parameters={ + "strategy": strategy, + "battery_capacity_wh": capacity_wh, + "seed": seed, + }, + custom_metrics={ + "electricity_cost": vs.ElectricityImportCost("campus", "price"), + "grid_emissions": vs.GridImportEmissions("campus", "carbon"), + }, + ) + ) + env.run(86400) + result = recorder.result + result.add_metric( + "battery_capex", + capacity_wh / 1000 * 400, + "EUR", + description="Illustrative installed battery cost", + assumptions={"eur_per_kwh": 400, "source": "synthetic example"}, + ) + result.save(output / run_id / "experiment.json") + return result + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--output", type=Path, default=Path("results/strategy-comparison")) + parser.add_argument("--seed", type=int, default=42) + parser.add_argument("--scenarios", type=int, default=5) + parser.add_argument("--capacity-wh", type=float, default=4000) + parser.add_argument("--no-timeseries", action="store_true") + args = parser.parse_args() + if args.seed < 0 or args.scenarios < 1 or args.capacity_wh <= 0: + parser.error("seed must be nonnegative, scenarios and capacity must be positive") + fresh_output(args.output) + groups = [] + for strategy in ("self_consumption", "peak_shaving", "carbon_aware"): + runs = [] + for seed in range(args.seed, args.seed + args.scenarios): + run_id = f"{strategy}-s{seed}" + run_scenario( + args.output, + run_id, + args.capacity_wh, + strategy, + seed, + timeseries=not args.no_timeseries, + ) + runs.append(run_id) + groups.append((strategy, strategy.replace("_", " ").capitalize(), runs)) + write_study( + args.output, + "dispatch-comparison", + "Probabilistic sampling", + groups, + run_parameters=["seed"], + description=( + "How much does a battery's dispatch strategy matter when demand is uncertain? " + "Self-consumption, peak shaving, and carbon-aware dispatch each face the same " + f"{args.scenarios} synthetic daily demand scenarios. " + "Compare their costs, emissions, and peak grid imports, " + "including how outcomes vary across scenarios." + ), + ) + print(f"View: python -m vessim view {args.output}") + + +if __name__ == "__main__": + main() diff --git a/mkdocs.yml b/mkdocs.yml index 44e7061f..939fd680 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -14,19 +14,25 @@ nav: - Overview: index.md - Getting Started: getting_started.md - Concepts: - - Signals and Datasets: concepts/signals.md - - Dispatchables and Dispatch Policies: concepts/dispatchables.md + - Actors and Signals: concepts/signals.md + - Dispatchables and Policies: concepts/dispatchables.md - Controllers: concepts/controllers.md - - Software-in-the-Loop: concepts/sil.md + - Results and Metrics: concepts/results.md + - Connect External Simulators: concepts/coupling.md + - Optimization: concepts/optimization.md + - Use Cases: + - Simulation Studies: concepts/studies.md + - Live Simulation: concepts/live.md + - Vessim Viewer: vessim_viewer.md - API Reference: - Environment: api_reference/environment.md - Microgrid: api_reference/microgrid.md - - Actor: api_reference/actor.md - - Signal: api_reference/signal.md - - Dispatchable: api_reference/dispatchable.md - - Dispatch Policy: api_reference/dispatch_policy.md - - Controller: api_reference/controller.md - - CLI: api_reference/cli.md + - Actors: api_reference/actor.md + - Signals: api_reference/signal.md + - Dispatchables: api_reference/dispatchable.md + - Dispatch Policies: api_reference/dispatch_policy.md + - Controllers: api_reference/controller.md + - Recording and Metrics: api_reference/recorder.md - Publications: publications.md - About: about.md @@ -51,7 +57,14 @@ plugins: options: show_root_heading: true show_root_full_path: false - show_signature: false + heading_level: 2 + show_signature: true + separate_signature: true + show_signature_annotations: true + signature_crossrefs: true + merge_init_into_class: true + show_source: false + line_length: 80 show_symbol_type_heading: true show_labels: true members_order: source @@ -59,6 +72,8 @@ plugins: - "!^_" markdown_extensions: + - toc: + toc_depth: 2 - admonition - attr_list - pymdownx.highlight: @@ -70,4 +85,4 @@ markdown_extensions: - pymdownx.tabbed: alternate_style: true - pymdownx.arithmatex: - generic: true \ No newline at end of file + generic: true diff --git a/pyproject.toml b/pyproject.toml index 2db8a2d6..ed4c21eb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,23 +25,27 @@ classifiers = [ "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + "Programming Language :: Python :: 3.15", "Programming Language :: Python :: 3 :: Only", ] -requires-python = ">=3.9,<3.14" +requires-python = ">=3.9,<3.16" dependencies = [ "numpy>=1.26.0", "pandas>=2.0.0", - "mosaik>=3.3.0", - "mosaik-api-v3>=3.0.4", "loguru>=0.6.0", - "docopt-ng>=0.9.0", # mosaik-api-v3 depends on an outdated version of docopt, doctopt-ng is a maintained fork - "pyyaml>=6.0.0", ] [project.optional-dependencies] -sil = [ +mosaik = [ + "mosaik>=3.6.0,<4", + "mosaik-api-v3>=3.0.4,<4", + "docopt-ng>=0.9.0", +] +live = [ "requests>=2.26.0", # PrometheusSignal, WatttimeSignal, Api "fastapi>=0.104.0", # Api broker + "pydantic>=2.14.0; python_version >= '3.15'", # Python 3.15 support "uvicorn>=0.23.0", # Api broker "prometheus_client>0.22.0", # Api /metrics export ] @@ -50,7 +54,6 @@ dev = [ "mypy", "types-psutil", "pandas-stubs", - "types-PyYAML", "types-requests", "black", "ruff" @@ -69,15 +72,12 @@ vessim = "vessim._cli:main" requires = ["hatchling", "hatch-vcs"] build-backend = "hatchling.build" -[tool.hatch.build.targets.sdist.force-include] -"vessim/_viewer_dist" = "vessim/_viewer_dist" +[tool.hatch.build] +artifacts = ["vessim/_viewer_dist/**"] [tool.hatch.build.targets.wheel] packages = ["vessim"] -[tool.hatch.build.targets.wheel.force-include] -"vessim/_viewer_dist" = "vessim/_viewer_dist" - [tool.hatch.metadata] allow-direct-references = true @@ -129,4 +129,4 @@ filterwarnings = [ "error", # https://github.com/dateutil/dateutil/issues/1314 "ignore:datetime.datetime.utcfromtimestamp:DeprecationWarning:dateutil.tz.tz", -] \ No newline at end of file +] diff --git a/tests/simulators/power_source.py b/tests/simulators/power_source.py new file mode 100644 index 00000000..2d16862e --- /dev/null +++ b/tests/simulators/power_source.py @@ -0,0 +1,45 @@ +"""Small independent mosaik simulator, also runnable as an external process.""" +from pathlib import Path +import mosaik_api_v3 + + +class PowerSource(mosaik_api_v3.Simulator): + def __init__(self): + super().__init__({"type": "time-based", "models": { + "Source": {"public": True, "params": [], "attrs": ["power", "feedback"]}, + }}) + self.value = 100 + self.period = 1 + self.marker = None + self.records = None + self.failure = False + + def init(self, sid, time_resolution=1.0, **params): + self.period = params.get("period", 1) + self.marker = params.get("marker") + self.records = params.get("records") + self.failure = params.get("failure", False) + return self.meta + + def create(self, num, model): + return [{"eid": str(i), "type": model} for i in range(num)] + + def step(self, time, inputs, max_advance): + if self.failure and time == 1: + raise RuntimeError("external failure") + feedback = sum(inputs.get("0", {}).get("feedback", {}).values()) + self.value = 100 + time + feedback + if self.records is not None: + self.records.append((time, feedback)) + return time + self.period + + def get_data(self, outputs): + return {eid: {attr: self.value for attr in attrs} for eid, attrs in outputs.items()} + + def finalize(self): + if self.marker: + Path(self.marker).write_text("finalized") + + +if __name__ == "__main__": + mosaik_api_v3.start_simulation(PowerSource()) diff --git a/tests/test_actor.py b/tests/test_actor.py index 656c0798..d22ee4f6 100644 --- a/tests/test_actor.py +++ b/tests/test_actor.py @@ -3,47 +3,113 @@ import pytest -from vessim.actor import Actor -from vessim.signal import Signal - - -class TestActor: - @pytest.fixture - def mock_signal(self): - signal = Mock(spec=Signal) - signal.at.return_value = 10.0 - signal.__str__ = Mock(return_value="MockSignal") - return signal - - def test_init(self, mock_signal): - actor = Actor(name="test_actor", signal=mock_signal, step_size=60) - assert actor.name == "test_actor" - assert actor.signal == mock_signal - assert actor.step_size == 60 - - def test_power(self, mock_signal): - actor = Actor(name="test_actor", signal=mock_signal) - elapsed = timedelta(hours=12) - power = actor.power(elapsed) - assert power == 10.0 - mock_signal.at.assert_called_once_with(elapsed) - - def test_state(self, mock_signal): - actor = Actor(name="test_actor", signal=mock_signal) - elapsed = timedelta(hours=12) - state = actor.state(elapsed) - assert state == {"power": 10.0} - mock_signal.at.assert_called_once_with(elapsed) - - def test_consumer_negates_power(self, mock_signal): - actor = Actor(name="test_actor", signal=mock_signal, consumer=True) - assert actor.power(timedelta(hours=12)) == -10.0 - - def test_consumer_false_by_default(self, mock_signal): - actor = Actor(name="test_actor", signal=mock_signal) - assert actor.consumer is False - - def test_finalize(self, mock_signal): - actor = Actor(name="test_actor", signal=mock_signal) - actor.finalize() - mock_signal.finalize.assert_called_once() \ No newline at end of file +import vessim as vs + + +@pytest.mark.parametrize( + "actor_type, value, expected", + [ + (vs.Actor, -700, -700), + (vs.Actor, 700, 700), + (vs.Consumer, 700, -700), + (vs.Producer, 700, 700), + (vs.Consumer, 0, 0), + (vs.Producer, 0, 0), + ], +) +@pytest.mark.parametrize("use_signal", [False, True]) +def test_power_convention_and_single_sample(actor_type, value, expected, use_signal): + signal = Mock(spec=vs.Signal) + signal.at.return_value = value + actor = actor_type("device", signal=signal if use_signal else value, step_size=60) + elapsed = timedelta(hours=1) + assert actor.sample(elapsed) == (expected, {"power": expected}) + if use_signal: + signal.at.assert_called_once_with(elapsed) + assert actor.step_size == 60 + + +@pytest.mark.parametrize("actor_type", [vs.Actor, vs.Consumer, vs.Producer]) +@pytest.mark.parametrize("value", [float("nan"), float("inf"), True, "700", lambda t: 700]) +def test_invalid_constant_rejected_without_changing_input(actor_type, value): + with pytest.raises(ValueError, match="finite number"): + actor_type("invalid", signal=value) + actor = actor_type("device", signal=700) + with pytest.raises(ValueError, match="finite number"): + actor.signal = value + assert isinstance(actor.signal, vs.StaticSignal) + assert actor.signal.at() == 700 + + +@pytest.mark.parametrize("actor_type", [vs.Consumer, vs.Producer]) +def test_negative_magnitude_rejected_at_assignment_and_sampling(actor_type): + with pytest.raises(ValueError, match="nonnegative"): + actor_type("device", signal=-1) + actor = actor_type("device", signal=700) + with pytest.raises(ValueError, match="nonnegative"): + actor.signal = -1 + assert isinstance(actor.signal, vs.StaticSignal) + assert actor.signal.at() == 700 + signal = Mock(spec=vs.Signal) + signal.at.side_effect = [1, -1] + actor.signal = signal + actor.sample(0) + with pytest.raises(ValueError, match="nonnegative"): + actor.sample(1) + + +def test_signed_actor_can_change_direction(): + signal = Mock(spec=vs.Signal) + signal.at.side_effect = [-700, 200] + actor = vs.Actor("net_meter", signal=signal) + assert actor.sample(0)[0] == -700 + assert actor.sample(1)[0] == 200 + + +def test_custom_actor_extends_one_sample_with_diagnostics(): + signal = Mock(spec=vs.Signal) + signal.at.side_effect = [700, 800] + + class Datacenter(vs.Consumer): + def sample(self, elapsed): + power, state = super().sample(elapsed) + return power, {**state, "active_jobs": 3} + + actor = Datacenter("dc", signal=signal) + assert actor.sample(0) == (-700, {"power": -700, "active_jobs": 3}) + signal.at.assert_called_once_with(0) + + +def test_constant_update_and_configuration(): + actor = vs.Consumer("load", signal=700) + assert actor.config() == { + "name": "load", + "type": "Consumer", + "signal": "StaticSignal(700)", + "step_size": None, + } + actor.signal = 1200 + assert actor.sample(60) == (-1200, {"power": -1200}) + + +def test_numeric_assignment_replaces_shared_signal_without_changing_other_actor(): + signal = vs.StaticSignal(700) + first = vs.Consumer("first", signal=signal) + second = vs.Consumer("second", signal=signal) + assert first.signal is second.signal is signal + first.signal = 500 + assert first.signal is not signal + assert isinstance(first.signal, vs.StaticSignal) + assert first.sample(0)[0] == -500 + assert second.sample(0)[0] == -700 + + +@pytest.mark.parametrize("kind", [vs.Consumer, vs.Producer]) +def test_explicit_constant_validated_on_assignment_and_after_mutation(kind): + with pytest.raises(ValueError, match="nonnegative"): + kind("device", signal=vs.StaticSignal(-1)) + signal = vs.StaticSignal(700) + actor = kind("device", signal=signal) + signal.set_value(-1) + with pytest.raises(ValueError, match="nonnegative"): + actor.sample(0) diff --git a/tests/test_backends.py b/tests/test_backends.py new file mode 100644 index 00000000..88ca4fe4 --- /dev/null +++ b/tests/test_backends.py @@ -0,0 +1,418 @@ +"""Behavioral contracts shared by native and mosaik execution.""" + +import importlib.util +from datetime import timedelta +import subprocess +import sys +import time +from pathlib import Path + +import numpy as np +from pandas.testing import assert_frame_equal +import pytest + +import vessim as vs + +HAS_MOSAIK = importlib.util.find_spec("mosaik") is not None +BACKENDS = [ + "native", + pytest.param( + "mosaik", + marks=pytest.mark.skipif(not HAS_MOSAIK, reason="optional mosaik extra not installed"), + ), +] + + +@pytest.fixture(params=BACKENDS) +def backend(request): + return request.param + + +class Record(vs.Controller): + def __init__(self, event=None, failure=None): + self.events = event if event is not None else [] + self.failure = failure + + def start(self, env): + self.events.append("start") + + def step(self, now, states): + self.events.append("step") + if self.failure: + raise self.failure + + def finalize(self): + self.events.append("finalize") + + +@pytest.mark.skipif(not HAS_MOSAIK, reason="optional mosaik extra not installed") +@pytest.mark.parametrize( + "case", ["static", "traces", "csv", "many_grids", "mixed_rates", "feedback"] +) +def test_backends_record_same_trajectory(case, tmp_path): + from benchmarks.run import build + + fixture = Path(__file__).parents[1] / "benchmarks/input.csv" + traces = {c: vs.Trace.from_csv(fixture, column=c) for c in ("solar", "load", "carbon")} + steps = 200 if case == "many_grids" else 1000 + frames = [] + for backend in ("native", "mosaik"): + env, recorder = build(backend, case, traces, tmp_path / backend) + result = env.run(steps * 60) + assert result.steps == steps + frame = recorder.to_df() + assert len(frame) == steps * (10 if case == "many_grids" else 1) + frames.append(frame) + assert_frame_equal(frames[0], frames[1], check_exact=False, rtol=1e-12, atol=1e-9) + + +@pytest.mark.parametrize("horizon", [0, 0.5, 1, 2, 2.5]) +def test_boundaries_with_explicit_grid(backend, horizon): + env = vs.Environment("2026-01-01", backend=backend) + env.add_microgrid( + [vs.Actor("a.with.dots", 1)], + name="grid.with.dots", + dispatchables=[vs.GridConnection()], + ) + log = env.add_controller(vs.Recorder(metrics=False)) + result = env.run(horizon) + assert result.steps == len(range(0, int(np.ceil(horizon)))) + assert all( + state["grid.with.dots"]["dispatch_states"] + == {"grid": {"current_power": 1, "connected": True}} + for state in log.log.values() + ) + + +@pytest.mark.parametrize("failure", [RuntimeError("test failure"), KeyboardInterrupt()]) +def test_step_failure_cleanup(backend, failure): + closed = [] + + class Signal(vs.Signal): + def __init__(self, value): + self.value = value + + def at(self, elapsed): + return self.value + + def finalize(self): + closed.append("signal") + + env = vs.Environment("2026-01-01", backend=backend) + env.add_microgrid( + [vs.Actor("a", Signal(1))], + grid_signals={"x": Signal(2)}, + dispatchables=[vs.GridConnection()], + ) + record = env.add_controller(Record(failure=failure)) + with pytest.raises(type(failure)): + env.run(3) + assert record.events == ["start", "step", "finalize"] + assert closed == ["signal", "signal"] + assert env.status == ("interrupted" if isinstance(failure, KeyboardInterrupt) else "failed") + env.close() + assert len(closed) == 2 + + +def test_partial_initialization_and_cleanup_errors(backend): + events = [] + + class Broken(Record): + def start(self, env): + super().start(env) + raise ValueError("start failed") + + def finalize(self): + super().finalize() + raise RuntimeError("cleanup failed") + + env = vs.Environment("2026-01-01", backend=backend) + env.add_microgrid([vs.Actor("a", 1)], dispatchables=[vs.GridConnection()]) + env.add_controller(Broken(events)) + unstarted = env.add_controller(Record()) + with pytest.raises(ValueError, match="start failed"): + env.run(1) + assert events == ["start", "finalize"] + assert unstarted.events == [] + assert env._closed + + +def test_sampling_feedback_and_integration_order(backend): + samples = [] + + class Actor(vs.Actor): + def sample(self, elapsed): + samples.append(elapsed) + return super().sample(elapsed) + + actor = Actor("a", signal=100, step_size=120) + battery = vs.SimpleBattery("battery", 100, initial_soc=0.5) + + class Feedback(vs.Controller): + def step(self, now, states): + actor.signal = 200 + + env = vs.Environment("2026-01-01", step_size=60, backend=backend) + env.add_microgrid([actor], [battery, vs.GridConnection()], name="grid") + env.add_controller(Feedback()) + log = env.add_controller(vs.Recorder(metrics=False)) + env.run(180) + rows = [row["grid"] for row in log.log.values()] + assert samples == [0, 120] # One read per actor update. + assert [row["p_delta"] for row in rows] == [100, 100, 200] + assert rows[0]["dispatch_states"]["battery"]["soc"] == 0.5 + assert battery.charge_level == pytest.approx(50 + (2 * 100 + 200) / 60) + + +def test_live_wall_clock_pacing(backend): + timestamps = [] + + class Observe(vs.Controller): + def step(self, now, states): + timestamps.append(time.monotonic()) + + env = vs.Environment.live(backend=backend) + env.add_microgrid([vs.Actor("a", 1)], dispatchables=[vs.GridConnection()]) + env.add_controller(Observe()) + t0 = time.monotonic() + env.run(1.05) + assert len(timestamps) == 2 + assert timestamps[1] - t0 >= 1.0 + assert time.monotonic() - t0 >= 1.0 + + +def test_native_import_does_not_require_mosaik(): + source = """ +import sys +class BlockMosaik: + def find_spec(self, fullname, *args): + if fullname.startswith('mosaik'): + raise ImportError('blocked optional dependency') +sys.meta_path.insert(0, BlockMosaik()) +import vessim as vs +e = vs.Environment('2026-01-01') +e.add_microgrid([vs.Actor('a',1)], [vs.GridConnection()]) +assert e.run(2).steps == 2 +assert not any(n.startswith('mosaik') for n in sys.modules) +try: + vs.Environment('2026-01-01', backend='mosaik').world +except ImportError as exc: + assert 'vessim[mosaik]' in str(exc) +else: + raise AssertionError('expected missing-extra error') +""" + subprocess.run( + [sys.executable, "-c", source], check=True, capture_output=True, text=True, timeout=30 + ) + + +def test_unbounded_live_interrupt(backend): + env = vs.Environment.live(backend=backend) + env.add_microgrid([vs.Actor("a", 1)], dispatchables=[vs.GridConnection()]) + record = env.add_controller(Record(failure=KeyboardInterrupt())) + with pytest.raises(KeyboardInterrupt): + env.run() + assert record.events == ["start", "step", "finalize"] + assert env.status == "interrupted" + + +def test_custom_actor_and_dispatchable_extensions(backend): + class Device(vs.Dispatchable): + def __init__(self): + super().__init__("device") + self.energy = 0.0 + + def feasible_range(self, duration): + return -50, 50 + + def step(self, duration): + self.energy += self.current_power * duration / 3600 + + def state(self): + return {"nested": {"energy": self.energy}} + + def config(self): + return {} + + class Actor(vs.Actor): + def sample(self, elapsed): + power, state = super().sample(elapsed) + return power, {**state, "temperature": [20 + elapsed]} + + env = vs.Environment("2026-01-01", step_size=60, backend=backend) + device = Device() + env.add_microgrid([Actor("a", 100)], [device, vs.GridConnection()], name="grid") + log = env.add_controller(vs.Recorder(metrics=False)) + result = env.run(120) + rows = list(log.log.values()) + assert rows[0]["grid"]["dispatch_states"]["device"]["nested"]["energy"] == 0 + assert device.energy == pytest.approx(2 * 50 / 60) + assert rows[1]["grid"]["actor_states"]["a"]["temperature"] == [80] + result.states["grid"]["actor_states"]["a"]["temperature"][0] = 999 + assert rows[1]["grid"]["actor_states"]["a"]["temperature"] == [80] + + +@pytest.mark.parametrize("horizon", [0, 0.5, 30, 60, 90, 120, 150.5]) +@pytest.mark.parametrize("record", [False, True]) +def test_final_state_covers_the_exact_horizon(backend, horizon, record): + env = vs.Environment("2026-01-01", step_size=60, backend=backend) + battery = vs.SimpleBattery("battery", capacity=2, initial_soc=1) + env.add_microgrid([vs.Actor("load", -60)], [battery, vs.GridConnection()], name="site") + rec = env.add_controller(vs.Recorder()) if record else None + result = env.run(horizon) + discharged = min(2, horizon / 60) + assert battery.charge_level == pytest.approx(2 - discharged) + if horizon: + assert result.states["site"]["dispatch_states"]["battery"]["charge_level"] == ( + pytest.approx(battery.charge_level) + ) + if rec: + metrics = rec.metrics["microgrids"]["site"] + assert metrics["energy_consumed"] == pytest.approx(horizon / 60) + assert metrics["grid_import"] + discharged == pytest.approx(horizon / 60) + assert len(rec.log) == int(np.ceil(horizon / 60)) + assert (max(rec.log) - env.sim_start).total_seconds() < horizon + else: + assert result.states == {} + + +def test_final_partial_interval_uses_remaining_battery_energy(backend): + env = vs.Environment("2026-01-01", step_size=60, backend=backend) + battery = vs.SimpleBattery("battery", capacity=1.5, initial_soc=1) + # A pre-existing setpoint must not consume energy before time zero. + battery.set_power(-60, 60) + env.add_microgrid([vs.Actor("load", -60)], [battery, vs.GridConnection()], name="site") + rec = env.add_controller(vs.Recorder()) + result = env.run(90) + rows = list(rec.log.values()) + assert rows[0]["site"]["dispatch_states"]["battery"]["charge_level"] == 1.5 + assert rows[1]["site"]["dispatch_states"]["battery"]["charge_level"] == 0.5 + assert rows[1]["site"]["p_grid"] == 0 # 0.5 Wh covers the remaining 30 s. + assert rec.metrics["microgrids"]["site"]["grid_import"] == 0 + assert result.states["site"]["dispatch_states"]["battery"]["charge_level"] == 0 + assert result.last_time == env.sim_start + timedelta(seconds=60) + + +def test_clc_dispatch_respects_energy_available_above_min_soc(backend): + env = vs.Environment("2026-01-01", step_size=3600, backend=backend) + battery = vs.ClcBattery( + "battery", + initial_soc=1, + min_soc=0.2, + v_2=10, + u_1=0, + u_2=0, + eta_d=1, + eta_c=1, + alpha_d=-10, + ) + env.add_microgrid([vs.Actor("load", -10)], [battery, vs.GridConnection()], name="site") + rec = env.add_controller(vs.Recorder(timeseries=False)) + env.run(3600) + assert battery.charge_level == pytest.approx(2) + assert rec.metrics["microgrids"]["site"]["grid_import"] == pytest.approx(2) + + +@pytest.mark.parametrize("actor_period", [60, 120]) +def test_demand_response_changes_only_future_samples(backend, actor_period): + datacenter = vs.Consumer("dc", signal=1000, step_size=actor_period) + + class DemandResponse(vs.Controller): + def step(self, now, states): + datacenter.signal = 700 + + env = vs.Environment("2026-01-01", step_size=60, backend=backend) + env.add_microgrid([datacenter], [vs.GridConnection()], name="site") + env.add_controller(DemandResponse()) + recorder = env.add_controller(vs.Recorder()) + env.run(180) + expected = [-1000, -700 if actor_period == 60 else -1000, -700] + rows = [states["site"] for states in recorder.log.values()] + assert [row["actor_states"]["dc"]["power"] for row in rows] == expected + assert [row["p_delta"] for row in rows] == expected + assert [row["p_grid"] for row in rows] == expected + assert recorder.metrics["actors"]["site"]["dc"]["energy_consumed"] == pytest.approx( + -sum(expected) / 60 + ) + config = recorder.result.config["microgrids"]["site"]["actors"][0] + assert config["type"] == "Consumer" + assert config["signal"] == "StaticSignal(1000)" + + +def test_shared_and_replaced_signal_lifecycle(backend): + events = [] + + class Input(vs.Signal): + def __init__(self, name, value): + self.name = name + self.value = value + + def start(self): + events.append((self.name, "start")) + + def at(self, elapsed): + assert (self.name, "start") in events + return self.value + + def finalize(self): + events.append((self.name, "close")) + + first = Input("first", 10) + second = Input("second", 20) + load = vs.Consumer("load", signal=first) + solar = vs.Producer("solar", signal=first) + env = vs.Environment("2026-01-01", backend=backend) + grid = env.add_microgrid( + [load, solar], [vs.GridConnection()], grid_signals={"context": first}, name="site" + ) + assert events == [] + + class Replace(vs.Controller): + def step(self, now, states): + load.signal = second + solar.signal = 0 + grid.grid_signals["context"] = 0.25 + + env.add_controller(Replace()) + rec = env.add_controller(vs.Recorder()) + env.run(2) + env.close() + assert events == [ + ("first", "start"), + ("second", "start"), + ("first", "close"), + ("second", "close"), + ] + assert [s["site"]["p_delta"] for s in rec.log.values()] == [0, -20] + assert [s["site"]["grid_signals"]["context"] for s in rec.log.values()] == [10, 0.25] + + +def test_signal_start_failure_closes_all_inputs(backend): + events = [] + + class Input(vs.Signal): + def __init__(self, name, fail=False): + self.name = name + self.fail = fail + + def start(self): + events.append((self.name, "start")) + if self.fail: + raise RuntimeError("input startup failed") + + def at(self, elapsed): + raise AssertionError("Failed setup must not sample inputs") + + def finalize(self): + events.append((self.name, "close")) + + env = vs.Environment("2026-01-01", backend=backend) + env.add_microgrid( + [vs.Consumer("load", signal=Input("load", fail=True))], + [vs.GridConnection()], + grid_signals={"price": Input("price")}, + ) + with pytest.raises(RuntimeError, match="input startup failed"): + env.run(1) + env.close() + assert events == [("load", "start"), ("load", "close"), ("price", "close")] diff --git a/tests/test_benchmarks.py b/tests/test_benchmarks.py new file mode 100644 index 00000000..ab573e7d --- /dev/null +++ b/tests/test_benchmarks.py @@ -0,0 +1,74 @@ +"""Keep benchmark entry points runnable as simulation and recording APIs evolve.""" + +import json +from pathlib import Path +import subprocess +import sys + +from benchmarks.run import CASES, environment_info + +ROOT = Path(__file__).resolve().parents[1] + + +def run_benchmark(script, *args): + result = subprocess.run( + [sys.executable, str(ROOT / "benchmarks" / script), *map(str, args)], + cwd=ROOT, + capture_output=True, + text=True, + timeout=60, + ) + assert result.returncode == 0, result.stderr + + +def test_workloads_repeat_csv_recording_and_profile_each_case(tmp_path): + output = tmp_path / "reports/native.json" + profiles = tmp_path / "profiles" + run_benchmark( + "run.py", "--steps", 6, "--repeats", 2, "--output", output, "--profile", profiles + ) + report = json.loads(output.read_text()) + assert set(report["cases"]) == set(CASES) + assert {p.stem for p in profiles.glob("*.prof")} == set(CASES) + for case in report["cases"].values(): + assert len(case["runs"]) == 2 + assert len(case["trajectory_sha256"]) == 64 + assert ( + report["cases"]["csv"]["trajectory_sha256"] + == report["cases"]["traces"]["trajectory_sha256"] + ) + + +def test_study_inputs_cover_both_throughput_and_memory_horizons(tmp_path): + output = tmp_path / "reports/studies.json" + run_benchmark( + "studies.py", + "--runs", + 2, + "--steps", + 150, + "--memory-steps", + 30, + 180, + "--output", + output, + ) + report = json.loads(output.read_text()) + assert report["scenarios_per_second"] > 0 + assert {(row["output"], row["steps"]) for row in report["memory"]} == { + (mode, steps) for mode in ("none", "metrics", "trajectory") for steps in (30, 180) + } + + +def test_native_metadata_does_not_require_mosaik(monkeypatch): + from importlib import metadata + + version = metadata.version + + def without_mosaik(package): + if package == "mosaik": + raise metadata.PackageNotFoundError(package) + return version(package) + + monkeypatch.setattr(metadata, "version", without_mosaik) + assert environment_info()["mosaik"] is None diff --git a/tests/test_controller.py b/tests/test_controller.py deleted file mode 100644 index 22d59759..00000000 --- a/tests/test_controller.py +++ /dev/null @@ -1,105 +0,0 @@ -import pytest -from datetime import datetime -import pandas as pd -import tempfile -import os -import csv -from vessim.controller import MemoryLogger, CsvLogger -from vessim.microgrid import MicrogridState - -class TestMemoryLogger: - @pytest.fixture - def logger(self): - return MemoryLogger() - - @pytest.fixture - def sample_data(self): - now = datetime(2023, 1, 1, 12, 0) - state: MicrogridState = { - "p_delta": 10.0, - "p_grid": 5.0, - "actor_states": {"actor1": {"power": 2}}, - "policy_state": {"mode": "charge"}, - "dispatch_states": {"battery": {"soc": 0.5}}, - "grid_signals": {"co2": 100}, - } - return now, {"mg1": state} - - def test_step_logs_data(self, logger, sample_data): - now, states = sample_data - logger.step(now, states) - assert logger.log[now] == states - - def test_to_dict(self, logger, sample_data): - now, states = sample_data - logger.step(now, states) - d = logger.to_dict() - assert d[now] == states - - def test_to_df(self, logger, sample_data): - now, states = sample_data - logger.step(now, states) - df = logger.to_df() - - assert isinstance(df, pd.DataFrame) - assert len(df) == 1 - # Check index - assert df.index[0] == (now, "mg1") - # Check columns (flattened) - assert df.loc[(now, "mg1")]["p_delta"] == 10.0 - assert df.loc[(now, "mg1")]["actor_states.actor1.power"] == 2 - - -class TestCsvLogger: - @pytest.fixture - def temp_dir(self): - with tempfile.TemporaryDirectory() as d: - yield d - - def test_step_writes_csv(self, temp_dir): - logger = CsvLogger(temp_dir) - now = datetime(2023, 1, 1, 12, 0) - state: MicrogridState = { - "p_delta": 10.0, - "p_grid": 5.0, - "actor_states": {"actor1": {"power": 2}}, - "policy_state": {}, - "dispatch_states": None, - "grid_signals": None, - } - - logger.step(now, {"mg1": state}) - - with open(os.path.join(temp_dir, "timeseries.csv"), "r") as f: - reader = csv.DictReader(f) - rows = list(reader) - - assert len(rows) == 1 - assert rows[0]["microgrid"] == "mg1" - assert rows[0]["time"] == str(now) - assert float(rows[0]["p_delta"]) == 10.0 - assert float(rows[0]["actor_states.actor1.power"]) == 2.0 - - def test_step_appends_csv(self, temp_dir): - logger = CsvLogger(temp_dir) - now1 = datetime(2023, 1, 1, 12, 0) - now2 = datetime(2023, 1, 1, 12, 1) - state: MicrogridState = { - "p_delta": 10.0, - "p_grid": 5.0, - "actor_states": {}, - "policy_state": {}, - "dispatch_states": None, - "grid_signals": None, - } - - logger.step(now1, {"mg1": state}) - logger.step(now2, {"mg1": state}) - - with open(os.path.join(temp_dir, "timeseries.csv"), "r") as f: - reader = csv.DictReader(f) - rows = list(reader) - - assert len(rows) == 2 - assert rows[0]["time"] == str(now1) - assert rows[1]["time"] == str(now2) diff --git a/tests/test_dispatchable.py b/tests/test_dispatchable.py index 3ea80ea4..30c192b2 100644 --- a/tests/test_dispatchable.py +++ b/tests/test_dispatchable.py @@ -4,6 +4,21 @@ import vessim as vs +@pytest.mark.parametrize("factory", [vs.SimpleBattery, vs.ClcBattery]) +def test_battery_records_held_power(factory): + battery = ( + factory("battery", initial_soc=0.5) + if factory is vs.ClcBattery + else factory("battery", 1000, initial_soc=0.5) + ) + assert battery.state()["current_power"] == 0 + for power in (1, -1, 0): + battery.set_power(power, 1) + assert battery.state()["current_power"] == power + battery.step(1) + assert battery.state()["current_power"] == power + + class TestSimpleBattery: @pytest.fixture def battery(self) -> vs.SimpleBattery: @@ -49,9 +64,7 @@ def test_step(self, battery, power, duration, exp_charge_level, exp_soc): (-10, 30, 0, 0), ], ) - def test_step_c_rate( - self, battery_c, power, duration, exp_charge_level, exp_soc - ): + def test_step_c_rate(self, battery_c, power, duration, exp_charge_level, exp_soc): # duration in minutes and charge_level in Wh battery_c.set_power(power, duration=duration * 60) battery_c.step(duration=duration * 60) @@ -200,21 +213,19 @@ def test_feasible_range(self, battery): # Large duration: limited by energy # For charging: (5 - 10) / (0/3.63 - 3600*0.5/3600) = -5 / -0.5 = 10W per cell # 10W > alpha_c=0.5, so still limited by alpha (5W per cell * 10 cells = 50W) - # For discharging: (5 - 0) / (0/3.63 - 3600*2/3600) = 5 / -2 = -2.5W per cell - # -2.5W > alpha_d=-10, so limited by energy (-25W total) - assert battery.feasible_range(3600) == (-25, 50) + # For discharging, min_soc=0.1 reserves 1 Wh per cell. With eta_d=2, + # only (5 - 1) / 2 = 2 Wh per cell is deliverable over this hour. + assert battery.feasible_range(3600) == (-20, 50) @pytest.mark.parametrize( "power, duration, exp_charge_level", [ (1, 5, 7.583333333333333), (1, 15, 7.75), - (0.5, 45, 7.875), # energy limit for 45 min is 1.666W. 0.5W is safe. + (0.5, 45, 7.875), # energy limit for 45 min is 1.666W. 0.5W is safe. ], ) - def test_step_upper_energy_limits( - self, battery_u_energy, power, duration, exp_charge_level - ): + def test_step_upper_energy_limits(self, battery_u_energy, power, duration, exp_charge_level): # duration in minutes battery_u_energy.set_power(power, duration=duration * 60) battery_u_energy.step(duration=duration * 60) @@ -236,9 +247,7 @@ def test_feasible_range_u_energy(self, battery_u_energy): (-0.5, 45, 2.125), ], ) - def test_step_lower_energy_limits( - self, battery_l_energy, power, duration, exp_charge_level - ): + def test_step_lower_energy_limits(self, battery_l_energy, power, duration, exp_charge_level): # duration in minutes battery_l_energy.set_power(power, duration=duration * 60) battery_l_energy.step(duration=duration * 60) @@ -258,10 +267,6 @@ def policy(self) -> vs.DefaultDispatchPolicy: def policy_charge(self) -> vs.DefaultDispatchPolicy: return vs.DefaultDispatchPolicy(charge_power=10) - @pytest.fixture - def policy_islanded(self) -> vs.DefaultDispatchPolicy: - return vs.DefaultDispatchPolicy(mode="islanded") - @pytest.mark.parametrize( "power, duration, exp_delta, exp_soc", [ @@ -277,9 +282,11 @@ def policy_islanded(self) -> vs.DefaultDispatchPolicy: ) def test_apply_merit_order(self, battery, policy, power, duration, exp_delta, exp_soc): # duration in hours - p_grid = policy.apply(power, duration * 3600, [battery]) + grid = vs.GridConnection() + result = policy.apply(power, duration * 3600, [battery, grid]) battery.step(duration * 3600) - assert p_grid == exp_delta + assert result is None + assert grid.current_power == exp_delta assert math.isclose(battery.soc(), exp_soc) @pytest.mark.parametrize( @@ -296,9 +303,11 @@ def test_apply_merit_order(self, battery, policy, power, duration, exp_delta, ex ) def test_apply_charge_power(self, battery, policy_charge, power, duration, exp_delta, exp_soc): # duration in hours - p_grid = policy_charge.apply(power, duration * 3600, [battery]) + grid = vs.GridConnection() + result = policy_charge.apply(power, duration * 3600, [battery, grid]) battery.step(duration * 3600) - assert p_grid == exp_delta + assert result is None + assert grid.current_power == exp_delta assert math.isclose(battery.soc(), exp_soc) @pytest.mark.parametrize( @@ -308,17 +317,17 @@ def test_apply_charge_power(self, battery, policy_charge, power, duration, exp_d (0, 1000, 0, 0.8), # Charge (5, 2, 0, 0.9), - (100, 4, 0, 1), # Discharge (-5, 2, 0, 0.7), (-10, 7, 0, 0.1), ], ) - def test_apply_islanded(self, battery, policy_islanded, power, duration, exp_delta, exp_soc): + def test_apply_islanded(self, battery, policy, power, duration, exp_delta, exp_soc): # duration in hours - p_grid = policy_islanded.apply(power, duration * 3600, [battery]) + result = policy.apply(power, duration * 3600, [battery]) battery.step(duration * 3600) - assert p_grid == exp_delta + assert result is None + assert battery.current_power == power assert math.isclose(battery.soc(), exp_soc) @pytest.mark.parametrize( @@ -330,14 +339,69 @@ def test_apply_islanded(self, battery, policy_islanded, power, duration, exp_del ], ) def test_apply_no_dispatchables(self, policy, power, duration): - # With no dispatchables, all power goes to grid - p_grid = policy.apply(power, duration, []) - assert p_grid == power - - def test_apply_fails_if_no_power_in_islanded(self, battery, policy_islanded): - with pytest.raises(RuntimeError): - policy_islanded.apply(-1, 71 * 3600, [battery]) - - def test_apply_fails_if_no_power_in_islanded_without_dispatchables(self, policy_islanded): - with pytest.raises(RuntimeError): - policy_islanded.apply(-1, 1, []) + if power == 0: + assert policy.apply(power, duration, []) is None + else: + with pytest.raises(RuntimeError, match="dispatch must balance exactly"): + policy.apply(power, duration, []) + + def test_apply_rejects_unmet_demand(self, battery, policy): + with pytest.raises(RuntimeError, match="Unmet demand"): + policy.apply(-1, 71 * 3600, [battery]) + assert battery.current_power == 0 + + def test_apply_rejects_surplus(self, battery, policy): + with pytest.raises(RuntimeError, match="Unabsorbed surplus"): + policy.apply(100, 4 * 3600, [battery]) + assert battery.current_power == 0 + + +@pytest.mark.parametrize("value", [math.nan, math.inf, -math.inf]) +@pytest.mark.parametrize("parameter", ["capacity", "initial_soc", "min_soc", "c_rate"]) +def test_simple_battery_rejects_nonfinite_parameters(parameter, value): + with pytest.raises(ValueError, match="finite"): + vs.SimpleBattery("battery", **{"capacity": 100, parameter: value}) + + +@pytest.mark.parametrize("capacity", [0, -1]) +def test_simple_battery_requires_positive_capacity(capacity): + with pytest.raises(ValueError, match="capacity.*positive"): + vs.SimpleBattery("battery", capacity) + + +@pytest.mark.parametrize( + "factory", [lambda: vs.SimpleBattery("battery", 100), lambda: vs.ClcBattery("battery")] +) +@pytest.mark.parametrize("value", [math.nan, math.inf, -math.inf]) +def test_invalid_setpoints_and_durations_do_not_change_battery(factory, value): + battery = factory() + original = battery.state() + with pytest.raises(ValueError, match="finite"): + battery.set_power(value, 60) + with pytest.raises(ValueError, match="finite"): + battery.step(value) + with pytest.raises(ValueError, match="finite"): + battery.feasible_range(value) + assert battery.current_power == 0 + assert battery.state() == original + + +@pytest.mark.parametrize( + "parameter", + [ + "nom_voltage", + "u_1", + "v_1", + "u_2", + "v_2", + "alpha_d", + "alpha_c", + "eta_d", + "eta_c", + "charging_current_cutoff", + "discharging_current_cutoff", + ], +) +def test_clc_battery_rejects_nonfinite_parameters(parameter): + with pytest.raises(ValueError, match="finite"): + vs.ClcBattery("battery", **{parameter: math.nan}) diff --git a/tests/test_docs_viewer.py b/tests/test_docs_viewer.py new file mode 100644 index 00000000..e53fe5ae --- /dev/null +++ b/tests/test_docs_viewer.py @@ -0,0 +1,78 @@ +"""Check that the documentation gallery can be hosted independently of the checkout.""" + +import json + +import pytest +import vessim as vs +from viewer import export_docs + + +@pytest.fixture +def gallery(tmp_path, monkeypatch): + root = tmp_path / "checkout" + assets = root / "vessim/_viewer_dist" + assets.mkdir(parents=True) + (assets / "index.html").write_text('') + (assets / "app.js").write_text('document.title = "Viewer"') + run = root / "results/recordings/run" + with vs.Environment("2026-01-01", step_size=60) as env: + env.add_microgrid( + name="site", + actors=[vs.Consumer("load", 700)], + dispatchables=[vs.GridConnection()], + ) + env.add_controller(vs.Recorder(run)) + env.run(120) + record = vs.ExperimentResult.load(run) + record.config["input_file"] = str(root / "datasets/input.csv") + record.save(run / "experiment.json") + index = root / "results/gallery/example/study.json" + index.parent.mkdir(parents=True) + index.write_text( + json.dumps( + { + "format": "vessim.study", + "schema_version": 2, + "study_id": "example", + "configurations": [ + { + "configuration_id": "one", + "runs": [ + {"path": "../../recordings/run/experiment.json"}, + ], + } + ], + } + ) + ) + monkeypatch.setattr(export_docs, "STUDIES", ("example",)) + return root, run + + +def test_export_relocates_recordings_and_preserves_sources(gallery, tmp_path): + root, source = gallery + before = {file.name: file.read_bytes() for file in source.iterdir()} + destination = tmp_path / "published/en/latest/viewer" + export_docs.export_gallery(destination, root / "results/gallery", root) + catalog = json.loads((destination / "catalog.json").read_text()) + study = json.loads((destination / catalog["studies"][0]["url"]).read_text()) + run = study["configurations"][0]["runs"][0] + assert (destination / run["trajectoryUrl"]).read_bytes() == before["timeseries.csv"] + assert run["record"]["metrics"]["microgrids"]["site"]["grid_import"] == pytest.approx(700 / 30) + assert run["record"]["config"]["input_file"] == "datasets/input.csv" + assert {file.name: file.read_bytes() for file in source.iterdir()} == before + for asset in (root / "vessim/_viewer_dist").iterdir(): + assert (destination / asset.name).read_bytes() == asset.read_bytes() + + +def test_failed_export_preserves_existing_gallery(gallery, tmp_path): + root, source = gallery + record = vs.ExperimentResult.load(source) + record.execution["status"] = "failed" + record.save(source / "experiment.json") + destination = tmp_path / "existing" + destination.mkdir() + (destination / "keep.txt").write_text("previous gallery") + with pytest.raises(ValueError, match="not complete"): + export_docs.export_gallery(destination, root / "results/gallery", root) + assert (destination / "keep.txt").read_text() == "previous gallery" diff --git a/tests/test_environment.py b/tests/test_environment.py index 96589dc7..ac093859 100644 --- a/tests/test_environment.py +++ b/tests/test_environment.py @@ -1,106 +1,150 @@ from datetime import datetime, timedelta +import math import pytest -from unittest.mock import Mock, patch -from vessim.environment import Environment -from vessim.actor import Actor -from vessim.signal import Signal, SilSignal -from vessim.microgrid import Microgrid - -class TestEnvironment: - @pytest.fixture - def mock_mosaik_world(self): - with patch("vessim.environment.mosaik.World") as mock_world: - yield mock_world - - @pytest.fixture - def environment(self, mock_mosaik_world): - return Environment(sim_start="2023-01-01 00:00:00") - - def test_init(self, mock_mosaik_world): - env = Environment(sim_start="2023-01-01 00:00:00", step_size=60) - assert env.step_size == 60 - assert env.microgrids == {} - assert env.controllers == [] - mock_mosaik_world.assert_called_once() - - def test_add_microgrid_raises_no_actors(self, environment): - with pytest.raises(ValueError, match="There should be at least one actor"): - environment.add_microgrid(actors=[], dispatchables=[]) - - def test_add_microgrid(self, environment): - mock_actor = Mock(spec=Actor) - mock_actor.step_size = None - mock_actor.name = "test_actor" - - with patch("vessim.environment.Microgrid") as mock_microgrid_cls: - mg_instance = mock_microgrid_cls.return_value - mg_instance.name = "test_mg" - mg = environment.add_microgrid(actors=[mock_actor]) - - assert mg == mg_instance - assert mg in environment.microgrids.values() - mock_microgrid_cls.assert_called_once() - - def test_run_validates_sil_signals(self, environment): - class DummySilSignal(SilSignal): - def __init__(self): pass - def _fetch_current_value(self): return 0 - def finalize(self): pass - - mock_actor = Mock(spec=Actor) - mock_actor.signal = DummySilSignal() - - mg = Mock(spec=Microgrid) - mg.actors = [mock_actor] - environment.microgrids = {"mg": mg} - - with pytest.raises(RuntimeError, match="SiL signals detected"): - environment.run(until=100) - - @pytest.mark.parametrize( - "until, expected_seconds", - [ - (3600, 3600), - (3600.5, 3600.5), - (timedelta(hours=1), 3600.0), - (datetime(2023, 1, 1, 1, 0, 0), 3600.0), - ], + +import vessim as vs + + +def environment(**kwargs): + env = vs.Environment(sim_start="2026-01-01", **kwargs) + env.add_microgrid([vs.Actor("load", -100)], dispatchables=[vs.GridConnection()]) + return env + + +@pytest.mark.parametrize("step_size", [0, -1, 1.5, True]) +def test_invalid_step_size(step_size): + with pytest.raises(ValueError, match="positive integer"): + environment(step_size=step_size) + + +def test_backend_validation(): + with pytest.raises(ValueError, match="backend"): + environment(backend="unknown") + with pytest.raises(ValueError, match="requires"): + environment(sim_config={}) + env = environment() + with pytest.raises(RuntimeError, match="mosaik"): + _ = env.world + + +def test_native_setup_and_freeze(): + env = environment() + assert env.status == "created" + assert list(env.microgrids) == ["microgrid_0"] + env.prepare() + with pytest.raises(RuntimeError, match="frozen"): + env.add_controller(vs.Recorder(metrics=False)) + env.close() + with pytest.raises(RuntimeError, match="once"): + env.run(until=1) + + +@pytest.mark.parametrize( + "until, count", + [ + (0, 0), + (1, 1), + (2, 2), + (2.5, 3), + (timedelta(seconds=2), 2), + (datetime(2026, 1, 1, 0, 0, 2), 2), + ], +) +def test_exclusive_end(until, count): + env = environment() + result = env.run(until) + assert result.steps == count + assert env.status == "completed" + assert (result.last_time is None) == (count == 0) + with pytest.raises(RuntimeError, match="once"): + env.run(until) + + +@pytest.mark.parametrize("until", [None, -1, math.nan, math.inf, True, "1"]) +def test_invalid_end(until): + env = environment() + with pytest.raises((ValueError, TypeError)): + env.run(until) + assert env.status == "failed" + assert env._closed + + +def test_duplicate_names_and_models(): + env = environment() + with pytest.raises(ValueError, match="already exists"): + env.add_microgrid( + [vs.Actor("x", 1)], + name="microgrid_0", + dispatchables=[vs.GridConnection()], + ) + actor = env.microgrids["microgrid_0"].actors[0] + env.add_microgrid([actor], dispatchables=[vs.GridConnection()]) + with pytest.raises(ValueError, match="cannot be shared"): + env.run(1) + + +def test_grid_live_signal_is_validated_and_closed(): + class Live(vs.LiveSignal): + def __init__(self): + self.closed = False + + def _fetch_current_value(self): + return 0 + + def finalize(self): + self.closed = True + + signal = Live() + env = vs.Environment(sim_start="2026-01-01") + env.add_microgrid( + [vs.Actor("a", 0)], + grid_signals={"live": signal}, + dispatchables=[vs.GridConnection()], ) - def test_run_until_accepts_timedelta_and_datetime( - self, environment, until, expected_seconds - ): - environment.world = Mock() - environment.world.run = Mock() - environment._initialize_controllers = Mock() - environment.run(until=until) - kwargs = environment.world.run.call_args.kwargs - assert kwargs["until"] == expected_seconds - - def test_run_until_datetime_before_sim_start_raises(self, environment): - environment.world = Mock() - environment._initialize_controllers = Mock() - with pytest.raises(ValueError, match="must be after"): - environment.run(until=datetime(2022, 12, 31, 23, 59, 59)) - - def test_contains_sil_signals(self, environment): - mg = Mock(spec=Microgrid) - actor1 = Mock(spec=Actor) - actor1.signal = Mock(spec=Signal) - - actor2 = Mock(spec=Actor) - - class DummySilSignal(SilSignal): - def __init__(self): pass - def _fetch_current_value(self): return 0 - def finalize(self): pass - - actor2.signal = DummySilSignal() - - mg.actors = [actor1, actor2] - environment.microgrids = {"mg": mg} - - assert environment._contains_sil_signals() is True - - mg.actors = [actor1] - assert environment._contains_sil_signals() is False + with pytest.raises(RuntimeError, match="Live signals"): + env.run(1) + assert signal.closed + + +def test_actor_sampling_interval_rejected(): + env = vs.Environment(sim_start="2026-01-01", step_size=3) + with pytest.raises(ValueError, match="positive multiple"): + env.add_microgrid([vs.Actor("a", 1, step_size=2)], dispatchables=[vs.GridConnection()]) + + +def test_prepare_captures_actor_configuration(): + class CountingSignal(vs.Signal): + def at(self, elapsed): + calls.append(elapsed) + return 1 + + calls = [] + env = vs.Environment(sim_start="2026-01-01") + actor = vs.Actor("old_name", CountingSignal()) + env.add_microgrid([actor], dispatchables=[vs.GridConnection()]) + actor.name = "new_name" + actor.step_size = 2 + result = env.run(4) + assert calls == [0, 2] + assert list(result.states["microgrid_0"]["actor_states"]) == ["new_name"] + + +def test_repeated_numeric_updates_do_not_retain_replaced_constants(): + import gc + import weakref + + actor = vs.Consumer("load", signal=700) + signals = [] + + class Update(vs.Controller): + def step(self, now, states): + signals.append(weakref.ref(actor.signal)) + actor.signal = 500 + + env = vs.Environment("2026-01-01") + env.add_microgrid([actor], [vs.GridConnection()]) + env.add_controller(Update()) + env.run(100) + gc.collect() + assert all(reference() is None for reference in signals) diff --git a/tests/test_examples.py b/tests/test_examples.py index 348c6afb..e59caa66 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -1,67 +1,215 @@ -import time -import signal import subprocess -import os import sys import pytest +from pathlib import Path +import shutil +import json +import re -EXAMPLES_DIR = os.path.join(os.path.dirname(__file__), "..", "examples") +EXAMPLES_DIR = Path(__file__).resolve().parents[1] / "examples" -def run_script(script_name): - script_path = os.path.join(EXAMPLES_DIR, script_name) +def run_script(script_name, cwd=None, args=()): + return subprocess.run( + [sys.executable, str(EXAMPLES_DIR / script_name), *args], + capture_output=True, + text=True, + cwd=cwd or EXAMPLES_DIR.parent, + timeout=60, + ) + + +def test_basic_example(tmp_path): + result = run_script("basic_example.py", cwd=tmp_path) + assert result.returncode == 0, result.stderr + assert "Grid import: 26.71 kWh" in result.stdout + assert "Grid export: 11.08 kWh" in result.stdout + record = json.loads((tmp_path / "results/basic_example/experiment.json").read_text()) + assert record["execution"]["status"] == "completed" + assert record["execution"]["steps"] == 864 + assert record["execution"]["simulated_duration"] == 3 * 24 * 3600 + assert record["metrics"]["microgrids"]["site"]["energy_consumed"] == pytest.approx(50400) + + +@pytest.mark.parametrize("document", ["README.md", "docs/getting_started.md"]) +def test_first_use_documentation_runs_as_written(tmp_path, document): + shutil.copy( + Path(__file__).parents[1] / "datasets/solar_example.csv", tmp_path / "solar_example.csv" + ) + source = (Path(__file__).parents[1] / document).read_text() + code = "\n".join(re.findall(r"```python\n(.*?)```", source, re.S)) + assert code.strip(), f"No Python example found in {document}" result = subprocess.run( - [sys.executable, script_path], + [sys.executable, "-c", code], + cwd=tmp_path, capture_output=True, text=True, - cwd=os.path.dirname(EXAMPLES_DIR), # Run from project root + timeout=60, ) - return result + assert result.returncode == 0, result.stderr + assert "Grid import: 26.71 kWh" in result.stdout + assert "Grid export: 11.08 kWh" in result.stdout + + +def test_csv_walkthrough(tmp_path): + guide = (Path(__file__).parents[1] / "docs/concepts/signals.md").read_text() + data = re.findall(r"```csv\n(.*?)```", guide, re.S)[0] + (tmp_path / "input.csv").write_text(data) + result = run_script("replay_csv.py", cwd=tmp_path, args=["input.csv", "--hours", "6"]) + assert result.returncode == 0, result.stderr + assert "Grid import: 1.10 kWh" in result.stdout + assert "Grid export: 1.70 kWh" in result.stdout -def test_basic_example(): - result = run_script("basic_example.py") - assert result.returncode == 0, f"Script failed with output:\n{result.stderr}" - assert "Simulation finished successfully" in result.stderr or result.returncode == 0 +def test_battery_sizing_study_is_reproducible_across_workers(tmp_path): + for workers in (1, 2): + result = run_script( + "battery_sizing_study.py", + cwd=tmp_path, + args=[ + "--scenarios", + "4", + "--workers", + str(workers), + "--output", + str(tmp_path / str(workers)), + ], + ) + assert result.returncode == 0, result.stderr + sequential = [ + json.loads((tmp_path / "1" / r["path"]).read_text()) + for c in json.loads((tmp_path / "1/study.json").read_text())["configurations"] + for r in c["runs"] + ] + parallel = [ + json.loads((tmp_path / "2" / r["path"]).read_text()) + for c in json.loads((tmp_path / "2/study.json").read_text())["configurations"] + for r in c["runs"] + ] + assert len(sequential) == len(parallel) == 12 + # Wall-clock execution timestamps differ, scientific results must not. + for first, second in zip(sequential, parallel): + for key in ("run_id", "parameters", "config", "metrics", "metric_definitions"): + assert first[key] == second[key] + assert first["execution"]["status"] == second["execution"]["status"] == "completed" - # Cleanup - if os.path.exists("results.csv"): - os.remove("results.csv") +def test_external_simulator_coupling(tmp_path): + pytest.importorskip("mosaik") + result = run_script("external_simulator_coupling.py", cwd=tmp_path) + assert result.returncode == 0, result.stderr + assert "grid_import" in result.stdout -def test_sil_example(): + +def test_live_server_monitoring(tmp_path): pytest.importorskip("fastapi") - # This script runs with rt_factor=1 (real-time), so it blocks. - # We start it, wait a bit, and terminate it. - script_path = os.path.join(EXAMPLES_DIR, "sil_example.py") - - # Use context manager to ensure pipes are closed - with subprocess.Popen( - [sys.executable, script_path], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - cwd=os.path.dirname(EXAMPLES_DIR), - ) as process: - try: - # Wait a bit to let it initialize - time.sleep(2) - if process.poll() is not None: - # It exited early, check why - stdout, stderr = process.communicate() - assert False, ( - f"SiL example exited early with code {process.returncode}.\n" - f"Stderr: {stderr.decode()}" - ) - - # Terminate - process.send_signal(signal.SIGINT) - try: - process.wait(timeout=2) - except subprocess.TimeoutExpired: - process.kill() - - except Exception: - process.kill() - raise - - # If we got here without assertion error, it started successfully + from http.server import BaseHTTPRequestHandler, HTTPServer + from threading import Thread + import socket + import json + + class Prometheus(BaseHTTPRequestHandler): + def do_GET(self): + body = json.dumps( + { + "status": "success", + "data": { + "result": [{"value": [0, "100"]}], + }, + } + ).encode() + self.send_response(200) + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def log_message(self, *args): + pass + + server = HTTPServer(("127.0.0.1", 0), Prometheus) + worker = Thread(target=server.serve_forever, daemon=True) + worker.start() + with socket.socket() as sock: + sock.bind(("127.0.0.1", 0)) + broker_port = sock.getsockname()[1] + try: + result = run_script( + "live_server_monitoring.py", + cwd=tmp_path, + args=[ + "--prometheus-url", + f"http://127.0.0.1:{server.server_port}", + "--broker-port", + str(broker_port), + "--until", + "0.1", + ], + ) + assert result.returncode == 0, result.stderr + assert "API broker terminated" in result.stdout + finally: + server.shutdown() + worker.join() + server.server_close() + + +def test_custom_metrics_example(tmp_path): + result = run_script("custom_metrics.py", cwd=tmp_path) + assert result.returncode == 0, result.stderr + assert "Objectives:" in result.stdout + with (tmp_path / "results/custom_metrics/experiment.json").open() as file: + record = json.load(file) + assert record["timeseries"] is None + assert record["metrics"]["actors"]["site"]["solar"]["area"] == 25 + assert record["execution"]["status"] == "completed" + + +def test_demand_response_example(tmp_path): + result = run_script("demand_response.py", cwd=tmp_path) + assert result.returncode == 0, result.stderr + assert result.stdout.splitlines() == [ + "00:00:00: 1000 W", + "00:01:00: 700 W", + "00:02:00: 700 W", + "00:03:00: 1000 W", + ] + + +@pytest.mark.parametrize( + "script, options", + [ + ("basic_example.py", []), + ("replay_csv.py", []), + ("custom_metrics.py", []), + ("demand_response.py", []), + ("strategy_comparison.py", ["--scenarios", "2"]), + ("microgrid_comparison.py", ["--days", "1"]), + ("infrastructure_study.py", ["--days", "1", "--trials", "12"]), + ], +) +def test_examples_reproduce_recorded_results_and_keep_existing_output(tmp_path, script, options): + if "--trials" in options: + pytest.importorskip("optuna") + outputs = [tmp_path / "first", tmp_path / "second"] + for output in outputs: + result = run_script(script, cwd=tmp_path, args=[*options, "--output", str(output)]) + assert result.returncode == 0, result.stderr + first = sorted(outputs[0].rglob("experiment.json")) + assert first + for file in first: + relative = file.relative_to(outputs[0]) + a, b = json.loads(file.read_text()), json.loads((outputs[1] / relative).read_text()) + for field in ("run_id", "parameters", "config", "metrics", "metric_definitions"): + assert a[field] == b[field], (script, relative, field) + for field in ("status", "steps", "simulated_duration"): + assert a["execution"][field] == b["execution"][field] + csv = file.parent / "timeseries.csv" + if csv.exists(): + assert csv.read_bytes() == (outputs[1] / csv.relative_to(outputs[0])).read_bytes() + if (outputs[0] / "study.json").exists(): + assert (outputs[0] / "study.json").read_bytes() == (outputs[1] / "study.json").read_bytes() + before = first[0].read_bytes() + rejected = run_script(script, cwd=tmp_path, args=[*options, "--output", str(outputs[0])]) + assert rejected.returncode != 0 + assert "Output is not empty" in rejected.stderr + assert first[0].read_bytes() == before diff --git a/tests/test_experiment_files.py b/tests/test_experiment_files.py new file mode 100644 index 00000000..2401650d --- /dev/null +++ b/tests/test_experiment_files.py @@ -0,0 +1,133 @@ +import functools +import json +from http.server import HTTPServer +from threading import Thread +from urllib.request import urlopen + +import numpy as np +import pytest + +import vessim as vs +from vessim._cli import _ViewerHandler +from vessim._viewer import Catalog + + +def record(directory, **kwargs): + env = vs.Environment("2026-01-01") + env.add_microgrid([vs.Actor("load", -100)], name="site", dispatchables=[vs.GridConnection()]) + recorder = env.add_controller(vs.Recorder(directory, **kwargs)) + env.run(1) + return recorder.result + + +def test_cli_discovers_and_serves_full_and_compact_records(tmp_path): + record(tmp_path / "full") + record(tmp_path / "compact", timeseries=False) + handler = functools.partial(_ViewerHandler, viewer_dir=tmp_path, catalog=Catalog(tmp_path)) + server = HTTPServer(("127.0.0.1", 0), handler) + worker = Thread(target=server.serve_forever, daemon=True) + worker.start() + try: + base = f"http://127.0.0.1:{server.server_port}" + with urlopen(base + "/catalog.json", timeout=5) as response: + listing = json.load(response) + assert listing["errors"] == [] + with urlopen(base + "/" + listing["studies"][0]["url"], timeout=5) as response: + study = json.load(response) + compact, full = [r for c in study["configurations"] for r in c["runs"]] + assert compact["record"]["execution"]["status"] == "completed" + assert compact["trajectoryUrl"] is None + assert compact["record"]["metrics"]["microgrids"]["site"]["grid_import"] > 0 + with urlopen(base + "/" + full["trajectoryUrl"], timeout=5) as response: + assert "actor_states.load.power" in response.read().decode() + finally: + server.shutdown() + worker.join() + server.server_close() + + +def test_experiment_dict_is_json_compatible_and_detached(tmp_path): + result = record(tmp_path, timeseries=False, parameters={"seed": np.int64(42)}) + data = result.to_dict() + assert json.loads(json.dumps(data))["parameters"]["seed"] == 42 + data["parameters"]["seed"] = 99 + assert result.parameters["seed"] == 42 + assert isinstance(data["execution"]["last_time"], str) + + +@pytest.mark.parametrize( + "change", + [ + lambda r: r["metrics"]["microgrids"]["site"].update(grid_import=float("nan")), + lambda r: r["metrics"]["microgrids"]["site"].update(grid_import=float("inf")), + lambda r: r["metrics"]["microgrids"]["site"].update(grid_import=True), + lambda r: r["metric_definitions"]["microgrids"]["site"].pop("grid_import"), + lambda r: r["metric_definitions"]["microgrids"]["site"]["grid_import"].update(unit=""), + lambda r: r.update(timeseries=[]), + lambda r: r.update(timeseries="/absolute.csv"), + lambda r: r.update(timeseries=12), + lambda r: r.update(run_id=""), + lambda r: r.update(schema_version=True), + lambda r: r["execution"].update(status="unknown"), + lambda r: r["execution"].update(steps=1.5), + ], +) +def test_python_loader_and_catalog_reject_the_same_invalid_records(tmp_path, change): + data = record(tmp_path, timeseries=False).to_dict() + change(data) + (tmp_path / "experiment.json").write_text(json.dumps(data)) + with pytest.raises(ValueError): + vs.ExperimentResult.load(tmp_path) + catalog = Catalog(tmp_path) + assert not catalog.runs + assert len(catalog.errors) == 1 + + +def test_numeric_overflow_in_metadata_is_rejected_by_both_readers(tmp_path): + record(tmp_path, timeseries=False) + path = tmp_path / "experiment.json" + path.write_text(path.read_text().replace('"parameters": {}', '"parameters": {"x": 1e999}')) + with pytest.raises(ValueError): + vs.ExperimentResult.load(path) + assert Catalog(path).errors + + +def test_save_relocates_csv_and_preserves_source_result(tmp_path, monkeypatch): + source = tmp_path / "source" + record(source) + result = vs.ExperimentResult.load(source) + original_csv = (source / "timeseries.csv").read_bytes() + monkeypatch.chdir(tmp_path) + assert result.timeseries_path == source / "timeseries.csv" + destination = tmp_path / "export" / "copied.json" + result.save(destination) + copied = vs.ExperimentResult.load(destination) + assert copied.timeseries_path.parent == destination.parent + assert copied.timeseries_path.read_bytes() == original_csv + assert result.timeseries_path == source / "timeseries.csv" + assert result.timeseries == "timeseries.csv" + assert Catalog(destination).trajectory("0")[0] == original_csv + # Updating a summary in place neither copies nor truncates its trajectory. + copied.add_metric("cost", 42, "EUR") + copied.save(destination) + assert copied.timeseries_path.read_bytes() == original_csv + assert vs.ExperimentResult.load(destination).metrics["run"]["cost"] == 42 + + +def test_missing_csv_does_not_replace_destination_summary(tmp_path): + result = record(tmp_path / "source") + result.timeseries_path.unlink() + destination = tmp_path / "export" / "record.json" + destination.parent.mkdir() + destination.write_text("previous summary") + with pytest.raises(FileNotFoundError): + result.save(destination) + assert destination.read_text() == "previous summary" + + +def test_save_cannot_overwrite_its_own_trajectory(tmp_path): + result = record(tmp_path) + original = result.timeseries_path.read_bytes() + with pytest.raises(ValueError, match="must not overwrite"): + result.save(result.timeseries_path) + assert result.timeseries_path.read_bytes() == original diff --git a/tests/test_grid_connection.py b/tests/test_grid_connection.py new file mode 100644 index 00000000..c3569297 --- /dev/null +++ b/tests/test_grid_connection.py @@ -0,0 +1,326 @@ +"""Grid constraints, priority dispatch, and explicit power balance.""" + +import json +import math + +import pandas as pd +import pytest + +import vessim as vs + + +def test_limits_and_unlimited_connection(): + assert vs.GridConnection().feasible_range(1) == (-math.inf, math.inf) + grid = vs.GridConnection(max_import=5000, max_export=2000) + assert grid.feasible_range(60) == (-5000, 2000) + for power in (-5000, 0, 2000): + grid.set_power(power, 60) + for power in (-5001, 2001, math.inf, -math.inf, math.nan): + with pytest.raises(ValueError): + grid.set_power(power, 60) + assert grid.current_power == 2000 + json.dumps(grid.config(), allow_nan=False) + json.dumps(vs.GridConnection().config(), allow_nan=False) + + +@pytest.mark.parametrize("parameter", ["max_import", "max_export", "ramp_up", "ramp_down"]) +@pytest.mark.parametrize("value", [-1, math.nan, math.inf, -math.inf]) +def test_invalid_limits(parameter, value): + with pytest.raises(ValueError): + vs.GridConnection(**{parameter: value}) + + +@pytest.mark.parametrize("power", [-101, 51, math.nan, math.inf]) +def test_invalid_initial_power(power): + with pytest.raises(ValueError): + vs.GridConnection(max_import=100, max_export=50, initial_power=power) + + +def test_ramps_use_signed_power_and_previous_interval(): + grid = vs.GridConnection(initial_power=-5000, ramp_up=1000, ramp_down=2000) + assert grid.feasible_range(60) == (-7000, -4000) + grid.set_power(-4000, 60) + # Setting a power twice must not allow multiple ramp increments in one tick. + assert grid.feasible_range(60) == (-7000, -4000) + with pytest.raises(ValueError, match="outside feasible range"): + grid.set_power(-3000, 60) + grid.step(60) + assert grid.feasible_range(0.5) == (-6000, -3000) + grid.set_power(-6000, 0.5) + grid.step(0.5) + assert grid.feasible_range(60) == (-8000, -5000) + + +def test_ramp_crosses_zero_and_respects_capacity(): + grid = vs.GridConnection( + max_import=100, max_export=50, ramp_up=80, ramp_down=40, initial_power=-60 + ) + assert grid.feasible_range(1) == (-100, 20) + grid.set_power(20, 1) + grid.step(1) + assert grid.feasible_range(1) == (-20, 50) + + +def test_disconnection_overrides_ramps_and_reconnects_from_zero(): + grid = vs.GridConnection(initial_power=-5000, ramp_up=1000, ramp_down=1000) + grid.connected = False + assert grid.feasible_range(60) == (0, 0) + grid.set_power(0, 60) + grid.step(60) + grid.connected = True + assert grid.feasible_range(60) == (-1000, 1000) + + +def test_changed_limits_must_not_silently_violate_ramps(): + grid = vs.GridConnection(initial_power=-5000, ramp_up=1000) + grid.max_import = 1000 + with pytest.raises(ValueError, match="conflict"): + grid.feasible_range(60) + grid.max_import = -1 + with pytest.raises(ValueError, match="nonnegative"): + grid.feasible_range(60) + + +class Generator(vs.Dispatchable): + def feasible_range(self, duration): + return (-1000, 0) + + def step(self, duration): + pass + + def config(self): + return {} + + def state(self): + return {"current_power": self.current_power} + + +def test_grid_before_backup_generator(): + battery = vs.SimpleBattery("battery", 1000, initial_soc=0.5, c_rate=1) + grid = vs.GridConnection(max_import=5000) + backup = Generator("diesel") + assert vs.DefaultDispatchPolicy().apply(-6500, 60, [battery, grid, backup]) is None + assert [d.current_power for d in (battery, grid, backup)] == [-1000, -5000, -500] + + +@pytest.mark.parametrize("grid_first", [True, False]) +@pytest.mark.parametrize( + "initial,p_delta,battery_power", [(-5000, -3000, 1000), (5000, 3000, -1000)] +) +def test_mandatory_grid_power_is_reserved_before_flexible_dispatch( + grid_first, initial, p_delta, battery_power +): + battery = vs.SimpleBattery("battery", 10000, initial_soc=0.5) + grid = vs.GridConnection(initial_power=initial, ramp_up=1000, ramp_down=1000) + resources = [grid, battery] if grid_first else [battery, grid] + assert vs.DefaultDispatchPolicy().apply(p_delta, 60, resources) is None + assert battery.current_power == battery_power + assert grid.current_power == p_delta - battery_power + + +def test_grid_priority_preserves_battery(): + battery = vs.SimpleBattery("battery", 1000, initial_soc=0.5) + grid = vs.GridConnection() + vs.DefaultDispatchPolicy().apply(-500, 60, [grid, battery]) + assert grid.current_power == -500 + assert battery.current_power == 0 + + +def test_constraints_can_require_both_consumption_and_generation(): + importing = vs.GridConnection("import", initial_power=-5000, ramp_up=0, ramp_down=0) + exporting = vs.GridConnection("export", initial_power=3000, ramp_up=0, ramp_down=0) + battery = vs.SimpleBattery("battery", 10000, initial_soc=0.5) + assert vs.DefaultDispatchPolicy().apply(-1000, 60, [battery, importing, exporting]) is None + assert battery.current_power == 1000 + + +def make_environment(power, resources, policy=None, backend="native", step_size=60): + env = vs.Environment("2026-01-01", step_size=step_size, backend=backend) + env.add_microgrid([vs.Actor("net", signal=power)], resources, policy=policy, name="site") + return env + + +@pytest.mark.parametrize("backend", ["native", "mosaik"]) +def test_ramp_limited_trajectory_and_partial_final_interval(backend): + if backend == "mosaik": + pytest.importorskip("mosaik") + grid = vs.GridConnection(max_import=5000, ramp_up=1000, ramp_down=1000) + battery = vs.SimpleBattery("battery", 10000, initial_soc=0.5) + trace = vs.Trace(pd.Series([-3000, -3000, 0], index=[0, 60, 120])) + env = make_environment(trace, [grid, battery], backend=backend) + rec = env.add_controller(vs.Recorder()) + env.run(120.5) + rows = [states["site"] for states in rec.log.values()] + assert [s["p_grid"] for s in rows] == [-1000, -2000, -1000] + assert grid.current_power == -1000 + assert battery.charge_level == pytest.approx(5000 - 50 + 1000 * 0.5 / 3600) + assert rec.metrics["microgrids"]["site"]["grid_import"] == pytest.approx(50 + 0.5 / 3.6) + + +@pytest.mark.parametrize("backend", ["native", "mosaik"]) +def test_explicit_grid_export_recording(tmp_path, backend): + if backend == "mosaik": + pytest.importorskip("mosaik") + grid = vs.GridConnection(max_export=100) + env = make_environment(100, [grid], backend=backend) + rec = env.add_controller(vs.Recorder(tmp_path)) + result = env.run(90) + assert result.states["site"]["p_grid"] == 100 + assert rec.metrics["microgrids"]["site"]["grid_export"] == 2.5 + assert vs.ExperimentResult.load(tmp_path).metrics == rec.metrics + assert rec.to_df()["p_grid"].tolist() == [100, 100] + + +@pytest.mark.parametrize("backend", ["native", "mosaik"]) +@pytest.mark.parametrize("power", [-300, 300]) +def test_unbalanced_dispatch_fails_without_recording_an_interval(tmp_path, backend, power): + if backend == "mosaik": + pytest.importorskip("mosaik") + grid = vs.GridConnection(max_import=100, max_export=100) + env = make_environment(power, [grid], backend=backend) + rec = env.add_controller(vs.Recorder(tmp_path)) + with pytest.raises(RuntimeError, match="dispatch must balance exactly"): + env.run(90) + assert rec.steps == 0 + assert grid.current_power == 0 # Failed default allocation does not commit setpoints. + assert vs.ExperimentResult.load(tmp_path).execution["status"] == "failed" + + +@pytest.mark.parametrize( + "grid", [vs.GridConnection(max_import=10), vs.GridConnection(connected=False)] +) +def test_explicit_connection_never_gets_an_unlimited_fallback(grid): + env = make_environment(-100, [grid]) + assert env.microgrids["site"].dispatchables == [grid] + with pytest.raises(RuntimeError, match="Unmet demand"): + env.run(60) + assert env.status == "failed" + + +@pytest.mark.parametrize("resources", [None, [], [vs.GridConnection(connected=False)]]) +@pytest.mark.parametrize("power", [-100, 100]) +def test_no_connection_or_disconnected_connection_cannot_balance_power(resources, power): + env = make_environment(power, resources) + with pytest.raises(RuntimeError, match="dispatch must balance exactly"): + env.run(60) + + +def test_no_implicit_grid_and_caller_list_is_preserved(): + resources = [vs.SimpleBattery("battery", 100, initial_soc=0.5)] + env = make_environment(-10, resources) + assert env.microgrids["site"].dispatchables == resources + assert len(resources) == 1 + assert env.run(60).states["site"]["p_grid"] == 0 + + +def test_empty_dispatchables_can_run_a_balanced_island(): + env = make_environment(0, []) + state = env.run(60).states["site"] + assert env.microgrids["site"].dispatchables == [] + assert state["dispatch_states"] == {} + assert state["p_grid"] == 0 + + +def test_fixed_charging_cannot_bypass_import_limit(): + env = make_environment( + 0, + [vs.SimpleBattery("battery", 1000), vs.GridConnection(max_import=100)], + policy=vs.DefaultDispatchPolicy(charge_power=200), + ) + with pytest.raises(RuntimeError, match="Unmet demand of 100 W"): + env.run(60) + + +def test_custom_policy_cannot_leave_unmet_demand(): + class BadPolicy(vs.DispatchPolicy): + def apply(self, p_delta, duration, dispatchables, grid_signals=None): + pass + + env = make_environment(-100, [vs.GridConnection(max_import=10)], policy=BadPolicy()) + with pytest.raises(RuntimeError, match="Unmet demand"): + env.run(60) + + +def test_custom_policy_cannot_bypass_resource_limits(): + class BadPolicy(vs.DispatchPolicy): + def apply(self, p_delta, duration, dispatchables, grid_signals=None): + dispatchables[0].current_power = p_delta + + env = make_environment(-100, [vs.GridConnection(max_import=10)], policy=BadPolicy()) + with pytest.raises(ValueError, match="outside the feasible range"): + env.run(60) + + +@pytest.mark.parametrize("backend", ["native", "mosaik"]) +def test_controller_outage_and_reconnection(backend): + if backend == "mosaik": + pytest.importorskip("mosaik") + grid = vs.GridConnection(ramp_up=50, ramp_down=50) + battery = vs.SimpleBattery("battery", 1000, initial_soc=0.5) + env = make_environment(-100, [grid, battery], backend=backend) + + class Outage(vs.Controller): + def step(self, now, states): + grid.connected = now.minute != 0 + + env.add_controller(Outage()) + rec = env.add_controller(vs.Recorder()) + env.run(180) + rows = [states["site"] for states in rec.log.values()] + assert [s["p_grid"] for s in rows] == [-50, 0, -50] + assert [s["dispatch_states"]["grid"]["connected"] for s in rows] == [True, False, True] + assert battery.charge_level == pytest.approx(500 - 200 / 60) + + +def test_custom_policy_can_reorder_resources_without_changing_topology(): + class Reverse(vs.DefaultDispatchPolicy): + def apply(self, p_delta, duration, dispatchables, grid_signals=None): + dispatchables.reverse() + super().apply(p_delta, duration, dispatchables, grid_signals) + + battery = vs.SimpleBattery("battery", 1000, initial_soc=0.5, c_rate=0.01) + grid = vs.GridConnection(max_import=100) + env = make_environment(-100, [battery, grid], policy=Reverse()) + state = env.run(60).states["site"] + assert state["p_grid"] == -100 + assert battery.current_power == 0 + assert env.microgrids["site"].dispatchables == [battery, grid] + + +@pytest.mark.parametrize( + "bounds", [(math.nan, 0), (0, math.nan), (1, -1), (math.inf, math.inf), (-math.inf, -math.inf)] +) +def test_invalid_custom_feasible_ranges_are_rejected(bounds): + class Broken(Generator): + def feasible_range(self, duration): + return bounds + + with pytest.raises(ValueError, match="Invalid feasible range"): + vs.DefaultDispatchPolicy().apply(0, 60, [Broken("broken")]) + + +@pytest.mark.parametrize("power", [-1, 1]) +def test_default_policy_rejects_even_a_tiny_nonzero_delta(power): + grid = vs.GridConnection(max_import=1 - 1e-12, max_export=1 - 1e-12) + with pytest.raises(RuntimeError, match="dispatch must balance exactly"): + vs.DefaultDispatchPolicy().apply(power, 60, [grid]) + assert grid.current_power == 0 + + +@pytest.mark.parametrize("error", [-1, 1, -1e-12, 1e-12]) +def test_custom_policy_must_balance_without_tolerance(error): + class Unbalanced(vs.DispatchPolicy): + def apply(self, p_delta, duration, dispatchables, grid_signals=None): + dispatchables[0].set_power(p_delta - error, duration) + + env = make_environment(10, [vs.GridConnection()], policy=Unbalanced()) + with pytest.raises(RuntimeError, match="dispatch must balance exactly"): + env.run(60) + + +def test_default_policy_closes_fractional_delta_with_explicit_grid(): + battery = vs.SimpleBattery("battery", 10, initial_soc=0.5, c_rate=0.01) + grid = vs.GridConnection() + policy = vs.DefaultDispatchPolicy() + assert policy.apply(0.3, 60, [battery, grid]) is None + assert 0.3 - battery.current_power - grid.current_power == 0 diff --git a/tests/test_infrastructure_study.py b/tests/test_infrastructure_study.py new file mode 100644 index 00000000..cbb96ea6 --- /dev/null +++ b/tests/test_infrastructure_study.py @@ -0,0 +1,150 @@ +"""Check the example's accounting and reproducible evaluation contract.""" + +import importlib +from pathlib import Path +import subprocess +import sys + +import numpy as np +import pandas as pd +import pytest + +from vessim import ExperimentResult + +SCRIPT = Path(__file__).resolve().parents[1] / "examples/infrastructure_study.py" + + +@pytest.fixture(scope="module") +def scenario(): + with pytest.MonkeyPatch.context() as patch: + patch.syspath_prepend(str(SCRIPT.parent)) + yield importlib.import_module("infrastructure_study") + + +def test_annual_inputs_and_shared_realization(scenario): + annual = scenario.inputs() + assert len(annual) == 8760 + assert annual.index[-1] == 365 * 86400 - 3600 + assert np.isfinite(annual).all().all() + assert annual.solar_cf.between(0, 1).all() + assert annual.wind_cf.between(0, 1).all() + assert (annual.load_kw > 0).all() + pd.testing.assert_frame_equal(annual, scenario.inputs()) + fine = scenario.inputs(step_seconds=900) + pd.testing.assert_frame_equal(fine.loc[annual.index], annual) + assert not scenario.inputs(seed=43).equals(annual) + with pytest.raises(ValueError, match="divide 3600"): + scenario.inputs(step_seconds=2500) + + +def test_grid_baseline_matches_input_integrals_and_preserves_output(tmp_path, scenario): + result = scenario.run_scenario(tmp_path, "baseline", 0, 0, 0, days=2, timeseries=False) + frame = scenario.inputs(days=2) + metrics = result.metrics["microgrids"]["Microgrid 1"] + assert metrics["grid_import"] == pytest.approx(frame.load_kw.sum() * 1000) + assert metrics["grid_emissions"] == pytest.approx((frame.load_kw * frame.carbon).sum()) + assert metrics["grid_export"] == 0 + assert result.execution["simulated_duration"] == 2 * 86400 + assert result.timeseries is None + assert scenario.objective_values(result)[0] == 0 + saved = tmp_path / "baseline/experiment.json" + assert scenario.objective_values(ExperimentResult.load(saved)) == scenario.objective_values( + result + ) + with pytest.raises(FileExistsError, match="Preserving"): + scenario.run_scenario(tmp_path, "baseline", 0, 0, 0, days=2, timeseries=False) + result.metrics["microgrids"]["Microgrid 1"]["grid_emissions"] = None + with pytest.raises(ValueError, match="finite"): + scenario.objective_values(result) + result.execution["status"] = "failed" + with pytest.raises(ValueError, match="complete"): + scenario.objective_values(result) + + +def test_storage_energy_balance_and_recorded_emissions(tmp_path, scenario): + result = scenario.run_scenario(tmp_path, "storage", 1500, 4000, 8000, days=3, timeseries=True) + frame = pd.read_csv(tmp_path / "storage/timeseries.csv") + imports = (-frame.p_grid).clip(lower=0) + expected_emissions = (imports.to_numpy() / 1000 * scenario.inputs(days=3).carbon).sum() + assert scenario.objective_values(result) == pytest.approx((3800000, expected_emissions)) + metrics = result.metrics["microgrids"]["Microgrid 1"] + # Empty initial storage, no battery losses: net input equals final stored energy. + stored = ( + metrics["energy_produced"] + + metrics["grid_import"] + - metrics["energy_consumed"] + - metrics["grid_export"] + ) + charge = frame["dispatch_states.battery.charge_level"] + battery_power = frame.p_delta - frame.p_grid + # Recorded state is at the beginning of each held dispatch interval. + np.testing.assert_allclose(np.diff(charge), battery_power.iloc[:-1], atol=1e-5) + terminal_charge = charge.iloc[-1] + battery_power.iloc[-1] # one-hour final interval + assert stored == pytest.approx(terminal_charge, abs=1e-5) + assert len(frame) == 72 + + +def test_optuna_records_objectives_and_preserves_existing_output(tmp_path, scenario): + optuna = pytest.importorskip("optuna") + command = [ + sys.executable, + str(SCRIPT), + "--output", + str(tmp_path), + "--days", + "1", + "--trials", + "2", + ] + result = subprocess.run(command, capture_output=True, text=True, timeout=60) + assert result.returncode == 0, result.stderr + storage = optuna.storages.RDBStorage(f"sqlite:///{tmp_path / 'optuna.db'}") + try: + study = optuna.load_study(study_name="infrastructure-emissions", storage=storage) + completed = study.get_trials(states=(optuna.trial.TrialState.COMPLETE,)) + finally: + storage.remove_session() + storage.engine.dispose() + assert len(completed) == 2 + for trial in completed: + record = ExperimentResult.load(tmp_path / trial.user_attrs["vessim_experiment"]) + assert trial.user_attrs["vessim_run_id"] == record.run_id + assert trial.values == list(scenario.objective_values(record)) + before = (tmp_path / "study.json").read_bytes() + command[command.index("--days") + 1] = "2" + rejected = subprocess.run(command, capture_output=True, text=True, timeout=60) + assert rejected.returncode != 0 + assert "Output is not empty" in rejected.stderr + assert (tmp_path / "study.json").read_bytes() == before + + +def test_financial_metrics_match_recorded_cashflows_and_annual_allocation(tmp_path, scenario): + result = scenario.run_scenario( + tmp_path, + "financial", + 1500, + 2000, + 4000, + financial=True, + battery_c_rate=0.25, + timeseries=True, + ) + frame = pd.read_csv(tmp_path / "financial/timeseries.csv") + grid = result.metrics["microgrids"]["Microgrid 1"] + imports = (-frame.p_grid).clip(lower=0) / 1000 + exports = frame.p_grid.clip(lower=0) / 1000 + assert grid["electricity_cost"] == pytest.approx((imports * frame["grid_signals.price"]).sum()) + assert grid["export_revenue"] == pytest.approx( + (exports * frame["grid_signals.export_price"]).sum() + ) + capital = 1500 * 1300 + 2000 * 700 + 4000 * 300 + annual = 1500 * 1300 / 20 + 2000 * 700 / 25 + 4000 * 300 / 10 + assert result.metrics["run"]["equipment_capex"] == capital + assert result.metrics["run"]["annualized_equipment_cost"] == annual + assert scenario.objective_values(result, financial=True)[0] == pytest.approx( + annual + grid["electricity_cost"] - grid["export_revenue"] + ) + assert result.parameters["battery_c_rate"] == 0.25 + assert len(frame) == 8760 + with pytest.raises(ValueError, match="365-day"): + scenario.run_scenario(tmp_path, "short-finance", 0, 0, 0, financial=True, days=1) diff --git a/tests/test_live_resources.py b/tests/test_live_resources.py new file mode 100644 index 00000000..9a0d978e --- /dev/null +++ b/tests/test_live_resources.py @@ -0,0 +1,125 @@ +import socket +from threading import Event + +import pytest + +import vessim as vs + + +def test_signal_worker_stops_after_run_and_after_interruption(): + for interrupted in (False, True): + fetched = Event() + + class Signal(vs.LiveSignal): + def _fetch_current_value(self): + fetched.set() + return 123 + + def at(self, elapsed): + assert fetched.wait(5) + return super().at(elapsed) + + signal = Signal(update_interval=60, timeout=0.5) + assert signal._thread is None + assert not fetched.is_set() + env = vs.Environment.live() + env.add_microgrid([vs.Actor("live", signal)], dispatchables=[vs.GridConnection()]) + + class Finish(vs.Controller): + def step(self, now, states): + if interrupted: + raise KeyboardInterrupt + + env.add_controller(Finish()) + if interrupted: + with pytest.raises(KeyboardInterrupt): + env.run(0.01) + else: + assert env.run(0.01).states["microgrid_0"]["p_delta"] == 123 + assert not signal._thread.is_alive() + signal.finalize() # Idempotent even when shared with another owner. + + +def test_live_broker_round_trip_and_cleanup(): + pytest.importorskip("fastapi") + requests = pytest.importorskip("requests") + with socket.socket() as sock: + sock.bind(("127.0.0.1", 0)) + port = sock.getsockname()[1] + env = vs.Environment.live() + load = vs.Consumer("load", signal=100) + env.add_microgrid([load], name="grid", dispatchables=[vs.GridConnection()]) + api = env.add_controller(vs.Api(broker_port=port)) + observed = [] + + class Client(vs.Controller): + def step(self, now, states): + response = requests.get(f"{api.broker_url}/microgrids/grid", timeout=5) + response.raise_for_status() + observed.append(response.json()) + if len(observed) == 1: + response = requests.put( + f"{api.broker_url}/microgrids/grid/actors/load/signal", + params={"value": 200}, + timeout=5, + ) + response.raise_for_status() + assert response.json() == {"status": "command queued"} + + env.add_controller(Client()) + env.run(2.01) + assert [row["actor_states"]["load"]["power"] for row in observed] == [-100, -100, -200] + assert isinstance(load.signal, vs.StaticSignal) + assert load.signal.at() == 200 + assert not api.broker_process.is_alive() + + +def test_failed_prometheus_setup_starts_no_worker(monkeypatch): + requests = pytest.importorskip("requests") + + def failed(*args, **kwargs): + raise requests.ConnectionError("unavailable") + + monkeypatch.setattr(requests, "get", failed) + signal = vs.PrometheusSignal("http://localhost:1", "up", timeout=0.1) + assert signal._thread is None + env = vs.Environment.live() + env.add_microgrid([vs.Consumer("load", signal=signal)], [vs.GridConnection()]) + with pytest.raises(ConnectionError, match="Could not connect"): + env.run(0.01) + assert signal._thread is None + assert signal._stop.is_set() + + +def test_watttime_initialization_is_deferred_and_failure_is_cleaned_up(monkeypatch): + pytest.importorskip("requests") + initialized = [] + + def failed_login(self): + initialized.append(self) + raise RuntimeError("login failed") + + monkeypatch.setattr(vs.WatttimeSignal, "_get_token", failed_login) + signal = vs.WatttimeSignal("user", "password", location=(52, 13)) + assert initialized == [] + assert "location=(52, 13)" in repr(signal) + env = vs.Environment.live() + env.add_microgrid( + [vs.Consumer("load", signal=100)], [vs.GridConnection()], grid_signals={"carbon": signal} + ) + with pytest.raises(RuntimeError, match="login failed"): + env.run(1) + assert initialized == [signal] + assert signal._thread is None + assert signal._stop.is_set() + + +def test_closed_live_signal_cannot_contact_service_again(monkeypatch): + pytest.importorskip("requests") + calls = [] + monkeypatch.setattr(vs.PrometheusSignal, "_validate_connection", lambda self: calls.append(1)) + signal = vs.PrometheusSignal("http://localhost:1", "up") + signal.finalize() + with pytest.raises(RuntimeError, match="closed live signal"): + signal.start() + assert calls == [] diff --git a/tests/test_microgrid.py b/tests/test_microgrid.py index 350d23dd..a81086b5 100644 --- a/tests/test_microgrid.py +++ b/tests/test_microgrid.py @@ -1,83 +1,81 @@ import pytest -from unittest.mock import Mock -from vessim.microgrid import Microgrid -from vessim.actor import Actor -from vessim.dispatch_policy import DispatchPolicy - -class TestMicrogrid: - @pytest.fixture - def mock_world(self): - return Mock() - - @pytest.fixture - def mock_policy(self): - return Mock(spec=DispatchPolicy) - - def test_init_raises_invalid_step_size(self, mock_world, mock_policy): - actor = Mock(spec=Actor) - actor.step_size = 10 - actor.name = "test_actor" - - with pytest.raises(ValueError, match="Actor step size has to be a multiple"): - Microgrid( - world=mock_world, - step_size=3, - actors=[actor], - dispatchables=[], - policy=mock_policy - ) - - def test_init_connects_components(self, mock_world, mock_policy): - actor = Mock(spec=Actor) - actor.step_size = 10 - actor.name = "test_actor" - - # Setup mocks for world.start() returns - actor_sim = Mock() - microgrid_sim = Mock() - - mock_world.start.side_effect = [actor_sim, microgrid_sim] - - actor_entity = Mock() - actor_sim.Actor.return_value = actor_entity - - microgrid_entity = Mock() - microgrid_sim.Microgrid.return_value = microgrid_entity - - mg = Microgrid( - world=mock_world, - step_size=5, # 10 % 5 == 0 - actors=[actor], - dispatchables=[], - policy=mock_policy, + +import vessim as vs + + +def test_microgrid_balances_power_without_an_environment(): + grid = vs.Microgrid( + step_size=60, + actors=[vs.Actor("a", 100)], + dispatchables=[vs.GridConnection()], + policy=vs.DefaultDispatchPolicy(), + ) + grid.sample(0) + grid.advance(0) + assert grid.state()["p_grid"] == 100 + assert grid.state()["dispatch_states"] == {"grid": {"current_power": 100, "connected": True}} + + +def test_duplicate_actor_names_rejected(): + with pytest.raises(ValueError, match="unique"): + vs.Microgrid( + 1, + [vs.Actor("a", 0), vs.Actor("a", 1)], + [], + vs.DefaultDispatchPolicy(), ) - # Check if actors are started - mock_world.start.assert_any_call("Actor", sim_id=f"{mg.name}.actor.test_actor", - step_size=10) - # Check if microgrid sim is started - mock_world.start.assert_any_call("Microgrid", sim_id=f"{mg.name}.microgrid", - step_size=5, grid_signals=None) +def test_state_is_detached_from_mutable_custom_actor(): + class Stateful(vs.Actor): + data = {"nested": {"value": 1}} - # Check connections - mock_world.connect.assert_any_call(actor_entity, microgrid_entity, "power") + def sample(self, elapsed): + return 0, self.data - def test_finalize(self, mock_world, mock_policy): - actor = Mock(spec=Actor) - actor.step_size = 5 - actor.name = "test_actor" + actor = Stateful("a", 0) + grid = vs.Microgrid(1, [actor], [], vs.DefaultDispatchPolicy()) + grid.sample(0) + grid.advance(0) + state = grid.state() + actor.data["nested"]["value"] = 2 + assert state["actor_states"]["a"]["nested"]["value"] == 1 + state["actor_states"]["a"]["nested"]["value"] = 3 + assert actor.data["nested"]["value"] == 2 - # Simplify world mock for this test - mock_world.start.return_value = Mock() - mg = Microgrid( - world=mock_world, - step_size=5, - actors=[actor], - dispatchables=[], - policy=mock_policy - ) +def test_custom_scalar_subclass_state_is_copied(): + class Scalar(float): + pass + + value = Scalar(1) + value.history = [1] + + class Actor(vs.Actor): + def sample(self, elapsed): + return 0, {"value": value} + + grid = vs.Microgrid(1, [Actor("a", 0)], [], vs.DefaultDispatchPolicy()) + grid.sample(0) + snapshot = grid.state() + value.history.append(2) + assert snapshot["actor_states"]["a"]["value"].history == [1] + + +@pytest.mark.parametrize("kind, wrong_power", [(vs.Consumer, 1), (vs.Producer, -1)]) +def test_custom_actor_cannot_reverse_its_role(kind, wrong_power): + class Invalid(kind): + def sample(self, elapsed): + return wrong_power, {} + + env = vs.Environment("2026-01-01") + env.add_microgrid([Invalid("invalid", signal=0)], [vs.GridConnection()]) + with pytest.raises(ValueError, match="wrong direction"): + env.run(1) + - mg.finalize() - actor.finalize.assert_called_once() +@pytest.mark.parametrize("value", [float("nan"), float("inf"), True, "0.3", lambda t: 0.3]) +def test_context_constants_are_validated(value): + env = vs.Environment("2026-01-01") + with pytest.raises(ValueError, match="finite number"): + env.add_microgrid([vs.Consumer("load", signal=700)], grid_signals={"price": value}) diff --git a/tests/test_microgrid_comparison.py b/tests/test_microgrid_comparison.py new file mode 100644 index 00000000..f7e73567 --- /dev/null +++ b/tests/test_microgrid_comparison.py @@ -0,0 +1,27 @@ +"""Validate site-scoped and coincident metrics in the multi-microgrid example.""" + +import importlib +from pathlib import Path + +import pandas as pd +import pytest + + +def test_three_microgrids_keep_scope_and_compute_coincident_peak(tmp_path, monkeypatch): + monkeypatch.syspath_prepend(str(Path(__file__).resolve().parents[1] / "examples")) + scenario = importlib.import_module("microgrid_comparison") + result = scenario.run_scenario(tmp_path, "multiple", 1500, 3000, days=1) + grids = result.metrics["microgrids"] + assert set(grids) == {"Microgrid 1", "Microgrid 2", "Microgrid 3"} + assert result.metrics["run"]["total_grid_emissions"] == pytest.approx( + sum(grid["grid_emissions"] for grid in grids.values()) + ) + frame = pd.read_csv(tmp_path / "multiple/timeseries.csv") + assert frame.groupby("microgrid").size().tolist() == [24, 24, 24] + frame["import_power"] = (-frame.p_grid).clip(lower=0) + peak = frame.groupby("time").import_power.sum().max() + assert result.metrics["run"]["coincident_import_peak"] == pytest.approx(peak) + assert peak <= frame.groupby("microgrid").import_power.max().sum() + assert len(set(grid["energy_consumed"] for grid in grids.values())) == 3 + with pytest.raises(FileExistsError): + scenario.run_scenario(tmp_path, "multiple", 1500, 3000, days=1) diff --git a/tests/test_mosaik.py b/tests/test_mosaik.py new file mode 100644 index 00000000..f194c2ac --- /dev/null +++ b/tests/test_mosaik.py @@ -0,0 +1,146 @@ +import sys +from pathlib import Path + +import pytest + +pytest.importorskip("mosaik") + +import vessim as vs + +SIMULATOR = Path(__file__).parent / "simulators/power_source.py" + + +@pytest.fixture(autouse=True) +def simulator_import_path(monkeypatch): + monkeypatch.syspath_prepend(str(SIMULATOR.parent)) + + +@pytest.mark.parametrize("live", [False, True]) +@pytest.mark.parametrize("source_name", ["Source", "Vessim"]) +def test_external_power_and_delayed_feedback(live, source_name, tmp_path): + create = vs.Environment.live if live else vs.Environment + env = create( + sim_start="2026-01-01", + backend="mosaik", + sim_config={ + source_name: {"python": "power_source:PowerSource"}, + }, + ) + marker = tmp_path / "closed" + records = [] + with env: + source = env.world.start(source_name, marker=str(marker), records=records).Source() + env.add_microgrid([], name="external", dispatchables=[vs.GridConnection()]) + log = env.add_controller(vs.Recorder(metrics=False)) + env.prepare() + entity = env.entities["external"] + env.world.connect(source, entity, "power") + env.world.connect( + entity, source, ("p_grid", "feedback"), time_shifted=True, initial_data={"p_grid": 0} + ) + env.run(2) + assert [row["external"]["p_grid"] for row in log.log.values()] == [100, 201] + assert records == [(0, 0), (1, 100)] + assert marker.read_text() == "finalized" + + +def test_external_slower_sampling_and_cleanup(tmp_path): + env = vs.Environment( + "2026-01-01", + backend="mosaik", + sim_config={ + "Source": {"python": "power_source:PowerSource"}, + }, + ) + marker = tmp_path / "closed" + with env: + source = env.world.start("Source", marker=str(marker), period=2).Source() + env.add_microgrid([], name="external", dispatchables=[vs.GridConnection()]) + log = env.add_controller(vs.Recorder(metrics=False)) + env.prepare() + env.world.connect(source, env.entities["external"], "power") + env.run(5) + assert [row["external"]["p_grid"] for row in log.log.values()] == [100, 100, 102, 102, 104] + assert marker.exists() + + +def test_external_failure_and_setup_failure_cleanup(tmp_path): + for fail_setup in (False, True): + marker = tmp_path / str(fail_setup) + with pytest.raises(RuntimeError): + with vs.Environment( + "2026-01-01", + backend="mosaik", + sim_config={ + "Source": {"python": "power_source:PowerSource"}, + }, + ) as env: + source = env.world.start("Source", marker=str(marker), failure=True).Source() + if fail_setup: + raise RuntimeError("setup failed") + env.add_microgrid([], name="external", dispatchables=[vs.GridConnection()]) + env.prepare() + env.world.connect(source, env.entities["external"], "power") + env.run(3) + assert marker.exists() + + +def test_remote_process_coupling(tmp_path): + marker = tmp_path / "remote_closed" + with vs.Environment( + "2026-01-01", + backend="mosaik", + sim_config={ + "Remote": {"cmd": f'"{sys.executable}" "{SIMULATOR}" %(addr)s', "posix": True}, + }, + ) as env: + source = env.world.start("Remote", marker=str(marker)).Source() + env.add_microgrid([], name="external", dispatchables=[vs.GridConnection()]) + env.prepare() + env.world.connect(source, env.entities["external"], "power") + result = env.run(3) + assert result.states["external"]["p_grid"] == 102 + assert marker.read_text() == "finalized" + + +def test_missing_external_input_is_explicit(): + env = vs.Environment("2026-01-01", backend="mosaik") + env.add_microgrid([], name="external", dispatchables=[vs.GridConnection()]) + with pytest.raises(ValueError, match="requires external power"): + env.run(1) + + +def test_unsupported_input_is_explicit(): + with vs.Environment( + "2026-01-01", + backend="mosaik", + sim_config={ + "Source": {"python": "power_source:PowerSource"}, + }, + ) as env: + source = env.world.start("Source").Source() + env.add_microgrid([vs.Actor("a", 0)], name="grid", dispatchables=[vs.GridConnection()]) + env.prepare() + env.world.connect(source, env.entities["grid"], ("power", "policy_state")) + with pytest.raises(ValueError, match="Only the 'power' input"): + env.run(1) + + +def test_remote_sink_failure_at_last_tick_is_not_reported_as_success(tmp_path): + marker = tmp_path / "sink_closed" + env = vs.Environment( + "2026-01-01", + backend="mosaik", + sim_config={ + "Sink": {"cmd": f'"{sys.executable}" "{SIMULATOR}" %(addr)s', "posix": True}, + }, + ) + with env: + sink = env.world.start("Sink", marker=str(marker), failure=True).Source() + env.add_microgrid([vs.Actor("a", 100)], name="grid", dispatchables=[vs.GridConnection()]) + env.prepare() + env.world.connect(env.entities["grid"], sink, ("p_grid", "feedback")) + with pytest.raises(RuntimeError, match="all simulators completed"): + env.run(2) + assert env.status == "failed" + assert marker.exists() diff --git a/tests/test_results.py b/tests/test_results.py new file mode 100644 index 00000000..c0ff0929 --- /dev/null +++ b/tests/test_results.py @@ -0,0 +1,491 @@ +import csv +import json +import math + +import pandas as pd +import pytest + +import vessim as vs + + +def environment(backend="native"): + env = vs.Environment("2026-01-01", step_size=60, backend=backend) + env.add_microgrid([vs.Actor("load", -120)], name="load", dispatchables=[vs.GridConnection()]) + env.add_microgrid( + [vs.Actor("solar", 60)], + dispatchables=[vs.SimpleBattery("battery", 1), vs.GridConnection()], + name="solar", + ) + return env + + +@pytest.mark.parametrize("backend", ["native", "mosaik"]) +def test_compact_record_clips_last_interval_and_writes_once(tmp_path, backend): + if backend == "mosaik": + pytest.importorskip("mosaik") + env = environment(backend) + recorder = env.add_controller(vs.Recorder(tmp_path, timeseries=False)) + + class Inspect(vs.Controller): + def step(self, now, states): + assert not (tmp_path / "experiment.json").exists() + + env.add_controller(Inspect()) + env.run(90) + assert recorder.duration == 90 + assert recorder.metrics["microgrids"]["load"]["grid_import"] == 3 + assert recorder.metrics["microgrids"]["load"]["peak_grid_import"] == 120 + assert recorder.metrics["actors"]["load"]["load"]["energy_consumed"] == 3 + assert recorder.log == {} + assert not recorder.csv_path.exists() + result = vs.ExperimentResult.load(tmp_path) + assert result.execution["status"] == "completed" + assert result.metrics == recorder.metrics + assert result.timeseries is None + assert result.metric_definitions["actors"]["load"]["load"]["energy_consumed"]["unit"] == "Wh" + + +def test_defaults_stream_csv_and_use_one_schema(tmp_path): + env = environment() + recorder = env.add_controller(vs.Recorder(tmp_path)) + env.run(180) + assert recorder._file is None + assert not recorder.log + with recorder.csv_path.open() as file: + rows = list(csv.DictReader(file)) + assert len(rows) == 6 + assert [r["microgrid"] for r in rows] == ["load", "solar"] * 3 + assert all(None not in row for row in rows) + assert rows[0]["dispatch_states.battery.soc"] == "" + assert rows[0]["dispatch_states.battery.current_power"] == "" + assert float(rows[1]["dispatch_states.battery.current_power"]) == 60 + assert rows[1]["actor_states.solar.power"] == "60" + result = vs.ExperimentResult.load(tmp_path) + assert result.execution["status"] == "completed" + assert result.timeseries == "timeseries.csv" + assert result.metrics["microgrids"]["load"]["energy_consumed"] == 6 + assert len(recorder.to_df()) == 6 + + +def test_csv_flush_and_schema_change_failure(tmp_path): + env = environment() + recorder = env.add_controller(vs.Recorder(tmp_path, flush=True)) + + class Changing(vs.Controller): + def step(self, now, states): + assert recorder.csv_path.read_text() + assert vs.ExperimentResult.load(tmp_path).execution["status"] == "running" + env.microgrids["load"].actors[0].sample = lambda t: (-120, {"new_column": 1}) + + env.add_controller(Changing()) + with pytest.raises(ValueError, match="fields not in fieldnames"): + env.run(120) + assert recorder._file is None + assert vs.ExperimentResult.load(tmp_path).execution["status"] == "failed" + + +@pytest.mark.parametrize("failure", [KeyboardInterrupt(), ValueError("broken controller")]) +def test_partial_records_preserve_status(tmp_path, failure): + env = environment() + recorder = env.add_controller(vs.Recorder(tmp_path)) + + class Interrupt(vs.Controller): + def step(self, now, states): + raise failure + + env.add_controller(Interrupt()) + with pytest.raises(type(failure)): + env.run(120) + result = vs.ExperimentResult.load(tmp_path) + assert result.execution["status"] == ( + "interrupted" if isinstance(failure, KeyboardInterrupt) else "failed" + ) + assert result.execution["steps"] == 1 + assert recorder._file is None + + +def test_production_and_consumption_are_separate_not_net(): + env = vs.Environment("2026-01-01", step_size=60) + trace = vs.Trace(pd.Series([60, -120, 60], index=[0, 60, 120])) + env.add_microgrid( + [ + vs.Actor("bidirectional", trace), + vs.Consumer("load", 60), + ], + name="grid", + dispatchables=[vs.GridConnection()], + ) + recorder = env.add_controller(vs.Recorder(timeseries=False)) + env.run(150) + grid = recorder.metrics["microgrids"]["grid"] + actor = recorder.metrics["actors"]["grid"]["bidirectional"] + assert actor == {"energy_produced": 1.5, "energy_consumed": 2.0} + assert grid["energy_produced"] == 1.5 + assert grid["energy_consumed"] == 4.5 + assert grid["grid_import"] == 3 + assert grid["grid_export"] == 0 + + +def test_metrics_use_sampled_power_independent_of_custom_state(): + class DiagnosticActor(vs.Actor): + def sample(self, elapsed): + power, _ = super().sample(elapsed) + return power, {"temperature": 20, "power": 9999} + + env = vs.Environment("2026-01-01") + env.add_microgrid( + [DiagnosticActor("device", -3600, step_size=2)], + dispatchables=[vs.GridConnection()], + ) + recorder = env.add_controller(vs.Recorder(timeseries=False)) + env.run(2.5) + assert recorder.metrics["actors"]["microgrid_0"]["device"]["energy_consumed"] == 2.5 + + +def test_custom_metrics_static_accumulated_derived_and_scoped(tmp_path): + class HighImportDuration(vs.Metric): + def step(self, now, states, duration): + if states["load"]["p_grid"] < -100: + self.value += duration + + class Undefined(vs.Metric): + def finalize(self): + return None + + env = environment() + recorder = env.add_controller( + vs.Recorder( + tmp_path, + timeseries=False, + parameters={"seed": 42}, + run_id="trial-42", + custom_metrics={ + "high_import_duration": HighImportDuration("s", microgrid="load"), + "undefined_ratio": Undefined("1"), + }, + ) + ) + env.run(90) + recorder.result.add_metric("capex", 1000, "EUR", assumptions={"source": "example"}) + recorder.result.add_metric("area", 5, "m2", microgrid="solar", actor="solar") + recorder.result.add_metric("double_capex", recorder.metrics["run"]["capex"] * 2, "EUR") + recorder.result.save(tmp_path / "experiment.json") + result = vs.ExperimentResult.load(tmp_path) + assert result.metrics["run"] == {"capex": 1000, "double_capex": 2000, "undefined_ratio": None} + assert result.metrics["microgrids"]["load"]["high_import_duration"] == 90 + assert result.metrics["actors"]["solar"]["solar"]["area"] == 5 + assert result.parameters == {"seed": 42} + assert result.run_id == "trial-42" + assert result.metric_definitions["run"]["capex"]["assumptions"] == {"source": "example"} + detached = recorder.result.to_dict() + detached["metrics"]["run"]["capex"] = 999 + assert recorder.metrics["run"]["capex"] == 1000 + + +@pytest.mark.parametrize("backend", ["native", "mosaik"]) +def test_optional_price_and_carbon_integrals(backend): + if backend == "mosaik": + pytest.importorskip("mosaik") + env = vs.Environment("2026-01-01", step_size=60, backend=backend) + env.add_microgrid( + [vs.Actor("device", vs.Trace(pd.Series([-6000, 6000], index=[0, 60])))], + name="grid", + grid_signals={ + "buy": vs.Trace(pd.Series([0.2, 0.5], index=[0, 60])), + "sell": -0.1, + "carbon": 0.4, + }, + dispatchables=[vs.GridConnection()], + ) + rec = env.add_controller( + vs.Recorder( + timeseries=False, + custom_metrics={ + "electricity_import_cost": vs.ElectricityImportCost("grid", "buy"), + "electricity_export_revenue": vs.ElectricityExportRevenue("grid", "sell"), + "grid_import_emissions": vs.GridImportEmissions("grid", "carbon"), + }, + ) + ) + env.run(90) + m = rec.metrics["microgrids"]["grid"] + assert m["electricity_import_cost"] == pytest.approx(0.02) + assert m["electricity_export_revenue"] == pytest.approx(-0.005) + assert m["grid_import_emissions"] == pytest.approx(0.04) + + +@pytest.mark.parametrize( + "custom,match", + [ + ({"cost": vs.ElectricityImportCost("load", "missing")}, "requires grid signal"), + ({"grid_import": vs.Metric("Wh", microgrid="load")}, "already exists"), + ({"x": vs.Metric("Wh", microgrid="missing")}, "Unknown scope"), + ], +) +def test_invalid_custom_configuration_fails_before_stepping(tmp_path, custom, match): + env = environment() + rec = env.add_controller(vs.Recorder(tmp_path, custom_metrics=custom)) + with pytest.raises(ValueError, match=match): + env.run(60) + assert rec.steps == 0 + assert vs.ExperimentResult.load(tmp_path).execution["status"] == "failed" + + +def test_finalizer_failure_keeps_other_metric_results_and_valid_json(tmp_path): + class Bad(vs.Metric): + def finalize(self): + return math.nan + + env = environment() + env.add_controller( + vs.Recorder( + tmp_path, + custom_metrics={ + "bad": Bad("1"), + "good": vs.Metric("1"), + }, + ) + ) + with pytest.raises(ValueError, match="finite"): + env.run(60) + result = vs.ExperimentResult.load(tmp_path) + assert result.execution["status"] == "failed" + assert result.metrics["run"] == {"bad": None, "good": 0} + + +def test_metrics_can_be_disabled_independently(): + env = environment() + rec = env.add_controller( + vs.Recorder( + metrics=False, + custom_metrics={ + "custom": vs.Metric("1"), + }, + ) + ) + env.run(60) + assert rec.metrics["microgrids"]["load"] == {} + assert rec.metrics["run"] == {"custom": 0} + assert len(rec.log) == 1 + + +def test_empty_run_is_valid_and_serializable(tmp_path): + env = environment() + rec = env.add_controller(vs.Recorder()) + env.run(0) + assert rec.to_df().empty + assert rec.result.timeseries is None + assert rec.result.execution["last_time"] is None + assert rec.metrics["microgrids"]["load"]["grid_import"] == 0 + rec.result.save(tmp_path / "empty.json") + assert vs.ExperimentResult.load(tmp_path / "empty.json").execution["steps"] == 0 + + +def test_reader_rejects_unknown_format(tmp_path): + path = tmp_path / "experiment.json" + path.write_text(json.dumps({"format": "vessim.experiment", "schema_version": 99})) + with pytest.raises(ValueError, match="Unsupported"): + vs.ExperimentResult.load(path) + + +def test_same_custom_metric_name_in_multiple_scopes(): + env = environment() + rec = env.add_controller( + vs.Recorder( + timeseries=False, + custom_metrics={ + "cost": [ + vs.Metric("EUR", microgrid="load"), + vs.Metric("EUR", microgrid="solar"), + ], + }, + ) + ) + env.run(60) + assert rec.metrics["microgrids"]["load"]["cost"] == 0 + assert rec.metrics["microgrids"]["solar"]["cost"] == 0 + + +def test_live_record_is_available_before_first_tick_and_finalized(tmp_path): + env = vs.Environment.live() + env.add_microgrid([vs.Actor("load", -100)], dispatchables=[vs.GridConnection()]) + rec = env.add_controller(vs.Recorder(tmp_path)) + + class Inspect(vs.Controller): + def start(self, environment): + assert vs.ExperimentResult.load(tmp_path).execution["status"] == "running" + + def step(self, now, states): + assert vs.ExperimentResult.load(tmp_path).execution["steps"] == 1 + + env.add_controller(Inspect()) + env.run(0.01) + assert vs.ExperimentResult.load(tmp_path).execution["status"] == "completed" + assert rec.metrics["microgrids"]["microgrid_0"]["grid_import"] == pytest.approx( + 100 * 0.01 / 3600 + ) + + +def test_memory_observations_stay_out_of_serialized_summary(tmp_path): + env = environment() + rec = env.add_controller(vs.Recorder()) + env.run(120) + assert len(rec.log) == 2 + frame = rec.to_df() + assert len(frame) == 4 + assert frame.index.names == ["time", "microgrid"] + assert frame.loc[(slice(None), "load"), "p_grid"].tolist() == [-120, -120] + rec.result.save(tmp_path / "summary.json") + result = vs.ExperimentResult.load(tmp_path / "summary.json") + assert result.timeseries is None + assert result.metrics == rec.metrics + assert list(tmp_path.iterdir()) == [tmp_path / "summary.json"] + + +def test_no_trajectory_means_no_dataframe(): + env = environment() + recorder = env.add_controller(vs.Recorder(timeseries=False)) + env.run(1) + with pytest.raises(RuntimeError, match="disabled"): + recorder.to_df() + + +@pytest.mark.parametrize("failure_source", ["controller", "signal", "metric"]) +@pytest.mark.parametrize("recorder_first", [True, False]) +def test_final_status_includes_all_cleanup_failures(tmp_path, failure_source, recorder_first): + class BadCleanup(vs.Controller): + def step(self, now, states): + pass + + def finalize(self): + raise RuntimeError("cleanup failed") + + class BadSignal(vs.Signal): + def at(self, elapsed): + return -100 + + def finalize(self): + raise RuntimeError("cleanup failed") + + class BadMetric(vs.Metric): + def finalize(self): + raise RuntimeError("cleanup failed") + + env = vs.Environment("2026-01-01") + signal = BadSignal() if failure_source == "signal" else -100 + env.add_microgrid([vs.Actor("load", signal)], dispatchables=[vs.GridConnection()]) + recorder = vs.Recorder(tmp_path / "main") + other = ( + vs.Recorder(tmp_path / "other", custom_metrics={"bad": BadMetric("1")}) + if failure_source == "metric" + else BadCleanup() + ) + if failure_source == "signal": + env.add_controller(recorder) + else: + for controller in [recorder, other] if recorder_first else [other, recorder]: + env.add_controller(controller) + with pytest.raises(RuntimeError, match="cleanup failed"): + env.run(1) + assert env.status == recorder.result.execution["status"] == "failed" + assert vs.ExperimentResult.load(tmp_path / "main").execution["status"] == "failed" + if failure_source == "metric": + assert vs.ExperimentResult.load(tmp_path / "other").execution["status"] == "failed" + + +def test_publication_failure_updates_other_recorders(tmp_path, monkeypatch): + env = environment() + first = env.add_controller(vs.Recorder(tmp_path / "first")) + second = env.add_controller(vs.Recorder(tmp_path / "second")) + + def fail(): + raise OSError("disk full") + + monkeypatch.setattr(second, "_publish", fail) + with pytest.raises(OSError, match="disk full"): + env.run(1) + assert ( + env.status + == first.result.execution["status"] + == second.result.execution["status"] + == "failed" + ) + assert vs.ExperimentResult.load(tmp_path / "first").execution["status"] == "failed" + + +def test_csv_preserves_identifier_strings_and_distinct_nested_fields(tmp_path): + class NestedActor(vs.Actor): + def sample(self, elapsed): + return -10, {"b": {"power": 7}, "literal.key": 3, "literal%2Ekey": 4} + + env = vs.Environment("2026-01-01") + for name in ("123", "true", "NA"): + env.add_microgrid( + [ + vs.Actor("a.b", -100), + NestedActor("a", -10), + ], + name=name, + dispatchables=[vs.GridConnection()], + ) + rec = env.add_controller(vs.Recorder(tmp_path)) + env.run(1) + frame = rec.to_df() + assert list(frame.index.get_level_values("microgrid")) == ["123", "true", "NA"] + row = frame.iloc[0] + assert row["actor_states.a%2Eb.power"] == -100 + assert row["actor_states.a.b.power"] == 7 + assert row["actor_states.a.literal%2Ekey"] == 3 + assert row["actor_states.a.literal%252Ekey"] == 4 + + +@pytest.mark.parametrize("value", [float("nan"), float("inf"), True, "12"]) +def test_attached_metrics_reject_invalid_values_without_mutation(value): + env = environment() + rec = env.add_controller(vs.Recorder(timeseries=False)) + env.run(1) + before = rec.result.to_dict() + with pytest.raises(ValueError, match="finite number"): + rec.result.add_metric("bad", value, "EUR") + assert rec.result.to_dict() == before + + +def test_attached_metrics_validate_scope_and_do_not_replace_existing_values(): + env = environment() + rec = env.add_controller(vs.Recorder(timeseries=False)) + env.run(1) + result = rec.result + for kwargs in ({"microgrid": "missing"}, {"microgrid": "load", "actor": "missing"}): + with pytest.raises(ValueError, match="Unknown scope"): + result.add_metric("cost", 1, "EUR", **kwargs) + with pytest.raises(ValueError, match="requires a microgrid"): + result.add_metric("cost", 1, "EUR", actor="load") + with pytest.raises(ValueError, match="already exists"): + result.add_metric("grid_import", 1, "Wh", microgrid="load") + result.add_metric("undefined", None, "1") + assert result.to_dict()["metrics"]["run"]["undefined"] is None + + +@pytest.mark.parametrize("source", ["actor", "grid_signal", "policy"]) +@pytest.mark.parametrize("value", [math.nan, math.inf, -math.inf]) +def test_nonfinite_runtime_inputs_fail_instead_of_recording_zero(tmp_path, source, value): + class BadSignal(vs.Signal): + def at(self, elapsed=None): + return value + + class BadPolicy(vs.DefaultDispatchPolicy): + def apply(self, *args, **kwargs): + kwargs["dispatchables"][0].current_power = value + + env = vs.Environment("2026-01-01") + env.add_microgrid( + [vs.Actor("load", BadSignal() if source == "actor" else -60)], + grid_signals={"price": BadSignal()} if source == "grid_signal" else None, + policy=BadPolicy() if source == "policy" else None, + dispatchables=[vs.GridConnection()], + ) + recorder = env.add_controller(vs.Recorder(tmp_path, timeseries=False)) + with pytest.raises(ValueError, match="finite"): + env.run(60) + assert recorder.steps == 0 + assert vs.ExperimentResult.load(tmp_path).execution["status"] == "failed" diff --git a/tests/test_runtime_optimizations.py b/tests/test_runtime_optimizations.py new file mode 100644 index 00000000..1933504d --- /dev/null +++ b/tests/test_runtime_optimizations.py @@ -0,0 +1,116 @@ +"""Guard numerical and extension behavior of the optimized native tick.""" + +from datetime import timedelta +from decimal import Decimal +from fractions import Fraction + +import numpy as np +import pandas as pd +import pytest + +import vessim as vs +from vessim._util import require_finite + + +@pytest.mark.parametrize( + "value", [0, -2, 0.5, np.float64(0.5), np.float32(0.5), np.int64(2), Fraction(1, 3)] +) +def test_numeric_validation_accepts_real_scalar_types(value): + require_finite(value, "test") + + +@pytest.mark.parametrize( + "value", + [ + True, + np.bool_(False), + "1", + None, + complex(1), + Decimal("1"), + np.array([1]), + float("nan"), + float("inf"), + np.float64("nan"), + np.float32("inf"), + ], +) +def test_numeric_validation_rejects_nonreal_and_nonfinite_values(value): + with pytest.raises(ValueError, match="finite number"): + require_finite(value, "test") + + +@pytest.mark.parametrize("fill", ["ffill", "bfill"]) +@pytest.mark.parametrize("spacing", [0.000002, 0.25, 300, 3600]) +def test_regular_trace_matches_search_for_arbitrary_query_order(fill, spacing): + offsets = np.arange(20) * spacing + values = np.arange(20, dtype=float) ** 2 + frame = pd.DataFrame({"first": values, "second": -values}, index=offsets) + trace = vs.Trace(frame, fill_method=fill) + selected = trace.select("second") + rng = np.random.default_rng(42) + queries = np.concatenate([offsets, rng.uniform(0, offsets[-1], 100)]) + rng.shuffle(queries) + ns = pd.to_timedelta(offsets, unit="s").to_numpy().astype("timedelta64[ns]").view("i8") + for elapsed in queries: + at = round(elapsed * 1_000_000) * 1000 + index = np.searchsorted(ns, at, side="right" if fill == "ffill" else "left") + if fill == "ffill": + index -= 1 + assert trace.at(float(elapsed), "first") == values[index] + assert selected.at(timedelta(seconds=float(elapsed))) == -values[index] + + +@pytest.mark.parametrize("fill", ["ffill", "bfill"]) +def test_irregular_and_single_sample_traces_retain_lookup_behavior(fill): + trace = vs.Trace(pd.Series([1, 2, 3], index=[0, 1, 3]), fill_method=fill) + assert trace.at(2) == (2 if fill == "ffill" else 3) + assert trace.at(1) == 2 + single = vs.Trace(pd.Series([7], index=[0]), fill_method=fill) + assert single.at(0) == 7 + with pytest.raises(ValueError, match="after the end"): + single.at(0.1) + + +def test_dispatch_uses_updated_limits_for_the_final_partial_interval(): + connection = vs.GridConnection(max_import=100) + battery = vs.SimpleBattery("battery", 1000, initial_soc=0.5) + env = vs.Environment("2026-01-01", step_size=60) + env.add_microgrid([vs.Consumer("load", 100)], [connection, battery], name="site") + + class Limit(vs.Controller): + def step(self, now, states): + connection.max_import = 25 + + env.add_controller(Limit()) + rec = env.add_controller(vs.Recorder()) + env.run(90) + assert [states["site"]["p_grid"] for states in rec.log.values()] == [-100, -25] + assert battery.charge_level == pytest.approx(500 - 75 * 30 / 3600) + + +def test_custom_setpoint_hook_runs_and_uses_updated_limits(): + class Limited(vs.GridConnection): + def set_power(self, power, duration): + self.max_import = 10 + super().set_power(power, duration) + + connection = Limited() + env = vs.Environment("2026-01-01") + env.add_microgrid([vs.Consumer("load", 100)], [connection]) + with pytest.raises(ValueError, match="outside feasible range"): + env.run(1) + assert connection.current_power == 0 + + +def test_valid_battery_limit_does_not_log(monkeypatch): + from vessim.dispatchable import logger + + messages = [] + monkeypatch.setattr(logger, "info", lambda *args, **kwargs: messages.append(args)) + battery = vs.SimpleBattery("battery", 1000, initial_soc=0.5, c_rate=0.1) + for power in (100, -100): + battery.set_power(power, 60) + battery.step(60) + assert messages == [] + assert battery.charge_level == pytest.approx(500) diff --git a/tests/test_signal.py b/tests/test_signal.py index 3fbcc8a5..ebcaef06 100644 --- a/tests/test_signal.py +++ b/tests/test_signal.py @@ -10,24 +10,23 @@ class TestTrace: @pytest.fixture def hist_signal(self) -> vs.Trace: - index = pd.to_datetime([ - "2023-01-01T00:00:00", - "2023-01-01T00:30:00", - "2023-01-01T01:00:00", - ]) + index = pd.to_datetime( + [ + "2023-01-01T00:00:00", + "2023-01-01T00:30:00", + "2023-01-01T01:00:00", + ] + ) actual = pd.DataFrame( - {"a": [1, 2, 3], "b": [0, 3, 0], "c": [None, 4, None]}, - index=index - index[0] + {"a": [1, 2, 3], "b": [0, 3, 0], "c": [0, 4, 0]}, index=index - index[0] ) return vs.Trace(actual) @pytest.fixture def hist_signal_single(self) -> vs.Trace: - index = pd.to_datetime([ - "2023-01-01T01:00:00", - "2023-01-01T00:30:00", - "2023-01-01T00:00:00" - ]) + index = pd.to_datetime( + ["2023-01-01T01:00:00", "2023-01-01T00:30:00", "2023-01-01T00:00:00"] + ) actual = pd.Series([3, 2, 1], index=index - index.min()) return vs.Trace(actual, fill_method="bfill") @@ -37,16 +36,12 @@ def test_columns(self, hist_signal): def test_actual_single_column(self, hist_signal_single): assert hist_signal_single.at(timedelta(minutes=45)) == 3 - def test_actual_none_values(self, hist_signal): - assert hist_signal.at(timedelta(hours=1, minutes=20), column="c") == 4 - @pytest.mark.parametrize( "elapsed, column, expected", [ (timedelta(0), "a", 1), (timedelta(seconds=10), "a", 1), (timedelta(hours=1), "a", 3), - (timedelta(hours=10), "a", 3), (timedelta(minutes=29, seconds=59), "b", 0), (timedelta(minutes=30), "b", 3), ], @@ -86,18 +81,18 @@ def test_actual_fails_if_at_is_none(self, hist_signal_single): hist_signal_single.at(None) def test_datetime_index_requires_anchor(self): - data = pd.Series( - [1, 2], index=pd.to_datetime(["2023-01-01", "2023-01-02"]) - ) + data = pd.Series([1, 2], index=pd.to_datetime(["2023-01-01", "2023-01-02"])) with pytest.raises(ValueError, match="anchor is required"): vs.Trace(data) def test_datetime_index_with_anchor_rebases(self): - index = pd.to_datetime([ - "2023-01-01 00:00:00", - "2023-01-01 01:00:00", - "2023-01-01 02:00:00", - ]) + index = pd.to_datetime( + [ + "2023-01-01 00:00:00", + "2023-01-01 01:00:00", + "2023-01-01 02:00:00", + ] + ) data = pd.Series([10, 20, 30], index=index) trace = vs.Trace(data, anchor="2023-01-01 01:00:00") # Anchor row becomes elapsed=0; row before is dropped. @@ -152,14 +147,86 @@ def test_unsupported_on_overflow(self): vs.Trace(pd.Series([1, 2], index=index), on_overflow="loop") # type: ignore[arg-type] -class TestStaticSignal: - def test_returns_value(self): - s = vs.StaticSignal(42) - assert s.at() == 42 - assert s.at(timedelta(hours=5)) == 42 - assert s.at(60) == 42 # int seconds - - def test_set_value(self): - s = vs.StaticSignal(1) - s.set_value(7) - assert s.at() == 7 +def test_trace_views_share_immutable_data_and_sample_independently(): + trace = vs.Trace(pd.DataFrame({"a": [1, 2, 3], "b": [4, 5, 6]}, index=[0, 1, 2])) + a, b = trace.select("a"), trace.select("b") + assert a._offsets is trace._offsets + assert b._offsets is trace._offsets + assert [a.at(2), a.at(0), b.at(1), a.at(0.5)] == [3, 1, 5, 1] + with pytest.raises(ValueError): + a._offsets["a"][1][0] = 10 + with pytest.raises(ValueError): + trace.select("missing") + + +@pytest.mark.parametrize("elapsed", [0.000001, 0.0000015, 0.1234567, 1.9999999, 2.0]) +def test_trace_fractional_seconds_match_timedelta(elapsed): + trace = vs.Trace(pd.Series([1, 2, 3], index=[0, 0.000002, 2])) + assert trace.at(elapsed) == trace.at(timedelta(seconds=elapsed)) + + +@pytest.mark.parametrize("fill_method", ["ffill", "bfill"]) +def test_trace_bounds_apply_to_both_fill_methods(fill_method): + trace = vs.Trace(pd.Series([10, 20], index=[0, 60]), fill_method=fill_method) + assert trace.at(0) == 10 + assert trace.at(60) == 20 + assert trace.at(30) == (10 if fill_method == "ffill" else 20) + for elapsed, boundary in [(-1, "before the start"), (61, "after the end")]: + with pytest.raises(ValueError, match=boundary): + trace.at(elapsed) + + +@pytest.mark.parametrize("value", [np.nan, np.inf, -np.inf]) +def test_nonfinite_signal_inputs_fail_at_construction(value): + with pytest.raises(ValueError, match="column 'load'.*offset"): + vs.Trace(pd.Series([10, value], index=[0, 60], name="load")) + trace = vs.Trace(pd.Series([10, 20], index=[0, 60])) + with pytest.raises(ValueError, match="finite"): + trace.at(value) + + +@pytest.mark.parametrize( + "index", [[0, 0], [0, np.nan], [0, np.inf], pd.to_timedelta([0, None], unit="s")] +) +def test_trace_rejects_ambiguous_or_invalid_timestamps(index): + with pytest.raises(ValueError, match="unique|missing|finite"): + vs.Trace(pd.Series([10, 20], index=index)) + + +def test_trace_rejects_empty_and_invalid_columns(): + for data in [ + pd.DataFrame(index=[0, 60]), + pd.Series([np.nan], index=[0]), + pd.DataFrame([[1, 2]], columns=["load", "load"], index=[0]), + ]: + with pytest.raises(ValueError): + vs.Trace(data) + with pytest.raises(ValueError, match="Available columns.*load"): + vs.Trace(pd.Series([10], name="load", index=[0]), column="missing") + + +def test_csv_errors_identify_columns_and_timestamps(tmp_path): + path = tmp_path / "load.csv" + path.write_text("seconds,load\n0,10\n60,broken\n") + with pytest.raises(ValueError, match="column 'load'.*numeric"): + vs.Trace.from_csv(path) + path.write_text("seconds,load\nyesterday-ish,10\n") + with pytest.raises(ValueError, match="first column.*load.csv"): + vs.Trace.from_csv(path) + + +def test_static_signal_returns_updated_value_at_any_time(): + signal = vs.StaticSignal(700) + assert signal.at() == signal.at(60) == signal.at(timedelta(seconds=60)) == 700 + signal.set_value(500) + assert signal.at(0) == 500 + + +@pytest.mark.parametrize("value", [np.nan, np.inf, -np.inf, True, "700"]) +def test_static_signal_rejects_invalid_values_without_mutation(value): + with pytest.raises(ValueError, match="finite number"): + vs.StaticSignal(value) + signal = vs.StaticSignal(700) + with pytest.raises(ValueError, match="finite number"): + signal.set_value(value) + assert signal.at() == 700 diff --git a/tests/test_viewer.py b/tests/test_viewer.py new file mode 100644 index 00000000..3bdce61a --- /dev/null +++ b/tests/test_viewer.py @@ -0,0 +1,178 @@ +"""Canonical study loading, missing records, lazy artifacts and portable exports.""" + +import json + +import pytest + +from vessim._viewer import Catalog + + +def record(trajectory=None, run_id="run", status="completed"): + return { + "format": "vessim.experiment", + "schema_version": 1, + "run_id": run_id, + "config": {"microgrids": {}}, + "parameters": {"seed": 42}, + "execution": {"status": status}, + "metrics": {"run": {"missing": None}, "microgrids": {}, "actors": {}}, + "metric_definitions": {"run": {"missing": {"unit": "1"}}, "microgrids": {}, "actors": {}}, + "timeseries": trajectory, + } + + +def save(path, data): + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(data)) + return path + + +def study(configurations, identity="study"): + return { + "format": "vessim.study", + "schema_version": 2, + "study_id": identity, + "name": identity, + "run_parameters": ["seed"], + "configurations": configurations, + } + + +def test_file_directory_collection_and_summary_only(tmp_path): + file = save(tmp_path / "a/experiment.json", record(run_id="a")) + save(tmp_path / "b/experiment.json", record(run_id="b")) + for path in (file, file.parent, tmp_path): + catalog = Catalog(path) + assert not catalog.errors + assert len(catalog.runs) == (2 if path == tmp_path else 1) + assert catalog.runs[0]["record"]["metrics"]["run"]["missing"] is None + assert catalog.runs[0]["trajectoryUrl"] is None + assert "configurations" not in catalog.payload()["studies"][0] + + +def test_study_preserves_grouping_and_loads_csv_only_when_requested(tmp_path): + save(tmp_path / "records/a/experiment.json", record("power.csv", "a")) + save(tmp_path / "records/b/experiment.json", record(run_id="b")) + csv = tmp_path / "records/a/power.csv" + index = save( + tmp_path / "indexes/study.json", + study( + [ + { + "configuration_id": "candidate", + "name": "Candidate", + "runs": [ + {"path": "../records/a/experiment.json"}, + {"path": "../records/b/experiment.json"}, + ], + } + ] + ), + ) + # The index must load even when the trajectory is not available yet. + catalog = Catalog(index) + group = catalog.study("0")["configurations"][0] + assert group["name"] == "Candidate" + assert [r["run_id"] for r in group["runs"]] == ["a", "b"] + assert not catalog.errors + assert catalog.study("0")["run_parameters"] == ["seed"] + csv.write_text("microgrid,time,p_grid\nsite,2026-01-01,-1\n") + assert catalog.trajectory("0")[0] == csv.read_bytes() + + +def test_partial_catalog_excludes_incomplete_and_invalid_records(tmp_path): + save(tmp_path / "a/experiment.json", record("missing.csv", "a")) + save(tmp_path / "b/experiment.json", {**record(), "schema_version": 99}) + save(tmp_path / "c/experiment.json", record(run_id="c", status="interrupted")) + save(tmp_path / "d/experiment.json", record(run_id="d", status="failed")) + catalog = Catalog(tmp_path) + assert len(catalog.runs) == 1 + assert len(catalog.errors) == 3 + with pytest.raises(FileNotFoundError): + catalog.trajectory("0") + + +@pytest.mark.parametrize("reference", ["../outside.csv", "/etc/passwd"]) +def test_trajectory_cannot_escape_experiment_directory(tmp_path, reference): + catalog = Catalog(save(tmp_path / "experiment.json", record(reference))) + assert not catalog.runs + assert "within" in catalog.errors[0] or "relative CSV" in catalog.errors[0] + + +def test_static_export_uses_same_summaries_and_lazy_urls(tmp_path): + file = save(tmp_path / "source/experiment.json", record("timeseries.csv")) + (file.parent / "timeseries.csv").write_text("microgrid,time,p_grid\nsite,2026-01-01,-1\n") + assets = tmp_path / "assets" + assets.mkdir() + (assets / "index.html").write_text("viewer") + catalog = Catalog(file) + destination = tmp_path / "export" + catalog.export(destination, assets) + payload = json.loads((destination / "catalog.json").read_text()) + assert payload == catalog.payload() + exported = json.loads((destination / payload["studies"][0]["url"]).read_text()) + assert exported == catalog.study("0") + assert ( + destination / exported["configurations"][0]["runs"][0]["trajectoryUrl"] + ).read_bytes() == (file.parent / "timeseries.csv").read_bytes() + with pytest.raises(ValueError, match="empty"): + catalog.export(destination, assets) + + +def test_duplicates_missing_references_and_unsupported_study_version(tmp_path): + save(tmp_path / "a/experiment.json", record(run_id="a")) + file = save( + tmp_path / "study.json", + study( + [ + { + "configuration_id": "c", + "runs": [ + {"path": "a/experiment.json"}, + {"path": "a/experiment.json"}, + {"path": "missing/experiment.json"}, + ], + } + ] + ), + ) + catalog = Catalog(file) + assert len(catalog.runs) == 1 + assert len(catalog.errors) == 2 + assert "Duplicate run_id" in catalog.errors[0] + save(file, {**study([]), "schema_version": 1}) + assert "schema_version 2" in Catalog(file).errors[0] + + +def test_batch_and_multiple_studies(tmp_path): + shared = record() + save( + tmp_path / "first/batch.json", + { + "format": "vessim.viewer.run-batch", + "schema_version": 1, + "shared": shared, + "runs": [ + {"run_id": "seed-1", "parameters": {"seed": 1}}, + {"run_id": "seed-2", "parameters": {"seed": 2}}, + ], + }, + ) + save( + tmp_path / "first/study.json", + study([{"configuration_id": "c", "run_batch": "batch.json"}], "first"), + ) + save(tmp_path / "second/study.json", study([], "second")) + catalog = Catalog(tmp_path) + assert not catalog.errors + assert len(catalog.payload()["studies"]) == 2 + assert [r["record"]["parameters"]["seed"] for r in catalog.runs] == [1, 2] + assert catalog.runs[0]["record"]["metrics"] == shared["metrics"] + + +def test_nonfinite_json_is_reported(tmp_path): + data = record() + data["metrics"]["run"]["bad"] = float("nan") + catalog = Catalog(save(tmp_path / "experiment.json", data)) + assert not catalog.runs + assert "Nonfinite" in catalog.errors[0] diff --git a/vessim/__init__.py b/vessim/__init__.py index d82c6726..9a728825 100644 --- a/vessim/__init__.py +++ b/vessim/__init__.py @@ -1,24 +1,40 @@ """A co-simulation testbed for microgrids and energy-aware systems.""" -from vessim.actor import Actor -from vessim.controller import Controller, MemoryLogger, CsvLogger +from vessim.actor import Actor, Consumer, Producer +from vessim._runtime import RunResult +from vessim.controller import Controller +from vessim.recorder import Recorder, ExperimentResult +from vessim.metrics import ( + Metric, + ElectricityImportCost, + ElectricityExportRevenue, + GridImportEmissions, +) from vessim.dispatch_policy import DispatchPolicy, DefaultDispatchPolicy -from vessim.dispatchable import Dispatchable, Storage, SimpleBattery, ClcBattery +from vessim.dispatchable import Dispatchable, GridConnection, Storage, SimpleBattery, ClcBattery from vessim.environment import Environment from vessim.microgrid import Microgrid, MicrogridState -from vessim.signal import Signal, Trace, StaticSignal +from vessim.signal import Signal, StaticSignal, Trace __all__ = [ + "RunResult", + "Recorder", + "ExperimentResult", + "Metric", + "ElectricityImportCost", + "ElectricityExportRevenue", + "GridImportEmissions", "Actor", + "Consumer", + "Producer", "Controller", - "MemoryLogger", - "CsvLogger", "Microgrid", "MicrogridState", "Environment", "DispatchPolicy", "DefaultDispatchPolicy", "Dispatchable", + "GridConnection", "Storage", "SimpleBattery", "ClcBattery", @@ -29,15 +45,15 @@ try: from vessim.controller import Api # noqa: F401 - from vessim.signal import SilSignal, WatttimeSignal, PrometheusSignal # noqa: F401 + from vessim.signal import LiveSignal, WatttimeSignal, PrometheusSignal # noqa: F401 - __all__.extend(["Api", "SilSignal", "WatttimeSignal", "PrometheusSignal"]) + __all__.extend(["Api", "LiveSignal", "WatttimeSignal", "PrometheusSignal"]) except ImportError: - _SIL_INSTALL_MSG = ( - "This feature requires the 'sil' extra. Install with: pip install 'vessim[sil]'" + _LIVE_INSTALL_MSG = ( + "This feature requires the 'live' extra. Install with: pip install 'vessim[live]'" ) def __getattr__(name: str): - if name in ("Api", "SilSignal", "WatttimeSignal", "PrometheusSignal"): - raise ImportError(f"vessim.{name} is not available. {_SIL_INSTALL_MSG}") + if name in ("Api", "LiveSignal", "WatttimeSignal", "PrometheusSignal"): + raise ImportError(f"vessim.{name} is not available. {_LIVE_INSTALL_MSG}") raise AttributeError(f"module 'vessim' has no attribute {name!r}") diff --git a/vessim/__main__.py b/vessim/__main__.py new file mode 100644 index 00000000..065af628 --- /dev/null +++ b/vessim/__main__.py @@ -0,0 +1,5 @@ +"""Allow the installed CLI to be invoked with python -m vessim.""" + +from vessim._cli import main + +main() diff --git a/vessim/_broker.py b/vessim/_broker.py index 3c758828..865a5778 100644 --- a/vessim/_broker.py +++ b/vessim/_broker.py @@ -16,7 +16,7 @@ def __init__(self): except ImportError as e: raise ImportError( "prometheus_client is required for Prometheus metrics export. " - "Install with: pip install vessim[sil]" + "Install with: pip install vessim[live]" ) from e self.microgrid_p_delta = Gauge( @@ -194,7 +194,7 @@ def set_actor_signal(name: str, actor_name: str, value: float): "microgrid": name, "target": "actor", "target_name": actor_name, - "property": "value", + "property": "signal", "value": value, }) return {"status": "command queued"} @@ -210,7 +210,7 @@ def get_prometheus_metrics(): except ImportError as e: raise ImportError( "prometheus_client is required for Prometheus metrics export. " - "Install with: pip install vessim[sil]" + "Install with: pip install vessim[live]" ) from e return Response(generate_latest(), media_type=CONTENT_TYPE_LATEST) diff --git a/vessim/_cli.py b/vessim/_cli.py index 14a47e90..d87689fb 100644 --- a/vessim/_cli.py +++ b/vessim/_cli.py @@ -1,20 +1,18 @@ -"""Vessim CLI. - -So far only supports: -- `vessim view ` -""" +"""Serve recorded experiments and studies in the Vessim viewer.""" from __future__ import annotations import argparse import functools +import gzip import json import sys import webbrowser -from http.server import HTTPServer, SimpleHTTPRequestHandler +from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path +from urllib.parse import unquote, urlsplit -import yaml +from vessim._viewer import Catalog def _viewer_dist_dir() -> Path: @@ -22,118 +20,91 @@ def _viewer_dist_dir() -> Path: class _ViewerHandler(SimpleHTTPRequestHandler): - """Serves the built viewer app, /results/* files, and a /experiments JSON endpoint.""" - - def __init__(self, *args, viewer_dir: Path, results_dir: Path, **kwargs): - self.viewer_dir = viewer_dir - self.results_dir = results_dir - super().__init__(*args, **kwargs) + def __init__(self, *args, catalog: Catalog, viewer_dir: Path, **kwargs): + self.catalog = catalog + super().__init__(*args, directory=str(viewer_dir), **kwargs) def do_GET(self): - url_path = self.path.split("?", 1)[0].split("#", 1)[0] - if url_path == "/experiments": - self._serve_experiments() - return - super().do_GET() - - def _serve_experiments(self): - root_config = self.results_dir / "metadata.yaml" - if root_config.exists(): - status = self._read_status(root_config) - data = {"mode": "single", "experiments": [{"name": "", "status": status}]} + path = unquote(urlsplit(self.path).path) + if path == "/catalog.json": + self._send( + json.dumps(self.catalog.payload(), allow_nan=False).encode(), "application/json" + ) + elif path.startswith("/data/"): + parts = path.strip("/").split("/") + try: + if len(parts) != 3: + raise KeyError(path) + if parts[2] == "study.json": + content = json.dumps(self.catalog.study(parts[1]), allow_nan=False).encode() + kind = "application/json" + elif parts[2] == "trajectory": + content, kind = self.catalog.trajectory(parts[1]) + else: + raise KeyError(path) + self._send(content, kind) + except (KeyError, OSError, ValueError): + self.send_error(404, "Recorded trajectory is unavailable") else: - experiments = [] - for subdir in sorted(self.results_dir.iterdir()): - config_file = subdir / "metadata.yaml" - if subdir.is_dir() and config_file.exists(): - experiments.append({ - "name": subdir.name, - "status": self._read_status(config_file) - }) - data = {"mode": "multi", "experiments": experiments} - - body = json.dumps(data).encode() + super().do_GET() + + def _send(self, body: bytes, content_type: str) -> None: + compressed = len(body) > 1024 and "gzip" in self.headers.get("Accept-Encoding", "") + if compressed: + body = gzip.compress(body, compresslevel=1) self.send_response(200) - self.send_header("Content-Type", "application/json") + self.send_header("Content-Type", content_type) self.send_header("Content-Length", str(len(body))) + self.send_header("Cache-Control", "no-cache") + self.send_header("Vary", "Accept-Encoding") + if compressed: + self.send_header("Content-Encoding", "gzip") self.end_headers() self.wfile.write(body) - def _read_status(self, config_file: Path) -> str | None: - try: - config = yaml.safe_load(config_file.read_text()) or {} - return config.get("execution", {}).get("status") - except Exception: - return None - - def translate_path(self, path: str) -> str: - # Strip query string and fragment - path = path.split("?", 1)[0].split("#", 1)[0] - - if path.startswith("/results/"): - rel = path[len("/results/"):] - return str(self.results_dir / rel) - if path == "/results": - return str(self.results_dir) - - # Serve from viewer dist - rel = path.lstrip("/") - full = self.viewer_dir / rel - # SPA fallback: if file doesn't exist, serve index.html - if not full.exists() or full.is_dir(): - if rel and not (full.exists() and full.is_dir()): - return str(self.viewer_dir / "index.html") - return str(full) - def log_message(self, format, *args): - # Silence request logs pass def _cmd_view(args: argparse.Namespace) -> None: - results_dir = Path(args.directory).resolve() - if not results_dir.is_dir(): - print(f"Error: '{results_dir}' is not a directory", file=sys.stderr) - sys.exit(1) - - viewer_dir = _viewer_dist_dir() - if not (viewer_dir / "index.html").exists(): - print("Error: viewer assets not found. Rebuild with `npm run build` in viewer/.", - file=sys.stderr) + try: + catalog = Catalog(Path(args.path)) + viewer_dir = _viewer_dist_dir() + if not (viewer_dir / "index.html").exists(): + raise ValueError("Viewer assets not found. Run npm run build in viewer/.") + if args.export: + catalog.export(Path(args.export), viewer_dir) + print(f"Static viewer exported to {args.export}") + return + except (ValueError, OSError) as exc: + print(f"Error: {exc}", file=sys.stderr) sys.exit(1) - - port = args.port - handler = functools.partial( - _ViewerHandler, - viewer_dir=viewer_dir, - results_dir=results_dir, - ) - server = HTTPServer(("localhost", port), handler) - - url = f"http://localhost:{port}" - print(f"Serving experiment viewer at {url}") - print(f"Results directory: {results_dir}") - print("Press Ctrl+C to stop.\n") - + handler = functools.partial(_ViewerHandler, catalog=catalog, viewer_dir=viewer_dir) + server = ThreadingHTTPServer(("localhost", args.port), handler) + url = f"http://localhost:{server.server_port}" + print(f"Experiment viewer: {url}\n{len(catalog.runs)} recorded runs · {catalog.source}") + for error in catalog.errors: + print(f"Skipped: {error}", file=sys.stderr) if not args.no_browser: webbrowser.open(url) - try: server.serve_forever() except KeyboardInterrupt: - print("\nStopped.") + pass + finally: server.server_close() def main(): parser = argparse.ArgumentParser(prog="vessim", description="Vessim CLI") sub = parser.add_subparsers(dest="command") - - view = sub.add_parser("view", help="View experiment results in the browser") - view.add_argument("directory", help="Path to a results directory") - view.add_argument("-p", "--port", type=int, default=8710, help="Port (default: 8710)") - view.add_argument("--no-browser", action="store_true", help="Don't open browser automatically") - + view = sub.add_parser("view", help="Explore recorded experiments or a study") + view.add_argument( + "path", help="Experiment JSON/directory, collection directory, or study JSON" + ) + view.add_argument("-p", "--port", type=int, default=8710) + view.add_argument("--no-browser", action="store_true") + view.add_argument("--export", metavar="DIRECTORY", help="Export a portable static viewer") args = parser.parse_args() if args.command == "view": _cmd_view(args) diff --git a/vessim/_experiment.py b/vessim/_experiment.py new file mode 100644 index 00000000..e276a4b7 --- /dev/null +++ b/vessim/_experiment.py @@ -0,0 +1,264 @@ +"""Experiment records and the shared validation boundary for persisted results.""" + +from __future__ import annotations + +import json +import math +import shutil +from dataclasses import dataclass, field +from datetime import datetime +from pathlib import Path +from typing import Optional + +import numpy as np + + +def _json_default(value): + if isinstance(value, np.generic): + return value.item() + if isinstance(value, (datetime, Path)): + return str(value) + raise TypeError(f"Experiment data must be JSON-compatible, got {type(value).__name__}.") + + +def _reject_constant(value: str): + raise ValueError(f"Nonfinite JSON number: {value}") + + +def read_json(path: Path): + """Read strict JSON; validation also rejects numeric overflow such as 1e999.""" + return json.loads(path.read_text(), parse_constant=_reject_constant) + + +def _object(value, label: str) -> dict: + if not isinstance(value, dict) or any(not isinstance(k, str) for k in value): + raise ValueError(f"{label} must be an object with string keys") + return value + + +def _definition(value, label: str) -> None: + definition = _object(value, label) + if not isinstance(definition.get("unit"), str) or not definition["unit"]: + raise ValueError(f"{label}.unit must be a nonempty string") + if "description" in definition and not isinstance(definition["description"], str): + raise ValueError(f"{label}.description must be a string") + if "assumptions" in definition: + _object(definition["assumptions"], f"{label}.assumptions") + + +def _metric_value(value, label: str) -> None: + if value is not None and ( + isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value) + ): + raise ValueError(f"Metric '{label}' must be a finite number or None") + + +def validate_record(raw: dict) -> None: + """Validate the same experiment contract for Python and the viewer catalog.""" + _object(raw, "Experiment") + if ( + raw.get("format") != "vessim.experiment" + or type(raw.get("schema_version")) is not int + or raw["schema_version"] != 1 + ): + raise ValueError("Unsupported Vessim experiment format or schema version.") + if not isinstance(raw.get("run_id"), str) or not raw["run_id"]: + raise ValueError("run_id must be a nonempty string") + for name in ("config", "parameters", "execution", "metrics", "metric_definitions"): + _object(raw.get(name), name) + _object(raw["config"].get("microgrids"), "config.microgrids") + execution = raw["execution"] + if execution.get("status") not in ("running", "completed", "failed", "interrupted", "closed"): + raise ValueError("execution.status is invalid") + for name in ("steps", "simulated_duration", "wall_time", "until"): + value = execution.get(name) + if value is not None and ( + isinstance(value, bool) + or not isinstance(value, (int, float)) + or not math.isfinite(value) + or value < 0 + ): + raise ValueError(f"execution.{name} must be nonnegative and finite or null") + if execution.get("steps") is not None and type(execution["steps"]) is not int: + raise ValueError("execution.steps must be an integer") + + def check_scope(values, definitions, depth, label): + _object(values, label) + _object(definitions, f"Definitions for {label}") + if values.keys() != definitions.keys(): + raise ValueError(f"Metrics and definitions must have matching keys at {label}") + for name, value in values.items(): + if not name: + raise ValueError(f"Empty name at {label}") + if depth: + check_scope(value, definitions[name], depth - 1, f"{label}.{name}") + else: + _metric_value(value, f"{label}.{name}") + _definition(definitions[name], f"{label}.{name}") + + for scope, depth in (("run", 0), ("microgrids", 1), ("actors", 2)): + check_scope(raw["metrics"].get(scope), raw["metric_definitions"].get(scope), depth, scope) + if "timeseries" not in raw: + raise ValueError("timeseries must be a relative CSV path or null") + trajectory = raw["timeseries"] + if trajectory is not None and ( + not isinstance(trajectory, str) + or not trajectory + or Path(trajectory).is_absolute() + or Path(trajectory).suffix.lower() != ".csv" + ): + raise ValueError("timeseries must be a relative CSV path or null") + # Check every metadata value, including overflow parsed from valid JSON syntax. + json.dumps(raw, allow_nan=False) + + +@dataclass +class ExperimentResult: + """Recorded configuration, execution status, and metrics for one experiment. + + Access through Recorder.result or load an existing record with load(). + + Attributes: + run_id: Identifier for this experiment. + config: Recorded component configuration. + parameters: User-supplied study parameters, such as battery capacity or seed. + execution: Status and timing information. Check "status" before comparing + results from completed, failed, or interrupted runs. + metrics: Values grouped under "run", "microgrids", and "actors". + metric_definitions: Matching units, descriptions, and assumptions. + timeseries: Relative path to the recorded CSV, or None. + + In-memory observations belong to Recorder.log, never to the persisted JSON. + Loaded and recorded results remember their trajectory directory. Saving to + another directory copies the CSV alongside the new JSON without changing + the original result. + """ + + run_id: str + config: dict + parameters: dict + execution: dict + metrics: dict + metric_definitions: dict + timeseries: Optional[str] + _directory: Optional[Path] = field(default=None, repr=False, compare=False) + + def to_dict(self) -> dict: + """Return a detached, validated JSON-compatible summary.""" + raw = { + "format": "vessim.experiment", + "schema_version": 1, + "run_id": self.run_id, + "config": self.config, + "parameters": self.parameters, + "execution": self.execution, + "metrics": self.metrics, + "metric_definitions": self.metric_definitions, + "timeseries": self.timeseries, + } + data = json.loads(json.dumps(raw, allow_nan=False, default=_json_default)) + validate_record(data) + return data + + @property + def timeseries_path(self) -> Optional[Path]: + """Absolute path to the recorded CSV, or None when no CSV was recorded.""" + if self.timeseries is None: + return None + if self._directory is None: + raise ValueError("CSV origin is unknown. Load the result from its JSON file.") + return (self._directory / self.timeseries).resolve() + + def save(self, path: str | Path) -> None: + """Save JSON atomically and copy a referenced CSV when exporting elsewhere. + + The copied CSV is named .timeseries.csv. Saving in the source + directory keeps the existing reference. In-memory recordings save only + their summary. Use Recorder(output=...) to persist their trajectory. + """ + path = Path(path).resolve() + data = self.to_dict() + source = self.timeseries_path + if source == path: + raise ValueError("The JSON destination must not overwrite its CSV trajectory") + if source is not None and path.parent != self._directory: + target = path.with_name(path.stem + ".timeseries.csv") + if target == path: + raise ValueError("The JSON and CSV destinations must be different") + path.parent.mkdir(parents=True, exist_ok=True) + if source != target: + temporary_csv = target.with_name(target.name + ".tmp") + shutil.copyfile(source, temporary_csv) + temporary_csv.replace(target) + data["timeseries"] = target.name + content = json.dumps(data, indent=2, allow_nan=False) + "\n" + path.parent.mkdir(parents=True, exist_ok=True) + temporary = path.with_name(path.name + ".tmp") + temporary.write_text(content) + temporary.replace(path) + + @classmethod + def load(cls, path: str | Path) -> ExperimentResult: + """Load and validate an experiment JSON or directory, retaining its origin.""" + path = Path(path).resolve() + if path.is_dir(): + path /= "experiment.json" + raw = read_json(path) + validate_record(raw) + return cls( + **{ + name: raw[name] + for name in ( + "run_id", + "config", + "parameters", + "execution", + "metrics", + "metric_definitions", + "timeseries", + ) + }, + _directory=path.parent, + ) + + def add_metric( + self, + name: str, + value: Optional[float], + unit: str, + *, + microgrid: Optional[str] = None, + actor: Optional[str] = None, + description: str = "", + assumptions: Optional[dict] = None, + ) -> None: + """Attach a scalar after the run. Call save() to persist the updated record. + + Names must be new within their scope. Compute derived values in ordinary + Python before calling this method. No calculator dependencies are inferred. + """ + if self.execution["status"] == "running": + raise RuntimeError("Add scalar metrics after the run has finished") + if not isinstance(name, str) or not name: + raise ValueError("Metric names must be nonempty strings") + if actor is not None and microgrid is None: + raise ValueError("An actor metric requires a microgrid name") + definition = {"unit": unit, "description": description, "assumptions": assumptions or {}} + definition = json.loads(json.dumps(definition, allow_nan=False, default=_json_default)) + _definition(definition, name) + value = _json_default(value) if isinstance(value, np.generic) else value + _metric_value(value, name) + try: + values, definitions = self.metrics, self.metric_definitions + path = ( + ["actors", microgrid, actor] + if actor is not None + else ["microgrids", microgrid] if microgrid is not None else ["run"] + ) + for key in path: + values, definitions = values[key], definitions[key] + except KeyError as exc: + raise ValueError(f"Unknown scope for metric '{name}'") from exc + if name in values: + raise ValueError(f"Metric '{name}' already exists in this scope") + values[name], definitions[name] = value, definition diff --git a/vessim/_mosaik.py b/vessim/_mosaik.py new file mode 100644 index 00000000..a8d6413f --- /dev/null +++ b/vessim/_mosaik.py @@ -0,0 +1,145 @@ +"""Optional mosaik adapter. The native execution path never imports this module.""" + +from __future__ import annotations + +import asyncio +import math +import warnings + +try: + import mosaik + import mosaik_api_v3 +except ImportError as exc: + raise ImportError("The mosaik backend requires: pip install 'vessim[mosaik]'") from exc + +from vessim._runtime import Runtime + + +class MosaikBackend: + def __init__(self, sim_config: dict): + self._simulator_name = "Vessim" + while self._simulator_name in sim_config: + self._simulator_name += "_" + self.world = mosaik.World( + {**sim_config, self._simulator_name: {"python": "vessim._mosaik:VessimSimulator"}}, + skip_greetings=True, + configure_logging=False, + ) + self.entities: dict = {} + self.runtime: Runtime + + def prepare(self, runtime: Runtime) -> None: + self.runtime = runtime + sim = self.world.start(self._simulator_name, runtime=runtime) + for grid in runtime.grids: + self.entities[grid.name] = sim.Microgrid(name=grid.name) + + def run(self, until: float, live: bool, print_progress) -> None: + horizon = math.ceil(until) if math.isfinite(until) else until + # mosaik warns even for microseconds of normal scheduling latency. Our + # shared runtime reports lateness using the configured threshold. + with warnings.catch_warnings(): + warnings.filterwarnings( + "ignore", + message="Simulation too slow for real-time factor", + category=UserWarning, + module=r"mosaik\.scheduler", + ) + self.world.run( + horizon, # type: ignore[arg-type] + rt_factor=1.0 if live else None, + print_progress=print_progress, + shutdown=False, + ) + # mosaik handles some exceptions internally. Preserve Vessim's lifecycle + # and never report a partially executed finite run as successful. + if self.runtime.error is not None: + raise self.runtime.error + if math.isfinite(until): + expected = math.ceil(until / self.runtime.environment.step_size) + unfinished = [ + name + for name, sim in self.world.compile(use_cache=True).items() + if sim.progress.time.time < horizon + ] + if self.runtime.steps != expected or unfinished: + raise RuntimeError( + "mosaik stopped before all simulators completed the requested horizon. " + f"Unfinished simulators: {unfinished}" + ) + else: + raise KeyboardInterrupt + + def close(self) -> None: + loop = self.world.loop + if loop.is_closed(): + return + # Failed mosaik runs may leave sibling scheduler tasks pending. Cancel + # them before shutdown so no task outlives the resources it accesses. + pending = {t for t in asyncio.all_tasks(loop) if t.get_name().startswith("Runner for ")} + for task in pending: + task.cancel() + if pending: + loop.run_until_complete(asyncio.gather(*pending, return_exceptions=True)) + self.world.shutdown() + + +class VessimSimulator(mosaik_api_v3.Simulator): + META = { + "type": "time-based", + "models": { + "Microgrid": { + "public": True, + "params": ["name"], + "attrs": [ + "power", + "p_delta", + "p_grid", + "actor_states", + "dispatch_states", + "policy_state", + "grid_signals", + ], + }, + }, + } + + def __init__(self): + super().__init__(self.META) + self.names: set[str] = set() + self.runtime: Runtime + + def init(self, sid, time_resolution=1.0, **sim_params): + if time_resolution != 1.0: + raise ValueError("Vessim requires mosaik time_resolution=1.0 (seconds).") + self.runtime = sim_params["runtime"] + return self.meta + + def create(self, num, model, **model_params): + if num != 1: + raise ValueError("Create microgrid entities individually with unique names.") + name = model_params["name"] + if name in self.names or name not in self.runtime.environment.microgrids: + raise ValueError(f"Unknown or duplicate microgrid: {name}") + self.names.add(name) + return [{"eid": name, "type": model}] + + def step(self, time, inputs, max_advance): + power = {} + for grid in self.runtime.grids: + if not grid.actors and not inputs.get(grid.name, {}).get("power"): + raise ValueError(f"Microgrid '{grid.name}' requires external power inputs.") + for name, attributes in inputs.items(): + if set(attributes) - {"power"}: + raise ValueError("Only the 'power' input is supported on Vessim microgrids.") + power[name] = sum(attributes.get("power", {}).values()) + self.runtime.step(time, power) + return time + self.runtime.environment.step_size + + def get_data(self, outputs): + if any("power" in attrs for attrs in outputs.values()): + raise ValueError("'power' is an input; use 'p_delta' or 'p_grid' as an output.") + return { + name: {attr: self.runtime.states[name][attr] for attr in attrs} + for name, attrs in outputs.items() + } diff --git a/vessim/_native.py b/vessim/_native.py new file mode 100644 index 00000000..7926a53a --- /dev/null +++ b/vessim/_native.py @@ -0,0 +1,20 @@ +"""Direct execution of the shared tick, with no co-simulation scheduler.""" +from __future__ import annotations + +import sys +import time + +from vessim._runtime import Runtime + + +def run(runtime: Runtime, until: float, print_progress: bool) -> None: + elapsed = 0 + next_progress = 0.0 + while elapsed < until: + runtime.step(elapsed) + elapsed += runtime.environment.step_size + if print_progress and time.monotonic() >= next_progress: + print(f"\rSimulated {min(elapsed, until):g}s", end="", file=sys.stderr) + next_progress = time.monotonic() + 0.25 + if print_progress: + print(file=sys.stderr) diff --git a/vessim/_runtime.py b/vessim/_runtime.py new file mode 100644 index 00000000..d32e5142 --- /dev/null +++ b/vessim/_runtime.py @@ -0,0 +1,104 @@ +"""The shared Vessim tick; execution backends only arrange when it is called.""" + +from __future__ import annotations + +import time +from copy import deepcopy +from dataclasses import dataclass +from datetime import datetime, timedelta +from typing import TYPE_CHECKING, Optional + +from loguru import logger + +from vessim.microgrid import MicrogridState + +if TYPE_CHECKING: + from vessim.environment import Environment + + +@dataclass(frozen=True) +class RunResult: + """Final state and execution timing returned by Environment.run(). + + Attributes: + steps: Number of simulation ticks executed. + until: Simulation horizon in elapsed seconds. + last_time: Timestamp of the last tick, or None for a zero-length run. + states: Microgrid observations keyed by name. Dispatchable states are + advanced to the horizon. Actor power, grid signals, and dispatch + values come from the last tick. + wall_time: Execution time in seconds. + + Use Recorder for time series and aggregate metrics. No extra observation + or controller call occurs at the horizon. + """ + + steps: int + until: float + last_time: Optional[datetime] + states: dict[str, MicrogridState] + wall_time: float + + +class Runtime: + def __init__(self, environment: Environment): + self.environment = environment + self.grids = tuple(environment.microgrids.values()) + self.steps = 0 + self.last_elapsed: Optional[int] = None + self.states: dict[str, MicrogridState] = {} + self.error: Optional[BaseException] = None + self.started = 0.0 + self._last_warning = float("-inf") + + def pace(self, elapsed: float) -> None: + if not self.environment.is_live: + return + delay = self.started + elapsed - time.monotonic() + if delay > 0: + time.sleep(delay) + elif -delay > self.environment.behind_threshold: + now = time.monotonic() + if now - self._last_warning >= max(1.0, self.environment.behind_threshold): + logger.warning("Live simulation is {:.3f}s behind wall-clock time", -delay) + self._last_warning = now + + def step(self, elapsed: int, external: Optional[dict[str, float]] = None) -> None: + try: + self.pace(elapsed) + self.environment._start_signals() + # All actors are sampled before any policy or controller can mutate them. + for grid in self.grids: + grid.sample(elapsed) + for grid in self.grids: + duration = min(grid.step_size, self.environment.until - elapsed) + grid.advance(elapsed, (external or {}).get(grid.name, 0.0), duration) + self.steps += 1 + self.last_elapsed = elapsed + if self.environment.controllers or self.environment.backend == "mosaik": + self.states = {grid.name: grid.state() for grid in self.grids} + if self.environment.controllers: + assert self.environment.sim_start is not None + now = self.environment.sim_start + timedelta(seconds=elapsed) + for controller in self.environment.controllers: + controller.step(now, self.states) + except BaseException as exc: + self.error = exc + raise + + def finish(self, until: float) -> None: + """Advance resources through the final interval without an extra tick.""" + if self.last_elapsed is not None: + for grid in self.grids: + grid.finish(until) + self.states = {grid.name: grid.state() for grid in self.grids} + + def result(self, wall_time: float) -> RunResult: + env = self.environment + last_time = None + if self.last_elapsed is not None: + assert env.sim_start is not None + last_time = env.sim_start + timedelta(seconds=self.last_elapsed) + if not self.states: + self.states = {grid.name: grid.state() for grid in self.grids} + return RunResult(self.steps, env.until, last_time, deepcopy(self.states), wall_time) diff --git a/vessim/_util.py b/vessim/_util.py index d7ebf333..fa928e56 100644 --- a/vessim/_util.py +++ b/vessim/_util.py @@ -1,49 +1,40 @@ -from __future__ import annotations +import math +from numbers import Real +from typing import Any +from collections.abc import Iterable -import sys -from datetime import datetime -from typing import Union, Any +from numpy import float64 -import numpy as np -from loguru import logger -DatetimeLike = Union[str, datetime, np.datetime64] +_REAL_SCALARS = (float, int, float64) -def disable_rt_warnings(behind_threshold: float): - """Disables Mosaik's rt_check warnings. +def require_finite(value: float, name: str) -> None: + """Reject invalid physical inputs before they enter simulation arithmetic.""" + if ( + type(value) not in _REAL_SCALARS + and (isinstance(value, bool) or not isinstance(value, Real)) + ) or not math.isfinite(value): + raise ValueError(f"{name} must be a finite number, got {value!r}.") - Mosaik's current implementation of the real-time checks are faulty and a new - implementation is already outlined in - https://gitlab.com/mosaik/mosaik/-/issues/172. As this issue was opened in - June 2023, it is unlikely that the issue will be resolved soon. Therefore, - we disable the warnings with a threshold after which these offsets are - logged. - Args: - behind_threshold: Time the simulation is allowed to be behind schedule. - """ - - def filter_record(record): - is_warning = record["level"].name == "WARNING" - is_mosaik_log = record["name"].startswith("mosaik") - is_user_warning = record["function"] == "user_warning" - try: - message_parts = record["message"].split(" - ")[1].split("s")[0] - is_below_threshold = float(message_parts) < behind_threshold - except IndexError: - return False - return not (is_warning and is_mosaik_log and is_user_warning and is_below_threshold) - - # Add the filter to the logger - logger.remove() - logger.add(sys.stdout, filter=filter_record) +def require_balanced(p_delta: float, powers: Iterable[float]) -> None: + """Require an exactly zero residual after subtracting powers in dispatch order.""" + remaining = p_delta + for power in powers: + remaining -= power + require_finite(remaining, "Remaining power delta") + if remaining != 0: + reason = "Unmet demand" if remaining < 0 else "Unabsorbed surplus" + raise RuntimeError(f"{reason} of {abs(remaining):g} W: dispatch must balance exactly.") def flatten_dict(d: dict, parent_key: str = "") -> dict: items: list[tuple[str, Any]] = [] for k, v in d.items(): - new_key = parent_key + "." + k if parent_key else k + # Escape path separators and the escape character to preserve distinct keys. + key = k.replace("%", "%25").replace(".", "%2E") + new_key = parent_key + "." + key if parent_key else key if isinstance(v, dict): items.extend(flatten_dict(v, str(new_key)).items()) else: diff --git a/vessim/_viewer.py b/vessim/_viewer.py new file mode 100644 index 00000000..f49457f5 --- /dev/null +++ b/vessim/_viewer.py @@ -0,0 +1,229 @@ +"""Study catalogs shared by the local viewer, static reports and documentation.""" + +from __future__ import annotations + +import json +import shutil +from pathlib import Path +from typing import Optional + +from vessim._experiment import read_json, validate_record + + +class Catalog: + """Load completed experiment summaries; read CSVs only when requested. + + Study v2 references are relative to the study file, batch references likewise. + CSV references are relative to their experiment (or batch) file and must stay + within that file's directory. Only explicitly registered files are served. + """ + + def __init__(self, source: Path, *, study_paths: Optional[list[Path]] = None): + self.source = source.resolve() + self.trajectories: dict[str, Path] = {} + self.studies: list[dict] = [] + self.runs: list[dict] = [] + self.errors: list[str] = [] + if not self.source.exists(): + raise ValueError(f"Input does not exist: {source}") + if study_paths is not None: + files = study_paths + elif self.source.is_file(): + files = [self.source] + elif (self.source / "study.json").is_file(): + files = [self.source / "study.json"] + elif (self.source / "experiment.json").is_file(): + files = [self.source / "experiment.json"] + else: + files = sorted(self.source.glob("*/study.json")) + if not files: + files = sorted(self.source.glob("*/experiment.json")) + self._collection(files) + return + for file in files: + try: + raw = read_json(file) + if raw.get("format") == "vessim.study": + self._study(raw, file) + elif raw.get("format") == "vessim.experiment": + self._collection([file]) + else: + raise ValueError("Expected vessim.study v2 or vessim.experiment v1") + except (OSError, ValueError, TypeError, KeyError, AttributeError) as exc: + self.errors.append(f"{file.name}: {exc}") + + def _collection(self, files: list[Path]) -> None: + study: dict = { + "study_id": self.source.stem, + "name": self.source.stem, + "configurations": [], + "errors": [], + } + seen: set[str] = set() + for file in files: + try: + run = self._run(read_json(file), file, seen) + if run is not None: + study["configurations"].append( + {"configuration_id": run["run_id"], "runs": [run]} + ) + except (OSError, ValueError, TypeError, KeyError) as exc: + study["errors"].append(f"{file.parent.name}/{file.name}: {exc}") + self._register(study) + + def _study(self, raw: dict, file: Path) -> None: + if raw.get("schema_version") != 2 or not isinstance(raw.get("configurations"), list): + raise ValueError("Expected vessim.study schema_version 2 with configurations") + identity = raw.get("study_id") + if not isinstance(identity, str) or not identity: + raise ValueError("study_id must be a nonempty string") + if any(s["study_id"] == identity for s in self.studies): + raise ValueError(f"Duplicate study_id: {identity}") + run_parameters = raw.get("run_parameters", []) + if not isinstance(run_parameters, list) or any( + not isinstance(p, str) for p in run_parameters + ): + raise ValueError("run_parameters must be a list of parameter names") + study = {**raw, "configurations": [], "errors": []} + config_ids: set[str] = set() + seen: set[str] = set() + for config in raw["configurations"]: + try: + cid = config["configuration_id"] + if not isinstance(cid, str) or not cid or cid in config_ids: + raise ValueError("Invalid or duplicate configuration_id") + config_ids.add(cid) + group = {"configuration_id": cid, "runs": []} + if config.get("name"): + group["name"] = str(config["name"]) + if "run_batch" in config: + if "runs" in config: + raise ValueError("Choose runs or run_batch, not both") + batch_file = self._reference(file, config["run_batch"]) + batch = read_json(batch_file) + if ( + batch.get("format") != "vessim.viewer.run-batch" + or batch.get("schema_version") != 1 + or not isinstance(batch.get("runs"), list) + ): + raise ValueError("Unsupported run batch") + shared = batch["shared"] + for item in batch["runs"]: + record = { + **shared, + **item, + "parameters": { + **shared.get("parameters", {}), + **item.get("parameters", {}), + }, + } + self._append(group, record, batch_file, seen, study) + else: + if not isinstance(config.get("runs"), list): + raise ValueError("Configuration must contain runs or run_batch") + for reference in config["runs"]: + try: + origin = self._reference(file, reference["path"]) + record = read_json(origin) + if reference.get("run_id") is not None and reference[ + "run_id" + ] != record.get("run_id"): + raise ValueError("Run identity mismatch") + self._append(group, record, origin, seen, study) + except (OSError, ValueError, TypeError, KeyError) as exc: + study["errors"].append(f"{cid}: {exc}") + if group["runs"]: + study["configurations"].append(group) + except (OSError, ValueError, TypeError, KeyError) as exc: + study["errors"].append(f"Configuration: {exc}") + self._register(study) + + @staticmethod + def _reference(origin: Path, reference: str) -> Path: + if not isinstance(reference, str) or not reference or Path(reference).is_absolute(): + raise ValueError("References must be nonempty relative paths") + return (origin.parent / reference).resolve() + + def _append(self, group, record, origin, seen, study): + try: + run = self._run(record, origin, seen) + if run is not None: + group["runs"].append(run) + except (OSError, ValueError, TypeError, KeyError) as exc: + study["errors"].append(f"{group['configuration_id']}: {exc}") + + def _run(self, record: dict, origin: Path, seen: set[str]) -> Optional[dict]: + validate_record(record) + if record["execution"]["status"] != "completed": + raise ValueError(f"Run {record['run_id']} is not completed") + if record["run_id"] in seen: + raise ValueError(f"Duplicate run_id: {record['run_id']}") + trajectory = record["timeseries"] + key = str(len(self.trajectories)) + url = None + if trajectory is not None: + target = self._reference(origin, trajectory) + if not target.is_relative_to(origin.parent.resolve()): + raise ValueError("Trajectory must stay within the experiment directory") + self.trajectories[key] = target + url = f"data/{key}/trajectory" + seen.add(record["run_id"]) + # The catalog is transport, not another persisted experiment format. + return { + "run_id": record["run_id"], + "record": {**record, "timeseries": None}, + "trajectoryUrl": url, + } + + def _register(self, study: dict) -> None: + self.studies.append(study) + self.runs.extend(r for c in study["configurations"] for r in c["runs"]) + self.errors.extend( + f"{study['name'] if 'name' in study else study['study_id']}: {e}" + for e in study["errors"] + ) + + def payload(self) -> dict: + """Return lightweight study choices without any run or trajectory data.""" + return { + "studies": [ + { + "study_id": s["study_id"], + "name": s.get("name", s["study_id"]), + "url": f"data/{i}/study.json", + } + for i, s in enumerate(self.studies) + ], + "errors": list(self.errors), + } + + def study(self, key: str) -> dict: + """Return summaries and explicit configuration membership for one study.""" + if not key.isdecimal() or int(key) >= len(self.studies): + raise KeyError(key) + return self.studies[int(key)] + + def trajectory(self, key: str) -> tuple[bytes, str]: + """Read a recording only when a user opens its charts.""" + return self.trajectories[key].read_bytes(), "text/csv; charset=utf-8" + + def export(self, destination: Path, assets: Path) -> None: + """Create a portable report using the exact same catalog and interface.""" + if destination.exists() and any(destination.iterdir()): + raise ValueError("Static export destination must be empty") + destination.mkdir(parents=True, exist_ok=True) + shutil.copytree(assets, destination, dirs_exist_ok=True) + payload = self.payload() + for i, study in enumerate(self.studies): + target = destination / "data" / str(i) / "study.json" + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(json.dumps(study, allow_nan=False)) + for key in self.trajectories: + try: + content, _ = self.trajectory(key) + target = destination / "data" / key / "trajectory" + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(content) + except OSError as exc: + payload["errors"].append(f"Trajectory {key} unavailable: {exc}") + (destination / "catalog.json").write_text(json.dumps(payload, allow_nan=False)) diff --git a/vessim/actor.py b/vessim/actor.py index f27326d2..db9a35ff 100644 --- a/vessim/actor.py +++ b/vessim/actor.py @@ -3,107 +3,125 @@ from datetime import timedelta from typing import Optional -import mosaik_api_v3 # type: ignore - -from vessim.signal import Signal +from vessim._util import require_finite +from vessim.signal import Signal, StaticSignal class Actor: - """An exogenous energy consumer or producer based on a `Signal`. + """A component whose power is determined outside the dispatch policy. - Actors represent non-dispatchable energy devices whose power output is - determined externally like solar panels, wind turbines, or (compute) loads. + Positive power generates electricity and negative power consumes it. Use + Consumer or Producer for inputs expressed as nonnegative magnitudes. A + controller may change an actor's signal or model parameters. The resulting + demand or generation is observed at the next scheduled actor update. Args: name: The name of the actor. - signal: The `Signal` that determines the power consumption/production. Can be - a `StaticSignal` for constant power, a `Trace` for time series data, or any - custom signal (e.g., based on real-time monitoring). - consumer: If True, the signal value is negated (Vessim convention: consumption - is negative, production is positive). Defaults to False. - step_size: The step size of the actor in seconds. If None, the step size - of the microgrid is used. + signal: Signed power in W, as a finite number or a Signal. + step_size: Sampling interval in seconds. Defaults to the microgrid's + interval. Must be a positive multiple of that interval. """ + _direction = 1 + _nonnegative = False + def __init__( self, name: str, - signal: Signal, - consumer: bool = False, + signal: float | Signal, step_size: Optional[int] = None, ) -> None: self.name = name self.step_size = step_size self.signal = signal - self.consumer = consumer - def power(self, elapsed: timedelta | float) -> float: - """Current power consumption/production at `elapsed` time since `sim_start`.""" - value = self.signal.at(elapsed) - return -value if self.consumer else value + @property + def signal(self) -> Signal: + """Input source, always a Signal. Numbers are wrapped in StaticSignal. + + Consumer and Producer inputs are nonnegative magnitudes. Actor inputs + are signed. Assignment affects the next scheduled sample and does not + change an already sampled observation. + """ + return self._signal + + @signal.setter + def signal(self, value: float | Signal) -> None: + if not isinstance(value, Signal): + self._validate_input(value) + value = StaticSignal(value) + elif type(value) is StaticSignal: + self._validate_input(value.at()) + self._signal = value + + def _validate_input(self, value: float) -> None: + require_finite(value, f"Power input for actor '{self.name}'") + if self._nonnegative and value < 0: + raise ValueError(f"{type(self).__name__} '{self.name}' requires nonnegative power.") + + def _validate_sample(self, power: float) -> None: + require_finite(power, f"Power from actor '{self.name}'") + if self._nonnegative and power * self._direction < 0: + raise ValueError(f"Sampled power has the wrong direction for '{self.name}'.") def config(self) -> dict: - """Static configuration of the actor. Used for experiment config export.""" + """Configuration for experiment export, captured before the first tick.""" return { "name": self.name, + "type": type(self).__name__, "signal": str(self.signal), - "consumer": self.consumer, "step_size": self.step_size, } - def state(self, elapsed: timedelta | float) -> dict: - """Dynamic state of the actor at `elapsed` time since `sim_start`. + def sample(self, elapsed: timedelta | float) -> tuple[float, dict]: + """Return signed power and diagnostic state from one scheduled update. - This can be extended to include any relevant information about e.g. internal - states of simulators that may be useful for control (e.g. temperature) or logs. + Override this method for a custom model. Call super().sample(elapsed) + to sample the configured input once, then extend the returned state. + The engine records the returned power under the state's "power" key. + State must not trigger another input sample or advance the model again. """ - return {"power": self.power(elapsed)} + value = self.signal.at(elapsed) + self._validate_input(value) + power = self._direction * value + return power, {"power": power} def finalize(self) -> None: - """Clean up resources.""" - self.signal.finalize() - - -class _ActorSim(mosaik_api_v3.Simulator): - META = { - "type": "time-based", - "models": { - "Actor": { - "public": True, - "params": ["actor"], - "attrs": ["power", "state"], - }, - }, - } - - def __init__(self): - super().__init__(self.META) - self.eid = None - self.step_size = None - self.actor = None - self.power = 0 - self.state = {} - - def init(self, sid, time_resolution=1.0, **sim_params): - self.step_size = sim_params["step_size"] - return self.meta - - def create(self, num, model, **model_params): - assert num == 1, "Only one instance per simulation is supported" - self.actor = model_params["actor"] - self.eid = self.actor.name - return [{"eid": self.eid, "type": model}] - - def step(self, time, inputs, max_advance): - assert self.actor is not None - self.power = self.actor.power(time) - self.state = self.actor.state(time) - assert self.step_size is not None - return time + self.step_size - - def get_data(self, outputs): - return {self.eid: {"power": self.power, "state": self.state}} - - # TODO: implement finalize() that calls self.actor.finalize() so SiL - # signal background polling threads stop on normal simulation exit - # (currently only stopped on exception via Microgrid.finalize()). + """Release resources owned by a custom actor. + + The environment finalizes input signals separately, once per signal. + """ + + +class Consumer(Actor): + """An actor consuming a nonnegative power input in W. + + Accepts a number or Signal. Sampled and recorded power is negative. + Controllers can update the signal for subsequent samples. The dispatch policy + balances the resulting demand but does not choose this component's power. + + Args: + name: The name of the consumer. + signal: Nonnegative demand in W, as a finite number or a Signal. + step_size: Sampling interval in seconds, defaulting to the microgrid's + interval. Must be a positive multiple of that interval. + """ + + _direction = -1 + _nonnegative = True + + +class Producer(Actor): + """An actor generating a nonnegative power input in W. + + Accepts a number or Signal. Sampled and recorded power is positive. + Use Actor for signed inputs that alternate between consumption and generation. + + Args: + name: The name of the producer. + signal: Nonnegative generation in W, as a finite number or a Signal. + step_size: Sampling interval in seconds, defaulting to the microgrid's + interval. Must be a positive multiple of that interval. + """ + + _nonnegative = True diff --git a/vessim/controller.py b/vessim/controller.py index 937fd7f0..4c85ac4b 100644 --- a/vessim/controller.py +++ b/vessim/controller.py @@ -1,17 +1,14 @@ from __future__ import annotations +import math import multiprocessing import time from abc import ABC, abstractmethod -from collections import defaultdict -from csv import DictWriter -from datetime import datetime, timedelta -from pathlib import Path -from typing import Any, Optional, TYPE_CHECKING +from datetime import datetime +from typing import Optional, TYPE_CHECKING -import mosaik_api_v3 # type: ignore +from vessim.signal import Signal -from vessim._util import flatten_dict if TYPE_CHECKING: from vessim.environment import Environment @@ -25,6 +22,8 @@ def _build_experiment_config(environment: Environment) -> dict: "name": environment.name, "sim_start": str(environment.sim_start), "step_size": environment.step_size, + "backend": environment.backend, + "mode": "live" if environment.is_live else "study", }, "microgrids": { mg.name: { @@ -38,8 +37,12 @@ def _build_experiment_config(environment: Environment) -> dict: **mg.policy.state(), }, "grid_signals": ( - {name: str(signal) for name, signal in mg.grid_signals.items()} - if mg.grid_signals else None + { + name: str(signal) if isinstance(signal, Signal) else signal + for name, signal in mg.grid_signals.items() + } + if mg.grid_signals + else None ), "coords": mg.coords, } @@ -75,146 +78,6 @@ def finalize(self) -> None: """Executed after simulation has ended. Can be overridden for clean-up.""" -class MemoryLogger(Controller): - """Controller that logs the state of the simulation in memory. - - The logged state can be retrieved as a dictionary or a pandas DataFrame. - After the simulation, ``config`` contains the static experiment metadata - (same information that ``CsvLogger`` writes to ``metadata.yaml``). - """ - - def __init__(self): - self.log: dict[datetime, dict[str, MicrogridState]] = defaultdict(dict) - self.config: dict = {} - - def start(self, environment: Environment) -> None: - self.config = _build_experiment_config(environment) - - def step(self, now: datetime, microgrid_states: dict[str, MicrogridState]) -> None: - self.log[now] = microgrid_states - - def to_dict(self) -> dict[datetime, dict[str, MicrogridState]]: - """Returns the logged data as a dictionary.""" - return dict(self.log) - - def to_df(self): - """Returns the logged data as a pandas DataFrame. - - The DataFrame has a MultiIndex (time, microgrid) and columns for each - state variable. Requires 'pandas' to be installed. - """ - try: - import pandas as pd - except ImportError: - raise ImportError( - "MemoryLogger.to_df() requires 'pandas'. " - "Install with: pip install pandas" - ) - - data = [] - for t, microgrid_states in self.log.items(): - for mg_name, state in microgrid_states.items(): - row = flatten_dict(state) - row["time"] = t - row["microgrid"] = mg_name - data.append(row) - - df = pd.DataFrame(data) - if not df.empty: - df = df.set_index(["time", "microgrid"]) - return df - - -class CsvLogger(Controller): - """Controller that logs simulation results to a directory. - - Writes two files: - - ``metadata.yaml``: static experiment configuration (run metadata, microgrid - topology, actor and dispatchable parameters) written once before the simulation - starts. - - ``timeseries.csv``: dynamic state logged at every simulation step. - - Args: - outdir: Path to the output directory (created if it doesn't exist). - """ - - def __init__(self, outdir: str | Path): - self.outdir = Path(outdir) - self.outdir.mkdir(exist_ok=True, parents=True) - self.csv_path = self.outdir / "timeseries.csv" - self.fieldnames: dict[str, list] = {} - self._last_sim_time: Optional[datetime] = None - - def start(self, environment: Environment) -> None: - try: - import yaml - except ImportError: - raise ImportError( - "CsvLogger requires 'pyyaml'. Install with: pip install pyyaml" - ) - - self._yaml = yaml - - git_hash: Optional[str] = None - try: - import subprocess - result = subprocess.run( - ["git", "rev-parse", "HEAD"], - capture_output=True, text=True, check=True, - ) - git_hash = result.stdout.strip() - except Exception: - pass - - config = _build_experiment_config(environment) - config["execution"] = { - "status": "running", - "git_hash": git_hash, - "start": datetime.now().isoformat(), - "end": None, - "duration": None, - } - - self._exec_start = datetime.now() - with (self.outdir / "metadata.yaml").open("w") as f: - yaml.dump(config, f, default_flow_style=False, sort_keys=False) - - def step(self, now: datetime, microgrid_states: dict[str, MicrogridState]) -> None: - self._last_sim_time = now - for mg_name, mg_state in microgrid_states.items(): - log_entry = { - "microgrid": mg_name, - "time": now, - **flatten_dict(dict(mg_state)), - } - - if mg_name not in self.fieldnames: - self.fieldnames[mg_name] = list(log_entry.keys()) - mode, write_header = "w", True - else: - mode, write_header = "a", False - - with self.csv_path.open(mode, newline="") as csvfile: - writer = DictWriter(csvfile, fieldnames=self.fieldnames[mg_name]) - if write_header: - writer.writeheader() - writer.writerow(log_entry) - - def finalize(self) -> None: - experiment_path = self.outdir / "metadata.yaml" - if experiment_path.exists() and hasattr(self, "_yaml"): - end = datetime.now() - with experiment_path.open() as f: - config = self._yaml.safe_load(f) - config["execution"]["status"] = "completed" - config["execution"]["end"] = end.isoformat() - config["execution"]["duration"] = round((end - self._exec_start).total_seconds(), 3) - if self._last_sim_time is not None: - config["environment"]["sim_end"] = str(self._last_sim_time) - with experiment_path.open("w") as f: - self._yaml.dump(config, f, default_flow_style=False, sort_keys=False) - - class Api(Controller): """REST API interface for microgrid data and control. @@ -225,12 +88,14 @@ class Api(Controller): Args: export_prometheus: Whether to export metrics to Prometheus. Defaults to False. broker_port: The port on which the API broker should run. Defaults to 8700. + request_timeout: Timeout in seconds for broker readiness and HTTP requests. """ def __init__( self, export_prometheus: bool = False, broker_port: int = 8700, + request_timeout: float = 5.0, ): try: import requests @@ -238,9 +103,13 @@ def __init__( self.requests = requests except ImportError: raise ImportError( - "Api requires 'requests' package. Install with: pip install vessim[sil]" + "Api requires 'requests' package. Install with: pip install vessim[live]" ) + if not math.isfinite(request_timeout) or request_timeout <= 0: + raise ValueError("request_timeout must be finite and positive.") + self.request_timeout = request_timeout + self._session = self.requests.Session() self.broker_port = broker_port self.broker_url = f"http://localhost:{broker_port}" self.broker_process: Optional[multiprocessing.Process] = None @@ -268,25 +137,44 @@ def start(self, environment: Environment) -> None: }, "coords": mg.coords, } - self.requests.post(f"{self.broker_url}/internal/microgrids/{mg_name}", json=config) + response = self._session.post( + f"{self.broker_url}/internal/microgrids/{mg_name}", + json=config, + timeout=self.request_timeout, + ) + response.raise_for_status() print(f"Registered {len(self.microgrids)} microgrids with API broker.") def _start_broker(self): from vessim._broker import run_broker self.broker_process = multiprocessing.Process( - target=run_broker, - args=(self.broker_port, self.export_prometheus), - daemon=True + target=run_broker, args=(self.broker_port, self.export_prometheus), daemon=True ) self.broker_process.start() - time.sleep(2) + deadline = time.monotonic() + self.request_timeout + while time.monotonic() < deadline: + if not self.broker_process.is_alive(): + raise RuntimeError("The API broker exited during startup.") + try: + response = self._session.get( + f"{self.broker_url}/microgrids", timeout=min(0.2, self.request_timeout) + ) + response.raise_for_status() + break + except self.requests.RequestException: + time.sleep(0.02) + else: + raise TimeoutError("The API broker did not become ready before request_timeout.") prometheus_str = " (incl. Prometheus exporter)" if self.export_prometheus else "" print(f"🌐 API{prometheus_str} available at: {self.broker_url}") def step(self, now: datetime, microgrid_states: dict[str, MicrogridState]) -> None: """Process commands and push microgrid states to broker.""" - response = self.requests.get(f"{self.broker_url}/internal/commands") + response = self._session.get( + f"{self.broker_url}/internal/commands", timeout=self.request_timeout + ) + response.raise_for_status() commands = response.json().get("commands", []) for cmd in commands: if cmd.get("type") == "set_parameter": @@ -309,84 +197,25 @@ def step(self, now: datetime, microgrid_states: dict[str, MicrogridState]) -> No elif target == "actor": actor_name = cmd.get("target_name") actor = next((a for a in mg.actors if a.name == actor_name), None) - if actor and hasattr(actor.signal, "set_value"): - actor.signal.set_value(val) + if actor and prop == "signal": + actor.signal = val for mg_name, mg_state in microgrid_states.items(): - self.requests.post( + response = self._session.post( f"{self.broker_url}/internal/data/{mg_name}", json={"microgrid": mg_name, "time": now.isoformat(), **mg_state}, + timeout=self.request_timeout, ) + response.raise_for_status() def finalize(self) -> None: """Clean up resources when simulation ends.""" if self.broker_process and self.broker_process.is_alive(): self.broker_process.terminate() self.broker_process.join(timeout=2) + if self.broker_process.is_alive(): + self.broker_process.kill() + self.broker_process.join(timeout=2) + self._session.close() print("🛑 API broker terminated") - - -class _ControllerSim(mosaik_api_v3.Simulator): - META = { - "type": "time-based", - "models": { - "Controller": { - "public": True, - "params": ["controllers"], - "attrs": [ - "p_delta", - "p_grid", - "actor_states", - "policy_state", - "dispatch_states", - "grid_signals", - ], - }, - }, - } - - def __init__(self): - super().__init__(self.META) - self.eid = "Controller" - self.step_size = None - self.sim_start = None - self.controllers: list[Controller] = [] - self.microgrid_states: dict[str, dict[str, Any]] = {} - - def init(self, sid, time_resolution=1.0, **sim_params): - self.step_size = sim_params["step_size"] - self.sim_start = sim_params["sim_start"] - return self.meta - - def create(self, num, model, **model_params): - assert num == 1, "Only one instance per simulation is supported" - self.controllers = model_params["controllers"] - return [{"eid": self.eid, "type": model}] - - def step(self, time, inputs, max_advance): - assert self.sim_start is not None - assert self.step_size is not None - - data = inputs[self.eid] - mg_suffix = ".microgrid.Microgrid" - microgrids = [key.removesuffix(mg_suffix) for key in data["p_delta"].keys()] - microgrid_states: dict[str, MicrogridState] = {name: { - "p_delta": data["p_delta"][f"{name}{mg_suffix}"], - "p_grid": data["p_grid"][f"{name}{mg_suffix}"], - "actor_states": {k.split(".")[-1]: data["actor_states"][k] for k in data["actor_states"].keys() if k.startswith(f"{name}.actor.")}, # noqa: E501 - "policy_state": data["policy_state"].get(f"{name}{mg_suffix}", {}), - "dispatch_states": data.get("dispatch_states", {}).get(f"{name}{mg_suffix}"), - "grid_signals": data["grid_signals"].get(f"{name}{mg_suffix}"), - } for name in microgrids} - - now = self.sim_start + timedelta(seconds=time) - for controller in self.controllers: - controller.step(now, microgrid_states) - - return time + self.step_size - - def finalize(self) -> None: - """Stops the api server and the collector thread when the simulation finishes.""" - for controller in self.controllers: - controller.finalize() diff --git a/vessim/dispatch_policy.py b/vessim/dispatch_policy.py index fb077916..6354f626 100644 --- a/vessim/dispatch_policy.py +++ b/vessim/dispatch_policy.py @@ -1,125 +1,120 @@ from __future__ import annotations from abc import ABC, abstractmethod -from typing import TYPE_CHECKING, Optional, Literal +from typing import TYPE_CHECKING, Optional -from vessim.dispatchable import Storage +from vessim.dispatchable import ClcBattery, GridConnection, SimpleBattery, Storage +from vessim._util import require_balanced, require_finite if TYPE_CHECKING: from vessim.dispatchable import Dispatchable -class DispatchPolicy(ABC): - """Policy that describes how a microgrid dispatches power across dispatchables. +_BUILTIN_DISPATCHABLES = (SimpleBattery, ClcBattery, GridConnection) - The dispatch policy manages energy excess and shortage of a microgrid by allocating - power across dispatchable resources (batteries, generators, etc.) and exchanging - remaining energy with the public grid. - """ + +class DispatchPolicy(ABC): + """Allocate power across controllable resources, including grid connections.""" @abstractmethod def apply( self, p_delta: float, - duration: int, + duration: float, dispatchables: list[Dispatchable], grid_signals: Optional[dict[str, float]] = None, - ) -> float: - """Allocate power delta across dispatchables. + ) -> None: + """Assign feasible resource setpoints that fully balance the power delta. + + No value is returned. Microgrid validates setpoints and requires an exactly + zero remaining delta after subtracting powers in resource order. Neither + unmet demand nor surplus is accepted. Curtailment is not implicit. Args: - p_delta: Power imbalance in W. Positive means excess power (can charge), - negative means power deficit (need to discharge/generate). - duration: Duration of the timestep in seconds. - dispatchables: List of flexible components available for dispatch, - like batteries or on-site diesel or gas generators. - grid_signals: Current grid signal values (e.g., carbon intensity, energy - price, curtailment), if any are configured on the microgrid. - - Returns: - Power in W exchanged with the public grid. Negative means power drawn - from the grid, positive means power fed to the grid. + p_delta: Power imbalance in W. Positive means excess power. + Negative means a deficit. + duration: Duration of the upcoming interval in seconds. + dispatchables: Controllable resources in priority order, including + any GridConnection. Every resource must receive a feasible setpoint. + grid_signals: Current context such as energy price or carbon intensity. """ def state(self) -> dict: - """Returns information about the current state of the policy.""" + """Return information about the current state of the policy.""" return {} class DefaultDispatchPolicy(DispatchPolicy): - """Default dispatch policy that allocates power in list order. + """Allocate power in list order while honoring every resource's constraints. - Iterates through dispatchables in the order they are given and allocates as much - of the power delta as each can handle. Remaining power is exchanged with the - public grid in grid-connected mode. + Allocate power in priority order, reserving mandatory power for subsequent + resources. Grid connections participate at their position in the list. + If no balanced allocation is possible, raise RuntimeError before changing + setpoints. Both unmet demand and unabsorbed surplus are errors. Args: - mode: Operating mode. In ``"grid-connected"`` mode, remaining power is - exchanged with the grid. In ``"islanded"`` mode, an error is raised - if the delta cannot be fully served by dispatchables. - charge_power: Optional fixed charge/discharge rate applied to storage dispatchables. - If set, storage is charged/discharged at this rate regardless of the power delta, - and remaining dispatchables are dispatched sequentially as usual. - Only works in grid-connected mode. Defaults to None. + charge_power: Optional fixed charge/discharge power in W for each Storage, + clipped to its feasible range. Other resources balance these setpoints. + Zero or None uses ordinary priority dispatch. If the fixed setpoints + cannot be balanced, dispatch raises an error. """ - def __init__( - self, - mode: Literal["grid-connected", "islanded"] = "grid-connected", - charge_power: Optional[float] = None, - ): - self.mode = mode + def __init__(self, charge_power: Optional[float] = None): + if charge_power is not None: + require_finite(charge_power, "charge_power") self.charge_power = charge_power if charge_power else 0.0 def apply( self, p_delta: float, - duration: int, + duration: float, dispatchables: list[Dispatchable], grid_signals: Optional[dict[str, float]] = None, - ) -> float: + ) -> None: + self._apply(p_delta, duration, dispatchables) + + def _apply( + self, + p_delta: float, + duration: float, + dispatchables: list[Dispatchable], + ranges: Optional[list[tuple[float, float]]] = None, + ) -> None: + """Allocate with optional bounds from the microgrid's current dispatch.""" + require_finite(p_delta, "Power delta") + require_finite(self.charge_power, "charge_power") + require_finite(duration, "Dispatch duration") + if duration <= 0: + raise ValueError("Dispatch duration must be positive.") + reuse_ranges = ranges is not None + if ranges is None: + ranges = [d._validated_range(duration) for d in dispatchables] + fixed = [bool(self.charge_power) and isinstance(d, Storage) for d in dispatchables] + powers = [ + max(lo, min(hi, self.charge_power if force else 0)) + for (lo, hi), force in zip(ranges, fixed) + ] + # Reserve mandatory/fixed power from resources later in the list. Keep + # the allocation and final validation in the same subtraction order. + # The last flexible resource consumes the exact remaining delta. + reserved = [0.0] * (len(powers) + 1) + for i in range(len(powers) - 1, -1, -1): + reserved[i] = powers[i] + reserved[i + 1] + require_finite(reserved[i], "Reserved power") remaining = p_delta - force_charged = set() - - if self.charge_power and self.mode == "grid-connected": - # Pre-pass: force-charge/discharge storage at specified rate - for d in dispatchables: - if isinstance(d, Storage): - lo, hi = d.feasible_range(duration) - power = max(lo, min(hi, self.charge_power)) - d.set_power(power, duration) - remaining -= power - force_charged.add(d) - - # Sequential dispatch for remaining dispatchables - for d in dispatchables: - if d in force_charged: - continue - if remaining == 0: - d.set_power(0.0, duration) - continue - - lo, hi = d.feasible_range(duration) - - if remaining > 0: # excess: try to charge (positive power) - allocated = max(0.0, min(hi, remaining)) - else: # deficit: try to discharge (negative power) - allocated = min(0.0, max(lo, remaining)) - - d.set_power(allocated, duration) - remaining -= allocated - - if self.mode == "islanded": - if remaining < 0: - raise RuntimeError( - "Not enough energy available to operate in islanded mode." - ) - remaining = 0.0 - - return remaining # p_grid + for i, ((lo, hi), force) in enumerate(zip(ranges, fixed)): + if not force: + target = remaining - reserved[i + 1] if reserved[i + 1] else remaining + powers[i] = max(lo, min(hi, target)) + remaining -= powers[i] + + require_balanced(p_delta, powers) + for d, power, bounds in zip(dispatchables, powers, ranges): + if reuse_ranges: + require_finite(power, f"Power setpoint for '{d.name}'") + d._set_power_in_range(power, duration, bounds) + else: + d.set_power(power, duration) def state(self) -> dict: - return { - "mode": self.mode, - "charge_power": self.charge_power, - } + return {"charge_power": self.charge_power} diff --git a/vessim/dispatchable.py b/vessim/dispatchable.py index b26967bc..431e483c 100644 --- a/vessim/dispatchable.py +++ b/vessim/dispatchable.py @@ -1,17 +1,32 @@ from __future__ import annotations from abc import ABC, abstractmethod +import math from typing import Optional import numpy as np from loguru import logger +from vessim._util import require_finite + + +def _positive_duration(duration: float) -> None: + require_finite(duration, "Duration") + if duration <= 0: + raise ValueError("Duration must be positive.") + + +def _soc_range(value: float, name: str) -> None: + require_finite(value, name) + if not 0 <= value <= 1: + raise ValueError(f"{name} must be between 0 and 1.") + class Dispatchable(ABC): """Abstract base class for dispatchable energy resources. - Dispatchables are energy resources whose power output can be controlled by a - dispatch policy (e.g., batteries, diesel generators, gas turbines). + Dispatchables are controllable energy resources whose power setpoint is chosen + by a dispatch policy (e.g., batteries, generators, loads, grid connections). Args: name: The name of the dispatchable. @@ -21,13 +36,25 @@ def __init__(self, name: str) -> None: self.name = name self.current_power: float = 0.0 - def set_power(self, power: float, duration: int) -> None: + def set_power(self, power: float, duration: float) -> None: """Set the power setpoint for the given duration. + Args: + power: Power in W. Positive absorbs power, negative supplies power. + duration: Upcoming interval length in seconds, used to check limits. + Raises: ValueError: If power is outside the feasible range. """ - lo, hi = self.feasible_range(duration) + require_finite(power, f"Power setpoint for '{self.name}'") + _positive_duration(duration) + self._set_power_in_range(power, duration, self._validated_range(duration)) + + def _set_power_in_range( + self, power: float, duration: float, bounds: tuple[float, float] + ) -> None: + """Commit a finite setpoint using bounds validated for this dispatch.""" + lo, hi = bounds if power < lo or power > hi: raise ValueError( f"Power {power} W is outside feasible range [{lo}, {hi}] W " @@ -35,8 +62,14 @@ def set_power(self, power: float, duration: int) -> None: ) self.current_power = power + def _validated_range(self, duration: float) -> tuple[float, float]: + lo, hi = self.feasible_range(duration) + if math.isnan(lo) or math.isnan(hi) or lo > hi or lo == math.inf or hi == -math.inf: + raise ValueError(f"Invalid feasible range [{lo}, {hi}] for '{self.name}'.") + return lo, hi + @abstractmethod - def feasible_range(self, duration: int) -> tuple[float, float]: + def feasible_range(self, duration: float) -> tuple[float, float]: """Returns the (min, max) power achievable for the given timestep duration. Args: @@ -45,10 +78,12 @@ def feasible_range(self, duration: int) -> tuple[float, float]: Returns: (min_power, max_power) tuple. Negative values indicate discharging/generation, positive values indicate charging/consumption. + Bounds may be infinite for an unlimited resource. Zero need not be + feasible, for example when ramp constraints require continued operation. """ @abstractmethod - def step(self, duration: int) -> None: + def step(self, duration: float) -> None: """Update internal state after a timestep has elapsed. Args: @@ -75,6 +110,107 @@ def state(self) -> dict: # TODO: Document units consistently across the API (Wh for capacity, W for power). +class GridConnection(Dispatchable): + """Connection to an external grid with power and per-step ramp limits. + + Negative power imports energy. Positive power exports it. Setpoints are held + constant over each interval. Ramp limits apply to changes between dispatch + steps, including the first change from ``initial_power`` at tick zero. They + are not scaled by the interval duration. Repeated set_power() calls within + one interval share the same ramp bounds. step() commits the held setpoint + as the reference for the next dispatch. + + Args: + name: Resource name. Defaults to "grid". + max_import: Maximum import power in W, or None for unlimited. + max_export: Maximum export power in W, or None for unlimited. + ramp_up: Maximum increase in signed power per step in W (toward export). + None disables this limit. + ramp_down: Maximum decrease in signed power per step in W (toward import). + None disables this limit. + initial_power: Initial ramp reference in W. Defaults to zero. + connected: Whether the connection is available. Disconnection forces zero + at the next dispatch, overriding ramp limits. Can be changed during a run. + """ + + def __init__( + self, + name: str = "grid", + *, + max_import: Optional[float] = None, + max_export: Optional[float] = None, + ramp_up: Optional[float] = None, + ramp_down: Optional[float] = None, + initial_power: float = 0.0, + connected: bool = True, + ): + super().__init__(name) + self.max_import = max_import + self.max_export = max_export + self.ramp_up = ramp_up + self.ramp_down = ramp_down + self.initial_power = initial_power + self.connected = connected + self._validate_limits() + require_finite(initial_power, "initial_power") + lo, hi = self._power_limits() + if not lo <= initial_power <= hi: + raise ValueError("initial_power must be within the connection's power limits.") + self.current_power = initial_power + self._previous_power = initial_power + + def _validate_limits(self) -> None: + for name in ("max_import", "max_export", "ramp_up", "ramp_down"): + value = getattr(self, name) + if value is not None: + require_finite(value, name) + if value < 0: + raise ValueError(f"{name} must be nonnegative or None.") + if not isinstance(self.connected, bool): + raise ValueError("connected must be a boolean.") + + def _power_limits(self) -> tuple[float, float]: + if not self.connected: + return 0.0, 0.0 + return ( + -self.max_import if self.max_import is not None else -math.inf, + self.max_export if self.max_export is not None else math.inf, + ) + + def feasible_range(self, duration: float) -> tuple[float, float]: + """Intersect connection limits with ramps from the previous interval.""" + _positive_duration(duration) + self._validate_limits() + lo, hi = self._power_limits() + if self.connected: + if self.ramp_down is not None: + lo = max(lo, self._previous_power - self.ramp_down) + if self.ramp_up is not None: + hi = min(hi, self._previous_power + self.ramp_up) + if lo > hi: + raise ValueError(f"Connection limits and ramp limits conflict for '{self.name}'.") + return lo, hi + + def step(self, duration: float) -> None: + """Remember the held power for the next dispatch's ramp constraints.""" + _positive_duration(duration) + require_finite(self.current_power, f"Power setpoint for '{self.name}'") + self._previous_power = self.current_power + + def config(self) -> dict: + return { + "max_import": self.max_import, + "max_export": self.max_export, + "ramp_up": self.ramp_up, + "ramp_down": self.ramp_down, + "initial_power": self.initial_power, + "connected": self.connected, + } + + def state(self) -> dict: + return {"current_power": self.current_power, "connected": self.connected} + + class Storage(Dispatchable): """Abstract base class for energy storage devices. @@ -102,9 +238,9 @@ class SimpleBattery(Storage): If current SoC is below or equal to the minimum SoC, battery is not discharged further. Defaults to 0. Can be altered during simulation. c_rate: Optional C-rate, which defines the charge and discharge rate of the battery. - For more information on C-rate, see - `C-rate explanation `_. - Defaults to None. + A value of 1 limits power in W to the capacity in Wh, corresponding + to a full charge or discharge in one hour. None imposes no power + limit beyond the available energy and remaining capacity. """ def __init__( @@ -116,15 +252,22 @@ def __init__( c_rate: Optional[float] = None, ): super().__init__(name) + require_finite(capacity, "Battery capacity") + if capacity <= 0: + raise ValueError("Battery capacity must be positive (Wh).") + if c_rate is not None: + require_finite(c_rate, "Battery c_rate") + if c_rate < 0: + raise ValueError("Battery c_rate must be nonnegative.") self.capacity = capacity - assert 0 <= initial_soc <= 1, "Invalid initial state-of-charge. Has to be between 0 and 1." + _soc_range(initial_soc, "initial_soc") self.charge_level = capacity * initial_soc self._soc = initial_soc - assert 0 <= min_soc <= 1, "Invalid minimum state-of-charge. Has to be between 0 and 1." + _soc_range(min_soc, "min_soc") self.min_soc = min_soc self.c_rate = c_rate - def feasible_range(self, duration: int) -> tuple[float, float]: + def feasible_range(self, duration: float) -> tuple[float, float]: """Returns (min_power, max_power) achievable for the given duration. Accounts for both c_rate limits and energy capacity limits. @@ -132,6 +275,7 @@ def feasible_range(self, duration: int) -> tuple[float, float]: Args: duration: Duration of the timestep in seconds. """ + _positive_duration(duration) # Discharge limit (negative power) if self._soc <= self.min_soc: min_power = 0.0 @@ -158,12 +302,12 @@ def feasible_range(self, duration: int) -> tuple[float, float]: return (min_power, max_power) - def step(self, duration: int) -> None: + def step(self, duration: float) -> None: """Update battery state based on current_power setpoint and duration.""" - if duration <= 0: - raise ValueError("Duration needs to be a positive value") + _positive_duration(duration) power = self.current_power + require_finite(power, f"Power setpoint for '{self.name}'") if power == 0.0: return @@ -172,13 +316,13 @@ def step(self, duration: int) -> None: if self.c_rate is not None: max_power = self.c_rate * self.capacity - if power >= max_power: + if power > max_power: logger.info( f"Trying to charge '{self.name}' with " f"{power} W but only {max_power} W are supported." ) power = max_power - if power <= -max_power: + if power < -max_power: logger.info( f"Trying to discharge '{self.name}' " f"with {power} W but only {max_power} W are supported." @@ -214,6 +358,7 @@ def state(self) -> dict: return { "soc": self._soc, "charge_level": self.charge_level, + "current_power": self.current_power, } @@ -228,9 +373,9 @@ class ClcBattery(Storage): Kazhamiaka, F., Rosenberg, C. & Keshav, S. Tractable lithium-ion storage models for optimizing energy systems. Energy Inform 2, 4 (2019). - `doi:10.1186/s42162-019-0070-6 `_ + [doi:10.1186/s42162-019-0070-6](https://doi.org/10.1186/s42162-019-0070-6) - The default parameterization models a pack of LGM50 21700 rechargable lithium-ion cells. + The default parameterization models a pack of LGM50 21700 rechargeable lithium-ion cells. This model should not be used in combination with large step sizes. Args: @@ -280,33 +425,57 @@ def __init__( charging_current_cutoff: float = 0.05, ) -> None: super().__init__(name) - assert number_of_cells > 0, "There has to be a positive number of cells." + if ( + isinstance(number_of_cells, bool) + or not isinstance(number_of_cells, int) + or number_of_cells <= 0 + ): + raise ValueError("number_of_cells must be a positive integer.") + for key, value in { + "nom_voltage": nom_voltage, + "u_1": u_1, + "v_1": v_1, + "u_2": u_2, + "v_2": v_2, + "alpha_d": alpha_d, + "alpha_c": alpha_c, + "eta_d": eta_d, + "eta_c": eta_c, + "discharging_current_cutoff": discharging_current_cutoff, + "charging_current_cutoff": charging_current_cutoff, + }.items(): + require_finite(value, key) + if nom_voltage <= 0 or v_2 <= 0: + raise ValueError("nom_voltage and v_2 (cell capacity) must be positive.") self.number_of_cells = number_of_cells self.u_1 = u_1 self.v_1 = v_1 self.u_2 = u_2 self.v_2 = v_2 - assert 0 <= initial_soc <= 1, "Invalid initial state-of-charge. Has to be between 0 and 1." + _soc_range(initial_soc, "initial_soc") self._soc = initial_soc self.charge_level = self.v_2 * initial_soc # Charge level of one cell - assert 0 <= min_soc <= 1, "Invalid minimum state-of-charge. Has to be between 0 and 1." + _soc_range(min_soc, "min_soc") self.min_soc = min_soc self.nom_voltage = nom_voltage self.alpha_d = alpha_d * self.v_2 self.alpha_c = alpha_c * self.v_2 - assert eta_d >= 1, "Invalid discharging inefficiency. Has to be greater or equal to 1." + if eta_d < 1: + raise ValueError("eta_d must be greater than or equal to 1.") self.eta_d = eta_d - assert 0 <= eta_c <= 1, "Invalid charging inefficiency. Has to be between 0 and 1." + if not 0 <= eta_c <= 1: + raise ValueError("eta_c must be between 0 and 1.") self.eta_c = eta_c self.discharging_power_cutoff = discharging_current_cutoff * self.nom_voltage self.charging_power_cutoff = charging_current_cutoff * self.nom_voltage - def feasible_range(self, duration: int) -> tuple[float, float]: + def feasible_range(self, duration: float) -> tuple[float, float]: """Returns (min_power, max_power) based on CLC model constraints. Args: duration: Duration of the timestep in seconds. """ + _positive_duration(duration) # Discharge limit if self._soc <= self.min_soc: min_power = 0.0 @@ -319,6 +488,15 @@ def feasible_range(self, duration: int) -> tuple[float, float]: ) * self.number_of_cells ) + # Respect min_soc in dispatch, not just when step() clips the state. + # Otherwise the grid balance can count energy the battery cannot supply. + soc_limit = ( + (self.min_soc * self.v_2 - self.charge_level) + * 3600 + / (self.eta_d * duration) + * self.number_of_cells + ) + min_power = max(min_power, soc_limit) if min_power > self.discharging_power_cutoff: min_power = 0.0 @@ -343,18 +521,18 @@ def soc(self) -> float: """Returns the state-of-charge (SoC) of the battery (0 to 1).""" return self._soc - def step(self, duration: int) -> None: + def step(self, duration: float) -> None: """Update battery state based on current_power setpoint and duration.""" - if duration <= 0: - raise ValueError("Duration needs to be a positive value") + _positive_duration(duration) power = self.current_power + require_finite(power, f"Power setpoint for '{self.name}'") if power > 0: self._charge(power, duration) elif power < 0 and self.soc() > self.min_soc: self._discharge(power, duration) - def _charge(self, power: float, duration: int) -> None: + def _charge(self, power: float, duration: float) -> None: max_power = ( np.minimum( (self.charge_level - self.v_2) @@ -369,7 +547,7 @@ def _charge(self, power: float, duration: int) -> None: self.charge_level += self.eta_c * (power / self.number_of_cells) * (duration / 3600) self._soc = self.charge_level / self.v_2 - def _discharge(self, power: float, duration: int) -> None: + def _discharge(self, power: float, duration: float) -> None: min_power = ( np.maximum( (self.charge_level - self.v_1) @@ -401,6 +579,5 @@ def state(self) -> dict: return { "soc": self._soc, "charge_level": self.charge_level * self.number_of_cells, + "current_power": self.current_power, } - - diff --git a/vessim/environment.py b/vessim/environment.py index 37e12a4a..666ef411 100644 --- a/vessim/environment.py +++ b/vessim/environment.py @@ -1,73 +1,83 @@ from __future__ import annotations +import math +import time +from dataclasses import replace from datetime import datetime, timedelta -from typing import Optional, Literal +from typing import Any, Iterator, Literal, Optional, TypeVar -import mosaik # type: ignore import pandas as pd from loguru import logger -from vessim._util import disable_rt_warnings +from vessim._runtime import RunResult, Runtime from vessim.actor import Actor from vessim.controller import Controller from vessim.dispatch_policy import DispatchPolicy, DefaultDispatchPolicy from vessim.dispatchable import Dispatchable from vessim.microgrid import Microgrid -from vessim.signal import Signal, SilSignal +from vessim.signal import Signal, StaticSignal, LiveSignal +ControllerT = TypeVar("ControllerT", bound=Controller) -class Environment: - """Environment for a Vessim co-simulation. - - This class manages the simulation time, the interaction between different components, - and the execution of the [Mosaik](https://mosaik.offis.de/) co-simulation. - Two modes are supported: +class Environment: + """Run a simulation study or a live experiment using shared component models. - - **Simulated** — `Environment(sim_start=..., step_size=...)`. The simulation - clock advances as fast as possible, anchored at the explicit `sim_start`. - - **Live** — `Environment.live(step_size=...)`. The simulation clock tracks - wall-clock time and `sim_start` is captured when `run()` is called (i.e. - defaults to "now at the moment of running"). Use this when mixing in - `SilSignal`s. + An environment runs once. Construct a fresh environment and mutable models + for each scenario. Immutable traces can be reused. Use as a context manager + when setup may acquire live resources or start external simulators. Args: - sim_start: The start time of the simulation. Can be a `datetime` object or a - string in the format "YYYY-MM-DD HH:MM:SS". - step_size: The step size of the simulation in seconds. Defaults to 1. + sim_start: Timestamp labeling tick zero. Required for studies. + step_size: Positive integer tick interval in seconds. + name: Optional experiment name. + backend: ``native`` (default) or ``mosaik`` for external coupling. + sim_config: Additional mosaik simulator configurations. Only accepted + with backend="mosaik". """ - COSIM_CONFIG: mosaik.SimConfig = { - "Actor": {"python": "vessim.actor:_ActorSim"}, - "Controller": {"python": "vessim.controller:_ControllerSim"}, - "Microgrid": {"python": "vessim.microgrid:_MicrogridSim"}, - } - def __init__( self, sim_start: Optional[str | datetime] = None, step_size: int = 1, name: Optional[str] = None, + *, + backend: Literal["native", "mosaik"] = "native", + sim_config: Optional[dict] = None, _live: bool = False, _behind_threshold: float = 5.0, ): if not _live and sim_start is None: raise ValueError( - "sim_start is required for simulated mode. " - "Use Environment.live(...) for real-time experiments." + "sim_start is required for studies. Use Environment.live() for live runs." ) - # In live mode, sim_start is purely a label (for logging / metadata). - # If omitted, run() will fall back to datetime.now() at start. - self.sim_start: pd.Timestamp | None = ( - pd.to_datetime(sim_start) if sim_start is not None else None - ) + if isinstance(step_size, bool) or not isinstance(step_size, int) or step_size <= 0: + raise ValueError("step_size must be a positive integer number of seconds.") + if backend not in ("native", "mosaik"): + raise ValueError("backend must be 'native' or 'mosaik'.") + if sim_config is not None and backend != "mosaik": + raise ValueError("sim_config requires backend='mosaik'.") + if not math.isfinite(_behind_threshold) or _behind_threshold < 0: + raise ValueError("behind_threshold must be finite and nonnegative.") + self.sim_start = pd.Timestamp(sim_start) if sim_start is not None else None + if self.sim_start is not None and pd.isna(self.sim_start): + raise ValueError("sim_start must be a valid timestamp.") self.step_size = step_size self.name = name + self.backend = backend + self.is_live = _live + self.behind_threshold = _behind_threshold self.microgrids: dict[str, Microgrid] = {} self.controllers: list[Controller] = [] - self.world = mosaik.World(self.COSIM_CONFIG, skip_greetings=True) - self._live = _live - self._behind_threshold = _behind_threshold + self.status = "created" + self.until = 0.0 + self.result: Optional[RunResult] = None + self._sim_config = dict(sim_config or {}) + self._mosaik: Any = None + self._runtime: Optional[Runtime] = None + self._started_controllers: list[Controller] = [] + self._signals: dict[int, Signal] = {} + self._closed = False @classmethod def live( @@ -76,177 +86,333 @@ def live( step_size: int = 1, behind_threshold: float = 5.0, name: Optional[str] = None, - ) -> "Environment": - """Create an environment that advances in real-time (1× wall-clock). - - The simulation clock advances 1:1 with wall-clock time, so traces start - replaying from `sim_start` and SiL signals stay in sync with the system - clock. + *, + backend: Literal["native", "mosaik"] = "native", + sim_config: Optional[dict] = None, + ) -> Environment: + """Create an environment that advances alongside real time. Args: - sim_start: Optional label used only for logging and result metadata. - Does not affect signal queries (which are always indexed by - elapsed time since `run()`). Defaults to `datetime.now()` at - the moment `run()` is called. - step_size: Step size in seconds. Defaults to 1. - behind_threshold: Seconds the simulation may fall behind real-time before - a warning is logged. Defaults to 5. - name: Optional name for the environment. + sim_start: Timestamp labeling tick zero. Defaults to the time run() + starts. Signals still receive elapsed seconds from tick zero. + step_size: Simulation interval in seconds. + behind_threshold: Warn when the simulation falls this many seconds + behind real time. No ticks are skipped. + name: Optional experiment name. + backend: "native" or "mosaik" for external simulators. + sim_config: Additional simulator configurations for Mosaik. + + Returns: + An Environment configured for live simulation. """ return cls( - sim_start=sim_start, - step_size=step_size, - name=name, + sim_start, + step_size, + name, + backend=backend, + sim_config=sim_config, _live=True, _behind_threshold=behind_threshold, ) + def _require_setup(self) -> None: + if self._closed or self.status != "created": + raise RuntimeError( + "The scenario is frozen after prepare() or run(). Create a new one." + ) + def add_microgrid( self, actors: list[Actor], dispatchables: Optional[list[Dispatchable]] = None, policy: Optional[DispatchPolicy] = None, - grid_signals: Optional[dict[str, Signal]] = None, + grid_signals: Optional[dict[str, float | Signal]] = None, name: Optional[str] = None, coords: Optional[tuple[float, float]] = None, ) -> Microgrid: - """Add a microgrid to the environment. + """Create a microgrid and register it with this environment. Args: - actors: A list of exogenous actors (consumers/producers) in the microgrid. - dispatchables: Optional list of dispatchable resources (e.g., batteries, - generators). - policy: The dispatch policy that controls energy management. If None, a - `DefaultDispatchPolicy` is used. - grid_signals: Optional signals from the public grid (e.g., carbon intensity). - name: An optional name for the microgrid. - coords: Optional coordinates (latitude, longitude) for the microgrid. + actors: Consumers, producers, or custom actors supplying power inputs. + dispatchables: Controllable resources, in dispatch priority order. + Include a GridConnection for grid-connected operation. + policy: Dispatch policy. Defaults to DefaultDispatchPolicy. + grid_signals: Named numbers or signals, such as price or carbon intensity. + name: Unique microgrid name. Defaults to microgrid_0, microgrid_1, etc. + coords: Optional (latitude, longitude) for the viewer. Returns: - The created `Microgrid` instance. - """ - if not actors: - raise ValueError("There should be at least one actor in the Microgrid.") - - microgrid = Microgrid( - world=self.world, - step_size=self.step_size, - actors=actors, - dispatchables=dispatchables or [], - policy=policy if policy is not None else DefaultDispatchPolicy(), - grid_signals=grid_signals, - name=name, - coords=coords, - ) - if microgrid.name in self.microgrids: - raise ValueError( - f"A microgrid named '{microgrid.name}' already exists in this environment." - ) - self.microgrids[microgrid.name] = microgrid - return microgrid + The registered Microgrid. - def add_controller(self, controller: Controller): - """Add a controller to the environment. + An empty actor list is supported only with mosaik, for microgrids fed + entirely by external power measurements. Names default to microgrid_0, + microgrid_1, etc. and must be unique within this environment. - Args: - controller: The controller instance. + Include GridConnection explicitly in dispatchables for grid-connected + operation. Without one, the microgrid is islanded. Every dispatch must + balance exactly. Unmet demand and unabsorbed surplus raise RuntimeError. """ + self._require_setup() + if not actors and self.backend != "mosaik": + raise ValueError("There should be at least one actor in a native Microgrid.") + name = name if name is not None else f"microgrid_{len(self.microgrids)}" + if not isinstance(name, str) or not name: + raise ValueError("Microgrid names must be nonempty strings.") + if name in self.microgrids: + raise ValueError(f"A microgrid named '{name}' already exists in this environment.") + grid = Microgrid( + self.step_size, + actors, + dispatchables or [], + policy if policy is not None else DefaultDispatchPolicy(), + grid_signals, + name, + coords, + ) + self.microgrids[name] = grid + return grid + + def add_controller(self, controller: ControllerT) -> ControllerT: + """Register a controller in execution order and return it for result access.""" + self._require_setup() + if any(c is controller for c in self.controllers): + raise ValueError("A controller instance can only be registered once.") self.controllers.append(controller) + return controller - def _initialize_controllers(self): - """Initialize all controllers after all microgrids have been added.""" - if not self.controllers: - return + def _mosaik_backend(self): + if self.backend != "mosaik": + raise RuntimeError("External simulator coupling requires backend='mosaik'.") + if self._closed: + raise RuntimeError("This environment is closed.") + if self._mosaik is None: + from vessim._mosaik import MosaikBackend - # Execute start() method on all controllers - for controller in self.controllers: - controller.start(self) + self._mosaik = MosaikBackend(self._sim_config) + return self._mosaik - # Create one global controller simulator - controller_sim = self.world.start( - "Controller", - sim_start=self.sim_start, - step_size=self.step_size, - ) - controller_entity = controller_sim.Controller(controllers=self.controllers) - - # Connect global controller to all microgrids - for microgrid in self.microgrids.values(): - self.world.connect(microgrid.entity, controller_entity, "p_delta") - self.world.connect(microgrid.entity, controller_entity, "grid_signals") - self.world.connect(microgrid.entity, controller_entity, "p_grid") - self.world.connect(microgrid.entity, controller_entity, "policy_state") - if microgrid.dispatchables: - self.world.connect( - microgrid.entity, controller_entity, "dispatch_states" - ) + @property + def world(self) -> Any: + """The mosaik World for starting and connecting external simulators. - # Connect actors for state - for actor_entity in microgrid.actor_entities.values(): - self.world.connect( - actor_entity, - controller_entity, - ("state", "actor_states"), - ) + Only available with backend="mosaik". Call environment.run(), not + world.run(), so Vessim initialization, pacing and cleanup are applied. + """ + return self._mosaik_backend().world + + @property + def entities(self) -> dict[str, Any]: + """Microgrid name to mosaik entity, available after prepare().""" + backend = self._mosaik_backend() + if self._runtime is None: + raise RuntimeError("Call prepare() before accessing mosaik entities.") + return dict(backend.entities) + + def prepare(self) -> None: + """Validate and freeze the scenario, registering mosaik entities if needed. + + run() calls this automatically. Call it explicitly to connect external + simulators to ``entities`` before running. Controllers start at run(), + after all connections have been established. + """ + if self.status == "prepared" and not self._closed: + return + self._require_setup() + if not self.microgrids: + raise ValueError("Add at least one microgrid before running.") + seen: set[int] = set() + for grid in self.microgrids.values(): + grid._validate() + for component in [*grid.actors, *grid.dispatchables, grid.policy]: + if id(component) in seen: + raise ValueError( + "Mutable actors, dispatchables and policies cannot be shared " + "between microgrids. Only signals may be shared." + ) + seen.add(id(component)) + if self._contains_live_signals() and not self.is_live: + raise RuntimeError("Live signals detected. Use Environment.live() for live inputs.") + self._runtime = Runtime(self) + if self.backend == "mosaik": + self._mosaik_backend().prepare(self._runtime) + self.status = "prepared" + + def _end_time(self, until: Optional[timedelta | datetime | int | float]) -> float: + if until is None: + if not self.is_live: + raise ValueError("Simulation studies require a finite until horizon.") + return math.inf + if isinstance(until, timedelta): + end = until.total_seconds() + elif isinstance(until, datetime): + assert self.sim_start is not None + end = (pd.Timestamp(until) - self.sim_start).total_seconds() + else: + if isinstance(until, bool) or not isinstance(until, (int, float)): + raise TypeError("until must be elapsed seconds, timedelta, datetime, or None.") + end = float(until) + if not math.isfinite(end) or end < 0: + raise ValueError("until must be finite and nonnegative (at or after sim_start).") + return end def run( self, until: Optional[timedelta | datetime | int | float] = None, - print_progress: bool | Literal["individual"] = True, - ): - """Run the simulation. + print_progress: bool | Literal["individual"] = False, + ) -> RunResult: + """Run once up to a horizon and return the state at that horizon. + + ``until`` accepts elapsed seconds, a timedelta, or an absolute datetime. + Ticks are exclusive of ``until``. Dispatch and integration cover the + entire duration, including a partial final interval. No additional + controller call or signal sample occurs at the horizon. + Add a Recorder to retain time series or calculate metrics. + All owned resources are finalized on success or failure. Args: - until: When the simulation should end. Accepts: + until: Simulation horizon as elapsed seconds, a timedelta, or an + absolute datetime. Only live simulations may omit it to run + until interrupted. + print_progress: Show a progress bar. The value "individual" shows + per-simulator progress and requires the Mosaik backend. - - `int` or `float` — elapsed seconds since `sim_start`. - - `timedelta` — elapsed time since `sim_start`. - - `datetime` — absolute wall-clock end (resolved against `sim_start`). - - `None` — run indefinitely. - print_progress: Whether to print a progress bar. + Returns: + Final component states and execution timing in a RunResult. """ - # Live mode: anchor sim_start to "now" if the user didn't pin one explicitly. - if self._live and self.sim_start is None: - self.sim_start = pd.to_datetime(datetime.now()) - - if until is None: - until = float("inf") # type: ignore - elif isinstance(until, timedelta): - until = until.total_seconds() - elif isinstance(until, datetime): - assert self.sim_start is not None - until = (pd.to_datetime(until) - self.sim_start).total_seconds() - if until < 0: - raise ValueError("`until` must be after `sim_start`.") - assert until is not None - - # Initialize controllers before running simulation - if self.name: - logger.info(f"Experiment: {self.name}") - self._initialize_controllers() - - # SiL signals require live mode (otherwise they'd be polled out of sync - # with simulated time). - if self._contains_sil_signals() and not self._live: - raise RuntimeError( - "SiL signals detected but not running in live mode. " - "Use Environment.live(...) instead of Environment(...)." - ) - - rt_factor = 1.0 if self._live else None - if rt_factor: - disable_rt_warnings(self._behind_threshold) + if self._closed or self.status not in ("created", "prepared"): + raise RuntimeError("An Environment can only run once. Create a fresh scenario.") + started = time.perf_counter() + failure = None try: - self.world.run(until=until, rt_factor=rt_factor, print_progress=print_progress) # type: ignore - except Exception: - for microgrid in self.microgrids.values(): - microgrid.finalize() + if self.sim_start is None: + self.sim_start = pd.Timestamp(datetime.now()) + self.until = self._end_time(until) + if print_progress not in (False, True, "individual"): + raise ValueError("print_progress must be True, False or 'individual'.") + if self.backend == "native" and print_progress == "individual": + raise ValueError("Individual simulator progress requires backend='mosaik'.") + self.prepare() + assert self._runtime is not None + self.status = "running" + for controller in self.controllers: + self._started_controllers.append(controller) + controller.start(self) + self._start_signals() + self._runtime.started = time.monotonic() + if self.backend == "mosaik": + self._mosaik.run(self.until, self.is_live, print_progress) + else: + from vessim._native import run + + run(self._runtime, self.until, bool(print_progress)) + if math.isfinite(self.until): + self._runtime.pace(self.until) + self._runtime.finish(self.until) + self.result = self._runtime.result(time.perf_counter() - started) + self.status = "completed" + except BaseException as exc: + failure = exc + self.status = "interrupted" if isinstance(exc, KeyboardInterrupt) else "failed" raise + finally: + try: + self.close() + except BaseException: + if failure is None: + self.status = "failed" + raise + logger.exception("Cleanup also failed. Preserving the original simulation error") + assert self.result is not None + self.result = replace(self.result, wall_time=time.perf_counter() - started) + return self.result + + def _input_signals(self) -> Iterator[Signal]: + for grid in self.microgrids.values(): + for value in [*(a.signal for a in grid.actors), *(grid.grid_signals or {}).values()]: + if isinstance(value, Signal): + yield value + + def _contains_live_signals(self) -> bool: + return any(isinstance(signal, LiveSignal) for signal in self._input_signals()) + + def _start_signals(self) -> None: + for signal in self._input_signals(): + # Numeric assignments create resource-free constants on each update. + if type(signal) is StaticSignal or id(signal) in self._signals: + continue + self._signals[id(signal)] = signal + if isinstance(signal, LiveSignal) and not self.is_live: + raise RuntimeError( + "Live signals detected. Use Environment.live() for live inputs." + ) + signal.start() + + def close(self) -> None: + """Release owned resources once, including when setup never reached run(). - def _contains_sil_signals(self) -> bool: - """Check if any microgrid contains SiL signals.""" - for microgrid in self.microgrids.values(): - for actor in microgrid.actors: - if isinstance(actor.signal, SilSignal): - return True - return False + Every cleanup is attempted even if another fails. The first cleanup + error is raised. An existing run error takes precedence in run(). + """ + if self._closed: + return + self._closed = True + errors: list[BaseException] = [] + callbacks = [c.finalize for c in self._started_controllers] + seen: set[int] = set() + for grid in self.microgrids.values(): + for actor in grid.actors: + if id(actor) not in seen: + callbacks.append(actor.finalize) + seen.add(id(actor)) + signals = {**self._signals, **{id(s): s for s in self._input_signals()}} + callbacks.extend(signal.finalize for signal in signals.values()) + if self._mosaik is not None: + callbacks.append(self._mosaik.close) + for callback in callbacks: + try: + callback() + except BaseException as exc: + errors.append(exc) + if self.status in ("created", "prepared"): + self.status = "closed" + if errors and self.status not in ("failed", "interrupted"): + self.status = "failed" + # Recorders collect metrics during finalize(), but must publish only after + # every controller, signal, and backend has had its cleanup attempted. + from vessim.recorder import Recorder + + recorders = [c for c in self._started_controllers if isinstance(c, Recorder)] + publication_failed = False + for recorder in recorders: + try: + recorder._complete() + except BaseException as exc: + errors.append(exc) + publication_failed = True + if publication_failed: + if self.status != "interrupted": + self.status = "failed" + # A later writer can fail after another has published. Synchronize + # every result and retry publication of the final failure status. + for recorder in recorders: + try: + recorder._complete() + except BaseException: + pass # Preserve the first error. All writers were attempted. + if errors: + raise errors[0] + + def __enter__(self) -> Environment: + """Own resources acquired during scenario setup and execution.""" + if self._closed: + raise RuntimeError("This environment is closed.") + return self + + def __exit__(self, exc_type, exc, traceback) -> None: + """Close even if setup or execution was interrupted.""" + try: + self.close() + except BaseException: + if exc is None: + raise + logger.exception("Cleanup failed while leaving an environment with an active error") diff --git a/vessim/metrics.py b/vessim/metrics.py new file mode 100644 index 00000000..71e6ba73 --- /dev/null +++ b/vessim/metrics.py @@ -0,0 +1,153 @@ +"""Incremental metrics for experiment recording.""" + +from __future__ import annotations + +import math +from datetime import datetime +from typing import TYPE_CHECKING, Optional + +if TYPE_CHECKING: + from vessim.environment import Environment + from vessim.microgrid import MicrogridState + + +class Metric: + """A user-defined scalar metric, scoped to a run, microgrid, or actor. + + Override start(), step(), or finalize() as needed. ``duration`` is the + observation's interval in seconds, clipped at the requested horizon. + ``finalize()`` returns this calculator's scalar or None when undefined. + Attach constants and derived values with ExperimentResult.add_metric() + after the run. Metrics must not change simulation components. + + Args: + unit: Unit of the result, such as Wh, EUR, kgCO2e, or 1 for a ratio. + microgrid: Microgrid name, or None for a run-level metric. + actor: Actor name within microgrid, or None for a microgrid-level metric. + description: Human-readable definition of the calculation. + assumptions: JSON-compatible assumptions, such as lifetime or allocation rules. + + Attributes: + value (float): Accumulated result, initialized to zero by start() and + returned by the default finalize() implementation. + """ + + def __init__( + self, + unit: str, + *, + microgrid: Optional[str] = None, + actor: Optional[str] = None, + description: str = "", + assumptions: Optional[dict] = None, + ): + if not isinstance(unit, str) or not unit: + raise ValueError("A metric must declare its unit.") + if actor is not None and microgrid is None: + raise ValueError("An actor metric requires a microgrid name.") + self.unit = unit + self.microgrid = microgrid + self.actor = actor + self.description = description + self.assumptions = dict(assumptions or {}) + self.value = 0.0 + + def start(self, environment: Environment) -> None: + """Initialize counters or calculate scenario-dependent values.""" + self.value = 0.0 + + def step(self, now: datetime, states: dict[str, MicrogridState], duration: float) -> None: + """Accumulate one observation without retaining the trajectory.""" + + def finalize(self) -> Optional[float]: + """Return the final scalar computed by this calculator.""" + return self.value + + def definition(self) -> dict: + """Describe the metric without embedding its calculation in the result.""" + return { + "unit": self.unit, + "description": self.description, + "assumptions": self.assumptions, + } + + +class _GridIntegral(Metric): + def __init__(self, microgrid: str, signal: str, unit: str, direction: int, description: str): + super().__init__( + unit, + microgrid=microgrid, + description=description, + assumptions={ + "factor_signal": signal, + "factor_unit": f"{unit}/kWh", + "period": "simulated_intervals", + }, + ) + self.signal = signal + self.direction = direction + + def start(self, environment: Environment) -> None: + super().start(environment) + assert self.microgrid is not None + grid = environment.microgrids[self.microgrid] + if self.signal not in (grid.grid_signals or {}): + raise ValueError(f"Metric requires grid signal '{self.signal}' on '{self.microgrid}'.") + + def step(self, now, states, duration): + state = states[self.microgrid] + factor = (state["grid_signals"] or {}).get(self.signal) + if factor is None or not math.isfinite(factor): + raise ValueError(f"Metric signal '{self.signal}' must contain finite values.") + power = max(0.0, self.direction * state["p_grid"]) + self.value += power * duration / 3_600_000 * factor + + +class ElectricityImportCost(_GridIntegral): + """Integrate imported electricity times a price signal in currency/kWh. + + This excludes maintenance, demand charges, and other operating costs. + Negative electricity prices are supported. No price signal is inferred. + + Args: + microgrid: Name of the microgrid to evaluate. + price: Grid signal containing the import price in currency/kWh. + currency: Currency label for the resulting metric. + """ + + def __init__(self, microgrid: str, price: str, currency: str = "EUR"): + super().__init__(microgrid, price, currency, -1, "Electricity import cost") + + +class ElectricityExportRevenue(_GridIntegral): + """Integrate exported electricity times a sale price in currency/kWh. + + Args: + microgrid: Name of the microgrid to evaluate. + price: Grid signal containing the export price in currency/kWh. + currency: Currency label for the resulting metric. + """ + + def __init__(self, microgrid: str, price: str, currency: str = "EUR"): + super().__init__(microgrid, price, currency, 1, "Electricity export revenue") + + +class GridImportEmissions(_GridIntegral): + """Integrate imported electricity times an emissions signal in kgCO2e/kWh. + + This excludes onsite fuel use and embodied emissions. Exported electricity + does not offset the result. The supplied factor determines the accounting basis. + + Args: + microgrid: Name of the microgrid to evaluate. + carbon_intensity: Grid signal containing emissions intensity in kgCO2e/kWh. + """ + + def __init__(self, microgrid: str, carbon_intensity: str): + super().__init__( + microgrid, + carbon_intensity, + "kgCO2e", + -1, + "Emissions associated with imported electricity", + ) diff --git a/vessim/microgrid.py b/vessim/microgrid.py index 23705e38..759de6d6 100644 --- a/vessim/microgrid.py +++ b/vessim/microgrid.py @@ -1,185 +1,219 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Optional, TypedDict +from copy import deepcopy +from typing import Optional, TypedDict -import mosaik -import mosaik_api_v3 +import numpy as np -if TYPE_CHECKING: - from vessim.actor import Actor - from vessim.dispatch_policy import DispatchPolicy - from vessim.dispatchable import Dispatchable - from vessim.signal import Signal +from vessim.actor import Actor +from vessim._util import require_balanced, require_finite +from vessim.dispatchable import Dispatchable, GridConnection +from vessim.dispatch_policy import DispatchPolicy, DefaultDispatchPolicy, _BUILTIN_DISPATCHABLES +from vessim.signal import Signal class MicrogridState(TypedDict): - """State of a microgrid. - - This state is passed to `Controller`s on every step. + """Observation at a tick, after dispatch and before controllers execute. + + Attributes: + p_delta: Net actor and external simulator power in W. Positive means + surplus generation, negative means a demand deficit. + p_grid: Net exchange across grid connections in W. Positive means export, + negative means import. + actor_states: Actor names mapped to their observations, including signed + "power" in W. + dispatch_states: Resource names mapped to their observations, such as + "current_power", "soc", and "charge_level". + policy_state: Diagnostic state returned by the dispatch policy. + grid_signals: Sampled contextual signals keyed by name, or None. """ - p_delta: float # Current power delta in W - p_grid: float # Last step's power exchange with the public grid in W - actor_states: dict[str, dict] # States of all actors in the microgrid - policy_state: dict # State of the microgrid dispatch policy - dispatch_states: dict[str, dict] # States of all dispatchables, keyed by name - grid_signals: Optional[dict[str, float]] # Current grid signals, if available + p_delta: float + p_grid: float + actor_states: dict[str, dict] + policy_state: dict + dispatch_states: dict[str, dict] + grid_signals: Optional[dict[str, float]] + + +_IMMUTABLE_SCALARS = (str, int, float, bool, bytes, type(None), np.float64) + + +def _snapshot(state: dict) -> dict: + # Built-in observations are flat dictionaries of immutable scalars. Avoid + # deepcopy's recursive bookkeeping there, retaining it for custom state. + if all(type(value) in _IMMUTABLE_SCALARS for value in state.values()): + return state.copy() + return deepcopy(state) class Microgrid: - """A simulated energy system. + """Energy-system models and current state, independent of an execution backend. - A microgrid is a collection of actors (exogenous consumers and producers), - dispatchables (controllable resources like batteries), and a dispatch policy - that governs their interaction. It can also be connected to the public grid. + Usually constructed through + [Environment.add_microgrid()][vessim.Environment.add_microgrid]. Powers are + in W. Actor generation is positive and consumption negative. Dispatchable + supply/import is negative. Absorption/export is positive. Args: - world: The mosaik world instance. - step_size: The step size of the simulation in seconds. - actors: The exogenous actors in the microgrid. - dispatchables: The dispatchable resources in the microgrid. - policy: The `DispatchPolicy` that controls the microgrid. - grid_signals: Optional signals from the public grid. - name: Optional name for the microgrid. - coords: Optional coordinates (latitude, longitude) for visualizing the - microgrid's location in the dashboard. + step_size: Positive integer sampling interval in seconds. + actors: Exogenous loads and generators. + dispatchables: Controllable resources, in dispatch order. Include a + GridConnection explicitly for grid-connected operation. Without one, + the microgrid is islanded. + policy: Policy assigning the next power setpoints. + grid_signals: Context such as price or carbon intensity. + name: Unique name within the environment. + coords: Optional latitude and longitude for the viewer. """ def __init__( self, - world: mosaik.World, step_size: int, actors: list[Actor], dispatchables: list[Dispatchable], policy: DispatchPolicy, - grid_signals: Optional[dict[str, Signal]] = None, - name: Optional[str] = None, + grid_signals: Optional[dict[str, float | Signal]] = None, + name: str = "microgrid", coords: Optional[tuple[float, float]] = None, ): self.step_size = step_size - self.actors = actors - self.dispatchables = dispatchables + self.actors = list(actors) + self.dispatchables = list(dispatchables) self.policy = policy - self.grid_signals = grid_signals - self.name = name or f"microgrid_{id(self)}" + self.grid_signals = dict(grid_signals) if grid_signals else None + self.name = name self.coords = coords - - self.actor_entities = {} - for actor in actors: - actor_step_size = actor.step_size if actor.step_size else step_size - if actor_step_size % step_size != 0: - raise ValueError("Actor step size has to be a multiple of grids step size.") - actor_sim = world.start( - "Actor", - sim_id=f"{self.name}.actor.{actor.name}", - step_size=actor_step_size, - ) - # We initialize all actors before the microgrid simulation to make sure - # that there is already a valid p_delta at step 0 - self.actor_entities[actor.name] = actor_sim.Actor(actor=actor) - - microgrid_sim = world.start( - "Microgrid", - sim_id=f"{self.name}.microgrid", - step_size=step_size, - grid_signals=grid_signals, - ) - self.entity = microgrid_sim.Microgrid( - dispatchables=dispatchables, policy=policy, - ) - for actor_entity in self.actor_entities.values(): - world.connect(actor_entity, self.entity, "power") - - def finalize(self): - """Clean up in case the simulation was interrupted. - - Mosaik already has a cleanup functionality but this is an additional safety net - in case the user interrupts the simulation before entering the mosiak event loop. - """ - for actor in self.actors: - actor.finalize() - - -class _MicrogridSim(mosaik_api_v3.Simulator): - """Mosaik simulator that aggregates actor power and dispatches to flexible resources.""" - - META = { - "type": "time-based", - "models": { - "Microgrid": { - "public": True, - "params": ["dispatchables", "policy", "grid_signals"], - "attrs": [ - "power", - "p_delta", - "grid_signals", - "p_grid", - "dispatch_states", - "policy_state", - ], - }, - }, - } - - def __init__(self): - super().__init__(self.META) - self.eid = "Microgrid" - self.step_size = None - self.grid_signals: Optional[dict[str, Signal]] = None self.p_delta = 0.0 self.p_grid = 0.0 - self.dispatch_states: dict = {} - self.policy_state: dict = {} - - def init(self, sid, time_resolution=1.0, **sim_params): - self.step_size = sim_params["step_size"] - self.grid_signals = sim_params.get("grid_signals") - return self.meta - - def create(self, num, model, **model_params): - assert num == 1, "Only one instance per simulation is supported" - self.dispatchables = model_params["dispatchables"] - self.policy = model_params["policy"] - return [{"eid": self.eid, "type": model}] - - def step(self, time, inputs, max_advance): - assert self.step_size is not None - - # Phase 1: Advance dispatchable state from previous round's power setpoints - for d in self.dispatchables: - d.step(self.step_size) - - # Phase 2: Aggregate actor power into p_delta - self.p_delta = sum(inputs[self.eid]["power"].values()) - - # Phase 3: Resolve grid signals at current time - if self.grid_signals: - self._resolved_grid_signals = { - name: signal.at(time) for name, signal in self.grid_signals.items() + self._resolved_grid_signals: Optional[dict[str, float]] = None + self._actor_states: dict[str, dict] = {} + self._last_elapsed = 0.0 + self._validate() + + def _validate(self) -> None: + if ( + isinstance(self.step_size, bool) + or not isinstance(self.step_size, int) + or self.step_size <= 0 + ): + raise ValueError("step_size must be a positive integer number of seconds.") + for group in (self.actors, self.dispatchables): + names = [item.name for item in group] + if any(not isinstance(n, str) or not n for n in names): + raise ValueError("Component names must be nonempty strings.") + if len(set(names)) != len(names): + raise ValueError("Component names must be unique within each microgrid.") + for actor in self.actors: + period = self.step_size if actor.step_size is None else actor.step_size + if ( + isinstance(period, bool) + or not isinstance(period, int) + or period <= 0 + or period % self.step_size + ): + raise ValueError( + "Actor step size has to be a positive multiple of grid step size." + ) + for name, value in (self.grid_signals or {}).items(): + if not isinstance(value, Signal): + require_finite(value, f"Grid signal '{name}'") + # Capture the final scenario configuration when Environment.prepare() + # validates it, including changes made after add_microgrid(). + self._sampling = [(a, a.step_size or self.step_size) for a in self.actors] + self._powers = {a.name: 0.0 for a in self.actors} + + def sample(self, elapsed: int) -> None: + """Update due actors, holding slower actors' measurements between updates.""" + for actor, period in self._sampling: + if elapsed % period == 0: + power, state = actor.sample(elapsed) + actor._validate_sample(power) + self._powers[actor.name] = power + self._actor_states[actor.name] = {**state, "power": power} + + def advance( + self, elapsed: int, external_power: float = 0.0, duration: Optional[float] = None + ) -> None: + """Integrate previous setpoints and dispatch the next interval. + + Tick zero observes the initial state without integrating any energy. + ``duration`` clips dispatch to the remaining time in the final interval. + Call ``finish(until)`` to integrate that interval without another dispatch. + """ + self.finish(elapsed) + require_finite(external_power, f"External power for microgrid '{self.name}'") + self.p_delta = sum(self._powers.values()) + if external_power: + self.p_delta += external_power + self._resolved_grid_signals = ( + { + name: signal.at(elapsed) if isinstance(signal, Signal) else signal + for name, signal in self.grid_signals.items() } + if self.grid_signals + else None + ) + require_finite(self.p_delta, f"Total power for microgrid '{self.name}'") + for name, value in (self._resolved_grid_signals or {}).items(): + require_finite(value, f"Grid signal '{name}' at {elapsed}s") + interval = self.step_size if duration is None else duration + bounds = [d._validated_range(interval) for d in self.dispatchables] + if type(self.policy) is DefaultDispatchPolicy and all( + type(d) in _BUILTIN_DISPATCHABLES for d in self.dispatchables + ): + self.policy._apply(self.p_delta, interval, self.dispatchables, bounds) else: - self._resolved_grid_signals = None - - # Phase 4: Dispatch policy allocates new power setpoints - self.p_grid = self.policy.apply( - self.p_delta, - duration=self.step_size, - dispatchables=self.dispatchables, - grid_signals=self._resolved_grid_signals, + self.policy.apply( + self.p_delta, + duration=interval, + dispatchables=list(self.dispatchables), + grid_signals=self._resolved_grid_signals, + ) + for d, (lo, hi) in zip(self.dispatchables, bounds): + require_finite(d.current_power, f"Power setpoint for '{d.name}'") + if not lo <= d.current_power <= hi: + raise ValueError(f"Policy set power outside the feasible range for '{d.name}'.") + require_balanced(self.p_delta, (d.current_power for d in self.dispatchables)) + self.p_grid = sum( + d.current_power for d in self.dispatchables if isinstance(d, GridConnection) + ) + require_finite(self.p_grid, f"Grid power for '{self.name}'") + + def finish(self, elapsed: float) -> None: + """Integrate held setpoints up to elapsed time, without sampling or dispatch.""" + require_finite(elapsed, "Integration time") + duration = elapsed - self._last_elapsed + if duration < 0: + raise ValueError("Microgrid integration time must not move backwards.") + if duration > 0: + for dispatchable in self.dispatchables: + dispatchable.step(duration) + self._last_elapsed = elapsed + + def state(self) -> MicrogridState: + """Return a detached observation, safe to retain across subsequent ticks.""" + return MicrogridState( + p_delta=self.p_delta, + p_grid=self.p_grid, + actor_states={name: _snapshot(state) for name, state in self._actor_states.items()}, + policy_state=_snapshot(self.policy.state()), + dispatch_states={d.name: _snapshot(d.state()) for d in self.dispatchables}, + grid_signals=( + dict(self._resolved_grid_signals) + if self._resolved_grid_signals is not None + else None + ), ) - self.policy_state = self.policy.state() - self.dispatch_states = {d.name: d.state() for d in self.dispatchables} - - return time + self.step_size - - def get_data(self, outputs): - return { - self.eid: { - "p_delta": self.p_delta, - "grid_signals": self._resolved_grid_signals, - "p_grid": self.p_grid, - "dispatch_states": self.dispatch_states, - "policy_state": self.policy_state, - } + def finalize(self) -> None: + """Finalize actors and grid signals when used outside an Environment.""" + for actor in self.actors: + actor.finalize() + signals = { + id(value): value + for value in [*(a.signal for a in self.actors), *(self.grid_signals or {}).values()] + if isinstance(value, Signal) } + for signal in signals.values(): + signal.finalize() diff --git a/vessim/recorder.py b/vessim/recorder.py new file mode 100644 index 00000000..5e6428d2 --- /dev/null +++ b/vessim/recorder.py @@ -0,0 +1,339 @@ +"""A shared experiment format for live experiments and simulation studies.""" + +from __future__ import annotations + +import math +import time +from copy import deepcopy +from csv import DictWriter +from datetime import datetime +from pathlib import Path +from typing import TYPE_CHECKING, Optional, TextIO +from uuid import uuid4 + + +from vessim._experiment import ExperimentResult +from vessim._util import flatten_dict +from vessim.controller import Controller, _build_experiment_config +from vessim.metrics import Metric + +if TYPE_CHECKING: + from vessim.environment import Environment + from vessim.microgrid import MicrogridState + + +def _scopes(environment: Environment) -> dict: + return { + "run": {}, + "microgrids": {name: {} for name in environment.microgrids}, + "actors": { + name: {actor.name: {} for actor in grid.actors} + for name, grid in environment.microgrids.items() + }, + } + + +def _scope(data: dict, metric: Metric) -> dict: + if metric.actor is not None: + return data["actors"][metric.microgrid][metric.actor] + if metric.microgrid is not None: + return data["microgrids"][metric.microgrid] + return data["run"] + + +_ENERGY_DEFINITIONS = { + "energy_produced": { + "unit": "Wh", + "description": "Positive actor power integrated over recorded intervals", + }, + "energy_consumed": { + "unit": "Wh", + "description": "Absolute negative actor power integrated over recorded intervals", + }, +} +_GRID_DEFINITIONS = { + **_ENERGY_DEFINITIONS, + "grid_import": {"unit": "Wh", "description": "Imported grid energy over recorded intervals"}, + "grid_export": {"unit": "Wh", "description": "Exported grid energy over recorded intervals"}, + "peak_grid_import": {"unit": "W", "description": "Maximum sampled grid import power"}, + "peak_grid_export": {"unit": "W", "description": "Maximum sampled grid export power"}, +} + + +class Recorder(Controller): + """Record an experiment with metrics and time series enabled by default. + + With output set, time series stream to CSV without being retained in memory. + Otherwise they remain in memory and are accessible through log and to_df(). + Set timeseries=False for bounded-memory studies. Study metadata is written + once at completion. Live output is refreshed each tick by default. + + Args: + output: Experiment directory for experiment.json and optional timeseries.csv. + metrics: Collect standard physical metrics (default True). + timeseries: Collect detailed observations (default True). + custom_metrics: Named Metric instances, extending the standard metrics. + A list under one name registers that metric in several scopes. + Instances must belong to one recorder and names must be unique in scope. + parameters: User-supplied scenario parameters, including seeds if relevant. + run_id: Identifier for joining results to a study. Generated when omitted. + flush: Override per-tick publication (defaults to the environment's live mode). + """ + + def __init__( + self, + output: Optional[str | Path] = None, + *, + metrics: bool = True, + timeseries: bool = True, + custom_metrics: Optional[dict[str, Metric | list[Metric]]] = None, + parameters: Optional[dict] = None, + run_id: Optional[str] = None, + flush: Optional[bool] = None, + ): + self.output = Path(output) if output is not None else None + self.collect_metrics = metrics + self.collect_timeseries = timeseries + self.custom_metrics = dict(custom_metrics or {}) + self.parameters = deepcopy(parameters or {}) + self.run_id = run_id if run_id is not None else uuid4().hex + self.flush = flush + self.log: dict[datetime, dict[str, MicrogridState]] = {} + self._result: Optional[ExperimentResult] = None + self._file: Optional[TextIO] = None + self._writer: Optional[DictWriter] = None + self._started_metrics: list[tuple[str, Metric]] = [] + self._finalized = False + self.steps = 0 + self.duration = 0.0 + + @property + def result(self) -> ExperimentResult: + """The experiment record, available after start and finalized after run.""" + if self._result is None: + raise RuntimeError("Recorder must be started by an Environment first.") + return self._result + + @property + def metrics(self) -> dict: + """Scoped numeric values, including completed custom metrics after run.""" + return self.result.metrics + + @property + def csv_path(self) -> Path: + """The on-disk trajectory path, when output is configured.""" + if self.output is None: + raise RuntimeError("This recorder has no output directory.") + return self.output / "timeseries.csv" + + def start(self, environment: Environment) -> None: + """Capture configuration and initialize metric calculators.""" + if self._result is not None: + raise RuntimeError("A Recorder is single-use. Create one for each experiment.") + self._environment = environment + self._started = time.perf_counter() + self._flush = environment.is_live if self.flush is None else self.flush + trajectory = ( + "timeseries.csv" if self.collect_timeseries and self.output is not None else None + ) + self._result = ExperimentResult( + run_id=self.run_id, + config=_build_experiment_config(environment), + parameters=deepcopy(self.parameters), + execution={ + "status": "running", + "start": datetime.now().isoformat(), + "end": None, + "wall_time": None, + "steps": 0, + "simulated_duration": 0.0, + "until": environment.until if math.isfinite(environment.until) else None, + "last_time": None, + }, + metrics=_scopes(environment), + metric_definitions=_scopes(environment), + timeseries=trajectory, + _directory=self.output.resolve() if self.output is not None else None, + ) + if self.collect_metrics: + for name, grid in environment.microgrids.items(): + self.metrics["microgrids"][name] = {key: 0.0 for key in _GRID_DEFINITIONS} + self.result.metric_definitions["microgrids"][name] = deepcopy(_GRID_DEFINITIONS) + for actor in grid.actors: + self.metrics["actors"][name][actor.name] = { + key: 0.0 for key in _ENERGY_DEFINITIONS + } + self.result.metric_definitions["actors"][name][actor.name] = deepcopy( + _ENERGY_DEFINITIONS + ) + seen = set() + registrations = [ + (name, metric) + for name, calculators in self.custom_metrics.items() + for metric in (calculators if isinstance(calculators, list) else [calculators]) + ] + for name, metric in registrations: + if not isinstance(name, str) or not name or not isinstance(metric, Metric): + raise ValueError("custom_metrics must map nonempty names to Metric instances.") + if id(metric) in seen: + raise ValueError("A Metric instance can only be registered once.") + seen.add(id(metric)) + try: + values = _scope(self.metrics, metric) + definitions = _scope(self.result.metric_definitions, metric) + except KeyError as exc: + raise ValueError(f"Unknown scope for custom metric '{name}'.") from exc + if name in values: + raise ValueError(f"Metric '{name}' already exists in this scope.") + values[name] = None + definitions[name] = deepcopy(metric.definition()) + metric.start(environment) + self._started_metrics.append((name, metric)) + # Reject invalid metadata before beginning the simulation. + self.result.to_dict() + if self.output is not None: + self.output.mkdir(parents=True, exist_ok=True) + if self.collect_timeseries: + self._file = self.csv_path.open("w", newline="") + if self._flush: + self._publish() + + def step(self, now: datetime, microgrid_states: dict[str, MicrogridState]) -> None: + """Accumulate an interval and optionally retain or stream its observation.""" + env = self._environment + assert env.sim_start is not None + elapsed = (now - env.sim_start).total_seconds() + duration = min(env.step_size, env.until - elapsed) + self.steps += 1 + self.duration += duration + self.result.execution.update( + { + "steps": self.steps, + "simulated_duration": self.duration, + "last_time": now, + } + ) + if self.collect_metrics: + self._physical_metrics(microgrid_states, duration) + for _, metric in self._started_metrics: + metric.step(now, microgrid_states, duration) + if self.collect_timeseries: + if self.output is None: + # Runtime observations are already detached from component state. + self.log[now] = microgrid_states + else: + rows = [ + {"microgrid": name, "time": now, **flatten_dict(dict(state))} + for name, state in microgrid_states.items() + ] + if self._writer is None: + fields = list(dict.fromkeys(key for row in rows for key in row)) + assert self._file is not None + self._writer = DictWriter(self._file, fieldnames=fields) + self._writer.writeheader() + self._writer.writerows(rows) + if self._flush and self.output is not None: + if self._file is not None: + self._file.flush() + self._publish() + + def _physical_metrics(self, states: dict[str, MicrogridState], duration: float) -> None: + hours = duration / 3600 + metrics = self.metrics + for name, state in states.items(): + values = metrics["microgrids"][name] + imported, exported = max(0.0, -state["p_grid"]), max(0.0, state["p_grid"]) + values["grid_import"] += imported * hours + values["grid_export"] += exported * hours + values["peak_grid_import"] = max(values["peak_grid_import"], imported) + values["peak_grid_export"] = max(values["peak_grid_export"], exported) + # Use the sampled powers used by dispatch, even when a custom actor's + # state() omits power or exposes a different diagnostic value. + for actor, power in self._environment.microgrids[name]._powers.items(): + produced, consumed = max(0.0, power) * hours, max(0.0, -power) * hours + actor_values = metrics["actors"][name][actor] + actor_values["energy_produced"] += produced + actor_values["energy_consumed"] += consumed + values["energy_produced"] += produced + values["energy_consumed"] += consumed + + def finalize(self) -> None: + """Close the CSV and finalize metrics before Environment publishes the result.""" + if self._result is None or self._finalized: + return + self._finalized = True + errors: list[BaseException] = [] + if self._file is not None: + try: + self._file.close() + except BaseException as exc: + errors.append(exc) + self._file = None + for name, metric in self._started_metrics: + try: + value = metric.finalize() + if value is not None: + value = float(value) + if not math.isfinite(value): + raise ValueError(f"Metric '{name}' must return a finite value or None.") + _scope(self.metrics, metric)[name] = value + except BaseException as exc: + errors.append(exc) + if errors: + self.result.execution["metric_error"] = str(errors[0]) + raise errors[0] + + def _complete(self) -> None: + """Publish only after Environment has attempted all cleanup callbacks.""" + if self._result is None: + return + self.result.execution.update( + { + "status": self._environment.status, + "end": datetime.now().isoformat(), + "wall_time": time.perf_counter() - self._started, + } + ) + if self.output is not None: + self._publish() + + def _publish(self) -> None: + assert self.output is not None + self.result.save(self.output / "experiment.json") + + def to_df(self): + """Read the retained or saved time series as a pandas DataFrame. + + The index has "time" and "microgrid" levels. Columns use dot-separated + state paths, such as "actor_states.load.power" and + "dispatch_states.battery.soc". + + Raises: + RuntimeError: If time-series recording is disabled. + """ + import pandas as pd + + if not self.collect_timeseries: + raise RuntimeError("Time series recording is disabled.") + if self.output is not None: + if self._file is not None: + self._file.flush() + try: + frame = pd.read_csv( + self.csv_path, + parse_dates=["time"], + dtype={"microgrid": str}, + keep_default_na=False, + na_values=[""], + ) + except pd.errors.EmptyDataError: + return pd.DataFrame() + else: + frame = pd.DataFrame( + [ + {"time": now, "microgrid": name, **flatten_dict(state)} + for now, states in self.log.items() + for name, state in states.items() + ] + ) + return frame.set_index(["time", "microgrid"]) if not frame.empty else frame diff --git a/vessim/signal.py b/vessim/signal.py index 365dd061..6325aae9 100644 --- a/vessim/signal.py +++ b/vessim/signal.py @@ -1,6 +1,9 @@ from __future__ import annotations +import math import time +from copy import copy +from threading import Event, Thread, current_thread from abc import ABC, abstractmethod from datetime import timedelta from pathlib import Path @@ -9,9 +12,22 @@ import numpy as np import pandas as pd +from vessim._util import require_finite + class Signal(ABC): - """Abstract base class for signals.""" + """A numeric simulation input sampled at elapsed time. + + Use for actor power or contextual data such as prices and carbon intensity. + The environment calls start() before sampling and finalize() during cleanup. + """ + + def __repr__(self) -> str: + """Describe custom signals without process-specific memory addresses.""" + return f"{type(self).__name__}()" + + def start(self) -> None: + """Acquire resources before the first simulation sample.""" @abstractmethod def at(self, elapsed: Optional[timedelta | float] = None) -> float: @@ -26,18 +42,27 @@ def finalize(self) -> None: class StaticSignal(Signal): + """A constant numeric input, also used for numeric actor input shorthand. + + Args: + value: A finite number. Units are determined by the input using it. + """ + def __init__(self, value: float) -> None: - self._v = value + self.set_value(value) - def __repr__(self): - """Returns a string representation for the Vessim viewer.""" - return f"StaticSignal({self._v})" + def __repr__(self) -> str: + """Describe the constant input in experiment configuration.""" + return f"StaticSignal({self._value})" def set_value(self, value: float) -> None: - self._v = value + """Change the value returned by subsequent samples.""" + require_finite(value, "StaticSignal value") + self._value = value - def at(self, elapsed: Optional[timedelta | float] = None): - return self._v + def at(self, elapsed: Optional[timedelta | float] = None) -> float: + """Return the current value, independent of elapsed time.""" + return self._value class Trace(Signal): @@ -55,16 +80,17 @@ class Trace(Signal): as-is. Must start at offset=0. `anchor` must not be provided. This is the canonical form. - `DatetimeIndex`: a convenience for calendar-stamped data. `anchor` is - required and must match an existing row exactly; that row is rebased + required and must match an existing row exactly. That row is rebased to offset=0 and earlier rows are dropped. For loading offset-indexed or datetime-indexed CSVs, use `Trace.from_csv`. Args: data: A pandas `Series` or `DataFrame`. See above for accepted index - types. Each column represents one zone of data; the column name + types. Each column represents one zone of data. The column name is the zone name. Between samples, values are interpolated using - `fill_method` (`ffill` or `bfill`). + `fill_method` (`ffill` or `bfill`). Values must be finite and + timestamps unique. Handle missing data explicitly before loading. anchor: Required when `data` has a `DatetimeIndex`. Must be a value present in the index. The matching row is rebased to offset=0. Forbidden for `TimedeltaIndex` and numeric indices (which are @@ -92,6 +118,8 @@ def __init__( f"on_overflow={on_overflow!r} is not yet supported. Only 'raise' is available." ) + if fill_method not in ("ffill", "bfill"): + raise ValueError("fill_method must be 'ffill' or 'bfill'.") self._fill_method = fill_method self._on_overflow = on_overflow self.default_column = column @@ -101,6 +129,10 @@ def __init__( raise ValueError(f"Incompatible type {type(data)} for 'data'.") raw_index = data.index + if raw_index.hasnans: + raise ValueError("Trace timestamps must not contain missing values (NaN or NaT).") + if raw_index.has_duplicates: + raise ValueError("Trace timestamps must be unique; resolve duplicate rows first.") if isinstance(raw_index, pd.DatetimeIndex): if anchor is None: raise ValueError( @@ -115,22 +147,18 @@ def __init__( ) data = data.loc[raw_index >= anchor_ts] assert isinstance(data.index, pd.DatetimeIndex) - offsets_array = (data.index - anchor_ts).to_numpy().astype( - "timedelta64[ns]" - ) + offsets_array = (data.index - anchor_ts).to_numpy().astype("timedelta64[ns]") elif isinstance(raw_index, pd.TimedeltaIndex): if anchor is not None: - raise ValueError( - "anchor is only valid for datetime-indexed data." - ) + raise ValueError("anchor is only valid for datetime-indexed data.") offsets_array = raw_index.to_numpy().astype("timedelta64[ns]") elif pd.api.types.is_numeric_dtype(raw_index): if anchor is not None: - raise ValueError( - "anchor is only valid for datetime-indexed data." - ) - offsets_array = pd.to_timedelta(raw_index, unit="s").to_numpy().astype( - "timedelta64[ns]" + raise ValueError("anchor is only valid for datetime-indexed data.") + if not np.isfinite(raw_index.to_numpy()).all(): + raise ValueError("Trace offsets must be finite numbers of seconds.") + offsets_array = ( + pd.to_timedelta(raw_index, unit="s").to_numpy().astype("timedelta64[ns]") ) else: raise TypeError( @@ -140,26 +168,65 @@ def __init__( if offsets_array.size == 0: raise ValueError("Trace data cannot be empty.") + if np.isnat(offsets_array).any(): + raise ValueError("Trace timestamps must be valid and within the supported range.") sorter = np.argsort(offsets_array) offsets_ns = offsets_array[sorter] + if np.any(offsets_ns[1:] == offsets_ns[:-1]): + raise ValueError("Trace timestamps must be unique at nanosecond resolution.") if offsets_ns[0] != np.timedelta64(0, "ns"): raise ValueError( f"Trace must start at offset=0, but starts at {_td(offsets_ns[0])}. " - f"To delay a trace, pad the beginning of your data with zeros or NaNs." + f"To delay a trace, pad the beginning of your data with zeros." ) self._offsets: dict[str, tuple[np.ndarray, np.ndarray]] = {} - if isinstance(data, pd.Series): - values = data.to_numpy(dtype=float, copy=True)[sorter] - mask = ~np.isnan(values) - self._offsets[str(data.name)] = (offsets_ns[mask], values[mask]) - else: - for col in data.columns: - values = data[col].to_numpy(dtype=float, copy=True)[sorter] - mask = ~np.isnan(values) - self._offsets[str(col)] = (offsets_ns[mask], values[mask]) + columns = [(data.name, data)] if isinstance(data, pd.Series) else list(data.items()) + if not columns: + raise ValueError("Trace needs at least one value column.") + for col, series in columns: + name = str(col) + if name in self._offsets: + raise ValueError(f"Trace column names must be unique; duplicate {name!r}.") + try: + values = series.to_numpy(dtype=float, copy=True)[sorter] + except (TypeError, ValueError) as exc: + raise ValueError(f"Trace column {name!r} must contain numeric values.") from exc + invalid = np.flatnonzero(~np.isfinite(values)) + if invalid.size: + raise ValueError( + f"Trace column {name!r} has a missing or nonfinite value at " + f"offset {_td(offsets_ns[invalid[0]])}. " + "Fill or remove missing values explicitly before creating a Trace." + ) + self._offsets[name] = (offsets_ns, values) + + if column is not None: + _get_column_name(self._offsets, column) + + for name, (offsets, values) in self._offsets.items(): + offsets = offsets.view("i8") + offsets.setflags(write=False) + values.setflags(write=False) + self._offsets[name] = (offsets, values) + offsets = offsets_ns.view("i8") + spacing = np.diff(offsets) + self._regular_step = ( + int(spacing[0]) if spacing.size and np.all(spacing == spacing[0]) else None + ) + + def select(self, column: str) -> Trace: + """Return a column view sharing immutable arrays with this trace. + + Load a multi-column CSV once and use views for different actors or + scenarios. Sampling is stateless. No per-run cursor is stored here. + """ + _get_column_name(self._offsets, column) + selected = copy(self) + selected.default_column = column + return selected def __repr__(self): """Returns a string representation for the Vessim viewer.""" @@ -183,12 +250,12 @@ def from_csv( As a utility, datetime-indexed CSVs are also accepted and converted on load: pass `anchor=` to mark which row corresponds to - offset=0. See [Signals and Datasets](../concepts/signals.md) for the - full schema and recipes for fetching data from public APIs. + offset=0. See [Actors and signals](../concepts/signals.md#trace-signals) + for pandas and CSV examples. Args: path: Path to the CSV file. - anchor: Required if the first column is a datetime; must match a + anchor: Required if the first column is a datetime. Must match a row in the data exactly. That row becomes `offset=0` and earlier rows are dropped. Forbidden if the first column is already numeric. @@ -197,12 +264,19 @@ def from_csv( on_overflow: See `Trace`. fill_method: See `Trace`. """ + require_finite(scale, "Trace scale") df = pd.read_csv(path, index_col=0) if scale != 1.0: df = df.astype(float) * scale if not pd.api.types.is_numeric_dtype(df.index): - df.index = pd.to_datetime(df.index) + try: + df.index = pd.to_datetime(df.index) + except (TypeError, ValueError) as exc: + raise ValueError( + f"The first column of {str(path)!r} must contain offsets in seconds " + "or valid timestamps." + ) from exc return cls( df, @@ -240,30 +314,40 @@ def at( if elapsed is None: raise ValueError("Argument elapsed cannot be None.") if not isinstance(elapsed, timedelta): - elapsed = timedelta(seconds=elapsed) + require_finite(elapsed, "Trace elapsed time") + if isinstance(elapsed, timedelta): + np_at = ( + (elapsed.days * 86400 + elapsed.seconds) * 1_000_000 + elapsed.microseconds + ) * 1000 + elif isinstance(elapsed, int): + np_at = elapsed * 1_000_000_000 + else: + # Preserve timedelta's microsecond resolution for fractional seconds. + np_at = round(elapsed * 1_000_000) * 1000 if column is None: column = self.default_column resolved = _get_column_name(self._offsets, column) offsets, values = self._offsets[resolved] - np_at = np.timedelta64(elapsed) - if self._fill_method == "ffill": - index = offsets.searchsorted(np_at, side="right") - 1 - if index >= 0: - return values[index] + if np_at < offsets[0] or np_at > offsets[-1]: + boundary = "before the start" if np_at < offsets[0] else "after the end" raise ValueError( - f"Elapsed time {elapsed} is before the start of column '{resolved}' " - f"(trace covers {_td(offsets[0])} to {_td(offsets[-1])} since sim_start)." + f"Elapsed time {elapsed} is {boundary} of column '{resolved}' " + f"(trace covers {_td(offsets[0])} to {_td(offsets[-1])} since sim_start). " + "Extend the input data or shorten the simulation." ) + + if self._regular_step is not None: + position, remainder = divmod(np_at, self._regular_step) + if self._fill_method == "bfill" and remainder: + position += 1 + return values[position] + if self._fill_method == "ffill": + index = offsets.searchsorted(np_at, side="right") - 1 else: index = offsets.searchsorted(np_at, side="left") - if index < offsets.size: - return values[index] - raise ValueError( - f"Elapsed time {elapsed} is after the end of column '{resolved}' " - f"(trace covers {_td(offsets[0])} to {_td(offsets[-1])} since sim_start)." - ) + return values[index] def _get_column_name(data: dict[str, Any], column: Optional[str]) -> str: @@ -273,7 +357,7 @@ def _get_column_name(data: dict[str, Any], column: Optional[str]) -> str: raise ValueError("Column needs to be specified.") if column in data: return column - raise ValueError(f"Cannot retrieve data for column '{column}'.") + raise ValueError(f"Unknown trace column {column!r}. Available columns: {list(data)}.") def _td(value: np.timedelta64) -> timedelta: @@ -281,8 +365,8 @@ def _td(value: np.timedelta64) -> timedelta: return pd.Timedelta(value).to_pytimedelta() -class SilSignal(Signal): - """Base class for Software-in-the-Loop signals with background polling. +class LiveSignal(Signal): + """Base class for live signals with background polling. This class provides common functionality for signals that need to periodically fetch data from external sources (APIs, databases, etc.) and cache the results. @@ -293,21 +377,33 @@ class SilSignal(Signal): """ def __init__(self, update_interval: float = 5.0, timeout: float = 10.0): - try: - from threading import Timer - except ImportError: - raise ImportError("SilSignal requires threading support") - - self.Timer = Timer + if ( + not math.isfinite(update_interval) + or not math.isfinite(timeout) + or update_interval <= 0 + or timeout <= 0 + ): + raise ValueError("update_interval and timeout must be finite and positive.") self.update_interval = update_interval self.timeout = timeout - self._last_update: Optional[float] = None self._cached_value: float = 0.0 - self._stop_polling = False + self._stop = Event() + self._thread: Optional[Thread] = None + + def start(self) -> None: + """Start background polling when the environment starts using this signal.""" + if self._stop.is_set(): + raise RuntimeError("A closed live signal cannot be reused. Create a new signal.") + if self._thread is None: + self._initialize() + self._thread = Thread( + target=self._poll, daemon=True, name=f"vessim-{type(self).__name__}" + ) + self._thread.start() - # Start background polling - self._start_background_polling() + def _initialize(self) -> None: + """Prepare the external source before starting a polling worker.""" def __repr__(self): """Returns a string representation for the Vessim viewer.""" @@ -327,22 +423,15 @@ def _fetch_current_value(self) -> float: Exception: Any exception that occurs during data fetching """ - def _start_background_polling(self) -> None: - """Start background polling in a separate thread.""" - - def poll(): - if not self._stop_polling: - try: - self._cached_value = self._fetch_current_value() - self._last_update = time.time() - except Exception: - pass # Keep using cached value - # Schedule next poll - # TODO: daemonize the Timer threads so a forgotten finalize() - # cannot keep the interpreter alive after main returns. - self.Timer(self.update_interval, poll).start() - - self.Timer(0, poll).start() # Start immediately + def _poll(self) -> None: + while not self._stop.is_set(): + try: + self._cached_value = self._fetch_current_value() + self._last_update = time.time() + except Exception: + pass # Retain the last observation if a source is temporarily unavailable. + if self._stop.wait(self.update_interval): + break def at(self, elapsed: Optional[timedelta | float] = None) -> float: """Return the current cached value. @@ -357,10 +446,12 @@ def at(self, elapsed: Optional[timedelta | float] = None) -> float: def finalize(self) -> None: """Stop background polling and clean up resources.""" - self._stop_polling = True + self._stop.set() + if self._thread is not None and current_thread() is not self._thread: + self._thread.join(timeout=self.timeout + 0.1) -class PrometheusSignal(SilSignal): +class PrometheusSignal(LiveSignal): """Signal that pulls energy usage data from a Prometheus instance. Args: @@ -400,9 +491,9 @@ def __init__( if username and password: self._auth = requests.auth.HTTPBasicAuth(username, password) - # Initialize parent class (starts background polling) super().__init__(update_interval=update_interval, timeout=timeout) + def _initialize(self) -> None: self._validate_connection() def _validate_connection(self) -> None: @@ -447,7 +538,7 @@ def __repr__(self): return f"PrometheusSignal({self.query})" -class WatttimeSignal(SilSignal): +class WatttimeSignal(LiveSignal): """Real-time carbon intensity signal from WattTime API. This signal fetches real-time marginal carbon intensity data from the WattTime API. @@ -483,7 +574,7 @@ def __init__( except ImportError: raise ImportError( "WatttimeSignal requires 'requests' package. " - "Install with: pip install 'vessim[sil]'" + "Install with: pip install 'vessim[live]'" ) # Validate that not both region and location are provided @@ -497,25 +588,23 @@ def __init__( self._username = username self._password = password self._base_url = base_url + self.timeout = timeout self._token: Optional[str] = None self._token_expires: Optional[float] = None - # Try to get initial token (will auto-register if needed) - self._get_token() - - # Determine region from coordinates if location is provided - if location is not None: - self._region = self._get_region_from_location(location) - else: - # region is guaranteed to be not None due to validation above - assert region is not None - self._region = region - - # Initialize parent class (starts background polling) + self._region = region + self._location = location super().__init__(update_interval=update_interval, timeout=timeout) + def _initialize(self) -> None: + self._get_token() + if self._location is not None: + self._region = self._get_region_from_location(self._location) + def __repr__(self): """Returns a string representation for the Vessim viewer.""" + if self._location is not None: + return f"WatttimeSignal(location={self._location})" return f"WatttimeSignal(region={self._region})" def _get_token(self) -> str: @@ -529,7 +618,7 @@ def _get_token(self) -> str: # Try to get new token login_url = f"{self._base_url}/login" try: - response = self._requests.get(login_url, auth=self._auth) + response = self._requests.get(login_url, auth=self._auth, timeout=self.timeout) response.raise_for_status() self._token = response.json()["token"] @@ -543,7 +632,7 @@ def _get_token(self) -> str: # Login failed, try to register self._register_user() # Retry login after registration - response = self._requests.get(login_url, auth=self._auth) + response = self._requests.get(login_url, auth=self._auth, timeout=self.timeout) response.raise_for_status() self._token = response.json()["token"] @@ -580,7 +669,7 @@ def _register_user(self) -> None: "email": email, } - response = self._requests.post(register_url, json=registration_data) + response = self._requests.post(register_url, json=registration_data, timeout=self.timeout) response.raise_for_status() print("✓ Registration successful!") @@ -595,7 +684,9 @@ def _get_region_from_location(self, location: tuple[float, float]) -> str: "signal_type": "co2_moer", } - response = self._requests.get(region_url, headers=headers, params=params) + response = self._requests.get( + region_url, headers=headers, params=params, timeout=self.timeout + ) response.raise_for_status() data = response.json() diff --git a/viewer/README.md b/viewer/README.md index 0c2ec2e0..7fa30f60 100644 --- a/viewer/README.md +++ b/viewer/README.md @@ -1,32 +1,173 @@ # Vessim Viewer -Browser-based experiment viewer for Vessim simulation results. Reads `metadata.yaml` and `timeseries.csv` from a results directory. No running simulation required. +One interface for the local CLI, exported reports and the documentation gallery. +Python defines and runs scenarios. The viewer analyzes completed simulation results; +it does not monitor live systems, execute scenarios, or implement an optimizer. -## Usage +## Open results -The easiest way is via the CLI: +```sh +vessim view results/study.json +vessim view results/run +vessim view results/collection +vessim view results/studies +vessim view results/study.json --export report +python -m http.server --directory report +``` + +The input can be an experiment JSON/directory, a directory of experiment directories, +a grouped study JSON/directory, or a directory of study directories. When a directory +contains `study.json`, its explicit grouping takes precedence over discovery. A collection +of experiments gives each run its own configuration; grouping is never guessed from +parameters. A collection of studies enables the study selector. + +Only completed runs appear in the analysis. Invalid, failed, interrupted and missing +records produce data issues without preventing valid runs from loading. Missing +recordings are reported when charts are opened; summary-only results are supported. + +## Formats and reference origins + +`vessim.experiment` **v1** is the canonical experiment record. The only supported study +format is `vessim.study` **v2**, a thin index: -```bash -vessim view results/my_experiment +```json +{ + "format": "vessim.study", + "schema_version": 2, + "study_id": "dispatch", + "name": "Dispatch comparison", + "run_parameters": ["seed"], + "configurations": [ + { + "configuration_id": "self-consumption", + "name": "Self consumption", + "runs": [ + {"path": "runs/seed-41/experiment.json"}, + {"path": "runs/seed-42/experiment.json"} + ] + } + ] +} ``` -This serves the pre-built viewer and opens it in your browser. Alternatively, open the viewer directly (`npm run dev`) and use the file picker to load a results directory. +Names and descriptions are optional; IDs are stable, nonempty strings. Run IDs must +be unique within a study, and configuration IDs must be unique. Study references are +relative to the study JSON, including references to experiments outside its directory. +CSV references are relative to the experiment JSON and must stay within its directory. +The server exposes only registered summaries and trajectories, not arbitrary input files. +The experiment schema supports a CSV path or null, not inline trajectory arrays. + +`run_parameters` identifies realization inputs such as seeds; remaining inputs shared +within a configuration are derived from the experiment records. No inputs or result +metrics are copied into the study index. Summary metrics, units, descriptions and +assumptions remain authoritative. There is no support for the older embedded-study +format or the retired viewer. + +For large summary-only studies, a configuration may use `run_batch` instead of `runs`. +The referenced `vessim.viewer.run-batch` v1 JSON contains `shared` experiment fields and +an array of `runs` overriding those fields. Parameters are merged by key. Each expanded +record is validated as a canonical experiment; execution, metrics and definitions are +preserved. Batch references are relative to the study; any batch recording references +are relative to the batch. + +## Analysis workflow + +- **Results:** searchable, sortable, paginated table; selected-first sorting; column + selection/reordering and display units; selection-linked plots; selected/filtered CSV. +- **Repeated runs:** individual runs or configuration summaries, selectable statistic + and range/IQR. Distribution, trade-off and empirical-distribution plots; optional faint + runs and a selectable Pareto front. Fronts describe the displayed metrics and selected + minimize/maximize directions; they are not an optimization engine. + With the front enabled, **Select Pareto front** replaces the current selection with + its configurations or runs, using all filtered results. +- **Details:** configuration inputs, realization inputs, grouped metrics with recorded + descriptions and assumptions, actor metrics, and simulation settings. +- **Time series:** select runs/configurations, overlay compatible signals, choose + elapsed or UTC alignment, reorder charts, customize colors, and synchronize zoom. + +Single-microgrid studies combine run and microgrid metrics under Energy, Cost, Carbon, +then Other results. Multiple microgrids retain Whole-system results and each microgrid's +results. These headings do not change recorded scope or units. Inputs shared by every run appear under Fixed inputs. +Varying realization inputs remain Run inputs. Integer inputs have no decimals; +metric columns normally have two, with extra precision for tiny quantities. Decimal +alignment is consistent, missing values are not zero, and exports retain full precision. +Currency is recorded per metric; the viewer does not perform currency conversion. -## Development +Only Results and Time series are navigation destinations. There is no separate Compare +page, baseline role, scenario editor, or live dashboard. Prometheus/Grafana handle live +monitoring; Optuna handles optimization and its search history. Hydra is a project choice. -```bash -cd viewer -npm install -npm run dev # Start dev server with hot reload -npm run build # Production build → dist/ +## Performance and limits + +Catalog discovery reads experiment summaries without opening CSVs. The browser fetches +only the active study's summaries. Tables render 50 rows per page. Large scatter plots +show at most 10,000 sampled points; the table and CSV retain all runs. Configuration +plots show at most 50 configurations; empirical distributions show at most 12. + +CSV parsing takes place in a worker. Signals through 10,000 samples are retained in full; +larger signals use extrema-preserving reduction. Zoom does not fetch finer samples. +Repeated-run trajectory summaries require identical timestamps after alignment and +unreduced recordings; no interpolation or missing-value imputation occurs. Bands are +observed ranges/IQR, not confidence intervals. Pointwise aggregate curves may not be +physically realizable runs. Outcome summaries always use recorded metrics. + +The catalog currently indexes all supplied studies in server memory; it is not a +persistent query service. The browser also holds all summaries for the active study. + +## Development and validation + +```sh +npm --prefix viewer ci +npm --prefix viewer run build +npm --prefix viewer run lint +npm --prefix viewer test +.venv/bin/python -m pytest tests/test_viewer.py tests/test_docs_viewer.py +.venv/bin/python viewer/tests/prepare.py +cd viewer && npx playwright test ``` -After building, copy the output into the Python package: +`src/` contains the React interface and the independent analysis modules. +`components/controls.jsx` defines shared selects, multi-choice menus, and unit controls. +Field selectors and column menus use the same ordering from `columnGroups()`. +`Chart` manages ECharts instances and their cleanup, while plot calculations stay outside React. +`view-link.js` owns the versioned sharing format and validates links against the loaded study. +View changes replace the current URL, while page and study navigation add history entries. +Keep its v1 field names stable and retain the saved-link regression tests when changing view state. -```bash -rm -rf ../vessim/_viewer_dist && cp -r dist ../vessim/_viewer_dist +`build.mjs` uses Vite to bundle React and the recording worker with relative asset URLs. +ECharts and dependency licenses are included in `vessim/_viewer_dist` alongside the bundle. +The same files ship in the Python wheel, exported reports, and documentation gallery. +Users do not need Node installed. +Rebuild after frontend edits, then run `vessim view`. + +Browser tests start CLI and static-export servers on ports 8721/8722 and exercise both +interfaces, repeated runs, multi-microgrid groups, missing recordings and 10,000 summary +runs. `tests/prepare.py` creates a deterministic fixture in `results/viewer-release`. +Screenshots and timings are recorded there. Python tests cover reference origins, +partial data, duplicate identities, batches and exports. + +## Recorded examples and documentation + +```sh +python examples/build_gallery.py --output results/gallery +python viewer/export_docs.py --source results/gallery +python -m http.server --directory docs ``` -## Stack +The builder runs the four published examples through their normal command-line interfaces. +It uses a fresh output directory, a fixed seed, and 100 sequential Optuna trials by default. +It never reuses old recordings or resumes an existing search. +Each example writes its own study index alongside the recorded runs. + +`make docs-viewer` builds the frontend, generates the examples in a temporary directory, and exports the gallery. +The exporter validates every study and recording before replacing generated documentation output. +The gallery contains Getting started, Exploring microgrid configurations, Optuna search, and Probabilistic sampling. + +## Initial display -React, TypeScript, Vite, Tailwind CSS, Recharts. YAML/CSV parsing happens client-side via js-yaml and PapaParse. +The results table starts with up to four varying configuration inputs and three metrics. +Fixed inputs remain available in the column picker and run details. +Metrics prefer varying values, whole-system scope, and a mix of Energy, Cost, Carbon, and other results. +The first two selected metrics become the plot axes. +Column menus use category and alphabetical order, with no study-specific rules. +Saved URLs override these defaults. diff --git a/viewer/benchmark.mjs b/viewer/benchmark.mjs new file mode 100644 index 00000000..dba51567 --- /dev/null +++ b/viewer/benchmark.mjs @@ -0,0 +1,30 @@ +// Isolated indexing benchmark, not a browser or network benchmark. +// Run: node viewer/benchmark.mjs [configuration-count] +import { indexStudy } from './src/model.js' + +const count = Number(process.argv[2] || 10000) +if (!Number.isSafeInteger(count) || count < 1) throw Error('Count must be a positive integer') +const fixture = { + configurations: Array.from({ length: count }, (_, i) => ({ + configuration_id: `c-${i}`, + runs: [{ record: { + run_id: `r-${i}`, + parameters: { size: i, seed: 42 }, + execution: { status: 'completed' }, + metrics: { run: { cost: i * 7, emissions: count - i }, microgrids: {}, actors: {} }, + metric_definitions: { + run: { cost: { unit: 'EUR' }, emissions: { unit: 'kgCO2e' } }, + microgrids: {}, actors: {}, + }, + } }], + })), +} +const milliseconds = [] +for (let iteration = 0; iteration < 4; iteration++) { + const start = performance.now() + const result = indexStudy(fixture) + milliseconds.push(Math.round(performance.now() - start)) + if (result.runs.length !== count || result.configurations.some(c => c.runs.length !== 1)) + throw Error('Unexpected indexed membership') +} +console.log(JSON.stringify({ configurations: count, runs: count, milliseconds })) diff --git a/viewer/build.mjs b/viewer/build.mjs new file mode 100644 index 00000000..81bd7216 --- /dev/null +++ b/viewer/build.mjs @@ -0,0 +1,47 @@ +// One static bundle for the CLI, exported reports and documentation, at any URL prefix. +import { build } from 'vite' +import react from '@vitejs/plugin-react' +import { cp, mkdir, readdir, mkdtemp, rm } from 'node:fs/promises' +import { fileURLToPath } from 'node:url' +import { tmpdir } from 'node:os' +import path from 'node:path' +const root = path.dirname(fileURLToPath(import.meta.url)) +const output = path.join(root, '../vessim/_viewer_dist') +const publicDir = await mkdtemp(path.join(tmpdir(), 'vessim-viewer-')) +try { + await mkdir(path.join(publicDir, 'vendor')) + for (const [packageName, filename] of [ + ['echarts', 'dist/echarts.min.js'], + ['papaparse', 'papaparse.min.js'], + ]) { + const directory = path.join(root, 'node_modules', packageName) + await cp( + path.join(directory, filename), + path.join(publicDir, 'vendor', path.basename(filename)), + ) + const license = (await readdir(directory)).find((name) => + /^LICENSE(\.txt)?$/i.test(name), + ) + if (license) + await cp( + path.join(directory, license), + path.join(publicDir, 'vendor', `${packageName}-LICENSE.txt`), + ) + } + for (const name of ['react', 'react-dom']) + await cp( + path.join(root, 'node_modules', name, 'LICENSE'), + path.join(publicDir, 'vendor', `${name}-LICENSE.txt`), + ) + await build({ + configFile: false, + root: path.join(root, 'src'), + base: './', + publicDir, + plugins: [react()], + build: { outDir: output, emptyOutDir: true }, + }) +} finally { + await rm(publicDir, { recursive: true, force: true }) +} +console.log(`Built Vessim Viewer → ${output}`) diff --git a/viewer/eslint.config.js b/viewer/eslint.config.js index 5e6b472f..cc186f41 100644 --- a/viewer/eslint.config.js +++ b/viewer/eslint.config.js @@ -1,23 +1,32 @@ import js from '@eslint/js' import globals from 'globals' -import reactHooks from 'eslint-plugin-react-hooks' -import reactRefresh from 'eslint-plugin-react-refresh' -import tseslint from 'typescript-eslint' -import { defineConfig, globalIgnores } from 'eslint/config' - -export default defineConfig([ - globalIgnores(['dist']), +import react from 'eslint-plugin-react' +import hooks from 'eslint-plugin-react-hooks' +export default [ { - files: ['**/*.{ts,tsx}'], - extends: [ - js.configs.recommended, - tseslint.configs.recommended, - reactHooks.configs.flat.recommended, - reactRefresh.configs.vite, + ignores: [ + 'node_modules/**', + 'test-results/**', + 'playwright-report/**', + 'design/**', ], + }, + { + files: ['src/**/*.{js,jsx}'], languageOptions: { - ecmaVersion: 2020, - globals: globals.browser, + ecmaVersion: 'latest', + sourceType: 'module', + parserOptions: { ecmaFeatures: { jsx: true } }, + globals: { ...globals.browser, ...globals.worker }, + }, + plugins: { react, 'react-hooks': hooks }, + rules: { + ...js.configs.recommended.rules, + 'react/jsx-uses-vars': 'error', + 'react/jsx-key': 'error', + 'react-hooks/rules-of-hooks': 'error', + 'react-hooks/exhaustive-deps': 'warn', + 'no-unused-vars': ['error', { argsIgnorePattern: '^_' }], }, }, -]) +] diff --git a/viewer/export_docs.py b/viewer/export_docs.py new file mode 100644 index 00000000..e0579c2a --- /dev/null +++ b/viewer/export_docs.py @@ -0,0 +1,78 @@ +"""Build and export the examples with the same viewer used by vessim view.""" + +import argparse +import json +from pathlib import Path +import shutil +import subprocess +import sys +from tempfile import TemporaryDirectory + +from vessim._viewer import Catalog + +ROOT = Path(__file__).resolve().parents[1] +STUDIES = ( + "getting-started", + "infrastructure-year", + "optuna-search", + "dispatch-comparison", +) + + +def export_gallery(destination: Path, source: Path, root: Path = ROOT) -> None: + """Validate all published examples before replacing the generated docs gallery.""" + assets = root / "vessim/_viewer_dist" + if not (assets / "index.html").is_file(): + raise ValueError("Build the viewer first: npm --prefix viewer run build") + indexes = [source / identity / "study.json" for identity in STUDIES] + catalog = Catalog(source, study_paths=indexes) + if catalog.errors: + raise ValueError("Cannot publish incomplete examples: " + "; ".join(catalog.errors)) + for run in catalog.runs: + run["record"]["config"] = json.loads( + json.dumps(run["record"]["config"]).replace(f"{root.as_posix()}/", "") + ) + with TemporaryDirectory() as temporary: + bundle = Path(temporary) / "viewer" + catalog.export(bundle, assets) + exported = json.loads((bundle / "catalog.json").read_text()) + if exported["errors"]: + raise ValueError("Cannot publish missing recordings: " + "; ".join(exported["errors"])) + if destination.exists(): + shutil.rmtree(destination) + shutil.copytree(bundle, destination) + print(f"Exported {len(catalog.studies)} studies and {len(catalog.runs)} runs to {destination}") + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--output", type=Path, default=ROOT / "docs/viewer") + parser.add_argument("--source", type=Path, help="Export an already generated example gallery") + parser.add_argument("--trials", type=int, default=100) + parser.add_argument("--seed", type=int, default=42) + args = parser.parse_args() + if args.output.exists() and args.output.resolve() != (ROOT / "docs/viewer").resolve(): + parser.error("Custom output directories must not already exist.") + if args.source: + export_gallery(args.output, args.source) + else: + with TemporaryDirectory() as temporary: + source = Path(temporary) / "gallery" + subprocess.run( + [ + sys.executable, + str(ROOT / "examples/build_gallery.py"), + "--output", + str(source), + "--trials", + str(args.trials), + "--seed", + str(args.seed), + ], + check=True, + ) + export_gallery(args.output, source) + + +if __name__ == "__main__": + main() diff --git a/viewer/index.html b/viewer/index.html deleted file mode 100644 index 984e31dc..00000000 --- a/viewer/index.html +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - dashboard - - -
- - - diff --git a/viewer/package-lock.json b/viewer/package-lock.json index 9c3d7e04..0cbf7fb2 100644 --- a/viewer/package-lock.json +++ b/viewer/package-lock.json @@ -1,47 +1,38 @@ { - "name": "dashboard", + "name": "vessim-viewer", "version": "0.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "dashboard", + "name": "vessim-viewer", "version": "0.0.0", "dependencies": { "echarts": "^5.6.0", - "echarts-for-react": "^3.0.2", - "js-yaml": "^4.3.1", "papaparse": "^5.5.3", - "react": "^19.2.4", - "react-dom": "^19.2.4" + "react": "^19.3.1", + "react-dom": "^19.3.1" }, "devDependencies": { "@eslint/js": "^9.39.4", - "@tailwindcss/vite": "^4.2.2", - "@types/js-yaml": "^4.0.9", - "@types/node": "^24.12.0", - "@types/papaparse": "^5.5.2", - "@types/react": "^19.2.14", - "@types/react-dom": "^19.2.3", - "@vitejs/plugin-react": "^6.0.1", + "@playwright/test": "^1.64.1", + "@vitejs/plugin-react": "^6.1.2", "eslint": "^9.39.4", - "eslint-plugin-react-hooks": "^7.0.1", - "eslint-plugin-react-refresh": "^0.5.2", + "eslint-plugin-react": "^7.37.5", + "eslint-plugin-react-hooks": "^7.1.1", "globals": "^17.4.0", - "tailwindcss": "^4.2.2", - "typescript": "~5.9.3", - "typescript-eslint": "^8.57.0", - "vite": "^8.0.1" + "prettier": "^3.9.10", + "vite": "^8.3.4" } }, "node_modules/@babel/code-frame": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", - "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.28.5", + "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" }, @@ -50,9 +41,9 @@ } }, "node_modules/@babel/compat-data": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", - "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", "dev": true, "license": "MIT", "engines": { @@ -60,21 +51,21 @@ } }, "node_modules/@babel/core": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", - "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.29.0", - "@babel/generator": "^7.29.0", - "@babel/helper-compilation-targets": "^7.28.6", - "@babel/helper-module-transforms": "^7.28.6", - "@babel/helpers": "^7.28.6", - "@babel/parser": "^7.29.0", - "@babel/template": "^7.28.6", - "@babel/traverse": "^7.29.0", - "@babel/types": "^7.29.0", + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", @@ -91,14 +82,14 @@ } }, "node_modules/@babel/generator": { - "version": "7.29.1", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", - "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.29.0", - "@babel/types": "^7.29.0", + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" @@ -108,14 +99,14 @@ } }, "node_modules/@babel/helper-compilation-targets": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", - "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", "dev": true, "license": "MIT", "dependencies": { - "@babel/compat-data": "^7.28.6", - "@babel/helper-validator-option": "^7.27.1", + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" @@ -125,9 +116,9 @@ } }, "node_modules/@babel/helper-globals": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", - "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", "dev": true, "license": "MIT", "engines": { @@ -135,29 +126,29 @@ } }, "node_modules/@babel/helper-module-imports": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", - "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", "dev": true, "license": "MIT", "dependencies": { - "@babel/traverse": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-transforms": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", - "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-module-imports": "^7.28.6", - "@babel/helper-validator-identifier": "^7.28.5", - "@babel/traverse": "^7.28.6" + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -167,9 +158,9 @@ } }, "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", "dev": true, "license": "MIT", "engines": { @@ -177,9 +168,9 @@ } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", "dev": true, "license": "MIT", "engines": { @@ -187,9 +178,9 @@ } }, "node_modules/@babel/helper-validator-option": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", - "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", "dev": true, "license": "MIT", "engines": { @@ -197,27 +188,27 @@ } }, "node_modules/@babel/helpers": { - "version": "7.29.2", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", - "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "version": "7.29.10", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.10.tgz", + "integrity": "sha512-EMCx3AGZ3WfAl6ht7AyQoPm2Lh+BGYEMdc4Q6Hf7uSCDOZFuLq44qNwL+iaim8VlyYhRAo4X43+weadK8ZB4Yg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/template": "^7.28.6", - "@babel/types": "^7.29.0" + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.29.2", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", - "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "version": "7.29.9", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.9.tgz", + "integrity": "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.29.0" + "@babel/types": "^7.29.8" }, "bin": { "parser": "bin/babel-parser.js" @@ -227,33 +218,33 @@ } }, "node_modules/@babel/template": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", - "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.28.6", - "@babel/parser": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/traverse": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", - "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "version": "7.29.10", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.10.tgz", + "integrity": "sha512-B3lYAs2nkKskbgQ1nfy8Ma9ao9Q1rntTAQHrzzyDuj+TSO+X3le6PfWwqDvodsb+OumE10eMFfuMCTQSQ5iuIQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.29.0", - "@babel/generator": "^7.29.0", - "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.29.0", - "@babel/template": "^7.28.6", - "@babel/types": "^7.29.0", + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.9", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", "debug": "^4.3.1" }, "engines": { @@ -261,53 +252,19 @@ } }, "node_modules/@babel/types": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", - "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, - "node_modules/@emnapi/core": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", - "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.1", - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/runtime": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", - "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/wasi-threads": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", - "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@eslint-community/eslint-utils": { "version": "4.9.1", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", @@ -550,9 +507,9 @@ } }, "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", "dev": true, "license": "MIT" }, @@ -567,39 +524,53 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, - "node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", - "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "node_modules/@oxc-project/types": { + "version": "0.154.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.154.0.tgz", + "integrity": "sha512-KYOUGQFlLWR+s4QMV2Q9ko38TZm7EdO87Qm7rvZLqk/webse/cDjtyxKhg/47l0byCbs/k46LT8qc4PKjAikzA==", "dev": true, "license": "MIT", - "optional": true, + "funding": { + "url": "https://github.com/sponsors/oxc-project" + } + }, + "node_modules/@playwright/test": { + "version": "1.64.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.64.1.tgz", + "integrity": "sha512-IaYGml/nSOiZWROfPtuIL7neuu9qDOZ3TNvNljUIrhDMxiGTgPjzj/g9p9RxpS2tSepPDRY+UwAaEeiKtUvIAg==", + "dev": true, + "license": "Apache-2.0", "dependencies": { - "@tybys/wasm-util": "^0.10.1" + "playwright": "1.64.1" }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" + "bin": { + "playwright": "cli.js" }, - "peerDependencies": { - "@emnapi/core": "^1.7.1", - "@emnapi/runtime": "^1.7.1" + "engines": { + "node": ">=20" } }, - "node_modules/@oxc-project/types": { - "version": "0.133.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz", - "integrity": "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==", + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.14.tgz", + "integrity": "sha512-aWR+XKH9+rW7m5fWq/5360J3mHycrx/o6xZM2DQFdJnTMdyP7/WzxPjhtLbVWdKo2y612/4U1bWqb3HQhiF7vg==", + "cpu": [ + "arm" + ], "dev": true, "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/Boshen" + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" } }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz", - "integrity": "sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==", + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.14.tgz", + "integrity": "sha512-QiVxHofl6A8lQU7WAtOUY4QMVZXohxwz7WJJoeh8NBEUyKfIkMRKhYyq8az/1RypNiIXzNTtiS6KwHz0erxYRQ==", "cpu": [ "arm64" ], @@ -614,9 +585,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.3.tgz", - "integrity": "sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==", + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.14.tgz", + "integrity": "sha512-lTQOzHuBIooh3jIr5XINQkjPGEuGy+Dsy0meXDr6Sjty0b99kYgGh4uBC/8mJX1T0kC8d6S0aPP1Y+bHkh6aBw==", "cpu": [ "arm64" ], @@ -631,9 +602,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.3.tgz", - "integrity": "sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==", + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.14.tgz", + "integrity": "sha512-hyMKEGatjmBYCs/Q4sbIcL/GqpFEqsX3wc+HYVQ/85A4rxc1o7rPm4DUI4GV4k/h0ITddwFSGpqQunI14GXjTA==", "cpu": [ "x64" ], @@ -648,9 +619,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.3.tgz", - "integrity": "sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==", + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.14.tgz", + "integrity": "sha512-u371GbXoVXrEgP20PwzoGNOdAOqAMhzo19JzzY+V4YmmnC+DTOoqdbMu+O+54emM59Mik/UdrtWgcuEDKWRPKA==", "cpu": [ "x64" ], @@ -665,9 +636,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.3.tgz", - "integrity": "sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==", + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.14.tgz", + "integrity": "sha512-BqwKsdZQf6EqVWr8fv2MrDYzAvOZ7+NYFSJESRGs87fkseEhs0TU9TXgRJTkZoRRkxTizyEg1LKUXfU3/78uqg==", "cpu": [ "arm" ], @@ -682,9 +653,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.3.tgz", - "integrity": "sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==", + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.14.tgz", + "integrity": "sha512-SD9kY8M009x/7s57Z1dgUVlP5+uYBgKQK0Tkr1WIWngrtUJmRCW1RJFMY7QDla2khXhmg2i5Io7MUjcmZNZ/KQ==", "cpu": [ "arm64" ], @@ -702,9 +673,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.3.tgz", - "integrity": "sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==", + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.14.tgz", + "integrity": "sha512-BJ2CL19M6uuWesMgnqJzgxoI+N+eV7b0A9O9vLV42y+gitC41fNa8KElau1tHVzuLWX5GzEuxMPhAO0tQXQMkg==", "cpu": [ "arm64" ], @@ -722,9 +693,9 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.3.tgz", - "integrity": "sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==", + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.14.tgz", + "integrity": "sha512-4+Kl8daPV8NDHaD7mfCcc1pRM+Qg7ab8DyMm5FG5WoZgv4h2i3KFN1CybYBNbQqmuYm7ftJLvmMI1eHYJ22S/Q==", "cpu": [ "ppc64" ], @@ -742,9 +713,9 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.3.tgz", - "integrity": "sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==", + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.14.tgz", + "integrity": "sha512-V/h8ePq1fKDrmtFMz6xcCN3DKvuvMRrwwD1q0PkBeeo4DpK64biaKvqWGVxqCeYKqeRk7gE7/iKPxLCn08O97A==", "cpu": [ "s390x" ], @@ -762,9 +733,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.3.tgz", - "integrity": "sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==", + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.14.tgz", + "integrity": "sha512-WxalXzgrLVpKC93xfpaJ3gCXUWzeCr+FRCbDVD2mUAy4L6Vo3lNVz0epUEUpxPWJkLNYUwM4OSIikv36OJ9mLg==", "cpu": [ "x64" ], @@ -782,9 +753,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.3.tgz", - "integrity": "sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==", + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.14.tgz", + "integrity": "sha512-3G3tnAmTpdk9jO3GQtyHXqeFD2SlecfTws/Y3M2mmj9nMBwwuLT4kur8sZKeu7ENUHSpVO3cIsm5A52LEWWwlg==", "cpu": [ "x64" ], @@ -802,9 +773,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.3.tgz", - "integrity": "sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==", + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.14.tgz", + "integrity": "sha512-qyr5XiNGxm2ytx17vvtcJ9nXn6v1gENETi503bJx3OGwZ1tSonj7BosxbMa/xeAfSl5iS9X6KjEWYXLu6v1L7Q==", "cpu": [ "arm64" ], @@ -818,29 +789,10 @@ "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.3.tgz", - "integrity": "sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==", - "cpu": [ - "wasm32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/core": "1.10.0", - "@emnapi/runtime": "1.10.0", - "@napi-rs/wasm-runtime": "^1.1.4" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.3.tgz", - "integrity": "sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==", + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.14.tgz", + "integrity": "sha512-oA4o5o0S2yB199YyYXDzKUv1Z56Rc+mgOjwm7Z/vJ9Z8igiDPYvoOuDyo6g7AK/ZaF09nPhDKVnGBcYHO11GXA==", "cpu": [ "arm64" ], @@ -855,9 +807,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.3.tgz", - "integrity": "sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==", + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.14.tgz", + "integrity": "sha512-9mE94bf2GZUaR/fUSXCMpBoWkctu421Mi0ORpm4zqfbkjKep7RxzPFa5etcGs5h+2KApoWBOA/hAJa/7saGgEQ==", "cpu": [ "x64" ], @@ -872,992 +824,868 @@ } }, "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-rc.7", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.7.tgz", - "integrity": "sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", "dev": true, "license": "MIT" }, - "node_modules/@tailwindcss/node": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.2.tgz", - "integrity": "sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA==", + "node_modules/@vitejs/plugin-react": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.2.tgz", + "integrity": "sha512-fhJNGUdscQa4HkI0xFxwonWyhnVReCPDbXI7FH7jmzpnVCrFQI7PRrhWecO263610vOrs9tr2Y4xcf8uAdFxbw==", "dev": true, "license": "MIT", "dependencies": { - "@jridgewell/remapping": "^2.3.5", - "enhanced-resolve": "^5.19.0", - "jiti": "^2.6.1", - "lightningcss": "1.32.0", - "magic-string": "^0.30.21", - "source-map-js": "^1.2.1", - "tailwindcss": "4.2.2" + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "oxc-transform-react": "^0.152.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "oxc-transform-react": { + "optional": true + } } }, - "node_modules/@tailwindcss/oxide": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.2.tgz", - "integrity": "sha512-qEUA07+E5kehxYp9BVMpq9E8vnJuBHfJEC0vPC5e7iL/hw7HR61aDKoVoKzrG+QKp56vhNZe4qwkRmMC0zDLvg==", + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", - "engines": { - "node": ">= 20" + "bin": { + "acorn": "bin/acorn" }, - "optionalDependencies": { - "@tailwindcss/oxide-android-arm64": "4.2.2", - "@tailwindcss/oxide-darwin-arm64": "4.2.2", - "@tailwindcss/oxide-darwin-x64": "4.2.2", - "@tailwindcss/oxide-freebsd-x64": "4.2.2", - "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.2", - "@tailwindcss/oxide-linux-arm64-gnu": "4.2.2", - "@tailwindcss/oxide-linux-arm64-musl": "4.2.2", - "@tailwindcss/oxide-linux-x64-gnu": "4.2.2", - "@tailwindcss/oxide-linux-x64-musl": "4.2.2", - "@tailwindcss/oxide-wasm32-wasi": "4.2.2", - "@tailwindcss/oxide-win32-arm64-msvc": "4.2.2", - "@tailwindcss/oxide-win32-x64-msvc": "4.2.2" - } - }, - "node_modules/@tailwindcss/oxide-android-arm64": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.2.tgz", - "integrity": "sha512-dXGR1n+P3B6748jZO/SvHZq7qBOqqzQ+yFrXpoOWWALWndF9MoSKAT3Q0fYgAzYzGhxNYOoysRvYlpixRBBoDg==", - "cpu": [ - "arm64" - ], + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">= 20" + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, - "node_modules/@tailwindcss/oxide-darwin-arm64": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.2.tgz", - "integrity": "sha512-iq9Qjr6knfMpZHj55/37ouZeykwbDqF21gPFtfnhCCKGDcPI/21FKC9XdMO/XyBM7qKORx6UIhGgg6jLl7BZlg==", - "cpu": [ - "arm64" - ], + "node_modules/ajv": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 20" + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" } }, - "node_modules/@tailwindcss/oxide-darwin-x64": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.2.tgz", - "integrity": "sha512-BlR+2c3nzc8f2G639LpL89YY4bdcIdUmiOOkv2GQv4/4M0vJlpXEa0JXNHhCHU7VWOKWT/CjqHdTP8aUuDJkuw==", - "cpu": [ - "x64" - ], + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], + "dependencies": { + "color-convert": "^2.0.1" + }, "engines": { - "node": ">= 20" + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/@tailwindcss/oxide-freebsd-x64": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.2.tgz", - "integrity": "sha512-YUqUgrGMSu2CDO82hzlQ5qSb5xmx3RUrke/QgnoEx7KvmRJHQuZHZmZTLSuuHwFf0DJPybFMXMYf+WJdxHy/nQ==", - "cpu": [ - "x64" - ], + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/array-buffer-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", + "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], + "dependencies": { + "call-bound": "^1.0.3", + "is-array-buffer": "^3.0.5" + }, "engines": { - "node": ">= 20" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.2.tgz", - "integrity": "sha512-FPdhvsW6g06T9BWT0qTwiVZYE2WIFo2dY5aCSpjG/S/u1tby+wXoslXS0kl3/KXnULlLr1E3NPRRw0g7t2kgaQ==", - "cpu": [ - "arm" - ], + "node_modules/array-includes": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.2.0.tgz", + "integrity": "sha512-VXY5eFRarnXcYxwBjJzPmEhH55+rmP79/+ueDhi0F+TuqfHCItagIHqxeUZrmgrOPa31QTh9H85DjX3FfJ0FTg==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.2", + "es-object-atoms": "^1.1.2", + "es-shim-unscopables": "^1.1.0", + "is-string": "^1.1.1", + "math-intrinsics": "^1.1.0" + }, "engines": { - "node": ">= 20" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.2.tgz", - "integrity": "sha512-4og1V+ftEPXGttOO7eCmW7VICmzzJWgMx+QXAJRAhjrSjumCwWqMfkDrNu1LXEQzNAwz28NCUpucgQPrR4S2yw==", - "cpu": [ - "arm64" - ], + "node_modules/array.prototype.findlast": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/array.prototype.findlast/-/array.prototype.findlast-1.2.5.tgz", + "integrity": "sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "es-shim-unscopables": "^1.0.2" + }, "engines": { - "node": ">= 20" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/@tailwindcss/oxide-linux-arm64-musl": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.2.tgz", - "integrity": "sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag==", - "cpu": [ - "arm64" - ], + "node_modules/array.prototype.flat": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.3.tgz", + "integrity": "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, "engines": { - "node": ">= 20" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/@tailwindcss/oxide-linux-x64-gnu": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.2.tgz", - "integrity": "sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg==", - "cpu": [ - "x64" - ], + "node_modules/array.prototype.flatmap": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.3.tgz", + "integrity": "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, "engines": { - "node": ">= 20" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/@tailwindcss/oxide-linux-x64-musl": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.2.tgz", - "integrity": "sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ==", - "cpu": [ - "x64" - ], + "node_modules/array.prototype.tosorted": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/array.prototype.tosorted/-/array.prototype.tosorted-1.1.4.tgz", + "integrity": "sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.3", + "es-errors": "^1.3.0", + "es-shim-unscopables": "^1.0.2" + }, "engines": { - "node": ">= 20" + "node": ">= 0.4" } }, - "node_modules/@tailwindcss/oxide-wasm32-wasi": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.2.tgz", - "integrity": "sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q==", - "bundleDependencies": [ - "@napi-rs/wasm-runtime", - "@emnapi/core", - "@emnapi/runtime", - "@tybys/wasm-util", - "@emnapi/wasi-threads", - "tslib" - ], - "cpu": [ - "wasm32" - ], + "node_modules/arraybuffer.prototype.slice": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", + "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", "dev": true, "license": "MIT", - "optional": true, "dependencies": { - "@emnapi/core": "^1.8.1", - "@emnapi/runtime": "^1.8.1", - "@emnapi/wasi-threads": "^1.1.0", - "@napi-rs/wasm-runtime": "^1.1.1", - "@tybys/wasm-util": "^0.10.1", - "tslib": "^2.8.1" + "array-buffer-byte-length": "^1.0.1", + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "is-array-buffer": "^3.0.4" }, "engines": { - "node": ">=14.0.0" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.2.tgz", - "integrity": "sha512-qPmaQM4iKu5mxpsrWZMOZRgZv1tOZpUm+zdhhQP0VhJfyGGO3aUKdbh3gDZc/dPLQwW4eSqWGrrcWNBZWUWaXQ==", - "cpu": [ - "arm64" - ], + "node_modules/async-function": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", + "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-win32-x64-msvc": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.2.tgz", - "integrity": "sha512-1T/37VvI7WyH66b+vqHj/cLwnCxt7Qt3WFu5Q8hk65aOvlwAhs7rAp1VkulBJw/N4tMirXjVnylTR72uI0HGcA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], "engines": { - "node": ">= 20" + "node": ">= 0.4" } }, - "node_modules/@tailwindcss/vite": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.2.2.tgz", - "integrity": "sha512-mEiF5HO1QqCLXoNEfXVA1Tzo+cYsrqV7w9Juj2wdUFyW07JRenqMG225MvPwr3ZD9N1bFQj46X7r33iHxLUW0w==", + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", "dev": true, "license": "MIT", "dependencies": { - "@tailwindcss/node": "4.2.2", - "@tailwindcss/oxide": "4.2.2", - "tailwindcss": "4.2.2" + "possible-typed-array-names": "^1.0.0" }, - "peerDependencies": { - "vite": "^5.2.0 || ^6 || ^7 || ^8" - } - }, - "node_modules/@tybys/wasm-util": { - "version": "0.10.1", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", - "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/js-yaml": { - "version": "4.0.9", - "resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz", - "integrity": "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==", + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "dev": true, "license": "MIT" }, - "node_modules/@types/json-schema": { - "version": "7.0.15", - "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", - "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "node_modules/baseline-browser-mapping": { + "version": "2.11.29", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.29.tgz", + "integrity": "sha512-YeesxhUmR4h5y5V8KF8iBCqJb8YFrBJ++XBTeGcWIaQOYXGN19JM0FoiOjAhwuDoln8owMgXcWBmgHtYSkky2w==", "dev": true, - "license": "MIT" + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } }, - "node_modules/@types/node": { - "version": "24.12.2", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.2.tgz", - "integrity": "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==", + "node_modules/brace-expansion": { + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~7.16.0" + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" } }, - "node_modules/@types/papaparse": { - "version": "5.5.2", - "resolved": "https://registry.npmjs.org/@types/papaparse/-/papaparse-5.5.2.tgz", - "integrity": "sha512-gFnFp/JMzLHCwRf7tQHrNnfhN4eYBVYYI897CGX4MY1tzY9l2aLkVyx2IlKZ/SAqDbB3I1AOZW5gTMGGsqWliA==", + "node_modules/browserslist": { + "version": "4.29.3", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.29.3.tgz", + "integrity": "sha512-1R4kiYKXGViqEN0CnoDrXc1StD9niAwu+j2dukWzrD4bJgsD4lDmEp0CRbc6E/vYJIfTHwPmwyaKtVSudICdPA==", "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], "license": "MIT", "dependencies": { - "@types/node": "*" + "baseline-browser-mapping": "^2.11.26", + "caniuse-lite": "^1.0.30001813", + "electron-to-chromium": "^1.5.439", + "node-releases": "^2.0.57", + "update-browserslist-db": "^1.3.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, - "node_modules/@types/react": { - "version": "19.2.14", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", - "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", + "node_modules/call-bind": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", + "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==", "dev": true, "license": "MIT", "dependencies": { - "csstype": "^3.2.2" + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "get-intrinsic": "^1.3.0", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/@types/react-dom": { - "version": "19.2.3", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", - "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", "dev": true, "license": "MIT", - "peerDependencies": { - "@types/react": "^19.2.0" + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" } }, - "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.58.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.58.0.tgz", - "integrity": "sha512-RLkVSiNuUP1C2ROIWfqX+YcUfLaSnxGE/8M+Y57lopVwg9VTYYfhuz15Yf1IzCKgZj6/rIbYTmJCUSqr76r0Wg==", + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", "dev": true, "license": "MIT", "dependencies": { - "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.58.0", - "@typescript-eslint/type-utils": "8.58.0", - "@typescript-eslint/utils": "8.58.0", - "@typescript-eslint/visitor-keys": "8.58.0", - "ignore": "^7.0.5", - "natural-compare": "^1.4.0", - "ts-api-utils": "^2.5.0" + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": ">= 0.4" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "@typescript-eslint/parser": "^8.58.0", - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.1.0" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { - "version": "7.0.5", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", - "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", "dev": true, "license": "MIT", "engines": { - "node": ">= 4" + "node": ">=6" } }, - "node_modules/@typescript-eslint/parser": { - "version": "8.58.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.58.0.tgz", - "integrity": "sha512-rLoGZIf9afaRBYsPUMtvkDWykwXwUPL60HebR4JgTI8mxfFe2cQTu3AGitANp4b9B2QlVru6WzjgB2IzJKiCSA==", + "node_modules/caniuse-lite": { + "version": "1.0.30001816", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001816.tgz", + "integrity": "sha512-+8Yx7BXCoqk6IF8yTicjfUR6D/ldJzdlsodlEOiOSPmhK844WIgk67/zIQaGAOxNkZyyvBlrRFnLvqKIoqbAFw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.58.0", - "@typescript-eslint/types": "8.58.0", - "@typescript-eslint/typescript-estree": "8.58.0", - "@typescript-eslint/visitor-keys": "8.58.0", - "debug": "^4.4.3" + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": ">=10" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.1.0" + "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/@typescript-eslint/project-service": { - "version": "8.58.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.58.0.tgz", - "integrity": "sha512-8Q/wBPWLQP1j16NxoPNIKpDZFMaxl7yWIoqXWYeWO+Bbd2mjgvoF0dxP2jKZg5+x49rgKdf7Ck473M8PC3V9lg==", + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.58.0", - "@typescript-eslint/types": "^8.58.0", - "debug": "^4.4.3" + "color-name": "~1.1.4" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.1.0" + "node": ">=7.0.0" } }, - "node_modules/@typescript-eslint/scope-manager": { - "version": "8.58.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.58.0.tgz", - "integrity": "sha512-W1Lur1oF50FxSnNdGp3Vs6P+yBRSmZiw4IIjEeYxd8UQJwhUF0gDgDD/W/Tgmh73mxgEU3qX0Bzdl/NGuSPEpQ==", + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.58.0", - "@typescript-eslint/visitor-keys": "8.58.0" + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" + "node": ">= 8" } }, - "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.58.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.58.0.tgz", - "integrity": "sha512-doNSZEVJsWEu4htiVC+PR6NpM+pa+a4ClH9INRWOWCUzMst/VA9c4gXq92F8GUD1rwhNvRLkgjfYtFXegXQF7A==", + "node_modules/data-view-buffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", + "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==", "dev": true, "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": ">= 0.4" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.1.0" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/@typescript-eslint/type-utils": { - "version": "8.58.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.58.0.tgz", - "integrity": "sha512-aGsCQImkDIqMyx1u4PrVlbi/krmDsQUs4zAcCV6M7yPcPev+RqVlndsJy9kJ8TLihW9TZ0kbDAzctpLn5o+lOg==", + "node_modules/data-view-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz", + "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.58.0", - "@typescript-eslint/typescript-estree": "8.58.0", - "@typescript-eslint/utils": "8.58.0", - "debug": "^4.4.3", - "ts-api-utils": "^2.5.0" + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": ">= 0.4" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.1.0" + "url": "https://github.com/sponsors/inspect-js" } }, - "node_modules/@typescript-eslint/types": { - "version": "8.58.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.58.0.tgz", - "integrity": "sha512-O9CjxypDT89fbHxRfETNoAnHj/i6IpRK0CvbVN3qibxlLdo5p5hcLmUuCCrHMpxiWSwKyI8mCP7qRNYuOJ0Uww==", + "node_modules/data-view-byte-offset": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", + "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==", "dev": true, "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": ">= 0.4" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.58.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.58.0.tgz", - "integrity": "sha512-7vv5UWbHqew/dvs+D3e1RvLv1v2eeZ9txRHPnEEBUgSNLx5ghdzjHa0sgLWYVKssH+lYmV0JaWdoubo0ncGYLA==", + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.58.0", - "@typescript-eslint/tsconfig-utils": "8.58.0", - "@typescript-eslint/types": "8.58.0", - "@typescript-eslint/visitor-keys": "8.58.0", - "debug": "^4.4.3", - "minimatch": "^10.2.2", - "semver": "^7.7.3", - "tinyglobby": "^0.2.15", - "ts-api-utils": "^2.5.0" + "ms": "^2.1.3" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" + "node": ">=6.0" }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.1.0" + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } } }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", "dev": true, - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } + "license": "MIT" }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "5.0.9", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", - "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", "dev": true, "license": "MIT", "dependencies": { - "balanced-match": "^4.0.2" + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" }, "engines": { - "node": "20 || >=22" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { - "version": "10.2.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", - "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", "dev": true, - "license": "BlueOak-1.0.0", + "license": "MIT", "dependencies": { - "brace-expansion": "^5.0.5" + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" }, "engines": { - "node": "18 || 20 || >=22" + "node": ">= 0.4" }, "funding": { - "url": "https://github.com/sponsors/isaacs" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, + "license": "Apache-2.0", "engines": { - "node": ">=10" + "node": ">=8" } }, - "node_modules/@typescript-eslint/utils": { - "version": "8.58.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.58.0.tgz", - "integrity": "sha512-RfeSqcFeHMHlAWzt4TBjWOAtoW9lnsAGiP3GbaX9uVgTYYrMbVnGONEfUCiSss+xMHFl+eHZiipmA8WkQ7FuNA==", + "node_modules/doctrine": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", + "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", "dev": true, - "license": "MIT", + "license": "Apache-2.0", "dependencies": { - "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.58.0", - "@typescript-eslint/types": "8.58.0", - "@typescript-eslint/typescript-estree": "8.58.0" + "esutils": "^2.0.2" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.1.0" + "node": ">=0.10.0" } }, - "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.58.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.58.0.tgz", - "integrity": "sha512-XJ9UD9+bbDo4a4epraTwG3TsNPeiB9aShrUneAVXy8q4LuwowN+qu89/6ByLMINqvIMeI9H9hOHQtg/ijrYXzQ==", + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.58.0", - "eslint-visitor-keys": "^5.0.0" + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" + "node": ">= 0.4" } }, - "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", - "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", - "dev": true, + "node_modules/echarts": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/echarts/-/echarts-5.6.0.tgz", + "integrity": "sha512-oTbVTsXfKuEhxftHqL5xprgLoc0k7uScAwtryCgWF6hPYFLRwOUHiFmHGCBKP5NPFNkDVopOieyUqYGH8Fa3kA==", "license": "Apache-2.0", - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - }, - "funding": { - "url": "https://opencollective.com/eslint" + "dependencies": { + "tslib": "2.3.0", + "zrender": "5.6.1" } }, - "node_modules/@vitejs/plugin-react": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.1.tgz", - "integrity": "sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@rolldown/pluginutils": "1.0.0-rc.7" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "peerDependencies": { - "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", - "babel-plugin-react-compiler": "^1.0.0", - "vite": "^8.0.0" - }, - "peerDependenciesMeta": { - "@rolldown/plugin-babel": { - "optional": true - }, - "babel-plugin-react-compiler": { - "optional": true - } - } - }, - "node_modules/acorn": { - "version": "8.16.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", - "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", - "dev": true, - "license": "MIT", - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } + "node_modules/echarts/node_modules/tslib": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz", + "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==", + "license": "0BSD" }, - "node_modules/acorn-jsx": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", - "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "node_modules/electron-to-chromium": { + "version": "1.5.459", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.459.tgz", + "integrity": "sha512-4SC9HHSFjO/liAhifpdOL5hG9b4BP7/5G3y3UPZVO8trlAXr1CaUEEJL0gWK3kOcYcZrOA5rkwG45Gra/lTIgg==", "dev": true, - "license": "MIT", - "peerDependencies": { - "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" - } + "license": "ISC" }, - "node_modules/ajv": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", - "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", + "node_modules/es-abstract": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.2.tgz", + "integrity": "sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg==", "dev": true, "license": "MIT", "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" + "array-buffer-byte-length": "^1.0.2", + "arraybuffer.prototype.slice": "^1.0.4", + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "data-view-buffer": "^1.0.2", + "data-view-byte-length": "^1.0.2", + "data-view-byte-offset": "^1.0.1", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-set-tostringtag": "^2.1.0", + "es-to-primitive": "^1.3.0", + "function.prototype.name": "^1.1.8", + "get-intrinsic": "^1.3.0", + "get-proto": "^1.0.1", + "get-symbol-description": "^1.1.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "internal-slot": "^1.1.0", + "is-array-buffer": "^3.0.5", + "is-callable": "^1.2.7", + "is-data-view": "^1.0.2", + "is-negative-zero": "^2.0.3", + "is-regex": "^1.2.1", + "is-set": "^2.0.3", + "is-shared-array-buffer": "^1.0.4", + "is-string": "^1.1.1", + "is-typed-array": "^1.1.15", + "is-weakref": "^1.1.1", + "math-intrinsics": "^1.1.0", + "object-inspect": "^1.13.4", + "object-keys": "^1.1.1", + "object.assign": "^4.1.7", + "own-keys": "^1.0.1", + "regexp.prototype.flags": "^1.5.4", + "safe-array-concat": "^1.1.3", + "safe-push-apply": "^1.0.0", + "safe-regex-test": "^1.1.0", + "set-proto": "^1.0.0", + "stop-iteration-iterator": "^1.1.0", + "string.prototype.trim": "^1.2.10", + "string.prototype.trimend": "^1.0.9", + "string.prototype.trimstart": "^1.0.8", + "typed-array-buffer": "^1.0.3", + "typed-array-byte-length": "^1.0.3", + "typed-array-byte-offset": "^1.0.4", + "typed-array-length": "^1.0.7", + "unbox-primitive": "^1.1.0", + "which-typed-array": "^1.1.19" + }, + "engines": { + "node": ">= 0.4" }, "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "node_modules/es-abstract-get": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/es-abstract-get/-/es-abstract-get-1.0.0.tgz", + "integrity": "sha512-6PMWXpdhshVvFp+FoWYs1EvG1Nj0tvk0dZM+XcK0xMEM1czRVcP6ohqPWHy6qPagSpC8j4+p89WXlT+xXJs/fg==", "dev": true, "license": "MIT", "dependencies": { - "color-convert": "^2.0.1" + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.2", + "is-callable": "^1.2.7", + "object-inspect": "^1.13.4" }, "engines": { - "node": ">=8" + "node": ">= 0.4" }, "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "license": "Python-2.0" - }, - "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/baseline-browser-mapping": { - "version": "2.10.14", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.14.tgz", - "integrity": "sha512-fOVLPAsFTsQfuCkvahZkzq6nf8KvGWanlYoTh0SVA0A/PIUxQGU2AOZAoD95n2gFLVDW/jP6sbGLny95nmEuHA==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "baseline-browser-mapping": "dist/cli.cjs" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/brace-expansion": { - "version": "1.1.18", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", - "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/browserslist": { - "version": "4.28.2", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", - "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], "license": "MIT", - "dependencies": { - "baseline-browser-mapping": "^2.10.12", - "caniuse-lite": "^1.0.30001782", - "electron-to-chromium": "^1.5.328", - "node-releases": "^2.0.36", - "update-browserslist-db": "^1.2.3" - }, - "bin": { - "browserslist": "cli.js" - }, "engines": { - "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + "node": ">= 0.4" } }, - "node_modules/callsites": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", - "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", "dev": true, "license": "MIT", "engines": { - "node": ">=6" + "node": ">= 0.4" } }, - "node_modules/caniuse-lite": { - "version": "1.0.30001784", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001784.tgz", - "integrity": "sha512-WU346nBTklUV9YfUl60fqRbU5ZqyXlqvo1SgigE1OAXK5bFL8LL9q1K7aap3N739l4BvNqnkm3YrGHiY9sfUQw==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/caniuse-lite" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "CC-BY-4.0" - }, - "node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "node_modules/es-iterator-helpers": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.4.0.tgz", + "integrity": "sha512-c/A0P0oxkACDc+cKWw8evLXK83oBKgn0qPOqCYT4x9uolpCIJAcYvJC9QYKNDRPsTeGyCrQ326jrvgZWdCdK5Q==", "dev": true, "license": "MIT", "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.2", + "es-errors": "^1.3.0", + "es-set-tostringtag": "^2.1.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.3.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "internal-slot": "^1.1.0", + "iterator.prototype": "^1.1.5", + "math-intrinsics": "^1.1.0" }, "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" + "node": ">= 0.4" } }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", "dev": true, "license": "MIT", "dependencies": { - "color-name": "~1.1.4" + "es-errors": "^1.3.0" }, "engines": { - "node": ">=7.0.0" + "node": ">= 0.4" } }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, - "license": "MIT" - }, - "node_modules/concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true, - "license": "MIT" - }, - "node_modules/convert-source-map": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", - "dev": true, - "license": "MIT" - }, - "node_modules/cross-spawn": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", "dev": true, "license": "MIT", "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" }, "engines": { - "node": ">= 8" + "node": ">= 0.4" } }, - "node_modules/csstype": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", - "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "node_modules/es-shim-unscopables": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.1.0.tgz", + "integrity": "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==", "dev": true, "license": "MIT", "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" + "hasown": "^2.0.2" }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/deep-is": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", - "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/detect-libc": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", - "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "dev": true, - "license": "Apache-2.0", "engines": { - "node": ">=8" - } - }, - "node_modules/echarts": { - "version": "5.6.0", - "resolved": "https://registry.npmjs.org/echarts/-/echarts-5.6.0.tgz", - "integrity": "sha512-oTbVTsXfKuEhxftHqL5xprgLoc0k7uScAwtryCgWF6hPYFLRwOUHiFmHGCBKP5NPFNkDVopOieyUqYGH8Fa3kA==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "2.3.0", - "zrender": "5.6.1" - } - }, - "node_modules/echarts-for-react": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/echarts-for-react/-/echarts-for-react-3.0.6.tgz", - "integrity": "sha512-4zqLgTGWS3JvkQDXjzkR1k1CHRdpd6by0988TWMJgnvDytegWLbeP/VNZmMa+0VJx2eD7Y632bi2JquXDgiGJg==", - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3", - "size-sensor": "^1.0.1" - }, - "peerDependencies": { - "echarts": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0", - "react": "^15.0.0 || >=16.0.0" + "node": ">= 0.4" } }, - "node_modules/echarts/node_modules/tslib": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz", - "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==", - "license": "0BSD" - }, - "node_modules/electron-to-chromium": { - "version": "1.5.331", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.331.tgz", - "integrity": "sha512-IbxXrsTlD3hRodkLnbxAPP4OuJYdWCeM3IOdT+CpcMoIwIoDfCmRpEtSPfwBXxVkg9xmBeY7Lz2Eo2TDn/HC3Q==", - "dev": true, - "license": "ISC" - }, - "node_modules/enhanced-resolve": { - "version": "5.20.1", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz", - "integrity": "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==", + "node_modules/es-to-primitive": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.4.tgz", + "integrity": "sha512-yPDz7wqpg1/mmHLmS3tcfTfbw5f1eryXvyghYBffGdERwe+mV7ZcWzTR8LR17Kvqt3qfPurjlonmnq3MKXIOXw==", "dev": true, "license": "MIT", "dependencies": { - "graceful-fs": "^4.2.4", - "tapable": "^2.3.0" + "es-abstract-get": "^1.0.0", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "is-callable": "^1.2.7", + "is-date-object": "^1.1.0", + "is-symbol": "^1.1.1" }, "engines": { - "node": ">=10.13.0" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, "node_modules/escalade": { @@ -1943,34 +1771,57 @@ } } }, - "node_modules/eslint-plugin-react-hooks": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.0.1.tgz", - "integrity": "sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==", + "node_modules/eslint-plugin-react": { + "version": "7.37.5", + "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.5.tgz", + "integrity": "sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/core": "^7.24.4", - "@babel/parser": "^7.24.4", - "hermes-parser": "^0.25.1", - "zod": "^3.25.0 || ^4.0.0", - "zod-validation-error": "^3.5.0 || ^4.0.0" + "array-includes": "^3.1.8", + "array.prototype.findlast": "^1.2.5", + "array.prototype.flatmap": "^1.3.3", + "array.prototype.tosorted": "^1.1.4", + "doctrine": "^2.1.0", + "es-iterator-helpers": "^1.2.1", + "estraverse": "^5.3.0", + "hasown": "^2.0.2", + "jsx-ast-utils": "^2.4.1 || ^3.0.0", + "minimatch": "^3.1.2", + "object.entries": "^1.1.9", + "object.fromentries": "^2.0.8", + "object.values": "^1.2.1", + "prop-types": "^15.8.1", + "resolve": "^2.0.0-next.5", + "semver": "^6.3.1", + "string.prototype.matchall": "^4.0.12", + "string.prototype.repeat": "^1.0.0" }, "engines": { - "node": ">=18" + "node": ">=4" }, "peerDependencies": { - "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" + "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7" } }, - "node_modules/eslint-plugin-react-refresh": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.5.2.tgz", - "integrity": "sha512-hmgTH57GfzoTFjVN0yBwTggnsVUF2tcqi7RJZHqi9lIezSs4eFyAMktA68YD4r5kNw1mxyY4dmkyoFDb3FIqrA==", + "node_modules/eslint-plugin-react-hooks": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.1.1.tgz", + "integrity": "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==", "dev": true, "license": "MIT", + "dependencies": { + "@babel/core": "^7.24.4", + "@babel/parser": "^7.24.4", + "hermes-parser": "^0.25.1", + "zod": "^3.25.0 || ^4.0.0", + "zod-validation-error": "^3.5.0 || ^4.0.0" + }, + "engines": { + "node": ">=18" + }, "peerDependencies": { - "eslint": "^9 || ^10" + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0" } }, "node_modules/eslint-scope": { @@ -2071,6 +1922,7 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, "license": "MIT" }, "node_modules/fast-json-stable-stringify": { @@ -2156,6 +2008,22 @@ "dev": true, "license": "ISC" }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -2171,6 +2039,60 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/function.prototype.name": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.2.0.tgz", + "integrity": "sha512-jObKIik1P2QjPHP5nz5BaOtUlfgS0fWo8IUByNXkM+o+02sJOi94em77GwJKQSJ3gfPHdgzLNrHc1uokV4P/ew==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2", + "hasown": "^2.0.4", + "is-callable": "^1.2.7", + "is-document.all": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/functions-have-names": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/generator-function": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", + "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -2181,6 +2103,63 @@ "node": ">=6.9.0" } }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-symbol-description": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", + "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -2207,12 +2186,48 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/graceful-fs": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", - "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", "dev": true, - "license": "ISC" + "license": "MIT", + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-bigints": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", + "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } }, "node_modules/has-flag": { "version": "4.0.0", @@ -2224,6 +2239,77 @@ "node": ">=8" } }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-proto": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz", + "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/hermes-estree": { "version": "0.25.1", "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", @@ -2278,786 +2364,1811 @@ "node": ">=0.8.19" } }, - "node_modules/is-extglob": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "node_modules/internal-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", + "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", "dev": true, "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "hasown": "^2.0.2", + "side-channel": "^1.1.0" + }, "engines": { - "node": ">=0.10.0" + "node": ">= 0.4" } }, - "node_modules/is-glob": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "node_modules/is-array-buffer": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", + "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", "dev": true, "license": "MIT", "dependencies": { - "is-extglob": "^2.1.1" + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" }, "engines": { - "node": ">=0.10.0" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true, - "license": "ISC" - }, - "node_modules/jiti": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", - "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "node_modules/is-async-function": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", + "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==", "dev": true, "license": "MIT", - "bin": { - "jiti": "lib/jiti-cli.mjs" + "dependencies": { + "async-function": "^1.0.0", + "call-bound": "^1.0.3", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "node_modules/is-bigint": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", + "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", "dev": true, - "license": "MIT" - }, - "node_modules/js-yaml": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", - "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/puzrin" - }, - { - "type": "github", - "url": "https://github.com/sponsors/nodeca" - } - ], "license": "MIT", "dependencies": { - "argparse": "^2.0.1" + "has-bigints": "^1.0.2" }, - "bin": { - "js-yaml": "bin/js-yaml.js" + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/jsesc": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", - "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "node_modules/is-boolean-object": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", + "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", "dev": true, "license": "MIT", - "bin": { - "jsesc": "bin/jsesc" + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" }, "engines": { - "node": ">=6" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/json-buffer": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", - "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true, - "license": "MIT" - }, - "node_modules/json-stable-stringify-without-jsonify": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", - "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/json5": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", - "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", "dev": true, "license": "MIT", - "bin": { - "json5": "lib/cli.js" - }, "engines": { - "node": ">=6" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/keyv": { - "version": "4.5.4", - "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", - "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "node_modules/is-core-module": { + "version": "2.17.0", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.17.0.tgz", + "integrity": "sha512-J/vG0zBCbIKOQFfufSwyXdMrsohyJIUNkrnmo6WZGzoM7tr/lsbfW5b2BvisL6zsyMzK9UxV9L6c7AoFbyXHOA==", "dev": true, "license": "MIT", "dependencies": { - "json-buffer": "3.0.1" + "hasown": "^2.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/levn": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", - "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "node_modules/is-data-view": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz", + "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==", "dev": true, "license": "MIT", "dependencies": { - "prelude-ls": "^1.2.1", - "type-check": "~0.4.0" + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "is-typed-array": "^1.1.13" }, "engines": { - "node": ">= 0.8.0" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/lightningcss": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", - "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "node_modules/is-date-object": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", + "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", "dev": true, - "license": "MPL-2.0", + "license": "MIT", "dependencies": { - "detect-libc": "^2.0.3" + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" }, "engines": { - "node": ">= 12.0.0" + "node": ">= 0.4" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - }, - "optionalDependencies": { - "lightningcss-android-arm64": "1.32.0", - "lightningcss-darwin-arm64": "1.32.0", - "lightningcss-darwin-x64": "1.32.0", - "lightningcss-freebsd-x64": "1.32.0", - "lightningcss-linux-arm-gnueabihf": "1.32.0", - "lightningcss-linux-arm64-gnu": "1.32.0", - "lightningcss-linux-arm64-musl": "1.32.0", - "lightningcss-linux-x64-gnu": "1.32.0", - "lightningcss-linux-x64-musl": "1.32.0", - "lightningcss-win32-arm64-msvc": "1.32.0", - "lightningcss-win32-x64-msvc": "1.32.0" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/lightningcss-android-arm64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", - "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", - "cpu": [ - "arm64" - ], + "node_modules/is-document.all": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-document.all/-/is-document.all-1.0.0.tgz", + "integrity": "sha512-+XSoyS05OdBbhFuELhgTCpFNHkpBOJqtsZfUFFpe5QTw+9Sjbh8zitxhQkYAo6wV7e1Vb8cAPvpCk9jGam/82g==", "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "android" - ], + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4" + }, "engines": { - "node": ">= 12.0.0" + "node": ">= 0.4" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/lightningcss-darwin-arm64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", - "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", - "cpu": [ - "arm64" - ], + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], + "license": "MIT", "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" + "node": ">=0.10.0" } }, - "node_modules/lightningcss-darwin-x64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", - "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", - "cpu": [ - "x64" - ], + "node_modules/is-finalizationregistry": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", + "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==", "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, "engines": { - "node": ">= 12.0.0" + "node": ">= 0.4" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/lightningcss-freebsd-x64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", - "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", - "cpu": [ - "x64" - ], + "node_modules/is-generator-function": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", + "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "freebsd" - ], + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4", + "generator-function": "^2.0.0", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, "engines": { - "node": ">= 12.0.0" + "node": ">= 0.4" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", - "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", - "cpu": [ - "arm" - ], + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, "engines": { - "node": ">= 12.0.0" + "node": ">=0.10.0" + } + }, + "node_modules/is-map": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", + "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/lightningcss-linux-arm64-gnu": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", - "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", - "cpu": [ - "arm64" - ], + "node_modules/is-negative-zero": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", + "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], + "license": "MIT", "engines": { - "node": ">= 12.0.0" + "node": ">= 0.4" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/lightningcss-linux-arm64-musl": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", - "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", - "cpu": [ - "arm64" - ], + "node_modules/is-number-object": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", + "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, "engines": { - "node": ">= 12.0.0" + "node": ">= 0.4" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/lightningcss-linux-x64-gnu": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", - "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", - "cpu": [ - "x64" - ], + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, "engines": { - "node": ">= 12.0.0" + "node": ">= 0.4" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/lightningcss-linux-x64-musl": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", - "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", - "cpu": [ - "x64" - ], + "node_modules/is-set": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", + "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], + "license": "MIT", "engines": { - "node": ">= 12.0.0" + "node": ">= 0.4" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/lightningcss-win32-arm64-msvc": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", - "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", - "cpu": [ - "arm64" - ], + "node_modules/is-shared-array-buffer": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", + "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, "engines": { - "node": ">= 12.0.0" + "node": ">= 0.4" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/lightningcss-win32-x64-msvc": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", - "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", - "cpu": [ - "x64" - ], + "node_modules/is-string": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", + "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, "engines": { - "node": ">= 12.0.0" + "node": ">= 0.4" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/locate-path": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", - "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "node_modules/is-symbol": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", + "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", "dev": true, "license": "MIT", "dependencies": { - "p-locate": "^5.0.0" + "call-bound": "^1.0.2", + "has-symbols": "^1.1.0", + "safe-regex-test": "^1.1.0" }, "engines": { - "node": ">=10" + "node": ">= 0.4" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/lodash.merge": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", - "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "node_modules/is-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "dependencies": { + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } }, - "node_modules/lru-cache": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", - "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "node_modules/is-weakmap": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", + "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", "dev": true, - "license": "ISC", - "dependencies": { - "yallist": "^3.0.2" + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/magic-string": { - "version": "0.30.21", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", - "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "node_modules/is-weakref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz", + "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==", "dev": true, "license": "MIT", "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.5" + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "node_modules/is-weakset": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", + "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", "dev": true, - "license": "ISC", + "license": "MIT", "dependencies": { - "brace-expansion": "^1.1.7" + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" }, "engines": { - "node": "*" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", "dev": true, "license": "MIT" }, - "node_modules/nanoid": { - "version": "3.3.18", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", - "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/iterator.prototype": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz", + "integrity": "sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==", "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.6", + "get-proto": "^1.0.0", + "has-symbols": "^1.1.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", + "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, "bin": { - "nanoid": "bin/nanoid.cjs" + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" }, "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + "node": ">=6" } }, - "node_modules/natural-compare": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", - "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", "dev": true, "license": "MIT" }, - "node_modules/node-releases": { - "version": "2.0.37", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.37.tgz", - "integrity": "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==", + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", "dev": true, "license": "MIT" }, - "node_modules/optionator": { - "version": "0.9.4", - "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", - "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsx-ast-utils": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", + "integrity": "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-includes": "^3.1.6", + "array.prototype.flat": "^1.3.1", + "object.assign": "^4.1.4", + "object.values": "^1.1.6" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", "dev": true, "license": "MIT", "dependencies": { - "deep-is": "^0.1.3", - "fast-levenshtein": "^2.0.6", - "levn": "^0.4.1", "prelude-ls": "^1.2.1", - "type-check": "^0.4.0", - "word-wrap": "^1.2.5" + "type-check": "~0.4.0" }, "engines": { "node": ">= 0.8.0" } }, - "node_modules/p-limit": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", - "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", "dev": true, - "license": "MIT", + "license": "MPL-2.0", "dependencies": { - "yocto-queue": "^0.1.0" + "detect-libc": "^2.0.3" }, "engines": { - "node": ">=10" + "node": ">= 12.0.0" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-exports-info": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.2.tgz", + "integrity": "sha512-kXs9Go0cah0qHVV2v389IXQLdLCeE1xfFtjOAF+iobu0OIoG1pje8At2vMHyaPMiPMnG/LWP50twML21eMcAag==", + "dev": true, + "license": "MIT", + "dependencies": { + "array.prototype.flatmap": "^1.3.3", + "es-errors": "^1.3.0", + "object.entries": "^1.1.9", + "semver": "^6.3.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/node-releases": { + "version": "2.0.59", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.59.tgz", + "integrity": "sha512-5qr2okC4ItAI0hG5SaV0HoYMpnM/Y6+Oqr7AmPYy/2uEoGvS+GbSKLeRxSS1uAdu59yz0ouzcuXievSgg///WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.assign": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", + "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0", + "has-symbols": "^1.1.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.entries": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.9.tgz", + "integrity": "sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.fromentries": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.8.tgz", + "integrity": "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.values": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.2.1.tgz", + "integrity": "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/own-keys": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.2.tgz", + "integrity": "sha512-19YVAg7T+WTrxggPukVq7DjTv6+PJ867TmhCvBsYwmbFCsZd344rq2Ld1p0wo8f8Qrrhgp82c6FJRqdXWtSEhg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4", + "get-intrinsic": "^1.3.0", + "object-keys": "^1.1.1", + "safe-push-apply": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/papaparse": { + "version": "5.5.3", + "resolved": "https://registry.npmjs.org/papaparse/-/papaparse-5.5.3.tgz", + "integrity": "sha512-5QvjGxYVjxO59MGU2lHVYpRWBBtKHnlIAcSe1uNFCkkptUh63NFRj0FJQm7nR67puEruUci/ZkjmEFrjCAyP4A==", + "license": "MIT" + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/playwright": { + "version": "1.64.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.64.1.tgz", + "integrity": "sha512-HCrVhHr5QQuwYoswEUGTb7gGp522yflbv/ST8nF4sFWGuLjQCt17Q/EubpGGSNKYn1tsTCPZxqHhHCfVURu7UA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.64.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright-core": { + "version": "1.64.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.1.tgz", + "integrity": "sha512-BC4s8y6SEVA6Q1rSxZTto8hI1KUI3xXlHGZgvGqP4IqPdI/a6FX9H29IDNz7vKo9l21b6LfYosScPL6M3xxyDQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/postcss": { + "version": "8.5.30", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.30.tgz", + "integrity": "sha512-zkd+e9S1AQHhmaBKye9zlFHWjOwVgqXl1q3ECYy8561K7qxR2cREf/STLskulBFd78k6AOFeA+ff1TtEzk9bAA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.20", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.9.10", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.10.tgz", + "integrity": "sha512-skGHTH1Yb9QvaTC4hWnRTzEYtCpApF0UMBPz+t/0ZPzP8ihefo/btKd+QA7pUE0fEwA1SbS/NmhyEcstdXOIdA==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "dev": true, + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/react": { + "version": "19.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-19.3.1.tgz", + "integrity": "sha512-QNhWdt7nrgVWWQG58QuGsAR3jv3Hbe4mb4vz7w2csnHavciZJgda1CPNaldF6eVxv90ud3jUXpKlHL69gO38yw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.1.tgz", + "integrity": "sha512-Y0bARz53lhEC1NMCASs0/j+faB/zqt3R8hpvg1e8+61ZVJ4UL0UdC4cBbXfZMcIuhrfgeILTLe+ZeBCavQPtKQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.28.0" + }, + "peerDependencies": { + "react": "^19.3.1" + } + }, + "node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/reflect.getprototypeof": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", + "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.7", + "get-proto": "^1.0.1", + "which-builtin-type": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/regexp.prototype.flags": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", + "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-errors": "^1.3.0", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve": { + "version": "2.0.0-next.7", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.7.tgz", + "integrity": "sha512-tqt+NBWwyaMgw3zDsnygx4CByWjQEJHOPMdslYhppaQSJUtL/D4JO9CcBBlhPoI8lz9oJIDXkwXfhF4aWqP8xQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.2", + "node-exports-info": "^1.6.0", + "object-keys": "^1.1.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/rolldown": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.14.tgz", + "integrity": "sha512-eTBTTqMR6uHKLgcUqpJP8ntzDI6V4LYA8negAMBEI7gkgVvOdrx6VsvS2Ym+83KhOPdLCHWJu0w7UQFJZhHKNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.154.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.14", + "@rolldown/binding-android-arm64": "1.2.14", + "@rolldown/binding-darwin-arm64": "1.2.14", + "@rolldown/binding-darwin-x64": "1.2.14", + "@rolldown/binding-freebsd-x64": "1.2.14", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.14", + "@rolldown/binding-linux-arm64-gnu": "1.2.14", + "@rolldown/binding-linux-arm64-musl": "1.2.14", + "@rolldown/binding-linux-ppc64-gnu": "1.2.14", + "@rolldown/binding-linux-s390x-gnu": "1.2.14", + "@rolldown/binding-linux-x64-gnu": "1.2.14", + "@rolldown/binding-linux-x64-musl": "1.2.14", + "@rolldown/binding-openharmony-arm64": "1.2.14", + "@rolldown/binding-win32-arm64-msvc": "1.2.14", + "@rolldown/binding-win32-x64-msvc": "1.2.14" + } + }, + "node_modules/safe-array-concat": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.4.tgz", + "integrity": "sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "get-intrinsic": "^1.3.0", + "has-symbols": "^1.1.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">=0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-push-apply": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", + "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/scheduler": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz", + "integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" } }, - "node_modules/p-locate": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", - "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "node_modules/set-function-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", "dev": true, "license": "MIT", "dependencies": { - "p-limit": "^3.0.2" + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2" }, "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">= 0.4" } }, - "node_modules/papaparse": { - "version": "5.5.3", - "resolved": "https://registry.npmjs.org/papaparse/-/papaparse-5.5.3.tgz", - "integrity": "sha512-5QvjGxYVjxO59MGU2lHVYpRWBBtKHnlIAcSe1uNFCkkptUh63NFRj0FJQm7nR67puEruUci/ZkjmEFrjCAyP4A==", - "license": "MIT" - }, - "node_modules/parent-module": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", - "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "node_modules/set-proto": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz", + "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==", "dev": true, "license": "MIT", "dependencies": { - "callsites": "^3.0.0" + "dunder-proto": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0" }, "engines": { - "node": ">=6" + "node": ">= 0.4" } }, - "node_modules/path-exists": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", - "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", "dev": true, "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, "engines": { "node": ">=8" } }, - "node_modules/path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", "dev": true, "license": "MIT", "engines": { "node": ">=8" } }, - "node_modules/picocolors": { + "node_modules/side-channel": { "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, - "license": "ISC" - }, - "node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", "dev": true, "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, "engines": { - "node": ">=12" + "node": ">= 0.4" }, "funding": { - "url": "https://github.com/sponsors/jonschlinkert" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/postcss": { - "version": "8.5.26", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", - "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.17", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" }, "engines": { - "node": "^10 || ^12 || >=14" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/prelude-ls": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", - "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", "dev": true, "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, "engines": { - "node": ">= 0.8.0" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/punycode": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", - "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", "dev": true, "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, "engines": { - "node": ">=6" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/react": { - "version": "19.2.4", - "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", - "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", - "license": "MIT", + "node_modules/source-map-js": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", + "dev": true, + "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" } }, - "node_modules/react-dom": { - "version": "19.2.4", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", - "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", + "node_modules/stop-iteration-iterator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", + "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", + "dev": true, "license": "MIT", "dependencies": { - "scheduler": "^0.27.0" + "es-errors": "^1.3.0", + "internal-slot": "^1.1.0" }, - "peerDependencies": { - "react": "^19.2.4" - } - }, - "node_modules/resolve-from": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", - "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", - "dev": true, - "license": "MIT", "engines": { - "node": ">=4" + "node": ">= 0.4" } }, - "node_modules/rolldown": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz", - "integrity": "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==", + "node_modules/string.prototype.matchall": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.1.0.tgz", + "integrity": "sha512-tHNHTxInrYLCga9O9YGxWA3G9/nnzQw8UGAyqGx3Ar1pSTTzIuM4woFSq4SowkXCjJIwq5sIiQvEfRI9tCH1qQ==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.133.0", - "@rolldown/pluginutils": "^1.0.0" - }, - "bin": { - "rolldown": "bin/cli.mjs" + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.2", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.2", + "get-intrinsic": "^1.3.0", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "internal-slot": "^1.1.0", + "regexp.prototype.flags": "^1.5.4", + "set-function-name": "^2.0.2", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.0.3", - "@rolldown/binding-darwin-arm64": "1.0.3", - "@rolldown/binding-darwin-x64": "1.0.3", - "@rolldown/binding-freebsd-x64": "1.0.3", - "@rolldown/binding-linux-arm-gnueabihf": "1.0.3", - "@rolldown/binding-linux-arm64-gnu": "1.0.3", - "@rolldown/binding-linux-arm64-musl": "1.0.3", - "@rolldown/binding-linux-ppc64-gnu": "1.0.3", - "@rolldown/binding-linux-s390x-gnu": "1.0.3", - "@rolldown/binding-linux-x64-gnu": "1.0.3", - "@rolldown/binding-linux-x64-musl": "1.0.3", - "@rolldown/binding-openharmony-arm64": "1.0.3", - "@rolldown/binding-wasm32-wasi": "1.0.3", - "@rolldown/binding-win32-arm64-msvc": "1.0.3", - "@rolldown/binding-win32-x64-msvc": "1.0.3" - } - }, - "node_modules/rolldown/node_modules/@rolldown/pluginutils": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", - "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", - "dev": true, - "license": "MIT" - }, - "node_modules/scheduler": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", - "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", - "license": "MIT" + "funding": { + "url": "https://github.com/sponsors/ljharb" + } }, - "node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "node_modules/string.prototype.repeat": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/string.prototype.repeat/-/string.prototype.repeat-1.0.0.tgz", + "integrity": "sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==", "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" + "license": "MIT", + "dependencies": { + "define-properties": "^1.1.3", + "es-abstract": "^1.17.5" } }, - "node_modules/shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "node_modules/string.prototype.trim": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.11.tgz", + "integrity": "sha512-PwvK7BU+CMTJGYQCTZb5RWXIML92lftJLhQz1tBzgKiqGxJaMlBAa48POXaNAC2s4y8jr3EFqrkF9+44neS46w==", "dev": true, "license": "MIT", "dependencies": { - "shebang-regex": "^3.0.0" + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "define-data-property": "^1.1.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.2", + "es-object-atoms": "^1.1.2", + "has-property-descriptors": "^1.0.2", + "safe-regex-test": "^1.1.0" }, "engines": { - "node": ">=8" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "node_modules/string.prototype.trimend": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.10.tgz", + "integrity": "sha512-2+3aDAOmPTmuFwjDnmJG2ctEkQKVki7vOSqaxkv42Mowj1V6PnvuwFCRrR5lChUux1TBskPjfkeTOhqczDMxTw==", "dev": true, "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.1.2" + }, "engines": { - "node": ">=8" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/size-sensor": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/size-sensor/-/size-sensor-1.0.3.tgz", - "integrity": "sha512-+k9mJ2/rQMiRmQUcjn+qznch260leIXY8r4FyYKKyRBO/s5UoeMAHGkCJyE1R/4wrIhTJONfyloY55SkE7ve3A==", - "license": "ISC" - }, - "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "node_modules/string.prototype.trimstart": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", + "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", "dev": true, - "license": "BSD-3-Clause", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, "engines": { - "node": ">=0.10.0" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, "node_modules/strip-json-comments": { @@ -3086,25 +4197,17 @@ "node": ">=8" } }, - "node_modules/tailwindcss": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz", - "integrity": "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/tapable": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.2.tgz", - "integrity": "sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA==", + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", "dev": true, "license": "MIT", "engines": { - "node": ">=6" + "node": ">= 0.4" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" + "url": "https://github.com/sponsors/ljharb" } }, "node_modules/tinyglobby": { @@ -3124,27 +4227,6 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, - "node_modules/ts-api-utils": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", - "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18.12" - }, - "peerDependencies": { - "typescript": ">=4.8.4" - } - }, - "node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, - "license": "0BSD", - "optional": true - }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -3158,55 +4240,106 @@ "node": ">= 0.8.0" } }, - "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "node_modules/typed-array-buffer": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", + "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.14" }, "engines": { - "node": ">=14.17" + "node": ">= 0.4" } }, - "node_modules/typescript-eslint": { - "version": "8.58.0", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.58.0.tgz", - "integrity": "sha512-e2TQzKfaI85fO+F3QywtX+tCTsu/D3WW5LVU6nz8hTFKFZ8yBJ6mSYRpXqdR3mFjPWmO0eWsTa5f+UpAOe/FMA==", + "node_modules/typed-array-byte-length": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz", + "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.58.0", - "@typescript-eslint/parser": "8.58.0", - "@typescript-eslint/typescript-estree": "8.58.0", - "@typescript-eslint/utils": "8.58.0" + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.14" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": ">= 0.4" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-byte-offset": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.5.tgz", + "integrity": "sha512-0FHJvLPqZ7KJzp17O13jfsAjsqazgrxBu2zEK95PmUz8lv2+GjRuxUInCr2Rk9Dms3ihN21zJ929ZO43yJ95QQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.9", + "for-each": "^0.3.5", + "gopd": "^1.2.0", + "is-typed-array": "^1.1.15", + "reflect.getprototypeof": "^1.0.10" }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.1.0" + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/undici-types": { - "version": "7.16.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", - "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "node_modules/typed-array-length": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.8.tgz", + "integrity": "sha512-phPGCwqr2+Qo0fwniCE8e4pKnGu/yFb5nD5Y8bf0EEeiI5GklnACYA9GFy/DrAeRrKHXvHn+1SUsOWgJp6RO+g==", "dev": true, - "license": "MIT" + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "for-each": "^0.3.5", + "gopd": "^1.2.0", + "is-typed-array": "^1.1.15", + "possible-typed-array-names": "^1.1.0", + "reflect.getprototypeof": "^1.0.10" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/unbox-primitive": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", + "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-bigints": "^1.0.2", + "has-symbols": "^1.1.0", + "which-boxed-primitive": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } }, "node_modules/update-browserslist-db": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", - "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.4.tgz", + "integrity": "sha512-t8wonIOdQyOjtPd2VktlBu6QjTVTks6v8bSi7kd0r2Vll8TK1JYDZtRRdbmCHOAMphkZgrzK+L+vXK4pyZ8t4A==", "dev": true, "funding": [ { @@ -3245,16 +4378,16 @@ } }, "node_modules/vite": { - "version": "8.0.16", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz", - "integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==", + "version": "8.3.4", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.4.tgz", + "integrity": "sha512-IftLE8phJhdNRIGNEV4JCCvfNScoZQVfcXaN8TQropWGJtOHcu+iAB9pHcuGvmEb/kddw0LBe/jS8ztdC6xdiQ==", "dev": true, "license": "MIT", "dependencies": { - "lightningcss": "^1.32.0", - "picomatch": "^4.0.4", - "postcss": "^8.5.15", - "rolldown": "1.0.3", + "lightningcss": "^1.33.0", + "picomatch": "^4.0.7", + "postcss": "^8.5.29", + "rolldown": "~1.2.12", "tinyglobby": "^0.2.17" }, "bin": { @@ -3271,7 +4404,7 @@ }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.1.18", + "@vitejs/devtools": "^0.7.1", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", @@ -3338,6 +4471,95 @@ "node": ">= 8" } }, + "node_modules/which-boxed-primitive": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", + "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-bigint": "^1.1.0", + "is-boolean-object": "^1.2.1", + "is-number-object": "^1.1.1", + "is-string": "^1.1.1", + "is-symbol": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-builtin-type": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.1.tgz", + "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "function.prototype.name": "^1.1.6", + "has-tostringtag": "^1.0.2", + "is-async-function": "^2.0.0", + "is-date-object": "^1.1.0", + "is-finalizationregistry": "^1.1.0", + "is-generator-function": "^1.0.10", + "is-regex": "^1.2.1", + "is-weakref": "^1.0.2", + "isarray": "^2.0.5", + "which-boxed-primitive": "^1.1.0", + "which-collection": "^1.0.2", + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-collection": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", + "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-map": "^2.0.3", + "is-set": "^2.0.3", + "is-weakmap": "^2.0.2", + "is-weakset": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-typed-array": { + "version": "1.1.24", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.24.tgz", + "integrity": "sha512-wk4Mf4pR5mRP7eYuuTBCIQ9d0ud2Fv2jRLQpfgnRjbOxAFHmjKFValgTpitVKzJJS8ajnYQV2Du1SZ8j6b/EUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -3369,9 +4591,9 @@ } }, "node_modules/zod": { - "version": "4.3.6", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", - "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "version": "4.6.7", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.7.tgz", + "integrity": "sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg==", "dev": true, "license": "MIT", "funding": { diff --git a/viewer/package.json b/viewer/package.json index 8d11bdd4..23067458 100644 --- a/viewer/package.json +++ b/viewer/package.json @@ -1,38 +1,29 @@ { - "name": "dashboard", + "name": "vessim-viewer", "private": true, "version": "0.0.0", "type": "module", "scripts": { - "dev": "vite", - "build": "tsc -b && vite build", + "build": "node build.mjs", "lint": "eslint .", - "preview": "vite preview" + "test": "node --test tests/*.test.mjs", + "test:browser": "playwright test" }, "dependencies": { "echarts": "^5.6.0", - "echarts-for-react": "^3.0.2", - "js-yaml": "^4.3.1", "papaparse": "^5.5.3", - "react": "^19.2.4", - "react-dom": "^19.2.4" + "react": "^19.3.1", + "react-dom": "^19.3.1" }, "devDependencies": { "@eslint/js": "^9.39.4", - "@tailwindcss/vite": "^4.2.2", - "@types/js-yaml": "^4.0.9", - "@types/node": "^24.12.0", - "@types/papaparse": "^5.5.2", - "@types/react": "^19.2.14", - "@types/react-dom": "^19.2.3", - "@vitejs/plugin-react": "^6.0.1", + "@playwright/test": "^1.64.1", + "@vitejs/plugin-react": "^6.1.2", "eslint": "^9.39.4", - "eslint-plugin-react-hooks": "^7.0.1", - "eslint-plugin-react-refresh": "^0.5.2", + "eslint-plugin-react": "^7.37.5", + "eslint-plugin-react-hooks": "^7.1.1", "globals": "^17.4.0", - "tailwindcss": "^4.2.2", - "typescript": "~5.9.3", - "typescript-eslint": "^8.57.0", - "vite": "^8.0.1" + "prettier": "^3.9.10", + "vite": "^8.3.4" } } diff --git a/viewer/playwright.config.ts b/viewer/playwright.config.ts new file mode 100644 index 00000000..f84221fd --- /dev/null +++ b/viewer/playwright.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from '@playwright/test' +export default defineConfig({ + testDir: './tests/browser', timeout: 30000, fullyParallel: false, workers: 1, + use: { channel: process.env.PLAYWRIGHT_CHANNEL || 'chrome', headless: true, + viewport: { width: 1440, height: 1000 }, colorScheme: 'light' }, + reporter: 'list', + webServer: [ + { command: '../.venv/bin/python -m vessim view ../results/viewer-release/input --port 8721 --no-browser', + url: 'http://localhost:8721/catalog.json', reuseExistingServer: false, timeout: 30000 }, + { command: '../.venv/bin/python -m http.server 8722 --bind 127.0.0.1 --directory ../results/viewer-release', + url: 'http://127.0.0.1:8722/report/catalog.json', reuseExistingServer: false, timeout: 30000 }, + ], +}) diff --git a/viewer/src/App.jsx b/viewer/src/App.jsx new file mode 100644 index 00000000..cd0dfd79 --- /dev/null +++ b/viewer/src/App.jsx @@ -0,0 +1,158 @@ +import { useCallback, useEffect, useMemo, useState } from 'react' +import { ViewerContext } from './context.jsx' +import { presentation } from './presentation.js' +import { RecordingStore } from './recordings.js' +import { Results } from './components/Results.jsx' +import { TimeSeries } from './components/TimeSeries.jsx' +import { RunDetails } from './components/RunDetails.jsx' +import { Issues, NavLink, Select } from './components/controls.jsx' +import logo from './logo.svg' +import { navigate, useNavigation, useViewState } from './navigation.js' +import { json, useStudy } from './study.js' + +export default function App() { + const route = useNavigation(), + { url } = route + const [catalog, setCatalog] = useState(null), + [catalogError, setCatalogError] = useState('') + const [recordings] = useState(() => new RecordingStore()) + const active = + url.searchParams.get('study') || catalog?.studies[0]?.study_id || '' + const page = url.hash.slice(1) || 'results' + const { study, error } = useStudy(catalog, active) + const [view, patch] = useViewState(study, route) + useEffect(() => { + const abort = new AbortController() + json(new URL('catalog.json', location.href), abort.signal) + .then((value) => { + if (!Array.isArray(value.studies)) + throw new Error('Invalid viewer catalog') + setCatalog(value) + }) + .catch((e) => { + if (!abort.signal.aborted) setCatalogError(e.message) + }) + return () => abort.abort() + }, []) + useEffect(() => () => recordings.dispose(), [recordings]) + useEffect(() => { + window.scrollTo(0, 0) + }, [active, page]) + const openTimeSeries = useCallback(() => { + patch((s) => + s.mode === 'configurations' && s.configs.size + ? { timeConfigs: new Set(s.configs), timeMode: 'configurations' } + : s.mode === 'runs' && s.runs.size + ? { timeMode: 'runs' } + : {}, + ) + navigate('timeseries') + }, [patch]) + const context = useMemo( + () => + study && view + ? { + study, + view, + patch, + presentation: presentation(study, view), + recordings, + navigate, + openTimeSeries, + } + : null, + [study, view, patch, recordings, openTimeSeries], + ) + const visibleError = catalogError || error + const count = + view?.timeMode === 'configurations' + ? study.configurations + .filter((c) => view.timeConfigs.has(c.key)) + .reduce((n, c) => n + c.runs.length, 0) + : view?.runs.size || 0 + let runId = '' + try { + runId = decodeURIComponent(page.slice(4)) + } catch { + /* Malformed links return to results. */ + } + return ( +
+