From 733ef4093b18049e1d6b7d6b8f7b912f09a8fa54 Mon Sep 17 00:00:00 2001 From: Stijn van Houwelingen Date: Thu, 19 Feb 2026 16:02:06 +0100 Subject: [PATCH 001/181] refactor: migrate from tox/PyScaffold to uv for dependency management, consolidating all dependencies into pyproject.toml and a single uv.lock file. This simplifies and speeds up the development setup greatly. Changes: - Switch build backend from setuptools to hatchling - Remove setup.cfg, tox.ini, .isort.cfg and setup.py in favour of .flake8 and pyproject.toml - Add Poethepoet tasks - Upgrade main Python version (CI/CD, .python-version, etc.) to 3.12 - Deprecate Python 3.9 - Add .python-version for consistent Python version management - Create separate Dockerfile for flexmeasures-client - Replace pip-tools with uv in all CI/CD workflows - Remove ci/run_mypy.sh in favour of Poethepoet task - Update documentation Signed-off-by: Stijn van Houwelingen --- .flake8 | 10 + .github/agents/test-specialist.md | 50 +- .github/workflows/ci.yml | 172 +-- .github/workflows/release.yml | 69 ++ .gitignore | 1 - .isort.cfg | 3 - .python-version | 1 + Dockerfile | 64 ++ ci/run_mypy.sh | 10 - docker-compose.yml | 12 +- docs/conf.py | 6 +- docs/requirements.txt | 5 - pyproject.toml | 105 +- setup.cfg | 137 --- setup.py | 22 - tox.ini | 99 -- uv.lock | 1653 +++++++++++++++++++++++++++++ 17 files changed, 1985 insertions(+), 434 deletions(-) create mode 100644 .flake8 create mode 100644 .github/workflows/release.yml delete mode 100644 .isort.cfg create mode 100644 .python-version create mode 100644 Dockerfile delete mode 100755 ci/run_mypy.sh delete mode 100644 docs/requirements.txt delete mode 100644 setup.cfg delete mode 100644 setup.py delete mode 100644 tox.ini create mode 100644 uv.lock diff --git a/.flake8 b/.flake8 new file mode 100644 index 00000000..58448390 --- /dev/null +++ b/.flake8 @@ -0,0 +1,10 @@ +[flake8] +max_line_length = 88 +extend_ignore = E203, E501, W503 +# ^ Black-compatible +# E203, E501 and W503 have edge cases handled by black +exclude = + build + dist + .eggs + docs/conf.py diff --git a/.github/agents/test-specialist.md b/.github/agents/test-specialist.md index 7aeb528a..b8bd106f 100644 --- a/.github/agents/test-specialist.md +++ b/.github/agents/test-specialist.md @@ -55,45 +55,71 @@ When writing tests for this project, follow these patterns: ## Code Quality and Linting -Before finalizing tests, always apply the project's code quality checks: +Before finalizing tests, always apply the project's code quality checks. + +### Poe Tasks +The project uses [poethepoet](https://poethepoet.natn.io/) for common tasks. Prefer these over running tools directly: + +```bash +uv run poe lint # Run all pre-commit hooks on all files +uv run poe type-check # Run mypy on files with type hints +uv run poe test # Run the full test suite +uv run poe test-no-s2 # Run tests excluding S2 +uv run poe test-s2 # Run S2 tests only +``` ### Running Pre-commit Hooks -The project uses `.pre-commit-config.yaml` to enforce code quality standards. Always run pre-commit hooks before committing: +The project uses `.pre-commit-config.yaml` to enforce code quality standards. `pre-commit` is included in the `dev` dependency group, so no separate installation is needed: ```bash -# Install pre-commit (if not already installed) -pip install pre-commit +# If you have not installed pre-commit already: +uv tool install pre-commit # Run all pre-commit hooks on all files -pre-commit run --all-files +uv run pre-commit run --all-files + +# Or via the poe task +uv run poe lint ``` ### Pre-commit Hooks in This Project The following hooks are configured: - **trailing-whitespace**: Removes trailing whitespace -- **end-of-file-fixer**: Ensures files end with a newline +- **check-added-large-files**: Prevents committing large files - **check-ast**: Validates Python syntax - **check-json**: Validates JSON files +- **check-merge-conflict**: Detects merge conflict markers +- **check-xml**: Validates XML files - **check-yaml**: Validates YAML files - **debug-statements**: Detects debug statements +- **end-of-file-fixer**: Ensures files end with a newline +- **requirements-txt-fixer**: Sorts requirements files +- **mixed-line-ending**: Normalises line endings - **isort**: Sorts Python imports - **black**: Formats Python code (line length, style) - **flake8**: Checks Python code style and quality -- **mypy**: Performs static type checking + +### Type Checking +Mypy is run separately from pre-commit via the poe task: + +```bash +uv run poe type-check +``` + +When mypy reports errors: +- Add type hints where needed +- Use `# type: ignore` comments sparingly for known issues ### Fixing Linting Issues When pre-commit hooks fail: 1. Review the output to understand what failed -2. Many hooks auto-fix issues (black, isort, end-of-file-fixer) - re-run to verify +2. Many hooks auto-fix issues (black, isort, end-of-file-fixer) — re-run to verify 3. For manual fixes (flake8 errors): - Address unused imports, undefined names, line too long, etc. - Run pre-commit again to verify fixes -4. For mypy type errors: - - Add type hints where needed - - Use `# type: ignore` comments sparingly for known issues ### Best Practices -- Run pre-commit hooks frequently during development +- Run `uv run poe lint` frequently during development - Fix linting issues before requesting code review - Keep test code clean and well-formatted like production code - Ensure all hooks pass before pushing changes diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f936f754..5dd91102 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,23 +1,10 @@ -# GitHub Actions configuration **EXAMPLE**, -# MODIFY IT ACCORDING TO YOUR NEEDS! -# Reference: https://docs.github.com/en/actions - -name: tests +name: lint-and-test on: push: - # Avoid using all the resources/limits available by checking only - # relevant branches and tags. Other branches can be checked via PRs. branches: [main, hotfix/hackathon] - tags: - - 'v[0-9]+\.[0-9]+\.[0-9]+\.dev[0-9]+' # Match tags that resemble a version - - 'v[0-9]+\.[0-9]+\.[0-9]+' # Match tags that resemble a version - pull_request: # Run in every PR - workflow_dispatch: # Allow manually triggering the workflow - schedule: - # Run roughly every 15 days at 00:00 UTC - # (useful to check if updates on dependencies break the package) - - cron: "0 0 1,16 * *" + pull_request: + workflow_dispatch: concurrency: group: >- @@ -26,132 +13,61 @@ concurrency: cancel-in-progress: true jobs: - prepare: + check: runs-on: ubuntu-latest - outputs: - wheel-distribution: ${{ steps.wheel-distribution.outputs.path }} + name: Check (lint) steps: - - uses: actions/checkout@v3 - with: { fetch-depth: 0 } # deep clone for setuptools-scm - - uses: actions/setup-python@v4 - id: setup-python - with: { python-version: "3.11" } - - name: Run static analysis and format checkers - run: pipx run pre-commit run --all-files --show-diff-on-failure - - name: Build package distribution files - run: >- - pipx run --python '${{ steps.setup-python.outputs.python-path }}' - tox -e clean,build - - name: Record the path of wheel distribution - id: wheel-distribution - run: echo "path=$(ls dist/*.whl)" >> $GITHUB_OUTPUT - - name: Store the distribution files for use in other stages - # `tests` and `publish` will use the same pre-built distributions, - # so we make sure to release the exact same package that was tested - uses: actions/upload-artifact@v4 + - uses: actions/checkout@v4 + - name: Install uv + uses: astral-sh/setup-uv@v7 with: - name: python-distribution-files - path: dist/ - retention-days: 1 + enable-cache: true + uv-version: "0.10" + python-version-file: ".python-version" + - uses: pre-commit/action@v3.0.1 test: - needs: prepare + needs: check + runs-on: ubuntu-latest strategy: + fail-fast: false matrix: - python: - - "3.10" - - "3.11" - - "3.12" - platform: - - ubuntu-latest - - macos-latest -# - windows-latest - runs-on: ${{ matrix.platform }} + python: ["3.10", "3.11", "3.12"] + name: "Test (on Python ${{ matrix.python }})" steps: - - uses: actions/checkout@v3 - - uses: actions/setup-python@v4 - id: setup-python + - uses: actions/checkout@v4 with: - python-version: ${{ matrix.python }} - - name: Retrieve pre-built distribution files - uses: actions/download-artifact@v4 + fetch-depth: 0 # needed for hatch-vcs versioning + - name: Install uv + uses: astral-sh/setup-uv@v7 with: - name: python-distribution-files - path: dist/ + enable-cache: true + python-version: ${{ matrix.python }} + - name: Install dependencies + run: uv sync --frozen --group test - name: Run tests - run: >- - pipx run --python '${{ steps.setup-python.outputs.python-path }}' - tox --installpkg '${{ needs.prepare.outputs.wheel-distribution }}' - -- -rFEx --durations 10 --color yes # pytest args - - name: Run tests (s2) - run: >- - pipx run --python '${{ steps.setup-python.outputs.python-path }}' - tox -e s2 --installpkg '${{ needs.prepare.outputs.wheel-distribution }}' - -- -rFEx --durations 10 --color yes # pytest args - # - name: Generate coverage report - # run: pipx run coverage lcov -o coverage.lcov - # - name: Upload partial coverage report - # uses: coverallsapp/github-action@master - # with: - # path-to-lcov: coverage.lcov - # github-token: ${{ secrets.GITHUB_TOKEN }} - # flag-name: ${{ matrix.platform }} - py${{ matrix.python }} - # parallel: true + run: uv run pytest tests --ignore=tests/s2 -rFEx --durations 10 --color yes - finalize: - needs: test + test-s2: + needs: check runs-on: ubuntu-latest + name: "Test S2 (on Python 3.12)" steps: - - run: echo "Finished checks" - - publish: - needs: finalize - if: ${{ github.event_name == 'push' && contains(github.ref, 'refs/tags/v') }} - runs-on: ubuntu-latest - environment: - name: release - url: https://pypi.org/project/flexmeasures-client/ - permissions: - id-token: write - steps: - - uses: actions/checkout@v3 - with: {fetch-depth: 0} # deep clone for setuptools-scm - - uses: actions/setup-python@v4 - with: { python-version: "3.11" } - - name: Retrieve pre-built distribution files - uses: actions/download-artifact@v4 + - uses: actions/checkout@v4 with: - name: python-distribution-files - path: dist/ - - name: Publish Package - uses: pypa/gh-action-pypi-publish@release/v1 - # run: pipx run tox -e publish - - name: Publish release on GitHub - uses: softprops/action-gh-release@v2 + fetch-depth: 0 + - name: Install uv + uses: astral-sh/setup-uv@v7 with: - generate_release_notes: true - tag_name: ${{ inputs.custom_version || github.ref_name }} - env: - GITHUB_TOKEN: ${{ secrets.GH_RELEASE_PAT }} + enable-cache: true + python-version-file: ".python-version" + - name: Install dependencies + run: uv sync --frozen --extra s2 --group test + - name: Run S2 tests + run: uv run pytest tests/s2 -rFEx --durations 10 --color yes - # test-publish: - # needs: finalize - # if: ${{ github.event_name == 'push' && contains(github.ref, 'refs/tags/test') }} - # runs-on: ubuntu-latest - # environment: - # name: testpypi - # url: https://test.pypi.org/project/flexmeasures-client/ - # permissions: - # id-token: write - # steps: - # - uses: actions/checkout@v3 - # - uses: actions/setup-python@v4 - # with: {python-version: "3.11"} - # - name: Retrieve pre-built distribution files - # uses: actions/download-artifact@v3 - # with: {name: python-distribution-files, path: dist/} - # - name: Publish Package - # uses: pypa/gh-action-pypi-publish@release/v1 - # with: - # repository-url: https://test.pypi.org/legacy/ - # # run: pipx run tox -e publish + finalize: + needs: [test, test-s2] + runs-on: ubuntu-latest + steps: + - run: echo "Finished checks" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..857b48b9 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,69 @@ +name: release + +on: + push: + tags: + - 'v[0-9]+\.[0-9]+\.[0-9]+' + - 'v[0-9]+\.[0-9]+\.[0-9]+\.dev[0-9]+' + +permissions: + contents: write + +jobs: + release-build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v7 + with: + uv-version: "0.10" + python-version-file: ".python-version" + enable-cache: true + + - name: Build release distributions + run: uv build + + - name: Upload distributions + uses: actions/upload-artifact@v4 + with: + name: release-dists + path: dist/ + + pypi-publish: + runs-on: ubuntu-latest + needs: release-build + permissions: + # IMPORTANT: this permission is mandatory for trusted publishing + id-token: write + + # Dedicated environments with protections for publishing are strongly recommended. + # For more information, see: https://docs.github.com/en/actions/deployment/targeting-different-environments/using-environments-for-deployment#deployment-protection-rules + environment: + name: pypi + url: https://pypi.org/project/flexmeasures-client/ + + steps: + - name: Retrieve release distributions + uses: actions/download-artifact@v4 + with: + name: release-dists + path: dist/ + + - name: Publish release distributions to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + with: + packages-dir: dist/ + + github-release: + runs-on: ubuntu-latest + needs: pypi-publish + steps: + - name: Publish release on GitHub + uses: softprops/action-gh-release@v2 + with: + generate_release_notes: true + tag_name: ${{ inputs.custom_version || github.ref_name }} + env: + GITHUB_TOKEN: ${{ secrets.GH_RELEASE_PAT }} diff --git a/.gitignore b/.gitignore index be5b7d9e..81a97133 100644 --- a/.gitignore +++ b/.gitignore @@ -51,7 +51,6 @@ MANIFEST # Per-project virtualenvs .venv*/ .conda*/ -.python-version venv/* log diff --git a/.isort.cfg b/.isort.cfg deleted file mode 100644 index 0a105ec9..00000000 --- a/.isort.cfg +++ /dev/null @@ -1,3 +0,0 @@ -[settings] -profile = black -known_first_party = flexmeasures_client diff --git a/.python-version b/.python-version new file mode 100644 index 00000000..e4fba218 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.12 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..862c98a8 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,64 @@ +ARG UV_MAJOR_VERSION=0.10 +ARG PYTHON_VERSION=3.12 +ARG DEBIAN_VERSION=trixie + +# Build the virtual environment using UV +FROM ghcr.io/astral-sh/uv:${UV_MAJOR_VERSION}-python${PYTHON_VERSION}-${DEBIAN_VERSION}-slim AS builder + +# Redeclare ARG after FROM to make it available in this stage +ARG UV_COMPILE_BYTECODE=1 + +ENV LC_ALL=C.UTF-8 +ENV LANG=C.UTF-8 +ENV DEBIAN_FRONTEND=noninteractive +ENV UV_COMPILE_BYTECODE=${UV_COMPILE_BYTECODE} +ENV UV_LINK_MODE=copy + +# Install git for hatch-vcs version detection +RUN apt-get update && apt-get install -y --no-install-recommends \ + git && \ + apt-get clean && \ + rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +# Sync dependencies without installing the project itself (creates .venv) +RUN --mount=type=cache,target=/root/.cache/uv \ + --mount=type=bind,source=uv.lock,target=uv.lock \ + --mount=type=bind,source=pyproject.toml,target=pyproject.toml \ + uv sync --locked --all-extras --no-install-project + +# Ensure subsequent commands use the virtual environment +ENV VIRTUAL_ENV=/app/.venv \ + PATH="/app/.venv/bin:$PATH" + +# Copy application code (including .git for version detection) +COPY pyproject.toml uv.lock README.rst ./ +COPY src/ ./src +COPY .git ./.git + +# Install FlexMeasures itself in the virtual environment +RUN --mount=type=cache,target=/root/.cache/uv \ + uv sync --frozen --all-extras + +# Use a separate runtime image to run the code +FROM python:${PYTHON_VERSION}-slim-${DEBIAN_VERSION} AS runtime + +ENV LC_ALL=C.UTF-8 +ENV LANG=C.UTF-8 +ENV DEBIAN_FRONTEND=noninteractive + +WORKDIR /app + +ENV VIRTUAL_ENV=/app/.venv \ + PATH="/app/.venv/bin:$PATH" + +# Copy virtual environment from builder +COPY --from=builder ${VIRTUAL_ENV} ${VIRTUAL_ENV} + +# Copy application code +COPY --from=builder /app/src ./src + +# Set environment variables to optimize Python +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ No newline at end of file diff --git a/ci/run_mypy.sh b/ci/run_mypy.sh deleted file mode 100755 index e9f9412a..00000000 --- a/ci/run_mypy.sh +++ /dev/null @@ -1,10 +0,0 @@ -#!/bin/bash -set -e -pip install --upgrade 'mypy>=0.902' -pip install types-pytz types-requests types-Flask types-click types-redis types-tzlocal types-python-dateutil types-setuptools types-tabulate types-PyYAML -# We are checking python files which have type hints, and leave out bigger issues we made issues for -# * data/scripts: We'll remove legacy code: https://trello.com/c/1wEnHOkK/7-remove-custom-data-scripts -# * data/models and data/services: https://trello.com/c/rGxZ9h2H/540-makequery-call-signature-is-incoherent - files=$(find src \ - -name \*.py | xargs grep -l "from typing import") -mypy --follow-imports skip --ignore-missing-imports $files diff --git a/docker-compose.yml b/docker-compose.yml index 8068b475..16fbf2a0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -13,8 +13,6 @@ services: build: context: . dockerfile: Dockerfile - depends_on: - - server restart: always ports: - "8080:8080" # aiohttp default; adjust if you change it @@ -26,10 +24,10 @@ services: LOGGING_LEVEL: INFO volumes: # If flexmeasures_client lives in your repo and you want live edits - - ../flexmeasures-client:/app/flexmeasures-client:rw + - flexmeasures-client:/app/flexmeasures-client:rw entrypoint: ["/bin/sh", "-c"] command: - - | - # pip install --break-system-packages --no-cache-dir "git+https://github.com/FlexMeasures/flexmeasures-client.git@main#egg=flexmeasures-client[s2]" - pip install --break-system-packages -e /app/flexmeasures-client[s2] - python3 /app/flexmeasures-client/src/flexmeasures_client/s2/script/websockets_server.py + - python3 /app/src/flexmeasures_client/s2/script/websockets_server.py + +volumes: + flexmeasures-client: \ No newline at end of file diff --git a/docs/conf.py b/docs/conf.py index 3f65d0ca..2742a1d1 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -24,9 +24,9 @@ # This hack is necessary since RTD does not issue `sphinx-apidoc` before running # `sphinx-build -b html . _build/html`. See Issue: # https://github.com/readthedocs/readthedocs.org/issues/1139 -# DON'T FORGET: Check the box "Install your project inside a virtualenv using -# setup.py install" in the RTD Advanced Settings. -# Additionally it helps us to avoid running apidoc manually +# Additionally it helps us to avoid running apidoc manually. +# Docs dependencies are declared in the `docs` dependency group in pyproject.toml. +# Install with: uv sync --group docs try: # for Sphinx >= 1.7 from sphinx.ext import apidoc diff --git a/docs/requirements.txt b/docs/requirements.txt deleted file mode 100644 index 2ddf98af..00000000 --- a/docs/requirements.txt +++ /dev/null @@ -1,5 +0,0 @@ -# Requirements file for ReadTheDocs, check .readthedocs.yml. -# To build the module reference correctly, make sure every external package -# under `install_requires` in `setup.cfg` is also listed here! -sphinx>=3.2.1 -# sphinx_rtd_theme diff --git a/pyproject.toml b/pyproject.toml index 92b887e3..db37c95d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,12 +1,103 @@ [build-system] -# AVOID CHANGING REQUIRES: IT WILL BE UPDATED BY PYSCAFFOLD! -requires = ["setuptools>=46.1.0", "setuptools_scm[toml]>=5"] -build-backend = "setuptools.build_meta" +requires = ["hatchling", "hatch-vcs"] +build-backend = "hatchling.build" -[tool.setuptools_scm] -# For smarter version schemes and other configuration options, -# check out https://github.com/pypa/setuptools_scm -version_scheme = "no-guess-dev" +[project] +name = "flexmeasures-client" +description = "Async client to connect to the FlexMeasures API" +readme = "README.rst" +requires-python = ">=3.10, <3.13" +license = "Apache-2.0" +license-files = ["LICENSE.txt"] +authors = [ + {name = "Flexmeasures", email = "info@seita.nl"} +] +keywords = ["flexmeasures", "energy", "flexibility", "scheduling", "smart grid"] +classifiers = [ + "Development Status :: 4 - Beta", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", +] +dynamic = ["version"] +dependencies = [ + "aiohttp>=3.13.3", + "pandas>=2.1.4", + "async-timeout>=5.0.1", + "packaging>=26.0", +] + +[project.urls] +Homepage = "https://github.com/FlexMeasures/flexmeasures-client" +Documentation = "https://flexmeasures-client.readthedocs.io/" +"Source code" = "https://github.com/FlexMeasures/flexmeasures-client" + +[project.optional-dependencies] +s2 = [ + "requests>=2.32.5", + "s2-python>=0.8.1", + "semver>=3.0.4", + "tzdata>=2025.3", +] + +[dependency-groups] +test = [ + "aioresponses>=0.7.8", + "pytest>=9.0.2", + "pytest-asyncio>=1.3.0", + "pytest-cov>=7.0.0", + "pytest-mock>=3.15.1", +] +dev = [ + "mypy>=0.902", + "poethepoet>=0.41.0", + "pre-commit>=4.5.1", + "types-pytz>=2025.2.0.20251108", + "types-requests>=2.32.4.20260107", +] +docs = [ + "sphinx>=3.2.1", +] + +[tool.hatch.build.targets.wheel] +packages = ["src/flexmeasures_client"] + +[tool.hatch.version] +source = "vcs" + +[tool.poe.tasks.test] +help = "Run the full test suite." +cmd = "pytest" + +[tool.poe.tasks.test-no-s2] +help = "Run the test suite (excluding S2)." +cmd = "pytest --ignore=tests/s2" + +[tool.poe.tasks.test-s2] +help = "Run S2 tests." +cmd = "pytest tests/s2" + +[tool.poe.tasks.type-check] +help = "Run mypy on files with type hints." +shell = """ + files=$(find src -name \\*.py | xargs grep -l "from typing import") + mypy --follow-imports skip --ignore-missing-imports $files +""" + +[tool.poe.tasks.lint] +help = "Run pre-commit hooks on all files." +cmd = "pre-commit run --all-files" + +[tool.pytest] +# https://github.com/zupo/awesome-pytest-speedup?tab=readme-ov-file +testpaths = ["tests"] +addopts = ["--strict-markers", "--import-mode=importlib"] [tool.black] line-length = 88 + +[tool.isort] +profile = "black" +known_first_party = ["flexmeasures_client"] diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index 30a1bbd4..00000000 --- a/setup.cfg +++ /dev/null @@ -1,137 +0,0 @@ -# This file is used to configure your project. -# Read more about the various options under: -# https://setuptools.pypa.io/en/latest/userguide/declarative_config.html -# https://setuptools.pypa.io/en/latest/references/keywords.html - -[metadata] -name = flexmeasures-client -description = Async client to connect to the FlexMeasures API -author = Flexmeasures -author_email = info@seita.nl -license = APACHE -license_files = LICENSE.txt -long_description = file: README.rst -long_description_content_type = text/x-rst; charset=UTF-8 -url = https://github.com/FlexMeasures/flexmeasures-client -# Add here related links, for example: -project_urls = - # Documentation = https://pyscaffold.org/ -# Source = https://github.com/pyscaffold/pyscaffold/ -# Changelog = https://pyscaffold.org/en/latest/changelog.html -# Tracker = https://github.com/pyscaffold/pyscaffold/issues -# Conda-Forge = https://anaconda.org/conda-forge/pyscaffold -# Download = https://pypi.org/project/PyScaffold/#files -# Twitter = https://twitter.com/PyScaffold - -# Change if running only on Windows, Mac or Linux (comma-separated) -platforms = any - -# Add here all kinds of additional classifiers as defined under -# https://pypi.org/classifiers/ -classifiers = - Development Status :: 4 - Beta - Programming Language :: Python - - -[options] -zip_safe = False -packages = find_namespace: -include_package_data = True -package_dir = - =src - -# Require a min/specific Python version (comma-separated conditions) -python_requires >= 3.9 - -# Add here dependencies of your project (line-separated), e.g. requests>=2.2,<3.0. -# Version specifiers like >=2.2,<3.0 avoid problems due to API changes in -# new major versions. This works if the required packages follow Semantic Versioning. -# For more information, check out https://semver.org/. -install_requires = - importlib-metadata; python_version<"3.8" - aiohttp - pandas>=2.1.4 - async_timeout - packaging - -[options.packages.find] -where = src -exclude = - tests - -[options.extras_require] -# Add here additional requirements for extra features, to install with: -# `pip install flexmeasures-client[s2]` like: -s2= - requests - s2-python - semver - tzdata - -# Add here test requirements (semicolon/line-separated) -testing = - setuptools - pytest - pytest-cov - pytest-asyncio - pytest-mock - aioresponses>=0.7.8 - -[options.entry_points] -# Add here console scripts like: -# console_scripts = -# script_name = flexmeasures_client.module:function -# For example: -# console_scripts = -# fibonacci = flexmeasures_client.skeleton:run -# And any other entry points, for example: -# pyscaffold.cli = -# awesome = pyscaffoldext.awesome.extension:AwesomeExtension - - -[tool:pytest] -# Specify command line options as you would do when invoking pytest directly. -# e.g. --cov-report html (or xml) for html/xml output or --junitxml junit.xml -# in order to write a coverage file that can be read by Jenkins. -# CAUTION: --cov flags may prohibit setting breakpoints while debugging. -# Comment those flags to avoid this pytest issue. -# addopts = -# --cov flexmeasures_client --cov-report term-missing -# --verbose -# norecursedirs = -# dist -# build -# .tox -testpaths = tests -# Use pytest markers to select/deselect specific tests -# markers = -# slow: mark tests as slow (deselect with '-m "not slow"') -# system: mark end-to-end system tests - -[devpi:upload] -# Options for the devpi: PyPI server and packaging tool -# VCS export must be deactivated since we are using setuptools-scm -no_vcs = 1 -formats = bdist_wheel - -[flake8] -# Some sane defaults for the code style checker flake8 -max_line_length = 88 -extend_ignore = E203, E501, W503 -# ^ Black-compatible -# E203, E501 and W503 have edge cases handled by black -exclude = - .tox - build - dist - .eggs - docs/conf.py - -[pyscaffold] -# PyScaffold's parameters when the project was created. -# This will be used when updating. Do not change! -version = 4.4 -package = flexmeasures_client -extensions = - github_actions - pre_commit diff --git a/setup.py b/setup.py deleted file mode 100644 index 81744491..00000000 --- a/setup.py +++ /dev/null @@ -1,22 +0,0 @@ -""" -Setup file for flexmeasures-client. -Use setup.cfg to configure your project. - -This file was generated with PyScaffold 4.4. -PyScaffold helps you to put up the scaffold of your new Python project. -Learn more under: https://pyscaffold.org/ -""" - -from setuptools import setup - -if __name__ == "__main__": - try: - setup(use_scm_version={"version_scheme": "no-guess-dev"}) - except: # noqa - print( - "\n\nAn error occurred while building the project, " - "please ensure you have the most updated version of setuptools, " - "setuptools_scm and wheel with:\n" - " pip install -U setuptools setuptools_scm wheel\n\n" - ) - raise diff --git a/tox.ini b/tox.ini deleted file mode 100644 index 443c45e8..00000000 --- a/tox.ini +++ /dev/null @@ -1,99 +0,0 @@ -# Tox configuration file -# Read more under https://tox.wiki/ -# THIS SCRIPT IS SUPPOSED TO BE AN EXAMPLE. MODIFY IT ACCORDING TO YOUR NEEDS! - -[tox] -minversion = 3.24 -envlist = default -isolated_build = True - - -[testenv] -description = Invoke pytest to run automated tests -setenv = - TOXINIDIR = {toxinidir} -passenv = - HOME - SETUPTOOLS_* -extras = - testing -commands = - pytest {posargs} --ignore=tests/s2 - -[testenv:s2] -deps = - .[s2] # Install the package with optional dependencies -commands = pytest tests/s2 - - - -# # To run `tox -e lint` you need to make sure you have a -# # `.pre-commit-config.yaml` file. See https://pre-commit.com -# [testenv:lint] -# description = Perform static analysis and style checks -# skip_install = True -# deps = pre-commit -# passenv = -# HOMEPATH -# PROGRAMDATA -# SETUPTOOLS_* -# commands = -# pre-commit run --all-files {posargs:--show-diff-on-failure} - - -[testenv:{build,clean}] -description = - build: Build the package in isolation according to PEP517, see https://github.com/pypa/build - clean: Remove old distribution files and temporary build artifacts (./build and ./dist) -# https://setuptools.pypa.io/en/stable/build_meta.html#how-to-use-it -skip_install = True -changedir = {toxinidir} -deps = - build: build[virtualenv] -passenv = - SETUPTOOLS_* -commands = - clean: python -c 'import shutil; [shutil.rmtree(p, True) for p in ("build", "dist", "docs/_build")]' - clean: python -c 'import pathlib, shutil; [shutil.rmtree(p, True) for p in pathlib.Path("src").glob("*.egg-info")]' - build: python -m build {posargs} -# By default, both `sdist` and `wheel` are built. If your sdist is too big or you don't want -# to make it available, consider running: `tox -e build -- --wheel` - - -[testenv:{docs,doctests,linkcheck}] -description = - docs: Invoke sphinx-build to build the docs - doctests: Invoke sphinx-build to run doctests - linkcheck: Check for broken links in the documentation -passenv = - SETUPTOOLS_* -setenv = - DOCSDIR = {toxinidir}/docs - BUILDDIR = {toxinidir}/docs/_build - docs: BUILD = html - doctests: BUILD = doctest - linkcheck: BUILD = linkcheck -deps = - -r {toxinidir}/docs/requirements.txt - # ^ requirements.txt shared with Read The Docs -commands = - sphinx-build --color -b {env:BUILD} -d "{env:BUILDDIR}/doctrees" "{env:DOCSDIR}" "{env:BUILDDIR}/{env:BUILD}" {posargs} - - -[testenv:publish] -description = - Publish the package you have been developing to a package index server. - By default, it uses testpypi. If you really want to publish your package - to be publicly accessible in PyPI, use the `-- --repository pypi` option. -skip_install = True -changedir = {toxinidir} -passenv = - # See: https://twine.readthedocs.io/en/latest/ - TWINE_USERNAME - TWINE_PASSWORD - TWINE_REPOSITORY - TWINE_REPOSITORY_URL -deps = twine -commands = - python -m twine check dist/* - python -m twine upload {posargs:--repository {env:TWINE_REPOSITORY:testpypi}} dist/* diff --git a/uv.lock b/uv.lock new file mode 100644 index 00000000..800ff0dc --- /dev/null +++ b/uv.lock @@ -0,0 +1,1653 @@ +version = 1 +revision = 3 +requires-python = ">=3.10, <3.13" +resolution-markers = [ + "python_full_version >= '3.12' and sys_platform == 'win32'", + "python_full_version == '3.11.*' and sys_platform == 'win32'", + "python_full_version >= '3.12' and sys_platform == 'emscripten'", + "python_full_version == '3.11.*' and sys_platform == 'emscripten'", + "python_full_version >= '3.12' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version < '3.11'", +] + +[[package]] +name = "aiohappyeyeballs" +version = "2.6.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/26/30/f84a107a9c4331c14b2b586036f40965c128aa4fee4dda5d3d51cb14ad54/aiohappyeyeballs-2.6.1.tar.gz", hash = "sha256:c3f9d0113123803ccadfdf3f0faa505bc78e6a72d1cc4806cbd719826e943558", size = 22760, upload-time = "2025-03-12T01:42:48.764Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0f/15/5bf3b99495fb160b63f95972b81750f18f7f4e02ad051373b669d17d44f2/aiohappyeyeballs-2.6.1-py3-none-any.whl", hash = "sha256:f349ba8f4b75cb25c99c5c2d84e997e485204d2902a9597802b0371f09331fb8", size = 15265, upload-time = "2025-03-12T01:42:47.083Z" }, +] + +[[package]] +name = "aiohttp" +version = "3.13.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohappyeyeballs" }, + { name = "aiosignal" }, + { name = "async-timeout", marker = "python_full_version < '3.11'" }, + { name = "attrs" }, + { name = "frozenlist" }, + { name = "multidict" }, + { name = "propcache" }, + { name = "yarl" }, +] +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/36/d6/5aec9313ee6ea9c7cde8b891b69f4ff4001416867104580670a31daeba5b/aiohttp-3.13.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d5a372fd5afd301b3a89582817fdcdb6c34124787c70dbcc616f259013e7eef7", size = 738950, upload-time = "2026-01-03T17:29:13.002Z" }, + { url = "https://files.pythonhosted.org/packages/68/03/8fa90a7e6d11ff20a18837a8e2b5dd23db01aabc475aa9271c8ad33299f5/aiohttp-3.13.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:147e422fd1223005c22b4fe080f5d93ced44460f5f9c105406b753612b587821", size = 496099, upload-time = "2026-01-03T17:29:15.268Z" }, + { url = "https://files.pythonhosted.org/packages/d2/23/b81f744d402510a8366b74eb420fc0cc1170d0c43daca12d10814df85f10/aiohttp-3.13.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:859bd3f2156e81dd01432f5849fc73e2243d4a487c4fd26609b1299534ee1845", size = 491072, upload-time = "2026-01-03T17:29:16.922Z" }, + { url = "https://files.pythonhosted.org/packages/d5/e1/56d1d1c0dd334cd203dd97706ce004c1aa24b34a813b0b8daf3383039706/aiohttp-3.13.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dca68018bf48c251ba17c72ed479f4dafe9dbd5a73707ad8d28a38d11f3d42af", size = 1671588, upload-time = "2026-01-03T17:29:18.539Z" }, + { url = "https://files.pythonhosted.org/packages/5f/34/8d7f962604f4bc2b4e39eb1220dac7d4e4cba91fb9ba0474b4ecd67db165/aiohttp-3.13.3-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:fee0c6bc7db1de362252affec009707a17478a00ec69f797d23ca256e36d5940", size = 1640334, upload-time = "2026-01-03T17:29:21.028Z" }, + { url = "https://files.pythonhosted.org/packages/94/1d/fcccf2c668d87337ddeef9881537baee13c58d8f01f12ba8a24215f2b804/aiohttp-3.13.3-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c048058117fd649334d81b4b526e94bde3ccaddb20463a815ced6ecbb7d11160", size = 1722656, upload-time = "2026-01-03T17:29:22.531Z" }, + { url = "https://files.pythonhosted.org/packages/aa/98/c6f3b081c4c606bc1e5f2ec102e87d6411c73a9ef3616fea6f2d5c98c062/aiohttp-3.13.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:215a685b6fbbfcf71dfe96e3eba7a6f58f10da1dfdf4889c7dd856abe430dca7", size = 1817625, upload-time = "2026-01-03T17:29:24.276Z" }, + { url = "https://files.pythonhosted.org/packages/2c/c0/cfcc3d2e11b477f86e1af2863f3858c8850d751ce8dc39c4058a072c9e54/aiohttp-3.13.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de2c184bb1fe2cbd2cefba613e9db29a5ab559323f994b6737e370d3da0ac455", size = 1672604, upload-time = "2026-01-03T17:29:26.099Z" }, + { url = "https://files.pythonhosted.org/packages/1e/77/6b4ffcbcac4c6a5d041343a756f34a6dd26174ae07f977a64fe028dda5b0/aiohttp-3.13.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:75ca857eba4e20ce9f546cd59c7007b33906a4cd48f2ff6ccf1ccfc3b646f279", size = 1554370, upload-time = "2026-01-03T17:29:28.121Z" }, + { url = "https://files.pythonhosted.org/packages/f2/f0/e3ddfa93f17d689dbe014ba048f18e0c9f9b456033b70e94349a2e9048be/aiohttp-3.13.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:81e97251d9298386c2b7dbeb490d3d1badbdc69107fb8c9299dd04eb39bddc0e", size = 1642023, upload-time = "2026-01-03T17:29:30.002Z" }, + { url = "https://files.pythonhosted.org/packages/eb/45/c14019c9ec60a8e243d06d601b33dcc4fd92379424bde3021725859d7f99/aiohttp-3.13.3-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:c0e2d366af265797506f0283487223146af57815b388623f0357ef7eac9b209d", size = 1649680, upload-time = "2026-01-03T17:29:31.782Z" }, + { url = "https://files.pythonhosted.org/packages/9c/fd/09c9451dae5aa5c5ed756df95ff9ef549d45d4be663bafd1e4954fd836f0/aiohttp-3.13.3-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:4e239d501f73d6db1522599e14b9b321a7e3b1de66ce33d53a765d975e9f4808", size = 1692407, upload-time = "2026-01-03T17:29:33.392Z" }, + { url = "https://files.pythonhosted.org/packages/a6/81/938bc2ec33c10efd6637ccb3d22f9f3160d08e8f3aa2587a2c2d5ab578eb/aiohttp-3.13.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:0db318f7a6f065d84cb1e02662c526294450b314a02bd9e2a8e67f0d8564ce40", size = 1543047, upload-time = "2026-01-03T17:29:34.855Z" }, + { url = "https://files.pythonhosted.org/packages/f7/23/80488ee21c8d567c83045e412e1d9b7077d27171591a4eb7822586e8c06a/aiohttp-3.13.3-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:bfc1cc2fe31a6026a8a88e4ecfb98d7f6b1fec150cfd708adbfd1d2f42257c29", size = 1715264, upload-time = "2026-01-03T17:29:36.389Z" }, + { url = "https://files.pythonhosted.org/packages/e2/83/259a8da6683182768200b368120ab3deff5370bed93880fb9a3a86299f34/aiohttp-3.13.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:af71fff7bac6bb7508956696dce8f6eec2bbb045eceb40343944b1ae62b5ef11", size = 1657275, upload-time = "2026-01-03T17:29:38.162Z" }, + { url = "https://files.pythonhosted.org/packages/3f/4f/2c41f800a0b560785c10fb316216ac058c105f9be50bdc6a285de88db625/aiohttp-3.13.3-cp310-cp310-win32.whl", hash = "sha256:37da61e244d1749798c151421602884db5270faf479cf0ef03af0ff68954c9dd", size = 434053, upload-time = "2026-01-03T17:29:40.074Z" }, + { url = "https://files.pythonhosted.org/packages/80/df/29cd63c7ecfdb65ccc12f7d808cac4fa2a19544660c06c61a4a48462de0c/aiohttp-3.13.3-cp310-cp310-win_amd64.whl", hash = "sha256:7e63f210bc1b57ef699035f2b4b6d9ce096b5914414a49b0997c839b2bd2223c", size = 456687, upload-time = "2026-01-03T17:29:41.819Z" }, + { url = "https://files.pythonhosted.org/packages/f1/4c/a164164834f03924d9a29dc3acd9e7ee58f95857e0b467f6d04298594ebb/aiohttp-3.13.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:5b6073099fb654e0a068ae678b10feff95c5cae95bbfcbfa7af669d361a8aa6b", size = 746051, upload-time = "2026-01-03T17:29:43.287Z" }, + { url = "https://files.pythonhosted.org/packages/82/71/d5c31390d18d4f58115037c432b7e0348c60f6f53b727cad33172144a112/aiohttp-3.13.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cb93e166e6c28716c8c6aeb5f99dfb6d5ccf482d29fe9bf9a794110e6d0ab64", size = 499234, upload-time = "2026-01-03T17:29:44.822Z" }, + { url = "https://files.pythonhosted.org/packages/0e/c9/741f8ac91e14b1d2e7100690425a5b2b919a87a5075406582991fb7de920/aiohttp-3.13.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:28e027cf2f6b641693a09f631759b4d9ce9165099d2b5d92af9bd4e197690eea", size = 494979, upload-time = "2026-01-03T17:29:46.405Z" }, + { url = "https://files.pythonhosted.org/packages/75/b5/31d4d2e802dfd59f74ed47eba48869c1c21552c586d5e81a9d0d5c2ad640/aiohttp-3.13.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3b61b7169ababd7802f9568ed96142616a9118dd2be0d1866e920e77ec8fa92a", size = 1748297, upload-time = "2026-01-03T17:29:48.083Z" }, + { url = "https://files.pythonhosted.org/packages/1a/3e/eefad0ad42959f226bb79664826883f2687d602a9ae2941a18e0484a74d3/aiohttp-3.13.3-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:80dd4c21b0f6237676449c6baaa1039abae86b91636b6c91a7f8e61c87f89540", size = 1707172, upload-time = "2026-01-03T17:29:49.648Z" }, + { url = "https://files.pythonhosted.org/packages/c5/3a/54a64299fac2891c346cdcf2aa6803f994a2e4beeaf2e5a09dcc54acc842/aiohttp-3.13.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:65d2ccb7eabee90ce0503c17716fc77226be026dcc3e65cce859a30db715025b", size = 1805405, upload-time = "2026-01-03T17:29:51.244Z" }, + { url = "https://files.pythonhosted.org/packages/6c/70/ddc1b7169cf64075e864f64595a14b147a895a868394a48f6a8031979038/aiohttp-3.13.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5b179331a481cb5529fca8b432d8d3c7001cb217513c94cd72d668d1248688a3", size = 1899449, upload-time = "2026-01-03T17:29:53.938Z" }, + { url = "https://files.pythonhosted.org/packages/a1/7e/6815aab7d3a56610891c76ef79095677b8b5be6646aaf00f69b221765021/aiohttp-3.13.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d4c940f02f49483b18b079d1c27ab948721852b281f8b015c058100e9421dd1", size = 1748444, upload-time = "2026-01-03T17:29:55.484Z" }, + { url = "https://files.pythonhosted.org/packages/6b/f2/073b145c4100da5511f457dc0f7558e99b2987cf72600d42b559db856fbc/aiohttp-3.13.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f9444f105664c4ce47a2a7171a2418bce5b7bae45fb610f4e2c36045d85911d3", size = 1606038, upload-time = "2026-01-03T17:29:57.179Z" }, + { url = "https://files.pythonhosted.org/packages/0a/c1/778d011920cae03ae01424ec202c513dc69243cf2db303965615b81deeea/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:694976222c711d1d00ba131904beb60534f93966562f64440d0c9d41b8cdb440", size = 1724156, upload-time = "2026-01-03T17:29:58.914Z" }, + { url = "https://files.pythonhosted.org/packages/0e/cb/3419eabf4ec1e9ec6f242c32b689248365a1cf621891f6f0386632525494/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:f33ed1a2bf1997a36661874b017f5c4b760f41266341af36febaf271d179f6d7", size = 1722340, upload-time = "2026-01-03T17:30:01.962Z" }, + { url = "https://files.pythonhosted.org/packages/7a/e5/76cf77bdbc435bf233c1f114edad39ed4177ccbfab7c329482b179cff4f4/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e636b3c5f61da31a92bf0d91da83e58fdfa96f178ba682f11d24f31944cdd28c", size = 1783041, upload-time = "2026-01-03T17:30:03.609Z" }, + { url = "https://files.pythonhosted.org/packages/9d/d4/dd1ca234c794fd29c057ce8c0566b8ef7fd6a51069de5f06fa84b9a1971c/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:5d2d94f1f5fcbe40838ac51a6ab5704a6f9ea42e72ceda48de5e6b898521da51", size = 1596024, upload-time = "2026-01-03T17:30:05.132Z" }, + { url = "https://files.pythonhosted.org/packages/55/58/4345b5f26661a6180afa686c473620c30a66afdf120ed3dd545bbc809e85/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:2be0e9ccf23e8a94f6f0650ce06042cefc6ac703d0d7ab6c7a917289f2539ad4", size = 1804590, upload-time = "2026-01-03T17:30:07.135Z" }, + { url = "https://files.pythonhosted.org/packages/7b/06/05950619af6c2df7e0a431d889ba2813c9f0129cec76f663e547a5ad56f2/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9af5e68ee47d6534d36791bbe9b646d2a7c7deb6fc24d7943628edfbb3581f29", size = 1740355, upload-time = "2026-01-03T17:30:09.083Z" }, + { url = "https://files.pythonhosted.org/packages/3e/80/958f16de79ba0422d7c1e284b2abd0c84bc03394fbe631d0a39ffa10e1eb/aiohttp-3.13.3-cp311-cp311-win32.whl", hash = "sha256:a2212ad43c0833a873d0fb3c63fa1bacedd4cf6af2fee62bf4b739ceec3ab239", size = 433701, upload-time = "2026-01-03T17:30:10.869Z" }, + { url = "https://files.pythonhosted.org/packages/dc/f2/27cdf04c9851712d6c1b99df6821a6623c3c9e55956d4b1e318c337b5a48/aiohttp-3.13.3-cp311-cp311-win_amd64.whl", hash = "sha256:642f752c3eb117b105acbd87e2c143de710987e09860d674e068c4c2c441034f", size = 457678, upload-time = "2026-01-03T17:30:12.719Z" }, + { 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" }, +] + +[[package]] +name = "aioresponses" +version = "0.7.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohttp" }, + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/de/03/532bbc645bdebcf3b6af3b25d46655259d66ce69abba7720b71ebfabbade/aioresponses-0.7.8.tar.gz", hash = "sha256:b861cdfe5dc58f3b8afac7b0a6973d5d7b2cb608dd0f6253d16b8ee8eaf6df11", size = 40253, upload-time = "2025-01-19T18:14:03.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/b7/584157e43c98aa89810bc2f7099e7e01c728ecf905a66cf705106009228f/aioresponses-0.7.8-py2.py3-none-any.whl", hash = "sha256:b73bd4400d978855e55004b23a3a84cb0f018183bcf066a85ad392800b5b9a94", size = 12518, upload-time = "2025-01-19T18:13:59.633Z" }, +] + +[[package]] +name = "aiosignal" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "frozenlist" }, + { name = "typing-extensions" }, +] +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 = [ + { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" }, +] + +[[package]] +name = "alabaster" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a6/f8/d9c74d0daf3f742840fd818d69cfae176fa332022fd44e3469487d5a9420/alabaster-1.0.0.tar.gz", hash = "sha256:c00dca57bca26fa62a6d7d0a9fcce65f3e026e9bfe33e9c538fd3fbb2144fd9e", size = 24210, upload-time = "2024-07-26T18:15:03.762Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/b3/6b4067be973ae96ba0d615946e314c5ae35f9f993eca561b356540bb0c2b/alabaster-1.0.0-py3-none-any.whl", hash = "sha256:fc6786402dc3fcb2de3cabd5fe455a2db534b371124f1f21de8731783dec828b", size = 13929, upload-time = "2024-07-26T18:15:02.05Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "async-timeout" +version = "5.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a5/ae/136395dfbfe00dfc94da3f3e136d0b13f394cba8f4841120e34226265780/async_timeout-5.0.1.tar.gz", hash = "sha256:d9321a7a3d5a6a5e187e824d2fa0793ce379a202935782d555d6e9d2735677d3", size = 9274, upload-time = "2024-11-06T16:41:39.6Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fe/ba/e2081de779ca30d473f21f5b30e0e737c438205440784c7dfc81efc2b029/async_timeout-5.0.1-py3-none-any.whl", hash = "sha256:39e3809566ff85354557ec2398b55e096c8364bacac9405a7a1fa429e77fe76c", size = 6233, upload-time = "2024-11-06T16:41:37.9Z" }, +] + +[[package]] +name = "attrs" +version = "25.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6b/5c/685e6633917e101e5dcb62b9dd76946cbb57c26e133bae9e0cd36033c0a9/attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11", size = 934251, upload-time = "2025-10-06T13:54:44.725Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" }, +] + +[[package]] +name = "babel" +version = "2.18.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/b2/51899539b6ceeeb420d40ed3cd4b7a40519404f9baf3d4ac99dc413a834b/babel-2.18.0.tar.gz", hash = "sha256:b80b99a14bd085fcacfa15c9165f651fbb3406e66cc603abf11c5750937c992d", size = 9959554, upload-time = "2026-02-01T12:30:56.078Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/f5/21d2de20e8b8b0408f0681956ca2c69f1320a3848ac50e6e7f39c6159675/babel-2.18.0-py3-none-any.whl", hash = "sha256:e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35", size = 10196845, upload-time = "2026-02-01T12:30:53.445Z" }, +] + +[[package]] +name = "backports-asyncio-runner" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/ff/70dca7d7cb1cbc0edb2c6cc0c38b65cba36cccc491eca64cabd5fe7f8670/backports_asyncio_runner-1.2.0.tar.gz", hash = "sha256:a5aa7b2b7d8f8bfcaa2b57313f70792df84e32a2a746f585213373f900b42162", size = 69893, upload-time = "2025-07-02T02:27:15.685Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/59/76ab57e3fe74484f48a53f8e337171b4a2349e506eabe136d7e01d059086/backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5", size = 12313, upload-time = "2025-07-02T02:27:14.263Z" }, +] + +[[package]] +name = "certifi" +version = "2026.1.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/2d/a891ca51311197f6ad14a7ef42e2399f36cf2f9bd44752b3dc4eab60fdc5/certifi-2026.1.4.tar.gz", hash = "sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120", size = 154268, upload-time = "2026-01-04T02:42:41.825Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e6/ad/3cc14f097111b4de0040c83a525973216457bbeeb63739ef1ed275c1c021/certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c", size = 152900, upload-time = "2026-01-04T02:42:40.15Z" }, +] + +[[package]] +name = "cfgv" +version = "3.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/b5/721b8799b04bf9afe054a3899c6cf4e880fcf8563cc71c15610242490a0c/cfgv-3.5.0.tar.gz", hash = "sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132", size = 7334, upload-time = "2025-11-19T20:55:51.612Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445, upload-time = "2025-11-19T20:55:50.744Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1f/b8/6d51fc1d52cbd52cd4ccedd5b5b2f0f6a11bbf6765c782298b0f3e808541/charset_normalizer-3.4.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e824f1492727fa856dd6eda4f7cee25f8518a12f3c4a56a74e8095695089cf6d", size = 209709, upload-time = "2025-10-14T04:40:11.385Z" }, + { url = "https://files.pythonhosted.org/packages/5c/af/1f9d7f7faafe2ddfb6f72a2e07a548a629c61ad510fe60f9630309908fef/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4bd5d4137d500351a30687c2d3971758aac9a19208fc110ccb9d7188fbe709e8", size = 148814, upload-time = "2025-10-14T04:40:13.135Z" }, + { url = "https://files.pythonhosted.org/packages/79/3d/f2e3ac2bbc056ca0c204298ea4e3d9db9b4afe437812638759db2c976b5f/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:027f6de494925c0ab2a55eab46ae5129951638a49a34d87f4c3eda90f696b4ad", size = 144467, upload-time = "2025-10-14T04:40:14.728Z" }, + { url = "https://files.pythonhosted.org/packages/ec/85/1bf997003815e60d57de7bd972c57dc6950446a3e4ccac43bc3070721856/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f820802628d2694cb7e56db99213f930856014862f3fd943d290ea8438d07ca8", size = 162280, upload-time = "2025-10-14T04:40:16.14Z" }, + { url = "https://files.pythonhosted.org/packages/3e/8e/6aa1952f56b192f54921c436b87f2aaf7c7a7c3d0d1a765547d64fd83c13/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:798d75d81754988d2565bff1b97ba5a44411867c0cf32b77a7e8f8d84796b10d", size = 159454, upload-time = "2025-10-14T04:40:17.567Z" }, + { url = "https://files.pythonhosted.org/packages/36/3b/60cbd1f8e93aa25d1c669c649b7a655b0b5fb4c571858910ea9332678558/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d1bb833febdff5c8927f922386db610b49db6e0d4f4ee29601d71e7c2694313", size = 153609, upload-time = "2025-10-14T04:40:19.08Z" }, + { url = "https://files.pythonhosted.org/packages/64/91/6a13396948b8fd3c4b4fd5bc74d045f5637d78c9675585e8e9fbe5636554/charset_normalizer-3.4.4-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9cd98cdc06614a2f768d2b7286d66805f94c48cde050acdbbb7db2600ab3197e", size = 151849, upload-time = "2025-10-14T04:40:20.607Z" }, + { url = "https://files.pythonhosted.org/packages/b7/7a/59482e28b9981d105691e968c544cc0df3b7d6133152fb3dcdc8f135da7a/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:077fbb858e903c73f6c9db43374fd213b0b6a778106bc7032446a8e8b5b38b93", size = 151586, upload-time = "2025-10-14T04:40:21.719Z" }, + { url = "https://files.pythonhosted.org/packages/92/59/f64ef6a1c4bdd2baf892b04cd78792ed8684fbc48d4c2afe467d96b4df57/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:244bfb999c71b35de57821b8ea746b24e863398194a4014e4c76adc2bbdfeff0", size = 145290, upload-time = "2025-10-14T04:40:23.069Z" }, + { url = "https://files.pythonhosted.org/packages/6b/63/3bf9f279ddfa641ffa1962b0db6a57a9c294361cc2f5fcac997049a00e9c/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:64b55f9dce520635f018f907ff1b0df1fdc31f2795a922fb49dd14fbcdf48c84", size = 163663, upload-time = "2025-10-14T04:40:24.17Z" }, + { url = "https://files.pythonhosted.org/packages/ed/09/c9e38fc8fa9e0849b172b581fd9803bdf6e694041127933934184e19f8c3/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:faa3a41b2b66b6e50f84ae4a68c64fcd0c44355741c6374813a800cd6695db9e", size = 151964, upload-time = "2025-10-14T04:40:25.368Z" }, + { url = "https://files.pythonhosted.org/packages/d2/d1/d28b747e512d0da79d8b6a1ac18b7ab2ecfd81b2944c4c710e166d8dd09c/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6515f3182dbe4ea06ced2d9e8666d97b46ef4c75e326b79bb624110f122551db", size = 161064, upload-time = "2025-10-14T04:40:26.806Z" }, + { url = "https://files.pythonhosted.org/packages/bb/9a/31d62b611d901c3b9e5500c36aab0ff5eb442043fb3a1c254200d3d397d9/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:cc00f04ed596e9dc0da42ed17ac5e596c6ccba999ba6bd92b0e0aef2f170f2d6", size = 155015, upload-time = "2025-10-14T04:40:28.284Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f3/107e008fa2bff0c8b9319584174418e5e5285fef32f79d8ee6a430d0039c/charset_normalizer-3.4.4-cp310-cp310-win32.whl", hash = "sha256:f34be2938726fc13801220747472850852fe6b1ea75869a048d6f896838c896f", size = 99792, upload-time = "2025-10-14T04:40:29.613Z" }, + { url = "https://files.pythonhosted.org/packages/eb/66/e396e8a408843337d7315bab30dbf106c38966f1819f123257f5520f8a96/charset_normalizer-3.4.4-cp310-cp310-win_amd64.whl", hash = "sha256:a61900df84c667873b292c3de315a786dd8dac506704dea57bc957bd31e22c7d", size = 107198, upload-time = "2025-10-14T04:40:30.644Z" }, + { url = "https://files.pythonhosted.org/packages/b5/58/01b4f815bf0312704c267f2ccb6e5d42bcc7752340cd487bc9f8c3710597/charset_normalizer-3.4.4-cp310-cp310-win_arm64.whl", hash = "sha256:cead0978fc57397645f12578bfd2d5ea9138ea0fac82b2f63f7f7c6877986a69", size = 100262, upload-time = "2025-10-14T04:40:32.108Z" }, + { url = "https://files.pythonhosted.org/packages/ed/27/c6491ff4954e58a10f69ad90aca8a1b6fe9c5d3c6f380907af3c37435b59/charset_normalizer-3.4.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6e1fcf0720908f200cd21aa4e6750a48ff6ce4afe7ff5a79a90d5ed8a08296f8", size = 206988, upload-time = "2025-10-14T04:40:33.79Z" }, + { url = "https://files.pythonhosted.org/packages/94/59/2e87300fe67ab820b5428580a53cad894272dbb97f38a7a814a2a1ac1011/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f819d5fe9234f9f82d75bdfa9aef3a3d72c4d24a6e57aeaebba32a704553aa0", size = 147324, upload-time = "2025-10-14T04:40:34.961Z" }, + { url = "https://files.pythonhosted.org/packages/07/fb/0cf61dc84b2b088391830f6274cb57c82e4da8bbc2efeac8c025edb88772/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a59cb51917aa591b1c4e6a43c132f0cdc3c76dbad6155df4e28ee626cc77a0a3", size = 142742, upload-time = "2025-10-14T04:40:36.105Z" }, + { url = "https://files.pythonhosted.org/packages/62/8b/171935adf2312cd745d290ed93cf16cf0dfe320863ab7cbeeae1dcd6535f/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ef3c867360f88ac904fd3f5e1f902f13307af9052646963ee08ff4f131adafc", size = 160863, upload-time = "2025-10-14T04:40:37.188Z" }, + { url = "https://files.pythonhosted.org/packages/09/73/ad875b192bda14f2173bfc1bc9a55e009808484a4b256748d931b6948442/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d9e45d7faa48ee908174d8fe84854479ef838fc6a705c9315372eacbc2f02897", size = 157837, upload-time = "2025-10-14T04:40:38.435Z" }, + { url = "https://files.pythonhosted.org/packages/6d/fc/de9cce525b2c5b94b47c70a4b4fb19f871b24995c728e957ee68ab1671ea/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:840c25fb618a231545cbab0564a799f101b63b9901f2569faecd6b222ac72381", size = 151550, upload-time = "2025-10-14T04:40:40.053Z" }, + { url = "https://files.pythonhosted.org/packages/55/c2/43edd615fdfba8c6f2dfbd459b25a6b3b551f24ea21981e23fb768503ce1/charset_normalizer-3.4.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ca5862d5b3928c4940729dacc329aa9102900382fea192fc5e52eb69d6093815", size = 149162, upload-time = "2025-10-14T04:40:41.163Z" }, + { url = "https://files.pythonhosted.org/packages/03/86/bde4ad8b4d0e9429a4e82c1e8f5c659993a9a863ad62c7df05cf7b678d75/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9c7f57c3d666a53421049053eaacdd14bbd0a528e2186fcb2e672effd053bb0", size = 150019, upload-time = "2025-10-14T04:40:42.276Z" }, + { url = "https://files.pythonhosted.org/packages/1f/86/a151eb2af293a7e7bac3a739b81072585ce36ccfb4493039f49f1d3cae8c/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:277e970e750505ed74c832b4bf75dac7476262ee2a013f5574dd49075879e161", size = 143310, upload-time = "2025-10-14T04:40:43.439Z" }, + { url = "https://files.pythonhosted.org/packages/b5/fe/43dae6144a7e07b87478fdfc4dbe9efd5defb0e7ec29f5f58a55aeef7bf7/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:31fd66405eaf47bb62e8cd575dc621c56c668f27d46a61d975a249930dd5e2a4", size = 162022, upload-time = "2025-10-14T04:40:44.547Z" }, + { url = "https://files.pythonhosted.org/packages/80/e6/7aab83774f5d2bca81f42ac58d04caf44f0cc2b65fc6db2b3b2e8a05f3b3/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:0d3d8f15c07f86e9ff82319b3d9ef6f4bf907608f53fe9d92b28ea9ae3d1fd89", size = 149383, upload-time = "2025-10-14T04:40:46.018Z" }, + { url = "https://files.pythonhosted.org/packages/4f/e8/b289173b4edae05c0dde07f69f8db476a0b511eac556dfe0d6bda3c43384/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:9f7fcd74d410a36883701fafa2482a6af2ff5ba96b9a620e9e0721e28ead5569", size = 159098, upload-time = "2025-10-14T04:40:47.081Z" }, + { url = "https://files.pythonhosted.org/packages/d8/df/fe699727754cae3f8478493c7f45f777b17c3ef0600e28abfec8619eb49c/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ebf3e58c7ec8a8bed6d66a75d7fb37b55e5015b03ceae72a8e7c74495551e224", size = 152991, upload-time = "2025-10-14T04:40:48.246Z" }, + { url = "https://files.pythonhosted.org/packages/1a/86/584869fe4ddb6ffa3bd9f491b87a01568797fb9bd8933f557dba9771beaf/charset_normalizer-3.4.4-cp311-cp311-win32.whl", hash = "sha256:eecbc200c7fd5ddb9a7f16c7decb07b566c29fa2161a16cf67b8d068bd21690a", size = 99456, upload-time = "2025-10-14T04:40:49.376Z" }, + { url = "https://files.pythonhosted.org/packages/65/f6/62fdd5feb60530f50f7e38b4f6a1d5203f4d16ff4f9f0952962c044e919a/charset_normalizer-3.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:5ae497466c7901d54b639cf42d5b8c1b6a4fead55215500d2f486d34db48d016", size = 106978, upload-time = "2025-10-14T04:40:50.844Z" }, + { url = "https://files.pythonhosted.org/packages/7a/9d/0710916e6c82948b3be62d9d398cb4fcf4e97b56d6a6aeccd66c4b2f2bd5/charset_normalizer-3.4.4-cp311-cp311-win_arm64.whl", hash = "sha256:65e2befcd84bc6f37095f5961e68a6f077bf44946771354a28ad434c2cce0ae1", size = 99969, upload-time = "2025-10-14T04:40:52.272Z" }, + { url = "https://files.pythonhosted.org/packages/f3/85/1637cd4af66fa687396e757dec650f28025f2a2f5a5531a3208dc0ec43f2/charset_normalizer-3.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394", size = 208425, upload-time = "2025-10-14T04:40:53.353Z" }, + { url = "https://files.pythonhosted.org/packages/9d/6a/04130023fef2a0d9c62d0bae2649b69f7b7d8d24ea5536feef50551029df/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25", size = 148162, upload-time = "2025-10-14T04:40:54.558Z" }, + { url = "https://files.pythonhosted.org/packages/78/29/62328d79aa60da22c9e0b9a66539feae06ca0f5a4171ac4f7dc285b83688/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef", size = 144558, upload-time = "2025-10-14T04:40:55.677Z" }, + { url = "https://files.pythonhosted.org/packages/86/bb/b32194a4bf15b88403537c2e120b817c61cd4ecffa9b6876e941c3ee38fe/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d", size = 161497, upload-time = "2025-10-14T04:40:57.217Z" }, + { url = "https://files.pythonhosted.org/packages/19/89/a54c82b253d5b9b111dc74aca196ba5ccfcca8242d0fb64146d4d3183ff1/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8", size = 159240, upload-time = "2025-10-14T04:40:58.358Z" }, + { url = "https://files.pythonhosted.org/packages/c0/10/d20b513afe03acc89ec33948320a5544d31f21b05368436d580dec4e234d/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86", size = 153471, upload-time = "2025-10-14T04:40:59.468Z" }, + { url = "https://files.pythonhosted.org/packages/61/fa/fbf177b55bdd727010f9c0a3c49eefa1d10f960e5f09d1d887bf93c2e698/charset_normalizer-3.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a", size = 150864, upload-time = "2025-10-14T04:41:00.623Z" }, + { url = "https://files.pythonhosted.org/packages/05/12/9fbc6a4d39c0198adeebbde20b619790e9236557ca59fc40e0e3cebe6f40/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f", size = 150647, upload-time = "2025-10-14T04:41:01.754Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/6a9a593d52e3e8c5d2b167daf8c6b968808efb57ef4c210acb907c365bc4/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc", size = 145110, upload-time = "2025-10-14T04:41:03.231Z" }, + { url = "https://files.pythonhosted.org/packages/30/42/9a52c609e72471b0fc54386dc63c3781a387bb4fe61c20231a4ebcd58bdd/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf", size = 162839, upload-time = "2025-10-14T04:41:04.715Z" }, + { url = "https://files.pythonhosted.org/packages/c4/5b/c0682bbf9f11597073052628ddd38344a3d673fda35a36773f7d19344b23/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15", size = 150667, upload-time = "2025-10-14T04:41:05.827Z" }, + { url = "https://files.pythonhosted.org/packages/e4/24/a41afeab6f990cf2daf6cb8c67419b63b48cf518e4f56022230840c9bfb2/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9", size = 160535, upload-time = "2025-10-14T04:41:06.938Z" }, + { url = "https://files.pythonhosted.org/packages/2a/e5/6a4ce77ed243c4a50a1fecca6aaaab419628c818a49434be428fe24c9957/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0", size = 154816, upload-time = "2025-10-14T04:41:08.101Z" }, + { url = "https://files.pythonhosted.org/packages/a8/ef/89297262b8092b312d29cdb2517cb1237e51db8ecef2e9af5edbe7b683b1/charset_normalizer-3.4.4-cp312-cp312-win32.whl", hash = "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26", size = 99694, upload-time = "2025-10-14T04:41:09.23Z" }, + { url = "https://files.pythonhosted.org/packages/3d/2d/1e5ed9dd3b3803994c155cd9aacb60c82c331bad84daf75bcb9c91b3295e/charset_normalizer-3.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525", size = 107131, upload-time = "2025-10-14T04:41:10.467Z" }, + { url = "https://files.pythonhosted.org/packages/d0/d9/0ed4c7098a861482a7b6a95603edce4c0d9db2311af23da1fb2b75ec26fc/charset_normalizer-3.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3", size = 100390, upload-time = "2025-10-14T04:41:11.915Z" }, + { url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" }, +] + +[[package]] +name = "click" +version = "8.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "coverage" +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/44/d4/7827d9ffa34d5d4d752eec907022aa417120936282fc488306f5da08c292/coverage-7.13.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0fc31c787a84f8cd6027eba44010517020e0d18487064cd3d8968941856d1415", size = 219152, upload-time = "2026-02-09T12:56:11.974Z" }, + { url = "https://files.pythonhosted.org/packages/35/b0/d69df26607c64043292644dbb9dc54b0856fabaa2cbb1eeee3331cc9e280/coverage-7.13.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a32ebc02a1805adf637fc8dec324b5cdacd2e493515424f70ee33799573d661b", size = 219667, upload-time = "2026-02-09T12:56:13.33Z" }, + { url = "https://files.pythonhosted.org/packages/82/a4/c1523f7c9e47b2271dbf8c2a097e7a1f89ef0d66f5840bb59b7e8814157b/coverage-7.13.4-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e24f9156097ff9dc286f2f913df3a7f63c0e333dcafa3c196f2c18b4175ca09a", size = 246425, upload-time = "2026-02-09T12:56:14.552Z" }, + { url = "https://files.pythonhosted.org/packages/f8/02/aa7ec01d1a5023c4b680ab7257f9bfde9defe8fdddfe40be096ac19e8177/coverage-7.13.4-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8041b6c5bfdc03257666e9881d33b1abc88daccaf73f7b6340fb7946655cd10f", size = 248229, upload-time = "2026-02-09T12:56:16.31Z" }, + { url = "https://files.pythonhosted.org/packages/35/98/85aba0aed5126d896162087ef3f0e789a225697245256fc6181b95f47207/coverage-7.13.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2a09cfa6a5862bc2fc6ca7c3def5b2926194a56b8ab78ffcf617d28911123012", size = 250106, upload-time = "2026-02-09T12:56:18.024Z" }, + { url = "https://files.pythonhosted.org/packages/96/72/1db59bd67494bc162e3e4cd5fbc7edba2c7026b22f7c8ef1496d58c2b94c/coverage-7.13.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:296f8b0af861d3970c2a4d8c91d48eb4dd4771bcef9baedec6a9b515d7de3def", size = 252021, upload-time = "2026-02-09T12:56:19.272Z" }, + { url = "https://files.pythonhosted.org/packages/9d/97/72899c59c7066961de6e3daa142d459d47d104956db43e057e034f015c8a/coverage-7.13.4-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e101609bcbbfb04605ea1027b10dc3735c094d12d40826a60f897b98b1c30256", size = 247114, upload-time = "2026-02-09T12:56:21.051Z" }, + { url = "https://files.pythonhosted.org/packages/39/1f/f1885573b5970235e908da4389176936c8933e86cb316b9620aab1585fa2/coverage-7.13.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:aa3feb8db2e87ff5e6d00d7e1480ae241876286691265657b500886c98f38bda", size = 248143, upload-time = "2026-02-09T12:56:22.585Z" }, + { url = "https://files.pythonhosted.org/packages/a8/cf/e80390c5b7480b722fa3e994f8202807799b85bc562aa4f1dde209fbb7be/coverage-7.13.4-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:4fc7fa81bbaf5a02801b65346c8b3e657f1d93763e58c0abdf7c992addd81a92", size = 246152, upload-time = "2026-02-09T12:56:23.748Z" }, + { url = "https://files.pythonhosted.org/packages/44/bf/f89a8350d85572f95412debb0fb9bb4795b1d5b5232bd652923c759e787b/coverage-7.13.4-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:33901f604424145c6e9c2398684b92e176c0b12df77d52db81c20abd48c3794c", size = 249959, upload-time = "2026-02-09T12:56:25.209Z" }, + { url = "https://files.pythonhosted.org/packages/f7/6e/612a02aece8178c818df273e8d1642190c4875402ca2ba74514394b27aba/coverage-7.13.4-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:bb28c0f2cf2782508a40cec377935829d5fcc3ad9a3681375af4e84eb34b6b58", size = 246416, upload-time = "2026-02-09T12:56:26.475Z" }, + { url = "https://files.pythonhosted.org/packages/cb/98/b5afc39af67c2fa6786b03c3a7091fc300947387ce8914b096db8a73d67a/coverage-7.13.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:9d107aff57a83222ddbd8d9ee705ede2af2cc926608b57abed8ef96b50b7e8f9", size = 247025, upload-time = "2026-02-09T12:56:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/51/30/2bba8ef0682d5bd210c38fe497e12a06c9f8d663f7025e9f5c2c31ce847d/coverage-7.13.4-cp310-cp310-win32.whl", hash = "sha256:a6f94a7d00eb18f1b6d403c91a88fd58cfc92d4b16080dfdb774afc8294469bf", size = 221758, upload-time = "2026-02-09T12:56:29.051Z" }, + { url = "https://files.pythonhosted.org/packages/78/13/331f94934cf6c092b8ea59ff868eb587bc8fe0893f02c55bc6c0183a192e/coverage-7.13.4-cp310-cp310-win_amd64.whl", hash = "sha256:2cb0f1e000ebc419632bbe04366a8990b6e32c4e0b51543a6484ffe15eaeda95", size = 222693, upload-time = "2026-02-09T12:56:30.366Z" }, + { url = "https://files.pythonhosted.org/packages/b4/ad/b59e5b451cf7172b8d1043dc0fa718f23aab379bc1521ee13d4bd9bfa960/coverage-7.13.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d490ba50c3f35dd7c17953c68f3270e7ccd1c6642e2d2afe2d8e720b98f5a053", size = 219278, upload-time = "2026-02-09T12:56:31.673Z" }, + { url = "https://files.pythonhosted.org/packages/f1/17/0cb7ca3de72e5f4ef2ec2fa0089beafbcaaaead1844e8b8a63d35173d77d/coverage-7.13.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:19bc3c88078789f8ef36acb014d7241961dbf883fd2533d18cb1e7a5b4e28b11", size = 219783, upload-time = "2026-02-09T12:56:33.104Z" }, + { url = "https://files.pythonhosted.org/packages/ab/63/325d8e5b11e0eaf6d0f6a44fad444ae58820929a9b0de943fa377fe73e85/coverage-7.13.4-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3998e5a32e62fdf410c0dbd3115df86297995d6e3429af80b8798aad894ca7aa", size = 250200, upload-time = "2026-02-09T12:56:34.474Z" }, + { url = "https://files.pythonhosted.org/packages/76/53/c16972708cbb79f2942922571a687c52bd109a7bd51175aeb7558dff2236/coverage-7.13.4-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8e264226ec98e01a8e1054314af91ee6cde0eacac4f465cc93b03dbe0bce2fd7", size = 252114, upload-time = "2026-02-09T12:56:35.749Z" }, + { url = "https://files.pythonhosted.org/packages/eb/c2/7ab36d8b8cc412bec9ea2d07c83c48930eb4ba649634ba00cb7e4e0f9017/coverage-7.13.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a3aa4e7b9e416774b21797365b358a6e827ffadaaca81b69ee02946852449f00", size = 254220, upload-time = "2026-02-09T12:56:37.796Z" }, + { url = "https://files.pythonhosted.org/packages/d6/4d/cf52c9a3322c89a0e6febdfbc83bb45c0ed3c64ad14081b9503adee702e7/coverage-7.13.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:71ca20079dd8f27fcf808817e281e90220475cd75115162218d0e27549f95fef", size = 256164, upload-time = "2026-02-09T12:56:39.016Z" }, + { url = "https://files.pythonhosted.org/packages/78/e9/eb1dd17bd6de8289df3580e967e78294f352a5df8a57ff4671ee5fc3dcd0/coverage-7.13.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e2f25215f1a359ab17320b47bcdaca3e6e6356652e8256f2441e4ef972052903", size = 250325, upload-time = "2026-02-09T12:56:40.668Z" }, + { url = "https://files.pythonhosted.org/packages/71/07/8c1542aa873728f72267c07278c5cc0ec91356daf974df21335ccdb46368/coverage-7.13.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d65b2d373032411e86960604dc4edac91fdfb5dca539461cf2cbe78327d1e64f", size = 251913, upload-time = "2026-02-09T12:56:41.97Z" }, + { url = "https://files.pythonhosted.org/packages/74/d7/c62e2c5e4483a748e27868e4c32ad3daa9bdddbba58e1bc7a15e252baa74/coverage-7.13.4-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94eb63f9b363180aff17de3e7c8760c3ba94664ea2695c52f10111244d16a299", size = 249974, upload-time = "2026-02-09T12:56:43.323Z" }, + { url = "https://files.pythonhosted.org/packages/98/9f/4c5c015a6e98ced54efd0f5cf8d31b88e5504ecb6857585fc0161bb1e600/coverage-7.13.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e856bf6616714c3a9fbc270ab54103f4e685ba236fa98c054e8f87f266c93505", size = 253741, upload-time = "2026-02-09T12:56:45.155Z" }, + { url = "https://files.pythonhosted.org/packages/bd/59/0f4eef89b9f0fcd9633b5d350016f54126ab49426a70ff4c4e87446cabdc/coverage-7.13.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:65dfcbe305c3dfe658492df2d85259e0d79ead4177f9ae724b6fb245198f55d6", size = 249695, upload-time = "2026-02-09T12:56:46.636Z" }, + { url = "https://files.pythonhosted.org/packages/b5/2c/b7476f938deb07166f3eb281a385c262675d688ff4659ad56c6c6b8e2e70/coverage-7.13.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b507778ae8a4c915436ed5c2e05b4a6cecfa70f734e19c22a005152a11c7b6a9", size = 250599, upload-time = "2026-02-09T12:56:48.13Z" }, + { url = "https://files.pythonhosted.org/packages/b8/34/c3420709d9846ee3785b9f2831b4d94f276f38884032dca1457fa83f7476/coverage-7.13.4-cp311-cp311-win32.whl", hash = "sha256:784fc3cf8be001197b652d51d3fd259b1e2262888693a4636e18879f613a62a9", size = 221780, upload-time = "2026-02-09T12:56:50.479Z" }, + { url = "https://files.pythonhosted.org/packages/61/08/3d9c8613079d2b11c185b865de9a4c1a68850cfda2b357fae365cf609f29/coverage-7.13.4-cp311-cp311-win_amd64.whl", hash = "sha256:2421d591f8ca05b308cf0092807308b2facbefe54af7c02ac22548b88b95c98f", size = 222715, upload-time = "2026-02-09T12:56:51.815Z" }, + { url = "https://files.pythonhosted.org/packages/18/1a/54c3c80b2f056164cc0a6cdcb040733760c7c4be9d780fe655f356f433e4/coverage-7.13.4-cp311-cp311-win_arm64.whl", hash = "sha256:79e73a76b854d9c6088fe5d8b2ebe745f8681c55f7397c3c0a016192d681045f", size = 221385, upload-time = "2026-02-09T12:56:53.194Z" }, + { 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/0d/4a/331fe2caf6799d591109bb9c08083080f6de90a823695d412a935622abb2/coverage-7.13.4-py3-none-any.whl", hash = "sha256:1af1641e57cf7ba1bd67d677c9abdbcd6cc2ab7da3bca7fa1e2b7e50e65f2ad0", size = 211242, upload-time = "2026-02-09T12:59:02.032Z" }, +] + +[package.optional-dependencies] +toml = [ + { name = "tomli", marker = "python_full_version <= '3.11'" }, +] + +[[package]] +name = "distlib" +version = "0.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/96/8e/709914eb2b5749865801041647dc7f4e6d00b549cfe88b65ca192995f07c/distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d", size = 614605, upload-time = "2025-07-17T16:52:00.465Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" }, +] + +[[package]] +name = "docutils" +version = "0.21.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +sdist = { url = "https://files.pythonhosted.org/packages/ae/ed/aefcc8cd0ba62a0560c3c18c33925362d46c6075480bfa4df87b28e169a9/docutils-0.21.2.tar.gz", hash = "sha256:3a6b18732edf182daa3cd12775bbb338cf5691468f91eeeb109deff6ebfa986f", size = 2204444, upload-time = "2024-04-23T18:57:18.24Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8f/d7/9322c609343d929e75e7e5e6255e614fcc67572cfd083959cdef3b7aad79/docutils-0.21.2-py3-none-any.whl", hash = "sha256:dafca5b9e384f0e419294eb4d2ff9fa826435bf15f15b7bd45723e8ad76811b2", size = 587408, upload-time = "2024-04-23T18:57:14.835Z" }, +] + +[[package]] +name = "docutils" +version = "0.22.4" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12' and sys_platform == 'win32'", + "python_full_version == '3.11.*' and sys_platform == 'win32'", + "python_full_version >= '3.12' and sys_platform == 'emscripten'", + "python_full_version == '3.11.*' and sys_platform == 'emscripten'", + "python_full_version >= '3.12' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", +] +sdist = { url = "https://files.pythonhosted.org/packages/ae/b6/03bb70946330e88ffec97aefd3ea75ba575cb2e762061e0e62a213befee8/docutils-0.22.4.tar.gz", hash = "sha256:4db53b1fde9abecbb74d91230d32ab626d94f6badfc575d6db9194a49df29968", size = 2291750, upload-time = "2025-12-18T19:00:26.443Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/10/5da547df7a391dcde17f59520a231527b8571e6f46fc8efb02ccb370ab12/docutils-0.22.4-py3-none-any.whl", hash = "sha256:d0013f540772d1420576855455d050a2180186c91c15779301ac2ccb3eeb68de", size = 633196, upload-time = "2025-12-18T19:00:18.077Z" }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, +] + +[[package]] +name = "filelock" +version = "3.24.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/73/92/a8e2479937ff39185d20dd6a851c1a63e55849e447a55e798cc2e1f49c65/filelock-3.24.3.tar.gz", hash = "sha256:011a5644dc937c22699943ebbfc46e969cdde3e171470a6e40b9533e5a72affa", size = 37935, upload-time = "2026-02-19T00:48:20.543Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9c/0f/5d0c71a1aefeb08efff26272149e07ab922b64f46c63363756224bd6872e/filelock-3.24.3-py3-none-any.whl", hash = "sha256:426e9a4660391f7f8a810d71b0555bce9008b0a1cc342ab1f6947d37639e002d", size = 24331, upload-time = "2026-02-19T00:48:18.465Z" }, +] + +[[package]] +name = "flexmeasures-client" +source = { editable = "." } +dependencies = [ + { name = "aiohttp" }, + { name = "async-timeout" }, + { name = "packaging" }, + { name = "pandas", version = "2.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "pandas", version = "3.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, +] + +[package.optional-dependencies] +s2 = [ + { name = "requests" }, + { name = "s2-python" }, + { name = "semver" }, + { name = "tzdata" }, +] + +[package.dev-dependencies] +dev = [ + { name = "mypy" }, + { name = "poethepoet" }, + { name = "pre-commit" }, + { name = "types-pytz" }, + { name = "types-requests" }, +] +docs = [ + { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "sphinx", version = "9.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "sphinx", version = "9.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, +] +test = [ + { name = "aioresponses" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-cov" }, + { name = "pytest-mock" }, +] + +[package.metadata] +requires-dist = [ + { name = "aiohttp", specifier = ">=3.13.3" }, + { name = "async-timeout", specifier = ">=5.0.1" }, + { name = "packaging", specifier = ">=26.0" }, + { name = "pandas", specifier = ">=2.1.4" }, + { name = "requests", marker = "extra == 's2'", specifier = ">=2.32.5" }, + { name = "s2-python", marker = "extra == 's2'", specifier = ">=0.8.1" }, + { name = "semver", marker = "extra == 's2'", specifier = ">=3.0.4" }, + { name = "tzdata", marker = "extra == 's2'", specifier = ">=2025.3" }, +] +provides-extras = ["s2"] + +[package.metadata.requires-dev] +dev = [ + { name = "mypy", specifier = ">=0.902" }, + { name = "poethepoet", specifier = ">=0.41.0" }, + { name = "pre-commit", specifier = ">=4.5.1" }, + { name = "types-pytz", specifier = ">=2025.2.0.20251108" }, + { name = "types-requests", specifier = ">=2.32.4.20260107" }, +] +docs = [{ name = "sphinx", specifier = ">=3.2.1" }] +test = [ + { name = "aioresponses", specifier = ">=0.7.8" }, + { name = "pytest", specifier = ">=9.0.2" }, + { name = "pytest-asyncio", specifier = ">=1.3.0" }, + { name = "pytest-cov", specifier = ">=7.0.0" }, + { name = "pytest-mock", specifier = ">=3.15.1" }, +] + +[[package]] +name = "frozenlist" +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/83/4a/557715d5047da48d54e659203b9335be7bfaafda2c3f627b7c47e0b3aaf3/frozenlist-1.8.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:b37f6d31b3dcea7deb5e9696e529a6aa4a898adc33db82da12e4c60a7c4d2011", size = 86230, upload-time = "2025-10-06T05:35:23.699Z" }, + { url = "https://files.pythonhosted.org/packages/a2/fb/c85f9fed3ea8fe8740e5b46a59cc141c23b842eca617da8876cfce5f760e/frozenlist-1.8.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ef2b7b394f208233e471abc541cc6991f907ffd47dc72584acee3147899d6565", size = 49621, upload-time = "2025-10-06T05:35:25.341Z" }, + { url = "https://files.pythonhosted.org/packages/63/70/26ca3f06aace16f2352796b08704338d74b6d1a24ca38f2771afbb7ed915/frozenlist-1.8.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a88f062f072d1589b7b46e951698950e7da00442fc1cacbe17e19e025dc327ad", size = 49889, upload-time = "2025-10-06T05:35:26.797Z" }, + { url = "https://files.pythonhosted.org/packages/5d/ed/c7895fd2fde7f3ee70d248175f9b6cdf792fb741ab92dc59cd9ef3bd241b/frozenlist-1.8.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f57fb59d9f385710aa7060e89410aeb5058b99e62f4d16b08b91986b9a2140c2", size = 219464, upload-time = "2025-10-06T05:35:28.254Z" }, + { url = "https://files.pythonhosted.org/packages/6b/83/4d587dccbfca74cb8b810472392ad62bfa100bf8108c7223eb4c4fa2f7b3/frozenlist-1.8.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:799345ab092bee59f01a915620b5d014698547afd011e691a208637312db9186", size = 221649, upload-time = "2025-10-06T05:35:29.454Z" }, + { url = "https://files.pythonhosted.org/packages/6a/c6/fd3b9cd046ec5fff9dab66831083bc2077006a874a2d3d9247dea93ddf7e/frozenlist-1.8.0-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c23c3ff005322a6e16f71bf8692fcf4d5a304aaafe1e262c98c6d4adc7be863e", size = 219188, upload-time = "2025-10-06T05:35:30.951Z" }, + { url = "https://files.pythonhosted.org/packages/ce/80/6693f55eb2e085fc8afb28cf611448fb5b90e98e068fa1d1b8d8e66e5c7d/frozenlist-1.8.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8a76ea0f0b9dfa06f254ee06053d93a600865b3274358ca48a352ce4f0798450", size = 231748, upload-time = "2025-10-06T05:35:32.101Z" }, + { url = "https://files.pythonhosted.org/packages/97/d6/e9459f7c5183854abd989ba384fe0cc1a0fb795a83c033f0571ec5933ca4/frozenlist-1.8.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c7366fe1418a6133d5aa824ee53d406550110984de7637d65a178010f759c6ef", size = 236351, upload-time = "2025-10-06T05:35:33.834Z" }, + { url = "https://files.pythonhosted.org/packages/97/92/24e97474b65c0262e9ecd076e826bfd1d3074adcc165a256e42e7b8a7249/frozenlist-1.8.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:13d23a45c4cebade99340c4165bd90eeb4a56c6d8a9d8aa49568cac19a6d0dc4", size = 218767, upload-time = "2025-10-06T05:35:35.205Z" }, + { url = "https://files.pythonhosted.org/packages/ee/bf/dc394a097508f15abff383c5108cb8ad880d1f64a725ed3b90d5c2fbf0bb/frozenlist-1.8.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:e4a3408834f65da56c83528fb52ce7911484f0d1eaf7b761fc66001db1646eff", size = 235887, upload-time = "2025-10-06T05:35:36.354Z" }, + { url = "https://files.pythonhosted.org/packages/40/90/25b201b9c015dbc999a5baf475a257010471a1fa8c200c843fd4abbee725/frozenlist-1.8.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:42145cd2748ca39f32801dad54aeea10039da6f86e303659db90db1c4b614c8c", size = 228785, upload-time = "2025-10-06T05:35:37.949Z" }, + { url = "https://files.pythonhosted.org/packages/84/f4/b5bc148df03082f05d2dd30c089e269acdbe251ac9a9cf4e727b2dbb8a3d/frozenlist-1.8.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:e2de870d16a7a53901e41b64ffdf26f2fbb8917b3e6ebf398098d72c5b20bd7f", size = 230312, upload-time = "2025-10-06T05:35:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/db/4b/87e95b5d15097c302430e647136b7d7ab2398a702390cf4c8601975709e7/frozenlist-1.8.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:20e63c9493d33ee48536600d1a5c95eefc870cd71e7ab037763d1fbb89cc51e7", size = 217650, upload-time = "2025-10-06T05:35:40.377Z" }, + { url = "https://files.pythonhosted.org/packages/e5/70/78a0315d1fea97120591a83e0acd644da638c872f142fd72a6cebee825f3/frozenlist-1.8.0-cp310-cp310-win32.whl", hash = "sha256:adbeebaebae3526afc3c96fad434367cafbfd1b25d72369a9e5858453b1bb71a", size = 39659, upload-time = "2025-10-06T05:35:41.863Z" }, + { url = "https://files.pythonhosted.org/packages/66/aa/3f04523fb189a00e147e60c5b2205126118f216b0aa908035c45336e27e4/frozenlist-1.8.0-cp310-cp310-win_amd64.whl", hash = "sha256:667c3777ca571e5dbeb76f331562ff98b957431df140b54c85fd4d52eea8d8f6", size = 43837, upload-time = "2025-10-06T05:35:43.205Z" }, + { url = "https://files.pythonhosted.org/packages/39/75/1135feecdd7c336938bd55b4dc3b0dfc46d85b9be12ef2628574b28de776/frozenlist-1.8.0-cp310-cp310-win_arm64.whl", hash = "sha256:80f85f0a7cc86e7a54c46d99c9e1318ff01f4687c172ede30fd52d19d1da1c8e", size = 39989, upload-time = "2025-10-06T05:35:44.596Z" }, + { url = "https://files.pythonhosted.org/packages/bc/03/077f869d540370db12165c0aa51640a873fb661d8b315d1d4d67b284d7ac/frozenlist-1.8.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:09474e9831bc2b2199fad6da3c14c7b0fbdd377cce9d3d77131be28906cb7d84", size = 86912, upload-time = "2025-10-06T05:35:45.98Z" }, + { url = "https://files.pythonhosted.org/packages/df/b5/7610b6bd13e4ae77b96ba85abea1c8cb249683217ef09ac9e0ae93f25a91/frozenlist-1.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:17c883ab0ab67200b5f964d2b9ed6b00971917d5d8a92df149dc2c9779208ee9", size = 50046, upload-time = "2025-10-06T05:35:47.009Z" }, + { url = "https://files.pythonhosted.org/packages/6e/ef/0e8f1fe32f8a53dd26bdd1f9347efe0778b0fddf62789ea683f4cc7d787d/frozenlist-1.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:fa47e444b8ba08fffd1c18e8cdb9a75db1b6a27f17507522834ad13ed5922b93", size = 50119, upload-time = "2025-10-06T05:35:48.38Z" }, + { url = "https://files.pythonhosted.org/packages/11/b1/71a477adc7c36e5fb628245dfbdea2166feae310757dea848d02bd0689fd/frozenlist-1.8.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2552f44204b744fba866e573be4c1f9048d6a324dfe14475103fd51613eb1d1f", size = 231067, upload-time = "2025-10-06T05:35:49.97Z" }, + { url = "https://files.pythonhosted.org/packages/45/7e/afe40eca3a2dc19b9904c0f5d7edfe82b5304cb831391edec0ac04af94c2/frozenlist-1.8.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:957e7c38f250991e48a9a73e6423db1bb9dd14e722a10f6b8bb8e16a0f55f695", size = 233160, upload-time = "2025-10-06T05:35:51.729Z" }, + { url = "https://files.pythonhosted.org/packages/a6/aa/7416eac95603ce428679d273255ffc7c998d4132cfae200103f164b108aa/frozenlist-1.8.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8585e3bb2cdea02fc88ffa245069c36555557ad3609e83be0ec71f54fd4abb52", size = 228544, upload-time = "2025-10-06T05:35:53.246Z" }, + { url = "https://files.pythonhosted.org/packages/8b/3d/2a2d1f683d55ac7e3875e4263d28410063e738384d3adc294f5ff3d7105e/frozenlist-1.8.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:edee74874ce20a373d62dc28b0b18b93f645633c2943fd90ee9d898550770581", size = 243797, upload-time = "2025-10-06T05:35:54.497Z" }, + { url = "https://files.pythonhosted.org/packages/78/1e/2d5565b589e580c296d3bb54da08d206e797d941a83a6fdea42af23be79c/frozenlist-1.8.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c9a63152fe95756b85f31186bddf42e4c02c6321207fd6601a1c89ebac4fe567", size = 247923, upload-time = "2025-10-06T05:35:55.861Z" }, + { url = "https://files.pythonhosted.org/packages/aa/c3/65872fcf1d326a7f101ad4d86285c403c87be7d832b7470b77f6d2ed5ddc/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b6db2185db9be0a04fecf2f241c70b63b1a242e2805be291855078f2b404dd6b", size = 230886, upload-time = "2025-10-06T05:35:57.399Z" }, + { url = "https://files.pythonhosted.org/packages/a0/76/ac9ced601d62f6956f03cc794f9e04c81719509f85255abf96e2510f4265/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:f4be2e3d8bc8aabd566f8d5b8ba7ecc09249d74ba3c9ed52e54dc23a293f0b92", size = 245731, upload-time = "2025-10-06T05:35:58.563Z" }, + { url = "https://files.pythonhosted.org/packages/b9/49/ecccb5f2598daf0b4a1415497eba4c33c1e8ce07495eb07d2860c731b8d5/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:c8d1634419f39ea6f5c427ea2f90ca85126b54b50837f31497f3bf38266e853d", size = 241544, upload-time = "2025-10-06T05:35:59.719Z" }, + { url = "https://files.pythonhosted.org/packages/53/4b/ddf24113323c0bbcc54cb38c8b8916f1da7165e07b8e24a717b4a12cbf10/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:1a7fa382a4a223773ed64242dbe1c9c326ec09457e6b8428efb4118c685c3dfd", size = 241806, upload-time = "2025-10-06T05:36:00.959Z" }, + { url = "https://files.pythonhosted.org/packages/a7/fb/9b9a084d73c67175484ba2789a59f8eebebd0827d186a8102005ce41e1ba/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:11847b53d722050808926e785df837353bd4d75f1d494377e59b23594d834967", size = 229382, upload-time = "2025-10-06T05:36:02.22Z" }, + { url = "https://files.pythonhosted.org/packages/95/a3/c8fb25aac55bf5e12dae5c5aa6a98f85d436c1dc658f21c3ac73f9fa95e5/frozenlist-1.8.0-cp311-cp311-win32.whl", hash = "sha256:27c6e8077956cf73eadd514be8fb04d77fc946a7fe9f7fe167648b0b9085cc25", size = 39647, upload-time = "2025-10-06T05:36:03.409Z" }, + { url = "https://files.pythonhosted.org/packages/0a/f5/603d0d6a02cfd4c8f2a095a54672b3cf967ad688a60fb9faf04fc4887f65/frozenlist-1.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:ac913f8403b36a2c8610bbfd25b8013488533e71e62b4b4adce9c86c8cea905b", size = 44064, upload-time = "2025-10-06T05:36:04.368Z" }, + { url = "https://files.pythonhosted.org/packages/5d/16/c2c9ab44e181f043a86f9a8f84d5124b62dbcb3a02c0977ec72b9ac1d3e0/frozenlist-1.8.0-cp311-cp311-win_arm64.whl", hash = "sha256:d4d3214a0f8394edfa3e303136d0575eece0745ff2b47bd2cb2e66dd92d4351a", size = 39937, upload-time = "2025-10-06T05:36:05.669Z" }, + { 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/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" }, +] + +[[package]] +name = "identify" +version = "2.6.16" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5b/8d/e8b97e6bd3fb6fb271346f7981362f1e04d6a7463abd0de79e1fda17c067/identify-2.6.16.tar.gz", hash = "sha256:846857203b5511bbe94d5a352a48ef2359532bc8f6727b5544077a0dcfb24980", size = 99360, upload-time = "2026-01-12T18:58:58.201Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b8/58/40fbbcefeda82364720eba5cf2270f98496bdfa19ea75b4cccae79c698e6/identify-2.6.16-py2.py3-none-any.whl", hash = "sha256:391ee4d77741d994189522896270b787aed8670389bfd60f326d677d64a6dfb0", size = 99202, upload-time = "2026-01-12T18:58:56.627Z" }, +] + +[[package]] +name = "idna" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, +] + +[[package]] +name = "imagesize" +version = "1.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a7/84/62473fb57d61e31fef6e36d64a179c8781605429fd927b5dd608c997be31/imagesize-1.4.1.tar.gz", hash = "sha256:69150444affb9cb0d5cc5a92b3676f0b2fb7cd9ae39e947a5e11a36b4497cd4a", size = 1280026, upload-time = "2022-07-01T12:21:05.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ff/62/85c4c919272577931d407be5ba5d71c20f0b616d31a0befe0ae45bb79abd/imagesize-1.4.1-py2.py3-none-any.whl", hash = "sha256:0d8d18d08f840c19d0ee7ca1fd82490fdc3729b7ac93f49870406ddde8ef8d8b", size = 8769, upload-time = "2022-07-01T12:21:02.467Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "librt" +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/7c/5f/63f5fa395c7a8a93558c0904ba8f1c8d1b997ca6a3de61bc7659970d66bf/librt-0.8.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:81fd938344fecb9373ba1b155968c8a329491d2ce38e7ddb76f30ffb938f12dc", size = 65697, upload-time = "2026-02-17T16:11:06.903Z" }, + { url = "https://files.pythonhosted.org/packages/ff/e0/0472cf37267b5920eff2f292ccfaede1886288ce35b7f3203d8de00abfe6/librt-0.8.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5db05697c82b3a2ec53f6e72b2ed373132b0c2e05135f0696784e97d7f5d48e7", size = 68376, upload-time = "2026-02-17T16:11:08.395Z" }, + { url = "https://files.pythonhosted.org/packages/c8/be/8bd1359fdcd27ab897cd5963294fa4a7c83b20a8564678e4fd12157e56a5/librt-0.8.1-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d56bc4011975f7460bea7b33e1ff425d2f1adf419935ff6707273c77f8a4ada6", size = 197084, upload-time = "2026-02-17T16:11:09.774Z" }, + { url = "https://files.pythonhosted.org/packages/e2/fe/163e33fdd091d0c2b102f8a60cc0a61fd730ad44e32617cd161e7cd67a01/librt-0.8.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5cdc0f588ff4b663ea96c26d2a230c525c6fc62b28314edaaaca8ed5af931ad0", size = 207337, upload-time = "2026-02-17T16:11:11.311Z" }, + { url = "https://files.pythonhosted.org/packages/01/99/f85130582f05dcf0c8902f3d629270231d2f4afdfc567f8305a952ac7f14/librt-0.8.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:97c2b54ff6717a7a563b72627990bec60d8029df17df423f0ed37d56a17a176b", size = 219980, upload-time = "2026-02-17T16:11:12.499Z" }, + { url = "https://files.pythonhosted.org/packages/6f/54/cb5e4d03659e043a26c74e08206412ac9a3742f0477d96f9761a55313b5f/librt-0.8.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8f1125e6bbf2f1657d9a2f3ccc4a2c9b0c8b176965bb565dd4d86be67eddb4b6", size = 212921, upload-time = "2026-02-17T16:11:14.484Z" }, + { url = "https://files.pythonhosted.org/packages/b1/81/a3a01e4240579c30f3487f6fed01eb4bc8ef0616da5b4ebac27ca19775f3/librt-0.8.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:8f4bb453f408137d7581be309b2fbc6868a80e7ef60c88e689078ee3a296ae71", size = 221381, upload-time = "2026-02-17T16:11:17.459Z" }, + { url = "https://files.pythonhosted.org/packages/08/b0/fc2d54b4b1c6fb81e77288ff31ff25a2c1e62eaef4424a984f228839717b/librt-0.8.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:c336d61d2fe74a3195edc1646d53ff1cddd3a9600b09fa6ab75e5514ba4862a7", size = 216714, upload-time = "2026-02-17T16:11:19.197Z" }, + { url = "https://files.pythonhosted.org/packages/96/96/85daa73ffbd87e1fb287d7af6553ada66bf25a2a6b0de4764344a05469f6/librt-0.8.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:eb5656019db7c4deacf0c1a55a898c5bb8f989be904597fcb5232a2f4828fa05", size = 214777, upload-time = "2026-02-17T16:11:20.443Z" }, + { url = "https://files.pythonhosted.org/packages/12/9c/c3aa7a2360383f4bf4f04d98195f2739a579128720c603f4807f006a4225/librt-0.8.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c25d9e338d5bed46c1632f851babf3d13c78f49a225462017cf5e11e845c5891", size = 237398, upload-time = "2026-02-17T16:11:22.083Z" }, + { url = "https://files.pythonhosted.org/packages/61/19/d350ea89e5274665185dabc4bbb9c3536c3411f862881d316c8b8e00eb66/librt-0.8.1-cp310-cp310-win32.whl", hash = "sha256:aaab0e307e344cb28d800957ef3ec16605146ef0e59e059a60a176d19543d1b7", size = 54285, upload-time = "2026-02-17T16:11:23.27Z" }, + { url = "https://files.pythonhosted.org/packages/4f/d6/45d587d3d41c112e9543a0093d883eb57a24a03e41561c127818aa2a6bcc/librt-0.8.1-cp310-cp310-win_amd64.whl", hash = "sha256:56e04c14b696300d47b3bc5f1d10a00e86ae978886d0cee14e5714fafb5df5d2", size = 61352, upload-time = "2026-02-17T16:11:24.207Z" }, + { url = "https://files.pythonhosted.org/packages/1d/01/0e748af5e4fee180cf7cd12bd12b0513ad23b045dccb2a83191bde82d168/librt-0.8.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:681dc2451d6d846794a828c16c22dc452d924e9f700a485b7ecb887a30aad1fd", size = 65315, upload-time = "2026-02-17T16:11:25.152Z" }, + { url = "https://files.pythonhosted.org/packages/9d/4d/7184806efda571887c798d573ca4134c80ac8642dcdd32f12c31b939c595/librt-0.8.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a3b4350b13cc0e6f5bec8fa7caf29a8fb8cdc051a3bae45cfbfd7ce64f009965", size = 68021, upload-time = "2026-02-17T16:11:26.129Z" }, + { url = "https://files.pythonhosted.org/packages/ae/88/c3c52d2a5d5101f28d3dc89298444626e7874aa904eed498464c2af17627/librt-0.8.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ac1e7817fd0ed3d14fd7c5df91daed84c48e4c2a11ee99c0547f9f62fdae13da", size = 194500, upload-time = "2026-02-17T16:11:27.177Z" }, + { url = "https://files.pythonhosted.org/packages/d6/5d/6fb0a25b6a8906e85b2c3b87bee1d6ed31510be7605b06772f9374ca5cb3/librt-0.8.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:747328be0c5b7075cde86a0e09d7a9196029800ba75a1689332348e998fb85c0", size = 205622, upload-time = "2026-02-17T16:11:28.242Z" }, + { url = "https://files.pythonhosted.org/packages/b2/a6/8006ae81227105476a45691f5831499e4d936b1c049b0c1feb17c11b02d1/librt-0.8.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f0af2bd2bc204fa27f3d6711d0f360e6b8c684a035206257a81673ab924aa11e", size = 218304, upload-time = "2026-02-17T16:11:29.344Z" }, + { url = "https://files.pythonhosted.org/packages/ee/19/60e07886ad16670aae57ef44dada41912c90906a6fe9f2b9abac21374748/librt-0.8.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d480de377f5b687b6b1bc0c0407426da556e2a757633cc7e4d2e1a057aa688f3", size = 211493, upload-time = "2026-02-17T16:11:30.445Z" }, + { url = "https://files.pythonhosted.org/packages/9c/cf/f666c89d0e861d05600438213feeb818c7514d3315bae3648b1fc145d2b6/librt-0.8.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d0ee06b5b5291f609ddb37b9750985b27bc567791bc87c76a569b3feed8481ac", size = 219129, upload-time = "2026-02-17T16:11:32.021Z" }, + { url = "https://files.pythonhosted.org/packages/8f/ef/f1bea01e40b4a879364c031476c82a0dc69ce068daad67ab96302fed2d45/librt-0.8.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:9e2c6f77b9ad48ce5603b83b7da9ee3e36b3ab425353f695cba13200c5d96596", size = 213113, upload-time = "2026-02-17T16:11:33.192Z" }, + { url = "https://files.pythonhosted.org/packages/9b/80/cdab544370cc6bc1b72ea369525f547a59e6938ef6863a11ab3cd24759af/librt-0.8.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:439352ba9373f11cb8e1933da194dcc6206daf779ff8df0ed69c5e39113e6a99", size = 212269, upload-time = "2026-02-17T16:11:34.373Z" }, + { url = "https://files.pythonhosted.org/packages/9d/9c/48d6ed8dac595654f15eceab2035131c136d1ae9a1e3548e777bb6dbb95d/librt-0.8.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:82210adabbc331dbb65d7868b105185464ef13f56f7f76688565ad79f648b0fe", size = 234673, upload-time = "2026-02-17T16:11:36.063Z" }, + { url = "https://files.pythonhosted.org/packages/16/01/35b68b1db517f27a01be4467593292eb5315def8900afad29fabf56304ba/librt-0.8.1-cp311-cp311-win32.whl", hash = "sha256:52c224e14614b750c0a6d97368e16804a98c684657c7518752c356834fff83bb", size = 54597, upload-time = "2026-02-17T16:11:37.544Z" }, + { url = "https://files.pythonhosted.org/packages/71/02/796fe8f02822235966693f257bf2c79f40e11337337a657a8cfebba5febc/librt-0.8.1-cp311-cp311-win_amd64.whl", hash = "sha256:c00e5c884f528c9932d278d5c9cbbea38a6b81eb62c02e06ae53751a83a4d52b", size = 61733, upload-time = "2026-02-17T16:11:38.691Z" }, + { url = "https://files.pythonhosted.org/packages/28/ad/232e13d61f879a42a4e7117d65e4984bb28371a34bb6fb9ca54ec2c8f54e/librt-0.8.1-cp311-cp311-win_arm64.whl", hash = "sha256:f7cdf7f26c2286ffb02e46d7bac56c94655540b26347673bea15fa52a6af17e9", size = 52273, upload-time = "2026-02-17T16:11:40.308Z" }, + { 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" }, +] + +[[package]] +name = "markupsafe" +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/e8/4b/3541d44f3937ba468b75da9eebcae497dcf67adb65caa16760b0a6807ebb/markupsafe-3.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559", size = 11631, upload-time = "2025-09-27T18:36:05.558Z" }, + { url = "https://files.pythonhosted.org/packages/98/1b/fbd8eed11021cabd9226c37342fa6ca4e8a98d8188a8d9b66740494960e4/markupsafe-3.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419", size = 12057, upload-time = "2025-09-27T18:36:07.165Z" }, + { url = "https://files.pythonhosted.org/packages/40/01/e560d658dc0bb8ab762670ece35281dec7b6c1b33f5fbc09ebb57a185519/markupsafe-3.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695", size = 22050, upload-time = "2025-09-27T18:36:08.005Z" }, + { url = "https://files.pythonhosted.org/packages/af/cd/ce6e848bbf2c32314c9b237839119c5a564a59725b53157c856e90937b7a/markupsafe-3.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591", size = 20681, upload-time = "2025-09-27T18:36:08.881Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2a/b5c12c809f1c3045c4d580b035a743d12fcde53cf685dbc44660826308da/markupsafe-3.0.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c", size = 20705, upload-time = "2025-09-27T18:36:10.131Z" }, + { url = "https://files.pythonhosted.org/packages/cf/e3/9427a68c82728d0a88c50f890d0fc072a1484de2f3ac1ad0bfc1a7214fd5/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f", size = 21524, upload-time = "2025-09-27T18:36:11.324Z" }, + { url = "https://files.pythonhosted.org/packages/bc/36/23578f29e9e582a4d0278e009b38081dbe363c5e7165113fad546918a232/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6", size = 20282, upload-time = "2025-09-27T18:36:12.573Z" }, + { url = "https://files.pythonhosted.org/packages/56/21/dca11354e756ebd03e036bd8ad58d6d7168c80ce1fe5e75218e4945cbab7/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1", size = 20745, upload-time = "2025-09-27T18:36:13.504Z" }, + { url = "https://files.pythonhosted.org/packages/87/99/faba9369a7ad6e4d10b6a5fbf71fa2a188fe4a593b15f0963b73859a1bbd/markupsafe-3.0.3-cp310-cp310-win32.whl", hash = "sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa", size = 14571, upload-time = "2025-09-27T18:36:14.779Z" }, + { url = "https://files.pythonhosted.org/packages/d6/25/55dc3ab959917602c96985cb1253efaa4ff42f71194bddeb61eb7278b8be/markupsafe-3.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8", size = 15056, upload-time = "2025-09-27T18:36:16.125Z" }, + { url = "https://files.pythonhosted.org/packages/d0/9e/0a02226640c255d1da0b8d12e24ac2aa6734da68bff14c05dd53b94a0fc3/markupsafe-3.0.3-cp310-cp310-win_arm64.whl", hash = "sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1", size = 13932, upload-time = "2025-09-27T18:36:17.311Z" }, + { url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" }, + { url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" }, + { url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940, upload-time = "2025-09-27T18:36:22.249Z" }, + { url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887, upload-time = "2025-09-27T18:36:23.535Z" }, + { url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692, upload-time = "2025-09-27T18:36:24.823Z" }, + { url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471, upload-time = "2025-09-27T18:36:25.95Z" }, + { url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923, upload-time = "2025-09-27T18:36:27.109Z" }, + { url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572, upload-time = "2025-09-27T18:36:28.045Z" }, + { url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077, upload-time = "2025-09-27T18:36:29.025Z" }, + { url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876, upload-time = "2025-09-27T18:36:29.954Z" }, + { 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" }, +] + +[[package]] +name = "multidict" +version = "6.7.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +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/84/0b/19348d4c98980c4851d2f943f8ebafdece2ae7ef737adcfa5994ce8e5f10/multidict-6.7.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:c93c3db7ea657dd4637d57e74ab73de31bccefe144d3d4ce370052035bc85fb5", size = 77176, upload-time = "2026-01-26T02:42:59.784Z" }, + { url = "https://files.pythonhosted.org/packages/ef/04/9de3f8077852e3d438215c81e9b691244532d2e05b4270e89ce67b7d103c/multidict-6.7.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:974e72a2474600827abaeda71af0c53d9ebbc3c2eb7da37b37d7829ae31232d8", size = 44996, upload-time = "2026-01-26T02:43:01.674Z" }, + { url = "https://files.pythonhosted.org/packages/31/5c/08c7f7fe311f32e83f7621cd3f99d805f45519cd06fafb247628b861da7d/multidict-6.7.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:cdea2e7b2456cfb6694fb113066fd0ec7ea4d67e3a35e1f4cbeea0b448bf5872", size = 44631, upload-time = "2026-01-26T02:43:03.169Z" }, + { url = "https://files.pythonhosted.org/packages/b7/7f/0e3b1390ae772f27501199996b94b52ceeb64fe6f9120a32c6c3f6b781be/multidict-6.7.1-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:17207077e29342fdc2c9a82e4b306f1127bf1ea91f8b71e02d4798a70bb99991", size = 242561, upload-time = "2026-01-26T02:43:04.733Z" }, + { url = "https://files.pythonhosted.org/packages/dd/f4/8719f4f167586af317b69dd3e90f913416c91ca610cac79a45c53f590312/multidict-6.7.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d4f49cb5661344764e4c7c7973e92a47a59b8fc19b6523649ec9dc4960e58a03", size = 242223, upload-time = "2026-01-26T02:43:06.695Z" }, + { url = "https://files.pythonhosted.org/packages/47/ab/7c36164cce64a6ad19c6d9a85377b7178ecf3b89f8fd589c73381a5eedfd/multidict-6.7.1-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a9fc4caa29e2e6ae408d1c450ac8bf19892c5fca83ee634ecd88a53332c59981", size = 222322, upload-time = "2026-01-26T02:43:08.472Z" }, + { url = "https://files.pythonhosted.org/packages/f5/79/a25add6fb38035b5337bc5734f296d9afc99163403bbcf56d4170f97eb62/multidict-6.7.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c5f0c21549ab432b57dcc82130f388d84ad8179824cc3f223d5e7cfbfd4143f6", size = 254005, upload-time = "2026-01-26T02:43:10.127Z" }, + { url = "https://files.pythonhosted.org/packages/4a/7b/64a87cf98e12f756fc8bd444b001232ffff2be37288f018ad0d3f0aae931/multidict-6.7.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7dfb78d966b2c906ae1d28ccf6e6712a3cd04407ee5088cd276fe8cb42186190", size = 251173, upload-time = "2026-01-26T02:43:11.731Z" }, + { url = "https://files.pythonhosted.org/packages/4b/ac/b605473de2bb404e742f2cc3583d12aedb2352a70e49ae8fce455b50c5aa/multidict-6.7.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9b0d9b91d1aa44db9c1f1ecd0d9d2ae610b2f4f856448664e01a3b35899f3f92", size = 243273, upload-time = "2026-01-26T02:43:13.063Z" }, + { url = "https://files.pythonhosted.org/packages/03/65/11492d6a0e259783720f3bc1d9ea55579a76f1407e31ed44045c99542004/multidict-6.7.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:dd96c01a9dcd4889dcfcf9eb5544ca0c77603f239e3ffab0524ec17aea9a93ee", size = 238956, upload-time = "2026-01-26T02:43:14.843Z" }, + { url = "https://files.pythonhosted.org/packages/5f/a7/7ee591302af64e7c196fb63fe856c788993c1372df765102bd0448e7e165/multidict-6.7.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:067343c68cd6612d375710f895337b3a98a033c94f14b9a99eff902f205424e2", size = 233477, upload-time = "2026-01-26T02:43:16.025Z" }, + { url = "https://files.pythonhosted.org/packages/9c/99/c109962d58756c35fd9992fed7f2355303846ea2ff054bb5f5e9d6b888de/multidict-6.7.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:5884a04f4ff56c6120f6ccf703bdeb8b5079d808ba604d4d53aec0d55dc33568", size = 243615, upload-time = "2026-01-26T02:43:17.84Z" }, + { url = "https://files.pythonhosted.org/packages/d5/5f/1973e7c771c86e93dcfe1c9cc55a5481b610f6614acfc28c0d326fe6bfad/multidict-6.7.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:8affcf1c98b82bc901702eb73b6947a1bfa170823c153fe8a47b5f5f02e48e40", size = 249930, upload-time = "2026-01-26T02:43:19.06Z" }, + { url = "https://files.pythonhosted.org/packages/5d/a5/f170fc2268c3243853580203378cd522446b2df632061e0a5409817854c7/multidict-6.7.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:0d17522c37d03e85c8098ec8431636309b2682cf12e58f4dbc76121fb50e4962", size = 243807, upload-time = "2026-01-26T02:43:20.286Z" }, + { url = "https://files.pythonhosted.org/packages/de/01/73856fab6d125e5bc652c3986b90e8699a95e84b48d72f39ade6c0e74a8c/multidict-6.7.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:24c0cf81544ca5e17cfcb6e482e7a82cd475925242b308b890c9452a074d4505", size = 239103, upload-time = "2026-01-26T02:43:21.508Z" }, + { url = "https://files.pythonhosted.org/packages/e7/46/f1220bd9944d8aa40d8ccff100eeeee19b505b857b6f603d6078cb5315b0/multidict-6.7.1-cp310-cp310-win32.whl", hash = "sha256:d82dd730a95e6643802f4454b8fdecdf08667881a9c5670db85bc5a56693f122", size = 41416, upload-time = "2026-01-26T02:43:22.703Z" }, + { url = "https://files.pythonhosted.org/packages/68/00/9b38e272a770303692fc406c36e1a4c740f401522d5787691eb38a8925a8/multidict-6.7.1-cp310-cp310-win_amd64.whl", hash = "sha256:cf37cbe5ced48d417ba045aca1b21bafca67489452debcde94778a576666a1df", size = 46022, upload-time = "2026-01-26T02:43:23.77Z" }, + { url = "https://files.pythonhosted.org/packages/64/65/d8d42490c02ee07b6bbe00f7190d70bb4738b3cce7629aaf9f213ef730dd/multidict-6.7.1-cp310-cp310-win_arm64.whl", hash = "sha256:59bc83d3f66b41dac1e7460aac1d196edc70c9ba3094965c467715a70ecb46db", size = 43238, upload-time = "2026-01-26T02:43:24.882Z" }, + { url = "https://files.pythonhosted.org/packages/ce/f1/a90635c4f88fb913fbf4ce660b83b7445b7a02615bda034b2f8eb38fd597/multidict-6.7.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7ff981b266af91d7b4b3793ca3382e53229088d193a85dfad6f5f4c27fc73e5d", size = 76626, upload-time = "2026-01-26T02:43:26.485Z" }, + { url = "https://files.pythonhosted.org/packages/a6/9b/267e64eaf6fc637a15b35f5de31a566634a2740f97d8d094a69d34f524a4/multidict-6.7.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:844c5bca0b5444adb44a623fb0a1310c2f4cd41f402126bb269cd44c9b3f3e1e", size = 44706, upload-time = "2026-01-26T02:43:27.607Z" }, + { url = "https://files.pythonhosted.org/packages/dd/a4/d45caf2b97b035c57267791ecfaafbd59c68212004b3842830954bb4b02e/multidict-6.7.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f2a0a924d4c2e9afcd7ec64f9de35fcd96915149b2216e1cb2c10a56df483855", size = 44356, upload-time = "2026-01-26T02:43:28.661Z" }, + { url = "https://files.pythonhosted.org/packages/fd/d2/0a36c8473f0cbaeadd5db6c8b72d15bbceeec275807772bfcd059bef487d/multidict-6.7.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8be1802715a8e892c784c0197c2ace276ea52702a0ede98b6310c8f255a5afb3", size = 244355, upload-time = "2026-01-26T02:43:31.165Z" }, + { url = "https://files.pythonhosted.org/packages/5d/16/8c65be997fd7dd311b7d39c7b6e71a0cb449bad093761481eccbbe4b42a2/multidict-6.7.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2e2d2ed645ea29f31c4c7ea1552fcfd7cb7ba656e1eafd4134a6620c9f5fdd9e", size = 246433, upload-time = "2026-01-26T02:43:32.581Z" }, + { url = "https://files.pythonhosted.org/packages/01/fb/4dbd7e848d2799c6a026ec88ad39cf2b8416aa167fcc903baa55ecaa045c/multidict-6.7.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:95922cee9a778659e91db6497596435777bd25ed116701a4c034f8e46544955a", size = 225376, upload-time = "2026-01-26T02:43:34.417Z" }, + { url = "https://files.pythonhosted.org/packages/b6/8a/4a3a6341eac3830f6053062f8fbc9a9e54407c80755b3f05bc427295c2d0/multidict-6.7.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6b83cabdc375ffaaa15edd97eb7c0c672ad788e2687004990074d7d6c9b140c8", size = 257365, upload-time = "2026-01-26T02:43:35.741Z" }, + { url = "https://files.pythonhosted.org/packages/f7/a2/dd575a69c1aa206e12d27d0770cdf9b92434b48a9ef0cd0d1afdecaa93c4/multidict-6.7.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:38fb49540705369bab8484db0689d86c0a33a0a9f2c1b197f506b71b4b6c19b0", size = 254747, upload-time = "2026-01-26T02:43:36.976Z" }, + { url = "https://files.pythonhosted.org/packages/5a/56/21b27c560c13822ed93133f08aa6372c53a8e067f11fbed37b4adcdac922/multidict-6.7.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:439cbebd499f92e9aa6793016a8acaa161dfa749ae86d20960189f5398a19144", size = 246293, upload-time = "2026-01-26T02:43:38.258Z" }, + { url = "https://files.pythonhosted.org/packages/5a/a4/23466059dc3854763423d0ad6c0f3683a379d97673b1b89ec33826e46728/multidict-6.7.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6d3bc717b6fe763b8be3f2bee2701d3c8eb1b2a8ae9f60910f1b2860c82b6c49", size = 242962, upload-time = "2026-01-26T02:43:40.034Z" }, + { url = "https://files.pythonhosted.org/packages/1f/67/51dd754a3524d685958001e8fa20a0f5f90a6a856e0a9dcabff69be3dbb7/multidict-6.7.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:619e5a1ac57986dbfec9f0b301d865dddf763696435e2962f6d9cf2fdff2bb71", size = 237360, upload-time = "2026-01-26T02:43:41.752Z" }, + { url = "https://files.pythonhosted.org/packages/64/3f/036dfc8c174934d4b55d86ff4f978e558b0e585cef70cfc1ad01adc6bf18/multidict-6.7.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:0b38ebffd9be37c1170d33bc0f36f4f262e0a09bc1aac1c34c7aa51a7293f0b3", size = 245940, upload-time = "2026-01-26T02:43:43.042Z" }, + { url = "https://files.pythonhosted.org/packages/3d/20/6214d3c105928ebc353a1c644a6ef1408bc5794fcb4f170bb524a3c16311/multidict-6.7.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:10ae39c9cfe6adedcdb764f5e8411d4a92b055e35573a2eaa88d3323289ef93c", size = 253502, upload-time = "2026-01-26T02:43:44.371Z" }, + { url = "https://files.pythonhosted.org/packages/b1/e2/c653bc4ae1be70a0f836b82172d643fcf1dade042ba2676ab08ec08bff0f/multidict-6.7.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:25167cc263257660290fba06b9318d2026e3c910be240a146e1f66dd114af2b0", size = 247065, upload-time = "2026-01-26T02:43:45.745Z" }, + { url = "https://files.pythonhosted.org/packages/c8/11/a854b4154cd3bd8b1fd375e8a8ca9d73be37610c361543d56f764109509b/multidict-6.7.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:128441d052254f42989ef98b7b6a6ecb1e6f708aa962c7984235316db59f50fa", size = 241870, upload-time = "2026-01-26T02:43:47.054Z" }, + { url = "https://files.pythonhosted.org/packages/13/bf/9676c0392309b5fdae322333d22a829715b570edb9baa8016a517b55b558/multidict-6.7.1-cp311-cp311-win32.whl", hash = "sha256:d62b7f64ffde3b99d06b707a280db04fb3855b55f5a06df387236051d0668f4a", size = 41302, upload-time = "2026-01-26T02:43:48.753Z" }, + { url = "https://files.pythonhosted.org/packages/c9/68/f16a3a8ba6f7b6dc92a1f19669c0810bd2c43fc5a02da13b1cbf8e253845/multidict-6.7.1-cp311-cp311-win_amd64.whl", hash = "sha256:bdbf9f3b332abd0cdb306e7c2113818ab1e922dc84b8f8fd06ec89ed2a19ab8b", size = 45981, upload-time = "2026-01-26T02:43:49.921Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ad/9dd5305253fa00cd3c7555dbef69d5bf4133debc53b87ab8d6a44d411665/multidict-6.7.1-cp311-cp311-win_arm64.whl", hash = "sha256:b8c990b037d2fff2f4e33d3f21b9b531c5745b33a49a7d6dbe7a177266af44f6", size = 43159, upload-time = "2026-01-26T02:43:51.635Z" }, + { 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/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319, upload-time = "2026-01-26T02:46:44.004Z" }, +] + +[[package]] +name = "mypy" +version = "1.19.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions" }, +] +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/2f/63/e499890d8e39b1ff2df4c0c6ce5d371b6844ee22b8250687a99fd2f657a8/mypy-1.19.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5f05aa3d375b385734388e844bc01733bd33c644ab48e9684faa54e5389775ec", size = 13101333, upload-time = "2025-12-15T05:03:03.28Z" }, + { url = "https://files.pythonhosted.org/packages/72/4b/095626fc136fba96effc4fd4a82b41d688ab92124f8c4f7564bffe5cf1b0/mypy-1.19.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:022ea7279374af1a5d78dfcab853fe6a536eebfda4b59deab53cd21f6cd9f00b", size = 12164102, upload-time = "2025-12-15T05:02:33.611Z" }, + { url = "https://files.pythonhosted.org/packages/0c/5b/952928dd081bf88a83a5ccd49aaecfcd18fd0d2710c7ff07b8fb6f7032b9/mypy-1.19.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee4c11e460685c3e0c64a4c5de82ae143622410950d6be863303a1c4ba0e36d6", size = 12765799, upload-time = "2025-12-15T05:03:28.44Z" }, + { url = "https://files.pythonhosted.org/packages/2a/0d/93c2e4a287f74ef11a66fb6d49c7a9f05e47b0a4399040e6719b57f500d2/mypy-1.19.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de759aafbae8763283b2ee5869c7255391fbc4de3ff171f8f030b5ec48381b74", size = 13522149, upload-time = "2025-12-15T05:02:36.011Z" }, + { url = "https://files.pythonhosted.org/packages/7b/0e/33a294b56aaad2b338d203e3a1d8b453637ac36cb278b45005e0901cf148/mypy-1.19.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ab43590f9cd5108f41aacf9fca31841142c786827a74ab7cc8a2eacb634e09a1", size = 13810105, upload-time = "2025-12-15T05:02:40.327Z" }, + { url = "https://files.pythonhosted.org/packages/0e/fd/3e82603a0cb66b67c5e7abababce6bf1a929ddf67bf445e652684af5c5a0/mypy-1.19.1-cp310-cp310-win_amd64.whl", hash = "sha256:2899753e2f61e571b3971747e302d5f420c3fd09650e1951e99f823bc3089dac", size = 10057200, upload-time = "2025-12-15T05:02:51.012Z" }, + { url = "https://files.pythonhosted.org/packages/ef/47/6b3ebabd5474d9cdc170d1342fbf9dddc1b0ec13ec90bf9004ee6f391c31/mypy-1.19.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d8dfc6ab58ca7dda47d9237349157500468e404b17213d44fc1cb77bce532288", size = 13028539, upload-time = "2025-12-15T05:03:44.129Z" }, + { url = "https://files.pythonhosted.org/packages/5c/a6/ac7c7a88a3c9c54334f53a941b765e6ec6c4ebd65d3fe8cdcfbe0d0fd7db/mypy-1.19.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e3f276d8493c3c97930e354b2595a44a21348b320d859fb4a2b9f66da9ed27ab", size = 12083163, upload-time = "2025-12-15T05:03:37.679Z" }, + { url = "https://files.pythonhosted.org/packages/67/af/3afa9cf880aa4a2c803798ac24f1d11ef72a0c8079689fac5cfd815e2830/mypy-1.19.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2abb24cf3f17864770d18d673c85235ba52456b36a06b6afc1e07c1fdcd3d0e6", size = 12687629, upload-time = "2025-12-15T05:02:31.526Z" }, + { url = "https://files.pythonhosted.org/packages/2d/46/20f8a7114a56484ab268b0ab372461cb3a8f7deed31ea96b83a4e4cfcfca/mypy-1.19.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a009ffa5a621762d0c926a078c2d639104becab69e79538a494bcccb62cc0331", size = 13436933, upload-time = "2025-12-15T05:03:15.606Z" }, + { url = "https://files.pythonhosted.org/packages/5b/f8/33b291ea85050a21f15da910002460f1f445f8007adb29230f0adea279cb/mypy-1.19.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f7cee03c9a2e2ee26ec07479f38ea9c884e301d42c6d43a19d20fb014e3ba925", size = 13661754, upload-time = "2025-12-15T05:02:26.731Z" }, + { url = "https://files.pythonhosted.org/packages/fd/a3/47cbd4e85bec4335a9cd80cf67dbc02be21b5d4c9c23ad6b95d6c5196bac/mypy-1.19.1-cp311-cp311-win_amd64.whl", hash = "sha256:4b84a7a18f41e167f7995200a1d07a4a6810e89d29859df936f1c3923d263042", size = 10055772, upload-time = "2025-12-15T05:03:26.179Z" }, + { 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/8d/f4/4ce9a05ce5ded1de3ec1c1d96cf9f9504a04e54ce0ed55cfa38619a32b8d/mypy-1.19.1-py3-none-any.whl", hash = "sha256:f1235f5ea01b7db5468d53ece6aaddf1ad0b88d9e7462b86ef96fe04995d7247", size = 2471239, upload-time = "2025-12-15T05:03:07.248Z" }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, +] + +[[package]] +name = "nodeenv" +version = "1.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" }, +] + +[[package]] +name = "numpy" +version = "2.2.6" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +sdist = { url = "https://files.pythonhosted.org/packages/76/21/7d2a95e4bba9dc13d043ee156a356c0a8f0c6309dff6b21b4d71a073b8a8/numpy-2.2.6.tar.gz", hash = "sha256:e29554e2bef54a90aa5cc07da6ce955accb83f21ab5de01a62c8478897b264fd", size = 20276440, upload-time = "2025-05-17T22:38:04.611Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/3e/ed6db5be21ce87955c0cbd3009f2803f59fa08df21b5df06862e2d8e2bdd/numpy-2.2.6-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b412caa66f72040e6d268491a59f2c43bf03eb6c96dd8f0307829feb7fa2b6fb", size = 21165245, upload-time = "2025-05-17T21:27:58.555Z" }, + { url = "https://files.pythonhosted.org/packages/22/c2/4b9221495b2a132cc9d2eb862e21d42a009f5a60e45fc44b00118c174bff/numpy-2.2.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8e41fd67c52b86603a91c1a505ebaef50b3314de0213461c7a6e99c9a3beff90", size = 14360048, upload-time = "2025-05-17T21:28:21.406Z" }, + { url = "https://files.pythonhosted.org/packages/fd/77/dc2fcfc66943c6410e2bf598062f5959372735ffda175b39906d54f02349/numpy-2.2.6-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:37e990a01ae6ec7fe7fa1c26c55ecb672dd98b19c3d0e1d1f326fa13cb38d163", size = 5340542, upload-time = "2025-05-17T21:28:30.931Z" }, + { url = "https://files.pythonhosted.org/packages/7a/4f/1cb5fdc353a5f5cc7feb692db9b8ec2c3d6405453f982435efc52561df58/numpy-2.2.6-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:5a6429d4be8ca66d889b7cf70f536a397dc45ba6faeb5f8c5427935d9592e9cf", size = 6878301, upload-time = "2025-05-17T21:28:41.613Z" }, + { url = "https://files.pythonhosted.org/packages/eb/17/96a3acd228cec142fcb8723bd3cc39c2a474f7dcf0a5d16731980bcafa95/numpy-2.2.6-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:efd28d4e9cd7d7a8d39074a4d44c63eda73401580c5c76acda2ce969e0a38e83", size = 14297320, upload-time = "2025-05-17T21:29:02.78Z" }, + { url = "https://files.pythonhosted.org/packages/b4/63/3de6a34ad7ad6646ac7d2f55ebc6ad439dbbf9c4370017c50cf403fb19b5/numpy-2.2.6-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc7b73d02efb0e18c000e9ad8b83480dfcd5dfd11065997ed4c6747470ae8915", size = 16801050, upload-time = "2025-05-17T21:29:27.675Z" }, + { url = "https://files.pythonhosted.org/packages/07/b6/89d837eddef52b3d0cec5c6ba0456c1bf1b9ef6a6672fc2b7873c3ec4e2e/numpy-2.2.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:74d4531beb257d2c3f4b261bfb0fc09e0f9ebb8842d82a7b4209415896adc680", size = 15807034, upload-time = "2025-05-17T21:29:51.102Z" }, + { url = "https://files.pythonhosted.org/packages/01/c8/dc6ae86e3c61cfec1f178e5c9f7858584049b6093f843bca541f94120920/numpy-2.2.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8fc377d995680230e83241d8a96def29f204b5782f371c532579b4f20607a289", size = 18614185, upload-time = "2025-05-17T21:30:18.703Z" }, + { url = "https://files.pythonhosted.org/packages/5b/c5/0064b1b7e7c89137b471ccec1fd2282fceaae0ab3a9550f2568782d80357/numpy-2.2.6-cp310-cp310-win32.whl", hash = "sha256:b093dd74e50a8cba3e873868d9e93a85b78e0daf2e98c6797566ad8044e8363d", size = 6527149, upload-time = "2025-05-17T21:30:29.788Z" }, + { url = "https://files.pythonhosted.org/packages/a3/dd/4b822569d6b96c39d1215dbae0582fd99954dcbcf0c1a13c61783feaca3f/numpy-2.2.6-cp310-cp310-win_amd64.whl", hash = "sha256:f0fd6321b839904e15c46e0d257fdd101dd7f530fe03fd6359c1ea63738703f3", size = 12904620, upload-time = "2025-05-17T21:30:48.994Z" }, + { url = "https://files.pythonhosted.org/packages/da/a8/4f83e2aa666a9fbf56d6118faaaf5f1974d456b1823fda0a176eff722839/numpy-2.2.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f9f1adb22318e121c5c69a09142811a201ef17ab257a1e66ca3025065b7f53ae", size = 21176963, upload-time = "2025-05-17T21:31:19.36Z" }, + { url = "https://files.pythonhosted.org/packages/b3/2b/64e1affc7972decb74c9e29e5649fac940514910960ba25cd9af4488b66c/numpy-2.2.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c820a93b0255bc360f53eca31a0e676fd1101f673dda8da93454a12e23fc5f7a", size = 14406743, upload-time = "2025-05-17T21:31:41.087Z" }, + { url = "https://files.pythonhosted.org/packages/4a/9f/0121e375000b5e50ffdd8b25bf78d8e1a5aa4cca3f185d41265198c7b834/numpy-2.2.6-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:3d70692235e759f260c3d837193090014aebdf026dfd167834bcba43e30c2a42", size = 5352616, upload-time = "2025-05-17T21:31:50.072Z" }, + { url = "https://files.pythonhosted.org/packages/31/0d/b48c405c91693635fbe2dcd7bc84a33a602add5f63286e024d3b6741411c/numpy-2.2.6-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:481b49095335f8eed42e39e8041327c05b0f6f4780488f61286ed3c01368d491", size = 6889579, upload-time = "2025-05-17T21:32:01.712Z" }, + { url = "https://files.pythonhosted.org/packages/52/b8/7f0554d49b565d0171eab6e99001846882000883998e7b7d9f0d98b1f934/numpy-2.2.6-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b64d8d4d17135e00c8e346e0a738deb17e754230d7e0810ac5012750bbd85a5a", size = 14312005, upload-time = "2025-05-17T21:32:23.332Z" }, + { url = "https://files.pythonhosted.org/packages/b3/dd/2238b898e51bd6d389b7389ffb20d7f4c10066d80351187ec8e303a5a475/numpy-2.2.6-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba10f8411898fc418a521833e014a77d3ca01c15b0c6cdcce6a0d2897e6dbbdf", size = 16821570, upload-time = "2025-05-17T21:32:47.991Z" }, + { url = "https://files.pythonhosted.org/packages/83/6c/44d0325722cf644f191042bf47eedad61c1e6df2432ed65cbe28509d404e/numpy-2.2.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:bd48227a919f1bafbdda0583705e547892342c26fb127219d60a5c36882609d1", size = 15818548, upload-time = "2025-05-17T21:33:11.728Z" }, + { url = "https://files.pythonhosted.org/packages/ae/9d/81e8216030ce66be25279098789b665d49ff19eef08bfa8cb96d4957f422/numpy-2.2.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9551a499bf125c1d4f9e250377c1ee2eddd02e01eac6644c080162c0c51778ab", size = 18620521, upload-time = "2025-05-17T21:33:39.139Z" }, + { url = "https://files.pythonhosted.org/packages/6a/fd/e19617b9530b031db51b0926eed5345ce8ddc669bb3bc0044b23e275ebe8/numpy-2.2.6-cp311-cp311-win32.whl", hash = "sha256:0678000bb9ac1475cd454c6b8c799206af8107e310843532b04d49649c717a47", size = 6525866, upload-time = "2025-05-17T21:33:50.273Z" }, + { url = "https://files.pythonhosted.org/packages/31/0a/f354fb7176b81747d870f7991dc763e157a934c717b67b58456bc63da3df/numpy-2.2.6-cp311-cp311-win_amd64.whl", hash = "sha256:e8213002e427c69c45a52bbd94163084025f533a55a59d6f9c5b820774ef3303", size = 12907455, upload-time = "2025-05-17T21:34:09.135Z" }, + { url = "https://files.pythonhosted.org/packages/82/5d/c00588b6cf18e1da539b45d3598d3557084990dcc4331960c15ee776ee41/numpy-2.2.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:41c5a21f4a04fa86436124d388f6ed60a9343a6f767fced1a8a71c3fbca038ff", size = 20875348, upload-time = "2025-05-17T21:34:39.648Z" }, + { url = "https://files.pythonhosted.org/packages/66/ee/560deadcdde6c2f90200450d5938f63a34b37e27ebff162810f716f6a230/numpy-2.2.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:de749064336d37e340f640b05f24e9e3dd678c57318c7289d222a8a2f543e90c", size = 14119362, upload-time = "2025-05-17T21:35:01.241Z" }, + { url = "https://files.pythonhosted.org/packages/3c/65/4baa99f1c53b30adf0acd9a5519078871ddde8d2339dc5a7fde80d9d87da/numpy-2.2.6-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:894b3a42502226a1cac872f840030665f33326fc3dac8e57c607905773cdcde3", size = 5084103, upload-time = "2025-05-17T21:35:10.622Z" }, + { url = "https://files.pythonhosted.org/packages/cc/89/e5a34c071a0570cc40c9a54eb472d113eea6d002e9ae12bb3a8407fb912e/numpy-2.2.6-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:71594f7c51a18e728451bb50cc60a3ce4e6538822731b2933209a1f3614e9282", size = 6625382, upload-time = "2025-05-17T21:35:21.414Z" }, + { url = "https://files.pythonhosted.org/packages/f8/35/8c80729f1ff76b3921d5c9487c7ac3de9b2a103b1cd05e905b3090513510/numpy-2.2.6-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f2618db89be1b4e05f7a1a847a9c1c0abd63e63a1607d892dd54668dd92faf87", size = 14018462, upload-time = "2025-05-17T21:35:42.174Z" }, + { url = "https://files.pythonhosted.org/packages/8c/3d/1e1db36cfd41f895d266b103df00ca5b3cbe965184df824dec5c08c6b803/numpy-2.2.6-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd83c01228a688733f1ded5201c678f0c53ecc1006ffbc404db9f7a899ac6249", size = 16527618, upload-time = "2025-05-17T21:36:06.711Z" }, + { url = "https://files.pythonhosted.org/packages/61/c6/03ed30992602c85aa3cd95b9070a514f8b3c33e31124694438d88809ae36/numpy-2.2.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:37c0ca431f82cd5fa716eca9506aefcabc247fb27ba69c5062a6d3ade8cf8f49", size = 15505511, upload-time = "2025-05-17T21:36:29.965Z" }, + { url = "https://files.pythonhosted.org/packages/b7/25/5761d832a81df431e260719ec45de696414266613c9ee268394dd5ad8236/numpy-2.2.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fe27749d33bb772c80dcd84ae7e8df2adc920ae8297400dabec45f0dedb3f6de", size = 18313783, upload-time = "2025-05-17T21:36:56.883Z" }, + { url = "https://files.pythonhosted.org/packages/57/0a/72d5a3527c5ebffcd47bde9162c39fae1f90138c961e5296491ce778e682/numpy-2.2.6-cp312-cp312-win32.whl", hash = "sha256:4eeaae00d789f66c7a25ac5f34b71a7035bb474e679f410e5e1a94deb24cf2d4", size = 6246506, upload-time = "2025-05-17T21:37:07.368Z" }, + { url = "https://files.pythonhosted.org/packages/36/fa/8c9210162ca1b88529ab76b41ba02d433fd54fecaf6feb70ef9f124683f1/numpy-2.2.6-cp312-cp312-win_amd64.whl", hash = "sha256:c1f9540be57940698ed329904db803cf7a402f3fc200bfe599334c9bd84a40b2", size = 12614190, upload-time = "2025-05-17T21:37:26.213Z" }, + { url = "https://files.pythonhosted.org/packages/9e/3b/d94a75f4dbf1ef5d321523ecac21ef23a3cd2ac8b78ae2aac40873590229/numpy-2.2.6-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0b605b275d7bd0c640cad4e5d30fa701a8d59302e127e5f79138ad62762c3e3d", size = 21040391, upload-time = "2025-05-17T21:44:35.948Z" }, + { url = "https://files.pythonhosted.org/packages/17/f4/09b2fa1b58f0fb4f7c7963a1649c64c4d315752240377ed74d9cd878f7b5/numpy-2.2.6-pp310-pypy310_pp73-macosx_14_0_x86_64.whl", hash = "sha256:7befc596a7dc9da8a337f79802ee8adb30a552a94f792b9c9d18c840055907db", size = 6786754, upload-time = "2025-05-17T21:44:47.446Z" }, + { url = "https://files.pythonhosted.org/packages/af/30/feba75f143bdc868a1cc3f44ccfa6c4b9ec522b36458e738cd00f67b573f/numpy-2.2.6-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ce47521a4754c8f4593837384bd3424880629f718d87c5d44f8ed763edd63543", size = 16643476, upload-time = "2025-05-17T21:45:11.871Z" }, + { url = "https://files.pythonhosted.org/packages/37/48/ac2a9584402fb6c0cd5b5d1a91dcf176b15760130dd386bbafdbfe3640bf/numpy-2.2.6-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d042d24c90c41b54fd506da306759e06e568864df8ec17ccc17e9e884634fd00", size = 12812666, upload-time = "2025-05-17T21:45:31.426Z" }, +] + +[[package]] +name = "numpy" +version = "2.4.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12' and sys_platform == 'win32'", + "python_full_version == '3.11.*' and sys_platform == 'win32'", + "python_full_version >= '3.12' and sys_platform == 'emscripten'", + "python_full_version == '3.11.*' and sys_platform == 'emscripten'", + "python_full_version >= '3.12' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", +] +sdist = { url = "https://files.pythonhosted.org/packages/57/fd/0005efbd0af48e55eb3c7208af93f2862d4b1a56cd78e84309a2d959208d/numpy-2.4.2.tar.gz", hash = "sha256:659a6107e31a83c4e33f763942275fd278b21d095094044eb35569e86a21ddae", size = 20723651, upload-time = "2026-01-31T23:13:10.135Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d3/44/71852273146957899753e69986246d6a176061ea183407e95418c2aa4d9a/numpy-2.4.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:e7e88598032542bd49af7c4747541422884219056c268823ef6e5e89851c8825", size = 16955478, upload-time = "2026-01-31T23:10:25.623Z" }, + { url = "https://files.pythonhosted.org/packages/74/41/5d17d4058bd0cd96bcbd4d9ff0fb2e21f52702aab9a72e4a594efa18692f/numpy-2.4.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7edc794af8b36ca37ef5fcb5e0d128c7e0595c7b96a2318d1badb6fcd8ee86b1", size = 14965467, upload-time = "2026-01-31T23:10:28.186Z" }, + { url = "https://files.pythonhosted.org/packages/49/48/fb1ce8136c19452ed15f033f8aee91d5defe515094e330ce368a0647846f/numpy-2.4.2-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:6e9f61981ace1360e42737e2bae58b27bf28a1b27e781721047d84bd754d32e7", size = 5475172, upload-time = "2026-01-31T23:10:30.848Z" }, + { url = "https://files.pythonhosted.org/packages/40/a9/3feb49f17bbd1300dd2570432961f5c8a4ffeff1db6f02c7273bd020a4c9/numpy-2.4.2-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:cb7bbb88aa74908950d979eeaa24dbdf1a865e3c7e45ff0121d8f70387b55f73", size = 6805145, upload-time = "2026-01-31T23:10:32.352Z" }, + { url = "https://files.pythonhosted.org/packages/3f/39/fdf35cbd6d6e2fcad42fcf85ac04a85a0d0fbfbf34b30721c98d602fd70a/numpy-2.4.2-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4f069069931240b3fc703f1e23df63443dbd6390614c8c44a87d96cd0ec81eb1", size = 15966084, upload-time = "2026-01-31T23:10:34.502Z" }, + { url = "https://files.pythonhosted.org/packages/1b/46/6fa4ea94f1ddf969b2ee941290cca6f1bfac92b53c76ae5f44afe17ceb69/numpy-2.4.2-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c02ef4401a506fb60b411467ad501e1429a3487abca4664871d9ae0b46c8ba32", size = 16899477, upload-time = "2026-01-31T23:10:37.075Z" }, + { url = "https://files.pythonhosted.org/packages/09/a1/2a424e162b1a14a5bd860a464ab4e07513916a64ab1683fae262f735ccd2/numpy-2.4.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2653de5c24910e49c2b106499803124dde62a5a1fe0eedeaecf4309a5f639390", size = 17323429, upload-time = "2026-01-31T23:10:39.704Z" }, + { url = "https://files.pythonhosted.org/packages/ce/a2/73014149ff250628df72c58204822ac01d768697913881aacf839ff78680/numpy-2.4.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1ae241bbfc6ae276f94a170b14785e561cb5e7f626b6688cf076af4110887413", size = 18635109, upload-time = "2026-01-31T23:10:41.924Z" }, + { url = "https://files.pythonhosted.org/packages/6c/0c/73e8be2f1accd56df74abc1c5e18527822067dced5ec0861b5bb882c2ce0/numpy-2.4.2-cp311-cp311-win32.whl", hash = "sha256:df1b10187212b198dd45fa943d8985a3c8cf854aed4923796e0e019e113a1bda", size = 6237915, upload-time = "2026-01-31T23:10:45.26Z" }, + { url = "https://files.pythonhosted.org/packages/76/ae/e0265e0163cf127c24c3969d29f1c4c64551a1e375d95a13d32eab25d364/numpy-2.4.2-cp311-cp311-win_amd64.whl", hash = "sha256:b9c618d56a29c9cb1c4da979e9899be7578d2e0b3c24d52079c166324c9e8695", size = 12607972, upload-time = "2026-01-31T23:10:47.021Z" }, + { url = "https://files.pythonhosted.org/packages/29/a5/c43029af9b8014d6ea157f192652c50042e8911f4300f8f6ed3336bf437f/numpy-2.4.2-cp311-cp311-win_arm64.whl", hash = "sha256:47c5a6ed21d9452b10227e5e8a0e1c22979811cad7dcc19d8e3e2fb8fa03f1a3", size = 10485763, upload-time = "2026-01-31T23:10:50.087Z" }, + { url = "https://files.pythonhosted.org/packages/51/6e/6f394c9c77668153e14d4da83bcc247beb5952f6ead7699a1a2992613bea/numpy-2.4.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:21982668592194c609de53ba4933a7471880ccbaadcc52352694a59ecc860b3a", size = 16667963, upload-time = "2026-01-31T23:10:52.147Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f8/55483431f2b2fd015ae6ed4fe62288823ce908437ed49db5a03d15151678/numpy-2.4.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40397bda92382fcec844066efb11f13e1c9a3e2a8e8f318fb72ed8b6db9f60f1", size = 14693571, upload-time = "2026-01-31T23:10:54.789Z" }, + { url = "https://files.pythonhosted.org/packages/2f/20/18026832b1845cdc82248208dd929ca14c9d8f2bac391f67440707fff27c/numpy-2.4.2-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:b3a24467af63c67829bfaa61eecf18d5432d4f11992688537be59ecd6ad32f5e", size = 5203469, upload-time = "2026-01-31T23:10:57.343Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/2eb97c8a77daaba34eaa3fa7241a14ac5f51c46a6bd5911361b644c4a1e2/numpy-2.4.2-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:805cc8de9fd6e7a22da5aed858e0ab16be5a4db6c873dde1d7451c541553aa27", size = 6550820, upload-time = "2026-01-31T23:10:59.429Z" }, + { url = "https://files.pythonhosted.org/packages/b1/91/b97fdfd12dc75b02c44e26c6638241cc004d4079a0321a69c62f51470c4c/numpy-2.4.2-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6d82351358ffbcdcd7b686b90742a9b86632d6c1c051016484fa0b326a0a1548", size = 15663067, upload-time = "2026-01-31T23:11:01.291Z" }, + { url = "https://files.pythonhosted.org/packages/f5/c6/a18e59f3f0b8071cc85cbc8d80cd02d68aa9710170b2553a117203d46936/numpy-2.4.2-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9e35d3e0144137d9fdae62912e869136164534d64a169f86438bc9561b6ad49f", size = 16619782, upload-time = "2026-01-31T23:11:03.669Z" }, + { url = "https://files.pythonhosted.org/packages/b7/83/9751502164601a79e18847309f5ceec0b1446d7b6aa12305759b72cf98b2/numpy-2.4.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:adb6ed2ad29b9e15321d167d152ee909ec73395901b70936f029c3bc6d7f4460", size = 17013128, upload-time = "2026-01-31T23:11:05.913Z" }, + { url = "https://files.pythonhosted.org/packages/61/c4/c4066322256ec740acc1c8923a10047818691d2f8aec254798f3dd90f5f2/numpy-2.4.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8906e71fd8afcb76580404e2a950caef2685df3d2a57fe82a86ac8d33cc007ba", size = 18345324, upload-time = "2026-01-31T23:11:08.248Z" }, + { url = "https://files.pythonhosted.org/packages/ab/af/6157aa6da728fa4525a755bfad486ae7e3f76d4c1864138003eb84328497/numpy-2.4.2-cp312-cp312-win32.whl", hash = "sha256:ec055f6dae239a6299cace477b479cca2fc125c5675482daf1dd886933a1076f", size = 5960282, upload-time = "2026-01-31T23:11:10.497Z" }, + { url = "https://files.pythonhosted.org/packages/92/0f/7ceaaeaacb40567071e94dbf2c9480c0ae453d5bb4f52bea3892c39dc83c/numpy-2.4.2-cp312-cp312-win_amd64.whl", hash = "sha256:209fae046e62d0ce6435fcfe3b1a10537e858249b3d9b05829e2a05218296a85", size = 12314210, upload-time = "2026-01-31T23:11:12.176Z" }, + { url = "https://files.pythonhosted.org/packages/2f/a3/56c5c604fae6dd40fa2ed3040d005fca97e91bd320d232ac9931d77ba13c/numpy-2.4.2-cp312-cp312-win_arm64.whl", hash = "sha256:fbde1b0c6e81d56f5dccd95dd4a711d9b95df1ae4009a60887e56b27e8d903fa", size = 10220171, upload-time = "2026-01-31T23:11:14.684Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f8/50e14d36d915ef64d8f8bc4a087fc8264d82c785eda6711f80ab7e620335/numpy-2.4.2-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:89f7268c009bc492f506abd6f5265defa7cb3f7487dc21d357c3d290add45082", size = 16833179, upload-time = "2026-01-31T23:12:53.5Z" }, + { url = "https://files.pythonhosted.org/packages/17/17/809b5cad63812058a8189e91a1e2d55a5a18fd04611dbad244e8aeae465c/numpy-2.4.2-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:e6dee3bb76aa4009d5a912180bf5b2de012532998d094acee25d9cb8dee3e44a", size = 14889755, upload-time = "2026-01-31T23:12:55.933Z" }, + { url = "https://files.pythonhosted.org/packages/3e/ea/181b9bcf7627fc8371720316c24db888dcb9829b1c0270abf3d288b2e29b/numpy-2.4.2-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:cd2bd2bbed13e213d6b55dc1d035a4f91748a7d3edc9480c13898b0353708920", size = 5399500, upload-time = "2026-01-31T23:12:58.671Z" }, + { url = "https://files.pythonhosted.org/packages/33/9f/413adf3fc955541ff5536b78fcf0754680b3c6d95103230252a2c9408d23/numpy-2.4.2-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:cf28c0c1d4c4bf00f509fa7eb02c58d7caf221b50b467bcb0d9bbf1584d5c821", size = 6714252, upload-time = "2026-01-31T23:13:00.518Z" }, + { url = "https://files.pythonhosted.org/packages/91/da/643aad274e29ccbdf42ecd94dafe524b81c87bcb56b83872d54827f10543/numpy-2.4.2-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e04ae107ac591763a47398bb45b568fc38f02dbc4aa44c063f67a131f99346cb", size = 15797142, upload-time = "2026-01-31T23:13:02.219Z" }, + { url = "https://files.pythonhosted.org/packages/66/27/965b8525e9cb5dc16481b30a1b3c21e50c7ebf6e9dbd48d0c4d0d5089c7e/numpy-2.4.2-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:602f65afdef699cda27ec0b9224ae5dc43e328f4c24c689deaf77133dbee74d0", size = 16727979, upload-time = "2026-01-31T23:13:04.62Z" }, + { url = "https://files.pythonhosted.org/packages/de/e5/b7d20451657664b07986c2f6e3be564433f5dcaf3482d68eaecd79afaf03/numpy-2.4.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:be71bf1edb48ebbbf7f6337b5bfd2f895d1902f6335a5830b20141fc126ffba0", size = 12502577, upload-time = "2026-01-31T23:13:07.08Z" }, +] + +[[package]] +name = "packaging" +version = "26.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, +] + +[[package]] +name = "pandas" +version = "2.3.3" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +dependencies = [ + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "python-dateutil", marker = "python_full_version < '3.11'" }, + { name = "pytz", marker = "python_full_version < '3.11'" }, + { name = "tzdata", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/33/01/d40b85317f86cf08d853a4f495195c73815fdf205eef3993821720274518/pandas-2.3.3.tar.gz", hash = "sha256:e05e1af93b977f7eafa636d043f9f94c7ee3ac81af99c13508215942e64c993b", size = 4495223, upload-time = "2025-09-29T23:34:51.853Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/f7/f425a00df4fcc22b292c6895c6831c0c8ae1d9fac1e024d16f98a9ce8749/pandas-2.3.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:376c6446ae31770764215a6c937f72d917f214b43560603cd60da6408f183b6c", size = 11555763, upload-time = "2025-09-29T23:16:53.287Z" }, + { url = "https://files.pythonhosted.org/packages/13/4f/66d99628ff8ce7857aca52fed8f0066ce209f96be2fede6cef9f84e8d04f/pandas-2.3.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e19d192383eab2f4ceb30b412b22ea30690c9e618f78870357ae1d682912015a", size = 10801217, upload-time = "2025-09-29T23:17:04.522Z" }, + { url = "https://files.pythonhosted.org/packages/1d/03/3fc4a529a7710f890a239cc496fc6d50ad4a0995657dccc1d64695adb9f4/pandas-2.3.3-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5caf26f64126b6c7aec964f74266f435afef1c1b13da3b0636c7518a1fa3e2b1", size = 12148791, upload-time = "2025-09-29T23:17:18.444Z" }, + { url = "https://files.pythonhosted.org/packages/40/a8/4dac1f8f8235e5d25b9955d02ff6f29396191d4e665d71122c3722ca83c5/pandas-2.3.3-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dd7478f1463441ae4ca7308a70e90b33470fa593429f9d4c578dd00d1fa78838", size = 12769373, upload-time = "2025-09-29T23:17:35.846Z" }, + { url = "https://files.pythonhosted.org/packages/df/91/82cc5169b6b25440a7fc0ef3a694582418d875c8e3ebf796a6d6470aa578/pandas-2.3.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:4793891684806ae50d1288c9bae9330293ab4e083ccd1c5e383c34549c6e4250", size = 13200444, upload-time = "2025-09-29T23:17:49.341Z" }, + { url = "https://files.pythonhosted.org/packages/10/ae/89b3283800ab58f7af2952704078555fa60c807fff764395bb57ea0b0dbd/pandas-2.3.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:28083c648d9a99a5dd035ec125d42439c6c1c525098c58af0fc38dd1a7a1b3d4", size = 13858459, upload-time = "2025-09-29T23:18:03.722Z" }, + { url = "https://files.pythonhosted.org/packages/85/72/530900610650f54a35a19476eca5104f38555afccda1aa11a92ee14cb21d/pandas-2.3.3-cp310-cp310-win_amd64.whl", hash = "sha256:503cf027cf9940d2ceaa1a93cfb5f8c8c7e6e90720a2850378f0b3f3b1e06826", size = 11346086, upload-time = "2025-09-29T23:18:18.505Z" }, + { url = "https://files.pythonhosted.org/packages/c1/fa/7ac648108144a095b4fb6aa3de1954689f7af60a14cf25583f4960ecb878/pandas-2.3.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:602b8615ebcc4a0c1751e71840428ddebeb142ec02c786e8ad6b1ce3c8dec523", size = 11578790, upload-time = "2025-09-29T23:18:30.065Z" }, + { url = "https://files.pythonhosted.org/packages/9b/35/74442388c6cf008882d4d4bdfc4109be87e9b8b7ccd097ad1e7f006e2e95/pandas-2.3.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8fe25fc7b623b0ef6b5009149627e34d2a4657e880948ec3c840e9402e5c1b45", size = 10833831, upload-time = "2025-09-29T23:38:56.071Z" }, + { url = "https://files.pythonhosted.org/packages/fe/e4/de154cbfeee13383ad58d23017da99390b91d73f8c11856f2095e813201b/pandas-2.3.3-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b468d3dad6ff947df92dcb32ede5b7bd41a9b3cceef0a30ed925f6d01fb8fa66", size = 12199267, upload-time = "2025-09-29T23:18:41.627Z" }, + { url = "https://files.pythonhosted.org/packages/bf/c9/63f8d545568d9ab91476b1818b4741f521646cbdd151c6efebf40d6de6f7/pandas-2.3.3-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b98560e98cb334799c0b07ca7967ac361a47326e9b4e5a7dfb5ab2b1c9d35a1b", size = 12789281, upload-time = "2025-09-29T23:18:56.834Z" }, + { url = "https://files.pythonhosted.org/packages/f2/00/a5ac8c7a0e67fd1a6059e40aa08fa1c52cc00709077d2300e210c3ce0322/pandas-2.3.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37b5848ba49824e5c30bedb9c830ab9b7751fd049bc7914533e01c65f79791", size = 13240453, upload-time = "2025-09-29T23:19:09.247Z" }, + { url = "https://files.pythonhosted.org/packages/27/4d/5c23a5bc7bd209231618dd9e606ce076272c9bc4f12023a70e03a86b4067/pandas-2.3.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:db4301b2d1f926ae677a751eb2bd0e8c5f5319c9cb3f88b0becbbb0b07b34151", size = 13890361, upload-time = "2025-09-29T23:19:25.342Z" }, + { url = "https://files.pythonhosted.org/packages/8e/59/712db1d7040520de7a4965df15b774348980e6df45c129b8c64d0dbe74ef/pandas-2.3.3-cp311-cp311-win_amd64.whl", hash = "sha256:f086f6fe114e19d92014a1966f43a3e62285109afe874f067f5abbdcbb10e59c", size = 11348702, upload-time = "2025-09-29T23:19:38.296Z" }, + { url = "https://files.pythonhosted.org/packages/9c/fb/231d89e8637c808b997d172b18e9d4a4bc7bf31296196c260526055d1ea0/pandas-2.3.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d21f6d74eb1725c2efaa71a2bfc661a0689579b58e9c0ca58a739ff0b002b53", size = 11597846, upload-time = "2025-09-29T23:19:48.856Z" }, + { url = "https://files.pythonhosted.org/packages/5c/bd/bf8064d9cfa214294356c2d6702b716d3cf3bb24be59287a6a21e24cae6b/pandas-2.3.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3fd2f887589c7aa868e02632612ba39acb0b8948faf5cc58f0850e165bd46f35", size = 10729618, upload-time = "2025-09-29T23:39:08.659Z" }, + { url = "https://files.pythonhosted.org/packages/57/56/cf2dbe1a3f5271370669475ead12ce77c61726ffd19a35546e31aa8edf4e/pandas-2.3.3-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ecaf1e12bdc03c86ad4a7ea848d66c685cb6851d807a26aa245ca3d2017a1908", size = 11737212, upload-time = "2025-09-29T23:19:59.765Z" }, + { url = "https://files.pythonhosted.org/packages/e5/63/cd7d615331b328e287d8233ba9fdf191a9c2d11b6af0c7a59cfcec23de68/pandas-2.3.3-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b3d11d2fda7eb164ef27ffc14b4fcab16a80e1ce67e9f57e19ec0afaf715ba89", size = 12362693, upload-time = "2025-09-29T23:20:14.098Z" }, + { url = "https://files.pythonhosted.org/packages/a6/de/8b1895b107277d52f2b42d3a6806e69cfef0d5cf1d0ba343470b9d8e0a04/pandas-2.3.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a68e15f780eddf2b07d242e17a04aa187a7ee12b40b930bfdd78070556550e98", size = 12771002, upload-time = "2025-09-29T23:20:26.76Z" }, + { url = "https://files.pythonhosted.org/packages/87/21/84072af3187a677c5893b170ba2c8fbe450a6ff911234916da889b698220/pandas-2.3.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:371a4ab48e950033bcf52b6527eccb564f52dc826c02afd9a1bc0ab731bba084", size = 13450971, upload-time = "2025-09-29T23:20:41.344Z" }, + { url = "https://files.pythonhosted.org/packages/86/41/585a168330ff063014880a80d744219dbf1dd7a1c706e75ab3425a987384/pandas-2.3.3-cp312-cp312-win_amd64.whl", hash = "sha256:a16dcec078a01eeef8ee61bf64074b4e524a2a3f4b3be9326420cabe59c4778b", size = 10992722, upload-time = "2025-09-29T23:20:54.139Z" }, +] + +[[package]] +name = "pandas" +version = "3.0.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12' and sys_platform == 'win32'", + "python_full_version == '3.11.*' and sys_platform == 'win32'", + "python_full_version >= '3.12' and sys_platform == 'emscripten'", + "python_full_version == '3.11.*' and sys_platform == 'emscripten'", + "python_full_version >= '3.12' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", +] +dependencies = [ + { name = "numpy", version = "2.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "python-dateutil", marker = "python_full_version >= '3.11'" }, + { name = "tzdata", marker = "(python_full_version >= '3.11' and sys_platform == 'emscripten') or (python_full_version >= '3.11' and sys_platform == 'win32')" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2e/0c/b28ed414f080ee0ad153f848586d61d1878f91689950f037f976ce15f6c8/pandas-3.0.1.tar.gz", hash = "sha256:4186a699674af418f655dbd420ed87f50d56b4cd6603784279d9eef6627823c8", size = 4641901, upload-time = "2026-02-17T22:20:16.434Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ff/07/c7087e003ceee9b9a82539b40414ec557aa795b584a1a346e89180853d79/pandas-3.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:de09668c1bf3b925c07e5762291602f0d789eca1b3a781f99c1c78f6cac0e7ea", size = 10323380, upload-time = "2026-02-17T22:18:16.133Z" }, + { url = "https://files.pythonhosted.org/packages/c1/27/90683c7122febeefe84a56f2cde86a9f05f68d53885cebcc473298dfc33e/pandas-3.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:24ba315ba3d6e5806063ac6eb717504e499ce30bd8c236d8693a5fd3f084c796", size = 9923455, upload-time = "2026-02-17T22:18:19.13Z" }, + { url = "https://files.pythonhosted.org/packages/0e/f1/ed17d927f9950643bc7631aa4c99ff0cc83a37864470bc419345b656a41f/pandas-3.0.1-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:406ce835c55bac912f2a0dcfaf27c06d73c6b04a5dde45f1fd3169ce31337389", size = 10753464, upload-time = "2026-02-17T22:18:21.134Z" }, + { url = "https://files.pythonhosted.org/packages/2e/7c/870c7e7daec2a6c7ff2ac9e33b23317230d4e4e954b35112759ea4a924a7/pandas-3.0.1-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:830994d7e1f31dd7e790045235605ab61cff6c94defc774547e8b7fdfbff3dc7", size = 11255234, upload-time = "2026-02-17T22:18:24.175Z" }, + { url = "https://files.pythonhosted.org/packages/5c/39/3653fe59af68606282b989c23d1a543ceba6e8099cbcc5f1d506a7bae2aa/pandas-3.0.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a64ce8b0f2de1d2efd2ae40b0abe7f8ae6b29fbfb3812098ed5a6f8e235ad9bf", size = 11767299, upload-time = "2026-02-17T22:18:26.824Z" }, + { url = "https://files.pythonhosted.org/packages/9b/31/1daf3c0c94a849c7a8dab8a69697b36d313b229918002ba3e409265c7888/pandas-3.0.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9832c2c69da24b602c32e0c7b1b508a03949c18ba08d4d9f1c1033426685b447", size = 12333292, upload-time = "2026-02-17T22:18:28.996Z" }, + { url = "https://files.pythonhosted.org/packages/1f/67/af63f83cd6ca603a00fe8530c10a60f0879265b8be00b5930e8e78c5b30b/pandas-3.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:84f0904a69e7365f79a0c77d3cdfccbfb05bf87847e3a51a41e1426b0edb9c79", size = 9892176, upload-time = "2026-02-17T22:18:31.79Z" }, + { url = "https://files.pythonhosted.org/packages/79/ab/9c776b14ac4b7b4140788eca18468ea39894bc7340a408f1d1e379856a6b/pandas-3.0.1-cp311-cp311-win_arm64.whl", hash = "sha256:4a68773d5a778afb31d12e34f7dd4612ab90de8c6fb1d8ffe5d4a03b955082a1", size = 9151328, upload-time = "2026-02-17T22:18:35.721Z" }, + { url = "https://files.pythonhosted.org/packages/37/51/b467209c08dae2c624873d7491ea47d2b47336e5403309d433ea79c38571/pandas-3.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:476f84f8c20c9f5bc47252b66b4bb25e1a9fc2fa98cead96744d8116cb85771d", size = 10344357, upload-time = "2026-02-17T22:18:38.262Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f1/e2567ffc8951ab371db2e40b2fe068e36b81d8cf3260f06ae508700e5504/pandas-3.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0ab749dfba921edf641d4036c4c21c0b3ea70fea478165cb98a998fb2a261955", size = 9884543, upload-time = "2026-02-17T22:18:41.476Z" }, + { url = "https://files.pythonhosted.org/packages/d7/39/327802e0b6d693182403c144edacbc27eb82907b57062f23ef5a4c4a5ea7/pandas-3.0.1-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b8e36891080b87823aff3640c78649b91b8ff6eea3c0d70aeabd72ea43ab069b", size = 10396030, upload-time = "2026-02-17T22:18:43.822Z" }, + { url = "https://files.pythonhosted.org/packages/3d/fe/89d77e424365280b79d99b3e1e7d606f5165af2f2ecfaf0c6d24c799d607/pandas-3.0.1-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:532527a701281b9dd371e2f582ed9094f4c12dd9ffb82c0c54ee28d8ac9520c4", size = 10876435, upload-time = "2026-02-17T22:18:45.954Z" }, + { url = "https://files.pythonhosted.org/packages/b5/a6/2a75320849dd154a793f69c951db759aedb8d1dd3939eeacda9bdcfa1629/pandas-3.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:356e5c055ed9b0da1580d465657bc7d00635af4fd47f30afb23025352ba764d1", size = 11405133, upload-time = "2026-02-17T22:18:48.533Z" }, + { url = "https://files.pythonhosted.org/packages/58/53/1d68fafb2e02d7881df66aa53be4cd748d25cbe311f3b3c85c93ea5d30ca/pandas-3.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:9d810036895f9ad6345b8f2a338dd6998a74e8483847403582cab67745bff821", size = 11932065, upload-time = "2026-02-17T22:18:50.837Z" }, + { url = "https://files.pythonhosted.org/packages/75/08/67cc404b3a966b6df27b38370ddd96b3b023030b572283d035181854aac5/pandas-3.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:536232a5fe26dd989bd633e7a0c450705fdc86a207fec7254a55e9a22950fe43", size = 9741627, upload-time = "2026-02-17T22:18:53.905Z" }, + { url = "https://files.pythonhosted.org/packages/86/4f/caf9952948fb00d23795f09b893d11f1cacb384e666854d87249530f7cbe/pandas-3.0.1-cp312-cp312-win_arm64.whl", hash = "sha256:0f463ebfd8de7f326d38037c7363c6dacb857c5881ab8961fb387804d6daf2f7", size = 9052483, upload-time = "2026-02-17T22:18:57.31Z" }, +] + +[[package]] +name = "pastel" +version = "0.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/76/f1/4594f5e0fcddb6953e5b8fe00da8c317b8b41b547e2b3ae2da7512943c62/pastel-0.2.1.tar.gz", hash = "sha256:e6581ac04e973cac858828c6202c1e1e81fee1dc7de7683f3e1ffe0bfd8a573d", size = 7555, upload-time = "2020-09-16T19:21:12.43Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/aa/18/a8444036c6dd65ba3624c63b734d3ba95ba63ace513078e1580590075d21/pastel-0.2.1-py2.py3-none-any.whl", hash = "sha256:4349225fcdf6c2bb34d483e523475de5bb04a5c10ef711263452cb37d7dd4364", size = 5955, upload-time = "2020-09-16T19:21:11.409Z" }, +] + +[[package]] +name = "pathspec" +version = "1.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fa/36/e27608899f9b8d4dff0617b2d9ab17ca5608956ca44461ac14ac48b44015/pathspec-1.0.4.tar.gz", hash = "sha256:0210e2ae8a21a9137c0d470578cb0e595af87edaa6ebf12ff176f14a02e0e645", size = 131200, upload-time = "2026-01-27T03:59:46.938Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/3c/2c197d226f9ea224a9ab8d197933f9da0ae0aac5b6e0f884e2b8d9c8e9f7/pathspec-1.0.4-py3-none-any.whl", hash = "sha256:fb6ae2fd4e7c921a165808a552060e722767cfa526f99ca5156ed2ce45a5c723", size = 55206, upload-time = "2026-01-27T03:59:45.137Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.9.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/04/fea538adf7dbbd6d186f551d595961e564a3b6715bdf276b477460858672/platformdirs-4.9.2.tar.gz", hash = "sha256:9a33809944b9db043ad67ca0db94b14bf452cc6aeaac46a88ea55b26e2e9d291", size = 28394, upload-time = "2026-02-16T03:56:10.574Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/48/31/05e764397056194206169869b50cf2fee4dbbbc71b344705b9c0d878d4d8/platformdirs-4.9.2-py3-none-any.whl", hash = "sha256:9170634f126f8efdae22fb58ae8a0eaa86f38365bc57897a6c4f781d1f5875bd", size = 21168, upload-time = "2026-02-16T03:56:08.891Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "poethepoet" +version = "0.41.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pastel" }, + { name = "pyyaml" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a8/b9/fa92286560f70eaa40d473ea48376d20c6c21f63627d33c6bb1c5e385175/poethepoet-0.41.0.tar.gz", hash = "sha256:dcaad621dc061f6a90b17d091bebb9ca043d67bfe9bd6aa4185aea3ebf7ff3e6", size = 87780, upload-time = "2026-02-08T20:45:36.061Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/5e/0b83e0222ce5921b3f9081eeca8c6fb3e1cfd5ca0d06338adf93b28ce061/poethepoet-0.41.0-py3-none-any.whl", hash = "sha256:4bab9fd8271664c5d21407e8f12827daeb6aa484dc6cc7620f0c3b4e62b42ee4", size = 113590, upload-time = "2026-02-08T20:45:34.697Z" }, +] + +[[package]] +name = "pre-commit" +version = "4.5.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cfgv" }, + { name = "identify" }, + { name = "nodeenv" }, + { name = "pyyaml" }, + { name = "virtualenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/40/f1/6d86a29246dfd2e9b6237f0b5823717f60cad94d47ddc26afa916d21f525/pre_commit-4.5.1.tar.gz", hash = "sha256:eb545fcff725875197837263e977ea257a402056661f09dae08e4b149b030a61", size = 198232, upload-time = "2025-12-16T21:14:33.552Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/19/fd3ef348460c80af7bb4669ea7926651d1f95c23ff2df18b9d24bab4f3fa/pre_commit-4.5.1-py2.py3-none-any.whl", hash = "sha256:3b3afd891e97337708c1674210f8eba659b52a38ea5f822ff142d10786221f77", size = 226437, upload-time = "2025-12-16T21:14:32.409Z" }, +] + +[[package]] +name = "propcache" +version = "0.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9e/da/e9fc233cf63743258bff22b3dfa7ea5baef7b5bc324af47a0ad89b8ffc6f/propcache-0.4.1.tar.gz", hash = "sha256:f48107a8c637e80362555f37ecf49abe20370e557cc4ab374f04ec4423c97c3d", size = 46442, upload-time = "2025-10-08T19:49:02.291Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/0e/934b541323035566a9af292dba85a195f7b78179114f2c6ebb24551118a9/propcache-0.4.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7c2d1fa3201efaf55d730400d945b5b3ab6e672e100ba0f9a409d950ab25d7db", size = 79534, upload-time = "2025-10-08T19:46:02.083Z" }, + { url = "https://files.pythonhosted.org/packages/a1/6b/db0d03d96726d995dc7171286c6ba9d8d14251f37433890f88368951a44e/propcache-0.4.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:1eb2994229cc8ce7fe9b3db88f5465f5fd8651672840b2e426b88cdb1a30aac8", size = 45526, upload-time = "2025-10-08T19:46:03.884Z" }, + { url = "https://files.pythonhosted.org/packages/e4/c3/82728404aea669e1600f304f2609cde9e665c18df5a11cdd57ed73c1dceb/propcache-0.4.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:66c1f011f45a3b33d7bcb22daed4b29c0c9e2224758b6be00686731e1b46f925", size = 47263, upload-time = "2025-10-08T19:46:05.405Z" }, + { url = "https://files.pythonhosted.org/packages/df/1b/39313ddad2bf9187a1432654c38249bab4562ef535ef07f5eb6eb04d0b1b/propcache-0.4.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9a52009f2adffe195d0b605c25ec929d26b36ef986ba85244891dee3b294df21", size = 201012, upload-time = "2025-10-08T19:46:07.165Z" }, + { url = "https://files.pythonhosted.org/packages/5b/01/f1d0b57d136f294a142acf97f4ed58c8e5b974c21e543000968357115011/propcache-0.4.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5d4e2366a9c7b837555cf02fb9be2e3167d333aff716332ef1b7c3a142ec40c5", size = 209491, upload-time = "2025-10-08T19:46:08.909Z" }, + { url = "https://files.pythonhosted.org/packages/a1/c8/038d909c61c5bb039070b3fb02ad5cccdb1dde0d714792e251cdb17c9c05/propcache-0.4.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9d2b6caef873b4f09e26ea7e33d65f42b944837563a47a94719cc3544319a0db", size = 215319, upload-time = "2025-10-08T19:46:10.7Z" }, + { url = "https://files.pythonhosted.org/packages/08/57/8c87e93142b2c1fa2408e45695205a7ba05fb5db458c0bf5c06ba0e09ea6/propcache-0.4.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2b16ec437a8c8a965ecf95739448dd938b5c7f56e67ea009f4300d8df05f32b7", size = 196856, upload-time = "2025-10-08T19:46:12.003Z" }, + { url = "https://files.pythonhosted.org/packages/42/df/5615fec76aa561987a534759b3686008a288e73107faa49a8ae5795a9f7a/propcache-0.4.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:296f4c8ed03ca7476813fe666c9ea97869a8d7aec972618671b33a38a5182ef4", size = 193241, upload-time = "2025-10-08T19:46:13.495Z" }, + { url = "https://files.pythonhosted.org/packages/d5/21/62949eb3a7a54afe8327011c90aca7e03547787a88fb8bd9726806482fea/propcache-0.4.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:1f0978529a418ebd1f49dad413a2b68af33f85d5c5ca5c6ca2a3bed375a7ac60", size = 190552, upload-time = "2025-10-08T19:46:14.938Z" }, + { url = "https://files.pythonhosted.org/packages/30/ee/ab4d727dd70806e5b4de96a798ae7ac6e4d42516f030ee60522474b6b332/propcache-0.4.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:fd138803047fb4c062b1c1dd95462f5209456bfab55c734458f15d11da288f8f", size = 200113, upload-time = "2025-10-08T19:46:16.695Z" }, + { url = "https://files.pythonhosted.org/packages/8a/0b/38b46208e6711b016aa8966a3ac793eee0d05c7159d8342aa27fc0bc365e/propcache-0.4.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:8c9b3cbe4584636d72ff556d9036e0c9317fa27b3ac1f0f558e7e84d1c9c5900", size = 200778, upload-time = "2025-10-08T19:46:18.023Z" }, + { url = "https://files.pythonhosted.org/packages/cf/81/5abec54355ed344476bee711e9f04815d4b00a311ab0535599204eecc257/propcache-0.4.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f93243fdc5657247533273ac4f86ae106cc6445a0efacb9a1bfe982fcfefd90c", size = 193047, upload-time = "2025-10-08T19:46:19.449Z" }, + { url = "https://files.pythonhosted.org/packages/ec/b6/1f237c04e32063cb034acd5f6ef34ef3a394f75502e72703545631ab1ef6/propcache-0.4.1-cp310-cp310-win32.whl", hash = "sha256:a0ee98db9c5f80785b266eb805016e36058ac72c51a064040f2bc43b61101cdb", size = 38093, upload-time = "2025-10-08T19:46:20.643Z" }, + { url = "https://files.pythonhosted.org/packages/a6/67/354aac4e0603a15f76439caf0427781bcd6797f370377f75a642133bc954/propcache-0.4.1-cp310-cp310-win_amd64.whl", hash = "sha256:1cdb7988c4e5ac7f6d175a28a9aa0c94cb6f2ebe52756a3c0cda98d2809a9e37", size = 41638, upload-time = "2025-10-08T19:46:21.935Z" }, + { url = "https://files.pythonhosted.org/packages/e0/e1/74e55b9fd1a4c209ff1a9a824bf6c8b3d1fc5a1ac3eabe23462637466785/propcache-0.4.1-cp310-cp310-win_arm64.whl", hash = "sha256:d82ad62b19645419fe79dd63b3f9253e15b30e955c0170e5cebc350c1844e581", size = 38229, upload-time = "2025-10-08T19:46:23.368Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d4/4e2c9aaf7ac2242b9358f98dccd8f90f2605402f5afeff6c578682c2c491/propcache-0.4.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:60a8fda9644b7dfd5dece8c61d8a85e271cb958075bfc4e01083c148b61a7caf", size = 80208, upload-time = "2025-10-08T19:46:24.597Z" }, + { url = "https://files.pythonhosted.org/packages/c2/21/d7b68e911f9c8e18e4ae43bdbc1e1e9bbd971f8866eb81608947b6f585ff/propcache-0.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c30b53e7e6bda1d547cabb47c825f3843a0a1a42b0496087bb58d8fedf9f41b5", size = 45777, upload-time = "2025-10-08T19:46:25.733Z" }, + { url = "https://files.pythonhosted.org/packages/d3/1d/11605e99ac8ea9435651ee71ab4cb4bf03f0949586246476a25aadfec54a/propcache-0.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6918ecbd897443087a3b7cd978d56546a812517dcaaca51b49526720571fa93e", size = 47647, upload-time = "2025-10-08T19:46:27.304Z" }, + { url = "https://files.pythonhosted.org/packages/58/1a/3c62c127a8466c9c843bccb503d40a273e5cc69838805f322e2826509e0d/propcache-0.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3d902a36df4e5989763425a8ab9e98cd8ad5c52c823b34ee7ef307fd50582566", size = 214929, upload-time = "2025-10-08T19:46:28.62Z" }, + { url = "https://files.pythonhosted.org/packages/56/b9/8fa98f850960b367c4b8fe0592e7fc341daa7a9462e925228f10a60cf74f/propcache-0.4.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a9695397f85973bb40427dedddf70d8dc4a44b22f1650dd4af9eedf443d45165", size = 221778, upload-time = "2025-10-08T19:46:30.358Z" }, + { url = "https://files.pythonhosted.org/packages/46/a6/0ab4f660eb59649d14b3d3d65c439421cf2f87fe5dd68591cbe3c1e78a89/propcache-0.4.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2bb07ffd7eaad486576430c89f9b215f9e4be68c4866a96e97db9e97fead85dc", size = 228144, upload-time = "2025-10-08T19:46:32.607Z" }, + { url = "https://files.pythonhosted.org/packages/52/6a/57f43e054fb3d3a56ac9fc532bc684fc6169a26c75c353e65425b3e56eef/propcache-0.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fd6f30fdcf9ae2a70abd34da54f18da086160e4d7d9251f81f3da0ff84fc5a48", size = 210030, upload-time = "2025-10-08T19:46:33.969Z" }, + { url = "https://files.pythonhosted.org/packages/40/e2/27e6feebb5f6b8408fa29f5efbb765cd54c153ac77314d27e457a3e993b7/propcache-0.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:fc38cba02d1acba4e2869eef1a57a43dfbd3d49a59bf90dda7444ec2be6a5570", size = 208252, upload-time = "2025-10-08T19:46:35.309Z" }, + { url = "https://files.pythonhosted.org/packages/9e/f8/91c27b22ccda1dbc7967f921c42825564fa5336a01ecd72eb78a9f4f53c2/propcache-0.4.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:67fad6162281e80e882fb3ec355398cf72864a54069d060321f6cd0ade95fe85", size = 202064, upload-time = "2025-10-08T19:46:36.993Z" }, + { url = "https://files.pythonhosted.org/packages/f2/26/7f00bd6bd1adba5aafe5f4a66390f243acab58eab24ff1a08bebb2ef9d40/propcache-0.4.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f10207adf04d08bec185bae14d9606a1444715bc99180f9331c9c02093e1959e", size = 212429, upload-time = "2025-10-08T19:46:38.398Z" }, + { url = "https://files.pythonhosted.org/packages/84/89/fd108ba7815c1117ddca79c228f3f8a15fc82a73bca8b142eb5de13b2785/propcache-0.4.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:e9b0d8d0845bbc4cfcdcbcdbf5086886bc8157aa963c31c777ceff7846c77757", size = 216727, upload-time = "2025-10-08T19:46:39.732Z" }, + { url = "https://files.pythonhosted.org/packages/79/37/3ec3f7e3173e73f1d600495d8b545b53802cbf35506e5732dd8578db3724/propcache-0.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:981333cb2f4c1896a12f4ab92a9cc8f09ea664e9b7dbdc4eff74627af3a11c0f", size = 205097, upload-time = "2025-10-08T19:46:41.025Z" }, + { url = "https://files.pythonhosted.org/packages/61/b0/b2631c19793f869d35f47d5a3a56fb19e9160d3c119f15ac7344fc3ccae7/propcache-0.4.1-cp311-cp311-win32.whl", hash = "sha256:f1d2f90aeec838a52f1c1a32fe9a619fefd5e411721a9117fbf82aea638fe8a1", size = 38084, upload-time = "2025-10-08T19:46:42.693Z" }, + { url = "https://files.pythonhosted.org/packages/f4/78/6cce448e2098e9f3bfc91bb877f06aa24b6ccace872e39c53b2f707c4648/propcache-0.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:364426a62660f3f699949ac8c621aad6977be7126c5807ce48c0aeb8e7333ea6", size = 41637, upload-time = "2025-10-08T19:46:43.778Z" }, + { url = "https://files.pythonhosted.org/packages/9c/e9/754f180cccd7f51a39913782c74717c581b9cc8177ad0e949f4d51812383/propcache-0.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:e53f3a38d3510c11953f3e6a33f205c6d1b001129f972805ca9b42fc308bc239", size = 38064, upload-time = "2025-10-08T19:46:44.872Z" }, + { url = "https://files.pythonhosted.org/packages/a2/0f/f17b1b2b221d5ca28b4b876e8bb046ac40466513960646bda8e1853cdfa2/propcache-0.4.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e153e9cd40cc8945138822807139367f256f89c6810c2634a4f6902b52d3b4e2", size = 80061, upload-time = "2025-10-08T19:46:46.075Z" }, + { url = "https://files.pythonhosted.org/packages/76/47/8ccf75935f51448ba9a16a71b783eb7ef6b9ee60f5d14c7f8a8a79fbeed7/propcache-0.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:cd547953428f7abb73c5ad82cbb32109566204260d98e41e5dfdc682eb7f8403", size = 46037, upload-time = "2025-10-08T19:46:47.23Z" }, + { url = "https://files.pythonhosted.org/packages/0a/b6/5c9a0e42df4d00bfb4a3cbbe5cf9f54260300c88a0e9af1f47ca5ce17ac0/propcache-0.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f048da1b4f243fc44f205dfd320933a951b8d89e0afd4c7cacc762a8b9165207", size = 47324, upload-time = "2025-10-08T19:46:48.384Z" }, + { url = "https://files.pythonhosted.org/packages/9e/d3/6c7ee328b39a81ee877c962469f1e795f9db87f925251efeb0545e0020d0/propcache-0.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ec17c65562a827bba85e3872ead335f95405ea1674860d96483a02f5c698fa72", size = 225505, upload-time = "2025-10-08T19:46:50.055Z" }, + { url = "https://files.pythonhosted.org/packages/01/5d/1c53f4563490b1d06a684742cc6076ef944bc6457df6051b7d1a877c057b/propcache-0.4.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:405aac25c6394ef275dee4c709be43745d36674b223ba4eb7144bf4d691b7367", size = 230242, upload-time = "2025-10-08T19:46:51.815Z" }, + { url = "https://files.pythonhosted.org/packages/20/e1/ce4620633b0e2422207c3cb774a0ee61cac13abc6217763a7b9e2e3f4a12/propcache-0.4.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0013cb6f8dde4b2a2f66903b8ba740bdfe378c943c4377a200551ceb27f379e4", size = 238474, upload-time = "2025-10-08T19:46:53.208Z" }, + { url = "https://files.pythonhosted.org/packages/46/4b/3aae6835b8e5f44ea6a68348ad90f78134047b503765087be2f9912140ea/propcache-0.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15932ab57837c3368b024473a525e25d316d8353016e7cc0e5ba9eb343fbb1cf", size = 221575, upload-time = "2025-10-08T19:46:54.511Z" }, + { url = "https://files.pythonhosted.org/packages/6e/a5/8a5e8678bcc9d3a1a15b9a29165640d64762d424a16af543f00629c87338/propcache-0.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:031dce78b9dc099f4c29785d9cf5577a3faf9ebf74ecbd3c856a7b92768c3df3", size = 216736, upload-time = "2025-10-08T19:46:56.212Z" }, + { url = "https://files.pythonhosted.org/packages/f1/63/b7b215eddeac83ca1c6b934f89d09a625aa9ee4ba158338854c87210cc36/propcache-0.4.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:ab08df6c9a035bee56e31af99be621526bd237bea9f32def431c656b29e41778", size = 213019, upload-time = "2025-10-08T19:46:57.595Z" }, + { url = "https://files.pythonhosted.org/packages/57/74/f580099a58c8af587cac7ba19ee7cb418506342fbbe2d4a4401661cca886/propcache-0.4.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4d7af63f9f93fe593afbf104c21b3b15868efb2c21d07d8732c0c4287e66b6a6", size = 220376, upload-time = "2025-10-08T19:46:59.067Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ee/542f1313aff7eaf19c2bb758c5d0560d2683dac001a1c96d0774af799843/propcache-0.4.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:cfc27c945f422e8b5071b6e93169679e4eb5bf73bbcbf1ba3ae3a83d2f78ebd9", size = 226988, upload-time = "2025-10-08T19:47:00.544Z" }, + { url = "https://files.pythonhosted.org/packages/8f/18/9c6b015dd9c6930f6ce2229e1f02fb35298b847f2087ea2b436a5bfa7287/propcache-0.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:35c3277624a080cc6ec6f847cbbbb5b49affa3598c4535a0a4682a697aaa5c75", size = 215615, upload-time = "2025-10-08T19:47:01.968Z" }, + { url = "https://files.pythonhosted.org/packages/80/9e/e7b85720b98c45a45e1fca6a177024934dc9bc5f4d5dd04207f216fc33ed/propcache-0.4.1-cp312-cp312-win32.whl", hash = "sha256:671538c2262dadb5ba6395e26c1731e1d52534bfe9ae56d0b5573ce539266aa8", size = 38066, upload-time = "2025-10-08T19:47:03.503Z" }, + { url = "https://files.pythonhosted.org/packages/54/09/d19cff2a5aaac632ec8fc03737b223597b1e347416934c1b3a7df079784c/propcache-0.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:cb2d222e72399fcf5890d1d5cc1060857b9b236adff2792ff48ca2dfd46c81db", size = 41655, upload-time = "2025-10-08T19:47:04.973Z" }, + { url = "https://files.pythonhosted.org/packages/68/ab/6b5c191bb5de08036a8c697b265d4ca76148efb10fa162f14af14fb5f076/propcache-0.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:204483131fb222bdaaeeea9f9e6c6ed0cac32731f75dfc1d4a567fc1926477c1", size = 37789, upload-time = "2025-10-08T19:47:06.077Z" }, + { url = "https://files.pythonhosted.org/packages/5b/5a/bc7b4a4ef808fa59a816c17b20c4bef6884daebbdf627ff2a161da67da19/propcache-0.4.1-py3-none-any.whl", hash = "sha256:af2a6052aeb6cf17d3e46ee169099044fd8224cbaf75c76a2ef596e8163e2237", size = 13305, upload-time = "2025-10-08T19:49:00.792Z" }, +] + +[[package]] +name = "pydantic" +version = "2.12.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.41.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c6/90/32c9941e728d564b411d574d8ee0cf09b12ec978cb22b294995bae5549a5/pydantic_core-2.41.5-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:77b63866ca88d804225eaa4af3e664c5faf3568cea95360d21f4725ab6e07146", size = 2107298, upload-time = "2025-11-04T13:39:04.116Z" }, + { url = "https://files.pythonhosted.org/packages/fb/a8/61c96a77fe28993d9a6fb0f4127e05430a267b235a124545d79fea46dd65/pydantic_core-2.41.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:dfa8a0c812ac681395907e71e1274819dec685fec28273a28905df579ef137e2", size = 1901475, upload-time = "2025-11-04T13:39:06.055Z" }, + { url = "https://files.pythonhosted.org/packages/5d/b6/338abf60225acc18cdc08b4faef592d0310923d19a87fba1faf05af5346e/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5921a4d3ca3aee735d9fd163808f5e8dd6c6972101e4adbda9a4667908849b97", size = 1918815, upload-time = "2025-11-04T13:39:10.41Z" }, + { url = "https://files.pythonhosted.org/packages/d1/1c/2ed0433e682983d8e8cba9c8d8ef274d4791ec6a6f24c58935b90e780e0a/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e25c479382d26a2a41b7ebea1043564a937db462816ea07afa8a44c0866d52f9", size = 2065567, upload-time = "2025-11-04T13:39:12.244Z" }, + { url = "https://files.pythonhosted.org/packages/b3/24/cf84974ee7d6eae06b9e63289b7b8f6549d416b5c199ca2d7ce13bbcf619/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f547144f2966e1e16ae626d8ce72b4cfa0caedc7fa28052001c94fb2fcaa1c52", size = 2230442, upload-time = "2025-11-04T13:39:13.962Z" }, + { url = "https://files.pythonhosted.org/packages/fd/21/4e287865504b3edc0136c89c9c09431be326168b1eb7841911cbc877a995/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6f52298fbd394f9ed112d56f3d11aabd0d5bd27beb3084cc3d8ad069483b8941", size = 2350956, upload-time = "2025-11-04T13:39:15.889Z" }, + { url = "https://files.pythonhosted.org/packages/a8/76/7727ef2ffa4b62fcab916686a68a0426b9b790139720e1934e8ba797e238/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:100baa204bb412b74fe285fb0f3a385256dad1d1879f0a5cb1499ed2e83d132a", size = 2068253, upload-time = "2025-11-04T13:39:17.403Z" }, + { url = "https://files.pythonhosted.org/packages/d5/8c/a4abfc79604bcb4c748e18975c44f94f756f08fb04218d5cb87eb0d3a63e/pydantic_core-2.41.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:05a2c8852530ad2812cb7914dc61a1125dc4e06252ee98e5638a12da6cc6fb6c", size = 2177050, upload-time = "2025-11-04T13:39:19.351Z" }, + { url = "https://files.pythonhosted.org/packages/67/b1/de2e9a9a79b480f9cb0b6e8b6ba4c50b18d4e89852426364c66aa82bb7b3/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:29452c56df2ed968d18d7e21f4ab0ac55e71dc59524872f6fc57dcf4a3249ed2", size = 2147178, upload-time = "2025-11-04T13:39:21Z" }, + { url = "https://files.pythonhosted.org/packages/16/c1/dfb33f837a47b20417500efaa0378adc6635b3c79e8369ff7a03c494b4ac/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:d5160812ea7a8a2ffbe233d8da666880cad0cbaf5d4de74ae15c313213d62556", size = 2341833, upload-time = "2025-11-04T13:39:22.606Z" }, + { url = "https://files.pythonhosted.org/packages/47/36/00f398642a0f4b815a9a558c4f1dca1b4020a7d49562807d7bc9ff279a6c/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:df3959765b553b9440adfd3c795617c352154e497a4eaf3752555cfb5da8fc49", size = 2321156, upload-time = "2025-11-04T13:39:25.843Z" }, + { url = "https://files.pythonhosted.org/packages/7e/70/cad3acd89fde2010807354d978725ae111ddf6d0ea46d1ea1775b5c1bd0c/pydantic_core-2.41.5-cp310-cp310-win32.whl", hash = "sha256:1f8d33a7f4d5a7889e60dc39856d76d09333d8a6ed0f5f1190635cbec70ec4ba", size = 1989378, upload-time = "2025-11-04T13:39:27.92Z" }, + { url = "https://files.pythonhosted.org/packages/76/92/d338652464c6c367e5608e4488201702cd1cbb0f33f7b6a85a60fe5f3720/pydantic_core-2.41.5-cp310-cp310-win_amd64.whl", hash = "sha256:62de39db01b8d593e45871af2af9e497295db8d73b085f6bfd0b18c83c70a8f9", size = 2013622, upload-time = "2025-11-04T13:39:29.848Z" }, + { url = "https://files.pythonhosted.org/packages/e8/72/74a989dd9f2084b3d9530b0915fdda64ac48831c30dbf7c72a41a5232db8/pydantic_core-2.41.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6", size = 2105873, upload-time = "2025-11-04T13:39:31.373Z" }, + { url = "https://files.pythonhosted.org/packages/12/44/37e403fd9455708b3b942949e1d7febc02167662bf1a7da5b78ee1ea2842/pydantic_core-2.41.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b", size = 1899826, upload-time = "2025-11-04T13:39:32.897Z" }, + { url = "https://files.pythonhosted.org/packages/33/7f/1d5cab3ccf44c1935a359d51a8a2a9e1a654b744b5e7f80d41b88d501eec/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a", size = 1917869, upload-time = "2025-11-04T13:39:34.469Z" }, + { url = "https://files.pythonhosted.org/packages/6e/6a/30d94a9674a7fe4f4744052ed6c5e083424510be1e93da5bc47569d11810/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8", size = 2063890, upload-time = "2025-11-04T13:39:36.053Z" }, + { url = "https://files.pythonhosted.org/packages/50/be/76e5d46203fcb2750e542f32e6c371ffa9b8ad17364cf94bb0818dbfb50c/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e", size = 2229740, upload-time = "2025-11-04T13:39:37.753Z" }, + { url = "https://files.pythonhosted.org/packages/d3/ee/fed784df0144793489f87db310a6bbf8118d7b630ed07aa180d6067e653a/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1", size = 2350021, upload-time = "2025-11-04T13:39:40.94Z" }, + { url = "https://files.pythonhosted.org/packages/c8/be/8fed28dd0a180dca19e72c233cbf58efa36df055e5b9d90d64fd1740b828/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b", size = 2066378, upload-time = "2025-11-04T13:39:42.523Z" }, + { url = "https://files.pythonhosted.org/packages/b0/3b/698cf8ae1d536a010e05121b4958b1257f0b5522085e335360e53a6b1c8b/pydantic_core-2.41.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b", size = 2175761, upload-time = "2025-11-04T13:39:44.553Z" }, + { url = "https://files.pythonhosted.org/packages/b8/ba/15d537423939553116dea94ce02f9c31be0fa9d0b806d427e0308ec17145/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284", size = 2146303, upload-time = "2025-11-04T13:39:46.238Z" }, + { url = "https://files.pythonhosted.org/packages/58/7f/0de669bf37d206723795f9c90c82966726a2ab06c336deba4735b55af431/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594", size = 2340355, upload-time = "2025-11-04T13:39:48.002Z" }, + { url = "https://files.pythonhosted.org/packages/e5/de/e7482c435b83d7e3c3ee5ee4451f6e8973cff0eb6007d2872ce6383f6398/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e", size = 2319875, upload-time = "2025-11-04T13:39:49.705Z" }, + { url = "https://files.pythonhosted.org/packages/fe/e6/8c9e81bb6dd7560e33b9053351c29f30c8194b72f2d6932888581f503482/pydantic_core-2.41.5-cp311-cp311-win32.whl", hash = "sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b", size = 1987549, upload-time = "2025-11-04T13:39:51.842Z" }, + { url = "https://files.pythonhosted.org/packages/11/66/f14d1d978ea94d1bc21fc98fcf570f9542fe55bfcc40269d4e1a21c19bf7/pydantic_core-2.41.5-cp311-cp311-win_amd64.whl", hash = "sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe", size = 2011305, upload-time = "2025-11-04T13:39:53.485Z" }, + { url = "https://files.pythonhosted.org/packages/56/d8/0e271434e8efd03186c5386671328154ee349ff0354d83c74f5caaf096ed/pydantic_core-2.41.5-cp311-cp311-win_arm64.whl", hash = "sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f", size = 1972902, upload-time = "2025-11-04T13:39:56.488Z" }, + { url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" }, + { url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" }, + { url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" }, + { url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" }, + { url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" }, + { url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" }, + { url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" }, + { url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" }, + { url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" }, + { url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" }, + { url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" }, + { url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" }, + { url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" }, + { url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" }, + { url = "https://files.pythonhosted.org/packages/11/72/90fda5ee3b97e51c494938a4a44c3a35a9c96c19bba12372fb9c634d6f57/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b96d5f26b05d03cc60f11a7761a5ded1741da411e7fe0909e27a5e6a0cb7b034", size = 2115441, upload-time = "2025-11-04T13:42:39.557Z" }, + { url = "https://files.pythonhosted.org/packages/1f/53/8942f884fa33f50794f119012dc6a1a02ac43a56407adaac20463df8e98f/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:634e8609e89ceecea15e2d61bc9ac3718caaaa71963717bf3c8f38bfde64242c", size = 1930291, upload-time = "2025-11-04T13:42:42.169Z" }, + { url = "https://files.pythonhosted.org/packages/79/c8/ecb9ed9cd942bce09fc888ee960b52654fbdbede4ba6c2d6e0d3b1d8b49c/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93e8740d7503eb008aa2df04d3b9735f845d43ae845e6dcd2be0b55a2da43cd2", size = 1948632, upload-time = "2025-11-04T13:42:44.564Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1b/687711069de7efa6af934e74f601e2a4307365e8fdc404703afc453eab26/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f15489ba13d61f670dcc96772e733aad1a6f9c429cc27574c6cdaed82d0146ad", size = 2138905, upload-time = "2025-11-04T13:42:47.156Z" }, + { url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" }, + { url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" }, + { url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" }, + { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" }, + { url = "https://files.pythonhosted.org/packages/e6/b0/1a2aa41e3b5a4ba11420aba2d091b2d17959c8d1519ece3627c371951e73/pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b5819cd790dbf0c5eb9f82c73c16b39a65dd6dd4d1439dcdea7816ec9adddab8", size = 2103351, upload-time = "2025-11-04T13:43:02.058Z" }, + { url = "https://files.pythonhosted.org/packages/a4/ee/31b1f0020baaf6d091c87900ae05c6aeae101fa4e188e1613c80e4f1ea31/pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:5a4e67afbc95fa5c34cf27d9089bca7fcab4e51e57278d710320a70b956d1b9a", size = 1925363, upload-time = "2025-11-04T13:43:05.159Z" }, + { url = "https://files.pythonhosted.org/packages/e1/89/ab8e86208467e467a80deaca4e434adac37b10a9d134cd2f99b28a01e483/pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ece5c59f0ce7d001e017643d8d24da587ea1f74f6993467d85ae8a5ef9d4f42b", size = 2135615, upload-time = "2025-11-04T13:43:08.116Z" }, + { url = "https://files.pythonhosted.org/packages/99/0a/99a53d06dd0348b2008f2f30884b34719c323f16c3be4e6cc1203b74a91d/pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:16f80f7abe3351f8ea6858914ddc8c77e02578544a0ebc15b4c2e1a0e813b0b2", size = 2175369, upload-time = "2025-11-04T13:43:12.49Z" }, + { url = "https://files.pythonhosted.org/packages/6d/94/30ca3b73c6d485b9bb0bc66e611cff4a7138ff9736b7e66bcf0852151636/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:33cb885e759a705b426baada1fe68cbb0a2e68e34c5d0d0289a364cf01709093", size = 2144218, upload-time = "2025-11-04T13:43:15.431Z" }, + { url = "https://files.pythonhosted.org/packages/87/57/31b4f8e12680b739a91f472b5671294236b82586889ef764b5fbc6669238/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:c8d8b4eb992936023be7dee581270af5c6e0697a8559895f527f5b7105ecd36a", size = 2329951, upload-time = "2025-11-04T13:43:18.062Z" }, + { url = "https://files.pythonhosted.org/packages/7d/73/3c2c8edef77b8f7310e6fb012dbc4b8551386ed575b9eb6fb2506e28a7eb/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:242a206cd0318f95cd21bdacff3fcc3aab23e79bba5cac3db5a841c9ef9c6963", size = 2318428, upload-time = "2025-11-04T13:43:20.679Z" }, + { url = "https://files.pythonhosted.org/packages/2f/02/8559b1f26ee0d502c74f9cca5c0d2fd97e967e083e006bbbb4e97f3a043a/pydantic_core-2.41.5-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d3a978c4f57a597908b7e697229d996d77a6d3c94901e9edee593adada95ce1a", size = 2147009, upload-time = "2025-11-04T13:43:23.286Z" }, + { url = "https://files.pythonhosted.org/packages/5f/9b/1b3f0e9f9305839d7e84912f9e8bfbd191ed1b1ef48083609f0dabde978c/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26", size = 2101980, upload-time = "2025-11-04T13:43:25.97Z" }, + { url = "https://files.pythonhosted.org/packages/a4/ed/d71fefcb4263df0da6a85b5d8a7508360f2f2e9b3bf5814be9c8bccdccc1/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808", size = 1923865, upload-time = "2025-11-04T13:43:28.763Z" }, + { url = "https://files.pythonhosted.org/packages/ce/3a/626b38db460d675f873e4444b4bb030453bbe7b4ba55df821d026a0493c4/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc", size = 2134256, upload-time = "2025-11-04T13:43:31.71Z" }, + { url = "https://files.pythonhosted.org/packages/83/d9/8412d7f06f616bbc053d30cb4e5f76786af3221462ad5eee1f202021eb4e/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1", size = 2174762, upload-time = "2025-11-04T13:43:34.744Z" }, + { url = "https://files.pythonhosted.org/packages/55/4c/162d906b8e3ba3a99354e20faa1b49a85206c47de97a639510a0e673f5da/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84", size = 2143141, upload-time = "2025-11-04T13:43:37.701Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f2/f11dd73284122713f5f89fc940f370d035fa8e1e078d446b3313955157fe/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770", size = 2330317, upload-time = "2025-11-04T13:43:40.406Z" }, + { url = "https://files.pythonhosted.org/packages/88/9d/b06ca6acfe4abb296110fb1273a4d848a0bfb2ff65f3ee92127b3244e16b/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f", size = 2316992, upload-time = "2025-11-04T13:43:43.602Z" }, + { url = "https://files.pythonhosted.org/packages/36/c7/cfc8e811f061c841d7990b0201912c3556bfeb99cdcb7ed24adc8d6f8704/pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51", size = 2145302, upload-time = "2025-11-04T13:43:46.64Z" }, +] + +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "backports-asyncio-runner", marker = "python_full_version < '3.11'" }, + { name = "pytest" }, + { name = "typing-extensions" }, +] +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 = [ + { url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" }, +] + +[[package]] +name = "pytest-cov" +version = "7.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage", extra = ["toml"] }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5e/f7/c933acc76f5208b3b00089573cf6a2bc26dc80a8aece8f52bb7d6b1855ca/pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1", size = 54328, upload-time = "2025-09-09T10:57:02.113Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" }, +] + +[[package]] +name = "pytest-mock" +version = "3.15.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/68/14/eb014d26be205d38ad5ad20d9a80f7d201472e08167f0bb4361e251084a9/pytest_mock-3.15.1.tar.gz", hash = "sha256:1849a238f6f396da19762269de72cb1814ab44416fa73a8686deac10b0d87a0f", size = 34036, upload-time = "2025-09-16T16:37:27.081Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/cc/06253936f4a7fa2e0f48dfe6d851d9c56df896a9ab09ac019d70b760619c/pytest_mock-3.15.1-py3-none-any.whl", hash = "sha256:0a25e2eb88fe5168d535041d09a4529a188176ae608a6d249ee65abc0949630d", size = 10095, upload-time = "2025-09-16T16:37:25.734Z" }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + +[[package]] +name = "pytz" +version = "2025.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f8/bf/abbd3cdfb8fbc7fb3d4d38d320f2441b1e7cbe29be4f23797b4a2b5d8aac/pytz-2025.2.tar.gz", hash = "sha256:360b9e3dbb49a209c21ad61809c7fb453643e048b38924c765813546746e81c3", size = 320884, upload-time = "2025-03-25T02:25:00.538Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/c4/34e93fe5f5429d7570ec1fa436f1986fb1f00c3e0f43a589fe2bbcd22c3f/pytz-2025.2-py2.py3-none-any.whl", hash = "sha256:5ddf76296dd8c44c26eb8f4b6f35488f3ccbf6fbbd7adee0b7262d43f0ec2f00", size = 509225, upload-time = "2025-03-25T02:24:58.468Z" }, +] + +[[package]] +name = "pyyaml" +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/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227, upload-time = "2025-09-25T21:31:46.04Z" }, + { url = "https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", size = 174019, upload-time = "2025-09-25T21:31:47.706Z" }, + { url = "https://files.pythonhosted.org/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646, upload-time = "2025-09-25T21:31:49.21Z" }, + { url = "https://files.pythonhosted.org/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793, upload-time = "2025-09-25T21:31:50.735Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293, upload-time = "2025-09-25T21:31:51.828Z" }, + { url = "https://files.pythonhosted.org/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872, upload-time = "2025-09-25T21:31:53.282Z" }, + { url = "https://files.pythonhosted.org/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828, upload-time = "2025-09-25T21:31:54.807Z" }, + { url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415, upload-time = "2025-09-25T21:31:55.885Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561, upload-time = "2025-09-25T21:31:57.406Z" }, + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { 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" }, +] + +[[package]] +name = "requests" +version = "2.32.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, +] + +[[package]] +name = "roman-numerals" +version = "4.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ae/f9/41dc953bbeb056c17d5f7a519f50fdf010bd0553be2d630bc69d1e022703/roman_numerals-4.1.0.tar.gz", hash = "sha256:1af8b147eb1405d5839e78aeb93131690495fe9da5c91856cb33ad55a7f1e5b2", size = 9077, upload-time = "2025-12-17T18:25:34.381Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/54/6f679c435d28e0a568d8e8a7c0a93a09010818634c3c3907fc98d8983770/roman_numerals-4.1.0-py3-none-any.whl", hash = "sha256:647ba99caddc2cc1e55a51e4360689115551bf4476d90e8162cf8c345fe233c7", size = 7676, upload-time = "2025-12-17T18:25:33.098Z" }, +] + +[[package]] +name = "s2-python" +version = "0.8.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "pydantic" }, + { name = "pytz" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/36/dc/7c9f0537e574ee5fbd81d77f57951d728f8e08dcc580fb6cc2532b1d4180/s2_python-0.8.1.tar.gz", hash = "sha256:62cc6f7b98c57357c5d2c0cfdbe5fa9c9ee0951883645a30d5d4f3d9ae40b4a3", size = 81379, upload-time = "2025-11-26T14:02:36.603Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5e/be/6fcbc1ed2170db732079f80111e676e6c53018f4e762ea52d5f2cfd2b62d/s2_python-0.8.1-py3-none-any.whl", hash = "sha256:14608126b381bc793155f593de8688cf3d30d98893fe92f76db722e170b510be", size = 66860, upload-time = "2025-11-26T14:02:35.055Z" }, +] + +[[package]] +name = "semver" +version = "3.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/d1/d3159231aec234a59dd7d601e9dd9fe96f3afff15efd33c1070019b26132/semver-3.0.4.tar.gz", hash = "sha256:afc7d8c584a5ed0a11033af086e8af226a9c0b206f313e0301f8dd7b6b589602", size = 269730, upload-time = "2025-01-24T13:19:27.617Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a6/24/4d91e05817e92e3a61c8a21e08fd0f390f5301f1c448b137c57c4bc6e543/semver-3.0.4-py3-none-any.whl", hash = "sha256:9c824d87ba7f7ab4a1890799cec8596f15c1241cb473404ea1cb0c55e4b04746", size = 17912, upload-time = "2025-01-24T13:19:24.949Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "snowballstemmer" +version = "3.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/75/a7/9810d872919697c9d01295633f5d574fb416d47e535f258272ca1f01f447/snowballstemmer-3.0.1.tar.gz", hash = "sha256:6d5eeeec8e9f84d4d56b847692bacf79bc2c8e90c7f80ca4444ff8b6f2e52895", size = 105575, upload-time = "2025-05-09T16:34:51.843Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/78/3565d011c61f5a43488987ee32b6f3f656e7f107ac2782dd57bdd7d91d9a/snowballstemmer-3.0.1-py3-none-any.whl", hash = "sha256:6cd7b3897da8d6c9ffb968a6781fa6532dce9c3618a4b127d920dab764a19064", size = 103274, upload-time = "2025-05-09T16:34:50.371Z" }, +] + +[[package]] +name = "sphinx" +version = "8.1.3" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +dependencies = [ + { name = "alabaster", marker = "python_full_version < '3.11'" }, + { name = "babel", marker = "python_full_version < '3.11'" }, + { name = "colorama", marker = "python_full_version < '3.11' and sys_platform == 'win32'" }, + { name = "docutils", version = "0.21.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "imagesize", marker = "python_full_version < '3.11'" }, + { name = "jinja2", marker = "python_full_version < '3.11'" }, + { name = "packaging", marker = "python_full_version < '3.11'" }, + { name = "pygments", marker = "python_full_version < '3.11'" }, + { name = "requests", marker = "python_full_version < '3.11'" }, + { name = "snowballstemmer", marker = "python_full_version < '3.11'" }, + { name = "sphinxcontrib-applehelp", marker = "python_full_version < '3.11'" }, + { name = "sphinxcontrib-devhelp", marker = "python_full_version < '3.11'" }, + { name = "sphinxcontrib-htmlhelp", marker = "python_full_version < '3.11'" }, + { name = "sphinxcontrib-jsmath", marker = "python_full_version < '3.11'" }, + { name = "sphinxcontrib-qthelp", marker = "python_full_version < '3.11'" }, + { name = "sphinxcontrib-serializinghtml", marker = "python_full_version < '3.11'" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/be0b61178fe2cdcb67e2a92fc9ebb488e3c51c4f74a36a7824c0adf23425/sphinx-8.1.3.tar.gz", hash = "sha256:43c1911eecb0d3e161ad78611bc905d1ad0e523e4ddc202a58a821773dc4c927", size = 8184611, upload-time = "2024-10-13T20:27:13.93Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/60/1ddff83a56d33aaf6f10ec8ce84b4c007d9368b21008876fceda7e7381ef/sphinx-8.1.3-py3-none-any.whl", hash = "sha256:09719015511837b76bf6e03e42eb7595ac8c2e41eeb9c29c5b755c6b677992a2", size = 3487125, upload-time = "2024-10-13T20:27:10.448Z" }, +] + +[[package]] +name = "sphinx" +version = "9.0.4" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version == '3.11.*' and sys_platform == 'win32'", + "python_full_version == '3.11.*' and sys_platform == 'emscripten'", + "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", +] +dependencies = [ + { name = "alabaster", marker = "python_full_version == '3.11.*'" }, + { name = "babel", marker = "python_full_version == '3.11.*'" }, + { name = "colorama", marker = "python_full_version == '3.11.*' and sys_platform == 'win32'" }, + { name = "docutils", version = "0.22.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "imagesize", marker = "python_full_version == '3.11.*'" }, + { name = "jinja2", marker = "python_full_version == '3.11.*'" }, + { name = "packaging", marker = "python_full_version == '3.11.*'" }, + { name = "pygments", marker = "python_full_version == '3.11.*'" }, + { name = "requests", marker = "python_full_version == '3.11.*'" }, + { name = "roman-numerals", marker = "python_full_version == '3.11.*'" }, + { name = "snowballstemmer", marker = "python_full_version == '3.11.*'" }, + { name = "sphinxcontrib-applehelp", marker = "python_full_version == '3.11.*'" }, + { name = "sphinxcontrib-devhelp", marker = "python_full_version == '3.11.*'" }, + { name = "sphinxcontrib-htmlhelp", marker = "python_full_version == '3.11.*'" }, + { name = "sphinxcontrib-jsmath", marker = "python_full_version == '3.11.*'" }, + { name = "sphinxcontrib-qthelp", marker = "python_full_version == '3.11.*'" }, + { name = "sphinxcontrib-serializinghtml", marker = "python_full_version == '3.11.*'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/50/a8c6ccc36d5eacdfd7913ddccd15a9cee03ecafc5ee2bc40e1f168d85022/sphinx-9.0.4.tar.gz", hash = "sha256:594ef59d042972abbc581d8baa577404abe4e6c3b04ef61bd7fc2acbd51f3fa3", size = 8710502, upload-time = "2025-12-04T07:45:27.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c6/3f/4bbd76424c393caead2e1eb89777f575dee5c8653e2d4b6afd7a564f5974/sphinx-9.0.4-py3-none-any.whl", hash = "sha256:5bebc595a5e943ea248b99c13814c1c5e10b3ece718976824ffa7959ff95fffb", size = 3917713, upload-time = "2025-12-04T07:45:24.944Z" }, +] + +[[package]] +name = "sphinx" +version = "9.1.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12' and sys_platform == 'win32'", + "python_full_version >= '3.12' and sys_platform == 'emscripten'", + "python_full_version >= '3.12' and sys_platform != 'emscripten' and sys_platform != 'win32'", +] +dependencies = [ + { name = "alabaster", marker = "python_full_version >= '3.12'" }, + { name = "babel", marker = "python_full_version >= '3.12'" }, + { name = "colorama", marker = "python_full_version >= '3.12' and sys_platform == 'win32'" }, + { name = "docutils", version = "0.22.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "imagesize", marker = "python_full_version >= '3.12'" }, + { name = "jinja2", marker = "python_full_version >= '3.12'" }, + { name = "packaging", marker = "python_full_version >= '3.12'" }, + { name = "pygments", marker = "python_full_version >= '3.12'" }, + { name = "requests", marker = "python_full_version >= '3.12'" }, + { name = "roman-numerals", marker = "python_full_version >= '3.12'" }, + { name = "snowballstemmer", marker = "python_full_version >= '3.12'" }, + { name = "sphinxcontrib-applehelp", marker = "python_full_version >= '3.12'" }, + { name = "sphinxcontrib-devhelp", marker = "python_full_version >= '3.12'" }, + { name = "sphinxcontrib-htmlhelp", marker = "python_full_version >= '3.12'" }, + { name = "sphinxcontrib-jsmath", marker = "python_full_version >= '3.12'" }, + { name = "sphinxcontrib-qthelp", marker = "python_full_version >= '3.12'" }, + { name = "sphinxcontrib-serializinghtml", marker = "python_full_version >= '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cd/bd/f08eb0f4eed5c83f1ba2a3bd18f7745a2b1525fad70660a1c00224ec468a/sphinx-9.1.0.tar.gz", hash = "sha256:7741722357dd75f8190766926071fed3bdc211c74dd2d7d4df5404da95930ddb", size = 8718324, upload-time = "2025-12-31T15:09:27.646Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/73/f7/b1884cb3188ab181fc81fa00c266699dab600f927a964df02ec3d5d1916a/sphinx-9.1.0-py3-none-any.whl", hash = "sha256:c84fdd4e782504495fe4f2c0b3413d6c2bf388589bb352d439b2a3bb99991978", size = 3921742, upload-time = "2025-12-31T15:09:25.561Z" }, +] + +[[package]] +name = "sphinxcontrib-applehelp" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/6e/b837e84a1a704953c62ef8776d45c3e8d759876b4a84fe14eba2859106fe/sphinxcontrib_applehelp-2.0.0.tar.gz", hash = "sha256:2f29ef331735ce958efa4734873f084941970894c6090408b079c61b2e1c06d1", size = 20053, upload-time = "2024-07-29T01:09:00.465Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/85/9ebeae2f76e9e77b952f4b274c27238156eae7979c5421fba91a28f4970d/sphinxcontrib_applehelp-2.0.0-py3-none-any.whl", hash = "sha256:4cd3f0ec4ac5dd9c17ec65e9ab272c9b867ea77425228e68ecf08d6b28ddbdb5", size = 119300, upload-time = "2024-07-29T01:08:58.99Z" }, +] + +[[package]] +name = "sphinxcontrib-devhelp" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/d2/5beee64d3e4e747f316bae86b55943f51e82bb86ecd325883ef65741e7da/sphinxcontrib_devhelp-2.0.0.tar.gz", hash = "sha256:411f5d96d445d1d73bb5d52133377b4248ec79db5c793ce7dbe59e074b4dd1ad", size = 12967, upload-time = "2024-07-29T01:09:23.417Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/35/7a/987e583882f985fe4d7323774889ec58049171828b58c2217e7f79cdf44e/sphinxcontrib_devhelp-2.0.0-py3-none-any.whl", hash = "sha256:aefb8b83854e4b0998877524d1029fd3e6879210422ee3780459e28a1f03a8a2", size = 82530, upload-time = "2024-07-29T01:09:21.945Z" }, +] + +[[package]] +name = "sphinxcontrib-htmlhelp" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/93/983afd9aa001e5201eab16b5a444ed5b9b0a7a010541e0ddfbbfd0b2470c/sphinxcontrib_htmlhelp-2.1.0.tar.gz", hash = "sha256:c9e2916ace8aad64cc13a0d233ee22317f2b9025b9cf3295249fa985cc7082e9", size = 22617, upload-time = "2024-07-29T01:09:37.889Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0a/7b/18a8c0bcec9182c05a0b3ec2a776bba4ead82750a55ff798e8d406dae604/sphinxcontrib_htmlhelp-2.1.0-py3-none-any.whl", hash = "sha256:166759820b47002d22914d64a075ce08f4c46818e17cfc9470a9786b759b19f8", size = 98705, upload-time = "2024-07-29T01:09:36.407Z" }, +] + +[[package]] +name = "sphinxcontrib-jsmath" +version = "1.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b2/e8/9ed3830aeed71f17c026a07a5097edcf44b692850ef215b161b8ad875729/sphinxcontrib-jsmath-1.0.1.tar.gz", hash = "sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8", size = 5787, upload-time = "2019-01-21T16:10:16.347Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/42/4c8646762ee83602e3fb3fbe774c2fac12f317deb0b5dbeeedd2d3ba4b77/sphinxcontrib_jsmath-1.0.1-py2.py3-none-any.whl", hash = "sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178", size = 5071, upload-time = "2019-01-21T16:10:14.333Z" }, +] + +[[package]] +name = "sphinxcontrib-qthelp" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/68/bc/9104308fc285eb3e0b31b67688235db556cd5b0ef31d96f30e45f2e51cae/sphinxcontrib_qthelp-2.0.0.tar.gz", hash = "sha256:4fe7d0ac8fc171045be623aba3e2a8f613f8682731f9153bb2e40ece16b9bbab", size = 17165, upload-time = "2024-07-29T01:09:56.435Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/83/859ecdd180cacc13b1f7e857abf8582a64552ea7a061057a6c716e790fce/sphinxcontrib_qthelp-2.0.0-py3-none-any.whl", hash = "sha256:b18a828cdba941ccd6ee8445dbe72ffa3ef8cbe7505d8cd1fa0d42d3f2d5f3eb", size = 88743, upload-time = "2024-07-29T01:09:54.885Z" }, +] + +[[package]] +name = "sphinxcontrib-serializinghtml" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3b/44/6716b257b0aa6bfd51a1b31665d1c205fb12cb5ad56de752dfa15657de2f/sphinxcontrib_serializinghtml-2.0.0.tar.gz", hash = "sha256:e9d912827f872c029017a53f0ef2180b327c3f7fd23c87229f7a8e8b70031d4d", size = 16080, upload-time = "2024-07-29T01:10:09.332Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/52/a7/d2782e4e3f77c8450f727ba74a8f12756d5ba823d81b941f1b04da9d033a/sphinxcontrib_serializinghtml-2.0.0-py3-none-any.whl", hash = "sha256:6e2cb0eef194e10c27ec0023bfeb25badbbb5868244cf5bc5bdc04e4464bf331", size = 92072, upload-time = "2024-07-29T01:10:08.203Z" }, +] + +[[package]] +name = "tomli" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/30/31573e9457673ab10aa432461bee537ce6cef177667deca369efb79df071/tomli-2.4.0.tar.gz", hash = "sha256:aa89c3f6c277dd275d8e243ad24f3b5e701491a860d5121f2cdd399fbb31fc9c", size = 17477, upload-time = "2026-01-11T11:22:38.165Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/d9/3dc2289e1f3b32eb19b9785b6a006b28ee99acb37d1d47f78d4c10e28bf8/tomli-2.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b5ef256a3fd497d4973c11bf142e9ed78b150d36f5773f1ca6088c230ffc5867", size = 153663, upload-time = "2026-01-11T11:21:45.27Z" }, + { url = "https://files.pythonhosted.org/packages/51/32/ef9f6845e6b9ca392cd3f64f9ec185cc6f09f0a2df3db08cbe8809d1d435/tomli-2.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5572e41282d5268eb09a697c89a7bee84fae66511f87533a6f88bd2f7b652da9", size = 148469, upload-time = "2026-01-11T11:21:46.873Z" }, + { url = "https://files.pythonhosted.org/packages/d6/c2/506e44cce89a8b1b1e047d64bd495c22c9f71f21e05f380f1a950dd9c217/tomli-2.4.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:551e321c6ba03b55676970b47cb1b73f14a0a4dce6a3e1a9458fd6d921d72e95", size = 236039, upload-time = "2026-01-11T11:21:48.503Z" }, + { url = "https://files.pythonhosted.org/packages/b3/40/e1b65986dbc861b7e986e8ec394598187fa8aee85b1650b01dd925ca0be8/tomli-2.4.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e3f639a7a8f10069d0e15408c0b96a2a828cfdec6fca05296ebcdcc28ca7c76", size = 243007, upload-time = "2026-01-11T11:21:49.456Z" }, + { url = "https://files.pythonhosted.org/packages/9c/6f/6e39ce66b58a5b7ae572a0f4352ff40c71e8573633deda43f6a379d56b3e/tomli-2.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1b168f2731796b045128c45982d3a4874057626da0e2ef1fdd722848b741361d", size = 240875, upload-time = "2026-01-11T11:21:50.755Z" }, + { url = "https://files.pythonhosted.org/packages/aa/ad/cb089cb190487caa80204d503c7fd0f4d443f90b95cf4ef5cf5aa0f439b0/tomli-2.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:133e93646ec4300d651839d382d63edff11d8978be23da4cc106f5a18b7d0576", size = 246271, upload-time = "2026-01-11T11:21:51.81Z" }, + { url = "https://files.pythonhosted.org/packages/0b/63/69125220e47fd7a3a27fd0de0c6398c89432fec41bc739823bcc66506af6/tomli-2.4.0-cp311-cp311-win32.whl", hash = "sha256:b6c78bdf37764092d369722d9946cb65b8767bfa4110f902a1b2542d8d173c8a", size = 96770, upload-time = "2026-01-11T11:21:52.647Z" }, + { url = "https://files.pythonhosted.org/packages/1e/0d/a22bb6c83f83386b0008425a6cd1fa1c14b5f3dd4bad05e98cf3dbbf4a64/tomli-2.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:d3d1654e11d724760cdb37a3d7691f0be9db5fbdaef59c9f532aabf87006dbaa", size = 107626, upload-time = "2026-01-11T11:21:53.459Z" }, + { url = "https://files.pythonhosted.org/packages/2f/6d/77be674a3485e75cacbf2ddba2b146911477bd887dda9d8c9dfb2f15e871/tomli-2.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:cae9c19ed12d4e8f3ebf46d1a75090e4c0dc16271c5bce1c833ac168f08fb614", size = 94842, upload-time = "2026-01-11T11:21:54.831Z" }, + { url = "https://files.pythonhosted.org/packages/3c/43/7389a1869f2f26dba52404e1ef13b4784b6b37dac93bac53457e3ff24ca3/tomli-2.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:920b1de295e72887bafa3ad9f7a792f811847d57ea6b1215154030cf131f16b1", size = 154894, upload-time = "2026-01-11T11:21:56.07Z" }, + { url = "https://files.pythonhosted.org/packages/e9/05/2f9bf110b5294132b2edf13fe6ca6ae456204f3d749f623307cbb7a946f2/tomli-2.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d6d9a4aee98fac3eab4952ad1d73aee87359452d1c086b5ceb43ed02ddb16b8", size = 149053, upload-time = "2026-01-11T11:21:57.467Z" }, + { url = "https://files.pythonhosted.org/packages/e8/41/1eda3ca1abc6f6154a8db4d714a4d35c4ad90adc0bcf700657291593fbf3/tomli-2.4.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:36b9d05b51e65b254ea6c2585b59d2c4cb91c8a3d91d0ed0f17591a29aaea54a", size = 243481, upload-time = "2026-01-11T11:21:58.661Z" }, + { url = "https://files.pythonhosted.org/packages/d2/6d/02ff5ab6c8868b41e7d4b987ce2b5f6a51d3335a70aa144edd999e055a01/tomli-2.4.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c8a885b370751837c029ef9bc014f27d80840e48bac415f3412e6593bbc18c1", size = 251720, upload-time = "2026-01-11T11:22:00.178Z" }, + { url = "https://files.pythonhosted.org/packages/7b/57/0405c59a909c45d5b6f146107c6d997825aa87568b042042f7a9c0afed34/tomli-2.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8768715ffc41f0008abe25d808c20c3d990f42b6e2e58305d5da280ae7d1fa3b", size = 247014, upload-time = "2026-01-11T11:22:01.238Z" }, + { url = "https://files.pythonhosted.org/packages/2c/0e/2e37568edd944b4165735687cbaf2fe3648129e440c26d02223672ee0630/tomli-2.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b438885858efd5be02a9a133caf5812b8776ee0c969fea02c45e8e3f296ba51", size = 251820, upload-time = "2026-01-11T11:22:02.727Z" }, + { url = "https://files.pythonhosted.org/packages/5a/1c/ee3b707fdac82aeeb92d1a113f803cf6d0f37bdca0849cb489553e1f417a/tomli-2.4.0-cp312-cp312-win32.whl", hash = "sha256:0408e3de5ec77cc7f81960c362543cbbd91ef883e3138e81b729fc3eea5b9729", size = 97712, upload-time = "2026-01-11T11:22:03.777Z" }, + { url = "https://files.pythonhosted.org/packages/69/13/c07a9177d0b3bab7913299b9278845fc6eaaca14a02667c6be0b0a2270c8/tomli-2.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:685306e2cc7da35be4ee914fd34ab801a6acacb061b6a7abca922aaf9ad368da", size = 108296, upload-time = "2026-01-11T11:22:04.86Z" }, + { url = "https://files.pythonhosted.org/packages/18/27/e267a60bbeeee343bcc279bb9e8fbed0cbe224bc7b2a3dc2975f22809a09/tomli-2.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:5aa48d7c2356055feef06a43611fc401a07337d5b006be13a30f6c58f869e3c3", size = 94553, upload-time = "2026-01-11T11:22:05.854Z" }, + { url = "https://files.pythonhosted.org/packages/23/d1/136eb2cb77520a31e1f64cbae9d33ec6df0d78bdf4160398e86eec8a8754/tomli-2.4.0-py3-none-any.whl", hash = "sha256:1f776e7d669ebceb01dee46484485f43a4048746235e683bcdffacdf1fb4785a", size = 14477, upload-time = "2026-01-11T11:22:37.446Z" }, +] + +[[package]] +name = "types-pytz" +version = "2025.2.0.20251108" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/40/ff/c047ddc68c803b46470a357454ef76f4acd8c1088f5cc4891cdd909bfcf6/types_pytz-2025.2.0.20251108.tar.gz", hash = "sha256:fca87917836ae843f07129567b74c1929f1870610681b4c92cb86a3df5817bdb", size = 10961, upload-time = "2025-11-08T02:55:57.001Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/c1/56ef16bf5dcd255155cc736d276efa6ae0a5c26fd685e28f0412a4013c01/types_pytz-2025.2.0.20251108-py3-none-any.whl", hash = "sha256:0f1c9792cab4eb0e46c52f8845c8f77cf1e313cb3d68bf826aa867fe4717d91c", size = 10116, upload-time = "2025-11-08T02:55:56.194Z" }, +] + +[[package]] +name = "types-requests" +version = "2.32.4.20260107" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0f/f3/a0663907082280664d745929205a89d41dffb29e89a50f753af7d57d0a96/types_requests-2.32.4.20260107.tar.gz", hash = "sha256:018a11ac158f801bfa84857ddec1650750e393df8a004a8a9ae2a9bec6fcb24f", size = 23165, upload-time = "2026-01-07T03:20:54.091Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1c/12/709ea261f2bf91ef0a26a9eed20f2623227a8ed85610c1e54c5805692ecb/types_requests-2.32.4.20260107-py3-none-any.whl", hash = "sha256:b703fe72f8ce5b31ef031264fe9395cac8f46a04661a79f7ed31a80fb308730d", size = 20676, upload-time = "2026-01-07T03:20:52.929Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "tzdata" +version = "2025.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5e/a7/c202b344c5ca7daf398f3b8a477eeb205cf3b6f32e7ec3a6bac0629ca975/tzdata-2025.3.tar.gz", hash = "sha256:de39c2ca5dc7b0344f2eba86f49d614019d29f060fc4ebc8a417896a620b56a7", size = 196772, upload-time = "2025-12-13T17:45:35.667Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/b0/003792df09decd6849a5e39c28b513c06e84436a54440380862b5aeff25d/tzdata-2025.3-py2.py3-none-any.whl", hash = "sha256:06a47e5700f3081aab02b2e513160914ff0694bce9947d6b76ebd6bf57cfc5d1", size = 348521, upload-time = "2025-12-13T17:45:33.889Z" }, +] + +[[package]] +name = "urllib3" +version = "2.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, +] + +[[package]] +name = "virtualenv" +version = "20.38.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "distlib" }, + { name = "filelock" }, + { name = "platformdirs" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d2/03/a94d404ca09a89a7301a7008467aed525d4cdeb9186d262154dd23208709/virtualenv-20.38.0.tar.gz", hash = "sha256:94f39b1abaea5185bf7ea5a46702b56f1d0c9aa2f41a6c2b8b0af4ddc74c10a7", size = 5864558, upload-time = "2026-02-19T07:48:02.385Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/42/d7/394801755d4c8684b655d35c665aea7836ec68320304f62ab3c94395b442/virtualenv-20.38.0-py3-none-any.whl", hash = "sha256:d6e78e5889de3a4742df2d3d44e779366325a90cf356f15621fddace82431794", size = 5837778, upload-time = "2026-02-19T07:47:59.778Z" }, +] + +[[package]] +name = "yarl" +version = "1.22.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "multidict" }, + { name = "propcache" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/57/63/0c6ebca57330cd313f6102b16dd57ffaf3ec4c83403dcb45dbd15c6f3ea1/yarl-1.22.0.tar.gz", hash = "sha256:bebf8557577d4401ba8bd9ff33906f1376c877aa78d1fe216ad01b4d6745af71", size = 187169, upload-time = "2025-10-06T14:12:55.963Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/43/a2204825342f37c337f5edb6637040fa14e365b2fcc2346960201d457579/yarl-1.22.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:c7bd6683587567e5a49ee6e336e0612bec8329be1b7d4c8af5687dcdeb67ee1e", size = 140517, upload-time = "2025-10-06T14:08:42.494Z" }, + { url = "https://files.pythonhosted.org/packages/44/6f/674f3e6f02266428c56f704cd2501c22f78e8b2eeb23f153117cc86fb28a/yarl-1.22.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5cdac20da754f3a723cceea5b3448e1a2074866406adeb4ef35b469d089adb8f", size = 93495, upload-time = "2025-10-06T14:08:46.2Z" }, + { url = "https://files.pythonhosted.org/packages/b8/12/5b274d8a0f30c07b91b2f02cba69152600b47830fcfb465c108880fcee9c/yarl-1.22.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:07a524d84df0c10f41e3ee918846e1974aba4ec017f990dc735aad487a0bdfdf", size = 94400, upload-time = "2025-10-06T14:08:47.855Z" }, + { url = "https://files.pythonhosted.org/packages/e2/7f/df1b6949b1fa1aa9ff6de6e2631876ad4b73c4437822026e85d8acb56bb1/yarl-1.22.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e1b329cb8146d7b736677a2440e422eadd775d1806a81db2d4cded80a48efc1a", size = 347545, upload-time = "2025-10-06T14:08:49.683Z" }, + { url = "https://files.pythonhosted.org/packages/84/09/f92ed93bd6cd77872ab6c3462df45ca45cd058d8f1d0c9b4f54c1704429f/yarl-1.22.0-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:75976c6945d85dbb9ee6308cd7ff7b1fb9409380c82d6119bd778d8fcfe2931c", size = 319598, upload-time = "2025-10-06T14:08:51.215Z" }, + { url = "https://files.pythonhosted.org/packages/c3/97/ac3f3feae7d522cf7ccec3d340bb0b2b61c56cb9767923df62a135092c6b/yarl-1.22.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:80ddf7a5f8c86cb3eb4bc9028b07bbbf1f08a96c5c0bc1244be5e8fefcb94147", size = 363893, upload-time = "2025-10-06T14:08:53.144Z" }, + { url = "https://files.pythonhosted.org/packages/06/49/f3219097403b9c84a4d079b1d7bda62dd9b86d0d6e4428c02d46ab2c77fc/yarl-1.22.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d332fc2e3c94dad927f2112395772a4e4fedbcf8f80efc21ed7cdfae4d574fdb", size = 371240, upload-time = "2025-10-06T14:08:55.036Z" }, + { url = "https://files.pythonhosted.org/packages/35/9f/06b765d45c0e44e8ecf0fe15c9eacbbde342bb5b7561c46944f107bfb6c3/yarl-1.22.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0cf71bf877efeac18b38d3930594c0948c82b64547c1cf420ba48722fe5509f6", size = 346965, upload-time = "2025-10-06T14:08:56.722Z" }, + { url = "https://files.pythonhosted.org/packages/c5/69/599e7cea8d0fcb1694323b0db0dda317fa3162f7b90166faddecf532166f/yarl-1.22.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:663e1cadaddae26be034a6ab6072449a8426ddb03d500f43daf952b74553bba0", size = 342026, upload-time = "2025-10-06T14:08:58.563Z" }, + { url = "https://files.pythonhosted.org/packages/95/6f/9dfd12c8bc90fea9eab39832ee32ea48f8e53d1256252a77b710c065c89f/yarl-1.22.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:6dcbb0829c671f305be48a7227918cfcd11276c2d637a8033a99a02b67bf9eda", size = 335637, upload-time = "2025-10-06T14:09:00.506Z" }, + { url = "https://files.pythonhosted.org/packages/57/2e/34c5b4eb9b07e16e873db5b182c71e5f06f9b5af388cdaa97736d79dd9a6/yarl-1.22.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:f0d97c18dfd9a9af4490631905a3f131a8e4c9e80a39353919e2cfed8f00aedc", size = 359082, upload-time = "2025-10-06T14:09:01.936Z" }, + { url = "https://files.pythonhosted.org/packages/31/71/fa7e10fb772d273aa1f096ecb8ab8594117822f683bab7d2c5a89914c92a/yarl-1.22.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:437840083abe022c978470b942ff832c3940b2ad3734d424b7eaffcd07f76737", size = 357811, upload-time = "2025-10-06T14:09:03.445Z" }, + { url = "https://files.pythonhosted.org/packages/26/da/11374c04e8e1184a6a03cf9c8f5688d3e5cec83ed6f31ad3481b3207f709/yarl-1.22.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:a899cbd98dce6f5d8de1aad31cb712ec0a530abc0a86bd6edaa47c1090138467", size = 351223, upload-time = "2025-10-06T14:09:05.401Z" }, + { url = "https://files.pythonhosted.org/packages/82/8f/e2d01f161b0c034a30410e375e191a5d27608c1f8693bab1a08b089ca096/yarl-1.22.0-cp310-cp310-win32.whl", hash = "sha256:595697f68bd1f0c1c159fcb97b661fc9c3f5db46498043555d04805430e79bea", size = 82118, upload-time = "2025-10-06T14:09:11.148Z" }, + { url = "https://files.pythonhosted.org/packages/62/46/94c76196642dbeae634c7a61ba3da88cd77bed875bf6e4a8bed037505aa6/yarl-1.22.0-cp310-cp310-win_amd64.whl", hash = "sha256:cb95a9b1adaa48e41815a55ae740cfda005758104049a640a398120bf02515ca", size = 86852, upload-time = "2025-10-06T14:09:12.958Z" }, + { url = "https://files.pythonhosted.org/packages/af/af/7df4f179d3b1a6dcb9a4bd2ffbc67642746fcafdb62580e66876ce83fff4/yarl-1.22.0-cp310-cp310-win_arm64.whl", hash = "sha256:b85b982afde6df99ecc996990d4ad7ccbdbb70e2a4ba4de0aecde5922ba98a0b", size = 82012, upload-time = "2025-10-06T14:09:14.664Z" }, + { url = "https://files.pythonhosted.org/packages/4d/27/5ab13fc84c76a0250afd3d26d5936349a35be56ce5785447d6c423b26d92/yarl-1.22.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:1ab72135b1f2db3fed3997d7e7dc1b80573c67138023852b6efb336a5eae6511", size = 141607, upload-time = "2025-10-06T14:09:16.298Z" }, + { url = "https://files.pythonhosted.org/packages/6a/a1/d065d51d02dc02ce81501d476b9ed2229d9a990818332242a882d5d60340/yarl-1.22.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:669930400e375570189492dc8d8341301578e8493aec04aebc20d4717f899dd6", size = 94027, upload-time = "2025-10-06T14:09:17.786Z" }, + { url = "https://files.pythonhosted.org/packages/c1/da/8da9f6a53f67b5106ffe902c6fa0164e10398d4e150d85838b82f424072a/yarl-1.22.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:792a2af6d58177ef7c19cbf0097aba92ca1b9cb3ffdd9c7470e156c8f9b5e028", size = 94963, upload-time = "2025-10-06T14:09:19.662Z" }, + { url = "https://files.pythonhosted.org/packages/68/fe/2c1f674960c376e29cb0bec1249b117d11738db92a6ccc4a530b972648db/yarl-1.22.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ea66b1c11c9150f1372f69afb6b8116f2dd7286f38e14ea71a44eee9ec51b9d", size = 368406, upload-time = "2025-10-06T14:09:21.402Z" }, + { url = "https://files.pythonhosted.org/packages/95/26/812a540e1c3c6418fec60e9bbd38e871eaba9545e94fa5eff8f4a8e28e1e/yarl-1.22.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3e2daa88dc91870215961e96a039ec73e4937da13cf77ce17f9cad0c18df3503", size = 336581, upload-time = "2025-10-06T14:09:22.98Z" }, + { url = "https://files.pythonhosted.org/packages/0b/f5/5777b19e26fdf98563985e481f8be3d8a39f8734147a6ebf459d0dab5a6b/yarl-1.22.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ba440ae430c00eee41509353628600212112cd5018d5def7e9b05ea7ac34eb65", size = 388924, upload-time = "2025-10-06T14:09:24.655Z" }, + { url = "https://files.pythonhosted.org/packages/86/08/24bd2477bd59c0bbd994fe1d93b126e0472e4e3df5a96a277b0a55309e89/yarl-1.22.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e6438cc8f23a9c1478633d216b16104a586b9761db62bfacb6425bac0a36679e", size = 392890, upload-time = "2025-10-06T14:09:26.617Z" }, + { url = "https://files.pythonhosted.org/packages/46/00/71b90ed48e895667ecfb1eaab27c1523ee2fa217433ed77a73b13205ca4b/yarl-1.22.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4c52a6e78aef5cf47a98ef8e934755abf53953379b7d53e68b15ff4420e6683d", size = 365819, upload-time = "2025-10-06T14:09:28.544Z" }, + { url = "https://files.pythonhosted.org/packages/30/2d/f715501cae832651d3282387c6a9236cd26bd00d0ff1e404b3dc52447884/yarl-1.22.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:3b06bcadaac49c70f4c88af4ffcfbe3dc155aab3163e75777818092478bcbbe7", size = 363601, upload-time = "2025-10-06T14:09:30.568Z" }, + { url = "https://files.pythonhosted.org/packages/f8/f9/a678c992d78e394e7126ee0b0e4e71bd2775e4334d00a9278c06a6cce96a/yarl-1.22.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:6944b2dc72c4d7f7052683487e3677456050ff77fcf5e6204e98caf785ad1967", size = 358072, upload-time = "2025-10-06T14:09:32.528Z" }, + { url = "https://files.pythonhosted.org/packages/2c/d1/b49454411a60edb6fefdcad4f8e6dbba7d8019e3a508a1c5836cba6d0781/yarl-1.22.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:d5372ca1df0f91a86b047d1277c2aaf1edb32d78bbcefffc81b40ffd18f027ed", size = 385311, upload-time = "2025-10-06T14:09:34.634Z" }, + { url = "https://files.pythonhosted.org/packages/87/e5/40d7a94debb8448c7771a916d1861d6609dddf7958dc381117e7ba36d9e8/yarl-1.22.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:51af598701f5299012b8416486b40fceef8c26fc87dc6d7d1f6fc30609ea0aa6", size = 381094, upload-time = "2025-10-06T14:09:36.268Z" }, + { url = "https://files.pythonhosted.org/packages/35/d8/611cc282502381ad855448643e1ad0538957fc82ae83dfe7762c14069e14/yarl-1.22.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b266bd01fedeffeeac01a79ae181719ff848a5a13ce10075adbefc8f1daee70e", size = 370944, upload-time = "2025-10-06T14:09:37.872Z" }, + { url = "https://files.pythonhosted.org/packages/2d/df/fadd00fb1c90e1a5a8bd731fa3d3de2e165e5a3666a095b04e31b04d9cb6/yarl-1.22.0-cp311-cp311-win32.whl", hash = "sha256:a9b1ba5610a4e20f655258d5a1fdc7ebe3d837bb0e45b581398b99eb98b1f5ca", size = 81804, upload-time = "2025-10-06T14:09:39.359Z" }, + { url = "https://files.pythonhosted.org/packages/b5/f7/149bb6f45f267cb5c074ac40c01c6b3ea6d8a620d34b337f6321928a1b4d/yarl-1.22.0-cp311-cp311-win_amd64.whl", hash = "sha256:078278b9b0b11568937d9509b589ee83ef98ed6d561dfe2020e24a9fd08eaa2b", size = 86858, upload-time = "2025-10-06T14:09:41.068Z" }, + { url = "https://files.pythonhosted.org/packages/2b/13/88b78b93ad3f2f0b78e13bfaaa24d11cbc746e93fe76d8c06bf139615646/yarl-1.22.0-cp311-cp311-win_arm64.whl", hash = "sha256:b6a6f620cfe13ccec221fa312139135166e47ae169f8253f72a0abc0dae94376", size = 81637, upload-time = "2025-10-06T14:09:42.712Z" }, + { url = "https://files.pythonhosted.org/packages/75/ff/46736024fee3429b80a165a732e38e5d5a238721e634ab41b040d49f8738/yarl-1.22.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e340382d1afa5d32b892b3ff062436d592ec3d692aeea3bef3a5cfe11bbf8c6f", size = 142000, upload-time = "2025-10-06T14:09:44.631Z" }, + { url = "https://files.pythonhosted.org/packages/5a/9a/b312ed670df903145598914770eb12de1bac44599549b3360acc96878df8/yarl-1.22.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f1e09112a2c31ffe8d80be1b0988fa6a18c5d5cad92a9ffbb1c04c91bfe52ad2", size = 94338, upload-time = "2025-10-06T14:09:46.372Z" }, + { url = "https://files.pythonhosted.org/packages/ba/f5/0601483296f09c3c65e303d60c070a5c19fcdbc72daa061e96170785bc7d/yarl-1.22.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:939fe60db294c786f6b7c2d2e121576628468f65453d86b0fe36cb52f987bd74", size = 94909, upload-time = "2025-10-06T14:09:48.648Z" }, + { url = "https://files.pythonhosted.org/packages/60/41/9a1fe0b73dbcefce72e46cf149b0e0a67612d60bfc90fb59c2b2efdfbd86/yarl-1.22.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e1651bf8e0398574646744c1885a41198eba53dc8a9312b954073f845c90a8df", size = 372940, upload-time = "2025-10-06T14:09:50.089Z" }, + { url = "https://files.pythonhosted.org/packages/17/7a/795cb6dfee561961c30b800f0ed616b923a2ec6258b5def2a00bf8231334/yarl-1.22.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b8a0588521a26bf92a57a1705b77b8b59044cdceccac7151bd8d229e66b8dedb", size = 345825, upload-time = "2025-10-06T14:09:52.142Z" }, + { url = "https://files.pythonhosted.org/packages/d7/93/a58f4d596d2be2ae7bab1a5846c4d270b894958845753b2c606d666744d3/yarl-1.22.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:42188e6a615c1a75bcaa6e150c3fe8f3e8680471a6b10150c5f7e83f47cc34d2", size = 386705, upload-time = "2025-10-06T14:09:54.128Z" }, + { url = "https://files.pythonhosted.org/packages/61/92/682279d0e099d0e14d7fd2e176bd04f48de1484f56546a3e1313cd6c8e7c/yarl-1.22.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f6d2cb59377d99718913ad9a151030d6f83ef420a2b8f521d94609ecc106ee82", size = 396518, upload-time = "2025-10-06T14:09:55.762Z" }, + { url = "https://files.pythonhosted.org/packages/db/0f/0d52c98b8a885aeda831224b78f3be7ec2e1aa4a62091f9f9188c3c65b56/yarl-1.22.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50678a3b71c751d58d7908edc96d332af328839eea883bb554a43f539101277a", size = 377267, upload-time = "2025-10-06T14:09:57.958Z" }, + { url = "https://files.pythonhosted.org/packages/22/42/d2685e35908cbeaa6532c1fc73e89e7f2efb5d8a7df3959ea8e37177c5a3/yarl-1.22.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1e8fbaa7cec507aa24ea27a01456e8dd4b6fab829059b69844bd348f2d467124", size = 365797, upload-time = "2025-10-06T14:09:59.527Z" }, + { url = "https://files.pythonhosted.org/packages/a2/83/cf8c7bcc6355631762f7d8bdab920ad09b82efa6b722999dfb05afa6cfac/yarl-1.22.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:433885ab5431bc3d3d4f2f9bd15bfa1614c522b0f1405d62c4f926ccd69d04fa", size = 365535, upload-time = "2025-10-06T14:10:01.139Z" }, + { url = "https://files.pythonhosted.org/packages/25/e1/5302ff9b28f0c59cac913b91fe3f16c59a033887e57ce9ca5d41a3a94737/yarl-1.22.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:b790b39c7e9a4192dc2e201a282109ed2985a1ddbd5ac08dc56d0e121400a8f7", size = 382324, upload-time = "2025-10-06T14:10:02.756Z" }, + { url = "https://files.pythonhosted.org/packages/bf/cd/4617eb60f032f19ae3a688dc990d8f0d89ee0ea378b61cac81ede3e52fae/yarl-1.22.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:31f0b53913220599446872d757257be5898019c85e7971599065bc55065dc99d", size = 383803, upload-time = "2025-10-06T14:10:04.552Z" }, + { url = "https://files.pythonhosted.org/packages/59/65/afc6e62bb506a319ea67b694551dab4a7e6fb7bf604e9bd9f3e11d575fec/yarl-1.22.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a49370e8f711daec68d09b821a34e1167792ee2d24d405cbc2387be4f158b520", size = 374220, upload-time = "2025-10-06T14:10:06.489Z" }, + { url = "https://files.pythonhosted.org/packages/e7/3d/68bf18d50dc674b942daec86a9ba922d3113d8399b0e52b9897530442da2/yarl-1.22.0-cp312-cp312-win32.whl", hash = "sha256:70dfd4f241c04bd9239d53b17f11e6ab672b9f1420364af63e8531198e3f5fe8", size = 81589, upload-time = "2025-10-06T14:10:09.254Z" }, + { url = "https://files.pythonhosted.org/packages/c8/9a/6ad1a9b37c2f72874f93e691b2e7ecb6137fb2b899983125db4204e47575/yarl-1.22.0-cp312-cp312-win_amd64.whl", hash = "sha256:8884d8b332a5e9b88e23f60bb166890009429391864c685e17bd73a9eda9105c", size = 87213, upload-time = "2025-10-06T14:10:11.369Z" }, + { url = "https://files.pythonhosted.org/packages/44/c5/c21b562d1680a77634d748e30c653c3ca918beb35555cff24986fff54598/yarl-1.22.0-cp312-cp312-win_arm64.whl", hash = "sha256:ea70f61a47f3cc93bdf8b2f368ed359ef02a01ca6393916bc8ff877427181e74", size = 81330, upload-time = "2025-10-06T14:10:13.112Z" }, + { url = "https://files.pythonhosted.org/packages/73/ae/b48f95715333080afb75a4504487cbe142cae1268afc482d06692d605ae6/yarl-1.22.0-py3-none-any.whl", hash = "sha256:1380560bdba02b6b6c90de54133c81c9f2a453dee9912fe58c1dcced1edb7cff", size = 46814, upload-time = "2025-10-06T14:12:53.872Z" }, +] From d66105692a19fe2584da38a6efc789a39459a469 Mon Sep 17 00:00:00 2001 From: Stijn van Houwelingen Date: Mon, 23 Feb 2026 14:43:39 +0100 Subject: [PATCH 002/181] fix: fix whitespace issues Signed-off-by: Stijn van Houwelingen --- .github/workflows/release.yml | 2 +- Dockerfile | 2 +- docker-compose.yml | 2 +- docs/conf.py | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 857b48b9..fe318b93 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -37,7 +37,7 @@ jobs: permissions: # IMPORTANT: this permission is mandatory for trusted publishing id-token: write - + # Dedicated environments with protections for publishing are strongly recommended. # For more information, see: https://docs.github.com/en/actions/deployment/targeting-different-environments/using-environments-for-deployment#deployment-protection-rules environment: diff --git a/Dockerfile b/Dockerfile index 862c98a8..1374c38a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -61,4 +61,4 @@ COPY --from=builder /app/src ./src # Set environment variables to optimize Python ENV PYTHONDONTWRITEBYTECODE=1 \ - PYTHONUNBUFFERED=1 \ No newline at end of file + PYTHONUNBUFFERED=1 diff --git a/docker-compose.yml b/docker-compose.yml index 16fbf2a0..02d12369 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -30,4 +30,4 @@ services: - python3 /app/src/flexmeasures_client/s2/script/websockets_server.py volumes: - flexmeasures-client: \ No newline at end of file + flexmeasures-client: diff --git a/docs/conf.py b/docs/conf.py index 2742a1d1..537c1212 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -8,8 +8,8 @@ # serve to show the default. import os -import sys import shutil +import sys # -- Path setup -------------------------------------------------------------- From cef07f3a1dc05caad700f4d9d90d175f2885363c Mon Sep 17 00:00:00 2001 From: Stijn van Houwelingen Date: Mon, 23 Feb 2026 14:46:19 +0100 Subject: [PATCH 003/181] feat: move from pre-commit git repos to using uv for pre-commit scripts Signed-off-by: Stijn van Houwelingen --- .github/workflows/ci.yml | 14 +++++-- .pre-commit-config.yaml | 35 +++++++++------- pyproject.toml | 3 ++ uv.lock | 86 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 120 insertions(+), 18 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5dd91102..143a2370 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,16 +15,22 @@ concurrency: jobs: check: runs-on: ubuntu-latest - name: Check (lint) + name: Check (on Python 3.12) steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 + - name: "Set up Python" + uses: actions/setup-python@v6 + with: + python-version-file: ".python-version" - name: Install uv uses: astral-sh/setup-uv@v7 with: enable-cache: true + cache-dependency-glob: '.pre-commit-config.yaml' uv-version: "0.10" - python-version-file: ".python-version" - - uses: pre-commit/action@v3.0.1 + - name: Install dev dependencies + run: uv sync --only-group dev --frozen + - uses: tox-dev/action-pre-commit-uv@v1 test: needs: check diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 8cc92f3f..206147c0 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -35,16 +35,22 @@ repos: # --remove-unused-variables, # ] -- repo: https://github.com/PyCQA/isort - rev: 5.12.0 +- repo: local hooks: - - id: isort + - id: isort + name: isort + language: system + entry: uv run isort . + require_serial: true + types_or: [cython, pyi, python] -- repo: https://github.com/psf/black - rev: 25.1.0 +- repo: local hooks: - - id: black - language_version: python3 + - id: black + name: black (code formatting) + language: system + entry: uv run black + types: [python] ## If like to embrace black styles even in the docs: # - repo: https://github.com/asottile/blacken-docs @@ -53,12 +59,13 @@ repos: # - id: blacken-docs # additional_dependencies: [black] -- repo: https://github.com/PyCQA/flake8 - rev: 7.3.0 +- repo: local hooks: - - id: flake8 - ## You can add flake8 plugins via `additional_dependencies`: - # additional_dependencies: [flake8-bugbear] + - id: flake8 + name: flake8 (code linting) + language: system + entry: uv run flake8 + types: [python] ## Check for misspells in documentation files: # - repo: https://github.com/codespell-project/codespell @@ -71,6 +78,6 @@ repos: - id: mypy name: mypy (static typing) pass_filenames: false - language: script - entry: ci/run_mypy.sh + language: system + entry: uv run poe type-check verbose: true diff --git a/pyproject.toml b/pyproject.toml index db37c95d..c6e57495 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -51,6 +51,9 @@ test = [ "pytest-mock>=3.15.1", ] dev = [ + "black>=25.1.0", + "flake8>=7.3.0", + "isort>=5.12.0", "mypy>=0.902", "poethepoet>=0.41.0", "pre-commit>=4.5.1", diff --git a/uv.lock b/uv.lock index 800ff0dc..7eafbebf 100644 --- a/uv.lock +++ b/uv.lock @@ -169,6 +169,36 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a0/59/76ab57e3fe74484f48a53f8e337171b4a2349e506eabe136d7e01d059086/backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5", size = 12313, upload-time = "2025-07-02T02:27:14.263Z" }, ] +[[package]] +name = "black" +version = "25.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "mypy-extensions" }, + { name = "packaging" }, + { name = "pathspec" }, + { name = "platformdirs" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/94/49/26a7b0f3f35da4b5a65f081943b7bcd22d7002f5f0fb8098ec1ff21cb6ef/black-25.1.0.tar.gz", hash = "sha256:33496d5cd1222ad73391352b4ae8da15253c5de89b93a80b3e2c8d9a19ec2666", size = 649449, upload-time = "2025-01-29T04:15:40.373Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4d/3b/4ba3f93ac8d90410423fdd31d7541ada9bcee1df32fb90d26de41ed40e1d/black-25.1.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:759e7ec1e050a15f89b770cefbf91ebee8917aac5c20483bc2d80a6c3a04df32", size = 1629419, upload-time = "2025-01-29T05:37:06.642Z" }, + { url = "https://files.pythonhosted.org/packages/b4/02/0bde0485146a8a5e694daed47561785e8b77a0466ccc1f3e485d5ef2925e/black-25.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0e519ecf93120f34243e6b0054db49c00a35f84f195d5bce7e9f5cfc578fc2da", size = 1461080, upload-time = "2025-01-29T05:37:09.321Z" }, + { url = "https://files.pythonhosted.org/packages/52/0e/abdf75183c830eaca7589144ff96d49bce73d7ec6ad12ef62185cc0f79a2/black-25.1.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:055e59b198df7ac0b7efca5ad7ff2516bca343276c466be72eb04a3bcc1f82d7", size = 1766886, upload-time = "2025-01-29T04:18:24.432Z" }, + { url = "https://files.pythonhosted.org/packages/dc/a6/97d8bb65b1d8a41f8a6736222ba0a334db7b7b77b8023ab4568288f23973/black-25.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:db8ea9917d6f8fc62abd90d944920d95e73c83a5ee3383493e35d271aca872e9", size = 1419404, upload-time = "2025-01-29T04:19:04.296Z" }, + { url = "https://files.pythonhosted.org/packages/7e/4f/87f596aca05c3ce5b94b8663dbfe242a12843caaa82dd3f85f1ffdc3f177/black-25.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a39337598244de4bae26475f77dda852ea00a93bd4c728e09eacd827ec929df0", size = 1614372, upload-time = "2025-01-29T05:37:11.71Z" }, + { url = "https://files.pythonhosted.org/packages/e7/d0/2c34c36190b741c59c901e56ab7f6e54dad8df05a6272a9747ecef7c6036/black-25.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:96c1c7cd856bba8e20094e36e0f948718dc688dba4a9d78c3adde52b9e6c2299", size = 1442865, upload-time = "2025-01-29T05:37:14.309Z" }, + { url = "https://files.pythonhosted.org/packages/21/d4/7518c72262468430ead45cf22bd86c883a6448b9eb43672765d69a8f1248/black-25.1.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bce2e264d59c91e52d8000d507eb20a9aca4a778731a08cfff7e5ac4a4bb7096", size = 1749699, upload-time = "2025-01-29T04:18:17.688Z" }, + { url = "https://files.pythonhosted.org/packages/58/db/4f5beb989b547f79096e035c4981ceb36ac2b552d0ac5f2620e941501c99/black-25.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:172b1dbff09f86ce6f4eb8edf9dede08b1fce58ba194c87d7a4f1a5aa2f5b3c2", size = 1428028, upload-time = "2025-01-29T04:18:51.711Z" }, + { url = "https://files.pythonhosted.org/packages/83/71/3fe4741df7adf015ad8dfa082dd36c94ca86bb21f25608eb247b4afb15b2/black-25.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4b60580e829091e6f9238c848ea6750efed72140b91b048770b64e74fe04908b", size = 1650988, upload-time = "2025-01-29T05:37:16.707Z" }, + { url = "https://files.pythonhosted.org/packages/13/f3/89aac8a83d73937ccd39bbe8fc6ac8860c11cfa0af5b1c96d081facac844/black-25.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1e2978f6df243b155ef5fa7e558a43037c3079093ed5d10fd84c43900f2d8ecc", size = 1453985, upload-time = "2025-01-29T05:37:18.273Z" }, + { url = "https://files.pythonhosted.org/packages/6f/22/b99efca33f1f3a1d2552c714b1e1b5ae92efac6c43e790ad539a163d1754/black-25.1.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3b48735872ec535027d979e8dcb20bf4f70b5ac75a8ea99f127c106a7d7aba9f", size = 1783816, upload-time = "2025-01-29T04:18:33.823Z" }, + { url = "https://files.pythonhosted.org/packages/18/7e/a27c3ad3822b6f2e0e00d63d58ff6299a99a5b3aee69fa77cd4b0076b261/black-25.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:ea0213189960bda9cf99be5b8c8ce66bb054af5e9e861249cd23471bd7b0b3ba", size = 1440860, upload-time = "2025-01-29T04:19:12.944Z" }, + { url = "https://files.pythonhosted.org/packages/09/71/54e999902aed72baf26bca0d50781b01838251a462612966e9fc4891eadd/black-25.1.0-py3-none-any.whl", hash = "sha256:95e8176dae143ba9097f351d174fdaf0ccd29efb414b362ae3fd72bf0f710717", size = 207646, upload-time = "2025-01-29T04:15:38.082Z" }, +] + [[package]] name = "certifi" version = "2026.1.4" @@ -382,6 +412,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9c/0f/5d0c71a1aefeb08efff26272149e07ab922b64f46c63363756224bd6872e/filelock-3.24.3-py3-none-any.whl", hash = "sha256:426e9a4660391f7f8a810d71b0555bce9008b0a1cc342ab1f6947d37639e002d", size = 24331, upload-time = "2026-02-19T00:48:18.465Z" }, ] +[[package]] +name = "flake8" +version = "7.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mccabe" }, + { name = "pycodestyle" }, + { name = "pyflakes" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9b/af/fbfe3c4b5a657d79e5c47a2827a362f9e1b763336a52f926126aa6dc7123/flake8-7.3.0.tar.gz", hash = "sha256:fe044858146b9fc69b551a4b490d69cf960fcb78ad1edcb84e7fbb1b4a8e3872", size = 48326, upload-time = "2025-06-20T19:31:35.838Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9f/56/13ab06b4f93ca7cac71078fbe37fcea175d3216f31f85c3168a6bbd0bb9a/flake8-7.3.0-py2.py3-none-any.whl", hash = "sha256:b9696257b9ce8beb888cdbe31cf885c90d31928fe202be0889a7cdafad32f01e", size = 57922, upload-time = "2025-06-20T19:31:34.425Z" }, +] + [[package]] name = "flexmeasures-client" source = { editable = "." } @@ -403,6 +447,9 @@ s2 = [ [package.dev-dependencies] dev = [ + { name = "black" }, + { name = "flake8" }, + { name = "isort" }, { name = "mypy" }, { name = "poethepoet" }, { name = "pre-commit" }, @@ -437,6 +484,9 @@ provides-extras = ["s2"] [package.metadata.requires-dev] dev = [ + { name = "black", specifier = ">=25.1.0" }, + { name = "flake8", specifier = ">=7.3.0" }, + { name = "isort", specifier = ">=5.12.0" }, { name = "mypy", specifier = ">=0.902" }, { name = "poethepoet", specifier = ">=0.41.0" }, { name = "pre-commit", specifier = ">=4.5.1" }, @@ -545,6 +595,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] +[[package]] +name = "isort" +version = "5.12.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a9/c4/dc00e42c158fc4dda2afebe57d2e948805c06d5169007f1724f0683010a9/isort-5.12.0.tar.gz", hash = "sha256:8bef7dde241278824a6d83f44a544709b065191b95b6e50894bdc722fcba0504", size = 174643, upload-time = "2023-01-28T17:10:22.636Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0a/63/4036ae70eea279c63e2304b91ee0ac182f467f24f86394ecfe726092340b/isort-5.12.0-py3-none-any.whl", hash = "sha256:f84c2818376e66cf843d497486ea8fed8700b340f308f076c6fb1229dff318b6", size = 91198, upload-time = "2023-01-28T17:10:21.149Z" }, +] + [[package]] name = "jinja2" version = "3.1.6" @@ -644,6 +703,15 @@ wheels = [ { 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" }, ] +[[package]] +name = "mccabe" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/ff/0ffefdcac38932a54d2b5eed4e0ba8a408f215002cd178ad1df0f2806ff8/mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325", size = 9658, upload-time = "2022-01-24T01:14:51.113Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/1a/1f68f9ba0c207934b35b86a8ca3aad8395a3d6dd7921c0686e23853ff5a9/mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e", size = 7350, upload-time = "2022-01-24T01:14:49.62Z" }, +] + [[package]] name = "multidict" version = "6.7.1" @@ -1056,6 +1124,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5b/5a/bc7b4a4ef808fa59a816c17b20c4bef6884daebbdf627ff2a161da67da19/propcache-0.4.1-py3-none-any.whl", hash = "sha256:af2a6052aeb6cf17d3e46ee169099044fd8224cbaf75c76a2ef596e8163e2237", size = 13305, upload-time = "2025-10-08T19:49:00.792Z" }, ] +[[package]] +name = "pycodestyle" +version = "2.14.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/11/e0/abfd2a0d2efe47670df87f3e3a0e2edda42f055053c85361f19c0e2c1ca8/pycodestyle-2.14.0.tar.gz", hash = "sha256:c4b5b517d278089ff9d0abdec919cd97262a3367449ea1c8b49b91529167b783", size = 39472, upload-time = "2025-06-20T18:49:48.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/27/a58ddaf8c588a3ef080db9d0b7e0b97215cee3a45df74f3a94dbbf5c893a/pycodestyle-2.14.0-py2.py3-none-any.whl", hash = "sha256:dd6bf7cb4ee77f8e016f9c8e74a35ddd9f67e1d5fd4184d86c3b98e07099f42d", size = 31594, upload-time = "2025-06-20T18:49:47.491Z" }, +] + [[package]] name = "pydantic" version = "2.12.5" @@ -1147,6 +1224,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/36/c7/cfc8e811f061c841d7990b0201912c3556bfeb99cdcb7ed24adc8d6f8704/pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51", size = 2145302, upload-time = "2025-11-04T13:43:46.64Z" }, ] +[[package]] +name = "pyflakes" +version = "3.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/45/dc/fd034dc20b4b264b3d015808458391acbf9df40b1e54750ef175d39180b1/pyflakes-3.4.0.tar.gz", hash = "sha256:b24f96fafb7d2ab0ec5075b7350b3d2d2218eab42003821c06344973d3ea2f58", size = 64669, upload-time = "2025-06-20T18:45:27.834Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/2f/81d580a0fb83baeb066698975cb14a618bdbed7720678566f1b046a95fe8/pyflakes-3.4.0-py2.py3-none-any.whl", hash = "sha256:f742a7dbd0d9cb9ea41e9a24a918996e8170c799fa528688d40dd582c8265f4f", size = 63551, upload-time = "2025-06-20T18:45:26.937Z" }, +] + [[package]] name = "pygments" version = "2.19.2" From d63afe5684d7e7eb97120335d6f81db2d1906b1b Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Wed, 25 Feb 2026 13:27:41 +0100 Subject: [PATCH 004/181] dev: try using the _sending_queue instead Signed-off-by: F.N. Claessen --- src/flexmeasures_client/s2/cem.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/flexmeasures_client/s2/cem.py b/src/flexmeasures_client/s2/cem.py index ecbdc8fa..83d6c866 100644 --- a/src/flexmeasures_client/s2/cem.py +++ b/src/flexmeasures_client/s2/cem.py @@ -290,7 +290,7 @@ async def handle_handshake(self, message: Handshake): # TODO: check the version that the RM is using and send a # `selected_protocol_version` that matches the one of the RM # TODO: Return a TBD "CloseConnection" message to close the connection - await self.send_message(get_reception_status(message, ReceptionStatusValues.OK)) + await self._sending_queue.put(get_reception_status(message, ReceptionStatusValues.OK)) latest_compatible_version = get_latest_compatible_version( message.supported_protocol_versions, From 564b8f1193fb0236040ee8d30a81bb7716620d42 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Wed, 25 Feb 2026 15:00:53 +0100 Subject: [PATCH 005/181] Revert "dev: try using the _sending_queue instead" This reverts commit d63afe5684d7e7eb97120335d6f81db2d1906b1b. --- src/flexmeasures_client/s2/cem.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/flexmeasures_client/s2/cem.py b/src/flexmeasures_client/s2/cem.py index 83d6c866..ecbdc8fa 100644 --- a/src/flexmeasures_client/s2/cem.py +++ b/src/flexmeasures_client/s2/cem.py @@ -290,7 +290,7 @@ async def handle_handshake(self, message: Handshake): # TODO: check the version that the RM is using and send a # `selected_protocol_version` that matches the one of the RM # TODO: Return a TBD "CloseConnection" message to close the connection - await self._sending_queue.put(get_reception_status(message, ReceptionStatusValues.OK)) + await self.send_message(get_reception_status(message, ReceptionStatusValues.OK)) latest_compatible_version = get_latest_compatible_version( message.supported_protocol_versions, From 2db7a4f5dbb439bb3693c3f81ffa66d2b707d42a Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Wed, 25 Feb 2026 14:22:17 +0100 Subject: [PATCH 006/181] fix: give the sending task enough time to flush the queue before the connection shuts down Signed-off-by: F.N. Claessen --- src/flexmeasures_client/s2/cem.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/flexmeasures_client/s2/cem.py b/src/flexmeasures_client/s2/cem.py index ecbdc8fa..b39da1c1 100644 --- a/src/flexmeasures_client/s2/cem.py +++ b/src/flexmeasures_client/s2/cem.py @@ -291,6 +291,7 @@ async def handle_handshake(self, message: Handshake): # `selected_protocol_version` that matches the one of the RM # TODO: Return a TBD "CloseConnection" message to close the connection await self.send_message(get_reception_status(message, ReceptionStatusValues.OK)) + await asyncio.sleep(0.5) latest_compatible_version = get_latest_compatible_version( message.supported_protocol_versions, From 35cc9add109290b20d7be8849aa773944f5c19e0 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Wed, 25 Feb 2026 14:58:34 +0100 Subject: [PATCH 007/181] dev: use DEBUG logging level Signed-off-by: F.N. Claessen --- docker-compose.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker-compose.yml b/docker-compose.yml index 8068b475..051c038c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -23,7 +23,7 @@ services: FLEXMEASURES_BASE_URL: http://server:5000 FLEXMEASURES_USER: toy-user@flexmeasures.io FLEXMEASURES_PASSWORD: toy-password - LOGGING_LEVEL: INFO + LOGGING_LEVEL: DEBUG volumes: # If flexmeasures_client lives in your repo and you want live edits - ../flexmeasures-client:/app/flexmeasures-client:rw From 375b49ce9482f6cf7fdc849dfd9d4bde8c0911dc Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Wed, 25 Feb 2026 15:00:01 +0100 Subject: [PATCH 008/181] feat: await message sent rather than message queued Signed-off-by: F.N. Claessen --- src/flexmeasures_client/s2/cem.py | 25 ++++++++++++------- .../s2/script/websockets_server.py | 10 +++++--- 2 files changed, 23 insertions(+), 12 deletions(-) diff --git a/src/flexmeasures_client/s2/cem.py b/src/flexmeasures_client/s2/cem.py index b39da1c1..a87c8017 100644 --- a/src/flexmeasures_client/s2/cem.py +++ b/src/flexmeasures_client/s2/cem.py @@ -59,7 +59,7 @@ class CEM(Handler): ] # maps the CommodityQuantity power measurement sensors to FM sensor IDs _fm_client: FlexMeasuresClient - _sending_queue: Queue[pydantic.BaseModel] + _sending_queue: asyncio.Queue[tuple[pydantic.BaseModel, asyncio.Future]] _timers: dict[str, datetime] _datastore: dict @@ -216,7 +216,7 @@ async def handle_message(self, message: Dict | pydantic.BaseModel | str): ) if response is not None: - await self._sending_queue.put(response) + await self.send_message(response) def update_control_type(self, control_type: ControlType): """ @@ -232,14 +232,20 @@ async def get_message(self) -> str: str: message in JSON format """ - message = await self._sending_queue.get() - await asyncio.sleep(0.3) + item = await self._sending_queue.get() + + if not isinstance(item, tuple) or len(item) != 2: + raise RuntimeError( + "Invalid item in sending queue. All messages must go through send_message() rather than _sending_queue.put()." + ) + + message, fut = item # Pending for pydantic V2 to implement model.model_dump(mode="json") in # PR #1409 (https://github.com/pydantic/pydantic/issues/1409) message = json.loads(message.json()) - return message + return message, fut async def activate_control_type( self, control_type: ControlType @@ -279,8 +285,7 @@ async def activate_control_type( ].register_success_callbacks( message_id, self.update_control_type, control_type=control_type ) - - await self._sending_queue.put( + await self.send_message( SelectControlType(message_id=message_id, control_type=control_type) ) return None @@ -291,7 +296,6 @@ async def handle_handshake(self, message: Handshake): # `selected_protocol_version` that matches the one of the RM # TODO: Return a TBD "CloseConnection" message to close the connection await self.send_message(get_reception_status(message, ReceptionStatusValues.OK)) - await asyncio.sleep(0.5) latest_compatible_version = get_latest_compatible_version( message.supported_protocol_versions, @@ -426,8 +430,11 @@ def handle_revoke_object(self, message: RevokeObject): return get_reception_status(message, ReceptionStatusValues.OK) async def send_message(self, message): + loop = asyncio.get_running_loop() + fut = loop.create_future() self._logger.debug(f"Sent: {message}") - await self._sending_queue.put(message) + await self._sending_queue.put((message, fut)) + await fut # wait until actually sent def get_commodity_unit(commodity_quantity) -> str: diff --git a/src/flexmeasures_client/s2/script/websockets_server.py b/src/flexmeasures_client/s2/script/websockets_server.py index afceeb56..8c223237 100644 --- a/src/flexmeasures_client/s2/script/websockets_server.py +++ b/src/flexmeasures_client/s2/script/websockets_server.py @@ -47,9 +47,13 @@ async def websocket_producer(ws, cem: CEM): cem._logger.debug("start websocket message producer") cem._logger.debug(f"IS CLOSED? {cem.is_closed()}") while not cem.is_closed(): - message = await cem.get_message() - cem._logger.debug("sending message") - await ws.send_json(message) + message, fut = await cem.get_message() + try: + cem._logger.debug("sending message") + await ws.send_json(message) + fut.set_result(True) + except Exception as exc: + fut.set_exception(exc) cem._logger.debug("cem closed") From 14f77924f25e235031c9a752cf04c55b897236f5 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Wed, 25 Feb 2026 15:00:19 +0100 Subject: [PATCH 009/181] fix: also close WS if closing CEM Signed-off-by: F.N. Claessen --- src/flexmeasures_client/s2/script/websockets_server.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/flexmeasures_client/s2/script/websockets_server.py b/src/flexmeasures_client/s2/script/websockets_server.py index 8c223237..cd3a27ff 100644 --- a/src/flexmeasures_client/s2/script/websockets_server.py +++ b/src/flexmeasures_client/s2/script/websockets_server.py @@ -75,6 +75,7 @@ async def websocket_consumer(ws, cem: CEM): elif msg.type == aiohttp.WSMsgType.ERROR: cem._logger.debug("close...") cem.close() + await ws.close() cem._logger.error(f"ws connection closed with exception {ws.exception()}") # TODO: save cem state? From 47995853ab9914c4b47cd7856874af964a619c18 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Wed, 25 Feb 2026 15:27:29 +0100 Subject: [PATCH 010/181] feat: skip setting up the toy account Signed-off-by: F.N. Claessen --- docker-compose.yml => docker-compose.override.yml | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) rename docker-compose.yml => docker-compose.override.yml (79%) diff --git a/docker-compose.yml b/docker-compose.override.yml similarity index 79% rename from docker-compose.yml rename to docker-compose.override.yml index 051c038c..25273db2 100644 --- a/docker-compose.yml +++ b/docker-compose.override.yml @@ -4,11 +4,18 @@ # run this from the flexmeasures folder (which contains the Dockerfile): # docker compose \ # -f docker-compose.yml \ -# -f ../flexmeasures-client/docker-compose.yml \ +# -f ../flexmeasures-client/docker-compose.override.yml \ # up # ------------------------------------------------------------------ services: + server: + command: + - | + pip install --break-system-packages -r /usr/var/flexmeasures-instance/requirements.txt + flexmeasures db upgrade + # toy account step removed + gunicorn --bind 0.0.0.0:5000 --worker-tmp-dir /dev/shm --workers 2 --threads 4 wsgi:applicationservices: cem: build: context: . From 89d433800d3d21ec8eb1058b3dd58a9a1ae3090f Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Wed, 25 Feb 2026 15:30:44 +0100 Subject: [PATCH 011/181] feat: post prices in background task Signed-off-by: F.N. Claessen --- .../s2/script/websockets_server.py | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/src/flexmeasures_client/s2/script/websockets_server.py b/src/flexmeasures_client/s2/script/websockets_server.py index cd3a27ff..9a10af37 100644 --- a/src/flexmeasures_client/s2/script/websockets_server.py +++ b/src/flexmeasures_client/s2/script/websockets_server.py @@ -169,12 +169,17 @@ async def configure_site( generic_asset_id=site_asset["id"], timezone="Europe/Amsterdam", ) - await fm_client.post_sensor_data( - sensor_id=price_sensor["id"], - start="2026-01-15T00:00+01", # 2026-01-01T00:00+01 - duration="P3D", # P1M - values=[0.3], - unit="EUR/kWh", + + # Continue immediately without awaiting + LOGGER.debug("Posting 3 days of prices in a background task..") + asyncio.create_task( + fm_client.post_sensor_data( + sensor_id=price_sensor["id"], + start="2026-01-15T00:00+01", # 2026-01-01T00:00+01 + duration="P3D", # P1M + values=[0.3], + unit="EUR/kWh", + ) ) if power_sensor is None: power_sensor = await fm_client.add_sensor( From 60ff6af74133ac12547e8508f89072d3eef3d661 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Wed, 25 Feb 2026 15:33:19 +0100 Subject: [PATCH 012/181] Revert "feat: skip setting up the toy account" This reverts commit 47995853ab9914c4b47cd7856874af964a619c18. --- docker-compose.override.yml => docker-compose.yml | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) rename docker-compose.override.yml => docker-compose.yml (79%) diff --git a/docker-compose.override.yml b/docker-compose.yml similarity index 79% rename from docker-compose.override.yml rename to docker-compose.yml index 25273db2..051c038c 100644 --- a/docker-compose.override.yml +++ b/docker-compose.yml @@ -4,18 +4,11 @@ # run this from the flexmeasures folder (which contains the Dockerfile): # docker compose \ # -f docker-compose.yml \ -# -f ../flexmeasures-client/docker-compose.override.yml \ +# -f ../flexmeasures-client/docker-compose.yml \ # up # ------------------------------------------------------------------ services: - server: - command: - - | - pip install --break-system-packages -r /usr/var/flexmeasures-instance/requirements.txt - flexmeasures db upgrade - # toy account step removed - gunicorn --bind 0.0.0.0:5000 --worker-tmp-dir /dev/shm --workers 2 --threads 4 wsgi:applicationservices: cem: build: context: . From 404ed6b373dc66cddad8c7de31888acebc833896 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Wed, 25 Feb 2026 15:54:37 +0100 Subject: [PATCH 013/181] chore: use modern method name Signed-off-by: F.N. Claessen --- src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py index 6e81f291..691c8dc7 100644 --- a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py +++ b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py @@ -62,7 +62,7 @@ def now(self): return self._timezone.localize(datetime.now()) async def send_storage_status(self, status: FRBCStorageStatus): - await self._fm_client.post_measurements( + await self._fm_client.post_sensor_data( self._soc_sensor_id, start=self.now(), values=[status.present_fill_level], @@ -82,7 +82,7 @@ async def send_actuator_status(self, status: FRBCActuatorStatus): dt = status.transition_timestamp # self.now() - await self._fm_client.post_measurements( + await self._fm_client.post_sensor_data( self._rm_discharge_sensor_id, start=dt, values=[-power], From 5d4de1070fe156a9abe1e9530c039a56f9591ccf Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Wed, 25 Feb 2026 15:55:07 +0100 Subject: [PATCH 014/181] fix: fall back on now in case FRBCActuatorStatus.transition_timestamp is missing Signed-off-by: F.N. Claessen --- src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py index 691c8dc7..f9244837 100644 --- a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py +++ b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py @@ -80,7 +80,7 @@ async def send_actuator_status(self, status: FRBCActuatorStatus): + (fill_rate.end_of_range - fill_rate.start_of_range) * factor ) - dt = status.transition_timestamp # self.now() + dt = status.transition_timestamp or self.now() await self._fm_client.post_sensor_data( self._rm_discharge_sensor_id, From f8ea65849cdd492a8ccb16b43e5f28f74d7df058 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Wed, 25 Feb 2026 15:57:21 +0100 Subject: [PATCH 015/181] fix: actuator status unit Signed-off-by: F.N. Claessen --- .../s2/control_types/FRBC/frbc_simple.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py index f9244837..72af00b8 100644 --- a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py +++ b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py @@ -45,6 +45,8 @@ def __init__( schedule_duration: timedelta = timedelta(hours=12), max_size: int = 100, valid_from_shift: timedelta = timedelta(days=1), + power_unit: str = "kW", + energy_unit: str = "kWh", ) -> None: super().__init__(max_size) self._power_sensor_id = power_sensor_id @@ -57,6 +59,8 @@ def __init__( # delay the start of the schedule from the time `valid_from` # of the FRBC.SystemDescription. self._valid_from_shift = valid_from_shift + self.power_unit = power_unit + self.energy_unit = energy_unit def now(self): return self._timezone.localize(datetime.now()) @@ -66,7 +70,7 @@ async def send_storage_status(self, status: FRBCStorageStatus): self._soc_sensor_id, start=self.now(), values=[status.present_fill_level], - unit="MWh", + unit=self.energy_unit, duration=timedelta(minutes=1), ) @@ -86,7 +90,7 @@ async def send_actuator_status(self, status: FRBCActuatorStatus): self._rm_discharge_sensor_id, start=dt, values=[-power], - unit="MWh", + unit=self.power_unit, duration=timedelta(minutes=15), ) From d59f3407a8d10ac2de5717fe9d7bea0aaa32052c Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Wed, 25 Feb 2026 15:27:29 +0100 Subject: [PATCH 016/181] feat: skip setting up the toy account Signed-off-by: F.N. Claessen --- docker-compose.yml => docker-compose.override.yml | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) rename docker-compose.yml => docker-compose.override.yml (79%) diff --git a/docker-compose.yml b/docker-compose.override.yml similarity index 79% rename from docker-compose.yml rename to docker-compose.override.yml index 051c038c..25273db2 100644 --- a/docker-compose.yml +++ b/docker-compose.override.yml @@ -4,11 +4,18 @@ # run this from the flexmeasures folder (which contains the Dockerfile): # docker compose \ # -f docker-compose.yml \ -# -f ../flexmeasures-client/docker-compose.yml \ +# -f ../flexmeasures-client/docker-compose.override.yml \ # up # ------------------------------------------------------------------ services: + server: + command: + - | + pip install --break-system-packages -r /usr/var/flexmeasures-instance/requirements.txt + flexmeasures db upgrade + # toy account step removed + gunicorn --bind 0.0.0.0:5000 --worker-tmp-dir /dev/shm --workers 2 --threads 4 wsgi:applicationservices: cem: build: context: . From 6abd2a11deac510c9f246044048e0af94ea491d5 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Wed, 25 Feb 2026 16:02:41 +0100 Subject: [PATCH 017/181] fix: copy-paste mistake Signed-off-by: F.N. Claessen --- docker-compose.override.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker-compose.override.yml b/docker-compose.override.yml index 25273db2..e562a60c 100644 --- a/docker-compose.override.yml +++ b/docker-compose.override.yml @@ -15,7 +15,7 @@ services: pip install --break-system-packages -r /usr/var/flexmeasures-instance/requirements.txt flexmeasures db upgrade # toy account step removed - gunicorn --bind 0.0.0.0:5000 --worker-tmp-dir /dev/shm --workers 2 --threads 4 wsgi:applicationservices: + gunicorn --bind 0.0.0.0:5000 --worker-tmp-dir /dev/shm --workers 2 --threads 4 wsgi:application cem: build: context: . From 5328de99f14f5797cf96105eb4f97a00b0f7a30a Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Wed, 25 Feb 2026 16:05:13 +0100 Subject: [PATCH 018/181] fix: discharge unit Signed-off-by: F.N. Claessen --- src/flexmeasures_client/s2/script/websockets_server.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/flexmeasures_client/s2/script/websockets_server.py b/src/flexmeasures_client/s2/script/websockets_server.py index 9a10af37..6d6e7550 100644 --- a/src/flexmeasures_client/s2/script/websockets_server.py +++ b/src/flexmeasures_client/s2/script/websockets_server.py @@ -201,7 +201,7 @@ async def configure_site( rm_discharge_sensor = await fm_client.add_sensor( name="RM discharge", event_resolution="PT15M", - unit="dimensionless", + unit="kW", generic_asset_id=site_asset["id"], timezone="Europe/Amsterdam", ) From eae232bc3c4a930b71b07a340421e91621b4de60 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Wed, 25 Feb 2026 16:06:33 +0100 Subject: [PATCH 019/181] docs: update instruction to run docker-compose stack Signed-off-by: F.N. Claessen --- docs/CEM.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/CEM.rst b/docs/CEM.rst index 72661c75..98fe41d5 100644 --- a/docs/CEM.rst +++ b/docs/CEM.rst @@ -15,14 +15,14 @@ Then point your Resource Managers (RMs) to ``http://localhost:8080/ws`` and run: python3 flexmeasures_client/s2/scripts/websockets_server.py -We also included a ``docker-compose.yaml`` that can be used to set up the CEM including the FlexMeasures server, creating a fully self-hosted HEMS. +We also included a ``docker-compose.override.yaml`` that can be used to set up the CEM including the FlexMeasures server, creating a fully self-hosted HEMS. Assuming your ``flexmeasures`` and ``flexmeasures-client`` repo folders are located side by side, run this from your flexmeasures folder: .. code-block:: bash docker compose \ -f docker-compose.yml \ - -f ../flexmeasures-client/docker-compose.yml \ + -f ../flexmeasures-client/docker-compose.override.yml \ up From 0e72c25776b4c25d8790cb8356cff42786032fbf Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Wed, 25 Feb 2026 16:13:12 +0100 Subject: [PATCH 020/181] Revert "fix: discharge unit" This reverts commit 5328de99f14f5797cf96105eb4f97a00b0f7a30a. --- src/flexmeasures_client/s2/script/websockets_server.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/flexmeasures_client/s2/script/websockets_server.py b/src/flexmeasures_client/s2/script/websockets_server.py index 6d6e7550..9a10af37 100644 --- a/src/flexmeasures_client/s2/script/websockets_server.py +++ b/src/flexmeasures_client/s2/script/websockets_server.py @@ -201,7 +201,7 @@ async def configure_site( rm_discharge_sensor = await fm_client.add_sensor( name="RM discharge", event_resolution="PT15M", - unit="kW", + unit="dimensionless", generic_asset_id=site_asset["id"], timezone="Europe/Amsterdam", ) From abb0a7436643faeaa0bd493e34fbedd4c291ec57 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Wed, 25 Feb 2026 16:13:38 +0100 Subject: [PATCH 021/181] fix: schedule power sensor instead of dimensionless discharge sensor Signed-off-by: F.N. Claessen --- src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py index 72af00b8..8a1c3353 100644 --- a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py +++ b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py @@ -87,7 +87,7 @@ async def send_actuator_status(self, status: FRBCActuatorStatus): dt = status.transition_timestamp or self.now() await self._fm_client.post_sensor_data( - self._rm_discharge_sensor_id, + self._power_sensor_id, start=dt, values=[-power], unit=self.power_unit, From bbaac73bfaa9e8e83a7a10f39c6a9a94864771b6 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Wed, 25 Feb 2026 16:39:45 +0100 Subject: [PATCH 022/181] feat: trigger schedule with each storage status (not yet rate limited) and make system_description_id optional Signed-off-by: F.N. Claessen --- .../s2/control_types/FRBC/frbc_simple.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py index 8a1c3353..dfb70b8b 100644 --- a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py +++ b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py @@ -73,6 +73,7 @@ async def send_storage_status(self, status: FRBCStorageStatus): unit=self.energy_unit, duration=timedelta(minutes=1), ) + await self.trigger_schedule() async def send_actuator_status(self, status: FRBCActuatorStatus): factor = status.operation_mode_factor @@ -94,12 +95,15 @@ async def send_actuator_status(self, status: FRBCActuatorStatus): duration=timedelta(minutes=15), ) - async def trigger_schedule(self, system_description_id: str): + async def trigger_schedule(self, system_description_id: str | None = None): """Translates S2 System Description into FM API calls""" - system_description: FRBCSystemDescription = self._system_description_history[ - system_description_id - ] + if system_description_id: + system_description: FRBCSystemDescription = self._system_description_history[ + system_description_id + ] + else: + system_description: FRBCSystemDescription = list(self._system_description_history.values())[-1] if len(self._storage_status_history) > 0: soc_at_start = list(self._storage_status_history.values())[ From 374e44aff236795522dffcdf077af979020b4117 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Wed, 25 Feb 2026 16:39:59 +0100 Subject: [PATCH 023/181] feat: post 1 year of data in background tasks Signed-off-by: F.N. Claessen --- .../s2/script/websockets_server.py | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/src/flexmeasures_client/s2/script/websockets_server.py b/src/flexmeasures_client/s2/script/websockets_server.py index 9a10af37..10076db2 100644 --- a/src/flexmeasures_client/s2/script/websockets_server.py +++ b/src/flexmeasures_client/s2/script/websockets_server.py @@ -171,16 +171,17 @@ async def configure_site( ) # Continue immediately without awaiting - LOGGER.debug("Posting 3 days of prices in a background task..") - asyncio.create_task( - fm_client.post_sensor_data( - sensor_id=price_sensor["id"], - start="2026-01-15T00:00+01", # 2026-01-01T00:00+01 - duration="P3D", # P1M - values=[0.3], - unit="EUR/kWh", + LOGGER.debug("Posting 1 year of prices in monthly background tasks..") + for m in range(12): + asyncio.create_task( + fm_client.post_sensor_data( + sensor_id=price_sensor["id"], + start=f"2026-{m+ 1:02}-01T00:00+01", # 2026-01-01T00:00+01 + m months + duration="P31D", + values=[0.3], + unit="EUR/kWh", + ) ) - ) if power_sensor is None: power_sensor = await fm_client.add_sensor( name="power", From a1d6ba749c0e39ebded28967f71168908aad3b55 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Wed, 25 Feb 2026 16:40:08 +0100 Subject: [PATCH 024/181] Revert "feat: post 1 year of data in background tasks" This reverts commit 374e44aff236795522dffcdf077af979020b4117. --- .../s2/script/websockets_server.py | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/src/flexmeasures_client/s2/script/websockets_server.py b/src/flexmeasures_client/s2/script/websockets_server.py index 10076db2..9a10af37 100644 --- a/src/flexmeasures_client/s2/script/websockets_server.py +++ b/src/flexmeasures_client/s2/script/websockets_server.py @@ -171,17 +171,16 @@ async def configure_site( ) # Continue immediately without awaiting - LOGGER.debug("Posting 1 year of prices in monthly background tasks..") - for m in range(12): - asyncio.create_task( - fm_client.post_sensor_data( - sensor_id=price_sensor["id"], - start=f"2026-{m+ 1:02}-01T00:00+01", # 2026-01-01T00:00+01 + m months - duration="P31D", - values=[0.3], - unit="EUR/kWh", - ) + LOGGER.debug("Posting 3 days of prices in a background task..") + asyncio.create_task( + fm_client.post_sensor_data( + sensor_id=price_sensor["id"], + start="2026-01-15T00:00+01", # 2026-01-01T00:00+01 + duration="P3D", # P1M + values=[0.3], + unit="EUR/kWh", ) + ) if power_sensor is None: power_sensor = await fm_client.add_sensor( name="power", From f8e0d621efa883b23db7612277693d0dfdcc95da Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Wed, 25 Feb 2026 16:42:16 +0100 Subject: [PATCH 025/181] fix: set prior knowledge of prices and test with now Signed-off-by: F.N. Claessen --- src/flexmeasures_client/s2/script/websockets_server.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/flexmeasures_client/s2/script/websockets_server.py b/src/flexmeasures_client/s2/script/websockets_server.py index 9a10af37..a22d8780 100644 --- a/src/flexmeasures_client/s2/script/websockets_server.py +++ b/src/flexmeasures_client/s2/script/websockets_server.py @@ -175,7 +175,8 @@ async def configure_site( asyncio.create_task( fm_client.post_sensor_data( sensor_id=price_sensor["id"], - start="2026-01-15T00:00+01", # 2026-01-01T00:00+01 + start="2026-02-25T00:00+01", # now + prior="2026-01-01T00:00+01", # 2026-01-01T00:00+01 duration="P3D", # P1M values=[0.3], unit="EUR/kWh", From be6d8d30176b21ab651e118682f8b70699cf7d31 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Wed, 25 Feb 2026 16:52:16 +0100 Subject: [PATCH 026/181] fix: floor the schedule start Signed-off-by: F.N. Claessen --- src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py index dfb70b8b..5336cfef 100644 --- a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py +++ b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py @@ -116,9 +116,10 @@ async def trigger_schedule(self, system_description_id: str | None = None): soc_min, soc_max = get_soc_min_max(system_description) # call schedule + start = system_description.valid_from + self._valid_from_shift # TODO: localize datetime + start = start.replace(minute=(start.minute // 15) * 15, second=0, microsecond=0) schedule = await self._fm_client.trigger_and_get_schedule( - start=system_description.valid_from - + self._valid_from_shift, # TODO: localize datetime + start=start, sensor_id=self._power_sensor_id, flex_context={ "production-price": {"sensor": self._price_sensor_id}, From 5334c0c083f9281f3f6f7d9fd634b1b198f319bc Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Wed, 25 Feb 2026 16:54:10 +0100 Subject: [PATCH 027/181] fix: messages should now be routed through cem.send_message Signed-off-by: F.N. Claessen --- src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py index 5336cfef..0131ac87 100644 --- a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py +++ b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py @@ -144,4 +144,4 @@ async def trigger_schedule(self, system_description_id: str | None = None): # put instructions to sending queue for instruction in instructions: - await self._sending_queue.put(instruction) + await self.send_message(instruction) From 2845036cade18fee7f2d139c34719b448f1bf52b Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Wed, 25 Feb 2026 17:01:56 +0100 Subject: [PATCH 028/181] fix: messages should now be routed through cem.send_message; update handlers accordingly Signed-off-by: F.N. Claessen --- src/flexmeasures_client/s2/cem.py | 4 ++-- src/flexmeasures_client/s2/control_types/FRBC/__init__.py | 2 +- src/flexmeasures_client/s2/control_types/__init__.py | 5 ++--- 3 files changed, 5 insertions(+), 6 deletions(-) diff --git a/src/flexmeasures_client/s2/cem.py b/src/flexmeasures_client/s2/cem.py index a87c8017..46bc7f99 100644 --- a/src/flexmeasures_client/s2/cem.py +++ b/src/flexmeasures_client/s2/cem.py @@ -159,8 +159,8 @@ def register_control_type(self, control_type_handler: ControlTypeHandler): # add fm_client to control_type handler control_type_handler._fm_client = self._fm_client - # add sending queue - control_type_handler._sending_queue = self._sending_queue + # add send_message method so the handler can send messages + control_type_handler.send_message = self.send_message # Add logger control_type_handler._logger = self._logger diff --git a/src/flexmeasures_client/s2/control_types/FRBC/__init__.py b/src/flexmeasures_client/s2/control_types/FRBC/__init__.py index 2b4ccf15..94c85b43 100644 --- a/src/flexmeasures_client/s2/control_types/FRBC/__init__.py +++ b/src/flexmeasures_client/s2/control_types/FRBC/__init__.py @@ -196,4 +196,4 @@ async def trigger_schedule(self, system_description_id: str): ) # put instruction into the sending queue - await self._sending_queue.put(instruction) + await self.send_message(instruction) diff --git a/src/flexmeasures_client/s2/control_types/__init__.py b/src/flexmeasures_client/s2/control_types/__init__.py index b3e19f5b..7fc6137c 100644 --- a/src/flexmeasures_client/s2/control_types/__init__.py +++ b/src/flexmeasures_client/s2/control_types/__init__.py @@ -1,8 +1,7 @@ from __future__ import annotations -from asyncio import Queue from logging import Logger -from typing import cast +from typing import cast, Callable from pydantic import BaseModel from s2python.common import ( @@ -22,7 +21,7 @@ class ControlTypeHandler(Handler): _instruction_history: SizeLimitOrderedDict[str, BaseModel] _instruction_status_history: SizeLimitOrderedDict[str, InstructionStatus] _fm_client: FlexMeasuresClient - _sending_queue: Queue + send_message: Callable _logger: Logger def __init__(self, max_size: int = 100) -> None: From e01176d9e8c72860f7c619b7e73177c53001de5b Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Wed, 25 Feb 2026 17:12:39 +0100 Subject: [PATCH 029/181] feat: roll 3 days of test prices Signed-off-by: F.N. Claessen --- src/flexmeasures_client/s2/script/websockets_server.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/flexmeasures_client/s2/script/websockets_server.py b/src/flexmeasures_client/s2/script/websockets_server.py index a22d8780..e5a3103e 100644 --- a/src/flexmeasures_client/s2/script/websockets_server.py +++ b/src/flexmeasures_client/s2/script/websockets_server.py @@ -2,6 +2,8 @@ import json import logging import os +from datetime import datetime +from zoneinfo import ZoneInfo import aiohttp from aiohttp import web @@ -172,10 +174,11 @@ async def configure_site( # Continue immediately without awaiting LOGGER.debug("Posting 3 days of prices in a background task..") + start_of_today = datetime.now(ZoneInfo("Europe/Amsterdam")).replace(hour=0, minute=0, second=0, microsecond=0).isoformat() asyncio.create_task( fm_client.post_sensor_data( sensor_id=price_sensor["id"], - start="2026-02-25T00:00+01", # now + start=start_of_today, prior="2026-01-01T00:00+01", # 2026-01-01T00:00+01 duration="P3D", # P1M values=[0.3], From c780f9899e2dd9a3ff255c417757e907c026ee7a Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Wed, 25 Feb 2026 17:37:30 +0100 Subject: [PATCH 030/181] style: black, isort Signed-off-by: F.N. Claessen --- .../s2/control_types/FRBC/frbc_simple.py | 14 +++++++++----- .../s2/control_types/__init__.py | 2 +- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py index 0131ac87..1a3afbe7 100644 --- a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py +++ b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py @@ -99,11 +99,13 @@ async def trigger_schedule(self, system_description_id: str | None = None): """Translates S2 System Description into FM API calls""" if system_description_id: - system_description: FRBCSystemDescription = self._system_description_history[ - system_description_id - ] + system_description: FRBCSystemDescription = ( + self._system_description_history[system_description_id] + ) else: - system_description: FRBCSystemDescription = list(self._system_description_history.values())[-1] + system_description: FRBCSystemDescription = list( + self._system_description_history.values() + )[-1] if len(self._storage_status_history) > 0: soc_at_start = list(self._storage_status_history.values())[ @@ -116,7 +118,9 @@ async def trigger_schedule(self, system_description_id: str | None = None): soc_min, soc_max = get_soc_min_max(system_description) # call schedule - start = system_description.valid_from + self._valid_from_shift # TODO: localize datetime + start = ( + system_description.valid_from + self._valid_from_shift + ) # TODO: localize datetime start = start.replace(minute=(start.minute // 15) * 15, second=0, microsecond=0) schedule = await self._fm_client.trigger_and_get_schedule( start=start, diff --git a/src/flexmeasures_client/s2/control_types/__init__.py b/src/flexmeasures_client/s2/control_types/__init__.py index 7fc6137c..429ad419 100644 --- a/src/flexmeasures_client/s2/control_types/__init__.py +++ b/src/flexmeasures_client/s2/control_types/__init__.py @@ -1,7 +1,7 @@ from __future__ import annotations from logging import Logger -from typing import cast, Callable +from typing import Callable, cast from pydantic import BaseModel from s2python.common import ( From 26f4778349dd7334d251987a6a518a7eb8962505 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Wed, 25 Feb 2026 17:48:03 +0100 Subject: [PATCH 031/181] style: black Signed-off-by: F.N. Claessen --- src/flexmeasures_client/s2/script/websockets_server.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/flexmeasures_client/s2/script/websockets_server.py b/src/flexmeasures_client/s2/script/websockets_server.py index e5a3103e..7a08d025 100644 --- a/src/flexmeasures_client/s2/script/websockets_server.py +++ b/src/flexmeasures_client/s2/script/websockets_server.py @@ -174,7 +174,11 @@ async def configure_site( # Continue immediately without awaiting LOGGER.debug("Posting 3 days of prices in a background task..") - start_of_today = datetime.now(ZoneInfo("Europe/Amsterdam")).replace(hour=0, minute=0, second=0, microsecond=0).isoformat() + start_of_today = ( + datetime.now(ZoneInfo("Europe/Amsterdam")) + .replace(hour=0, minute=0, second=0, microsecond=0) + .isoformat() + ) asyncio.create_task( fm_client.post_sensor_data( sensor_id=price_sensor["id"], From 5fb3a262d11659262294988969f101d6f2258687 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Thu, 26 Feb 2026 13:37:26 +0100 Subject: [PATCH 032/181] feat: make schedules appear with consumption on the positive axis Signed-off-by: F.N. Claessen --- src/flexmeasures_client/s2/script/websockets_server.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/flexmeasures_client/s2/script/websockets_server.py b/src/flexmeasures_client/s2/script/websockets_server.py index 7a08d025..a56c6eac 100644 --- a/src/flexmeasures_client/s2/script/websockets_server.py +++ b/src/flexmeasures_client/s2/script/websockets_server.py @@ -196,6 +196,7 @@ async def configure_site( unit="kW", generic_asset_id=site_asset["id"], timezone="Europe/Amsterdam", + attributes={"consumption_is_positive": True}, ) if soc_sensor is None: soc_sensor = await fm_client.add_sensor( From 508611b631bfed4114191efbed0854b772c16c94 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Thu, 26 Feb 2026 13:39:57 +0100 Subject: [PATCH 033/181] fix: update _sending_queue.put to send_message in FillRateBasedControlTUNES, too Signed-off-by: F.N. Claessen --- .../s2/control_types/FRBC/frbc_tunes.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/flexmeasures_client/s2/control_types/FRBC/frbc_tunes.py b/src/flexmeasures_client/s2/control_types/FRBC/frbc_tunes.py index 6b822791..2c647209 100644 --- a/src/flexmeasures_client/s2/control_types/FRBC/frbc_tunes.py +++ b/src/flexmeasures_client/s2/control_types/FRBC/frbc_tunes.py @@ -215,7 +215,7 @@ async def send_storage_status(self, status: FRBCStorageStatus): subject_message_id=status.message_id, status=ReceptionStatusValues.PERMANENT_ERROR, ) - await self._sending_queue.put(response) + await self.send_message(response) await self.trigger_schedule() async def send_leakage_behaviour(self, leakage: FRBCLeakageBehaviour): @@ -246,7 +246,7 @@ async def send_leakage_behaviour(self, leakage: FRBCLeakageBehaviour): subject_message_id=leakage.message_id, status=ReceptionStatusValues.PERMANENT_ERROR, ) - await self._sending_queue.put(response) + await self.send_message(response) async def send_actuator_status(self, status: FRBCActuatorStatus): if not self._is_timer_due("actuator_status"): @@ -514,12 +514,12 @@ async def trigger_schedule(self): object_id=message_id, ) self._logger.debug(f"Sending revoke instruction for {message_id}") - await self._sending_queue.put(revoke_instruction) + await self.send_message(revoke_instruction) self._datastore["instructions"] = {} # Put the instruction in the sending queue for instruction in instructions: - await self._sending_queue.put(instruction) + await self.send_message(instruction) # Store instructions for instruction in instructions: From 07b55c51f2ddc1cc7b2e1c66c24847b4705e77be Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Fri, 27 Feb 2026 09:15:11 +0100 Subject: [PATCH 034/181] feat: port send_fill_level_target_profile Signed-off-by: F.N. Claessen --- .../s2/control_types/FRBC/frbc_simple.py | 51 +++++++++++++++++++ .../s2/script/websockets_server.py | 28 +++++++++- 2 files changed, 77 insertions(+), 2 deletions(-) diff --git a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py index 1a3afbe7..e8533c69 100644 --- a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py +++ b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py @@ -7,9 +7,12 @@ import pytz +import pandas as pd + try: from s2python.frbc import ( FRBCActuatorStatus, + FRBCFillLevelTargetProfile, FRBCStorageStatus, FRBCSystemDescription, ) @@ -25,6 +28,9 @@ fm_schedule_to_instructions, get_soc_min_max, ) +from flexmeasures_client.s2.control_types.translations import ( + translate_fill_level_target_profile, +) class FRBCSimple(FRBC): @@ -41,6 +47,8 @@ def __init__( soc_sensor_id: int, rm_discharge_sensor_id: int, price_sensor_id: int, + soc_minima_sensor_id: int, + soc_maxima_sensor_id: int, timezone: str = "UTC", schedule_duration: timedelta = timedelta(hours=12), max_size: int = 100, @@ -54,6 +62,8 @@ def __init__( self._schedule_duration = schedule_duration self._soc_sensor_id = soc_sensor_id self._rm_discharge_sensor_id = rm_discharge_sensor_id + self._soc_minima_sensor_id = soc_minima_sensor_id + self._soc_maxima_sensor_id = soc_maxima_sensor_id self._timezone = pytz.timezone(timezone) # delay the start of the schedule from the time `valid_from` @@ -135,6 +145,8 @@ async def trigger_schedule(self, system_description_id: str | None = None): "soc-at-start": soc_at_start, # TODO: use forecast of the SOC instead "soc-min": soc_min, "soc-max": soc_max, + "soc-minima": {"sensor": self._soc_minima_sensor_id}, + "soc-maxima": {"sensor": self._soc_maxima_sensor_id}, }, duration=self._schedule_duration, # next 12 hours # TODO: add SOC MAX AND SOC MIN FROM fill_level_range, @@ -149,3 +161,42 @@ async def trigger_schedule(self, system_description_id: str | None = None): # put instructions to sending queue for instruction in instructions: await self.send_message(instruction) + + async def send_fill_level_target_profile( + self, fill_level_target_profile: FRBCFillLevelTargetProfile + ): + """ + Send FRBC.FillLevelTargetProfile to FlexMeasures. + + Args: + fill_level_target_profile (FRBCFillLevelTargetProfile): The fill level target profile to be translated and sent. + """ + # if not self._is_timer_due("fill_level_target_profile"): + # return + RESOLUTION = "15min" + + soc_minima, soc_maxima = translate_fill_level_target_profile( + fill_level_target_profile=fill_level_target_profile, + resolution=RESOLUTION, + fill_level_scale=1, + ) + + duration = str(pd.Timedelta(RESOLUTION) * len(soc_maxima)) + + # POST SOC Minima measurements to FlexMeasures + await self._fm_client.post_sensor_data( + sensor_id=self._soc_minima_sensor_id, + start=fill_level_target_profile.start_time, + values=soc_minima.tolist(), + unit=self.energy_unit, + duration=duration, + ) + + # POST SOC Maxima measurements to FlexMeasures + await self._fm_client.post_sensor_data( + sensor_id=self._soc_maxima_sensor_id, + start=fill_level_target_profile.start_time, + values=soc_maxima.tolist(), + unit=self.energy_unit, + duration=duration, + ) diff --git a/src/flexmeasures_client/s2/script/websockets_server.py b/src/flexmeasures_client/s2/script/websockets_server.py index a56c6eac..6bf6e57b 100644 --- a/src/flexmeasures_client/s2/script/websockets_server.py +++ b/src/flexmeasures_client/s2/script/websockets_server.py @@ -93,7 +93,7 @@ async def websocket_handler(request): "toy-password", "toy-user@flexmeasures.io", host="server:5000" ) - price_sensor, power_sensor, soc_sensor, rm_discharge_sensor = await configure_site( + price_sensor, power_sensor, soc_sensor, rm_discharge_sensor, soc_minima_sensor, soc_maxima_sensor = await configure_site( site_name, fm_client ) @@ -107,6 +107,8 @@ async def websocket_handler(request): price_sensor_id=price_sensor["id"], soc_sensor_id=soc_sensor["id"], rm_discharge_sensor_id=rm_discharge_sensor["id"], + soc_minima_sensor_id=soc_minima_sensor["id"], + soc_maxima_sensor_id=soc_maxima_sensor["id"], ) cem.register_control_type(frbc) @@ -153,6 +155,8 @@ async def configure_site( power_sensor = None soc_sensor = None rm_discharge_sensor = None + soc_minima_sensor = None + soc_maxima_sensor = None for sensor in sensors: if sensor["name"] == "price": price_sensor = sensor @@ -162,6 +166,10 @@ async def configure_site( soc_sensor = sensor elif sensor["name"] == "RM discharge": rm_discharge_sensor = sensor + elif sensor["name"] == "soc-minima": + soc_minima_sensor = sensor + elif sensor["name"] == "soc-maxima": + soc_maxima_sensor = sensor if price_sensor is None: price_sensor = await fm_client.add_sensor( @@ -214,7 +222,23 @@ async def configure_site( generic_asset_id=site_asset["id"], timezone="Europe/Amsterdam", ) - return price_sensor, power_sensor, soc_sensor, rm_discharge_sensor + if soc_minima_sensor is None: + soc_minima_sensor = await fm_client.add_sensor( + name="soc-minima", + event_resolution="PT15M", + unit="kWh", + generic_asset_id=site_asset["id"], + timezone="Europe/Amsterdam", + ) + if soc_maxima_sensor is None: + soc_maxima_sensor = await fm_client.add_sensor( + name="soc-maxima", + event_resolution="PT15M", + unit="kWh", + generic_asset_id=site_asset["id"], + timezone="Europe/Amsterdam", + ) + return price_sensor, power_sensor, soc_sensor, rm_discharge_sensor, soc_minima_sensor, soc_maxima_sensor app = web.Application() From 872224c5036bd6ba1a3f563db5ab4bdd4597bc15 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Fri, 27 Feb 2026 09:16:43 +0100 Subject: [PATCH 035/181] feat: save scheduled state-of-charge, too Signed-off-by: F.N. Claessen --- src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py index e8533c69..81213c88 100644 --- a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py +++ b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py @@ -147,6 +147,7 @@ async def trigger_schedule(self, system_description_id: str | None = None): "soc-max": soc_max, "soc-minima": {"sensor": self._soc_minima_sensor_id}, "soc-maxima": {"sensor": self._soc_maxima_sensor_id}, + "state-of-charge": {"sensor": self._soc_sensor_id}, }, duration=self._schedule_duration, # next 12 hours # TODO: add SOC MAX AND SOC MIN FROM fill_level_range, From 0b96cc5f9d86bb4c7d31ace4d9042ef06aa58715 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Fri, 27 Feb 2026 10:19:38 +0100 Subject: [PATCH 036/181] feat: set sensors_to_show on CEM asset Signed-off-by: F.N. Claessen --- .../s2/script/websockets_server.py | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/src/flexmeasures_client/s2/script/websockets_server.py b/src/flexmeasures_client/s2/script/websockets_server.py index 6bf6e57b..b55a67db 100644 --- a/src/flexmeasures_client/s2/script/websockets_server.py +++ b/src/flexmeasures_client/s2/script/websockets_server.py @@ -128,7 +128,7 @@ async def configure_site( account = await fm_client.get_account() assets = await fm_client.get_assets(parse_json_fields=True) - site_asset = None + site_asset: dict | None = None for asset in assets: if asset["name"] == site_name: site_asset = asset @@ -238,6 +238,20 @@ async def configure_site( generic_asset_id=site_asset["id"], timezone="Europe/Amsterdam", ) + sensors_to_show = [ + { + "title": "State of charge", + "sensors": [soc_minima_sensor["id"], soc_maxima_sensor["id"], soc_sensor["id"]], + }, + { + "title": "Prices", + "sensor": price_sensor["id"], + }, + ] + await fm_client.update_asset( + asset_id=site_asset["id"], + updates=dict(sensors_to_show=sensors_to_show), + ) return price_sensor, power_sensor, soc_sensor, rm_discharge_sensor, soc_minima_sensor, soc_maxima_sensor From 5ef6f6431b44ac4edce331fda029eadcb29bba85 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Fri, 27 Feb 2026 10:20:04 +0100 Subject: [PATCH 037/181] fix: type annotation Signed-off-by: F.N. Claessen --- src/flexmeasures_client/s2/script/websockets_server.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/flexmeasures_client/s2/script/websockets_server.py b/src/flexmeasures_client/s2/script/websockets_server.py index b55a67db..575f57e4 100644 --- a/src/flexmeasures_client/s2/script/websockets_server.py +++ b/src/flexmeasures_client/s2/script/websockets_server.py @@ -124,7 +124,7 @@ async def websocket_handler(request): async def configure_site( site_name: str, fm_client: FlexMeasuresClient -) -> tuple[dict, dict, dict, dict]: +) -> tuple[dict, dict, dict, dict, dict, dict]: account = await fm_client.get_account() assets = await fm_client.get_assets(parse_json_fields=True) From 010d584f50542d8b8696e7975160d80ae34ba703 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Fri, 27 Feb 2026 10:24:42 +0100 Subject: [PATCH 038/181] fix: flex-model soc-unit Signed-off-by: F.N. Claessen --- src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py index 81213c88..432fda1a 100644 --- a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py +++ b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py @@ -141,7 +141,7 @@ async def trigger_schedule(self, system_description_id: str | None = None): "site-power-capacity": f"{3 * 25 * 230} VA", }, flex_model={ - "soc-unit": "MWh", + "soc-unit": self.energy_unit, "soc-at-start": soc_at_start, # TODO: use forecast of the SOC instead "soc-min": soc_min, "soc-max": soc_max, From c38951040e185f821c7f92f08109bfb317c57d42 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Fri, 27 Feb 2026 10:42:25 +0100 Subject: [PATCH 039/181] fix: get rid of valid_from_shift Signed-off-by: F.N. Claessen --- .../s2/control_types/FRBC/frbc_simple.py | 8 +------- .../s2/control_types/FRBC/frbc_tunes.py | 5 ----- 2 files changed, 1 insertion(+), 12 deletions(-) diff --git a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py index 432fda1a..80874739 100644 --- a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py +++ b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py @@ -39,7 +39,6 @@ class FRBCSimple(FRBC): _soc_sensor_id: int _rm_discharge_sensor_id: int _schedule_duration: timedelta - _valid_from_shift: timedelta def __init__( self, @@ -52,7 +51,6 @@ def __init__( timezone: str = "UTC", schedule_duration: timedelta = timedelta(hours=12), max_size: int = 100, - valid_from_shift: timedelta = timedelta(days=1), power_unit: str = "kW", energy_unit: str = "kWh", ) -> None: @@ -65,10 +63,6 @@ def __init__( self._soc_minima_sensor_id = soc_minima_sensor_id self._soc_maxima_sensor_id = soc_maxima_sensor_id self._timezone = pytz.timezone(timezone) - - # delay the start of the schedule from the time `valid_from` - # of the FRBC.SystemDescription. - self._valid_from_shift = valid_from_shift self.power_unit = power_unit self.energy_unit = energy_unit @@ -129,7 +123,7 @@ async def trigger_schedule(self, system_description_id: str | None = None): # call schedule start = ( - system_description.valid_from + self._valid_from_shift + system_description.valid_from ) # TODO: localize datetime start = start.replace(minute=(start.minute // 15) * 15, second=0, microsecond=0) schedule = await self._fm_client.trigger_and_get_schedule( diff --git a/src/flexmeasures_client/s2/control_types/FRBC/frbc_tunes.py b/src/flexmeasures_client/s2/control_types/FRBC/frbc_tunes.py index 2c647209..21217669 100644 --- a/src/flexmeasures_client/s2/control_types/FRBC/frbc_tunes.py +++ b/src/flexmeasures_client/s2/control_types/FRBC/frbc_tunes.py @@ -123,7 +123,6 @@ def __init__( schedule_duration: timedelta = timedelta(hours=12), max_size: int = 100, fill_level_scale: float = 0.1, - valid_from_shift: timedelta = timedelta(days=1), timers: dict[str, datetime] | None = None, datastore: dict | None = None, **kwargs, @@ -154,10 +153,6 @@ def __init__( self._production_price_sensor_id = production_price_sensor self._timezone = pytz.timezone(timezone) - - # delay the start of the schedule from the time `valid_from` of the FRBC.SystemDescription - self._valid_from_shift = valid_from_shift - self._fill_level_scale = fill_level_scale self._active_recurring_schedule = False From e5ad8a0a9f86bb248dc09161d8bab92e7697ae4a Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Fri, 27 Feb 2026 10:45:01 +0100 Subject: [PATCH 040/181] dev: log used SystemDescription Signed-off-by: F.N. Claessen --- src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py index 80874739..672cda95 100644 --- a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py +++ b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py @@ -107,9 +107,11 @@ async def trigger_schedule(self, system_description_id: str | None = None): self._system_description_history[system_description_id] ) else: + # Use last SystemDescription system_description: FRBCSystemDescription = list( self._system_description_history.values() )[-1] + self._logger.debug(f"Using system description: {system_description}") if len(self._storage_status_history) > 0: soc_at_start = list(self._storage_status_history.values())[ From 54dd188138d2b4737610d97afa326ec16b8875b4 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Fri, 27 Feb 2026 10:47:07 +0100 Subject: [PATCH 041/181] style: black Signed-off-by: F.N. Claessen --- .../s2/control_types/FRBC/frbc_simple.py | 13 +++++++--- .../s2/script/websockets_server.py | 26 +++++++++++++++---- 2 files changed, 31 insertions(+), 8 deletions(-) diff --git a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py index 672cda95..b81a8c53 100644 --- a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py +++ b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py @@ -111,6 +111,15 @@ async def trigger_schedule(self, system_description_id: str | None = None): system_description: FRBCSystemDescription = list( self._system_description_history.values() )[-1] + system_descriptions = self._system_description_history.values() + self._logger.error( + list( + [ + system_description.valid_from + for system_description in system_descriptions + ] + ) + ) self._logger.debug(f"Using system description: {system_description}") if len(self._storage_status_history) > 0: @@ -124,9 +133,7 @@ async def trigger_schedule(self, system_description_id: str | None = None): soc_min, soc_max = get_soc_min_max(system_description) # call schedule - start = ( - system_description.valid_from - ) # TODO: localize datetime + start = system_description.valid_from # TODO: localize datetime start = start.replace(minute=(start.minute // 15) * 15, second=0, microsecond=0) schedule = await self._fm_client.trigger_and_get_schedule( start=start, diff --git a/src/flexmeasures_client/s2/script/websockets_server.py b/src/flexmeasures_client/s2/script/websockets_server.py index 575f57e4..b1018adc 100644 --- a/src/flexmeasures_client/s2/script/websockets_server.py +++ b/src/flexmeasures_client/s2/script/websockets_server.py @@ -93,9 +93,14 @@ async def websocket_handler(request): "toy-password", "toy-user@flexmeasures.io", host="server:5000" ) - price_sensor, power_sensor, soc_sensor, rm_discharge_sensor, soc_minima_sensor, soc_maxima_sensor = await configure_site( - site_name, fm_client - ) + ( + price_sensor, + power_sensor, + soc_sensor, + rm_discharge_sensor, + soc_minima_sensor, + soc_maxima_sensor, + ) = await configure_site(site_name, fm_client) cem = CEM( sensor_id=power_sensor["id"], @@ -241,7 +246,11 @@ async def configure_site( sensors_to_show = [ { "title": "State of charge", - "sensors": [soc_minima_sensor["id"], soc_maxima_sensor["id"], soc_sensor["id"]], + "sensors": [ + soc_minima_sensor["id"], + soc_maxima_sensor["id"], + soc_sensor["id"], + ], }, { "title": "Prices", @@ -252,7 +261,14 @@ async def configure_site( asset_id=site_asset["id"], updates=dict(sensors_to_show=sensors_to_show), ) - return price_sensor, power_sensor, soc_sensor, rm_discharge_sensor, soc_minima_sensor, soc_maxima_sensor + return ( + price_sensor, + power_sensor, + soc_sensor, + rm_discharge_sensor, + soc_minima_sensor, + soc_maxima_sensor, + ) app = web.Application() From 2819e4f4da0f7661725e2344db981b58f69531d0 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Fri, 27 Feb 2026 10:55:10 +0100 Subject: [PATCH 042/181] feat: port send_usage_forecast Signed-off-by: F.N. Claessen --- .../s2/control_types/FRBC/frbc_simple.py | 53 +++++++++++++++++-- .../s2/script/websockets_server.py | 16 +++++- 2 files changed, 64 insertions(+), 5 deletions(-) diff --git a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py index b81a8c53..849d3a92 100644 --- a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py +++ b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py @@ -15,6 +15,7 @@ FRBCFillLevelTargetProfile, FRBCStorageStatus, FRBCSystemDescription, + FRBCUsageForecast, ) except ImportError: raise ImportError( @@ -30,6 +31,7 @@ ) from flexmeasures_client.s2.control_types.translations import ( translate_fill_level_target_profile, + translate_usage_forecast_to_fm, ) @@ -38,7 +40,12 @@ class FRBCSimple(FRBC): _price_sensor_id: int _soc_sensor_id: int _rm_discharge_sensor_id: int + _soc_minima_sensor_id: int + _soc_maxima_sensor_id: int + _usage_forecast_sensor_id: int _schedule_duration: timedelta + _fill_level_scale: int = 1 + _resolution = "15min" def __init__( self, @@ -48,6 +55,7 @@ def __init__( price_sensor_id: int, soc_minima_sensor_id: int, soc_maxima_sensor_id: int, + usage_forecast_sensor_id: int, timezone: str = "UTC", schedule_duration: timedelta = timedelta(hours=12), max_size: int = 100, @@ -62,6 +70,7 @@ def __init__( self._rm_discharge_sensor_id = rm_discharge_sensor_id self._soc_minima_sensor_id = soc_minima_sensor_id self._soc_maxima_sensor_id = soc_maxima_sensor_id + self._usage_forecast_sensor_id = usage_forecast_sensor_id self._timezone = pytz.timezone(timezone) self.power_unit = power_unit self.energy_unit = energy_unit @@ -151,6 +160,7 @@ async def trigger_schedule(self, system_description_id: str | None = None): "soc-minima": {"sensor": self._soc_minima_sensor_id}, "soc-maxima": {"sensor": self._soc_maxima_sensor_id}, "state-of-charge": {"sensor": self._soc_sensor_id}, + "soc-usage": [{"sensor": self._usage_forecast_sensor_id}], }, duration=self._schedule_duration, # next 12 hours # TODO: add SOC MAX AND SOC MIN FROM fill_level_range, @@ -177,15 +187,14 @@ async def send_fill_level_target_profile( """ # if not self._is_timer_due("fill_level_target_profile"): # return - RESOLUTION = "15min" soc_minima, soc_maxima = translate_fill_level_target_profile( fill_level_target_profile=fill_level_target_profile, - resolution=RESOLUTION, - fill_level_scale=1, + resolution=self._resolution, + fill_level_scale=self._fill_level_scale, ) - duration = str(pd.Timedelta(RESOLUTION) * len(soc_maxima)) + duration = str(pd.Timedelta(self._resolution) * len(soc_maxima)) # POST SOC Minima measurements to FlexMeasures await self._fm_client.post_sensor_data( @@ -204,3 +213,39 @@ async def send_fill_level_target_profile( unit=self.energy_unit, duration=duration, ) + + async def send_usage_forecast(self, usage_forecast: FRBCUsageForecast): + """ + Send FRBC.UsageForecast to FlexMeasures. + + Args: + usage_forecast (FRBCUsageForecast): The usage forecast to be translated and sent. + """ + # if not self._is_timer_due("usage_forecast"): + # return + + start_time = usage_forecast.start_time + + # flooring to previous 15min tick + start_time = start_time.replace( + minute=(start_time.minute // 15) * 15, second=0, microsecond=0 + ) + + usage_forecast = translate_usage_forecast_to_fm( + usage_forecast, + self._resolution, + strategy="mean", + fill_level_scale=self._fill_level_scale, + ) + + # Scale usage forecast e.g. [0, 100] %/s -> [0, 100] %/(15 min) + scale = timedelta(minutes=15) / timedelta(seconds=1) + scaled_usage_forecast = usage_forecast * scale + + await self._fm_client.post_sensor_data( + sensor_id=self._usage_forecast_sensor_id, + start=start_time, + values=scaled_usage_forecast.tolist(), + unit=self.power_unit, # e.g. [0, 100] MW/(15 min) + duration=str(pd.Timedelta(self._resolution) * len(usage_forecast)), + ) diff --git a/src/flexmeasures_client/s2/script/websockets_server.py b/src/flexmeasures_client/s2/script/websockets_server.py index b1018adc..19c104cb 100644 --- a/src/flexmeasures_client/s2/script/websockets_server.py +++ b/src/flexmeasures_client/s2/script/websockets_server.py @@ -100,6 +100,7 @@ async def websocket_handler(request): rm_discharge_sensor, soc_minima_sensor, soc_maxima_sensor, + usage_forecast_sensor, ) = await configure_site(site_name, fm_client) cem = CEM( @@ -114,6 +115,7 @@ async def websocket_handler(request): rm_discharge_sensor_id=rm_discharge_sensor["id"], soc_minima_sensor_id=soc_minima_sensor["id"], soc_maxima_sensor_id=soc_maxima_sensor["id"], + usage_forecast_sensor_id=usage_forecast_sensor["id"], ) cem.register_control_type(frbc) @@ -129,7 +131,7 @@ async def websocket_handler(request): async def configure_site( site_name: str, fm_client: FlexMeasuresClient -) -> tuple[dict, dict, dict, dict, dict, dict]: +) -> tuple[dict, dict, dict, dict, dict, dict, dict]: account = await fm_client.get_account() assets = await fm_client.get_assets(parse_json_fields=True) @@ -162,6 +164,7 @@ async def configure_site( rm_discharge_sensor = None soc_minima_sensor = None soc_maxima_sensor = None + usage_forecast_sensor = None for sensor in sensors: if sensor["name"] == "price": price_sensor = sensor @@ -175,6 +178,8 @@ async def configure_site( soc_minima_sensor = sensor elif sensor["name"] == "soc-maxima": soc_maxima_sensor = sensor + elif sensor["name"] == "usage-forecast": + usage_forecast_sensor = sensor if price_sensor is None: price_sensor = await fm_client.add_sensor( @@ -243,6 +248,14 @@ async def configure_site( generic_asset_id=site_asset["id"], timezone="Europe/Amsterdam", ) + if usage_forecast_sensor is None: + usage_forecast_sensor = await fm_client.add_sensor( + name="usage-forecast", + event_resolution="PT15M", + unit="kW", + generic_asset_id=site_asset["id"], + timezone="Europe/Amsterdam", + ) sensors_to_show = [ { "title": "State of charge", @@ -268,6 +281,7 @@ async def configure_site( rm_discharge_sensor, soc_minima_sensor, soc_maxima_sensor, + usage_forecast_sensor, ) From 6cf71ad21d17c4f02f169c0c80772a24ccf5ecd5 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Fri, 27 Feb 2026 11:04:42 +0100 Subject: [PATCH 043/181] feat: relax constraints Signed-off-by: F.N. Claessen --- src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py index 849d3a92..4a1b1f39 100644 --- a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py +++ b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py @@ -151,6 +151,7 @@ async def trigger_schedule(self, system_description_id: str | None = None): "production-price": {"sensor": self._price_sensor_id}, "consumption-price": {"sensor": self._price_sensor_id}, "site-power-capacity": f"{3 * 25 * 230} VA", + "relax-constraints": True, }, flex_model={ "soc-unit": self.energy_unit, From b78fbc8cd956665637a8e063c544f0d1af47a0a3 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Fri, 27 Feb 2026 14:11:53 +0100 Subject: [PATCH 044/181] feat: we already remove scheduled power values that are not a change with respect to the previous value, but here we also remove instructions that are not a change to the previous instructions; because sometimes different scheduled power values can map to the same instruction Signed-off-by: F.N. Claessen --- .../s2/control_types/FRBC/utils.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/flexmeasures_client/s2/control_types/FRBC/utils.py b/src/flexmeasures_client/s2/control_types/FRBC/utils.py index 04ce1056..ebb1319a 100644 --- a/src/flexmeasures_client/s2/control_types/FRBC/utils.py +++ b/src/flexmeasures_client/s2/control_types/FRBC/utils.py @@ -161,6 +161,7 @@ def fm_schedule_to_instructions( deltaT = timedelta(minutes=15) / timedelta(hours=1) + previous_instruction = None for timestamp, row in schedule.iterrows(): power = row["schedule"] usage = row.get("usage_forecast", 0) @@ -221,9 +222,21 @@ def fm_schedule_to_instructions( execution_time=timestamp, abnormal_condition=False, ) + if previous_instruction and all( + getattr(previous_instruction, attr) == getattr(instruction, attr) + for attr in ( + "actuator_id", + "operation_mode", + "operation_mode_factor", + "abnormal_condition", + ) + ): + logger.info("Instruction removed, no changes to previous instruction") + continue logger.info( f"Instruction created: at {timestamp} set {actuator.diagnostic_label if isinstance(actuator.diagnostic_label, str) else actuator} to {best_operation_mode.diagnostic_label if isinstance(best_operation_mode.diagnostic_label, str) else best_operation_mode} with factor {operation_mode_factor}" ) + previous_instruction = instruction instructions.append(instruction) # Update fill level From baac3bb3ae922502e0b590e823029a05b759cdb1 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Fri, 27 Feb 2026 14:14:17 +0100 Subject: [PATCH 045/181] dev: debug log JSON instructions Signed-off-by: F.N. Claessen --- .../s2/control_types/FRBC/utils.py | 50 ++++++++++++++++++- 1 file changed, 48 insertions(+), 2 deletions(-) diff --git a/src/flexmeasures_client/s2/control_types/FRBC/utils.py b/src/flexmeasures_client/s2/control_types/FRBC/utils.py index ebb1319a..46cc7d4e 100644 --- a/src/flexmeasures_client/s2/control_types/FRBC/utils.py +++ b/src/flexmeasures_client/s2/control_types/FRBC/utils.py @@ -1,4 +1,6 @@ +import json import logging +import uuid from datetime import timedelta from math import isclose from typing import List @@ -9,6 +11,7 @@ try: from s2python.common import NumberRange from s2python.frbc import ( + FRBCActuatorDescription, FRBCInstruction, FRBCLeakageBehaviour, FRBCOperationMode, @@ -155,7 +158,12 @@ def fm_schedule_to_instructions( f"{len(system_description.actuators)} were provided" ) - operation_modes: list[FRBCOperationMode] = actuator.operation_modes + actuators: dict[uuid.UUID, FRBCActuatorDescription] = { + a.id: a for a in system_description.actuators + } + operation_modes: dict[uuid.UUID, FRBCOperationMode] = { + om.id: om for om in actuator.operation_modes + } fill_level = initial_fill_level @@ -170,7 +178,7 @@ def fm_schedule_to_instructions( # Convert from power to fill rate results = [ (om, *power_to_fill_rate_with_metrics(om, power, fill_level)) - for om in operation_modes + for om in operation_modes.values() ] # Step 1: minimize fill-level penalty (primary) @@ -238,6 +246,18 @@ def fm_schedule_to_instructions( ) previous_instruction = instruction instructions.append(instruction) + logger.debug( + "Instructions JSON: %s", + json.dumps( + [ + serialize_instruction( + instr, actuators=actuators, operation_modes=operation_modes + ) + for instr in instructions + ], + indent=2, + ), + ) # Update fill level fill_level = compute_next_fill_level( @@ -394,3 +414,29 @@ def explain_choice( lines.append(f"{label} (element={element_label}): rejected due to {reason}") return "; ".join(lines) + + +def serialize_instruction( + instr: FRBCInstruction, + actuators: dict[uuid.UUID, FRBCActuatorDescription], + operation_modes: dict[uuid.UUID, FRBCOperationMode], +): + """Create dict of instructions suitable for logging.""" + actuator = ( + getattr(actuators[instr.actuator_id], "diagnostic_label", None) + or instr.actuator_id + ) + operation_mode = ( + getattr(operation_modes[instr.operation_mode], "diagnostic_label", None) + or instr.operation_mode + ) + return { + "message_type": instr.message_type, + "message_id": str(instr.message_id), + "instruction_id": str(instr.id), + "actuator": str(actuator), + "operation_mode": str(operation_mode), + "operation_mode_factor": instr.operation_mode_factor, + "execution_time": instr.execution_time.isoformat(), + "abnormal_condition": instr.abnormal_condition, + } From 0577d534c5cb5e34faa5863676a7185bedf6ad1f Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Fri, 27 Feb 2026 14:18:59 +0100 Subject: [PATCH 046/181] dev: speed up polling for simulations Signed-off-by: F.N. Claessen --- src/flexmeasures_client/s2/script/websockets_server.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/flexmeasures_client/s2/script/websockets_server.py b/src/flexmeasures_client/s2/script/websockets_server.py index 19c104cb..0660922e 100644 --- a/src/flexmeasures_client/s2/script/websockets_server.py +++ b/src/flexmeasures_client/s2/script/websockets_server.py @@ -90,7 +90,10 @@ async def websocket_handler(request): site_name = "My CEM" fm_client = FlexMeasuresClient( - "toy-password", "toy-user@flexmeasures.io", host="server:5000" + host="server:5000", + email="toy-user@flexmeasures.io", + password="toy-password", + polling_interval=0.5, ) ( From b99b39b0508b3ae65207a930534ce85873ba9304 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Fri, 27 Feb 2026 16:03:14 +0100 Subject: [PATCH 047/181] docs: add instruction to create an admin user Signed-off-by: F.N. Claessen --- docs/CEM.rst | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/CEM.rst b/docs/CEM.rst index 98fe41d5..5a604d45 100644 --- a/docs/CEM.rst +++ b/docs/CEM.rst @@ -40,6 +40,14 @@ To test, run the included example RM: python3 flexmeasures_client/s2/script/websockets_client.py +For full access via the UI, create an admin user for the Docker Toy Account (here, we assume it has ID 1): + +.. code-block:: bash + + docker exec -it flexmeasures-server-1 bash + flexmeasures show accounts + flexmeasures add user --roles admin --account 1 --email --username + Disclaimer ========== From 7ce4c35bbcc6edb76c8d2f9f8f1425b130a5e52b Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Fri, 27 Feb 2026 16:10:48 +0100 Subject: [PATCH 048/181] refactor: rename variable Signed-off-by: F.N. Claessen --- src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py index 4a1b1f39..d85ec637 100644 --- a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py +++ b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py @@ -98,11 +98,11 @@ async def send_actuator_status(self, status: FRBCActuatorStatus): + (fill_rate.end_of_range - fill_rate.start_of_range) * factor ) - dt = status.transition_timestamp or self.now() + start = status.transition_timestamp or self.now() await self._fm_client.post_sensor_data( self._power_sensor_id, - start=dt, + start=start, values=[-power], unit=self.power_unit, duration=timedelta(minutes=15), From 806dae0cf51e041923ddf676656da37236d39721 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Fri, 27 Feb 2026 16:23:19 +0100 Subject: [PATCH 049/181] style: isort Signed-off-by: F.N. Claessen --- src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py index d85ec637..8b7604fb 100644 --- a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py +++ b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py @@ -5,9 +5,8 @@ from datetime import datetime, timedelta -import pytz - import pandas as pd +import pytz try: from s2python.frbc import ( From 5be4b64ac9f55a1fa0ef500cca5f1d641d9b6e82 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Fri, 27 Feb 2026 16:27:44 +0100 Subject: [PATCH 050/181] fix: mypy Signed-off-by: F.N. Claessen --- src/flexmeasures_client/s2/cem.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/flexmeasures_client/s2/cem.py b/src/flexmeasures_client/s2/cem.py index 46bc7f99..4abebde7 100644 --- a/src/flexmeasures_client/s2/cem.py +++ b/src/flexmeasures_client/s2/cem.py @@ -225,7 +225,7 @@ def update_control_type(self, control_type: ControlType): """ self._control_type = control_type - async def get_message(self) -> str: + async def get_message(self) -> tuple[str, asyncio.Future]: """Call this function to get the messages to be sent to the RM Returns: From abb68e062124e36c5070fe7af9bc9351538e1945 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Fri, 27 Feb 2026 16:33:54 +0100 Subject: [PATCH 051/181] chore: resolve implicit todo Signed-off-by: F.N. Claessen --- src/flexmeasures_client/s2/cem.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/flexmeasures_client/s2/cem.py b/src/flexmeasures_client/s2/cem.py index 4abebde7..ace85a71 100644 --- a/src/flexmeasures_client/s2/cem.py +++ b/src/flexmeasures_client/s2/cem.py @@ -240,10 +240,7 @@ async def get_message(self) -> tuple[str, asyncio.Future]: ) message, fut = item - - # Pending for pydantic V2 to implement model.model_dump(mode="json") in - # PR #1409 (https://github.com/pydantic/pydantic/issues/1409) - message = json.loads(message.json()) + message = message.model_dump(mode="json") return message, fut From 59108c0dec5223bad98988778d79b55cfdbf20d0 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Fri, 27 Feb 2026 16:35:29 +0100 Subject: [PATCH 052/181] fix: obvious typo Signed-off-by: F.N. Claessen --- src/flexmeasures_client/s2/control_types/FRBC/frbc_tunes.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/flexmeasures_client/s2/control_types/FRBC/frbc_tunes.py b/src/flexmeasures_client/s2/control_types/FRBC/frbc_tunes.py index 21217669..58428840 100644 --- a/src/flexmeasures_client/s2/control_types/FRBC/frbc_tunes.py +++ b/src/flexmeasures_client/s2/control_types/FRBC/frbc_tunes.py @@ -230,7 +230,7 @@ async def send_leakage_behaviour(self, leakage: FRBCLeakageBehaviour): leakage_behaviour_to_storage_efficiency( message=leakage, resolution=timedelta(minutes=15), - fill_level_scale=self._fill_level_scalefill_level_scale, + fill_level_scale=self._fill_level_scale, ) ], unit=PERCENTAGE, From 92fd551132d302646552311898d00e56a72833c0 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Fri, 27 Feb 2026 16:47:14 +0100 Subject: [PATCH 053/181] fix: debug log instead of error log Signed-off-by: F.N. Claessen --- src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py index 8b7604fb..76d314ef 100644 --- a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py +++ b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py @@ -120,7 +120,7 @@ async def trigger_schedule(self, system_description_id: str | None = None): self._system_description_history.values() )[-1] system_descriptions = self._system_description_history.values() - self._logger.error( + self._logger.debug( list( [ system_description.valid_from From 1b8d4c96e1e86817fd1a15703a9dd0a89970dbb9 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Fri, 27 Feb 2026 16:52:13 +0100 Subject: [PATCH 054/181] feat: port send_leakage_behaviour Signed-off-by: F.N. Claessen --- .../s2/control_types/FRBC/frbc_simple.py | 28 +++++++++++++++++++ .../s2/script/websockets_server.py | 16 ++++++++++- 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py index 76d314ef..c8d70631 100644 --- a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py +++ b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py @@ -12,6 +12,7 @@ from s2python.frbc import ( FRBCActuatorStatus, FRBCFillLevelTargetProfile, + FRBCLeakageBehaviour, FRBCStorageStatus, FRBCSystemDescription, FRBCUsageForecast, @@ -31,6 +32,7 @@ from flexmeasures_client.s2.control_types.translations import ( translate_fill_level_target_profile, translate_usage_forecast_to_fm, + leakage_behaviour_to_storage_efficiency, ) @@ -42,6 +44,7 @@ class FRBCSimple(FRBC): _soc_minima_sensor_id: int _soc_maxima_sensor_id: int _usage_forecast_sensor_id: int + _leakage_behaviour_sensor_id: int _schedule_duration: timedelta _fill_level_scale: int = 1 _resolution = "15min" @@ -55,6 +58,7 @@ def __init__( soc_minima_sensor_id: int, soc_maxima_sensor_id: int, usage_forecast_sensor_id: int, + leakage_behaviour_sensor_id: int, timezone: str = "UTC", schedule_duration: timedelta = timedelta(hours=12), max_size: int = 100, @@ -70,6 +74,7 @@ def __init__( self._soc_minima_sensor_id = soc_minima_sensor_id self._soc_maxima_sensor_id = soc_maxima_sensor_id self._usage_forecast_sensor_id = usage_forecast_sensor_id + self._leakage_behaviour_sensor_id = leakage_behaviour_sensor_id self._timezone = pytz.timezone(timezone) self.power_unit = power_unit self.energy_unit = energy_unit @@ -161,6 +166,7 @@ async def trigger_schedule(self, system_description_id: str | None = None): "soc-maxima": {"sensor": self._soc_maxima_sensor_id}, "state-of-charge": {"sensor": self._soc_sensor_id}, "soc-usage": [{"sensor": self._usage_forecast_sensor_id}], + "storage-efficiency": {"sensor": self._leakage_behaviour_sensor_id}, }, duration=self._schedule_duration, # next 12 hours # TODO: add SOC MAX AND SOC MIN FROM fill_level_range, @@ -249,3 +255,25 @@ async def send_usage_forecast(self, usage_forecast: FRBCUsageForecast): unit=self.power_unit, # e.g. [0, 100] MW/(15 min) duration=str(pd.Timedelta(self._resolution) * len(usage_forecast)), ) + + async def send_leakage_behaviour(self, leakage: FRBCLeakageBehaviour): + # if not self._is_timer_due("leakage_behaviour"): + # return + + start = leakage.valid_from or self.now() + start = start.replace(minute=(start.minute // 15) * 15, second=0, microsecond=0) + + storage_efficiency = leakage_behaviour_to_storage_efficiency( + message=leakage, + resolution=timedelta(minutes=15), + fill_level_scale=self._fill_level_scale, + ) + self._logger.debug(storage_efficiency) + + await self._fm_client.post_sensor_data( + self._leakage_behaviour_sensor_id, + start=start, + values=[storage_efficiency], + unit="%", + duration=timedelta(hours=48), + ) diff --git a/src/flexmeasures_client/s2/script/websockets_server.py b/src/flexmeasures_client/s2/script/websockets_server.py index 0660922e..6ef67321 100644 --- a/src/flexmeasures_client/s2/script/websockets_server.py +++ b/src/flexmeasures_client/s2/script/websockets_server.py @@ -104,6 +104,7 @@ async def websocket_handler(request): soc_minima_sensor, soc_maxima_sensor, usage_forecast_sensor, + leakage_behaviour_sensor_id, ) = await configure_site(site_name, fm_client) cem = CEM( @@ -119,6 +120,7 @@ async def websocket_handler(request): soc_minima_sensor_id=soc_minima_sensor["id"], soc_maxima_sensor_id=soc_maxima_sensor["id"], usage_forecast_sensor_id=usage_forecast_sensor["id"], + leakage_behaviour_sensor_id=leakage_behaviour_sensor_id["id"], ) cem.register_control_type(frbc) @@ -134,7 +136,7 @@ async def websocket_handler(request): async def configure_site( site_name: str, fm_client: FlexMeasuresClient -) -> tuple[dict, dict, dict, dict, dict, dict, dict]: +) -> tuple[dict, dict, dict, dict, dict, dict, dict, dict]: account = await fm_client.get_account() assets = await fm_client.get_assets(parse_json_fields=True) @@ -168,6 +170,7 @@ async def configure_site( soc_minima_sensor = None soc_maxima_sensor = None usage_forecast_sensor = None + leakage_behaviour_sensor = None for sensor in sensors: if sensor["name"] == "price": price_sensor = sensor @@ -183,6 +186,8 @@ async def configure_site( soc_maxima_sensor = sensor elif sensor["name"] == "usage-forecast": usage_forecast_sensor = sensor + elif sensor["name"] == "leakage-behaviour": + leakage_behaviour_sensor = sensor if price_sensor is None: price_sensor = await fm_client.add_sensor( @@ -259,6 +264,14 @@ async def configure_site( generic_asset_id=site_asset["id"], timezone="Europe/Amsterdam", ) + if leakage_behaviour_sensor is None: + leakage_behaviour_sensor = await fm_client.add_sensor( + name="leakage-behaviour", + event_resolution="PT15M", + unit="%", + generic_asset_id=site_asset["id"], + timezone="Europe/Amsterdam", + ) sensors_to_show = [ { "title": "State of charge", @@ -285,6 +298,7 @@ async def configure_site( soc_minima_sensor, soc_maxima_sensor, usage_forecast_sensor, + leakage_behaviour_sensor, ) From fe02d3313db3a94ff217f104b7c02bb1b2de24d2 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Mon, 2 Mar 2026 17:28:52 +0100 Subject: [PATCH 055/181] feat: CEM supports setting simulation time, by wrapping the S2 message together with metadata in an envelope, where the metadata contains a dt Signed-off-by: F.N. Claessen --- src/flexmeasures_client/s2/cem.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/flexmeasures_client/s2/cem.py b/src/flexmeasures_client/s2/cem.py index ace85a71..6b1ea8fd 100644 --- a/src/flexmeasures_client/s2/cem.py +++ b/src/flexmeasures_client/s2/cem.py @@ -185,7 +185,19 @@ async def handle_message(self, message: Dict | pydantic.BaseModel | str): if isinstance(message, str): message = json.loads(message) - self._logger.debug(f"Received: {message}") + # Detect wrapper + if isinstance(message, dict) and "message" in message and "metadata" in message: + metadata = message["metadata"] + message = message["message"] + self._logger.debug(f"Received wrapped message") + self._logger.debug(f"Received message: {message}") + self._logger.debug(f"Received metadata: {metadata}") + if "dt" in metadata: + for control_type in self._control_types_handlers.values(): + control_type.now = lambda: metadata["dt"] + self.now = lambda: metadata["dt"] + else: + self._logger.debug(f"Received: {message}") # try to handle the message with the control_type handle if ( From fd91a624f538d76568fab7123ed0ef7cc88bfd84 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Mon, 2 Mar 2026 19:48:47 +0100 Subject: [PATCH 056/181] style: isort, flake8 Signed-off-by: F.N. Claessen --- src/flexmeasures_client/s2/cem.py | 2 +- src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/flexmeasures_client/s2/cem.py b/src/flexmeasures_client/s2/cem.py index 6b1ea8fd..e94394ff 100644 --- a/src/flexmeasures_client/s2/cem.py +++ b/src/flexmeasures_client/s2/cem.py @@ -189,7 +189,7 @@ async def handle_message(self, message: Dict | pydantic.BaseModel | str): if isinstance(message, dict) and "message" in message and "metadata" in message: metadata = message["metadata"] message = message["message"] - self._logger.debug(f"Received wrapped message") + self._logger.debug("Received wrapped message") self._logger.debug(f"Received message: {message}") self._logger.debug(f"Received metadata: {metadata}") if "dt" in metadata: diff --git a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py index c8d70631..1e5d3e64 100644 --- a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py +++ b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py @@ -30,9 +30,9 @@ get_soc_min_max, ) from flexmeasures_client.s2.control_types.translations import ( + leakage_behaviour_to_storage_efficiency, translate_fill_level_target_profile, translate_usage_forecast_to_fm, - leakage_behaviour_to_storage_efficiency, ) From e8e60061c3fd422dbf473482a8f841eaa61831aa Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Mon, 2 Mar 2026 19:55:29 +0100 Subject: [PATCH 057/181] feat: record data in FlexMeasures as if it was recorded at simulation time Signed-off-by: F.N. Claessen --- .../s2/control_types/FRBC/frbc_simple.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py index 1e5d3e64..309fd441 100644 --- a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py +++ b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py @@ -86,6 +86,7 @@ async def send_storage_status(self, status: FRBCStorageStatus): await self._fm_client.post_sensor_data( self._soc_sensor_id, start=self.now(), + prior=self.now(), values=[status.present_fill_level], unit=self.energy_unit, duration=timedelta(minutes=1), @@ -107,6 +108,7 @@ async def send_actuator_status(self, status: FRBCActuatorStatus): await self._fm_client.post_sensor_data( self._power_sensor_id, start=start, + prior=self.now(), values=[-power], unit=self.power_unit, duration=timedelta(minutes=15), @@ -206,6 +208,7 @@ async def send_fill_level_target_profile( await self._fm_client.post_sensor_data( sensor_id=self._soc_minima_sensor_id, start=fill_level_target_profile.start_time, + prior=self.now(), values=soc_minima.tolist(), unit=self.energy_unit, duration=duration, @@ -215,6 +218,7 @@ async def send_fill_level_target_profile( await self._fm_client.post_sensor_data( sensor_id=self._soc_maxima_sensor_id, start=fill_level_target_profile.start_time, + prior=self.now(), values=soc_maxima.tolist(), unit=self.energy_unit, duration=duration, @@ -251,6 +255,7 @@ async def send_usage_forecast(self, usage_forecast: FRBCUsageForecast): await self._fm_client.post_sensor_data( sensor_id=self._usage_forecast_sensor_id, start=start_time, + prior=self.now(), values=scaled_usage_forecast.tolist(), unit=self.power_unit, # e.g. [0, 100] MW/(15 min) duration=str(pd.Timedelta(self._resolution) * len(usage_forecast)), @@ -273,6 +278,7 @@ async def send_leakage_behaviour(self, leakage: FRBCLeakageBehaviour): await self._fm_client.post_sensor_data( self._leakage_behaviour_sensor_id, start=start, + prior=self.now(), values=[storage_efficiency], unit="%", duration=timedelta(hours=48), From 14bf5b8802eaf412329bfee73bfba2e799950724 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Mon, 2 Mar 2026 20:02:45 +0100 Subject: [PATCH 058/181] style: silence mypy on overwriting a method with a lambda function Signed-off-by: F.N. Claessen --- src/flexmeasures_client/s2/cem.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/flexmeasures_client/s2/cem.py b/src/flexmeasures_client/s2/cem.py index e94394ff..d0594783 100644 --- a/src/flexmeasures_client/s2/cem.py +++ b/src/flexmeasures_client/s2/cem.py @@ -194,8 +194,8 @@ async def handle_message(self, message: Dict | pydantic.BaseModel | str): self._logger.debug(f"Received metadata: {metadata}") if "dt" in metadata: for control_type in self._control_types_handlers.values(): - control_type.now = lambda: metadata["dt"] - self.now = lambda: metadata["dt"] + control_type.now = lambda: metadata["dt"] # type: ignore + self.now = lambda: metadata["dt"] # type: ignore else: self._logger.debug(f"Received: {message}") From 7e6b29ae50bb6d110c3b8c1308397b1b8c026f27 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Mon, 9 Mar 2026 18:06:18 +0100 Subject: [PATCH 059/181] feat: force new scheduling job creation when using a prior Signed-off-by: F.N. Claessen --- src/flexmeasures_client/client.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/flexmeasures_client/client.py b/src/flexmeasures_client/client.py index 395fa109..9a25b85e 100644 --- a/src/flexmeasures_client/client.py +++ b/src/flexmeasures_client/client.py @@ -1228,6 +1228,7 @@ async def trigger_schedule( if prior is not None: message["prior"] = pd.Timestamp(prior).isoformat() + message["force_new_job_creation"] = True if scheduler is not None: if asset_id is None: raise ValueError( From a89a41e95784652773ccefe7a92822be241b0f96 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Mon, 9 Mar 2026 18:07:23 +0100 Subject: [PATCH 060/181] fix: upgrade timely-beliefs to fix scheduler bug with resampling from non-instantaneous to instantaneous Signed-off-by: F.N. Claessen --- docker-compose.override.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docker-compose.override.yml b/docker-compose.override.yml index e562a60c..ad076c39 100644 --- a/docker-compose.override.yml +++ b/docker-compose.override.yml @@ -13,9 +13,16 @@ services: command: - | pip install --break-system-packages -r /usr/var/flexmeasures-instance/requirements.txt + pip install timely-beliefs -U --break-system-packages flexmeasures db upgrade # toy account step removed gunicorn --bind 0.0.0.0:5000 --worker-tmp-dir /dev/shm --workers 2 --threads 4 wsgi:application + worker: + command: + - | + pip install --break-system-packages -r /usr/var/flexmeasures-instance/requirements.txt + pip install timely-beliefs -U --break-system-packages + flexmeasures jobs run-worker --name flexmeasures-worker --queue forecasting\|scheduling cem: build: context: . From 31bddecbe3801b4bc2f844b6c79ca292ba0b9695 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Tue, 10 Mar 2026 10:01:15 +0100 Subject: [PATCH 061/181] fix: pass prior to trigger_and_get_schedule Signed-off-by: F.N. Claessen --- src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py index 309fd441..27a45347 100644 --- a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py +++ b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py @@ -149,9 +149,9 @@ async def trigger_schedule(self, system_description_id: str | None = None): # call schedule start = system_description.valid_from # TODO: localize datetime - start = start.replace(minute=(start.minute // 15) * 15, second=0, microsecond=0) schedule = await self._fm_client.trigger_and_get_schedule( - start=start, + start=start.replace(minute=(start.minute // 15) * 15, second=0, microsecond=0), + prior=start, sensor_id=self._power_sensor_id, flex_context={ "production-price": {"sensor": self._price_sensor_id}, From 906a7f57df68558140b586ac96ff1083d262e7a9 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Tue, 10 Mar 2026 10:04:40 +0100 Subject: [PATCH 062/181] fix: start schedule from the time of the most recent storage status Signed-off-by: F.N. Claessen --- .../s2/control_types/FRBC/frbc_simple.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py index 27a45347..f15d390c 100644 --- a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py +++ b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py @@ -83,15 +83,16 @@ def now(self): return self._timezone.localize(datetime.now()) async def send_storage_status(self, status: FRBCStorageStatus): + now = self.now() await self._fm_client.post_sensor_data( self._soc_sensor_id, - start=self.now(), - prior=self.now(), + start=now, + prior=now, values=[status.present_fill_level], unit=self.energy_unit, duration=timedelta(minutes=1), ) - await self.trigger_schedule() + await self.trigger_schedule(now) async def send_actuator_status(self, status: FRBCActuatorStatus): factor = status.operation_mode_factor @@ -114,7 +115,7 @@ async def send_actuator_status(self, status: FRBCActuatorStatus): duration=timedelta(minutes=15), ) - async def trigger_schedule(self, system_description_id: str | None = None): + async def trigger_schedule(self, start: datetime, system_description_id: str | None = None): """Translates S2 System Description into FM API calls""" if system_description_id: @@ -148,7 +149,8 @@ async def trigger_schedule(self, system_description_id: str | None = None): soc_min, soc_max = get_soc_min_max(system_description) # call schedule - start = system_description.valid_from # TODO: localize datetime + if isinstance(start, str): + start = pd.Timestamp(start) schedule = await self._fm_client.trigger_and_get_schedule( start=start.replace(minute=(start.minute // 15) * 15, second=0, microsecond=0), prior=start, From f61fd60783bd8f60d7763be5070daadeb5150dc0 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Tue, 10 Mar 2026 10:17:27 +0100 Subject: [PATCH 063/181] style: black Signed-off-by: F.N. Claessen --- .../s2/control_types/FRBC/frbc_simple.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py index f15d390c..3907d587 100644 --- a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py +++ b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py @@ -115,7 +115,9 @@ async def send_actuator_status(self, status: FRBCActuatorStatus): duration=timedelta(minutes=15), ) - async def trigger_schedule(self, start: datetime, system_description_id: str | None = None): + async def trigger_schedule( + self, start: datetime, system_description_id: str | None = None + ): """Translates S2 System Description into FM API calls""" if system_description_id: @@ -152,7 +154,9 @@ async def trigger_schedule(self, start: datetime, system_description_id: str | N if isinstance(start, str): start = pd.Timestamp(start) schedule = await self._fm_client.trigger_and_get_schedule( - start=start.replace(minute=(start.minute // 15) * 15, second=0, microsecond=0), + start=start.replace( + minute=(start.minute // 15) * 15, second=0, microsecond=0 + ), prior=start, sensor_id=self._power_sensor_id, flex_context={ From 2d5b0fe6af316845ed01fb9185a5234b97aa36aa Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Tue, 10 Mar 2026 10:59:25 +0100 Subject: [PATCH 064/181] fix: still make sure to run `flexmeasures add toy-account` once Signed-off-by: F.N. Claessen --- docker-compose.override.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docker-compose.override.yml b/docker-compose.override.yml index ad076c39..93b0e749 100644 --- a/docker-compose.override.yml +++ b/docker-compose.override.yml @@ -15,7 +15,9 @@ services: pip install --break-system-packages -r /usr/var/flexmeasures-instance/requirements.txt pip install timely-beliefs -U --break-system-packages flexmeasures db upgrade - # toy account step removed + if ! flexmeasures show accounts | grep -q "Docker Toy Account"; then + flexmeasures add toy-account --name 'Docker Toy Account' + fi gunicorn --bind 0.0.0.0:5000 --worker-tmp-dir /dev/shm --workers 2 --threads 4 wsgi:application worker: command: From eda6b2854fc8fde751b0139b1daf44ea2667bbe5 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Tue, 10 Mar 2026 14:39:27 +0100 Subject: [PATCH 065/181] feat: add ability to customize fill_level_scale Signed-off-by: F.N. Claessen --- .../s2/control_types/FRBC/frbc_simple.py | 21 ++++++++++++------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py index 3907d587..cd6f0fec 100644 --- a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py +++ b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py @@ -46,7 +46,7 @@ class FRBCSimple(FRBC): _usage_forecast_sensor_id: int _leakage_behaviour_sensor_id: int _schedule_duration: timedelta - _fill_level_scale: int = 1 + _fill_level_scale: float _resolution = "15min" def __init__( @@ -62,6 +62,7 @@ def __init__( timezone: str = "UTC", schedule_duration: timedelta = timedelta(hours=12), max_size: int = 100, + fill_level_scale: float = 1, power_unit: str = "kW", energy_unit: str = "kWh", ) -> None: @@ -76,6 +77,7 @@ def __init__( self._usage_forecast_sensor_id = usage_forecast_sensor_id self._leakage_behaviour_sensor_id = leakage_behaviour_sensor_id self._timezone = pytz.timezone(timezone) + self._fill_level_scale = fill_level_scale self.power_unit = power_unit self.energy_unit = energy_unit @@ -88,7 +90,7 @@ async def send_storage_status(self, status: FRBCStorageStatus): self._soc_sensor_id, start=now, prior=now, - values=[status.present_fill_level], + values=[status.present_fill_level * self._fill_level_scale], unit=self.energy_unit, duration=timedelta(minutes=1), ) @@ -102,7 +104,7 @@ async def send_actuator_status(self, status: FRBCActuatorStatus): power = ( fill_rate.start_of_range + (fill_rate.end_of_range - fill_rate.start_of_range) * factor - ) + ) * self._fill_level_scale start = status.transition_timestamp or self.now() @@ -141,14 +143,15 @@ async def trigger_schedule( self._logger.debug(f"Using system description: {system_description}") if len(self._storage_status_history) > 0: - soc_at_start = list(self._storage_status_history.values())[ - -1 - ].present_fill_level + soc_at_start = ( + list(self._storage_status_history.values())[-1].present_fill_level + * self._fill_level_scale + ) else: print("Can't trigger schedule without knowing the status of the storage...") return - soc_min, soc_max = get_soc_min_max(system_description) + soc_min, soc_max = get_soc_min_max(system_description, self._fill_level_scale) # call schedule if isinstance(start, str): @@ -183,7 +186,9 @@ async def trigger_schedule( # translate FlexMeasures schedule into instructions. SOC -> Power -> PowerFactor instructions = fm_schedule_to_instructions( - schedule, system_description, initial_fill_level=soc_at_start + schedule, + system_description, + initial_fill_level=soc_at_start / self._fill_level_scale, ) # put instructions to sending queue From 1abdd718f2b0f848f679d068e9261f6d53393c14 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Tue, 10 Mar 2026 14:45:18 +0100 Subject: [PATCH 066/181] dev: remove todo (soc-at-start is now actually coming from the latest storage status) Signed-off-by: F.N. Claessen --- src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py index cd6f0fec..2259d7b0 100644 --- a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py +++ b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py @@ -170,7 +170,7 @@ async def trigger_schedule( }, flex_model={ "soc-unit": self.energy_unit, - "soc-at-start": soc_at_start, # TODO: use forecast of the SOC instead + "soc-at-start": soc_at_start, "soc-min": soc_min, "soc-max": soc_max, "soc-minima": {"sensor": self._soc_minima_sensor_id}, From 9f20ab4379d0c06bdafaee70f3067d67752a6657 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Tue, 10 Mar 2026 15:16:26 +0100 Subject: [PATCH 067/181] feat: derive consumption-capacity and production-capacity from operation mode elements Signed-off-by: F.N. Claessen --- .../s2/control_types/FRBC/frbc_simple.py | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py index 2259d7b0..47576f6d 100644 --- a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py +++ b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py @@ -151,6 +151,27 @@ async def trigger_schedule( print("Can't trigger schedule without knowing the status of the storage...") return + # Assume a single actuator + actuator = system_description.actuators[0] + + # Derive the overall power range + charging_capacity = None + discharging_capacity = None + for operation_mode in actuator.operation_modes: + for element in operation_mode.elements: + for power_range in element.power_ranges: + # todo: distinguish power range per commodity + p_min = power_range.end_of_range + if discharging_capacity is None: + discharging_capacity = p_min + else: + discharging_capacity = min(discharging_capacity, p_min) + p_max = power_range.start_of_range + if charging_capacity is None: + charging_capacity = p_max + else: + charging_capacity = max(charging_capacity, p_max) + soc_min, soc_max = get_soc_min_max(system_description, self._fill_level_scale) # call schedule @@ -178,6 +199,8 @@ async def trigger_schedule( "state-of-charge": {"sensor": self._soc_sensor_id}, "soc-usage": [{"sensor": self._usage_forecast_sensor_id}], "storage-efficiency": {"sensor": self._leakage_behaviour_sensor_id}, + "consumption-capacity": f"{charging_capacity} {self.power_unit}", + "production-capacity": f"{discharging_capacity} {self.power_unit}", }, duration=self._schedule_duration, # next 12 hours # TODO: add SOC MAX AND SOC MIN FROM fill_level_range, From ea68aa0360f00e4779e67fa6d4b060683a8c68ba Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Tue, 10 Mar 2026 15:19:42 +0100 Subject: [PATCH 068/181] docs: update docstring Signed-off-by: F.N. Claessen --- src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py index 47576f6d..9429f947 100644 --- a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py +++ b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py @@ -120,7 +120,9 @@ async def send_actuator_status(self, status: FRBCActuatorStatus): async def trigger_schedule( self, start: datetime, system_description_id: str | None = None ): - """Translates S2 System Description into FM API calls""" + """ + Ask FlexMeasures for a new schedule and create FRBC.Instructions to send back to the ResourceManager + """ if system_description_id: system_description: FRBCSystemDescription = ( From 605980ac730a5b194e8840b87f661ac80e7096a6 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Tue, 10 Mar 2026 15:36:40 +0100 Subject: [PATCH 069/181] feat: move to J and W as default energy unit and power unit, respectively Signed-off-by: F.N. Claessen --- .../s2/control_types/FRBC/frbc_simple.py | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py index 9429f947..98b98f57 100644 --- a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py +++ b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py @@ -63,8 +63,8 @@ def __init__( schedule_duration: timedelta = timedelta(hours=12), max_size: int = 100, fill_level_scale: float = 1, - power_unit: str = "kW", - energy_unit: str = "kWh", + power_unit: str = "W", + energy_unit: str = "J", ) -> None: super().__init__(max_size) self._power_sensor_id = power_sensor_id @@ -176,6 +176,16 @@ async def trigger_schedule( soc_min, soc_max = get_soc_min_max(system_description, self._fill_level_scale) + # Support for J energy unit (FM server only accepts kWh and MWh) + if self.energy_unit == "J": + f = 3.6 * 10 ** 6 + energy_unit = "kWh" + soc_at_start *= f + soc_min *= f + soc_max *= f + else: + energy_unit = self.energy_unit + # call schedule if isinstance(start, str): start = pd.Timestamp(start) @@ -192,7 +202,7 @@ async def trigger_schedule( "relax-constraints": True, }, flex_model={ - "soc-unit": self.energy_unit, + "soc-unit": energy_unit, "soc-at-start": soc_at_start, "soc-min": soc_min, "soc-max": soc_max, From c2bab5d1a18b320ac0c51da5926bfcf4d413f646 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Tue, 10 Mar 2026 16:22:54 +0100 Subject: [PATCH 070/181] fix: wrong conversion Signed-off-by: F.N. Claessen --- .../s2/control_types/FRBC/frbc_simple.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py index 98b98f57..99daa69b 100644 --- a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py +++ b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py @@ -180,9 +180,9 @@ async def trigger_schedule( if self.energy_unit == "J": f = 3.6 * 10 ** 6 energy_unit = "kWh" - soc_at_start *= f - soc_min *= f - soc_max *= f + soc_at_start /= f + soc_min /= f + soc_max /= f else: energy_unit = self.energy_unit From 00f363ab24551631a2080de6998207ba630ba00b Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Tue, 10 Mar 2026 16:30:19 +0100 Subject: [PATCH 071/181] docs: clarify inline note Signed-off-by: F.N. Claessen --- src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py index 99daa69b..5e3d6b2c 100644 --- a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py +++ b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py @@ -176,7 +176,7 @@ async def trigger_schedule( soc_min, soc_max = get_soc_min_max(system_description, self._fill_level_scale) - # Support for J energy unit (FM server only accepts kWh and MWh) + # Support for J energy unit (FM server scheduling trigger endpoint only accepts kWh and MWh) if self.energy_unit == "J": f = 3.6 * 10 ** 6 energy_unit = "kWh" From 80b2f6154ab128a7358877e3d44d1664d09e3ee5 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Tue, 10 Mar 2026 20:01:51 +0100 Subject: [PATCH 072/181] refactor: prepare for more params Signed-off-by: F.N. Claessen --- src/flexmeasures_client/client.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/flexmeasures_client/client.py b/src/flexmeasures_client/client.py index 9a25b85e..26a579d1 100644 --- a/src/flexmeasures_client/client.py +++ b/src/flexmeasures_client/client.py @@ -607,12 +607,9 @@ async def get_schedule( 'unit': 'MW' } """ + params = {} if duration is not None: - params = { - "duration": pd.Timedelta(duration).isoformat(), # for example: PT1H - } - else: - params = {} + params["duration"] = pd.Timedelta(duration).isoformat(), # for example: PT1H schedule, status = await self.request( uri=f"sensors/{sensor_id}/schedules/{schedule_id}", method="GET", From 2cc3b750a73fe11dca24e4c1a188d1c9afc22251 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Tue, 10 Mar 2026 20:04:39 +0100 Subject: [PATCH 073/181] feat: support getting a schedule in a given unit Signed-off-by: F.N. Claessen --- src/flexmeasures_client/client.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/flexmeasures_client/client.py b/src/flexmeasures_client/client.py index 26a579d1..58260ca3 100644 --- a/src/flexmeasures_client/client.py +++ b/src/flexmeasures_client/client.py @@ -596,6 +596,7 @@ async def get_schedule( sensor_id: int, schedule_id: str, duration: str | timedelta | None = None, + unit: str | None = None, ) -> dict: """Get schedule with given ID. @@ -609,7 +610,11 @@ async def get_schedule( """ params = {} if duration is not None: - params["duration"] = pd.Timedelta(duration).isoformat(), # for example: PT1H + params["duration"] = ( + pd.Timedelta(duration).isoformat(), + ) # for example: PT1H + if unit is not None: + params["unit"] = unit schedule, status = await self.request( uri=f"sensors/{sensor_id}/schedules/{schedule_id}", method="GET", @@ -828,6 +833,7 @@ async def trigger_and_get_schedule( asset_id: int | None = None, prior: datetime | None = None, scheduler: str | None = None, + unit: str | None = None, ) -> dict | list[dict]: """Trigger a schedule and then fetch it. @@ -863,7 +869,10 @@ async def trigger_and_get_schedule( if sensor_id is not None: # Get the schedule for a single device return await self.get_schedule( - sensor_id=sensor_id, schedule_id=schedule_id, duration=duration + sensor_id=sensor_id, + schedule_id=schedule_id, + duration=duration, + unit=unit, ) elif flex_model is None: # If there is no flex-model referencing power sensors, no power schedules are retrieved From 3a547b1c5046aa60f5cc5397221dd224cd57b1bb Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Tue, 10 Mar 2026 20:05:28 +0100 Subject: [PATCH 074/181] feat: require minimum version for getting a schedule in a given unit Signed-off-by: F.N. Claessen --- src/flexmeasures_client/client.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/flexmeasures_client/client.py b/src/flexmeasures_client/client.py index 58260ca3..f2d99bcb 100644 --- a/src/flexmeasures_client/client.py +++ b/src/flexmeasures_client/client.py @@ -614,6 +614,12 @@ async def get_schedule( pd.Timedelta(duration).isoformat(), ) # for example: PT1H if unit is not None: + await self.ensure_server_version() + if Version(self.server_version) < Version("0.31.0"): + self.logger.warning( + "get_schedule(): The 'unit' parameter requires FlexMeasures server version 0.31.0 or above. " + "This parameter will be ignored." + ) params["unit"] = unit schedule, status = await self.request( uri=f"sensors/{sensor_id}/schedules/{schedule_id}", From d29e64c9d1291d72630cf85e1aa19ad69b60a7da Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Tue, 10 Mar 2026 20:06:00 +0100 Subject: [PATCH 075/181] fix: get schedule in the assumed power unit Signed-off-by: F.N. Claessen --- src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py index 5e3d6b2c..57c46595 100644 --- a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py +++ b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py @@ -217,6 +217,7 @@ async def trigger_schedule( duration=self._schedule_duration, # next 12 hours # TODO: add SOC MAX AND SOC MIN FROM fill_level_range, # this needs changes on the client + unit=self.power_unit, ) # translate FlexMeasures schedule into instructions. SOC -> Power -> PowerFactor From fca2c5ba575fb9cc69803986a5efe561213255ce Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Tue, 10 Mar 2026 20:13:27 +0100 Subject: [PATCH 076/181] feat: note the current FM server version Signed-off-by: F.N. Claessen --- src/flexmeasures_client/client.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/flexmeasures_client/client.py b/src/flexmeasures_client/client.py index f2d99bcb..77604dc2 100644 --- a/src/flexmeasures_client/client.py +++ b/src/flexmeasures_client/client.py @@ -618,7 +618,7 @@ async def get_schedule( if Version(self.server_version) < Version("0.31.0"): self.logger.warning( "get_schedule(): The 'unit' parameter requires FlexMeasures server version 0.31.0 or above. " - "This parameter will be ignored." + f"This parameter will be ignored for server version {self.server_version}." ) params["unit"] = unit schedule, status = await self.request( @@ -754,7 +754,7 @@ async def get_assets( if Version(self.server_version) < Version("0.31.0"): self.logger.warning( "get_assets(): The 'root', 'depth' and 'fields' parameters require FlexMeasures server version 0.31.0 or above. " - "These parameters will be ignored." + f"These parameters will be ignored for server version {self.server_version}." ) if root and isinstance(root, int): uri += f"&root={root}" @@ -1127,7 +1127,7 @@ async def update_asset(self, asset_id: int, updates: dict) -> dict: if Version(self.server_version) < Version("0.31.0"): self.logger.warning( "update_asset(): The 'aggregate-power' flex-context field requires FlexMeasures server version 0.31.0 or above. " - "The 'aggregate-power' field will be ignored by the server." + f"The 'aggregate-power' field will be ignored by the server, which is at version {self.server_version}." ) updates["flex_context"] = json.dumps(updates["flex_context"]) if "flex_model" in updates: From fb6d9478514d1077196ca5948e7c5f9cbee863f2 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Tue, 10 Mar 2026 20:17:20 +0100 Subject: [PATCH 077/181] fix: actually requires v0.32.0 Signed-off-by: F.N. Claessen --- src/flexmeasures_client/client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/flexmeasures_client/client.py b/src/flexmeasures_client/client.py index 77604dc2..0ee72861 100644 --- a/src/flexmeasures_client/client.py +++ b/src/flexmeasures_client/client.py @@ -615,7 +615,7 @@ async def get_schedule( ) # for example: PT1H if unit is not None: await self.ensure_server_version() - if Version(self.server_version) < Version("0.31.0"): + if Version(self.server_version) < Version("0.32.0"): self.logger.warning( "get_schedule(): The 'unit' parameter requires FlexMeasures server version 0.31.0 or above. " f"This parameter will be ignored for server version {self.server_version}." From 7d4c0c0916d975e01d3b319c98f9f1f13194a7f0 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Tue, 10 Mar 2026 20:18:43 +0100 Subject: [PATCH 078/181] docs: clarify that the server ignores the parameter, not the client Signed-off-by: F.N. Claessen --- src/flexmeasures_client/client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/flexmeasures_client/client.py b/src/flexmeasures_client/client.py index 0ee72861..eca21e8c 100644 --- a/src/flexmeasures_client/client.py +++ b/src/flexmeasures_client/client.py @@ -618,7 +618,7 @@ async def get_schedule( if Version(self.server_version) < Version("0.32.0"): self.logger.warning( "get_schedule(): The 'unit' parameter requires FlexMeasures server version 0.31.0 or above. " - f"This parameter will be ignored for server version {self.server_version}." + f"This parameter will be ignored by the server, which is at version {self.server_version}." ) params["unit"] = unit schedule, status = await self.request( From cc28d77e7556af1c79c28b8e837453960e527efe Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Tue, 10 Mar 2026 21:23:17 +0100 Subject: [PATCH 079/181] style: black Signed-off-by: F.N. Claessen --- src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py index 57c46595..65797a6e 100644 --- a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py +++ b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py @@ -178,7 +178,7 @@ async def trigger_schedule( # Support for J energy unit (FM server scheduling trigger endpoint only accepts kWh and MWh) if self.energy_unit == "J": - f = 3.6 * 10 ** 6 + f = 3.6 * 10**6 energy_unit = "kWh" soc_at_start /= f soc_min /= f From 07608256240c3e024cfeafecf6d69f6c044d38e9 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Tue, 10 Mar 2026 21:31:05 +0100 Subject: [PATCH 080/181] fix: mistake while refactoring (or from running black?) Signed-off-by: F.N. Claessen --- src/flexmeasures_client/client.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/flexmeasures_client/client.py b/src/flexmeasures_client/client.py index eca21e8c..7b3cc149 100644 --- a/src/flexmeasures_client/client.py +++ b/src/flexmeasures_client/client.py @@ -610,9 +610,7 @@ async def get_schedule( """ params = {} if duration is not None: - params["duration"] = ( - pd.Timedelta(duration).isoformat(), - ) # for example: PT1H + params["duration"] = pd.Timedelta(duration).isoformat() # for example: PT1H if unit is not None: await self.ensure_server_version() if Version(self.server_version) < Version("0.32.0"): From 67cbd0b459fb32a9bec5dc798c2beb579fc57d91 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Wed, 11 Mar 2026 15:15:49 +0100 Subject: [PATCH 081/181] fix: misinterpreted the usage forecast scale Signed-off-by: F.N. Claessen --- .../s2/control_types/FRBC/frbc_simple.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py index 65797a6e..8f9a1780 100644 --- a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py +++ b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py @@ -295,16 +295,12 @@ async def send_usage_forecast(self, usage_forecast: FRBCUsageForecast): fill_level_scale=self._fill_level_scale, ) - # Scale usage forecast e.g. [0, 100] %/s -> [0, 100] %/(15 min) - scale = timedelta(minutes=15) / timedelta(seconds=1) - scaled_usage_forecast = usage_forecast * scale - await self._fm_client.post_sensor_data( sensor_id=self._usage_forecast_sensor_id, start=start_time, prior=self.now(), - values=scaled_usage_forecast.tolist(), - unit=self.power_unit, # e.g. [0, 100] MW/(15 min) + values=usage_forecast.tolist(), + unit=self.power_unit, # e.g. [0, 100] MW/(15 min) # todo: or: f"{self.energy_unit}/s" to scale usage forecast e.g. [0, 100] %/s -> [0, 100] %/(15 min) duration=str(pd.Timedelta(self._resolution) * len(usage_forecast)), ) From 6ba2337a00545bd8e7d34bab2dda116f06ee1c5a Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Wed, 11 Mar 2026 15:42:07 +0100 Subject: [PATCH 082/181] fix: CEM should only relax soc-constraints (not capacity-constraints and not site-capacity-constraints) Signed-off-by: F.N. Claessen --- src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py index 8f9a1780..d9a10cb9 100644 --- a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py +++ b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py @@ -199,7 +199,7 @@ async def trigger_schedule( "production-price": {"sensor": self._price_sensor_id}, "consumption-price": {"sensor": self._price_sensor_id}, "site-power-capacity": f"{3 * 25 * 230} VA", - "relax-constraints": True, + "relax-soc-constraints": True, }, flex_model={ "soc-unit": energy_unit, From 2711aff35ff647e1accfcf31519a8cc74488b22b Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Wed, 11 Mar 2026 15:44:11 +0100 Subject: [PATCH 083/181] fix: stop flipping the values from the actuator status (now that we use consumption_is_positive on the power sensor Signed-off-by: F.N. Claessen --- src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py index d9a10cb9..3a3c98d7 100644 --- a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py +++ b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py @@ -112,7 +112,7 @@ async def send_actuator_status(self, status: FRBCActuatorStatus): self._power_sensor_id, start=start, prior=self.now(), - values=[-power], + values=[power], unit=self.power_unit, duration=timedelta(minutes=15), ) From fbe2461df525673a8fdb26c7145c02d40055ba56 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Thu, 12 Mar 2026 10:43:22 +0100 Subject: [PATCH 084/181] feat: use local flexmeasures repo in server and worker Signed-off-by: F.N. Claessen --- docker-compose.override.yml | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/docker-compose.override.yml b/docker-compose.override.yml index 93b0e749..0b7db4a0 100644 --- a/docker-compose.override.yml +++ b/docker-compose.override.yml @@ -10,8 +10,16 @@ services: server: + volumes: + # A place for config and plugin code, and custom requirements.txt + # The 1st mount point is for running the FlexMeasures CLI, the 2nd for gunicorn + # We use :rw so flexmeasures CLI commands can write log files + - ./flexmeasures-instance/:/usr/var/flexmeasures-instance/:rw + - ./flexmeasures-instance/:/app/instance/:rw + - ../flexmeasures/flexmeasures:/app/flexmeasures:rw command: - | + pip install --break-system-packages -e /app pip install --break-system-packages -r /usr/var/flexmeasures-instance/requirements.txt pip install timely-beliefs -U --break-system-packages flexmeasures db upgrade @@ -20,8 +28,14 @@ services: fi gunicorn --bind 0.0.0.0:5000 --worker-tmp-dir /dev/shm --workers 2 --threads 4 wsgi:application worker: + volumes: + # a place for config and plugin code, and custom requirements.txt + - ./flexmeasures-instance/:/usr/var/flexmeasures-instance/:rw + - ../flexmeasures/flexmeasures:/app/flexmeasures:rw + - ./flexmeasures-instance/:/app/instance/:rw command: - | + pip install --break-system-packages -e /app pip install --break-system-packages -r /usr/var/flexmeasures-instance/requirements.txt pip install timely-beliefs -U --break-system-packages flexmeasures jobs run-worker --name flexmeasures-worker --queue forecasting\|scheduling From a273788c9e3c8f9dff2f5f48821967870f51e1b1 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Fri, 13 Mar 2026 14:56:19 +0100 Subject: [PATCH 085/181] fix: release step was split off to separate release.yml Signed-off-by: F.N. Claessen --- .github/workflows/ci.yml | 30 ------------------------------ 1 file changed, 30 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 72182174..bee16827 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -111,33 +111,3 @@ jobs: parallel-finished: true github-token: ${{ secrets.GITHUB_TOKEN }} - run: echo "Finished checks" - - publish: - needs: finalize - if: ${{ github.event_name == 'push' && contains(github.ref, 'refs/tags/v') }} - runs-on: ubuntu-latest - environment: - name: release - url: https://pypi.org/project/flexmeasures-client/ - permissions: - id-token: write - steps: - - uses: actions/checkout@v3 - with: {fetch-depth: 0} # deep clone for setuptools-scm - - uses: actions/setup-python@v4 - with: { python-version: "3.11" } - - name: Retrieve pre-built distribution files - uses: actions/download-artifact@v4 - with: - name: python-distribution-files - path: dist/ - - name: Publish Package - uses: pypa/gh-action-pypi-publish@release/v1 - # run: pipx run tox -e publish - - name: Publish release on GitHub - uses: softprops/action-gh-release@v2 - with: - generate_release_notes: true - tag_name: ${{ inputs.custom_version || github.ref_name }} - env: - GITHUB_TOKEN: ${{ secrets.GH_RELEASE_PAT }} From d4a72fe82194a37fda55fa90bb691d1d2933b4e6 Mon Sep 17 00:00:00 2001 From: Stijn van Houwelingen Date: Mon, 16 Mar 2026 15:49:15 +0100 Subject: [PATCH 086/181] feat: update github workflows. Clean up .pre-commit-config.yaml. Signed-off-by: Stijn van Houwelingen --- .github/workflows/ci.yml | 8 +++----- .github/workflows/release.yml | 2 +- .pre-commit-config.yaml | 35 ++--------------------------------- 3 files changed, 6 insertions(+), 39 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bee16827..92c8f572 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,7 +36,7 @@ jobs: with: enable-cache: true cache-dependency-glob: '.pre-commit-config.yaml' - uv-version: "0.10" + version: "0.10.9" - name: Install dev dependencies run: uv sync --only-group dev --frozen - uses: tox-dev/action-pre-commit-uv@v1 @@ -58,12 +58,11 @@ jobs: name: "Test (${{ matrix.platform }} on Python ${{ matrix.python }})" steps: - uses: actions/checkout@v4 - with: - fetch-depth: 0 # needed for hatch-vcs versioning - name: Install uv uses: astral-sh/setup-uv@v7 with: enable-cache: true + version: "0.10.9" python-version: ${{ matrix.python }} - name: Install dependencies run: uv sync --frozen --group test @@ -76,12 +75,11 @@ jobs: name: "Test S2 (on Python 3.12)" steps: - uses: actions/checkout@v4 - with: - fetch-depth: 0 - name: Install uv uses: astral-sh/setup-uv@v7 with: enable-cache: true + version: "0.10.9" python-version-file: ".python-version" - name: Install dependencies run: uv sync --frozen --extra s2 --group test diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fe318b93..51049da5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -18,7 +18,7 @@ jobs: - name: Install uv uses: astral-sh/setup-uv@v7 with: - uv-version: "0.10" + version: "0.10.9" python-version-file: ".python-version" enable-cache: true diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 206147c0..8f85e08b 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -2,7 +2,7 @@ exclude: '^docs/conf.py' repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.4.0 + rev: v6.0.0 hooks: - id: trailing-whitespace - id: check-added-large-files @@ -15,25 +15,7 @@ repos: - id: end-of-file-fixer - id: requirements-txt-fixer - id: mixed-line-ending - args: ['--fix=auto'] # replace 'auto' with 'lf' to enforce Linux/Mac line endings or 'crlf' for Windows - -## If you want to automatically "modernize" your Python code: -# - repo: https://github.com/asottile/pyupgrade -# rev: v3.3.1 -# hooks: -# - id: pyupgrade -# args: ['--py37-plus'] - -## If you want to avoid flake8 errors due to unused vars or imports: -# - repo: https://github.com/PyCQA/autoflake -# rev: v2.0.0 -# hooks: -# - id: autoflake -# args: [ -# --in-place, -# --remove-all-unused-imports, -# --remove-unused-variables, -# ] + args: ['--fix=auto'] - repo: local hooks: @@ -52,13 +34,6 @@ repos: entry: uv run black types: [python] -## If like to embrace black styles even in the docs: -# - repo: https://github.com/asottile/blacken-docs -# rev: v1.13.0 -# hooks: -# - id: blacken-docs -# additional_dependencies: [black] - - repo: local hooks: - id: flake8 @@ -67,12 +42,6 @@ repos: entry: uv run flake8 types: [python] -## Check for misspells in documentation files: -# - repo: https://github.com/codespell-project/codespell -# rev: v2.2.2 -# hooks: -# - id: codespell - - repo: local hooks: - id: mypy From 06406c98cf8433db41cb46ef923e407fdb3142e9 Mon Sep 17 00:00:00 2001 From: Stijn van Houwelingen Date: Mon, 16 Mar 2026 16:03:00 +0100 Subject: [PATCH 087/181] fix: fix s2 tests in CI. Use uv for s2 tests. Signed-off-by: Stijn van Houwelingen --- .github/workflows/ci.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 92c8f572..188ca0cf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -84,11 +84,11 @@ jobs: - name: Install dependencies run: uv sync --frozen --extra s2 --group test - name: Run S2 tests - run: uv run pytest tests/s2 -rFEx --durations 10 --color yes + run: uv run pytest tests/s2 -rFEx --durations 10 --color yes --cov - name: Show coverage report - run: pipx run coverage report + run: uv run coverage report - name: Generate coverage report - run: pipx run coverage lcov -o coverage.lcov + run: uv run coverage lcov -o coverage.lcov - name: Upload partial coverage report uses: coverallsapp/github-action@v2 with: From 274bbc687c61aa436422d36871dc5604e001a307 Mon Sep 17 00:00:00 2001 From: Stijn van Houwelingen Date: Mon, 16 Mar 2026 16:04:09 +0100 Subject: [PATCH 088/181] feat: use uv for readthedocs Signed-off-by: Stijn van Houwelingen --- .readthedocs.yml | 28 ++++++++++++---------------- 1 file changed, 12 insertions(+), 16 deletions(-) diff --git a/.readthedocs.yml b/.readthedocs.yml index a2bcab30..15001b40 100644 --- a/.readthedocs.yml +++ b/.readthedocs.yml @@ -4,24 +4,20 @@ # Required version: 2 -# Build documentation in the docs/ directory with Sphinx -sphinx: - configuration: docs/conf.py - -# Build documentation with MkDocs -#mkdocs: -# configuration: mkdocs.yml - # Optionally build your docs in additional formats such as PDF formats: - pdf build: - os: ubuntu-22.04 - tools: - python: "3.11" - -python: - install: - - requirements: docs/requirements.txt - - {path: ., method: pip} + os: ubuntu-lts-latest + tools: + python: "3.12" + jobs: + pre_create_environment: + - asdf plugin add uv + - asdf install uv 0.10.9 + - asdf global uv 0.10.9 + create_environment: + - uv venv "${READTHEDOCS_VIRTUALENV_PATH}" + install: + - UV_PROJECT_ENVIRONMENT="${READTHEDOCS_VIRTUALENV_PATH}" uv sync --only-group docs --frozen From 0de29b8bf1c371220692bf4b268bbcaae99d181b Mon Sep 17 00:00:00 2001 From: Stijn van Houwelingen Date: Mon, 16 Mar 2026 16:43:22 +0100 Subject: [PATCH 089/181] refactor: remove coverage for s2. Add coverage to general test Signed-off-by: Stijn van Houwelingen --- .github/workflows/ci.yml | 29 ++++++++++++++--------------- 1 file changed, 14 insertions(+), 15 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 188ca0cf..b35f79ba 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -53,7 +53,6 @@ jobs: platform: - ubuntu-latest - macos-latest -# - windows-latest runs-on: ${{ matrix.platform }} name: "Test (${{ matrix.platform }} on Python ${{ matrix.python }})" steps: @@ -67,7 +66,19 @@ jobs: - name: Install dependencies run: uv sync --frozen --group test - name: Run tests - run: uv run pytest tests --ignore=tests/s2 -rFEx --durations 10 --color yes + run: uv run pytest tests --ignore=tests/s2 -rFEx --durations 10 --color yes --cov + - name: Show coverage report + run: uv run coverage report + - name: Generate coverage report + run: uv run coverage lcov -o coverage.lcov + - name: Upload partial coverage report + uses: coverallsapp/github-action@v2 + with: + fail-on-error: false + path-to-lcov: coverage.lcov + github-token: ${{ secrets.GITHUB_TOKEN }} + flag-name: ${{ matrix.platform }} - py${{ matrix.python }} + parallel: true test-s2: needs: check @@ -84,19 +95,7 @@ jobs: - name: Install dependencies run: uv sync --frozen --extra s2 --group test - name: Run S2 tests - run: uv run pytest tests/s2 -rFEx --durations 10 --color yes --cov - - name: Show coverage report - run: uv run coverage report - - name: Generate coverage report - run: uv run coverage lcov -o coverage.lcov - - name: Upload partial coverage report - uses: coverallsapp/github-action@v2 - with: - fail-on-error: false - path-to-lcov: coverage.lcov - github-token: ${{ secrets.GITHUB_TOKEN }} - flag-name: ${{ matrix.platform }} - py${{ matrix.python }} - parallel: true + run: uv run pytest tests/s2 -rFEx --durations 10 --color yes finalize: needs: [ test, test-s2] From cd7b73248ccd4b74b7bedb75d0f9d342aa2be6e4 Mon Sep 17 00:00:00 2001 From: Stijn van Houwelingen Date: Wed, 18 Mar 2026 15:36:54 +0100 Subject: [PATCH 090/181] fix: fix issues in S2 client and server examples. Use Pydantic V2 methods for JSON serialization. Use environment variables for server script. Use variable pricing in server script. Delete redundant demo_setup.py. Signed-off-by: Stijn van Houwelingen --- .../s2/script/demo_setup.py | 47 ----------------- .../s2/script/websockets_client.py | 26 +++++----- .../s2/script/websockets_server.py | 50 ++++++++++++++++--- 3 files changed, 54 insertions(+), 69 deletions(-) delete mode 100644 src/flexmeasures_client/s2/script/demo_setup.py diff --git a/src/flexmeasures_client/s2/script/demo_setup.py b/src/flexmeasures_client/s2/script/demo_setup.py deleted file mode 100644 index 3524c037..00000000 --- a/src/flexmeasures_client/s2/script/demo_setup.py +++ /dev/null @@ -1,47 +0,0 @@ -import asyncio - -from flexmeasures_client.client import FlexMeasuresClient - -client = FlexMeasuresClient( - email="admin@admin.com", - password="admin", - host="localhost:5000", -) - - -async def my_script(): - await client.post_measurements( - sensor_id=2, - start="2023-05-14T00:00:00+02:00", - duration="PT24H", - unit="EUR/MWh", - values=[ - 10, - 11, - 12, - 15, - 18, - 17, - 10.5, - 9, - 9.5, - 9, - 8.5, - 10, - 8, - 5, - 4, - 4, - 5.5, - 8, - 12, - 13, - 14, - 12.5, - 10, - 7, - ], - ) - - -asyncio.run(my_script()) diff --git a/src/flexmeasures_client/s2/script/websockets_client.py b/src/flexmeasures_client/s2/script/websockets_client.py index bf990c7b..9e84ac69 100644 --- a/src/flexmeasures_client/s2/script/websockets_client.py +++ b/src/flexmeasures_client/s2/script/websockets_client.py @@ -41,7 +41,7 @@ async def main_s2(): print("SENDING: HANDSHAKE") - await ws.send_json(message.json()) + await ws.send_str(message.model_dump_json()) response = await ws.receive() @@ -64,11 +64,15 @@ async def main_s2(): provides_power_measurement_types=[ CommodityQuantity.ELECTRIC_POWER_3_PHASE_SYMMETRIC ], - ).json() + ).model_dump_json() print("SENDING: ResourceManagerDetails") - await ws.send_json(resource_manager_details_message) + await ws.send_str(resource_manager_details_message) + + response = await ws.receive() + + print("RECEIVING: ", response.json()) response = await ws.receive() @@ -87,7 +91,7 @@ async def main_s2(): ) print("SENDING: ReceptionStatus") - await ws.send_json(message.json()) + await ws.send_str(message.model_dump_json()) electric_power = CommodityQuantity.ELECTRIC_POWER_3_PHASE_SYMMETRIC @@ -98,7 +102,7 @@ async def main_s2(): print("SENDING: FRBC.StorageStatus") - await ws.send_json(storage_status.json()) + await ws.send_str(storage_status.model_dump_json()) response = await ws.receive() @@ -148,21 +152,15 @@ async def main_s2(): valid_from=valid_from, actuators=[actuator], storage=storage, - ).json() + ).model_dump_json() print("SENDING: FRBC.SystemDescription") - await ws.send_json(system_description_message) - - msg = await ws.receive() - print("RECEIVED: ", msg.json()) + await ws.send_str(system_description_message) - for i in range(6): - msg = await ws.receive() + async for msg in ws: print("RECEIVED: ", msg.json()) - await ws.close() - async def main_websocket(): async with aiohttp.ClientSession() as session: diff --git a/src/flexmeasures_client/s2/script/websockets_server.py b/src/flexmeasures_client/s2/script/websockets_server.py index afceeb56..6a11c948 100644 --- a/src/flexmeasures_client/s2/script/websockets_server.py +++ b/src/flexmeasures_client/s2/script/websockets_server.py @@ -2,6 +2,7 @@ import json import logging import os +from urllib.parse import urlparse import aiohttp from aiohttp import web @@ -49,7 +50,10 @@ async def websocket_producer(ws, cem: CEM): while not cem.is_closed(): message = await cem.get_message() cem._logger.debug("sending message") - await ws.send_json(message) + try: + await ws.send_json(message) + except aiohttp.ClientConnectionResetError: + break cem._logger.debug("cem closed") @@ -60,17 +64,17 @@ async def websocket_consumer(ws, cem: CEM): if msg.data == "close": # TODO: save cem state? cem._logger.debug("close...") - cem.close() + await cem.close() await ws.close() else: try: - await cem.handle_message(json.loads(msg.json())) - except TypeError: - await cem.handle_message(msg.json()) + await cem.handle_message(json.loads(msg.data)) + except (json.JSONDecodeError, KeyError): + cem._logger.exception(f"handle_message failed for {msg.data!r}") elif msg.type == aiohttp.WSMsgType.ERROR: cem._logger.debug("close...") - cem.close() + await cem.close() cem._logger.error(f"ws connection closed with exception {ws.exception()}") # TODO: save cem state? @@ -82,8 +86,13 @@ async def websocket_handler(request): await ws.prepare(request) site_name = "My CEM" + base_url = os.getenv("FLEXMEASURES_BASE_URL", "http://localhost:5000") + parsed = urlparse(base_url) fm_client = FlexMeasuresClient( - "toy-password", "toy-user@flexmeasures.io", host="server:5000" + password=os.getenv("FLEXMEASURES_PASSWORD", "toy-password"), + email=os.getenv("FLEXMEASURES_USER", "toy-user@flexmeasures.io"), + host=parsed.netloc, + ssl=parsed.scheme == "https", ) price_sensor, power_sensor, soc_sensor, rm_discharge_sensor = await configure_site( @@ -168,7 +177,32 @@ async def configure_site( sensor_id=price_sensor["id"], start="2026-01-15T00:00+01", # 2026-01-01T00:00+01 duration="P3D", # P1M - values=[0.3], + values=[ + 0.10, + 0.11, + 0.12, + 0.15, + 0.18, + 0.17, + 0.11, + 0.09, + 0.10, + 0.09, + 0.09, + 0.10, + 0.08, + 0.05, + 0.04, + 0.04, + 0.06, + 0.08, + 0.12, + 0.13, + 0.14, + 0.13, + 0.10, + 0.07, + ], unit="EUR/kWh", ) if power_sensor is None: From 1ec5a87b9c987c43cd0cf0e8215553e795266c0e Mon Sep 17 00:00:00 2001 From: Stijn van Houwelingen Date: Wed, 18 Mar 2026 15:39:58 +0100 Subject: [PATCH 091/181] feat: update docs to use new UV setup Signed-off-by: Stijn van Houwelingen --- .github/agents/copilot.md | 23 +++++---- CONTRIBUTING.rst | 98 ++++++++++++++++----------------------- README.rst | 37 +++++++++------ docs/HEMS.rst | 18 +++---- 4 files changed, 87 insertions(+), 89 deletions(-) diff --git a/.github/agents/copilot.md b/.github/agents/copilot.md index 386c9675..054e1971 100644 --- a/.github/agents/copilot.md +++ b/.github/agents/copilot.md @@ -19,25 +19,30 @@ The main client class is `FlexMeasuresClient` in `src/flexmeasures_client/client ## Running Tests ```bash -pip install -e ".[testing]" -python3 -m pytest tests/test_client.py -q +uv sync --group test +uv run poe test ``` ## Linting -Always fix linting before pushing. Run individually if `pre-commit` is unavailable: +Always fix linting before pushing. Run via pre-commit (preferred): ```bash -pip install black isort flake8 -black src/ tests/ -isort src/ tests/ -flake8 src/ tests/ +uv run poe lint ``` -Or via pre-commit (preferred): +Or run individually using `uv run`: ```bash -pip install pre-commit && pre-commit run --all-files +uv run black src/ tests/ +uv run isort src/ tests/ +uv run flake8 src/ tests/ +``` + +To install pre-commit as a standalone tool: + +```bash +uv tool install pre-commit && pre-commit run --all-files ``` ## Coding Patterns diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index daa7e655..b1f702ec 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -93,9 +93,10 @@ that any documentation update is done in the same way was a code contribution. submit your proposal. When working on documentation changes in your local machine, you can -compile them using |tox|_:: +compile them using Sphinx:: - tox -e docs + uv sync --group docs + uv run sphinx-build docs docs/_build/html and use Python's built-in web server for a preview in your web browser (``http://localhost:8000``):: @@ -122,17 +123,20 @@ This often provides additional considerations and avoids unnecessary work. Create an environment --------------------- -Before you start coding, we recommend creating an isolated `virtual -environment`_ to avoid any problems with your installed Python packages. -This can easily be done via either |virtualenv|_:: +We use the excellent `uv `_ tool to manage our dependencies. +First, `install uv `_. - virtualenv - source /bin/activate +Before you start coding, install all dependencies including the ones needed for development: -or Miniconda_:: +.. code-block:: bash - conda create -n flexmeasures-client python=3 six virtualenv pytest pytest-cov - conda activate flexmeasures-client + uv sync --group dev --group test + +This will also create an isolated virtual environment automatically. + +.. note:: + + If you prefer shorter commands during interactive development, you can either activate the virtual environment (``source .venv/bin/activate``, or ``.venv\\Scripts\\activate`` on Windows) or prefix commands with ``uv run``. Clone the repository -------------------- @@ -147,7 +151,7 @@ Clone the repository #. You should run:: - pip install -U pip setuptools -e . + uv sync --group dev --group test to be able to import the package under development in the Python REPL. @@ -155,7 +159,7 @@ Clone the repository #. Install |pre-commit|_:: - pip install pre-commit + uv tool install pre-commit pre-commit install ``flexmeasures-client`` comes with a lot of hooks configured to automatically help the @@ -201,9 +205,7 @@ Implement your changes #. Please check that your changes don't break any unit tests with:: - tox - - (after having installed |tox|_ with ``pip install tox`` or ``pipx``). + uv run poe test You can also use |tox|_ to run several other pre-configured tasks in the repository. Try ``tox -av`` to see a list of the available checks. @@ -239,32 +241,20 @@ package: ``.eggs``, as well as the ``*.egg-info`` folders in the ``src`` folder or potentially in the root of your project. -#. Sometimes |tox|_ misses out when new dependencies are added, especially to - ``setup.cfg`` and ``docs/requirements.txt``. If you find any problems with - missing dependencies when running a command with |tox|_, try to recreate the - ``tox`` environment using the ``-r`` flag. For example, instead of:: - - tox -e docs - - Try running:: +#. If you find any problems with missing dependencies, try recreating the + virtual environment by removing it and re-running ``uv sync``:: - tox -r -e docs + rm -rf .venv + uv sync --group dev --group test -#. Make sure to have a reliable |tox|_ installation that uses the correct - Python version (e.g., 3.7+). When in doubt you can run:: +#. Make sure ``uv`` is up to date:: - tox --version - # OR - which tox + uv self update - If you have trouble and are seeing weird errors upon running |tox|_, you can - also try to create a dedicated `virtual environment`_ with a |tox|_ binary - freshly installed. For example:: + If you have trouble with the virtual environment, you can verify which Python + is being used:: - virtualenv .venv - source .venv/bin/activate - .venv/bin/pip install tox - .venv/bin/tox -e all + uv run python --version #. `Pytest can drop you`_ in an interactive session in the case an error occurs. In order to do that you need to pass a ``--pdb`` option (for example by @@ -278,27 +268,21 @@ Maintainer tasks Releases -------- -.. todo:: This section assumes you are using PyPI to publicly release your package. - - If instead you are using a different/private package index, please update - the instructions accordingly. - -If you are part of the group of maintainers and have correct user permissions -on PyPI_, the following steps can be used to release a new version for -``flexmeasures-client``: - -#. Make sure all unit tests are successful. -#. Tag the current commit on the main branch with a release tag, e.g., ``v1.2.3``. -#. Push the new tag to the upstream repository_, e.g., ``git push upstream v1.2.3`` -#. Clean up the ``dist`` and ``build`` folders with ``tox -e clean`` - (or ``rm -rf dist build``) - to avoid confusion with old builds and Sphinx docs. -#. Run ``tox -e build`` and check that the files in ``dist`` have - the correct version (no ``.dirty`` or git_ hash) according to the git_ tag. - Also check the sizes of the distributions, if they are too big (e.g., > - 500KB), unwanted clutter may have been accidentally included. -#. Run ``tox -e publish -- --repository pypi`` and check that everything was - uploaded to PyPI_ correctly. +Releases are handled automatically by the CI pipeline (``release.yml``). To publish a new release: + +#. Make sure all unit tests are successful on ``main``. +#. Tag the commit with a version tag and push it:: + + git tag -s -a vX.Y.Z -m "Short summary" + git push upstream vX.Y.Z + +Or make a new release in the GitHub UI. + +The CI will then automatically: + +- Build the distribution with ``uv build`` +- Publish it to PyPI_ using trusted publishing (no API token required) +- Create a GitHub release with auto-generated release notes diff --git a/README.rst b/README.rst index 5ac539c3..a1c0e3de 100644 --- a/README.rst +++ b/README.rst @@ -45,17 +45,19 @@ Installation =============== -Install using ``pip``: +We use `uv `_ to manage dependencies. First, `install uv `_. + +Then add it to your project: .. code-block:: bash - pip install flexmeasures-client + uv add flexmeasures-client The FlexMeasures Client can also run as an `S2 CEM `_. To enable S2 features, you need to install extra requirements: .. code-block:: bash - pip install flexmeasures-client[s2] + uv add flexmeasures-client[s2] Initialization and authentication @@ -245,17 +247,23 @@ The client will re-try until the schedule is available or the ``MAX_POLLING_STEP Development ============== -If you want to develop this package it's necessary to install testing requirements: +We use `uv `_ to manage dependencies. First, `install uv `_. + +To install the package with all development and testing dependencies: .. code-block:: bash - pip install -e ".[testing]" + uv sync --group dev --group test Moreover, if you need to work on S2 features, you need to install extra dependencies: .. code-block:: bash - pip install -e ".[s2, testing]" + uv sync --extra s2 --group dev --group test + +.. note:: + + If you prefer shorter commands during interactive development, you can activate the virtual environment (``source .venv/bin/activate``, or ``.venv\\Scripts\\activate`` on Windows) or run commands directly with ``uv run ``. @@ -268,33 +276,32 @@ Making Changes & Contributing .. note: Read more details in CONTRIBUTING.rst -Install the project locally (in a virtual environment of your choice): +Install the project locally (creating a virtual environment automatically): .. code-block:: bash - pip install -e . + uv sync -Running tests locally is crucial as well. Staying close to the CI workflow: +Running tests locally is crucial as well: .. code-block:: bash - pip install tox - tox -e clean,build - tox -- -rFEx --durations 10 --color yes + uv run poe test -For S2 features, you need to add `-e s2` to tox: +For S2 features: .. code-block:: bash - tox -e s2 + uv sync --extra s2 --group test + uv run poe test-s2 This project uses `pre-commit`_, please make sure to install it before making any changes: .. code-block:: bash - pip install pre-commit + uv tool install pre-commit cd flexmeasures-client pre-commit install diff --git a/docs/HEMS.rst b/docs/HEMS.rst index 340e3006..3a43e858 100644 --- a/docs/HEMS.rst +++ b/docs/HEMS.rst @@ -21,21 +21,23 @@ This is the resulting dashboard: Set up your environment ======================== -To run the HEMS example (``HEMS_setup.py``), you'll need a virtual environment in which both ``flexmeasures`` (the server) and ``flexmeasures-client`` is installed. +To run the HEMS example (``HEMS_setup.py``), you'll need an environment in which both ``flexmeasures`` (the server) and ``flexmeasures-client`` is installed. + +We use `uv `_ to manage dependencies. First, `install uv `_. + +From the ``flexmeasures-client`` repository, install the client and the FlexMeasures server: .. code-block:: bash - python3.12 -m venv venv - pip install -e . - pip install git+https://github.com/flexmeasures/flexmeasures.git@main + uv sync + uv add git+https://github.com/flexmeasures/flexmeasures.git@main -Or, alternatively, for developers: +Or, alternatively, to install released versions into a fresh project: .. code-block:: bash - python3.12 -m venv venv - pip install flexmeasures-client - pip install flexmeasures + uv init my-hems && cd my-hems + uv add flexmeasures-client flexmeasures Next steps: From 3afc4afa155ff8a9cc63fa133a6a20acaf7c6cf3 Mon Sep 17 00:00:00 2001 From: Stijn van Houwelingen Date: Wed, 18 Mar 2026 15:41:35 +0100 Subject: [PATCH 092/181] fix: fix docker compose CEM not connecting to flexmeasures server. Fix miscellaneous docker compose issues Signed-off-by: Stijn van Houwelingen --- docker-compose.yml | 26 +++++++++----------------- 1 file changed, 9 insertions(+), 17 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 02d12369..7d9f25a2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,11 +1,11 @@ # ------------------------------------------------------------------ -# This allow to run the S2 CEM from your local FlexMeasures Client code in a docker compose stack. -# Assuming you have flexmeasures the repo next to your flexmeasures-client repo, -# run this from the flexmeasures folder (which contains the Dockerfile): -# docker compose \ -# -f docker-compose.yml \ -# -f ../flexmeasures-client/docker-compose.yml \ -# up +# Run the S2 CEM alongside a running FlexMeasures server stack. +# +# First, start the FlexMeasures server from the flexmeasures folder: +# docker compose up +# +# Then, from this (flexmeasures-client) folder: +# docker compose up # ------------------------------------------------------------------ services: @@ -14,20 +14,12 @@ services: context: . dockerfile: Dockerfile restart: always - ports: - - "8080:8080" # aiohttp default; adjust if you change it + network_mode: host environment: - # Optional, but useful if you later make this configurable - FLEXMEASURES_BASE_URL: http://server:5000 + FLEXMEASURES_BASE_URL: http://localhost:5000 FLEXMEASURES_USER: toy-user@flexmeasures.io FLEXMEASURES_PASSWORD: toy-password LOGGING_LEVEL: INFO - volumes: - # If flexmeasures_client lives in your repo and you want live edits - - flexmeasures-client:/app/flexmeasures-client:rw entrypoint: ["/bin/sh", "-c"] command: - python3 /app/src/flexmeasures_client/s2/script/websockets_server.py - -volumes: - flexmeasures-client: From db0c676860f3d629419d1bb194ed5ed42790edc5 Mon Sep 17 00:00:00 2001 From: Stijn van Houwelingen Date: Wed, 18 Mar 2026 15:42:36 +0100 Subject: [PATCH 093/181] feat: rewrite CEM docs to use new UV setup and new docker compose setup. Remove docs about deprecated demo file. Signed-off-by: Stijn van Houwelingen --- docs/CEM.rst | 73 +++++++++++++++++++++------------------------------- 1 file changed, 29 insertions(+), 44 deletions(-) diff --git a/docs/CEM.rst b/docs/CEM.rst index 72661c75..ace53690 100644 --- a/docs/CEM.rst +++ b/docs/CEM.rst @@ -3,79 +3,64 @@ CEM tutorial ------------- -To run the FlexMeasures Client as a local S2 Customer Energy Manager (CEM) using WebSocket communication: +There are two ways to run the FlexMeasures Client as a local S2 Customer Energy Manager (CEM) using WebSocket communication. -.. code-block:: bash - - pip install flexmeasures-client[s2] - -Then point your Resource Managers (RMs) to ``http://localhost:8080/ws`` and run: - -.. code-block:: bash +Docker Compose +-------------- - python3 flexmeasures_client/s2/scripts/websockets_server.py +This is the easiest way to run the CEM alongside a full FlexMeasures server stack. -We also included a ``docker-compose.yaml`` that can be used to set up the CEM including the FlexMeasures server, creating a fully self-hosted HEMS. -Assuming your ``flexmeasures`` and ``flexmeasures-client`` repo folders are located side by side, run this from your flexmeasures folder: +First, start the FlexMeasures server from the ``flexmeasures`` folder: .. code-block:: bash - docker compose \ - -f docker-compose.yml \ - -f ../flexmeasures-client/docker-compose.yml \ - up - + docker compose up -This creates the following containers for the CEM: +This starts: -- a WebSocket server (FlexMeasures Client) -- web and worker servers (FlexMeasures) -- a database server (Postgres) -- a queue server (Redis) -- a mail server (MailHog) +- Web and worker servers (FlexMeasures) +- Database server (Postgres) +- Queue server (Redis) +- Mail server (MailHog) -To test, run the included example RM: +Once the server is running, start the CEM from this (``flexmeasures-client``) folder: .. code-block:: bash - python3 flexmeasures_client/s2/script/websockets_client.py - -Disclaimer -========== + docker compose up -The `S2 Protocol `_ integration is still under active development. Please, beware that the logic and interfaces can change. +The CEM runs with host networking, so it can reach the FlexMeasures server at ``http://localhost:5000``. +TODO: fix networking. -Run Demo -========= +You can now point your Resource Managers (RMs) to ``http://localhost:8080/ws``. -Run the following commands in the flexmeasures folder to create a toy-account and an admin user: +Local development +------------------ -.. code-block:: bash +If you want to run the CEM locally for development purposes, you can do the following: - flexmeasures add toy-account - flexmeasures add user --username admin --account-id 1 --email admin@mycompany.io --roles admin +First, follow the instructions in the FlexMeasures repository to set up the server. -Launch server: +Then, in the folder containing this repository, run: .. code-block:: bash - flexmeasures run + uv sync --extra s2 -To load the data, run the following command in the flexmeasures-client repository: +Then point your Resource Managers (RMs) to ``http://localhost:8080/ws`` and run: .. code-block:: bash - python src/flexmeasures_client/s2/script/demo_setup.py + uv run src/flexmeasures_client/s2/script/websockets_server.py -Start the S2 server: +To test, run the included example RM: .. code-block:: bash - python src/flexmeasures_client/s2/script/websockets_server.py + uv run src/flexmeasures_client/s2/script/websockets_client.py -In a separate window, start the S2 Client: - -.. code-block:: bash +Disclaimer +========== - python src/flexmeasures_client/s2/script/websockets_client.py +The `S2 Protocol `_ integration is still under active development. Please, beware that the logic and interfaces can change. From 030c6bf12179688f1756708412ed17a3a6de6529 Mon Sep 17 00:00:00 2001 From: Stijn van Houwelingen Date: Wed, 18 Mar 2026 15:43:14 +0100 Subject: [PATCH 094/181] fix: trigger_schedule retreiving outdated schedule Signed-off-by: Stijn van Houwelingen --- src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py index 6e81f291..0502405d 100644 --- a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py +++ b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py @@ -124,6 +124,7 @@ async def trigger_schedule(self, system_description_id: str): "soc-max": soc_max, }, duration=self._schedule_duration, # next 12 hours + prior=self.now(), # TODO: add SOC MAX AND SOC MIN FROM fill_level_range, # this needs changes on the client ) From b78bacfa71f68338f60c60ddda82664a8e77cdc4 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Mon, 13 Apr 2026 14:27:37 +0200 Subject: [PATCH 095/181] dev: expose dev-db on local port Signed-off-by: F.N. Claessen --- docker-compose.override.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docker-compose.override.yml b/docker-compose.override.yml index 0b7db4a0..037690c1 100644 --- a/docker-compose.override.yml +++ b/docker-compose.override.yml @@ -9,6 +9,9 @@ # ------------------------------------------------------------------ services: + dev-db: + ports: + - "5432:5432" server: volumes: # A place for config and plugin code, and custom requirements.txt From 0e4489a80a4e4634acba731131a2e46cc0d07bdc Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Mon, 13 Apr 2026 15:58:23 +0200 Subject: [PATCH 096/181] fix: missing import Signed-off-by: F.N. Claessen --- src/flexmeasures_client/s2/cem.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/flexmeasures_client/s2/cem.py b/src/flexmeasures_client/s2/cem.py index a684297e..fb15f9a5 100644 --- a/src/flexmeasures_client/s2/cem.py +++ b/src/flexmeasures_client/s2/cem.py @@ -3,7 +3,7 @@ import json import logging import math -from asyncio import Queue +import asyncio from collections import defaultdict from datetime import datetime, timedelta from logging import Logger @@ -84,7 +84,7 @@ def __init__( super(CEM, self).__init__() self._fm_client = fm_client - self._sending_queue = Queue() + self._sending_queue = asyncio.Queue() self._power_sensors = dict() self.power_sensor_id = power_sensor_id self._control_types_handlers = dict() From f3114cc683f8ae87cc99486f342005a0dcc9900e Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Mon, 13 Apr 2026 15:59:10 +0200 Subject: [PATCH 097/181] dev: fix db port exposure Signed-off-by: F.N. Claessen --- docker-compose.override.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker-compose.override.yml b/docker-compose.override.yml index 037690c1..dd604844 100644 --- a/docker-compose.override.yml +++ b/docker-compose.override.yml @@ -11,7 +11,7 @@ services: dev-db: ports: - - "5432:5432" + - "5433:5432" server: volumes: # A place for config and plugin code, and custom requirements.txt From da7268a8eb414c6014d1961fa42959393b4dec32 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Mon, 13 Apr 2026 15:59:27 +0200 Subject: [PATCH 098/181] dev: expose queue-db port, too Signed-off-by: F.N. Claessen --- docker-compose.override.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docker-compose.override.yml b/docker-compose.override.yml index dd604844..64212113 100644 --- a/docker-compose.override.yml +++ b/docker-compose.override.yml @@ -12,6 +12,9 @@ services: dev-db: ports: - "5433:5432" + queue-db: + ports: + - "6380:6379" server: volumes: # A place for config and plugin code, and custom requirements.txt From 21d28a1223ce3bc3cae95d644d19cbd4d7e0beb2 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Mon, 13 Apr 2026 16:00:03 +0200 Subject: [PATCH 099/181] fix: install missing packages for CEM Signed-off-by: F.N. Claessen --- docker-compose.override.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docker-compose.override.yml b/docker-compose.override.yml index 64212113..6b1403a7 100644 --- a/docker-compose.override.yml +++ b/docker-compose.override.yml @@ -60,6 +60,7 @@ services: FLEXMEASURES_USER: toy-user@flexmeasures.io FLEXMEASURES_PASSWORD: toy-password LOGGING_LEVEL: DEBUG + SETUPTOOLS_SCM_PRETEND_VERSION_FOR_FLEXMEASURES_CLIENT: "0.0.0" volumes: # If flexmeasures_client lives in your repo and you want live edits - ../flexmeasures-client:/app/flexmeasures-client:rw @@ -68,4 +69,7 @@ services: - | # pip install --break-system-packages --no-cache-dir "git+https://github.com/FlexMeasures/flexmeasures-client.git@main#egg=flexmeasures-client[s2]" pip install --break-system-packages -e /app/flexmeasures-client[s2] + pip install --break-system-packages aiohttp + pip install --break-system-packages pytz + pip install --break-system-packages s2-python==0.8.2 python3 /app/flexmeasures-client/src/flexmeasures_client/s2/script/websockets_server.py From 78b87d5f9f3c50b0aad26f649a536fb352176d66 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Mon, 13 Apr 2026 16:03:35 +0200 Subject: [PATCH 100/181] feat: add charging-efficiency sensor Signed-off-by: F.N. Claessen --- .../s2/control_types/FRBC/frbc_simple.py | 4 ++++ .../s2/script/websockets_server.py | 14 +++++++++++++- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py index 3a3c98d7..eefd90f4 100644 --- a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py +++ b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py @@ -45,6 +45,7 @@ class FRBCSimple(FRBC): _soc_maxima_sensor_id: int _usage_forecast_sensor_id: int _leakage_behaviour_sensor_id: int + _charging_efficiency_sensor_id: int _schedule_duration: timedelta _fill_level_scale: float _resolution = "15min" @@ -59,6 +60,7 @@ def __init__( soc_maxima_sensor_id: int, usage_forecast_sensor_id: int, leakage_behaviour_sensor_id: int, + charging_efficiency_sensor_id: int, timezone: str = "UTC", schedule_duration: timedelta = timedelta(hours=12), max_size: int = 100, @@ -76,6 +78,7 @@ def __init__( self._soc_maxima_sensor_id = soc_maxima_sensor_id self._usage_forecast_sensor_id = usage_forecast_sensor_id self._leakage_behaviour_sensor_id = leakage_behaviour_sensor_id + self.charging_efficiency_sensor_id = charging_efficiency_sensor_id self._timezone = pytz.timezone(timezone) self._fill_level_scale = fill_level_scale self.power_unit = power_unit @@ -211,6 +214,7 @@ async def trigger_schedule( "state-of-charge": {"sensor": self._soc_sensor_id}, "soc-usage": [{"sensor": self._usage_forecast_sensor_id}], "storage-efficiency": {"sensor": self._leakage_behaviour_sensor_id}, + "charging-efficiency": {"sensor": self.charging_efficiency_sensor_id}, "consumption-capacity": f"{charging_capacity} {self.power_unit}", "production-capacity": f"{discharging_capacity} {self.power_unit}", }, diff --git a/src/flexmeasures_client/s2/script/websockets_server.py b/src/flexmeasures_client/s2/script/websockets_server.py index 6ef67321..01d60f02 100644 --- a/src/flexmeasures_client/s2/script/websockets_server.py +++ b/src/flexmeasures_client/s2/script/websockets_server.py @@ -105,6 +105,7 @@ async def websocket_handler(request): soc_maxima_sensor, usage_forecast_sensor, leakage_behaviour_sensor_id, + charging_efficiency_sensor, ) = await configure_site(site_name, fm_client) cem = CEM( @@ -121,6 +122,7 @@ async def websocket_handler(request): soc_maxima_sensor_id=soc_maxima_sensor["id"], usage_forecast_sensor_id=usage_forecast_sensor["id"], leakage_behaviour_sensor_id=leakage_behaviour_sensor_id["id"], + charging_efficiency_sensor_id=charging_efficiency_sensor["id"], ) cem.register_control_type(frbc) @@ -136,7 +138,7 @@ async def websocket_handler(request): async def configure_site( site_name: str, fm_client: FlexMeasuresClient -) -> tuple[dict, dict, dict, dict, dict, dict, dict, dict]: +) -> tuple[dict, dict, dict, dict, dict, dict, dict, dict, dict]: account = await fm_client.get_account() assets = await fm_client.get_assets(parse_json_fields=True) @@ -171,6 +173,7 @@ async def configure_site( soc_maxima_sensor = None usage_forecast_sensor = None leakage_behaviour_sensor = None + charging_efficiency_sensor = None for sensor in sensors: if sensor["name"] == "price": price_sensor = sensor @@ -272,6 +275,14 @@ async def configure_site( generic_asset_id=site_asset["id"], timezone="Europe/Amsterdam", ) + if charging_efficiency_sensor is None: + charging_efficiency_sensor = await fm_client.add_sensor( + name="charging-efficiency", + event_resolution="PT15M", + unit="%", + generic_asset_id=site_asset["id"], + timezone="Europe/Amsterdam", + ) sensors_to_show = [ { "title": "State of charge", @@ -299,6 +310,7 @@ async def configure_site( soc_maxima_sensor, usage_forecast_sensor, leakage_behaviour_sensor, + charging_efficiency_sensor, ) From 5e2e4cee54d9bf00268c482dbdb86833d6727af3 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Mon, 13 Apr 2026 16:03:59 +0200 Subject: [PATCH 101/181] refactor: rename variable Signed-off-by: F.N. Claessen --- src/flexmeasures_client/s2/script/websockets_server.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/flexmeasures_client/s2/script/websockets_server.py b/src/flexmeasures_client/s2/script/websockets_server.py index 01d60f02..00ea7ff5 100644 --- a/src/flexmeasures_client/s2/script/websockets_server.py +++ b/src/flexmeasures_client/s2/script/websockets_server.py @@ -104,7 +104,7 @@ async def websocket_handler(request): soc_minima_sensor, soc_maxima_sensor, usage_forecast_sensor, - leakage_behaviour_sensor_id, + leakage_behaviour_sensor, charging_efficiency_sensor, ) = await configure_site(site_name, fm_client) @@ -121,7 +121,7 @@ async def websocket_handler(request): soc_minima_sensor_id=soc_minima_sensor["id"], soc_maxima_sensor_id=soc_maxima_sensor["id"], usage_forecast_sensor_id=usage_forecast_sensor["id"], - leakage_behaviour_sensor_id=leakage_behaviour_sensor_id["id"], + leakage_behaviour_sensor_id=leakage_behaviour_sensor["id"], charging_efficiency_sensor_id=charging_efficiency_sensor["id"], ) cem.register_control_type(frbc) From 72321d4856775f1a7aa3c49a77ebbc0f0c57ce28 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Mon, 13 Apr 2026 16:06:36 +0200 Subject: [PATCH 102/181] docs: update developer note Signed-off-by: F.N. Claessen --- src/flexmeasures_client/s2/script/websockets_server.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/flexmeasures_client/s2/script/websockets_server.py b/src/flexmeasures_client/s2/script/websockets_server.py index 00ea7ff5..982fe7a0 100644 --- a/src/flexmeasures_client/s2/script/websockets_server.py +++ b/src/flexmeasures_client/s2/script/websockets_server.py @@ -42,7 +42,7 @@ async def rm_details_watchdog(ws, cem: CEM): cem._logger.debug(f"CONTROL TYPE: {cem._control_type}") - # after this, schedule will be triggered on reception of a new system description + # after this, schedule will be triggered on reception of a new storage status async def websocket_producer(ws, cem: CEM): From 7a87b91e4ce1754e1a5bc9581f3584781ad08fc9 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Tue, 14 Apr 2026 09:28:19 +0200 Subject: [PATCH 103/181] feat: add production price sensor Signed-off-by: F.N. Claessen --- .../s2/control_types/FRBC/frbc_simple.py | 5 +++- .../s2/script/websockets_server.py | 28 +++++++++++++++++-- 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py index eefd90f4..a5c9e088 100644 --- a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py +++ b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py @@ -39,6 +39,7 @@ class FRBCSimple(FRBC): _power_sensor_id: int _price_sensor_id: int + _production_price_sensor_id: int _soc_sensor_id: int _rm_discharge_sensor_id: int _soc_minima_sensor_id: int @@ -56,6 +57,7 @@ def __init__( soc_sensor_id: int, rm_discharge_sensor_id: int, price_sensor_id: int, + production_price_sensor_id: int, soc_minima_sensor_id: int, soc_maxima_sensor_id: int, usage_forecast_sensor_id: int, @@ -71,6 +73,7 @@ def __init__( super().__init__(max_size) self._power_sensor_id = power_sensor_id self._price_sensor_id = price_sensor_id + self._production_price_sensor_id = production_price_sensor_id self._schedule_duration = schedule_duration self._soc_sensor_id = soc_sensor_id self._rm_discharge_sensor_id = rm_discharge_sensor_id @@ -199,8 +202,8 @@ async def trigger_schedule( prior=start, sensor_id=self._power_sensor_id, flex_context={ - "production-price": {"sensor": self._price_sensor_id}, "consumption-price": {"sensor": self._price_sensor_id}, + "production-price": {"sensor": self._production_price_sensor_id}, "site-power-capacity": f"{3 * 25 * 230} VA", "relax-soc-constraints": True, }, diff --git a/src/flexmeasures_client/s2/script/websockets_server.py b/src/flexmeasures_client/s2/script/websockets_server.py index 982fe7a0..c6f5daa5 100644 --- a/src/flexmeasures_client/s2/script/websockets_server.py +++ b/src/flexmeasures_client/s2/script/websockets_server.py @@ -98,6 +98,7 @@ async def websocket_handler(request): ( price_sensor, + production_price_sensor, power_sensor, soc_sensor, rm_discharge_sensor, @@ -116,6 +117,7 @@ async def websocket_handler(request): frbc = FRBCSimple( power_sensor_id=power_sensor["id"], price_sensor_id=price_sensor["id"], + production_price_sensor_id=production_price_sensor["id"], soc_sensor_id=soc_sensor["id"], rm_discharge_sensor_id=rm_discharge_sensor["id"], soc_minima_sensor_id=soc_minima_sensor["id"], @@ -138,7 +140,7 @@ async def websocket_handler(request): async def configure_site( site_name: str, fm_client: FlexMeasuresClient -) -> tuple[dict, dict, dict, dict, dict, dict, dict, dict, dict]: +) -> tuple[dict, dict, dict, dict, dict, dict, dict, dict, dict, dict]: account = await fm_client.get_account() assets = await fm_client.get_assets(parse_json_fields=True) @@ -166,6 +168,7 @@ async def configure_site( sensors = site_asset.get("sensors", []) price_sensor = None + production_price_sensor = None power_sensor = None soc_sensor = None rm_discharge_sensor = None @@ -177,6 +180,8 @@ async def configure_site( for sensor in sensors: if sensor["name"] == "price": price_sensor = sensor + if sensor["name"] == "production price": + production_price_sensor = sensor elif sensor["name"] == "power": power_sensor = sensor elif sensor["name"] == "state of charge": @@ -200,6 +205,14 @@ async def configure_site( generic_asset_id=site_asset["id"], timezone="Europe/Amsterdam", ) + if production_price_sensor is None: + production_price_sensor = await fm_client.add_sensor( + name="production price", + event_resolution="PT15M", + unit="EUR/kWh", + generic_asset_id=site_asset["id"], + timezone="Europe/Amsterdam", + ) # Continue immediately without awaiting LOGGER.debug("Posting 3 days of prices in a background task..") @@ -218,6 +231,16 @@ async def configure_site( unit="EUR/kWh", ) ) + asyncio.create_task( + fm_client.post_sensor_data( + sensor_id=production_price_sensor["id"], + start=start_of_today, + prior="2026-01-01T00:00+01", # 2026-01-01T00:00+01 + duration="P3D", # P1M + values=[0.2], + unit="EUR/kWh", + ) + ) if power_sensor is None: power_sensor = await fm_client.add_sensor( name="power", @@ -294,7 +317,7 @@ async def configure_site( }, { "title": "Prices", - "sensor": price_sensor["id"], + "sensors": [price_sensor["id"], production_price_sensor["id"]], }, ] await fm_client.update_asset( @@ -303,6 +326,7 @@ async def configure_site( ) return ( price_sensor, + production_price_sensor, power_sensor, soc_sensor, rm_discharge_sensor, From e700c225492324e8aa4a219037b6f5fa933d4c59 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Tue, 14 Apr 2026 10:01:27 +0200 Subject: [PATCH 104/181] fix: get existing charging_efficiency_sensor Signed-off-by: F.N. Claessen --- src/flexmeasures_client/s2/script/websockets_server.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/flexmeasures_client/s2/script/websockets_server.py b/src/flexmeasures_client/s2/script/websockets_server.py index c6f5daa5..5457edd8 100644 --- a/src/flexmeasures_client/s2/script/websockets_server.py +++ b/src/flexmeasures_client/s2/script/websockets_server.py @@ -196,6 +196,8 @@ async def configure_site( usage_forecast_sensor = sensor elif sensor["name"] == "leakage-behaviour": leakage_behaviour_sensor = sensor + elif sensor["name"] == "charging-efficiency": + charging_efficiency_sensor = sensor if price_sensor is None: price_sensor = await fm_client.add_sensor( From 347268d3face2ce1fca591c43a9daf380fda74cc Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Tue, 14 Apr 2026 10:33:24 +0200 Subject: [PATCH 105/181] fix: repeated keyword (prior comes from start, not now) Signed-off-by: F.N. Claessen --- src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py index 087ecf39..bd133d21 100644 --- a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py +++ b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py @@ -223,7 +223,6 @@ async def trigger_schedule( "production-capacity": f"{discharging_capacity} {self.power_unit}", }, duration=self._schedule_duration, # next 12 hours - prior=self.now(), # TODO: add SOC MAX AND SOC MIN FROM fill_level_range, # this needs changes on the client unit=self.power_unit, From 655ba10d2e716b4ea6eff8b691d47e7eb360949d Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Tue, 14 Apr 2026 10:39:10 +0200 Subject: [PATCH 106/181] fix: use new internal method Signed-off-by: F.N. Claessen --- src/flexmeasures_client/client.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/src/flexmeasures_client/client.py b/src/flexmeasures_client/client.py index e45079b4..cdb64614 100644 --- a/src/flexmeasures_client/client.py +++ b/src/flexmeasures_client/client.py @@ -635,12 +635,9 @@ async def get_schedule( if duration is not None: params["duration"] = pd.Timedelta(duration).isoformat() # for example: PT1H if unit is not None: - await self.ensure_server_version() - if Version(self.server_version) < Version("0.32.0"): - self.logger.warning( - "get_schedule(): The 'unit' parameter requires FlexMeasures server version 0.31.0 or above. " - f"This parameter will be ignored by the server, which is at version {self.server_version}." - ) + message = "get_schedule(): The 'unit' parameter requires FlexMeasures server version 0.31.0 or above. " + f"This parameter will be ignored by the server, which is at version {self.server_version}." + await self.ensure_minimum_server_version("0.32.0", message) params["unit"] = unit schedule, status = await self.request( uri=f"sensors/{sensor_id}/schedules/{schedule_id}", From 74fc8851767034a566816b5744e496e5ab0bbbcd Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Tue, 14 Apr 2026 10:42:39 +0200 Subject: [PATCH 107/181] dev: allow using dev server version Signed-off-by: F.N. Claessen --- src/flexmeasures_client/client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/flexmeasures_client/client.py b/src/flexmeasures_client/client.py index cdb64614..44826512 100644 --- a/src/flexmeasures_client/client.py +++ b/src/flexmeasures_client/client.py @@ -637,7 +637,7 @@ async def get_schedule( if unit is not None: message = "get_schedule(): The 'unit' parameter requires FlexMeasures server version 0.31.0 or above. " f"This parameter will be ignored by the server, which is at version {self.server_version}." - await self.ensure_minimum_server_version("0.32.0", message) + await self.ensure_minimum_server_version("0.31.0.dev90", message) params["unit"] = unit schedule, status = await self.request( uri=f"sensors/{sensor_id}/schedules/{schedule_id}", From a5b382f611a9dc315c883a2e3cf8005437825cff Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Tue, 14 Apr 2026 11:08:44 +0200 Subject: [PATCH 108/181] fix: parse two more JSON fields Signed-off-by: F.N. Claessen --- src/flexmeasures_client/client.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/flexmeasures_client/client.py b/src/flexmeasures_client/client.py index 44826512..f6acf824 100644 --- a/src/flexmeasures_client/client.py +++ b/src/flexmeasures_client/client.py @@ -61,6 +61,8 @@ def _parse_asset_json_fields(asset: dict) -> None: _parse_json_field(asset, "attributes") _parse_json_field(asset, "flex_context") _parse_json_field(asset, "flex_model") + _parse_json_field(asset, "sensors_to_show") + _parse_json_field(asset, "kpi_sensors_to_show") def _parse_sensor_json_fields(sensor: dict) -> None: From 5f62c4182d31531fa4ba7ca53c04c84d16da2926 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Tue, 14 Apr 2026 11:09:09 +0200 Subject: [PATCH 109/181] feat: add chart with power values Signed-off-by: F.N. Claessen --- src/flexmeasures_client/s2/script/websockets_server.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/flexmeasures_client/s2/script/websockets_server.py b/src/flexmeasures_client/s2/script/websockets_server.py index f3bc4c0d..9b7ed1eb 100644 --- a/src/flexmeasures_client/s2/script/websockets_server.py +++ b/src/flexmeasures_client/s2/script/websockets_server.py @@ -327,6 +327,10 @@ async def configure_site( "title": "Prices", "sensors": [price_sensor["id"], production_price_sensor["id"]], }, + { + "title": "Power", + "sensors": [power_sensor["id"]], + }, ] await fm_client.update_asset( asset_id=site_asset["id"], From 69e671e597688e33bcc5bf600245a3cd929e2a02 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Mon, 20 Apr 2026 09:49:35 +0200 Subject: [PATCH 110/181] style: black, flake8, isort (run pre-commit hooks) Signed-off-by: F.N. Claessen --- src/flexmeasures_client/s2/cem.py | 2 +- src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py | 3 +-- src/flexmeasures_client/s2/script/websockets_server.py | 4 +++- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/flexmeasures_client/s2/cem.py b/src/flexmeasures_client/s2/cem.py index fb15f9a5..109b9eab 100644 --- a/src/flexmeasures_client/s2/cem.py +++ b/src/flexmeasures_client/s2/cem.py @@ -1,9 +1,9 @@ from __future__ import annotations +import asyncio import json import logging import math -import asyncio from collections import defaultdict from datetime import datetime, timedelta from logging import Logger diff --git a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py index bd133d21..333ddfe1 100644 --- a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py +++ b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py @@ -4,10 +4,9 @@ """ from datetime import datetime, timedelta +from zoneinfo import ZoneInfo import pandas as pd -import pytz -from zoneinfo import ZoneInfo try: from s2python.frbc import ( diff --git a/src/flexmeasures_client/s2/script/websockets_server.py b/src/flexmeasures_client/s2/script/websockets_server.py index 9b7ed1eb..b48ecb88 100644 --- a/src/flexmeasures_client/s2/script/websockets_server.py +++ b/src/flexmeasures_client/s2/script/websockets_server.py @@ -92,7 +92,9 @@ async def websocket_handler(request): await ws.prepare(request) site_name = "My CEM" - base_url = os.getenv("FLEXMEASURES_BASE_URL", "http://localhost:5000") # or "server:5000" + base_url = os.getenv( + "FLEXMEASURES_BASE_URL", "http://localhost:5000" + ) # or "server:5000" parsed = urlparse(base_url) fm_client = FlexMeasuresClient( password=os.getenv("FLEXMEASURES_PASSWORD", "toy-password"), From 01ccccb3a3eeed1479296c74b0a1bc146400d200 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Tue, 21 Apr 2026 17:21:57 +0200 Subject: [PATCH 111/181] fix: hanging future Signed-off-by: F.N. Claessen --- src/flexmeasures_client/s2/cem.py | 1 - tests/s2/test_cem.py | 38 +++++++++++++++---------------- tests/s2/test_frbc_tunes.py | 10 ++++---- 3 files changed, 24 insertions(+), 25 deletions(-) diff --git a/src/flexmeasures_client/s2/cem.py b/src/flexmeasures_client/s2/cem.py index 109b9eab..14628254 100644 --- a/src/flexmeasures_client/s2/cem.py +++ b/src/flexmeasures_client/s2/cem.py @@ -442,7 +442,6 @@ async def send_message(self, message): fut = loop.create_future() self._logger.debug(f"Sent: {message}") await self._sending_queue.put((message, fut)) - await fut # wait until actually sent def get_commodity_unit(commodity_quantity) -> str: diff --git a/tests/s2/test_cem.py b/tests/s2/test_cem.py index 37f3aaff..137e9b46 100644 --- a/tests/s2/test_cem.py +++ b/tests/s2/test_cem.py @@ -26,8 +26,8 @@ async def test_handshake(rm_handshake): ) # check that two messages are put to the outgoing queue (ReceptionStatus and HandshakeResponse) # CEM response - response = await cem.get_message() # ReceptionStatus for Handshake - response = await cem.get_message() # HandshakeResponse + response, _ = await cem.get_message() # ReceptionStatus for Handshake + response, _ = await cem.get_message() # HandshakeResponse assert ( response["message_type"] == "HandshakeResponse" @@ -54,8 +54,8 @@ async def test_resource_manager_details(resource_manager_details, rm_handshake): cem._sending_queue.qsize() == 2 ) # check that message is put to the outgoing queue - response = await cem.get_message() # ReceptionStatus for Handshake - response = await cem.get_message() # HandshakeResponse + response, _ = await cem.get_message() # ReceptionStatus for Handshake + response, _ = await cem.get_message() # HandshakeResponse ########################## # ResourceManagerDetails # @@ -63,7 +63,7 @@ async def test_resource_manager_details(resource_manager_details, rm_handshake): # RM sends ResourceManagerDetails await cem.handle_message(resource_manager_details) - response = await cem.get_message() + response, _ = await cem.get_message() # CEM response is ReceptionStatus with an OK status assert response["message_type"] == "ReceptionStatus" @@ -92,14 +92,14 @@ async def test_activate_control_type( ############# await cem.handle_message(rm_handshake) - response = await cem.get_message() # ReceptionStatus for Handshake - response = await cem.get_message() # HandshakeResponse + response, _ = await cem.get_message() # ReceptionStatus for Handshake + response, _ = await cem.get_message() # HandshakeResponse ########################## # ResourceManagerDetails # ########################## await cem.handle_message(resource_manager_details) - response = await cem.get_message() + response, _ = await cem.get_message() ######################### # Activate control type # @@ -107,7 +107,7 @@ async def test_activate_control_type( # CEM sends a request to change te control type await cem.activate_control_type(ControlType.FILL_RATE_BASED_CONTROL) - message = await cem.get_message() + message, _ = await cem.get_message() assert cem.control_type == ControlType.NO_SELECTION, ( "the control type should still be NO_SELECTION (rather than FRBC)," @@ -139,21 +139,21 @@ async def test_messages_route_to_control_type_handler( ############# await cem.handle_message(rm_handshake) - response = await cem.get_message() # ReceptionStatus for Handshake - response = await cem.get_message() # HandshakeResponse + response, _ = await cem.get_message() # ReceptionStatus for Handshake + response, _ = await cem.get_message() # HandshakeResponse ########################## # ResourceManagerDetails # ########################## await cem.handle_message(resource_manager_details) - response = await cem.get_message() + response, _ = await cem.get_message() ######################### # Activate control type # ######################### await cem.activate_control_type(ControlType.FILL_RATE_BASED_CONTROL) - message = await cem.get_message() + message, _ = await cem.get_message() response = ReceptionStatus( subject_message_id=message.get("message_id"), status=ReceptionStatusValues.OK @@ -166,7 +166,7 @@ async def test_messages_route_to_control_type_handler( ######## await cem.handle_message(frbc_system_description) - response = await cem.get_message() + response, _ = await cem.get_message() # checking that FRBC handler is being called assert ( @@ -182,7 +182,7 @@ async def test_messages_route_to_control_type_handler( # change of control type is not performed in case that the RM answers # with a negative response await cem.activate_control_type(ControlType.NO_SELECTION) - response = await cem.get_message() + response, _ = await cem.get_message() assert ( cem._control_type == ControlType.FILL_RATE_BASED_CONTROL ), "control type should not change, confirmation still pending" @@ -222,8 +222,8 @@ async def test_automatic_change_control_type(resource_manager_details, rm_handsh cem._sending_queue.qsize() == 2 ) # check that message is put to the outgoing queue - response = await cem.get_message() - response = await cem.get_message() # HandshakeResponse + response, _ = await cem.get_message() + response, _ = await cem.get_message() # HandshakeResponse ########################## # ResourceManagerDetails # @@ -231,13 +231,13 @@ async def test_automatic_change_control_type(resource_manager_details, rm_handsh # RM sends ResourceManagerDetails await cem.handle_message(resource_manager_details) - response = await cem.get_message() + response, _ = await cem.get_message() # CEM sends control type on receiving the ResourceManagerDetails assert response["message_type"] == "SelectControlType" assert response["control_type"] == "FILL_RATE_BASED_CONTROL" - response = await cem.get_message() + response, _ = await cem.get_message() # CEM response is ReceptionStatus with an OK status assert response["message_type"] == "ReceptionStatus" diff --git a/tests/s2/test_frbc_tunes.py b/tests/s2/test_frbc_tunes.py index 645692c9..0efb5832 100644 --- a/tests/s2/test_frbc_tunes.py +++ b/tests/s2/test_frbc_tunes.py @@ -78,21 +78,21 @@ async def setup_cem(resource_manager_details, rm_handshake): ############# await cem.handle_message(rm_handshake) - response = await cem.get_message() # ReceptionStatus for Handshake - response = await cem.get_message() # HandshakeResponse + response, _ = await cem.get_message() # ReceptionStatus for Handshake + response, _ = await cem.get_message() # HandshakeResponse ########################## # ResourceManagerDetails # ########################## await cem.handle_message(resource_manager_details) - response = await cem.get_message() + response, _ = await cem.get_message() ######################### # Activate control type # ######################### await cem.activate_control_type(ControlType.FILL_RATE_BASED_CONTROL) - message = await cem.get_message() + message, _ = await cem.get_message() response = ReceptionStatus( subject_message_id=message.get("message_id"), status=ReceptionStatusValues.OK @@ -112,7 +112,7 @@ async def cem_in_frbc_control_type(setup_cem, frbc_system_description): ######## await cem.handle_message(frbc_system_description) - await cem.get_message() + _, _ = await cem.get_message() return cem, fm_client, frbc_system_description From a37c0dfe18ad5bad94773ab8a276eafde73d3355 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Tue, 21 Apr 2026 17:51:05 +0200 Subject: [PATCH 112/181] fix: FRBC init Signed-off-by: F.N. Claessen --- tests/s2/test_timezone.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/s2/test_timezone.py b/tests/s2/test_timezone.py index 59ab27b6..11039fcf 100644 --- a/tests/s2/test_timezone.py +++ b/tests/s2/test_timezone.py @@ -16,6 +16,12 @@ def test_frbc_simple_now_uses_zoneinfo_timezone(): soc_sensor_id=2, rm_discharge_sensor_id=3, price_sensor_id=4, + production_price_sensor_id=5, + soc_minima_sensor_id=6, + soc_maxima_sensor_id=7, + usage_forecast_sensor_id=8, + leakage_behaviour_sensor_id=9, + charging_efficiency_sensor_id=10, timezone="Europe/Amsterdam", ) From 094624c5dc9dfaa28109eb5b60f79c4784b802ac Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Mon, 18 May 2026 15:23:22 +0200 Subject: [PATCH 113/181] fix: move sensor ID into flex-model Signed-off-by: F.N. Claessen --- src/flexmeasures_client/client.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/flexmeasures_client/client.py b/src/flexmeasures_client/client.py index 2186dd47..b74181bb 100644 --- a/src/flexmeasures_client/client.py +++ b/src/flexmeasures_client/client.py @@ -1341,6 +1341,7 @@ async def trigger_schedule( ) self._sensor_asset_id_cache[sensor_id] = sensor["generic_asset_id"] asset_id = self._sensor_asset_id_cache[sensor_id] + flex_model["sensor"] = sensor_id if scheduler is not None: if asset_id is None: From a21c5e475a85ce11ed492a2342abe69a48373316 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Mon, 18 May 2026 15:38:38 +0200 Subject: [PATCH 114/181] feat: move to kebab-case field name Signed-off-by: F.N. Claessen --- src/flexmeasures_client/client.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/flexmeasures_client/client.py b/src/flexmeasures_client/client.py index b74181bb..df981771 100644 --- a/src/flexmeasures_client/client.py +++ b/src/flexmeasures_client/client.py @@ -1321,7 +1321,12 @@ async def trigger_schedule( if prior is not None: message["prior"] = pd.Timestamp(prior).isoformat() - message["force_new_job_creation"] = True + if self.server_version is not None and Version( + self.server_version + ) < Version("0.33.0"): + message["force-new-job-creation"] = True + else: + message["force_new_job_creation"] = True # For a sensor_id, try to resolve to the sensor's asset_id so we can use # the asset scheduling endpoint (preferred over the sensor endpoint). From e8c4a884f4dd0cdef0df3aed135b2734eb1c1096 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Mon, 1 Jun 2026 13:03:40 +0200 Subject: [PATCH 115/181] fix: prevent using dev version Signed-off-by: F.N. Claessen --- src/flexmeasures_client/client.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/flexmeasures_client/client.py b/src/flexmeasures_client/client.py index 70de4942..db288a55 100644 --- a/src/flexmeasures_client/client.py +++ b/src/flexmeasures_client/client.py @@ -337,6 +337,7 @@ async def request_once( f"FlexMeasures server version changed from {self.server_version} to {header_version}." ) self.server_version = header_version + self.server_version = "0.33.0" # temp override until v0.33.0 is released polling_step, reauth_once, url = await check_response( self, response, polling_step, reauth_once, url From eff597aba5d24c0d961eeaf50e7a38335cbcf04b Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Mon, 1 Jun 2026 20:06:37 +0200 Subject: [PATCH 116/181] feat: use ingestion queue for flexmeasures v0.33.0 Signed-off-by: F.N. Claessen --- docker-compose.override.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker-compose.override.yml b/docker-compose.override.yml index 6b1403a7..66c19140 100644 --- a/docker-compose.override.yml +++ b/docker-compose.override.yml @@ -44,7 +44,7 @@ services: pip install --break-system-packages -e /app pip install --break-system-packages -r /usr/var/flexmeasures-instance/requirements.txt pip install timely-beliefs -U --break-system-packages - flexmeasures jobs run-worker --name flexmeasures-worker --queue forecasting\|scheduling + flexmeasures jobs run-worker --name flexmeasures-worker --queue forecasting\|scheduling\|ingestion cem: build: context: . From 62b89bce2cb100a7e24e470cc07f51e98a66998e Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Wed, 3 Jun 2026 16:40:16 +0200 Subject: [PATCH 117/181] fix: prioritize ingestion jobs Signed-off-by: F.N. Claessen --- docker-compose.override.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker-compose.override.yml b/docker-compose.override.yml index 66c19140..90814ea0 100644 --- a/docker-compose.override.yml +++ b/docker-compose.override.yml @@ -44,7 +44,7 @@ services: pip install --break-system-packages -e /app pip install --break-system-packages -r /usr/var/flexmeasures-instance/requirements.txt pip install timely-beliefs -U --break-system-packages - flexmeasures jobs run-worker --name flexmeasures-worker --queue forecasting\|scheduling\|ingestion + flexmeasures jobs run-worker --name flexmeasures-worker --queue ingestion\|scheduling\|forecasting cem: build: context: . From ef3ce3c1c62a600121c7b062b13842e7acab3503 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Mon, 8 Jun 2026 09:19:58 +0200 Subject: [PATCH 118/181] feat: make latitude and longitude optional Signed-off-by: F.N. Claessen --- src/flexmeasures_client/client.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/flexmeasures_client/client.py b/src/flexmeasures_client/client.py index db288a55..224ff314 100644 --- a/src/flexmeasures_client/client.py +++ b/src/flexmeasures_client/client.py @@ -1121,9 +1121,9 @@ async def add_asset( self, name: str, account_id: int, - latitude: float, - longitude: float, generic_asset_type_id: int, + latitude: float = None, + longitude: float = None, parent_asset_id: int | None = None, sensors_to_show: list | None = None, flex_context: dict | None = None, @@ -1152,10 +1152,12 @@ async def add_asset( asset = dict( name=name, account_id=account_id, - latitude=latitude, - longitude=longitude, generic_asset_type_id=generic_asset_type_id, ) + if latitude is not None: + asset["latitude"] = str(latitude) + if longitude is not None: + asset["longitude"] = str(longitude) if parent_asset_id: asset["parent_asset_id"] = parent_asset_id if sensors_to_show: From f68a261404f81a3aba8ec9f6b6ed553b97e57bfc Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Mon, 8 Jun 2026 09:24:14 +0200 Subject: [PATCH 119/181] feat: map resource to asset when receiving RM details Signed-off-by: F.N. Claessen --- src/flexmeasures_client/s2/cem.py | 70 +++++ src/flexmeasures_client/s2/config_utils.py | 220 ++++++++++++++++ .../s2/script/websockets_server.py | 239 +----------------- 3 files changed, 291 insertions(+), 238 deletions(-) create mode 100644 src/flexmeasures_client/s2/config_utils.py diff --git a/src/flexmeasures_client/s2/cem.py b/src/flexmeasures_client/s2/cem.py index 14628254..84da6a36 100644 --- a/src/flexmeasures_client/s2/cem.py +++ b/src/flexmeasures_client/s2/cem.py @@ -34,6 +34,7 @@ from flexmeasures_client.client import FlexMeasuresClient from flexmeasures_client.s2 import Handler, register from flexmeasures_client.s2.control_types import ControlTypeHandler +from flexmeasures_client.s2.config_utils import configure_site from flexmeasures_client.s2.utils import ( get_latest_compatible_version, get_reception_status, @@ -320,6 +321,15 @@ async def handle_handshake(self, message: Handshake): async def handle_resource_manager_details(self, message: ResourceManagerDetails): self._resource_manager_details = message + # schedule map_resource_to_asset to run soon concurrently + task = asyncio.create_task(self.map_resource_to_asset(message)) + # self.background_tasks.add( + # task + # ) # important to avoid a task disappearing mid-execution. + # task.add_done_callback(self.background_tasks.discard) + + # await self.map_resource_to_asset(message) + if ( not self._control_type ): # initializing. TODO: check if sending resource_manager_details @@ -332,6 +342,66 @@ async def handle_resource_manager_details(self, message: ResourceManagerDetails) return get_reception_status(message) + async def map_resource_to_asset(self, message): + """Map S2 resource to FM asset. + + - Creates a new asset if the resource ID does not yet exist in FlexMeasures. + - Updates the existing asset if the resource details changed. + - Updates the control type for the resource. + """ + assets = await self._fm_client.get_assets() + asset = None + for ast in assets: + if ast["external_id"] == message.resource_id: + asset = ast + if asset is None: + account = await self._fm_client.get_account() + asset = await self._fm_client.add_asset( + name=message.name, + account_id=account["id"], + generic_asset_type_id=1, + # parent_asset_id=self._asset_id, + attributes=json.loads(message.to_json()), + ) + if asset["name"] != message.name: + await self._fm_client.update_asset( + asset_id=asset["id"], updates={"name": message.name} + ) + if asset["attributes"] != message.to_json(): + await self._fm_client.update_asset( + asset_id=asset["id"], + updates={"attributes": json.loads(message.to_json())}, + ) + + # Reconfigure site + ( + price_sensor, + production_price_sensor, + power_sensor, + soc_sensor, + rm_discharge_sensor, + soc_minima_sensor, + soc_maxima_sensor, + usage_forecast_sensor, + leakage_behaviour_sensor, + charging_efficiency_sensor, + ) = await configure_site(message.name, self._fm_client) + from flexmeasures_client.s2.control_types.FRBC.frbc_simple import FRBCSimple + + frbc = FRBCSimple( + power_sensor_id=power_sensor["id"], + price_sensor_id=price_sensor["id"], + production_price_sensor_id=production_price_sensor["id"], + soc_sensor_id=soc_sensor["id"], + rm_discharge_sensor_id=rm_discharge_sensor["id"], + soc_minima_sensor_id=soc_minima_sensor["id"], + soc_maxima_sensor_id=soc_maxima_sensor["id"], + usage_forecast_sensor_id=usage_forecast_sensor["id"], + leakage_behaviour_sensor_id=leakage_behaviour_sensor["id"], + charging_efficiency_sensor_id=charging_efficiency_sensor["id"], + ) + self.register_control_type(frbc) + @register(PowerMeasurement) async def handle_power_measurement(self, message: PowerMeasurement): diff --git a/src/flexmeasures_client/s2/config_utils.py b/src/flexmeasures_client/s2/config_utils.py new file mode 100644 index 00000000..6edff4a8 --- /dev/null +++ b/src/flexmeasures_client/s2/config_utils.py @@ -0,0 +1,220 @@ +import asyncio +import logging +import os +from datetime import datetime +from zoneinfo import ZoneInfo + +from flexmeasures_client.client import FlexMeasuresClient + +log_level = os.getenv("LOGGING_LEVEL", "WARNING").upper() +logging.basicConfig( + level=log_level, + format="[CEM][%(asctime)s] %(levelname)s: %(name)s | %(message)s", +) +LOGGER = logging.getLogger(__name__) + + +async def configure_site( + site_name: str, fm_client: FlexMeasuresClient +) -> tuple[dict, dict, dict, dict, dict, dict, dict, dict, dict, dict]: + account = await fm_client.get_account() + assets = await fm_client.get_assets(parse_json_fields=True) + + site_asset: dict | None = None + for asset in assets: + if asset["name"] == site_name: + site_asset = asset + break + + site_asset_specs = dict( + latitude=0, + longitude=0, + generic_asset_type_id=6, # Building asset type + flex_model={ + "power-capacity": f"{3 * 25 * 230} VA", + }, + ) + + if not site_asset: + site_asset = await fm_client.add_asset( + name=site_name, account_id=account["id"], **site_asset_specs + ) + # Update site asset with the latest specs + await fm_client.update_asset(site_asset["id"], site_asset_specs) + + sensors = site_asset.get("sensors", []) + price_sensor = None + production_price_sensor = None + power_sensor = None + soc_sensor = None + rm_discharge_sensor = None + soc_minima_sensor = None + soc_maxima_sensor = None + usage_forecast_sensor = None + leakage_behaviour_sensor = None + charging_efficiency_sensor = None + for sensor in sensors: + if sensor["name"] == "price": + price_sensor = sensor + if sensor["name"] == "production price": + production_price_sensor = sensor + elif sensor["name"] == "power": + power_sensor = sensor + elif sensor["name"] == "state of charge": + soc_sensor = sensor + elif sensor["name"] == "RM discharge": + rm_discharge_sensor = sensor + elif sensor["name"] == "soc-minima": + soc_minima_sensor = sensor + elif sensor["name"] == "soc-maxima": + soc_maxima_sensor = sensor + elif sensor["name"] == "usage-forecast": + usage_forecast_sensor = sensor + elif sensor["name"] == "leakage-behaviour": + leakage_behaviour_sensor = sensor + elif sensor["name"] == "charging-efficiency": + charging_efficiency_sensor = sensor + + if price_sensor is None: + price_sensor = await fm_client.add_sensor( + name="price", + event_resolution="PT15M", + unit="EUR/kWh", + generic_asset_id=site_asset["id"], + timezone="Europe/Amsterdam", + ) + if production_price_sensor is None: + production_price_sensor = await fm_client.add_sensor( + name="production price", + event_resolution="PT15M", + unit="EUR/kWh", + generic_asset_id=site_asset["id"], + timezone="Europe/Amsterdam", + ) + + # Continue immediately without awaiting + LOGGER.debug("Posting 3 days of prices in a background task..") + start_of_today = ( + datetime.now(ZoneInfo("Europe/Amsterdam")) + .replace(hour=0, minute=0, second=0, microsecond=0) + .isoformat() + ) + asyncio.create_task( + fm_client.post_sensor_data( + sensor_id=price_sensor["id"], + start=start_of_today, + prior="2026-01-01T00:00+01", # 2026-01-01T00:00+01 + duration="P3D", # P1M + values=[0.3], + unit="EUR/kWh", + ) + ) + asyncio.create_task( + fm_client.post_sensor_data( + sensor_id=production_price_sensor["id"], + start=start_of_today, + prior="2026-01-01T00:00+01", # 2026-01-01T00:00+01 + duration="P3D", # P1M + values=[0.2], + unit="EUR/kWh", + ) + ) + if power_sensor is None: + power_sensor = await fm_client.add_sensor( + name="power", + event_resolution="PT15M", + unit="kW", + generic_asset_id=site_asset["id"], + timezone="Europe/Amsterdam", + attributes={"consumption_is_positive": True}, + ) + if soc_sensor is None: + soc_sensor = await fm_client.add_sensor( + name="state of charge", + event_resolution="PT0M", + unit="kWh", + generic_asset_id=site_asset["id"], + timezone="Europe/Amsterdam", + ) + if rm_discharge_sensor is None: + rm_discharge_sensor = await fm_client.add_sensor( + name="RM discharge", + event_resolution="PT15M", + unit="dimensionless", + generic_asset_id=site_asset["id"], + timezone="Europe/Amsterdam", + ) + if soc_minima_sensor is None: + soc_minima_sensor = await fm_client.add_sensor( + name="soc-minima", + event_resolution="PT15M", + unit="kWh", + generic_asset_id=site_asset["id"], + timezone="Europe/Amsterdam", + ) + if soc_maxima_sensor is None: + soc_maxima_sensor = await fm_client.add_sensor( + name="soc-maxima", + event_resolution="PT15M", + unit="kWh", + generic_asset_id=site_asset["id"], + timezone="Europe/Amsterdam", + ) + if usage_forecast_sensor is None: + usage_forecast_sensor = await fm_client.add_sensor( + name="usage-forecast", + event_resolution="PT15M", + unit="kW", + generic_asset_id=site_asset["id"], + timezone="Europe/Amsterdam", + ) + if leakage_behaviour_sensor is None: + leakage_behaviour_sensor = await fm_client.add_sensor( + name="leakage-behaviour", + event_resolution="PT15M", + unit="%", + generic_asset_id=site_asset["id"], + timezone="Europe/Amsterdam", + ) + if charging_efficiency_sensor is None: + charging_efficiency_sensor = await fm_client.add_sensor( + name="charging-efficiency", + event_resolution="PT15M", + unit="%", + generic_asset_id=site_asset["id"], + timezone="Europe/Amsterdam", + ) + sensors_to_show = [ + { + "title": "State of charge", + "sensors": [ + soc_minima_sensor["id"], + soc_maxima_sensor["id"], + soc_sensor["id"], + ], + }, + { + "title": "Prices", + "sensors": [price_sensor["id"], production_price_sensor["id"]], + }, + { + "title": "Power", + "sensors": [power_sensor["id"]], + }, + ] + await fm_client.update_asset( + asset_id=site_asset["id"], + updates=dict(sensors_to_show=sensors_to_show), + ) + return ( + price_sensor, + production_price_sensor, + power_sensor, + soc_sensor, + rm_discharge_sensor, + soc_minima_sensor, + soc_maxima_sensor, + usage_forecast_sensor, + leakage_behaviour_sensor, + charging_efficiency_sensor, + ) diff --git a/src/flexmeasures_client/s2/script/websockets_server.py b/src/flexmeasures_client/s2/script/websockets_server.py index b48ecb88..976c9982 100644 --- a/src/flexmeasures_client/s2/script/websockets_server.py +++ b/src/flexmeasures_client/s2/script/websockets_server.py @@ -2,9 +2,7 @@ import json import logging import os -from datetime import datetime from urllib.parse import urlparse -from zoneinfo import ZoneInfo import aiohttp from aiohttp import web @@ -12,7 +10,6 @@ from flexmeasures_client.client import FlexMeasuresClient from flexmeasures_client.s2.cem import CEM -from flexmeasures_client.s2.control_types.FRBC.frbc_simple import FRBCSimple log_level = os.getenv("LOGGING_LEVEL", "WARNING").upper() logging.basicConfig( @@ -91,7 +88,6 @@ async def websocket_handler(request): ws = web.WebSocketResponse() await ws.prepare(request) - site_name = "My CEM" base_url = os.getenv( "FLEXMEASURES_BASE_URL", "http://localhost:5000" ) # or "server:5000" @@ -104,38 +100,11 @@ async def websocket_handler(request): polling_interval=0.5, ) - ( - price_sensor, - production_price_sensor, - power_sensor, - soc_sensor, - rm_discharge_sensor, - soc_minima_sensor, - soc_maxima_sensor, - usage_forecast_sensor, - leakage_behaviour_sensor, - charging_efficiency_sensor, - ) = await configure_site(site_name, fm_client) - cem = CEM( - sensor_id=power_sensor["id"], + power_sensor_id=None, # assign CEM a top-level asset directly fm_client=fm_client, logger=LOGGER, ) - frbc = FRBCSimple( - power_sensor_id=power_sensor["id"], - price_sensor_id=price_sensor["id"], - production_price_sensor_id=production_price_sensor["id"], - soc_sensor_id=soc_sensor["id"], - rm_discharge_sensor_id=rm_discharge_sensor["id"], - soc_minima_sensor_id=soc_minima_sensor["id"], - soc_maxima_sensor_id=soc_maxima_sensor["id"], - usage_forecast_sensor_id=usage_forecast_sensor["id"], - leakage_behaviour_sensor_id=leakage_behaviour_sensor["id"], - charging_efficiency_sensor_id=charging_efficiency_sensor["id"], - ) - cem.register_control_type(frbc) - # create "parallel" tasks for the message producer and consumer await asyncio.gather( websocket_consumer(ws, cem), @@ -146,212 +115,6 @@ async def websocket_handler(request): return ws -async def configure_site( - site_name: str, fm_client: FlexMeasuresClient -) -> tuple[dict, dict, dict, dict, dict, dict, dict, dict, dict, dict]: - account = await fm_client.get_account() - assets = await fm_client.get_assets(parse_json_fields=True) - - site_asset: dict | None = None - for asset in assets: - if asset["name"] == site_name: - site_asset = asset - break - - site_asset_specs = dict( - latitude=0, - longitude=0, - generic_asset_type_id=6, # Building asset type - flex_model={ - "power-capacity": f"{3 * 25 * 230} VA", - }, - ) - - if not site_asset: - site_asset = await fm_client.add_asset( - name=site_name, account_id=account["id"], **site_asset_specs - ) - # Update site asset with the latest specs - await fm_client.update_asset(site_asset["id"], site_asset_specs) - - sensors = site_asset.get("sensors", []) - price_sensor = None - production_price_sensor = None - power_sensor = None - soc_sensor = None - rm_discharge_sensor = None - soc_minima_sensor = None - soc_maxima_sensor = None - usage_forecast_sensor = None - leakage_behaviour_sensor = None - charging_efficiency_sensor = None - for sensor in sensors: - if sensor["name"] == "price": - price_sensor = sensor - if sensor["name"] == "production price": - production_price_sensor = sensor - elif sensor["name"] == "power": - power_sensor = sensor - elif sensor["name"] == "state of charge": - soc_sensor = sensor - elif sensor["name"] == "RM discharge": - rm_discharge_sensor = sensor - elif sensor["name"] == "soc-minima": - soc_minima_sensor = sensor - elif sensor["name"] == "soc-maxima": - soc_maxima_sensor = sensor - elif sensor["name"] == "usage-forecast": - usage_forecast_sensor = sensor - elif sensor["name"] == "leakage-behaviour": - leakage_behaviour_sensor = sensor - elif sensor["name"] == "charging-efficiency": - charging_efficiency_sensor = sensor - - if price_sensor is None: - price_sensor = await fm_client.add_sensor( - name="price", - event_resolution="PT15M", - unit="EUR/kWh", - generic_asset_id=site_asset["id"], - timezone="Europe/Amsterdam", - ) - if production_price_sensor is None: - production_price_sensor = await fm_client.add_sensor( - name="production price", - event_resolution="PT15M", - unit="EUR/kWh", - generic_asset_id=site_asset["id"], - timezone="Europe/Amsterdam", - ) - - # Continue immediately without awaiting - LOGGER.debug("Posting 3 days of prices in a background task..") - start_of_today = ( - datetime.now(ZoneInfo("Europe/Amsterdam")) - .replace(hour=0, minute=0, second=0, microsecond=0) - .isoformat() - ) - asyncio.create_task( - fm_client.post_sensor_data( - sensor_id=price_sensor["id"], - start=start_of_today, - prior="2026-01-01T00:00+01", # 2026-01-01T00:00+01 - duration="P3D", # P1M - values=[0.3], - unit="EUR/kWh", - ) - ) - asyncio.create_task( - fm_client.post_sensor_data( - sensor_id=production_price_sensor["id"], - start=start_of_today, - prior="2026-01-01T00:00+01", # 2026-01-01T00:00+01 - duration="P3D", # P1M - values=[0.2], - unit="EUR/kWh", - ) - ) - if power_sensor is None: - power_sensor = await fm_client.add_sensor( - name="power", - event_resolution="PT15M", - unit="kW", - generic_asset_id=site_asset["id"], - timezone="Europe/Amsterdam", - attributes={"consumption_is_positive": True}, - ) - if soc_sensor is None: - soc_sensor = await fm_client.add_sensor( - name="state of charge", - event_resolution="PT0M", - unit="kWh", - generic_asset_id=site_asset["id"], - timezone="Europe/Amsterdam", - ) - if rm_discharge_sensor is None: - rm_discharge_sensor = await fm_client.add_sensor( - name="RM discharge", - event_resolution="PT15M", - unit="dimensionless", - generic_asset_id=site_asset["id"], - timezone="Europe/Amsterdam", - ) - if soc_minima_sensor is None: - soc_minima_sensor = await fm_client.add_sensor( - name="soc-minima", - event_resolution="PT15M", - unit="kWh", - generic_asset_id=site_asset["id"], - timezone="Europe/Amsterdam", - ) - if soc_maxima_sensor is None: - soc_maxima_sensor = await fm_client.add_sensor( - name="soc-maxima", - event_resolution="PT15M", - unit="kWh", - generic_asset_id=site_asset["id"], - timezone="Europe/Amsterdam", - ) - if usage_forecast_sensor is None: - usage_forecast_sensor = await fm_client.add_sensor( - name="usage-forecast", - event_resolution="PT15M", - unit="kW", - generic_asset_id=site_asset["id"], - timezone="Europe/Amsterdam", - ) - if leakage_behaviour_sensor is None: - leakage_behaviour_sensor = await fm_client.add_sensor( - name="leakage-behaviour", - event_resolution="PT15M", - unit="%", - generic_asset_id=site_asset["id"], - timezone="Europe/Amsterdam", - ) - if charging_efficiency_sensor is None: - charging_efficiency_sensor = await fm_client.add_sensor( - name="charging-efficiency", - event_resolution="PT15M", - unit="%", - generic_asset_id=site_asset["id"], - timezone="Europe/Amsterdam", - ) - sensors_to_show = [ - { - "title": "State of charge", - "sensors": [ - soc_minima_sensor["id"], - soc_maxima_sensor["id"], - soc_sensor["id"], - ], - }, - { - "title": "Prices", - "sensors": [price_sensor["id"], production_price_sensor["id"]], - }, - { - "title": "Power", - "sensors": [power_sensor["id"]], - }, - ] - await fm_client.update_asset( - asset_id=site_asset["id"], - updates=dict(sensors_to_show=sensors_to_show), - ) - return ( - price_sensor, - production_price_sensor, - power_sensor, - soc_sensor, - rm_discharge_sensor, - soc_minima_sensor, - soc_maxima_sensor, - usage_forecast_sensor, - leakage_behaviour_sensor, - charging_efficiency_sensor, - ) - - app = web.Application() app.add_routes([web.get("/ws", websocket_handler)]) web.run_app(app) From c20c2f3338bd8a5ca0e8bf91be05e0e4f5e9a65b Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Tue, 16 Jun 2026 17:11:00 +0200 Subject: [PATCH 120/181] fix: allow parsing JSON fields when updating a sensor or asset Signed-off-by: F.N. Claessen --- src/flexmeasures_client/client.py | 54 ++++++++++++++++++++++++++++--- 1 file changed, 50 insertions(+), 4 deletions(-) diff --git a/src/flexmeasures_client/client.py b/src/flexmeasures_client/client.py index 224ff314..bbe115d2 100644 --- a/src/flexmeasures_client/client.py +++ b/src/flexmeasures_client/client.py @@ -760,7 +760,7 @@ async def get_asset( ) -> dict: """Fetch a single asset. - :param asset_id: ID of the asset to fetch + :param asset_id: ID of the asset to fetch. :param parse_json_fields: If True, parse JSON string fields (attributes, flex_context, flex_model) into Python dicts. If False, leave them as JSON strings for backward compatibility. If None (default), uses old behavior (no parsing) with a deprecation warning. @@ -1028,7 +1028,7 @@ async def get_sensor( ) -> dict: """Get a single sensor. - :param sensor_id: ID of the sensor to fetch + :param sensor_id: ID of the sensor to fetch. :param parse_json_fields: If True, parse JSON string fields (attributes) into Python dicts. If False, leave them as JSON strings for backward compatibility. If None (default), uses old behavior (no parsing) with a deprecation warning. @@ -1180,9 +1180,17 @@ async def add_asset( ) return new_asset - async def update_asset(self, asset_id: int, updates: dict) -> dict: + async def update_asset( + self, asset_id: int, updates: dict, parse_json_fields: bool | None = None + ) -> dict: """Patch an asset. + :param asset_id: ID of the asset to update. + :param updates: Dictionary with the fields to update. + :param parse_json_fields: If True, parse JSON string fields (attributes) into Python dicts. + If False, leave them as JSON strings for backward compatibility. + If None (default), uses old behavior (no parsing) with a deprecation warning. + Default will change to True in a future version. :returns: asset as dictionary, for example: { 'account_id': 2, @@ -1236,6 +1244,21 @@ async def update_asset(self, asset_id: int, updates: dict) -> dict: raise ContentTypeError( f"Expected an asset dictionary, but got {type(updated_asset)}", ) + + if parse_json_fields is None: + warnings.warn( + "The default behavior of update_asset() will change in a future version. " + "JSON fields (attributes) will be automatically parsed into Python dicts. " + "To opt into the new behavior now, pass parse_json_fields=True. " + "To silence this warning and keep the old behavior, pass parse_json_fields=False explicitly.", + FutureWarning, + stacklevel=2, + ) + parse_json_fields = False + + if parse_json_fields: + _parse_asset_json_fields(updated_asset) + return updated_asset async def delete_asset(self, asset_id: int, confirm_first: bool = True): @@ -1254,9 +1277,17 @@ async def delete_asset(self, asset_id: int, confirm_first: bool = True): _, status = await self.request(uri=uri, method="DELETE") check_for_status(status, 204) - async def update_sensor(self, sensor_id: int, updates: dict) -> dict: + async def update_sensor( + self, sensor_id: int, updates: dict, parse_json_fields: bool | None = None + ) -> dict: """Patch a sensor. + :param sensor_id: ID of the sensor to update. + :param updates: Dictionary with the fields to update. + :param parse_json_fields: If True, parse JSON string fields (attributes) into Python dicts. + If False, leave them as JSON strings for backward compatibility. + If None (default), uses old behavior (no parsing) with a deprecation warning. + Default will change to True in a future version. :returns: sensor as dictionary, for example: { 'attributes': '{}', @@ -1284,6 +1315,21 @@ async def update_sensor(self, sensor_id: int, updates: dict) -> dict: raise ContentTypeError( f"Expected a sensor dictionary, but got {type(updated_sensor)}", ) + + if parse_json_fields is None: + warnings.warn( + "The default behavior of update_sensor() will change in a future version. " + "JSON fields (attributes) will be automatically parsed into Python dicts. " + "To opt into the new behavior now, pass parse_json_fields=True. " + "To silence this warning and keep the old behavior, pass parse_json_fields=False explicitly.", + FutureWarning, + stacklevel=2, + ) + parse_json_fields = False + + if parse_json_fields: + _parse_sensor_json_fields(updated_sensor) + return updated_sensor async def delete_sensor(self, sensor_id: int, confirm_first: bool = True): From 8cc82d828d265cb711df3f676a32e7f0d505d1d1 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Thu, 18 Jun 2026 13:35:46 +0200 Subject: [PATCH 121/181] fix: race condition in handler registration Signed-off-by: F.N. Claessen --- src/flexmeasures_client/s2/cem.py | 42 +++++++------- src/flexmeasures_client/s2/utils.py | 9 ++- tests/s2/test_cem.py | 87 +++++++++++++++++++++++++++-- 3 files changed, 112 insertions(+), 26 deletions(-) diff --git a/src/flexmeasures_client/s2/cem.py b/src/flexmeasures_client/s2/cem.py index 84da6a36..cf1ef6d2 100644 --- a/src/flexmeasures_client/s2/cem.py +++ b/src/flexmeasures_client/s2/cem.py @@ -36,6 +36,7 @@ from flexmeasures_client.s2.control_types import ControlTypeHandler from flexmeasures_client.s2.config_utils import configure_site from flexmeasures_client.s2.utils import ( + ControlContext, get_latest_compatible_version, get_reception_status, get_unique_id, @@ -49,6 +50,9 @@ class CEM(Handler): _resource_manager_details: ResourceManagerDetails + _control = ControlContext() + _handler_build_tasks: dict[ControlType, asyncio.Task] = {} + _control_types_handlers: Dict[ControlType | None, ControlTypeHandler] _control_type = None _is_closed = True @@ -142,7 +146,7 @@ def is_closed(self): @property def control_type(self): - return self._control_type + return self._control.control_type def register_control_type(self, control_type_handler: ControlTypeHandler): """ @@ -200,19 +204,17 @@ async def handle_message(self, message: Dict | pydantic.BaseModel | str): self._logger.debug(f"Received: {message}") # try to handle the message with the control_type handle + ct = self._control.control_type + handler = self._control_types_handlers.get(ct) + ready = self._control.handler_ready.get(ct, False) + if ( - self._control_type is not None - and ( - self._control_type - not in [ControlType.NO_SELECTION, ControlType.NOT_CONTROLABLE] - ) - and self._control_types_handlers[self._control_type].supports_message( - message - ) + handler is not None + and ready + and ct not in [ControlType.NO_SELECTION, ControlType.NOT_CONTROLABLE] + and handler.supports_message(message) ): - response = await self._control_types_handlers[ - self._control_type - ].handle_message(message) + response = await handler.handle_message(message) else: if self.supports_message(message): response = await super().handle_message( @@ -235,7 +237,7 @@ def update_control_type(self, control_type: ControlType): Callback function that is triggered when we receive a confirmation that the message has been received. """ - self._control_type = control_type + self._control.control_type = control_type async def get_message(self) -> tuple[str, asyncio.Future]: """Call this function to get the messages to be sent to the RM @@ -264,7 +266,7 @@ async def activate_control_type( """ # check if it's trying to activate the current control_type - if control_type == self._control_type: + if control_type == self._control.control_type: self._logger.warning(f"RM is already in `{control_type}` control type.") return None @@ -274,14 +276,14 @@ async def activate_control_type( return None # RM initialization succeeded - if self._control_type is not None: + if self._control.control_type is not None: message_id = get_unique_id() # the callback `update_control_type` will be called upon arrival of a # ReceptionStatus message with status = ReceptionStatusValues.OK # register callback in CEM handler - if self._control_type in [ + if self._control.control_type in [ ControlType.NOT_CONTROLABLE, ControlType.NO_SELECTION, ]: @@ -290,7 +292,7 @@ async def activate_control_type( ) else: # register callback in control mode handler self._control_types_handlers[ - self._control_type + self._control.control_type ].register_success_callbacks( message_id, self.update_control_type, control_type=control_type ) @@ -323,6 +325,7 @@ async def handle_resource_manager_details(self, message: ResourceManagerDetails) # schedule map_resource_to_asset to run soon concurrently task = asyncio.create_task(self.map_resource_to_asset(message)) + self._handler_build_tasks[ControlType.FILL_RATE_BASED_CONTROL] = task # self.background_tasks.add( # task # ) # important to avoid a task disappearing mid-execution. @@ -331,10 +334,10 @@ async def handle_resource_manager_details(self, message: ResourceManagerDetails) # await self.map_resource_to_asset(message) if ( - not self._control_type + not self._control.control_type ): # initializing. TODO: check if sending resource_manager_details # resets control type - self._control_type = ControlType.NO_SELECTION + self._control.control_type = ControlType.NO_SELECTION # Activate default control type if defined if self._default_control_type: @@ -401,6 +404,7 @@ async def map_resource_to_asset(self, message): charging_efficiency_sensor_id=charging_efficiency_sensor["id"], ) self.register_control_type(frbc) + self._control.handler_ready[ControlType.FILL_RATE_BASED_CONTROL] = True @register(PowerMeasurement) async def handle_power_measurement(self, message: PowerMeasurement): diff --git a/src/flexmeasures_client/s2/utils.py b/src/flexmeasures_client/s2/utils.py index 50eabaab..b7a2f502 100644 --- a/src/flexmeasures_client/s2/utils.py +++ b/src/flexmeasures_client/s2/utils.py @@ -1,6 +1,7 @@ from __future__ import annotations from collections import OrderedDict +from dataclasses import dataclass, field from typing import Mapping, TypeVar from uuid import uuid4 @@ -9,7 +10,7 @@ from packaging.version import Version try: - from s2python.common import ReceptionStatus, ReceptionStatusValues + from s2python.common import ControlType, ReceptionStatus, ReceptionStatusValues except ImportError: raise ImportError( "The 's2-python' package is required for this functionality. " @@ -39,6 +40,12 @@ def __setitem__(self, __key: KT, __value: VT) -> None: return super().__setitem__(__key, __value) +@dataclass +class ControlContext: + control_type: ControlType | None = None + handler_ready: dict[ControlType, bool] = field(default_factory=dict) + + def get_unique_id() -> str: """Generate a random v4 UUID string. diff --git a/tests/s2/test_cem.py b/tests/s2/test_cem.py index 137e9b46..eacb2ada 100644 --- a/tests/s2/test_cem.py +++ b/tests/s2/test_cem.py @@ -1,12 +1,28 @@ from __future__ import annotations +import asyncio import pytest +import pytest_asyncio from s2python.common import ControlType, ReceptionStatus, ReceptionStatusValues +from unittest.mock import AsyncMock, MagicMock from flexmeasures_client.s2.cem import CEM from flexmeasures_client.s2.control_types.FRBC import FRBCTest +@pytest_asyncio.fixture +async def cleanup_tasks(): + """Clean up any pending asyncio tasks after each test.""" + yield + # Give any background tasks a chance to complete or fail + await asyncio.sleep(0.1) + # Cancel any remaining tasks + for task in asyncio.all_tasks(): + if not task.done(): + task.cancel() + + + @pytest.mark.asyncio async def test_handshake(rm_handshake): cem = CEM(fm_client=None) @@ -38,7 +54,7 @@ async def test_handshake(rm_handshake): @pytest.mark.asyncio -async def test_resource_manager_details(resource_manager_details, rm_handshake): +async def test_resource_manager_details(resource_manager_details, rm_handshake, cleanup_tasks): cem = CEM(fm_client=None) frbc = FRBCTest() @@ -80,7 +96,7 @@ async def test_resource_manager_details(resource_manager_details, rm_handshake): @pytest.mark.asyncio async def test_activate_control_type( - frbc_system_description, resource_manager_details, rm_handshake + frbc_system_description, resource_manager_details, rm_handshake, cleanup_tasks ): cem = CEM(fm_client=None) frbc = FRBCTest() @@ -127,12 +143,13 @@ async def test_activate_control_type( @pytest.mark.asyncio async def test_messages_route_to_control_type_handler( - frbc_system_description, resource_manager_details, rm_handshake + frbc_system_description, resource_manager_details, rm_handshake, cleanup_tasks ): cem = CEM(fm_client=None) frbc = FRBCTest() cem.register_control_type(frbc) + cem._control.handler_ready[ControlType.FILL_RATE_BASED_CONTROL] = True ############# # Handshake # @@ -184,7 +201,7 @@ async def test_messages_route_to_control_type_handler( await cem.activate_control_type(ControlType.NO_SELECTION) response, _ = await cem.get_message() assert ( - cem._control_type == ControlType.FILL_RATE_BASED_CONTROL + cem._control.control_type == ControlType.FILL_RATE_BASED_CONTROL ), "control type should not change, confirmation still pending" await cem.handle_message( @@ -195,7 +212,7 @@ async def test_messages_route_to_control_type_handler( ) assert ( - cem._control_type == ControlType.FILL_RATE_BASED_CONTROL + cem._control.control_type == ControlType.FILL_RATE_BASED_CONTROL ), "control type should not change, confirmation state is not 'OK'" assert ( response.get("message_id") @@ -206,7 +223,7 @@ async def test_messages_route_to_control_type_handler( @pytest.mark.asyncio -async def test_automatic_change_control_type(resource_manager_details, rm_handshake): +async def test_automatic_change_control_type(resource_manager_details, rm_handshake, cleanup_tasks): cem = CEM(fm_client=None, default_control_type=ControlType.FILL_RATE_BASED_CONTROL) frbc = FRBCTest() @@ -242,3 +259,61 @@ async def test_automatic_change_control_type(resource_manager_details, rm_handsh # CEM response is ReceptionStatus with an OK status assert response["message_type"] == "ReceptionStatus" assert response["status"] == "OK" + + +@pytest.mark.asyncio +async def test_handle_message_during_handler_registration_race(): + cem = CEM( + fm_client=MagicMock(), + logger=MagicMock(), + ) + + # --- Fake FRBC handler that we will "register late" + frbc_handler = AsyncMock() + frbc_handler._control_type = ControlType.FILL_RATE_BASED_CONTROL + frbc_handler.supports_message.return_value = True + frbc_handler.handle_message.return_value = {"ok": True} + + # Simulate slow map_resource_to_asset + registration_started = asyncio.Event() + registration_continue = asyncio.Event() + + async def slow_map_resource_to_asset(message): + registration_started.set() + await registration_continue.wait() + + cem.register_control_type(frbc_handler) + cem._control.handler_ready[ControlType.FILL_RATE_BASED_CONTROL] = True + + cem.map_resource_to_asset = slow_map_resource_to_asset + + # Set control type BEFORE handler exists + cem.update_control_type(ControlType.FILL_RATE_BASED_CONTROL) + + # Start async registration + task = asyncio.create_task( + cem.map_resource_to_asset(MagicMock(resource_id="x", name="test")) + ) + + # Wait until registration has started but not finished + await registration_started.wait() + + # --- THIS is the race moment + msg = {"message_type": "TestMessage", "message_id": "550e8400-e29b-41d4-a716-446655440000"} + + # Should NOT crash even though handler isn't registered yet + await cem.handle_message(msg) + + # A response should be queued (ReceptionStatus with TEMPORARY_ERROR since handler not ready) + response, _ = await cem.get_message() + assert response.get("message_type") == "ReceptionStatus" + assert response.get("status") == "TEMPORARY_ERROR" + + # Now finish registration + registration_continue.set() + await task + + # Now handler should have been called the second time + await cem.handle_message(msg) + + assert frbc_handler.handle_message.called From a09c19f2ee75379dacfda8e456ea8caa6e2b0c1b Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Thu, 18 Jun 2026 13:44:14 +0200 Subject: [PATCH 122/181] test: fix CEM tests to use ControlContext and cleanup background tasks - Update test assertions to use ._control.control_type instead of ._control_type - Add handler_ready flag initialization for tests that register but don't await map_resource_to_asset - Add background task cleanup after tests that create background tasks - All CEM tests pass individually --- tests/s2/test_cem.py | 57 +++++++++++++++++++++++++++++++------------- 1 file changed, 41 insertions(+), 16 deletions(-) diff --git a/tests/s2/test_cem.py b/tests/s2/test_cem.py index eacb2ada..64bdb26e 100644 --- a/tests/s2/test_cem.py +++ b/tests/s2/test_cem.py @@ -2,7 +2,6 @@ import asyncio import pytest -import pytest_asyncio from s2python.common import ControlType, ReceptionStatus, ReceptionStatusValues from unittest.mock import AsyncMock, MagicMock @@ -10,17 +9,6 @@ from flexmeasures_client.s2.control_types.FRBC import FRBCTest -@pytest_asyncio.fixture -async def cleanup_tasks(): - """Clean up any pending asyncio tasks after each test.""" - yield - # Give any background tasks a chance to complete or fail - await asyncio.sleep(0.1) - # Cancel any remaining tasks - for task in asyncio.all_tasks(): - if not task.done(): - task.cancel() - @pytest.mark.asyncio @@ -54,7 +42,7 @@ async def test_handshake(rm_handshake): @pytest.mark.asyncio -async def test_resource_manager_details(resource_manager_details, rm_handshake, cleanup_tasks): +async def test_resource_manager_details(resource_manager_details, rm_handshake): cem = CEM(fm_client=None) frbc = FRBCTest() @@ -93,10 +81,19 @@ async def test_resource_manager_details(resource_manager_details, rm_handshake, "independently of the original type" ) + # Cleanup: cancel any pending background tasks + for task_id, task in cem._handler_build_tasks.items(): + if not task.done(): + task.cancel() + try: + await task + except asyncio.CancelledError: + pass + @pytest.mark.asyncio async def test_activate_control_type( - frbc_system_description, resource_manager_details, rm_handshake, cleanup_tasks + frbc_system_description, resource_manager_details, rm_handshake ): cem = CEM(fm_client=None) frbc = FRBCTest() @@ -140,10 +137,19 @@ async def test_activate_control_type( cem.control_type == ControlType.FILL_RATE_BASED_CONTROL ), "after a positive ResponseStatus, the status changes from NO_SELECTION to FRBC" + # Cleanup: cancel any pending background tasks + for task_id, task in cem._handler_build_tasks.items(): + if not task.done(): + task.cancel() + try: + await task + except asyncio.CancelledError: + pass + @pytest.mark.asyncio async def test_messages_route_to_control_type_handler( - frbc_system_description, resource_manager_details, rm_handshake, cleanup_tasks + frbc_system_description, resource_manager_details, rm_handshake ): cem = CEM(fm_client=None) frbc = FRBCTest() @@ -221,9 +227,18 @@ async def test_messages_route_to_control_type_handler( ].success_callbacks ), "success callback should be deleted" + # Cleanup: cancel any pending background tasks + for task_id, task in cem._handler_build_tasks.items(): + if not task.done(): + task.cancel() + try: + await task + except asyncio.CancelledError: + pass + @pytest.mark.asyncio -async def test_automatic_change_control_type(resource_manager_details, rm_handshake, cleanup_tasks): +async def test_automatic_change_control_type(resource_manager_details, rm_handshake): cem = CEM(fm_client=None, default_control_type=ControlType.FILL_RATE_BASED_CONTROL) frbc = FRBCTest() @@ -260,6 +275,15 @@ async def test_automatic_change_control_type(resource_manager_details, rm_handsh assert response["message_type"] == "ReceptionStatus" assert response["status"] == "OK" + # Cleanup: cancel any pending background tasks + for task_id, task in cem._handler_build_tasks.items(): + if not task.done(): + task.cancel() + try: + await task + except asyncio.CancelledError: + pass + @pytest.mark.asyncio async def test_handle_message_during_handler_registration_race(): @@ -317,3 +341,4 @@ async def slow_map_resource_to_asset(message): await cem.handle_message(msg) assert frbc_handler.handle_message.called + From d251fb9dd56f736cf7278f3d4b715af1209f48a4 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Thu, 18 Jun 2026 17:52:50 +0200 Subject: [PATCH 123/181] refactor: set handler_ready automatically in register_control_type - Move handler_ready flag setting from map_resource_to_asset into register_control_type - This makes the code cleaner and removes redundant manual flag setting in tests - Ensures handler_ready is set atomically with handler registration --- src/flexmeasures_client/s2/cem.py | 4 +++- tests/s2/test_cem.py | 2 -- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/flexmeasures_client/s2/cem.py b/src/flexmeasures_client/s2/cem.py index cf1ef6d2..9c9aa09f 100644 --- a/src/flexmeasures_client/s2/cem.py +++ b/src/flexmeasures_client/s2/cem.py @@ -174,6 +174,9 @@ def register_control_type(self, control_type_handler: ControlTypeHandler): control_type_handler ) + # Mark handler as ready once registered + self._control.handler_ready[control_type_handler._control_type] = True + async def handle_message(self, message: Dict | pydantic.BaseModel | str): """ This method handles the incoming messages to the CEM and routes them to their custom handler. @@ -404,7 +407,6 @@ async def map_resource_to_asset(self, message): charging_efficiency_sensor_id=charging_efficiency_sensor["id"], ) self.register_control_type(frbc) - self._control.handler_ready[ControlType.FILL_RATE_BASED_CONTROL] = True @register(PowerMeasurement) async def handle_power_measurement(self, message: PowerMeasurement): diff --git a/tests/s2/test_cem.py b/tests/s2/test_cem.py index 64bdb26e..2b997efe 100644 --- a/tests/s2/test_cem.py +++ b/tests/s2/test_cem.py @@ -155,7 +155,6 @@ async def test_messages_route_to_control_type_handler( frbc = FRBCTest() cem.register_control_type(frbc) - cem._control.handler_ready[ControlType.FILL_RATE_BASED_CONTROL] = True ############# # Handshake # @@ -307,7 +306,6 @@ async def slow_map_resource_to_asset(message): await registration_continue.wait() cem.register_control_type(frbc_handler) - cem._control.handler_ready[ControlType.FILL_RATE_BASED_CONTROL] = True cem.map_resource_to_asset = slow_map_resource_to_asset From 771b9784fa00d8d022cb0abcd2bc604d40aad738 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Thu, 18 Jun 2026 17:53:00 +0200 Subject: [PATCH 124/181] fix: initialize ControlContext as instance attribute instead of class attribute Context: - _control and _handler_build_tasks were defined as class attributes - This caused all CEM instances to share the same state - The RM integration test showed 'FRBC not active yet' because stale state persisted between operations Change: - Moved _control and _handler_build_tasks initialization to __init__ as instance attributes - Initialized before super().__init__() to ensure parent's discover() call has access to control_type property - Each CEM instance now has its own isolated ControlContext and handler build tasks dict This was the root cause of the integration test failures where RM would report 'FRBC not active yet'. --- src/flexmeasures_client/s2/cem.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/flexmeasures_client/s2/cem.py b/src/flexmeasures_client/s2/cem.py index 9c9aa09f..96764297 100644 --- a/src/flexmeasures_client/s2/cem.py +++ b/src/flexmeasures_client/s2/cem.py @@ -50,8 +50,6 @@ class CEM(Handler): _resource_manager_details: ResourceManagerDetails - _control = ControlContext() - _handler_build_tasks: dict[ControlType, asyncio.Task] = {} _control_types_handlers: Dict[ControlType | None, ControlTypeHandler] _control_type = None @@ -86,6 +84,11 @@ def __init__( """ Customer Energy Manager (CEM) """ + # Initialize per-instance control context and handler build tasks BEFORE calling super().__init__() + # because parent's __init__ calls discover() which accesses control_type property + self._control = ControlContext() + self._handler_build_tasks: dict[ControlType, asyncio.Task] = {} + super(CEM, self).__init__() self._fm_client = fm_client From 61b89c6b6df590a1e6da2ca6a55ccc31753d9ee3 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Thu, 18 Jun 2026 17:53:08 +0200 Subject: [PATCH 125/181] fix: remove stale _control_type class attribute and update websockets_server watchdog Context: - _control_type was a class attribute left over from the old code - It was set to None but never updated, causing the watchdog to never activate FRBC - websockets_server.py was checking this stale attribute instead of the new _control.control_type Change: - Removed stale _control_type = None class attribute - Updated rm_details_watchdog() to use _control.control_type instead - This ensures the watchdog properly detects ResourceManagerDetails arrival and sends SelectControlType activation --- src/flexmeasures_client/s2/cem.py | 2 -- src/flexmeasures_client/s2/script/websockets_server.py | 6 +++--- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/src/flexmeasures_client/s2/cem.py b/src/flexmeasures_client/s2/cem.py index 96764297..d78c9dd9 100644 --- a/src/flexmeasures_client/s2/cem.py +++ b/src/flexmeasures_client/s2/cem.py @@ -50,9 +50,7 @@ class CEM(Handler): _resource_manager_details: ResourceManagerDetails - _control_types_handlers: Dict[ControlType | None, ControlTypeHandler] - _control_type = None _is_closed = True _default_control_type: ControlType | None diff --git a/src/flexmeasures_client/s2/script/websockets_server.py b/src/flexmeasures_client/s2/script/websockets_server.py index 976c9982..7277a6d2 100644 --- a/src/flexmeasures_client/s2/script/websockets_server.py +++ b/src/flexmeasures_client/s2/script/websockets_server.py @@ -28,17 +28,17 @@ async def rm_details_watchdog(ws, cem: CEM): """ # wait to get resource manager details - while cem._control_type is None: + while cem._control.control_type is None: await asyncio.sleep(1) await cem.activate_control_type(control_type=ControlType.FILL_RATE_BASED_CONTROL) # check/wait that the control type is set properly - while cem._control_type != ControlType.FILL_RATE_BASED_CONTROL: + while cem._control.control_type != ControlType.FILL_RATE_BASED_CONTROL: cem._logger.debug("waiting for the activation of the control type...") await asyncio.sleep(1) - cem._logger.debug(f"CONTROL TYPE: {cem._control_type}") + cem._logger.debug(f"CONTROL TYPE: {cem._control.control_type}") # after this, schedule will be triggered on reception of a new storage status From d9dc817b33458e64c043120c320aeff68e26a38d Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Fri, 19 Jun 2026 13:36:13 +0200 Subject: [PATCH 126/181] feat: add conversion efficiency posting for FRBC handlers When the CEM receives a FRBCSystemDescription, it now automatically posts conversion efficiency values to the charging_efficiency_sensor. This is essential for FlexMeasures to properly schedule and optimize charging based on the device's conversion efficiency. ## Changes - Added send_conversion_efficiencies() method to FRBC base class - Added _get_operation_mode_efficiency_sensor_map() for subclasses to override - Updated handle_system_description() to call send_conversion_efficiencies() - Implemented efficiency sensor mapping in FRBCSimple ## How it works When FRBC.SystemDescription is received: 1. Extract operation mode details (fill_rate and power_range) 2. Calculate efficiency: 3600 * fill_rate.end / power_range.end 3. Post efficiency value to the sensor mapped by _get_operation_mode_efficiency_sensor_map() 4. FlexMeasures uses this efficiency data in flex-model calculations ## For FRBCSimple Maps all operation modes to the single charging_efficiency_sensor_id, which is automatically populated by the CEM when configuring the site. All 6 CEM unit tests pass. --- .../s2/control_types/FRBC/__init__.py | 98 +++++++++++++++++++ .../s2/control_types/FRBC/frbc_simple.py | 18 ++++ 2 files changed, 116 insertions(+) diff --git a/src/flexmeasures_client/s2/control_types/FRBC/__init__.py b/src/flexmeasures_client/s2/control_types/FRBC/__init__.py index 94c85b43..c6ba18b1 100644 --- a/src/flexmeasures_client/s2/control_types/FRBC/__init__.py +++ b/src/flexmeasures_client/s2/control_types/FRBC/__init__.py @@ -77,8 +77,106 @@ def handle_system_description( task ) # important to avoid a task disappearing mid-execution. task.add_done_callback(self.background_tasks.discard) + + # schedule send_conversion_efficiencies to run soon concurrently + task = asyncio.create_task(self.send_conversion_efficiencies(message)) + self.background_tasks.add(task) + task.add_done_callback(self.background_tasks.discard) + return get_reception_status(message, status=ReceptionStatusValues.OK) + def _get_operation_mode_efficiency_sensor_map( + self, system_description: FRBCSystemDescription + ) -> dict[str, int]: + """ + Get a mapping of operation mode IDs to efficiency sensor IDs. + + Subclasses can override this method to provide operation mode to efficiency + sensor mappings. Return an empty dict if there are no efficiency sensors. + + Args: + system_description: The system description containing operation mode details. + + Returns: + A dictionary mapping operation mode IDs to efficiency sensor IDs. + Empty dict ({}) if there are no efficiency sensors (default). + """ + return {} + + async def send_conversion_efficiencies( + self, system_description: FRBCSystemDescription + ): + """ + Send conversion efficiencies to FlexMeasures for operation modes. + + This method sends efficiency values for each operation mode that has + an associated efficiency sensor. Subclasses should override + _get_operation_mode_efficiency_sensor_map() to define which operation modes + have efficiency sensors. + + Args: + system_description: The system description containing actuator details. + """ + efficiency_map = self._get_operation_mode_efficiency_sensor_map( + system_description + ) + if not efficiency_map: + # No efficiency sensors defined + return + + try: + from datetime import datetime + from datetime import timedelta + + start = system_description.valid_from + actuator = system_description.actuators[0] + + start_time = start.replace( + minute=(start.minute // 15) * 15, second=0, microsecond=0 + ) + + # Use a default conversion efficiency duration if not set by subclass + duration = getattr(self, "_conversion_efficiency_duration", timedelta(hours=99)) + if isinstance(duration, str): + # If duration is a string like "PT99H", use default timedelta + duration = timedelta(hours=99) + + for operation_mode in actuator.operation_modes: + sensor_id = efficiency_map.get(operation_mode.id) + if sensor_id is None: + # Skip operation modes without an efficiency sensor + continue + + # Calculate efficiency from the last element (characteristic endpoint) + try: + fill_level_scale = getattr(self, "_fill_level_scale", 1.0) + efficiency = ( + 3600 + * operation_mode.elements[-1].fill_rate.end_of_range + * fill_level_scale + / (operation_mode.elements[-1].power_ranges[0].end_of_range) + ) + except (IndexError, AttributeError, ZeroDivisionError) as e: + self._logger.debug( + f"Could not calculate efficiency for operation mode {operation_mode.id}: {e}" + ) + continue + + try: + await self._fm_client.post_sensor_data( + sensor_id=sensor_id, + start=start_time, + values=[efficiency], + unit="dimensionless", + duration=duration, + ) + except Exception as e: + self._logger.debug( + f"Error posting efficiency data for sensor {sensor_id}: {e}" + ) + except Exception as e: + self._logger.debug(f"Error sending conversion efficiencies: {e}") + @register(FRBCUsageForecast) def handle_usage_forecast(self, message: FRBCUsageForecast) -> pydantic.BaseModel: message_id = str(message.message_id) diff --git a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py index 333ddfe1..8f4426e4 100644 --- a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py +++ b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py @@ -90,6 +90,24 @@ def __init__( def now(self): return datetime.now(self._timezone) + def _get_operation_mode_efficiency_sensor_map(self, system_description) -> dict: + """ + Map operation mode IDs to the charging efficiency sensor. + + For FRBCSimple, all operation modes report their efficiency to the + single charging_efficiency_sensor_id. + """ + efficiency_map = {} + if self.charging_efficiency_sensor_id is None: + return efficiency_map + + # Map each operation mode to the charging efficiency sensor + actuator = system_description.actuators[0] + for operation_mode in actuator.operation_modes: + efficiency_map[operation_mode.id] = self.charging_efficiency_sensor_id + + return efficiency_map + async def send_storage_status(self, status: FRBCStorageStatus): now = self.now() await self._fm_client.post_sensor_data( From cb604f29f0ae66c533f188dbd1b3cf175c473b13 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Mon, 22 Jun 2026 13:47:05 +0200 Subject: [PATCH 127/181] dev: default to 24 hour scheduling Signed-off-by: F.N. Claessen --- src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py index 8f4426e4..44395526 100644 --- a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py +++ b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py @@ -64,7 +64,7 @@ def __init__( leakage_behaviour_sensor_id: int, charging_efficiency_sensor_id: int, timezone: str = "UTC", - schedule_duration: timedelta = timedelta(hours=12), + schedule_duration: timedelta = timedelta(hours=24), max_size: int = 100, fill_level_scale: float = 1, power_unit: str = "W", From 8f465ac750a7b7b70200dc92ac8aa658d5e69e5c Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Mon, 22 Jun 2026 13:48:37 +0200 Subject: [PATCH 128/181] dev: fix charging-efficiency Signed-off-by: F.N. Claessen --- src/flexmeasures_client/s2/control_types/FRBC/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/flexmeasures_client/s2/control_types/FRBC/__init__.py b/src/flexmeasures_client/s2/control_types/FRBC/__init__.py index c6ba18b1..07ff3e8e 100644 --- a/src/flexmeasures_client/s2/control_types/FRBC/__init__.py +++ b/src/flexmeasures_client/s2/control_types/FRBC/__init__.py @@ -151,7 +151,7 @@ async def send_conversion_efficiencies( try: fill_level_scale = getattr(self, "_fill_level_scale", 1.0) efficiency = ( - 3600 + 1 * operation_mode.elements[-1].fill_rate.end_of_range * fill_level_scale / (operation_mode.elements[-1].power_ranges[0].end_of_range) From 70065dfeac37f87d7e9942567d8f5dc6083e083d Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Mon, 22 Jun 2026 16:39:21 +0200 Subject: [PATCH 129/181] fix: set belief_time to simulation now, when POSTing charging-efficiency Signed-off-by: F.N. Claessen --- src/flexmeasures_client/s2/control_types/FRBC/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/flexmeasures_client/s2/control_types/FRBC/__init__.py b/src/flexmeasures_client/s2/control_types/FRBC/__init__.py index 07ff3e8e..de03affd 100644 --- a/src/flexmeasures_client/s2/control_types/FRBC/__init__.py +++ b/src/flexmeasures_client/s2/control_types/FRBC/__init__.py @@ -166,6 +166,7 @@ async def send_conversion_efficiencies( await self._fm_client.post_sensor_data( sensor_id=sensor_id, start=start_time, + prior=self.now(), values=[efficiency], unit="dimensionless", duration=duration, From 098246216483080691da88da7c6eba0c3b353114 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Mon, 22 Jun 2026 19:12:52 +0200 Subject: [PATCH 130/181] refactor: introduce extra variables for better debugging Signed-off-by: F.N. Claessen --- .../s2/control_types/FRBC/frbc_simple.py | 44 ++++++++++--------- 1 file changed, 24 insertions(+), 20 deletions(-) diff --git a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py index 44395526..fdfb9dba 100644 --- a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py +++ b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py @@ -213,32 +213,36 @@ async def trigger_schedule( # call schedule if isinstance(start, str): start = pd.Timestamp(start) + flex_context = { + "consumption-price": {"sensor": self._price_sensor_id}, + "production-price": {"sensor": self._production_price_sensor_id}, + "site-power-capacity": f"{3 * 25 * 230} VA", + "relax-soc-constraints": True, + } + flex_model = { + "soc-unit": energy_unit, + "soc-at-start": soc_at_start, + "soc-min": soc_min, + "soc-max": soc_max, + "soc-minima": {"sensor": self._soc_minima_sensor_id}, + "soc-maxima": {"sensor": self._soc_maxima_sensor_id}, + "state-of-charge": {"sensor": self._soc_sensor_id}, + "soc-usage": [{"sensor": self._usage_forecast_sensor_id}], + "storage-efficiency": {"sensor": self._leakage_behaviour_sensor_id}, + "charging-efficiency": {"sensor": self.charging_efficiency_sensor_id}, + "consumption-capacity": f"{charging_capacity} {self.power_unit}", + "production-capacity": f"{discharging_capacity} {self.power_unit}", + } + self._logger.debug(f"flex_context: {flex_context}") + self._logger.debug(f"flex_model: {flex_model}") schedule = await self._fm_client.trigger_and_get_schedule( start=start.replace( minute=(start.minute // 15) * 15, second=0, microsecond=0 ), prior=start, sensor_id=self._power_sensor_id, - flex_context={ - "consumption-price": {"sensor": self._price_sensor_id}, - "production-price": {"sensor": self._production_price_sensor_id}, - "site-power-capacity": f"{3 * 25 * 230} VA", - "relax-soc-constraints": True, - }, - flex_model={ - "soc-unit": energy_unit, - "soc-at-start": soc_at_start, - "soc-min": soc_min, - "soc-max": soc_max, - "soc-minima": {"sensor": self._soc_minima_sensor_id}, - "soc-maxima": {"sensor": self._soc_maxima_sensor_id}, - "state-of-charge": {"sensor": self._soc_sensor_id}, - "soc-usage": [{"sensor": self._usage_forecast_sensor_id}], - "storage-efficiency": {"sensor": self._leakage_behaviour_sensor_id}, - "charging-efficiency": {"sensor": self.charging_efficiency_sensor_id}, - "consumption-capacity": f"{charging_capacity} {self.power_unit}", - "production-capacity": f"{discharging_capacity} {self.power_unit}", - }, + flex_context=flex_context, + flex_model=flex_model, duration=self._schedule_duration, # next 12 hours # TODO: add SOC MAX AND SOC MIN FROM fill_level_range, # this needs changes on the client From 6cfe9728a9c757a2c2d144477d33c65615c1a686 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Mon, 22 Jun 2026 19:15:00 +0200 Subject: [PATCH 131/181] feat: infer next schedule_duration from previous usage_forecast duration Signed-off-by: F.N. Claessen --- .../s2/control_types/FRBC/frbc_simple.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py index fdfb9dba..42e00533 100644 --- a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py +++ b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py @@ -323,6 +323,7 @@ async def send_usage_forecast(self, usage_forecast: FRBCUsageForecast): strategy="mean", fill_level_scale=self._fill_level_scale, ) + usage_forecast_duration = pd.Timedelta(self._resolution) * len(usage_forecast) await self._fm_client.post_sensor_data( sensor_id=self._usage_forecast_sensor_id, @@ -330,9 +331,12 @@ async def send_usage_forecast(self, usage_forecast: FRBCUsageForecast): prior=self.now(), values=usage_forecast.tolist(), unit=self.power_unit, # e.g. [0, 100] MW/(15 min) # todo: or: f"{self.energy_unit}/s" to scale usage forecast e.g. [0, 100] %/s -> [0, 100] %/(15 min) - duration=str(pd.Timedelta(self._resolution) * len(usage_forecast)), + duration=usage_forecast_duration, ) + # Use usage forecast duration as the next schedule duration + self._schedule_duration = usage_forecast_duration + async def send_leakage_behaviour(self, leakage: FRBCLeakageBehaviour): # if not self._is_timer_due("leakage_behaviour"): # return From a6b0c1d4f43dc24c074ec1949e2368bd542f6f04 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Mon, 22 Jun 2026 19:19:03 +0200 Subject: [PATCH 132/181] Revert "feat: infer next schedule_duration from previous usage_forecast duration"; it works after the first schedule, but I fear it may sometimes lead to a very short schedule duration; I prefer not to make this dependent on the usage forecast duration This reverts commit 10fa1556532e789c42e81f2fa26af18ade611257. --- .../s2/control_types/FRBC/frbc_simple.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py index 42e00533..fdfb9dba 100644 --- a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py +++ b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py @@ -323,7 +323,6 @@ async def send_usage_forecast(self, usage_forecast: FRBCUsageForecast): strategy="mean", fill_level_scale=self._fill_level_scale, ) - usage_forecast_duration = pd.Timedelta(self._resolution) * len(usage_forecast) await self._fm_client.post_sensor_data( sensor_id=self._usage_forecast_sensor_id, @@ -331,12 +330,9 @@ async def send_usage_forecast(self, usage_forecast: FRBCUsageForecast): prior=self.now(), values=usage_forecast.tolist(), unit=self.power_unit, # e.g. [0, 100] MW/(15 min) # todo: or: f"{self.energy_unit}/s" to scale usage forecast e.g. [0, 100] %/s -> [0, 100] %/(15 min) - duration=usage_forecast_duration, + duration=str(pd.Timedelta(self._resolution) * len(usage_forecast)), ) - # Use usage forecast duration as the next schedule duration - self._schedule_duration = usage_forecast_duration - async def send_leakage_behaviour(self, leakage: FRBCLeakageBehaviour): # if not self._is_timer_due("leakage_behaviour"): # return From 14d21d16583eb869c10cb7ee851f26c73b1446ef Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Tue, 16 Jun 2026 12:36:03 +0200 Subject: [PATCH 133/181] dev: add debugging statements Signed-off-by: F.N. Claessen --- .../s2/control_types/FRBC/__init__.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/flexmeasures_client/s2/control_types/FRBC/__init__.py b/src/flexmeasures_client/s2/control_types/FRBC/__init__.py index de03affd..c8710a5b 100644 --- a/src/flexmeasures_client/s2/control_types/FRBC/__init__.py +++ b/src/flexmeasures_client/s2/control_types/FRBC/__init__.py @@ -3,7 +3,7 @@ import pydantic try: - from s2python.common import ControlType, ReceptionStatusValues + from s2python.common import ControlType, ReceptionStatus, ReceptionStatusValues from s2python.frbc import ( FRBCActuatorStatus, FRBCFillLevelTargetProfile, @@ -156,6 +156,10 @@ async def send_conversion_efficiencies( * fill_level_scale / (operation_mode.elements[-1].power_ranges[0].end_of_range) ) + self._logger.debug(f"operation_mode.elements[-1].fill_rate.end_of_range: {operation_mode.elements[-1].fill_rate.end_of_range}") + self._logger.debug(f"operation_mode.elements[-1].power_ranges[0].end_of_range: {operation_mode.elements[-1].power_ranges[0].end_of_range}") + self._logger.debug(f"fill_level_scale: {fill_level_scale}") + self._logger.debug(f"efficiency: {efficiency}") except (IndexError, AttributeError, ZeroDivisionError) as e: self._logger.debug( f"Could not calculate efficiency for operation mode {operation_mode.id}: {e}" @@ -247,6 +251,11 @@ def handle_fill_level_target_profile( task.add_done_callback(self.background_tasks.discard) return get_reception_status(message, status=ReceptionStatusValues.OK) + @register(ReceptionStatus) + def handle_reception_status(self, message: ReceptionStatus): + self._logger.debug(message) + self._logger.debug(message.subject_message_id) + @register(FRBCTimerStatus) def handle_frbc_timer_status(self, message: FRBCTimerStatus) -> pydantic.BaseModel: return get_reception_status(message, status=ReceptionStatusValues.OK) From ad1cf67dfd044728dc2b98004dc349b514329fc3 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Mon, 6 Jul 2026 13:09:16 +0200 Subject: [PATCH 134/181] feat: support running multiple CEM instances on different ports Signed-off-by: F.N. Claessen --- docker-compose.override.yml | 76 ++++++++++++------- .../s2/script/websockets_server.py | 2 +- 2 files changed, 51 insertions(+), 27 deletions(-) diff --git a/docker-compose.override.yml b/docker-compose.override.yml index 90814ea0..4e97155e 100644 --- a/docker-compose.override.yml +++ b/docker-compose.override.yml @@ -8,6 +8,36 @@ # up # ------------------------------------------------------------------ +# Shared definition for all CEM instances. Each instance keeps listening on +# port 8080 inside its own container; only the host port mapping differs, +# so RMs connect to ws://localhost:8080/ws, ws://localhost:8081/ws, etc. +x-cem: &cem + build: + context: . + dockerfile: Dockerfile + image: flexmeasures-client-cem + depends_on: + - server + restart: always + environment: + FLEXMEASURES_BASE_URL: http://server:5000 + FLEXMEASURES_USER: toy-user@flexmeasures.io + FLEXMEASURES_PASSWORD: toy-password + LOGGING_LEVEL: DEBUG + SETUPTOOLS_SCM_PRETEND_VERSION_FOR_FLEXMEASURES_CLIENT: "0.0.0" + volumes: + # If flexmeasures_client lives in your repo and you want live edits + - ../flexmeasures-client:/app/flexmeasures-client:rw + entrypoint: ["/bin/sh", "-c"] + command: + - | + # pip install --break-system-packages --no-cache-dir "git+https://github.com/FlexMeasures/flexmeasures-client.git@main#egg=flexmeasures-client[s2]" + pip install --break-system-packages -e /app/flexmeasures-client[s2] + pip install --break-system-packages aiohttp + pip install --break-system-packages pytz + pip install --break-system-packages s2-python==0.8.2 + python3 /app/flexmeasures-client/src/flexmeasures_client/s2/script/websockets_server.py + services: dev-db: ports: @@ -45,31 +75,25 @@ services: pip install --break-system-packages -r /usr/var/flexmeasures-instance/requirements.txt pip install timely-beliefs -U --break-system-packages flexmeasures jobs run-worker --name flexmeasures-worker --queue ingestion\|scheduling\|forecasting + # One CEM per simulated household. Add or remove instances as needed; + # scale by copying an entry and bumping the host port. cem: - build: - context: . - dockerfile: Dockerfile - depends_on: - - server - restart: always + <<: *cem ports: - - "8080:8080" # aiohttp default; adjust if you change it - environment: - # Optional, but useful if you later make this configurable - FLEXMEASURES_BASE_URL: http://server:5000 - FLEXMEASURES_USER: toy-user@flexmeasures.io - FLEXMEASURES_PASSWORD: toy-password - LOGGING_LEVEL: DEBUG - SETUPTOOLS_SCM_PRETEND_VERSION_FOR_FLEXMEASURES_CLIENT: "0.0.0" - volumes: - # If flexmeasures_client lives in your repo and you want live edits - - ../flexmeasures-client:/app/flexmeasures-client:rw - entrypoint: ["/bin/sh", "-c"] - command: - - | - # pip install --break-system-packages --no-cache-dir "git+https://github.com/FlexMeasures/flexmeasures-client.git@main#egg=flexmeasures-client[s2]" - pip install --break-system-packages -e /app/flexmeasures-client[s2] - pip install --break-system-packages aiohttp - pip install --break-system-packages pytz - pip install --break-system-packages s2-python==0.8.2 - python3 /app/flexmeasures-client/src/flexmeasures_client/s2/script/websockets_server.py + - "8080:8080" + cem-2: + <<: *cem + ports: + - "8081:8080" + cem-3: + <<: *cem + ports: + - "8082:8080" + cem-4: + <<: *cem + ports: + - "8083:8080" + cem-5: + <<: *cem + ports: + - "8084:8080" diff --git a/src/flexmeasures_client/s2/script/websockets_server.py b/src/flexmeasures_client/s2/script/websockets_server.py index 7277a6d2..812fda27 100644 --- a/src/flexmeasures_client/s2/script/websockets_server.py +++ b/src/flexmeasures_client/s2/script/websockets_server.py @@ -117,4 +117,4 @@ async def websocket_handler(request): app = web.Application() app.add_routes([web.get("/ws", websocket_handler)]) -web.run_app(app) +web.run_app(app, port=int(os.getenv("CEM_PORT", "8080"))) From a230540decb936556e9f662a305167ef22baca15 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Mon, 6 Jul 2026 15:20:02 +0200 Subject: [PATCH 135/181] feat: load the seita_ems plugin into the server and worker containers Signed-off-by: F.N. Claessen --- docker-compose.override.yml | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/docker-compose.override.yml b/docker-compose.override.yml index 4e97155e..7446a7d6 100644 --- a/docker-compose.override.yml +++ b/docker-compose.override.yml @@ -53,11 +53,18 @@ services: - ./flexmeasures-instance/:/usr/var/flexmeasures-instance/:rw - ./flexmeasures-instance/:/app/instance/:rw - ../flexmeasures/flexmeasures:/app/flexmeasures:rw + # The seita_ems plugin (provides the CommunityScheduler) and the client + # repo it imports (flexmeasures_client + examples/HEMS) + - ../ems/seita_ems:/app/plugins/seita_ems:rw + - ../flexmeasures-client:/app/flexmeasures-client:rw + environment: + FLEXMEASURES_PLUGINS: /app/plugins/seita_ems command: - | pip install --break-system-packages -e /app pip install --break-system-packages -r /usr/var/flexmeasures-instance/requirements.txt pip install timely-beliefs -U --break-system-packages + pip install --break-system-packages -e /app/flexmeasures-client[s2] flexmeasures db upgrade if ! flexmeasures show accounts | grep -q "Docker Toy Account"; then flexmeasures add toy-account --name 'Docker Toy Account' @@ -69,12 +76,19 @@ services: - ./flexmeasures-instance/:/usr/var/flexmeasures-instance/:rw - ../flexmeasures/flexmeasures:/app/flexmeasures:rw - ./flexmeasures-instance/:/app/instance/:rw + # The seita_ems plugin (provides the CommunityScheduler) and the client + # repo it imports (flexmeasures_client + examples/HEMS) + - ../ems/seita_ems:/app/plugins/seita_ems:rw + - ../flexmeasures-client:/app/flexmeasures-client:rw + environment: + FLEXMEASURES_PLUGINS: /app/plugins/seita_ems command: - | pip install --break-system-packages -e /app pip install --break-system-packages -r /usr/var/flexmeasures-instance/requirements.txt pip install timely-beliefs -U --break-system-packages - flexmeasures jobs run-worker --name flexmeasures-worker --queue ingestion\|scheduling\|forecasting + pip install --break-system-packages -e /app/flexmeasures-client[s2] + flexmeasures jobs run-worker --name flexmeasures-worker --queue scheduling\|forecasting # One CEM per simulated household. Add or remove instances as needed; # scale by copying an entry and bumping the host port. cem: From 04b3999aadcdc3fc112941138733832c3dda9e4b Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Mon, 6 Jul 2026 16:27:49 +0200 Subject: [PATCH 136/181] fix: match returning resource managers to assets by name when external_id is unavailable Signed-off-by: F.N. Claessen --- src/flexmeasures_client/s2/cem.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/flexmeasures_client/s2/cem.py b/src/flexmeasures_client/s2/cem.py index d78c9dd9..dad7f0dc 100644 --- a/src/flexmeasures_client/s2/cem.py +++ b/src/flexmeasures_client/s2/cem.py @@ -361,6 +361,14 @@ async def map_resource_to_asset(self, message): for ast in assets: if ast["external_id"] == message.resource_id: asset = ast + if asset is None: + # Fall back to matching by name: servers without external_id support + # cannot persist the resource ID, and RMs generate a fresh resource ID + # on every connection, so a returning RM would otherwise cause a + # duplicate-name asset creation attempt. + for ast in assets: + if ast["name"] == message.name: + asset = ast if asset is None: account = await self._fm_client.get_account() asset = await self._fm_client.add_asset( From dedfe9fffb4e2304ac84ce32ecc29a98298ebb56 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Tue, 7 Jul 2026 08:32:01 +0200 Subject: [PATCH 137/181] fix: use more gunicorn worker processes instead of threads, to isolate slow requests Signed-off-by: F.N. Claessen --- docker-compose.override.yml | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/docker-compose.override.yml b/docker-compose.override.yml index 7446a7d6..58c7a0f4 100644 --- a/docker-compose.override.yml +++ b/docker-compose.override.yml @@ -46,6 +46,10 @@ services: ports: - "6380:6379" server: + # SYS_PTRACE: lets py-spy attach to the gunicorn workers for live debugging + # of the CEM-asset-reuse hang (see projects/009 HANDOFF.md in pps_flexed). + cap_add: + - SYS_PTRACE volumes: # A place for config and plugin code, and custom requirements.txt # The 1st mount point is for running the FlexMeasures CLI, the 2nd for gunicorn @@ -65,11 +69,19 @@ services: pip install --break-system-packages -r /usr/var/flexmeasures-instance/requirements.txt pip install timely-beliefs -U --break-system-packages pip install --break-system-packages -e /app/flexmeasures-client[s2] + pip install --break-system-packages py-spy flexmeasures db upgrade if ! flexmeasures show accounts | grep -q "Docker Toy Account"; then flexmeasures add toy-account --name 'Docker Toy Account' fi - gunicorn --bind 0.0.0.0:5000 --worker-tmp-dir /dev/shm --workers 2 --threads 4 wsgi:application + # More worker PROCESSES, fewer threads per worker: a slow CPU-bound + # request (see projects/009 HANDOFF.md - belief-dedup in + # flexmeasures/data/services/time_series.py can be slow) previously + # monopolized the GIL of its gunicorn worker under --threads 4, + # starving unrelated concurrent requests until they hit client-side + # timeouts. Separate OS processes aren't GIL-bound, so this isolates + # a slow request to its own worker instead of blocking the others. + gunicorn --bind 0.0.0.0:5000 --worker-tmp-dir /dev/shm --workers 4 --threads 1 --timeout 120 wsgi:application worker: volumes: # a place for config and plugin code, and custom requirements.txt From 51346d6f834a4bf6d66190f99c773c0f8517a4ed Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Tue, 7 Jul 2026 08:32:36 +0200 Subject: [PATCH 138/181] feat: add trace logging around asset reuse and schedule triggering; stop silently swallowing exceptions in the fire-and-forget storage-status task Signed-off-by: F.N. Claessen --- src/flexmeasures_client/s2/cem.py | 22 +++-- src/flexmeasures_client/s2/config_utils.py | 10 +++ .../s2/control_types/FRBC/frbc_simple.py | 90 +++++++++++++------ 3 files changed, 92 insertions(+), 30 deletions(-) diff --git a/src/flexmeasures_client/s2/cem.py b/src/flexmeasures_client/s2/cem.py index dad7f0dc..36c4b6b1 100644 --- a/src/flexmeasures_client/s2/cem.py +++ b/src/flexmeasures_client/s2/cem.py @@ -157,7 +157,7 @@ def register_control_type(self, control_type_handler: ControlTypeHandler): # skip registering if there's a handler already registered for # the same control type if control_type_handler._control_type in self._control_types_handlers: - self._logger.warning( + self._logger.debug( "Control Type {control_type} already registered. Updating..." ) @@ -271,12 +271,12 @@ async def activate_control_type( # check if it's trying to activate the current control_type if control_type == self._control.control_type: - self._logger.warning(f"RM is already in `{control_type}` control type.") + self._logger.debug(f"RM is already in `{control_type}` control type.") return None # check if the RM supports the control type if control_type not in self._resource_manager_details.available_control_types: - self._logger.warning(f"RM does not support `{control_type}` control type.") + self._logger.debug(f"RM does not support `{control_type}` control type.") return None # RM initialization succeeded @@ -370,6 +370,10 @@ async def map_resource_to_asset(self, message): if ast["name"] == message.name: asset = ast if asset is None: + self._logger.debug( + f"HANGDEBUG map_resource_to_asset: no existing asset found for " + f"{message.name!r}, creating a new one" + ) account = await self._fm_client.get_account() asset = await self._fm_client.add_asset( name=message.name, @@ -378,6 +382,14 @@ async def map_resource_to_asset(self, message): # parent_asset_id=self._asset_id, attributes=json.loads(message.to_json()), ) + self._logger.debug( + f"HANGDEBUG map_resource_to_asset: created asset id={asset['id']}" + ) + else: + self._logger.debug( + f"HANGDEBUG map_resource_to_asset: reusing existing asset " + f"id={asset['id']} name={asset['name']!r} for {message.name!r}" + ) if asset["name"] != message.name: await self._fm_client.update_asset( asset_id=asset["id"], updates={"name": message.name} @@ -438,7 +450,7 @@ async def handle_power_measurement(self, message: PowerMeasurement): continue sensor_id = s_id else: - self._logger.warning( + self._logger.debug( f"No power sensor IDs set up. Ignoring measurement {power_measurement.value} at {message.measurement_timestamp}." ) continue @@ -489,7 +501,7 @@ async def handle_power_measurement(self, message: PowerMeasurement): unit=get_commodity_unit(commodity_quantity), ) except Exception as e: # noqa: B902 - intentional safety net - self._logger.warning( + self._logger.debug( f"POSTing power measurement failed with error: {e}" ) diff --git a/src/flexmeasures_client/s2/config_utils.py b/src/flexmeasures_client/s2/config_utils.py index 6edff4a8..2425f416 100644 --- a/src/flexmeasures_client/s2/config_utils.py +++ b/src/flexmeasures_client/s2/config_utils.py @@ -36,11 +36,20 @@ async def configure_site( ) if not site_asset: + LOGGER.debug(f"HANGDEBUG configure_site: creating new asset for {site_name!r}") site_asset = await fm_client.add_asset( name=site_name, account_id=account["id"], **site_asset_specs ) + else: + LOGGER.debug( + f"HANGDEBUG configure_site: reusing existing asset id={site_asset['id']} " + f"for {site_name!r}, existing sensors: " + f"{[s['name'] for s in site_asset.get('sensors', [])]}" + ) # Update site asset with the latest specs + LOGGER.debug(f"HANGDEBUG configure_site: updating asset {site_asset['id']} specs") await fm_client.update_asset(site_asset["id"], site_asset_specs) + LOGGER.debug(f"HANGDEBUG configure_site: asset {site_asset['id']} specs updated") sensors = site_asset.get("sensors", []) price_sensor = None @@ -206,6 +215,7 @@ async def configure_site( asset_id=site_asset["id"], updates=dict(sensors_to_show=sensors_to_show), ) + LOGGER.debug(f"HANGDEBUG configure_site: done, returning sensors for asset {site_asset['id']}") return ( price_sensor, production_price_sensor, diff --git a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py index fdfb9dba..f5cdd5bd 100644 --- a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py +++ b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py @@ -109,16 +109,31 @@ def _get_operation_mode_efficiency_sensor_map(self, system_description) -> dict: return efficiency_map async def send_storage_status(self, status: FRBCStorageStatus): + # HANGDEBUG: this runs as a fire-and-forget asyncio.create_task (see + # handle_storage_status), so any exception here is normally silently + # dropped (only surfacing as "Task exception was never retrieved" once + # the task is garbage collected). Log explicitly so nothing is missed. now = self.now() - await self._fm_client.post_sensor_data( - self._soc_sensor_id, - start=now, - prior=now, - values=[status.present_fill_level * self._fill_level_scale], - unit=self.energy_unit, - duration=timedelta(minutes=1), - ) - await self.trigger_schedule(now) + try: + self._logger.debug( + f"HANGDEBUG send_storage_status: posting SoC to sensor {self._soc_sensor_id}" + ) + await self._fm_client.post_sensor_data( + self._soc_sensor_id, + start=now, + prior=now, + values=[status.present_fill_level * self._fill_level_scale], + unit=self.energy_unit, + duration=timedelta(minutes=1), + ) + self._logger.debug( + "HANGDEBUG send_storage_status: SoC posted, calling trigger_schedule" + ) + await self.trigger_schedule(now) + self._logger.debug("HANGDEBUG send_storage_status: trigger_schedule returned") + except Exception: + self._logger.exception("HANGDEBUG send_storage_status: raised") + raise async def send_actuator_status(self, status: FRBCActuatorStatus): factor = status.operation_mode_factor @@ -235,30 +250,55 @@ async def trigger_schedule( } self._logger.debug(f"flex_context: {flex_context}") self._logger.debug(f"flex_model: {flex_model}") - schedule = await self._fm_client.trigger_and_get_schedule( - start=start.replace( - minute=(start.minute // 15) * 15, second=0, microsecond=0 - ), - prior=start, - sensor_id=self._power_sensor_id, - flex_context=flex_context, - flex_model=flex_model, - duration=self._schedule_duration, # next 12 hours - # TODO: add SOC MAX AND SOC MIN FROM fill_level_range, - # this needs changes on the client - unit=self.power_unit, + self._logger.debug( + f"HANGDEBUG trigger_schedule: about to call trigger_and_get_schedule " + f"(power_sensor_id={self._power_sensor_id})" + ) + try: + schedule = await self._fm_client.trigger_and_get_schedule( + start=start.replace( + minute=(start.minute // 15) * 15, second=0, microsecond=0 + ), + prior=start, + sensor_id=self._power_sensor_id, + flex_context=flex_context, + flex_model=flex_model, + duration=self._schedule_duration, # next 12 hours + # TODO: add SOC MAX AND SOC MIN FROM fill_level_range, + # this needs changes on the client + unit=self.power_unit, + ) + except Exception: + self._logger.exception( + "HANGDEBUG trigger_schedule: trigger_and_get_schedule raised" + ) + raise + self._logger.debug( + f"HANGDEBUG trigger_schedule: got schedule back: {schedule}" ) # translate FlexMeasures schedule into instructions. SOC -> Power -> PowerFactor - instructions = fm_schedule_to_instructions( - schedule, - system_description, - initial_fill_level=soc_at_start / self._fill_level_scale, + try: + instructions = fm_schedule_to_instructions( + schedule, + system_description, + initial_fill_level=soc_at_start / self._fill_level_scale, + ) + except Exception: + self._logger.exception( + "HANGDEBUG trigger_schedule: fm_schedule_to_instructions raised" + ) + raise + self._logger.debug( + f"HANGDEBUG trigger_schedule: built {len(instructions)} instructions" ) # put instructions to sending queue for instruction in instructions: await self.send_message(instruction) + self._logger.debug( + "HANGDEBUG trigger_schedule: all instructions queued for sending" + ) async def send_fill_level_target_profile( self, fill_level_target_profile: FRBCFillLevelTargetProfile From bc709df8209885ed4b22eebbeaa3f60e92651846 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Tue, 7 Jul 2026 10:18:58 +0200 Subject: [PATCH 139/181] chore: scale gunicorn workers to match the number of CEM instances --- docker-compose.override.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/docker-compose.override.yml b/docker-compose.override.yml index 58c7a0f4..8beab8d3 100644 --- a/docker-compose.override.yml +++ b/docker-compose.override.yml @@ -81,7 +81,11 @@ services: # starving unrelated concurrent requests until they hit client-side # timeouts. Separate OS processes aren't GIL-bound, so this isolates # a slow request to its own worker instead of blocking the others. - gunicorn --bind 0.0.0.0:5000 --worker-tmp-dir /dev/shm --workers 4 --threads 1 --timeout 120 wsgi:application + # Worker count matches the number of CEM instances below (one per + # concurrently-simulated apartment), since each apartment's RM/CEM + # can be mid-request at the same time. Bump both together when + # scaling to more apartments (see the cem-* services' own comment). + gunicorn --bind 0.0.0.0:5000 --worker-tmp-dir /dev/shm --workers 5 --threads 1 --timeout 120 wsgi:application worker: volumes: # a place for config and plugin code, and custom requirements.txt From c0ddc15928da896260356f812cf90a0181df2d24 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Tue, 7 Jul 2026 11:26:38 +0200 Subject: [PATCH 140/181] fix: relax all site-capacity constraints, not just SOC, so infeasibility degrades gracefully --- .../s2/control_types/FRBC/frbc_simple.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py index f5cdd5bd..57f5727e 100644 --- a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py +++ b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py @@ -232,7 +232,12 @@ async def trigger_schedule( "consumption-price": {"sensor": self._price_sensor_id}, "production-price": {"sensor": self._production_price_sensor_id}, "site-power-capacity": f"{3 * 25 * 230} VA", - "relax-soc-constraints": True, + # relax-constraints (not just relax-soc-constraints): also fills in a + # default site-consumption/production-breach-price when none is set, so + # a site-level capacity constraint (e.g. from community steering) becomes + # a soft, penalized violation instead of causing infeasibility that + # silently falls back to a scheduler which ignores the constraint entirely. + "relax-constraints": True, } flex_model = { "soc-unit": energy_unit, From c19cf8d123643b8211a373403c12ef3116d09bc4 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Tue, 7 Jul 2026 12:09:08 +0200 Subject: [PATCH 141/181] fix: place back command for running a worker against the ingestion queue Signed-off-by: F.N. Claessen --- docker-compose.override.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker-compose.override.yml b/docker-compose.override.yml index 8beab8d3..814896f3 100644 --- a/docker-compose.override.yml +++ b/docker-compose.override.yml @@ -104,7 +104,7 @@ services: pip install --break-system-packages -r /usr/var/flexmeasures-instance/requirements.txt pip install timely-beliefs -U --break-system-packages pip install --break-system-packages -e /app/flexmeasures-client[s2] - flexmeasures jobs run-worker --name flexmeasures-worker --queue scheduling\|forecasting + flexmeasures jobs run-worker --name flexmeasures-worker --queue ingestion\|scheduling\|forecasting # One CEM per simulated household. Add or remove instances as needed; # scale by copying an entry and bumping the host port. cem: From 52843ff4dd004496f16ccb4adcaaae4872869814 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Tue, 7 Jul 2026 12:25:43 +0200 Subject: [PATCH 142/181] fix: revert worker to the scheduling|forecasting queue list (ingestion is not a valid queue on this branch) --- docker-compose.override.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker-compose.override.yml b/docker-compose.override.yml index 814896f3..8beab8d3 100644 --- a/docker-compose.override.yml +++ b/docker-compose.override.yml @@ -104,7 +104,7 @@ services: pip install --break-system-packages -r /usr/var/flexmeasures-instance/requirements.txt pip install timely-beliefs -U --break-system-packages pip install --break-system-packages -e /app/flexmeasures-client[s2] - flexmeasures jobs run-worker --name flexmeasures-worker --queue ingestion\|scheduling\|forecasting + flexmeasures jobs run-worker --name flexmeasures-worker --queue scheduling\|forecasting # One CEM per simulated household. Add or remove instances as needed; # scale by copying an entry and bumping the host port. cem: From d2805b60758f30df6216ddcb2dd9fb182d9cffcd Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Tue, 7 Jul 2026 13:30:47 +0200 Subject: [PATCH 143/181] fix: raise the CEM's schedule-polling budget to match FlexMeasures' longer job timeout --- src/flexmeasures_client/s2/script/websockets_server.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/flexmeasures_client/s2/script/websockets_server.py b/src/flexmeasures_client/s2/script/websockets_server.py index 812fda27..d9554d11 100644 --- a/src/flexmeasures_client/s2/script/websockets_server.py +++ b/src/flexmeasures_client/s2/script/websockets_server.py @@ -98,6 +98,14 @@ async def websocket_handler(request): host=parsed.netloc, ssl=parsed.scheme == "https", polling_interval=0.5, + # Must comfortably exceed FlexMeasures' own scheduling job timeout (see + # flexmeasures/data/services/scheduling.py, FLEXMEASURES_JOB_TIMEOUT_SCHEDULING, + # currently 600s), or this client gives up on a schedule that's still being + # computed/saved server-side. The exponential backoff (0.5 * 2**step) also needs + # enough steps to reach that long: 10 steps only sums to ~511s regardless of + # polling_timeout, so max_polling_steps is raised too. + polling_timeout=650.0, + max_polling_steps=12, ) cem = CEM( From 29027058404fcdf6d27ef7412f7277b134ba69ba Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Tue, 7 Jul 2026 13:52:01 +0200 Subject: [PATCH 144/181] fix: relax site capacity specifically instead of the broad relax-constraints flag, which pushed HiGHS into 10+ minute solves --- .../s2/control_types/FRBC/frbc_simple.py | 20 +++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py index 57f5727e..13c37aa5 100644 --- a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py +++ b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py @@ -232,12 +232,20 @@ async def trigger_schedule( "consumption-price": {"sensor": self._price_sensor_id}, "production-price": {"sensor": self._production_price_sensor_id}, "site-power-capacity": f"{3 * 25 * 230} VA", - # relax-constraints (not just relax-soc-constraints): also fills in a - # default site-consumption/production-breach-price when none is set, so - # a site-level capacity constraint (e.g. from community steering) becomes - # a soft, penalized violation instead of causing infeasibility that - # silently falls back to a scheduler which ignores the constraint entirely. - "relax-constraints": True, + "relax-soc-constraints": True, + # Also relax site-level capacity specifically (not the broader + # relax-constraints/relax-capacity-constraints, which additionally soften + # *device*-level capacity constraints): this fills in a default + # site-consumption/production-breach-price so a site-level capacity + # constraint (e.g. from community steering) becomes a soft, penalized + # violation instead of causing infeasibility that silently falls back to a + # scheduler which ignores the constraint entirely. The broader flag was + # tried and reverted: with this apartment model's large SOC magnitudes + # (hundreds of kWh of thermal storage against a sub-1kW power capacity), + # the extra device-capacity breach-price terms it adds pushed HiGHS from a + # ~10s solve into one that ran 10+ minutes without converging (confirmed + # via py-spy: time was spent inside the solver itself, not a Python hang). + "relax-site-capacity-constraints": True, } flex_model = { "soc-unit": energy_unit, From 2d2d97cf3164b4b95278c49c94edf358df250d1b Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Tue, 7 Jul 2026 13:53:11 +0200 Subject: [PATCH 145/181] revert: drop the CEM polling-timeout increase, no longer needed now the real solver-slowdown cause is fixed --- src/flexmeasures_client/s2/script/websockets_server.py | 8 -------- 1 file changed, 8 deletions(-) diff --git a/src/flexmeasures_client/s2/script/websockets_server.py b/src/flexmeasures_client/s2/script/websockets_server.py index d9554d11..812fda27 100644 --- a/src/flexmeasures_client/s2/script/websockets_server.py +++ b/src/flexmeasures_client/s2/script/websockets_server.py @@ -98,14 +98,6 @@ async def websocket_handler(request): host=parsed.netloc, ssl=parsed.scheme == "https", polling_interval=0.5, - # Must comfortably exceed FlexMeasures' own scheduling job timeout (see - # flexmeasures/data/services/scheduling.py, FLEXMEASURES_JOB_TIMEOUT_SCHEDULING, - # currently 600s), or this client gives up on a schedule that's still being - # computed/saved server-side. The exponential backoff (0.5 * 2**step) also needs - # enough steps to reach that long: 10 steps only sums to ~511s regardless of - # polling_timeout, so max_polling_steps is raised too. - polling_timeout=650.0, - max_polling_steps=12, ) cem = CEM( From 9aa318d11b12df2438a73554a6bf0723b9301e35 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Tue, 7 Jul 2026 14:41:58 +0200 Subject: [PATCH 146/181] dev: let py-spy attach to the RQ worker subprocess for live debugging Signed-off-by: F.N. Claessen --- docker-compose.override.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docker-compose.override.yml b/docker-compose.override.yml index 8beab8d3..382c3b24 100644 --- a/docker-compose.override.yml +++ b/docker-compose.override.yml @@ -87,6 +87,11 @@ services: # scaling to more apartments (see the cem-* services' own comment). gunicorn --bind 0.0.0.0:5000 --worker-tmp-dir /dev/shm --workers 5 --threads 1 --timeout 120 wsgi:application worker: + # SYS_PTRACE: lets py-spy attach to the RQ worker subprocess for live debugging + # of slow/stuck scheduling jobs (py-spy itself isn't preinstalled here; install + # it ad hoc with `docker exec flexmeasures-worker-1 pip install --break-system-packages py-spy`). + cap_add: + - SYS_PTRACE volumes: # a place for config and plugin code, and custom requirements.txt - ./flexmeasures-instance/:/usr/var/flexmeasures-instance/:rw From b3540dd0c0598adc9772c367830a36122f1a6b3c Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Tue, 7 Jul 2026 15:42:23 +0200 Subject: [PATCH 147/181] feat: scale RQ workers to 3, one per concurrently-simulated apartment, so scheduling jobs run in parallel instead of serializing through a single worker --- docker-compose.override.yml | 83 +++++++++++++++++++++++++++++-------- 1 file changed, 66 insertions(+), 17 deletions(-) diff --git a/docker-compose.override.yml b/docker-compose.override.yml index 382c3b24..b47bc1ee 100644 --- a/docker-compose.override.yml +++ b/docker-compose.override.yml @@ -38,6 +38,50 @@ x-cem: &cem pip install --break-system-packages s2-python==0.8.2 python3 /app/flexmeasures-client/src/flexmeasures_client/s2/script/websockets_server.py +# Shared definition for all RQ worker instances. Scaled one-per-concurrently- +# simulated-apartment, same as the cem-* services and the server's gunicorn +# worker count below, since otherwise every apartment's scheduling jobs funnel +# through a single worker process and serialize even though gunicorn/CEM are +# already scaled per apartment. Bump alongside those when scaling further. +x-worker: &worker + # worker-2/worker-3 are new services (not present in the base docker-compose.yml, + # unlike "worker" itself), so this anchor must be self-contained rather than + # relying on a base-file merge for build/depends_on/restart/base environment. + build: + context: . + dockerfile: Dockerfile + depends_on: + - dev-db + - queue-db + - mailhog + restart: on-failure + cap_add: + # SYS_PTRACE: lets py-spy attach to the RQ worker subprocess for live debugging + # of slow/stuck scheduling jobs (py-spy itself isn't preinstalled here; install + # it ad hoc with `docker exec pip install --break-system-packages py-spy`). + - SYS_PTRACE + volumes: + # a place for config and plugin code, and custom requirements.txt + - ./flexmeasures-instance/:/usr/var/flexmeasures-instance/:rw + - ../flexmeasures/flexmeasures:/app/flexmeasures:rw + - ./flexmeasures-instance/:/app/instance/:rw + # The seita_ems plugin (provides the CommunityScheduler) and the client + # repo it imports (flexmeasures_client + examples/HEMS) + - ../ems/seita_ems:/app/plugins/seita_ems:rw + - ../flexmeasures-client:/app/flexmeasures-client:rw + entrypoint: ["/bin/sh", "-c"] + environment: + SQLALCHEMY_DATABASE_URI: "postgresql://fm-dev-db-user:fm-dev-db-pass@dev-db:5432/fm-dev-db" + FLEXMEASURES_REDIS_URL: queue-db + FLEXMEASURES_REDIS_PASSWORD: fm-redis-pass + SECRET_KEY: notsecret + SECURITY_TOTP_SECRETS: '{"1": "something-secret"}' + FLEXMEASURES_ENV: development + MAIL_SERVER: mailhog + MAIL_PORT: 1025 + LOGGING_LEVEL: INFO + FLEXMEASURES_PLUGINS: /app/plugins/seita_ems + services: dev-db: ports: @@ -86,30 +130,35 @@ services: # can be mid-request at the same time. Bump both together when # scaling to more apartments (see the cem-* services' own comment). gunicorn --bind 0.0.0.0:5000 --worker-tmp-dir /dev/shm --workers 5 --threads 1 --timeout 120 wsgi:application + # One RQ worker per concurrently-simulated apartment (see x-worker comment + # above). Add or remove instances alongside the cem-* services. worker: - # SYS_PTRACE: lets py-spy attach to the RQ worker subprocess for live debugging - # of slow/stuck scheduling jobs (py-spy itself isn't preinstalled here; install - # it ad hoc with `docker exec flexmeasures-worker-1 pip install --break-system-packages py-spy`). - cap_add: - - SYS_PTRACE - volumes: - # a place for config and plugin code, and custom requirements.txt - - ./flexmeasures-instance/:/usr/var/flexmeasures-instance/:rw - - ../flexmeasures/flexmeasures:/app/flexmeasures:rw - - ./flexmeasures-instance/:/app/instance/:rw - # The seita_ems plugin (provides the CommunityScheduler) and the client - # repo it imports (flexmeasures_client + examples/HEMS) - - ../ems/seita_ems:/app/plugins/seita_ems:rw - - ../flexmeasures-client:/app/flexmeasures-client:rw - environment: - FLEXMEASURES_PLUGINS: /app/plugins/seita_ems + <<: *worker + command: + - | + pip install --break-system-packages -e /app + pip install --break-system-packages -r /usr/var/flexmeasures-instance/requirements.txt + pip install timely-beliefs -U --break-system-packages + pip install --break-system-packages -e /app/flexmeasures-client[s2] + flexmeasures jobs run-worker --name flexmeasures-worker-1 --queue scheduling\|forecasting + worker-2: + <<: *worker + command: + - | + pip install --break-system-packages -e /app + pip install --break-system-packages -r /usr/var/flexmeasures-instance/requirements.txt + pip install timely-beliefs -U --break-system-packages + pip install --break-system-packages -e /app/flexmeasures-client[s2] + flexmeasures jobs run-worker --name flexmeasures-worker-2 --queue scheduling\|forecasting + worker-3: + <<: *worker command: - | pip install --break-system-packages -e /app pip install --break-system-packages -r /usr/var/flexmeasures-instance/requirements.txt pip install timely-beliefs -U --break-system-packages pip install --break-system-packages -e /app/flexmeasures-client[s2] - flexmeasures jobs run-worker --name flexmeasures-worker --queue scheduling\|forecasting + flexmeasures jobs run-worker --name flexmeasures-worker-3 --queue scheduling\|forecasting # One CEM per simulated household. Add or remove instances as needed; # scale by copying an entry and bumping the host port. cem: From 117cdac28aa3131670b5371ed995b9dd3bcce113 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Tue, 7 Jul 2026 18:47:29 +0200 Subject: [PATCH 148/181] fix: retry a transient scheduling-job failure instead of permanently stalling the timestep Observed a pandas 'cannot reindex on an axis with duplicate labels' error from a scheduling job during a multi-day, frequently-re-triggered (implementation_horizon_hours=3) run; re-running the exact same job manually succeeded immediately, confirming it's transient. Without a retry, trigger_schedule's fire-and-forget task just logs and drops the exception, so the RM never receives an instruction for that timestep and polls forever. Retries up to 3 times on a ValueError containing 'Scheduling job failed' before giving up for real. --- .../s2/control_types/FRBC/frbc_simple.py | 60 +++++++++++++------ 1 file changed, 41 insertions(+), 19 deletions(-) diff --git a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py index 13c37aa5..4515bb0b 100644 --- a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py +++ b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py @@ -3,6 +3,7 @@ Used it at your own risk :) """ +import asyncio from datetime import datetime, timedelta from zoneinfo import ZoneInfo @@ -267,25 +268,46 @@ async def trigger_schedule( f"HANGDEBUG trigger_schedule: about to call trigger_and_get_schedule " f"(power_sensor_id={self._power_sensor_id})" ) - try: - schedule = await self._fm_client.trigger_and_get_schedule( - start=start.replace( - minute=(start.minute // 15) * 15, second=0, microsecond=0 - ), - prior=start, - sensor_id=self._power_sensor_id, - flex_context=flex_context, - flex_model=flex_model, - duration=self._schedule_duration, # next 12 hours - # TODO: add SOC MAX AND SOC MIN FROM fill_level_range, - # this needs changes on the client - unit=self.power_unit, - ) - except Exception: - self._logger.exception( - "HANGDEBUG trigger_schedule: trigger_and_get_schedule raised" - ) - raise + # A scheduling job can fail transiently server-side (e.g. a pandas + # "cannot reindex on an axis with duplicate labels" error observed with + # overlapping recurring schedule windows) and succeed cleanly when re-run + # moments later. Without a retry here, that single failure permanently stalls + # this timestep: the RM never gets an instruction and polls forever, since + # this whole call runs as a fire-and-forget task whose exception is otherwise + # just logged and dropped. Retry a few times before giving up for real. + max_attempts = 3 + for attempt in range(1, max_attempts + 1): + try: + schedule = await self._fm_client.trigger_and_get_schedule( + start=start.replace( + minute=(start.minute // 15) * 15, second=0, microsecond=0 + ), + prior=start, + sensor_id=self._power_sensor_id, + flex_context=flex_context, + flex_model=flex_model, + duration=self._schedule_duration, # next 12 hours + # TODO: add SOC MAX AND SOC MIN FROM fill_level_range, + # this needs changes on the client + unit=self.power_unit, + ) + break + except ValueError as exc: + if "Scheduling job failed" not in str(exc) or attempt == max_attempts: + self._logger.exception( + "HANGDEBUG trigger_schedule: trigger_and_get_schedule raised" + ) + raise + self._logger.warning( + f"HANGDEBUG trigger_schedule: scheduling job failed " + f"(attempt {attempt}/{max_attempts}), retrying: {exc}" + ) + await asyncio.sleep(1.0) + except Exception: + self._logger.exception( + "HANGDEBUG trigger_schedule: trigger_and_get_schedule raised" + ) + raise self._logger.debug( f"HANGDEBUG trigger_schedule: got schedule back: {schedule}" ) From 52bfa824ea2ce727ceef3819a6887dffb206cadd Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Fri, 10 Jul 2026 16:07:47 +0200 Subject: [PATCH 149/181] refactor: move the seita_ems plugin mounts to an overlay in the ems repo The base override now runs without access to the private ems repo (needed for single-house simulations by collaborators without that repo); community runs add ../ems/docker-compose.community.yml as a third compose file. --- docker-compose.override.yml | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/docker-compose.override.yml b/docker-compose.override.yml index b47bc1ee..1b0395a9 100644 --- a/docker-compose.override.yml +++ b/docker-compose.override.yml @@ -65,9 +65,9 @@ x-worker: &worker - ./flexmeasures-instance/:/usr/var/flexmeasures-instance/:rw - ../flexmeasures/flexmeasures:/app/flexmeasures:rw - ./flexmeasures-instance/:/app/instance/:rw - # The seita_ems plugin (provides the CommunityScheduler) and the client - # repo it imports (flexmeasures_client + examples/HEMS) - - ../ems/seita_ems:/app/plugins/seita_ems:rw + # The client repo (flexmeasures_client + examples/HEMS). The seita_ems + # community plugin is mounted by the ems repo's docker-compose.community.yml + # overlay instead, so this stack also runs without access to that repo. - ../flexmeasures-client:/app/flexmeasures-client:rw entrypoint: ["/bin/sh", "-c"] environment: @@ -80,7 +80,6 @@ x-worker: &worker MAIL_SERVER: mailhog MAIL_PORT: 1025 LOGGING_LEVEL: INFO - FLEXMEASURES_PLUGINS: /app/plugins/seita_ems services: dev-db: @@ -101,12 +100,10 @@ services: - ./flexmeasures-instance/:/usr/var/flexmeasures-instance/:rw - ./flexmeasures-instance/:/app/instance/:rw - ../flexmeasures/flexmeasures:/app/flexmeasures:rw - # The seita_ems plugin (provides the CommunityScheduler) and the client - # repo it imports (flexmeasures_client + examples/HEMS) - - ../ems/seita_ems:/app/plugins/seita_ems:rw + # The client repo (flexmeasures_client + examples/HEMS). The seita_ems + # community plugin is mounted by the ems repo's docker-compose.community.yml + # overlay instead, so this stack also runs without access to that repo. - ../flexmeasures-client:/app/flexmeasures-client:rw - environment: - FLEXMEASURES_PLUGINS: /app/plugins/seita_ems command: - | pip install --break-system-packages -e /app From 292b646255d65e4e10116c6fccecc2b195682d96 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Fri, 10 Jul 2026 16:56:34 +0200 Subject: [PATCH 150/181] feat: pass S2 operation-mode power ranges to FlexMeasures as power bands Builds the new flex-model "operation-modes" field from the FRBC system description's operation-mode element power ranges (only when they actually restrict the range), so FlexMeasures schedules only power values the device can run at. Requires flexmeasures feat/2113-operation-mode-power-bands. --- .../s2/control_types/FRBC/frbc_simple.py | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py index 4515bb0b..a3f3f277 100644 --- a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py +++ b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py @@ -214,6 +214,29 @@ async def trigger_schedule( else: charging_capacity = max(charging_capacity, p_max) + # Translate the S2 operation modes into FM power bands (the flex-model's + # "operation-modes" field): one band per operation-mode element power + # range, so FlexMeasures only schedules power values the device can + # actually run at (e.g. {0 W} U {883.7 W} for an on/off heater, instead + # of a fractional power that the RM would have to round to a full-on + # block, overshooting site capacity limits). Only sent when the bands + # actually restrict the power range (more than one distinct band). + operation_mode_bands: list[dict] = [] + for operation_mode in actuator.operation_modes: + for element in operation_mode.elements: + for power_range in element.power_ranges: + band_min, band_max = sorted( + (power_range.start_of_range, power_range.end_of_range) + ) + band = { + "power-range": [ + f"{band_min} {self.power_unit}", + f"{band_max} {self.power_unit}", + ] + } + if band not in operation_mode_bands: + operation_mode_bands.append(band) + soc_min, soc_max = get_soc_min_max(system_description, self._fill_level_scale) # Support for J energy unit (FM server scheduling trigger endpoint only accepts kWh and MWh) @@ -262,6 +285,8 @@ async def trigger_schedule( "consumption-capacity": f"{charging_capacity} {self.power_unit}", "production-capacity": f"{discharging_capacity} {self.power_unit}", } + if len(operation_mode_bands) > 1: + flex_model["operation-modes"] = operation_mode_bands self._logger.debug(f"flex_context: {flex_context}") self._logger.debug(f"flex_model: {flex_model}") self._logger.debug( From f924543437b87eff3d2b7bab8cfd969ab2990286 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Fri, 10 Jul 2026 20:35:03 +0200 Subject: [PATCH 151/181] fix: tear down the CEM session when its websocket closes A plain disconnect (RMs reconnect at every replan boundary) previously left the producer and watchdog tasks and the CEM itself running forever; the old session lived on as a zombie able to keep posting SoC and triggering schedules for an asset the RM no longer implements. --- .../s2/script/websockets_server.py | 25 ++++++++++++++----- 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/src/flexmeasures_client/s2/script/websockets_server.py b/src/flexmeasures_client/s2/script/websockets_server.py index 812fda27..addda7f4 100644 --- a/src/flexmeasures_client/s2/script/websockets_server.py +++ b/src/flexmeasures_client/s2/script/websockets_server.py @@ -105,12 +105,25 @@ async def websocket_handler(request): fm_client=fm_client, logger=LOGGER, ) - # create "parallel" tasks for the message producer and consumer - await asyncio.gather( - websocket_consumer(ws, cem), - websocket_producer(ws, cem), - rm_details_watchdog(ws, cem), - ) + # Create "parallel" tasks for the message producer and consumer. The + # consumer ends when the websocket closes (RMs reconnect at every replan + # boundary); the producer and watchdog must then be torn down along with + # the CEM itself, or the old session lives on as a zombie - its periodic + # loops keep posting SoC and triggering FlexMeasures schedules for an + # asset the RM no longer implements, racing the RM's new session (seen as + # a parallel steering-less schedule stream in the community co-simulation). + consumer = asyncio.create_task(websocket_consumer(ws, cem)) + side_tasks = [ + asyncio.create_task(websocket_producer(ws, cem)), + asyncio.create_task(rm_details_watchdog(ws, cem)), + ] + try: + await consumer + finally: + await cem.close() + for task in side_tasks: + task.cancel() + await asyncio.gather(*side_tasks, return_exceptions=True) return ws From 7df1a4b164ed10e110cdca5e5893da7fbcc03562 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Sun, 12 Jul 2026 07:31:34 +0200 Subject: [PATCH 152/181] fix: retry schedule triggers that race the initial battery-SoC post At startup, the CEM's first trigger_schedule can beat the controller's initial battery-SoC post (a separate client writing to the flex-model's state-of-charge sensor). FlexMeasures then rejects the trigger with a 422 "No recent state-of-charge value found", and since this whole call runs as a fire-and-forget task, that single failure permanently stalled the house at its barrier. Treat it as transient alongside "Scheduling job failed" (5 attempts, 2s backoff) - it resolves itself once the SoC post lands moments later. Heavier stacks lose this race more often. --- .../s2/control_types/FRBC/frbc_simple.py | 20 +++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py index a3f3f277..9ce27487 100644 --- a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py +++ b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py @@ -300,7 +300,16 @@ async def trigger_schedule( # this timestep: the RM never gets an instruction and polls forever, since # this whole call runs as a fire-and-forget task whose exception is otherwise # just logged and dropped. Retry a few times before giving up for real. - max_attempts = 3 + # "No recent state-of-charge value found" is the same class of transient: + # at startup, this trigger can race the controller's initial battery-SoC + # post (a separate client posting to the flex-model's SoC sensor), and + # FlexMeasures then rejects the trigger with a 422 that resolves itself + # once that post lands moments later. + max_attempts = 5 + transient_errors = ( + "Scheduling job failed", + "No recent state-of-charge value found", + ) for attempt in range(1, max_attempts + 1): try: schedule = await self._fm_client.trigger_and_get_schedule( @@ -318,16 +327,19 @@ async def trigger_schedule( ) break except ValueError as exc: - if "Scheduling job failed" not in str(exc) or attempt == max_attempts: + if ( + not any(marker in str(exc) for marker in transient_errors) + or attempt == max_attempts + ): self._logger.exception( "HANGDEBUG trigger_schedule: trigger_and_get_schedule raised" ) raise self._logger.warning( - f"HANGDEBUG trigger_schedule: scheduling job failed " + f"HANGDEBUG trigger_schedule: transient trigger failure " f"(attempt {attempt}/{max_attempts}), retrying: {exc}" ) - await asyncio.sleep(1.0) + await asyncio.sleep(2.0) except Exception: self._logger.exception( "HANGDEBUG trigger_schedule: trigger_and_get_schedule raised" From aae8c23c16b2c58ef5781b6f1ffce4f1afd4b522 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Mon, 13 Jul 2026 19:50:14 +0200 Subject: [PATCH 153/181] feat: route S2 PowerMeasurements to a measured-power sensor at simulated time The CEM now creates a dedicated 'measured-power' sensor (kW, consumption-positive) per site, separate from the StorageScheduler 'power' schedule sensor, and maps ELECTRIC.POWER.L1 measurements to it (replacing a hardcoded fallback sensor id). Measurements are binned and posted at their own SIMULATED timestamp instead of wall-clock time, and posted directly per incoming value (the RM sends one aggregated value per interval, so the old 5-minute wall-clock buffered-averaging would have suppressed nearly every post in a co-simulation). Values are posted in Watts (S2 PowerMeasurement convention) and converted to the kW sensor by FlexMeasures. --- src/flexmeasures_client/s2/cem.py | 92 +++++++++------------- src/flexmeasures_client/s2/config_utils.py | 20 ++++- 2 files changed, 57 insertions(+), 55 deletions(-) diff --git a/src/flexmeasures_client/s2/cem.py b/src/flexmeasures_client/s2/cem.py index 36c4b6b1..a4e9b7e0 100644 --- a/src/flexmeasures_client/s2/cem.py +++ b/src/flexmeasures_client/s2/cem.py @@ -5,7 +5,7 @@ import logging import math from collections import defaultdict -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone from logging import Logger from typing import Dict, Optional @@ -412,7 +412,16 @@ async def map_resource_to_asset(self, message): usage_forecast_sensor, leakage_behaviour_sensor, charging_efficiency_sensor, + measured_power_sensor, ) = await configure_site(message.name, self._fm_client) + + # Wire up the apartment's dedicated MEASUREMENT sensor (distinct from the + # "power" SCHEDULE sensor above) so incoming S2 PowerMeasurements land on + # their own sensor instead of a hardcoded/wrong one. + if self.power_sensor_id is None: + self.power_sensor_id = {} + self.power_sensor_id["ELECTRIC.POWER.L1"] = measured_power_sensor["id"] + from flexmeasures_client.s2.control_types.FRBC.frbc_simple import FRBCSimple frbc = FRBCSimple( @@ -435,81 +444,58 @@ async def handle_power_measurement(self, message: PowerMeasurement): for power_measurement in message.values: commodity_quantity = power_measurement.commodity_quantity.value - if ( - self.power_sensor_id is None - and commodity_quantity == "ELECTRIC.POWER.L1" - ): - sensor_id = 357 - elif self.power_sensor_id: - s_id = self.power_sensor_id.get(commodity_quantity) - if s_id is None: + if self.power_sensor_id: + sensor_id = self.power_sensor_id.get(commodity_quantity) + if sensor_id is None: # TODO: create a new sensor or return ReceptionStatus self._logger.debug( f"No power sensor set up for {commodity_quantity}. Ignoring measurement {power_measurement.value} at {message.measurement_timestamp}." ) continue - sensor_id = s_id else: self._logger.debug( f"No power sensor IDs set up. Ignoring measurement {power_measurement.value} at {message.measurement_timestamp}." ) continue - # Store the value in the buffer - self._power_buffer[commodity_quantity].append( - (message.measurement_timestamp, power_measurement.value) - ) - - # Compute bin - now = datetime.now(self._timezone) - m = self._minimum_measurement_period // pd.Timedelta(minutes=1) - bin_end = now.replace( - second=0, microsecond=0, minute=(now.minute // m) * m - ) # e.g. 10:15:00 - bin_start = bin_end - self._minimum_measurement_period - - # If timer not due, just collect values - if not self._is_timer_due(f"power_measurement_{commodity_quantity}"): - self._logger.debug( - f"Collecting 5-minute average for {commodity_quantity} ({bin_start.isoformat()} – {bin_end.isoformat()})" - ) - continue - - # Compute average of all buffered values in last 5 minutes - buffer = self._power_buffer[commodity_quantity] - period_values = [v for (t, v) in buffer if bin_start <= t < bin_end] - - if not period_values: - self._logger.debug( - f"No samples found for {commodity_quantity} in {bin_start}–{bin_end}, skipping." - ) - continue - - avg_value = sum(period_values) / len(period_values) - self._logger.debug( - f"Posting 5-minute average for {commodity_quantity}: " - f"{avg_value} ({bin_start.isoformat()} – {bin_end.isoformat()})" + # Bin to the SIMULATED measurement timestamp (not wall-clock time): + # this co-sim runs on 2022 simulated time, so datetime.now() would bin + # measurements onto a meaningless (real-world) event_start. + measurement_ts = message.measurement_timestamp + if measurement_ts.tzinfo is None: + # This co-sim treats naive sim times as UTC. + measurement_ts = measurement_ts.replace(tzinfo=timezone.utc) + period = self._minimum_measurement_period + m = period // pd.Timedelta(minutes=1) + bin_start = measurement_ts.replace( + second=0, microsecond=0, minute=(measurement_ts.minute // m) * m ) - # Send measurement + # Post DIRECTLY, without the wall-clock _is_timer_due throttle or the + # 5-minute buffered-averaging that the original real-time streaming path + # used. In this co-simulation the RM sends exactly one already-aggregated + # apartment-power value per simulated step, and simulated time advances at + # its own (non-real-time) pace; gating on datetime.now() would suppress + # almost every post, and re-averaging a single value is a no-op. Each + # value is simply written at its own simulated event_start. try: await self._fm_client.post_sensor_data( sensor_id, start=bin_start.isoformat(), - duration=self._minimum_measurement_period.isoformat(), # TODO: not specified in S2 Protocol - values=[avg_value], - unit=get_commodity_unit(commodity_quantity), + duration=period.isoformat(), # TODO: not specified in S2 Protocol + values=[power_measurement.value], + # S2 PowerMeasurement values are in Watts (unlike this codebase's + # S2 power *ranges*, which carry kW-magnitude values that + # get_commodity_unit labels "kW"). Post as W and let FlexMeasures + # convert to the measured-power sensor's kW unit, so a ~5000 W + # realized load is stored as 5 kW, not 5000. + unit="W", ) except Exception as e: # noqa: B902 - intentional safety net self._logger.debug( f"POSTing power measurement failed with error: {e}" ) - # Keep only samples newer than this bin (for next period) - self._power_buffer[commodity_quantity] = [ - (t, v) for (t, v) in buffer if t >= bin_end - ] - return get_reception_status(message) @register(RevokeObject) diff --git a/src/flexmeasures_client/s2/config_utils.py b/src/flexmeasures_client/s2/config_utils.py index 2425f416..83bac17a 100644 --- a/src/flexmeasures_client/s2/config_utils.py +++ b/src/flexmeasures_client/s2/config_utils.py @@ -16,7 +16,7 @@ async def configure_site( site_name: str, fm_client: FlexMeasuresClient -) -> tuple[dict, dict, dict, dict, dict, dict, dict, dict, dict, dict]: +) -> tuple[dict, dict, dict, dict, dict, dict, dict, dict, dict, dict, dict]: account = await fm_client.get_account() assets = await fm_client.get_assets(parse_json_fields=True) @@ -55,6 +55,7 @@ async def configure_site( price_sensor = None production_price_sensor = None power_sensor = None + measured_power_sensor = None soc_sensor = None rm_discharge_sensor = None soc_minima_sensor = None @@ -69,6 +70,8 @@ async def configure_site( production_price_sensor = sensor elif sensor["name"] == "power": power_sensor = sensor + elif sensor["name"] == "measured-power": + measured_power_sensor = sensor elif sensor["name"] == "state of charge": soc_sensor = sensor elif sensor["name"] == "RM discharge": @@ -137,6 +140,18 @@ async def configure_site( timezone="Europe/Amsterdam", attributes={"consumption_is_positive": True}, ) + if measured_power_sensor is None: + # Dedicated sensor for the REALIZED (measured) aggregated apartment power, + # kept separate from the "power" sensor which holds the StorageScheduler's + # device-power SCHEDULE. Never conflate schedule and measurement on one sensor. + measured_power_sensor = await fm_client.add_sensor( + name="measured-power", + event_resolution="PT15M", + unit="kW", + generic_asset_id=site_asset["id"], + timezone="Europe/Amsterdam", + attributes={"consumption_is_positive": True}, + ) if soc_sensor is None: soc_sensor = await fm_client.add_sensor( name="state of charge", @@ -208,7 +223,7 @@ async def configure_site( }, { "title": "Power", - "sensors": [power_sensor["id"]], + "sensors": [power_sensor["id"], measured_power_sensor["id"]], }, ] await fm_client.update_asset( @@ -227,4 +242,5 @@ async def configure_site( usage_forecast_sensor, leakage_behaviour_sensor, charging_efficiency_sensor, + measured_power_sensor, ) From e44619f0a6d209466f59771c4ceeba2a891f618b Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Mon, 13 Jul 2026 22:30:54 +0200 Subject: [PATCH 154/181] fix: give control-type handlers a default close() so CEM teardown can't crash CEM.close() calls close() on every registered control-type handler when a websocket tears down, but only FRBCTunes implemented it - FRBCSimple (and any other handler) raised AttributeError, which killed the CEM's request handler and left it unable to serve the next RM connection (houses then hang forever waiting for a schedule). Add a default no-op close() on the ControlTypeHandler base class. --- src/flexmeasures_client/s2/control_types/__init__.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/flexmeasures_client/s2/control_types/__init__.py b/src/flexmeasures_client/s2/control_types/__init__.py index 429ad419..a55ff3f9 100644 --- a/src/flexmeasures_client/s2/control_types/__init__.py +++ b/src/flexmeasures_client/s2/control_types/__init__.py @@ -30,6 +30,17 @@ def __init__(self, max_size: int = 100) -> None: self._instruction_history = SizeLimitOrderedDict(max_size=max_size) self._instruction_status_history = SizeLimitOrderedDict(max_size=max_size) + async def close(self): + """Release any resources / stop recurring tasks for this handler. + + Default no-op so CEM.close() (which calls close() on every registered + handler when a websocket tears down) works for any control-type handler. + Subclasses that own recurring tasks override this. Previously FRBCSimple + had no close(), so a websocket teardown raised AttributeError inside + CEM.close(), killing the CEM's request handler and hanging the RM. + """ + self._logger.debug(f"Closing {self.__class__.__name__} handler") + @register(InstructionStatusUpdate) def handle_instruction_status_update(self, message: InstructionStatusUpdate): instruction_id: str = cast(str, message.instruction_id) From 80a7e5156da86a6ace85561c7e5fdc8cd34cbbb3 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Tue, 14 Jul 2026 15:32:17 +0200 Subject: [PATCH 155/181] fix: install and run the CEM with one and the same Python interpreter Images built from FlexMeasures' uv-based Dockerfile put a pip-less /app/.venv first on PATH while bare `pip` is the system one under /usr/local, so packages installed at container start were invisible to the runtime interpreter (ModuleNotFoundError: aiohttp) on any freshly built image - notably Samuel's Windows setups - while long-cached single-Python images kept working. Use `python3 -m pip` throughout (with an ensurepip bootstrap for pip-less venvs) and exec the server with that same python3. Also set the generic SETUPTOOLS_SCM_PRETEND_VERSION alongside the client-specific one for the git-less editable install. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01LQFkeGxDwErydUV5ZmHmki --- docker-compose.override.yml | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/docker-compose.override.yml b/docker-compose.override.yml index 1b0395a9..7761bacd 100644 --- a/docker-compose.override.yml +++ b/docker-compose.override.yml @@ -25,18 +25,25 @@ x-cem: &cem FLEXMEASURES_PASSWORD: toy-password LOGGING_LEVEL: DEBUG SETUPTOOLS_SCM_PRETEND_VERSION_FOR_FLEXMEASURES_CLIENT: "0.0.0" + SETUPTOOLS_SCM_PRETEND_VERSION: "0.0.0" volumes: # If flexmeasures_client lives in your repo and you want live edits - ../flexmeasures-client:/app/flexmeasures-client:rw entrypoint: ["/bin/sh", "-c"] command: - | - # pip install --break-system-packages --no-cache-dir "git+https://github.com/FlexMeasures/flexmeasures-client.git@main#egg=flexmeasures-client[s2]" - pip install --break-system-packages -e /app/flexmeasures-client[s2] - pip install --break-system-packages aiohttp - pip install --break-system-packages pytz - pip install --break-system-packages s2-python==0.8.2 - python3 /app/flexmeasures-client/src/flexmeasures_client/s2/script/websockets_server.py + # Install and run with the SAME interpreter (`python3 -m pip`, not bare `pip`). + # In images built from FlexMeasures' uv-based Dockerfile, `python3` resolves to + # the /app/.venv virtualenv (which ships without pip and cannot see system + # site-packages) while bare `pip` is the system one under /usr/local - so + # packages installed with `pip` are invisible at runtime (ModuleNotFoundError: + # aiohttp). Older cached images had a single system Python, masking this. + # The ensurepip guard bootstraps pip into the venv on fresh images and is a + # no-op wherever `python3 -m pip` already works. + python3 -m pip --version >/dev/null 2>&1 || python3 -m ensurepip --upgrade + python3 -m pip install --break-system-packages -e "/app/flexmeasures-client[s2]" + python3 -m pip install --break-system-packages aiohttp pytz s2-python==0.8.2 + exec python3 /app/flexmeasures-client/src/flexmeasures_client/s2/script/websockets_server.py # Shared definition for all RQ worker instances. Scaled one-per-concurrently- # simulated-apartment, same as the cem-* services and the server's gunicorn From d409b516b5c8d72aeb1c1e23d13a2fbef79f9abd Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Tue, 14 Jul 2026 15:50:16 +0200 Subject: [PATCH 156/181] fix: stop deriving a phantom production capacity from S2 operation modes An S2 PowerRange runs from start_of_range (lower bound) to end_of_range (upper bound), but the capacity derivation introduced in 9f20ab4 (March 2026) read them the other way around: production-capacity became the smallest upper bound across operation modes (e.g. 2.5 kW for a consumption-only heat pump) and consumption-capacity the largest lower bound. The scheduler consequently discharged the heat pump's thermal storage as if it were an electric battery, and capped its consumption below the device's real maximum. Derive the overall [min, max] power range instead, with production only where ranges extend below zero. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01LQFkeGxDwErydUV5ZmHmki --- .../s2/control_types/FRBC/frbc_simple.py | 28 ++++++++++--------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py index 9ce27487..22cb7a3a 100644 --- a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py +++ b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py @@ -196,23 +196,25 @@ async def trigger_schedule( # Assume a single actuator actuator = system_description.actuators[0] - # Derive the overall power range - charging_capacity = None - discharging_capacity = None + # Derive the overall power range. + # NB an S2 PowerRange runs from start_of_range (lower bound) to + # end_of_range (upper bound). The device can only produce if some + # range extends below zero; a consumption-only device (e.g. a heat + # pump) must get a zero production-capacity, or the scheduler will + # happily "discharge" its thermal storage as if it were an electric + # battery. + overall_min = None + overall_max = None for operation_mode in actuator.operation_modes: for element in operation_mode.elements: for power_range in element.power_ranges: # todo: distinguish power range per commodity - p_min = power_range.end_of_range - if discharging_capacity is None: - discharging_capacity = p_min - else: - discharging_capacity = min(discharging_capacity, p_min) - p_max = power_range.start_of_range - if charging_capacity is None: - charging_capacity = p_max - else: - charging_capacity = max(charging_capacity, p_max) + lo = power_range.start_of_range + hi = power_range.end_of_range + overall_min = lo if overall_min is None else min(overall_min, lo) + overall_max = hi if overall_max is None else max(overall_max, hi) + charging_capacity = max(overall_max, 0) + discharging_capacity = max(-min(overall_min, 0), 0) # Translate the S2 operation modes into FM power bands (the flex-model's # "operation-modes" field): one band per operation-mode element power From a7f154aaf926d341e624800d62d112e75b4c410b Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Tue, 14 Jul 2026 21:09:17 +0200 Subject: [PATCH 157/181] Fix CEM co-sim belief time, timezone anchor, and live aggregate-power realizations Belief time (defect 4b): PowerMeasurements posted by the CEM now carry a simulated prior (the binned interval's end), so realized apartment power is stamped at simulation time instead of wall-clock 2026, and the UI horizon view no longer shows data "recorded in 2026". Timezone (defect 2): naive S2 measurement timestamps are now localized as Europe/Amsterdam (was UTC), matching the community orchestrator, RM and controller which all localize the same naive sim times as Europe/Amsterdam. Live apartment realizations (defect 4a): besides the dedicated measured-power sensor, the CEM now also mirrors the realized apartment aggregate onto the apartment's flex-context "aggregate-power" sensor, resolved lazily from the asset's flex_context (it is attached by the community runner only after the CEM connects, so it appears from step 1). Realized posts stay distinguishable from the StorageScheduler schedule data on that same sensor by their own source and simulated belief time. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01LQFkeGxDwErydUV5ZmHmki --- src/flexmeasures_client/s2/cem.py | 111 ++++++++++++++++++++++++------ 1 file changed, 91 insertions(+), 20 deletions(-) diff --git a/src/flexmeasures_client/s2/cem.py b/src/flexmeasures_client/s2/cem.py index a4e9b7e0..728d385b 100644 --- a/src/flexmeasures_client/s2/cem.py +++ b/src/flexmeasures_client/s2/cem.py @@ -5,8 +5,9 @@ import logging import math from collections import defaultdict -from datetime import datetime, timedelta, timezone +from datetime import datetime, timedelta from logging import Logger +from zoneinfo import ZoneInfo from typing import Dict, Optional import pandas as pd @@ -93,6 +94,13 @@ def __init__( self._sending_queue = asyncio.Queue() self._power_sensors = dict() self.power_sensor_id = power_sensor_id + # The apartment's FlexMeasures asset id (set once mapped), and the id of its + # flex-context "aggregate-power" sensor (attached by the community runner AFTER + # this CEM connects, i.e. only resolvable from step 1 onward). Resolved lazily in + # handle_power_measurement so realized apartment power lands on the aggregate-power + # sensor too, not only on measured-power (defect 4a: live apartment realizations). + self._apartment_asset_id: int | None = None + self._aggregate_power_sensor_id: int | None = None self._control_types_handlers = dict() self._default_control_type = default_control_type @@ -422,6 +430,13 @@ async def map_resource_to_asset(self, message): self.power_sensor_id = {} self.power_sensor_id["ELECTRIC.POWER.L1"] = measured_power_sensor["id"] + # Remember the apartment asset so handle_power_measurement can lazily resolve its + # flex-context "aggregate-power" sensor (attached by the community runner only + # after this CEM has connected). Resetting the cached sensor id lets a + # reconnect/reconfigure pick up a freshly-attached aggregate-power sensor. + self._apartment_asset_id = asset["id"] + self._aggregate_power_sensor_id = None + from flexmeasures_client.s2.control_types.FRBC.frbc_simple import FRBCSimple frbc = FRBCSimple( @@ -463,13 +478,34 @@ async def handle_power_measurement(self, message: PowerMeasurement): # measurements onto a meaningless (real-world) event_start. measurement_ts = message.measurement_timestamp if measurement_ts.tzinfo is None: - # This co-sim treats naive sim times as UTC. - measurement_ts = measurement_ts.replace(tzinfo=timezone.utc) + # This co-sim treats naive sim times as Europe/Amsterdam (kept + # consistent with the community orchestrator, RM and controller, + # which all localize the same naive sim times as Europe/Amsterdam). + measurement_ts = measurement_ts.replace( + tzinfo=ZoneInfo("Europe/Amsterdam") + ) period = self._minimum_measurement_period m = period // pd.Timedelta(minutes=1) bin_start = measurement_ts.replace( second=0, microsecond=0, minute=(measurement_ts.minute // m) * m ) + # Belief time = the SIMULATED instant the measurement became known (just + # after its interval elapses). Without this, FlexMeasures stamps the belief + # time at wall-clock now (2026), so the UI's horizon view shows realized data + # "recorded in 2026" instead of at simulation time (defect 4b). + prior = (bin_start + period).isoformat() + + # Resolve the apartment's flex-context "aggregate-power" sensor lazily: it is + # attached by the community runner AFTER this CEM connects, so it only exists + # from step 1 onward. When present, mirror the realized value onto it too, so + # advancing the sim produces live apartment realizations on the aggregate-power + # sensor (defect 4a) - not just on the dedicated measured-power sensor. That + # sensor also carries StorageScheduler SCHEDULE data natively; the realized + # posts stay distinguishable by their own (CEM/user) source and simulated + # belief time. Only mirror ELECTRIC.POWER.L1 (the aggregated apartment power). + aggregate_sensor_id = None + if commodity_quantity == "ELECTRIC.POWER.L1": + aggregate_sensor_id = await self._resolve_aggregate_power_sensor_id() # Post DIRECTLY, without the wall-clock _is_timer_due throttle or the # 5-minute buffered-averaging that the original real-time streaming path @@ -478,26 +514,61 @@ async def handle_power_measurement(self, message: PowerMeasurement): # its own (non-real-time) pace; gating on datetime.now() would suppress # almost every post, and re-averaging a single value is a no-op. Each # value is simply written at its own simulated event_start. - try: - await self._fm_client.post_sensor_data( - sensor_id, - start=bin_start.isoformat(), - duration=period.isoformat(), # TODO: not specified in S2 Protocol - values=[power_measurement.value], - # S2 PowerMeasurement values are in Watts (unlike this codebase's - # S2 power *ranges*, which carry kW-magnitude values that - # get_commodity_unit labels "kW"). Post as W and let FlexMeasures - # convert to the measured-power sensor's kW unit, so a ~5000 W - # realized load is stored as 5 kW, not 5000. - unit="W", - ) - except Exception as e: # noqa: B902 - intentional safety net - self._logger.debug( - f"POSTing power measurement failed with error: {e}" - ) + target_sensor_ids = [sensor_id] + if aggregate_sensor_id is not None and aggregate_sensor_id != sensor_id: + target_sensor_ids.append(aggregate_sensor_id) + for target_sensor_id in target_sensor_ids: + try: + await self._fm_client.post_sensor_data( + target_sensor_id, + start=bin_start.isoformat(), + duration=period.isoformat(), # TODO: not specified in S2 Protocol + values=[power_measurement.value], + # S2 PowerMeasurement values are in Watts (unlike this codebase's + # S2 power *ranges*, which carry kW-magnitude values that + # get_commodity_unit labels "kW"). Post as W and let FlexMeasures + # convert to the sensor's kW unit, so a ~5000 W realized load is + # stored as 5 kW, not 5000. + unit="W", + prior=prior, + ) + except Exception as e: # noqa: B902 - intentional safety net + self._logger.debug( + f"POSTing power measurement failed with error: {e}" + ) return get_reception_status(message) + async def _resolve_aggregate_power_sensor_id(self) -> int | None: + """Lazily resolve the apartment's flex-context "aggregate-power" sensor id. + + The community runner attaches this sensor (and the flex-context key) only after + the CEM has connected, so it is absent during step 0 and appears from step 1 + onward. We re-read the apartment asset's flex_context until the key is present, + then cache the id. Returns None (and posts nothing extra) while it is absent. + """ + if self._aggregate_power_sensor_id is not None: + return self._aggregate_power_sensor_id + if self._apartment_asset_id is None: + return None + try: + asset = await self._fm_client.get_asset( + self._apartment_asset_id, parse_json_fields=True + ) + flex_context = asset.get("flex_context") or {} + entry = flex_context.get("aggregate-power") + if isinstance(entry, dict): + sensor_id = entry.get("sensor") + if sensor_id is not None: + self._aggregate_power_sensor_id = int(sensor_id) + return self._aggregate_power_sensor_id + except Exception as e: # noqa: B902 - best-effort, never break measurement posting + self._logger.debug( + f"Could not resolve aggregate-power sensor for asset " + f"{self._apartment_asset_id}: {e}" + ) + return None + @register(RevokeObject) def handle_revoke_object(self, message: RevokeObject): """ From cfd562e2f88027836063f9307bb5733cebba6e41 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Wed, 15 Jul 2026 21:44:15 +0200 Subject: [PATCH 158/181] fix: stamp power measurements with zero belief horizon The RM sends one PowerMeasurement per 15-minute interval (stamped at interval start), but the posting path binned and stamped priors with the 5-minute _minimum_measurement_period, yielding belief times 10 minutes before each event's end - an ex-ante horizon on measurements. Bin and stamp at the measured-power sensor's 15-minute event resolution instead, so each realized value is believed exactly when its interval elapses (zero horizon). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01LQFkeGxDwErydUV5ZmHmki --- src/flexmeasures_client/s2/cem.py | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/src/flexmeasures_client/s2/cem.py b/src/flexmeasures_client/s2/cem.py index 728d385b..8dad8545 100644 --- a/src/flexmeasures_client/s2/cem.py +++ b/src/flexmeasures_client/s2/cem.py @@ -484,15 +484,21 @@ async def handle_power_measurement(self, message: PowerMeasurement): measurement_ts = measurement_ts.replace( tzinfo=ZoneInfo("Europe/Amsterdam") ) - period = self._minimum_measurement_period + # Bin and stamp at the measured-power sensor's own 15-minute event + # resolution (the RM sends one value per 15-minute interval, stamped at + # the interval START). Using the 5-minute _minimum_measurement_period + # here made prior = bin_start + 5min, i.e. a belief 10 minutes BEFORE + # the event's end - an ex-ante horizon on what is a measurement. With + # the event resolution, prior = the interval's end: zero belief horizon. + period = pd.Timedelta(minutes=15) m = period // pd.Timedelta(minutes=1) bin_start = measurement_ts.replace( second=0, microsecond=0, minute=(measurement_ts.minute // m) * m ) - # Belief time = the SIMULATED instant the measurement became known (just - # after its interval elapses). Without this, FlexMeasures stamps the belief - # time at wall-clock now (2026), so the UI's horizon view shows realized data - # "recorded in 2026" instead of at simulation time (defect 4b). + # Belief time = the SIMULATED instant the measurement became known (the + # moment its interval elapses). Without this, FlexMeasures stamps the + # belief time at wall-clock now (2026), so the UI's horizon view shows + # realized data "recorded in 2026" instead of at simulation time. prior = (bin_start + period).isoformat() # Resolve the apartment's flex-context "aggregate-power" sensor lazily: it is From 89b5893c04b92a3c7b63b0d57fbabd283a50b046 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Wed, 15 Jul 2026 23:11:00 +0200 Subject: [PATCH 159/181] fix: hard-bound the FRBC heat pump against production An explicit production-capacity of 0 kW gets softened by FlexMeasures' relax-constraints defaults (100 EUR/kW device breach price), so under a tight community capacity the scheduler chose to 'produce' 1.764 kW from a heat pump rather than pay the 10000 EUR/kW site breach. Setting is_strictly_non_positive on the FRBC power sensor keeps production hard-bounded at zero regardless of relaxation settings. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01LQFkeGxDwErydUV5ZmHmki --- src/flexmeasures_client/s2/config_utils.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/flexmeasures_client/s2/config_utils.py b/src/flexmeasures_client/s2/config_utils.py index 83bac17a..50a80f73 100644 --- a/src/flexmeasures_client/s2/config_utils.py +++ b/src/flexmeasures_client/s2/config_utils.py @@ -138,7 +138,16 @@ async def configure_site( unit="kW", generic_asset_id=site_asset["id"], timezone="Europe/Amsterdam", - attributes={"consumption_is_positive": True}, + # is_strictly_non_positive marks this FRBC device (a heat pump) as + # physically unable to produce: FlexMeasures then keeps production + # hard-bounded at zero even when relax-constraints turns directional + # capacities into soft, breach-priced constraints. Without it, a + # tight site capacity made the scheduler "produce" from the heat + # pump (cheap device breach vs expensive site breach). + attributes={ + "consumption_is_positive": True, + "is_strictly_non_positive": True, + }, ) if measured_power_sensor is None: # Dedicated sensor for the REALIZED (measured) aggregated apartment power, From ec0b1039d553193248b7ad258c5fc144c98aa58a Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Fri, 24 Jul 2026 16:22:42 +0200 Subject: [PATCH 160/181] fix: cap exponential polling backoff at MAX_POLLING_SLEEP Later polling steps slept for polling_interval * 2**step seconds (minutes), dozing far past the moment a schedule became available - and taking just as long to conclude it never would. Cap each sleep at 10s so both success and failure are detected promptly. Co-Authored-By: Claude Fable 5 Signed-off-by: F.N. Claessen --- src/flexmeasures_client/client.py | 5 ++++- src/flexmeasures_client/constants.py | 6 ++++++ src/flexmeasures_client/response_handling.py | 9 +++++++-- 3 files changed, 17 insertions(+), 3 deletions(-) diff --git a/src/flexmeasures_client/client.py b/src/flexmeasures_client/client.py index bbe115d2..17423c5c 100644 --- a/src/flexmeasures_client/client.py +++ b/src/flexmeasures_client/client.py @@ -252,7 +252,10 @@ async def request( if response.status < 300: break except asyncio.TimeoutError: - sleep_interval = self.polling_interval * (2**polling_step) + sleep_interval = min( + self.polling_interval * (2**polling_step), + MAX_POLLING_SLEEP, + ) message = f"Client request timeout occurred while connecting to the API. Polling step: {polling_step}. Retrying in {sleep_interval} seconds..." # noqa: E501 self.logger.debug(message) polling_step += 1 diff --git a/src/flexmeasures_client/constants.py b/src/flexmeasures_client/constants.py index 60e832be..573a4e08 100644 --- a/src/flexmeasures_client/constants.py +++ b/src/flexmeasures_client/constants.py @@ -3,3 +3,9 @@ "Content-Type": CONTENT_TYPE, } API_VERSION = "v3_0" + +# Ceiling for the exponential backoff between polls (see client.py / +# response_handling.py): without it, later polling steps sleep for minutes +# (polling_interval * 2**step), dozing far past the moment a result becomes +# available - and taking just as long to conclude that it never will. +MAX_POLLING_SLEEP = 10.0 # seconds diff --git a/src/flexmeasures_client/response_handling.py b/src/flexmeasures_client/response_handling.py index b665c702..74ec65d0 100644 --- a/src/flexmeasures_client/response_handling.py +++ b/src/flexmeasures_client/response_handling.py @@ -8,6 +8,7 @@ from yarl import URL from flexmeasures_client.constants import CONTENT_TYPE +from flexmeasures_client.constants import MAX_POLLING_SLEEP if TYPE_CHECKING: # Only imports the below statements during type checking from flexmeasures_client.client import FlexMeasuresClient @@ -43,7 +44,9 @@ async def check_response( or "Scheduling job has an unknown status" in payload.get("message", "") ): # can be removed in a later version GH issue #645 of the FlexMeasures repo - sleep_interval = self.polling_interval * (2**polling_step) + sleep_interval = min( + self.polling_interval * (2**polling_step), MAX_POLLING_SLEEP + ) message = f"Server indicated to try again later. Retrying in {sleep_interval} seconds..." # noqa: E501 self.logger.debug(message) polling_step += 1 @@ -58,7 +61,9 @@ async def check_response( await self.get_access_token() reauth_once = False elif status == 503 and "Retry-After" in headers: - sleep_interval = self.polling_interval * (2**polling_step) + sleep_interval = min( + self.polling_interval * (2**polling_step), MAX_POLLING_SLEEP + ) polling_step += 1 await asyncio.sleep(sleep_interval) elif payload.get("errors"): From edac914ac65de2a3a8f9fd02e0051f7e1bbf4767 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Fri, 24 Jul 2026 16:52:24 +0200 Subject: [PATCH 161/181] fix: raise MAX_POLLING_STEPS so polling_timeout governs patience With backoff sleeps capped at 10s, 10 polling steps amounted to only ~90s of patience - less than a time-limited (120s) scheduling job - so clients gave up on schedules that were about to arrive. 30 steps puts the ceiling above polling_timeout (200s), which is the intended budget. Co-Authored-By: Claude Fable 5 Signed-off-by: F.N. Claessen --- src/flexmeasures_client/client.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/flexmeasures_client/client.py b/src/flexmeasures_client/client.py index 17423c5c..36a6d09a 100644 --- a/src/flexmeasures_client/client.py +++ b/src/flexmeasures_client/client.py @@ -35,7 +35,10 @@ LOGGER = logging.getLogger(__name__) -MAX_POLLING_STEPS: int = 10 # seconds +# With each backoff sleep capped at MAX_POLLING_SLEEP, the number of steps is +# what bounds total patience (~step count x 10s); keep it high enough that +# polling_timeout (200s), not the step count, is the effective budget. +MAX_POLLING_STEPS: int = 30 POLLING_TIMEOUT = 200.0 # seconds REQUEST_TIMEOUT = 40.0 # seconds POLLING_INTERVAL = 10.0 # seconds From 65849dfa9dc943408b56fc304af43e386a5994dc Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Fri, 24 Jul 2026 18:02:26 +0200 Subject: [PATCH 162/181] fix: widen hard soc bounds to include the current state The scalar soc-min/soc-max are hard bounds server-side; only the soc-minima/maxima profiles are softened by relax-soc-constraints. The FRBC fill range is a comfort band and the realized state can drift outside it (community steering keeping the heat pump off drains the buffer below the comfort floor), which made every subsequent day's schedule hard-infeasible. Clamp the hard band around the current state and leave comfort steering to the soft profiles. Co-Authored-By: Claude Fable 5 Signed-off-by: F.N. Claessen --- .../s2/control_types/FRBC/frbc_simple.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py index 22cb7a3a..517589e7 100644 --- a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py +++ b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py @@ -273,6 +273,16 @@ async def trigger_schedule( # via py-spy: time was spent inside the solver itself, not a Python hang). "relax-site-capacity-constraints": True, } + # The scalar soc-min/soc-max are HARD bounds server-side (only the + # soc-minima/maxima profiles are softened by relax-soc-constraints). + # The FRBC fill range they derive from is a comfort band, and the + # realized state can drift outside it (e.g. community steering keeps + # the heat pump off long enough to drain the buffer below the comfort + # floor). A start state outside hard bounds makes the whole problem + # infeasible, so widen the hard band to include the current state and + # leave comfort steering to the soft minima/maxima profiles. + soc_min = min(soc_min, soc_at_start) + soc_max = max(soc_max, soc_at_start) flex_model = { "soc-unit": energy_unit, "soc-at-start": soc_at_start, From 19ff07568268e1777691acb0289e88e9e1c5d941 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Fri, 24 Jul 2026 19:24:06 +0200 Subject: [PATCH 163/181] fix: raise schedule polling budget to 360s Under multi-house load a scheduling job can queue behind a full wave of solver-time-limited (120s) jobs, so 200s of polling gave up on schedules that were one wave away. 360s covers two waves plus overhead; step count raised to keep the per-sleep cap (10s) from binding first. Co-Authored-By: Claude Fable 5 Signed-off-by: F.N. Claessen --- src/flexmeasures_client/client.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/flexmeasures_client/client.py b/src/flexmeasures_client/client.py index 36a6d09a..cbbc87b8 100644 --- a/src/flexmeasures_client/client.py +++ b/src/flexmeasures_client/client.py @@ -38,8 +38,8 @@ # With each backoff sleep capped at MAX_POLLING_SLEEP, the number of steps is # what bounds total patience (~step count x 10s); keep it high enough that # polling_timeout (200s), not the step count, is the effective budget. -MAX_POLLING_STEPS: int = 30 -POLLING_TIMEOUT = 200.0 # seconds +MAX_POLLING_STEPS: int = 40 +POLLING_TIMEOUT = 360.0 # seconds REQUEST_TIMEOUT = 40.0 # seconds POLLING_INTERVAL = 10.0 # seconds API_VERSIONS_LIST = ["v3_0"] From 6adb15e2e47f92416571b7002e6b018df3784f4b Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Mon, 27 Jul 2026 13:38:15 +0200 Subject: [PATCH 164/181] feat: optional 'metadata' field on schedule triggers Opaque orchestration metadata passed alongside the domain payload (a sibling of flex-model/flex-context, mirroring the S2 wrapper convention). Passed through verbatim to servers that accept it. Signed-off-by: F.N. Claessen --- src/flexmeasures_client/client.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/flexmeasures_client/client.py b/src/flexmeasures_client/client.py index cbbc87b8..dceed3bd 100644 --- a/src/flexmeasures_client/client.py +++ b/src/flexmeasures_client/client.py @@ -1362,7 +1362,12 @@ async def trigger_schedule( asset_id: int | None = None, prior: datetime | None = None, scheduler: str | None = None, + metadata: dict | None = None, ) -> str: + """metadata: opaque orchestration metadata passed alongside the domain + payload (a sibling of flex-model/flex-context, mirroring the S2 wrapper + convention). Requires a server that accepts the trigger "metadata" + field; it is passed through verbatim to the Scheduler.""" if (sensor_id is None) == (asset_id is None): raise ValueError("Pass either a sensor_id or an asset_id.") message = { @@ -1375,6 +1380,8 @@ async def trigger_schedule( message["flex-model"] = flex_model if flex_context is not None: message["flex-context"] = flex_context + if metadata is not None: + message["metadata"] = metadata if prior is not None: message["prior"] = pd.Timestamp(prior).isoformat() From ba7f865649e0a29efc77597587e526dd523736fd Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Mon, 27 Jul 2026 13:38:39 +0200 Subject: [PATCH 165/181] fix: activate FRBC implicitly when the RM sends FRBC messages Per S2, an RM only sends FRBC.* after accepting SelectControlType, so such a message proves activation. A dropped ReceptionStatus previously wedged the CEM in NO_SELECTION, bouncing every FRBC.SystemDescription with TEMPORARY_ERROR while the RM retried forever (in-vivo hang, 2026-07-26). With regression test. Signed-off-by: F.N. Claessen --- src/flexmeasures_client/s2/cem.py | 27 ++++++++++++++++ tests/s2/test_cem.py | 54 +++++++++++++++++++++++++++++++ 2 files changed, 81 insertions(+) diff --git a/src/flexmeasures_client/s2/cem.py b/src/flexmeasures_client/s2/cem.py index 8dad8545..ab91207c 100644 --- a/src/flexmeasures_client/s2/cem.py +++ b/src/flexmeasures_client/s2/cem.py @@ -215,6 +215,33 @@ async def handle_message(self, message: Dict | pydantic.BaseModel | str): else: self._logger.debug(f"Received: {message}") + # Implicit control-type activation: per the S2 protocol, an RM only + # sends control-type-specific messages (e.g. FRBC.*) AFTER it has + # accepted our SelectControlType - so such a message is itself proof + # that the control type is active on the RM side. Without this, a + # dropped/mis-routed ReceptionStatus for SelectControlType left the + # CEM stuck in NO_SELECTION forever: every FRBC.SystemDescription was + # answered with TEMPORARY_ERROR, the RM retried indefinitely, and the + # whole simulation hung at its first barrier (observed in vivo, + # 2026-07-26, house APP_4). + message_type = ( + message.get("message_type", "") if isinstance(message, dict) else "" + ) + if ( + self._control.control_type in (None, ControlType.NO_SELECTION) + and isinstance(message_type, str) + and message_type.startswith("FRBC.") + and ControlType.FILL_RATE_BASED_CONTROL in self._control_types_handlers + ): + self._logger.warning( + "Received %s while no control type is active; the RM only " + "sends FRBC messages after accepting SelectControlType, so " + "activating FILL_RATE_BASED_CONTROL implicitly (the " + "SelectControlType confirmation was presumably lost).", + message_type, + ) + self._control.control_type = ControlType.FILL_RATE_BASED_CONTROL + # try to handle the message with the control_type handle ct = self._control.control_type handler = self._control_types_handlers.get(ct) diff --git a/tests/s2/test_cem.py b/tests/s2/test_cem.py index 2b997efe..3f7225d1 100644 --- a/tests/s2/test_cem.py +++ b/tests/s2/test_cem.py @@ -91,6 +91,60 @@ async def test_resource_manager_details(resource_manager_details, rm_handshake): pass +@pytest.mark.asyncio +async def test_frbc_message_implicitly_activates_frbc( + frbc_system_description, resource_manager_details, rm_handshake +): + """A lost SelectControlType confirmation must not wedge the CEM. + + Per the S2 protocol, an RM only sends control-type-specific messages + (FRBC.*) AFTER it has accepted the CEM's SelectControlType - so an + incoming FRBC message is itself proof of activation. Without implicit + activation, a dropped/mis-routed ReceptionStatus left the CEM in + NO_SELECTION forever, answering every FRBC.SystemDescription with + TEMPORARY_ERROR while the RM retried indefinitely (in-vivo simulation + hang, 2026-07-26).""" + cem = CEM(fm_client=None) + frbc = FRBCTest() + cem.register_control_type(frbc) + + await cem.handle_message(rm_handshake) + await cem.get_message() # ReceptionStatus for Handshake + await cem.get_message() # HandshakeResponse + await cem.handle_message(resource_manager_details) + await cem.get_message() # ReceptionStatus for ResourceManagerDetails + + # The CEM requests FRBC, but the RM's OK confirmation never arrives. + await cem.activate_control_type(ControlType.FILL_RATE_BASED_CONTROL) + await cem.get_message() # the SelectControlType going out to the RM + assert cem.control_type == ControlType.NO_SELECTION + + # The RM - which did activate FRBC on its side - sends its system + # description anyway. + await cem.handle_message(frbc_system_description) + response, _ = await cem.get_message() + + assert ( + cem.control_type == ControlType.FILL_RATE_BASED_CONTROL + ), "an incoming FRBC message implies the RM accepted FRBC" + assert response["message_type"] == "ReceptionStatus" + assert response["status"] == "OK", ( + "the system description must be accepted, not bounced with" + " TEMPORARY_ERROR" + ) + + # Cleanup: cancel any pending background tasks + for task in list(cem._handler_build_tasks.values()) + list( + frbc.background_tasks + ): + if not task.done(): + task.cancel() + try: + await task + except asyncio.CancelledError: + pass + + @pytest.mark.asyncio async def test_activate_control_type( frbc_system_description, resource_manager_details, rm_handshake From c519d595521c1286fd47a277710b80811a898b50 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Mon, 27 Jul 2026 13:38:39 +0200 Subject: [PATCH 166/181] feat(FRBC): supersession, sysdesc dedupe, transition-filtered instruction batches, sent-instruction registry Latest-request-wins generation counters kill backlogged handler tasks (observed storm: 96 -> 2,225 instructions/burst, ack starvation, RM retry loops, 800 s control misses); content-hash dedupe of re-sent system descriptions; instruction batches carry only state CHANGES (the RM holds state, fm-s2 applies the same filter); sent-instruction registry with InstructionStatusUpdate tracking and opt-in RevokeObject on batch replacement (send_revocations, off by default for RMs without revoke handling). Six new tests. Signed-off-by: F.N. Claessen --- .../s2/control_types/FRBC/__init__.py | 170 ++++++++++++++- .../s2/control_types/FRBC/frbc_simple.py | 34 ++- tests/s2/test_frbc_registry.py | 203 ++++++++++++++++++ 3 files changed, 399 insertions(+), 8 deletions(-) create mode 100644 tests/s2/test_frbc_registry.py diff --git a/src/flexmeasures_client/s2/control_types/FRBC/__init__.py b/src/flexmeasures_client/s2/control_types/FRBC/__init__.py index c8710a5b..691d8616 100644 --- a/src/flexmeasures_client/s2/control_types/FRBC/__init__.py +++ b/src/flexmeasures_client/s2/control_types/FRBC/__init__.py @@ -3,7 +3,14 @@ import pydantic try: - from s2python.common import ControlType, ReceptionStatus, ReceptionStatusValues + from s2python.common import ( + ControlType, + InstructionStatusUpdate, + ReceptionStatus, + ReceptionStatusValues, + RevokableObjects, + RevokeObject, + ) from s2python.frbc import ( FRBCActuatorStatus, FRBCFillLevelTargetProfile, @@ -62,10 +69,150 @@ def __init__(self, max_size: int = 100) -> None: self._usage_forecast_history = SizeLimitOrderedDict(max_size=max_size) self.background_tasks = set() + # --- Outstanding-work registry (2026-07, instruction-storm fix) --- + # Supersession counters: one per request kind (e.g. "storage_status"). + # Every incoming request bumps its counter; a spawned handler task + # records the value and no-ops if a newer request has arrived by the + # time it runs (or, for long chains, re-checks before queueing + # instructions). In co-simulation, retrigger rounds share one + # simulated timestamp while steering signals change server-side, so + # time-based throttles (live-system style, see the TUNES handler's + # timers) cannot distinguish stale work from fresh work - only + # request ordering can. Without this, backlogged handler tasks each + # completed a full FlexMeasures round trip and dumped a full + # instruction batch, hours-stale batches included (observed in vivo: + # 96 -> 2,225 instructions per burst, ack starvation on the shared + # sending queue, RM retry storms, and 800 s control timeouts). + self._supersession_counters: dict[str, int] = {} + # Instructions of the CURRENT batch, by message_id (cleared when a + # new batch replaces them), plus any status updates the RM reports. + # Modeled after the flexmeasures-s2 plugin's ConnectionState registry. + self._sent_instructions: dict[str, FRBCInstruction] = {} + self._instruction_statuses: dict[str, str] = {} + # Whether replacing a batch also sends RevokeObject for the previous + # batch's instructions. Off by default: not every RM implements + # RevokeObject handling (the FLEXED co-simulation RM does not; it + # keys instructions to its current request and drops late ones). + # Live deployments with a compliant RM should enable this. + self.send_revocations: bool = False + + def _bump_generation(self, key: str) -> int: + """Register a new request of the given kind; newer requests supersede + older ones (see _is_current_generation).""" + generation = self._supersession_counters.get(key, 0) + 1 + self._supersession_counters[key] = generation + return generation + + def _is_current_generation(self, key: str, generation: int) -> bool: + return self._supersession_counters.get(key, 0) == generation + + @staticmethod + def filter_instruction_transitions( + instructions: list[FRBCInstruction], + ) -> list[FRBCInstruction]: + """Keep only instructions that CHANGE the actuator's state. + + FRBC instructions are switch commands: an instruction that repeats + the previous instruction's (actuator, operation mode, factor) is a + no-op for the RM, which holds its state until told otherwise (the + flexmeasures-s2 plugin applies the same filter server-side). One + instruction per 15-min slot of a 24 h schedule is thus typically + 90%+ redundant traffic on the serial S2 link. + """ + filtered: list[FRBCInstruction] = [] + last_state: dict = {} + for instruction in instructions: + state = ( + str(instruction.operation_mode), + float(instruction.operation_mode_factor), + ) + actuator = str(instruction.actuator_id) + if last_state.get(actuator) == state: + continue + last_state[actuator] = state + filtered.append(instruction) + return filtered + + async def send_instruction_batch( + self, + instructions: list[FRBCInstruction], + supersession_key: str | None = None, + generation: int | None = None, + ) -> int: + """Replace the outstanding instruction batch with a new one. + + Applies the transition filter, optionally revokes the previous + batch (send_revocations), checks supersession one last time right + before queueing (a newer request may have arrived while the + schedule was being computed - queueing a stale batch would let the + RM file it under its CURRENT request), sends, and records the new + batch in the registry. Returns the number of instructions sent. + """ + filtered = self.filter_instruction_transitions(instructions) + if ( + supersession_key is not None + and generation is not None + and not self._is_current_generation(supersession_key, generation) + ): + self._logger.info( + f"Dropping stale instruction batch ({len(filtered)} instructions " + f"after transition-filtering {len(instructions)}): a newer " + f"'{supersession_key}' request has since arrived." + ) + return 0 + if self.send_revocations: + for message_id in list(self._sent_instructions): + status = self._instruction_statuses.get(message_id, "NEW") + if status in ("NEW", "ACCEPTED"): + await self.send_message( + RevokeObject( + message_id=get_unique_id(), + object_type=RevokableObjects.FRBC_Instruction, + object_id=self._sent_instructions[message_id].id, + ) + ) + self._sent_instructions = {} + self._instruction_statuses = {} + for instruction in filtered: + await self.send_message(instruction) + self._sent_instructions[str(instruction.message_id)] = instruction + self._logger.debug( + f"Sent instruction batch: {len(filtered)} instructions " + f"({len(instructions)} before transition-filtering)." + ) + return len(filtered) + + @register(InstructionStatusUpdate) + def handle_instruction_status_update( + self, message: InstructionStatusUpdate + ) -> pydantic.BaseModel: + """Track the RM's reported status of outstanding instructions, so a + batch replacement only revokes instructions that are still pending.""" + self._instruction_statuses[str(message.instruction_id)] = str( + getattr(message.status_type, "value", message.status_type) + ) + return get_reception_status(message, status=ReceptionStatusValues.OK) + @register(FRBCSystemDescription) def handle_system_description( self, message: FRBCSystemDescription ) -> pydantic.BaseModel: + # Content-hash dedupe (lifted from the TUNES handler): RMs commonly + # re-send their (static) system description with every request + # payload, under a fresh message_id each time. Re-processing an + # unchanged description would spawn a redundant schedule trigger + # per request on top of the storage status's trigger - half of the + # instruction-storm amplification observed in co-simulation. + message_dict = message.to_dict() + message_dict.pop("message_id") + system_description_hash = hash(str(message_dict)) + if getattr(self, "_last_system_description_hash", 0) == system_description_hash: + self._logger.debug( + "Ignoring re-sent system description (content unchanged)." + ) + return get_reception_status(message, status=ReceptionStatusValues.OK) + self._last_system_description_hash = system_description_hash + system_description_id = str(message.message_id) # store system_description message for later @@ -201,7 +348,26 @@ def handle_storage_status(self, message: FRBCStorageStatus) -> pydantic.BaseMode self._storage_status_history[message_id] = message - task = asyncio.create_task(self.send_storage_status(message)) + # Latest-request-wins: each storage status is a (re)planning request; + # if several arrive while earlier ones still await the event loop + # (backlog under load, or an RM retry re-sending under a fresh + # message_id), only the newest may do the heavy work - the older + # requests are superseded and their tasks no-op. Subclasses whose + # send_storage_status runs a long chain should re-check with + # _is_current_generation before queueing instructions (see + # send_instruction_batch). + generation = self._bump_generation("storage_status") + + async def run_if_current(): + if not self._is_current_generation("storage_status", generation): + self._logger.info( + "Skipping superseded storage-status request " + f"(generation {generation})." + ) + return + await self.send_storage_status(message) + + task = asyncio.create_task(run_if_current()) self.background_tasks.add( task ) # important to avoid a task disappearing mid-execution. diff --git a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py index 517589e7..69ddf6b4 100644 --- a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py +++ b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py @@ -115,6 +115,12 @@ async def send_storage_status(self, status: FRBCStorageStatus): # dropped (only surfacing as "Task exception was never retrieved" once # the task is garbage collected). Log explicitly so nothing is missed. now = self.now() + # Snapshot this request's generation: the schedule round trip below + # takes tens of seconds, during which a newer storage status (= a + # newer planning request) may arrive. The batch send re-checks this + # so a stale batch is dropped instead of being filed by the RM under + # its CURRENT request (see FRBC.send_instruction_batch). + generation = self._supersession_counters.get("storage_status", 0) try: self._logger.debug( f"HANGDEBUG send_storage_status: posting SoC to sensor {self._soc_sensor_id}" @@ -130,7 +136,7 @@ async def send_storage_status(self, status: FRBCStorageStatus): self._logger.debug( "HANGDEBUG send_storage_status: SoC posted, calling trigger_schedule" ) - await self.trigger_schedule(now) + await self.trigger_schedule(now, generation=generation) self._logger.debug("HANGDEBUG send_storage_status: trigger_schedule returned") except Exception: self._logger.exception("HANGDEBUG send_storage_status: raised") @@ -158,7 +164,10 @@ async def send_actuator_status(self, status: FRBCActuatorStatus): ) async def trigger_schedule( - self, start: datetime, system_description_id: str | None = None + self, + start: datetime, + system_description_id: str | None = None, + generation: int | None = None, ): """ Ask FlexMeasures for a new schedule and create FRBC.Instructions to send back to the ResourceManager @@ -361,6 +370,15 @@ async def trigger_schedule( f"HANGDEBUG trigger_schedule: got schedule back: {schedule}" ) + if generation is not None and not self._is_current_generation( + "storage_status", generation + ): + self._logger.info( + "HANGDEBUG trigger_schedule: superseded while awaiting the " + "schedule; not building instructions." + ) + return + # translate FlexMeasures schedule into instructions. SOC -> Power -> PowerFactor try: instructions = fm_schedule_to_instructions( @@ -377,11 +395,15 @@ async def trigger_schedule( f"HANGDEBUG trigger_schedule: built {len(instructions)} instructions" ) - # put instructions to sending queue - for instruction in instructions: - await self.send_message(instruction) + # Replace the outstanding batch (transition-filtered; supersession + # re-checked right before queueing - see FRBC.send_instruction_batch). + sent = await self.send_instruction_batch( + instructions, + supersession_key="storage_status" if generation is not None else None, + generation=generation, + ) self._logger.debug( - "HANGDEBUG trigger_schedule: all instructions queued for sending" + f"HANGDEBUG trigger_schedule: {sent} instructions queued for sending" ) async def send_fill_level_target_profile( diff --git a/tests/s2/test_frbc_registry.py b/tests/s2/test_frbc_registry.py new file mode 100644 index 00000000..4aa5843e --- /dev/null +++ b/tests/s2/test_frbc_registry.py @@ -0,0 +1,203 @@ +"""Tests for the FRBC outstanding-work registry (instruction-storm fix): + +- transition filtering of instruction batches, +- latest-request-wins supersession of storage-status handler work, +- content-hash dedupe of re-sent system descriptions, +- the sent-instruction registry, status updates and opt-in revocation. +""" + +from __future__ import annotations + +import asyncio +import logging +from datetime import datetime, timezone + +import pytest +from s2python.common import ( + InstructionStatus, + InstructionStatusUpdate, + ReceptionStatusValues, +) +from s2python.frbc import FRBCInstruction, FRBCStorageStatus, FRBCSystemDescription + +from flexmeasures_client.s2.control_types.FRBC import FRBC, FRBCTest +from flexmeasures_client.s2.utils import get_unique_id + + +def make_instruction(mode: str, factor: float, slot: int, actuator: str) -> FRBCInstruction: + return FRBCInstruction( + message_id=get_unique_id(), + id=get_unique_id(), + actuator_id=actuator, + operation_mode=mode, + operation_mode_factor=factor, + execution_time=datetime(2022, 12, 1, slot // 4, (slot % 4) * 15, tzinfo=timezone.utc), + abnormal_condition=False, + ) + + +class TestTransitionFilter: + def test_repeats_are_dropped_and_changes_kept(self): + on, off = get_unique_id(), get_unique_id() + actuator = get_unique_id() + modes = [on, on, on, off, off, on, on, on] + instructions = [ + make_instruction(m, 1.0, i, actuator) for i, m in enumerate(modes) + ] + filtered = FRBC.filter_instruction_transitions(instructions) + assert [str(i.operation_mode) for i in filtered] == [str(on), str(off), str(on)] + # The first instruction always survives (it establishes the state). + assert filtered[0] is instructions[0] + + def test_factor_change_is_a_transition(self): + mode = get_unique_id() + actuator = get_unique_id() + instructions = [ + make_instruction(mode, f, i, actuator) + for i, f in enumerate([0.5, 0.5, 1.0, 1.0]) + ] + filtered = FRBC.filter_instruction_transitions(instructions) + assert [i.operation_mode_factor for i in filtered] == [0.5, 1.0] + + def test_actuators_are_filtered_independently(self): + mode = get_unique_id() + a1, a2 = get_unique_id(), get_unique_id() + instructions = [ + make_instruction(mode, 1.0, 0, a1), + make_instruction(mode, 1.0, 0, a2), # other actuator: kept + make_instruction(mode, 1.0, 1, a1), # repeat for a1: dropped + ] + filtered = FRBC.filter_instruction_transitions(instructions) + assert len(filtered) == 2 + assert {str(i.actuator_id) for i in filtered} == {str(a1), str(a2)} + + +class _RecordingFRBC(FRBCTest): + """FRBCTest with a recording send_storage_status chain that mimics the + Simple handler's long round trip (SoC post + schedule wait).""" + + def __init__(self, delay_s: float = 0.05, **kwargs): + super().__init__(**kwargs) + self._logger = logging.getLogger("test") + self.completed_generations: list[int] = [] + self.delay_s = delay_s + self.sent = [] + + async def send_message(self, message): # capture instead of queueing + self.sent.append(message) + + async def send_storage_status(self, status: FRBCStorageStatus): + generation = self._supersession_counters.get("storage_status", 0) + await asyncio.sleep(self.delay_s) # the FM round trip + batch = [ + make_instruction(get_unique_id(), 1.0, i, get_unique_id()) + for i in range(3) + ] + sent = await self.send_instruction_batch( + batch, supersession_key="storage_status", generation=generation + ) + if sent: + self.completed_generations.append(generation) + + +def make_status(fill_level: float = 0.5) -> FRBCStorageStatus: + return FRBCStorageStatus( + message_id=get_unique_id(), present_fill_level=fill_level + ) + + +@pytest.mark.asyncio +async def test_only_the_latest_storage_status_produces_a_batch(): + """Three rapid statuses (a backlog, or an RM retry storm re-sending under + fresh message ids): only the newest request's batch may reach the RM.""" + frbc = _RecordingFRBC() + + for _ in range(3): + await frbc.handle_message(make_status()) + await asyncio.gather(*frbc.background_tasks) + + assert frbc.completed_generations == [3], ( + "only generation 3 (the newest request) may complete a batch" + ) + # Registry holds exactly the surviving batch. + assert len(frbc._sent_instructions) == 3 + + +@pytest.mark.asyncio +async def test_resent_system_description_is_acked_but_not_reprocessed( + frbc_system_description, +): + frbc = _RecordingFRBC() + + response_1 = await frbc.handle_message(frbc_system_description) + await asyncio.gather(*frbc.background_tasks) + history_size = len(frbc._system_description_history) + + resent = FRBCSystemDescription( + message_id=get_unique_id(), # fresh id, identical content + valid_from=frbc_system_description.valid_from, + actuators=frbc_system_description.actuators, + storage=frbc_system_description.storage, + ) + response_2 = await frbc.handle_message(resent) + await asyncio.gather(*frbc.background_tasks) + + assert str(response_1.status) == str(ReceptionStatusValues.OK) + assert str(response_2.status) == str(ReceptionStatusValues.OK), ( + "the RM's resend must still be acknowledged" + ) + assert len(frbc._system_description_history) == history_size, ( + "unchanged content must not be stored or reprocessed" + ) + + +@pytest.mark.asyncio +async def test_batch_replacement_revokes_only_pending_instructions(): + frbc = _RecordingFRBC() + frbc.send_revocations = True + + mode, actuator = get_unique_id(), get_unique_id() + first = [make_instruction(mode, float(i % 2), i, actuator) for i in range(4)] + await frbc.send_instruction_batch(first) + first_sent = list(frbc._sent_instructions.values()) + assert len(first_sent) == 4 # alternating factors: all are transitions + + # The RM reports one instruction finished; it must not be revoked. + finished = first_sent[0] + await frbc.handle_message( + InstructionStatusUpdate( + message_id=get_unique_id(), + instruction_id=finished.message_id, + status_type=InstructionStatus.SUCCEEDED, + timestamp=datetime(2022, 12, 1, 12, 0, tzinfo=timezone.utc), + ) + ) + + frbc.sent.clear() + second = [make_instruction(get_unique_id(), 1.0, 0, actuator)] + await frbc.send_instruction_batch(second) + + revokes = [m for m in frbc.sent if m.__class__.__name__ == "RevokeObject"] + assert len(revokes) == 3, "only NEW/ACCEPTED instructions are revoked" + revoked_ids = {str(r.object_id) for r in revokes} + assert str(finished.id) not in revoked_ids + # Registry now holds only the new batch. + assert len(frbc._sent_instructions) == 1 + + +@pytest.mark.asyncio +async def test_stale_batch_is_dropped_even_after_the_schedule_returned(): + """The supersession re-check at queueing time: a batch computed for an + older request is dropped when a newer request arrived meanwhile.""" + frbc = _RecordingFRBC() + + generation = frbc._bump_generation("storage_status") + frbc._bump_generation("storage_status") # a newer request arrives + + batch = [make_instruction(get_unique_id(), 1.0, 0, get_unique_id())] + sent = await frbc.send_instruction_batch( + batch, supersession_key="storage_status", generation=generation + ) + assert sent == 0 + assert frbc.sent == [] + assert frbc._sent_instructions == {} From 3081698c9dc58b361db9a02820cf3cfc6742e1a8 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Mon, 27 Jul 2026 13:38:39 +0200 Subject: [PATCH 167/181] fix(config): fit the State-of-charge subplot y-axis to the data Large thermal fill levels sit far from zero; the default zero-including axis flattened all variation into a sliver. Signed-off-by: F.N. Claessen --- src/flexmeasures_client/s2/config_utils.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/flexmeasures_client/s2/config_utils.py b/src/flexmeasures_client/s2/config_utils.py index 50a80f73..3444fee6 100644 --- a/src/flexmeasures_client/s2/config_utils.py +++ b/src/flexmeasures_client/s2/config_utils.py @@ -220,6 +220,10 @@ async def configure_site( sensors_to_show = [ { "title": "State of charge", + # Fit the y-axis to the data: SoC values (large thermal-storage + # fill levels) sit far from zero, so the default zero-including + # axis flattens all variation into a sliver at the top. + "y-axis": "data", "sensors": [ soc_minima_sensor["id"], soc_maxima_sensor["id"], From 47419db063d79853f1684be8079686f8bdec8578 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Mon, 27 Jul 2026 15:15:28 +0200 Subject: [PATCH 168/181] fix(FRBC): never retry deterministic scheduling failures The trigger retry loop treats 'Scheduling job failed' as transient, but a deterministic server-side failure (e.g. a UniqueViolation on saving the schedule) re-fails identically on every attempt; re-triggering it five times with fresh jobs only multiplies failed jobs across the worker pool. Skip retries when the failure message carries a UniqueViolation/duplicate-key signature. Signed-off-by: F.N. Claessen --- .../s2/control_types/FRBC/frbc_simple.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py index 69ddf6b4..d8cd31a1 100644 --- a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py +++ b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py @@ -331,6 +331,15 @@ async def trigger_schedule( "Scheduling job failed", "No recent state-of-charge value found", ) + # Deterministic server-side failures re-fail identically on every + # retry; re-triggering them only multiplies failed jobs across the + # worker pool (observed with a scheduler bug that violated the + # timed_belief primary key on every attempt). Never retry these, + # even though their message carries a transient-looking marker. + deterministic_errors = ( + "UniqueViolation", + "duplicate key value violates unique constraint", + ) for attempt in range(1, max_attempts + 1): try: schedule = await self._fm_client.trigger_and_get_schedule( @@ -349,7 +358,8 @@ async def trigger_schedule( break except ValueError as exc: if ( - not any(marker in str(exc) for marker in transient_errors) + any(marker in str(exc) for marker in deterministic_errors) + or not any(marker in str(exc) for marker in transient_errors) or attempt == max_attempts ): self._logger.exception( From a5ccebe0028de3b7be1c6922b6c8a2a516e68d62 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Tue, 28 Jul 2026 00:36:52 +0200 Subject: [PATCH 169/181] perf(s2): post realized-power measurements concurrently The RM forwards realized power as one S2 PowerMeasurement per 15-min interval, serially; awaiting each FM post inline serialized ~200 HTTP round-trips per house per simulated day on the co-simulation critical path. Posts now run as tracked asyncio tasks with identical payloads (same event_start, prior, unit and values), and CEM.flush_measurement_posts() provides the read barrier: FRBC trigger_schedule awaits it, so FlexMeasures schedulers (and the community compliance check they feed) still see the complete realized series, exactly as the old serial awaits guaranteed. Post failures are swallowed with a debug log as before. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01WtuVTVfL4fQ9QSqbLXmAGD Signed-off-by: F.N. Claessen --- src/flexmeasures_client/s2/cem.py | 46 ++++++++++++++++--- .../s2/control_types/FRBC/frbc_simple.py | 8 ++++ 2 files changed, 48 insertions(+), 6 deletions(-) diff --git a/src/flexmeasures_client/s2/cem.py b/src/flexmeasures_client/s2/cem.py index ab91207c..118aaf36 100644 --- a/src/flexmeasures_client/s2/cem.py +++ b/src/flexmeasures_client/s2/cem.py @@ -101,6 +101,10 @@ def __init__( # sensor too, not only on measured-power (defect 4a: live apartment realizations). self._apartment_asset_id: int | None = None self._aggregate_power_sensor_id: int | None = None + # In-flight measurement posts (see handle_power_measurement): posts run + # concurrently as tasks; flush_measurement_posts() is the barrier that + # schedule triggering awaits so FlexMeasures reads see all realized data. + self._pending_measurement_posts: set[asyncio.Task] = set() self._control_types_handlers = dict() self._default_control_type = default_control_type @@ -172,6 +176,10 @@ def register_control_type(self, control_type_handler: ControlTypeHandler): # add fm_client to control_type handler control_type_handler._fm_client = self._fm_client + # back-reference so handlers can await flush_measurement_posts() before + # triggering a schedule (realized-power posts run concurrently) + control_type_handler._cem = self + # add send_message method so the handler can send messages control_type_handler.send_message = self.send_message @@ -551,8 +559,15 @@ async def handle_power_measurement(self, message: PowerMeasurement): if aggregate_sensor_id is not None and aggregate_sensor_id != sensor_id: target_sensor_ids.append(aggregate_sensor_id) for target_sensor_id in target_sensor_ids: - try: - await self._fm_client.post_sensor_data( + # Post CONCURRENTLY: the RM forwards realized power as one + # PowerMeasurement per 15-min interval, serially, and awaiting + # each post here serialized ~200 HTTP round-trips per house per + # simulated day on the co-sim critical path. Each post keeps its + # own event_start/prior exactly as before; the posts merely + # overlap in time. flush_measurement_posts() is the barrier for + # readers (awaited before any schedule trigger). + task = asyncio.create_task( + self._post_measurement_safely( target_sensor_id, start=bin_start.isoformat(), duration=period.isoformat(), # TODO: not specified in S2 Protocol @@ -565,13 +580,32 @@ async def handle_power_measurement(self, message: PowerMeasurement): unit="W", prior=prior, ) - except Exception as e: # noqa: B902 - intentional safety net - self._logger.debug( - f"POSTing power measurement failed with error: {e}" - ) + ) + self._pending_measurement_posts.add(task) + task.add_done_callback(self._pending_measurement_posts.discard) return get_reception_status(message) + async def _post_measurement_safely(self, target_sensor_id, **kwargs) -> None: + """Post one measurement, swallowing errors exactly like the historical + inline try/except did (a lost measurement must not break the S2 session).""" + try: + await self._fm_client.post_sensor_data(target_sensor_id, **kwargs) + except Exception as e: # noqa: B902 - intentional safety net + self._logger.debug(f"POSTing power measurement failed with error: {e}") + + async def flush_measurement_posts(self) -> None: + """Barrier: wait until all in-flight realized-power posts have landed. + + Called before triggering a FlexMeasures schedule so the scheduler (and the + community compliance check it feeds) sees the complete realized series - + the same guarantee the old serial awaits gave. Tasks never raise (see + _post_measurement_safely).""" + while self._pending_measurement_posts: + await asyncio.gather( + *list(self._pending_measurement_posts), return_exceptions=True + ) + async def _resolve_aggregate_power_sensor_id(self) -> int | None: """Lazily resolve the apartment's flex-context "aggregate-power" sensor id. diff --git a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py index d8cd31a1..0e214617 100644 --- a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py +++ b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py @@ -173,6 +173,14 @@ async def trigger_schedule( Ask FlexMeasures for a new schedule and create FRBC.Instructions to send back to the ResourceManager """ + # Barrier: realized-power posts run concurrently (see + # CEM.handle_power_measurement); the scheduler and the community + # compliance check it feeds must see the complete realized series + # before triggering, exactly as the old serial posting guaranteed. + cem = getattr(self, "_cem", None) + if cem is not None: + await cem.flush_measurement_posts() + if system_description_id: system_description: FRBCSystemDescription = ( self._system_description_history[system_description_id] From 5e862a2253f5891c475e36a95aedb6e85785f1ab Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Tue, 28 Jul 2026 01:12:52 +0200 Subject: [PATCH 170/181] perf(s2): deduplicate re-sent realized-power measurements The RM re-sends the same realized-power series with every retrigger round's flexinput, so the CEM re-posted ~200 identical values per house per round. Track content keys (sensor, event start, value) and skip posts already made or in flight; a failed post releases its key so a later re-send retries it. Changed values (which realized data should never produce) still post. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01WtuVTVfL4fQ9QSqbLXmAGD Signed-off-by: F.N. Claessen --- src/flexmeasures_client/s2/cem.py | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/src/flexmeasures_client/s2/cem.py b/src/flexmeasures_client/s2/cem.py index 118aaf36..86e9ba43 100644 --- a/src/flexmeasures_client/s2/cem.py +++ b/src/flexmeasures_client/s2/cem.py @@ -105,6 +105,12 @@ def __init__( # concurrently as tasks; flush_measurement_posts() is the barrier that # schedule triggering awaits so FlexMeasures reads see all realized data. self._pending_measurement_posts: set[asyncio.Task] = set() + # Content keys of measurements already posted (or in flight): the RM + # re-sends the SAME realized-power series with every retrigger round's + # flexinput, so without this each round re-posted ~200 identical + # values per house. Keys are dropped again if a post fails, so a + # later re-send retries it. + self._posted_measurement_keys: set[tuple] = set() self._control_types_handlers = dict() self._default_control_type = default_control_type @@ -559,6 +565,14 @@ async def handle_power_measurement(self, message: PowerMeasurement): if aggregate_sensor_id is not None and aggregate_sensor_id != sensor_id: target_sensor_ids.append(aggregate_sensor_id) for target_sensor_id in target_sensor_ids: + measurement_key = ( + target_sensor_id, + bin_start.isoformat(), + float(power_measurement.value), + ) + if measurement_key in self._posted_measurement_keys: + continue + self._posted_measurement_keys.add(measurement_key) # Post CONCURRENTLY: the RM forwards realized power as one # PowerMeasurement per 15-min interval, serially, and awaiting # each post here serialized ~200 HTTP round-trips per house per @@ -568,6 +582,7 @@ async def handle_power_measurement(self, message: PowerMeasurement): # readers (awaited before any schedule trigger). task = asyncio.create_task( self._post_measurement_safely( + measurement_key, target_sensor_id, start=bin_start.isoformat(), duration=period.isoformat(), # TODO: not specified in S2 Protocol @@ -586,12 +601,15 @@ async def handle_power_measurement(self, message: PowerMeasurement): return get_reception_status(message) - async def _post_measurement_safely(self, target_sensor_id, **kwargs) -> None: + async def _post_measurement_safely(self, measurement_key, target_sensor_id, **kwargs) -> None: """Post one measurement, swallowing errors exactly like the historical - inline try/except did (a lost measurement must not break the S2 session).""" + inline try/except did (a lost measurement must not break the S2 session). + On failure the content key is released so the RM's next re-send of the + same series retries the post instead of being deduplicated away.""" try: await self._fm_client.post_sensor_data(target_sensor_id, **kwargs) except Exception as e: # noqa: B902 - intentional safety net + self._posted_measurement_keys.discard(measurement_key) self._logger.debug(f"POSTing power measurement failed with error: {e}") async def flush_measurement_posts(self) -> None: From e51d628d329efbc6d5d36a33518f2a206ff879fe Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Tue, 28 Jul 2026 10:47:05 +0200 Subject: [PATCH 171/181] feat(s2): log an error when FlexMeasures used its fallback scheduler The server silently substitutes the fallback scheduler's coarse charging policy when the real problem is infeasible, and the fallback saves no state-of-charge stream - which broke follow-the-schedule batteries a day later (soc-at-start resolution found nothing) and starved houses in co-simulation. Inspect scheduler_info on the returned schedule and log an ERROR naming the window, so a fallback result is never mistaken for a healthy schedule. The fallback scheduler is slated for removal in FlexMeasures v1. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01WtuVTVfL4fQ9QSqbLXmAGD Signed-off-by: F.N. Claessen --- .../s2/control_types/FRBC/frbc_simple.py | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py index 0e214617..ce72e937 100644 --- a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py +++ b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py @@ -388,6 +388,30 @@ async def trigger_schedule( f"HANGDEBUG trigger_schedule: got schedule back: {schedule}" ) + # The server silently substitutes its fallback scheduler's result when + # the real scheduling problem is infeasible (the GET follows the + # fallback job unless FLEXMEASURES_FALLBACK_REDIRECT is set). That + # schedule is a coarse charging policy, not an optimum - and the + # fallback saves NO state-of-charge stream, so any device relying on + # scheduler-saved SoC (e.g. a follow-the-schedule battery) loses its + # SoC trail and later windows fail on soc-at-start resolution + # (observed as a mid-run house starvation in co-simulation). Surface + # it as an ERROR so it is never mistaken for a healthy schedule. + # NB the fallback scheduler is slated for removal in FlexMeasures v1. + scheduler_name = str( + (schedule.get("scheduler_info") or {}).get("scheduler", "") + if isinstance(schedule, dict) + else "" + ) + if "fallback" in scheduler_name.lower(): + self._logger.error( + f"FlexMeasures used its fallback scheduler ({scheduler_name}) " + f"for the window starting {start.isoformat()}: the real " + "scheduling problem was infeasible. The returned schedule is a " + "coarse charging policy and no state-of-charge stream was " + "saved server-side. Investigate the infeasibility." + ) + if generation is not None and not self._is_current_generation( "storage_status", generation ): From 0ce28b2a61f434451439287856b7fc62dd90b02d Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Tue, 28 Jul 2026 13:12:59 +0200 Subject: [PATCH 172/181] fix(s2): make measurement-post dedupe survive RM reconnections The CEM object is rebuilt for every RM websocket (re)connection - in co-simulation that is every replan round - so the instance-level dedupe keys and pending-post set reset each round: the dedupe never held (a profiled 1-day run re-posted each house's realized series ~3x, ~13k sensor-data calls), and a fresh instance's flush barrier could not await the previous instance's still-in-flight posts. Hoist both sets to process scope; one CEM server process serves one apartment, so that is their correct lifetime. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01WtuVTVfL4fQ9QSqbLXmAGD Signed-off-by: F.N. Claessen --- src/flexmeasures_client/s2/cem.py | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/src/flexmeasures_client/s2/cem.py b/src/flexmeasures_client/s2/cem.py index 86e9ba43..06ca2f89 100644 --- a/src/flexmeasures_client/s2/cem.py +++ b/src/flexmeasures_client/s2/cem.py @@ -45,6 +45,16 @@ _LOGGER = logging.getLogger(__name__) +# Process-global measurement-post bookkeeping. The CEM object is rebuilt for +# every RM websocket (re)connection - in co-simulation that is EVERY replan +# round - so instance-level sets reset each round: the dedupe never held (a +# profiled 1-day run re-posted each house's realized series ~3x, ~13k calls) +# and a new instance's flush barrier could not await the old instance's still +# in-flight posts. One CEM server process serves one apartment, so process +# scope is the correct lifetime for both. +_POSTED_MEASUREMENT_KEYS: set[tuple] = set() +_PENDING_MEASUREMENT_POSTS: set = set() + class CEM(Handler): __version__ = "0.0.2-beta" @@ -104,13 +114,12 @@ def __init__( # In-flight measurement posts (see handle_power_measurement): posts run # concurrently as tasks; flush_measurement_posts() is the barrier that # schedule triggering awaits so FlexMeasures reads see all realized data. - self._pending_measurement_posts: set[asyncio.Task] = set() - # Content keys of measurements already posted (or in flight): the RM - # re-sends the SAME realized-power series with every retrigger round's - # flexinput, so without this each round re-posted ~200 identical - # values per house. Keys are dropped again if a post fails, so a - # later re-send retries it. - self._posted_measurement_keys: set[tuple] = set() + # Content keys dedupe the series the RM re-sends with every retrigger + # round's flexinput; a failed post drops its key so a later re-send + # retries it. Both live at PROCESS scope (see module globals): the CEM + # object itself is rebuilt on every RM reconnection. + self._pending_measurement_posts = _PENDING_MEASUREMENT_POSTS + self._posted_measurement_keys = _POSTED_MEASUREMENT_KEYS self._control_types_handlers = dict() self._default_control_type = default_control_type From 5316b12e281009a84b504d8571cc777e6900fe00 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Tue, 28 Jul 2026 13:24:12 +0200 Subject: [PATCH 173/181] perf(s2): lean site-asset lookup in configure_site configure_site runs on every RM (re)connection - in co-simulation that is every replan round - and its full-catalog get_assets scan cost ~5-7s per call against a large database, about half the API time of a profiled co-simulation day. Find the site asset via an id/name listing and fetch only the match, falling back to the full scan on servers without the fields parameter (< 0.31). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01WtuVTVfL4fQ9QSqbLXmAGD Signed-off-by: F.N. Claessen --- src/flexmeasures_client/s2/config_utils.py | 27 ++++++++++++++++++---- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/src/flexmeasures_client/s2/config_utils.py b/src/flexmeasures_client/s2/config_utils.py index 3444fee6..354afbd9 100644 --- a/src/flexmeasures_client/s2/config_utils.py +++ b/src/flexmeasures_client/s2/config_utils.py @@ -18,13 +18,30 @@ async def configure_site( site_name: str, fm_client: FlexMeasuresClient ) -> tuple[dict, dict, dict, dict, dict, dict, dict, dict, dict, dict, dict]: account = await fm_client.get_account() - assets = await fm_client.get_assets(parse_json_fields=True) + # Find the site asset with a lean id/name listing, then fetch only the + # match. This runs on EVERY RM (re)connection - in co-simulation that is + # every replan round - and the previous full-catalog get_assets scan cost + # ~5-7 s per call against a large database (about half the API time of a + # profiled co-simulation day). Falls back to the full scan on servers + # too old for the `fields` parameter (< 0.31). site_asset: dict | None = None - for asset in assets: - if asset["name"] == site_name: - site_asset = asset - break + try: + asset_listing = await fm_client.get_assets( + fields=["id", "name"], parse_json_fields=False + ) + for asset in asset_listing: + if asset.get("name") == site_name: + site_asset = await fm_client.get_asset( + asset_id=asset["id"], parse_json_fields=True + ) + break + except ValueError: + assets = await fm_client.get_assets(parse_json_fields=True) + for asset in assets: + if asset["name"] == site_name: + site_asset = asset + break site_asset_specs = dict( latitude=0, From 8d6c09c99d0726132f97839732caa2c893e9bab0 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Tue, 28 Jul 2026 13:34:17 +0200 Subject: [PATCH 174/181] perf(s2): cache the site-asset id across RM reconnections Even the lean id/name listing costs seconds against a large database, and configure_site runs on every RM (re)connection - every replan round in co-simulation. Remember the resolved site-asset id at process scope (one CEM server process per apartment) and fetch it directly on reconnections, invalidating on a miss. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01WtuVTVfL4fQ9QSqbLXmAGD Signed-off-by: F.N. Claessen --- src/flexmeasures_client/s2/config_utils.py | 54 +++++++++++++++------- 1 file changed, 38 insertions(+), 16 deletions(-) diff --git a/src/flexmeasures_client/s2/config_utils.py b/src/flexmeasures_client/s2/config_utils.py index 354afbd9..86ff0ff8 100644 --- a/src/flexmeasures_client/s2/config_utils.py +++ b/src/flexmeasures_client/s2/config_utils.py @@ -13,6 +13,9 @@ ) LOGGER = logging.getLogger(__name__) +# site_name -> asset id, per CEM server process (see configure_site) +_SITE_ASSET_IDS: dict[str, int] = {} + async def configure_site( site_name: str, fm_client: FlexMeasuresClient @@ -26,22 +29,39 @@ async def configure_site( # profiled co-simulation day). Falls back to the full scan on servers # too old for the `fields` parameter (< 0.31). site_asset: dict | None = None - try: - asset_listing = await fm_client.get_assets( - fields=["id", "name"], parse_json_fields=False - ) - for asset in asset_listing: - if asset.get("name") == site_name: - site_asset = await fm_client.get_asset( - asset_id=asset["id"], parse_json_fields=True - ) - break - except ValueError: - assets = await fm_client.get_assets(parse_json_fields=True) - for asset in assets: - if asset["name"] == site_name: - site_asset = asset - break + # Asset ids are stable for the lifetime of this CEM server process (one + # process per apartment; the RM reconnects every replan round, and even + # the lean listing costs seconds against a large database), so remember + # the resolved id and fetch it directly on reconnections. A stale id + # (asset deleted between runs never happens within a process lifetime, + # but be safe) falls through to a fresh lookup. + cached_id = _SITE_ASSET_IDS.get(site_name) + if cached_id is not None: + try: + candidate = await fm_client.get_asset( + asset_id=cached_id, parse_json_fields=True + ) + if candidate.get("name") == site_name: + site_asset = candidate + except Exception: + _SITE_ASSET_IDS.pop(site_name, None) + if site_asset is None: + try: + asset_listing = await fm_client.get_assets( + fields=["id", "name"], parse_json_fields=False + ) + for asset in asset_listing: + if asset.get("name") == site_name: + site_asset = await fm_client.get_asset( + asset_id=asset["id"], parse_json_fields=True + ) + break + except ValueError: + assets = await fm_client.get_assets(parse_json_fields=True) + for asset in assets: + if asset["name"] == site_name: + site_asset = asset + break site_asset_specs = dict( latitude=0, @@ -63,6 +83,8 @@ async def configure_site( f"for {site_name!r}, existing sensors: " f"{[s['name'] for s in site_asset.get('sensors', [])]}" ) + _SITE_ASSET_IDS[site_name] = site_asset["id"] + # Update site asset with the latest specs LOGGER.debug(f"HANGDEBUG configure_site: updating asset {site_asset['id']} specs") await fm_client.update_asset(site_asset["id"], site_asset_specs) From 75a7b0c60dd0b58e731332ce270f5698192a4ec4 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Tue, 28 Jul 2026 23:36:49 +0200 Subject: [PATCH 175/181] feat(frbc): treat the declared fill-level range as a soft comfort band The RM's declared fill-level range used to be sent to FlexMeasures as hard soc-min/soc-max bounds. A hard bound at the declared range made physically-reachable states infeasible: free-floating above "full", or a time-capped incumbent left below "empty" for the next day, which is a deterministically infeasible scheduling job (the sweep-9 run-killer class). The comfort steering machinery is already soft: the fill-level target profile is posted onto soc-minima/-maxima sensors, which the scheduler enforces as breach-priced StockCommitments under relax-soc-constraints. So make the declared range the behavioral band on the soft path, and demote the hard bounds to wide safety rails: - trigger_schedule: soc-min = 0 and soc-max = 1.5x the declared range top (still widened to include the current state as a last resort). The rails only exist to keep the LP bounded (e.g. negative-price hours combined with a soc-value-at-end incentive). - send_fill_level_target_profile: clip the posted soc-minima/-maxima into the declared range (night setbacks clip up to the range bottom; ceilings clip down to the range top). Where a target band lies entirely outside the declared range, both bounds collapse to the midpoint of the clipped pair (keeping minima <= maxima pointwise) and a warning is logged. The clip lives in clip_fill_level_target_profile (FRBC/utils.py), unit-tested in tests/s2/test_frbc_soft_range.py along with the flex-model rails. FM-side needs no change. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01WtuVTVfL4fQ9QSqbLXmAGD Signed-off-by: F.N. Claessen --- .../s2/control_types/FRBC/frbc_simple.py | 49 ++++- .../s2/control_types/FRBC/utils.py | 35 ++++ tests/s2/test_frbc_soft_range.py | 195 ++++++++++++++++++ 3 files changed, 270 insertions(+), 9 deletions(-) create mode 100644 tests/s2/test_frbc_soft_range.py diff --git a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py index ce72e937..70450ca3 100644 --- a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py +++ b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py @@ -28,6 +28,7 @@ from flexmeasures_client.s2.control_types.FRBC import FRBC from flexmeasures_client.s2.control_types.FRBC.utils import ( fm_schedule_to_instructions, + clip_fill_level_target_profile, get_soc_min_max, ) from flexmeasures_client.s2.control_types.translations import ( @@ -256,7 +257,20 @@ async def trigger_schedule( if band not in operation_mode_bands: operation_mode_bands.append(band) - soc_min, soc_max = get_soc_min_max(system_description, self._fill_level_scale) + # The RM's declared fill-level range is a comfort band, steered SOFTLY + # via the soc-minima/-maxima profiles (breach-priced server-side under + # relax-soc-constraints; see send_fill_level_target_profile, which + # clips those profiles into the declared range). The scalar + # soc-min/soc-max, by contrast, are HARD bounds server-side, and a + # hard bound at the declared range made physically-reachable states + # infeasible: free-float above "full", or a time-capped incumbent + # left below "empty" for the next day (a deterministically infeasible + # job). Declare wide safety rails instead; they only exist to keep + # the LP bounded (e.g. negative-price hours combined with a + # soc-value-at-end incentive). + _, range_top = get_soc_min_max(system_description, self._fill_level_scale) + soc_min = 0.0 + soc_max = range_top * 1.5 # Support for J energy unit (FM server scheduling trigger endpoint only accepts kWh and MWh) if self.energy_unit == "J": @@ -290,14 +304,8 @@ async def trigger_schedule( # via py-spy: time was spent inside the solver itself, not a Python hang). "relax-site-capacity-constraints": True, } - # The scalar soc-min/soc-max are HARD bounds server-side (only the - # soc-minima/maxima profiles are softened by relax-soc-constraints). - # The FRBC fill range they derive from is a comfort band, and the - # realized state can drift outside it (e.g. community steering keeps - # the heat pump off long enough to drain the buffer below the comfort - # floor). A start state outside hard bounds makes the whole problem - # infeasible, so widen the hard band to include the current state and - # leave comfort steering to the soft minima/maxima profiles. + # Last-resort rail: a start state outside the hard bounds would make + # the whole problem infeasible, so widen them to include it. soc_min = min(soc_min, soc_at_start) soc_max = max(soc_max, soc_at_start) flex_model = { @@ -466,6 +474,29 @@ async def send_fill_level_target_profile( fill_level_scale=self._fill_level_scale, ) + # Clip the target profile into the RM's declared fill-level range: + # these profiles steer comfort as breach-priced SOFT constraints + # server-side, while the declared range is no longer sent as a hard + # soc-min/soc-max (see trigger_schedule), so an out-of-range target + # (e.g. a night setback dropping below the range bottom) would + # otherwise price the scheduler into states the RM declared out of + # range. + system_descriptions = list(self._system_description_history.values()) + if system_descriptions: + range_bottom, range_top = get_soc_min_max( + system_descriptions[-1], self._fill_level_scale + ) + soc_minima, soc_maxima, n_crossed = clip_fill_level_target_profile( + soc_minima, soc_maxima, range_bottom, range_top + ) + if n_crossed: + self._logger.warning( + "Fill-level target profile lies outside the declared " + f"fill-level range [{range_bottom}, {range_top}] on " + f"{n_crossed} of {len(soc_minima)} time steps; collapsed " + "minima and maxima to their midpoint there." + ) + duration = str(pd.Timedelta(self._resolution) * len(soc_maxima)) # POST SOC Minima measurements to FlexMeasures diff --git a/src/flexmeasures_client/s2/control_types/FRBC/utils.py b/src/flexmeasures_client/s2/control_types/FRBC/utils.py index 46cc7d4e..e917cc6b 100644 --- a/src/flexmeasures_client/s2/control_types/FRBC/utils.py +++ b/src/flexmeasures_client/s2/control_types/FRBC/utils.py @@ -293,6 +293,41 @@ def get_soc_min_max( return soc_min, soc_max +def clip_fill_level_target_profile( + soc_minima: pd.Series, + soc_maxima: pd.Series, + range_bottom: float, + range_top: float, +) -> tuple[pd.Series, pd.Series, int]: + """Clip a translated fill-level target profile into the declared fill-level range. + + The target profile steers comfort as breach-priced SOFT constraints + server-side, while the declared range itself is no longer declared as a + hard soc-min/soc-max (those became wide safety rails). A target outside + the declared range (e.g. a night setback dropping below the range bottom) + would therefore price the scheduler into states the RM declared out of + range, so: + + - minima are clipped up to the range bottom, + - maxima are clipped down to the range top, + - where the clipped bounds cross (i.e. the target band lies entirely + outside the declared range), both collapse to the midpoint of the + clipped pair, keeping minima <= maxima pointwise. + + Returns the clipped (minima, maxima) and the number of time steps on + which the bounds crossed (so the caller can log a warning). + """ + soc_minima = soc_minima.clip(lower=range_bottom) + soc_maxima = soc_maxima.clip(upper=range_top) + crossed = soc_maxima < soc_minima + n_crossed = int(crossed.sum()) + if n_crossed: + midpoint = (soc_minima + soc_maxima) / 2 + soc_minima = soc_minima.mask(crossed, midpoint) + soc_maxima = soc_maxima.mask(crossed, midpoint) + return soc_minima, soc_maxima, n_crossed + + def power_to_fill_rate_with_metrics( operation_mode: FRBCOperationMode, power: float, diff --git a/tests/s2/test_frbc_soft_range.py b/tests/s2/test_frbc_soft_range.py new file mode 100644 index 00000000..05408bd6 --- /dev/null +++ b/tests/s2/test_frbc_soft_range.py @@ -0,0 +1,195 @@ +"""The declared FRBC fill-level range is a SOFT comfort band. + +The CEM steers it via the soc-minima/-maxima profiles (breach-priced +server-side), clipped into the declared range, while the hard scalar +soc-min/soc-max in the flex model are wide safety rails only. +""" + +from __future__ import annotations + +import logging +from datetime import datetime, timedelta, timezone +from unittest.mock import AsyncMock + +import pandas as pd +import pytest +from s2python.common import Duration, NumberRange +from s2python.frbc import ( + FRBCFillLevelTargetProfile, + FRBCFillLevelTargetProfileElement, + FRBCStorageStatus, +) + +from flexmeasures_client.s2.control_types.FRBC.frbc_simple import FRBCSimple +from flexmeasures_client.s2.control_types.FRBC.utils import ( + clip_fill_level_target_profile, +) +from flexmeasures_client.s2.utils import get_unique_id + +RANGE_BOTTOM = 278.0 +RANGE_TOP = 355.0 + + +def series(values): + index = pd.date_range( + "2024-01-01T00:00:00+00:00", periods=len(values), freq="15min" + ) + return pd.Series(values, index=index, dtype=float) + + +def test_clip_is_a_noop_inside_the_range(): + minima, maxima, n_crossed = clip_fill_level_target_profile( + series([300, 310]), series([320, 330]), RANGE_BOTTOM, RANGE_TOP + ) + assert minima.tolist() == [300, 310] + assert maxima.tolist() == [320, 330] + assert n_crossed == 0 + + +def test_night_setback_minima_clip_up_to_the_range_bottom(): + # Night setback drops the target floor below the declared range bottom; + # the posted minima must clip UP to the bottom (maxima untouched). + minima, maxima, n_crossed = clip_fill_level_target_profile( + series([250, 300]), series([320, 330]), RANGE_BOTTOM, RANGE_TOP + ) + assert minima.tolist() == [RANGE_BOTTOM, 300] + assert maxima.tolist() == [320, 330] + assert n_crossed == 0 + + +def test_maxima_clip_down_to_the_range_top(): + minima, maxima, n_crossed = clip_fill_level_target_profile( + series([300]), series([400]), RANGE_BOTTOM, RANGE_TOP + ) + assert minima.tolist() == [300] + assert maxima.tolist() == [RANGE_TOP] + assert n_crossed == 0 + + +def test_target_band_entirely_outside_the_range_collapses_to_midpoint(): + # Target band entirely below the range bottom: clipped bounds cross + # (minima -> bottom, maxima stays below it), so both collapse to the + # midpoint of the clipped pair, keeping minima <= maxima pointwise. + minima, maxima, n_crossed = clip_fill_level_target_profile( + series([250, 300]), series([260, 330]), RANGE_BOTTOM, RANGE_TOP + ) + expected_midpoint = (RANGE_BOTTOM + 260) / 2 + assert minima.tolist() == [expected_midpoint, 300] + assert maxima.tolist() == [expected_midpoint, 330] + assert (minima <= maxima).all() + assert n_crossed == 1 + + +def make_frbc(fill_level_scale: float = 1.0) -> FRBCSimple: + frbc = FRBCSimple( + power_sensor_id=1, + soc_sensor_id=2, + rm_discharge_sensor_id=3, + price_sensor_id=4, + production_price_sensor_id=5, + soc_minima_sensor_id=6, + soc_maxima_sensor_id=7, + usage_forecast_sensor_id=8, + leakage_behaviour_sensor_id=9, + charging_efficiency_sensor_id=10, + fill_level_scale=fill_level_scale, + energy_unit="kWh", + ) + # normally attached by CEM.register_control_type + frbc._logger = logging.getLogger("test_frbc_soft_range") + return frbc + + +@pytest.mark.asyncio +async def test_flex_model_declares_wide_safety_rails(frbc_system_description): + """soc-min/soc-max are wide rails (0 and 1.5x the declared range top), + not the declared fill-level range.""" + frbc = make_frbc() + sd = frbc_system_description + frbc._system_description_history[str(sd.message_id)] = sd + status = FRBCStorageStatus(message_id=get_unique_id(), present_fill_level=0.5) + frbc._storage_status_history[str(status.message_id)] = status + + frbc._fm_client = AsyncMock() + frbc._fm_client.trigger_and_get_schedule = AsyncMock( + side_effect=RuntimeError("stop after capturing the flex model") + ) + with pytest.raises(RuntimeError, match="stop after capturing"): + await frbc.trigger_schedule(datetime(2024, 1, 1, 12, 0, tzinfo=timezone.utc)) + + kwargs = frbc._fm_client.trigger_and_get_schedule.call_args.kwargs + flex_model = kwargs["flex_model"] + range_top = sd.storage.fill_level_range.end_of_range + assert flex_model["soc-min"] == 0.0 + assert flex_model["soc-max"] == pytest.approx(range_top * 1.5) + # comfort steering stays on the (clipped) profile sensors + assert flex_model["soc-minima"] == {"sensor": 6} + assert flex_model["soc-maxima"] == {"sensor": 7} + + +@pytest.mark.asyncio +async def test_safety_rails_still_widen_to_include_the_start_state( + frbc_system_description, +): + """A start state above the upper rail widens the rail (last resort + against infeasibility).""" + frbc = make_frbc() + sd = frbc_system_description + frbc._system_description_history[str(sd.message_id)] = sd + range_top = sd.storage.fill_level_range.end_of_range + status = FRBCStorageStatus( + message_id=get_unique_id(), present_fill_level=range_top * 2 + ) + frbc._storage_status_history[str(status.message_id)] = status + + frbc._fm_client = AsyncMock() + frbc._fm_client.trigger_and_get_schedule = AsyncMock( + side_effect=RuntimeError("stop after capturing the flex model") + ) + with pytest.raises(RuntimeError, match="stop after capturing"): + await frbc.trigger_schedule(datetime(2024, 1, 1, 12, 0, tzinfo=timezone.utc)) + + flex_model = frbc._fm_client.trigger_and_get_schedule.call_args.kwargs["flex_model"] + assert flex_model["soc-max"] == pytest.approx(range_top * 2) + + +@pytest.mark.asyncio +async def test_fill_level_target_profile_is_posted_clipped(frbc_system_description): + """The posted soc-minima/-maxima series are clipped into the declared + fill-level range (night setback clips up to the range bottom).""" + frbc = make_frbc(fill_level_scale=RANGE_TOP) # declared range: 0..1 -> 0..355 + sd = frbc_system_description + frbc._system_description_history[str(sd.message_id)] = sd + range_bottom = sd.storage.fill_level_range.start_of_range * RANGE_TOP # 0.0 + range_top = sd.storage.fill_level_range.end_of_range * RANGE_TOP # 355.0 + + profile = FRBCFillLevelTargetProfile( + message_id=get_unique_id(), + start_time=datetime(2024, 1, 1, 0, 0, tzinfo=timezone.utc), + elements=[ + # night setback: target floor below the declared range bottom + FRBCFillLevelTargetProfileElement( + duration=Duration.from_timedelta(timedelta(minutes=15)), + fill_level_range=NumberRange(start_of_range=-0.5, end_of_range=0.8), + ), + # daytime: target ceiling above the declared range top + FRBCFillLevelTargetProfileElement( + duration=Duration.from_timedelta(timedelta(minutes=15)), + fill_level_range=NumberRange(start_of_range=0.8, end_of_range=1.2), + ), + ], + ) + + frbc._fm_client = AsyncMock() + await frbc.send_fill_level_target_profile(profile) + + posts = { + call.kwargs["sensor_id"]: call.kwargs["values"] + for call in frbc._fm_client.post_sensor_data.call_args_list + } + minima, maxima = posts[6], posts[7] + assert minima[0] == pytest.approx(range_bottom) # clipped UP to the bottom + assert maxima[0] == pytest.approx(0.8 * RANGE_TOP) # untouched + assert minima[1] == pytest.approx(0.8 * RANGE_TOP) # untouched + assert maxima[1] == pytest.approx(range_top) # clipped DOWN to the top + assert all(lo <= hi for lo, hi in zip(minima, maxima)) From f94a9bcd6609d26c1914eff5c82250307b4b0a9d Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Wed, 29 Jul 2026 15:00:36 +0200 Subject: [PATCH 176/181] fix(build): fall back to a static version when git is unavailable Containers that mount this repo and pip-install it at startup have no git binary, so hatch-vcs cannot derive the version and the editable install fails (observed as a worker crash-loop after a host reboot forced fresh startup installs). Configure setuptools-scm's fallback_version so such installs succeed; tagged versions still come from git wherever it is available. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01WtuVTVfL4fQ9QSqbLXmAGD Signed-off-by: F.N. Claessen --- pyproject.toml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 2c963688..8919d887 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -72,6 +72,13 @@ packages = ["src/flexmeasures_client"] [tool.hatch.version] source = "vcs" +[tool.hatch.version.raw-options] +# Containers mounting this repo have no git binary, so hatch-vcs +# (setuptools-scm underneath) cannot derive the version at pip-install +# time and the install fails. Fall back to a static version there; +# proper versions still come from git tags wherever git is available. +fallback_version = "0.9.3" + [tool.poe.tasks.test] help = "Run the full test suite." cmd = "pytest" From 1412e6406e113c5f0f98652458336c891cf9a156 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Wed, 29 Jul 2026 23:59:15 +0200 Subject: [PATCH 177/181] feat: await asynchronous server-side sensor-data ingestion FlexMeasures PR #2101 made POST .../sensors/data (and the file-upload endpoint) return 202 Accepted with a background job id whenever the "ingestion" RQ queue has connected workers, instead of always processing synchronously and returning 200. The client treated 202 as done, so callers believed data was committed when it was only queued - post-then-trigger flows (e.g. s2/cem.py's flush_measurement_posts() barrier before triggering a schedule) could then race ahead of ingestion. post_sensor_data() gains await_ingestion: bool = True. On a 202 response with a job id, it now polls GET .../jobs/ (exponential backoff 0.25s -> 2s cap, ingestion_polling_timeout seconds total, default 60s) until the job reaches a terminal state: - FINISHED: returns normally. - FAILED/STOPPED/CANCELED: raises the new IngestionFailedError. - still pending when the timeout elapses: logs an ERROR ("ingestion not confirmed within Xs; proceeding - downstream schedules may read incomplete data") and returns normally rather than raising - the co-sim's watchdog/compliance checks are the intended safety net for that case, not this call. 200 (synchronous) responses are unaffected; await_ingestion=False opts out entirely, restoring the pre-this-change behavior. Verified the interaction with s2/cem.py's _post_measurement_safely(): it already wraps post_sensor_data() in try/except Exception and releases the dedupe key on failure, so IngestionFailedError (like any other post failure) causes the RM's next re-send of the same series to retry the post. flush_measurement_posts() needed no change - it already awaits the post tasks to completion, which now simply takes a bit longer when ingestion is queued. Also fixed _post_sensor_data_file() to accept 202 (it previously treated any non-200 status as a hard failure), since the upload endpoint is backed by the same process_sensor_data_ingestion() as the JSON endpoint. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01R5EEnE2aCN7sGf1n37XuJJ Signed-off-by: F.N. Claessen --- CHANGELOG.rst | 16 +++ src/flexmeasures_client/client.py | 103 +++++++++++++- src/flexmeasures_client/exceptions.py | 13 ++ tests/client/test_sensor.py | 184 ++++++++++++++++++++++++++ 4 files changed, 313 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index af76e198..201e4610 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -2,6 +2,22 @@ Changelog ========= +Unreleased +========== + +- ``post_sensor_data()`` now awaits asynchronous server-side ingestion by + default (``await_ingestion=True``), restoring read-your-writes semantics + after FlexMeasures PR #2101 made ``POST .../sensors/data`` (and the file + upload endpoint) return ``202 Accepted`` with a background job id instead + of processing synchronously. The client polls the job-status endpoint + (exponential backoff from 0.25s up to a 2s cap, ``ingestion_polling_timeout`` + seconds total, default 60s) until the job finishes. A failed job raises the + new ``IngestionFailedError``; a job still pending when polling times out + logs an ERROR and returns normally, so callers with their own downstream + safety nets are not blocked indefinitely. Pass ``await_ingestion=False`` to + opt out and return as soon as the POST is acknowledged, matching the old + (PR #2101) behavior. + Version 0.1.1 ============= diff --git a/src/flexmeasures_client/client.py b/src/flexmeasures_client/client.py index dceed3bd..9b6c835a 100644 --- a/src/flexmeasures_client/client.py +++ b/src/flexmeasures_client/client.py @@ -23,6 +23,7 @@ ContentTypeError, EmailValidationError, EmptyPasswordError, + IngestionFailedError, InsufficientServerVersionError, WrongAPIVersionError, WrongHostError, @@ -44,6 +45,19 @@ POLLING_INTERVAL = 10.0 # seconds API_VERSIONS_LIST = ["v3_0"] +# Since FlexMeasures PR #2101, POST .../sensors/data (and the file-upload +# equivalent) can return 202 Accepted with a background job id instead of +# processing synchronously. post_sensor_data() polls the job-status endpoint +# (GET .../jobs/) to restore read-your-writes semantics: by the time +# post_sensor_data() returns, the data is confirmed ingested (or polling gave +# up - see IngestionFailedError / the ERROR log on timeout). These constants +# tune that polling loop specifically; they are intentionally much gentler +# than MAX_POLLING_SLEEP/POLLING_INTERVAL above (which govern retrying the +# HTTP request itself, not waiting on a background job). +INGESTION_POLL_INITIAL_INTERVAL = 0.25 # seconds +INGESTION_POLL_MAX_INTERVAL = 2.0 # seconds +INGESTION_POLL_TIMEOUT = 60.0 # seconds + def _parse_json_field(data: dict, field_name: str) -> None: """Parse a JSON string field in-place if it exists and is a string.""" @@ -122,6 +136,7 @@ class FlexMeasuresClient: polling_timeout: float = POLLING_TIMEOUT # seconds request_timeout: float = REQUEST_TIMEOUT # seconds polling_interval: float = POLLING_INTERVAL # seconds + ingestion_polling_timeout: float = INGESTION_POLL_TIMEOUT # seconds session: ClientSession | None = None server_version: str | None = None logger: Logger = LOGGER @@ -448,6 +463,7 @@ async def post_sensor_data( # Parameters for file upload file_path: str | None = None, belief_time_measured_instantly: bool = False, + await_ingestion: bool = True, ): """ Post sensor data for the given time range. @@ -458,6 +474,28 @@ async def post_sensor_data( The method automatically chooses the appropriate API endpoint based on the provided parameters. + Since FlexMeasures PR #2101, the server may process the ingestion of posted + data asynchronously: it returns ``202 Accepted`` with a background job id + instead of ``200 OK`` when an ingestion worker is available, and processes + the data (typically well) after the request returns. By default + (``await_ingestion=True``), this method polls the job-status endpoint until + the job reaches a terminal state, restoring read-your-writes semantics for + callers: once this call returns, the data is confirmed ingested (unless + polling times out - see below). Pass ``await_ingestion=False`` to opt out + and return as soon as the POST is acknowledged, regardless of whether + ingestion has completed. + + :param await_ingestion: If True (default) and the server responds 202 with + a job id, poll GET .../jobs/ (with exponential backoff, capped + at ``self.ingestion_polling_timeout`` seconds) until the job finishes. + - Job status FINISHED: returns normally. + - Job status FAILED (or STOPPED/CANCELED): raises IngestionFailedError. + - Polling timeout reached while the job is still pending: logs an ERROR + and returns normally (this is a deliberate choice - the caller's own + safety nets, e.g. a compliance check, are expected to catch data that + never lands). + Has no effect on synchronous (200 OK) responses. + This function raises a ValueError when an unhandled status code is returned. """ # Check parameter combinations @@ -499,6 +537,7 @@ async def post_sensor_data( values=values, unit=unit, prior=prior, + await_ingestion=await_ingestion, ) else: # Type assertion to help the type checker understand that file_path is not None @@ -509,8 +548,49 @@ async def post_sensor_data( sensor_id=sensor_id, file_path=file_path, belief_time_measured_instantly=belief_time_measured_instantly, + await_ingestion=await_ingestion, ) + async def _await_sensor_data_ingestion(self, job_id: str) -> None: + """Poll the job-status endpoint until an ingestion job reaches a terminal + state, or until self.ingestion_polling_timeout is exceeded. + + :raises IngestionFailedError: if the job reports status FAILED, STOPPED + or CANCELED. + """ + loop = asyncio.get_event_loop() + deadline = loop.time() + self.ingestion_polling_timeout + sleep_interval = INGESTION_POLL_INITIAL_INTERVAL + + while True: + response, status = await self.request(uri=f"jobs/{job_id}", method="GET") + check_for_status(status, 200) + job_status = response.get("status") if isinstance(response, dict) else None + + if job_status == "FINISHED": + self.logger.debug(f"Ingestion job {job_id} finished.") + return + + if job_status in ("FAILED", "STOPPED", "CANCELED"): + message = ( + response.get("message") if isinstance(response, dict) else None + ) + raise IngestionFailedError( + f"Ingestion job {job_id} did not complete successfully " + f"(status: {job_status}). {message or ''}".strip() + ) + + if loop.time() >= deadline: + self.logger.error( + f"Ingestion not confirmed within {self.ingestion_polling_timeout}s " + f"(job {job_id}, last status: {job_status}); proceeding - " + "downstream schedules may read incomplete data." + ) + return + + await asyncio.sleep(sleep_interval) + sleep_interval = min(sleep_interval * 2, INGESTION_POLL_MAX_INTERVAL) + async def _post_sensor_data_json( self, sensor_id: int, @@ -519,6 +599,7 @@ async def _post_sensor_data_json( values: list[float], unit: str, prior: str | datetime | None = None, + await_ingestion: bool = True, ): """ Post sensor data using JSON payload. @@ -534,7 +615,7 @@ async def _post_sensor_data_json( if prior: json_payload["prior"] = pd.Timestamp(prior).isoformat() - _response, status = await self.request( + response, status = await self.request( uri=f"sensors/{sensor_id}/data", json_payload=json_payload, minimum_server_version="0.28.0", @@ -543,11 +624,16 @@ async def _post_sensor_data_json( check_for_status(status, 200) self.logger.info("Sensor data sent successfully via JSON.") + job_id = response.get("job_id") if isinstance(response, dict) else None + if await_ingestion and status == 202 and job_id: + await self._await_sensor_data_ingestion(job_id) + async def _post_sensor_data_file( self, sensor_id: int, file_path: str, belief_time_measured_instantly: bool = False, + await_ingestion: bool = True, ): """ Post sensor data using file upload. @@ -603,8 +689,10 @@ async def _post_sensor_data_file( allow_redirects=False, ) - # Check response status - if response.status != 200: + # Check response status. 202 is accepted here too: since + # FlexMeasures PR #2101, this endpoint may process the upload + # asynchronously (see await_ingestion handling below). + if response.status not in (200, 202): try: error_data = await response.json() error_message = f"Request failed with status code {response.status}: {error_data}" @@ -620,6 +708,15 @@ async def _post_sensor_data_file( self.logger.info( f"File uploaded successfully: {os.path.basename(file_path)}" ) + + job_id = ( + response_data.get("job_id") + if isinstance(response_data, dict) + else None + ) + if await_ingestion and response.status == 202 and job_id: + await self._await_sensor_data_ingestion(job_id) + return response_data, response.status except Exception as e: diff --git a/src/flexmeasures_client/exceptions.py b/src/flexmeasures_client/exceptions.py index 96beb4d9..b813837c 100644 --- a/src/flexmeasures_client/exceptions.py +++ b/src/flexmeasures_client/exceptions.py @@ -44,3 +44,16 @@ class InsufficientServerVersionError(Exception): def __init__(self, message): self.message = message super().__init__(self.message) + + +class IngestionFailedError(Exception): + """Raised when a server-side asynchronous sensor-data ingestion job fails. + + Raised by post_sensor_data() (with await_ingestion=True, the default) when + the FlexMeasures server accepted a POST for background processing (202) and + the resulting ingestion job later reports status FAILED. + """ + + def __init__(self, message): + self.message = message + super().__init__(self.message) diff --git a/tests/client/test_sensor.py b/tests/client/test_sensor.py index 2080566b..bceead89 100644 --- a/tests/client/test_sensor.py +++ b/tests/client/test_sensor.py @@ -1,5 +1,6 @@ from __future__ import annotations +import logging import os from unittest.mock import patch @@ -7,6 +8,7 @@ from aioresponses import aioresponses from flexmeasures_client.client import ContentTypeError, FlexMeasuresClient +from flexmeasures_client.exceptions import IngestionFailedError @pytest.mark.asyncio @@ -580,3 +582,185 @@ async def test_get_sensor_data_content_type_error(): resolution="PT15M", ) await client.close() + + +# --- await_ingestion (async server-side ingestion, restoring read-your-writes) --- + + +@pytest.mark.asyncio +async def test_post_sensor_data_await_ingestion_finished(): + """202 + job_id, job later FINISHED: post_sensor_data polls and returns + normally, without raising.""" + with aioresponses() as m: + client = FlexMeasuresClient(email="test@test.test", password="test") + client.access_token = "test-token" + sensor_id = 5 + m.post( + f"http://localhost:5000/api/v3_0/sensors/{sensor_id}/data", + status=202, + payload={ + "status": "ACCEPTED", + "message": "Sensor data has been accepted for processing.", + "job_monitor_url": "http://localhost:5000/api/v3_0/jobs/job-1", + "job_id": "job-1", + }, + ) + m.get( + "http://localhost:5000/api/v3_0/jobs/job-1", + status=200, + payload={ + "status": "FINISHED", + "message": "Sensor data ingestion job has finished.", + "result": None, + }, + ) + + await client.post_sensor_data( + sensor_id=sensor_id, + start="2023-01-01T00:00+00:00", + duration="PT1H", + values=[1.0], + unit="MW", + ) + await client.close() + + +@pytest.mark.asyncio +async def test_post_sensor_data_await_ingestion_failed(): + """202 + job_id, job later FAILED: post_sensor_data raises + IngestionFailedError.""" + with aioresponses() as m: + client = FlexMeasuresClient(email="test@test.test", password="test") + client.access_token = "test-token" + sensor_id = 5 + m.post( + f"http://localhost:5000/api/v3_0/sensors/{sensor_id}/data", + status=202, + payload={ + "status": "ACCEPTED", + "message": "Sensor data has been accepted for processing.", + "job_monitor_url": "http://localhost:5000/api/v3_0/jobs/job-2", + "job_id": "job-2", + }, + ) + m.get( + "http://localhost:5000/api/v3_0/jobs/job-2", + status=200, + payload={ + "status": "FAILED", + "message": "Sensor data ingestion job failed with ValueError: boom", + "result": None, + }, + ) + + with pytest.raises(IngestionFailedError, match="job-2"): + await client.post_sensor_data( + sensor_id=sensor_id, + start="2023-01-01T00:00+00:00", + duration="PT1H", + values=[1.0], + unit="MW", + ) + await client.close() + + +@pytest.mark.asyncio +async def test_post_sensor_data_await_ingestion_timeout(caplog): + """202 + job_id, job stays QUEUED past the polling timeout: post_sensor_data + logs an ERROR and returns normally (does not raise).""" + with aioresponses() as m: + client = FlexMeasuresClient(email="test@test.test", password="test") + client.access_token = "test-token" + # Very small timeout so the test doesn't need to wait for the default 60s. + client.ingestion_polling_timeout = 0.05 + sensor_id = 5 + m.post( + f"http://localhost:5000/api/v3_0/sensors/{sensor_id}/data", + status=202, + payload={ + "status": "ACCEPTED", + "message": "Sensor data has been accepted for processing.", + "job_monitor_url": "http://localhost:5000/api/v3_0/jobs/job-3", + "job_id": "job-3", + }, + ) + m.get( + "http://localhost:5000/api/v3_0/jobs/job-3", + status=200, + payload={ + "status": "QUEUED", + "message": "Sensor data ingestion job waiting to be processed.", + "result": None, + }, + repeat=True, + ) + + with caplog.at_level(logging.ERROR): + await client.post_sensor_data( + sensor_id=sensor_id, + start="2023-01-01T00:00+00:00", + duration="PT1H", + values=[1.0], + unit="MW", + ) + assert any( + "not confirmed within" in record.message for record in caplog.records + ) + await client.close() + + +@pytest.mark.asyncio +async def test_post_sensor_data_200_no_polling(): + """A synchronous 200 OK response is not followed by any job-status + polling.""" + with aioresponses() as m: + client = FlexMeasuresClient(email="test@test.test", password="test") + client.access_token = "test-token" + sensor_id = 5 + m.post( + f"http://localhost:5000/api/v3_0/sensors/{sensor_id}/data", + status=200, + payload={"status": "PROCESSED", "message": "ok"}, + ) + # No jobs/* endpoint mocked: if post_sensor_data attempted to poll, the + # unmocked GET request would raise inside aioresponses. + + await client.post_sensor_data( + sensor_id=sensor_id, + start="2023-01-01T00:00+00:00", + duration="PT1H", + values=[1.0], + unit="MW", + ) + await client.close() + + +@pytest.mark.asyncio +async def test_post_sensor_data_await_ingestion_false_no_polling(): + """await_ingestion=False opts out of polling, even for a 202 response.""" + with aioresponses() as m: + client = FlexMeasuresClient(email="test@test.test", password="test") + client.access_token = "test-token" + sensor_id = 5 + m.post( + f"http://localhost:5000/api/v3_0/sensors/{sensor_id}/data", + status=202, + payload={ + "status": "ACCEPTED", + "message": "Sensor data has been accepted for processing.", + "job_monitor_url": "http://localhost:5000/api/v3_0/jobs/job-4", + "job_id": "job-4", + }, + ) + # No jobs/* endpoint mocked: if post_sensor_data attempted to poll despite + # await_ingestion=False, the unmocked GET request would raise. + + await client.post_sensor_data( + sensor_id=sensor_id, + start="2023-01-01T00:00+00:00", + duration="PT1H", + values=[1.0], + unit="MW", + await_ingestion=False, + ) + await client.close() From 8299d0a8340433e6e889922660a19acb834c940d Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Mon, 7 Sep 2026 13:47:25 +0200 Subject: [PATCH 178/181] fix: workers handle ingestion, too Signed-off-by: F.N. Claessen --- docker-compose.override.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docker-compose.override.yml b/docker-compose.override.yml index 7761bacd..bc25514a 100644 --- a/docker-compose.override.yml +++ b/docker-compose.override.yml @@ -144,7 +144,7 @@ services: pip install --break-system-packages -r /usr/var/flexmeasures-instance/requirements.txt pip install timely-beliefs -U --break-system-packages pip install --break-system-packages -e /app/flexmeasures-client[s2] - flexmeasures jobs run-worker --name flexmeasures-worker-1 --queue scheduling\|forecasting + flexmeasures jobs run-worker --name flexmeasures-worker-1 --queue scheduling\|forecasting\|ingestion worker-2: <<: *worker command: @@ -153,7 +153,7 @@ services: pip install --break-system-packages -r /usr/var/flexmeasures-instance/requirements.txt pip install timely-beliefs -U --break-system-packages pip install --break-system-packages -e /app/flexmeasures-client[s2] - flexmeasures jobs run-worker --name flexmeasures-worker-2 --queue scheduling\|forecasting + flexmeasures jobs run-worker --name flexmeasures-worker-2 --queue scheduling\|forecasting\|ingestion worker-3: <<: *worker command: @@ -162,7 +162,7 @@ services: pip install --break-system-packages -r /usr/var/flexmeasures-instance/requirements.txt pip install timely-beliefs -U --break-system-packages pip install --break-system-packages -e /app/flexmeasures-client[s2] - flexmeasures jobs run-worker --name flexmeasures-worker-3 --queue scheduling\|forecasting + flexmeasures jobs run-worker --name flexmeasures-worker-3 --queue scheduling\|forecasting\|ingestion # One CEM per simulated household. Add or remove instances as needed; # scale by copying an entry and bumping the host port. cem: From d2a1a5f8d6b87247a86e0ce789476a337c4b6be8 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Tue, 8 Sep 2026 14:30:27 +0200 Subject: [PATCH 179/181] feat(FRBC): map S2 power ranges onto FlexMeasures' signed operation modes FlexMeasures v1 replaced the flex-model's single signed "power-range" per operation mode with the sign-explicit "consumption-range" and "production-range", each non-negative. Sending the old field against v1 fails validation (422 Unknown field), which the CEM only surfaces as a stalled simulation: the schedule trigger raises inside a fire-and-forget task, so no instruction is ever produced and the resource manager waits forever. Split each S2 power range by sign, as v1's OperationModeSchema documents: the non-negative part becomes the consumption range, the negative part becomes the production range with its sign flipped, and a range spanning zero maps onto both, each starting at zero. Verified against that schema for an on/off heater, a battery band through zero, and a production-only band. NOT compatible with pre-v1 servers, which still require "power-range" - hence a branch of its own rather than dev/fix-handshake-handler, which the current (hybrid) simulation stack runs from. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016pU9h8MTdf4nausk3FR5gR Signed-off-by: F.N. Claessen --- .../s2/control_types/FRBC/frbc_simple.py | 26 ++++++++++++++++--- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py index 87274c94..a535b3aa 100644 --- a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py +++ b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py @@ -250,12 +250,30 @@ async def trigger_schedule( band_min, band_max = sorted( (power_range.start_of_range, power_range.end_of_range) ) - band = { - "power-range": [ - f"{band_min} {self.power_unit}", + # S2 fixes one sign convention for power (positive means + # consumption), while FlexMeasures asks for the two + # directions separately, each non-negative. Split the S2 + # range by sign: its non-negative part is the consumption + # range, and its negative part becomes the production range + # with the sign flipped. A band spanning zero maps onto + # both, and each of those then starts at zero. + band: dict[str, list[str]] = {} + if band_max > 0: + band["consumption-range"] = [ + f"{max(band_min, 0)} {self.power_unit}", f"{band_max} {self.power_unit}", ] - } + if band_min < 0: + band["production-range"] = [ + f"{max(-band_max, 0)} {self.power_unit}", + f"{-band_min} {self.power_unit}", + ] + if not band: + # A band of exactly {0}: no power in either direction. + band["consumption-range"] = [ + f"0 {self.power_unit}", + f"0 {self.power_unit}", + ] if band not in operation_mode_bands: operation_mode_bands.append(band) From 2ff470f166e39990e38ccc835250dd7225722458 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Wed, 9 Sep 2026 23:32:59 +0200 Subject: [PATCH 180/181] fix(FRBC): choose the power-band shape from the server version The previous commit made the client send consumption-range and production-range unconditionally, which FlexMeasures v1.0.0 and newer expect but older servers reject, so the branch could only be used against one or the other. The shape is now chosen from the server's own reported version: the sign-explicit split from v1.0.0 onwards, the single signed power-range before that. An unknown version falls back to the older shape, which is the safe direction: a server that predates the version header also predates the split fields. This lets one branch serve both, instead of asking users to match a branch to their server. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016pU9h8MTdf4nausk3FR5gR Signed-off-by: F.N. Claessen --- .../s2/control_types/FRBC/frbc_simple.py | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py index a535b3aa..06d0eb15 100644 --- a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py +++ b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py @@ -9,6 +9,8 @@ import pandas as pd +from flexmeasures_client.client import _server_version_at_least + try: from s2python.frbc import ( FRBCActuatorStatus, @@ -243,6 +245,14 @@ async def trigger_schedule( # of a fractional power that the RM would have to round to a full-on # block, overshooting site capacity limits). Only sent when the bands # actually restrict the power range (more than one distinct band). + # Which shape the power bands take depends on the server: FlexMeasures from + # v1.0.0 wants consumption and production stated separately and rejects the + # single signed field, while older servers want only that signed field. Asking + # the server rather than pinning it per branch keeps one client working against + # both. + split_power_ranges = _server_version_at_least( + await self._fm_client._resolve_server_version(), "1.0.0" + ) operation_mode_bands: list[dict] = [] for operation_mode in actuator.operation_modes: for element in operation_mode.elements: @@ -258,7 +268,12 @@ async def trigger_schedule( # with the sign flipped. A band spanning zero maps onto # both, and each of those then starts at zero. band: dict[str, list[str]] = {} - if band_max > 0: + if not split_power_ranges: + band["power-range"] = [ + f"{band_min} {self.power_unit}", + f"{band_max} {self.power_unit}", + ] + elif band_max > 0: band["consumption-range"] = [ f"{max(band_min, 0)} {self.power_unit}", f"{band_max} {self.power_unit}", @@ -269,7 +284,7 @@ async def trigger_schedule( f"{-band_min} {self.power_unit}", ] if not band: - # A band of exactly {0}: no power in either direction. + # A band of exactly {0} on a v1 server: no power either way. band["consumption-range"] = [ f"0 {self.power_unit}", f"0 {self.power_unit}", From b32402f412a9226fb944e6e9b376f6ba0d23946a Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Thu, 10 Sep 2026 13:13:30 +0200 Subject: [PATCH 181/181] fix(compose): ingest sensor data synchronously, and clear stale ingestion registrations Since FlexMeasures PR #2101, POST /sensors/data returns 202 and enqueues a background job whenever the ingestion queue has connected workers, and ingests synchronously otherwise. Because these workers listed the ingestion queue, every measurement post became a job competing with the schedulers for the same three workers: one 40-minute community simulation ran 4201 ingestion jobs totalling 1520 seconds of worker time, against 753 seconds of actual scheduling work. Dropping the queue restores in-request ingestion, which is also what the client's post-then-trigger sequence assumes: a 202 means the data may not be readable yet when the schedule is triggered. That alone is not enough, because FlexMeasures asks Redis which workers are registered and STALE registrations count. A dead worker's entry left behind by a crashed container sends posts to a queue nobody serves, and the run hangs with no error at all - twice observed. Each worker therefore clears any registration naming that queue just before starting. The guard touches only entries naming ingestion, so it is idempotent, safe to run on every start, and safe for all three workers to run concurrently. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016pU9h8MTdf4nausk3FR5gR Signed-off-by: F.N. Claessen --- docker-compose.override.yml | 72 +++++++++++++++++++++++++++++++++++-- 1 file changed, 69 insertions(+), 3 deletions(-) diff --git a/docker-compose.override.yml b/docker-compose.override.yml index bc25514a..bd23e2cd 100644 --- a/docker-compose.override.yml +++ b/docker-compose.override.yml @@ -144,7 +144,29 @@ services: pip install --break-system-packages -r /usr/var/flexmeasures-instance/requirements.txt pip install timely-beliefs -U --break-system-packages pip install --break-system-packages -e /app/flexmeasures-client[s2] - flexmeasures jobs run-worker --name flexmeasures-worker-1 --queue scheduling\|forecasting\|ingestion + # Drop any lingering ingestion registration before this worker starts. + # FlexMeasures decides synchronous vs asynchronous sensor-data ingestion by asking + # Redis which workers are registered for the ingestion queue (PR #2101), and STALE + # registrations count: if a dead worker's entry survives, posts are enqueued to a + # queue nobody serves and the run hangs with no error. Removing only entries that + # name the ingestion queue is safe to run on every start - no worker here registers + # for it - and it is idempotent, so all three workers may do it concurrently. + python3 - <<'PYGUARD' + import os, redis + r = redis.Redis(host=os.environ.get("FLEXMEASURES_REDIS_URL", "queue-db"), + port=int(os.environ.get("FLEXMEASURES_REDIS_PORT", 6379)), + password=os.environ.get("FLEXMEASURES_REDIS_PASSWORD")) + try: + for name in r.smembers("rq:workers"): + key = name.decode() + queues = (r.hget(key, "queues") or b"").decode() + if "ingestion" in queues or not queues: + r.srem("rq:workers", name); r.delete(key) + r.delete("rq:workers:ingestion") + except Exception as exc: + print(f"ingestion-registration guard skipped: {exc}") + PYGUARD + flexmeasures jobs run-worker --name flexmeasures-worker-1 --queue scheduling\|forecasting worker-2: <<: *worker command: @@ -153,7 +175,29 @@ services: pip install --break-system-packages -r /usr/var/flexmeasures-instance/requirements.txt pip install timely-beliefs -U --break-system-packages pip install --break-system-packages -e /app/flexmeasures-client[s2] - flexmeasures jobs run-worker --name flexmeasures-worker-2 --queue scheduling\|forecasting\|ingestion + # Drop any lingering ingestion registration before this worker starts. + # FlexMeasures decides synchronous vs asynchronous sensor-data ingestion by asking + # Redis which workers are registered for the ingestion queue (PR #2101), and STALE + # registrations count: if a dead worker's entry survives, posts are enqueued to a + # queue nobody serves and the run hangs with no error. Removing only entries that + # name the ingestion queue is safe to run on every start - no worker here registers + # for it - and it is idempotent, so all three workers may do it concurrently. + python3 - <<'PYGUARD' + import os, redis + r = redis.Redis(host=os.environ.get("FLEXMEASURES_REDIS_URL", "queue-db"), + port=int(os.environ.get("FLEXMEASURES_REDIS_PORT", 6379)), + password=os.environ.get("FLEXMEASURES_REDIS_PASSWORD")) + try: + for name in r.smembers("rq:workers"): + key = name.decode() + queues = (r.hget(key, "queues") or b"").decode() + if "ingestion" in queues or not queues: + r.srem("rq:workers", name); r.delete(key) + r.delete("rq:workers:ingestion") + except Exception as exc: + print(f"ingestion-registration guard skipped: {exc}") + PYGUARD + flexmeasures jobs run-worker --name flexmeasures-worker-2 --queue scheduling\|forecasting worker-3: <<: *worker command: @@ -162,7 +206,29 @@ services: pip install --break-system-packages -r /usr/var/flexmeasures-instance/requirements.txt pip install timely-beliefs -U --break-system-packages pip install --break-system-packages -e /app/flexmeasures-client[s2] - flexmeasures jobs run-worker --name flexmeasures-worker-3 --queue scheduling\|forecasting\|ingestion + # Drop any lingering ingestion registration before this worker starts. + # FlexMeasures decides synchronous vs asynchronous sensor-data ingestion by asking + # Redis which workers are registered for the ingestion queue (PR #2101), and STALE + # registrations count: if a dead worker's entry survives, posts are enqueued to a + # queue nobody serves and the run hangs with no error. Removing only entries that + # name the ingestion queue is safe to run on every start - no worker here registers + # for it - and it is idempotent, so all three workers may do it concurrently. + python3 - <<'PYGUARD' + import os, redis + r = redis.Redis(host=os.environ.get("FLEXMEASURES_REDIS_URL", "queue-db"), + port=int(os.environ.get("FLEXMEASURES_REDIS_PORT", 6379)), + password=os.environ.get("FLEXMEASURES_REDIS_PASSWORD")) + try: + for name in r.smembers("rq:workers"): + key = name.decode() + queues = (r.hget(key, "queues") or b"").decode() + if "ingestion" in queues or not queues: + r.srem("rq:workers", name); r.delete(key) + r.delete("rq:workers:ingestion") + except Exception as exc: + print(f"ingestion-registration guard skipped: {exc}") + PYGUARD + flexmeasures jobs run-worker --name flexmeasures-worker-3 --queue scheduling\|forecasting # One CEM per simulated household. Add or remove instances as needed; # scale by copying an entry and bumping the host port. cem: