From a59a834919049a59c4710c7cd9b412d6d46f753f Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Mon, 30 Mar 2026 16:45:09 -0700 Subject: [PATCH 01/35] bump version to 1.0.10 and python to 3.14 --- pyproject.toml | 4 +- span_panel_simulator/Dockerfile | 2 +- span_panel_simulator/config.yaml | 2 +- src/span_panel_simulator/__init__.py | 2 +- uv.lock | 466 +-------------------------- 5 files changed, 7 insertions(+), 469 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index ba5ca03..e6c20d6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,9 +4,9 @@ build-backend = "hatchling.build" [project] name = "span-panel-simulator" -version = "1.0.9" +version = "1.0.10" description = "Standalone eBus simulator for SPAN panels" -requires-python = ">=3.12" +requires-python = ">=3.14" dependencies = [ "aiomqtt>=2.0.0", "aiohttp>=3.9.0", diff --git a/span_panel_simulator/Dockerfile b/span_panel_simulator/Dockerfile index b1279b4..dffad18 100644 --- a/span_panel_simulator/Dockerfile +++ b/span_panel_simulator/Dockerfile @@ -32,7 +32,7 @@ EXPOSE 18883 8081 18080 LABEL io.hass.name="SPAN Panel Simulator" \ io.hass.description="Simulates a SPAN electrical panel for testing and upgrade modeling" \ io.hass.type="addon" \ - io.hass.version="1.0.9" \ + io.hass.version="1.0.10" \ io.hass.arch="aarch64|amd64" CMD ["/run.sh"] diff --git a/span_panel_simulator/config.yaml b/span_panel_simulator/config.yaml index 1456a4f..72ef6ee 100644 --- a/span_panel_simulator/config.yaml +++ b/span_panel_simulator/config.yaml @@ -1,6 +1,6 @@ name: "SPAN Panel Simulator" description: "Simulates a SPAN electrical panel for testing and upgrade modeling" -version: "1.0.9" +version: "1.0.10" slug: "span_panel_simulator" url: "https://github.com/SpanPanel/simulator" image: "ghcr.io/spanpanel/simulator/{arch}" diff --git a/src/span_panel_simulator/__init__.py b/src/span_panel_simulator/__init__.py index fdf3591..90764fd 100644 --- a/src/span_panel_simulator/__init__.py +++ b/src/span_panel_simulator/__init__.py @@ -1,3 +1,3 @@ """Standalone eBus simulator for SPAN panels.""" -__version__ = "1.0.9" +__version__ = "1.0.10" diff --git a/uv.lock b/uv.lock index ce8ca8a..f8e10e6 100644 --- a/uv.lock +++ b/uv.lock @@ -1,6 +1,6 @@ version = 1 revision = 3 -requires-python = ">=3.12" +requires-python = ">=3.14" [[package]] name = "aiohappyeyeballs" @@ -26,40 +26,6 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/50/42/32cf8e7704ceb4481406eb87161349abb46a57fee3f008ba9cb610968646/aiohttp-3.13.3.tar.gz", hash = "sha256:a949eee43d3782f2daae4f4a2819b2cb9b0c5d3b7f7a927067cc84dafdbb9f88", size = 7844556, upload-time = "2026-01-03T17:33:05.204Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a0/be/4fc11f202955a69e0db803a12a062b8379c970c7c84f4882b6da17337cc1/aiohttp-3.13.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:b903a4dfee7d347e2d87697d0713be59e0b87925be030c9178c5faa58ea58d5c", size = 739732, upload-time = "2026-01-03T17:30:14.23Z" }, - { url = "https://files.pythonhosted.org/packages/97/2c/621d5b851f94fa0bb7430d6089b3aa970a9d9b75196bc93bb624b0db237a/aiohttp-3.13.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a45530014d7a1e09f4a55f4f43097ba0fd155089372e105e4bff4ca76cb1b168", size = 494293, upload-time = "2026-01-03T17:30:15.96Z" }, - { url = "https://files.pythonhosted.org/packages/5d/43/4be01406b78e1be8320bb8316dc9c42dbab553d281c40364e0f862d5661c/aiohttp-3.13.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:27234ef6d85c914f9efeb77ff616dbf4ad2380be0cda40b4db086ffc7ddd1b7d", size = 493533, upload-time = "2026-01-03T17:30:17.431Z" }, - { url = "https://files.pythonhosted.org/packages/8d/a8/5a35dc56a06a2c90d4742cbf35294396907027f80eea696637945a106f25/aiohttp-3.13.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d32764c6c9aafb7fb55366a224756387cd50bfa720f32b88e0e6fa45b27dcf29", size = 1737839, upload-time = "2026-01-03T17:30:19.422Z" }, - { url = "https://files.pythonhosted.org/packages/bf/62/4b9eeb331da56530bf2e198a297e5303e1c1ebdceeb00fe9b568a65c5a0c/aiohttp-3.13.3-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b1a6102b4d3ebc07dad44fbf07b45bb600300f15b552ddf1851b5390202ea2e3", size = 1703932, upload-time = "2026-01-03T17:30:21.756Z" }, - { url = "https://files.pythonhosted.org/packages/7c/f6/af16887b5d419e6a367095994c0b1332d154f647e7dc2bd50e61876e8e3d/aiohttp-3.13.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c014c7ea7fb775dd015b2d3137378b7be0249a448a1612268b5a90c2d81de04d", size = 1771906, upload-time = "2026-01-03T17:30:23.932Z" }, - { url = "https://files.pythonhosted.org/packages/ce/83/397c634b1bcc24292fa1e0c7822800f9f6569e32934bdeef09dae7992dfb/aiohttp-3.13.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2b8d8ddba8f95ba17582226f80e2de99c7a7948e66490ef8d947e272a93e9463", size = 1871020, upload-time = "2026-01-03T17:30:26Z" }, - { url = "https://files.pythonhosted.org/packages/86/f6/a62cbbf13f0ac80a70f71b1672feba90fdb21fd7abd8dbf25c0105fb6fa3/aiohttp-3.13.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9ae8dd55c8e6c4257eae3a20fd2c8f41edaea5992ed67156642493b8daf3cecc", size = 1755181, upload-time = "2026-01-03T17:30:27.554Z" }, - { url = "https://files.pythonhosted.org/packages/0a/87/20a35ad487efdd3fba93d5843efdfaa62d2f1479eaafa7453398a44faf13/aiohttp-3.13.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:01ad2529d4b5035578f5081606a465f3b814c542882804e2e8cda61adf5c71bf", size = 1561794, upload-time = "2026-01-03T17:30:29.254Z" }, - { url = "https://files.pythonhosted.org/packages/de/95/8fd69a66682012f6716e1bc09ef8a1a2a91922c5725cb904689f112309c4/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bb4f7475e359992b580559e008c598091c45b5088f28614e855e42d39c2f1033", size = 1697900, upload-time = "2026-01-03T17:30:31.033Z" }, - { url = "https://files.pythonhosted.org/packages/e5/66/7b94b3b5ba70e955ff597672dad1691333080e37f50280178967aff68657/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:c19b90316ad3b24c69cd78d5c9b4f3aa4497643685901185b65166293d36a00f", size = 1728239, upload-time = "2026-01-03T17:30:32.703Z" }, - { url = "https://files.pythonhosted.org/packages/47/71/6f72f77f9f7d74719692ab65a2a0252584bf8d5f301e2ecb4c0da734530a/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:96d604498a7c782cb15a51c406acaea70d8c027ee6b90c569baa6e7b93073679", size = 1740527, upload-time = "2026-01-03T17:30:34.695Z" }, - { url = "https://files.pythonhosted.org/packages/fa/b4/75ec16cbbd5c01bdaf4a05b19e103e78d7ce1ef7c80867eb0ace42ff4488/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:084911a532763e9d3dd95adf78a78f4096cd5f58cdc18e6fdbc1b58417a45423", size = 1554489, upload-time = "2026-01-03T17:30:36.864Z" }, - { url = "https://files.pythonhosted.org/packages/52/8f/bc518c0eea29f8406dcf7ed1f96c9b48e3bc3995a96159b3fc11f9e08321/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:7a4a94eb787e606d0a09404b9c38c113d3b099d508021faa615d70a0131907ce", size = 1767852, upload-time = "2026-01-03T17:30:39.433Z" }, - { url = "https://files.pythonhosted.org/packages/9d/f2/a07a75173124f31f11ea6f863dc44e6f09afe2bca45dd4e64979490deab1/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:87797e645d9d8e222e04160ee32aa06bc5c163e8499f24db719e7852ec23093a", size = 1722379, upload-time = "2026-01-03T17:30:41.081Z" }, - { url = "https://files.pythonhosted.org/packages/3c/4a/1a3fee7c21350cac78e5c5cef711bac1b94feca07399f3d406972e2d8fcd/aiohttp-3.13.3-cp312-cp312-win32.whl", hash = "sha256:b04be762396457bef43f3597c991e192ee7da460a4953d7e647ee4b1c28e7046", size = 428253, upload-time = "2026-01-03T17:30:42.644Z" }, - { url = "https://files.pythonhosted.org/packages/d9/b7/76175c7cb4eb73d91ad63c34e29fc4f77c9386bba4a65b53ba8e05ee3c39/aiohttp-3.13.3-cp312-cp312-win_amd64.whl", hash = "sha256:e3531d63d3bdfa7e3ac5e9b27b2dd7ec9df3206a98e0b3445fa906f233264c57", size = 455407, upload-time = "2026-01-03T17:30:44.195Z" }, - { url = "https://files.pythonhosted.org/packages/97/8a/12ca489246ca1faaf5432844adbfce7ff2cc4997733e0af120869345643a/aiohttp-3.13.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:5dff64413671b0d3e7d5918ea490bdccb97a4ad29b3f311ed423200b2203e01c", size = 734190, upload-time = "2026-01-03T17:30:45.832Z" }, - { url = "https://files.pythonhosted.org/packages/32/08/de43984c74ed1fca5c014808963cc83cb00d7bb06af228f132d33862ca76/aiohttp-3.13.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:87b9aab6d6ed88235aa2970294f496ff1a1f9adcd724d800e9b952395a80ffd9", size = 491783, upload-time = "2026-01-03T17:30:47.466Z" }, - { url = "https://files.pythonhosted.org/packages/17/f8/8dd2cf6112a5a76f81f81a5130c57ca829d101ad583ce57f889179accdda/aiohttp-3.13.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:425c126c0dc43861e22cb1c14ba4c8e45d09516d0a3ae0a3f7494b79f5f233a3", size = 490704, upload-time = "2026-01-03T17:30:49.373Z" }, - { url = "https://files.pythonhosted.org/packages/6d/40/a46b03ca03936f832bc7eaa47cfbb1ad012ba1be4790122ee4f4f8cba074/aiohttp-3.13.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7f9120f7093c2a32d9647abcaf21e6ad275b4fbec5b55969f978b1a97c7c86bf", size = 1720652, upload-time = "2026-01-03T17:30:50.974Z" }, - { url = "https://files.pythonhosted.org/packages/f7/7e/917fe18e3607af92657e4285498f500dca797ff8c918bd7d90b05abf6c2a/aiohttp-3.13.3-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:697753042d57f4bf7122cab985bf15d0cef23c770864580f5af4f52023a56bd6", size = 1692014, upload-time = "2026-01-03T17:30:52.729Z" }, - { url = "https://files.pythonhosted.org/packages/71/b6/cefa4cbc00d315d68973b671cf105b21a609c12b82d52e5d0c9ae61d2a09/aiohttp-3.13.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6de499a1a44e7de70735d0b39f67c8f25eb3d91eb3103be99ca0fa882cdd987d", size = 1759777, upload-time = "2026-01-03T17:30:54.537Z" }, - { url = "https://files.pythonhosted.org/packages/fb/e3/e06ee07b45e59e6d81498b591fc589629be1553abb2a82ce33efe2a7b068/aiohttp-3.13.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:37239e9f9a7ea9ac5bf6b92b0260b01f8a22281996da609206a84df860bc1261", size = 1861276, upload-time = "2026-01-03T17:30:56.512Z" }, - { url = "https://files.pythonhosted.org/packages/7c/24/75d274228acf35ceeb2850b8ce04de9dd7355ff7a0b49d607ee60c29c518/aiohttp-3.13.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f76c1e3fe7d7c8afad7ed193f89a292e1999608170dcc9751a7462a87dfd5bc0", size = 1743131, upload-time = "2026-01-03T17:30:58.256Z" }, - { url = "https://files.pythonhosted.org/packages/04/98/3d21dde21889b17ca2eea54fdcff21b27b93f45b7bb94ca029c31ab59dc3/aiohttp-3.13.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fc290605db2a917f6e81b0e1e0796469871f5af381ce15c604a3c5c7e51cb730", size = 1556863, upload-time = "2026-01-03T17:31:00.445Z" }, - { url = "https://files.pythonhosted.org/packages/9e/84/da0c3ab1192eaf64782b03971ab4055b475d0db07b17eff925e8c93b3aa5/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4021b51936308aeea0367b8f006dc999ca02bc118a0cc78c303f50a2ff6afb91", size = 1682793, upload-time = "2026-01-03T17:31:03.024Z" }, - { url = "https://files.pythonhosted.org/packages/ff/0f/5802ada182f575afa02cbd0ec5180d7e13a402afb7c2c03a9aa5e5d49060/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:49a03727c1bba9a97d3e93c9f93ca03a57300f484b6e935463099841261195d3", size = 1716676, upload-time = "2026-01-03T17:31:04.842Z" }, - { url = "https://files.pythonhosted.org/packages/3f/8c/714d53bd8b5a4560667f7bbbb06b20c2382f9c7847d198370ec6526af39c/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3d9908a48eb7416dc1f4524e69f1d32e5d90e3981e4e37eb0aa1cd18f9cfa2a4", size = 1733217, upload-time = "2026-01-03T17:31:06.868Z" }, - { url = "https://files.pythonhosted.org/packages/7d/79/e2176f46d2e963facea939f5be2d26368ce543622be6f00a12844d3c991f/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2712039939ec963c237286113c68dbad80a82a4281543f3abf766d9d73228998", size = 1552303, upload-time = "2026-01-03T17:31:08.958Z" }, - { url = "https://files.pythonhosted.org/packages/ab/6a/28ed4dea1759916090587d1fe57087b03e6c784a642b85ef48217b0277ae/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:7bfdc049127717581866fa4708791220970ce291c23e28ccf3922c700740fdc0", size = 1763673, upload-time = "2026-01-03T17:31:10.676Z" }, - { url = "https://files.pythonhosted.org/packages/e8/35/4a3daeb8b9fab49240d21c04d50732313295e4bd813a465d840236dd0ce1/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8057c98e0c8472d8846b9c79f56766bcc57e3e8ac7bfd510482332366c56c591", size = 1721120, upload-time = "2026-01-03T17:31:12.575Z" }, - { url = "https://files.pythonhosted.org/packages/bc/9f/d643bb3c5fb99547323e635e251c609fbbc660d983144cfebec529e09264/aiohttp-3.13.3-cp313-cp313-win32.whl", hash = "sha256:1449ceddcdbcf2e0446957863af03ebaaa03f94c090f945411b61269e2cb5daf", size = 427383, upload-time = "2026-01-03T17:31:14.382Z" }, - { url = "https://files.pythonhosted.org/packages/4e/f1/ab0395f8a79933577cdd996dd2f9aa6014af9535f65dddcf88204682fe62/aiohttp-3.13.3-cp313-cp313-win_amd64.whl", hash = "sha256:693781c45a4033d31d4187d2436f5ac701e7bbfe5df40d917736108c1cc7436e", size = 453899, upload-time = "2026-01-03T17:31:15.958Z" }, { url = "https://files.pythonhosted.org/packages/99/36/5b6514a9f5d66f4e2597e40dea2e3db271e023eb7a5d22defe96ba560996/aiohttp-3.13.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:ea37047c6b367fd4bd632bff8077449b8fa034b69e812a18e0132a00fae6e808", size = 737238, upload-time = "2026-01-03T17:31:17.909Z" }, { url = "https://files.pythonhosted.org/packages/f7/49/459327f0d5bcd8c6c9ca69e60fdeebc3622861e696490d8674a6d0cb90a6/aiohttp-3.13.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:6fc0e2337d1a4c3e6acafda6a78a39d4c14caea625124817420abceed36e2415", size = 492292, upload-time = "2026-01-03T17:31:19.919Z" }, { url = "https://files.pythonhosted.org/packages/e8/0b/b97660c5fd05d3495b4eb27f2d0ef18dc1dc4eff7511a9bf371397ff0264/aiohttp-3.13.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c685f2d80bb67ca8c3837823ad76196b3694b0159d232206d1e461d3d434666f", size = 493021, upload-time = "2026-01-03T17:31:21.636Z" }, @@ -127,7 +93,6 @@ version = "1.4.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "frozenlist" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007, upload-time = "2025-07-03T22:54:43.528Z" } wheels = [ @@ -152,30 +117,6 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" }, - { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" }, - { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, - { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" }, - { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" }, - { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" }, - { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" }, - { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" }, - { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" }, - { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, - { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, - { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, - { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, - { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, - { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, - { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, - { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, - { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, - { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, - { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, - { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, - { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, - { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, - { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, @@ -224,51 +165,6 @@ version = "7.13.4" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/24/56/95b7e30fa389756cb56630faa728da46a27b8c6eb46f9d557c68fff12b65/coverage-7.13.4.tar.gz", hash = "sha256:e5c8f6ed1e61a8b2dcdf31eb0b9bbf0130750ca79c1c49eb898e2ad86f5ccc91", size = 827239, upload-time = "2026-02-09T12:59:03.86Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d1/81/4ce2fdd909c5a0ed1f6dedb88aa57ab79b6d1fbd9b588c1ac7ef45659566/coverage-7.13.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:02231499b08dabbe2b96612993e5fc34217cdae907a51b906ac7fca8027a4459", size = 219449, upload-time = "2026-02-09T12:56:54.889Z" }, - { url = "https://files.pythonhosted.org/packages/5d/96/5238b1efc5922ddbdc9b0db9243152c09777804fb7c02ad1741eb18a11c0/coverage-7.13.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40aa8808140e55dc022b15d8aa7f651b6b3d68b365ea0398f1441e0b04d859c3", size = 219810, upload-time = "2026-02-09T12:56:56.33Z" }, - { url = "https://files.pythonhosted.org/packages/78/72/2f372b726d433c9c35e56377cf1d513b4c16fe51841060d826b95caacec1/coverage-7.13.4-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5b856a8ccf749480024ff3bd7310adaef57bf31fd17e1bfc404b7940b6986634", size = 251308, upload-time = "2026-02-09T12:56:57.858Z" }, - { url = "https://files.pythonhosted.org/packages/5d/a0/2ea570925524ef4e00bb6c82649f5682a77fac5ab910a65c9284de422600/coverage-7.13.4-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2c048ea43875fbf8b45d476ad79f179809c590ec7b79e2035c662e7afa3192e3", size = 254052, upload-time = "2026-02-09T12:56:59.754Z" }, - { url = "https://files.pythonhosted.org/packages/e8/ac/45dc2e19a1939098d783c846e130b8f862fbb50d09e0af663988f2f21973/coverage-7.13.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b7b38448866e83176e28086674fe7368ab8590e4610fb662b44e345b86d63ffa", size = 255165, upload-time = "2026-02-09T12:57:01.287Z" }, - { url = "https://files.pythonhosted.org/packages/2d/4d/26d236ff35abc3b5e63540d3386e4c3b192168c1d96da5cb2f43c640970f/coverage-7.13.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:de6defc1c9badbf8b9e67ae90fd00519186d6ab64e5cc5f3d21359c2a9b2c1d3", size = 257432, upload-time = "2026-02-09T12:57:02.637Z" }, - { url = "https://files.pythonhosted.org/packages/ec/55/14a966c757d1348b2e19caf699415a2a4c4f7feaa4bbc6326a51f5c7dd1b/coverage-7.13.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7eda778067ad7ffccd23ecffce537dface96212576a07924cbf0d8799d2ded5a", size = 251716, upload-time = "2026-02-09T12:57:04.056Z" }, - { url = "https://files.pythonhosted.org/packages/77/33/50116647905837c66d28b2af1321b845d5f5d19be9655cb84d4a0ea806b4/coverage-7.13.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e87f6c587c3f34356c3759f0420693e35e7eb0e2e41e4c011cb6ec6ecbbf1db7", size = 253089, upload-time = "2026-02-09T12:57:05.503Z" }, - { url = "https://files.pythonhosted.org/packages/c2/b4/8efb11a46e3665d92635a56e4f2d4529de6d33f2cb38afd47d779d15fc99/coverage-7.13.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:8248977c2e33aecb2ced42fef99f2d319e9904a36e55a8a68b69207fb7e43edc", size = 251232, upload-time = "2026-02-09T12:57:06.879Z" }, - { url = "https://files.pythonhosted.org/packages/51/24/8cd73dd399b812cc76bb0ac260e671c4163093441847ffe058ac9fda1e32/coverage-7.13.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:25381386e80ae727608e662474db537d4df1ecd42379b5ba33c84633a2b36d47", size = 255299, upload-time = "2026-02-09T12:57:08.245Z" }, - { url = "https://files.pythonhosted.org/packages/03/94/0a4b12f1d0e029ce1ccc1c800944a9984cbe7d678e470bb6d3c6bc38a0da/coverage-7.13.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:ee756f00726693e5ba94d6df2bdfd64d4852d23b09bb0bc700e3b30e6f333985", size = 250796, upload-time = "2026-02-09T12:57:10.142Z" }, - { url = "https://files.pythonhosted.org/packages/73/44/6002fbf88f6698ca034360ce474c406be6d5a985b3fdb3401128031eef6b/coverage-7.13.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fdfc1e28e7c7cdce44985b3043bc13bbd9c747520f94a4d7164af8260b3d91f0", size = 252673, upload-time = "2026-02-09T12:57:12.197Z" }, - { url = "https://files.pythonhosted.org/packages/de/c6/a0279f7c00e786be75a749a5674e6fa267bcbd8209cd10c9a450c655dfa7/coverage-7.13.4-cp312-cp312-win32.whl", hash = "sha256:01d4cbc3c283a17fc1e42d614a119f7f438eabb593391283adca8dc86eff1246", size = 221990, upload-time = "2026-02-09T12:57:14.085Z" }, - { url = "https://files.pythonhosted.org/packages/77/4e/c0a25a425fcf5557d9abd18419c95b63922e897bc86c1f327f155ef234a9/coverage-7.13.4-cp312-cp312-win_amd64.whl", hash = "sha256:9401ebc7ef522f01d01d45532c68c5ac40fb27113019b6b7d8b208f6e9baa126", size = 222800, upload-time = "2026-02-09T12:57:15.944Z" }, - { url = "https://files.pythonhosted.org/packages/47/ac/92da44ad9a6f4e3a7debd178949d6f3769bedca33830ce9b1dcdab589a37/coverage-7.13.4-cp312-cp312-win_arm64.whl", hash = "sha256:b1ec7b6b6e93255f952e27ab58fbc68dcc468844b16ecbee881aeb29b6ab4d8d", size = 221415, upload-time = "2026-02-09T12:57:17.497Z" }, - { url = "https://files.pythonhosted.org/packages/db/23/aad45061a31677d68e47499197a131eea55da4875d16c1f42021ab963503/coverage-7.13.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b66a2da594b6068b48b2692f043f35d4d3693fb639d5ea8b39533c2ad9ac3ab9", size = 219474, upload-time = "2026-02-09T12:57:19.332Z" }, - { url = "https://files.pythonhosted.org/packages/a5/70/9b8b67a0945f3dfec1fd896c5cefb7c19d5a3a6d74630b99a895170999ae/coverage-7.13.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3599eb3992d814d23b35c536c28df1a882caa950f8f507cef23d1cbf334995ac", size = 219844, upload-time = "2026-02-09T12:57:20.66Z" }, - { url = "https://files.pythonhosted.org/packages/97/fd/7e859f8fab324cef6c4ad7cff156ca7c489fef9179d5749b0c8d321281c2/coverage-7.13.4-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:93550784d9281e374fb5a12bf1324cc8a963fd63b2d2f223503ef0fd4aa339ea", size = 250832, upload-time = "2026-02-09T12:57:22.007Z" }, - { url = "https://files.pythonhosted.org/packages/e4/dc/b2442d10020c2f52617828862d8b6ee337859cd8f3a1f13d607dddda9cf7/coverage-7.13.4-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b720ce6a88a2755f7c697c23268ddc47a571b88052e6b155224347389fdf6a3b", size = 253434, upload-time = "2026-02-09T12:57:23.339Z" }, - { url = "https://files.pythonhosted.org/packages/5a/88/6728a7ad17428b18d836540630487231f5470fb82454871149502f5e5aa2/coverage-7.13.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7b322db1284a2ed3aa28ffd8ebe3db91c929b7a333c0820abec3d838ef5b3525", size = 254676, upload-time = "2026-02-09T12:57:24.774Z" }, - { url = "https://files.pythonhosted.org/packages/7c/bc/21244b1b8cedf0dff0a2b53b208015fe798d5f2a8d5348dbfece04224fff/coverage-7.13.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f4594c67d8a7c89cf922d9df0438c7c7bb022ad506eddb0fdb2863359ff78242", size = 256807, upload-time = "2026-02-09T12:57:26.125Z" }, - { url = "https://files.pythonhosted.org/packages/97/a0/ddba7ed3251cff51006737a727d84e05b61517d1784a9988a846ba508877/coverage-7.13.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:53d133df809c743eb8bce33b24bcababb371f4441340578cd406e084d94a6148", size = 251058, upload-time = "2026-02-09T12:57:27.614Z" }, - { url = "https://files.pythonhosted.org/packages/9b/55/e289addf7ff54d3a540526f33751951bf0878f3809b47f6dfb3def69c6f7/coverage-7.13.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:76451d1978b95ba6507a039090ba076105c87cc76fc3efd5d35d72093964d49a", size = 252805, upload-time = "2026-02-09T12:57:29.066Z" }, - { url = "https://files.pythonhosted.org/packages/13/4e/cc276b1fa4a59be56d96f1dabddbdc30f4ba22e3b1cd42504c37b3313255/coverage-7.13.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:7f57b33491e281e962021de110b451ab8a24182589be17e12a22c79047935e23", size = 250766, upload-time = "2026-02-09T12:57:30.522Z" }, - { url = "https://files.pythonhosted.org/packages/94/44/1093b8f93018f8b41a8cf29636c9292502f05e4a113d4d107d14a3acd044/coverage-7.13.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:1731dc33dc276dafc410a885cbf5992f1ff171393e48a21453b78727d090de80", size = 254923, upload-time = "2026-02-09T12:57:31.946Z" }, - { url = "https://files.pythonhosted.org/packages/8b/55/ea2796da2d42257f37dbea1aab239ba9263b31bd91d5527cdd6db5efe174/coverage-7.13.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:bd60d4fe2f6fa7dff9223ca1bbc9f05d2b6697bc5961072e5d3b952d46e1b1ea", size = 250591, upload-time = "2026-02-09T12:57:33.842Z" }, - { url = "https://files.pythonhosted.org/packages/d4/fa/7c4bb72aacf8af5020675aa633e59c1fbe296d22aed191b6a5b711eb2bc7/coverage-7.13.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9181a3ccead280b828fae232df12b16652702b49d41e99d657f46cc7b1f6ec7a", size = 252364, upload-time = "2026-02-09T12:57:35.743Z" }, - { url = "https://files.pythonhosted.org/packages/5c/38/a8d2ec0146479c20bbaa7181b5b455a0c41101eed57f10dd19a78ab44c80/coverage-7.13.4-cp313-cp313-win32.whl", hash = "sha256:f53d492307962561ac7de4cd1de3e363589b000ab69617c6156a16ba7237998d", size = 222010, upload-time = "2026-02-09T12:57:37.25Z" }, - { url = "https://files.pythonhosted.org/packages/e2/0c/dbfafbe90a185943dcfbc766fe0e1909f658811492d79b741523a414a6cc/coverage-7.13.4-cp313-cp313-win_amd64.whl", hash = "sha256:e6f70dec1cc557e52df5306d051ef56003f74d56e9c4dd7ddb07e07ef32a84dd", size = 222818, upload-time = "2026-02-09T12:57:38.734Z" }, - { url = "https://files.pythonhosted.org/packages/04/d1/934918a138c932c90d78301f45f677fb05c39a3112b96fd2c8e60503cdc7/coverage-7.13.4-cp313-cp313-win_arm64.whl", hash = "sha256:fb07dc5da7e849e2ad31a5d74e9bece81f30ecf5a42909d0a695f8bd1874d6af", size = 221438, upload-time = "2026-02-09T12:57:40.223Z" }, - { url = "https://files.pythonhosted.org/packages/52/57/ee93ced533bcb3e6df961c0c6e42da2fc6addae53fb95b94a89b1e33ebd7/coverage-7.13.4-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:40d74da8e6c4b9ac18b15331c4b5ebc35a17069410cad462ad4f40dcd2d50c0d", size = 220165, upload-time = "2026-02-09T12:57:41.639Z" }, - { url = "https://files.pythonhosted.org/packages/c5/e0/969fc285a6fbdda49d91af278488d904dcd7651b2693872f0ff94e40e84a/coverage-7.13.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4223b4230a376138939a9173f1bdd6521994f2aff8047fae100d6d94d50c5a12", size = 220516, upload-time = "2026-02-09T12:57:44.215Z" }, - { url = "https://files.pythonhosted.org/packages/b1/b8/9531944e16267e2735a30a9641ff49671f07e8138ecf1ca13db9fd2560c7/coverage-7.13.4-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1d4be36a5114c499f9f1f9195e95ebf979460dbe2d88e6816ea202010ba1c34b", size = 261804, upload-time = "2026-02-09T12:57:45.989Z" }, - { url = "https://files.pythonhosted.org/packages/8a/f3/e63df6d500314a2a60390d1989240d5f27318a7a68fa30ad3806e2a9323e/coverage-7.13.4-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:200dea7d1e8095cc6e98cdabe3fd1d21ab17d3cee6dab00cadbb2fe35d9c15b9", size = 263885, upload-time = "2026-02-09T12:57:47.42Z" }, - { url = "https://files.pythonhosted.org/packages/f3/67/7654810de580e14b37670b60a09c599fa348e48312db5b216d730857ffe6/coverage-7.13.4-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b8eb931ee8e6d8243e253e5ed7336deea6904369d2fd8ae6e43f68abbf167092", size = 266308, upload-time = "2026-02-09T12:57:49.345Z" }, - { url = "https://files.pythonhosted.org/packages/37/6f/39d41eca0eab3cc82115953ad41c4e77935286c930e8fad15eaed1389d83/coverage-7.13.4-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:75eab1ebe4f2f64d9509b984f9314d4aa788540368218b858dad56dc8f3e5eb9", size = 267452, upload-time = "2026-02-09T12:57:50.811Z" }, - { url = "https://files.pythonhosted.org/packages/50/6d/39c0fbb8fc5cd4d2090811e553c2108cf5112e882f82505ee7495349a6bf/coverage-7.13.4-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c35eb28c1d085eb7d8c9b3296567a1bebe03ce72962e932431b9a61f28facf26", size = 261057, upload-time = "2026-02-09T12:57:52.447Z" }, - { url = "https://files.pythonhosted.org/packages/a4/a2/60010c669df5fa603bb5a97fb75407e191a846510da70ac657eb696b7fce/coverage-7.13.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:eb88b316ec33760714a4720feb2816a3a59180fd58c1985012054fa7aebee4c2", size = 263875, upload-time = "2026-02-09T12:57:53.938Z" }, - { url = "https://files.pythonhosted.org/packages/3e/d9/63b22a6bdbd17f1f96e9ed58604c2a6b0e72a9133e37d663bef185877cf6/coverage-7.13.4-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:7d41eead3cc673cbd38a4417deb7fd0b4ca26954ff7dc6078e33f6ff97bed940", size = 261500, upload-time = "2026-02-09T12:57:56.012Z" }, - { url = "https://files.pythonhosted.org/packages/70/bf/69f86ba1ad85bc3ad240e4c0e57a2e620fbc0e1645a47b5c62f0e941ad7f/coverage-7.13.4-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:fb26a934946a6afe0e326aebe0730cdff393a8bc0bbb65a2f41e30feddca399c", size = 265212, upload-time = "2026-02-09T12:57:57.5Z" }, - { url = "https://files.pythonhosted.org/packages/ae/f2/5f65a278a8c2148731831574c73e42f57204243d33bedaaf18fa79c5958f/coverage-7.13.4-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:dae88bc0fc77edaa65c14be099bd57ee140cf507e6bfdeea7938457ab387efb0", size = 260398, upload-time = "2026-02-09T12:57:59.027Z" }, - { url = "https://files.pythonhosted.org/packages/ef/80/6e8280a350ee9fea92f14b8357448a242dcaa243cb2c72ab0ca591f66c8c/coverage-7.13.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:845f352911777a8e722bfce168958214951e07e47e5d5d9744109fa5fe77f79b", size = 262584, upload-time = "2026-02-09T12:58:01.129Z" }, - { url = "https://files.pythonhosted.org/packages/22/63/01ff182fc95f260b539590fb12c11ad3e21332c15f9799cb5e2386f71d9f/coverage-7.13.4-cp313-cp313t-win32.whl", hash = "sha256:2fa8d5f8de70688a28240de9e139fa16b153cc3cbb01c5f16d88d6505ebdadf9", size = 222688, upload-time = "2026-02-09T12:58:02.736Z" }, - { url = "https://files.pythonhosted.org/packages/a9/43/89de4ef5d3cd53b886afa114065f7e9d3707bdb3e5efae13535b46ae483d/coverage-7.13.4-cp313-cp313t-win_amd64.whl", hash = "sha256:9351229c8c8407645840edcc277f4a2d44814d1bc34a2128c11c2a031d45a5dd", size = 223746, upload-time = "2026-02-09T12:58:05.362Z" }, - { url = "https://files.pythonhosted.org/packages/35/39/7cf0aa9a10d470a5309b38b289b9bb07ddeac5d61af9b664fe9775a4cb3e/coverage-7.13.4-cp313-cp313t-win_arm64.whl", hash = "sha256:30b8d0512f2dc8c8747557e8fb459d6176a2c9e5731e2b74d311c03b78451997", size = 222003, upload-time = "2026-02-09T12:58:06.952Z" }, { url = "https://files.pythonhosted.org/packages/92/11/a9cf762bb83386467737d32187756a42094927150c3e107df4cb078e8590/coverage-7.13.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:300deaee342f90696ed186e3a00c71b5b3d27bffe9e827677954f4ee56969601", size = 219522, upload-time = "2026-02-09T12:58:08.623Z" }, { url = "https://files.pythonhosted.org/packages/d3/28/56e6d892b7b052236d67c95f1936b6a7cf7c3e2634bf27610b8cbd7f9c60/coverage-7.13.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:29e3220258d682b6226a9b0925bc563ed9a1ebcff3cad30f043eceea7eaf2689", size = 219855, upload-time = "2026-02-09T12:58:10.176Z" }, { url = "https://files.pythonhosted.org/packages/e5/69/233459ee9eb0c0d10fcc2fe425a029b3fa5ce0f040c966ebce851d030c70/coverage-7.13.4-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:391ee8f19bef69210978363ca930f7328081c6a0152f1166c91f0b5fdd2a773c", size = 250887, upload-time = "2026-02-09T12:58:12.503Z" }, @@ -387,54 +283,6 @@ version = "1.8.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/2d/f5/c831fac6cc817d26fd54c7eaccd04ef7e0288806943f7cc5bbf69f3ac1f0/frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad", size = 45875, upload-time = "2025-10-06T05:38:17.865Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/69/29/948b9aa87e75820a38650af445d2ef2b6b8a6fab1a23b6bb9e4ef0be2d59/frozenlist-1.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:78f7b9e5d6f2fdb88cdde9440dc147259b62b9d3b019924def9f6478be254ac1", size = 87782, upload-time = "2025-10-06T05:36:06.649Z" }, - { url = "https://files.pythonhosted.org/packages/64/80/4f6e318ee2a7c0750ed724fa33a4bdf1eacdc5a39a7a24e818a773cd91af/frozenlist-1.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:229bf37d2e4acdaf808fd3f06e854a4a7a3661e871b10dc1f8f1896a3b05f18b", size = 50594, upload-time = "2025-10-06T05:36:07.69Z" }, - { url = "https://files.pythonhosted.org/packages/2b/94/5c8a2b50a496b11dd519f4a24cb5496cf125681dd99e94c604ccdea9419a/frozenlist-1.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f833670942247a14eafbb675458b4e61c82e002a148f49e68257b79296e865c4", size = 50448, upload-time = "2025-10-06T05:36:08.78Z" }, - { url = "https://files.pythonhosted.org/packages/6a/bd/d91c5e39f490a49df14320f4e8c80161cfcce09f1e2cde1edd16a551abb3/frozenlist-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:494a5952b1c597ba44e0e78113a7266e656b9794eec897b19ead706bd7074383", size = 242411, upload-time = "2025-10-06T05:36:09.801Z" }, - { url = "https://files.pythonhosted.org/packages/8f/83/f61505a05109ef3293dfb1ff594d13d64a2324ac3482be2cedc2be818256/frozenlist-1.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96f423a119f4777a4a056b66ce11527366a8bb92f54e541ade21f2374433f6d4", size = 243014, upload-time = "2025-10-06T05:36:11.394Z" }, - { url = "https://files.pythonhosted.org/packages/d8/cb/cb6c7b0f7d4023ddda30cf56b8b17494eb3a79e3fda666bf735f63118b35/frozenlist-1.8.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3462dd9475af2025c31cc61be6652dfa25cbfb56cbbf52f4ccfe029f38decaf8", size = 234909, upload-time = "2025-10-06T05:36:12.598Z" }, - { url = "https://files.pythonhosted.org/packages/31/c5/cd7a1f3b8b34af009fb17d4123c5a778b44ae2804e3ad6b86204255f9ec5/frozenlist-1.8.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4c800524c9cd9bac5166cd6f55285957fcfc907db323e193f2afcd4d9abd69b", size = 250049, upload-time = "2025-10-06T05:36:14.065Z" }, - { url = "https://files.pythonhosted.org/packages/c0/01/2f95d3b416c584a1e7f0e1d6d31998c4a795f7544069ee2e0962a4b60740/frozenlist-1.8.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d6a5df73acd3399d893dafc71663ad22534b5aa4f94e8a2fabfe856c3c1b6a52", size = 256485, upload-time = "2025-10-06T05:36:15.39Z" }, - { url = "https://files.pythonhosted.org/packages/ce/03/024bf7720b3abaebcff6d0793d73c154237b85bdf67b7ed55e5e9596dc9a/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:405e8fe955c2280ce66428b3ca55e12b3c4e9c336fb2103a4937e891c69a4a29", size = 237619, upload-time = "2025-10-06T05:36:16.558Z" }, - { url = "https://files.pythonhosted.org/packages/69/fa/f8abdfe7d76b731f5d8bd217827cf6764d4f1d9763407e42717b4bed50a0/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:908bd3f6439f2fef9e85031b59fd4f1297af54415fb60e4254a95f75b3cab3f3", size = 250320, upload-time = "2025-10-06T05:36:17.821Z" }, - { url = "https://files.pythonhosted.org/packages/f5/3c/b051329f718b463b22613e269ad72138cc256c540f78a6de89452803a47d/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:294e487f9ec720bd8ffcebc99d575f7eff3568a08a253d1ee1a0378754b74143", size = 246820, upload-time = "2025-10-06T05:36:19.046Z" }, - { url = "https://files.pythonhosted.org/packages/0f/ae/58282e8f98e444b3f4dd42448ff36fa38bef29e40d40f330b22e7108f565/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:74c51543498289c0c43656701be6b077f4b265868fa7f8a8859c197006efb608", size = 250518, upload-time = "2025-10-06T05:36:20.763Z" }, - { url = "https://files.pythonhosted.org/packages/8f/96/007e5944694d66123183845a106547a15944fbbb7154788cbf7272789536/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:776f352e8329135506a1d6bf16ac3f87bc25b28e765949282dcc627af36123aa", size = 239096, upload-time = "2025-10-06T05:36:22.129Z" }, - { url = "https://files.pythonhosted.org/packages/66/bb/852b9d6db2fa40be96f29c0d1205c306288f0684df8fd26ca1951d461a56/frozenlist-1.8.0-cp312-cp312-win32.whl", hash = "sha256:433403ae80709741ce34038da08511d4a77062aa924baf411ef73d1146e74faf", size = 39985, upload-time = "2025-10-06T05:36:23.661Z" }, - { url = "https://files.pythonhosted.org/packages/b8/af/38e51a553dd66eb064cdf193841f16f077585d4d28394c2fa6235cb41765/frozenlist-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:34187385b08f866104f0c0617404c8eb08165ab1272e884abc89c112e9c00746", size = 44591, upload-time = "2025-10-06T05:36:24.958Z" }, - { url = "https://files.pythonhosted.org/packages/a7/06/1dc65480ab147339fecc70797e9c2f69d9cea9cf38934ce08df070fdb9cb/frozenlist-1.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:fe3c58d2f5db5fbd18c2987cba06d51b0529f52bc3a6cdc33d3f4eab725104bd", size = 40102, upload-time = "2025-10-06T05:36:26.333Z" }, - { url = "https://files.pythonhosted.org/packages/2d/40/0832c31a37d60f60ed79e9dfb5a92e1e2af4f40a16a29abcc7992af9edff/frozenlist-1.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8d92f1a84bb12d9e56f818b3a746f3efba93c1b63c8387a73dde655e1e42282a", size = 85717, upload-time = "2025-10-06T05:36:27.341Z" }, - { url = "https://files.pythonhosted.org/packages/30/ba/b0b3de23f40bc55a7057bd38434e25c34fa48e17f20ee273bbde5e0650f3/frozenlist-1.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:96153e77a591c8adc2ee805756c61f59fef4cf4073a9275ee86fe8cba41241f7", size = 49651, upload-time = "2025-10-06T05:36:28.855Z" }, - { url = "https://files.pythonhosted.org/packages/0c/ab/6e5080ee374f875296c4243c381bbdef97a9ac39c6e3ce1d5f7d42cb78d6/frozenlist-1.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f21f00a91358803399890ab167098c131ec2ddd5f8f5fd5fe9c9f2c6fcd91e40", size = 49417, upload-time = "2025-10-06T05:36:29.877Z" }, - { url = "https://files.pythonhosted.org/packages/d5/4e/e4691508f9477ce67da2015d8c00acd751e6287739123113a9fca6f1604e/frozenlist-1.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fb30f9626572a76dfe4293c7194a09fb1fe93ba94c7d4f720dfae3b646b45027", size = 234391, upload-time = "2025-10-06T05:36:31.301Z" }, - { url = "https://files.pythonhosted.org/packages/40/76/c202df58e3acdf12969a7895fd6f3bc016c642e6726aa63bd3025e0fc71c/frozenlist-1.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eaa352d7047a31d87dafcacbabe89df0aa506abb5b1b85a2fb91bc3faa02d822", size = 233048, upload-time = "2025-10-06T05:36:32.531Z" }, - { url = "https://files.pythonhosted.org/packages/f9/c0/8746afb90f17b73ca5979c7a3958116e105ff796e718575175319b5bb4ce/frozenlist-1.8.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:03ae967b4e297f58f8c774c7eabcce57fe3c2434817d4385c50661845a058121", size = 226549, upload-time = "2025-10-06T05:36:33.706Z" }, - { url = "https://files.pythonhosted.org/packages/7e/eb/4c7eefc718ff72f9b6c4893291abaae5fbc0c82226a32dcd8ef4f7a5dbef/frozenlist-1.8.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f6292f1de555ffcc675941d65fffffb0a5bcd992905015f85d0592201793e0e5", size = 239833, upload-time = "2025-10-06T05:36:34.947Z" }, - { url = "https://files.pythonhosted.org/packages/c2/4e/e5c02187cf704224f8b21bee886f3d713ca379535f16893233b9d672ea71/frozenlist-1.8.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29548f9b5b5e3460ce7378144c3010363d8035cea44bc0bf02d57f5a685e084e", size = 245363, upload-time = "2025-10-06T05:36:36.534Z" }, - { url = "https://files.pythonhosted.org/packages/1f/96/cb85ec608464472e82ad37a17f844889c36100eed57bea094518bf270692/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ec3cc8c5d4084591b4237c0a272cc4f50a5b03396a47d9caaf76f5d7b38a4f11", size = 229314, upload-time = "2025-10-06T05:36:38.582Z" }, - { url = "https://files.pythonhosted.org/packages/5d/6f/4ae69c550e4cee66b57887daeebe006fe985917c01d0fff9caab9883f6d0/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:517279f58009d0b1f2e7c1b130b377a349405da3f7621ed6bfae50b10adf20c1", size = 243365, upload-time = "2025-10-06T05:36:40.152Z" }, - { url = "https://files.pythonhosted.org/packages/7a/58/afd56de246cf11780a40a2c28dc7cbabbf06337cc8ddb1c780a2d97e88d8/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:db1e72ede2d0d7ccb213f218df6a078a9c09a7de257c2fe8fcef16d5925230b1", size = 237763, upload-time = "2025-10-06T05:36:41.355Z" }, - { url = "https://files.pythonhosted.org/packages/cb/36/cdfaf6ed42e2644740d4a10452d8e97fa1c062e2a8006e4b09f1b5fd7d63/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b4dec9482a65c54a5044486847b8a66bf10c9cb4926d42927ec4e8fd5db7fed8", size = 240110, upload-time = "2025-10-06T05:36:42.716Z" }, - { url = "https://files.pythonhosted.org/packages/03/a8/9ea226fbefad669f11b52e864c55f0bd57d3c8d7eb07e9f2e9a0b39502e1/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:21900c48ae04d13d416f0e1e0c4d81f7931f73a9dfa0b7a8746fb2fe7dd970ed", size = 233717, upload-time = "2025-10-06T05:36:44.251Z" }, - { url = "https://files.pythonhosted.org/packages/1e/0b/1b5531611e83ba7d13ccc9988967ea1b51186af64c42b7a7af465dcc9568/frozenlist-1.8.0-cp313-cp313-win32.whl", hash = "sha256:8b7b94a067d1c504ee0b16def57ad5738701e4ba10cec90529f13fa03c833496", size = 39628, upload-time = "2025-10-06T05:36:45.423Z" }, - { url = "https://files.pythonhosted.org/packages/d8/cf/174c91dbc9cc49bc7b7aab74d8b734e974d1faa8f191c74af9b7e80848e6/frozenlist-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:878be833caa6a3821caf85eb39c5ba92d28e85df26d57afb06b35b2efd937231", size = 43882, upload-time = "2025-10-06T05:36:46.796Z" }, - { url = "https://files.pythonhosted.org/packages/c1/17/502cd212cbfa96eb1388614fe39a3fc9ab87dbbe042b66f97acb57474834/frozenlist-1.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:44389d135b3ff43ba8cc89ff7f51f5a0bb6b63d829c8300f79a2fe4fe61bcc62", size = 39676, upload-time = "2025-10-06T05:36:47.8Z" }, - { url = "https://files.pythonhosted.org/packages/d2/5c/3bbfaa920dfab09e76946a5d2833a7cbdf7b9b4a91c714666ac4855b88b4/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:e25ac20a2ef37e91c1b39938b591457666a0fa835c7783c3a8f33ea42870db94", size = 89235, upload-time = "2025-10-06T05:36:48.78Z" }, - { url = "https://files.pythonhosted.org/packages/d2/d6/f03961ef72166cec1687e84e8925838442b615bd0b8854b54923ce5b7b8a/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:07cdca25a91a4386d2e76ad992916a85038a9b97561bf7a3fd12d5d9ce31870c", size = 50742, upload-time = "2025-10-06T05:36:49.837Z" }, - { url = "https://files.pythonhosted.org/packages/1e/bb/a6d12b7ba4c3337667d0e421f7181c82dda448ce4e7ad7ecd249a16fa806/frozenlist-1.8.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4e0c11f2cc6717e0a741f84a527c52616140741cd812a50422f83dc31749fb52", size = 51725, upload-time = "2025-10-06T05:36:50.851Z" }, - { url = "https://files.pythonhosted.org/packages/bc/71/d1fed0ffe2c2ccd70b43714c6cab0f4188f09f8a67a7914a6b46ee30f274/frozenlist-1.8.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b3210649ee28062ea6099cfda39e147fa1bc039583c8ee4481cb7811e2448c51", size = 284533, upload-time = "2025-10-06T05:36:51.898Z" }, - { url = "https://files.pythonhosted.org/packages/c9/1f/fb1685a7b009d89f9bf78a42d94461bc06581f6e718c39344754a5d9bada/frozenlist-1.8.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:581ef5194c48035a7de2aefc72ac6539823bb71508189e5de01d60c9dcd5fa65", size = 292506, upload-time = "2025-10-06T05:36:53.101Z" }, - { url = "https://files.pythonhosted.org/packages/e6/3b/b991fe1612703f7e0d05c0cf734c1b77aaf7c7d321df4572e8d36e7048c8/frozenlist-1.8.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3ef2d026f16a2b1866e1d86fc4e1291e1ed8a387b2c333809419a2f8b3a77b82", size = 274161, upload-time = "2025-10-06T05:36:54.309Z" }, - { url = "https://files.pythonhosted.org/packages/ca/ec/c5c618767bcdf66e88945ec0157d7f6c4a1322f1473392319b7a2501ded7/frozenlist-1.8.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5500ef82073f599ac84d888e3a8c1f77ac831183244bfd7f11eaa0289fb30714", size = 294676, upload-time = "2025-10-06T05:36:55.566Z" }, - { url = "https://files.pythonhosted.org/packages/7c/ce/3934758637d8f8a88d11f0585d6495ef54b2044ed6ec84492a91fa3b27aa/frozenlist-1.8.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50066c3997d0091c411a66e710f4e11752251e6d2d73d70d8d5d4c76442a199d", size = 300638, upload-time = "2025-10-06T05:36:56.758Z" }, - { url = "https://files.pythonhosted.org/packages/fc/4f/a7e4d0d467298f42de4b41cbc7ddaf19d3cfeabaf9ff97c20c6c7ee409f9/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5c1c8e78426e59b3f8005e9b19f6ff46e5845895adbde20ece9218319eca6506", size = 283067, upload-time = "2025-10-06T05:36:57.965Z" }, - { url = "https://files.pythonhosted.org/packages/dc/48/c7b163063d55a83772b268e6d1affb960771b0e203b632cfe09522d67ea5/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:eefdba20de0d938cec6a89bd4d70f346a03108a19b9df4248d3cf0d88f1b0f51", size = 292101, upload-time = "2025-10-06T05:36:59.237Z" }, - { url = "https://files.pythonhosted.org/packages/9f/d0/2366d3c4ecdc2fd391e0afa6e11500bfba0ea772764d631bbf82f0136c9d/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:cf253e0e1c3ceb4aaff6df637ce033ff6535fb8c70a764a8f46aafd3d6ab798e", size = 289901, upload-time = "2025-10-06T05:37:00.811Z" }, - { url = "https://files.pythonhosted.org/packages/b8/94/daff920e82c1b70e3618a2ac39fbc01ae3e2ff6124e80739ce5d71c9b920/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:032efa2674356903cd0261c4317a561a6850f3ac864a63fc1583147fb05a79b0", size = 289395, upload-time = "2025-10-06T05:37:02.115Z" }, - { url = "https://files.pythonhosted.org/packages/e3/20/bba307ab4235a09fdcd3cc5508dbabd17c4634a1af4b96e0f69bfe551ebd/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6da155091429aeba16851ecb10a9104a108bcd32f6c1642867eadaee401c1c41", size = 283659, upload-time = "2025-10-06T05:37:03.711Z" }, - { url = "https://files.pythonhosted.org/packages/fd/00/04ca1c3a7a124b6de4f8a9a17cc2fcad138b4608e7a3fc5877804b8715d7/frozenlist-1.8.0-cp313-cp313t-win32.whl", hash = "sha256:0f96534f8bfebc1a394209427d0f8a63d343c9779cda6fc25e8e121b5fd8555b", size = 43492, upload-time = "2025-10-06T05:37:04.915Z" }, - { url = "https://files.pythonhosted.org/packages/59/5e/c69f733a86a94ab10f68e496dc6b7e8bc078ebb415281d5698313e3af3a1/frozenlist-1.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:5d63a068f978fc69421fb0e6eb91a9603187527c86b7cd3f534a5b77a592b888", size = 48034, upload-time = "2025-10-06T05:37:06.343Z" }, - { url = "https://files.pythonhosted.org/packages/16/6c/be9d79775d8abe79b05fa6d23da99ad6e7763a1d080fbae7290b286093fd/frozenlist-1.8.0-cp313-cp313t-win_arm64.whl", hash = "sha256:bf0a7e10b077bf5fb9380ad3ae8ce20ef919a6ad93b4552896419ac7e1d8e042", size = 41749, upload-time = "2025-10-06T05:37:07.431Z" }, { url = "https://files.pythonhosted.org/packages/f1/c8/85da824b7e7b9b6e7f7705b2ecaf9591ba6f79c1177f324c2735e41d36a2/frozenlist-1.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cee686f1f4cadeb2136007ddedd0aaf928ab95216e7691c63e50a8ec066336d0", size = 86127, upload-time = "2025-10-06T05:37:08.438Z" }, { url = "https://files.pythonhosted.org/packages/8e/e8/a1185e236ec66c20afd72399522f142c3724c785789255202d27ae992818/frozenlist-1.8.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:119fb2a1bd47307e899c2fac7f28e85b9a543864df47aa7ec9d3c1b4545f096f", size = 49698, upload-time = "2025-10-06T05:37:09.48Z" }, { url = "https://files.pythonhosted.org/packages/a1/93/72b1736d68f03fda5fdf0f2180fb6caaae3894f1b854d006ac61ecc727ee/frozenlist-1.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4970ece02dbc8c3a92fcc5228e36a3e933a01a999f7094ff7c23fbd2beeaa67c", size = 49749, upload-time = "2025-10-06T05:37:10.569Z" }, @@ -476,18 +324,6 @@ version = "4.4.2" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/0a/8e/ebe086a820bbe02a932db9cbff9c1730cba9a6e9a0a140d4a2ed0ce7508a/h3-4.4.2.tar.gz", hash = "sha256:b25ab9f339e40b10dcb5e2e6988d07672e780a4950d79c25380d308cdf14f82f", size = 171891, upload-time = "2026-01-29T19:22:55.41Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/05/8a/a4d2d7b0b750d558542999f19d188a22333eb6e4db89d9938feeca8534a7/h3-4.4.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0f79e9ee215e6896dae41c93ebf95b3fc594818feecbcecf7833e1f8b9c892b7", size = 822106, upload-time = "2026-01-29T19:22:17.39Z" }, - { url = "https://files.pythonhosted.org/packages/8d/ae/6ef2c13d2267c04ba32e056dfda2d93265ceca51ba16a36a53084c56e157/h3-4.4.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:03b423232cafe89b4b06c2f8c636a7c04efecf6cc0f539e4eaa6301a9b21fd46", size = 974881, upload-time = "2026-01-29T19:22:18.505Z" }, - { url = "https://files.pythonhosted.org/packages/24/2c/b135f8ea5fb89f9cc0e0084cb8ff0885d5df1c0b7117f2b71400fe54f51f/h3-4.4.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0947c86532005870eb5565bda80270dc37029f34ffa4903e5f353ce5d4acab41", size = 1019313, upload-time = "2026-01-29T19:22:19.488Z" }, - { url = "https://files.pythonhosted.org/packages/9d/ea/7aa732f2c8b4bdabe3c5ab36a58149963fd1bf1d556e976a3fbea35def2d/h3-4.4.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:06267484978c7df7d8e675dfab660967387f9ef154c7452ae38b578d80641445", size = 1030677, upload-time = "2026-01-29T19:22:20.534Z" }, - { url = "https://files.pythonhosted.org/packages/f8/86/b92030c634572b9f5e93a3fa626c68576e4a12e6a99e5117f85cc7b7fbd9/h3-4.4.2-cp312-cp312-win_amd64.whl", hash = "sha256:8d612b6dc0ccca41fe4bdcfe3b59915c9455aa791db031b49f05d48f243fcc05", size = 787290, upload-time = "2026-01-29T19:22:21.572Z" }, - { url = "https://files.pythonhosted.org/packages/ab/06/895d6489052e50a0eb292507a93f53763bf711a27451312ea2149622f811/h3-4.4.2-cp312-cp312-win_arm64.whl", hash = "sha256:6f1e76252ed43fd58f92fbf29d4d0d0aa9af26b97c31d2ea0a6d38f074f89e78", size = 704310, upload-time = "2026-01-29T19:22:22.595Z" }, - { url = "https://files.pythonhosted.org/packages/1c/8c/52a6d8fea33fa0f32a2eb84f1e96eea6efd6f8f8c5119797f555dcee8417/h3-4.4.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:76dbf27b8bdf0d21275ad5f16206385208c11e6f1b96d5a88c9df974ff6b8fe5", size = 817555, upload-time = "2026-01-29T19:22:24.228Z" }, - { url = "https://files.pythonhosted.org/packages/a6/df/44a1c9807d3635edc2b996a9161d991b77935d2f14a740296c28ee675b19/h3-4.4.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9b61e8d5cafd39434c036a905d34729e99c3ebede591a92e8532840cad41d51f", size = 971465, upload-time = "2026-01-29T19:22:26.138Z" }, - { url = "https://files.pythonhosted.org/packages/45/a6/9cac4b5ea1dad5767a1b6dcc2eb8255a5bc5ec2c968e558484fcde328304/h3-4.4.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e243abf0f738d7ef5683ee52e3dd1ab0abebe9c38d05d485b5aff7cec97be3e9", size = 1014979, upload-time = "2026-01-29T19:22:27.189Z" }, - { url = "https://files.pythonhosted.org/packages/14/fb/8371d46fc3979b9e40ab7ea8636ebc54267982a27936704c98e2f975067a/h3-4.4.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:bc0059c73dabf82d9be29f524d1bd8cb670b903406d11d02e5826acd8b2f887c", size = 1026394, upload-time = "2026-01-29T19:22:28.754Z" }, - { url = "https://files.pythonhosted.org/packages/82/a7/283e814577d80a6de11f8ef352501cbce7402308a2de20a282eefa9c1fce/h3-4.4.2-cp313-cp313-win_amd64.whl", hash = "sha256:1ddff977c69afca3bd37af0c97a942ab35d5a98830af2ba9b7450ee0365d5852", size = 784314, upload-time = "2026-01-29T19:22:29.798Z" }, - { url = "https://files.pythonhosted.org/packages/71/0a/b95da51292b14d7cc28884770f393b85c6f419ba3040d45a5d7493cf97c1/h3-4.4.2-cp313-cp313-win_arm64.whl", hash = "sha256:ff52be8019c943cc124f99664c61eb0dddf86f1c35cddce10685d86bc0e609ad", size = 702391, upload-time = "2026-01-29T19:22:30.942Z" }, { url = "https://files.pythonhosted.org/packages/4c/d9/0a06b7b907200ef861c809bc5410d5e6d39930f1e95316a3c98ce351a5b5/h3-4.4.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4afd9362353852e751029e2f1933af9e0606bd0903f9e48daf35a3ded26251ad", size = 821574, upload-time = "2026-01-29T19:22:32.236Z" }, { url = "https://files.pythonhosted.org/packages/1b/20/679efa28e708192bc8bc686b4cb6e7882a2aa8df97e9cce4dac520599bd7/h3-4.4.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f3b09636f08ab5213d703940638eaf58c8d1d8c0d0e13405b72b4d48a3e06c38", size = 979796, upload-time = "2026-01-29T19:22:33.21Z" }, { url = "https://files.pythonhosted.org/packages/43/a1/0dc47be62f67e141eb6e12e77aae135414875fea4d3199ec173391a20e85/h3-4.4.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:aa8fd684e0e0d367c65d94c33b4bc6653524688ee9d1099a5f92e46c3db4b11e", size = 1018019, upload-time = "2026-01-29T19:22:34.279Z" }, @@ -553,32 +389,6 @@ version = "0.8.1" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/56/9c/b4b0c54d84da4a94b37bd44151e46d5e583c9534c7e02250b961b1b6d8a8/librt-0.8.1.tar.gz", hash = "sha256:be46a14693955b3bd96014ccbdb8339ee8c9346fbe11c1b78901b55125f14c73", size = 177471, upload-time = "2026-02-17T16:13:06.101Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/95/21/d39b0a87ac52fc98f621fb6f8060efb017a767ebbbac2f99fbcbc9ddc0d7/librt-0.8.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a28f2612ab566b17f3698b0da021ff9960610301607c9a5e8eaca62f5e1c350a", size = 66516, upload-time = "2026-02-17T16:11:41.604Z" }, - { url = "https://files.pythonhosted.org/packages/69/f1/46375e71441c43e8ae335905e069f1c54febee63a146278bcee8782c84fd/librt-0.8.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:60a78b694c9aee2a0f1aaeaa7d101cf713e92e8423a941d2897f4fa37908dab9", size = 68634, upload-time = "2026-02-17T16:11:43.268Z" }, - { url = "https://files.pythonhosted.org/packages/0a/33/c510de7f93bf1fa19e13423a606d8189a02624a800710f6e6a0a0f0784b3/librt-0.8.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:758509ea3f1eba2a57558e7e98f4659d0ea7670bff49673b0dde18a3c7e6c0eb", size = 198941, upload-time = "2026-02-17T16:11:44.28Z" }, - { url = "https://files.pythonhosted.org/packages/dd/36/e725903416409a533d92398e88ce665476f275081d0d7d42f9c4951999e5/librt-0.8.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:039b9f2c506bd0ab0f8725aa5ba339c6f0cd19d3b514b50d134789809c24285d", size = 209991, upload-time = "2026-02-17T16:11:45.462Z" }, - { url = "https://files.pythonhosted.org/packages/30/7a/8d908a152e1875c9f8eac96c97a480df425e657cdb47854b9efaa4998889/librt-0.8.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5bb54f1205a3a6ab41a6fd71dfcdcbd278670d3a90ca502a30d9da583105b6f7", size = 224476, upload-time = "2026-02-17T16:11:46.542Z" }, - { url = "https://files.pythonhosted.org/packages/a8/b8/a22c34f2c485b8903a06f3fe3315341fe6876ef3599792344669db98fcff/librt-0.8.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:05bd41cdee35b0c59c259f870f6da532a2c5ca57db95b5f23689fcb5c9e42440", size = 217518, upload-time = "2026-02-17T16:11:47.746Z" }, - { url = "https://files.pythonhosted.org/packages/79/6f/5c6fea00357e4f82ba44f81dbfb027921f1ab10e320d4a64e1c408d035d9/librt-0.8.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:adfab487facf03f0d0857b8710cf82d0704a309d8ffc33b03d9302b4c64e91a9", size = 225116, upload-time = "2026-02-17T16:11:49.298Z" }, - { url = "https://files.pythonhosted.org/packages/f2/a0/95ced4e7b1267fe1e2720a111685bcddf0e781f7e9e0ce59d751c44dcfe5/librt-0.8.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:153188fe98a72f206042be10a2c6026139852805215ed9539186312d50a8e972", size = 217751, upload-time = "2026-02-17T16:11:50.49Z" }, - { url = "https://files.pythonhosted.org/packages/93/c2/0517281cb4d4101c27ab59472924e67f55e375bc46bedae94ac6dc6e1902/librt-0.8.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:dd3c41254ee98604b08bd5b3af5bf0a89740d4ee0711de95b65166bf44091921", size = 218378, upload-time = "2026-02-17T16:11:51.783Z" }, - { url = "https://files.pythonhosted.org/packages/43/e8/37b3ac108e8976888e559a7b227d0ceac03c384cfd3e7a1c2ee248dbae79/librt-0.8.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e0d138c7ae532908cbb342162b2611dbd4d90c941cd25ab82084aaf71d2c0bd0", size = 241199, upload-time = "2026-02-17T16:11:53.561Z" }, - { url = "https://files.pythonhosted.org/packages/4b/5b/35812d041c53967fedf551a39399271bbe4257e681236a2cf1a69c8e7fa1/librt-0.8.1-cp312-cp312-win32.whl", hash = "sha256:43353b943613c5d9c49a25aaffdba46f888ec354e71e3529a00cca3f04d66a7a", size = 54917, upload-time = "2026-02-17T16:11:54.758Z" }, - { url = "https://files.pythonhosted.org/packages/de/d1/fa5d5331b862b9775aaf2a100f5ef86854e5d4407f71bddf102f4421e034/librt-0.8.1-cp312-cp312-win_amd64.whl", hash = "sha256:ff8baf1f8d3f4b6b7257fcb75a501f2a5499d0dda57645baa09d4d0d34b19444", size = 62017, upload-time = "2026-02-17T16:11:55.748Z" }, - { url = "https://files.pythonhosted.org/packages/c7/7c/c614252f9acda59b01a66e2ddfd243ed1c7e1deab0293332dfbccf862808/librt-0.8.1-cp312-cp312-win_arm64.whl", hash = "sha256:0f2ae3725904f7377e11cc37722d5d401e8b3d5851fb9273d7f4fe04f6b3d37d", size = 52441, upload-time = "2026-02-17T16:11:56.801Z" }, - { url = "https://files.pythonhosted.org/packages/c5/3c/f614c8e4eaac7cbf2bbdf9528790b21d89e277ee20d57dc6e559c626105f/librt-0.8.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7e6bad1cd94f6764e1e21950542f818a09316645337fd5ab9a7acc45d99a8f35", size = 66529, upload-time = "2026-02-17T16:11:57.809Z" }, - { url = "https://files.pythonhosted.org/packages/ab/96/5836544a45100ae411eda07d29e3d99448e5258b6e9c8059deb92945f5c2/librt-0.8.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cf450f498c30af55551ba4f66b9123b7185362ec8b625a773b3d39aa1a717583", size = 68669, upload-time = "2026-02-17T16:11:58.843Z" }, - { url = "https://files.pythonhosted.org/packages/06/53/f0b992b57af6d5531bf4677d75c44f095f2366a1741fb695ee462ae04b05/librt-0.8.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:eca45e982fa074090057132e30585a7e8674e9e885d402eae85633e9f449ce6c", size = 199279, upload-time = "2026-02-17T16:11:59.862Z" }, - { url = "https://files.pythonhosted.org/packages/f3/ad/4848cc16e268d14280d8168aee4f31cea92bbd2b79ce33d3e166f2b4e4fc/librt-0.8.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c3811485fccfda840861905b8c70bba5ec094e02825598bb9d4ca3936857a04", size = 210288, upload-time = "2026-02-17T16:12:00.954Z" }, - { url = "https://files.pythonhosted.org/packages/52/05/27fdc2e95de26273d83b96742d8d3b7345f2ea2bdbd2405cc504644f2096/librt-0.8.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e4af413908f77294605e28cfd98063f54b2c790561383971d2f52d113d9c363", size = 224809, upload-time = "2026-02-17T16:12:02.108Z" }, - { url = "https://files.pythonhosted.org/packages/7a/d0/78200a45ba3240cb042bc597d6f2accba9193a2c57d0356268cbbe2d0925/librt-0.8.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5212a5bd7fae98dae95710032902edcd2ec4dc994e883294f75c857b83f9aba0", size = 218075, upload-time = "2026-02-17T16:12:03.631Z" }, - { url = "https://files.pythonhosted.org/packages/af/72/a210839fa74c90474897124c064ffca07f8d4b347b6574d309686aae7ca6/librt-0.8.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e692aa2d1d604e6ca12d35e51fdc36f4cda6345e28e36374579f7ef3611b3012", size = 225486, upload-time = "2026-02-17T16:12:04.725Z" }, - { url = "https://files.pythonhosted.org/packages/a3/c1/a03cc63722339ddbf087485f253493e2b013039f5b707e8e6016141130fa/librt-0.8.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:4be2a5c926b9770c9e08e717f05737a269b9d0ebc5d2f0060f0fe3fe9ce47acb", size = 218219, upload-time = "2026-02-17T16:12:05.828Z" }, - { url = "https://files.pythonhosted.org/packages/58/f5/fff6108af0acf941c6f274a946aea0e484bd10cd2dc37610287ce49388c5/librt-0.8.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:fd1a720332ea335ceb544cf0a03f81df92abd4bb887679fd1e460976b0e6214b", size = 218750, upload-time = "2026-02-17T16:12:07.09Z" }, - { url = "https://files.pythonhosted.org/packages/71/67/5a387bfef30ec1e4b4f30562c8586566faf87e47d696768c19feb49e3646/librt-0.8.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:93c2af9e01e0ef80d95ae3c720be101227edae5f2fe7e3dc63d8857fadfc5a1d", size = 241624, upload-time = "2026-02-17T16:12:08.43Z" }, - { url = "https://files.pythonhosted.org/packages/d4/be/24f8502db11d405232ac1162eb98069ca49c3306c1d75c6ccc61d9af8789/librt-0.8.1-cp313-cp313-win32.whl", hash = "sha256:086a32dbb71336627e78cc1d6ee305a68d038ef7d4c39aaff41ae8c9aa46e91a", size = 54969, upload-time = "2026-02-17T16:12:09.633Z" }, - { url = "https://files.pythonhosted.org/packages/5c/73/c9fdf6cb2a529c1a092ce769a12d88c8cca991194dfe641b6af12fa964d2/librt-0.8.1-cp313-cp313-win_amd64.whl", hash = "sha256:e11769a1dbda4da7b00a76cfffa67aa47cfa66921d2724539eee4b9ede780b79", size = 62000, upload-time = "2026-02-17T16:12:10.632Z" }, - { url = "https://files.pythonhosted.org/packages/d3/97/68f80ca3ac4924f250cdfa6e20142a803e5e50fca96ef5148c52ee8c10ea/librt-0.8.1-cp313-cp313-win_arm64.whl", hash = "sha256:924817ab3141aca17893386ee13261f1d100d1ef410d70afe4389f2359fea4f0", size = 52495, upload-time = "2026-02-17T16:12:11.633Z" }, { url = "https://files.pythonhosted.org/packages/c9/6a/907ef6800f7bca71b525a05f1839b21f708c09043b1c6aa77b6b827b3996/librt-0.8.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:6cfa7fe54fd4d1f47130017351a959fe5804bda7a0bc7e07a2cdbc3fdd28d34f", size = 66081, upload-time = "2026-02-17T16:12:12.766Z" }, { url = "https://files.pythonhosted.org/packages/1b/18/25e991cd5640c9fb0f8d91b18797b29066b792f17bf8493da183bf5caabe/librt-0.8.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:228c2409c079f8c11fb2e5d7b277077f694cb93443eb760e00b3b83cb8b3176c", size = 68309, upload-time = "2026-02-17T16:12:13.756Z" }, { url = "https://files.pythonhosted.org/packages/a4/36/46820d03f058cfb5a9de5940640ba03165ed8aded69e0733c417bb04df34/librt-0.8.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7aae78ab5e3206181780e56912d1b9bb9f90a7249ce12f0e8bf531d0462dd0fc", size = 196804, upload-time = "2026-02-17T16:12:14.818Z" }, @@ -613,39 +423,6 @@ version = "3.0.3" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, - { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, - { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, - { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, - { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, - { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, - { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, - { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, - { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, - { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, - { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, - { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, - { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, - { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, - { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, - { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, - { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, - { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, - { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, - { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, - { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, - { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, - { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, - { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, - { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, - { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, - { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, - { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, - { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, - { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, - { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, - { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, - { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, @@ -676,60 +453,6 @@ version = "6.7.1" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/1a/c2/c2d94cbe6ac1753f3fc980da97b3d930efe1da3af3c9f5125354436c073d/multidict-6.7.1.tar.gz", hash = "sha256:ec6652a1bee61c53a3e5776b6049172c53b6aaba34f18c9ad04f82712bac623d", size = 102010, upload-time = "2026-01-26T02:46:45.979Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8d/9c/f20e0e2cf80e4b2e4b1c365bf5fe104ee633c751a724246262db8f1a0b13/multidict-6.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a90f75c956e32891a4eda3639ce6dd86e87105271f43d43442a3aedf3cddf172", size = 76893, upload-time = "2026-01-26T02:43:52.754Z" }, - { url = "https://files.pythonhosted.org/packages/fe/cf/18ef143a81610136d3da8193da9d80bfe1cb548a1e2d1c775f26b23d024a/multidict-6.7.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3fccb473e87eaa1382689053e4a4618e7ba7b9b9b8d6adf2027ee474597128cd", size = 45456, upload-time = "2026-01-26T02:43:53.893Z" }, - { url = "https://files.pythonhosted.org/packages/a9/65/1caac9d4cd32e8433908683446eebc953e82d22b03d10d41a5f0fefe991b/multidict-6.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b0fa96985700739c4c7853a43c0b3e169360d6855780021bfc6d0f1ce7c123e7", size = 43872, upload-time = "2026-01-26T02:43:55.041Z" }, - { url = "https://files.pythonhosted.org/packages/cf/3b/d6bd75dc4f3ff7c73766e04e705b00ed6dbbaccf670d9e05a12b006f5a21/multidict-6.7.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cb2a55f408c3043e42b40cc8eecd575afa27b7e0b956dfb190de0f8499a57a53", size = 251018, upload-time = "2026-01-26T02:43:56.198Z" }, - { url = "https://files.pythonhosted.org/packages/fd/80/c959c5933adedb9ac15152e4067c702a808ea183a8b64cf8f31af8ad3155/multidict-6.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb0ce7b2a32d09892b3dd6cc44877a0d02a33241fafca5f25c8b6b62374f8b75", size = 258883, upload-time = "2026-01-26T02:43:57.499Z" }, - { url = "https://files.pythonhosted.org/packages/86/85/7ed40adafea3d4f1c8b916e3b5cc3a8e07dfcdcb9cd72800f4ed3ca1b387/multidict-6.7.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c3a32d23520ee37bf327d1e1a656fec76a2edd5c038bf43eddfa0572ec49c60b", size = 242413, upload-time = "2026-01-26T02:43:58.755Z" }, - { url = "https://files.pythonhosted.org/packages/d2/57/b8565ff533e48595503c785f8361ff9a4fde4d67de25c207cd0ba3befd03/multidict-6.7.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9c90fed18bffc0189ba814749fdcc102b536e83a9f738a9003e569acd540a733", size = 268404, upload-time = "2026-01-26T02:44:00.216Z" }, - { url = "https://files.pythonhosted.org/packages/e0/50/9810c5c29350f7258180dfdcb2e52783a0632862eb334c4896ac717cebcb/multidict-6.7.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:da62917e6076f512daccfbbde27f46fed1c98fee202f0559adec8ee0de67f71a", size = 269456, upload-time = "2026-01-26T02:44:02.202Z" }, - { url = "https://files.pythonhosted.org/packages/f3/8d/5e5be3ced1d12966fefb5c4ea3b2a5b480afcea36406559442c6e31d4a48/multidict-6.7.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bfde23ef6ed9db7eaee6c37dcec08524cb43903c60b285b172b6c094711b3961", size = 256322, upload-time = "2026-01-26T02:44:03.56Z" }, - { url = "https://files.pythonhosted.org/packages/31/6e/d8a26d81ac166a5592782d208dd90dfdc0a7a218adaa52b45a672b46c122/multidict-6.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3758692429e4e32f1ba0df23219cd0b4fc0a52f476726fff9337d1a57676a582", size = 253955, upload-time = "2026-01-26T02:44:04.845Z" }, - { url = "https://files.pythonhosted.org/packages/59/4c/7c672c8aad41534ba619bcd4ade7a0dc87ed6b8b5c06149b85d3dd03f0cd/multidict-6.7.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:398c1478926eca669f2fd6a5856b6de9c0acf23a2cb59a14c0ba5844fa38077e", size = 251254, upload-time = "2026-01-26T02:44:06.133Z" }, - { url = "https://files.pythonhosted.org/packages/7b/bd/84c24de512cbafbdbc39439f74e967f19570ce7924e3007174a29c348916/multidict-6.7.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c102791b1c4f3ab36ce4101154549105a53dc828f016356b3e3bcae2e3a039d3", size = 252059, upload-time = "2026-01-26T02:44:07.518Z" }, - { url = "https://files.pythonhosted.org/packages/fa/ba/f5449385510825b73d01c2d4087bf6d2fccc20a2d42ac34df93191d3dd03/multidict-6.7.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:a088b62bd733e2ad12c50dad01b7d0166c30287c166e137433d3b410add807a6", size = 263588, upload-time = "2026-01-26T02:44:09.382Z" }, - { url = "https://files.pythonhosted.org/packages/d7/11/afc7c677f68f75c84a69fe37184f0f82fce13ce4b92f49f3db280b7e92b3/multidict-6.7.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3d51ff4785d58d3f6c91bdbffcb5e1f7ddfda557727043aa20d20ec4f65e324a", size = 259642, upload-time = "2026-01-26T02:44:10.73Z" }, - { url = "https://files.pythonhosted.org/packages/2b/17/ebb9644da78c4ab36403739e0e6e0e30ebb135b9caf3440825001a0bddcb/multidict-6.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc5907494fccf3e7d3f94f95c91d6336b092b5fc83811720fae5e2765890dfba", size = 251377, upload-time = "2026-01-26T02:44:12.042Z" }, - { url = "https://files.pythonhosted.org/packages/ca/a4/840f5b97339e27846c46307f2530a2805d9d537d8b8bd416af031cad7fa0/multidict-6.7.1-cp312-cp312-win32.whl", hash = "sha256:28ca5ce2fd9716631133d0e9a9b9a745ad7f60bac2bccafb56aa380fc0b6c511", size = 41887, upload-time = "2026-01-26T02:44:14.245Z" }, - { url = "https://files.pythonhosted.org/packages/80/31/0b2517913687895f5904325c2069d6a3b78f66cc641a86a2baf75a05dcbb/multidict-6.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcee94dfbd638784645b066074b338bc9cc155d4b4bffa4adce1615c5a426c19", size = 46053, upload-time = "2026-01-26T02:44:15.371Z" }, - { url = "https://files.pythonhosted.org/packages/0c/5b/aba28e4ee4006ae4c7df8d327d31025d760ffa992ea23812a601d226e682/multidict-6.7.1-cp312-cp312-win_arm64.whl", hash = "sha256:ba0a9fb644d0c1a2194cf7ffb043bd852cea63a57f66fbd33959f7dae18517bf", size = 43307, upload-time = "2026-01-26T02:44:16.852Z" }, - { url = "https://files.pythonhosted.org/packages/f2/22/929c141d6c0dba87d3e1d38fbdf1ba8baba86b7776469f2bc2d3227a1e67/multidict-6.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2b41f5fed0ed563624f1c17630cb9941cf2309d4df00e494b551b5f3e3d67a23", size = 76174, upload-time = "2026-01-26T02:44:18.509Z" }, - { url = "https://files.pythonhosted.org/packages/c7/75/bc704ae15fee974f8fccd871305e254754167dce5f9e42d88a2def741a1d/multidict-6.7.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84e61e3af5463c19b67ced91f6c634effb89ef8bfc5ca0267f954451ed4bb6a2", size = 45116, upload-time = "2026-01-26T02:44:19.745Z" }, - { url = "https://files.pythonhosted.org/packages/79/76/55cd7186f498ed080a18440c9013011eb548f77ae1b297206d030eb1180a/multidict-6.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:935434b9853c7c112eee7ac891bc4cb86455aa631269ae35442cb316790c1445", size = 43524, upload-time = "2026-01-26T02:44:21.571Z" }, - { url = "https://files.pythonhosted.org/packages/e9/3c/414842ef8d5a1628d68edee29ba0e5bcf235dbfb3ccd3ea303a7fe8c72ff/multidict-6.7.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:432feb25a1cb67fe82a9680b4d65fb542e4635cb3166cd9c01560651ad60f177", size = 249368, upload-time = "2026-01-26T02:44:22.803Z" }, - { url = "https://files.pythonhosted.org/packages/f6/32/befed7f74c458b4a525e60519fe8d87eef72bb1e99924fa2b0f9d97a221e/multidict-6.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e82d14e3c948952a1a85503817e038cba5905a3352de76b9a465075d072fba23", size = 256952, upload-time = "2026-01-26T02:44:24.306Z" }, - { url = "https://files.pythonhosted.org/packages/03/d6/c878a44ba877f366630c860fdf74bfb203c33778f12b6ac274936853c451/multidict-6.7.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4cfb48c6ea66c83bcaaf7e4dfa7ec1b6bbcf751b7db85a328902796dfde4c060", size = 240317, upload-time = "2026-01-26T02:44:25.772Z" }, - { url = "https://files.pythonhosted.org/packages/68/49/57421b4d7ad2e9e60e25922b08ceb37e077b90444bde6ead629095327a6f/multidict-6.7.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1d540e51b7e8e170174555edecddbd5538105443754539193e3e1061864d444d", size = 267132, upload-time = "2026-01-26T02:44:27.648Z" }, - { url = "https://files.pythonhosted.org/packages/b7/fe/ec0edd52ddbcea2a2e89e174f0206444a61440b40f39704e64dc807a70bd/multidict-6.7.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:273d23f4b40f3dce4d6c8a821c741a86dec62cded82e1175ba3d99be128147ed", size = 268140, upload-time = "2026-01-26T02:44:29.588Z" }, - { url = "https://files.pythonhosted.org/packages/b0/73/6e1b01cbeb458807aa0831742232dbdd1fa92bfa33f52a3f176b4ff3dc11/multidict-6.7.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d624335fd4fa1c08a53f8b4be7676ebde19cd092b3895c421045ca87895b429", size = 254277, upload-time = "2026-01-26T02:44:30.902Z" }, - { url = "https://files.pythonhosted.org/packages/6a/b2/5fb8c124d7561a4974c342bc8c778b471ebbeb3cc17df696f034a7e9afe7/multidict-6.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:12fad252f8b267cc75b66e8fc51b3079604e8d43a75428ffe193cd9e2195dfd6", size = 252291, upload-time = "2026-01-26T02:44:32.31Z" }, - { url = "https://files.pythonhosted.org/packages/5a/96/51d4e4e06bcce92577fcd488e22600bd38e4fd59c20cb49434d054903bd2/multidict-6.7.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:03ede2a6ffbe8ef936b92cb4529f27f42be7f56afcdab5ab739cd5f27fb1cbf9", size = 250156, upload-time = "2026-01-26T02:44:33.734Z" }, - { url = "https://files.pythonhosted.org/packages/db/6b/420e173eec5fba721a50e2a9f89eda89d9c98fded1124f8d5c675f7a0c0f/multidict-6.7.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:90efbcf47dbe33dcf643a1e400d67d59abeac5db07dc3f27d6bdeae497a2198c", size = 249742, upload-time = "2026-01-26T02:44:35.222Z" }, - { url = "https://files.pythonhosted.org/packages/44/a3/ec5b5bd98f306bc2aa297b8c6f11a46714a56b1e6ef5ebda50a4f5d7c5fb/multidict-6.7.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c4b9bfc148f5a91be9244d6264c53035c8a0dcd2f51f1c3c6e30e30ebaa1c84", size = 262221, upload-time = "2026-01-26T02:44:36.604Z" }, - { url = "https://files.pythonhosted.org/packages/cd/f7/e8c0d0da0cd1e28d10e624604e1a36bcc3353aaebdfdc3a43c72bc683a12/multidict-6.7.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:401c5a650f3add2472d1d288c26deebc540f99e2fb83e9525007a74cd2116f1d", size = 258664, upload-time = "2026-01-26T02:44:38.008Z" }, - { url = "https://files.pythonhosted.org/packages/52/da/151a44e8016dd33feed44f730bd856a66257c1ee7aed4f44b649fb7edeb3/multidict-6.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:97891f3b1b3ffbded884e2916cacf3c6fc87b66bb0dde46f7357404750559f33", size = 249490, upload-time = "2026-01-26T02:44:39.386Z" }, - { url = "https://files.pythonhosted.org/packages/87/af/a3b86bf9630b732897f6fc3f4c4714b90aa4361983ccbdcd6c0339b21b0c/multidict-6.7.1-cp313-cp313-win32.whl", hash = "sha256:e1c5988359516095535c4301af38d8a8838534158f649c05dd1050222321bcb3", size = 41695, upload-time = "2026-01-26T02:44:41.318Z" }, - { url = "https://files.pythonhosted.org/packages/b2/35/e994121b0e90e46134673422dd564623f93304614f5d11886b1b3e06f503/multidict-6.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:960c83bf01a95b12b08fd54324a4eb1d5b52c88932b5cba5d6e712bb3ed12eb5", size = 45884, upload-time = "2026-01-26T02:44:42.488Z" }, - { url = "https://files.pythonhosted.org/packages/ca/61/42d3e5dbf661242a69c97ea363f2d7b46c567da8eadef8890022be6e2ab0/multidict-6.7.1-cp313-cp313-win_arm64.whl", hash = "sha256:563fe25c678aaba333d5399408f5ec3c383ca5b663e7f774dd179a520b8144df", size = 43122, upload-time = "2026-01-26T02:44:43.664Z" }, - { url = "https://files.pythonhosted.org/packages/6d/b3/e6b21c6c4f314bb956016b0b3ef2162590a529b84cb831c257519e7fde44/multidict-6.7.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:c76c4bec1538375dad9d452d246ca5368ad6e1c9039dadcf007ae59c70619ea1", size = 83175, upload-time = "2026-01-26T02:44:44.894Z" }, - { url = "https://files.pythonhosted.org/packages/fb/76/23ecd2abfe0957b234f6c960f4ade497f55f2c16aeb684d4ecdbf1c95791/multidict-6.7.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:57b46b24b5d5ebcc978da4ec23a819a9402b4228b8a90d9c656422b4bdd8a963", size = 48460, upload-time = "2026-01-26T02:44:46.106Z" }, - { url = "https://files.pythonhosted.org/packages/c4/57/a0ed92b23f3a042c36bc4227b72b97eca803f5f1801c1ab77c8a212d455e/multidict-6.7.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e954b24433c768ce78ab7929e84ccf3422e46deb45a4dc9f93438f8217fa2d34", size = 46930, upload-time = "2026-01-26T02:44:47.278Z" }, - { url = "https://files.pythonhosted.org/packages/b5/66/02ec7ace29162e447f6382c495dc95826bf931d3818799bbef11e8f7df1a/multidict-6.7.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3bd231490fa7217cc832528e1cd8752a96f0125ddd2b5749390f7c3ec8721b65", size = 242582, upload-time = "2026-01-26T02:44:48.604Z" }, - { url = "https://files.pythonhosted.org/packages/58/18/64f5a795e7677670e872673aca234162514696274597b3708b2c0d276cce/multidict-6.7.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:253282d70d67885a15c8a7716f3a73edf2d635793ceda8173b9ecc21f2fb8292", size = 250031, upload-time = "2026-01-26T02:44:50.544Z" }, - { url = "https://files.pythonhosted.org/packages/c8/ed/e192291dbbe51a8290c5686f482084d31bcd9d09af24f63358c3d42fd284/multidict-6.7.1-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0b4c48648d7649c9335cf1927a8b87fa692de3dcb15faa676c6a6f1f1aabda43", size = 228596, upload-time = "2026-01-26T02:44:51.951Z" }, - { url = "https://files.pythonhosted.org/packages/1e/7e/3562a15a60cf747397e7f2180b0a11dc0c38d9175a650e75fa1b4d325e15/multidict-6.7.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:98bc624954ec4d2c7cb074b8eefc2b5d0ce7d482e410df446414355d158fe4ca", size = 257492, upload-time = "2026-01-26T02:44:53.902Z" }, - { url = "https://files.pythonhosted.org/packages/24/02/7d0f9eae92b5249bb50ac1595b295f10e263dd0078ebb55115c31e0eaccd/multidict-6.7.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1b99af4d9eec0b49927b4402bcbb58dea89d3e0db8806a4086117019939ad3dd", size = 255899, upload-time = "2026-01-26T02:44:55.316Z" }, - { url = "https://files.pythonhosted.org/packages/00/e3/9b60ed9e23e64c73a5cde95269ef1330678e9c6e34dd4eb6b431b85b5a10/multidict-6.7.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6aac4f16b472d5b7dc6f66a0d49dd57b0e0902090be16594dc9ebfd3d17c47e7", size = 247970, upload-time = "2026-01-26T02:44:56.783Z" }, - { url = "https://files.pythonhosted.org/packages/3e/06/538e58a63ed5cfb0bd4517e346b91da32fde409d839720f664e9a4ae4f9d/multidict-6.7.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:21f830fe223215dffd51f538e78c172ed7c7f60c9b96a2bf05c4848ad49921c3", size = 245060, upload-time = "2026-01-26T02:44:58.195Z" }, - { url = "https://files.pythonhosted.org/packages/b2/2f/d743a3045a97c895d401e9bd29aaa09b94f5cbdf1bd561609e5a6c431c70/multidict-6.7.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:f5dd81c45b05518b9aa4da4aa74e1c93d715efa234fd3e8a179df611cc85e5f4", size = 235888, upload-time = "2026-01-26T02:44:59.57Z" }, - { url = "https://files.pythonhosted.org/packages/38/83/5a325cac191ab28b63c52f14f1131f3b0a55ba3b9aa65a6d0bf2a9b921a0/multidict-6.7.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:eb304767bca2bb92fb9c5bd33cedc95baee5bb5f6c88e63706533a1c06ad08c8", size = 243554, upload-time = "2026-01-26T02:45:01.054Z" }, - { url = "https://files.pythonhosted.org/packages/20/1f/9d2327086bd15da2725ef6aae624208e2ef828ed99892b17f60c344e57ed/multidict-6.7.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:c9035dde0f916702850ef66460bc4239d89d08df4d02023a5926e7446724212c", size = 252341, upload-time = "2026-01-26T02:45:02.484Z" }, - { url = "https://files.pythonhosted.org/packages/e8/2c/2a1aa0280cf579d0f6eed8ee5211c4f1730bd7e06c636ba2ee6aafda302e/multidict-6.7.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:af959b9beeb66c822380f222f0e0a1889331597e81f1ded7f374f3ecb0fd6c52", size = 246391, upload-time = "2026-01-26T02:45:03.862Z" }, - { url = "https://files.pythonhosted.org/packages/e5/03/7ca022ffc36c5a3f6e03b179a5ceb829be9da5783e6fe395f347c0794680/multidict-6.7.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:41f2952231456154ee479651491e94118229844dd7226541788be783be2b5108", size = 243422, upload-time = "2026-01-26T02:45:05.296Z" }, - { url = "https://files.pythonhosted.org/packages/dc/1d/b31650eab6c5778aceed46ba735bd97f7c7d2f54b319fa916c0f96e7805b/multidict-6.7.1-cp313-cp313t-win32.whl", hash = "sha256:df9f19c28adcb40b6aae30bbaa1478c389efd50c28d541d76760199fc1037c32", size = 47770, upload-time = "2026-01-26T02:45:06.754Z" }, - { url = "https://files.pythonhosted.org/packages/ac/5b/2d2d1d522e51285bd61b1e20df8f47ae1a9d80839db0b24ea783b3832832/multidict-6.7.1-cp313-cp313t-win_amd64.whl", hash = "sha256:d54ecf9f301853f2c5e802da559604b3e95bb7a3b01a9c295c6ee591b9882de8", size = 53109, upload-time = "2026-01-26T02:45:08.044Z" }, - { url = "https://files.pythonhosted.org/packages/3d/a3/cc409ba012c83ca024a308516703cf339bdc4b696195644a7215a5164a24/multidict-6.7.1-cp313-cp313t-win_arm64.whl", hash = "sha256:5a37ca18e360377cfda1d62f5f382ff41f2b8c4ccb329ed974cc2e1643440118", size = 45573, upload-time = "2026-01-26T02:45:09.349Z" }, { url = "https://files.pythonhosted.org/packages/91/cc/db74228a8be41884a567e88a62fd589a913708fcf180d029898c17a9a371/multidict-6.7.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8f333ec9c5eb1b7105e3b84b53141e66ca05a19a605368c55450b6ba208cb9ee", size = 75190, upload-time = "2026-01-26T02:45:10.651Z" }, { url = "https://files.pythonhosted.org/packages/d5/22/492f2246bb5b534abd44804292e81eeaf835388901f0c574bac4eeec73c5/multidict-6.7.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a407f13c188f804c759fc6a9f88286a565c242a76b27626594c133b82883b5c2", size = 44486, upload-time = "2026-01-26T02:45:11.938Z" }, { url = "https://files.pythonhosted.org/packages/f1/4f/733c48f270565d78b4544f2baddc2fb2a245e5a8640254b12c36ac7ac68e/multidict-6.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0e161ddf326db5577c3a4cc2d8648f81456e8a20d40415541587a71620d7a7d1", size = 43219, upload-time = "2026-01-26T02:45:14.346Z" }, @@ -781,18 +504,6 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/f5/db/4efed9504bc01309ab9c2da7e352cc223569f05478012b5d9ece38fd44d2/mypy-1.19.1.tar.gz", hash = "sha256:19d88bb05303fe63f71dd2c6270daca27cb9401c4ca8255fe50d1d920e0eb9ba", size = 3582404, upload-time = "2025-12-15T05:03:48.42Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/06/8a/19bfae96f6615aa8a0604915512e0289b1fad33d5909bf7244f02935d33a/mypy-1.19.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a8174a03289288c1f6c46d55cef02379b478bfbc8e358e02047487cad44c6ca1", size = 13206053, upload-time = "2025-12-15T05:03:46.622Z" }, - { url = "https://files.pythonhosted.org/packages/a5/34/3e63879ab041602154ba2a9f99817bb0c85c4df19a23a1443c8986e4d565/mypy-1.19.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffcebe56eb09ff0c0885e750036a095e23793ba6c2e894e7e63f6d89ad51f22e", size = 12219134, upload-time = "2025-12-15T05:03:24.367Z" }, - { url = "https://files.pythonhosted.org/packages/89/cc/2db6f0e95366b630364e09845672dbee0cbf0bbe753a204b29a944967cd9/mypy-1.19.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b64d987153888790bcdb03a6473d321820597ab8dd9243b27a92153c4fa50fd2", size = 12731616, upload-time = "2025-12-15T05:02:44.725Z" }, - { url = "https://files.pythonhosted.org/packages/00/be/dd56c1fd4807bc1eba1cf18b2a850d0de7bacb55e158755eb79f77c41f8e/mypy-1.19.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c35d298c2c4bba75feb2195655dfea8124d855dfd7343bf8b8c055421eaf0cf8", size = 13620847, upload-time = "2025-12-15T05:03:39.633Z" }, - { url = "https://files.pythonhosted.org/packages/6d/42/332951aae42b79329f743bf1da088cd75d8d4d9acc18fbcbd84f26c1af4e/mypy-1.19.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:34c81968774648ab5ac09c29a375fdede03ba253f8f8287847bd480782f73a6a", size = 13834976, upload-time = "2025-12-15T05:03:08.786Z" }, - { url = "https://files.pythonhosted.org/packages/6f/63/e7493e5f90e1e085c562bb06e2eb32cae27c5057b9653348d38b47daaecc/mypy-1.19.1-cp312-cp312-win_amd64.whl", hash = "sha256:b10e7c2cd7870ba4ad9b2d8a6102eb5ffc1f16ca35e3de6bfa390c1113029d13", size = 10118104, upload-time = "2025-12-15T05:03:10.834Z" }, - { url = "https://files.pythonhosted.org/packages/de/9f/a6abae693f7a0c697dbb435aac52e958dc8da44e92e08ba88d2e42326176/mypy-1.19.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e3157c7594ff2ef1634ee058aafc56a82db665c9438fd41b390f3bde1ab12250", size = 13201927, upload-time = "2025-12-15T05:02:29.138Z" }, - { url = "https://files.pythonhosted.org/packages/9a/a4/45c35ccf6e1c65afc23a069f50e2c66f46bd3798cbe0d680c12d12935caa/mypy-1.19.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bdb12f69bcc02700c2b47e070238f42cb87f18c0bc1fc4cdb4fb2bc5fd7a3b8b", size = 12206730, upload-time = "2025-12-15T05:03:01.325Z" }, - { url = "https://files.pythonhosted.org/packages/05/bb/cdcf89678e26b187650512620eec8368fded4cfd99cfcb431e4cdfd19dec/mypy-1.19.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f859fb09d9583a985be9a493d5cfc5515b56b08f7447759a0c5deaf68d80506e", size = 12724581, upload-time = "2025-12-15T05:03:20.087Z" }, - { url = "https://files.pythonhosted.org/packages/d1/32/dd260d52babf67bad8e6770f8e1102021877ce0edea106e72df5626bb0ec/mypy-1.19.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c9a6538e0415310aad77cb94004ca6482330fece18036b5f360b62c45814c4ef", size = 13616252, upload-time = "2025-12-15T05:02:49.036Z" }, - { url = "https://files.pythonhosted.org/packages/71/d0/5e60a9d2e3bd48432ae2b454b7ef2b62a960ab51292b1eda2a95edd78198/mypy-1.19.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:da4869fc5e7f62a88f3fe0b5c919d1d9f7ea3cef92d3689de2823fd27e40aa75", size = 13840848, upload-time = "2025-12-15T05:02:55.95Z" }, - { url = "https://files.pythonhosted.org/packages/98/76/d32051fa65ecf6cc8c6610956473abdc9b4c43301107476ac03559507843/mypy-1.19.1-cp313-cp313-win_amd64.whl", hash = "sha256:016f2246209095e8eda7538944daa1d60e1e8134d98983b9fc1e92c1fc0cb8dd", size = 10135510, upload-time = "2025-12-15T05:02:58.438Z" }, { url = "https://files.pythonhosted.org/packages/de/eb/b83e75f4c820c4247a58580ef86fcd35165028f191e7e1ba57128c52782d/mypy-1.19.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:06e6170bd5836770e8104c8fdd58e5e725cfeb309f0a6c681a811f557e97eac1", size = 13199744, upload-time = "2025-12-15T05:03:30.823Z" }, { url = "https://files.pythonhosted.org/packages/94/28/52785ab7bfa165f87fcbb61547a93f98bb20e7f82f90f165a1f69bce7b3d/mypy-1.19.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:804bd67b8054a85447c8954215a906d6eff9cabeabe493fb6334b24f4bfff718", size = 12215815, upload-time = "2025-12-15T05:02:42.323Z" }, { url = "https://files.pythonhosted.org/packages/0a/c6/bdd60774a0dbfb05122e3e925f2e9e846c009e479dcec4821dad881f5b52/mypy-1.19.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21761006a7f497cb0d4de3d8ef4ca70532256688b0523eee02baf9eec895e27b", size = 12740047, upload-time = "2025-12-15T05:03:33.168Z" }, @@ -826,38 +537,6 @@ version = "2.4.3" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/10/8b/c265f4823726ab832de836cdd184d0986dcf94480f81e8739692a7ac7af2/numpy-2.4.3.tar.gz", hash = "sha256:483a201202b73495f00dbc83796c6ae63137a9bdade074f7648b3e32613412dd", size = 20727743, upload-time = "2026-03-09T07:58:53.426Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a9/ed/6388632536f9788cea23a3a1b629f25b43eaacd7d7377e5d6bc7b9deb69b/numpy-2.4.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:61b0cbabbb6126c8df63b9a3a0c4b1f44ebca5e12ff6997b80fcf267fb3150ef", size = 16669628, upload-time = "2026-03-09T07:56:24.252Z" }, - { url = "https://files.pythonhosted.org/packages/74/1b/ee2abfc68e1ce728b2958b6ba831d65c62e1b13ce3017c13943f8f9b5b2e/numpy-2.4.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7395e69ff32526710748f92cd8c9849b361830968ea3e24a676f272653e8983e", size = 14696872, upload-time = "2026-03-09T07:56:26.991Z" }, - { url = "https://files.pythonhosted.org/packages/ba/d1/780400e915ff5638166f11ca9dc2c5815189f3d7cf6f8759a1685e586413/numpy-2.4.3-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:abdce0f71dcb4a00e4e77f3faf05e4616ceccfe72ccaa07f47ee79cda3b7b0f4", size = 5203489, upload-time = "2026-03-09T07:56:29.414Z" }, - { url = "https://files.pythonhosted.org/packages/0b/bb/baffa907e9da4cc34a6e556d6d90e032f6d7a75ea47968ea92b4858826c4/numpy-2.4.3-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:48da3a4ee1336454b07497ff7ec83903efa5505792c4e6d9bf83d99dc07a1e18", size = 6550814, upload-time = "2026-03-09T07:56:32.225Z" }, - { url = "https://files.pythonhosted.org/packages/7b/12/8c9f0c6c95f76aeb20fc4a699c33e9f827fa0d0f857747c73bb7b17af945/numpy-2.4.3-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:32e3bef222ad6b052280311d1d60db8e259e4947052c3ae7dd6817451fc8a4c5", size = 15666601, upload-time = "2026-03-09T07:56:34.461Z" }, - { url = "https://files.pythonhosted.org/packages/bd/79/cc665495e4d57d0aa6fbcc0aa57aa82671dfc78fbf95fe733ed86d98f52a/numpy-2.4.3-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e7dd01a46700b1967487141a66ac1a3cf0dd8ebf1f08db37d46389401512ca97", size = 16621358, upload-time = "2026-03-09T07:56:36.852Z" }, - { url = "https://files.pythonhosted.org/packages/a8/40/b4ecb7224af1065c3539f5ecfff879d090de09608ad1008f02c05c770cb3/numpy-2.4.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:76f0f283506c28b12bba319c0fab98217e9f9b54e6160e9c79e9f7348ba32e9c", size = 17016135, upload-time = "2026-03-09T07:56:39.337Z" }, - { url = "https://files.pythonhosted.org/packages/f7/b1/6a88e888052eed951afed7a142dcdf3b149a030ca59b4c71eef085858e43/numpy-2.4.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:737f630a337364665aba3b5a77e56a68cc42d350edd010c345d65a3efa3addcc", size = 18345816, upload-time = "2026-03-09T07:56:42.31Z" }, - { url = "https://files.pythonhosted.org/packages/f3/8f/103a60c5f8c3d7fc678c19cd7b2476110da689ccb80bc18050efbaeae183/numpy-2.4.3-cp312-cp312-win32.whl", hash = "sha256:26952e18d82a1dbbc2f008d402021baa8d6fc8e84347a2072a25e08b46d698b9", size = 5960132, upload-time = "2026-03-09T07:56:44.851Z" }, - { url = "https://files.pythonhosted.org/packages/d7/7c/f5ee1bf6ed888494978046a809df2882aad35d414b622893322df7286879/numpy-2.4.3-cp312-cp312-win_amd64.whl", hash = "sha256:65f3c2455188f09678355f5cae1f959a06b778bc66d535da07bf2ef20cd319d5", size = 12316144, upload-time = "2026-03-09T07:56:47.057Z" }, - { url = "https://files.pythonhosted.org/packages/71/46/8d1cb3f7a00f2fb6394140e7e6623696e54c6318a9d9691bb4904672cf42/numpy-2.4.3-cp312-cp312-win_arm64.whl", hash = "sha256:2abad5c7fef172b3377502bde47892439bae394a71bc329f31df0fd829b41a9e", size = 10220364, upload-time = "2026-03-09T07:56:49.849Z" }, - { url = "https://files.pythonhosted.org/packages/b6/d0/1fe47a98ce0df229238b77611340aff92d52691bcbc10583303181abf7fc/numpy-2.4.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b346845443716c8e542d54112966383b448f4a3ba5c66409771b8c0889485dd3", size = 16665297, upload-time = "2026-03-09T07:56:52.296Z" }, - { url = "https://files.pythonhosted.org/packages/27/d9/4e7c3f0e68dfa91f21c6fb6cf839bc829ec920688b1ce7ec722b1a6202fb/numpy-2.4.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2629289168f4897a3c4e23dc98d6f1731f0fc0fe52fb9db19f974041e4cc12b9", size = 14691853, upload-time = "2026-03-09T07:56:54.992Z" }, - { url = "https://files.pythonhosted.org/packages/3a/66/bd096b13a87549683812b53ab211e6d413497f84e794fb3c39191948da97/numpy-2.4.3-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:bb2e3cf95854233799013779216c57e153c1ee67a0bf92138acca0e429aefaee", size = 5198435, upload-time = "2026-03-09T07:56:57.184Z" }, - { url = "https://files.pythonhosted.org/packages/a2/2f/687722910b5a5601de2135c891108f51dfc873d8e43c8ed9f4ebb440b4a2/numpy-2.4.3-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:7f3408ff897f8ab07a07fbe2823d7aee6ff644c097cc1f90382511fe982f647f", size = 6546347, upload-time = "2026-03-09T07:56:59.531Z" }, - { url = "https://files.pythonhosted.org/packages/bf/ec/7971c4e98d86c564750393fab8d7d83d0a9432a9d78bb8a163a6dc59967a/numpy-2.4.3-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:decb0eb8a53c3b009b0962378065589685d66b23467ef5dac16cbe818afde27f", size = 15664626, upload-time = "2026-03-09T07:57:01.385Z" }, - { url = "https://files.pythonhosted.org/packages/7e/eb/7daecbea84ec935b7fc732e18f532073064a3816f0932a40a17f3349185f/numpy-2.4.3-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d5f51900414fc9204a0e0da158ba2ac52b75656e7dce7e77fb9f84bfa343b4cc", size = 16608916, upload-time = "2026-03-09T07:57:04.008Z" }, - { url = "https://files.pythonhosted.org/packages/df/58/2a2b4a817ffd7472dca4421d9f0776898b364154e30c95f42195041dc03b/numpy-2.4.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6bd06731541f89cdc01b261ba2c9e037f1543df7472517836b78dfb15bd6e476", size = 17015824, upload-time = "2026-03-09T07:57:06.347Z" }, - { url = "https://files.pythonhosted.org/packages/4a/ca/627a828d44e78a418c55f82dd4caea8ea4a8ef24e5144d9e71016e52fb40/numpy-2.4.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:22654fe6be0e5206f553a9250762c653d3698e46686eee53b399ab90da59bd92", size = 18334581, upload-time = "2026-03-09T07:57:09.114Z" }, - { url = "https://files.pythonhosted.org/packages/cd/c0/76f93962fc79955fcba30a429b62304332345f22d4daec1cb33653425643/numpy-2.4.3-cp313-cp313-win32.whl", hash = "sha256:d71e379452a2f670ccb689ec801b1218cd3983e253105d6e83780967e899d687", size = 5958618, upload-time = "2026-03-09T07:57:11.432Z" }, - { url = "https://files.pythonhosted.org/packages/b1/3c/88af0040119209b9b5cb59485fa48b76f372c73068dbf9254784b975ac53/numpy-2.4.3-cp313-cp313-win_amd64.whl", hash = "sha256:0a60e17a14d640f49146cb38e3f105f571318db7826d9b6fef7e4dce758faecd", size = 12312824, upload-time = "2026-03-09T07:57:13.586Z" }, - { url = "https://files.pythonhosted.org/packages/58/ce/3d07743aced3d173f877c3ef6a454c2174ba42b584ab0b7e6d99374f51ed/numpy-2.4.3-cp313-cp313-win_arm64.whl", hash = "sha256:c9619741e9da2059cd9c3f206110b97583c7152c1dc9f8aafd4beb450ac1c89d", size = 10221218, upload-time = "2026-03-09T07:57:16.183Z" }, - { url = "https://files.pythonhosted.org/packages/62/09/d96b02a91d09e9d97862f4fc8bfebf5400f567d8eb1fe4b0cc4795679c15/numpy-2.4.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:7aa4e54f6469300ebca1d9eb80acd5253cdfa36f2c03d79a35883687da430875", size = 14819570, upload-time = "2026-03-09T07:57:18.564Z" }, - { url = "https://files.pythonhosted.org/packages/b5/ca/0b1aba3905fdfa3373d523b2b15b19029f4f3031c87f4066bd9d20ef6c6b/numpy-2.4.3-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:d1b90d840b25874cf5cd20c219af10bac3667db3876d9a495609273ebe679070", size = 5326113, upload-time = "2026-03-09T07:57:21.052Z" }, - { url = "https://files.pythonhosted.org/packages/c0/63/406e0fd32fcaeb94180fd6a4c41e55736d676c54346b7efbce548b94a914/numpy-2.4.3-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:a749547700de0a20a6718293396ec237bb38218049cfce788e08fcb716e8cf73", size = 6646370, upload-time = "2026-03-09T07:57:22.804Z" }, - { url = "https://files.pythonhosted.org/packages/b6/d0/10f7dc157d4b37af92720a196be6f54f889e90dcd30dce9dc657ed92c257/numpy-2.4.3-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:94f3c4a151a2e529adf49c1d54f0f57ff8f9b233ee4d44af623a81553ab86368", size = 15723499, upload-time = "2026-03-09T07:57:24.693Z" }, - { url = "https://files.pythonhosted.org/packages/66/f1/d1c2bf1161396629701bc284d958dc1efa3a5a542aab83cf11ee6eb4cba5/numpy-2.4.3-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22c31dc07025123aedf7f2db9e91783df13f1776dc52c6b22c620870dc0fab22", size = 16657164, upload-time = "2026-03-09T07:57:27.676Z" }, - { url = "https://files.pythonhosted.org/packages/1a/be/cca19230b740af199ac47331a21c71e7a3d0ba59661350483c1600d28c37/numpy-2.4.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:148d59127ac95979d6f07e4d460f934ebdd6eed641db9c0db6c73026f2b2101a", size = 17081544, upload-time = "2026-03-09T07:57:30.664Z" }, - { url = "https://files.pythonhosted.org/packages/b9/c5/9602b0cbb703a0936fb40f8a95407e8171935b15846de2f0776e08af04c7/numpy-2.4.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a97cbf7e905c435865c2d939af3d93f99d18eaaa3cabe4256f4304fb51604349", size = 18380290, upload-time = "2026-03-09T07:57:33.763Z" }, - { url = "https://files.pythonhosted.org/packages/ed/81/9f24708953cd30be9ee36ec4778f4b112b45165812f2ada4cc5ea1c1f254/numpy-2.4.3-cp313-cp313t-win32.whl", hash = "sha256:be3b8487d725a77acccc9924f65fd8bce9af7fac8c9820df1049424a2115af6c", size = 6082814, upload-time = "2026-03-09T07:57:36.491Z" }, - { url = "https://files.pythonhosted.org/packages/e2/9e/52f6eaa13e1a799f0ab79066c17f7016a4a8ae0c1aefa58c82b4dab690b4/numpy-2.4.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1ec84fd7c8e652b0f4aaaf2e6e9cc8eaa9b1b80a537e06b2e3a2fb176eedcb26", size = 12452673, upload-time = "2026-03-09T07:57:38.281Z" }, - { url = "https://files.pythonhosted.org/packages/c4/04/b8cece6ead0b30c9fbd99bb835ad7ea0112ac5f39f069788c5558e3b1ab2/numpy-2.4.3-cp313-cp313t-win_arm64.whl", hash = "sha256:120df8c0a81ebbf5b9020c91439fccd85f5e018a927a39f624845be194a2be02", size = 10290907, upload-time = "2026-03-09T07:57:40.747Z" }, { url = "https://files.pythonhosted.org/packages/70/ae/3936f79adebf8caf81bd7a599b90a561334a658be4dcc7b6329ebf4ee8de/numpy-2.4.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:5884ce5c7acfae1e4e1b6fde43797d10aa506074d25b531b4f54bde33c0c31d4", size = 16664563, upload-time = "2026-03-09T07:57:43.817Z" }, { url = "https://files.pythonhosted.org/packages/9b/62/760f2b55866b496bb1fa7da2a6db076bef908110e568b02fcfc1422e2a3a/numpy-2.4.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:297837823f5bc572c5f9379b0c9f3a3365f08492cbdc33bcc3af174372ebb168", size = 14702161, upload-time = "2026-03-09T07:57:46.169Z" }, { url = "https://files.pythonhosted.org/packages/32/af/a7a39464e2c0a21526fb4fb76e346fb172ebc92f6d1c7a07c2c139cc17b1/numpy-2.4.3-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:a111698b4a3f8dcbe54c64a7708f049355abd603e619013c346553c1fd4ca90b", size = 5208738, upload-time = "2026-03-09T07:57:48.506Z" }, @@ -948,51 +627,6 @@ version = "0.4.1" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/9e/da/e9fc233cf63743258bff22b3dfa7ea5baef7b5bc324af47a0ad89b8ffc6f/propcache-0.4.1.tar.gz", hash = "sha256:f48107a8c637e80362555f37ecf49abe20370e557cc4ab374f04ec4423c97c3d", size = 46442, upload-time = "2025-10-08T19:49:02.291Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a2/0f/f17b1b2b221d5ca28b4b876e8bb046ac40466513960646bda8e1853cdfa2/propcache-0.4.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e153e9cd40cc8945138822807139367f256f89c6810c2634a4f6902b52d3b4e2", size = 80061, upload-time = "2025-10-08T19:46:46.075Z" }, - { url = "https://files.pythonhosted.org/packages/76/47/8ccf75935f51448ba9a16a71b783eb7ef6b9ee60f5d14c7f8a8a79fbeed7/propcache-0.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:cd547953428f7abb73c5ad82cbb32109566204260d98e41e5dfdc682eb7f8403", size = 46037, upload-time = "2025-10-08T19:46:47.23Z" }, - { url = "https://files.pythonhosted.org/packages/0a/b6/5c9a0e42df4d00bfb4a3cbbe5cf9f54260300c88a0e9af1f47ca5ce17ac0/propcache-0.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f048da1b4f243fc44f205dfd320933a951b8d89e0afd4c7cacc762a8b9165207", size = 47324, upload-time = "2025-10-08T19:46:48.384Z" }, - { url = "https://files.pythonhosted.org/packages/9e/d3/6c7ee328b39a81ee877c962469f1e795f9db87f925251efeb0545e0020d0/propcache-0.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ec17c65562a827bba85e3872ead335f95405ea1674860d96483a02f5c698fa72", size = 225505, upload-time = "2025-10-08T19:46:50.055Z" }, - { url = "https://files.pythonhosted.org/packages/01/5d/1c53f4563490b1d06a684742cc6076ef944bc6457df6051b7d1a877c057b/propcache-0.4.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:405aac25c6394ef275dee4c709be43745d36674b223ba4eb7144bf4d691b7367", size = 230242, upload-time = "2025-10-08T19:46:51.815Z" }, - { url = "https://files.pythonhosted.org/packages/20/e1/ce4620633b0e2422207c3cb774a0ee61cac13abc6217763a7b9e2e3f4a12/propcache-0.4.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0013cb6f8dde4b2a2f66903b8ba740bdfe378c943c4377a200551ceb27f379e4", size = 238474, upload-time = "2025-10-08T19:46:53.208Z" }, - { url = "https://files.pythonhosted.org/packages/46/4b/3aae6835b8e5f44ea6a68348ad90f78134047b503765087be2f9912140ea/propcache-0.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15932ab57837c3368b024473a525e25d316d8353016e7cc0e5ba9eb343fbb1cf", size = 221575, upload-time = "2025-10-08T19:46:54.511Z" }, - { url = "https://files.pythonhosted.org/packages/6e/a5/8a5e8678bcc9d3a1a15b9a29165640d64762d424a16af543f00629c87338/propcache-0.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:031dce78b9dc099f4c29785d9cf5577a3faf9ebf74ecbd3c856a7b92768c3df3", size = 216736, upload-time = "2025-10-08T19:46:56.212Z" }, - { url = "https://files.pythonhosted.org/packages/f1/63/b7b215eddeac83ca1c6b934f89d09a625aa9ee4ba158338854c87210cc36/propcache-0.4.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:ab08df6c9a035bee56e31af99be621526bd237bea9f32def431c656b29e41778", size = 213019, upload-time = "2025-10-08T19:46:57.595Z" }, - { url = "https://files.pythonhosted.org/packages/57/74/f580099a58c8af587cac7ba19ee7cb418506342fbbe2d4a4401661cca886/propcache-0.4.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4d7af63f9f93fe593afbf104c21b3b15868efb2c21d07d8732c0c4287e66b6a6", size = 220376, upload-time = "2025-10-08T19:46:59.067Z" }, - { url = "https://files.pythonhosted.org/packages/c4/ee/542f1313aff7eaf19c2bb758c5d0560d2683dac001a1c96d0774af799843/propcache-0.4.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:cfc27c945f422e8b5071b6e93169679e4eb5bf73bbcbf1ba3ae3a83d2f78ebd9", size = 226988, upload-time = "2025-10-08T19:47:00.544Z" }, - { url = "https://files.pythonhosted.org/packages/8f/18/9c6b015dd9c6930f6ce2229e1f02fb35298b847f2087ea2b436a5bfa7287/propcache-0.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:35c3277624a080cc6ec6f847cbbbb5b49affa3598c4535a0a4682a697aaa5c75", size = 215615, upload-time = "2025-10-08T19:47:01.968Z" }, - { url = "https://files.pythonhosted.org/packages/80/9e/e7b85720b98c45a45e1fca6a177024934dc9bc5f4d5dd04207f216fc33ed/propcache-0.4.1-cp312-cp312-win32.whl", hash = "sha256:671538c2262dadb5ba6395e26c1731e1d52534bfe9ae56d0b5573ce539266aa8", size = 38066, upload-time = "2025-10-08T19:47:03.503Z" }, - { url = "https://files.pythonhosted.org/packages/54/09/d19cff2a5aaac632ec8fc03737b223597b1e347416934c1b3a7df079784c/propcache-0.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:cb2d222e72399fcf5890d1d5cc1060857b9b236adff2792ff48ca2dfd46c81db", size = 41655, upload-time = "2025-10-08T19:47:04.973Z" }, - { url = "https://files.pythonhosted.org/packages/68/ab/6b5c191bb5de08036a8c697b265d4ca76148efb10fa162f14af14fb5f076/propcache-0.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:204483131fb222bdaaeeea9f9e6c6ed0cac32731f75dfc1d4a567fc1926477c1", size = 37789, upload-time = "2025-10-08T19:47:06.077Z" }, - { url = "https://files.pythonhosted.org/packages/bf/df/6d9c1b6ac12b003837dde8a10231a7344512186e87b36e855bef32241942/propcache-0.4.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:43eedf29202c08550aac1d14e0ee619b0430aaef78f85864c1a892294fbc28cf", size = 77750, upload-time = "2025-10-08T19:47:07.648Z" }, - { url = "https://files.pythonhosted.org/packages/8b/e8/677a0025e8a2acf07d3418a2e7ba529c9c33caf09d3c1f25513023c1db56/propcache-0.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d62cdfcfd89ccb8de04e0eda998535c406bf5e060ffd56be6c586cbcc05b3311", size = 44780, upload-time = "2025-10-08T19:47:08.851Z" }, - { url = "https://files.pythonhosted.org/packages/89/a4/92380f7ca60f99ebae761936bc48a72a639e8a47b29050615eef757cb2a7/propcache-0.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cae65ad55793da34db5f54e4029b89d3b9b9490d8abe1b4c7ab5d4b8ec7ebf74", size = 46308, upload-time = "2025-10-08T19:47:09.982Z" }, - { url = "https://files.pythonhosted.org/packages/2d/48/c5ac64dee5262044348d1d78a5f85dd1a57464a60d30daee946699963eb3/propcache-0.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:333ddb9031d2704a301ee3e506dc46b1fe5f294ec198ed6435ad5b6a085facfe", size = 208182, upload-time = "2025-10-08T19:47:11.319Z" }, - { url = "https://files.pythonhosted.org/packages/c6/0c/cd762dd011a9287389a6a3eb43aa30207bde253610cca06824aeabfe9653/propcache-0.4.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:fd0858c20f078a32cf55f7e81473d96dcf3b93fd2ccdb3d40fdf54b8573df3af", size = 211215, upload-time = "2025-10-08T19:47:13.146Z" }, - { url = "https://files.pythonhosted.org/packages/30/3e/49861e90233ba36890ae0ca4c660e95df565b2cd15d4a68556ab5865974e/propcache-0.4.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:678ae89ebc632c5c204c794f8dab2837c5f159aeb59e6ed0539500400577298c", size = 218112, upload-time = "2025-10-08T19:47:14.913Z" }, - { url = "https://files.pythonhosted.org/packages/f1/8b/544bc867e24e1bd48f3118cecd3b05c694e160a168478fa28770f22fd094/propcache-0.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d472aeb4fbf9865e0c6d622d7f4d54a4e101a89715d8904282bb5f9a2f476c3f", size = 204442, upload-time = "2025-10-08T19:47:16.277Z" }, - { url = "https://files.pythonhosted.org/packages/50/a6/4282772fd016a76d3e5c0df58380a5ea64900afd836cec2c2f662d1b9bb3/propcache-0.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4d3df5fa7e36b3225954fba85589da77a0fe6a53e3976de39caf04a0db4c36f1", size = 199398, upload-time = "2025-10-08T19:47:17.962Z" }, - { url = "https://files.pythonhosted.org/packages/3e/ec/d8a7cd406ee1ddb705db2139f8a10a8a427100347bd698e7014351c7af09/propcache-0.4.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:ee17f18d2498f2673e432faaa71698032b0127ebf23ae5974eeaf806c279df24", size = 196920, upload-time = "2025-10-08T19:47:19.355Z" }, - { url = "https://files.pythonhosted.org/packages/f6/6c/f38ab64af3764f431e359f8baf9e0a21013e24329e8b85d2da32e8ed07ca/propcache-0.4.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:580e97762b950f993ae618e167e7be9256b8353c2dcd8b99ec100eb50f5286aa", size = 203748, upload-time = "2025-10-08T19:47:21.338Z" }, - { url = "https://files.pythonhosted.org/packages/d6/e3/fa846bd70f6534d647886621388f0a265254d30e3ce47e5c8e6e27dbf153/propcache-0.4.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:501d20b891688eb8e7aa903021f0b72d5a55db40ffaab27edefd1027caaafa61", size = 205877, upload-time = "2025-10-08T19:47:23.059Z" }, - { url = "https://files.pythonhosted.org/packages/e2/39/8163fc6f3133fea7b5f2827e8eba2029a0277ab2c5beee6c1db7b10fc23d/propcache-0.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a0bd56e5b100aef69bd8562b74b46254e7c8812918d3baa700c8a8009b0af66", size = 199437, upload-time = "2025-10-08T19:47:24.445Z" }, - { url = "https://files.pythonhosted.org/packages/93/89/caa9089970ca49c7c01662bd0eeedfe85494e863e8043565aeb6472ce8fe/propcache-0.4.1-cp313-cp313-win32.whl", hash = "sha256:bcc9aaa5d80322bc2fb24bb7accb4a30f81e90ab8d6ba187aec0744bc302ad81", size = 37586, upload-time = "2025-10-08T19:47:25.736Z" }, - { url = "https://files.pythonhosted.org/packages/f5/ab/f76ec3c3627c883215b5c8080debb4394ef5a7a29be811f786415fc1e6fd/propcache-0.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:381914df18634f5494334d201e98245c0596067504b9372d8cf93f4bb23e025e", size = 40790, upload-time = "2025-10-08T19:47:26.847Z" }, - { url = "https://files.pythonhosted.org/packages/59/1b/e71ae98235f8e2ba5004d8cb19765a74877abf189bc53fc0c80d799e56c3/propcache-0.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:8873eb4460fd55333ea49b7d189749ecf6e55bf85080f11b1c4530ed3034cba1", size = 37158, upload-time = "2025-10-08T19:47:27.961Z" }, - { url = "https://files.pythonhosted.org/packages/83/ce/a31bbdfc24ee0dcbba458c8175ed26089cf109a55bbe7b7640ed2470cfe9/propcache-0.4.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:92d1935ee1f8d7442da9c0c4fa7ac20d07e94064184811b685f5c4fada64553b", size = 81451, upload-time = "2025-10-08T19:47:29.445Z" }, - { url = "https://files.pythonhosted.org/packages/25/9c/442a45a470a68456e710d96cacd3573ef26a1d0a60067e6a7d5e655621ed/propcache-0.4.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:473c61b39e1460d386479b9b2f337da492042447c9b685f28be4f74d3529e566", size = 46374, upload-time = "2025-10-08T19:47:30.579Z" }, - { url = "https://files.pythonhosted.org/packages/f4/bf/b1d5e21dbc3b2e889ea4327044fb16312a736d97640fb8b6aa3f9c7b3b65/propcache-0.4.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:c0ef0aaafc66fbd87842a3fe3902fd889825646bc21149eafe47be6072725835", size = 48396, upload-time = "2025-10-08T19:47:31.79Z" }, - { url = "https://files.pythonhosted.org/packages/f4/04/5b4c54a103d480e978d3c8a76073502b18db0c4bc17ab91b3cb5092ad949/propcache-0.4.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f95393b4d66bfae908c3ca8d169d5f79cd65636ae15b5e7a4f6e67af675adb0e", size = 275950, upload-time = "2025-10-08T19:47:33.481Z" }, - { url = "https://files.pythonhosted.org/packages/b4/c1/86f846827fb969c4b78b0af79bba1d1ea2156492e1b83dea8b8a6ae27395/propcache-0.4.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c07fda85708bc48578467e85099645167a955ba093be0a2dcba962195676e859", size = 273856, upload-time = "2025-10-08T19:47:34.906Z" }, - { url = "https://files.pythonhosted.org/packages/36/1d/fc272a63c8d3bbad6878c336c7a7dea15e8f2d23a544bda43205dfa83ada/propcache-0.4.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:af223b406d6d000830c6f65f1e6431783fc3f713ba3e6cc8c024d5ee96170a4b", size = 280420, upload-time = "2025-10-08T19:47:36.338Z" }, - { url = "https://files.pythonhosted.org/packages/07/0c/01f2219d39f7e53d52e5173bcb09c976609ba30209912a0680adfb8c593a/propcache-0.4.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a78372c932c90ee474559c5ddfffd718238e8673c340dc21fe45c5b8b54559a0", size = 263254, upload-time = "2025-10-08T19:47:37.692Z" }, - { url = "https://files.pythonhosted.org/packages/2d/18/cd28081658ce597898f0c4d174d4d0f3c5b6d4dc27ffafeef835c95eb359/propcache-0.4.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:564d9f0d4d9509e1a870c920a89b2fec951b44bf5ba7d537a9e7c1ccec2c18af", size = 261205, upload-time = "2025-10-08T19:47:39.659Z" }, - { url = "https://files.pythonhosted.org/packages/7a/71/1f9e22eb8b8316701c2a19fa1f388c8a3185082607da8e406a803c9b954e/propcache-0.4.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:17612831fda0138059cc5546f4d12a2aacfb9e47068c06af35c400ba58ba7393", size = 247873, upload-time = "2025-10-08T19:47:41.084Z" }, - { url = "https://files.pythonhosted.org/packages/4a/65/3d4b61f36af2b4eddba9def857959f1016a51066b4f1ce348e0cf7881f58/propcache-0.4.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:41a89040cb10bd345b3c1a873b2bf36413d48da1def52f268a055f7398514874", size = 262739, upload-time = "2025-10-08T19:47:42.51Z" }, - { url = "https://files.pythonhosted.org/packages/2a/42/26746ab087faa77c1c68079b228810436ccd9a5ce9ac85e2b7307195fd06/propcache-0.4.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:e35b88984e7fa64aacecea39236cee32dd9bd8c55f57ba8a75cf2399553f9bd7", size = 263514, upload-time = "2025-10-08T19:47:43.927Z" }, - { url = "https://files.pythonhosted.org/packages/94/13/630690fe201f5502d2403dd3cfd451ed8858fe3c738ee88d095ad2ff407b/propcache-0.4.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6f8b465489f927b0df505cbe26ffbeed4d6d8a2bbc61ce90eb074ff129ef0ab1", size = 257781, upload-time = "2025-10-08T19:47:45.448Z" }, - { url = "https://files.pythonhosted.org/packages/92/f7/1d4ec5841505f423469efbfc381d64b7b467438cd5a4bbcbb063f3b73d27/propcache-0.4.1-cp313-cp313t-win32.whl", hash = "sha256:2ad890caa1d928c7c2965b48f3a3815c853180831d0e5503d35cf00c472f4717", size = 41396, upload-time = "2025-10-08T19:47:47.202Z" }, - { url = "https://files.pythonhosted.org/packages/48/f0/615c30622316496d2cbbc29f5985f7777d3ada70f23370608c1d3e081c1f/propcache-0.4.1-cp313-cp313t-win_amd64.whl", hash = "sha256:f7ee0e597f495cf415bcbd3da3caa3bd7e816b74d0d52b8145954c5e6fd3ff37", size = 44897, upload-time = "2025-10-08T19:47:48.336Z" }, - { url = "https://files.pythonhosted.org/packages/fd/ca/6002e46eccbe0e33dcd4069ef32f7f1c9e243736e07adca37ae8c4830ec3/propcache-0.4.1-cp313-cp313t-win_arm64.whl", hash = "sha256:929d7cbe1f01bb7baffb33dc14eb5691c95831450a26354cd210a8155170c93a", size = 39789, upload-time = "2025-10-08T19:47:49.876Z" }, { url = "https://files.pythonhosted.org/packages/8e/5c/bca52d654a896f831b8256683457ceddd490ec18d9ec50e97dfd8fc726a8/propcache-0.4.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3f7124c9d820ba5548d431afb4632301acf965db49e666aa21c305cbe8c6de12", size = 78152, upload-time = "2025-10-08T19:47:51.051Z" }, { url = "https://files.pythonhosted.org/packages/65/9b/03b04e7d82a5f54fb16113d839f5ea1ede58a61e90edf515f6577c66fa8f/propcache-0.4.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:c0d4b719b7da33599dfe3b22d3db1ef789210a0597bc650b7cee9c77c2be8c5c", size = 44869, upload-time = "2025-10-08T19:47:52.594Z" }, { url = "https://files.pythonhosted.org/packages/b2/fa/89a8ef0468d5833a23fff277b143d0573897cf75bd56670a6d28126c7d68/propcache-0.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9f302f4783709a78240ebc311b793f123328716a60911d667e0c036bc5dcbded", size = 46596, upload-time = "2025-10-08T19:47:54.073Z" }, @@ -1066,7 +700,6 @@ version = "1.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pytest" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" } wheels = [ @@ -1106,26 +739,6 @@ version = "6.0.3" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, - { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, - { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, - { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, - { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, - { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, - { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, - { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, - { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, - { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, - { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, - { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, - { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, - { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, - { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, - { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, - { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, - { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, - { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, - { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, @@ -1173,7 +786,7 @@ wheels = [ [[package]] name = "span-panel-simulator" -version = "1.0.8" +version = "1.0.10" source = { editable = "." } dependencies = [ { name = "aiohttp" }, @@ -1280,60 +893,6 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/23/6e/beb1beec874a72f23815c1434518bfc4ed2175065173fb138c3705f658d4/yarl-1.23.0.tar.gz", hash = "sha256:53b1ea6ca88ebd4420379c330aea57e258408dd0df9af0992e5de2078dc9f5d5", size = 194676, upload-time = "2026-03-01T22:07:53.373Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/88/8a/94615bc31022f711add374097ad4144d569e95ff3c38d39215d07ac153a0/yarl-1.23.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:1932b6b8bba8d0160a9d1078aae5838a66039e8832d41d2992daa9a3a08f7860", size = 124737, upload-time = "2026-03-01T22:05:12.897Z" }, - { url = "https://files.pythonhosted.org/packages/e3/6f/c6554045d59d64052698add01226bc867b52fe4a12373415d7991fdca95d/yarl-1.23.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:411225bae281f114067578891bc75534cfb3d92a3b4dfef7a6ca78ba354e6069", size = 87029, upload-time = "2026-03-01T22:05:14.376Z" }, - { url = "https://files.pythonhosted.org/packages/19/2a/725ecc166d53438bc88f76822ed4b1e3b10756e790bafd7b523fe97c322d/yarl-1.23.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:13a563739ae600a631c36ce096615fe307f131344588b0bc0daec108cdb47b25", size = 86310, upload-time = "2026-03-01T22:05:15.71Z" }, - { url = "https://files.pythonhosted.org/packages/99/30/58260ed98e6ff7f90ba84442c1ddd758c9170d70327394a6227b310cd60f/yarl-1.23.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9cbf44c5cb4a7633d078788e1b56387e3d3cf2b8139a3be38040b22d6c3221c8", size = 97587, upload-time = "2026-03-01T22:05:17.384Z" }, - { url = "https://files.pythonhosted.org/packages/76/0a/8b08aac08b50682e65759f7f8dde98ae8168f72487e7357a5d684c581ef9/yarl-1.23.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:53ad387048f6f09a8969631e4de3f1bf70c50e93545d64af4f751b2498755072", size = 92528, upload-time = "2026-03-01T22:05:18.804Z" }, - { url = "https://files.pythonhosted.org/packages/52/07/0b7179101fe5f8385ec6c6bb5d0cb9f76bd9fb4a769591ab6fb5cdbfc69a/yarl-1.23.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4a59ba56f340334766f3a4442e0efd0af895fae9e2b204741ef885c446b3a1a8", size = 105339, upload-time = "2026-03-01T22:05:20.235Z" }, - { url = "https://files.pythonhosted.org/packages/d3/8a/36d82869ab5ec829ca8574dfcb92b51286fcfb1e9c7a73659616362dc880/yarl-1.23.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:803a3c3ce4acc62eaf01eaca1208dcf0783025ef27572c3336502b9c232005e7", size = 105061, upload-time = "2026-03-01T22:05:22.268Z" }, - { url = "https://files.pythonhosted.org/packages/66/3e/868e5c3364b6cee19ff3e1a122194fa4ce51def02c61023970442162859e/yarl-1.23.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a3d2bff8f37f8d0f96c7ec554d16945050d54462d6e95414babaa18bfafc7f51", size = 100132, upload-time = "2026-03-01T22:05:23.638Z" }, - { url = "https://files.pythonhosted.org/packages/cf/26/9c89acf82f08a52cb52d6d39454f8d18af15f9d386a23795389d1d423823/yarl-1.23.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c75eb09e8d55bceb4367e83496ff8ef2bc7ea6960efb38e978e8073ea59ecb67", size = 99289, upload-time = "2026-03-01T22:05:25.749Z" }, - { url = "https://files.pythonhosted.org/packages/6f/54/5b0db00d2cb056922356104468019c0a132e89c8d3ab67d8ede9f4483d2a/yarl-1.23.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:877b0738624280e34c55680d6054a307aa94f7d52fa0e3034a9cc6e790871da7", size = 96950, upload-time = "2026-03-01T22:05:27.318Z" }, - { url = "https://files.pythonhosted.org/packages/f6/40/10fa93811fd439341fad7e0718a86aca0de9548023bbb403668d6555acab/yarl-1.23.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:b5405bb8f0e783a988172993cfc627e4d9d00432d6bbac65a923041edacf997d", size = 93960, upload-time = "2026-03-01T22:05:28.738Z" }, - { url = "https://files.pythonhosted.org/packages/bc/d2/8ae2e6cd77d0805f4526e30ec43b6f9a3dfc542d401ac4990d178e4bf0cf/yarl-1.23.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1c3a3598a832590c5a3ce56ab5576361b5688c12cb1d39429cf5dba30b510760", size = 104703, upload-time = "2026-03-01T22:05:30.438Z" }, - { url = "https://files.pythonhosted.org/packages/2f/0c/b3ceacf82c3fe21183ce35fa2acf5320af003d52bc1fcf5915077681142e/yarl-1.23.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:8419ebd326430d1cbb7efb5292330a2cf39114e82df5cc3d83c9a0d5ebeaf2f2", size = 98325, upload-time = "2026-03-01T22:05:31.835Z" }, - { url = "https://files.pythonhosted.org/packages/9d/e0/12900edd28bdab91a69bd2554b85ad7b151f64e8b521fe16f9ad2f56477a/yarl-1.23.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:be61f6fff406ca40e3b1d84716fde398fc08bc63dd96d15f3a14230a0973ed86", size = 105067, upload-time = "2026-03-01T22:05:33.358Z" }, - { url = "https://files.pythonhosted.org/packages/15/61/74bb1182cf79c9bbe4eb6b1f14a57a22d7a0be5e9cedf8e2d5c2086474c3/yarl-1.23.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3ceb13c5c858d01321b5d9bb65e4cf37a92169ea470b70fec6f236b2c9dd7e34", size = 100285, upload-time = "2026-03-01T22:05:35.4Z" }, - { url = "https://files.pythonhosted.org/packages/69/7f/cd5ef733f2550de6241bd8bd8c3febc78158b9d75f197d9c7baa113436af/yarl-1.23.0-cp312-cp312-win32.whl", hash = "sha256:fffc45637bcd6538de8b85f51e3df3223e4ad89bccbfca0481c08c7fc8b7ed7d", size = 82359, upload-time = "2026-03-01T22:05:36.811Z" }, - { url = "https://files.pythonhosted.org/packages/f5/be/25216a49daeeb7af2bec0db22d5e7df08ed1d7c9f65d78b14f3b74fd72fc/yarl-1.23.0-cp312-cp312-win_amd64.whl", hash = "sha256:f69f57305656a4852f2a7203efc661d8c042e6cc67f7acd97d8667fb448a426e", size = 87674, upload-time = "2026-03-01T22:05:38.171Z" }, - { url = "https://files.pythonhosted.org/packages/d2/35/aeab955d6c425b227d5b7247eafb24f2653fedc32f95373a001af5dfeb9e/yarl-1.23.0-cp312-cp312-win_arm64.whl", hash = "sha256:6e87a6e8735b44816e7db0b2fbc9686932df473c826b0d9743148432e10bb9b9", size = 81879, upload-time = "2026-03-01T22:05:40.006Z" }, - { url = "https://files.pythonhosted.org/packages/9a/4b/a0a6e5d0ee8a2f3a373ddef8a4097d74ac901ac363eea1440464ccbe0898/yarl-1.23.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:16c6994ac35c3e74fb0ae93323bf8b9c2a9088d55946109489667c510a7d010e", size = 123796, upload-time = "2026-03-01T22:05:41.412Z" }, - { url = "https://files.pythonhosted.org/packages/67/b6/8925d68af039b835ae876db5838e82e76ec87b9782ecc97e192b809c4831/yarl-1.23.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4a42e651629dafb64fd5b0286a3580613702b5809ad3f24934ea87595804f2c5", size = 86547, upload-time = "2026-03-01T22:05:42.841Z" }, - { url = "https://files.pythonhosted.org/packages/ae/50/06d511cc4b8e0360d3c94af051a768e84b755c5eb031b12adaaab6dec6e5/yarl-1.23.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7c6b9461a2a8b47c65eef63bb1c76a4f1c119618ffa99ea79bc5bb1e46c5821b", size = 85854, upload-time = "2026-03-01T22:05:44.85Z" }, - { url = "https://files.pythonhosted.org/packages/c4/f4/4e30b250927ffdab4db70da08b9b8d2194d7c7b400167b8fbeca1e4701ca/yarl-1.23.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2569b67d616eab450d262ca7cb9f9e19d2f718c70a8b88712859359d0ab17035", size = 98351, upload-time = "2026-03-01T22:05:46.836Z" }, - { url = "https://files.pythonhosted.org/packages/86/fc/4118c5671ea948208bdb1492d8b76bdf1453d3e73df051f939f563e7dcc5/yarl-1.23.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e9d9a4d06d3481eab79803beb4d9bd6f6a8e781ec078ac70d7ef2dcc29d1bea5", size = 92711, upload-time = "2026-03-01T22:05:48.316Z" }, - { url = "https://files.pythonhosted.org/packages/56/11/1ed91d42bd9e73c13dc9e7eb0dd92298d75e7ac4dd7f046ad0c472e231cd/yarl-1.23.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f514f6474e04179d3d33175ed3f3e31434d3130d42ec153540d5b157deefd735", size = 106014, upload-time = "2026-03-01T22:05:50.028Z" }, - { url = "https://files.pythonhosted.org/packages/ce/c9/74e44e056a23fbc33aca71779ef450ca648a5bc472bdad7a82339918f818/yarl-1.23.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fda207c815b253e34f7e1909840fd14299567b1c0eb4908f8c2ce01a41265401", size = 105557, upload-time = "2026-03-01T22:05:51.416Z" }, - { url = "https://files.pythonhosted.org/packages/66/fe/b1e10b08d287f518994f1e2ff9b6d26f0adeecd8dd7d533b01bab29a3eda/yarl-1.23.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34b6cf500e61c90f305094911f9acc9c86da1a05a7a3f5be9f68817043f486e4", size = 101559, upload-time = "2026-03-01T22:05:52.872Z" }, - { url = "https://files.pythonhosted.org/packages/72/59/c5b8d94b14e3d3c2a9c20cb100119fd534ab5a14b93673ab4cc4a4141ea5/yarl-1.23.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d7504f2b476d21653e4d143f44a175f7f751cd41233525312696c76aa3dbb23f", size = 100502, upload-time = "2026-03-01T22:05:54.954Z" }, - { url = "https://files.pythonhosted.org/packages/77/4f/96976cb54cbfc5c9fd73ed4c51804f92f209481d1fb190981c0f8a07a1d7/yarl-1.23.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:578110dd426f0d209d1509244e6d4a3f1a3e9077655d98c5f22583d63252a08a", size = 98027, upload-time = "2026-03-01T22:05:56.409Z" }, - { url = "https://files.pythonhosted.org/packages/63/6e/904c4f476471afdbad6b7e5b70362fb5810e35cd7466529a97322b6f5556/yarl-1.23.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:609d3614d78d74ebe35f54953c5bbd2ac647a7ddb9c30a5d877580f5e86b22f2", size = 95369, upload-time = "2026-03-01T22:05:58.141Z" }, - { url = "https://files.pythonhosted.org/packages/9d/40/acfcdb3b5f9d68ef499e39e04d25e141fe90661f9d54114556cf83be8353/yarl-1.23.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4966242ec68afc74c122f8459abd597afd7d8a60dc93d695c1334c5fd25f762f", size = 105565, upload-time = "2026-03-01T22:06:00.286Z" }, - { url = "https://files.pythonhosted.org/packages/5e/c6/31e28f3a6ba2869c43d124f37ea5260cac9c9281df803c354b31f4dd1f3c/yarl-1.23.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e0fd068364a6759bc794459f0a735ab151d11304346332489c7972bacbe9e72b", size = 99813, upload-time = "2026-03-01T22:06:01.712Z" }, - { url = "https://files.pythonhosted.org/packages/08/1f/6f65f59e72d54aa467119b63fc0b0b1762eff0232db1f4720cd89e2f4a17/yarl-1.23.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:39004f0ad156da43e86aa71f44e033de68a44e5a31fc53507b36dd253970054a", size = 105632, upload-time = "2026-03-01T22:06:03.188Z" }, - { url = "https://files.pythonhosted.org/packages/a3/c4/18b178a69935f9e7a338127d5b77d868fdc0f0e49becd286d51b3a18c61d/yarl-1.23.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e5723c01a56c5028c807c701aa66722916d2747ad737a046853f6c46f4875543", size = 101895, upload-time = "2026-03-01T22:06:04.651Z" }, - { url = "https://files.pythonhosted.org/packages/8f/54/f5b870b5505663911dba950a8e4776a0dbd51c9c54c0ae88e823e4b874a0/yarl-1.23.0-cp313-cp313-win32.whl", hash = "sha256:1b6b572edd95b4fa8df75de10b04bc81acc87c1c7d16bcdd2035b09d30acc957", size = 82356, upload-time = "2026-03-01T22:06:06.04Z" }, - { url = "https://files.pythonhosted.org/packages/7a/84/266e8da36879c6edcd37b02b547e2d9ecdfea776be49598e75696e3316e1/yarl-1.23.0-cp313-cp313-win_amd64.whl", hash = "sha256:baaf55442359053c7d62f6f8413a62adba3205119bcb6f49594894d8be47e5e3", size = 87515, upload-time = "2026-03-01T22:06:08.107Z" }, - { url = "https://files.pythonhosted.org/packages/00/fd/7e1c66efad35e1649114fa13f17485f62881ad58edeeb7f49f8c5e748bf9/yarl-1.23.0-cp313-cp313-win_arm64.whl", hash = "sha256:fb4948814a2a98e3912505f09c9e7493b1506226afb1f881825368d6fb776ee3", size = 81785, upload-time = "2026-03-01T22:06:10.181Z" }, - { url = "https://files.pythonhosted.org/packages/9c/fc/119dd07004f17ea43bb91e3ece6587759edd7519d6b086d16bfbd3319982/yarl-1.23.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:aecfed0b41aa72b7881712c65cf764e39ce2ec352324f5e0837c7048d9e6daaa", size = 130719, upload-time = "2026-03-01T22:06:11.708Z" }, - { url = "https://files.pythonhosted.org/packages/e6/0d/9f2348502fbb3af409e8f47730282cd6bc80dec6630c1e06374d882d6eb2/yarl-1.23.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:a41bcf68efd19073376eb8cf948b8d9be0af26256403e512bb18f3966f1f9120", size = 89690, upload-time = "2026-03-01T22:06:13.429Z" }, - { url = "https://files.pythonhosted.org/packages/50/93/e88f3c80971b42cfc83f50a51b9d165a1dbf154b97005f2994a79f212a07/yarl-1.23.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:cde9a2ecd91668bcb7f077c4966d8ceddb60af01b52e6e3e2680e4cf00ad1a59", size = 89851, upload-time = "2026-03-01T22:06:15.53Z" }, - { url = "https://files.pythonhosted.org/packages/1c/07/61c9dd8ba8f86473263b4036f70fb594c09e99c0d9737a799dfd8bc85651/yarl-1.23.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5023346c4ee7992febc0068e7593de5fa2bf611848c08404b35ebbb76b1b0512", size = 95874, upload-time = "2026-03-01T22:06:17.553Z" }, - { url = "https://files.pythonhosted.org/packages/9e/e9/f9ff8ceefba599eac6abddcfb0b3bee9b9e636e96dbf54342a8577252379/yarl-1.23.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d1009abedb49ae95b136a8904a3f71b342f849ffeced2d3747bf29caeda218c4", size = 88710, upload-time = "2026-03-01T22:06:19.004Z" }, - { url = "https://files.pythonhosted.org/packages/eb/78/0231bfcc5d4c8eec220bc2f9ef82cb4566192ea867a7c5b4148f44f6cbcd/yarl-1.23.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a8d00f29b42f534cc8aa3931cfe773b13b23e561e10d2b26f27a8d309b0e82a1", size = 101033, upload-time = "2026-03-01T22:06:21.203Z" }, - { url = "https://files.pythonhosted.org/packages/cd/9b/30ea5239a61786f18fd25797151a17fbb3be176977187a48d541b5447dd4/yarl-1.23.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:95451e6ce06c3e104556d73b559f5da6c34a069b6b62946d3ad66afcd51642ea", size = 100817, upload-time = "2026-03-01T22:06:22.738Z" }, - { url = "https://files.pythonhosted.org/packages/62/e2/a4980481071791bc83bce2b7a1a1f7adcabfa366007518b4b845e92eeee3/yarl-1.23.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:531ef597132086b6cf96faa7c6c1dcd0361dd5f1694e5cc30375907b9b7d3ea9", size = 97482, upload-time = "2026-03-01T22:06:24.21Z" }, - { url = "https://files.pythonhosted.org/packages/e5/1e/304a00cf5f6100414c4b5a01fc7ff9ee724b62158a08df2f8170dfc72a2d/yarl-1.23.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:88f9fb0116fbfcefcab70f85cf4b74a2b6ce5d199c41345296f49d974ddb4123", size = 95949, upload-time = "2026-03-01T22:06:25.697Z" }, - { url = "https://files.pythonhosted.org/packages/68/03/093f4055ed4cae649ac53bca3d180bd37102e9e11d048588e9ab0c0108d0/yarl-1.23.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:e7b0460976dc75cb87ad9cc1f9899a4b97751e7d4e77ab840fc9b6d377b8fd24", size = 95839, upload-time = "2026-03-01T22:06:27.309Z" }, - { url = "https://files.pythonhosted.org/packages/b9/28/4c75ebb108f322aa8f917ae10a8ffa4f07cae10a8a627b64e578617df6a0/yarl-1.23.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:115136c4a426f9da976187d238e84139ff6b51a20839aa6e3720cd1026d768de", size = 90696, upload-time = "2026-03-01T22:06:29.048Z" }, - { url = "https://files.pythonhosted.org/packages/23/9c/42c2e2dd91c1a570402f51bdf066bfdb1241c2240ba001967bad778e77b7/yarl-1.23.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:ead11956716a940c1abc816b7df3fa2b84d06eaed8832ca32f5c5e058c65506b", size = 100865, upload-time = "2026-03-01T22:06:30.525Z" }, - { url = "https://files.pythonhosted.org/packages/74/05/1bcd60a8a0a914d462c305137246b6f9d167628d73568505fce3f1cb2e65/yarl-1.23.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:fe8f8f5e70e6dbdfca9882cd9deaac058729bcf323cf7a58660901e55c9c94f6", size = 96234, upload-time = "2026-03-01T22:06:32.692Z" }, - { url = "https://files.pythonhosted.org/packages/90/b2/f52381aac396d6778ce516b7bc149c79e65bfc068b5de2857ab69eeea3b7/yarl-1.23.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:a0e317df055958a0c1e79e5d2aa5a5eaa4a6d05a20d4b0c9c3f48918139c9fc6", size = 100295, upload-time = "2026-03-01T22:06:34.268Z" }, - { url = "https://files.pythonhosted.org/packages/e5/e8/638bae5bbf1113a659b2435d8895474598afe38b4a837103764f603aba56/yarl-1.23.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6f0fd84de0c957b2d280143522c4f91a73aada1923caee763e24a2b3fda9f8a5", size = 97784, upload-time = "2026-03-01T22:06:35.864Z" }, - { url = "https://files.pythonhosted.org/packages/80/25/a3892b46182c586c202629fc2159aa13975d3741d52ebd7347fd501d48d5/yarl-1.23.0-cp313-cp313t-win32.whl", hash = "sha256:93a784271881035ab4406a172edb0faecb6e7d00f4b53dc2f55919d6c9688595", size = 88313, upload-time = "2026-03-01T22:06:37.39Z" }, - { url = "https://files.pythonhosted.org/packages/43/68/8c5b36aa5178900b37387937bc2c2fe0e9505537f713495472dcf6f6fccc/yarl-1.23.0-cp313-cp313t-win_amd64.whl", hash = "sha256:dd00607bffbf30250fe108065f07453ec124dbf223420f57f5e749b04295e090", size = 94932, upload-time = "2026-03-01T22:06:39.579Z" }, - { url = "https://files.pythonhosted.org/packages/c6/cc/d79ba8292f51f81f4dc533a8ccfb9fc6992cabf0998ed3245de7589dc07c/yarl-1.23.0-cp313-cp313t-win_arm64.whl", hash = "sha256:ac09d42f48f80c9ee1635b2fcaa819496a44502737660d3c0f2ade7526d29144", size = 84786, upload-time = "2026-03-01T22:06:41.988Z" }, { url = "https://files.pythonhosted.org/packages/90/98/b85a038d65d1b92c3903ab89444f48d3cee490a883477b716d7a24b1a78c/yarl-1.23.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:21d1b7305a71a15b4794b5ff22e8eef96ff4a6d7f9657155e5aa419444b28912", size = 124455, upload-time = "2026-03-01T22:06:43.615Z" }, { url = "https://files.pythonhosted.org/packages/39/54/bc2b45559f86543d163b6e294417a107bb87557609007c007ad889afec18/yarl-1.23.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:85610b4f27f69984932a7abbe52703688de3724d9f72bceb1cca667deff27474", size = 86752, upload-time = "2026-03-01T22:06:45.425Z" }, { url = "https://files.pythonhosted.org/packages/24/f9/e8242b68362bffe6fb536c8db5076861466fc780f0f1b479fc4ffbebb128/yarl-1.23.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23f371bd662cf44a7630d4d113101eafc0cfa7518a2760d20760b26021454719", size = 86291, upload-time = "2026-03-01T22:06:46.974Z" }, @@ -1382,27 +941,6 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/67/46/10db987799629d01930176ae523f70879b63577060d63e05ebf9214aba4b/zeroconf-0.148.0.tar.gz", hash = "sha256:03fcca123df3652e23d945112d683d2f605f313637611b7d4adf31056f681702", size = 164447, upload-time = "2025-10-05T00:21:19.199Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/00/b3/6c08ccbda1e78c8f538d8add49fac2fe49ef85ee34b62877df4154715583/zeroconf-0.148.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:aef8699ea47cd47c9219e3f110a35ad50c13c34c7c6db992f3c9f75feec6ef8f", size = 1735431, upload-time = "2025-10-05T01:08:09.375Z" }, - { url = "https://files.pythonhosted.org/packages/cb/37/6b91c4a4258863e485602e6b1eb098fe406142a653112e8719c49b69afc4/zeroconf-0.148.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9097e7010b9f9a64e5f2084493e9973d446bd85c7a7cbef5032b2b0a2ecc5a12", size = 1701594, upload-time = "2025-10-05T01:08:11.448Z" }, - { url = "https://files.pythonhosted.org/packages/c6/78/5eaaf66d39b3bccc17b52187eebb2dde93f761f4ee8b6c83b8fe764273f5/zeroconf-0.148.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cdc566c387260fb7bf89f91d00460d0c9b9373dfddcf1fcc980ab3f7270154f9", size = 2134103, upload-time = "2025-10-05T01:08:13.061Z" }, - { url = "https://files.pythonhosted.org/packages/19/a5/e4ebe7b5fbea512fe13efb466d855124126d2f531a18216c7cb509b8a4dd/zeroconf-0.148.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:10cbd4134cacc22c3b3b169d7f782472a1dd36895e1421afa4f681caf181c07b", size = 1930109, upload-time = "2025-10-05T01:08:14.68Z" }, - { url = "https://files.pythonhosted.org/packages/e1/16/7f7c5cee5279afe2a6a8b9657de9a587ccb34168d7c99acc6d2b40b9d87e/zeroconf-0.148.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dde01541e6a45c4d1b6e6d97b532ea241abc32c183745a74021b134d867388d8", size = 2230425, upload-time = "2025-10-05T01:08:16.296Z" }, - { url = "https://files.pythonhosted.org/packages/cd/41/0e1999db76e390fca9eef8257455955445a0386b94ce0ef6ce74896d7e2a/zeroconf-0.148.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8ceab8f10ab6fc0847a2de74377663793a974fdba77e7e6ba1ff47679f4bb845", size = 2161052, upload-time = "2025-10-05T01:08:17.976Z" }, - { url = "https://files.pythonhosted.org/packages/5e/19/6585fe6308b8f1ac0ac4d37ac69064ec2a36b81cf9080813cb666229694c/zeroconf-0.148.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0a8c36c37d8835420fc337be4aaa03c3a34272028919de575124c10d31a7e304", size = 2015005, upload-time = "2025-10-05T01:08:20.318Z" }, - { url = "https://files.pythonhosted.org/packages/74/ec/a9d0a577be157170f513e6ad6ebb3cd8dd9602c670d74911e9c5534e1c1d/zeroconf-0.148.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:848d57df1bb3b48279ba9b66e6c1f727570e2c8e7e0c4518c2daffaf23419d03", size = 2253785, upload-time = "2025-10-05T01:08:21.971Z" }, - { url = "https://files.pythonhosted.org/packages/ae/43/6679c16d4e6897c9aa502ee35c122bb605eee855612fad2ef6e0e13722c4/zeroconf-0.148.0-cp312-cp312-win32.whl", hash = "sha256:ba6eaa6b769924391c213dc391f36bd1c7e3ebe45fa3fa0cd97451b4f9ccef5c", size = 1295810, upload-time = "2025-10-05T01:08:23.575Z" }, - { url = "https://files.pythonhosted.org/packages/8e/42/a2d61df82086ddd32b9a5870ac683e8e5038cae38e2433c4fa03fe044235/zeroconf-0.148.0-cp312-cp312-win_amd64.whl", hash = "sha256:cec84ae7028db4a3addcc18628d12456cf39a9e973abee4a41e3b94d0db7df4c", size = 1533317, upload-time = "2025-10-05T01:08:26.973Z" }, - { url = "https://files.pythonhosted.org/packages/46/09/394a24a633645063557c5144c9abb694699df76155dcab5e1e3078dd1323/zeroconf-0.148.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6ad889929bdc3953530546a4a2486d8c07f5a18d4ef494a98446bf17414897a7", size = 1714465, upload-time = "2025-10-05T01:08:28.692Z" }, - { url = "https://files.pythonhosted.org/packages/3d/db/f57c4bfcceb67fe474705cbadba3f8f7a88bdc95892e74ba6d85e24d28c3/zeroconf-0.148.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:29fb10be743650eb40863f1a1ee868df1869357a0c2ab75140ee3d7079540c1e", size = 1683877, upload-time = "2025-10-05T01:08:30.42Z" }, - { url = "https://files.pythonhosted.org/packages/54/6c/b3e2d39c40802a8cc9415357acdb76ff01bc29e25ffaa811771b6fffc428/zeroconf-0.148.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f2995e74969c577461060539164c47e1ba674470585cb0f954ebeb77f032f3c2", size = 2122874, upload-time = "2025-10-05T01:08:32.11Z" }, - { url = "https://files.pythonhosted.org/packages/66/eb/0ac2bf51d58d47cfa854628036a7ad95544a1802bc890f3d69649dc35e46/zeroconf-0.148.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5be50346efdc20823f9d68d8757612767d11ceb8da7637d46080977b87912551", size = 1922164, upload-time = "2025-10-05T01:08:33.78Z" }, - { url = "https://files.pythonhosted.org/packages/59/ff/c7372507c7e25ad3499fe08d4678deb1ed41c57f78ff5df43bd2d4d98cfc/zeroconf-0.148.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cc88fd01b5552ffb4d5bc551d027ac28a1852c03ceab754d02bd0d5f04c54e85", size = 2214119, upload-time = "2025-10-05T01:08:35.478Z" }, - { url = "https://files.pythonhosted.org/packages/d7/c7/57f0889f47923b4fa4364b62b7b3ffc347f6bad09a25ce4e578b8991a86d/zeroconf-0.148.0-cp313-cp313-manylinux_2_36_x86_64.whl", hash = "sha256:5af260c74187751c0df6a40f38d6fd17cb8658a734b0e1148a86084b71c1977c", size = 2137609, upload-time = "2025-10-05T00:21:15.953Z" }, - { url = "https://files.pythonhosted.org/packages/3b/33/9cb5558695c1377941dbb10a5591f88a787f9e1fba130642693d5c80663b/zeroconf-0.148.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b6078c73a76d49ba969ca2bb7067e4d58ebd2b79a5f956e45c4c989b11d36e03", size = 2154314, upload-time = "2025-10-05T01:08:37.523Z" }, - { url = "https://files.pythonhosted.org/packages/38/06/cf4e17a86922b4561d85d36f50f1adada1328723e882d95aa42baefa5479/zeroconf-0.148.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:3e686bf741158f4253d5e0aa6a8f9d34b3140bf5826c0aca9b906273b9c77a5f", size = 2004973, upload-time = "2025-10-05T01:08:39.825Z" }, - { url = "https://files.pythonhosted.org/packages/a4/61/937a405783317639cd11e7bfab3879669896297b6ca2edfb0d2d9c8dbb30/zeroconf-0.148.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:52d6ac06efe05a1e46089cfde066985782824f64b64c6982e8678e70b4b49453", size = 2237775, upload-time = "2025-10-05T01:08:41.535Z" }, - { url = "https://files.pythonhosted.org/packages/03/43/a1751c4b63e108a2318c2266e5afdd9d62292250aa8b1a8ed1674090885c/zeroconf-0.148.0-cp313-cp313-win32.whl", hash = "sha256:b9ba58e2bbb0cff020b54330916eaeb8ee8f4b0dde852e84f670f4ca3a0dd059", size = 1291073, upload-time = "2025-10-05T01:08:43.757Z" }, - { url = "https://files.pythonhosted.org/packages/5e/69/5f4f9eb14506e2afd2d423472e566d5455334d0c8740b933914d642bdbb5/zeroconf-0.148.0-cp313-cp313-win_amd64.whl", hash = "sha256:ee3fcc2edcc04635cf673c400abac2f0c22c9786490fbfb971e0a860a872bf26", size = 1528568, upload-time = "2025-10-05T01:08:45.505Z" }, { url = "https://files.pythonhosted.org/packages/a5/46/ac86e3a3ff355058cd0818b01a3a97ca3f2abc0a034f1edb8eea27cea65c/zeroconf-0.148.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:2158d8bfefcdb90237937df65b2235870ccef04644497e4e29d3ab5a4b3199b6", size = 1714870, upload-time = "2025-10-05T01:08:47.624Z" }, { url = "https://files.pythonhosted.org/packages/de/02/c5e8cd8dfda0ca16c7309c8d12c09a3114e5b50054bce3c93da65db8b8e4/zeroconf-0.148.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:695f6663bf8df30fe1826a2c4d5acd8213d9cbd9111f59d375bf1ad635790e98", size = 1697756, upload-time = "2025-10-05T01:08:49.472Z" }, { url = "https://files.pythonhosted.org/packages/63/04/a66c1011d05d7bb8ae6a847d41ac818271a942390f3d8c83c776389ca094/zeroconf-0.148.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa65a24ec055be0a1cba2b986ac3e1c5d97a40abe164991aabc6a6416cc9df02", size = 2146784, upload-time = "2025-10-05T01:08:51.766Z" }, From 11b9e906ecf589aa3c122c81af9f133a5b78e0bd Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Mon, 30 Mar 2026 16:50:41 -0700 Subject: [PATCH 02/35] Add dashboard i18n design spec Defines the approach for internationalizing the simulator dashboard: server-side Jinja2 translation via extended YAML files, JSON bridge for inline JS, Intl APIs for date/number formatting, locale detection from HA supervisor API or host system locale. --- .../specs/2026-03-30-dashboard-i18n-design.md | 177 ++++++++++++++++++ 1 file changed, 177 insertions(+) create mode 100644 docs/superpowers/specs/2026-03-30-dashboard-i18n-design.md diff --git a/docs/superpowers/specs/2026-03-30-dashboard-i18n-design.md b/docs/superpowers/specs/2026-03-30-dashboard-i18n-design.md new file mode 100644 index 0000000..90dae76 --- /dev/null +++ b/docs/superpowers/specs/2026-03-30-dashboard-i18n-design.md @@ -0,0 +1,177 @@ +# Dashboard Internationalization (i18n) Design + +## Overview + +Add internationalization support to the simulator dashboard so all +user-visible strings (labels, buttons, tabs, tutorial text) render in the +language appropriate to the deployment context. Standalone installations +use the host system locale; Home Assistant add-on installations use HA's +configured language. + +Supported languages match the existing translation files: en, nl, de, fr, +es, pt-BR. + +## Locale Resolution + +A single locale is determined once at dashboard startup and held for the +lifetime of the process. + +**Resolution order:** + +1. **HA add-on mode** (`SUPERVISOR_TOKEN` present): GET + `http://supervisor/core/api/config` with the supervisor token, read + the `language` field (e.g. `"nl"`). +2. **Standalone mode**: `locale.getlocale()` -> parse the language code + (e.g. `en_US.UTF-8` -> `"en"`). +3. **Fallback**: `"en"`. + +The resolved locale is validated against available translation files. If +the locale has no matching YAML file, fall back to `"en"`. + +The locale string is stored on `DashboardContext`, which already flows +into every route handler. + +## Translator Class + +A `Translator` class loads all YAML files from +`span_panel_simulator/translations/` at startup. Each file's `dashboard:` +section is flattened into dot-notation keys: + +```yaml +dashboard: + controls: + grid_online: Grid Online +``` + +Becomes: `{"controls.grid_online": "Grid Online"}` + +### Interface + +- `t(key: str) -> str` -- look up key in active locale, fall back to + `en` if missing, return the raw key string as last resort. +- `to_json() -> str` -- serialize the active locale's dashboard + dictionary as JSON for the JavaScript bridge. + +The translator is created once during `create_dashboard_app()` and +registered as a Jinja2 global. + +## Translation File Structure + +The existing `span_panel_simulator/translations/*.yaml` files are +extended with a `dashboard:` section alongside the existing +`configuration:` section: + +```yaml +configuration: + # ... existing HA add-on config strings unchanged ... + +dashboard: + title: SPAN Panel Simulator Dashboard + theme: + label: Theme + system: System + light: Light + dark: Dark + tabs: + getting_started: Getting started + clone: Clone + model: Model + purge: Purge + export: Export + getting_started: + title: Getting started + step_1: "Click a simulator configuration..." + # ... full tutorial text + controls: + grid_online: Grid Online + grid_offline: Grid Offline + islandable: Islandable + not_islandable: Not Islandable + runtime: Runtime + modeling: Modeling + date: Date + time_of_day: Time of Day + speed: Speed + chart: + live_power_flows: Live Power Flows + grid: Grid + solar: Solar + battery: Battery + panel_config: + serial: "Serial:" + tabs: "Tabs:" + main_breaker: "Main Breaker (A):" + # ... remaining config labels + sim_config: + interval: "Interval (s)" + noise: Noise + save_reload: Save & Reload + update: Update + panels: + title: Panels + import: Import + overwrite: Overwrite + cancel: Cancel +``` + +All 6 language files get the same `dashboard:` key structure. English is +the source of truth; other languages are translated to match. + +## Template Integration + +### Server-rendered HTML (Jinja2) + +Every hardcoded English string is replaced with a `{{ t('key') }}` call: + +```html + + + + + +``` + +### Inline JavaScript bridge + +In `base.html`, the locale and full dictionary are injected once: + +```html + +``` + +JS code references strings via `window.i18n['controls.grid_online']`. + +### Date and number formatting + +Hardcoded month arrays and manual number formatting are replaced with +`Intl` APIs using the locale: + +```js +new Intl.DateTimeFormat(window.i18nLocale, { month: 'short' }).format(date) +``` + +## Error Handling + +- `t(key)` never throws. Fallback chain: active locale -> `en` -> raw + key string. +- Raw keys appearing in the UI make missing translations obvious during + development without breaking rendering. + +## Testing + +- **Translator unit tests**: loading, key lookup, fallback chain, + `to_json()` output. +- **Locale resolution unit tests**: mock `SUPERVISOR_TOKEN` for HA mode, + mock `locale.getlocale()` for standalone, verify fallback to `"en"` for + unsupported locales. +- **Translation key parity test**: load all YAML files and assert every + non-English file has the same set of `dashboard:` keys as `en.yaml`. + Catches missing translations at CI time. + +## Dependencies + +No new dependencies. PyYAML is already in the project; `json` and +`locale` are stdlib. From 46c7e3fc4061002fa6ab231c49f424042957b9b8 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Tue, 31 Mar 2026 01:27:57 -0700 Subject: [PATCH 03/35] Add dashboard i18n implementation plan 16-task plan covering: Translator class, locale resolution, English and 5-language translation files, all 18 template conversions, JS Intl API integration, and full test coverage. --- .../plans/2026-03-31-dashboard-i18n.md | 1444 +++++++++++++++++ 1 file changed, 1444 insertions(+) create mode 100644 docs/superpowers/plans/2026-03-31-dashboard-i18n.md diff --git a/docs/superpowers/plans/2026-03-31-dashboard-i18n.md b/docs/superpowers/plans/2026-03-31-dashboard-i18n.md new file mode 100644 index 0000000..4af27ca --- /dev/null +++ b/docs/superpowers/plans/2026-03-31-dashboard-i18n.md @@ -0,0 +1,1444 @@ +# Dashboard Internationalization Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Internationalize the simulator dashboard so all user-visible strings render in the host system's language (standalone) or HA's configured language (add-on mode). + +**Architecture:** A `Translator` class loads YAML translation files at startup and exposes a `t(key)` function registered as a Jinja2 global. Templates call `{{ t('key') }}` for server-rendered strings. Inline JS receives the full dictionary as `window.i18n` and uses `Intl` APIs for date/number formatting. Locale is resolved once at startup from the HA supervisor API or host `locale.getlocale()`. + +**Tech Stack:** Python stdlib (`locale`, `json`), PyYAML (existing dep), aiohttp/Jinja2 (existing), JS `Intl` APIs. + +**Spec:** `docs/superpowers/specs/2026-03-30-dashboard-i18n-design.md` + +--- + +## File Structure + +| Action | File | Responsibility | +|--------|------|----------------| +| Create | `src/span_panel_simulator/dashboard/translator.py` | Translator class: load YAMLs, flatten keys, `t()`, `to_json()`, locale resolution | +| Create | `tests/test_translator.py` | Unit tests for Translator, locale resolution, fallback, key parity | +| Modify | `src/span_panel_simulator/dashboard/context.py` | Add `locale: str` field to `DashboardContext` | +| Modify | `src/span_panel_simulator/dashboard/__init__.py` | Instantiate Translator, register Jinja2 globals | +| Modify | `src/span_panel_simulator/dashboard/keys.py` | Add `APP_KEY_TRANSLATOR` app key | +| Modify | `src/span_panel_simulator/app.py` | Resolve locale and pass to DashboardContext | +| Modify | `span_panel_simulator/translations/en.yaml` | Add `dashboard:` section with all UI strings | +| Modify | `span_panel_simulator/translations/nl.yaml` | Add `dashboard:` section (Dutch) | +| Modify | `span_panel_simulator/translations/de.yaml` | Add `dashboard:` section (German) | +| Modify | `span_panel_simulator/translations/fr.yaml` | Add `dashboard:` section (French) | +| Modify | `span_panel_simulator/translations/es.yaml` | Add `dashboard:` section (Spanish) | +| Modify | `span_panel_simulator/translations/pt-BR.yaml` | Add `dashboard:` section (Portuguese) | +| Modify | `src/span_panel_simulator/dashboard/templates/base.html` | Inject i18n JS bridge, translate theme strings | +| Modify | `src/span_panel_simulator/dashboard/templates/dashboard.html` | Translate getting-started text | +| Modify | `src/span_panel_simulator/dashboard/templates/partials/runtime_controls.html` | Translate labels, buttons, chart legends; replace month arrays with Intl | +| Modify | `src/span_panel_simulator/dashboard/templates/partials/panel_config.html` | Translate form labels and JS messages | +| Modify | `src/span_panel_simulator/dashboard/templates/partials/sim_config.html` | Translate form labels and buttons | +| Modify | `src/span_panel_simulator/dashboard/templates/partials/entity_list.html` | Translate headings, buttons, hints | +| Modify | `src/span_panel_simulator/dashboard/templates/partials/entity_row.html` | Translate badges, tooltips, buttons | +| Modify | `src/span_panel_simulator/dashboard/templates/partials/entity_edit.html` | Translate all form labels | +| Modify | `src/span_panel_simulator/dashboard/templates/partials/clone_panel.html` | Translate labels, hints, buttons | +| Modify | `src/span_panel_simulator/dashboard/templates/partials/clone_confirm.html` | Translate dialog text | +| Modify | `src/span_panel_simulator/dashboard/templates/partials/running_panels.html` | Translate headings, buttons | +| Modify | `src/span_panel_simulator/dashboard/templates/partials/panels_list_rows.html` | Translate badges, buttons, tooltips, JS messages | +| Modify | `src/span_panel_simulator/dashboard/templates/partials/profile_editor.html` | Translate labels, buttons | +| Modify | `src/span_panel_simulator/dashboard/templates/partials/pv_profile.html` | Translate labels, replace month arrays with Intl | +| Modify | `src/span_panel_simulator/dashboard/templates/partials/battery_profile_editor.html` | Translate mode labels, hints, buttons | +| Modify | `src/span_panel_simulator/dashboard/templates/partials/evse_schedule.html` | Translate labels, buttons | +| Modify | `src/span_panel_simulator/dashboard/templates/partials/panel_source.html` | Translate headings, status text | +| Modify | `src/span_panel_simulator/dashboard/templates/partials/modeling_view.html` | Translate all labels, dialogs, chart text, JS messages | + +--- + +## Task 1: Translator Class — Core + +**Files:** +- Create: `src/span_panel_simulator/dashboard/translator.py` +- Create: `tests/test_translator.py` + +- [ ] **Step 1: Write test for YAML loading and key flattening** + +```python +# tests/test_translator.py +"""Tests for the dashboard Translator.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest +import yaml + +from span_panel_simulator.dashboard.translator import Translator + + +@pytest.fixture() +def translations_dir(tmp_path: Path) -> Path: + """Create a temporary translations directory with test YAML files.""" + en = { + "configuration": {"tick_interval": {"name": "Tick interval"}}, + "dashboard": { + "title": "Dashboard Title", + "controls": {"grid_online": "Grid Online", "speed": "Speed"}, + }, + } + nl = { + "configuration": {"tick_interval": {"name": "Tick-interval"}}, + "dashboard": { + "title": "Dashboard Titel", + "controls": {"grid_online": "Grid Aan", "speed": "Snelheid"}, + }, + } + (tmp_path / "en.yaml").write_text(yaml.dump(en, allow_unicode=True)) + (tmp_path / "nl.yaml").write_text(yaml.dump(nl, allow_unicode=True)) + return tmp_path + + +class TestTranslatorLoading: + def test_loads_english(self, translations_dir: Path) -> None: + t = Translator(translations_dir, "en") + assert t("title") == "Dashboard Title" + + def test_loads_nested_key(self, translations_dir: Path) -> None: + t = Translator(translations_dir, "en") + assert t("controls.grid_online") == "Grid Online" + + def test_loads_requested_locale(self, translations_dir: Path) -> None: + t = Translator(translations_dir, "nl") + assert t("title") == "Dashboard Titel" + assert t("controls.grid_online") == "Grid Aan" +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `python -m pytest tests/test_translator.py -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'span_panel_simulator.dashboard.translator'` + +- [ ] **Step 3: Implement Translator class** + +```python +# src/span_panel_simulator/dashboard/translator.py +"""Internationalization support for the dashboard. + +Loads YAML translation files and provides a ``t(key)`` function for +looking up translated strings by dot-notation key. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +import yaml + + +def _flatten(data: dict[str, Any], prefix: str = "") -> dict[str, str]: + """Flatten a nested dictionary into dot-notation keys.""" + result: dict[str, str] = {} + for key, value in data.items(): + full_key = f"{prefix}{key}" if prefix else key + if isinstance(value, dict): + result.update(_flatten(value, f"{full_key}.")) + else: + result[full_key] = str(value) + return result + + +class Translator: + """Provides translated strings for the dashboard UI. + + Loads all ``*.yaml`` files from the translations directory at init. + Each file's ``dashboard:`` section is flattened into dot-notation keys. + """ + + def __init__(self, translations_dir: Path, locale: str) -> None: + self._locale = locale + self._strings: dict[str, dict[str, str]] = {} # locale -> flat dict + + for path in translations_dir.glob("*.yaml"): + lang = path.stem # e.g. "en", "nl", "pt-BR" + raw = yaml.safe_load(path.read_text(encoding="utf-8")) or {} + dashboard = raw.get("dashboard", {}) + if dashboard: + self._strings[lang] = _flatten(dashboard) + + @property + def locale(self) -> str: + """The active locale code.""" + return self._locale + + def __call__(self, key: str) -> str: + """Look up a translated string. + + Fallback chain: active locale -> ``en`` -> raw key. + """ + active = self._strings.get(self._locale, {}) + value = active.get(key) + if value is not None: + return value + # Fall back to English. + en = self._strings.get("en", {}) + value = en.get(key) + if value is not None: + return value + # Last resort: return the key itself. + return key + + def to_json(self) -> str: + """Serialize the active locale's dashboard strings as JSON. + + Falls back to English for any keys missing in the active locale. + """ + en = self._strings.get("en", {}) + active = self._strings.get(self._locale, {}) + merged = {**en, **active} + return json.dumps(merged, ensure_ascii=False) +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `python -m pytest tests/test_translator.py::TestTranslatorLoading -v` +Expected: 3 PASSED + +- [ ] **Step 5: Commit** + +```bash +git add src/span_panel_simulator/dashboard/translator.py tests/test_translator.py +git commit -m "Add Translator class with YAML loading and dot-key lookup" +``` + +--- + +## Task 2: Translator — Fallback and JSON Bridge + +**Files:** +- Modify: `tests/test_translator.py` +- (No new source changes — testing existing behavior) + +- [ ] **Step 1: Write tests for fallback chain and to_json** + +Append to `tests/test_translator.py`: + +```python +class TestTranslatorFallback: + def test_falls_back_to_english_for_missing_key(self, translations_dir: Path) -> None: + # Add a partial locale missing some keys + partial = { + "dashboard": {"title": "Titulo"}, + } + (translations_dir / "es.yaml").write_text(yaml.dump(partial, allow_unicode=True)) + t = Translator(translations_dir, "es") + assert t("title") == "Titulo" + assert t("controls.grid_online") == "Grid Online" # falls back to en + + def test_returns_raw_key_when_missing_everywhere(self, translations_dir: Path) -> None: + t = Translator(translations_dir, "en") + assert t("nonexistent.key") == "nonexistent.key" + + def test_unsupported_locale_falls_back_to_english(self, translations_dir: Path) -> None: + t = Translator(translations_dir, "ja") + assert t("title") == "Dashboard Title" + + def test_empty_translations_dir(self, tmp_path: Path) -> None: + t = Translator(tmp_path, "en") + assert t("anything") == "anything" + + +class TestTranslatorJson: + def test_to_json_contains_all_keys(self, translations_dir: Path) -> None: + t = Translator(translations_dir, "en") + data = json.loads(t.to_json()) + assert data["title"] == "Dashboard Title" + assert data["controls.grid_online"] == "Grid Online" + assert data["controls.speed"] == "Speed" + + def test_to_json_merges_active_over_english(self, translations_dir: Path) -> None: + t = Translator(translations_dir, "nl") + data = json.loads(t.to_json()) + assert data["title"] == "Dashboard Titel" + assert data["controls.grid_online"] == "Grid Aan" + + def test_to_json_includes_english_fallbacks(self, translations_dir: Path) -> None: + partial = {"dashboard": {"title": "Titre"}} + (translations_dir / "fr.yaml").write_text(yaml.dump(partial, allow_unicode=True)) + t = Translator(translations_dir, "fr") + data = json.loads(t.to_json()) + assert data["title"] == "Titre" + assert data["controls.grid_online"] == "Grid Online" # en fallback +``` + +Add `import json` to the top of the test file. + +- [ ] **Step 2: Run tests to verify they pass** + +Run: `python -m pytest tests/test_translator.py -v` +Expected: All 10 tests PASS (implementation already handles these cases) + +- [ ] **Step 3: Commit** + +```bash +git add tests/test_translator.py +git commit -m "Add fallback and JSON bridge tests for Translator" +``` + +--- + +## Task 3: Locale Resolution + +**Files:** +- Modify: `src/span_panel_simulator/dashboard/translator.py` +- Modify: `tests/test_translator.py` + +- [ ] **Step 1: Write tests for locale resolution** + +Append to `tests/test_translator.py`: + +```python +from unittest.mock import AsyncMock, patch + +from span_panel_simulator.dashboard.translator import resolve_locale + + +class TestResolveLocale: + async def test_supervisor_mode_fetches_language(self) -> None: + mock_resp = AsyncMock() + mock_resp.status = 200 + mock_resp.json = AsyncMock(return_value={"language": "nl"}) + mock_resp.__aenter__ = AsyncMock(return_value=mock_resp) + mock_resp.__aexit__ = AsyncMock(return_value=False) + + mock_session = AsyncMock() + mock_session.get = AsyncMock(return_value=mock_resp) + mock_session.__aenter__ = AsyncMock(return_value=mock_session) + mock_session.__aexit__ = AsyncMock(return_value=False) + + available = {"en", "nl", "de"} + with patch.dict("os.environ", {"SUPERVISOR_TOKEN": "test-token"}): + with patch("aiohttp.ClientSession", return_value=mock_session): + result = await resolve_locale(available) + assert result == "nl" + + async def test_supervisor_unsupported_language_falls_back(self) -> None: + mock_resp = AsyncMock() + mock_resp.status = 200 + mock_resp.json = AsyncMock(return_value={"language": "ja"}) + mock_resp.__aenter__ = AsyncMock(return_value=mock_resp) + mock_resp.__aexit__ = AsyncMock(return_value=False) + + mock_session = AsyncMock() + mock_session.get = AsyncMock(return_value=mock_resp) + mock_session.__aenter__ = AsyncMock(return_value=mock_session) + mock_session.__aexit__ = AsyncMock(return_value=False) + + available = {"en", "nl"} + with patch.dict("os.environ", {"SUPERVISOR_TOKEN": "test-token"}): + with patch("aiohttp.ClientSession", return_value=mock_session): + result = await resolve_locale(available) + assert result == "en" + + async def test_standalone_uses_system_locale(self) -> None: + available = {"en", "de", "fr"} + with patch.dict("os.environ", {}, clear=True): + with patch("locale.getlocale", return_value=("de_DE", "UTF-8")): + result = await resolve_locale(available) + assert result == "de" + + async def test_standalone_none_locale_falls_back(self) -> None: + available = {"en", "nl"} + with patch.dict("os.environ", {}, clear=True): + with patch("locale.getlocale", return_value=(None, None)): + result = await resolve_locale(available) + assert result == "en" + + async def test_standalone_unsupported_locale_falls_back(self) -> None: + available = {"en", "nl"} + with patch.dict("os.environ", {}, clear=True): + with patch("locale.getlocale", return_value=("ja_JP", "UTF-8")): + result = await resolve_locale(available) + assert result == "en" + + async def test_supervisor_api_error_falls_back_to_system(self) -> None: + mock_resp = AsyncMock() + mock_resp.status = 500 + mock_resp.__aenter__ = AsyncMock(return_value=mock_resp) + mock_resp.__aexit__ = AsyncMock(return_value=False) + + mock_session = AsyncMock() + mock_session.get = AsyncMock(return_value=mock_resp) + mock_session.__aenter__ = AsyncMock(return_value=mock_session) + mock_session.__aexit__ = AsyncMock(return_value=False) + + available = {"en", "fr"} + with patch.dict("os.environ", {"SUPERVISOR_TOKEN": "test-token"}): + with patch("aiohttp.ClientSession", return_value=mock_session): + with patch("locale.getlocale", return_value=("fr_FR", "UTF-8")): + result = await resolve_locale(available) + assert result == "fr" +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `python -m pytest tests/test_translator.py::TestResolveLocale -v` +Expected: FAIL — `ImportError: cannot import name 'resolve_locale'` + +- [ ] **Step 3: Implement resolve_locale** + +Add to `src/span_panel_simulator/dashboard/translator.py` (at the top, add imports; at the bottom, add the function): + +```python +import locale as locale_mod +import logging +import os + +import aiohttp + +_LOGGER = logging.getLogger(__name__) + +_SUPERVISOR_CONFIG_URL = "http://supervisor/core/api/config" + + +async def resolve_locale(available_locales: set[str]) -> str: + """Determine the dashboard locale. + + Resolution order: + 1. HA Supervisor API language (add-on mode) + 2. Host system locale (standalone mode) + 3. Fallback to ``"en"`` + """ + lang = await _locale_from_supervisor() + if lang and lang in available_locales: + _LOGGER.info("Locale from HA Supervisor: %s", lang) + return lang + + lang = _locale_from_system() + if lang and lang in available_locales: + _LOGGER.info("Locale from system: %s", lang) + return lang + + _LOGGER.info("Locale fallback: en") + return "en" + + +async def _locale_from_supervisor() -> str | None: + """Fetch language from the HA Supervisor config API.""" + token = os.environ.get("SUPERVISOR_TOKEN") + if not token: + return None + + try: + async with aiohttp.ClientSession() as session: + async with session.get( + _SUPERVISOR_CONFIG_URL, + headers={"Authorization": f"Bearer {token}"}, + ) as resp: + if resp.status != 200: + _LOGGER.warning("Supervisor config API returned %s", resp.status) + return None + data = await resp.json() + return data.get("language") + except Exception: + _LOGGER.warning("Failed to fetch locale from Supervisor", exc_info=True) + return None + + +def _locale_from_system() -> str | None: + """Extract language code from the host system locale.""" + raw, _ = locale_mod.getlocale() + if not raw: + return None + # "en_US" -> "en", "pt_BR" -> "pt-BR" + parts = raw.split("_") + if len(parts) >= 2: + region = parts[1].split(".")[0] # strip encoding like ".UTF-8" + # Check for regional variants first (e.g. pt-BR) + regional = f"{parts[0]}-{region}" + return regional if regional != f"{parts[0]}-{parts[0].upper()}" else parts[0] + return parts[0] +``` + +Note on `_locale_from_system`: for most locales like `en_US`, `de_DE`, `fr_FR`, the region is just the uppercased language — we return just the language code (`en`, `de`, `fr`). For `pt_BR`, the language and region differ, so we return `pt-BR` to match the translation filename. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `python -m pytest tests/test_translator.py::TestResolveLocale -v` +Expected: 6 PASSED + +- [ ] **Step 5: Commit** + +```bash +git add src/span_panel_simulator/dashboard/translator.py tests/test_translator.py +git commit -m "Add locale resolution from HA supervisor and system locale" +``` + +--- + +## Task 4: Translation Key Parity Test + +**Files:** +- Modify: `tests/test_translator.py` + +- [ ] **Step 1: Write key parity test against real translation files** + +Append to `tests/test_translator.py`: + +```python +class TestTranslationKeyParity: + """Validate that all non-English YAML files have the same dashboard keys as en.yaml.""" + + @staticmethod + def _real_translations_dir() -> Path: + """Path to the actual translations directory.""" + return Path(__file__).resolve().parent.parent / "span_panel_simulator" / "translations" + + def test_all_languages_have_same_dashboard_keys(self) -> None: + translations_dir = self._real_translations_dir() + en_path = translations_dir / "en.yaml" + if not en_path.exists(): + pytest.skip("translations/en.yaml not found") + + en_raw = yaml.safe_load(en_path.read_text(encoding="utf-8")) or {} + en_dashboard = en_raw.get("dashboard") + if not en_dashboard: + pytest.skip("No dashboard section in en.yaml yet") + + en_keys = set(_flatten(en_dashboard).keys()) + + for path in sorted(translations_dir.glob("*.yaml")): + if path.stem == "en": + continue + raw = yaml.safe_load(path.read_text(encoding="utf-8")) or {} + dashboard = raw.get("dashboard", {}) + lang_keys = set(_flatten(dashboard).keys()) + missing = en_keys - lang_keys + extra = lang_keys - en_keys + assert not missing, f"{path.name} missing keys: {missing}" + assert not extra, f"{path.name} has extra keys: {extra}" +``` + +Add import at top of test file: + +```python +from span_panel_simulator.dashboard.translator import _flatten +``` + +- [ ] **Step 2: Run test — it should skip for now (no dashboard section yet)** + +Run: `python -m pytest tests/test_translator.py::TestTranslationKeyParity -v` +Expected: SKIPPED — "No dashboard section in en.yaml yet" + +- [ ] **Step 3: Commit** + +```bash +git add tests/test_translator.py +git commit -m "Add translation key parity test for CI validation" +``` + +--- + +## Task 5: Wire Translator into Dashboard App + +**Files:** +- Modify: `src/span_panel_simulator/dashboard/keys.py` +- Modify: `src/span_panel_simulator/dashboard/context.py` +- Modify: `src/span_panel_simulator/dashboard/__init__.py` + +- [ ] **Step 1: Add APP_KEY_TRANSLATOR to keys.py** + +Add to `src/span_panel_simulator/dashboard/keys.py`: + +```python +from span_panel_simulator.dashboard.translator import Translator + +APP_KEY_TRANSLATOR = web.AppKey("translator", Translator) +``` + +- [ ] **Step 2: Add locale field to DashboardContext** + +In `src/span_panel_simulator/dashboard/context.py`, add `locale` as the last field: + +```python + panel_browser: Any = None # PanelBrowser | None — mDNS discovery for standalone mode + locale: str = "en" +``` + +- [ ] **Step 3: Wire Translator into create_dashboard_app** + +In `src/span_panel_simulator/dashboard/__init__.py`, add imports: + +```python +from span_panel_simulator.dashboard.keys import ( + APP_KEY_DASHBOARD_CONTEXT, + APP_KEY_PENDING_CLONES, + APP_KEY_PRESET_REGISTRY, + APP_KEY_RATE_CACHE, + APP_KEY_STORE, + APP_KEY_TRANSLATOR, +) +from span_panel_simulator.dashboard.translator import Translator +``` + +After line 54 (`APP_KEY_RATE_CACHE` assignment), add: + +```python + translations_dir = Path(__file__).resolve().parent.parent.parent.parent / ( + "span_panel_simulator" / "translations" + ) + translator = Translator(translations_dir, context.locale) + app[APP_KEY_TRANSLATOR] = translator +``` + +After line 61 (`env.globals["static_url"] = "static"`), add: + +```python + env.globals["t"] = translator + env.globals["locale"] = translator.locale + env.globals["t_json"] = translator.to_json() +``` + +- [ ] **Step 4: Verify the app still starts** + +Run: `python -m pytest tests/ -x -q --timeout=30` +Expected: All existing tests pass (locale defaults to "en", translator loads but templates don't use it yet) + +- [ ] **Step 5: Commit** + +```bash +git add src/span_panel_simulator/dashboard/keys.py \ + src/span_panel_simulator/dashboard/context.py \ + src/span_panel_simulator/dashboard/__init__.py +git commit -m "Wire Translator into dashboard app with Jinja2 globals" +``` + +--- + +## Task 6: Resolve Locale at App Startup + +**Files:** +- Modify: `src/span_panel_simulator/app.py` (the section that builds `DashboardContext`) + +- [ ] **Step 1: Find where DashboardContext is constructed in app.py** + +Search `app.py` for `DashboardContext(` — this is where locale resolution will be called. + +- [ ] **Step 2: Add locale resolution call** + +Add import at top of `app.py`: + +```python +from span_panel_simulator.dashboard.translator import resolve_locale +``` + +Before the `DashboardContext(...)` construction, resolve the locale: + +```python + translations_dir = ( + Path(__file__).resolve().parent.parent / "span_panel_simulator" / "translations" + ) + available_locales = { + p.stem for p in translations_dir.glob("*.yaml") + } + locale = await resolve_locale(available_locales) +``` + +Then pass `locale=locale` to the `DashboardContext(...)` constructor. + +- [ ] **Step 3: Fix translations_dir in __init__.py to use a consistent path** + +The translations directory path needs to resolve correctly in both installed (wheel) and development modes. Instead of hard-coding the path in both `app.py` and `__init__.py`, have the `Translator` find its own translations directory. + +Update `src/span_panel_simulator/dashboard/translator.py` — add a module-level constant: + +```python +TRANSLATIONS_DIR = Path(__file__).resolve().parent.parent.parent.parent / ( + "span_panel_simulator" / "translations" +) +``` + +Then use `TRANSLATIONS_DIR` in both `app.py` (for `resolve_locale`) and `__init__.py` (for `Translator()`). Alternatively, pass the dir into both from `app.py` via `DashboardContext`. + +The cleanest approach: add `translations_dir: Path` as a field on `DashboardContext` alongside `locale`, and use it in `__init__.py` when constructing the Translator. The `app.py` already knows the right path. + +- [ ] **Step 4: Verify the app still starts with locale resolution** + +Run: `python -m pytest tests/ -x -q --timeout=30` +Expected: All tests pass. In standalone mode without `SUPERVISOR_TOKEN`, locale resolves from system or falls back to "en". + +- [ ] **Step 5: Commit** + +```bash +git add src/span_panel_simulator/app.py \ + src/span_panel_simulator/dashboard/context.py \ + src/span_panel_simulator/dashboard/__init__.py \ + src/span_panel_simulator/dashboard/translator.py +git commit -m "Resolve locale at startup from HA supervisor or system locale" +``` + +--- + +## Task 7: English Translation File — Dashboard Strings + +**Files:** +- Modify: `span_panel_simulator/translations/en.yaml` + +- [ ] **Step 1: Add the complete dashboard section to en.yaml** + +This is the source of truth. Every user-visible string from every template gets a key here. Append to `span_panel_simulator/translations/en.yaml` after the existing `configuration:` section: + +```yaml +dashboard: + title: SPAN Panel Simulator Dashboard + + theme: + label: Theme + system: System + light: Light + dark: Dark + + getting_started: + title: Getting started + step_click: >- + Click a simulator configuration to view it. Templates are read-only. + A running simulator appears as a discovered panel in the SpanPanel + integration (default configs excluded). + step_clone: >- + Clone creates an editable copy from a template or from your real + panel — cloning your panel preserves recorder history per circuit. + step_model: >- + Model opens the what-if view; add battery, PV, or circuits and + compare before/after. Edits mark equipment as SYN; click the badge + to revert to REC. + step_purge: >- + Purge removes recorder history written by the simulated panel's + sensors if you added the simulated panel to Home Assistant's + integration. + + tabs: + getting_started: Getting started + clone: Clone + model: Model + purge: Purge + export: Export + + controls: + title: Runtime Controls + date: Date + time_of_day: Time of Day + speed: Speed + grid_online: Grid Online + grid_offline: Grid Offline + islandable: Islandable + not_islandable: Not Islandable + runtime: Runtime + modeling: Modeling + soc: "SOC " + circuits_shed: "{count} circuit(s) shed" + + chart: + live_power_flows: Live Power Flows + grid: Grid + solar: Solar + battery: Battery + watts: Watts + watts_suffix: " W" + + panel_config: + title: Panel Config + serial: "Serial:" + tabs: "Tabs:" + main_breaker: "Main Breaker (A):" + soc_shed: "SOC Shed (%):" + soc_shed_hint: Battery SOC below which SOC_THRESHOLD circuits are shed + location: "Location:" + search_placeholder: Search city or address... + lat: "Lat:" + lon: "Lon:" + update: Update + no_results: No results + fetching_weather: Fetching historical weather data... + deterministic_weather: Using deterministic weather model + + sim_config: + title: Simulation Config + export: Export + save_reload: Save & Reload + interval: "Interval (s):" + noise: "Noise:" + update: Update + + panels: + title: Panels + config: config + configs: configs + import_btn: Import + overwrite: Overwrite + cancel: Cancel + already_exists: already exists. + no_configs: No config files found. Import or clone a panel to get started. + clone_hint: Clone a template above to create your own editable configuration. + clone_as: "Clone as:" + clone_failed: "Clone failed: " + unsaved_warning: You have unsaved changes that will be lost. Switch anyway? + switch_failed: "Failed to switch panel: " + + panel_row: + bootstrap_port: Bootstrap HTTP port + template: template + template_hint: Clone this template to create an editable config + viewing: viewing + editing: editing + clone: Clone + clone_hint: Clone this config + model: Model + model_hint: Open modeling view + restart: Restart + restart_hint: Restart engine + stop: Stop + stop_hint: Stop engine + start: Start + start_hint: Start engine + delete: Del + delete_hint: Delete config file + purge: Purge + purge_hint: Remove HA recorder history for this profile + + clone_panel: + title: Clone from Panel + hint_ha: >- + Clones circuit configuration from a SPAN panel registered with this + Home Assistant instance, including usage profiles from the recorder. + hint_standalone: Scrapes circuit configuration from a real SPAN panel via its eBus. + panel_label: Panel + scanning: "Scanning\u2026" + select_panel: "\u2014 select a panel \u2014" + no_panels: No panels found + discovery_unavailable: Discovery unavailable + panel_ip: Panel IP / hostname + ip_placeholder: 192.168.1.100 + passphrase: Passphrase + required: required + clone: Clone + + clone_confirm: + title: Clone from Panel + exists_prefix: "Config file " + exists_suffix: " already exists. Choose how to proceed:" + overwrite: "Overwrite " + save_as_new: "Save as new name:" + continue_btn: Continue + cancel: Cancel + + entities: + title: "Entities ({count})" + add_entity: "+ Add Entity" + clone_hint: Clone a template to create an editable configuration. + unmapped_tabs: "Unmapped Tabs ({count})" + add_from_tabs: Add Circuit from Selected Tabs + nothing_selected: "Nothing selected \u2014 all enabled" + select_tabs: Select 1 or 2 tabs + single_tab_hint: Add as 120V single-pole, or select a second tab for 240V + valid_pair: Valid 240V double-pole pair + invalid_pair: "Invalid pair: must be same parity, exactly 2 apart" + + entity_row: + overlay_hint: Overlay on modeling charts + toggle_relay: Toggle relay + override_hint: "Overridden \u2014 click to resume replay" + replay_hint: "Replaying recorded data \u2014 click for synthetic" + syn: SYN + rec: REC + rec_lost_hint: "Recorder link lost \u2014 click to restore" + rec_lost: "REC?" + tabs_prefix: "tabs: " + watts_suffix: W + edit: Edit + delete_confirm: "Delete " + delete: Del + + entity_edit: + editing: "Editing: " + name: "Name:" + tabs: "Tabs (comma-separated):" + priority: "Priority:" + relay_behavior: "Relay Behavior:" + breaker: "Breaker (A):" + breaker_placeholder: auto + pv_section: PV System + pv_nameplate: "Nameplate Rating (W):" + pv_efficiency: "Efficiency:" + pv_inverter_type: "Inverter Type:" + pv_grid_tied: Grid-Tied + pv_hybrid: Hybrid + evse_section: EVSE Charger + evse_charge_power: "Charge Power (W):" + evse_max_power: "Max Power (W):" + profile_section: Energy Profile + typical_power: "Typical Power (W):" + min_power: "Min Power (W):" + max_power: "Max Power (W):" + hvac_type: "HVAC Type:" + hvac_none: None + hvac_central: Central AC / Gas Furnace + hvac_heat_pump: Heat Pump + hvac_heat_pump_aux: Heat Pump + Aux Strips + cycling_section: Cycling Pattern + on_duration: "On Duration (s): " + off_duration: "Off Duration (s): " + smart_section: Smart Behavior + responds_to_grid: "Responds to Grid: " + max_power_reduction: "Max Power Reduction: " + battery_section: Battery Behavior + battery_nameplate: "Nameplate Capacity (kWh):" + battery_reserve: "Backup Reserve (%):" + battery_reserve_hint: "Normal discharge stops here; grid outages can draw deeper" + battery_charge_power: "Charge Power (W):" + battery_discharge_power: "Discharge Power (W):" + save: Save + cancel: Cancel + + profile_editor: + title: 24-Hour Profile + select_preset: "-- Select Preset --" + from: "from " + to: "to " + apply: Apply + active_days: Active Days + save_profile: Save Profile + + pv_profile: + title: Solar Production Profile + weather_degradation: Monthly Weather Degradation + no_weather: >- + No historical weather data available. Set a location and weather + data will be fetched automatically. + peak: "Peak: " + weather_label: " W | Weather: " + lat_label: "% | Lat: " + lon_label: ", Lon: " + error_loading: Error loading curve data + production_label: "Production (W)" + + battery_schedule: + title: Battery Schedule + charge_mode: Charge Mode + self_consumption: Self-Consumption + self_consumption_hint: >- + Discharge to offset grid import, charge from solar excess — always active + time_of_use: Time-of-Use + time_of_use_hint: Charge and discharge on a manual hourly schedule + backup_only: Backup Only + backup_only_hint: Holds battery at full charge, discharges only during grid outages + self_consumption_detail: >- + Battery automatically discharges to reduce grid import and charges + from surplus solar. No schedule needed. + backup_only_detail: Battery stays fully charged and only discharges during grid outages. + time_of_use_detail: Set charge and discharge hours in the schedule below. + discharge_preset: Discharge Preset + active_days: Active Days + idle: Idle + charge: Chg + discharge: Dis + save_schedule: Save Schedule + + evse_schedule: + title: Charging Schedule + select_preset: "-- Select Preset --" + apply: Apply + active_days: Active Days + start: "Start:" + duration: "Duration:" + duration_unit: h + apply_schedule: Apply + + panel_source: + title: Source Panel + cloned_from: "Cloned from " + last_synced: "\u2014 last synced " + utc: " UTC" + update_ebus: Update eBus Energy + + modeling: + title: "Modeling \u2014 " + back_to_runtime: Back to Runtime + horizon: Horizon + last_month: Last Month + last_3_months: Last 3 Months + last_6_months: Last 6 Months + last_year: Last Year + visible_range: Visible Range + loading: Loading modeling data... + billing_data: Billing Data (Opower) + change: Change + select_account: Select Electric Account + current_rate: Current Rate + data_source_hint: Data source attribution + no_rate: No rate plan selected + configure: Configure + refresh: Refresh + proposed_rate: Proposed Rate + using_current: Using current rate for comparison + set_proposed: Set Proposed Rate + clear: Clear + openei_title: OpenEI Rate Plan + api_settings: API Settings + api_url: API URL + api_url_placeholder: "https://api.openei.org/utility_rates" + api_key: API Key + api_key_placeholder: Enter your OpenEI API key + save: Save + get_api_key: Get a free API key + select_rate: Select Rate Plan + utility: Utility + loading_utilities: Loading utilities... + rate_plan: Rate Plan + select_utility_first: Select a utility first + use_this_rate: Use This Rate + rate_source_title: Rate Data Source + close: Close + before: Before + before_subtitle: "(Grid Power \u2014 recorder baseline)" + after: After + after_subtitle: "(Grid Power \u2014 current config)" + energy_kwh: Energy (kWh) + cost: Cost + difference: Difference + savings: Savings + full_horizon: Full Horizon + error_loading: "Error loading data: " + bought_suffix: " (bought), " + exported_suffix: " exp)" + cost_billed: " (billed)" + months_prefix: " of " + months_suffix: " months)" + import_suffix: " imp, " + export_net: " exp \u2014 Net: " + elec_label: " \u2014 ELEC " + select_utility: Select a utility... + loading_plans: Loading plans... + select_rate_plan: Select a rate plan... + error_loading_utilities: Error loading utilities + error_loading_plans: Error loading plans + network_error: "Network error: " + provider: "Provider:" + license: "License:" + urdb_label: "URDB Label:" + retrieved: "Retrieved:" + view_on_openei: View on OpenEI + engine_reload_timeout: Engine reload timed out + no_running_sim: No running simulation + cancel: Cancel +``` + +- [ ] **Step 2: Verify YAML is valid** + +Run: `python -c "import yaml; yaml.safe_load(open('span_panel_simulator/translations/en.yaml'))" && echo "valid"` +Expected: `valid` + +- [ ] **Step 3: Run key parity test — it should now run but skip non-English (they have no dashboard section yet)** + +Run: `python -m pytest tests/test_translator.py::TestTranslationKeyParity -v` +Expected: Either PASS (non-English files have no dashboard section so their key set is empty, assertion fails) or indicates what needs to happen next. + +- [ ] **Step 4: Commit** + +```bash +git add span_panel_simulator/translations/en.yaml +git commit -m "Add complete English dashboard translation strings" +``` + +--- + +## Task 8: Non-English Translation Files + +**Files:** +- Modify: `span_panel_simulator/translations/nl.yaml` +- Modify: `span_panel_simulator/translations/de.yaml` +- Modify: `span_panel_simulator/translations/fr.yaml` +- Modify: `span_panel_simulator/translations/es.yaml` +- Modify: `span_panel_simulator/translations/pt-BR.yaml` + +- [ ] **Step 1: Add dashboard sections to all 5 non-English files** + +Each file gets a `dashboard:` section with the exact same key structure as `en.yaml`, with values translated into the target language. The full translations for each language should be appended after the existing `configuration:` section. + +Translate all keys accurately for each language. Use professional-grade translations — these are UI strings, not marketing copy, so prefer clear and concise phrasing. + +- [ ] **Step 2: Validate all YAML files** + +Run: `for f in span_panel_simulator/translations/*.yaml; do python -c "import yaml; yaml.safe_load(open('$f'))" && echo "$f: valid"; done` +Expected: All 6 files report valid. + +- [ ] **Step 3: Run key parity test** + +Run: `python -m pytest tests/test_translator.py::TestTranslationKeyParity -v` +Expected: PASS — all languages have the same dashboard keys as English. + +- [ ] **Step 4: Commit** + +```bash +git add span_panel_simulator/translations/ +git commit -m "Add dashboard translations for nl, de, fr, es, pt-BR" +``` + +--- + +## Task 9: Template — base.html and dashboard.html + +**Files:** +- Modify: `src/span_panel_simulator/dashboard/templates/base.html` +- Modify: `src/span_panel_simulator/dashboard/templates/dashboard.html` + +- [ ] **Step 1: Update base.html** + +Replace hardcoded strings with `{{ t('key') }}` calls. Add the i18n JS bridge in a ` +``` + +- [ ] **Step 2: Update dashboard.html** + +Replace getting-started strings: +- "Getting started" heading → `{{ t('getting_started.title') }}` +- Each instruction paragraph → `{{ t('getting_started.step_click') }}`, `{{ t('getting_started.step_clone') }}`, etc. + +- [ ] **Step 3: Verify the dashboard still renders** + +Run: `python -m pytest tests/ -x -q --timeout=30` +Expected: All tests pass. + +- [ ] **Step 4: Commit** + +```bash +git add src/span_panel_simulator/dashboard/templates/base.html \ + src/span_panel_simulator/dashboard/templates/dashboard.html +git commit -m "Translate base.html and dashboard.html to use i18n" +``` + +--- + +## Task 10: Template — runtime_controls.html + +**Files:** +- Modify: `src/span_panel_simulator/dashboard/templates/partials/runtime_controls.html` + +- [ ] **Step 1: Replace HTML strings** + +- "Runtime Controls" → `{{ t('controls.title') }}` +- "Date" → `{{ t('controls.date') }}` +- "Time of Day" → `{{ t('controls.time_of_day') }}` +- "Speed" → `{{ t('controls.speed') }}` +- "Grid Online" / "Grid Offline" buttons → `{{ t('controls.grid_online') }}` / `{{ t('controls.grid_offline') }}` +- "Islandable" / "Not Islandable" → use `t()` calls +- "Runtime" / "Modeling" → use `t()` calls +- "Live Power Flows" → `{{ t('chart.live_power_flows') }}` +- Legend items "Grid", "Solar", "Battery" → `{{ t('chart.grid') }}`, etc. + +- [ ] **Step 2: Replace JavaScript strings** + +- Replace `MONTH_NAMES` array with `Intl.DateTimeFormat`: + +```javascript +function monthShort(monthIndex) { + return new Intl.DateTimeFormat(window.i18nLocale, { month: 'short' }) + .format(new Date(2024, monthIndex)); +} +``` + +- Replace hardcoded button text toggles in JS (e.g., `btn.textContent = 'Grid Offline'`) with `window.i18n['controls.grid_offline']` +- Replace tooltip `" W"` suffix with `window.i18n['chart.watts_suffix']` +- Replace SOC status text construction with i18n lookups + +- [ ] **Step 3: Verify rendering** + +Run: `python -m pytest tests/ -x -q --timeout=30` +Expected: All tests pass. + +- [ ] **Step 4: Commit** + +```bash +git add src/span_panel_simulator/dashboard/templates/partials/runtime_controls.html +git commit -m "Translate runtime_controls.html to use i18n" +``` + +--- + +## Task 11: Template — panel_config.html and sim_config.html + +**Files:** +- Modify: `src/span_panel_simulator/dashboard/templates/partials/panel_config.html` +- Modify: `src/span_panel_simulator/dashboard/templates/partials/sim_config.html` + +- [ ] **Step 1: Replace all hardcoded strings in panel_config.html** + +- "Panel Config" → `{{ t('panel_config.title') }}` +- All form labels (Serial, Tabs, Main Breaker, SOC Shed, Location, Lat, Lon) → `t()` calls +- Placeholder text → `t()` calls +- "Update" button → `{{ t('panel_config.update') }}` +- JS messages ("No results", "Fetching historical weather data...") → `window.i18n[...]` + +- [ ] **Step 2: Replace all hardcoded strings in sim_config.html** + +- "Simulation Config" → `{{ t('sim_config.title') }}` +- "Export", "Save & Reload" → `t()` calls +- "Interval (s):", "Noise:" → `t()` calls +- "Update" → `{{ t('sim_config.update') }}` + +- [ ] **Step 3: Verify and commit** + +Run: `python -m pytest tests/ -x -q --timeout=30` + +```bash +git add src/span_panel_simulator/dashboard/templates/partials/panel_config.html \ + src/span_panel_simulator/dashboard/templates/partials/sim_config.html +git commit -m "Translate panel_config and sim_config templates to use i18n" +``` + +--- + +## Task 12: Template — Entity Templates + +**Files:** +- Modify: `src/span_panel_simulator/dashboard/templates/partials/entity_list.html` +- Modify: `src/span_panel_simulator/dashboard/templates/partials/entity_row.html` +- Modify: `src/span_panel_simulator/dashboard/templates/partials/entity_edit.html` + +- [ ] **Step 1: Replace strings in entity_list.html** + +- "Entities (" heading → use `{{ t('entities.title').format(count=entities|length) }}` or split into prefix/suffix +- "+ Add Entity" → `{{ t('entities.add_entity') }}` +- Hint text → `{{ t('entities.clone_hint') }}` +- "Unmapped Tabs" → similar pattern +- JS hint strings → `window.i18n[...]` + +- [ ] **Step 2: Replace strings in entity_row.html** + +- Title attributes → `{{ t('entity_row.overlay_hint') }}`, etc. +- Badge text (SYN/REC) → `{{ t('entity_row.syn') }}`, etc. +- "Edit", "Del" buttons → `t()` calls + +- [ ] **Step 3: Replace strings in entity_edit.html** + +- All form labels (Name, Tabs, Priority, Relay Behavior, Breaker, etc.) → `t()` calls +- Fieldset legends (PV System, EVSE Charger, Energy Profile, etc.) → `t()` calls +- Select options (Grid-Tied, Hybrid, HVAC types) → `t()` calls +- Save/Cancel buttons → `t()` calls + +- [ ] **Step 4: Verify and commit** + +Run: `python -m pytest tests/ -x -q --timeout=30` + +```bash +git add src/span_panel_simulator/dashboard/templates/partials/entity_list.html \ + src/span_panel_simulator/dashboard/templates/partials/entity_row.html \ + src/span_panel_simulator/dashboard/templates/partials/entity_edit.html +git commit -m "Translate entity templates to use i18n" +``` + +--- + +## Task 13: Template — Clone and Panel Templates + +**Files:** +- Modify: `src/span_panel_simulator/dashboard/templates/partials/clone_panel.html` +- Modify: `src/span_panel_simulator/dashboard/templates/partials/clone_confirm.html` +- Modify: `src/span_panel_simulator/dashboard/templates/partials/running_panels.html` +- Modify: `src/span_panel_simulator/dashboard/templates/partials/panels_list_rows.html` +- Modify: `src/span_panel_simulator/dashboard/templates/partials/panel_source.html` + +- [ ] **Step 1: Replace strings in clone_panel.html** + +- "Clone from Panel" → `{{ t('clone_panel.title') }}` +- Hint text (HA / standalone) → `t()` calls +- Form labels, placeholders, button text → `t()` calls +- JS option text → `window.i18n[...]` + +- [ ] **Step 2: Replace strings in clone_confirm.html** + +- Dialog text and button labels → `t()` calls + +- [ ] **Step 3: Replace strings in running_panels.html** + +- "Panels" heading → `{{ t('panels.title') }}` +- "Import", "Overwrite", "Cancel" → `t()` calls +- "already exists." → `{{ t('panels.already_exists') }}` + +- [ ] **Step 4: Replace strings in panels_list_rows.html** + +- Badge text ("template", "viewing", "editing") → `t()` calls +- Button text and title attributes → `t()` calls +- JS messages (clone prompt, error messages, unsaved warning) → `window.i18n[...]` + +- [ ] **Step 5: Replace strings in panel_source.html** + +- "Source Panel" → `{{ t('panel_source.title') }}` +- "Cloned from", "last synced", "UTC" → `t()` calls +- "Update eBus Energy" → `{{ t('panel_source.update_ebus') }}` + +- [ ] **Step 6: Verify and commit** + +Run: `python -m pytest tests/ -x -q --timeout=30` + +```bash +git add src/span_panel_simulator/dashboard/templates/partials/clone_panel.html \ + src/span_panel_simulator/dashboard/templates/partials/clone_confirm.html \ + src/span_panel_simulator/dashboard/templates/partials/running_panels.html \ + src/span_panel_simulator/dashboard/templates/partials/panels_list_rows.html \ + src/span_panel_simulator/dashboard/templates/partials/panel_source.html +git commit -m "Translate clone and panel management templates to use i18n" +``` + +--- + +## Task 14: Template — Profile and Schedule Templates + +**Files:** +- Modify: `src/span_panel_simulator/dashboard/templates/partials/profile_editor.html` +- Modify: `src/span_panel_simulator/dashboard/templates/partials/pv_profile.html` +- Modify: `src/span_panel_simulator/dashboard/templates/partials/battery_profile_editor.html` +- Modify: `src/span_panel_simulator/dashboard/templates/partials/evse_schedule.html` + +- [ ] **Step 1: Replace strings in profile_editor.html** + +- "24-Hour Profile" → `{{ t('profile_editor.title') }}` +- "-- Select Preset --", "from", "to", "Apply" → `t()` calls +- "Active Days", "Save Profile" → `t()` calls + +- [ ] **Step 2: Replace strings in pv_profile.html** + +- "Solar Production Profile" → `{{ t('pv_profile.title') }}` +- "Monthly Weather Degradation" → `{{ t('pv_profile.weather_degradation') }}` +- No-weather hint text → `{{ t('pv_profile.no_weather') }}` +- Replace `MONTH_LABELS` and `MONTH_NAMES` arrays with `Intl.DateTimeFormat`: + +```javascript +const MONTH_LABELS = Array.from({length: 12}, (_, i) => + new Intl.DateTimeFormat(window.i18nLocale, { month: 'short' }).format(new Date(2024, i)) +); +const MONTH_NAMES = Array.from({length: 12}, (_, i) => + new Intl.DateTimeFormat(window.i18nLocale, { month: 'long' }).format(new Date(2024, i)) +); +``` + +- Chart labels and info text → `window.i18n[...]` + +- [ ] **Step 3: Replace strings in battery_profile_editor.html** + +- "Battery Schedule" → `{{ t('battery_schedule.title') }}` +- "Charge Mode" → `{{ t('battery_schedule.charge_mode') }}` +- Mode labels and hints → `t()` calls +- "Discharge Preset", "Active Days" → `t()` calls +- Hour labels (Idle, Chg, Dis) → `t()` calls +- "Save Schedule" → `{{ t('battery_schedule.save_schedule') }}` + +- [ ] **Step 4: Replace strings in evse_schedule.html** + +- "Charging Schedule" → `{{ t('evse_schedule.title') }}` +- All labels and buttons → `t()` calls + +- [ ] **Step 5: Verify and commit** + +Run: `python -m pytest tests/ -x -q --timeout=30` + +```bash +git add src/span_panel_simulator/dashboard/templates/partials/profile_editor.html \ + src/span_panel_simulator/dashboard/templates/partials/pv_profile.html \ + src/span_panel_simulator/dashboard/templates/partials/battery_profile_editor.html \ + src/span_panel_simulator/dashboard/templates/partials/evse_schedule.html +git commit -m "Translate profile and schedule templates to use i18n" +``` + +--- + +## Task 15: Template — modeling_view.html + +**Files:** +- Modify: `src/span_panel_simulator/dashboard/templates/partials/modeling_view.html` + +- [ ] **Step 1: Replace HTML strings** + +This is the largest partial. Replace all hardcoded strings: +- "Modeling —" heading → `{{ t('modeling.title') }}` +- "Back to Runtime" → `{{ t('modeling.back_to_runtime') }}` +- "Horizon", select options (Last Month, etc.) → `t()` calls +- "Visible Range", "Loading modeling data..." → `t()` calls +- "Billing Data (Opower)", "Change" → `t()` calls +- "Select Electric Account" dialog → `t()` calls +- "Current Rate", "Proposed Rate" sections → `t()` calls +- "OpenEI Rate Plan" dialog → `t()` calls +- "Before" / "After" chart sections → `t()` calls +- Table headers (Energy, Cost, Difference, Savings) → `t()` calls +- Legend items (Grid, Solar, Battery) → `t()` calls + +- [ ] **Step 2: Replace JavaScript strings** + +- Error messages → `window.i18n['modeling.error_loading']` +- Tooltip suffixes → `window.i18n[...]` +- Y-axis title "Watts" → `window.i18n['chart.watts']` +- Cost/energy display text construction → `window.i18n[...]` +- Opower account display → `window.i18n[...]` +- Utility/rate plan select options → `window.i18n[...]` +- Attribution popup labels → `window.i18n[...]` +- Engine reload/error messages → `window.i18n[...]` + +- [ ] **Step 3: Verify and commit** + +Run: `python -m pytest tests/ -x -q --timeout=30` + +```bash +git add src/span_panel_simulator/dashboard/templates/partials/modeling_view.html +git commit -m "Translate modeling_view.html to use i18n" +``` + +--- + +## Task 16: Full Integration Test + +**Files:** +- All previously modified files + +- [ ] **Step 1: Run full test suite** + +Run: `python -m pytest tests/ -v --timeout=60` +Expected: All tests pass. + +- [ ] **Step 2: Run type checker** + +Run: `mypy src/span_panel_simulator/dashboard/translator.py` +Expected: No errors. + +- [ ] **Step 3: Run linter** + +Run: `ruff check src/span_panel_simulator/dashboard/translator.py tests/test_translator.py` +Expected: No errors. + +- [ ] **Step 4: Run key parity test to validate all translations** + +Run: `python -m pytest tests/test_translator.py::TestTranslationKeyParity -v` +Expected: PASS — all 6 language files have identical dashboard key sets. + +- [ ] **Step 5: Manual smoke test** + +Start the simulator and verify the dashboard loads correctly in a browser. Check that: +- All strings render (no raw keys visible) +- Theme selector works +- Charts display with correct labels +- All buttons and form labels are translated + +- [ ] **Step 6: Commit any fixes** + +```bash +git add -u +git commit -m "Fix any issues found during integration testing" +``` From c72632bb605e226e8042e9b3b2348db294b0c416 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Thu, 2 Apr 2026 07:05:42 -0700 Subject: [PATCH 04/35] Add BESS circuit removal design spec --- .../2026-04-02-bess-circuit-removal-design.md | 121 ++++++++++++++++++ 1 file changed, 121 insertions(+) create mode 100644 docs/superpowers/specs/2026-04-02-bess-circuit-removal-design.md diff --git a/docs/superpowers/specs/2026-04-02-bess-circuit-removal-design.md b/docs/superpowers/specs/2026-04-02-bess-circuit-removal-design.md new file mode 100644 index 0000000..a81cead --- /dev/null +++ b/docs/superpowers/specs/2026-04-02-bess-circuit-removal-design.md @@ -0,0 +1,121 @@ +# BESS Circuit Removal — Design Spec + +**Date:** 2026-04-02 +**Status:** Draft +**Scope:** Remove phantom battery circuit; BESS exists only as GFE on upstream lugs + +## Problem + +The simulator creates a `battery_storage` circuit for BESS, but real SPAN panels +with BESS have the battery on the upstream lugs acting as Grid Forming Equipment +(GFE). There is no breaker, no relay, and no circuit for the battery. The current +design forces a fake circuit into existence and uses it as a data proxy between +the energy system and the API layer, requiring workarounds like excluding the +battery circuit from load summation and writing resolved power back onto the +circuit each tick. + +## Physical Topology + +``` +Utility <--[grid sensor]--> BESS <--> Panel (loads + PV) +``` + +- BESS sits between the grid sensor and the panel on the upstream lugs. +- The grid sensor measures net power at the utility meter point. +- `grid_sensor = load - pv - bess_discharge + bess_charge` (all positive magnitudes). +- BESS charges only from solar excess — never from the grid. +- Grid sensor: positive = importing, negative = exporting. + +## Approach + +Remove the battery circuit entirely (Approach B — full energy system decoupling). +BESS state exists only in `EnergySystem` / `BESSUnit` and `SpanBatterySnapshot`. +No circuit proxy, no writeback, no exclusion workarounds. + +## Changes + +### 1. Configuration Schema + +**Remove:** +- `battery` template from `circuit_templates` in all config YAMLs +- `battery_storage` circuit from `circuits` list in all config YAMLs +- `BatteryBehavior` TypedDict from `config_types.py` + +**Add:** +- Top-level `bess` key in YAML config (peer to `panel_config`): + +```yaml +bess: + enabled: true + nameplate_capacity_kwh: 13.5 + max_charge_w: 3500.0 + max_discharge_w: 3500.0 + charge_efficiency: 0.95 + discharge_efficiency: 0.95 + backup_reserve_pct: 20.0 + charge_mode: solar-gen + charge_hours: [8, 9, 10, 11, 12, 13, 14, 15] + discharge_hours: [16, 17, 18, 19, 20, 21, 22] +``` + +- `BESSConfigYAML` TypedDict in `config_types.py` for the new top-level section. + +### 2. Engine Refactor + +**Remove from `engine.py`:** +- `_find_battery_circuit()` — no battery circuit exists +- `_is_battery_circuit()` — no battery circuit to detect +- `_apply_battery_behavior()` — battery power resolved by EnergySystem, not circuit behavior +- Circuit-writeback logic in `get_snapshot()` — BESS power no longer proxied through a circuit +- Battery circuit exclusion in `_collect_power_inputs()` and `_powers_to_energy_inputs()` + +**Modify in `engine.py`:** +- `_build_energy_system()` — read BESS config from `self._config["bess"]` instead of + scanning circuits for `battery_behavior`. Direct dict-to-`BESSConfig` mapping. +- `get_snapshot()` — build `SpanBatterySnapshot` purely from `EnergySystem.bess` state. + No circuit snapshot rebuild step. +- `compute_modeling_data()` — battery power from `SystemState` only, no circuit intermediate. + +Grid sensor calculation stays in `EnergySystem.tick()` where it already lives. The bus +resolves load, PV, and BESS, then grid power is the remainder. + +### 3. Model & Publisher Cleanup + +**`energy/types.py` — `BESSConfig`:** +- Remove `feed_circuit_id` field. + +**`energy/components.py` — `BESSUnit`:** +- Remove `feed_circuit_id` parameter and property. +- GFE constraint, SOE integration, hybrid PV control unchanged. + +**`models.py` — `SpanBatterySnapshot`:** +- Remove `feed_circuit_id` field. + +**`publisher.py`:** +- Remove `feed` property publishing from `_map_bess()`. +- `bess-0` MQTT node still publishes SOE, capacity, grid-state. + +**`clone.py` — `_enrich_bess_template()`:** +- Refactor to write to top-level `bess` key in cloned config instead of enriching a + circuit template. The `feed` property from scraped panel data is ignored. + +### 4. Test Impact + +**`tests/test_clone.py` — `test_bess_mode()`:** +- Rewrite to assert cloned config has top-level `bess` section with expected fields. + The former battery circuit should not exist in cloned output. + +**`tests/test_modeling.py`:** +- Move `battery_behavior` from circuit template fixtures to top-level `bess` key. + +**`tests/test_energy/test_scenarios.py`:** +- Remove `feed_circuit_id` from explicit `BESSConfig` constructor calls. + Energy layer behavior unchanged. + +No new tests needed — structural refactor, not behavioral change. + +## Out of Scope + +- Enforcing "never charge from grid" constraint at the `BESSUnit._resolve_charge()` level + (currently enforced by the scheduler in `EnergySystem.tick()`; works correctly today). +- AC-coupled BESS behind a breaker (different product configuration, design when needed). From c3ee50591c7f8951f365bc7fc3034a6180e432bd Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Thu, 2 Apr 2026 07:09:30 -0700 Subject: [PATCH 05/35] Clarify config cleanup scope and add EVSE follow-on to out-of-scope --- .../specs/2026-04-02-bess-circuit-removal-design.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/superpowers/specs/2026-04-02-bess-circuit-removal-design.md b/docs/superpowers/specs/2026-04-02-bess-circuit-removal-design.md index a81cead..1450c71 100644 --- a/docs/superpowers/specs/2026-04-02-bess-circuit-removal-design.md +++ b/docs/superpowers/specs/2026-04-02-bess-circuit-removal-design.md @@ -36,9 +36,9 @@ No circuit proxy, no writeback, no exclusion workarounds. ### 1. Configuration Schema -**Remove:** -- `battery` template from `circuit_templates` in all config YAMLs -- `battery_storage` circuit from `circuits` list in all config YAMLs +**Remove from each config YAML that defines them:** +- `battery_storage` circuit entry from the `circuits` list +- `battery` template from `circuit_templates` (only consumer was the circuit entry above) - `BatteryBehavior` TypedDict from `config_types.py` **Add:** @@ -119,3 +119,4 @@ No new tests needed — structural refactor, not behavioral change. - Enforcing "never charge from grid" constraint at the `BESSUnit._resolve_charge()` level (currently enforced by the scheduler in `EnergySystem.tick()`; works correctly today). - AC-coupled BESS behind a breaker (different product configuration, design when needed). +- EVSE two-tab allocation (each EVSE needs two tabs; follow-on task). From 2025d4b6967e2af6ba6b3ba21495ebf551ef8ecb Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Thu, 2 Apr 2026 07:17:14 -0700 Subject: [PATCH 06/35] Add BESS circuit removal implementation plan --- .../plans/2026-04-02-bess-circuit-removal.md | 597 ++++++++++++++++++ 1 file changed, 597 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-02-bess-circuit-removal.md diff --git a/docs/superpowers/plans/2026-04-02-bess-circuit-removal.md b/docs/superpowers/plans/2026-04-02-bess-circuit-removal.md new file mode 100644 index 0000000..648e434 --- /dev/null +++ b/docs/superpowers/plans/2026-04-02-bess-circuit-removal.md @@ -0,0 +1,597 @@ +# BESS Circuit Removal Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Remove the phantom battery circuit so BESS exists only as GFE on the upstream lugs, with state living exclusively in the energy system layer. + +**Architecture:** BESS configuration moves from a circuit template's `battery_behavior` dict to a top-level `bess` YAML section. The engine reads BESS config directly from there instead of scanning circuits. All circuit-proxy writeback logic, battery circuit detection helpers, and `feed_circuit_id` references are removed. The energy system's power resolution is unchanged. + +**Tech Stack:** Python 3.14, YAML configs, pytest + +--- + +### Task 1: Add `BESSConfigYAML` TypedDict and remove `BatteryBehavior` + +**Files:** +- Modify: `src/span_panel_simulator/config_types.py:99-120` + +- [ ] **Step 1: Replace `BatteryBehavior` with `BESSConfigYAML`** + +Replace the `BatteryBehavior` TypedDict (lines 99-120) with a new `BESSConfigYAML` TypedDict for the top-level YAML section: + +```python +class BESSConfigYAML(TypedDict, total=False): + """Top-level BESS configuration in the simulator YAML.""" + + enabled: bool + nameplate_capacity_kwh: float + max_charge_w: float + max_discharge_w: float + charge_efficiency: float + discharge_efficiency: float + backup_reserve_pct: float + charge_mode: Literal["self-consumption", "custom", "backup-only"] + charge_hours: list[int] + discharge_hours: list[int] +``` + +- [ ] **Step 2: Update any imports of `BatteryBehavior`** + +Search for imports of `BatteryBehavior` across the codebase and replace with `BESSConfigYAML` where needed, or remove the import if the consuming code is also being deleted (e.g., `_apply_battery_behavior` helpers). + +Run: `grep -rn "BatteryBehavior" src/` + +Remove or replace each occurrence. The helpers `_get_charge_power`, `_get_discharge_power`, `_get_idle_power`, `_get_solar_intensity_from_config` in engine.py use `BatteryBehavior` as a type hint — these methods are deleted in Task 4, so no replacement needed. + +- [ ] **Step 3: Run type checker** + +Run: `mypy src/span_panel_simulator/config_types.py` +Expected: PASS + +- [ ] **Step 4: Commit** + +``` +git add src/span_panel_simulator/config_types.py +git commit -m "Replace BatteryBehavior TypedDict with BESSConfigYAML" +``` + +--- + +### Task 2: Remove `feed_circuit_id` from energy layer + +**Files:** +- Modify: `src/span_panel_simulator/energy/types.py:94-112` +- Modify: `src/span_panel_simulator/energy/components.py:93-142` +- Modify: `src/span_panel_simulator/energy/system.py:73-90` +- Modify: `src/span_panel_simulator/models.py:71-85` + +- [ ] **Step 1: Remove `feed_circuit_id` from `BESSConfig`** + +In `src/span_panel_simulator/energy/types.py`, remove line 108: + +```python + feed_circuit_id: str = "" +``` + +- [ ] **Step 2: Remove `feed_circuit_id` from `BESSUnit.__init__`** + +In `src/span_panel_simulator/energy/components.py`, remove the `feed_circuit_id` parameter (line 109) and the assignment `self.feed_circuit_id = feed_circuit_id` (line 132). + +- [ ] **Step 3: Remove `feed_circuit_id` from `EnergySystem.from_config`** + +In `src/span_panel_simulator/energy/system.py`, remove line 85: + +```python + feed_circuit_id=bc.feed_circuit_id, +``` + +- [ ] **Step 4: Remove `feed_circuit_id` from `SpanBatterySnapshot`** + +In `src/span_panel_simulator/models.py`, remove line 85: + +```python + feed_circuit_id: str | None = None +``` + +- [ ] **Step 5: Run type checker** + +Run: `mypy src/span_panel_simulator/energy/ src/span_panel_simulator/models.py` +Expected: May show errors in engine.py and publisher.py (fixed in later tasks). Energy layer itself should be clean. + +- [ ] **Step 6: Commit** + +``` +git add src/span_panel_simulator/energy/types.py src/span_panel_simulator/energy/components.py src/span_panel_simulator/energy/system.py src/span_panel_simulator/models.py +git commit -m "Remove feed_circuit_id from energy layer and models" +``` + +--- + +### Task 3: Remove `feed` publishing from publisher + +**Files:** +- Modify: `src/span_panel_simulator/publisher.py:417-426` + +- [ ] **Step 1: Remove `feed_circuit_id` publishing from `_map_bess`** + +In `src/span_panel_simulator/publisher.py`, remove lines 423-424: + +```python + if bat.feed_circuit_id: + p[self._prop_topic(n, "feed")] = self._ensure_circuit_uuid(bat.feed_circuit_id) +``` + +- [ ] **Step 2: Run type checker** + +Run: `mypy src/span_panel_simulator/publisher.py` +Expected: PASS + +- [ ] **Step 3: Commit** + +``` +git add src/span_panel_simulator/publisher.py +git commit -m "Remove feed property publishing from BESS MQTT node" +``` + +--- + +### Task 4: Remove battery circuit logic from behavior engine + +**Files:** +- Modify: `src/span_panel_simulator/engine.py:100,147-150,156-168,271-279,461-512,514-544,546-560` +- Modify: `src/span_panel_simulator/behavior_mutable_state.py` + +This task removes all battery-circuit-specific logic from `RealisticBehaviorEngine`. The energy system (not the behavior engine) drives BESS power. + +- [ ] **Step 1: Remove `_last_battery_direction` from `__init__`** + +In `engine.py` line 100, remove: + +```python + self._last_battery_direction: str = "idle" +``` + +- [ ] **Step 2: Remove `last_battery_direction` property** + +Remove lines 147-150: + +```python + @property + def last_battery_direction(self) -> str: + """Most recent battery direction set by charge mode logic.""" + return self._last_battery_direction +``` + +- [ ] **Step 3: Remove `_last_battery_direction` from `capture_mutable_state` and `restore_mutable_state`** + +In `capture_mutable_state` (line 158), remove the `last_battery_direction` kwarg. +In `restore_mutable_state` (line 167), remove the `self._last_battery_direction = ...` line. + +Updated `capture_mutable_state`: + +```python + def capture_mutable_state(self) -> BehaviorEngineMutableState: + """Return a deep snapshot of tick-local mutable fields.""" + return BehaviorEngineMutableState( + circuit_cycle_states=copy.deepcopy(self._circuit_cycle_states), + grid_offline=self._grid_offline, + ) +``` + +Updated `restore_mutable_state`: + +```python + def restore_mutable_state(self, state: BehaviorEngineMutableState) -> None: + """Restore fields previously captured with :meth:`capture_mutable_state`.""" + self._circuit_cycle_states = copy.deepcopy(state.circuit_cycle_states) + self._grid_offline = state.grid_offline +``` + +- [ ] **Step 4: Remove `last_battery_direction` from `BehaviorEngineMutableState`** + +In `src/span_panel_simulator/behavior_mutable_state.py`, remove line 18: + +```python + last_battery_direction: str +``` + +- [ ] **Step 5: Remove `_apply_battery_behavior` call site** + +In `engine.py` lines 271-279, remove the battery behavior block: + +```python + # Apply battery behavior + battery_behavior = template.get("battery_behavior", {}) + if isinstance(battery_behavior, dict) and battery_behavior.get("enabled", False): + base_power = self._apply_battery_behavior( + base_power, + template, + current_time, + stochastic_noise=stochastic_noise, + ) +``` + +- [ ] **Step 6: Remove `_apply_battery_behavior` method and its helpers** + +Remove these methods entirely from `engine.py`: +- `_apply_battery_behavior` (lines 461-512) +- `_get_charge_power` (lines 514-518) +- `_get_discharge_power` (lines 520-524) +- `_get_idle_power` (lines 526-544) +- `_get_solar_intensity_from_config` (lines 546-560) + +Also remove the `_get_demand_factor_from_config` method if it exists (check the lines following `_get_solar_intensity_from_config`). + +- [ ] **Step 7: Run type checker** + +Run: `mypy src/span_panel_simulator/engine.py src/span_panel_simulator/behavior_mutable_state.py` +Expected: May show errors from later-task removals. Battery behavior methods should be cleanly gone. + +- [ ] **Step 8: Commit** + +``` +git add src/span_panel_simulator/engine.py src/span_panel_simulator/behavior_mutable_state.py +git commit -m "Remove battery behavior logic from behavior engine" +``` + +--- + +### Task 5: Refactor engine to read BESS config from top-level YAML + +**Files:** +- Modify: `src/span_panel_simulator/engine.py` — `_build_energy_system()` (lines 1741-1822) + +- [ ] **Step 1: Replace circuit-scanning BESS config with top-level config read** + +Replace the BESS config block in `_build_energy_system()` (lines 1776-1812) with a direct read from `self._config.get("bess", {})`: + +```python + bess_config: BESSConfig | None = None + bess_yaml = self._config.get("bess", {}) + if isinstance(bess_yaml, dict) and bess_yaml.get("enabled", False): + nameplate = float(bess_yaml.get("nameplate_capacity_kwh", 13.5)) + hybrid = pv_config is not None and pv_config.inverter_type == "hybrid" + charge_hours_raw: list[int] = bess_yaml.get("charge_hours", []) + discharge_hours_raw: list[int] = bess_yaml.get("discharge_hours", []) + panel_tz = ( + str(self._behavior_engine.panel_timezone) + if self._behavior_engine is not None + else RealisticBehaviorEngine._DEFAULT_TZ + ) + charge_mode = str(bess_yaml.get("charge_mode", "self-consumption")) + bess_config = BESSConfig( + nameplate_kwh=nameplate, + max_charge_w=abs(float(bess_yaml.get("max_charge_w", 3500.0))), + max_discharge_w=abs(float(bess_yaml.get("max_discharge_w", 3500.0))), + charge_efficiency=float(bess_yaml.get("charge_efficiency", 0.95)), + discharge_efficiency=float(bess_yaml.get("discharge_efficiency", 0.95)), + backup_reserve_pct=float(bess_yaml.get("backup_reserve_pct", 20.0)), + hybrid=hybrid, + initial_soe_kwh=( + self._energy_system.bess.soe_kwh + if self._energy_system is not None and self._energy_system.bess is not None + else None + ), + panel_serial=self._config["panel_config"]["serial_number"], + charge_hours=tuple(charge_hours_raw), + discharge_hours=tuple(discharge_hours_raw), + panel_timezone=panel_tz, + charge_mode=charge_mode, + ) +``` + +Note: field names in the YAML now match `BESSConfig` directly (`max_charge_w` not `max_charge_power`). + +- [ ] **Step 2: Run type checker** + +Run: `mypy src/span_panel_simulator/engine.py` +Expected: May still show errors from battery circuit references not yet removed (Task 6). + +- [ ] **Step 3: Commit** + +``` +git add src/span_panel_simulator/engine.py +git commit -m "Read BESS config from top-level YAML instead of circuit templates" +``` + +--- + +### Task 6: Remove battery circuit detection and writeback from engine + +**Files:** +- Modify: `src/span_panel_simulator/engine.py` + +- [ ] **Step 1: Remove `_find_battery_circuit` method** + +Delete lines 1733-1739 (the method and its docstring). After Task 5's rewrite of `_build_energy_system`, this is the earlier line-number block — verify exact location. + +- [ ] **Step 2: Remove `_is_battery_circuit` static method** + +Delete lines 1391-1395. + +- [ ] **Step 3: Remove battery circuit exclusion from `_collect_power_inputs`** + +In `_collect_power_inputs` (lines 1705-1731), remove the `_is_battery_circuit` branch. The loop becomes: + +```python + for circuit in self._circuits.values(): + power = circuit.instant_power_w + if circuit.energy_mode == "producer": + pv_power += power + else: + load_power += power +``` + +Update the docstring to remove the BESS exclusion note. + +- [ ] **Step 4: Remove battery circuit exclusion from `_powers_to_energy_inputs`** + +In `_powers_to_energy_inputs` (lines 1397-1424), remove the `_is_battery_circuit` branch. Same simplification: + +```python + for cid, power in circuit_powers.items(): + circuit = self._circuits[cid] + if circuit.energy_mode == "producer": + pv_power += power + else: + load_power += power +``` + +Update the docstring to remove the BESS exclusion note. + +- [ ] **Step 5: Remove battery circuit references from `get_snapshot`** + +In `get_snapshot()`: + +1. Remove line 1157: `battery_circuit = self._find_battery_circuit()` +2. Remove lines 1167-1169 (reflect battery power back to circuit): + ```python + if battery_circuit is not None and self._energy_system.bess is not None: + battery_circuit._instant_power_w = self._energy_system.bess.effective_power_w + ``` +3. Remove `feed_circuit_id` from the `SpanBatterySnapshot` constructor (line 1195): + ```python + feed_circuit_id=bess.feed_circuit_id, + ``` +4. Remove lines 1212-1226 (rebuild battery circuit snapshot block): + ```python + # Rebuild battery circuit snapshot — the original was captured + # before the BSEE update and off-grid deficit calculation, so it + # has stale power. Sync the circuit object then re-snapshot. + if battery_circuit is not None: + battery_circuit._instant_power_w = abs(power_flow_battery) + cid = battery_circuit.circuit_id + snap = battery_circuit.to_snapshot() + if cid in shed_ids: + snap = replace( + snap, + relay_state="OPEN", + relay_requester="BACKUP", + instant_power_w=0.0, + ) + circuit_snapshots[cid] = snap + ``` + +- [ ] **Step 6: Run type checker** + +Run: `mypy src/span_panel_simulator/engine.py` +Expected: PASS (all battery circuit references removed) + +- [ ] **Step 7: Commit** + +``` +git add src/span_panel_simulator/engine.py +git commit -m "Remove battery circuit detection and writeback from engine" +``` + +--- + +### Task 7: Migrate YAML configs to top-level `bess` section + +**Files:** +- Modify: `configs/MAIN_40.yaml` +- Modify: `configs/default_MAIN_40.yaml` +- Modify: `configs/default_MAIN_32.yaml` +- Modify: `configs/default_MAIN_16.yaml` + +For each config file, three changes: (a) add top-level `bess` section after `panel_config`, (b) remove the `battery`/`battery_storage` template from `circuit_templates`, (c) remove the `battery_storage` circuit entry from `circuits`. + +- [ ] **Step 1: Migrate `configs/MAIN_40.yaml`** + +Add after `panel_config` section (after line 6, before `circuit_templates`): + +```yaml +bess: + enabled: true + nameplate_capacity_kwh: 13.5 + max_charge_w: 3500.0 + max_discharge_w: 3500.0 + charge_efficiency: 0.95 + discharge_efficiency: 0.95 + backup_reserve_pct: 20.0 + charge_mode: solar-gen + charge_hours: [8, 9, 10, 11, 12, 13, 14, 15] + discharge_hours: [16, 17, 18, 19, 20, 21, 22] +``` + +Remove the `battery` template block (lines 519-565). + +Remove the `battery_storage` circuit entry (lines 821-823): +```yaml +- id: battery_storage + name: Battery Storage + template: battery +``` + +- [ ] **Step 2: Migrate `configs/default_MAIN_40.yaml`** + +Same pattern. Add `bess` section after `panel_config`. Remove `battery` template (lines 504-549). Remove `battery_storage` circuit (lines 802-804). + +- [ ] **Step 3: Migrate `configs/default_MAIN_32.yaml`** + +Same pattern. Template is named `battery_storage` here (lines 443-462). Circuit entry is `battery_storage_1` (lines 668-670). Remove both, add top-level `bess`. + +- [ ] **Step 4: Migrate `configs/default_MAIN_16.yaml`** + +Same pattern. Remove `battery` template (lines 55-74). Remove `battery_storage` circuit (lines 107-109). Add top-level `bess`. + +- [ ] **Step 5: Validate YAML** + +Run: `python -c "import yaml; [yaml.safe_load(open(f)) for f in ['configs/MAIN_40.yaml', 'configs/default_MAIN_40.yaml', 'configs/default_MAIN_32.yaml', 'configs/default_MAIN_16.yaml']]"` +Expected: No errors + +- [ ] **Step 6: Commit** + +``` +git add configs/ +git commit -m "Migrate BESS config from circuit templates to top-level bess section" +``` + +--- + +### Task 8: Refactor clone pipeline for top-level BESS config + +**Files:** +- Modify: `src/span_panel_simulator/clone.py:593-632` + +- [ ] **Step 1: Refactor `_enrich_bess_template` to write top-level `bess` section** + +Rename to `_build_bess_config` and change it to return a dict instead of mutating a template. It no longer needs `feed_map` or `templates` parameters since it's not enriching a circuit template. + +```python +def _build_bess_config( + properties: dict[str, str], + prefix: str, + bess_node_id: str, +) -> dict[str, object]: + """Build top-level bess config from scraped BESS node properties.""" + nameplate = _float_prop(properties, prefix, bess_node_id, "nameplate-capacity") + nameplate_kwh = nameplate if nameplate is not None else 13.5 + + return { + "enabled": True, + "charge_mode": "custom", + "nameplate_capacity_kwh": nameplate_kwh, + "backup_reserve_pct": 20.0, + "charge_efficiency": 0.95, + "discharge_efficiency": 0.95, + "max_charge_w": 3500.0, + "max_discharge_w": 3500.0, + "charge_hours": [0, 1, 2, 3, 4, 5], + "discharge_hours": [16, 17, 18, 19, 20, 21], + } +``` + +- [ ] **Step 2: Update the caller of `_enrich_bess_template`** + +Find where `_enrich_bess_template` is called in `clone.py` and update it to: +1. Call the renamed `_build_bess_config` function +2. Assign the returned dict to `config["bess"]` instead of mutating a template +3. Do NOT create a battery circuit entry in the circuits list + +- [ ] **Step 3: Verify that the cloned config no longer creates a battery circuit** + +The circuit that was formerly the battery circuit's feed target should remain as a normal circuit if it has other uses, or be removed if it only existed for battery purposes. In practice, the clone pipeline scrapes real circuits — the battery circuit was synthetic. The `feed` cross-reference is simply not used. + +- [ ] **Step 4: Run type checker** + +Run: `mypy src/span_panel_simulator/clone.py` +Expected: PASS + +- [ ] **Step 5: Commit** + +``` +git add src/span_panel_simulator/clone.py +git commit -m "Refactor clone pipeline to write top-level bess config" +``` + +--- + +### Task 9: Update tests + +**Files:** +- Modify: `tests/test_clone.py:200-214` +- Modify: `tests/test_modeling.py:140-170` +- Modify: `tests/test_energy/test_scenarios.py` (if `feed_circuit_id` is explicitly passed) + +- [ ] **Step 1: Update `test_bess_mode` in `test_clone.py`** + +The test currently asserts that a circuit template gets `battery_behavior`. Rewrite to assert the cloned config has a top-level `bess` section: + +```python + def test_bess_mode(self) -> None: + """Cloned panel with BESS node gets top-level bess config.""" + config = translate_scraped_panel(_make_scraped()) + bess = config.get("bess") + assert isinstance(bess, dict) + assert bess["enabled"] is True + assert bess["nameplate_capacity_kwh"] == 13.5 +``` + +- [ ] **Step 2: Update `test_modeling.py` fixture** + +Move the battery config from the circuit template to top-level. Replace lines 140-154 (the `battery` template and its `battery_behavior`) with a simple removal of the battery template and circuit. Add a top-level `bess` section to the YAML fixture: + +```yaml +bess: + enabled: true + charge_mode: "custom" + charge_hours: [10, 11, 12, 13, 14] + discharge_hours: [17, 18, 19, 20, 21] + nameplate_capacity_kwh: 13.5 + backup_reserve_pct: 20 + max_charge_w: 3500.0 + max_discharge_w: 3500.0 +``` + +Remove the `battery` template block and the `batt` circuit entry from the fixture's `circuits` list. + +- [ ] **Step 3: Check `test_scenarios.py` for `feed_circuit_id`** + +The `_bess()` helper in `tests/test_energy/test_scenarios.py` does NOT pass `feed_circuit_id` (it uses the default). No change needed — but verify after Task 2's removal that the `BESSConfig` constructor call still works without the field. + +- [ ] **Step 4: Run full test suite** + +Run: `pytest tests/ -v` +Expected: All tests pass + +- [ ] **Step 5: Commit** + +``` +git add tests/ +git commit -m "Update tests for top-level BESS config" +``` + +--- + +### Task 10: Final verification and cleanup + +- [ ] **Step 1: Run full type check** + +Run: `mypy src/` +Expected: PASS with no battery-related errors + +- [ ] **Step 2: Run full test suite** + +Run: `pytest tests/ -v` +Expected: All tests pass + +- [ ] **Step 3: Search for orphaned references** + +Run: `grep -rn "battery_behavior\|feed_circuit_id\|_find_battery_circuit\|_is_battery_circuit\|_apply_battery_behavior\|BatteryBehavior" src/ tests/ configs/` + +Expected: No matches (or only in documentation/comments that should be cleaned up). + +- [ ] **Step 4: Verify no battery circuit in running simulation** + +Start the simulator with MAIN_40.yaml and confirm: +- No `battery_storage` circuit appears in the circuits list +- BESS snapshot still shows SOE, nameplate, vendor info +- Grid sensor reflects correct power flows + +- [ ] **Step 5: Commit any cleanup** + +``` +git add -A +git commit -m "Final cleanup: remove orphaned battery circuit references" +``` From ef16fd27f94989f69142ee2875a358a9b30509e2 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Thu, 2 Apr 2026 07:23:53 -0700 Subject: [PATCH 07/35] Replace BatteryBehavior TypedDict with BESSConfigYAML Remove the circuit-level BatteryBehavior TypedDict and introduce BESSConfigYAML as the top-level BESS config section, peer to panel_config. Update engine.py and circuit.py to drop the stale BatteryBehavior import, widen battery_behavior field to dict[str, object], and use cast() at all .get() call sites to preserve type safety without type-ignore annotations. --- src/span_panel_simulator/circuit.py | 10 ++-- src/span_panel_simulator/config_types.py | 24 +++----- src/span_panel_simulator/engine.py | 75 ++++++++++++++---------- 3 files changed, 57 insertions(+), 52 deletions(-) diff --git a/src/span_panel_simulator/circuit.py b/src/span_panel_simulator/circuit.py index 1c69dbf..a3a55de 100644 --- a/src/span_panel_simulator/circuit.py +++ b/src/span_panel_simulator/circuit.py @@ -9,7 +9,7 @@ from __future__ import annotations from copy import deepcopy -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, cast from span_panel_simulator.models import SpanCircuitSnapshot @@ -302,14 +302,14 @@ def _resolve_battery_direction(self, current_time: float) -> str: if not battery_config.get("enabled", True): return "unknown" - charge_mode: str = battery_config.get("charge_mode", "custom") + charge_mode = cast("str", battery_config.get("charge_mode", "custom")) if charge_mode != "custom": return self._behavior_engine.last_battery_direction current_hour = self._behavior_engine.local_hour(current_time) - charge_hours: list[int] = battery_config.get("charge_hours", []) - discharge_hours: list[int] = battery_config.get("discharge_hours", []) - idle_hours: list[int] = battery_config.get("idle_hours", []) + charge_hours = cast("list[int]", battery_config.get("charge_hours", [])) + discharge_hours = cast("list[int]", battery_config.get("discharge_hours", [])) + idle_hours = cast("list[int]", battery_config.get("idle_hours", [])) if current_hour in charge_hours: return "charging" diff --git a/src/span_panel_simulator/config_types.py b/src/span_panel_simulator/config_types.py index aa3c65b..cd3ccaa 100644 --- a/src/span_panel_simulator/config_types.py +++ b/src/span_panel_simulator/config_types.py @@ -96,27 +96,19 @@ class CircuitTemplate(TypedDict): priority: str # "MUST_HAVE", "NON_ESSENTIAL" -class BatteryBehavior(TypedDict, total=False): - """Battery behavior configuration.""" +class BESSConfigYAML(TypedDict, total=False): + """Top-level BESS configuration in the simulator YAML.""" enabled: bool - charge_mode: Literal["self-consumption", "custom", "backup-only"] - charge_power: float - discharge_power: float - idle_power: float + nameplate_capacity_kwh: float + max_charge_w: float + max_discharge_w: float charge_efficiency: float discharge_efficiency: float - nameplate_capacity_kwh: float # Total battery capacity in kWh - backup_reserve_pct: float # SOE % reserved for outages (default 20) + backup_reserve_pct: float + charge_mode: Literal["self-consumption", "custom", "backup-only"] charge_hours: list[int] discharge_hours: list[int] - max_charge_power: float - max_discharge_power: float - idle_hours: list[int] - idle_power_range: list[float] - solar_intensity_profile: dict[int, float] - demand_factor_profile: dict[int, float] - active_days: list[int] # Days of week active (0=Mon..6=Sun); empty = all class CircuitTemplateExtended(CircuitTemplate, total=False): @@ -125,7 +117,7 @@ class CircuitTemplateExtended(CircuitTemplate, total=False): cycling_pattern: CyclingPattern time_of_day_profile: TimeOfDayProfile smart_behavior: SmartBehavior - battery_behavior: BatteryBehavior + battery_behavior: dict[str, object] device_type: str # Explicit override: "circuit", "evse", "pv" hvac_type: str # "central_ac", "heat_pump", "heat_pump_aux" monthly_factors: dict[int, float] # month (1-12) -> multiplier (1.0 = peak month) diff --git a/src/span_panel_simulator/engine.py b/src/span_panel_simulator/engine.py index 421a0ae..5c01173 100644 --- a/src/span_panel_simulator/engine.py +++ b/src/span_panel_simulator/engine.py @@ -17,7 +17,7 @@ from dataclasses import replace from datetime import datetime from pathlib import Path -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, cast from zoneinfo import ZoneInfo import yaml @@ -40,7 +40,6 @@ if TYPE_CHECKING: from span_panel_simulator.config_types import ( - BatteryBehavior, CircuitTemplateExtended, SimulationConfig, TabSynchronization, @@ -477,13 +476,13 @@ def _apply_battery_behavior( current_hour = self.local_hour(current_time) # Skip inactive days — return idle power - active_days: list[int] = battery_config.get("active_days", []) + active_days = cast("list[int]", battery_config.get("active_days", [])) if active_days and self.local_weekday(current_time) not in active_days: self._last_battery_direction = "idle" return self._get_idle_power(battery_config, stochastic_noise=stochastic_noise) - discharge_hours: list[int] = battery_config.get("discharge_hours", []) - idle_hours: list[int] = battery_config.get("idle_hours", []) + discharge_hours = cast("list[int]", battery_config.get("discharge_hours", [])) + idle_hours = cast("list[int]", battery_config.get("idle_hours", [])) # Discharge hours always take precedence regardless of charge mode if current_hour in discharge_hours: @@ -494,7 +493,7 @@ def _apply_battery_behavior( self._last_battery_direction = "idle" return self._get_idle_power(battery_config, stochastic_noise=stochastic_noise) - charge_mode: str = battery_config.get("charge_mode", "self-consumption") + charge_mode = cast("str", battery_config.get("charge_mode", "self-consumption")) if charge_mode in ("self-consumption", "backup-only"): # Energy system drives BESS power for these modes; behavior @@ -503,7 +502,7 @@ def _apply_battery_behavior( return self._get_idle_power(battery_config, stochastic_noise=stochastic_noise) # "custom" (TOU) — original schedule-based logic - custom_charge_hours: list[int] = battery_config.get("charge_hours", []) + custom_charge_hours = cast("list[int]", battery_config.get("charge_hours", [])) if current_hour in custom_charge_hours: self._last_battery_direction = "charging" return self._get_charge_power(battery_config, current_hour) @@ -511,26 +510,26 @@ def _apply_battery_behavior( self._last_battery_direction = "idle" return base_power * 0.1 - def _get_charge_power(self, battery_config: BatteryBehavior, current_hour: int) -> float: + def _get_charge_power(self, battery_config: dict[str, object], current_hour: int) -> float: """Get charging power for the current hour.""" - max_charge_power: float = battery_config.get("max_charge_power", -3000.0) + max_charge_power = cast("float", battery_config.get("max_charge_power", -3000.0)) solar_intensity = self._get_solar_intensity_from_config(current_hour, battery_config) return abs(max_charge_power) * solar_intensity - def _get_discharge_power(self, battery_config: BatteryBehavior, current_hour: int) -> float: + def _get_discharge_power(self, battery_config: dict[str, object], current_hour: int) -> float: """Get discharging power for the current hour.""" - max_discharge_power: float = battery_config.get("max_discharge_power", 2500.0) + max_discharge_power = cast("float", battery_config.get("max_discharge_power", 2500.0)) demand_factor = self._get_demand_factor_from_config(current_hour, battery_config) return abs(max_discharge_power) * demand_factor def _get_idle_power( self, - battery_config: BatteryBehavior, + battery_config: dict[str, object], *, stochastic_noise: bool = True, ) -> float: """Get idle power (minimal power flow during low activity hours).""" - idle_range: list[float] = battery_config.get("idle_power_range", [-100.0, 100.0]) + idle_range = cast("list[float]", battery_config.get("idle_power_range", [-100.0, 100.0])) min_val, max_val = idle_range[0], idle_range[1] if min_val < 0 and max_val < 0: min_idle, max_idle = abs(max_val), abs(min_val) @@ -544,15 +543,17 @@ def _get_idle_power( return random.uniform(min_idle, max_idle) # nosec B311 def _get_solar_intensity_from_config( - self, hour: int, battery_config: BatteryBehavior + self, hour: int, battery_config: dict[str, object] ) -> float: """Get solar intensity from YAML configuration.""" - solar_profile: dict[int, float] = battery_config.get("solar_intensity_profile", {}) + solar_profile = cast("dict[int, float]", battery_config.get("solar_intensity_profile", {})) return solar_profile.get(hour, 0.1) - def _get_demand_factor_from_config(self, hour: int, battery_config: BatteryBehavior) -> float: + def _get_demand_factor_from_config( + self, hour: int, battery_config: dict[str, object] + ) -> float: """Get demand factor from YAML configuration.""" - demand_profile: dict[int, float] = battery_config.get("demand_factor_profile", {}) + demand_profile = cast("dict[int, float]", battery_config.get("demand_factor_profile", {})) return demand_profile.get(hour, 0.3) # ------------------------------------------------------------------ @@ -720,10 +721,12 @@ def _estimate_battery_annual_wh( if not isinstance(battery_config, dict) or not battery_config.get("enabled", False): return (0.0, 0.0) - charge_mode: str = battery_config.get("charge_mode", "custom") - max_charge = abs(float(battery_config.get("max_charge_power", 3000.0))) - max_discharge = abs(float(battery_config.get("max_discharge_power", 2500.0))) - discharge_hours: list[int] = battery_config.get("discharge_hours", []) + charge_mode = cast("str", battery_config.get("charge_mode", "custom")) + max_charge = abs(float(cast("float", battery_config.get("max_charge_power", 3000.0)))) + max_discharge = abs( + float(cast("float", battery_config.get("max_discharge_power", 2500.0))) + ) + discharge_hours = cast("list[int]", battery_config.get("discharge_hours", [])) # Discharge -> production (common to all charge modes) produced_wh = 0.0 @@ -736,7 +739,7 @@ def _estimate_battery_annual_wh( consumed_wh = 0.0 if charge_mode == "custom": - charge_hours: list[int] = battery_config.get("charge_hours", []) + charge_hours = cast("list[int]", battery_config.get("charge_hours", [])) if charge_hours: avg_charge = sum( max_charge * self._get_solar_intensity_from_config(h, battery_config) @@ -1777,25 +1780,35 @@ def _build_energy_system( for circuit in included.values(): battery_cfg = circuit.template.get("battery_behavior", {}) if isinstance(battery_cfg, dict) and battery_cfg.get("enabled", False): - nameplate = float(battery_cfg.get("nameplate_capacity_kwh", 13.5)) + nameplate = float(cast("float", battery_cfg.get("nameplate_capacity_kwh", 13.5))) # Hybrid status is a PV inverter property — derive from # the PV config already resolved above. hybrid = pv_config is not None and pv_config.inverter_type == "hybrid" - charge_hours_raw: list[int] = battery_cfg.get("charge_hours", []) - discharge_hours_raw: list[int] = battery_cfg.get("discharge_hours", []) + charge_hours_raw = cast("list[int]", battery_cfg.get("charge_hours", [])) + discharge_hours_raw = cast("list[int]", battery_cfg.get("discharge_hours", [])) panel_tz = ( str(self._behavior_engine.panel_timezone) if self._behavior_engine is not None else RealisticBehaviorEngine._DEFAULT_TZ ) - charge_mode = str(battery_cfg.get("charge_mode", "self-consumption")) + charge_mode = str(cast("str", battery_cfg.get("charge_mode", "self-consumption"))) bess_config = BESSConfig( nameplate_kwh=nameplate, - max_charge_w=abs(float(battery_cfg.get("max_charge_power", 3500.0))), - max_discharge_w=abs(float(battery_cfg.get("max_discharge_power", 3500.0))), - charge_efficiency=float(battery_cfg.get("charge_efficiency", 0.95)), - discharge_efficiency=float(battery_cfg.get("discharge_efficiency", 0.95)), - backup_reserve_pct=float(battery_cfg.get("backup_reserve_pct", 20.0)), + max_charge_w=abs( + float(cast("float", battery_cfg.get("max_charge_power", 3500.0))) + ), + max_discharge_w=abs( + float(cast("float", battery_cfg.get("max_discharge_power", 3500.0))) + ), + charge_efficiency=float( + cast("float", battery_cfg.get("charge_efficiency", 0.95)) + ), + discharge_efficiency=float( + cast("float", battery_cfg.get("discharge_efficiency", 0.95)) + ), + backup_reserve_pct=float( + cast("float", battery_cfg.get("backup_reserve_pct", 20.0)) + ), hybrid=hybrid, initial_soe_kwh=( self._energy_system.bess.soe_kwh From aee989861b70e25725a50a786918479cd45544aa Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Thu, 2 Apr 2026 07:26:09 -0700 Subject: [PATCH 08/35] Remove feed_circuit_id from energy layer and models --- src/span_panel_simulator/energy/components.py | 2 -- src/span_panel_simulator/energy/system.py | 1 - src/span_panel_simulator/energy/types.py | 1 - src/span_panel_simulator/engine.py | 2 -- src/span_panel_simulator/models.py | 1 - src/span_panel_simulator/publisher.py | 2 -- 6 files changed, 9 deletions(-) diff --git a/src/span_panel_simulator/energy/components.py b/src/span_panel_simulator/energy/components.py index 8ea8267..d9ac382 100644 --- a/src/span_panel_simulator/energy/components.py +++ b/src/span_panel_simulator/energy/components.py @@ -106,7 +106,6 @@ def __init__( scheduled_state: str = "idle", requested_power_w: float = 0.0, panel_serial: str = "", - feed_circuit_id: str = "", charge_hours: tuple[int, ...] = (), discharge_hours: tuple[int, ...] = (), panel_timezone: ZoneInfo | None = None, @@ -129,7 +128,6 @@ def __init__( # Identity / schedule self.panel_serial = panel_serial - self.feed_circuit_id = feed_circuit_id self._charge_hours = charge_hours self._discharge_hours = discharge_hours self._panel_timezone: ZoneInfo = panel_timezone or ZoneInfo("America/Los_Angeles") diff --git a/src/span_panel_simulator/energy/system.py b/src/span_panel_simulator/energy/system.py index a3dd384..2d2ea22 100644 --- a/src/span_panel_simulator/energy/system.py +++ b/src/span_panel_simulator/energy/system.py @@ -82,7 +82,6 @@ def from_config(config: EnergySystemConfig) -> EnergySystem: pv_source=pv, soe_kwh=initial_soe, panel_serial=bc.panel_serial, - feed_circuit_id=bc.feed_circuit_id, charge_hours=bc.charge_hours, discharge_hours=bc.discharge_hours, panel_timezone=ZoneInfo(bc.panel_timezone), diff --git a/src/span_panel_simulator/energy/types.py b/src/span_panel_simulator/energy/types.py index 3b0d3a0..4317c02 100644 --- a/src/span_panel_simulator/energy/types.py +++ b/src/span_panel_simulator/energy/types.py @@ -104,7 +104,6 @@ class BESSConfig: hybrid: bool = False initial_soe_kwh: float | None = None panel_serial: str = "" - feed_circuit_id: str = "" charge_hours: tuple[int, ...] = () discharge_hours: tuple[int, ...] = () panel_timezone: str = "America/Los_Angeles" diff --git a/src/span_panel_simulator/engine.py b/src/span_panel_simulator/engine.py index 5c01173..79d9bbe 100644 --- a/src/span_panel_simulator/engine.py +++ b/src/span_panel_simulator/engine.py @@ -1195,7 +1195,6 @@ async def get_snapshot(self) -> SpanPanelSnapshot: software_version=bess.software_version, nameplate_capacity_kwh=bess.nameplate_capacity_kwh, connected=bess.connected, - feed_circuit_id=bess.feed_circuit_id, ) dominant_power_source = self._energy_system.dominant_power_source grid_state = self._energy_system.grid_state @@ -1816,7 +1815,6 @@ def _build_energy_system( else None ), panel_serial=self._config["panel_config"]["serial_number"], - feed_circuit_id=circuit.circuit_id, charge_hours=tuple(charge_hours_raw), discharge_hours=tuple(discharge_hours_raw), panel_timezone=panel_tz, diff --git a/src/span_panel_simulator/models.py b/src/span_panel_simulator/models.py index b004041..dd91a00 100644 --- a/src/span_panel_simulator/models.py +++ b/src/span_panel_simulator/models.py @@ -82,7 +82,6 @@ class SpanBatterySnapshot: software_version: str | None = None nameplate_capacity_kwh: float | None = None connected: bool | None = None - feed_circuit_id: str | None = None @dataclass(frozen=True, slots=True) diff --git a/src/span_panel_simulator/publisher.py b/src/span_panel_simulator/publisher.py index 999fcdc..ef3f7dd 100644 --- a/src/span_panel_simulator/publisher.py +++ b/src/span_panel_simulator/publisher.py @@ -420,8 +420,6 @@ def _map_bess(self, s: SpanPanelSnapshot, p: dict[str, str]) -> None: return n = NODE_BESS self._apply_extractors(n, _BESS_EXTRACTORS, bat, p) - if bat.feed_circuit_id: - p[self._prop_topic(n, "feed")] = self._ensure_circuit_uuid(bat.feed_circuit_id) p[self._prop_topic(n, "grid-state")] = s.grid_state or "ON_GRID" p[self._prop_topic(n, "relative-position")] = "DOWNSTREAM" From c093b0d1157377dd0236b6ef9efe51c7e4a8a62a Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Thu, 2 Apr 2026 07:29:32 -0700 Subject: [PATCH 09/35] Remove battery behavior logic from behavior engine Removes _apply_battery_behavior and all helper methods (_get_charge_power, _get_discharge_power, _get_idle_power, _get_solar_intensity_from_config, _get_demand_factor_from_config, _estimate_battery_annual_wh) from RealisticBehaviorEngine. Also removes _last_battery_direction state and the last_battery_direction property. The energy system is the single source of truth for BESS power; circuit.py _resolve_battery_direction no longer queries the engine for battery direction on non-custom charge modes. --- .../behavior_mutable_state.py | 1 - src/span_panel_simulator/circuit.py | 4 +- src/span_panel_simulator/engine.py | 166 ------------------ 3 files changed, 3 insertions(+), 168 deletions(-) diff --git a/src/span_panel_simulator/behavior_mutable_state.py b/src/span_panel_simulator/behavior_mutable_state.py index 20c0051..9ce2eb9 100644 --- a/src/span_panel_simulator/behavior_mutable_state.py +++ b/src/span_panel_simulator/behavior_mutable_state.py @@ -15,5 +15,4 @@ class BehaviorEngineMutableState: """Immutable snapshot of fields that change during simulation ticks.""" circuit_cycle_states: dict[str, dict[str, Any]] - last_battery_direction: str grid_offline: bool diff --git a/src/span_panel_simulator/circuit.py b/src/span_panel_simulator/circuit.py index a3a55de..90a6ca7 100644 --- a/src/span_panel_simulator/circuit.py +++ b/src/span_panel_simulator/circuit.py @@ -304,7 +304,9 @@ def _resolve_battery_direction(self, current_time: float) -> str: charge_mode = cast("str", battery_config.get("charge_mode", "custom")) if charge_mode != "custom": - return self._behavior_engine.last_battery_direction + # Energy system drives BESS power for self-consumption/backup-only; + # direction tracking is not needed for the circuit energy counter. + return "idle" current_hour = self._behavior_engine.local_hour(current_time) charge_hours = cast("list[int]", battery_config.get("charge_hours", [])) diff --git a/src/span_panel_simulator/engine.py b/src/span_panel_simulator/engine.py index 79d9bbe..eb925e8 100644 --- a/src/span_panel_simulator/engine.py +++ b/src/span_panel_simulator/engine.py @@ -96,7 +96,6 @@ def __init__( self._config = config self._recorder = recorder self._circuit_cycle_states: dict[str, dict[str, Any]] = {} - self._last_battery_direction: str = "idle" self._grid_offline: bool = False self._tz = self._resolve_timezone(config) @@ -143,11 +142,6 @@ def local_datetime(self, timestamp: float) -> datetime: """Return a timezone-aware datetime at the panel's location.""" return datetime.fromtimestamp(timestamp, tz=self._tz) - @property - def last_battery_direction(self) -> str: - """Most recent battery direction set by charge mode logic.""" - return self._last_battery_direction - def set_grid_offline(self, offline: bool) -> None: """Propagate grid state so battery behaviour overrides schedules.""" self._grid_offline = offline @@ -156,14 +150,12 @@ def capture_mutable_state(self) -> BehaviorEngineMutableState: """Return a deep snapshot of tick-local mutable fields.""" return BehaviorEngineMutableState( circuit_cycle_states=copy.deepcopy(self._circuit_cycle_states), - last_battery_direction=self._last_battery_direction, grid_offline=self._grid_offline, ) def restore_mutable_state(self, state: BehaviorEngineMutableState) -> None: """Restore fields previously captured with :meth:`capture_mutable_state`.""" self._circuit_cycle_states = copy.deepcopy(state.circuit_cycle_states) - self._last_battery_direction = state.last_battery_direction self._grid_offline = state.grid_offline def copy_mutable_state_from(self, other: RealisticBehaviorEngine) -> None: @@ -267,16 +259,6 @@ def _synthetic_circuit_power( circuit_id, base_power, template, current_time ) - # Apply battery behavior - battery_behavior = template.get("battery_behavior", {}) - if isinstance(battery_behavior, dict) and battery_behavior.get("enabled", False): - base_power = self._apply_battery_behavior( - base_power, - template, - current_time, - stochastic_noise=stochastic_noise, - ) - # Apply smart behavior if template.get("smart_behavior", {}).get("responds_to_grid", False): base_power = self._apply_smart_behavior(base_power, template, current_time) @@ -457,105 +439,6 @@ def _apply_smart_behavior( return base_power - def _apply_battery_behavior( - self, - base_power: float, - template: CircuitTemplateExtended, - current_time: float, - *, - stochastic_noise: bool = True, - ) -> float: - """Apply battery behavior with charge mode support.""" - battery_config = template.get("battery_behavior", {}) - if not isinstance(battery_config, dict): - return base_power - - if not battery_config.get("enabled", True): - return base_power - - current_hour = self.local_hour(current_time) - - # Skip inactive days — return idle power - active_days = cast("list[int]", battery_config.get("active_days", [])) - if active_days and self.local_weekday(current_time) not in active_days: - self._last_battery_direction = "idle" - return self._get_idle_power(battery_config, stochastic_noise=stochastic_noise) - - discharge_hours = cast("list[int]", battery_config.get("discharge_hours", [])) - idle_hours = cast("list[int]", battery_config.get("idle_hours", [])) - - # Discharge hours always take precedence regardless of charge mode - if current_hour in discharge_hours: - self._last_battery_direction = "discharging" - return self._get_discharge_power(battery_config, current_hour) - - if current_hour in idle_hours: - self._last_battery_direction = "idle" - return self._get_idle_power(battery_config, stochastic_noise=stochastic_noise) - - charge_mode = cast("str", battery_config.get("charge_mode", "self-consumption")) - - if charge_mode in ("self-consumption", "backup-only"): - # Energy system drives BESS power for these modes; behavior - # engine returns idle power so circuit-level output is minimal. - self._last_battery_direction = "idle" - return self._get_idle_power(battery_config, stochastic_noise=stochastic_noise) - - # "custom" (TOU) — original schedule-based logic - custom_charge_hours = cast("list[int]", battery_config.get("charge_hours", [])) - if current_hour in custom_charge_hours: - self._last_battery_direction = "charging" - return self._get_charge_power(battery_config, current_hour) - - self._last_battery_direction = "idle" - return base_power * 0.1 - - def _get_charge_power(self, battery_config: dict[str, object], current_hour: int) -> float: - """Get charging power for the current hour.""" - max_charge_power = cast("float", battery_config.get("max_charge_power", -3000.0)) - solar_intensity = self._get_solar_intensity_from_config(current_hour, battery_config) - return abs(max_charge_power) * solar_intensity - - def _get_discharge_power(self, battery_config: dict[str, object], current_hour: int) -> float: - """Get discharging power for the current hour.""" - max_discharge_power = cast("float", battery_config.get("max_discharge_power", 2500.0)) - demand_factor = self._get_demand_factor_from_config(current_hour, battery_config) - return abs(max_discharge_power) * demand_factor - - def _get_idle_power( - self, - battery_config: dict[str, object], - *, - stochastic_noise: bool = True, - ) -> float: - """Get idle power (minimal power flow during low activity hours).""" - idle_range = cast("list[float]", battery_config.get("idle_power_range", [-100.0, 100.0])) - min_val, max_val = idle_range[0], idle_range[1] - if min_val < 0 and max_val < 0: - min_idle, max_idle = abs(max_val), abs(min_val) - elif min_val < 0: - min_idle, max_idle = 0.0, abs(max_val) - else: - min_idle, max_idle = min_val, max_val - - if not stochastic_noise: - return (min_idle + max_idle) / 2.0 - return random.uniform(min_idle, max_idle) # nosec B311 - - def _get_solar_intensity_from_config( - self, hour: int, battery_config: dict[str, object] - ) -> float: - """Get solar intensity from YAML configuration.""" - solar_profile = cast("dict[int, float]", battery_config.get("solar_intensity_profile", {})) - return solar_profile.get(hour, 0.1) - - def _get_demand_factor_from_config( - self, hour: int, battery_config: dict[str, object] - ) -> float: - """Get demand factor from YAML configuration.""" - demand_profile = cast("dict[int, float]", battery_config.get("demand_factor_profile", {})) - return demand_profile.get(hour, 0.3) - # ------------------------------------------------------------------ # Annual energy estimation (seeds initial circuit counters) # ------------------------------------------------------------------ @@ -576,9 +459,6 @@ def estimate_annual_energy_wh(self, template: CircuitTemplateExtended) -> tuple[ produced = abs(template["energy_profile"]["typical_power"]) * solar_factor * 8760 return (produced, 0.0) - if mode == "bidirectional": - return self._estimate_battery_annual_wh(template) - return (0.0, self._estimate_consumer_annual_wh(template)) def _estimate_solar_annual_factor(self) -> float: @@ -713,52 +593,6 @@ def _estimate_consumer_annual_wh(self, template: CircuitTemplateExtended) -> flo avg_power = typical_power * duty_cycle * tod_avg * seasonal_avg * smart_avg * usage_factor return avg_power * 8760 - def _estimate_battery_annual_wh( - self, template: CircuitTemplateExtended - ) -> tuple[float, float]: - """Estimate annual battery energy ``(produced_wh, consumed_wh)``.""" - battery_config = template.get("battery_behavior", {}) - if not isinstance(battery_config, dict) or not battery_config.get("enabled", False): - return (0.0, 0.0) - - charge_mode = cast("str", battery_config.get("charge_mode", "custom")) - max_charge = abs(float(cast("float", battery_config.get("max_charge_power", 3000.0)))) - max_discharge = abs( - float(cast("float", battery_config.get("max_discharge_power", 2500.0))) - ) - discharge_hours = cast("list[int]", battery_config.get("discharge_hours", [])) - - # Discharge -> production (common to all charge modes) - produced_wh = 0.0 - if discharge_hours: - avg_discharge = sum( - max_discharge * self._get_demand_factor_from_config(h, battery_config) - for h in discharge_hours - ) / len(discharge_hours) - produced_wh = avg_discharge * len(discharge_hours) * 365 - - consumed_wh = 0.0 - if charge_mode == "custom": - charge_hours = cast("list[int]", battery_config.get("charge_hours", [])) - if charge_hours: - avg_charge = sum( - max_charge * self._get_solar_intensity_from_config(h, battery_config) - for h in charge_hours - ) / len(charge_hours) - consumed_wh = avg_charge * len(charge_hours) * 365 - - elif charge_mode == "self-consumption": - # Self-consumption charges from PV excess; estimate ~30% of - # solar capacity goes to battery on average. - solar_factor = self._estimate_solar_annual_factor() - consumed_wh = 0.3 * max_charge * solar_factor * 8760 - - elif charge_mode == "backup-only": - # Backup-only keeps the battery topped up; minimal cycling. - consumed_wh = max_charge * 0.05 * 8760 - - return (produced_wh, consumed_wh) - # --------------------------------------------------------------------------- # DynamicSimulationEngine (orchestrator) From 09e02ba75c0368b1750fd70be2f643d68a54f4f3 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Thu, 2 Apr 2026 07:31:36 -0700 Subject: [PATCH 10/35] Read BESS config from top-level YAML instead of circuit templates --- src/span_panel_simulator/engine.py | 78 ++++++++++++------------------ 1 file changed, 32 insertions(+), 46 deletions(-) diff --git a/src/span_panel_simulator/engine.py b/src/span_panel_simulator/engine.py index eb925e8..3e8894c 100644 --- a/src/span_panel_simulator/engine.py +++ b/src/span_panel_simulator/engine.py @@ -17,7 +17,7 @@ from dataclasses import replace from datetime import datetime from pathlib import Path -from typing import TYPE_CHECKING, Any, cast +from typing import TYPE_CHECKING, Any from zoneinfo import ZoneInfo import yaml @@ -1610,51 +1610,37 @@ def _build_energy_system( break bess_config: BESSConfig | None = None - for circuit in included.values(): - battery_cfg = circuit.template.get("battery_behavior", {}) - if isinstance(battery_cfg, dict) and battery_cfg.get("enabled", False): - nameplate = float(cast("float", battery_cfg.get("nameplate_capacity_kwh", 13.5))) - # Hybrid status is a PV inverter property — derive from - # the PV config already resolved above. - hybrid = pv_config is not None and pv_config.inverter_type == "hybrid" - charge_hours_raw = cast("list[int]", battery_cfg.get("charge_hours", [])) - discharge_hours_raw = cast("list[int]", battery_cfg.get("discharge_hours", [])) - panel_tz = ( - str(self._behavior_engine.panel_timezone) - if self._behavior_engine is not None - else RealisticBehaviorEngine._DEFAULT_TZ - ) - charge_mode = str(cast("str", battery_cfg.get("charge_mode", "self-consumption"))) - bess_config = BESSConfig( - nameplate_kwh=nameplate, - max_charge_w=abs( - float(cast("float", battery_cfg.get("max_charge_power", 3500.0))) - ), - max_discharge_w=abs( - float(cast("float", battery_cfg.get("max_discharge_power", 3500.0))) - ), - charge_efficiency=float( - cast("float", battery_cfg.get("charge_efficiency", 0.95)) - ), - discharge_efficiency=float( - cast("float", battery_cfg.get("discharge_efficiency", 0.95)) - ), - backup_reserve_pct=float( - cast("float", battery_cfg.get("backup_reserve_pct", 20.0)) - ), - hybrid=hybrid, - initial_soe_kwh=( - self._energy_system.bess.soe_kwh - if self._energy_system is not None and self._energy_system.bess is not None - else None - ), - panel_serial=self._config["panel_config"]["serial_number"], - charge_hours=tuple(charge_hours_raw), - discharge_hours=tuple(discharge_hours_raw), - panel_timezone=panel_tz, - charge_mode=charge_mode, - ) - break + bess_yaml = self._config.get("bess", {}) + if isinstance(bess_yaml, dict) and bess_yaml.get("enabled", False): + nameplate = float(bess_yaml.get("nameplate_capacity_kwh", 13.5)) + hybrid = pv_config is not None and pv_config.inverter_type == "hybrid" + charge_hours_raw: list[int] = bess_yaml.get("charge_hours", []) + discharge_hours_raw: list[int] = bess_yaml.get("discharge_hours", []) + panel_tz = ( + str(self._behavior_engine.panel_timezone) + if self._behavior_engine is not None + else RealisticBehaviorEngine._DEFAULT_TZ + ) + charge_mode = str(bess_yaml.get("charge_mode", "self-consumption")) + bess_config = BESSConfig( + nameplate_kwh=nameplate, + max_charge_w=abs(float(bess_yaml.get("max_charge_w", 3500.0))), + max_discharge_w=abs(float(bess_yaml.get("max_discharge_w", 3500.0))), + charge_efficiency=float(bess_yaml.get("charge_efficiency", 0.95)), + discharge_efficiency=float(bess_yaml.get("discharge_efficiency", 0.95)), + backup_reserve_pct=float(bess_yaml.get("backup_reserve_pct", 20.0)), + hybrid=hybrid, + initial_soe_kwh=( + self._energy_system.bess.soe_kwh + if self._energy_system is not None and self._energy_system.bess is not None + else None + ), + panel_serial=self._config["panel_config"]["serial_number"], + charge_hours=tuple(charge_hours_raw), + discharge_hours=tuple(discharge_hours_raw), + panel_timezone=panel_tz, + charge_mode=charge_mode, + ) loads = [LoadConfig() for c in included.values() if c.energy_mode == "consumer"] From 4083227a2759df2499e6e69821e8edf9c7840468 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Thu, 2 Apr 2026 07:32:37 -0700 Subject: [PATCH 11/35] Remove battery circuit detection and writeback from engine --- src/span_panel_simulator/engine.py | 47 +----------------------------- 1 file changed, 1 insertion(+), 46 deletions(-) diff --git a/src/span_panel_simulator/engine.py b/src/span_panel_simulator/engine.py index 3e8894c..8d76272 100644 --- a/src/span_panel_simulator/engine.py +++ b/src/span_panel_simulator/engine.py @@ -991,7 +991,6 @@ async def get_snapshot(self) -> SpanPanelSnapshot: total_consumed_energy += circuit.consumed_energy_wh # 5b. Resolve power flows via EnergySystem (single source of truth) - battery_circuit = self._find_battery_circuit() if self._energy_system is None: raise SimulationConfigurationError("Energy system not initialized") @@ -1001,10 +1000,6 @@ async def get_snapshot(self) -> SpanPanelSnapshot: site_power = system_state.load_power_w - system_state.pv_power_w grid_power = system_state.grid_power_w - # Reflect effective battery power back to circuit - if battery_circuit is not None and self._energy_system.bess is not None: - battery_circuit._instant_power_w = self._energy_system.bess.effective_power_w - # Reflect PV curtailment back to producer circuits so snapshots # are consistent with the resolved system state. if self._energy_system.pv is not None: @@ -1045,22 +1040,6 @@ async def get_snapshot(self) -> SpanPanelSnapshot: else: power_flow_battery = system_state.bess_power_w - # Rebuild battery circuit snapshot — the original was captured - # before the BSEE update and off-grid deficit calculation, so it - # has stale power. Sync the circuit object then re-snapshot. - if battery_circuit is not None: - battery_circuit._instant_power_w = abs(power_flow_battery) - cid = battery_circuit.circuit_id - snap = battery_circuit.to_snapshot() - if cid in shed_ids: - snap = replace( - snap, - relay_state="OPEN", - relay_requester="BACKUP", - instant_power_w=0.0, - ) - circuit_snapshots[cid] = snap - # Rebuild PV circuit snapshots when curtailment reduced output if ( self._energy_system.pv is not None @@ -1224,22 +1203,13 @@ def _collect_circuit_powers_at_ts( return circuit_powers - @staticmethod - def _is_battery_circuit(circuit: SimulatedCircuit) -> bool: - """True when the circuit is the configured BESS (not EVSE or other bidirectional).""" - battery_cfg = circuit.template.get("battery_behavior", {}) - return isinstance(battery_cfg, dict) and bool(battery_cfg.get("enabled", False)) - def _powers_to_energy_inputs( self, circuit_powers: dict[str, float], ) -> PowerInputs: """Convert per-circuit power dict into PowerInputs for the energy system. - Only the BESS circuit is excluded from the power summation — the - energy system determines BESS power from the inverter rate and - bus state. Other bidirectional circuits (e.g. EVSE with V2G) - are treated as load. + Other bidirectional circuits (e.g. EVSE with V2G) are treated as load. """ pv_power = 0.0 load_power = 0.0 @@ -1248,8 +1218,6 @@ def _powers_to_energy_inputs( circuit = self._circuits[cid] if circuit.energy_mode == "producer": pv_power += power - elif self._is_battery_circuit(circuit): - continue else: load_power += power @@ -1544,9 +1512,6 @@ def _collect_power_inputs(self) -> PowerInputs: This method gathers raw measurements from circuits — it does NOT resolve energy scheduling. Schedule resolution is the energy module's responsibility (inside ``EnergySystem.tick``). - - Only the BESS circuit is excluded from load; other bidirectional - circuits (e.g. EVSE with V2G) are treated as load. """ pv_power = 0.0 load_power = 0.0 @@ -1555,8 +1520,6 @@ def _collect_power_inputs(self) -> PowerInputs: power = circuit.instant_power_w if circuit.energy_mode == "producer": pv_power += power - elif self._is_battery_circuit(circuit): - continue else: load_power += power @@ -1566,14 +1529,6 @@ def _collect_power_inputs(self) -> PowerInputs: grid_connected=not self._forced_grid_offline, ) - def _find_battery_circuit(self) -> SimulatedCircuit | None: - """Find the battery circuit instance, if any.""" - for circuit in self._circuits.values(): - battery_cfg = circuit.template.get("battery_behavior", {}) - if isinstance(battery_cfg, dict) and battery_cfg.get("enabled", False): - return circuit - return None - def _build_energy_system( self, *, From 454fe91b50f150cbea7ab9e102ba4d12ef6abae1 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Thu, 2 Apr 2026 07:34:33 -0700 Subject: [PATCH 12/35] Migrate BESS config from circuit templates to top-level bess section --- configs/MAIN_40.yaml | 801 +++++++++++++++++++++++++++++++++++ configs/default_MAIN_16.yaml | 37 +- configs/default_MAIN_32.yaml | 38 +- configs/default_MAIN_40.yaml | 60 +-- 4 files changed, 836 insertions(+), 100 deletions(-) create mode 100644 configs/MAIN_40.yaml diff --git a/configs/MAIN_40.yaml b/configs/MAIN_40.yaml new file mode 100644 index 0000000..d501a8e --- /dev/null +++ b/configs/MAIN_40.yaml @@ -0,0 +1,801 @@ +panel_config: + serial_number: sim-40t-001 + total_tabs: 40 + main_size: 200 + latitude: 37.7 + longitude: -122.4 +bess: + enabled: true + nameplate_capacity_kwh: 13.5 + max_charge_w: 3500.0 + max_discharge_w: 3500.0 + charge_efficiency: 0.95 + discharge_efficiency: 0.95 + backup_reserve_pct: 20.0 + charge_mode: solar-gen + charge_hours: [8, 9, 10, 11, 12, 13, 14, 15] + discharge_hours: [16, 17, 18, 19, 20, 21, 22] +circuit_templates: + lighting: + energy_profile: + mode: consumer + power_range: + - 0.0 + - 500.0 + typical_power: 80.0 + power_variation: 0.1 + relay_behavior: controllable + priority: NEVER + breaker_rating: 15 + time_of_day_profile: + enabled: true + hourly_multipliers: + 0: 0.1 + 1: 0.0 + 2: 0.0 + 3: 0.0 + 4: 0.0 + 5: 0.0 + 6: 0.1 + 7: 0.3 + 8: 0.2 + 9: 0.1 + 10: 0.1 + 11: 0.1 + 12: 0.1 + 13: 0.1 + 14: 0.1 + 15: 0.1 + 16: 0.2 + 17: 0.4 + 18: 0.8 + 19: 1.0 + 20: 1.0 + 21: 1.0 + 22: 0.7 + 23: 0.3 + recorder_entity: sensor.sim_40t_001_master_bedroom_lights_power + exterior_lighting: + energy_profile: + mode: consumer + power_range: + - 0.0 + - 300.0 + typical_power: 60.0 + power_variation: 0.1 + relay_behavior: controllable + priority: OFF_GRID + breaker_rating: 15 + time_of_day_profile: + enabled: true + hourly_multipliers: + 0: 0.3 + 1: 0.3 + 2: 0.3 + 3: 0.3 + 4: 0.3 + 5: 0.3 + 6: 0.1 + 7: 0.0 + 8: 0.0 + 9: 0.0 + 10: 0.0 + 11: 0.0 + 12: 0.0 + 13: 0.0 + 14: 0.0 + 15: 0.0 + 16: 0.0 + 17: 0.0 + 18: 0.3 + 19: 0.7 + 20: 1.0 + 21: 1.0 + 22: 1.0 + 23: 0.5 + recorder_entity: sensor.sim_40t_001_exterior_lights_power + outlets: + energy_profile: + mode: consumer + power_range: + - 0.0 + - 1800.0 + typical_power: 150.0 + power_variation: 0.4 + relay_behavior: controllable + priority: NEVER + breaker_rating: 15 + recorder_entity: sensor.sim_40t_001_master_bedroom_outlets_power + kitchen_outlets: + energy_profile: + mode: consumer + power_range: + - 0.0 + - 2400.0 + typical_power: 300.0 + power_variation: 0.5 + relay_behavior: controllable + priority: NEVER + breaker_rating: 20 + recorder_entity: sensor.sim_40t_001_kitchen_outlets_1_power + refrigerator: + energy_profile: + mode: consumer + power_range: + - 50.0 + - 200.0 + typical_power: 120.0 + power_variation: 0.2 + relay_behavior: non_controllable + priority: NEVER + breaker_rating: 20 + cycling_pattern: + on_duration: 600 + off_duration: 1800 + recorder_entity: sensor.sim_40t_001_refrigerator_power + large_appliance: + energy_profile: + mode: consumer + power_range: + - 0.0 + - 2500.0 + typical_power: 1200.0 + power_variation: 0.2 + relay_behavior: controllable + priority: OFF_GRID + breaker_rating: 20 + hvac: + energy_profile: + mode: consumer + power_range: + - 0.0 + - 3500.0 + typical_power: 2800.0 + power_variation: 0.1 + relay_behavior: controllable + priority: NEVER + breaker_rating: 30 + cycling_pattern: + on_duration: 1200 + off_duration: 2400 + hvac_type: central_ac + time_of_day_profile: + enabled: true + hourly_multipliers: + 0: 0.3 + 1: 0.3 + 2: 0.3 + 3: 0.3 + 4: 0.3 + 5: 0.3 + 6: 0.4 + 7: 0.5 + 8: 0.5 + 9: 0.5 + 10: 0.6 + 11: 0.6 + 12: 0.7 + 13: 0.7 + 14: 0.8 + 15: 0.9 + 16: 1.0 + 17: 1.0 + 18: 1.0 + 19: 1.0 + 20: 1.0 + 21: 1.0 + 22: 0.7 + 23: 0.4 + peak_hours: + - 14 + - 15 + - 16 + - 17 + - 18 + - 19 + - 20 + - 21 + recorder_entity: sensor.sim_40t_001_main_hvac_power + heat_pump: + energy_profile: + mode: consumer + power_range: + - 500.0 + - 4000.0 + typical_power: 2800.0 + power_variation: 0.25 + relay_behavior: controllable + priority: OFF_GRID + breaker_rating: 30 + cycling_pattern: + on_duration: 900 + off_duration: 1800 + hvac_type: heat_pump + recorder_entity: sensor.sim_40t_001_heat_pump_power + dishwasher: + energy_profile: + mode: consumer + power_range: + - 0.0 + - 1800.0 + typical_power: 1200.0 + power_variation: 0.15 + relay_behavior: controllable + priority: OFF_GRID + breaker_rating: 20 + cycling_pattern: + on_duration: 1200 + off_duration: 600 + time_of_day_profile: + enabled: true + hour_factors: + 0: 0.0 + 1: 0.0 + 2: 0.0 + 3: 0.0 + 4: 0.0 + 5: 0.0 + 6: 0.0 + 7: 0.0 + 8: 0.0 + 9: 0.0 + 10: 0.0 + 11: 0.0 + 12: 0.0 + 13: 0.0 + 14: 0.0 + 15: 0.0 + 16: 0.0 + 17: 0.0 + 18: 0.0 + 19: 0.8 + 20: 1.0 + 21: 0.5 + 22: 0.0 + 23: 0.0 + recorder_entity: sensor.sim_40t_001_dishwasher_power + washing_machine: + energy_profile: + mode: consumer + power_range: + - 0.0 + - 1500.0 + typical_power: 500.0 + power_variation: 0.3 + relay_behavior: controllable + priority: OFF_GRID + breaker_rating: 20 + cycling_pattern: + on_duration: 600 + off_duration: 300 + time_of_day_profile: + enabled: true + hour_factors: + 0: 0.0 + 1: 0.0 + 2: 0.0 + 3: 0.0 + 4: 0.0 + 5: 0.0 + 6: 0.0 + 7: 0.0 + 8: 0.3 + 9: 0.7 + 10: 1.0 + 11: 0.8 + 12: 0.0 + 13: 0.0 + 14: 0.0 + 15: 0.5 + 16: 0.7 + 17: 0.3 + 18: 0.0 + 19: 0.0 + 20: 0.0 + 21: 0.0 + 22: 0.0 + 23: 0.0 + recorder_entity: sensor.sim_40t_001_washing_machine_power + microwave: + energy_profile: + mode: consumer + power_range: + - 0.0 + - 1500.0 + typical_power: 1000.0 + power_variation: 0.1 + relay_behavior: controllable + priority: NEVER + breaker_rating: 20 + cycling_pattern: + on_duration: 180 + off_duration: 3420 + time_of_day_profile: + enabled: true + hour_factors: + 0: 0.0 + 1: 0.0 + 2: 0.0 + 3: 0.0 + 4: 0.0 + 5: 0.0 + 6: 0.0 + 7: 0.4 + 8: 0.3 + 9: 0.0 + 10: 0.0 + 11: 0.0 + 12: 0.6 + 13: 0.4 + 14: 0.0 + 15: 0.0 + 16: 0.0 + 17: 0.3 + 18: 0.5 + 19: 0.3 + 20: 0.0 + 21: 0.0 + 22: 0.0 + 23: 0.0 + recorder_entity: sensor.sim_40t_001_microwave_power + dryer: + energy_profile: + mode: consumer + power_range: + - 0.0 + - 5000.0 + typical_power: 3000.0 + power_variation: 0.1 + relay_behavior: controllable + priority: OFF_GRID + breaker_rating: 30 + cycling_pattern: + on_duration: 2400 + off_duration: 300 + time_of_day_profile: + enabled: true + hour_factors: + 0: 0.0 + 1: 0.0 + 2: 0.0 + 3: 0.0 + 4: 0.0 + 5: 0.0 + 6: 0.0 + 7: 0.0 + 8: 0.0 + 9: 0.0 + 10: 0.0 + 11: 0.0 + 12: 0.0 + 13: 0.0 + 14: 0.5 + 15: 1.0 + 16: 1.0 + 17: 0.5 + 18: 0.0 + 19: 0.0 + 20: 0.0 + 21: 0.0 + 22: 0.0 + 23: 0.0 + recorder_entity: sensor.sim_40t_001_dryer_power + oven_range: + energy_profile: + mode: consumer + power_range: + - 0.0 + - 5000.0 + typical_power: 2000.0 + power_variation: 0.2 + relay_behavior: controllable + priority: OFF_GRID + breaker_rating: 40 + cycling_pattern: + on_duration: 600 + off_duration: 600 + time_of_day_profile: + enabled: true + hour_factors: + 0: 0.0 + 1: 0.0 + 2: 0.0 + 3: 0.0 + 4: 0.0 + 5: 0.0 + 6: 0.0 + 7: 0.2 + 8: 0.2 + 9: 0.0 + 10: 0.0 + 11: 0.0 + 12: 0.3 + 13: 0.0 + 14: 0.0 + 15: 0.0 + 16: 0.0 + 17: 0.5 + 18: 1.0 + 19: 0.8 + 20: 0.3 + 21: 0.0 + 22: 0.0 + 23: 0.0 + recorder_entity: sensor.sim_40t_001_oven_power + water_heater: + energy_profile: + mode: consumer + power_range: + - 0.0 + - 4500.0 + typical_power: 4500.0 + power_variation: 0.05 + relay_behavior: controllable + priority: OFF_GRID + breaker_rating: 30 + cycling_pattern: + on_duration: 600 + off_duration: 2400 + time_of_day_profile: + enabled: true + hour_factors: + 0: 0.1 + 1: 0.1 + 2: 0.1 + 3: 0.1 + 4: 0.1 + 5: 0.2 + 6: 0.6 + 7: 1.0 + 8: 0.8 + 9: 0.4 + 10: 0.2 + 11: 0.2 + 12: 0.2 + 13: 0.2 + 14: 0.2 + 15: 0.2 + 16: 0.3 + 17: 0.5 + 18: 0.8 + 19: 1.0 + 20: 0.7 + 21: 0.4 + 22: 0.2 + 23: 0.1 + recorder_entity: sensor.sim_40t_001_water_heater_power + span_drive: + energy_profile: + mode: consumer + power_range: + - 0.0 + - 11500.0 + typical_power: 7200.0 + power_variation: 0.05 + relay_behavior: controllable + priority: OFF_GRID + device_type: evse + breaker_rating: 50 + smart_behavior: + responds_to_grid: true + max_power_reduction: 0.6 + time_of_day_profile: + enabled: true + hour_factors: + 0: 1.0 + 1: 1.0 + 2: 1.0 + 3: 1.0 + 4: 1.0 + 5: 1.0 + 6: 0.0 + 7: 0.0 + 8: 0.0 + 9: 0.0 + 10: 0.0 + 11: 0.0 + 12: 0.0 + 13: 0.0 + 14: 0.0 + 15: 0.0 + 16: 0.0 + 17: 0.0 + 18: 0.0 + 19: 0.0 + 20: 0.0 + 21: 0.0 + 22: 0.0 + 23: 0.0 + active_days: + - 0 + - 2 + - 4 + - 6 + recorder_entity: sensor.sim_40t_001_span_drive_garage_power + solar: + energy_profile: + mode: producer + power_range: + - -10000.0 + - 0.0 + typical_power: -6000.0 + power_variation: 0.25 + efficiency: 0.85 + nameplate_capacity_w: 10000.0 + relay_behavior: non_controllable + priority: NEVER + device_type: pv + breaker_rating: 30 + recorder_entity: sensor.sim_40t_001_solar_inverter_power + pool: + energy_profile: + mode: consumer + power_range: + - 0.0 + - 1200.0 + typical_power: 800.0 + power_variation: 0.1 + relay_behavior: controllable + priority: OFF_GRID + breaker_rating: 20 + cycling_pattern: + on_duration: 7200 + off_duration: 14400 + time_of_day_profile: + enabled: true + hourly_multipliers: + 0: 0.0 + 1: 0.0 + 2: 0.0 + 3: 0.0 + 4: 0.0 + 5: 0.0 + 6: 0.0 + 7: 0.1 + 8: 0.3 + 9: 0.7 + 10: 1.0 + 11: 1.0 + 12: 1.0 + 13: 1.0 + 14: 1.0 + 15: 0.8 + 16: 0.5 + 17: 0.3 + 18: 0.1 + 19: 0.0 + 20: 0.0 + 21: 0.0 + 22: 0.0 + 23: 0.0 + peak_hours: + - 10 + - 11 + - 12 + - 13 + - 14 + - 15 + recorder_entity: sensor.sim_40t_001_pool_pump_power + always_on: + energy_profile: + mode: consumer + power_range: + - 40.0 + - 100.0 + typical_power: 60.0 + power_variation: 0.1 + relay_behavior: controllable + priority: NEVER + breaker_rating: 15 + recorder_entity: sensor.sim_40t_001_garbage_disposal_power + new_circuit_tpl: + energy_profile: + mode: consumer + power_range: + - 0.0 + - 1800.0 + typical_power: 150.0 + power_variation: 0.3 + relay_behavior: controllable + priority: NEVER + breaker_rating: 15 + recorder_entity: sensor.sim_40t_001_new_circuit_power +circuits: +- id: master_bedroom_lights + name: Master Bedroom Lights + template: lighting + tabs: + - 1 + overrides: + typical_power: 40.0 +- id: living_room_lights + name: Living Room Lights + template: lighting + tabs: + - 2 + overrides: + typical_power: 50.0 +- id: bedroom_lights + name: Bedroom Lights + template: lighting + tabs: + - 4 +- id: bathroom_lights + name: Bathroom Lights + template: lighting + tabs: + - 5 + overrides: + typical_power: 30.0 +- id: exterior_lights + name: Exterior Lights + template: exterior_lighting + tabs: + - 6 +- id: master_bedroom_outlets + name: Master Bedroom Outlets + template: outlets + tabs: + - 7 +- id: living_room_outlets + name: Living Room Outlets + template: outlets + tabs: + - 8 + overrides: + typical_power: 250.0 +- id: kitchen_outlets_1 + name: Kitchen Outlets (Counter) + template: kitchen_outlets + tabs: + - 9 +- id: kitchen_outlets_2 + name: Kitchen Outlets (Island) + template: kitchen_outlets + tabs: + - 10 +- id: office_outlets + name: Office Outlets + template: outlets + tabs: + - 11 + overrides: + typical_power: 300.0 +- id: garage_outlets + name: Garage Outlets + template: outlets + tabs: + - 12 +- id: laundry_outlets + name: Laundry Room Outlets + template: outlets + tabs: + - 13 +- id: guest_room_outlets + name: Guest Room Outlets + template: outlets + tabs: + - 14 +- id: refrigerator + name: Refrigerator + template: refrigerator + tabs: + - 15 +- id: dishwasher + name: Dishwasher + template: dishwasher + tabs: + - 16 +- id: washing_machine + name: Washing Machine + template: washing_machine + tabs: + - 17 +- id: microwave + name: Microwave + template: microwave + tabs: + - 18 +- id: freezer + name: Chest Freezer + template: refrigerator + tabs: + - 19 + overrides: + typical_power: 80.0 +- id: garbage_disposal + name: Garbage Disposal + template: always_on + tabs: + - 21 + overrides: + typical_power: 0.0 + power_range: + - 0.0 + - 500.0 +- id: pool_pump + name: Pool Pump + template: pool + tabs: + - 39 + breaker_rating: 20 +- id: smoke_detectors + name: Smoke Detectors + template: always_on + tabs: + - 40 + overrides: + typical_power: 5.0 + power_range: + - 3.0 + - 10.0 +- id: dryer + name: Electric Dryer + template: dryer + tabs: + - 20 + - 22 +- id: main_hvac + name: Main HVAC + template: hvac + tabs: + - 23 + - 25 + breaker_rating: 30 + overrides: + typical_power: 1400.0 +- id: heat_pump + name: Heat Pump + template: heat_pump + tabs: + - 27 + - 29 +- id: oven + name: Electric Oven/Range + template: oven_range + tabs: + - 28 + - 30 +- id: water_heater + name: Water Heater + template: water_heater + tabs: + - 31 + - 33 +- id: span_drive_garage + name: SPAN Drive - Garage + template: span_drive + tabs: + - 32 + - 34 + breaker_rating: 50 +- id: span_drive_driveway + name: SPAN Drive - Driveway + template: span_drive + tabs: + - 35 + - 37 +- id: solar_inverter + name: Solar Inverter + template: solar + tabs: + - 36 + - 38 + breaker_rating: 30 +- id: new_circuit + name: kitchen Lights + template: new_circuit_tpl + tabs: + - 3 + overrides: + power_range: + - 0.0 + - 250.0 +unmapped_tabs: +- 24 +- 26 +simulation_params: + update_interval: 5.0 + time_acceleration: 1.0 + noise_factor: 0.01 + enable_realistic_behaviors: true diff --git a/configs/default_MAIN_16.yaml b/configs/default_MAIN_16.yaml index e29de9e..43a2d08 100644 --- a/configs/default_MAIN_16.yaml +++ b/configs/default_MAIN_16.yaml @@ -8,6 +8,18 @@ panel_config: latitude: 37.7 longitude: -122.4 +bess: + enabled: true + nameplate_capacity_kwh: 13.5 + max_charge_w: 3500.0 + max_discharge_w: 3500.0 + charge_efficiency: 0.95 + discharge_efficiency: 0.95 + backup_reserve_pct: 20.0 + charge_mode: solar-gen + charge_hours: [8, 9, 10, 11, 12, 13, 14, 15] + discharge_hours: [16, 17, 18, 19, 20, 21, 22] + circuit_templates: lighting: energy_profile: @@ -52,27 +64,6 @@ circuit_templates: device_type: "pv" breaker_rating: 30 - battery: - energy_profile: - mode: "bidirectional" - power_range: [-5000.0, 5000.0] - typical_power: 0.0 - power_variation: 0.02 - efficiency: 0.95 - relay_behavior: "controllable" - priority: "NEVER" - breaker_rating: 40 - battery_behavior: - enabled: true - nameplate_capacity_kwh: 13.5 - backup_reserve_pct: 20.0 - charge_mode: "solar-gen" - max_charge_power: 3500.0 - max_discharge_power: 3500.0 - charge_hours: [8, 9, 10, 11, 12, 13, 14, 15] - discharge_hours: [16, 17, 18, 19, 20, 21, 22] - idle_hours: [0, 1, 2, 3, 4, 5, 6, 7, 23] - circuits: - id: "living_room_lights" name: "Living Room Lights" @@ -104,10 +95,6 @@ circuits: template: "solar" tabs: [6, 8] - - id: "battery_storage" - name: "Battery Storage" - template: "battery" - unmapped_tabs: [9, 10, 11, 12, 13, 14, 15, 16] simulation_params: diff --git a/configs/default_MAIN_32.yaml b/configs/default_MAIN_32.yaml index c4b2c1c..d0e1a05 100644 --- a/configs/default_MAIN_32.yaml +++ b/configs/default_MAIN_32.yaml @@ -8,6 +8,18 @@ panel_config: latitude: 37.7 longitude: -122.4 +bess: + enabled: true + nameplate_capacity_kwh: 13.5 + max_charge_w: 3500.0 + max_discharge_w: 3500.0 + charge_efficiency: 0.95 + discharge_efficiency: 0.95 + backup_reserve_pct: 20.0 + charge_mode: solar-gen + charge_hours: [8, 9, 10, 11, 12, 13, 14, 15] + discharge_hours: [16, 17, 18, 19, 20, 21, 22] + # Circuit templates define reusable behavior patterns circuit_templates: # Always-on base load @@ -439,28 +451,6 @@ circuit_templates: priority: "NEVER" breaker_rating: 40 - # Battery storage - bidirectional - battery_storage: - energy_profile: - mode: "bidirectional" # Can charge or discharge - power_range: [-5000.0, 5000.0] # ±5kW battery - typical_power: 0.0 # Neutral when idle - power_variation: 0.02 # Very stable - efficiency: 0.95 # 95% round-trip efficiency - relay_behavior: "controllable" - priority: "NEVER" - breaker_rating: 40 - battery_behavior: - enabled: true - nameplate_capacity_kwh: 13.5 - backup_reserve_pct: 20.0 - charge_mode: "solar-gen" - max_charge_power: 3500.0 - max_discharge_power: 3500.0 - charge_hours: [8, 9, 10, 11, 12, 13, 14, 15] - discharge_hours: [16, 17, 18, 19, 20, 21, 22] - idle_hours: [0, 1, 2, 3, 4, 5, 6, 7, 23] - # Wind turbine - variable producer wind_production: energy_profile: @@ -665,10 +655,6 @@ circuits: overrides: nameplate_capacity_w: 8000.0 - - id: "battery_storage_1" - name: "Battery Storage" - template: "battery_storage" - unmapped_tabs: [31, 32] # Global simulation parameters diff --git a/configs/default_MAIN_40.yaml b/configs/default_MAIN_40.yaml index b0c9803..8741ccb 100644 --- a/configs/default_MAIN_40.yaml +++ b/configs/default_MAIN_40.yaml @@ -4,6 +4,17 @@ panel_config: main_size: 200 latitude: 37.7 longitude: -122.4 +bess: + enabled: true + nameplate_capacity_kwh: 13.5 + max_charge_w: 3500.0 + max_discharge_w: 3500.0 + charge_efficiency: 0.95 + discharge_efficiency: 0.95 + backup_reserve_pct: 20.0 + charge_mode: solar-gen + charge_hours: [8, 9, 10, 11, 12, 13, 14, 15] + discharge_hours: [16, 17, 18, 19, 20, 21, 22] circuit_templates: lighting: energy_profile: @@ -501,52 +512,6 @@ circuit_templates: priority: NEVER device_type: pv breaker_rating: 30 - battery: - energy_profile: - mode: bidirectional - power_range: - - -5000.0 - - 5000.0 - typical_power: 0.0 - power_variation: 0.02 - efficiency: 0.95 - relay_behavior: controllable - priority: NEVER - breaker_rating: 40 - battery_behavior: - enabled: true - nameplate_capacity_kwh: 13.5 - backup_reserve_pct: 20.0 - charge_mode: solar-gen - max_charge_power: 3500.0 - max_discharge_power: 3500.0 - charge_hours: - - 8 - - 9 - - 10 - - 11 - - 12 - - 13 - - 14 - - 15 - discharge_hours: - - 16 - - 17 - - 18 - - 19 - - 20 - - 21 - - 22 - idle_hours: - - 0 - - 1 - - 2 - - 3 - - 4 - - 5 - - 6 - - 7 - - 23 pool: energy_profile: mode: consumer @@ -799,9 +764,6 @@ circuits: - 36 - 38 breaker_rating: 30 -- id: battery_storage - name: Battery Storage - template: battery - id: new_circuit name: kitchen Lights template: new_circuit_tpl From 9002cfa87447c74c914b087f59e1d7252c884da5 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Thu, 2 Apr 2026 07:35:23 -0700 Subject: [PATCH 13/35] Refactor clone pipeline to write top-level bess config Replace _enrich_bess_template (which mutated a circuit template) with _build_bess_config returning a top-level bess config dict. Remove BESS from _build_feed_map since BESS no longer has a feed circuit. Remove the battery_behavior circuit-tab-stripping loop that is no longer needed. --- src/span_panel_simulator/clone.py | 65 +++++++------------------------ 1 file changed, 15 insertions(+), 50 deletions(-) diff --git a/src/span_panel_simulator/clone.py b/src/span_panel_simulator/clone.py index 1dfd988..4a5f3a7 100644 --- a/src/span_panel_simulator/clone.py +++ b/src/span_panel_simulator/clone.py @@ -101,7 +101,7 @@ def translate_scraped_panel( evse_nodes = _nodes_of_type(nodes, TYPE_EVSE) # Build feed cross-reference: circuit_uuid → device_type - feed_map = _build_feed_map(scraped.properties, prefix, bess_nodes, pv_nodes, evse_nodes) + feed_map = _build_feed_map(scraped.properties, prefix, pv_nodes, evse_nodes) # Extract panel-level values main_breaker = _int_prop(scraped.properties, prefix, "core", "breaker-rating") or 200 @@ -139,10 +139,6 @@ def translate_scraped_panel( circuits.append(circuit_def) used_tabs.update(tabs) - # Enrich BESS circuit template - for bess_id in bess_nodes: - _enrich_bess_template(scraped.properties, prefix, bess_id, feed_map, templates) - # Enrich PV circuit template for pv_id in pv_nodes: _enrich_pv_template(scraped.properties, prefix, pv_id, feed_map, templates) @@ -151,16 +147,6 @@ def translate_scraped_panel( for evse_id in evse_nodes: _enrich_evse_template(scraped.properties, prefix, evse_id, feed_map, templates) - # Battery entities sit between panel lugs and grid — strip their tabs. - for circ in circuits: - tpl_name = circ.get("template") - tpl = templates.get(str(tpl_name), {}) if tpl_name else {} - bb = tpl.get("battery_behavior") - if isinstance(bb, dict) and bb.get("enabled"): - freed = circ.pop("tabs", []) - if isinstance(freed, list): - used_tabs -= set(freed) - # Unmapped tabs all_tabs = set(range(1, total_tabs + 1)) unmapped = sorted(all_tabs - used_tabs) @@ -178,6 +164,10 @@ def translate_scraped_panel( }, } + # Build top-level BESS config + for bess_id in bess_nodes: + config["bess"] = _build_bess_config(scraped.properties, prefix, bess_id) + if host is not None: config["panel_source"] = { "origin_serial": scraped.serial_number, @@ -432,22 +422,17 @@ def _nodes_of_type( def _build_feed_map( properties: dict[str, str], prefix: str, - bess_nodes: list[str], pv_nodes: list[str], evse_nodes: list[str], ) -> dict[str, str]: """Build a mapping from circuit UUID to device type based on feed properties. - Device nodes (BESS, PV, EVSE) have a ``feed`` property whose value is the - UUID of the circuit they're associated with. + PV and EVSE nodes have a ``feed`` property whose value is the UUID of the + circuit they're associated with. BESS nodes no longer use a feed circuit — + their config goes to the top-level ``bess`` section. """ feed_map: dict[str, str] = {} - for node_id in bess_nodes: - circuit_uuid = _get_prop(properties, prefix, node_id, "feed") - if circuit_uuid: - feed_map[circuit_uuid] = "bess" - for node_id in pv_nodes: circuit_uuid = _get_prop(properties, prefix, node_id, "feed") if circuit_uuid: @@ -585,51 +570,31 @@ def _device_role_to_mode(device_role: str | None) -> str: """Map a device role from the feed map to an energy profile mode.""" if device_role == "pv": return "producer" - if device_role in ("bess", "evse"): + if device_role == "evse": return "bidirectional" return "consumer" -def _enrich_bess_template( +def _build_bess_config( properties: dict[str, str], prefix: str, bess_node_id: str, - feed_map: dict[str, str], - templates: dict[str, dict[str, object]], -) -> None: - """Add battery_behavior to the circuit template fed by this BESS node.""" - circuit_uuid = _get_prop(properties, prefix, bess_node_id, "feed") - template = _find_template_for_feed(circuit_uuid, feed_map, templates, properties, prefix) - if template is None: - return - +) -> dict[str, object]: + """Build top-level bess config from scraped BESS node properties.""" nameplate = _float_prop(properties, prefix, bess_node_id, "nameplate-capacity") nameplate_kwh = nameplate if nameplate is not None else 13.5 - # Derive max charge/discharge from breaker rating - breaker = template.get("breaker_rating", 40) - breaker_val = float(breaker) if isinstance(breaker, int | float) else 40.0 - ep = template.get("energy_profile") - is_240v = False - if isinstance(ep, dict): - pr = ep.get("power_range") - if isinstance(pr, list) and len(pr) == 2: - is_240v = abs(pr[0]) > 120 * breaker_val - voltage = 240.0 if is_240v else 120.0 - max_power = breaker_val * voltage * 0.8 - - template["battery_behavior"] = { + return { "enabled": True, "charge_mode": "custom", "nameplate_capacity_kwh": nameplate_kwh, "backup_reserve_pct": 20.0, "charge_efficiency": 0.95, "discharge_efficiency": 0.95, - "max_charge_power": max_power, - "max_discharge_power": max_power, + "max_charge_w": 3500.0, + "max_discharge_w": 3500.0, "charge_hours": [0, 1, 2, 3, 4, 5], "discharge_hours": [16, 17, 18, 19, 20, 21], - "idle_hours": [6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 22, 23], } From 75fc9e191e26d4fb4fd23685794fd8078ca807bd Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Thu, 2 Apr 2026 07:37:11 -0700 Subject: [PATCH 14/35] Update tests for top-level BESS config Replace the old battery_behavior circuit template assertions with checks against the new top-level bess config key; remove the battery circuit template and batt circuit entry from the modeling fixture. --- tests/test_clone.py | 18 +++++------------- tests/test_modeling.py | 28 ++++++++++------------------ 2 files changed, 15 insertions(+), 31 deletions(-) diff --git a/tests/test_clone.py b/tests/test_clone.py index a6c58b9..6d85770 100644 --- a/tests/test_clone.py +++ b/tests/test_clone.py @@ -198,20 +198,12 @@ def test_pv_mode(self) -> None: assert t.get("device_type") == "pv" def test_bess_mode(self) -> None: - """Circuit fed by BESS node gets bidirectional mode and battery_behavior.""" + """Cloned panel with BESS node gets top-level bess config.""" config = translate_scraped_panel(_make_scraped()) - templates = config["circuit_templates"] - assert isinstance(templates, dict) - t = templates["clone_11"] - assert isinstance(t, dict) - ep = t["energy_profile"] - assert isinstance(ep, dict) - assert ep["mode"] == "bidirectional" - assert "battery_behavior" in t - bb = t["battery_behavior"] - assert isinstance(bb, dict) - assert bb["enabled"] is True - assert bb["nameplate_capacity_kwh"] == 13.5 + bess = config.get("bess") + assert isinstance(bess, dict) + assert bess["enabled"] is True + assert bess["nameplate_capacity_kwh"] == 13.5 def test_evse_mode(self) -> None: """Circuit fed by EVSE node gets bidirectional mode and evse device type.""" diff --git a/tests/test_modeling.py b/tests/test_modeling.py index 57b0470..f5e1338 100644 --- a/tests/test_modeling.py +++ b/tests/test_modeling.py @@ -137,21 +137,16 @@ def simple_config(tmp_path: Path) -> Path: power_variation: 0.05 relay_behavior: "non_controllable" priority: "MUST_HAVE" - battery: - energy_profile: - mode: "bidirectional" - power_range: [0.0, 5000.0] - typical_power: 3000.0 - power_variation: 0.0 - relay_behavior: "non_controllable" - priority: "MUST_HAVE" - battery_behavior: - enabled: true - charge_mode: "custom" - charge_hours: [10, 11, 12, 13, 14] - discharge_hours: [17, 18, 19, 20, 21] - nameplate_capacity_kwh: 13.5 - backup_reserve_pct: 20 + +bess: + enabled: true + charge_mode: "custom" + charge_hours: [10, 11, 12, 13, 14] + discharge_hours: [17, 18, 19, 20, 21] + nameplate_capacity_kwh: 13.5 + backup_reserve_pct: 20 + max_charge_w: 3500.0 + max_discharge_w: 3500.0 circuits: - id: "lights" @@ -164,9 +159,6 @@ def simple_config(tmp_path: Path) -> Path: template: "solar" tabs: [3, 5] recorder_entity: "sensor.pv_power" - - id: "batt" - name: "Battery" - template: "battery" unmapped_tabs: [2, 4] From 2a716e58034ccf6cef7c42dc8c5d3200301d223d Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Thu, 2 Apr 2026 07:45:18 -0700 Subject: [PATCH 15/35] Remove orphaned battery_behavior references from non-dashboard code --- src/span_panel_simulator/circuit.py | 34 ++++------------ src/span_panel_simulator/config_types.py | 1 - src/span_panel_simulator/history_generator.py | 39 ------------------- .../profile_applicator.py | 5 +-- src/span_panel_simulator/validation.py | 12 ------ 5 files changed, 9 insertions(+), 82 deletions(-) diff --git a/src/span_panel_simulator/circuit.py b/src/span_panel_simulator/circuit.py index 90a6ca7..591b33e 100644 --- a/src/span_panel_simulator/circuit.py +++ b/src/span_panel_simulator/circuit.py @@ -9,7 +9,7 @@ from __future__ import annotations from copy import deepcopy -from typing import TYPE_CHECKING, cast +from typing import TYPE_CHECKING from span_panel_simulator.models import SpanCircuitSnapshot @@ -294,29 +294,11 @@ def _accumulate_energy(self, current_time: float) -> None: # consumer self._consumed_energy_wh += energy_increment - def _resolve_battery_direction(self, current_time: float) -> str: - """Determine battery direction from template config or engine state.""" - battery_config = self._template.get("battery_behavior", {}) - if not isinstance(battery_config, dict): - return "unknown" - if not battery_config.get("enabled", True): - return "unknown" - - charge_mode = cast("str", battery_config.get("charge_mode", "custom")) - if charge_mode != "custom": - # Energy system drives BESS power for self-consumption/backup-only; - # direction tracking is not needed for the circuit energy counter. - return "idle" - - current_hour = self._behavior_engine.local_hour(current_time) - charge_hours = cast("list[int]", battery_config.get("charge_hours", [])) - discharge_hours = cast("list[int]", battery_config.get("discharge_hours", [])) - idle_hours = cast("list[int]", battery_config.get("idle_hours", [])) - - if current_hour in charge_hours: - return "charging" - if current_hour in discharge_hours: - return "discharging" - if current_hour in idle_hours: - return "idle" + def _resolve_battery_direction(self, _current_time: float) -> str: + """Determine bidirectional circuit energy direction. + + With the battery circuit removed (BESS is GFE on upstream lugs), + bidirectional circuits are EVSE/V2G — direction is unknown at the + circuit level so energy is conservatively counted as consumption. + """ return "unknown" diff --git a/src/span_panel_simulator/config_types.py b/src/span_panel_simulator/config_types.py index cd3ccaa..ebbcc3d 100644 --- a/src/span_panel_simulator/config_types.py +++ b/src/span_panel_simulator/config_types.py @@ -117,7 +117,6 @@ class CircuitTemplateExtended(CircuitTemplate, total=False): cycling_pattern: CyclingPattern time_of_day_profile: TimeOfDayProfile smart_behavior: SmartBehavior - battery_behavior: dict[str, object] device_type: str # Explicit override: "circuit", "evse", "pv" hvac_type: str # "central_ac", "heat_pump", "heat_pump_aux" monthly_factors: dict[int, float] # month (1-12) -> multiplier (1.0 = peak month) diff --git a/src/span_panel_simulator/history_generator.py b/src/span_panel_simulator/history_generator.py index 88227d3..360edd9 100644 --- a/src/span_panel_simulator/history_generator.py +++ b/src/span_panel_simulator/history_generator.py @@ -326,14 +326,6 @@ def _generate_rows( if total > 0: duty_cycle = int(on_dur) / total - # Battery behavior (BESS schedule) - battery_behavior_raw = template.get("battery_behavior") - battery_behavior: dict[str, object] | None = None - if isinstance(battery_behavior_raw, dict) and bool( - battery_behavior_raw.get("enabled", False) - ): - battery_behavior = battery_behavior_raw - # Active days from time_of_day_profile active_days: list[int] = [] if isinstance(tod_profile, dict): @@ -379,7 +371,6 @@ def _generate_rows( duty_cycle=duty_cycle, active_days=active_days, weather_monthly=weather_monthly, - battery_behavior=battery_behavior, ) # Apply deterministic noise @@ -437,7 +428,6 @@ def _compute_power_at( duty_cycle: float | None, active_days: list[int], weather_monthly: dict[int, float] | None, - battery_behavior: dict[str, object] | None = None, ) -> float: """Compute synthetic power for one time step.""" dt = datetime.fromtimestamp(ts, tz=tz) @@ -448,35 +438,6 @@ def _compute_power_at( if active_days and weekday not in active_days: return 0.0 - # BESS schedule takes priority over consumer/producer logic - if battery_behavior is not None: - charge_hours_raw = battery_behavior.get("charge_hours", []) - discharge_hours_raw = battery_behavior.get("discharge_hours", []) - idle_hours_raw = battery_behavior.get("idle_hours", []) - charge_hours = list(charge_hours_raw) if isinstance(charge_hours_raw, list) else [] - discharge_hours = ( - list(discharge_hours_raw) if isinstance(discharge_hours_raw, list) else [] - ) - idle_hours = list(idle_hours_raw) if isinstance(idle_hours_raw, list) else [] - - if hour in charge_hours: - max_charge = battery_behavior.get("max_charge_power") - if isinstance(max_charge, int | float): - return -float(max_charge) - return -typical_power - - if hour in discharge_hours: - max_discharge = battery_behavior.get("max_discharge_power") - if isinstance(max_discharge, int | float): - return float(max_discharge) - return typical_power - - if hour in idle_hours: - idle_range = battery_behavior.get("idle_power_range") - if isinstance(idle_range, list) and len(idle_range) == 2: - return float(idle_range[0]) - return 0.0 - base = typical_power if mode == "producer": diff --git a/src/span_panel_simulator/profile_applicator.py b/src/span_panel_simulator/profile_applicator.py index 08ae720..4feae56 100644 --- a/src/span_panel_simulator/profile_applicator.py +++ b/src/span_panel_simulator/profile_applicator.py @@ -92,7 +92,7 @@ def apply_usage_profiles( } changed = True - # active_days → time_of_day_profile or battery_behavior + # active_days → time_of_day_profile if "active_days" in profile: raw_days = profile["active_days"] if isinstance(raw_days, list) and raw_days: @@ -101,9 +101,6 @@ def apply_usage_profiles( tod = template.get("time_of_day_profile") if isinstance(tod, dict): tod["active_days"] = days - bb = template.get("battery_behavior") - if isinstance(bb, dict) and bb.get("enabled"): - bb["active_days"] = days changed = True # duty_cycle → cycling_pattern diff --git a/src/span_panel_simulator/validation.py b/src/span_panel_simulator/validation.py index 5931b31..5dee375 100644 --- a/src/span_panel_simulator/validation.py +++ b/src/span_panel_simulator/validation.py @@ -94,23 +94,11 @@ def validate_single_circuit(index: int, circuit: Any, circuit_templates: dict[st raise ValueError(f"Circuit {index} references unknown template '{template_name}'") template = circuit_templates.get(template_name, {}) - is_battery = isinstance(template.get("battery_behavior"), dict) and template[ - "battery_behavior" - ].get("enabled") tabs = circuit.get("tabs", []) if not isinstance(tabs, list): raise ValueError(f"Circuit {index} ('tabs') must be a list") - if is_battery: - # Battery sits between panel lugs and grid — no breaker tabs. - if tabs: - raise ValueError( - f"Circuit {index} ('{circuit.get('name', '')}') is a battery and must not " - f"have tabs assigned. Batteries connect at the panel lugs, not on breakers." - ) - return - # Infrastructure entities (PV, EVSE) may have empty tabs # when added as virtual devices for "what-if" modeling. is_infrastructure = template.get("device_type") in ("pv", "evse") From 9ac56eb4a32e8da9d5cd58c78e377fc1866858e8 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Thu, 2 Apr 2026 07:46:16 -0700 Subject: [PATCH 16/35] Remove dead battery_behavior check from circuit device type derivation --- src/span_panel_simulator/circuit.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/span_panel_simulator/circuit.py b/src/span_panel_simulator/circuit.py index 591b33e..c7ae3f0 100644 --- a/src/span_panel_simulator/circuit.py +++ b/src/span_panel_simulator/circuit.py @@ -231,8 +231,7 @@ def _derive_device_type(self) -> str: """Derive device_type from the template. Checks for an explicit ``device_type`` field first, then falls back - to mode-based detection. Bidirectional circuits with - ``battery_behavior.enabled`` are batteries, not EVSE. + to mode-based detection. """ explicit = self._template.get("device_type") if explicit: @@ -241,9 +240,6 @@ def _derive_device_type(self) -> str: if mode == "producer": return "pv" if mode == "bidirectional": - battery = self._template.get("battery_behavior", {}) - if isinstance(battery, dict) and battery.get("enabled", False): - return "circuit" return "evse" return "circuit" From bc9147a3a0ceaf80deb7f10b477d1c0d1b1411a4 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Thu, 2 Apr 2026 08:06:32 -0700 Subject: [PATCH 17/35] Add dashboard BESS refactor design spec --- ...26-04-02-dashboard-bess-refactor-design.md | 94 +++++++++++++++++++ 1 file changed, 94 insertions(+) create mode 100644 docs/superpowers/specs/2026-04-02-dashboard-bess-refactor-design.md diff --git a/docs/superpowers/specs/2026-04-02-dashboard-bess-refactor-design.md b/docs/superpowers/specs/2026-04-02-dashboard-bess-refactor-design.md new file mode 100644 index 0000000..73db4f0 --- /dev/null +++ b/docs/superpowers/specs/2026-04-02-dashboard-bess-refactor-design.md @@ -0,0 +1,94 @@ +# Dashboard BESS Refactor — Design Spec + +**Date:** 2026-04-02 +**Status:** Draft +**Scope:** Migrate dashboard battery management from circuit-template-based to panel-level BESS config +**Depends on:** BESS circuit removal refactor (complete) + +## Problem + +The engine now reads BESS config from a top-level `bess` YAML section, but the +dashboard still reads/writes `battery_behavior` on circuit templates. This is a +complete data flow mismatch — dashboard edits have no effect on the running +simulation. + +## Approach + +Present BESS as a dedicated panel-level card in the dashboard (Option A from +mockup review). Battery is not an entity — it's a system-level feature of the +panel sitting on the upstream lugs as GFE. The dashboard shows a "Battery (GFE)" +card between Panel Config and the Entity list, with inline stats and edit/schedule +controls. + +## Changes + +### 1. ConfigStore — BESS as Panel-Level Config + +**Remove:** +- `EntityView.battery_behavior` field +- `_detect_entity_type()` check for `battery_behavior.enabled` +- `"battery"` from entity type defaults in `defaults.py` +- Battery entity from `get_entities()` output + +**Add:** +- `get_bess_config() -> dict` — returns `self._state.get("bess", {})` +- `update_bess_config(data: dict)` — writes to `self._state["bess"]` with field + name mapping (`max_charge_power` from form → `max_charge_w` in config) + +**Rewrite to use `self._state["bess"]` instead of circuit template navigation:** +- `get_battery_charge_mode()` — no entity_id param, reads `self._state["bess"]` +- `update_battery_charge_mode(mode)` — no entity_id param, writes `self._state["bess"]` +- `get_battery_profile()` — no entity_id param +- `update_battery_profile(hour_modes)` — no entity_id param +- `get_active_days()` — battery branch reads `self._state["bess"]` +- `update_active_days(days)` — battery branch writes `self._state["bess"]` +- `apply_battery_preset(preset_name)` — no entity_id param + +### 2. Routes and Templates + +**New partial:** `partials/bess_card.html` — dedicated battery card between panel +config and entity list. Shows nameplate, reserve, charge/discharge power, charge +mode, SOC from engine state. "Edit Settings" and "Schedule" buttons. + +**New routes (panel-level, no entity ID):** +- `GET /bess/edit` — returns BESS settings edit form +- `PUT /bess` — saves BESS settings (nameplate, reserve, power limits, charge mode) +- `PUT /bess/schedule` — saves the 24-hour charge/discharge schedule +- `POST /bess/schedule/preset` — applies a schedule preset +- `PUT /bess/active-days` — saves active days + +All new routes call `_persist_config()` after writing (fixing existing bug where +battery profile updates did not persist to YAML). + +**Remove old entity-based battery routes:** +- `PUT /entities/{id}/battery-charge-mode` +- `PUT /entities/{id}/battery-profile` +- `POST /entities/{id}/battery-profile/preset` + +**Form field mapping:** HTML forms use user-facing names (`max_charge_power`, +`max_discharge_power`). Route handlers translate to engine field names +(`max_charge_w`, `max_discharge_w`) when writing to config. + +**Entity list cleanup:** +- Remove `"battery"` from addable entity types dropdown +- Remove battery row handling from entity list template +- Entity count reflects only circuits + PV + EVSE + +**Battery profile editor:** Existing `battery_profile_editor.html` adapted to +work without entity ID. Schedule grid, charge mode radio buttons, and active days +checkboxes remain functionally identical. + +### 3. Energy Projection and Cleanup + +**Energy projection** in `config_store.py`: Read battery specs from +`self._state["bess"]` directly using new field names (`max_charge_w`, +`max_discharge_w`). Battery is not iterated as an entity. + +**Remove from templates:** +- `entity_edit.html`: Remove the `{% if e.battery_behavior %}` fieldset block + +## Out of Scope + +- EVSE two-tab allocation (separate follow-on) +- Charge mode enum changes (dashboard continues to offer `self-consumption`, + `custom`, `backup-only` — engine already accepts these) From a2cb6668bddff6fd8986af68eb00f513e16d3c74 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Thu, 2 Apr 2026 08:14:04 -0700 Subject: [PATCH 18/35] Add dashboard BESS refactor implementation plan --- .../2026-04-02-dashboard-bess-refactor.md | 611 ++++++++++++++++++ 1 file changed, 611 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-02-dashboard-bess-refactor.md diff --git a/docs/superpowers/plans/2026-04-02-dashboard-bess-refactor.md b/docs/superpowers/plans/2026-04-02-dashboard-bess-refactor.md new file mode 100644 index 0000000..dfc06f0 --- /dev/null +++ b/docs/superpowers/plans/2026-04-02-dashboard-bess-refactor.md @@ -0,0 +1,611 @@ +# Dashboard BESS Refactor Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Migrate the dashboard from managing BESS as a circuit entity to a dedicated panel-level card backed by the top-level `bess` YAML section. + +**Architecture:** BESS becomes a dedicated card between panel config and the entity list. ConfigStore reads/writes `self._state["bess"]` directly. All entity-based battery methods lose their `entity_id` parameter. Battery is no longer an entity type — it cannot be added, deleted, or listed alongside circuits. + +**Tech Stack:** Python 3.14, aiohttp, Jinja2, HTMX, pytest + +--- + +### Task 1: Remove battery from entity types and defaults + +**Files:** +- Modify: `src/span_panel_simulator/dashboard/defaults.py:106-129` +- Modify: `src/span_panel_simulator/dashboard/routes.py:77-79` +- Modify: `src/span_panel_simulator/dashboard/config_store.py:54-63,296-299` + +- [ ] **Step 1: Remove `"battery"` from `ENTITY_TYPES` and `_SINGLETON_TYPES`** + +In `src/span_panel_simulator/dashboard/routes.py`, change line 77: + +```python +ENTITY_TYPES = ["circuit", "pv", "evse"] +``` + +And line 79: + +```python +_SINGLETON_TYPES = {"pv"} +``` + +- [ ] **Step 2: Remove battery defaults from `defaults.py`** + +In `src/span_panel_simulator/dashboard/defaults.py`, remove the entire `"battery"` block (lines 106-129): + +```python + "battery": { + "template": { + ... + }, + "circuit": {}, + }, +``` + +Also remove `"battery"` from `default_name_for_type` (line 139). + +- [ ] **Step 3: Remove `battery_behavior` from `_detect_entity_type`** + +In `src/span_panel_simulator/dashboard/config_store.py`, simplify `_detect_entity_type` (lines 54-63): + +```python +def _detect_entity_type(template: dict[str, Any]) -> str: + """Infer entity type from template fields.""" + device_type = template.get("device_type", "") + if device_type == "pv": + return "pv" + if device_type == "evse": + return "evse" + return "circuit" +``` + +- [ ] **Step 4: Remove battery from `list_entities` sort order** + +In `src/span_panel_simulator/dashboard/config_store.py` line 297, update the sort order: + +```python + _type_order = {"pv": 0, "evse": 1, "circuit": 2} +``` + +- [ ] **Step 5: Remove battery exclusion from `get_unmapped_tabs`** + +In `config_store.py` around line 434, the method excludes battery entities from tab counting. Since battery is no longer an entity type, simplify: + +```python + def get_unmapped_tabs(self) -> list[int]: + """Return tab numbers not assigned to any circuit, sorted ascending.""" + total_tabs = self._state.get("panel_config", {}).get("total_tabs", 32) + used: set[int] = set() + for circ in self._circuits(): + used.update(circ.get("tabs", [])) + return sorted(t for t in range(1, total_tabs + 1) if t not in used) +``` + +- [ ] **Step 6: Run type checker and tests** + +Run: `mypy src/span_panel_simulator/dashboard/ && pytest tests/ -q` +Expected: May have errors from route handlers still referencing battery entity methods — that's OK, fixed in later tasks. + +- [ ] **Step 7: Commit** + +``` +git add src/span_panel_simulator/dashboard/defaults.py src/span_panel_simulator/dashboard/routes.py src/span_panel_simulator/dashboard/config_store.py +git commit -m "Remove battery from entity types, defaults, and detection" +``` + +--- + +### Task 2: Rewrite ConfigStore battery methods to use top-level `bess` + +**Files:** +- Modify: `src/span_panel_simulator/dashboard/config_store.py` + +- [ ] **Step 1: Remove `battery_behavior` from `EntityView` and `_merge_entity`** + +In `EntityView` dataclass (line 46), remove: +```python + battery_behavior: dict[str, Any] | None = None +``` + +In `_merge_entity` (line 287), remove: +```python + battery_behavior=template.get("battery_behavior"), +``` + +- [ ] **Step 2: Remove battery keys handling from `update_entity`** + +In `update_entity` (lines 372-382), remove the `battery_keys` block: +```python + battery_keys = ( + "nameplate_capacity_kwh", + "backup_reserve_pct", + "max_charge_power", + "max_discharge_power", + ) + if any(k in data for k in battery_keys): + bb: dict[str, Any] = template.setdefault("battery_behavior", {}) + for k in battery_keys: + if k in data: + bb[k] = float(data[k]) +``` + +Also remove the battery check from tabs handling (line 327): +```python + if "tabs" in data and _detect_entity_type(template) != "battery": +``` +Changes to: +```python + if "tabs" in data: +``` + +- [ ] **Step 3: Add `get_bess_config` and `update_bess_config` methods** + +Add after the panel config section (around line 140): + +```python + # -- BESS config -- + + def get_bess_config(self) -> dict[str, Any]: + """Return the top-level BESS configuration, or empty dict if absent.""" + bess = self._state.get("bess") + return dict(bess) if isinstance(bess, dict) else {} + + def has_bess(self) -> bool: + """Whether a BESS is configured and enabled.""" + bess = self._state.get("bess") + return isinstance(bess, dict) and bool(bess.get("enabled")) + + def update_bess_config(self, data: dict[str, Any]) -> None: + """Update top-level BESS settings from form data. + + Translates form field names to YAML field names: + ``max_charge_power`` → ``max_charge_w``, + ``max_discharge_power`` → ``max_discharge_w``. + """ + bess = self._state.setdefault("bess", {"enabled": True}) + field_map = { + "nameplate_capacity_kwh": "nameplate_capacity_kwh", + "backup_reserve_pct": "backup_reserve_pct", + "max_charge_power": "max_charge_w", + "max_discharge_power": "max_discharge_w", + } + for form_key, yaml_key in field_map.items(): + if form_key in data: + bess[yaml_key] = float(data[form_key]) + self._dirty = True +``` + +- [ ] **Step 4: Rewrite battery charge mode methods** + +Replace existing methods (lines 645-667): + +```python + # -- Battery charge mode -- + + def get_battery_charge_mode(self) -> str: + """Return the BESS charge mode (default ``"self-consumption"``).""" + bess = self.get_bess_config() + return str(bess.get("charge_mode", "self-consumption")) + + def update_battery_charge_mode(self, mode: str) -> None: + """Set the BESS charge mode.""" + valid_modes = ("self-consumption", "custom", "backup-only") + if mode not in valid_modes: + raise ValueError(f"Invalid charge mode: {mode!r}") + bess = self._state.setdefault("bess", {"enabled": True}) + bess["charge_mode"] = mode + self._dirty = True +``` + +- [ ] **Step 5: Rewrite battery profile methods** + +Replace existing methods (lines 671-713): + +```python + # -- Battery profile -- + + def get_battery_profile(self) -> dict[int, str]: + """Return the 24-hour BESS schedule as hour → mode mapping.""" + bess = self.get_bess_config() + charge_hours = set(bess.get("charge_hours", [])) + discharge_hours = set(bess.get("discharge_hours", [])) + + profile: dict[int, str] = {} + for h in range(24): + if h in charge_hours: + profile[h] = "charge" + elif h in discharge_hours: + profile[h] = "discharge" + else: + profile[h] = "idle" + return profile + + def update_battery_profile(self, hour_modes: dict[int, str]) -> None: + """Write per-hour charge/discharge/idle schedule into BESS config.""" + bess = self._state.setdefault("bess", {"enabled": True}) + bess["charge_hours"] = sorted(h for h, m in hour_modes.items() if m == "charge") + bess["discharge_hours"] = sorted(h for h, m in hour_modes.items() if m == "discharge") + self._dirty = True + + def apply_battery_preset(self, preset_name: str) -> dict[int, str]: + """Apply a named battery preset and return the schedule.""" + hour_modes = get_battery_preset(preset_name) + self.update_battery_profile(hour_modes) + self._dirty = True + return hour_modes +``` + +- [ ] **Step 6: Rewrite battery branch in `get_active_days` / `update_active_days`** + +In `get_active_days` (lines 498-511), replace the battery branch: + +```python + def get_bess_active_days(self) -> list[int]: + """Return active weekdays for BESS (empty = all days).""" + bess = self.get_bess_config() + days: list[int] = bess.get("active_days", []) + return [d for d in days if isinstance(d, int) and 0 <= d <= 6] + + def update_bess_active_days(self, days: list[int]) -> None: + """Write active weekdays into BESS config.""" + bess = self._state.setdefault("bess", {"enabled": True}) + clean = sorted(set(d for d in days if 0 <= d <= 6)) + if clean and len(clean) < 7: + bess["active_days"] = clean + else: + bess.pop("active_days", None) + self._dirty = True +``` + +The existing `get_active_days(entity_id)` and `update_active_days(entity_id, days)` methods keep their entity-based signatures but remove the battery branch — they now only handle circuits/EVSE via `time_of_day_profile`. + +- [ ] **Step 7: Update energy projection to read from `bess`** + +In the energy projection method (around line 844), replace the `entity.entity_type == "battery"` branch: + +```python + # Battery from top-level bess config (not an entity) + bess = self.get_bess_config() + if bess.get("enabled"): + charge_p = abs(float(bess.get("max_charge_w") or 3500)) + discharge_p = abs(float(bess.get("max_discharge_w") or 3500)) + charge_hrs: list[int] = bess.get("charge_hours") or [] + discharge_hrs: list[int] = bess.get("discharge_hours") or [] + battery_specs.append((charge_p, discharge_p, charge_hrs, discharge_hrs)) +``` + +Move this outside the entity loop (before or after) since it reads from panel config, not entities. + +- [ ] **Step 8: Run type checker** + +Run: `mypy src/span_panel_simulator/dashboard/config_store.py` +Expected: PASS (routes may still fail — fixed in Task 3) + +- [ ] **Step 9: Commit** + +``` +git add src/span_panel_simulator/dashboard/config_store.py +git commit -m "Rewrite ConfigStore battery methods to use top-level bess config" +``` + +--- + +### Task 3: Create BESS card template and update routes + +**Files:** +- Create: `src/span_panel_simulator/dashboard/templates/partials/bess_card.html` +- Modify: `src/span_panel_simulator/dashboard/templates/dashboard.html` +- Modify: `src/span_panel_simulator/dashboard/templates/partials/entity_edit.html` +- Modify: `src/span_panel_simulator/dashboard/templates/partials/battery_profile_editor.html` +- Modify: `src/span_panel_simulator/dashboard/routes.py` + +- [ ] **Step 1: Create `bess_card.html` partial** + +Create `src/span_panel_simulator/dashboard/templates/partials/bess_card.html`: + +```html +{% if bess_config.enabled is defined and bess_config.enabled %} +
+
+

Battery (GFE) UPSTREAM LUGS

+
+ + {% if bess_editing %} +
+
+ + + + +
+
+ + +
+
+ {% else %} +
+
+ {{ bess_config.nameplate_capacity_kwh | default(13.5) }} kWh + Reserve: {{ bess_config.backup_reserve_pct | default(20) }}% + Charge: {{ bess_config.max_charge_w | default(3500) }}W + Discharge: {{ bess_config.max_discharge_w | default(3500) }}W + Mode: {{ bess_config.charge_mode | default('self-consumption') }} +
+ {% if not readonly %} +
+ + +
+ {% endif %} +
+ {% endif %} +
+{% endif %} +``` + +- [ ] **Step 2: Include BESS card in dashboard layout** + +In `src/span_panel_simulator/dashboard/templates/dashboard.html`, add after the sim-config section (line 34) and before the entity list (line 36): + +```html +
+ {% include "partials/bess_card.html" %} +
+``` + +- [ ] **Step 3: Remove battery fieldset from entity_edit.html** + +In `src/span_panel_simulator/dashboard/templates/partials/entity_edit.html`, remove lines 135-162 (the `{% if e.battery_behavior %}` block). + +- [ ] **Step 4: Update battery_profile_editor.html for panel-level routes** + +In `src/span_panel_simulator/dashboard/templates/partials/battery_profile_editor.html`, replace all entity-based route references: + +- `hx-put="entities/{{ entity.id }}/battery-charge-mode"` → `hx-put="bess/charge-mode"` +- `hx-target="#battery-profile-{{ entity.id }}"` → `hx-target="#bess-card-section"` +- `hx-post="entities/{{ entity.id }}/battery-profile/preset"` → `hx-post="bess/schedule/preset"` +- `hx-put="entities/{{ entity.id }}/active-days"` → `hx-put="bess/active-days"` +- `hx-put="entities/{{ entity.id }}/battery-profile"` → `hx-put="bess/schedule"` +- `id="charge-mode-{{ entity.id }}"` → `id="bess-charge-mode"` +- `id="days-{{ entity.id }}"` → `id="bess-days"` + +Remove all `{{ entity.id }}` references — the template no longer needs an entity context. Keep `battery_profile`, `battery_charge_mode`, `battery_preset_labels`, `battery_active_preset`, and `active_days` context variables. + +- [ ] **Step 5: Add BESS context to `_dashboard_context`** + +In `routes.py`, in `_dashboard_context` (line 165), add BESS config to the context: + +```python + "bess_config": store.get_bess_config(), +``` + +- [ ] **Step 6: Add new BESS route handlers and register routes** + +Replace the old entity-based battery routes with panel-level ones. In `routes.py`: + +```python +def _bess_card_context(request: web.Request, editing: bool = False, schedule: bool = False) -> dict[str, Any]: + """Build the BESS card template context.""" + store = _store(request) + ctx: dict[str, Any] = { + "bess_config": store.get_bess_config(), + "bess_editing": editing, + "readonly": _is_readonly(_ctx(request)), + } + if schedule: + battery_profile = store.get_battery_profile() + ctx["bess_schedule"] = True + ctx["battery_profile"] = battery_profile + ctx["battery_preset_labels"] = _presets(request).battery_labels + ctx["battery_charge_mode"] = store.get_battery_charge_mode() + ctx["battery_active_preset"] = match_battery_preset(battery_profile) + ctx["active_days"] = store.get_bess_active_days() + return ctx + + +async def handle_get_bess(request: web.Request) -> web.Response: + """GET /bess — return BESS card in display mode.""" + return _render("partials/bess_card.html", request, _bess_card_context(request)) + + +async def handle_get_bess_edit(request: web.Request) -> web.Response: + """GET /bess/edit — return BESS card in edit mode.""" + return _render("partials/bess_card.html", request, _bess_card_context(request, editing=True)) + + +async def handle_put_bess(request: web.Request) -> web.Response: + """PUT /bess — save BESS settings.""" + data = await request.post() + _store(request).update_bess_config(dict(data)) + _persist_config(request) + return _render("partials/bess_card.html", request, _bess_card_context(request)) + + +async def handle_get_bess_schedule(request: web.Request) -> web.Response: + """GET /bess/schedule — return BESS card with schedule editor.""" + return _render("partials/bess_card.html", request, _bess_card_context(request, schedule=True)) + + +async def handle_put_bess_schedule(request: web.Request) -> web.Response: + """PUT /bess/schedule — save BESS charge/discharge schedule.""" + data = await request.post() + hour_modes: dict[int, str] = {} + for h in range(24): + key = f"hour_{h}" + mode = str(data.get(key, "idle")) + hour_modes[h] = mode if mode in ("charge", "discharge", "idle") else "idle" + store = _store(request) + store.update_battery_profile(hour_modes) + active = _parse_active_days(data) + if active is not None: + store.update_bess_active_days(active) + _persist_config(request) + return _render("partials/bess_card.html", request, _bess_card_context(request, schedule=True)) + + +async def handle_post_bess_schedule_preset(request: web.Request) -> web.Response: + """POST /bess/schedule/preset — apply a schedule preset.""" + data = await request.post() + preset_name = str(data.get("preset", "custom")) + _store(request).apply_battery_preset(preset_name) + _persist_config(request) + return _render("partials/bess_card.html", request, _bess_card_context(request, schedule=True)) + + +async def handle_put_bess_charge_mode(request: web.Request) -> web.Response: + """PUT /bess/charge-mode — change BESS charge mode.""" + data = await request.post() + mode = str(data.get("charge_mode", "custom")) + _store(request).update_battery_charge_mode(mode) + _persist_config(request) + return _render("partials/bess_card.html", request, _bess_card_context(request, schedule=True)) + + +async def handle_put_bess_active_days(request: web.Request) -> web.Response: + """PUT /bess/active-days — update BESS active days.""" + data = await request.post() + active = _parse_active_days(data) + if active is not None: + _store(request).update_bess_active_days(active) + _persist_config(request) + return _render("partials/bess_card.html", request, _bess_card_context(request, schedule=True)) +``` + +- [ ] **Step 7: Register new routes and remove old ones** + +In the route registration function (around line 506-510), replace: + +```python + # Battery profile + app.router.add_get("/entities/{id}/battery-profile", handle_get_battery_profile) + app.router.add_put("/entities/{id}/battery-profile", handle_put_battery_profile) + app.router.add_post("/entities/{id}/battery-profile/preset", handle_apply_battery_preset) + app.router.add_put("/entities/{id}/battery-charge-mode", handle_put_battery_charge_mode) +``` + +With: + +```python + # BESS (panel-level) + app.router.add_get("/bess", handle_get_bess) + app.router.add_get("/bess/edit", handle_get_bess_edit) + app.router.add_put("/bess", handle_put_bess) + app.router.add_get("/bess/schedule", handle_get_bess_schedule) + app.router.add_put("/bess/schedule", handle_put_bess_schedule) + app.router.add_post("/bess/schedule/preset", handle_post_bess_schedule_preset) + app.router.add_put("/bess/charge-mode", handle_put_bess_charge_mode) + app.router.add_put("/bess/active-days", handle_put_bess_active_days) +``` + +Remove the old handler functions: `handle_get_battery_profile`, `handle_put_battery_profile`, `handle_apply_battery_preset`, `handle_put_battery_charge_mode`. + +Also remove `_battery_profile_context` (lines 257-269) and the battery-specific section from `_entity_list_context` (lines 218-223). + +- [ ] **Step 8: Run type checker and tests** + +Run: `mypy src/span_panel_simulator/dashboard/ && pytest tests/ -q` +Expected: PASS + +- [ ] **Step 9: Commit** + +``` +git add src/span_panel_simulator/dashboard/ +git commit -m "Add BESS card, panel-level routes, remove entity-based battery handling" +``` + +--- + +### Task 4: Update bess_card.html to support schedule view + +**Files:** +- Modify: `src/span_panel_simulator/dashboard/templates/partials/bess_card.html` + +- [ ] **Step 1: Add schedule view to BESS card** + +The BESS card has three states: display, edit (settings), and schedule. Add the schedule state after the edit block and before the display block in `bess_card.html`: + +```html + {% elif bess_schedule is defined and bess_schedule %} +
+ {% include "partials/battery_profile_editor.html" %} +
+ +
+
+``` + +Insert this between `{% if bess_editing %}...{% else %}` — making it `{% elif bess_schedule %}`. + +- [ ] **Step 2: Run manually to verify** + +Start the simulator and verify: +- BESS card appears between sim config and entity list +- "Edit Settings" opens the settings form +- "Schedule" opens the schedule editor +- Save persists to YAML +- Charge mode radio buttons work +- Schedule grid works + +- [ ] **Step 3: Commit** + +``` +git add src/span_panel_simulator/dashboard/templates/partials/bess_card.html +git commit -m "Add schedule view to BESS card" +``` + +--- + +### Task 5: Final verification and cleanup + +- [ ] **Step 1: Run full type check** + +Run: `mypy src/` +Expected: PASS + +- [ ] **Step 2: Run full test suite** + +Run: `pytest tests/ -v` +Expected: All tests pass + +- [ ] **Step 3: Search for orphaned battery_behavior references** + +Run: `grep -rn "battery_behavior" src/ tests/ configs/` + +Expected: No matches in non-dashboard code. Dashboard should have zero remaining references. + +- [ ] **Step 4: Verify entity list no longer shows battery** + +Start the simulator and confirm: +- Entity list count reflects only circuits + PV + EVSE +- "Add Entity" dropdown does not include "Battery" +- BESS card is the only place to manage battery settings + +- [ ] **Step 5: Commit any cleanup** + +``` +git add -A +git commit -m "Final cleanup: remove orphaned dashboard battery_behavior references" +``` From 84bd4a77b87a6f350c4c3e09e13277600551e15c Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Thu, 2 Apr 2026 08:16:22 -0700 Subject: [PATCH 19/35] Remove battery from entity types, defaults, and detection BESS config now lives in the top-level bess YAML section; battery is no longer a circuit entity type. Removes battery from ENTITY_TYPES, _SINGLETON_TYPES, ENTITY_TYPE_DEFAULTS, default_name_for_type, _detect_entity_type, list_entities sort order, update_entity guard, and get_unmapped_tabs. --- .../dashboard/config_store.py | 18 ++++--------- .../dashboard/defaults.py | 25 ------------------- src/span_panel_simulator/dashboard/routes.py | 6 ++--- 3 files changed, 8 insertions(+), 41 deletions(-) diff --git a/src/span_panel_simulator/dashboard/config_store.py b/src/span_panel_simulator/dashboard/config_store.py index 16ebdcb..6642707 100644 --- a/src/span_panel_simulator/dashboard/config_store.py +++ b/src/span_panel_simulator/dashboard/config_store.py @@ -58,8 +58,6 @@ def _detect_entity_type(template: dict[str, Any]) -> str: return "pv" if device_type == "evse": return "evse" - if template.get("battery_behavior", {}).get("enabled"): - return "battery" return "circuit" @@ -293,8 +291,8 @@ def _merge_entity(self, circuit: dict[str, Any]) -> EntityView: ) def list_entities(self) -> list[EntityView]: - """Return entities with infrastructure (pv, battery, evse) first, then circuits.""" - _type_order = {"pv": 0, "battery": 1, "evse": 2, "circuit": 3} + """Return entities with infrastructure (pv, evse) first, then circuits.""" + _type_order = {"pv": 0, "evse": 1, "circuit": 2} entities = [self._merge_entity(c) for c in self._circuits()] entities.sort(key=lambda e: (_type_order.get(e.entity_type, 9), e.name.lower())) return entities @@ -324,7 +322,7 @@ def update_entity(self, entity_id: str, data: dict[str, Any]) -> None: if "name" in data: circuit["name"] = data["name"] - if "tabs" in data and _detect_entity_type(template) != "battery": + if "tabs" in data: tabs_raw = data["tabs"] if isinstance(tabs_raw, str): tabs_raw = [int(t.strip()) for t in tabs_raw.split(",") if t.strip()] @@ -422,17 +420,11 @@ def add_entity(self, entity_type: str) -> EntityView: return self._merge_entity(circuit_dict) def get_unmapped_tabs(self) -> list[int]: - """Return tab numbers not assigned to any circuit, sorted ascending. - - Battery entities are excluded — they sit between the panel lugs - and the grid, not on breaker tabs. - """ + """Return tab numbers not assigned to any circuit, sorted ascending.""" total_tabs = self._state.get("panel_config", {}).get("total_tabs", 32) used: set[int] = set() for circ in self._circuits(): - tpl = self._templates().get(circ.get("template", ""), {}) - if _detect_entity_type(tpl) != "battery": - used.update(circ.get("tabs", [])) + used.update(circ.get("tabs", [])) return sorted(t for t in range(1, total_tabs + 1) if t not in used) def add_entity_from_tabs(self, tabs: list[int]) -> EntityView: diff --git a/src/span_panel_simulator/dashboard/defaults.py b/src/span_panel_simulator/dashboard/defaults.py index d2885e4..1db9a8c 100644 --- a/src/span_panel_simulator/dashboard/defaults.py +++ b/src/span_panel_simulator/dashboard/defaults.py @@ -103,30 +103,6 @@ def _slugify(name: str) -> str: "tabs": [], }, }, - "battery": { - "template": { - "energy_profile": { - "mode": "bidirectional", - "power_range": [-5000.0, 5000.0], - "typical_power": 0.0, - "power_variation": 0.02, - "efficiency": 0.95, - }, - "relay_behavior": "controllable", - "priority": "NEVER", - "battery_behavior": { - "enabled": True, - "charge_mode": "self-consumption", - "nameplate_capacity_kwh": 13.5, - "backup_reserve_pct": 20.0, - "max_charge_power": 5000.0, - "max_discharge_power": 5000.0, - "charge_hours": [], - "discharge_hours": [], - }, - }, - "circuit": {}, - }, } @@ -136,7 +112,6 @@ def default_name_for_type(entity_type: str) -> str: "circuit": "New Circuit", "pv": "Solar Inverter", "evse": "SPAN Drive", - "battery": "Battery Storage", }.get(entity_type, "New Entity") diff --git a/src/span_panel_simulator/dashboard/routes.py b/src/span_panel_simulator/dashboard/routes.py index a990301..e583f04 100644 --- a/src/span_panel_simulator/dashboard/routes.py +++ b/src/span_panel_simulator/dashboard/routes.py @@ -74,15 +74,15 @@ class RecorderPurgeResult: "OFF_GRID", ] RELAY_BEHAVIORS = ["controllable", "non_controllable"] -ENTITY_TYPES = ["circuit", "pv", "evse", "battery"] +ENTITY_TYPES = ["circuit", "pv", "evse"] # Infrastructure types that should only appear once in a panel config. -_SINGLETON_TYPES = {"pv", "battery"} +_SINGLETON_TYPES = {"pv"} def _available_entity_types(store: ConfigStore) -> list[str]: """Return entity types available for adding. - Singleton types (pv, battery) are excluded when one already exists. + Singleton types (pv) are excluded when one already exists. """ existing = {e.entity_type for e in store.list_entities()} return [t for t in ENTITY_TYPES if t not in _SINGLETON_TYPES or t not in existing] From 9f6947aada435cffeb1bbf5ccf8ab5c5cbe99b1f Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Thu, 2 Apr 2026 08:20:26 -0700 Subject: [PATCH 20/35] Rewrite ConfigStore battery methods to use top-level bess config --- .../dashboard/config_store.py | 175 +++++++++--------- src/span_panel_simulator/dashboard/routes.py | 34 ++-- 2 files changed, 99 insertions(+), 110 deletions(-) diff --git a/src/span_panel_simulator/dashboard/config_store.py b/src/span_panel_simulator/dashboard/config_store.py index 6642707..958ea8a 100644 --- a/src/span_panel_simulator/dashboard/config_store.py +++ b/src/span_panel_simulator/dashboard/config_store.py @@ -34,7 +34,7 @@ class EntityView: id: str name: str - entity_type: str # "circuit" | "pv" | "evse" | "battery" + entity_type: str # "circuit" | "pv" | "evse" template_name: str tabs: list[int] energy_profile: dict[str, Any] @@ -43,7 +43,6 @@ class EntityView: cycling_pattern: dict[str, Any] | None = None time_of_day_profile: dict[str, Any] | None = None smart_behavior: dict[str, Any] | None = None - battery_behavior: dict[str, Any] | None = None hvac_type: str | None = None breaker_rating: int | None = None overrides: dict[str, Any] = field(default_factory=dict) @@ -149,6 +148,37 @@ def get_origin_serial(self) -> str | None: return str(origin) if isinstance(origin, str) else None return None + # -- BESS config -- + + def get_bess_config(self) -> dict[str, Any]: + """Return the top-level BESS configuration, or empty dict if absent.""" + bess = self._state.get("bess") + return dict(bess) if isinstance(bess, dict) else {} + + def has_bess(self) -> bool: + """Whether a BESS is configured and enabled.""" + bess = self._state.get("bess") + return isinstance(bess, dict) and bool(bess.get("enabled")) + + def update_bess_config(self, data: dict[str, Any]) -> None: + """Update top-level BESS settings from form data. + + Translates form field names to YAML field names: + ``max_charge_power`` -> ``max_charge_w``, + ``max_discharge_power`` -> ``max_discharge_w``. + """ + bess = self._state.setdefault("bess", {"enabled": True}) + field_map = { + "nameplate_capacity_kwh": "nameplate_capacity_kwh", + "backup_reserve_pct": "backup_reserve_pct", + "max_charge_power": "max_charge_w", + "max_discharge_power": "max_discharge_w", + } + for form_key, yaml_key in field_map.items(): + if form_key in data: + bess[yaml_key] = float(data[form_key]) + self._dirty = True + # -- Simulation params -- def get_simulation_params(self) -> dict[str, Any]: @@ -282,7 +312,6 @@ def _merge_entity(self, circuit: dict[str, Any]) -> EntityView: cycling_pattern=template.get("cycling_pattern"), time_of_day_profile=template.get("time_of_day_profile"), smart_behavior=template.get("smart_behavior"), - battery_behavior=template.get("battery_behavior"), hvac_type=template.get("hvac_type"), breaker_rating=circuit.get("breaker_rating") or template.get("breaker_rating"), overrides=dict(overrides), @@ -367,18 +396,6 @@ def update_entity(self, entity_id: str, data: dict[str, Any]) -> None: else: overrides.pop("power_range", None) - battery_keys = ( - "nameplate_capacity_kwh", - "backup_reserve_pct", - "max_charge_power", - "max_discharge_power", - ) - if any(k in data for k in battery_keys): - bb: dict[str, Any] = template.setdefault("battery_behavior", {}) - for k in battery_keys: - if k in data: - bb[k] = float(data[k]) - if "breaker_rating" in data: br_val = str(data["breaker_rating"]).strip() if br_val: @@ -490,50 +507,50 @@ def delete_entity(self, entity_id: str) -> None: def get_active_days(self, entity_id: str) -> list[int]: """Return active weekdays (0=Mon..6=Sun) for an entity. - Reads from ``time_of_day_profile`` for circuits/EVSE or - ``battery_behavior`` for battery entities. Empty list = all days. + Reads from ``time_of_day_profile``. Empty list = all days. """ entity = self.get_entity(entity_id) - if entity.entity_type == "battery": - bb = entity.battery_behavior or {} - days: list[int] = bb.get("active_days", []) - else: - tod = entity.time_of_day_profile or {} - days = tod.get("active_days", []) + tod = entity.time_of_day_profile or {} + days: list[int] = tod.get("active_days", []) return [d for d in days if isinstance(d, int) and 0 <= d <= 6] def update_active_days(self, entity_id: str, days: list[int]) -> None: - """Write active weekdays into the entity's template. - - Omits the key entirely when all 7 days are selected (backward compat). - """ + """Write active weekdays into the entity's template.""" circuit = self._find_circuit(entity_id) if circuit is None: raise KeyError(f"Entity not found: {entity_id}") template_name = circuit["template"] template = self._templates().get(template_name, {}) - entity = self._merge_entity(circuit) clean = sorted(set(d for d in days if 0 <= d <= 6)) store_value = clean if len(clean) < 7 else [] - if entity.entity_type == "battery": - bb: dict[str, Any] = template.setdefault("battery_behavior", {"enabled": True}) - if store_value: - bb["active_days"] = store_value - else: - bb.pop("active_days", None) + tod: dict[str, Any] = template.setdefault("time_of_day_profile", {"enabled": True}) + if store_value: + tod["active_days"] = store_value else: - tod: dict[str, Any] = template.setdefault("time_of_day_profile", {"enabled": True}) - if store_value: - tod["active_days"] = store_value - else: - tod.pop("active_days", None) + tod.pop("active_days", None) self._mark_user_modified(template_name) self._dirty = True + def get_bess_active_days(self) -> list[int]: + """Return active weekdays for BESS (empty = all days).""" + bess = self.get_bess_config() + days: list[int] = bess.get("active_days", []) + return [d for d in days if isinstance(d, int) and 0 <= d <= 6] + + def update_bess_active_days(self, days: list[int]) -> None: + """Write active weekdays into BESS config.""" + bess = self._state.setdefault("bess", {"enabled": True}) + clean = sorted(set(d for d in days if 0 <= d <= 6)) + if clean and len(clean) < 7: + bess["active_days"] = clean + else: + bess.pop("active_days", None) + self._dirty = True + # -- Profile -- def get_entity_profile(self, entity_id: str) -> dict[int, float]: @@ -634,41 +651,27 @@ def apply_preset( # -- Battery charge mode -- - def get_battery_charge_mode(self, entity_id: str) -> str: - """Return the charge mode for a battery entity (default ``"self-consumption"``).""" - entity = self.get_entity(entity_id) - bb = entity.battery_behavior or {} - return str(bb.get("charge_mode", "self-consumption")) + def get_battery_charge_mode(self) -> str: + """Return the BESS charge mode (default ``"self-consumption"``).""" + bess = self.get_bess_config() + return str(bess.get("charge_mode", "self-consumption")) - def update_battery_charge_mode(self, entity_id: str, mode: str) -> None: - """Set the charge mode on a battery entity's template.""" + def update_battery_charge_mode(self, mode: str) -> None: + """Set the BESS charge mode.""" valid_modes = ("self-consumption", "custom", "backup-only") if mode not in valid_modes: raise ValueError(f"Invalid charge mode: {mode!r}") - - circuit = self._find_circuit(entity_id) - if circuit is None: - raise KeyError(f"Entity not found: {entity_id}") - - template_name = circuit["template"] - template = self._templates().get(template_name, {}) - bb: dict[str, Any] = template.setdefault("battery_behavior", {"enabled": True}) - bb["charge_mode"] = mode - - self._mark_user_modified(template_name) + bess = self._state.setdefault("bess", {"enabled": True}) + bess["charge_mode"] = mode self._dirty = True # -- Battery profile -- - def get_battery_profile(self, entity_id: str) -> dict[int, str]: - """Return the 24-hour battery schedule as hour → mode mapping. - - Mode is one of ``"charge"``, ``"discharge"``, or ``"idle"``. - """ - entity = self.get_entity(entity_id) - bb = entity.battery_behavior or {} - charge_hours = set(bb.get("charge_hours", [])) - discharge_hours = set(bb.get("discharge_hours", [])) + def get_battery_profile(self) -> dict[int, str]: + """Return the 24-hour BESS schedule as hour -> mode mapping.""" + bess = self.get_bess_config() + charge_hours = set(bess.get("charge_hours", [])) + discharge_hours = set(bess.get("discharge_hours", [])) profile: dict[int, str] = {} for h in range(24): @@ -680,27 +683,17 @@ def get_battery_profile(self, entity_id: str) -> dict[int, str]: profile[h] = "idle" return profile - def update_battery_profile(self, entity_id: str, hour_modes: dict[int, str]) -> None: - """Write per-hour charge/discharge/idle schedule into battery_behavior.""" - circuit = self._find_circuit(entity_id) - if circuit is None: - raise KeyError(f"Entity not found: {entity_id}") - - template_name = circuit["template"] - template = self._templates().get(template_name, {}) - bb = template.setdefault("battery_behavior", {"enabled": True}) - - bb["charge_hours"] = sorted(h for h, m in hour_modes.items() if m == "charge") - bb["discharge_hours"] = sorted(h for h, m in hour_modes.items() if m == "discharge") - bb["idle_hours"] = sorted(h for h, m in hour_modes.items() if m == "idle") - - self._mark_user_modified(template_name) + def update_battery_profile(self, hour_modes: dict[int, str]) -> None: + """Write per-hour charge/discharge/idle schedule into BESS config.""" + bess = self._state.setdefault("bess", {"enabled": True}) + bess["charge_hours"] = sorted(h for h, m in hour_modes.items() if m == "charge") + bess["discharge_hours"] = sorted(h for h, m in hour_modes.items() if m == "discharge") self._dirty = True - def apply_battery_preset(self, entity_id: str, preset_name: str) -> dict[int, str]: + def apply_battery_preset(self, preset_name: str) -> dict[int, str]: """Apply a named battery preset and return the schedule.""" hour_modes = get_battery_preset(preset_name) - self.update_battery_profile(entity_id, hour_modes) + self.update_battery_profile(hour_modes) self._dirty = True return hour_modes @@ -823,6 +816,15 @@ def compute_energy_projection(self, period: str = "year") -> list[dict[str, floa pv_specs: list[tuple[float, float]] = [] # (nameplate, efficiency) battery_specs: list[tuple[float, float, list[int], list[int]]] = [] + # Battery from top-level bess config (not an entity) + bess = self.get_bess_config() + if bess.get("enabled"): + charge_p = abs(float(bess.get("max_charge_w") or 3500)) + discharge_p = abs(float(bess.get("max_discharge_w") or 3500)) + charge_hrs: list[int] = bess.get("charge_hours") or [] + discharge_hrs: list[int] = bess.get("discharge_hours") or [] + battery_specs.append((charge_p, discharge_p, charge_hrs, discharge_hrs)) + for entity in entities: ep = entity.energy_profile if entity.entity_type == "pv": @@ -833,13 +835,6 @@ def compute_energy_projection(self, period: str = "year") -> list[dict[str, floa raw_eff = ep.get("efficiency") efficiency = float(raw_eff) if raw_eff is not None else 0.85 pv_specs.append((nameplate, efficiency)) - elif entity.entity_type == "battery": - bb: dict[str, Any] = entity.battery_behavior or {} - charge_p = abs(float(bb.get("max_charge_power") or 3500)) - discharge_p = abs(float(bb.get("max_discharge_power") or 3500)) - charge_hrs: list[int] = bb.get("charge_hours") or [] - discharge_hrs: list[int] = bb.get("discharge_hours") or [] - battery_specs.append((charge_p, discharge_p, charge_hrs, discharge_hrs)) else: profile = self.get_entity_profile(entity.id) typical = float(ep["typical_power"]) diff --git a/src/span_panel_simulator/dashboard/routes.py b/src/span_panel_simulator/dashboard/routes.py index e583f04..cb08826 100644 --- a/src/span_panel_simulator/dashboard/routes.py +++ b/src/span_panel_simulator/dashboard/routes.py @@ -217,9 +217,9 @@ def _entity_list_context(request: web.Request, editing_id: str | None = None) -> ctx["active_days"] = store.get_active_days(editing_id) if entity.entity_type == "battery": ctx["battery_preset_labels"] = _presets(request).battery_labels - battery_profile = store.get_battery_profile(editing_id) + battery_profile = store.get_battery_profile() ctx["battery_profile"] = battery_profile - ctx["battery_charge_mode"] = store.get_battery_charge_mode(editing_id) + ctx["battery_charge_mode"] = store.get_battery_charge_mode() ctx["battery_active_preset"] = match_battery_preset(battery_profile) if entity.entity_type == "pv": panel = store.get_panel_config() @@ -254,18 +254,16 @@ def _profile_context(request: web.Request, entity_id: str) -> dict[str, Any]: } -def _battery_profile_context(request: web.Request, entity_id: str) -> dict[str, Any]: +def _battery_profile_context(request: web.Request) -> dict[str, Any]: """Build the battery profile editor template context.""" store = _store(request) - entity = store.get_entity(entity_id) - battery_profile = store.get_battery_profile(entity_id) + battery_profile = store.get_battery_profile() return { - "entity": entity, "battery_profile": battery_profile, "battery_preset_labels": _presets(request).battery_labels, - "battery_charge_mode": store.get_battery_charge_mode(entity_id), + "battery_charge_mode": store.get_battery_charge_mode(), "battery_active_preset": match_battery_preset(battery_profile), - "active_days": store.get_active_days(entity_id), + "active_days": store.get_bess_active_days(), } @@ -786,16 +784,14 @@ async def handle_apply_preset(request: web.Request) -> web.Response: async def handle_get_battery_profile(request: web.Request) -> web.Response: - entity_id = request.match_info["id"] return _render( "partials/battery_profile_editor.html", request, - _battery_profile_context(request, entity_id), + _battery_profile_context(request), ) async def handle_put_battery_profile(request: web.Request) -> web.Response: - entity_id = request.match_info["id"] data = await request.post() hour_modes: dict[int, str] = {} for h in range(24): @@ -809,38 +805,36 @@ async def handle_put_battery_profile(request: web.Request) -> web.Response: else: hour_modes[h] = "idle" store = _store(request) - store.update_battery_profile(entity_id, hour_modes) + store.update_battery_profile(hour_modes) active = _parse_active_days(data) if active is not None: - store.update_active_days(entity_id, active) + store.update_bess_active_days(active) return _render( "partials/battery_profile_editor.html", request, - _battery_profile_context(request, entity_id), + _battery_profile_context(request), ) async def handle_apply_battery_preset(request: web.Request) -> web.Response: - entity_id = request.match_info["id"] data = await request.post() preset_name = str(data.get("preset", "custom")) - _store(request).apply_battery_preset(entity_id, preset_name) + _store(request).apply_battery_preset(preset_name) return _render( "partials/battery_profile_editor.html", request, - _battery_profile_context(request, entity_id), + _battery_profile_context(request), ) async def handle_put_battery_charge_mode(request: web.Request) -> web.Response: - entity_id = request.match_info["id"] data = await request.post() mode = str(data.get("charge_mode", "custom")) - _store(request).update_battery_charge_mode(entity_id, mode) + _store(request).update_battery_charge_mode(mode) return _render( "partials/battery_profile_editor.html", request, - _battery_profile_context(request, entity_id), + _battery_profile_context(request), ) From 9380124f77e165a82ac6e0cd743af359c980aa2e Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Thu, 2 Apr 2026 08:24:44 -0700 Subject: [PATCH 21/35] Add BESS card, panel-level routes, remove entity-based battery handling - Create partials/bess_card.html with display, edit, and schedule states - Include bess-card-section in dashboard.html after sim-config - Remove battery fieldset and battery profile include from entity_edit.html - Rewrite battery_profile_editor.html to use panel-level BESS routes - Replace _battery_profile_context with _bess_card_context in routes.py - Add bess_config to _dashboard_context - Add BESS route handlers (handle_get_bess, handle_put_bess, etc.) - Replace entity-based battery route registrations with /bess/* routes - Remove entity-type==battery block from _entity_list_context --- src/span_panel_simulator/dashboard/routes.py | 128 ++++++++++-------- .../dashboard/templates/dashboard.html | 4 + .../partials/battery_profile_editor.html | 20 +-- .../templates/partials/bess_card.html | 67 +++++++++ .../templates/partials/entity_edit.html | 33 ----- 5 files changed, 156 insertions(+), 96 deletions(-) create mode 100644 src/span_panel_simulator/dashboard/templates/partials/bess_card.html diff --git a/src/span_panel_simulator/dashboard/routes.py b/src/span_panel_simulator/dashboard/routes.py index cb08826..848da1a 100644 --- a/src/span_panel_simulator/dashboard/routes.py +++ b/src/span_panel_simulator/dashboard/routes.py @@ -182,6 +182,7 @@ def _dashboard_context(request: web.Request) -> dict[str, Any]: "clone_host": panel_source.get("host", "") if panel_source else "", "panels": _all_panels(request), "readonly": _is_readonly(ctx), + "bess_config": store.get_bess_config(), } @@ -215,12 +216,6 @@ def _entity_list_context(request: web.Request, editing_id: str | None = None) -> ctx["relay_behaviors"] = RELAY_BEHAVIORS ctx["preset_labels"] = _presets_for_type(request, entity.entity_type) ctx["active_days"] = store.get_active_days(editing_id) - if entity.entity_type == "battery": - ctx["battery_preset_labels"] = _presets(request).battery_labels - battery_profile = store.get_battery_profile() - ctx["battery_profile"] = battery_profile - ctx["battery_charge_mode"] = store.get_battery_charge_mode() - ctx["battery_active_preset"] = match_battery_preset(battery_profile) if entity.entity_type == "pv": panel = store.get_panel_config() lat = panel.get("latitude", 37.7) @@ -254,17 +249,27 @@ def _profile_context(request: web.Request, entity_id: str) -> dict[str, Any]: } -def _battery_profile_context(request: web.Request) -> dict[str, Any]: - """Build the battery profile editor template context.""" +def _bess_card_context( + request: web.Request, + editing: bool = False, + schedule: bool = False, +) -> dict[str, Any]: + """Build the BESS card template context.""" store = _store(request) - battery_profile = store.get_battery_profile() - return { - "battery_profile": battery_profile, - "battery_preset_labels": _presets(request).battery_labels, - "battery_charge_mode": store.get_battery_charge_mode(), - "battery_active_preset": match_battery_preset(battery_profile), - "active_days": store.get_bess_active_days(), + ctx: dict[str, Any] = { + "bess_config": store.get_bess_config(), + "bess_editing": editing, + "readonly": _is_readonly(_ctx(request)), } + if schedule: + battery_profile = store.get_battery_profile() + ctx["bess_schedule"] = True + ctx["battery_profile"] = battery_profile + ctx["battery_preset_labels"] = _presets(request).battery_labels + ctx["battery_charge_mode"] = store.get_battery_charge_mode() + ctx["battery_active_preset"] = match_battery_preset(battery_profile) + ctx["active_days"] = store.get_bess_active_days() + return ctx async def handle_get_openei_config(request: web.Request) -> web.Response: @@ -501,11 +506,15 @@ def setup_routes(app: web.Application) -> None: # Active days (auto-save on toggle) app.router.add_put("/entities/{id}/active-days", handle_put_active_days) - # Battery profile - app.router.add_get("/entities/{id}/battery-profile", handle_get_battery_profile) - app.router.add_put("/entities/{id}/battery-profile", handle_put_battery_profile) - app.router.add_post("/entities/{id}/battery-profile/preset", handle_apply_battery_preset) - app.router.add_put("/entities/{id}/battery-charge-mode", handle_put_battery_charge_mode) + # BESS (panel-level) + app.router.add_get("/bess", handle_get_bess) + app.router.add_get("/bess/edit", handle_get_bess_edit) + app.router.add_put("/bess", handle_put_bess) + app.router.add_get("/bess/schedule", handle_get_bess_schedule) + app.router.add_put("/bess/schedule", handle_put_bess_schedule) + app.router.add_post("/bess/schedule/preset", handle_post_bess_schedule_preset) + app.router.add_put("/bess/charge-mode", handle_put_bess_charge_mode) + app.router.add_put("/bess/active-days", handle_put_bess_active_days) # EVSE schedule app.router.add_get("/entities/{id}/evse-schedule", handle_get_evse_schedule) @@ -780,62 +789,75 @@ async def handle_apply_preset(request: web.Request) -> web.Response: return _render("partials/profile_editor.html", request, _profile_context(request, entity_id)) -# -- Battery profile -- +# -- BESS (panel-level) -- -async def handle_get_battery_profile(request: web.Request) -> web.Response: - return _render( - "partials/battery_profile_editor.html", - request, - _battery_profile_context(request), - ) +async def handle_get_bess(request: web.Request) -> web.Response: + """GET /bess — return BESS card in display mode.""" + return _render("partials/bess_card.html", request, _bess_card_context(request)) + + +async def handle_get_bess_edit(request: web.Request) -> web.Response: + """GET /bess/edit — return BESS card in edit mode.""" + return _render("partials/bess_card.html", request, _bess_card_context(request, editing=True)) -async def handle_put_battery_profile(request: web.Request) -> web.Response: +async def handle_put_bess(request: web.Request) -> web.Response: + """PUT /bess — save BESS settings.""" + data = await request.post() + _store(request).update_bess_config(dict(data)) + _persist_config(request) + return _render("partials/bess_card.html", request, _bess_card_context(request)) + + +async def handle_get_bess_schedule(request: web.Request) -> web.Response: + """GET /bess/schedule — return BESS card with schedule editor.""" + return _render("partials/bess_card.html", request, _bess_card_context(request, schedule=True)) + + +async def handle_put_bess_schedule(request: web.Request) -> web.Response: + """PUT /bess/schedule — save BESS charge/discharge schedule.""" data = await request.post() hour_modes: dict[int, str] = {} for h in range(24): key = f"hour_{h}" - if key in data: - mode = str(data[key]) - if mode in ("charge", "discharge", "idle"): - hour_modes[h] = mode - else: - hour_modes[h] = "idle" - else: - hour_modes[h] = "idle" + mode = str(data.get(key, "idle")) + hour_modes[h] = mode if mode in ("charge", "discharge", "idle") else "idle" store = _store(request) store.update_battery_profile(hour_modes) active = _parse_active_days(data) if active is not None: store.update_bess_active_days(active) - return _render( - "partials/battery_profile_editor.html", - request, - _battery_profile_context(request), - ) + _persist_config(request) + return _render("partials/bess_card.html", request, _bess_card_context(request, schedule=True)) -async def handle_apply_battery_preset(request: web.Request) -> web.Response: +async def handle_post_bess_schedule_preset(request: web.Request) -> web.Response: + """POST /bess/schedule/preset — apply a schedule preset.""" data = await request.post() preset_name = str(data.get("preset", "custom")) _store(request).apply_battery_preset(preset_name) - return _render( - "partials/battery_profile_editor.html", - request, - _battery_profile_context(request), - ) + _persist_config(request) + return _render("partials/bess_card.html", request, _bess_card_context(request, schedule=True)) -async def handle_put_battery_charge_mode(request: web.Request) -> web.Response: +async def handle_put_bess_charge_mode(request: web.Request) -> web.Response: + """PUT /bess/charge-mode — change BESS charge mode.""" data = await request.post() mode = str(data.get("charge_mode", "custom")) _store(request).update_battery_charge_mode(mode) - return _render( - "partials/battery_profile_editor.html", - request, - _battery_profile_context(request), - ) + _persist_config(request) + return _render("partials/bess_card.html", request, _bess_card_context(request, schedule=True)) + + +async def handle_put_bess_active_days(request: web.Request) -> web.Response: + """PUT /bess/active-days — update BESS active days.""" + data = await request.post() + active = _parse_active_days(data) + if active is not None: + _store(request).update_bess_active_days(active) + _persist_config(request) + return _render("partials/bess_card.html", request, _bess_card_context(request, schedule=True)) # -- EVSE schedule -- diff --git a/src/span_panel_simulator/dashboard/templates/dashboard.html b/src/span_panel_simulator/dashboard/templates/dashboard.html index 0db1896..20acf9c 100644 --- a/src/span_panel_simulator/dashboard/templates/dashboard.html +++ b/src/span_panel_simulator/dashboard/templates/dashboard.html @@ -33,6 +33,10 @@

Getting started

{% include "partials/sim_config.html" %} +
+ {% include "partials/bess_card.html" %} +
+
{% include "partials/entity_list.html" %}
diff --git a/src/span_panel_simulator/dashboard/templates/partials/battery_profile_editor.html b/src/span_panel_simulator/dashboard/templates/partials/battery_profile_editor.html index 5c91d35..c2ea3d4 100644 --- a/src/span_panel_simulator/dashboard/templates/partials/battery_profile_editor.html +++ b/src/span_panel_simulator/dashboard/templates/partials/battery_profile_editor.html @@ -4,15 +4,15 @@

Charge Mode

+ id="bess-charge-mode"> {% for mode, label, hint in [ ("self-consumption", "Self-Consumption", "Discharge to offset grid import, charge from solar excess — always active"), ("custom", "Time-of-Use", "Charge and discharge on a manual hourly schedule"), ("backup-only", "Backup Only", "Holds battery at full charge, discharges only during grid outages"), ] %}
-
+
Active Days {% for d, label in [(0,'Mo'),(1,'Tu'),(2,'We'),(3,'Th'),(4,'Fr'),(5,'Sa'),(6,'Su')] %} {% endfor %}
-
{% for h in range(24) %} diff --git a/src/span_panel_simulator/dashboard/templates/partials/bess_card.html b/src/span_panel_simulator/dashboard/templates/partials/bess_card.html new file mode 100644 index 0000000..d731a87 --- /dev/null +++ b/src/span_panel_simulator/dashboard/templates/partials/bess_card.html @@ -0,0 +1,67 @@ +{% if bess_config and bess_config.enabled is defined and bess_config.enabled %} +
+
+

Battery (GFE) UPSTREAM LUGS

+
+ + {% if bess_editing is defined and bess_editing %} + +
+ + + + +
+
+ + +
+ + + {% elif bess_schedule is defined and bess_schedule %} +
+ {% include "partials/battery_profile_editor.html" %} +
+ +
+
+ + {% else %} +
+
+ {{ bess_config.nameplate_capacity_kwh | default(13.5) }} kWh + Reserve: {{ bess_config.backup_reserve_pct | default(20) }}% + Charge: {{ bess_config.max_charge_w | default(3500) }}W + Discharge: {{ bess_config.max_discharge_w | default(3500) }}W + Mode: {{ bess_config.charge_mode | default('self-consumption') }} +
+ {% if not readonly %} +
+ + +
+ {% endif %} +
+ {% endif %} +
+{% endif %} diff --git a/src/span_panel_simulator/dashboard/templates/partials/entity_edit.html b/src/span_panel_simulator/dashboard/templates/partials/entity_edit.html index 1bbf607..2888672 100644 --- a/src/span_panel_simulator/dashboard/templates/partials/entity_edit.html +++ b/src/span_panel_simulator/dashboard/templates/partials/entity_edit.html @@ -132,35 +132,6 @@

Editing: {{ e.name }}

{% endif %} - {% if e.battery_behavior %} -
- Battery Behavior -
- - - - -
-
- {% endif %} -
+ {% endif %}
{% if bess_editing is defined and bess_editing %} @@ -64,4 +69,17 @@

Battery (GFE) UPSTREAM LUGS

{% endif %}
+ +{% else %} +{# No BESS configured — show add button when editable #} +{% if not readonly %} +
+
+

Battery (GFE)

+ +
+

No battery configured. Add one to model BESS on upstream lugs.

+
+{% endif %} {% endif %} From 2e24995ce629dbfe28c8060ddbf3f08174bf412b Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Thu, 2 Apr 2026 09:03:51 -0700 Subject: [PATCH 23/35] Show schedule view immediately after adding BESS --- src/span_panel_simulator/dashboard/routes.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/span_panel_simulator/dashboard/routes.py b/src/span_panel_simulator/dashboard/routes.py index c08d234..214f738 100644 --- a/src/span_panel_simulator/dashboard/routes.py +++ b/src/span_panel_simulator/dashboard/routes.py @@ -800,10 +800,10 @@ async def handle_get_bess(request: web.Request) -> web.Response: async def handle_post_bess(request: web.Request) -> web.Response: - """POST /bess — add a default BESS configuration.""" + """POST /bess — add a default BESS configuration and show schedule.""" _store(request).add_bess() _persist_config(request) - return _render("partials/bess_card.html", request, _bess_card_context(request)) + return _render("partials/bess_card.html", request, _bess_card_context(request, schedule=True)) async def handle_delete_bess(request: web.Request) -> web.Response: From ccc307dc4a3b7799f1613003babe9388da10d26d Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Thu, 2 Apr 2026 09:09:37 -0700 Subject: [PATCH 24/35] Consolidate BESS settings and schedule into single edit view --- src/span_panel_simulator/dashboard/routes.py | 24 ++--- .../templates/partials/bess_card.html | 87 +++++++++---------- 2 files changed, 54 insertions(+), 57 deletions(-) diff --git a/src/span_panel_simulator/dashboard/routes.py b/src/span_panel_simulator/dashboard/routes.py index 214f738..21aa5a0 100644 --- a/src/span_panel_simulator/dashboard/routes.py +++ b/src/span_panel_simulator/dashboard/routes.py @@ -252,18 +252,20 @@ def _profile_context(request: web.Request, entity_id: str) -> dict[str, Any]: def _bess_card_context( request: web.Request, editing: bool = False, - schedule: bool = False, ) -> dict[str, Any]: - """Build the BESS card template context.""" + """Build the BESS card template context. + + The edit view includes both settings and schedule, so schedule + context is always included when editing. + """ store = _store(request) ctx: dict[str, Any] = { "bess_config": store.get_bess_config(), "bess_editing": editing, "readonly": _is_readonly(_ctx(request)), } - if schedule: + if editing: battery_profile = store.get_battery_profile() - ctx["bess_schedule"] = True ctx["battery_profile"] = battery_profile ctx["battery_preset_labels"] = _presets(request).battery_labels ctx["battery_charge_mode"] = store.get_battery_charge_mode() @@ -800,10 +802,10 @@ async def handle_get_bess(request: web.Request) -> web.Response: async def handle_post_bess(request: web.Request) -> web.Response: - """POST /bess — add a default BESS configuration and show schedule.""" + """POST /bess — add a default BESS configuration and show edit view.""" _store(request).add_bess() _persist_config(request) - return _render("partials/bess_card.html", request, _bess_card_context(request, schedule=True)) + return _render("partials/bess_card.html", request, _bess_card_context(request, editing=True)) async def handle_delete_bess(request: web.Request) -> web.Response: @@ -828,7 +830,7 @@ async def handle_put_bess(request: web.Request) -> web.Response: async def handle_get_bess_schedule(request: web.Request) -> web.Response: """GET /bess/schedule — return BESS card with schedule editor.""" - return _render("partials/bess_card.html", request, _bess_card_context(request, schedule=True)) + return _render("partials/bess_card.html", request, _bess_card_context(request, editing=True)) async def handle_put_bess_schedule(request: web.Request) -> web.Response: @@ -845,7 +847,7 @@ async def handle_put_bess_schedule(request: web.Request) -> web.Response: if active is not None: store.update_bess_active_days(active) _persist_config(request) - return _render("partials/bess_card.html", request, _bess_card_context(request, schedule=True)) + return _render("partials/bess_card.html", request, _bess_card_context(request, editing=True)) async def handle_post_bess_schedule_preset(request: web.Request) -> web.Response: @@ -854,7 +856,7 @@ async def handle_post_bess_schedule_preset(request: web.Request) -> web.Response preset_name = str(data.get("preset", "custom")) _store(request).apply_battery_preset(preset_name) _persist_config(request) - return _render("partials/bess_card.html", request, _bess_card_context(request, schedule=True)) + return _render("partials/bess_card.html", request, _bess_card_context(request, editing=True)) async def handle_put_bess_charge_mode(request: web.Request) -> web.Response: @@ -863,7 +865,7 @@ async def handle_put_bess_charge_mode(request: web.Request) -> web.Response: mode = str(data.get("charge_mode", "custom")) _store(request).update_battery_charge_mode(mode) _persist_config(request) - return _render("partials/bess_card.html", request, _bess_card_context(request, schedule=True)) + return _render("partials/bess_card.html", request, _bess_card_context(request, editing=True)) async def handle_put_bess_active_days(request: web.Request) -> web.Response: @@ -873,7 +875,7 @@ async def handle_put_bess_active_days(request: web.Request) -> web.Response: if active is not None: _store(request).update_bess_active_days(active) _persist_config(request) - return _render("partials/bess_card.html", request, _bess_card_context(request, schedule=True)) + return _render("partials/bess_card.html", request, _bess_card_context(request, editing=True)) # -- EVSE schedule -- diff --git a/src/span_panel_simulator/dashboard/templates/partials/bess_card.html b/src/span_panel_simulator/dashboard/templates/partials/bess_card.html index 9ae7c13..2353ef3 100644 --- a/src/span_panel_simulator/dashboard/templates/partials/bess_card.html +++ b/src/span_panel_simulator/dashboard/templates/partials/bess_card.html @@ -3,50 +3,53 @@

Battery (GFE) UPSTREAM LUGS

{% if not readonly %} - +
+ {% if not bess_editing %} + + {% endif %} + +
{% endif %}
{% if bess_editing is defined and bess_editing %} -
-
- - - - -
-
- - -
-
- - {% elif bess_schedule is defined and bess_schedule %}
+
+
+ + + + +
+
+ + +
+
+ +
+ {% include "partials/battery_profile_editor.html" %} -
- -
{% else %} @@ -58,14 +61,6 @@

Battery (GFE) UPSTREAM LUGS

Discharge: {{ bess_config.max_discharge_w | default(3500) }}W Mode: {{ bess_config.charge_mode | default('self-consumption') }}
- {% if not readonly %} -
- - -
- {% endif %} {% endif %} From af23c048f877c617f5396250e5c9339ef2bbc442 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Thu, 2 Apr 2026 09:14:01 -0700 Subject: [PATCH 25/35] Trigger modeling chart refresh on BESS card changes --- .../dashboard/templates/partials/modeling_view.html | 1 + 1 file changed, 1 insertion(+) diff --git a/src/span_panel_simulator/dashboard/templates/partials/modeling_view.html b/src/span_panel_simulator/dashboard/templates/partials/modeling_view.html index 6294473..65f1409 100644 --- a/src/span_panel_simulator/dashboard/templates/partials/modeling_view.html +++ b/src/span_panel_simulator/dashboard/templates/partials/modeling_view.html @@ -1037,6 +1037,7 @@

Rate Data Source

var tgt = evt.detail.target; if (!tgt) return; var inEntitySection = tgt.id === 'entity-list-section' + || tgt.id === 'bess-card-section' || (tgt.closest && tgt.closest('#entity-list-section')); if (!inEntitySection) return; From 109dfd4859f664162224a1d733cfdd9e16ccb94e Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Thu, 2 Apr 2026 09:24:06 -0700 Subject: [PATCH 26/35] Build modeling Before pass from original clone-time config snapshots --- src/span_panel_simulator/clone.py | 9 +++- src/span_panel_simulator/engine.py | 69 ++++++++++++++++++++++++++---- 2 files changed, 69 insertions(+), 9 deletions(-) diff --git a/src/span_panel_simulator/clone.py b/src/span_panel_simulator/clone.py index 7ee6337..eebdd77 100644 --- a/src/span_panel_simulator/clone.py +++ b/src/span_panel_simulator/clone.py @@ -171,12 +171,19 @@ def translate_scraped_panel( config["bess"] = bess_cfg if host is not None: - config["panel_source"] = { + panel_source: dict[str, object] = { "origin_serial": scraped.serial_number, "host": host, "passphrase": passphrase, "last_synced": datetime.now(UTC).isoformat(), } + # Snapshot the original BESS config so the modeling Before pass + # can reconstruct the clone-time energy system accurately. + if "bess" in config: + import copy + + panel_source["original_bess"] = copy.deepcopy(config["bess"]) + config["panel_source"] = panel_source _LOGGER.info( "Translated panel %s: %d circuits, %d templates, bess=%s, pv=%s, evse=%s", diff --git a/src/span_panel_simulator/engine.py b/src/span_panel_simulator/engine.py index 8d76272..9e763ba 100644 --- a/src/span_panel_simulator/engine.py +++ b/src/span_panel_simulator/engine.py @@ -1291,8 +1291,15 @@ async def compute_modeling_data(self, horizon_hours: int) -> dict[str, Any]: } all_circuit_ids = set(self._circuits.keys()) - # Build energy systems for each pass - before_energy_system = self._build_energy_system(circuit_ids=baseline_circuit_ids) + # Build baseline config from panel_source snapshots so the Before + # pass reflects the original clone state, not user edits. + baseline_config = self._build_baseline_config() + + # Build energy systems for each pass. + before_energy_system = self._build_energy_system( + circuit_ids=baseline_circuit_ids, + baseline_config=baseline_config, + ) after_energy_system = self._build_energy_system() if cloned_behavior is None or after_energy_system is None: @@ -1529,10 +1536,42 @@ def _collect_power_inputs(self) -> PowerInputs: grid_connected=not self._forced_grid_offline, ) + def _build_baseline_config(self) -> dict[str, Any] | None: + """Reconstruct the original config state from panel_source snapshots. + + Returns a dict with ``circuit_templates`` (from recorder_snapshots) + and ``bess`` (from the original clone's bess snapshot) that + ``_build_energy_system`` can use as the baseline for the Before + modeling pass. Returns ``None`` if no panel_source exists + (non-cloned configs have no baseline). + """ + if not self._config: + return None + ps = self._config.get("panel_source") + if not isinstance(ps, dict): + return None + + baseline: dict[str, Any] = {} + + # Original circuit templates (snapshotted at clone/profile-import time) + snapshots = ps.get("recorder_snapshots", {}) + if isinstance(snapshots, dict): + baseline["circuit_templates"] = snapshots + + # Original BESS config (snapshotted at clone time) + original_bess = ps.get("original_bess") + if isinstance(original_bess, dict): + baseline["bess"] = original_bess + # If no original_bess key exists, BESS was not part of the original + # clone — baseline has no bess, which is correct. + + return baseline + def _build_energy_system( self, *, circuit_ids: set[str] | None = None, + baseline_config: dict[str, Any] | None = None, ) -> EnergySystem | None: """Construct an EnergySystem from circuit configuration. @@ -1540,6 +1579,11 @@ def _build_energy_system( in the energy system. This is used for the modeling baseline pass where only recorder-backed circuits existed. When ``None`` (the default), all current circuits are included. + + When *baseline_config* is provided, PV and BESS configuration + are read from this dict instead of the live config. This lets + the modeling Before pass reconstruct the original energy system + as it existed at clone time (before user edits). """ if not self._config: return None @@ -1553,19 +1597,28 @@ def _build_energy_system( grid_config = GridConfig(connected=not self._forced_grid_offline) pv_config: PVConfig | None = None + baseline_templates = ( + baseline_config.get("circuit_templates", {}) if baseline_config is not None else {} + ) for circuit in included.values(): if circuit.energy_mode == "producer": - nameplate = float(circuit.template["energy_profile"]["typical_power"]) - # Dashboard stores inverter type as template priority - # (MUST_HAVE = hybrid, anything else = ac_coupled) - inverter_type = ( - "hybrid" if circuit.template.get("priority") == "MUST_HAVE" else "ac_coupled" + # Use snapshot template if available (Before pass), else live + tpl = ( + baseline_templates.get( + circuit.template_name, + circuit.template, + ) + if baseline_config is not None + else circuit.template ) + nameplate = float(tpl["energy_profile"]["typical_power"]) + inverter_type = "hybrid" if tpl.get("priority") == "MUST_HAVE" else "ac_coupled" pv_config = PVConfig(nameplate_w=abs(nameplate), inverter_type=inverter_type) break bess_config: BESSConfig | None = None - bess_yaml = self._config.get("bess", {}) + config_source = baseline_config if baseline_config is not None else self._config + bess_yaml = config_source.get("bess", {}) if isinstance(bess_yaml, dict) and bess_yaml.get("enabled", False): nameplate = float(bess_yaml.get("nameplate_capacity_kwh", 13.5)) hybrid = pv_config is not None and pv_config.inverter_type == "hybrid" From a2a79b7c6ba33357b0ce6e51532ec323b0684827 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Thu, 2 Apr 2026 09:37:27 -0700 Subject: [PATCH 27/35] Apply default post-solar discharge schedule when switching to TOU mode with empty schedule --- src/span_panel_simulator/dashboard/config_store.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/span_panel_simulator/dashboard/config_store.py b/src/span_panel_simulator/dashboard/config_store.py index f313bd1..35c06d3 100644 --- a/src/span_panel_simulator/dashboard/config_store.py +++ b/src/span_panel_simulator/dashboard/config_store.py @@ -678,12 +678,19 @@ def get_battery_charge_mode(self) -> str: return str(bess.get("charge_mode", "self-consumption")) def update_battery_charge_mode(self, mode: str) -> None: - """Set the BESS charge mode.""" + """Set the BESS charge mode. + + When switching to TOU (``custom``) mode with an empty schedule, + applies the ``post_solar_discharge`` preset as a sensible default. + """ valid_modes = ("self-consumption", "custom", "backup-only") if mode not in valid_modes: raise ValueError(f"Invalid charge mode: {mode!r}") bess = self._state.setdefault("bess", {"enabled": True}) bess["charge_mode"] = mode + # Apply default schedule when switching to TOU with no schedule + if mode == "custom" and not bess.get("charge_hours") and not bess.get("discharge_hours"): + self.apply_battery_preset("post_solar_discharge") self._dirty = True # -- Battery profile -- From 91f104d736f52a7b2937e001570f264abffdf6a8 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Thu, 2 Apr 2026 09:42:25 -0700 Subject: [PATCH 28/35] Derive BESS TOU schedule from active electricity rate periods --- .../dashboard/config_store.py | 14 +++- src/span_panel_simulator/dashboard/routes.py | 21 +++++- src/span_panel_simulator/rates/resolver.py | 68 +++++++++++++++++++ 3 files changed, 98 insertions(+), 5 deletions(-) diff --git a/src/span_panel_simulator/dashboard/config_store.py b/src/span_panel_simulator/dashboard/config_store.py index 35c06d3..9914578 100644 --- a/src/span_panel_simulator/dashboard/config_store.py +++ b/src/span_panel_simulator/dashboard/config_store.py @@ -677,11 +677,16 @@ def get_battery_charge_mode(self) -> str: bess = self.get_bess_config() return str(bess.get("charge_mode", "self-consumption")) - def update_battery_charge_mode(self, mode: str) -> None: + def update_battery_charge_mode( + self, + mode: str, + tou_schedule: dict[int, str] | None = None, + ) -> None: """Set the BESS charge mode. When switching to TOU (``custom``) mode with an empty schedule, - applies the ``post_solar_discharge`` preset as a sensible default. + applies *tou_schedule* if provided (derived from the active rate), + otherwise falls back to the ``post_solar_discharge`` preset. """ valid_modes = ("self-consumption", "custom", "backup-only") if mode not in valid_modes: @@ -690,7 +695,10 @@ def update_battery_charge_mode(self, mode: str) -> None: bess["charge_mode"] = mode # Apply default schedule when switching to TOU with no schedule if mode == "custom" and not bess.get("charge_hours") and not bess.get("discharge_hours"): - self.apply_battery_preset("post_solar_discharge") + if tou_schedule: + self.update_battery_profile(tou_schedule) + else: + self.apply_battery_preset("post_solar_discharge") self._dirty = True # -- Battery profile -- diff --git a/src/span_panel_simulator/dashboard/routes.py b/src/span_panel_simulator/dashboard/routes.py index 21aa5a0..544cb73 100644 --- a/src/span_panel_simulator/dashboard/routes.py +++ b/src/span_panel_simulator/dashboard/routes.py @@ -860,10 +860,27 @@ async def handle_post_bess_schedule_preset(request: web.Request) -> web.Response async def handle_put_bess_charge_mode(request: web.Request) -> web.Response: - """PUT /bess/charge-mode — change BESS charge mode.""" + """PUT /bess/charge-mode — change BESS charge mode. + + When switching to TOU, derives charge/discharge hours from the + active electricity rate schedule if one is configured. + """ data = await request.post() mode = str(data.get("charge_mode", "custom")) - _store(request).update_battery_charge_mode(mode) + + # Derive TOU schedule from the active rate when switching to custom + tou_schedule: dict[int, str] | None = None + if mode == "custom": + cache = _rate_cache(request) + label = cache.get_current_rate_label() + if label: + entry = cache.get_cached_rate(label) + if entry: + from span_panel_simulator.rates.resolver import derive_bess_tou_schedule + + tou_schedule = derive_bess_tou_schedule(entry.record) + + _store(request).update_battery_charge_mode(mode, tou_schedule=tou_schedule) _persist_config(request) return _render("partials/bess_card.html", request, _bess_card_context(request, editing=True)) diff --git a/src/span_panel_simulator/rates/resolver.py b/src/span_panel_simulator/rates/resolver.py index 7c3619b..e60499d 100644 --- a/src/span_panel_simulator/rates/resolver.py +++ b/src/span_panel_simulator/rates/resolver.py @@ -14,6 +14,74 @@ from zoneinfo import ZoneInfo +def derive_bess_tou_schedule( + record: dict[str, Any], + month: int = 6, +) -> dict[int, str]: + """Derive a 24-hour BESS schedule from URDB rate periods. + + Examines the weekday energy schedule for the given month to find + which hours are cheapest (charge) and most expensive (discharge). + Hours at intermediate rates are set to idle. + + Parameters + ---------- + record: + URDB record dict containing energyratestructure and schedule matrices. + month: + 1-based month to derive the schedule for (default: June). + + Returns + ------- + dict mapping hour (0-23) to ``"charge"``, ``"discharge"``, or ``"idle"``. + """ + month_idx = month - 1 + schedule = record.get("energyweekdayschedule", []) + rate_structure = record.get("energyratestructure", []) + + if not schedule or not rate_structure: + return {h: "idle" for h in range(24)} + + if month_idx >= len(schedule): + month_idx = 0 + + # Resolve the rate for each hour + hour_rates: dict[int, float] = {} + for hour in range(24): + period_idx = schedule[month_idx][hour] if hour < len(schedule[month_idx]) else 0 + rate = 0.0 + if period_idx < len(rate_structure): + tiers = rate_structure[period_idx] + if tiers: + rate = tiers[0].get("rate", 0.0) + hour_rates[hour] = rate + + if not hour_rates: + return {h: "idle" for h in range(24)} + + # Identify distinct rate levels + distinct_rates = sorted(set(hour_rates.values())) + + if len(distinct_rates) <= 1: + # Flat rate — no TOU benefit + return {h: "idle" for h in range(24)} + + min_rate = distinct_rates[0] + max_rate = distinct_rates[-1] + + result: dict[int, str] = {} + for hour in range(24): + rate = hour_rates[hour] + if rate == min_rate: + result[hour] = "charge" + elif rate == max_rate: + result[hour] = "discharge" + else: + result[hour] = "idle" + + return result + + def resolve_rate( timestamp: int, tz: str, From 9d8f3890a8abecd8fc209c584baaae2584d08eb7 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Thu, 2 Apr 2026 15:56:15 -0700 Subject: [PATCH 29/35] Rate-aware BESS TOU dispatch from URDB record MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the static hour-list TOU schedule with per-tick dispatch that consults the URDB rate record directly. Off-peak hours charge (solar preferred), peak hours discharge to cover load, and partial-peak hours provide self-consumption — adapting automatically by month, weekday vs weekend, and seasonal rate spread. - Add energy/tou.py with resolve_tou_dispatch and rate lookup helpers - Wire rate_record through BESSConfig → BESSUnit → EnergySystem.tick - Engine loads URDB record from rate cache via rate_label in BESS config - Dashboard derives display schedule from rate record for current month - Remove derive_bess_tou_schedule (replaced by rate-aware dispatch) - Propagate rate label to BESS config on rate plan change in TOU mode - 32 new tests (19 unit + 13 integration), 337 total passing --- .../dashboard/config_store.py | 21 +- src/span_panel_simulator/dashboard/routes.py | 105 ++++- .../partials/battery_profile_editor.html | 12 +- src/span_panel_simulator/energy/components.py | 3 + src/span_panel_simulator/energy/system.py | 44 +- src/span_panel_simulator/energy/tou.py | 181 ++++++++ src/span_panel_simulator/energy/types.py | 2 + src/span_panel_simulator/engine.py | 23 + src/span_panel_simulator/rates/resolver.py | 68 --- tests/test_energy/test_tou.py | 420 ++++++++++++++++++ tests/test_energy/test_tou_integration.py | 313 +++++++++++++ 11 files changed, 1083 insertions(+), 109 deletions(-) create mode 100644 src/span_panel_simulator/energy/tou.py create mode 100644 tests/test_energy/test_tou.py create mode 100644 tests/test_energy/test_tou_integration.py diff --git a/src/span_panel_simulator/dashboard/config_store.py b/src/span_panel_simulator/dashboard/config_store.py index 9914578..3269c1d 100644 --- a/src/span_panel_simulator/dashboard/config_store.py +++ b/src/span_panel_simulator/dashboard/config_store.py @@ -680,25 +680,26 @@ def get_battery_charge_mode(self) -> str: def update_battery_charge_mode( self, mode: str, - tou_schedule: dict[int, str] | None = None, + rate_label: str | None = None, ) -> None: """Set the BESS charge mode. - When switching to TOU (``custom``) mode with an empty schedule, - applies *tou_schedule* if provided (derived from the active rate), - otherwise falls back to the ``post_solar_discharge`` preset. + When *rate_label* is provided and mode is ``custom``, the label + is stored so the energy system resolves the full URDB record for + rate-aware dispatch. Static charge/discharge hour lists are + cleared since the rate record supersedes them. """ valid_modes = ("self-consumption", "custom", "backup-only") if mode not in valid_modes: raise ValueError(f"Invalid charge mode: {mode!r}") bess = self._state.setdefault("bess", {"enabled": True}) bess["charge_mode"] = mode - # Apply default schedule when switching to TOU with no schedule - if mode == "custom" and not bess.get("charge_hours") and not bess.get("discharge_hours"): - if tou_schedule: - self.update_battery_profile(tou_schedule) - else: - self.apply_battery_preset("post_solar_discharge") + if mode == "custom" and rate_label: + bess["rate_label"] = rate_label + bess.pop("charge_hours", None) + bess.pop("discharge_hours", None) + elif mode != "custom": + bess.pop("rate_label", None) self._dirty = True # -- Battery profile -- diff --git a/src/span_panel_simulator/dashboard/routes.py b/src/span_panel_simulator/dashboard/routes.py index 544cb73..f0a6407 100644 --- a/src/span_panel_simulator/dashboard/routes.py +++ b/src/span_panel_simulator/dashboard/routes.py @@ -21,7 +21,8 @@ from span_panel_simulator.dashboard.context import DashboardContext -from datetime import UTC +from datetime import UTC, datetime +from zoneinfo import ZoneInfo from span_panel_simulator.dashboard.keys import ( APP_KEY_DASHBOARD_CONTEXT, @@ -265,15 +266,80 @@ def _bess_card_context( "readonly": _is_readonly(_ctx(request)), } if editing: - battery_profile = store.get_battery_profile() - ctx["battery_profile"] = battery_profile + charge_mode = store.get_battery_charge_mode() + ctx["battery_charge_mode"] = charge_mode + + # When TOU mode has a rate record, derive the display schedule + # from the URDB record for the current month instead of the + # static charge_hours/discharge_hours lists. + rate_profile = _rate_derived_profile(request, store) if charge_mode == "custom" else None + if rate_profile is not None: + battery_profile, month_label = rate_profile + ctx["battery_profile"] = battery_profile + ctx["tou_rate_month"] = month_label + ctx["tou_rate_driven"] = True + else: + battery_profile = store.get_battery_profile() + ctx["battery_profile"] = battery_profile + ctx["tou_rate_driven"] = False + ctx["battery_preset_labels"] = _presets(request).battery_labels - ctx["battery_charge_mode"] = store.get_battery_charge_mode() ctx["battery_active_preset"] = match_battery_preset(battery_profile) ctx["active_days"] = store.get_bess_active_days() return ctx +def _rate_derived_profile( + request: web.Request, + store: ConfigStore, +) -> tuple[dict[int, str], str] | None: + """Derive a display schedule from the URDB rate record for today. + + Returns ``(profile, month_label)`` or ``None`` when no rate record + is available. + """ + bess = store.get_bess_config() + rate_label = bess.get("rate_label") + if not rate_label: + return None + + cache = _rate_cache(request) + entry = cache.get_cached_rate(rate_label) + if entry is None: + return None + + record = entry.record + panel_cfg = store.get_panel_config() + tz_name = panel_cfg.get("time_zone", "America/Los_Angeles") + tz = ZoneInfo(tz_name) + now = datetime.now(tz) + + from span_panel_simulator.energy.tou import _all_rates_for_day + + day_rates = _all_rates_for_day(now, record) + if not day_rates: + return None + + min_rate = min(day_rates.values()) + max_rate = max(day_rates.values()) + + profile: dict[int, str] = {} + for h in range(24): + rate = day_rates.get(h, 0.0) + if min_rate == max_rate: + profile[h] = "idle" + elif rate <= min_rate: + profile[h] = "charge" + elif rate >= max_rate: + profile[h] = "discharge" + else: + profile[h] = "idle" + month_label = now.strftime("%B") + ( + " — Summer rates" if now.month in (6, 7, 8, 9) else " — Winter rates" + ) + return profile, month_label + + async def handle_get_openei_config(request: web.Request) -> web.Response: """GET /rates/openei-config""" config = _rate_cache(request).get_openei_config() @@ -402,12 +468,23 @@ async def handle_get_current_rate(request: web.Request) -> web.Response: async def handle_put_current_rate(request: web.Request) -> web.Response: - """PUT /rates/current {label}""" + """PUT /rates/current {label} + + When the BESS is already in TOU mode, propagates the new rate label + into the BESS config and reloads the engine so dispatch and the + modeling projection reflect the newly selected rate immediately. + """ body = await request.json() label = body.get("label", "").strip() if not label: return web.json_response({"error": "label is required"}, status=400) _rate_cache(request).set_current_rate_label(label) + + store = _store(request) + if store.get_battery_charge_mode() == "custom": + store.update_battery_charge_mode("custom", rate_label=label) + _persist_config(request) + return web.json_response({"ok": True}) @@ -862,25 +939,17 @@ async def handle_post_bess_schedule_preset(request: web.Request) -> web.Response async def handle_put_bess_charge_mode(request: web.Request) -> web.Response: """PUT /bess/charge-mode — change BESS charge mode. - When switching to TOU, derives charge/discharge hours from the - active electricity rate schedule if one is configured. + When switching to TOU with an active rate, stores the rate label so + the energy system resolves dispatch from the URDB record at each tick. """ data = await request.post() mode = str(data.get("charge_mode", "custom")) - # Derive TOU schedule from the active rate when switching to custom - tou_schedule: dict[int, str] | None = None + rate_label: str | None = None if mode == "custom": - cache = _rate_cache(request) - label = cache.get_current_rate_label() - if label: - entry = cache.get_cached_rate(label) - if entry: - from span_panel_simulator.rates.resolver import derive_bess_tou_schedule - - tou_schedule = derive_bess_tou_schedule(entry.record) + rate_label = _rate_cache(request).get_current_rate_label() - _store(request).update_battery_charge_mode(mode, tou_schedule=tou_schedule) + _store(request).update_battery_charge_mode(mode, rate_label=rate_label) _persist_config(request) return _render("partials/bess_card.html", request, _bess_card_context(request, editing=True)) diff --git a/src/span_panel_simulator/dashboard/templates/partials/battery_profile_editor.html b/src/span_panel_simulator/dashboard/templates/partials/battery_profile_editor.html index c2ea3d4..0e3aa78 100644 --- a/src/span_panel_simulator/dashboard/templates/partials/battery_profile_editor.html +++ b/src/span_panel_simulator/dashboard/templates/partials/battery_profile_editor.html @@ -65,6 +65,13 @@

Discharge Preset

{% endfor %} + {% if tou_rate_driven and tou_rate_month is defined %} +

+ Schedule derived from rate plan — {{ tou_rate_month }}. + Dispatch adapts automatically each month. +

+ {% endif %} +
@@ -75,7 +82,8 @@

Discharge Preset