diff --git a/.coveragerc b/.coveragerc deleted file mode 100644 index c5941d49..00000000 --- a/.coveragerc +++ /dev/null @@ -1,19 +0,0 @@ -[run] -branch = True -parallel = True -omit = - tests/* - -source = src/ - -[report] -exclude_lines = - @overload - pragma: no cover - raise NotImplementedError - if TYPE_CHECKING: - pass - if __name__ == "__main__": - -# SNIPPET: use this to fail CI for missing test coverage -# fail_under = 100 diff --git a/.dvc/.gitignore b/.dvc/.gitignore new file mode 100644 index 00000000..528f30c7 --- /dev/null +++ b/.dvc/.gitignore @@ -0,0 +1,3 @@ +/config.local +/tmp +/cache diff --git a/.dvc/config b/.dvc/config new file mode 100644 index 00000000..201f2de0 --- /dev/null +++ b/.dvc/config @@ -0,0 +1,4 @@ +[core] + remote = idp-dvc-gs +['remote "idp-dvc-gs"'] + url = gs://examol-idp-prod-dvc/dvcstore-wepy2 diff --git a/.dvcignore b/.dvcignore new file mode 100644 index 00000000..51973055 --- /dev/null +++ b/.dvcignore @@ -0,0 +1,3 @@ +# Add patterns of files dvc should ignore, which could improve +# the performance. Learn more at +# https://dvc.org/doc/user-guide/dvcignore diff --git a/.envrc b/.envrc new file mode 100644 index 00000000..bb852784 --- /dev/null +++ b/.envrc @@ -0,0 +1,3 @@ +source_env_if_exists .envrc.local +. ./.venv/bin/activate + diff --git a/.gitattributes b/.gitattributes deleted file mode 100644 index c14939b4..00000000 --- a/.gitattributes +++ /dev/null @@ -1 +0,0 @@ -src/wepy/_version.py export-subst diff --git a/.gitignore b/.gitignore index 21fbb97d..102d2696 100644 --- a/.gitignore +++ b/.gitignore @@ -287,5 +287,6 @@ tags .vim # End of https://www.gitignore.io/api/vim -uv.lock _output +.envrc.local +_tmp \ No newline at end of file diff --git a/AUTHORS.org b/AUTHORS.md similarity index 71% rename from AUTHORS.org rename to AUTHORS.md index 839c73c3..512eb07a 100644 --- a/AUTHORS.org +++ b/AUTHORS.md @@ -1,10 +1,10 @@ -* Credits +# Credits -** Development Lead +## Development Lead Samuel D. Lotz -** Contributors +## Contributors Alex Dickson Nazanin Donyapour diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 44f8c7b1..c576c278 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,4 +1,40 @@ -# Contributing +# CONTRIBUTING + +``` +uv sync --all-extras +``` + +To test against other python versions run this first for each version +you are interested in: + +``` +uv sync --python 3.11 --all-extras +``` + + +## Tests + +The standard test run like: + +``` +uv run --all-extras pytest tests/unit +``` + +The flake finder flag will enable running certain tests multiple +times. Typically for parallel routines which intermittently fail. + +You can test on other python versions like: + +``` +uv run --python 3.11 --all-extras pytest --flake-finder tests/unit +``` + +The `--flake-finder` flag will run things multiple times to see if +they are flaky. + + + +# Contributing (OLD) Developing on wepy diff --git a/MANIFEST.in b/MANIFEST.in deleted file mode 100644 index 9ebaaf81..00000000 --- a/MANIFEST.in +++ /dev/null @@ -1,24 +0,0 @@ -graft src -graft info -graft envs - -prune info/examples/*/_env -prune info/examples/*/_output -prune info/examples/*/_tangle_source - -prune info/tutorials/*/_env -prune info/tutorials/*/_output -prune info/tutorials/*/_tangle_source - -include AUTHORS.org -include CHANGELOG.org -include LICENSE -include README.org - -include pyproject.toml - -include requirements.in -include versioneer.py - -global-exclude *.py[co] __pycache__ *.so *~ - diff --git a/README.md b/README.md new file mode 100644 index 00000000..798f701a --- /dev/null +++ b/README.md @@ -0,0 +1,62 @@ +DOI + +# Weighted Ensemble Python: wepy + +![Wepy Logo](./info/logo/wepy.svg) + + +[Documentation](https://adicksonlab.github.io/wepy/index.html) + +Modular implementation and framework for running weighted ensemble (WE) +simulations in pure python, where the aim is to have simple things +simple and complicated things possible. The latter being the priority. + +The goal of the architecture is that it should be highly modular to +allow extension, but provide a "killer app" for most uses that just +works, no questions asked. + +Comes equipped with support for +[OpenMM](https://github.com/pandegroup/openmm) molecular dynamics, +parallelization using multiprocessing, the +[WExplore](http://pubs.acs.org/doi/abs/10.1021/jp411479c) and +[REVO](https://pubmed.ncbi.nlm.nih.gov/31255090/) (Resampling +Ensembles by Variance Optimization) resampling algorithms, and an HDF5 +file format and library for storing and querying your WE datasets that +can be used from the command line. + +The deeper architecture of `wepy` is intended to be loosely coupled, +so that unforeseen use cases can be accomodated, but tightly +integrated for the most common of use cases, i.e. molecular dynamics. + +This allows freedom for fast development of new methods. + +## Installation + +Also see: [Installation Instructions](info/installation.org) + +```shell +pip install wepy + +# for openmm and MD related packages +pip install 'wepy[md]' +``` + +## Citations + +Current [Zenodo DOI](https://zenodo.org/badge/latestdoi/101077926). + +Cite software as: + +``` +Samuel D. Lotz, Nazanin Donyapour, Alex Dickson, Tom Dixon, Nicole Roussey, & Rob Hall. (2020, August 4). ADicksonLab/wepy: 1.0.0 Major version release (Version v1.0.0). Zenodo. http://doi.org/10.5281/zenodo.3973431 +``` + +Accompanying journal article: + +- [ACS Omega](https://pubs.acs.org/doi/abs/10.1021/acsomega.0c03892) article + + + + + + diff --git a/README.org b/README.org deleted file mode 100644 index 4b7edfc0..00000000 --- a/README.org +++ /dev/null @@ -1,93 +0,0 @@ -* Weighted Ensemble Python (wepy) - - #+ATTR_HTML: title="Join the chat at https://gitter.im/wepy/general" - [[https://gitter.im/wepy/general?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge][file:https://badges.gitter.im/wepy/general.svg]] - -[[./info/logo/wepy.svg]] - -# trying to make a zenodo badge but github doesn't support this -# directly. Would have to add a separate build step for this. -#+begin_export html -DOI -#+end_export - -[[https://adicksonlab.github.io/wepy/index.html][Sphinx Documentation]] - -[[https://github.com/ADicksonLab/wepy/blob/master/info/README.org][Plaintext Org-Mode Docs]] - -Modular implementation and framework for running weighted ensemble (WE) -simulations in pure python, where the aim is to have simple things -simple and complicated things possible. The latter being the priority. - -The goal of the architecture is that it should be highly modular to -allow extension, but provide a "killer app" for most uses that just -works, no questions asked. - -Comes equipped with support for [[https://github.com/pandegroup/openmm][OpenMM]] molecular dynamics, -parallelization using multiprocessing, the [[http://pubs.acs.org/doi/abs/10.1021/jp411479c][WExplore]] -and [[https://pubmed.ncbi.nlm.nih.gov/31255090/][REVO]] (Resampling Ensembles by Variance Optimization) resampling -algorithms, and an HDF5 file format and library for storing and -querying your WE datasets that can be used from the command line. - -The deeper architecture of ~wepy~ is intended to be loosely coupled, -so that unforeseen use cases can be accomodated, but tightly -integrated for the most common of use cases, i.e. molecular dynamics. - -This allows freedom for fast development of new methods. - -Full [[https://github.com/ADicksonLab/wepy/blob/master/info/introduction.org][introduction]]. - -** Installation - -Also see: [[info/installation.org][Installation Instructions]] - -We recommend running this version of `wepy` in a conda environment using `python=3.10` or greater: - -#+BEGIN_SRC bash - conda create -n wepy python=3.10 - conda activate wepy -#+END_SRC - -Next, install `wepy` with pip: - -#+BEGIN_SRC bash - pip install wepy -#+END_SRC - -which will also install most dependencies. - -Alternatively, the latest version of `wepy` can be installed from the git repo source: -#+BEGIN_SRC bash - git clone https://github.com/ADicksonLab/wepy.git - cd wepy - pip install . -#+END_SRC - -The OpenMM package can then be installed using conda: - -#+BEGIN_SRC bash - conda install -c conda-forge openmm -#+END_SRC - -Check its installed by running the command line interface: - -#+begin_src bash :tangle check_installation.bash -wepy --help -#+end_src - -** Citations - -Current [[https://zenodo.org/badge/latestdoi/101077926][Zenodo DOI]]. - -Cite software as: - -#+begin_example -Samuel D. Lotz, Nazanin Donyapour, Alex Dickson, Tom Dixon, Nicole Roussey, & Rob Hall. (2020, August 4). ADicksonLab/wepy: 1.0.0 Major version release (Version v1.0.0). Zenodo. http://doi.org/10.5281/zenodo.3973431 -#+end_example - -Accompanying journal article: - -- [[https://pubs.acs.org/doi/abs/10.1021/acsomega.0c03892][ACS Omega]] article - - - diff --git a/dev/README.md b/dev/README.md deleted file mode 100644 index d44d09cc..00000000 --- a/dev/README.md +++ /dev/null @@ -1,6 +0,0 @@ -This contains external specifications of the different profiles of dependencies -needed for different tasks. - -They are split up this way to allow for the absolute minimum dependencies needed -in environments like CI where we want performance to be optimal and reducing the -number of dependencies can help that a lot. diff --git a/dev/docs.requirements.txt b/dev/docs.requirements.txt deleted file mode 100644 index aa224a4a..00000000 --- a/dev/docs.requirements.txt +++ /dev/null @@ -1,7 +0,0 @@ -sphinx -sphinxcontrib-napoleon -sphinxcontrib-newsfeed -sphinxcontrib-bibtex -sphinxcontrib-newsfeed -nbsphinx -notebook diff --git a/dev/interactive.requirements.txt b/dev/interactive.requirements.txt deleted file mode 100644 index cc33b699..00000000 --- a/dev/interactive.requirements.txt +++ /dev/null @@ -1,2 +0,0 @@ -ipython -pdbpp diff --git a/dev/qa.requirements.txt b/dev/qa.requirements.txt deleted file mode 100644 index e0588d55..00000000 --- a/dev/qa.requirements.txt +++ /dev/null @@ -1,5 +0,0 @@ -black -isort -flake8 -flake8-bugbear -interrogate diff --git a/dev/testing.requirements.txt b/dev/testing.requirements.txt deleted file mode 100644 index 0c69b775..00000000 --- a/dev/testing.requirements.txt +++ /dev/null @@ -1,3 +0,0 @@ -pytest -coverage -pytest-cov diff --git a/dev/typechecking.requirements.txt b/dev/typechecking.requirements.txt deleted file mode 100644 index f0aa93ac..00000000 --- a/dev/typechecking.requirements.txt +++ /dev/null @@ -1 +0,0 @@ -mypy diff --git a/env.sh b/env.sh deleted file mode 100644 index a0a6e3d6..00000000 --- a/env.sh +++ /dev/null @@ -1 +0,0 @@ -. ./.venv/bin/activate diff --git a/info/users_guide.org b/info/users_guide.org index 19d0b93d..4a11bea4 100644 --- a/info/users_guide.org +++ b/info/users_guide.org @@ -583,7 +583,7 @@ We see that ReceptorDistance is inheriting from the ~Distance~ class, which is defined as such: #+BEGIN_SRC python - class Distance(object): + class Distance: """Abstract Base class for Distance classes.""" def __init__(self): @@ -615,7 +615,7 @@ as being equivalent to: We notice that the ~Distance~ class defines this method ~distance~: #+BEGIN_SRC python - class Distance(object): + class Distance: ... def distance(self, state_a, state_b): @@ -634,7 +634,7 @@ it still has access to it. We notice that ~Distance~ also defines the method ~image_distance~: #+BEGIN_SRC python - class Distance(object): + class Distance: ... @@ -1040,7 +1040,7 @@ weights of walkers in an ensemble (a simple list container). The implementation is very simple: #+begin_src python -class Walker(object): +class Walker: def __init__(self, state, weight): @@ -1077,7 +1077,7 @@ it doesn't directly inherit from the actual ~WalkerState~ class. The implementation is very simple: #+begin_src python - class WalkerState(object): + class WalkerState: def __init__(self, **kwargs): self._data = kwargs @@ -1478,8 +1478,8 @@ handle new ones, e.g.: #+end_src -In addition to the ABC ~Reporter~ class the ~FileReporter~ and -~ProgressiveFileReporter~ are very useful to inherit from as they +In addition to the ABC ~Reporter~ class the ~FileReporterABC~ and +~ProgressiveFileReporterABC~ are very useful to inherit from as they handle some file path and file mode logic, the latter updates modes to allow for repeated writes to the same file for each cycle of a simulation. @@ -1489,9 +1489,9 @@ specific to its own function and all the handling of filenames is done by a call to the superclass constructor: #+begin_src python - from wepy.reporter.reporter import ProgressiveFileReporter + from wepy.reporter.reporter import ProgressiveFileReporterABC - class DashboardReporter(ProgressiveFileReporter): + class DashboardReporter(ProgressiveFileReporterABC): def __init__(self, step_time=None, @@ -1524,7 +1524,7 @@ then using a simple for-loop to sequentially compute the segments: #+begin_src python - class Mapper(object): + class Mapper: def init(self, segment_func): diff --git a/justfile b/justfile new file mode 100644 index 00000000..d8ec323c --- /dev/null +++ b/justfile @@ -0,0 +1,42 @@ +#!/usr/bin/env just --justfile + +default_python := "3.13" + + +fmt-check: + uv run black --check src tests sphinx/conf.py + +fmt: + uv run black src tests sphinx/conf.py + +fix-check: + uv run isort --check src tests sphinx/conf.py + uv run ruff check src tests sphinx/conf.py + +fix: + uv run isort src tests sphinx/conf.py + uv run ruff check --fix src tests sphinx/conf.py + +check: + uv run mypy src + +test python=default_python: + uv run --all-extras --python {{python}} pytest tests/unit + +test-comprehensive: + uv run --all-extras --python 3.11 pytest tests/unit + uv run --all-extras --python 3.12 pytest tests/unit + uv run --all-extras --python 3.13 pytest tests/unit + uv run --all-extras --python 3.14 pytest tests/unit + + +test-integration python=default_python: + uv run --all-extras --python {{ python }} \ + pytest \ + --durations=0 \ + -s \ + -o log_cli=true --log-cli-level=INFO \ + tests/integration + +clean: + find . -type d -name "__pycache__" -prune -exec rm -rf {} + diff --git a/noxfile.py b/noxfile.py deleted file mode 100644 index c2ca4827..00000000 --- a/noxfile.py +++ /dev/null @@ -1,440 +0,0 @@ -# NOTE: A quick note on what Nox is used for specifically. Nox is used for -# anything that requires some sort of special virtual environment in order to -# operate. That includes creating standalone virtualenvs, and for single tasks -# requiring a specific environment. Nox should be considered an implementation -# detail of this however and all relevant high level targets should be still -# created in the Makefile. Furthermore, git hooks should reference those -# Makefile targets rather than the nox targets directly; keeping them decoupled. -# Of course feel free to use the nox targets if its easier. - -# Standard Library -import itertools as it -import os -from pathlib import Path - -# Third Party Library -import nox - -# exclude the 'dev' session here so its not run automatically -nox.options.sessions = [] - -# NOTE: that with 3.11 mdtraj fails to build -DEFAULT_PYTHON_VERSION = "3.10" - -PROJECT_ROOT_DIR = Path(__file__).parent - -SRC_DIR = PROJECT_ROOT_DIR / "src" - -SPHINX_SOURCE_DIR = PROJECT_ROOT_DIR / "sphinx" -SPHINX_BUILD_DIR = SPHINX_SOURCE_DIR / "_build" - -# listing of things to be formatted and checked -FORMAT_TARGETS = [ - "src", - "tests", - "noxfile.py", - SPHINX_SOURCE_DIR / "conf.py", -] - -LINT_TARGETS = FORMAT_TARGETS - -TYPECHECK_TARGETS = [] - -UNIT_TEST_DIRNAME = "unit" - -### Helpers - - -def install_requirements(requirements_paths: list[str]) -> list[str]: - """Given a list of requirements files generate the subprocess string for - installing all of them.""" - - return list( - it.chain( - *it.zip_longest( - [], - requirements_paths, - fillvalue="-r", - ) - ) - ) - - -def install_interactive(session: nox.Session) -> None: - """Install the standard set of interactive work dependencies, not useful in CI typically.""" - - session.install("-r", "dev/interactive.requirements.txt") - - -### Pinning - -# which extras to generate pin files for -PIN_EXTRAS = [ - "md", - "distributed", - "prometheus", - "graphics", -] - -EXTRAS_REQUIREMENTS_MAP = { - "md": "requirements-md.txt", - "distributed": "requirements-distributed.txt", - "prometheus": "requirements-prometheus.txt", -} - - -def resolve_extras_reqfiles(extras: str) -> list[str]: - extras_items = extras.split(",") - - extras_reqfiles = [] - for extra in extras_items: - extras_reqfiles.append(EXTRAS_REQUIREMENTS_MAP[extra]) - - return extras_reqfiles - - -@nox.session -@nox.parametrize("extras", PIN_EXTRAS) -def pin(session, extras): - session.install("pip-deepfreeze") - session.run("pip-df", "sync", "--extras", ",".join(PIN_EXTRAS)) - - -### Development Environment - -# this VENV_DIR constant specifies the name of the dir that the `dev` -# session will create, containing the virtualenv; -# the `resolve()` makes it portable -DEV_VENV_DIR = Path("./.venv").resolve() - - -def external_venv( - session, - requirements_txt_list: list[str], - venv_path: Path = DEV_VENV_DIR, -): - session.install("virtualenv") - session.run("virtualenv", os.fsdecode(venv_path), silent=True) - - python = os.fsdecode(venv_path.joinpath("bin/python")) - - install_spec = install_requirements(requirements_txt_list) - - session.run( - python, - "-m", - "pip", - "install", - *install_spec, - "-e", - ".", - external=True, - ) - - -@nox.session(python=DEFAULT_PYTHON_VERSION) -def dev_external(session: nox.Session) -> None: - """Set up a development environment in the '.venv' top-level folder. - - This development environment contains all dependencies needed for - development. - - """ - - mandatory_reqs = [ - "requirements.txt", - "dev/qa.requirements.txt", - "dev/typechecking.requirements.txt", - "dev/testing.requirements.txt", - ] - - # we use all the extras for the dev environment - extras_reqs = list(EXTRAS_REQUIREMENTS_MAP.values()) - - base_reqs = mandatory_reqs + extras_reqs - - if session.interactive: - reqs = base_reqs + ["dev/interactive.requirements.txt"] - - else: - reqs = base_reqs - - external_venv( - session, - reqs, - ) - - -@nox.session(python=DEFAULT_PYTHON_VERSION) -@nox.parametrize( - "extras", - [ - "postgres", - "postgres-async", - "postgres,postgres-async", - ], -) -def prod_external( - session: nox.Session, - extras: str, -) -> None: - extra_reqs = [f"requirements-{extra}.txt" for extra in extras.split(",")] - - external_venv( - session, - ["requirements.txt"] + extra_reqs, - ) - - -### QA - -## Base Functions - - -def _black_format(session): - session.run("black", *FORMAT_TARGETS) - - -def _isort_format(session): - session.run("isort", *FORMAT_TARGETS) - - -def _format(session): - _black_format(session) - _isort_format(session) - - -def _black_check(session): - session.run("black", "--check", *FORMAT_TARGETS) - - -def _isort_check(session): - session.run("isort", "--check", *FORMAT_TARGETS) - - -def _format_check(session): - _black_check(session) - _isort_check(session) - - -def _flake8(session): - session.run("flake8", *LINT_TARGETS) - - -def _interrogate(session): - session.run("interrogate", *LINT_TARGETS) - - -def _docstring_lint(session): - _interrogate(session) - - -def _lint(session): - _flake8(session) - - -def _typecheck(session): - session.run("mypy", "--strict", *TYPECHECK_TARGETS) - - -def qa_install(session): - install_spec = install_requirements( - [ - "dev/qa.requirements.txt", - ] - ) - - session.install(*install_spec) - - -## Fine Grained Sessions -@nox.session -def black_check(session): - qa_install(session) - _black_check(session) - - -@nox.session -def isort_check(session): - qa_install(session) - _isort_check(session) - - -@nox.session -def format_check(session): - qa_install(session) - _format_check(session) - - -@nox.session -def flake8(session): - qa_install(session) - _flake8(session) - - -@nox.session -def interrogate(session): - qa_install(session) - _interrogate(session) - - -@nox.session -def docstring_lint(session): - qa_install(session) - _docstring_lint(session) - - -@nox.session -def lint(session): - qa_install(session) - _lint(session) - - -## Top-Level Targets -@nox.session -def validate(session: nox.Session) -> None: - """Run all static analysis QA checks.""" - qa_install(session) - - _format_check(session) - _lint(session) - - -@nox.session(python=DEFAULT_PYTHON_VERSION) -def typecheck(session): - """Run typechecking for the project.""" - - install_spec = install_requirements( - [ - "dev/typechecking.requirements.txt", - ] - ) - - session.install(*install_spec, "-e", ".") - _typecheck(session) - - -@nox.session -def format(session): - """Run formatting on the code.""" - qa_install(session) - - _format(session) - - -### Tests - - -@nox.session(python=DEFAULT_PYTHON_VERSION) -def tests_unit( - session: nox.Session, -) -> None: - """Run the unit tests, generate the coverage database and the HTML report.""" - - install_spec = install_requirements( - [ - "dev/testing.requirements.txt", - "requirements.txt", - # we add all the extras in as well, we don't use them - # inappropriately though! - "requirements-distributed.txt", - "requirements-md.txt", - "requirements-prometheus.txt", - ] - ) - - session.install(*install_spec, "-e", ".") - - if session.interactive: - install_interactive(session) - - session.run( - "pytest", - "-s", - # modern way of importing stuff, use with `pythonpath` option in pytest.ini - "--import-mode=importlib", - "--cov-report=term-missing:skip-covered", - "--cov=wepy", - # for this stage don't fail on missing coverage - "--cov-fail-under=0", - # the pointer plugin, collect covered modules - # "--pointers-collect=src", - # "--pointers-report", - # "--pointers-func-min-pass=1", - # "--pointers-fail-under=100", - # # block the loading of the integration test plugins - # "-p", - # "no:local_test_utils.plugins.database", - # f"tests/{UNIT_TEST_DIRNAME}", - "tests/unit/test_work_mapper", - ) - - session.run("coverage", "html", "--fail-under=100", "--skip-covered") - - -@nox.session(python=DEFAULT_PYTHON_VERSION) -def coverage(session): - """Check that the coverage generated by unit tests passes.""" - - # TODO: add a way to report the unit coverage as well - - session.install("coverage") - - session.run("coverage", "html", "--skip-covered") - session.run( - "coverage", - "report", - "--fail-under=100", - "--data-file=.coverage", - "--show-missing", - ) - - -# TODO: integration, benchmark, acceptance, and docs tests -# TODO: build documentation - -## Builds & Releases - - -@nox.session(python=DEFAULT_PYTHON_VERSION) -def build(session): - session.install("hatch") - - session.run("hatch", "build") - - -@nox.session(python=DEFAULT_PYTHON_VERSION) -def bumpversion(session): - session.install("hatch") - - if session.posargs: - assert len(session.posargs) == 1, "Too many arguments only need 1." - part = session.posargs[0] - assert part in ( - "major", - "minor", - "patch", - ), "Must choose a valid bump part ('major', 'minor', 'patch')" - else: - part = "patch" - - session.run("hatch", "version", part) - - -# NOTE: this is the current owner of the PyPI package contact my email above for -# more info -PYPI_USER = "salotz" - - -@nox.session(python=DEFAULT_PYTHON_VERSION) -def publish(session): - session.install("hatch") - - session.run( - "hatch", - "-v", - "publish", - env={ - "HATCH_INDEX_USER": PYPI_USER, - }, - ) diff --git a/pyproject.toml b/pyproject.toml index a6d14d89..4b2aaee3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,22 +1,19 @@ -[build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" [project] name = "wepy" description = "Weighted Ensemble Framework" -readme = { "file" = "README.org", "content-type" = "text/plain" } +readme = { "file" = "README.md", "content-type" = "text/plain" } license = "MIT" -requires-python = ">=3.11" +requires-python = ">=3.11,<3.15" authors = [ - { name = "Samuel Lotz", email = "salotz@salotz.info" }, + { name = "Samuel Lotz", email = "samuel.lotz@salotz.info" }, { name = "Alex Dickson", email = "alexrd@msu.edu" }, { name = "Tom Dixon" }, { name = "Robert Hall" }, { name = "Nicole Roussey" }, ] -dynamic = ["version"] +version = "2.0.0b0" classifiers = [ "Topic :: Utilities", @@ -25,18 +22,19 @@ classifiers = [ ] dependencies = [ + "attrs", + "immutables", "numpy", "h5py>=3", "networkx", "pandas", - "dill", "click", "scipy", "geomm", "tabulate", "jinja2", "pint", - "multiprocessing_logging", + "more-itertools", ] [project.optional-dependencies] @@ -47,10 +45,8 @@ md = [ # NOTE: you should just install this yourself to get it the compute # runtime you need. "openmm", -] - -distributed = [ - "dask[bag]", + # for dealing with openmm serialized objects + "lxml", ] prometheus = [ @@ -72,20 +68,93 @@ Documentation = "https://adicksonlab.github.io/wepy/index.html" Source = "https://github.com/ADicksonLab/wepy" Issues = "https://github.com/ADicksonLab/wepy/issues" -[project.scripts] - -wepy = "wepy.__main__:cli" +[build-system] +requires = ["uv_build>=0.9.11,<0.10.0"] +build-backend = "uv_build" -# NOTE: currently disabled since it requires OpenMM currently to work which is -# not ideal for unit testing +[tool.uv.build-backend] +module-name = [ + "wepy", + "wepy_tools", +] -# [project.entry-points.pytest11] -# pytest-wepy = "pytest_wepy" +[dependency-groups] + +dev = [ + # for ad hoc executables like for data generation + "cyclopts", + # testing + "pytest", + "coverage", + "pytest-cov", + # for documentation testing + "pytest-shutil", + "pytest-check", + "pytest-datadir @ git+https://github.com/salotz/pytest-datadir-extras.git@41e9a1e94ba27efe28ce9d0019b1e0a73be34400", + "pytest-print", + "pytest-timeout", + "pytest-rerunfailures", + "pytest-flakefinder", + # for testing openmm wrappers + "lxml", + "psutil", + # qa + "black", + "isort", + "ruff", + "interrogate", + # typechecking + "mypy", + # interactive + "ipython", + "pdbpp", + # docs + "sphinx", + "sphinxcontrib-napoleon", + "sphinxcontrib-newsfeed", + "sphinxcontrib-bibtex", + "sphinxcontrib-newsfeed", + "nbsphinx", + "notebook", +] [tool.interrogate] fail-under = 100 verbose = 2 -[tool.hatch.version] -path = "src/wepy/__about__.py" +[tool.pytest] + +minversion = "9.0" +strict = true + +addopts = [ + "--import-mode=importlib", + "--timeout_method=thread", + # Global timeout just in case something goes wrong. You should set + # specific timeouts for known problematic tests + "--timeout=300", +] + +python_classes = ["Test*", "Test_*"] +python_functions = ["test_*"] +python_files = ["test_*.py"] + +markers = [ + "ray", # mark a test as running ray +] + +log_level = "INFO" + +[tool.mypy] + +pretty = true +color_output= true + +[[tool.mypy.overrides]] +module = "mdtraj.*" +ignore_missing_imports = true + +[[tool.mypy.overrides]] +module = "openmm.*" +ignore_missing_imports = true diff --git a/pytest.ini b/pytest.ini deleted file mode 100644 index 2e24f7bc..00000000 --- a/pytest.ini +++ /dev/null @@ -1,2 +0,0 @@ -[pytest] -addopts = --verbose diff --git a/requirements-distributed.txt b/requirements-distributed.txt deleted file mode 100644 index 2ee17232..00000000 --- a/requirements-distributed.txt +++ /dev/null @@ -1,11 +0,0 @@ -# frozen requirements generated by pip-deepfreeze -cloudpickle==2.2.1 -dask==2023.3.2 -fsspec==2023.3.0 -importlib-metadata==6.2.0 -locket==1.0.0 -packaging==23.0 -partd==1.3.0 -PyYAML==6.0 -toolz==0.12.0 -zipp==3.19.1 diff --git a/requirements-graphics.txt b/requirements-graphics.txt deleted file mode 100644 index 8538641a..00000000 --- a/requirements-graphics.txt +++ /dev/null @@ -1,11 +0,0 @@ -# frozen requirements generated by pip-deepfreeze -contourpy==1.1.1 -cycler==0.12.1 -fonttools==4.43.1 -importlib-resources==6.1.0 -kiwisolver==1.4.5 -matplotlib==3.8.0 -packaging==23.0 -Pillow>=10.3.0 -pyparsing==3.0.9 -zipp>=3.19.1 diff --git a/requirements-md.txt b/requirements-md.txt deleted file mode 100644 index 7e417c84..00000000 --- a/requirements-md.txt +++ /dev/null @@ -1,6 +0,0 @@ -# frozen requirements generated by pip-deepfreeze -astunparse==1.6.3 -mdtraj==1.9.7 -openmm-systems==0.0.0 -pyparsing==3.0.9 -wheel==0.41.2 diff --git a/requirements-prometheus.txt b/requirements-prometheus.txt deleted file mode 100644 index 733fc2b3..00000000 --- a/requirements-prometheus.txt +++ /dev/null @@ -1,3 +0,0 @@ -# frozen requirements generated by pip-deepfreeze -prometheus-client==0.16.0 -Pympler==1.0.1 diff --git a/requirements.in b/requirements.in deleted file mode 100644 index 839de833..00000000 --- a/requirements.in +++ /dev/null @@ -1,36 +0,0 @@ -# this is an abstract listing of the requirements for other projects -# which are co-developing with this repo can use to compile dependencies -# for - ---index-url https://pypi.python.org/simple/ - -numpy -h5py > 3 -networkx == 2.3 -pandas -dill - -click -scipy -matplotlib -tabulate -jinja2 -pint - -eliot -multiprocessing_logging - -# mdtraj -mdtraj - -# distributed -dask[bag] - -# prometheus -prometheus_client -pympler - -# causes simultaneous dev to fail -# git+https://github.com/ADicksonLab/geomm -# git+https://github.com/ADicksonLab/openmm_systems - diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index d2aa5d80..00000000 --- a/requirements.txt +++ /dev/null @@ -1,18 +0,0 @@ -# frozen requirements generated by pip-deepfreeze -click==8.1.3 -dill==0.3.6 -geomm==0.3.0 -h5py==3.8.0 -Jinja2>=3.1.6 -MarkupSafe==2.1.2 -multiprocessing-logging==0.3.4 -networkx==3.1 -numpy==1.24.2 -pandas==2.0.0 -Pint==0.20.1 -python-dateutil==2.8.2 -pytz==2023.3 -scipy==1.10.1 -six==1.16.0 -tabulate==0.9.0 -tzdata==2023.3 diff --git a/scrapyard/wexplore_image_reporter.py b/scrapyard/wexplore_image_reporter.py deleted file mode 100644 index bb3c95e2..00000000 --- a/scrapyard/wexplore_image_reporter.py +++ /dev/null @@ -1,131 +0,0 @@ -import logging -logger = logging.getLogger(__name__) - -import numpy as np - -import mdtraj as mdj - -from wepy.reporter.reporter import ProgressiveFileReporter -from wepy.util.mdtraj import json_to_mdtraj_topology, mdtraj_to_json_topology -from wepy.util.json_top import json_top_subset - -class WExploreAtomImageReporter(ProgressiveFileReporter): - """Reporter for generating 3D molecular structures from WExplore - region images. - - This will only be meaningful for WExplore simulations where the - region images are actually 3D coordinates. - - """ - - FILE_ORDER = ("init_state_path", "image_path") - SUGGESTED_EXTENSIONS = ("image_top.pdb", "wexplore_images.dcd") - - - def __init__(self, - init_image=None, - image_atom_idxs=None, - json_topology=None, - **kwargs): - """Constructor for the WExploreAtomImageReporter. - - Parameters - ---------- - - init_image : numpy.array, optional - The initial region image. Used for generating the topology - as well. If not given will be eventually generated. - (Default = None) - - image_atom_idxs : list of int - The indices of the atoms that are part of the topology - subset that comprises the image. - - json_topology : str - JSON format topology for the whole system. A subset of the - atoms will be taken using the image_atom_idxs. - - """ - - super().__init__(**kwargs) - - assert json_topology is not None, "must give a JSON format topology" - assert image_atom_idxs is not None, \ - "must give the indices of the atoms for the subset of the topology that is the image" - - self.image_atom_idxs = image_atom_idxs - - self.json_main_rep_top = json_top_subset(json_topology, self.image_atom_idxs) - - self.init_image = None - self._top_pdb_written = False - self.image_traj_positions = [] - - # if an initial image was given use it, otherwise just don't - # worry about it, the reason for this is that there is no - # interface for getting image indices from distance metrics as - # of now. - if init_image is not None: - self.init_image = init_image - self.image_traj_positions.append(self.init_image) - - # and times - self.times = [0] - - - def init(self, **kwargs): - - super().init(**kwargs) - - if self.init_image is not None: - - image_mdj_topology = json_to_mdtraj_topology(self.json_main_rep_top) - - # initialize the initial image into the image traj - init_image_traj = mdj.Trajectory([self.init_image], - time=self.times, - topology=image_mdj_topology) - - - - # save this as a PDB for a topology to view in VMD etc. to go - # along with the trajectory we will make - logger.info("Writing initial image to {}".format(self.init_state_path)) - init_image_traj.save_pdb(self.init_state_path) - - self._top_pdb_written = True - - def report(self, cycle_idx=None, resampler_data=None, - **kwargs): - - # load the json topology as an mdtraj one - image_mdj_topology = json_to_mdtraj_topology(self.json_main_rep_top) - - # collect the new images defined - new_images = [] - for resampler_rec in resampler_data: - image = resampler_rec['image'] - new_images.append(image) - - times = np.array([cycle_idx + 1 for _ in range(len(new_images))]) - - - # combine the new image positions and times with the old - self.image_traj_positions.extend(new_images) - self.times.extend(times) - - # only save if we have an image yet - if len(self.image_traj_positions) > 0: - - # make a trajectory of the new images, using the cycle_idx as the time - new_image_traj = mdj.Trajectory(self.image_traj_positions, - time=self.times, - topology=image_mdj_topology) - - # if we haven't already written a topology PDB write it now - if not self._top_pdb_written: - new_image_traj[0].save_pdb(self.init_state_path) - self._top_pdb_written = True - - # then the images to the trajectory file - new_image_traj.save_dcd(self.image_path) diff --git a/sphinx/conf.py b/sphinx/conf.py index 22f8e05e..a335e25d 100644 --- a/sphinx/conf.py +++ b/sphinx/conf.py @@ -230,7 +230,7 @@ # -- Options for intersphinx extension --------------------------------------- # Example configuration for intersphinx: refer to the Python standard library. -intersphinx_mapping = {'python': ("https://docs.python.org/", None)} +intersphinx_mapping = {"python": ("https://docs.python.org/", None)} # -- Options for todo extension ---------------------------------------------- diff --git a/src/pytest_wepy/__init__.py b/src/pytest_wepy/__init__.py deleted file mode 100644 index a349c178..00000000 --- a/src/pytest_wepy/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -# Third Party Library -import pytest - - -@pytest.fixture(scope="class") -def test_wepy_fixture(): - return "Hello" diff --git a/src/pytest_wepy/lennard_jones_pair.py b/src/pytest_wepy/lennard_jones_pair.py deleted file mode 100644 index 473d73f9..00000000 --- a/src/pytest_wepy/lennard_jones_pair.py +++ /dev/null @@ -1,613 +0,0 @@ -# Third Party Library -import openmm as omm -import pytest -from openmm_systems.test_systems import LennardJonesPair - -from wepy.runners.openmm import ( - GET_STATE_KWARG_DEFAULTS, - UNIT_NAMES, - OpenMMRunner, - gen_walker_state, -) - -from wepy_tools.sim_makers.openmm.sim_maker import OpenMMSimMaker - - -### Constants - -# only use the reference platform for python-only integration testing -# purposes -PLATFORM = "Reference" - - -### Sanity Test -@pytest.fixture(scope="class") -def lj_sanity_test(): - """Sanity test to make sure we even have the plugin fixtures installed.""" - return "sanity" - - -### Fixtures - - -## OpenMM Misc. - - -@pytest.fixture(scope="class") -def lj_omm_sys(): - return LennardJonesPair() - - -@pytest.fixture(scope="class") -def langevin_integrator(): - integrator = omm.LangevinIntegrator( - *OpenMMSimMaker.DEFAULT_INTEGRATOR_PARAMS["LangevinIntegrator"] - ) - - return integrator - - -integrators = [ - langevin_integrator, -] - - -@pytest.fixture( - scope="class", - params=[ - "LangevinIntegrator", - ], -) -def lj_integrator( - request, - *integrators, -): - intgr_spec = request.param - if intgr_spec == "LangevinIntegrator": - return langevin_integrator - else: - raise ValueError("Unkown integrator") - - -## Runner - - -@pytest.fixture( - scope="class", - params=[ - "Reference", - ], -) -def lj_openmm_runner(request, lj_omm_sys, lj_integrator): - # parametrize the platform - platform = request.param - - positions = test_sys.positions.value_in_unit(test_sys.positions.unit) - - init_state = gen_walker_state(positions, test_sys.system, integrator) - - # initialize the runner - runner = OpenMMRunner( - lj_omm_sys.system, lj_omm_sys.topology, lj_integrator, platform=platform - ) - - return runner - - -## Resampler - -# @pytest.fixture(scope='class') -# def lj_distance_metric(): -# return PairDistance() - - -# @pytest.fixture(scope='class') -# def lj_wexplore_resampler(lj_params, lj_distance_metric, lj_init_state): -# resampler = WExploreResampler(distance=lj_distance_metric, -# init_state=lj_init_state, -# max_region_sizes=lj_params['max_region_sizes'], -# max_n_regions=lj_params['max_n_regions'], -# pmin=lj_params['pmin'], pmax=lj_params['pmax']) - -# return resampler - -# @pytest.fixture(scope='class') -# def lj_revo_resampler(lj_params, lj_distance_metric, lj_init_state): -# resampler = REVOResampler(distance=lj_distance_metric, -# merge_dist=2.5, -# char_dist=1.0, -# init_state=lj_init_state, -# pmin=lj_params['pmin'], pmax=lj_params['pmax']) - -# return resampler - -# @pytest.fixture(scope='class') -# def lj_topology(lj_omm_sys): - -# # the mdtraj here is needed for the distance function -# mdtraj_topology = mdj.Topology.from_openmm(lj_omm_sys.topology) - -# ## Reporters if we want them -# json_str_top = mdtraj_to_json_topology(mdtraj_topology) - -# return json_str_top - - -# @pytest.fixture(scope='class') -# def lj_unbinding_bc(lj_params, lj_init_state, lj_topology, lj_omm_sys): - -# # initialize the unbinding boundary condition -# ubc = UnbindingBC(cutoff_distance=lj_params['cutoff_distance'], -# initial_state=lj_init_state, -# topology=lj_topology, -# ligand_idxs=np.array(lj_omm_sys.ligand_indices), -# receptor_idxs=np.array(lj_omm_sys.receptor_indices)) - -# return ubc - -# @pytest.fixture(scope='class') -# def lj_reporter_kwargs(lj_params, lj_topology, lj_wexplore_resampler, lj_unbinding_bc): -# """Reporters that work for all of the components.""" - -# # make a dictionary of units for adding to the HDF5 -# units = dict(UNIT_NAMES) - -# hdf5_reporter_kwargs = {'save_fields' : lj_params['save_fields'], -# 'resampler' : lj_wexplore_resampler, -# 'boundary_conditions' : lj_unbinding_bc, -# 'topology' : lj_topology, -# 'units' : units, -# } - -# dashboard_reporter_kwargs = {'step_time' : lj_params['step_size'].value_in_unit(unit.second), -# 'bc_cutoff_distance' : lj_unbinding_bc.cutoff_distance} - -# # Resampling Tree -# restree_reporter_kwargs = {'resampler' : lj_wexplore_resampler, -# 'boundary_condition' : lj_unbinding_bc, -# 'node_radius' : 3.0, -# 'row_spacing' : 5.0, -# 'step_spacing' : 20.0, -# 'progress_key' : 'min_distances', -# 'max_progress_value' : lj_unbinding_bc.cutoff_distance, -# 'colormap_name' : 'plasma'} - - -# reporter_kwargs = [hdf5_reporter_kwargs, dashboard_reporter_kwargs, -# restree_reporter_kwargs] - -# return reporter_kwargs - - -# @pytest.fixture(scope='class') -# def lj_reporter_classes(): -# reporter_classes = [WepyHDF5Reporter, DashboardReporter, -# ResTreeReporter] - -# return reporter_classes - -# @pytest.fixture(scope='class') -# def lj_init_walkers(lj_params, lj_init_sim_state): -# init_weight = 1.0 / lj_params['n_walkers'] -# init_walkers = [OpenMMWalker(OpenMMState(lj_init_sim_state), init_weight) -# for i in range(lj_params['n_walkers'])] - -# return init_walkers - - -# @pytest.fixture(scope='class') -# def lj_apparatus(lj_openmm_runner, lj_wexplore_resampler, lj_unbinding_bc): - -# sim_apparatus = WepySimApparatus(lj_openmm_runner, resampler=lj_wexplore_resampler, -# boundary_conditions=lj_unbinding_bc) - -# return sim_apparatus - -# @pytest.fixture(scope='class') -# def lj_null_apparatus(lj_openmm_runner): - -# sim_apparatus = WepySimApparatus(lj_openmm_runner, resampler=NoResampler()) - -# return sim_apparatus - -# @pytest.fixture(scope='class') -# def lj_snapshot(lj_init_walkers, lj_apparatus): - -# return SimSnapshot(lj_init_walkers, lj_apparatus) - - -# @pytest.fixture(scope='class') -# def lj_configuration(tmp_path_factory, lj_reporter_classes, lj_reporter_kwargs): - -# # make a temporary directory for this configuration to work with -# tmpdir = str(tmp_path_factory.mktemp('lj_fixture')) -# # tmpdir = tmp_path_factory.mktemp('lj_fixture/work_dir') - -# configuration = Configuration(work_dir=tmpdir, -# reporter_classes=lj_reporter_classes, -# reporter_partial_kwargs=lj_reporter_kwargs) - -# return configuration - -# @pytest.fixture(scope='class') -# def lj_null_configuration(tmp_path_factory, lj_reporter_classes, lj_reporter_kwargs, -# lj_params, lj_wexplore_resampler, lj_topology): - -# reporter_classes = [WepyHDF5Reporter] - -# # make a dictionary of units for adding to the HDF5 -# units = dict(UNIT_NAMES) - -# hdf5_reporter_kwargs = {'save_fields' : lj_params['save_fields'], -# 'resampler' : lj_wexplore_resampler, -# 'topology' : lj_topology, -# 'units' : units, -# } - -# reporter_kwargs = [hdf5_reporter_kwargs] - - -# # make a temporary directory for this configuration to work with -# tmpdir = str(tmp_path_factory.mktemp('lj_fixture')) -# # tmpdir = tmp_path_factory.mktemp('lj_fixture/work_dir') - -# configuration = Configuration(work_dir=tmpdir, -# reporter_classes=reporter_classes, -# reporter_partial_kwargs=reporter_kwargs) - -# return configuration - -# @pytest.fixture(scope='class') -# def lj_inmem_configuration(tmp_path_factory): - -# # make a temporary directory for this configuration to work with -# tmpdir = str(tmp_path_factory.mktemp('lj_fixture')) -# # tmpdir = tmp_path_factory.mktemp('lj_fixture/work_dir') - -# configuration = Configuration(work_dir=tmpdir) - -# return configuration - - -# @pytest.fixture(scope='class') -# def lj_work_mapper(lj_configuration): - -# work_mapper = Mapper() - -# return work_mapper - - -# @pytest.fixture(scope='class') -# def lj_work_mapper_worker(): - -# work_mapper = WorkerMapper(num_workers=1) - -# return work_mapper - -# @pytest.fixture(scope='class') -# def lj_work_mapper_task(): - -# work_mapper = TaskMapper(num_workers=1) - -# return work_mapper - - -# @pytest.fixture(scope='class') -# def lj_reporters(tmp_path_factory, lj_reporter_classes, lj_reporter_kwargs): - - -# # make a temporary directory for this configuration to work with -# tmpdir = str(tmp_path_factory.mktemp('lj_fixture')) - -# # make a config so that the reporters get parametrized properly -# config = Configuration(work_dir=tmpdir, -# reporter_classes=lj_reporter_classes, -# reporter_partial_kwargs=lj_reporter_kwargs) - -# return config.reporters - - -# @pytest.fixture(scope='class') -# def lj_orchestrator(lj_apparatus, lj_init_walkers, lj_configuration): - -# # use an in memory database with sqlite - -# # make a path to the temporary directory for this orchestrator -# # orch_path = tmp_path_factory.mktemp('lj_fixture/lj.orch.sqlite') - -# # then create the seed/root/master orchestrator which will be used -# # from here on out -# orch = Orchestrator() - -# return orch - -# @pytest.fixture(scope='class') -# def lj_orchestrator_defaults(lj_orchestrator, -# lj_apparatus, lj_init_walkers, lj_configuration): - - -# lj_orchestrator.set_default_sim_apparatus(lj_apparatus) -# lj_orchestrator.set_default_init_walkers(lj_init_walkers) -# lj_orchestrator.set_default_configuration(lj_configuration) - -# lj_orchestrator.gen_default_snapshot() - - -# return lj_orchestrator - -# @pytest.fixture(scope='class') -# def lj_orchestrator_defaults_inmem(lj_orchestrator, -# lj_apparatus, lj_init_walkers, lj_inmem_configuration): - - -# lj_orchestrator.set_default_sim_apparatus(lj_apparatus) -# lj_orchestrator.set_default_init_walkers(lj_init_walkers) -# lj_orchestrator.set_default_configuration(lj_configuration) - -# lj_orchestrator.gen_default_snapshot() - - -# return lj_orchestrator - -# @pytest.fixture(scope='class') -# def lj_orchestrator_defaults_null(lj_orchestrator, -# lj_null_apparatus, lj_init_walkers, -# lj_null_configuration): - -# lj_orchestrator.set_default_sim_apparatus(lj_null_apparatus) -# lj_orchestrator.set_default_init_walkers(lj_init_walkers) -# lj_orchestrator.set_default_configuration(lj_null_configuration) - -# lj_orchestrator.gen_default_snapshot() - - -# return lj_orchestrator - - -# @pytest.fixture(scope='class') -# def lj_orchestrator_file(tmp_path_factory, lj_apparatus, lj_init_walkers, lj_configuration): - -# # use an in memory database with sqlite - -# # make a path to the temporary directory for this orchestrator -# orch_path = str(tmp_path_factory.mktemp('lj_fixture') / "lj.orch.sqlite") - -# # then create the seed/root/master orchestrator which will be used -# # from here on out -# orch = Orchestrator(orch_path) - -# return orch - -# @pytest.fixture(scope='class') -# def lj_orchestrator_file_other(tmp_path_factory, -# lj_apparatus, lj_init_walkers, lj_configuration): - -# # use an in memory database with sqlite - -# # make a path to the temporary directory for this orchestrator -# orch_path = str(tmp_path_factory.mktemp('lj_fixture') / "lj_other.orch.sqlite") - -# # then create the seed/root/master orchestrator which will be used -# # from here on out -# orch = Orchestrator(orch_path) - -# return orch - -# @pytest.fixture(scope='class') -# def lj_orchestrator_defaults_file(lj_orchestrator_file, -# lj_apparatus, lj_init_walkers, lj_configuration): - - -# lj_orchestrator_file.set_default_sim_apparatus(lj_apparatus) -# lj_orchestrator_file.set_default_init_walkers(lj_init_walkers) -# lj_orchestrator_file.set_default_configuration(lj_configuration) - -# lj_orchestrator_file.gen_default_snapshot() - - -# return lj_orchestrator_file - -# @pytest.fixture(scope='class') -# def lj_orchestrator_defaults_file_other(lj_orchestrator_file_other, -# lj_apparatus, lj_init_walkers, lj_configuration): - -# lj_orchestrator_file_other.set_default_sim_apparatus(lj_apparatus) -# lj_orchestrator_file_other.set_default_init_walkers(lj_init_walkers) -# lj_orchestrator_file_other.set_default_configuration(lj_configuration) - -# lj_orchestrator_file_other.gen_default_snapshot() - - -# return lj_orchestrator_file_other - - -# @pytest.fixture(scope='class') -# def lj_sim_manager(tmp_path_factory, lj_orchestrator_defaults): - -# start_snapshot = lj_orchestrator_defaults.get_default_snapshot() -# configuration = lj_orchestrator_defaults.get_default_configuration() - -# # make a new temp dir for this configuration -# tempdir = str(tmp_path_factory.mktemp('lj_sim_manager')) -# configuration = configuration.reparametrize(work_dir=tempdir) - -# sim_manager = lj_orchestrator_defaults.gen_sim_manager(start_snapshot, -# configuration=configuration) - -# return sim_manager - - -# @pytest.fixture(scope='class') -# def lj_sim_manager_inmem(tmp_path_factory, lj_orchestrator_defaults_inmem): - -# start_snapshot = lj_orchestrator_defaults_inmem.get_default_snapshot() -# configuration = lj_orchestrator_defaults_inmem.get_default_configuration() - -# # make a new temp dir for this configuration -# tempdir = str(tmp_path_factory.mktemp('lj_sim_manager')) -# configuration = configuration.reparametrize(work_dir=tempdir) - -# sim_manager = lj_orchestrator_defaults.gen_sim_manager(start_snapshot, -# configuration=configuration) - -# return sim_manager - -# @pytest.fixture(scope='class') -# def lj_sim_manager_null(tmp_path_factory, lj_orchestrator_defaults_null): - -# start_snapshot = lj_orchestrator_defaults_null.get_default_snapshot() -# configuration = lj_orchestrator_defaults_null.get_default_configuration() - -# # make a new temp dir for this configuration -# tempdir = str(tmp_path_factory.mktemp('lj_sim_manager')) -# configuration = configuration.reparametrize(work_dir=tempdir) - -# sim_manager = lj_orchestrator_defaults_null.gen_sim_manager(start_snapshot, -# configuration=configuration) - -# return sim_manager - - -# @pytest.fixture(scope='class') -# def lj_sim_manager_run_results(lj_sim_manager): - -# n_cycles = 10 -# n_steps = 100 - -# steps = [n_steps for _ in range(n_cycles)] - -# return lj_sim_manager.run_simulation(n_cycles, steps) - -# @pytest.fixture(scope='class') -# def lj_sim_manager_null_run_results(lj_sim_manager_null): - -# n_cycles = 10 -# n_steps = 100 - -# steps = [n_steps for _ in range(n_cycles)] - -# return lj_sim_manager_null.run_simulation(n_cycles, steps) - -# @pytest.fixture(scope='class') -# def lj_orch_run_by_time_results(tmp_path_factory, lj_orchestrator_defaults): - -# runtime = 20 # seconds -# n_steps = 100 - -# start_snaphash = lj_orchestrator_defaults.get_default_snapshot_hash() - -# # make a new temp dir for this configuration -# configuration = lj_orchestrator_defaults.get_default_configuration() -# tempdir = str(tmp_path_factory.mktemp('lj_sim_manager')) -# configuration = configuration.reparametrize(work_dir=tempdir) - - -# return lj_orchestrator_defaults.run_snapshot_by_time(start_snaphash, -# runtime, n_steps, -# configuration=configuration) - -# @pytest.fixture(scope='class') -# def lj_orch_run_end_snapshot(lj_orch_run_by_time_results): - -# end_snapshot, _, _, _ = lj_orch_run_by_time_results - -# return end_snapshot - -# @pytest.fixture(scope='class') -# def lj_orch_orchestrated_run(tmp_path_factory, lj_orchestrator_defaults): - -# run_time = 20 # seconds -# n_steps = 100 - -# start_snaphash = lj_orchestrator_defaults.get_default_snapshot_hash() - -# tempdir = str(tmp_path_factory.mktemp('orchestrate_run')) - -# run_orch = lj_orchestrator_defaults.orchestrate_snapshot_run_by_time(start_snaphash, -# run_time, n_steps, -# work_dir=tempdir) - -# return run_orch - -# @pytest.fixture(scope='class') -# def lj_orch_file_orchestrated_run(tmp_path_factory, lj_orchestrator_defaults_file): - -# run_time = 20 # seconds -# n_steps = 100 - -# start_snaphash = lj_orchestrator_defaults_file.get_default_snapshot_hash() - -# tempdir = str(tmp_path_factory.mktemp('orchestrate_run')) - -# run_orch = lj_orchestrator_defaults_file.orchestrate_snapshot_run_by_time(start_snaphash, -# run_time, n_steps, -# work_dir=tempdir) - -# return run_orch - -# @pytest.fixture(scope='class') -# def lj_orch_file_other_orchestrated_run(tmp_path_factory, -# lj_orchestrator_defaults_file_other): - -# run_time = 20 # seconds -# n_steps = 100 - -# start_snaphash = lj_orchestrator_defaults_file_other.get_default_snapshot_hash() - -# tempdir = str(tmp_path_factory.mktemp('orchestrate_run_other')) - -# run_orch = lj_orchestrator_defaults_file_other.orchestrate_snapshot_run_by_time(start_snaphash, -# run_time, n_steps, -# work_dir=tempdir) - -# return run_orch - - -# @pytest.fixture(scope='class') -# def lj_orch_reconciled_orchs(tmp_path_factory, lj_apparatus, lj_init_walkers, lj_configuration): - -# run_time = 20 # seconds -# n_steps = 100 - -# # tempdirs for the orchestrators and configuration output -# first_tempdir = str(tmp_path_factory.mktemp('reconcile_first_run')) -# second_tempdir = str(tmp_path_factory.mktemp('reconcile_second_run')) - -# first_orch_path = osp.join(first_tempdir, "first.orch.sqlite") -# second_orch_path = osp.join(second_tempdir, "second.orch.sqlite") - -# # make two orchestrators in their directories -# first_orch = Orchestrator(orch_path=first_orch_path) -# second_orch = Orchestrator(orch_path=second_orch_path) - -# # configure them -# # 1 -# first_orch.set_default_sim_apparatus(lj_apparatus) -# first_orch.set_default_init_walkers(lj_init_walkers) -# first_orch.set_default_configuration(lj_configuration) -# first_orch.gen_default_snapshot() -# # 2 -# second_orch.set_default_sim_apparatus(lj_apparatus) -# second_orch.set_default_init_walkers(lj_init_walkers) -# second_orch.set_default_configuration(lj_configuration) -# second_orch.gen_default_snapshot() - -# # do independent runs for each of them - -# # start snapshot hashes -# first_starthash = first_orch.get_default_snapshot_hash() -# second_starthash = second_orch.get_default_snapshot_hash() - -# # then orchestrate the runs -# first_run_orch = first_orch.orchestrate_snapshot_run_by_time(first_starthash, -# run_time, n_steps, -# work_dir=first_tempdir) - -# second_run_orch = second_orch.orchestrate_snapshot_run_by_time(second_starthash, -# run_time, n_steps, -# work_dir=second_tempdir) - -# # then reconcile them -# reconciled_orch = reconcile_orchestrators(first_run_orch.orch_path, second_run_orch.orch_path) - -# return first_run_orch, second_run_orch, reconciled_orch diff --git a/src/pytest_wepy/test_hdf5.py b/src/pytest_wepy/test_hdf5.py deleted file mode 100644 index 0e7bd452..00000000 --- a/src/pytest_wepy/test_hdf5.py +++ /dev/null @@ -1,215 +0,0 @@ -# Testing hdf5 functionality -# -# 1) writing to HDF5 during simulation (boundary conditions, resampling) -# 2) reading in HDF5 (tests on sensible data) -# 3) compute observable -# 4) get traces - -# Standard Library -import os - -# Third Party Library -import mdtraj as mdj -import numpy as np - -# First Party Library -from wepy.boundary_conditions.boundary import NoBC -from wepy.boundary_conditions.randomwalk import RandomWalkBC -from wepy.hdf5 import WepyHDF5 -from wepy.reporter.hdf5 import WepyHDF5Reporter -from wepy.resampling.distances.randomwalk import RandomWalkDistance -from wepy.resampling.resamplers.resampler import NoResampler -from wepy.resampling.resamplers.revo import REVOResampler -from wepy.runners.randomwalk import UNIT_NAMES, RandomWalkRunner -from wepy.sim_manager import Manager -from wepy.util.mdtraj import mdtraj_to_json_topology -from wepy.walker import Walker, WalkerState -from wepy.work_mapper.mapper import Mapper - -num_walkers = 20 -hdf5_filename = "test.h5" -segment_length = 1 -threshold = 5 - - -def generate_topology(N): - """Creates an N-atom, dummy trajectory and topology for - the randomwalk system using the mdtraj package. Then creates a - JSON format for the topology. This JSON string is used in making - the WepyHDF5 reporter. - - Returns - ------- - topology: str - JSON string representing the topology of system being simulated. - """ - data = [] - top = mdj.Topology() - c = top.add_chain() - r = top.add_residue("test", c) - - for i in range(N): - at = top.add_atom(f"a{i}", mdj.element.argon, r, i) - - json_top_str = mdtraj_to_json_topology(top) - return json_top_str - - -def test_WriteReadH5(): - cleanup = True - - # 1D random walk - positions = np.zeros((1, 1)) - - init_state = WalkerState(positions=positions, time=0.0) - - # create list of init_walkers - initial_weight = 1 / num_walkers - init_walkers = [] - - init_walkers = [Walker(init_state, initial_weight) for i in range(num_walkers)] - - # set up runner for system - runner = RandomWalkRunner(probability=0.5) - - units = dict(UNIT_NAMES) - # instantiate a revo resampler and unbindingboundarycondition - - rw_distance = RandomWalkDistance() - - resampler = REVOResampler( - merge_dist=100, - char_dist=0.1, - distance=rw_distance, - init_state=init_state, - weights=True, - pmax=0.5, - dist_exponent=4, - ) - - json_top = generate_topology(1) - - rw_bc = RandomWalkBC(threshold=threshold, initial_states=[init_state]) - - hdf5_reporter = WepyHDF5Reporter( - file_path=hdf5_filename, - mode="w", - save_fields=["positions"], - boundary_conditions=rw_bc, - topology=json_top, - resampler=resampler, - n_dims=1, - ) - - sim_manager = Manager( - init_walkers, - runner=runner, - resampler=resampler, - boundary_conditions=rw_bc, - work_mapper=Mapper(), - reporters=[hdf5_reporter], - ) - - n_cycles = 20 - steps = [segment_length for i in range(n_cycles)] - - sim_manager.run_simulation(n_cycles, steps) - - # ------ - # data collected! now analyze - # ------ - - we = WepyHDF5(hdf5_filename, mode="r") - - initial_trace = [(i, 0) for i in range(num_walkers)] - final_trace = [(i, n_cycles - 1) for i in range(num_walkers)] - with we: - initial_pos = we.get_run_trace_fields(0, initial_trace, ["positions"]) - final_pos = we.get_run_trace_fields(0, final_trace, ["positions"]) - - # test that you haven't moved more than segment_length positions in the first cycle - assert initial_pos["positions"].max() <= segment_length - assert initial_pos["positions"].min() >= 0 - - # test that some of the trajectories have moved in the final pos - assert final_pos["positions"].max() >= 0 - - # test that no positions are further than the boundary condition - assert final_pos["positions"].max() <= int(threshold) - assert final_pos["positions"].min() >= 0 - - if cleanup: - os.remove(hdf5_filename) - - -def makeRandomWalkH5(h5name, resampling=True, warping=True): - # 1D random walk - positions = np.zeros((1, 1)) - - init_state = WalkerState(positions=positions, time=0.0) - - # create list of init_walkers - initial_weight = 1 / num_walkers - init_walkers = [] - - init_walkers = [Walker(init_state, initial_weight) for i in range(num_walkers)] - - # set up runner for system - runner = RandomWalkRunner(probability=0.5) - - units = dict(UNIT_NAMES) - # instantiate a revo resampler and unbindingboundarycondition - - rw_distance = RandomWalkDistance() - - if resampling: - resampler = REVOResampler( - merge_dist=100, - char_dist=0.1, - distance=rw_distance, - init_state=init_state, - weights=True, - pmax=0.5, - dist_exponent=4, - ) - else: - resampler = NoResampler() - - json_top = generate_topology(1) - - if warping: - rw_bc = RandomWalkBC(threshold=threshold, initial_states=[init_state]) - else: - rw_bc = NoBC() - - hdf5_reporter = WepyHDF5Reporter( - file_path=h5name, - mode="w", - save_fields=["positions"], - boundary_conditions=rw_bc, - topology=json_top, - resampler=resampler, - n_dims=1, - ) - - sim_manager = Manager( - init_walkers, - runner=runner, - resampler=resampler, - boundary_conditions=rw_bc, - work_mapper=Mapper(), - reporters=[hdf5_reporter], - ) - - n_cycles = 20 - steps = [segment_length for i in range(n_cycles)] - - sim_manager.run_simulation(n_cycles, steps) - - -if __name__ == "__main__": - makeRandomWalkH5("test_data/rw.h5", resampling=True, warping=True) - makeRandomWalkH5("test_data/rw_noresampling.h5", resampling=False, warping=True) - makeRandomWalkH5("test_data/rw_nowarping.h5", resampling=True, warping=False) - - print("HDF5s written") diff --git a/src/pytest_wepy/test_hdf5_analysis.py b/src/pytest_wepy/test_hdf5_analysis.py deleted file mode 100644 index 5be6abed..00000000 --- a/src/pytest_wepy/test_hdf5_analysis.py +++ /dev/null @@ -1,202 +0,0 @@ -# Testing hdf5 functionality -# -# 1) testing if warping events are written correctly -# 2) reading in HDF5 (tests on sensible data) -# 3) compute observable -# 4) get traces - -# Standard Library -import os -import unittest - -# Third Party Library -import mdtraj as mdj -import numpy as np - -# First Party Library -from wepy.analysis.contig_tree import ContigTree -from wepy.boundary_conditions.randomwalk import RandomWalkBC -from wepy.hdf5 import WepyHDF5 -from wepy.resampling.decisions.clone_merge import MultiCloneMergeDecision - -hdf5_filename = "test_data/rw.h5" -hdf5_filename_nores = "test_data/rw_noresampling.h5" -hdf5_filename_nowarp = "test_data/rw_nowarping.h5" -segment_length = 1 -boundary_position = 5 - - -def test_H5_warping(): - # ------------------------------------------------------------------ - # test_data/rw_noresampling.h5 holds 1D random walk data (generated by test_hdf5.py) - # includes warping, but no resampling - # ------ - we = WepyHDF5(hdf5_filename_nores, mode="r") - with we: - n_cycles = we.num_run_cycles(0) - wr_list = we.warping_records([0]) - warp_trace = [(wr.walker_idx, wr.cycle_idx) for wr in wr_list] - warp_pos = we.get_run_trace_fields(0, warp_trace, ["positions"]) - - # all of the warp positions should be at x = boundary_position - if len(warp_pos["positions"]) > 0: - assert warp_pos["positions"].max() == boundary_position - assert warp_pos["positions"].min() == boundary_position - - # make sure that the next cycle they get warped back to zero - warp_next_trace = [ - (wr.walker_idx, wr.cycle_idx + 1) - for wr in wr_list - if wr.cycle_idx + 1 < n_cycles - ] - with we: - warp_next_pos = we.get_run_trace_fields(0, warp_next_trace, ["positions"]) - - # test if the walkers were warped back to the beginning - assert warp_next_pos["positions"].max() <= segment_length - - -def test_H5_resampling(): - # ------------------------------------------------------------------ - # test_data/rw_nowarping.h5 holds 1D random walk data (generated by test_hdf5.py) - # includes resampling, but no warping - # ------ - we = WepyHDF5(hdf5_filename_nowarp, mode="r") - with we: - n_cycles = we.num_run_cycles(0) - wr_list = we.warping_records([0]) - n_walkers = we.num_walkers(0, 0) - all_pos = np.array([ - we.h5[f"runs/0/trajectories/{i}/positions"] for i in range(n_walkers) - ]) - all_wts = np.array([ - we.h5[f"runs/0/trajectories/{i}/weights"] for i in range(n_walkers) - ]) - - rrs = we.resampling_records([0]) - # - # find all cloning events - # - # MultiCloneMergeDecision: (1: Nothing; 2: Clone; 3: Squash; 4: Keep_Merge) - # - clone_rrs = [rr for rr in rrs if rr.decision_id == 2] - for cr in clone_rrs: - parent = cr.walker_idx - targets = cr.target_idxs - cycle_idx = cr.cycle_idx - if cycle_idx + 1 < n_cycles: - for target in targets: - # test if cloned walkers have the appropriate weights - assert ( - all_wts[parent][cycle_idx] / len(targets) - == all_wts[target][cycle_idx + 1] - ) - - # test if the children are within segment_length of the parent - assert ( - np.sum( - np.abs( - all_pos[parent][cycle_idx] - all_pos[target][cycle_idx + 1] - ) - ) - <= segment_length - ) - - # - # find all merging events - # - squash_rrs = [rr for rr in rrs if rr.decision_id == 3] - keep_merge_rrs = [rr for rr in rrs if rr.decision_id == 4] - for km in keep_merge_rrs: - cycle = km.cycle_idx - walker = km.walker_idx - if cycle + 1 < n_cycles: - # get all the squash records that correspond to this - squashed_walkers = [ - sr.walker_idx - for sr in squash_rrs - if (sr.target_idxs[0] == walker and sr.cycle_idx == cycle) - ] + [walker] - - # check if the sum of their weights (on this cycle) equals the km weight (on the next cycle) - np.testing.assert_almost_equal( - np.sum(all_wts[walker][cycle + 1]), - np.sum(all_wts[squashed_walkers, cycle]), - decimal=5, - ) - - -def test_H5_contig(): - # ------------------------------------------------------------------ - # test_data/rw.h5 holds 1D random walk data (generated by test_hdf5.py) - # includes both warping and resampling - # ------ - we = WepyHDF5(hdf5_filename, mode="r") - with we: - n_cycles = we.num_run_cycles(0) - n_walkers = we.num_walkers(0, 0) - all_pos = np.array([ - we.h5[f"runs/0/trajectories/{i}/positions"] for i in range(n_walkers) - ]) - ct = ContigTree( - we, - boundary_condition_class=RandomWalkBC, - decision_class=MultiCloneMergeDecision, - ) - - sw = ct.sliding_windows(3) - for trace in sw: - # test if traces are from adjacent cycles - assert trace[1][2] - trace[0][2] == 1 - assert trace[2][2] - trace[1][2] == 1 - - # test if positions are adjacent - assert ( - np.sum( - np.abs( - all_pos[trace[1][1], trace[1][2]] - - all_pos[trace[0][1], trace[0][2]] - ) - ) - <= 1 - ) - assert ( - np.sum( - np.abs( - all_pos[trace[2][1], trace[2][2]] - - all_pos[trace[1][1], trace[1][2]] - ) - ) - <= 1 - ) - - final_trace = [(0, i, n_cycles - 1) for i in range(n_walkers)] - lineages = ct.lineages(final_trace, discontinuities=True) - for lin in lineages: - for i in range(len(lin) - 1): - # test if positions are adjacent - assert ( - np.sum( - np.abs( - all_pos[lin[i][1], lin[i][2]] - - all_pos[lin[i + 1][1], lin[i + 1][2]] - ) - ) - <= 1 - ) - - disc_lineages = ct.lineages(final_trace, discontinuities=False) - for lin in disc_lineages: - for i in range(len(lin) - 1): - # if positions aren't adjacent, check that they correspond to warping events - if ( - np.sum( - np.abs( - all_pos[lin[i][1], lin[i][2]] - - all_pos[lin[i + 1][1], lin[i + 1][2]] - ) - ) - > 1 - ): - assert all_pos[lin[i][1], lin[i][2]] == boundary_position - assert all_pos[lin[i + 1][1], lin[i + 1][2]] <= segment_length diff --git a/src/wepy/__about__.py b/src/wepy/__about__.py index 6849410a..ef2ec23a 100644 --- a/src/wepy/__about__.py +++ b/src/wepy/__about__.py @@ -1 +1 @@ -__version__ = "1.1.0" +__version__ = "2.0.0b0" diff --git a/src/wepy/__init__.py b/src/wepy/__init__.py index e1c1fbb3..31587686 100644 --- a/src/wepy/__init__.py +++ b/src/wepy/__init__.py @@ -1,7 +1,216 @@ """Top-level package.""" # Local Modules -from .__about__ import __version__ +from .__about__ import __version__ as __version__ +from .analysis.contig_tree import ( + BaseContigTree, + Contig, + ContigTree, +) +from .analysis.network import ( + BaseMacroStateNetwork, + MacroStateNetwork, +) +from .analysis.network_layouts.layout_graph import LayoutGraph +from .analysis.parents import ( + ParentForest, + ancestors, + net_parent_table, + parent_cycle_discontinuities, + parent_panel, + parent_table_discontinuities, + resampling_panel, + sliding_window, +) +from .analysis.profiles import ( + ContigTreeProfiler, + contigtrees_bin_edges, + cumulative_partitions, + free_energy_profile, +) +from .analysis.rates import ( + calc_warp_rate, + contig_warp_rates, +) +from .boundary_conditions.boundary import BoundaryConditions +from .hdf5 import WepyHDF5 +from .monitor import Monitor +from .reporter.base import Reporter +from .reporter.dashboard import ( + BCDashboardSection, + DashboardReporter, + ResamplerDashboardSection, + RunnerDashboardSection, +) +from .reporter.hdf5 import WepyHDF5Reporter +from .reporter.openmm import OpenMMRunnerDashboardSection +from .reporter.restree import ResTreeReporter +from .reporter.revo.dashboard import REVODashboardSection +from .resampling.decisions.clone_merge import MultiCloneMergeDecision +from .resampling.decisions.decision import BaseDecisionABC, BaseDecisionRecord +from .resampling.decisions.no_decision import NoDecision +from .resampling.distances.base import Distance +from .resampling.distances.mock import MockDistance +from .resampling.distances.simple import XYDistanceState, XYEuclideanDistance +from .resampling.resamplers.noresampler import NoResampler, NoResamplerFactory +from .resampling.resamplers.resampler import Resampler +from .resampling.resamplers.revo import REVOResampler, REVOResamplerFactory +from .resampling.resamplers.wexplore import WExploreResampler, WExploreResamplerFactory +from .runners.mock import ( + MockRunner, + MockRunnerFactory, + MockState, +) +from .runners.openmm.logger import ( + EnergyLoggingReporter, + EnergyLoggingReporterFactory, + HeartBeatLoggingReporter, + HeartBeatLoggingReporterFactory, + SamplingTimeIntervalLoggingReporter, + StepIntervalLoggingReporter, + UnitCellLoggingReporter, + UnitCellLoggingReporterFactory, +) +from .runners.openmm.reporter import OpenMMReporter, OpenMMReporterNextReport +from .runners.openmm.runner import ( + OpenMMRunner, + OpenMMRunnerFactory, + DEFAULT_OPENMM_REPORTER_FACTORIES, +) +from .runners.openmm.state import ( + OPENMM_DEFAULT_UNITS, + OpenMMState, + OpenMMStateWrapper, +) +from .runners.runner import NoRunner, NoRunnerFactory, Runner +from .sim_manager import ( + Manager, + ResamplerFactory, + RunnerFactory, + WorkMapperFactory, +) +from .util.json_top import ( + json_top_atom_count, + json_top_atom_df, + json_top_chain_df, + json_top_residue_df, + json_top_subset, +) +from .util.mdtraj import ( + json_to_mdtraj_topology, + mdtraj_to_json_topology, + traj_fields_to_mdtraj, +) +from .walker import ( + Walker, + WalkerState, + WalkerStateBox, +) +from .work_mapper.base import WorkMapper +from .work_mapper.openmm.proc_pool import ( + OpenMMProcPoolWorkMapper, + OpenMMProcPoolWorkMapperFactory, +) +from .work_mapper.openmm.serial import ( + OpenMMSerialWorkMapper, + OpenMMSerialWorkMapperFactory, +) +from .work_mapper.serial import SerialMapper, SerialMapperFactory __author__ = "Samuel D. Lotz" __email__ = "samuel.lotz@salotz.info" + +__all__ = [ + "LayoutGraph", + "__version__", + "WepyHDF5", + "Monitor", + "Manager", + "Reporter", + "ResamplerDashboardSection", + "RunnerDashboardSection", + "BCDashboardSection", + "OpenMMRunnerDashboardSection", + "DashboardReporter", + "WepyHDF5Reporter", + "OpenMMReporterDashboardSection", + "ResTreeReporter", + "REVODashboardSection", + "BaseDecisionRecord", + "BaseDecisionABC", + "Walker", + "WalkerState", + "WalkerStateBox", + "NoDecision", + "MultiCloneMergeDecision", + "NoResampler", + "NoResamplerFactory", + "Resampler", + "REVOResampler", + "WExploreResampler", + "Distance", + "MockDistance", + "XYDistanceState", + "XYEuclideanDistance", + "Runner", + "NoRunner", + "MockState", + "MockRunner", + "MockRunnerFactory", + "OpenMMReporter", + "OpenMMReporterNextReport", + "StepIntervalLoggingReporter", + "SamplingTimeIntervalLoggingReporter", + "HeartBeatLoggingReporter", + "HeartBeatLoggingReporterFactory", + "EnergyLoggingReporter", + "EnergyLoggingReporterFactory", + "UnitCellLoggingReporter", + "UnitCellLoggingReporterFactory", + "OpenMMStateWrapper", + "OpenMMState", + "OpenMMRunner", + "OpenMMRunnerFactory", + "json_top_atom_df", + "json_top_residue_df", + "json_top_chain_df", + "json_top_atom_count", + "json_top_subset", + "mdtraj_to_json_topology", + "json_to_mdtraj_topology", + "traj_fields_to_mdtraj", + "BaseContigTree", + "ContigTree", + "Contig", + "BaseMacroStateNetwork", + "MacroStateNetwork", + "resampling_panel", + "parent_panel", + "net_parent_table", + "parent_table_discontinuities", + "sliding_window", + "ParentForest", + "ancestors", + "parent_cycle_discontinuities", + "cumulative_partitions", + "free_energy_profile", + "contigtrees_bin_edges", + "ContigTreeProfiler", + "calc_warp_rate", + "contig_warp_rates", + "BoundaryConditions", + "WorkMapper", + "SerialMapper", + "OpenMMSerialWorkMapper", + "OpenMMSerialWorkMapperFactory", + "OpenMMProcPoolWorkMapper", + "OpenMMProcPoolWorkMapperFactory", + "ResamplerFactory", + "WorkMapperFactory", + "RunnerFactory", + "REVOResamplerFactory", + "NoRunnerFactory", + "SerialMapperFactory", + "OPENMM_DEFAULT_UNITS", + "WExploreResamplerFactory", +] diff --git a/src/wepy/__main__.py b/src/wepy/__main__.py deleted file mode 100644 index f9c8391c..00000000 --- a/src/wepy/__main__.py +++ /dev/null @@ -1,26 +0,0 @@ -"""Glue all the CLIs together into one interface.""" - -# First Party Library -from wepy.orchestration.cli import cli as orch_cli - -cli = orch_cli - - -# SNIPPET: I was intending to aggregate multiple command lines other -# than the orchestration, but this never materialized or was -# needed. In the future though this can be the place for that. - -# @click.group() -# def cli(): -# """ """ -# pass - -# # add in the sub-clis -# cli.add_command(orch_cli) - -# # the orchestrator stuff we keep in the top-level still though -# for subgroup in orch_subgroups: -# cli.add_command(subgroup) - -if __name__ == "__main__": - cli() diff --git a/src/wepy/analysis/__init__.py b/src/wepy/analysis/__init__.py index 98c43f35..7d2a4605 100644 --- a/src/wepy/analysis/__init__.py +++ b/src/wepy/analysis/__init__.py @@ -38,7 +38,6 @@ See Also -------- - `wepy.hdf5.WepyHDF5.compute_observable` Notes diff --git a/src/wepy/analysis/contig_tree.py b/src/wepy/analysis/contig_tree.py index b46c8ff3..2fa1db36 100644 --- a/src/wepy/analysis/contig_tree.py +++ b/src/wepy/analysis/contig_tree.py @@ -9,15 +9,16 @@ """ # Standard Library -from typing import Final import warnings from collections import deque from copy import copy from operator import attrgetter +from typing import Final, Self # Third Party Library import networkx as nx import numpy as np +import numpy.typing try: # Third Party Library @@ -26,26 +27,26 @@ warnings.warn("Matplotlib not installed, these features will not work") # Third Party Library +import pandas as pd from geomm.free_energy import free_energy as calc_free_energy # First Party Library -from wepy.hdf5 import WepyHDF5 -from wepy.resampling.decisions.decision import Decision -from wepy.boundary_conditions.boundary import BoundaryConditions - from wepy.analysis.network_layouts.layout_graph import LayoutGraph from wepy.analysis.network_layouts.tree import ResamplingTreeLayout from wepy.analysis.parents import ( ParentForest, + ParentTable, ancestors, net_parent_table, parent_cycle_discontinuities, parent_panel, sliding_window, ) - -import pandas as pd - +from wepy.boundary_conditions.boundary import BoundaryConditions +from wepy.hdf5 import WepyHDF5 +from wepy.reporter.file import FileMode +from wepy.resampling.decisions.decision import BaseDecisionABC +from wepy.storage.protocol import ContigTrace, ContigWalkerTrace, RunTrace # the groups of run records RESAMPLING: Final = "resampling" @@ -60,6 +61,14 @@ """Record key for boundary condition records.""" +# (run_idx, cycle_idx) +NodeId = tuple[int, int] +Edge = tuple[NodeId, NodeId] + +# (run_idx, run_idx) +ContinuationsTable = list[tuple[int, int]] + + class BaseContigTree: """A base class for the contigtree which doesn't contain a WepyHDF5 object. Useful for serialization of the object and can then be @@ -74,13 +83,20 @@ class BaseContigTree: DISCONTINUITY_KEY: Final = "discontinuities" """Key for discontinuity node attributes in the tree graph.""" + _graph: nx.DiGraph + _boundary_condition_class: type[BoundaryConditions] | None + _decision_class: type[BaseDecisionABC] | None + _continuations: set[tuple[int, int]] + _run_idxs: set[int] + _spans: dict[int, ContigTrace] + def __init__( self, wepy_h5: WepyHDF5, - continuations: type(Ellipsis) | list[tuple[int, int]] = Ellipsis, + continuations: type(Ellipsis) | ContinuationsTable = Ellipsis, runs: type(Ellipsis) | list[int] = Ellipsis, boundary_condition_class: type[BoundaryConditions] | None = None, - decision_class: type[Decision] | None = None, + decision_class: type[BaseDecisionABC] | None = None, ): """The only required argument is an WepyHDF5 object from which to draw data. @@ -133,7 +149,6 @@ def __init__( Warnings -------- - Only set `continuations` if you know what you are doing. A `decision_class` must be given to be able to detect cloning and @@ -226,7 +241,7 @@ def graph(self) -> nx.DiGraph: return self._graph @property - def decision_class(self) -> type[Decision] | None: + def decision_class(self) -> type[BaseDecisionABC] | None: """The decision class used to determine parental lineages.""" return self._decision_class @@ -236,23 +251,23 @@ def boundary_condition_class(self) -> type[BoundaryConditions] | None: return self._boundary_condition_class @property - def span_traces(self): - """Dictionary mapping the spand indices to their run traces.""" + def span_traces(self) -> dict[int, ContigTrace]: + """Dictionary mapping the span indices to their run traces.""" return self._spans - def make_contig(self, span_trace): + def make_contig(self, contig_trace: ContigTrace) -> "Contig": raise NotImplementedError( f"'make_contig' is not implemented in '{self.__class__.__name__}'" ) - def span_contig(self, span_idx): + def span_contig(self, span_idx: int) -> "Contig": """Generates a contig object for the specified spanning contig.""" contig = self.make_contig(self.span_traces[span_idx]) return contig - def _create_tree(self, wepy_h5): + def _create_tree(self, wepy_h5: WepyHDF5) -> None: """Generate the tree of cycles from the WepyHDF5 object/file.""" # first go through each run without continuations @@ -289,7 +304,7 @@ def _create_tree(self, wepy_h5): # add this connector edge to the network self.graph.add_edge(*edge) - def _set_resampling_panels(self, wepy_h5): + def _set_resampling_panels(self, wepy_h5: WepyHDF5) -> None: """Generates resampling panels for each cycle and sets them as node attributes.""" # then get the resampling tables for each cycle and put them @@ -303,7 +318,7 @@ def _set_resampling_panels(self, wepy_h5): node = (run_idx, step_idx) self.graph.nodes[node][self.RESAMPLING_PANEL_KEY] = step - def _initialize_discontinuities(self, wepy_h5): + def _initialize_discontinuities(self, wepy_h5: WepyHDF5) -> None: """Initialize the nodes with discontinuities attributes but set to 0s indicating no discontinuities. """ @@ -316,7 +331,11 @@ def _initialize_discontinuities(self, wepy_h5): 0 for i in range(n_walkers) ] - def _set_discontinuities(self, wepy_h5, boundary_conditions_class): + def _set_discontinuities( + self, + wepy_h5: WepyHDF5, + boundary_conditions_class: type[BoundaryConditions], + ) -> None: """Given the boundary condition class sets node attributes for where there are discontinuities in the parental lineages. @@ -365,7 +384,7 @@ def _set_discontinuities(self, wepy_h5, boundary_conditions_class): rec_traj_idx ] = -1 - def _set_parents(self, decision_class): + def _set_parents(self, decision_class: BaseDecisionABC) -> None: """Determines the net parents for each cycle and sets them in-place to the cycle tree given. @@ -391,17 +410,20 @@ def _set_parents(self, decision_class): self.graph.nodes[node][self.PARENTS_KEY] = node_parents @property - def run_idxs(self): + def run_idxs(self) -> set[int]: """Indices of runs in WepyHDF5 used in this contig tree.""" return self._run_idxs @property - def continuations(self): + def continuations(self) -> set[tuple[int, int]]: """The continuations that are used in this contig tree over the runs.""" return self._continuations @staticmethod - def contig_trace_to_run_trace(contig_trace, contig_walker_trace): + def contig_trace_to_run_trace( + contig_trace: ContigTrace, + contig_walker_trace: ContigWalkerTrace, + ) -> RunTrace: """Combine a contig trace and a walker trace to get the equivalent run trace. The contig_walker_trace cycle_idxs must be a subset of the @@ -416,7 +438,6 @@ def contig_trace_to_run_trace(contig_trace, contig_walker_trace): Returns ------- - run_trace : list of tuples of ints (run_idx, traj_idx, cycle_idx) """ @@ -436,44 +457,15 @@ def contig_trace_to_run_trace(contig_trace, contig_walker_trace): return trace - def walker_trace_to_run_trace(self, contig_walker_trace): - """Combine a walker trace to get the equivalent run trace for this contig. - - The contig_walker_trace cycle_idxs must be a subset of the - frame indices given by the contig_trace. - - - Parameters - ---------- - - contig_walker_trace : list of tuples of ints (traj_idx, cycle_idx) - - Returns - ------- - - run_trace : list of tuples of ints (run_idx, traj_idx, cycle_idx) - - See Also - -------- - Contig.contig_trace_to_run_trace : calls this static method - - """ - - return self.contig_trace_to_run_trace(self.contig_trace, contig_walker_trace) - - def run_trace_to_contig_trace(self, run_trace): - """ - - Assumes that the run trace goes along a valid contig. + def run_trace_to_contig_trace(self, run_trace: RunTrace) -> ContigWalkerTrace: + """Assumes that the run trace goes along a valid contig. Parameters ---------- - run_trace : list of tuples of ints (run_idx, traj_idx, cycle_idx) Returns ------- - contig_walker_trace : list of tuples of ints (traj_idx, contig_cycle_idx) @@ -488,7 +480,7 @@ def run_trace_to_contig_trace(self, run_trace): return contig_walker_trace - def contig_cycle_idx(self, run_idx, cycle_idx): + def contig_cycle_idx(self, run_idx: int, cycle_idx: int) -> int: """Convert an in-run cycle index to an in-contig cyle_idx. Parameters @@ -501,7 +493,6 @@ def contig_cycle_idx(self, run_idx, cycle_idx): Returns ------- - contig_cycle_idx : int The cycle idx in the contig @@ -513,7 +504,12 @@ def contig_cycle_idx(self, run_idx, cycle_idx): # get the length and subtract one for the index return len(contig_trace) - 1 - def get_branch_trace(self, run_idx, cycle_idx, start_contig_idx=0): + def get_branch_trace( + self, + run_idx: int, + cycle_idx: int, + start_contig_idx: int = 0, + ) -> ContigTrace: """Get a contig trace for a branch of the contig tree from an end point back to a set point (defaults to root of contig tree). @@ -530,7 +526,6 @@ def get_branch_trace(self, run_idx, cycle_idx, start_contig_idx=0): Returns ------- - contig_trace : list of tuples of ints (run_idx, cycle_idx) """ @@ -568,7 +563,11 @@ def get_branch_trace(self, run_idx, cycle_idx, start_contig_idx=0): return contig_trace - def trace_parent_table(self, contig_trace, discontinuities=True): + def trace_parent_table( + self, + contig_trace: ContigTrace, + discontinuities: bool = True, + ) -> ParentTable: """Given a contig trace returns a parent table for that contig. Parameters @@ -598,7 +597,7 @@ def trace_parent_table(self, contig_trace, discontinuities=True): return parent_table @classmethod - def _tree_leaves(cls, root, tree): + def _tree_leaves(cls, root: NodeId, tree: nx.DiGraph) -> list[NodeId]: """Given the root node ID and the tree as a networkX DiGraph returns the leaves of the tree. @@ -657,7 +656,7 @@ def _tree_leaves(cls, root, tree): return leaves - def _subtree_leaves(self, root): + def _subtree_leaves(self, root: NodeId) -> list[NodeId]: """Given a root defining a subtree on the full tree returns the leaves of that subtree. @@ -685,7 +684,7 @@ def _subtree_leaves(self, root): return leaves - def leaves(self): + def leaves(self) -> list[NodeId]: """All of the leaves of this contig tree. Returns @@ -701,7 +700,7 @@ def leaves(self): return leaves - def root_leaves(self): + def root_leaves(self) -> dict[NodeId, NodeId]: """Return a dictionary mapping the roots to their leaves.""" root_leaves = {} @@ -710,7 +709,7 @@ def root_leaves(self): return root_leaves - def _subtree_root(self, node): + def _subtree_root(self, node: NodeId) -> NodeId: """Given a node find the root of the tree it is on Parameters @@ -750,7 +749,7 @@ def _subtree_root(self, node): return curr_node - def roots(self): + def roots(self) -> list[NodeId]: """Returns all of the roots in this contig tree (which is technically a forest and can have multiple roots). @@ -769,7 +768,7 @@ def roots(self): return subtree_roots - def subtrees(self): + def subtrees(self) -> list[nx.DiGraph]: """Returns all of the subtrees (with unique roots) in this contig tree (which is technically a forest and can have multiple roots). @@ -789,7 +788,7 @@ def subtrees(self): return subtree_nxs - def get_subtree(self, node): + def get_subtree(self, node: NodeId) -> nx.DiGraph: """Given a node defining a subtree root return that subtree. Parameters @@ -805,6 +804,8 @@ def get_subtree(self, node): # get all the subtrees subtrees = self.subtrees() + # TODO: This is ambiguous if it is in multiple subtrees... + # see which tree the node is in for subtree in subtrees: # if the node is in it this is the subtree it is in so @@ -812,7 +813,11 @@ def get_subtree(self, node): if node in subtree: return subtree - def contig_sliding_windows(self, contig_trace, window_length): + def contig_sliding_windows( + self, + contig_trace: ContigTrace, + window_length: int, + ) -> list[ContigWalkerTrace]: """Given a contig trace get the sliding windows of length 'window_length' as contig walker traces. @@ -825,7 +830,6 @@ def contig_sliding_windows(self, contig_trace, window_length): Returns ------- - windows : list of list of tuples of ints (traj_idx, cycle_idx) List of contig walker traces @@ -839,7 +843,7 @@ def contig_sliding_windows(self, contig_trace, window_length): return windows - def sliding_contig_windows(self, window_length): + def sliding_contig_windows(self, window_length: int) -> list[ContigTrace]: """Given a 'window_length' return all the windows over the contig tree as contig traces. @@ -869,7 +873,11 @@ def sliding_contig_windows(self, window_length): return contig_windows - def _subtree_sliding_contig_windows(self, subtree_root, window_length): + def _subtree_sliding_contig_windows( + self, + subtree_root: NodeId, + window_length: int, + ) -> list[ContigTrace]: """Get all the sliding windows of length 'window_length' from the subtree defined by the subtree root as run traces. @@ -965,7 +973,7 @@ def _subtree_sliding_contig_windows(self, subtree_root, window_length): return contig_windows - def sliding_windows(self, window_length): + def sliding_windows(self, window_length: int) -> list[RunTrace]: """Returns all the sliding windows over walker trajectories as run traces for a given window length. @@ -1000,63 +1008,64 @@ def sliding_windows(self, window_length): return windows - @classmethod - def _rec_spanning_paths(cls, edges, root): - """Given a set of directed edges (source, target) and a root node id - of a tree imposed over the edges, returns all the paths over - that tree which span from the root to a leaf. + # TODO: not used anywhere should be removed. Also doesn't work - This is a recursive function and has pretty bad performance - for nontrivial simulations. + # @classmethod + # def _rec_spanning_paths(cls, edges: Edge, root: NodeId) -> list[list[Edge]]: + # """Given a set of directed edges (source, target) and a root node id + # of a tree imposed over the edges, returns all the paths over + # that tree which span from the root to a leaf. - Parameters - ---------- - edges : (node_id, node_id) + # This is a recursive function and has pretty bad performance + # for nontrivial simulations. - root : node_id + # Parameters + # ---------- + # edges : (node_id, node_id) - Returns - ------- + # root : node_id - spanning_paths : list of edges + # Returns + # ------- + # spanning_paths : list of edges - """ + # """ - # nodes targetting this root - root_sources = [] - - # go through all the edges and find those with this - # node as their target - for edge_source, edge_target in edges: - # check if the target_node we are looking for matches - # the edge target node - if root == edge_target: - # if this root is a target of the source add it to the - # list of edges targetting this root - root_sources.append(edge_source) - - # from the list of source nodes targetting this root we choose - # the lowest index one, so we sort them and iterate through - # finding the paths starting from it recursively - root_paths = [] - root_sources.sort() - for new_root in root_sources: - # add these paths for this new root to the paths for the - # current root - root_paths.extend(cls._rec_spanning_paths(edges, new_root)) - - # if there are no more sources to this root it is a leaf node and - # we terminate recursion, by not entering the loop above, however - # we manually generate an empty list for a path so that we return - # this "root" node as a leaf, for default. - if len(root_paths) < 1: - root_paths = [[]] - - final_root_paths = [] - for root_path in root_paths: - final_root_paths.append([root] + root_path) - - return final_root_paths + # # nodes targetting this root + # root_sources = [] + + # # go through all the edges and find those with this + # # node as their target + # for edge_source, edge_target in edges: + # # check if the target_node we are looking for matches + # # the edge target node + # if root == edge_target: + # # if this root is a target of the source add it to the + # # list of edges targetting this root + # root_sources.append(edge_source) + + # # from the list of source nodes targetting this root we choose + # # the lowest index one, so we sort them and iterate through + # # finding the paths starting from it recursively + # root_paths = [] + # root_sources.sort() + # for new_root in root_sources: + # # add these paths for this new root to the paths for the + # # current root + # root_paths.extend(cls._rec_spanning_paths(edges, new_root)) + + # # if there are no more sources to this root it is a leaf node and + # # we terminate recursion, by not entering the loop above, however + # # we manually generate an empty list for a path so that we return + # # this "root" node as a leaf, for default. + # if len(root_paths) < 1: + # root_paths = [[]] + + # final_root_paths = [] + # for root_path in root_paths: + # final_root_paths.append([root] + root_path) + + # return final_root_paths # @classmethod # def _find_root_sources(cls, edges, root): @@ -1078,17 +1087,14 @@ def _rec_spanning_paths(cls, edges, root): # return root_sources - def _spanning_paths(self, root): - """ - - Parameters + def _spanning_paths(self, root: NodeId) -> dict[NodeId, list[NodeId]]: + """Parameters ---------- root : node_id Returns ------- - - spanning_paths : list of list of edges + spanning_paths """ @@ -1122,7 +1128,7 @@ def _spanning_paths(self, root): return leaf_paths - def spanning_contig_traces(self): + def spanning_contig_traces(self) -> list[ContigTrace]: """Returns a list of all possible spanning contigs given the continuations present in this file. Spanning contigs are paths through a tree that must start from a root node and end at a @@ -1146,7 +1152,7 @@ def spanning_contig_traces(self): return spanning_contig_traces - def _root_spanning_contig_traces(self): + def _root_spanning_contig_traces(self) -> dict[NodeId, list[ContigTrace]]: """Returns a list of all possible spanning contigs given the continuations present in this file. Spanning contigs are paths through a tree that must start from a root node and end at a @@ -1158,7 +1164,7 @@ def _root_spanning_contig_traces(self): Returns ------- - spanning_contig_traces : dict of root_id to list of tuples of ints (run_idx, cycle_idx) + spanning_contig_traces: Dictionary mapping the root ids to all spanning contigs for it which are contig traces. @@ -1178,7 +1184,7 @@ def _root_spanning_contig_traces(self): return spanning_contig_traces @classmethod - def _contig_trace_to_contig_runs(cls, contig_trace): + def _contig_trace_to_contig_runs(cls, contig_trace: ContigTrace) -> list[int]: """Convert a contig trace to a list of runs. Parameters @@ -1193,7 +1199,7 @@ def _contig_trace_to_contig_runs(cls, contig_trace): contig_runs = [] for run_idx, cycle_idx in contig_trace: - if not run_idx in contig_runs: + if run_idx not in contig_runs: contig_runs.append(run_idx) else: pass @@ -1201,7 +1207,9 @@ def _contig_trace_to_contig_runs(cls, contig_trace): return contig_runs @classmethod - def _contig_runs_to_continuations(cls, contig_runs): + def _contig_runs_to_continuations( + cls, contig_runs: list[int] + ) -> ContinuationsTable: """Helper function to convert a list of run indices defining a contig to continuations. @@ -1222,7 +1230,9 @@ def _contig_runs_to_continuations(cls, contig_runs): return continuations @classmethod - def _continuations_to_contig_runs(cls, continuations): + def _continuations_to_contig_runs( + cls, continuations: ContinuationsTable + ) -> list[int]: """Helper function that converts a list of continuations to a list of the runs in the order of the contigs defined by the continuations. @@ -1318,15 +1328,25 @@ class ContigTree(BaseContigTree): """ + closed: bool + _wepy_h5: WepyHDF5 + _base_contigtree: BaseContigTree + _graph: nx.DiGraph + _boundary_condition_class: type[BoundaryConditions] | None + _decision_class: type[BaseDecisionABC] | None + _continuations: set[tuple[int, int]] + _run_idxs: set[int] + _spans: dict[int, ContigTrace] + def __init__( self, - wepy_h5, - base_contigtree=None, - continuations=Ellipsis, - runs=Ellipsis, - boundary_condition_class=None, - decision_class=None, - ): + wepy_h5: WepyHDF5, + base_contigtree: BaseContigTree | None = None, + continuations: type(Ellipsis) | ContinuationsTable = Ellipsis, + runs: type(Ellipsis) | list[int] = Ellipsis, + boundary_condition_class: type[BoundaryConditions] | None = None, + decision_class: type[BaseDecisionABC] | None = None, + ) -> None: self.closed = True # if we pass a base contigtree use that one instead of building one manually @@ -1348,7 +1368,7 @@ def __init__( self._wepy_h5 = wepy_h5 - def _set_base_contigtree_to_self(self, base_contigtree): + def _set_base_contigtree_to_self(self, base_contigtree: BaseContigTree) -> None: self._base_contigtree = base_contigtree # then make references to this for the attributes we need @@ -1359,32 +1379,32 @@ def _set_base_contigtree_to_self(self, base_contigtree): self._run_idxs = self._base_contigtree._run_idxs self._spans = self._base_contigtree._spans - def open(self, mode=None): + def open(self, mode: FileMode | None = None) -> None: if self.closed: self.wepy_h5.open(mode=mode) self.closed = False else: raise IOError("This file is already open") - def close(self): + def close(self) -> None: self.wepy_h5.close() self.closed = True - def __enter__(self): + def __enter__(self) -> Self: self.wepy_h5.__enter__() self.closed = False return self - def __exit__(self, exc_type, exc_value, exc_tb): + def __exit__(self, exc_type, exc_value, exc_tb) -> None: self.wepy_h5.__exit__(exc_type, exc_value, exc_tb) self.close() @property - def base_contigtree(self): + def base_contigtree(self) -> BaseContigTree: return self._base_contigtree @property - def wepy_h5(self): + def wepy_h5(self) -> WepyHDF5: """The WepyHDF5 source object for which the contig tree is being constructed.""" return self._wepy_h5 @@ -1436,7 +1456,7 @@ def wepy_h5(self): # TODO: optimize this, we don't need to recalculate everything # each time to implement this - def make_contig(self, contig_trace): + def make_contig(self, contig_trace: ContigTrace) -> "Contig": """Create a Contig object given a contig trace. Parameters @@ -1465,7 +1485,7 @@ def make_contig(self, contig_trace): decision_class=self.decision_class, ) - def warp_trace(self): + def warp_trace(self) -> RunTrace: """Get the trace for all unique warping events from all contigs.""" with self: @@ -1478,13 +1498,12 @@ def warp_trace(self): # then cast to a set to get the unique ones return list(set(big_trace)) - def resampling_trace(self, decision_id): + def resampling_trace(self, decision_id: int) -> RunTrace: """Return full run traces for every specified type of resampling event. Parameters ---------- - decision_id : int The string ID of the decision you want to match on and get lineages for. @@ -1514,9 +1533,10 @@ def final_trace(self): # then cast to a set to get the unique ones return list(set(big_trace)) - def lineages(self, trace, discontinuities=True): + def lineages(self, trace: RunTrace, discontinuities: bool = True) -> list[RunTrace]: """Get the ancestry lineage for each element of the trace as a run - trace.""" + trace. + """ lines = [] # for each element of the trace we need to get it's lineage @@ -1555,12 +1575,40 @@ class Contig(ContigTree): """ - def __init__(self, wepy_h5, **kwargs): + closed: bool + _wepy_h5: WepyHDF5 + _base_contigtree: BaseContigTree + _graph: nx.DiGraph + _boundary_condition_class: type[BoundaryConditions] | None + _decision_class: type[BaseDecisionABC] | None + _continuations: set[tuple[int, int]] + _run_idxs: set[int] + _spans: dict[int, ContigTrace] + + _contig_trace: ContigTrace + _contig_run_idxs: list[int] + + def __init__( + self, + wepy_h5: WepyHDF5, + base_contigtree: BaseContigTree | None = None, + continuations: type(Ellipsis) | ContinuationsTable = Ellipsis, + runs: type(Ellipsis) | list[int] = Ellipsis, + boundary_condition_class: type[BoundaryConditions] | None = None, + decision_class: type[BaseDecisionABC] | None = None, + ): # uses superclass docstring exactly, this constructor just # generates some extra attributes # use the superclass initialization - super().__init__(wepy_h5, **kwargs) + super().__init__( + wepy_h5, + base_contigtree=base_contigtree, + continuations=continuations, + runs=runs, + boundary_condition_class=boundary_condition_class, + decision_class=decision_class, + ) # check that the result is a single contig spanning_contig_traces = self.spanning_contig_traces() @@ -1594,7 +1642,7 @@ def __init__(self, wepy_h5, **kwargs): else: self._contig_run_idxs = list(self.run_idxs) - def contig_fields(self, fields): + def contig_fields(self, fields: list[str]) -> dict[str, numpy.typing.ArrayLike]: """Returns trajectory field data for the specified fields. Parameters @@ -1611,7 +1659,7 @@ def contig_fields(self, fields): return self.wepy_h5.get_contig_trace_fields(self.contig_trace, fields) @property - def contig_trace(self): + def contig_trace(self) -> ContigTrace: """Returns the contig trace corresponding to this contig. Returns @@ -1623,7 +1671,7 @@ def contig_trace(self): return self._contig_trace @property - def num_cycles(self): + def num_cycles(self) -> int: """The number of cycles in this contig. Returns @@ -1633,8 +1681,33 @@ def num_cycles(self): """ return len(self.contig_trace) + def walker_trace_to_run_trace( + self, contig_walker_trace: ContigWalkerTrace + ) -> RunTrace: + """Combine a walker trace to get the equivalent run trace for this contig. + + The contig_walker_trace cycle_idxs must be a subset of the + frame indices given by the contig_trace. + + + Parameters + ---------- + contig_walker_trace : list of tuples of ints (traj_idx, cycle_idx) + + Returns + ------- + run_trace : list of tuples of ints (run_idx, traj_idx, cycle_idx) + + See Also + -------- + Contig.contig_trace_to_run_trace : calls this static method + + """ + + return self.contig_trace_to_run_trace(self.contig_trace, contig_walker_trace) + # TODO: may need to be implemented without using the wepy_h5 in the BaseContigTree - def num_walkers(self, cycle_idx): + def num_walkers(self, cycle_idx: int) -> int: """Get the number of walkers at a given cycle in the contig. Parameters @@ -1655,7 +1728,7 @@ def num_walkers(self, cycle_idx): return n_walkers - def records(self, record_key): + def records(self, record_key: str): """Returns the records for the given key. Parameters @@ -1669,7 +1742,7 @@ def records(self, record_key): """ return self.wepy_h5.run_contig_records(self._contig_run_idxs, record_key) - def records_dataframe(self, record_key): + def records_dataframe(self, record_key: str) -> pd.DataFrame: """Returns the records as a pandas.DataFrame for the given key. Parameters @@ -1697,7 +1770,7 @@ def resampling_records(self): return self.records(RESAMPLING) - def resampling_records_dataframe(self): + def resampling_records_dataframe(self) -> pd.DataFrame: """Returns the resampling records as a pandas.DataFrame. Returns @@ -1720,7 +1793,7 @@ def resampler_records(self): return self.records(RESAMPLER) - def resampler_records_dataframe(self): + def resampler_records_dataframe(self) -> pd.DataFrame: """Returns the resampler records as a pandas.DataFrame. Returns @@ -1743,7 +1816,7 @@ def warping_records(self): return self.records(WARPING) - def warping_records_dataframe(self): + def warping_records_dataframe(self) -> pd.DataFrame: """Returns the warping records as a pandas.DataFrame. Returns @@ -1766,7 +1839,7 @@ def bc_records(self): return self.records(BC) - def bc_records_dataframe(self): + def bc_records_dataframe(self) -> pd.DataFrame: """Returns the boundary conditions records as a pandas.DataFrame. Returns @@ -1789,7 +1862,7 @@ def progress_records(self): return self.records(PROGRESS) - def progress_records_dataframe(self): + def progress_records_dataframe(self) -> pd.DataFrame: """Returns the progress records as a pandas.DataFrame. Returns @@ -1812,18 +1885,16 @@ def resampling_panel(self): return self.wepy_h5.run_contig_resampling_panel(self._contig_run_idxs) - def parent_table(self, discontinuities=True): + def parent_table(self, discontinuities: bool = True) -> ParentTable: """Returns the full parent table for this contig. Notes ----- - This requires the decision class to be given to the Contig at construction. Warnings -------- - If the simulation was run with boundary conditions that result in discontinuous warping events and that class is not provided at construction time to this class these discontinuities will @@ -1840,7 +1911,11 @@ def parent_table(self, discontinuities=True): self.contig_trace, discontinuities=discontinuities ) - def lineages_contig(self, contig_trace, discontinuities=True): + def lineages_contig( + self, + contig_trace: ContigTrace, + discontinuities: bool = True, + ): # get the parent table for this contig parent_table = self.parent_table(discontinuities=discontinuities) @@ -1852,9 +1927,10 @@ def lineages_contig(self, contig_trace, discontinuities=True): return lineages - def lineages(self, contig_trace, discontinuities=True): + def lineages(self, contig_trace: ContigTrace, discontinuities: bool = True): """Get the ancestry lineage for each element of the trace as a run - trace.""" + trace. + """ return [ self.walker_trace_to_run_trace(trace) @@ -1863,7 +1939,7 @@ def lineages(self, contig_trace, discontinuities=True): ) ] - def warp_contig_trace(self): + def warp_contig_trace(self) -> ContigWalkerTrace: """Return a trace that gives all of the walkers that were warped.""" trace = [] @@ -1877,13 +1953,12 @@ def warp_contig_trace(self): return trace - def resampling_contig_trace(self, decision_id): + def resampling_contig_trace(self, decision_id: int) -> ContigWalkerTrace: """Return full run traces for every specified type of resampling event. Parameters ---------- - decision_id : int The integer ID of the decision you want to match on and get lineages for. This is the integer value of the decision @@ -1901,7 +1976,7 @@ def resampling_contig_trace(self, decision_id): return trace - def final_contig_trace(self): + def final_contig_trace(self) -> ContigWalkerTrace: # this is just the last cycle index last_cycle_idx = self.num_cycles - 1 @@ -1913,7 +1988,7 @@ def final_contig_trace(self): return trace - def warp_trace(self): + def warp_trace(self) -> RunTrace: """Return a run trace that gives all of the walkers that were warped.""" trace = self.warp_contig_trace() @@ -1922,13 +1997,12 @@ def warp_trace(self): return run_trace - def resampling_trace(self, decision_id): + def resampling_trace(self, decision_id: int) -> RunTrace: """Return full run traces for every specified type of resampling event. Parameters ---------- - decision_id : int The string ID of the decision you want to match on and get lineages for. @@ -1941,7 +2015,7 @@ def resampling_trace(self, decision_id): return run_trace - def final_trace(self): + def final_trace(self) -> RunTrace: """Return a trace of all the walkers at the end of the contig.""" trace = self.final_contig_trace() @@ -1952,16 +2026,16 @@ def final_trace(self): def resampling_tree_layout_graph( self, - bc_class=None, - progress_key=None, - node_shape="disc", - discontinuous_node_shape="square", - colormap_name="plasma", - node_radius=None, - row_spacing=None, - step_spacing=None, - central_axis=None, - ): + bc_class: type[BoundaryConditions] | None = None, + progress_key: str | None = None, + node_shape: str = "disc", + discontinuous_node_shape: str = "square", + colormap_name: str = "plasma", + node_radius: float | None = None, + row_spacing: float | None = None, + step_spacing: float | None = None, + central_axis: float | None = None, + ) -> LayoutGraph: ### The data we need for making the resampling tree ## parent table, don't include discontinuities, we will handle diff --git a/src/wepy/analysis/distributed.py b/src/wepy/analysis/distributed.py index 03305aa2..ff554db9 100644 --- a/src/wepy/analysis/distributed.py +++ b/src/wepy/analysis/distributed.py @@ -115,7 +115,6 @@ """ # Standard Library -import time from collections import defaultdict from copy import deepcopy @@ -135,9 +134,8 @@ def traj_fields_chunk_items( ): """Generate items that can be used to create a dask.bag object. - Arguments + Arguments: --------- - wepy_h5_path : str The file path to the WepyHDF5 file that will be read from. @@ -150,7 +148,7 @@ def traj_fields_chunk_items( data for which a single task will work on. Dask will also partition these chunks as it sees fit. - Returns + Returns: ------- chunk_specs : list of dict of str : value @@ -166,9 +164,9 @@ def traj_fields_chunk_items( with wepy_h5: # choose the run idxs if run_idxs is not Ellipsis: - assert all([run_idx in wepy_h5.run_idxs for run_idx in run_idxs]), ( - "run_idx not in runs" - ) + assert all( + [run_idx in wepy_h5.run_idxs for run_idx in run_idxs] + ), "run_idx not in runs" else: run_idxs = wepy_h5.run_idxs @@ -234,7 +232,7 @@ def chunk_func_funcgen( result_name = result_name def chunk_func(chunk_spec): - assert not result_name in chunk_spec.keys() + assert result_name not in chunk_spec.keys() fields = [] for key in input_keys: @@ -300,10 +298,12 @@ def chunk_concat(cum_chunk_spec, new_chunk_spec): cum_chunk_spec["fields"] = new_chunk_spec["fields"] # concatenate the frame indices in this chunk - new_chunk["frame_idxs"] = np.concatenate([ - cum_chunk_spec["frame_idxs"], - new_chunk_spec["frame_idxs"], - ]) + new_chunk["frame_idxs"] = np.concatenate( + [ + cum_chunk_spec["frame_idxs"], + new_chunk_spec["frame_idxs"], + ] + ) # for each extra concat function feed it the two chunk specs for concat_func in concat_funcs: @@ -319,10 +319,12 @@ def chunk_array_concat_funcgen(field): def func(cum_chunk_spec, new_chunk_spec): # only add it if it has been initialized in the cum_chunk if field in cum_chunk_spec: - cum_chunk_spec[field] = np.concatenate([ - cum_chunk_spec[field], - new_chunk_spec[field], - ]) + cum_chunk_spec[field] = np.concatenate( + [ + cum_chunk_spec[field], + new_chunk_spec[field], + ] + ) # otherwise set just the new chunk else: @@ -335,13 +337,16 @@ def func(cum_chunk_spec, new_chunk_spec): def chunk_traj_fields_concat(cum_chunk_spec, new_chunk_spec): """Binary operation for dask foldby reductions for concatenating chunk - specs with a traj_fields payload""" + specs with a traj_fields payload + """ # concatenate the traj fields - cum_chunk_spec["traj_fields"] = concat_traj_fields([ - cum_chunk_spec["traj_fields"], - new_chunk_spec["traj_fields"], - ]) + cum_chunk_spec["traj_fields"] = concat_traj_fields( + [ + cum_chunk_spec["traj_fields"], + new_chunk_spec["traj_fields"], + ] + ) return cum_chunk_spec diff --git a/src/wepy/analysis/network.py b/src/wepy/analysis/network.py index 7d00f343..bbba2587 100644 --- a/src/wepy/analysis/network.py +++ b/src/wepy/analysis/network.py @@ -12,7 +12,7 @@ import numpy as np # First Party Library -from wepy.analysis.transitions import counts_d_to_matrix, transition_counts +from wepy.analysis.transitions import transition_counts try: # Third Party Library @@ -129,7 +129,7 @@ def __init__( The 'transition_lag_time' must be given as an integer greater than 1. - Arguments + Arguments: --------- contig_tree : ContigTree object @@ -145,19 +145,19 @@ def __init__( arraylikes of shape (n_traj, observable_shape[0], ...). - See Also + See Also: """ self._graph = nx.DiGraph() - assert not (assg_field_key is None and assignments is None), ( - "either assg_field_key or assignments must be given" - ) + assert not ( + assg_field_key is None and assignments is None + ), "either assg_field_key or assignments must be given" - assert assg_field_key is not None or assignments is not None, ( - "one of assg_field_key or assignments must be given" - ) + assert ( + assg_field_key is not None or assignments is not None + ), "one of assg_field_key or assignments must be given" self._base_contig_tree = contig_tree.base_contigtree @@ -352,11 +352,13 @@ def _assignments_init(self, assignments): for run_idx, run in enumerate(assignments): for traj_idx, traj in enumerate(run): for frame_idx, assignment in enumerate(traj): - self._node_assignments[assignment].append(( - run_idx, - traj_idx, - frame_idx, - )) + self._node_assignments[assignment].append( + ( + run_idx, + traj_idx, + frame_idx, + ) + ) def _init_transition_counts( self, @@ -795,9 +797,9 @@ def write_gexf( exclude_node_fields = list(set(exclude_node_fields)) # exclude the layouts, we will set the viz manually for the layout - exclude_node_fields.extend([ - "_layouts/{}".format(layout_name) for layout_name in self.layouts - ]) + exclude_node_fields.extend( + ["_layouts/{}".format(layout_name) for layout_name in self.layouts] + ) for node in gexf_graph: # remove requested fields @@ -1007,7 +1009,6 @@ def node_map(self, func, map_func=map): Returns ------- - node_values : dict of node_id : values The mapping of node_ids to the values computed by the mapped func. @@ -1071,7 +1072,6 @@ def edge_attribute_to_matrix( Parameters ---------- - attribute_key : str The key of the edge attribute the matrix should be made of. @@ -1082,7 +1082,6 @@ def edge_attribute_to_matrix( Returns ------- - edge_matrix : numpy.ndarray Assymetric matrix of dim (n_macrostates, @@ -1171,7 +1170,6 @@ class MacroStateNetwork: Warnings -------- - This class is not serializable as it references a 'WepyHDF5' object. Either construct a 'BaseMacroStateNetwork' or use the attached instance in the 'base_network' attribute. @@ -1202,7 +1200,6 @@ def __init__( Parameters ---------- - base_network : BaseMacroStateNetwork object An already constructed network, which will avoid recomputing all in-memory network values again for this @@ -1424,7 +1421,6 @@ def get_node_fields(self, node_id, fields): Returns ------- - fields : dict of str: array_like A dictionary mapping the names of the fields to an array of the field. Like fields of a trace. @@ -1501,7 +1497,8 @@ def macrostate_weights(self): def set_macrostate_weights(self): """Compute the macrostate weights and set them as node attributes - 'total_weight'.""" + 'total_weight'. + """ self.base_network.set_nodes_observable( "total_weight", @@ -1542,14 +1539,12 @@ def node_fields_map(self, func, fields, map_func=map): Returns ------- - node_values : dict of node_id : values The mapping of node_ids to the values computed by the mapped func. Returns ------- - node_values : dict of node_id : values Dictionary mapping nodes to the computed values from the mapped function. diff --git a/src/wepy/analysis/network_layouts/layout_graph.py b/src/wepy/analysis/network_layouts/layout_graph.py index ba0c774f..cae7fd6f 100644 --- a/src/wepy/analysis/network_layouts/layout_graph.py +++ b/src/wepy/analysis/network_layouts/layout_graph.py @@ -58,7 +58,7 @@ def __init__(self, graph): the viz_graph, which has all node and edge attributes removed but keeps the topology. - Arguments + Arguments: --------- graph : any networkx graph @@ -252,13 +252,13 @@ def RGBA_to_hex(cls, color_vec): """ - assert all([ - True if (color <= 255 and color >= 0) else False for color in color_vec - ]), "invalid color values, must be between 0 and 255" + assert all( + [True if (color <= 255 and color >= 0) else False for color in color_vec] + ), "invalid color values, must be between 0 and 255" - return "#" + "".join(["{:02x}" for _ in color_vec]).format(*[ - color for color in color_vec - ]) + return "#" + "".join(["{:02x}" for _ in color_vec]).format( + *[color for color in color_vec] + ) # methods for setting gexf visualization attributes @classmethod diff --git a/src/wepy/analysis/network_layouts/tree.py b/src/wepy/analysis/network_layouts/tree.py index cb7306f0..25471cb9 100644 --- a/src/wepy/analysis/network_layouts/tree.py +++ b/src/wepy/analysis/network_layouts/tree.py @@ -9,17 +9,12 @@ # Standard Library import itertools as it -from collections import defaultdict from copy import copy from warnings import warn # Third Party Library -import networkx as nx import numpy as np -# First Party Library -from wepy.analysis.network_layouts.layout import LayoutError - class ResamplingTreeLayout: """Class that wraps the parameters for generating resampling tree layouts. @@ -28,7 +23,6 @@ class ResamplingTreeLayout: Attributes ---------- - node_radius : float Default node radius to use. row_spacing : float @@ -45,7 +39,7 @@ def __init__( """Constructing the object is just a setting of the parameters and collection of methods for generating layout positions. - Arguments + Arguments: --------- node_radius : float, optional Default node radius to use. @@ -70,9 +64,7 @@ def __init__( self.central_axis = central_axis def _overlaps(self, positions, node_radii, node_idx): - """ - - Parameters + """Parameters ---------- positions : @@ -118,7 +110,6 @@ def _node_row_length(self, node_positions, node_radii): Returns ------- - row_length : float """ @@ -131,9 +122,7 @@ def _node_row_length(self, node_positions, node_radii): return abs(max_edge - min_edge) def _simple_gen_distribution(self, nodes_x, node_radii): - """ - - Parameters + """Parameters ---------- nodes_x : @@ -142,7 +131,6 @@ def _simple_gen_distribution(self, nodes_x, node_radii): Returns ------- - new_nodes_positions """ @@ -349,16 +337,14 @@ def _simple_gen_distribution(self, nodes_x, node_radii): new_node_positions[node_idx] = eff_positions[n_groups + i] # sanity check that we covered them all - assert all([ - True if pos is not None else False for pos in new_node_positions - ]), "not all positions recovered from the effective nodes" + assert all( + [True if pos is not None else False for pos in new_node_positions] + ), "not all positions recovered from the effective nodes" return new_node_positions def _simple_next_gen(self, parents_x, children_parent_idxs, node_radii): - """ - - Parameters + """Parameters ---------- parents_x : @@ -369,7 +355,6 @@ def _simple_next_gen(self, parents_x, children_parent_idxs, node_radii): Returns ------- - children_x """ @@ -390,15 +375,12 @@ def _simple_next_gen(self, parents_x, children_parent_idxs, node_radii): return children_x def _initial_parent_distribution(self, node_radii): - """ - - Parameters + """Parameters ---------- node_radii : Returns ------- - positions """ @@ -447,7 +429,6 @@ def _center_row(self, positions, radii, center): Returns ------- - centered_positions """ @@ -478,7 +459,6 @@ def _layout_array(self, parent_table, radii_array): Returns ------- - node_positions """ @@ -544,9 +524,9 @@ def _layout_array(self, parent_table, radii_array): step_y = last_y + last_max_radius + self.step_spacing + this_max_radius # then generate the coordinates - node_positions[generation_idx] = np.array([ - np.array([x, step_y, 0.0]) for x in curr_gen_positions - ]) + node_positions[generation_idx] = np.array( + [np.array([x, step_y, 0.0]) for x in curr_gen_positions] + ) # set the last gen positions last_gen_positions = curr_gen_positions diff --git a/src/wepy/analysis/parents.py b/src/wepy/analysis/parents.py index 2e708436..78b44d3a 100644 --- a/src/wepy/analysis/parents.py +++ b/src/wepy/analysis/parents.py @@ -57,12 +57,19 @@ """ # Standard Library +import copy import itertools as it -from copy import copy # Third Party Library import networkx as nx -import numpy as np + +# First Party Library +from wepy.boundary_conditions.boundary import BoundaryConditions +from wepy.resampling.decisions.decision import BaseDecisionABC +from wepy.storage.protocol import ( + DecisionRecordUnstruct, + ResamplingRecordUnstruct, +) DISCONTINUITY_VALUE = -1 """Special value used to determine if a parent-child relationship has @@ -71,8 +78,19 @@ """ +DecisionPanel = list[list[list[DecisionRecordUnstruct]]] + +ParentPanel = list[list[list[int]]] +ParentTable = list[list[int]] + +# (traj_idx, cycle_idx) +Trace = list[tuple[int, int]] + -def resampling_panel(resampling_records, is_sorted=False): +def resampling_panel( + resampling_records: list[ResamplingRecordUnstruct], + is_sorted: bool = False, +) -> DecisionPanel: """Converts an unordered collection of resampling records into a structured array (lists) corresponding to cycles and resampling steps within cycles. @@ -100,12 +118,19 @@ def resampling_panel(resampling_records, is_sorted=False): res_panel = [] + _resampling_records = [ + (run_record.cycle_idx, run_record.record) for run_record in resampling_records + ] # if the records are not sorted this must be done: if not is_sorted: - resampling_records.sort() + _resampling_records.sort(key=lambda tup: tup[0]) + + # otherwise just unpack them + else: + _resampling_records = copy.copy(resampling_records) # iterate through the resampling records - rec_it = iter(resampling_records) + rec_it = iter(_resampling_records) last_cycle_idx = None cycle_recs = [] stop = False @@ -115,7 +140,7 @@ def resampling_panel(resampling_records, is_sorted=False): cycle_stop = False while not cycle_stop: try: - rec = next(rec_it) + cycle_idx, record = next(rec_it) except StopIteration: # this is the last record of all the records stop = True @@ -129,11 +154,11 @@ def resampling_panel(resampling_records, is_sorted=False): # cycle_idx so we know when in the records we have # gotten to the next cycle of records if last_cycle_idx is None: - last_cycle_idx = rec.cycle_idx + last_cycle_idx = cycle_idx # if the resampling record retrieved is from the next # cycle we finish the last cycle - if rec.cycle_idx > last_cycle_idx: + if cycle_idx > last_cycle_idx: cycle_stop = True # save the current cycle as a special # list which we will iterate through @@ -143,11 +168,11 @@ def resampling_panel(resampling_records, is_sorted=False): # start a new cycle_recs for the record # we just got - cycle_recs = [rec] + cycle_recs = [record] last_cycle_idx += 1 if not cycle_stop: - cycle_recs.append(rec) + cycle_recs.append(record) else: # we need to break up the records in the cycle into steps @@ -169,7 +194,7 @@ def resampling_panel(resampling_records, is_sorted=False): # or if the next stop index has been obtained else: - if cycle_rec.step_idx > step_idx: + if cycle_rec["step_idx"] > step_idx: step_stop = True # save the current step as a special # list which we will iterate through @@ -189,12 +214,17 @@ def resampling_panel(resampling_records, is_sorted=False): step_row = [None for _ in range(len(curr_step_recs))] for walker_rec in curr_step_recs: # collect data from the record - walker_idx = walker_rec.walker_idx - decision_id = walker_rec.decision_id - instruction = walker_rec.target_idxs + walker_idx = walker_rec["walker_idx"] + decision_id = walker_rec["decision_id"] + target_idxs = walker_rec["target_idxs"] # set the resampling record for the walker in the step records - step_row[walker_idx] = (decision_id, instruction) + step_row[walker_idx] = DecisionRecordUnstruct( + { + "decision_id": decision_id, + "target_idxs": target_idxs, + } + ) # add the records for this step to the cycle table cycle_table.append(step_row) @@ -205,7 +235,10 @@ def resampling_panel(resampling_records, is_sorted=False): return res_panel -def parent_panel(decision_class, resampling_panel): +def parent_panel( + decision_class: type[BaseDecisionABC], + resampling_panel: DecisionPanel, +) -> ParentPanel: """Using the parental interpretation of resampling records given by the decision_class, convert resampling records in a resampling panel to parent indices. @@ -232,9 +265,14 @@ def parent_panel(decision_class, resampling_panel): parent_table = [] # now iterate through the rest of the stages - for step in cycle: + for step_recs in cycle: + + # cast the unstructured record to decision records + decision_recs = [ + decision_class.DECISION_RECORD(**step_rec) for step_rec in step_recs + ] # get the parents idxs for the children of this step - step_parents = decision_class.parents(step) + step_parents = decision_class.parents(decision_recs) # for the full stage table save all the intermediate parents parent_table.append(step_parents) @@ -245,7 +283,7 @@ def parent_panel(decision_class, resampling_panel): return parent_panel_in -def net_parent_table(parent_panel): +def net_parent_table(parent_panel: ParentPanel) -> ParentTable: """Reduces a full parent panel to get parent indices on a cycle basis. The full parent panel has parent indices for every step in each @@ -296,8 +334,10 @@ def net_parent_table(parent_panel): def parent_table_discontinuities( - boundary_condition_class, parent_table, warping_records -): + boundary_condition_class: type[BoundaryConditions], + parent_table: ParentTable, + warping_records, +) -> ParentTable: """Given a parent table and warping records returns a new parent table with the discontinuous warping events for parents set to a special value (-1). @@ -321,7 +361,7 @@ def parent_table_discontinuities( """ # Make a copy of the parent table - new_parent_table = copy(parent_table) + new_parent_table = copy.copy(parent_table) for warp_record in warping_records: cycle_idx = warp_record[0] @@ -344,8 +384,11 @@ def parent_table_discontinuities( return new_parent_table -def parent_cycle_discontinuities(parent_idxs, discontinuities): - parent_row = copy(parent_idxs) +def parent_cycle_discontinuities( + parent_idxs: list[int], + discontinuities: list[bool], +) -> list[int]: + parent_row = copy.copy(parent_idxs) for walker_idx, disc in enumerate(discontinuities): # if there was a discontinuity in this walker, we need to # check for which children it had and apply the discontinuity @@ -360,7 +403,12 @@ def parent_cycle_discontinuities(parent_idxs, discontinuities): return parent_row -def ancestors(parent_table, cycle_idx, walker_idx, ancestor_cycle=0): +def ancestors( + parent_table: ParentTable, + cycle_idx: int, + walker_idx: int, + ancestor_cycle: int = 0, +) -> Trace: """Returns the lineage of ancestors as walker indices leading up to the given walker. @@ -403,7 +451,10 @@ def ancestors(parent_table, cycle_idx, walker_idx, ancestor_cycle=0): return lineage -def sliding_window(parent_table, window_length): +def sliding_window( + parent_table: ParentTable, + window_length: int, +) -> list[Trace]: """Return contig walker traces of sliding windows of given length over the parent forest imposed over the contig given by the parent table. @@ -490,7 +541,7 @@ def __init__( The underlying data structure used is a parent table. However, if a contig is given a reference to it will be kept. - Arguments + Arguments: --------- contig : Conting object, optional conditional on parent_table @@ -499,7 +550,7 @@ def __init__( include metadata on discontinuities use the contig input which is preferrable. - Raises + Raises: ------ ValueError If neither parent_table nor contig is given, or if both are given. @@ -521,9 +572,9 @@ def __init__( # otherwise use the one given else: - assert not self.DISCONTINUITY_VALUE in it.chain(*parent_table), ( - "Discontinuity values in parent table are not allowed." - ) + assert self.DISCONTINUITY_VALUE not in it.chain( + *parent_table + ), "Discontinuity values in parent table are not allowed." self._parent_table = parent_table diff --git a/src/wepy/analysis/profiles.py b/src/wepy/analysis/profiles.py index 4d1aba69..da886361 100644 --- a/src/wepy/analysis/profiles.py +++ b/src/wepy/analysis/profiles.py @@ -96,7 +96,6 @@ def cumulative_partitions( Parameters ---------- - ensemble_values : arraylikes of float of shape (n_cycles, n_trajs) Array of scalar values for all of the frames of an ensemble simulation. @@ -115,7 +114,6 @@ def cumulative_partitions( Yields ------ - cumulative_tranche : arraylike A slice along the cycles axis of the ensemble values starting at the beginning and including up to the end of the next @@ -146,10 +144,8 @@ def free_energy_profile( max_energy=100, zero_point_energy=1e-12, ): - """ - Parameters + """Parameters ---------- - weights : arraylikes of float of shape (n_trajs, n_cycles) The weights for all of the frames of an ensemble simulation. @@ -162,7 +158,6 @@ def free_energy_profile( Returns ------- - hist_fe : arraylike The free energies of the histogram bins @@ -171,9 +166,9 @@ def free_energy_profile( """ - assert weights.shape == observables.shape, ( - "Weights and observables must correspond in shape" - ) + assert ( + weights.shape == observables.shape + ), "Weights and observables must correspond in shape" hist_weights, bin_edges = np.histogram( observables, @@ -201,7 +196,6 @@ def contigtrees_bin_edges( Parameters ---------- - contigtrees : list of ContigTree objects The contigtrees to draw the data from. @@ -285,16 +279,16 @@ def contigtrees_bin_edges( return bin_edges -class ContigTreeProfiler(object): +class ContigTreeProfiler: """A wrapper class around a ContigTree that provides extra methods for - generating free energy profiles for observables.""" + generating free energy profiles for observables. + """ def __init__(self, contigtree, truncate_cycles=None): """Create a wrapper around a contigtree for the profiler. Parameters ---------- - contigtree : ContigTree object The contigtree you want to generate profiles for. @@ -328,7 +322,6 @@ def _get_ignore_trace(cls, contigtree, truncate_cycles): Parameters ---------- - contigtree : ContigTree truncate_cycles : int @@ -336,7 +329,6 @@ def _get_ignore_trace(cls, contigtree, truncate_cycles): Returns ------- - ignored_trace : set of (int, int) The frames to ignore given the truncation. @@ -346,10 +338,12 @@ def _get_ignore_trace(cls, contigtree, truncate_cycles): for span_idx, span_trace in contigtree.span_traces.items(): for run_idx, cycle_idx in span_trace: if cycle_idx >= truncate_cycles: - ignore_trace.add(( - run_idx, - cycle_idx, - )) + ignore_trace.add( + ( + run_idx, + cycle_idx, + ) + ) return ignore_trace @@ -369,7 +363,6 @@ def fe_profile_trace( Parameters ---------- - trace : list of tuple of ints (run_idx, traj_idx, cycle_idx) field_key : str @@ -387,7 +380,6 @@ def fe_profile_trace( Returns ------- - fe_profile : arraylike of dtype float An array of free energies for each bin. @@ -451,7 +443,6 @@ def fe_profile_all( Parameters ---------- - field_key : str The key for the trajectory field to calculate the profiles for. Must be a rank 0 (or equivalent (1,) rank) field. @@ -468,7 +459,6 @@ def fe_profile_all( Returns ------- - fe_profile : arraylike of dtype float An array of free energies for each bin. @@ -545,10 +535,12 @@ def fe_profile_all( ] # reshape to match - weights = weights.reshape(( - weights.shape[0], - weights.shape[1], - )) + weights = weights.reshape( + ( + weights.shape[0], + weights.shape[1], + ) + ) all_weights.append(weights) all_values.append(values) @@ -607,7 +599,6 @@ def fe_profile(self, span, field_key, bins=None, ignore_truncate=False): Parameters ---------- - span : int The index of the span to calculate profiles for. @@ -627,7 +618,6 @@ def fe_profile(self, span, field_key, bins=None, ignore_truncate=False): Returns ------- - fe_profile : arraylike of dtype float An array of free energies for each bin. @@ -709,7 +699,6 @@ def fe_cumulative_profiles( Parameters ---------- - span : int The index of the span to calculate profiles for. @@ -740,7 +729,6 @@ def fe_cumulative_profiles( Returns ------- - cumulative_fe_profiles : list of arraylike of dtype float A list of each cumulative free energy profile. Each profile is an array of free energies for each bin. @@ -828,7 +816,6 @@ def fe_all_cumulative_profiles( Parameters ---------- - field_key : str The key for the trajectory field to calculate the profiles for. Must be a rank 0 (or equivalent (1,) rank) field. @@ -858,7 +845,6 @@ def fe_all_cumulative_profiles( Returns ------- - cumulative_fe_profiles : list of arraylike of dtype float A list of each cumulative free energy profile. Each profile is an array of free energies for each bin. @@ -892,9 +878,9 @@ def fe_all_cumulative_profiles( # trace of all of the frames in the contigtree all_trace = list( - it.chain(*[ - span_trace for span_trace in self.contigtree.span_traces.values() - ]) + it.chain( + *[span_trace for span_trace in self.contigtree.span_traces.values()] + ) ) # filter it for the ignored fields if applicable @@ -983,10 +969,12 @@ def fe_all_cumulative_profiles( contig_values = contig.contig_fields([field_key])[field_key] # reshape to match - contig_weights = contig_weights.reshape(( - contig_weights.shape[0], - contig_weights.shape[1], - )) + contig_weights = contig_weights.reshape( + ( + contig_weights.shape[0], + contig_weights.shape[1], + ) + ) # make the cumulative generators for each contig_weights_partition_gen = cumulative_partitions( @@ -1041,7 +1029,6 @@ def bin_edges(self, bins, field_key): Parameters ---------- - bins : int or str The number of bins to make or the method to use for autobinning. @@ -1061,10 +1048,12 @@ def bin_edges(self, bins, field_key): """ - all_values = np.concatenate([ - fields[field_key] - for fields in self.contigtree.wepy_h5.iter_trajs_fields([field_key]) - ]) + all_values = np.concatenate( + [ + fields[field_key] + for fields in self.contigtree.wepy_h5.iter_trajs_fields([field_key]) + ] + ) bin_edges = np.histogram_bin_edges(all_values, bins=bins) diff --git a/src/wepy/analysis/rates.py b/src/wepy/analysis/rates.py index 7179cded..7e095789 100644 --- a/src/wepy/analysis/rates.py +++ b/src/wepy/analysis/rates.py @@ -15,7 +15,6 @@ def calc_warp_rate(warping_records, total_sampling_time): Parameters ---------- - warping_records : list of namedtuples implementing the warping interface The list of warping records for which events will be used to calculate the rates. @@ -26,7 +25,6 @@ def calc_warp_rate(warping_records, total_sampling_time): Returns ------- - target_weights_rates : dict of int : tuple of (float, float, float) A dictionary where each key is for a target present in the warping records and each value is a tuple giving the total @@ -36,7 +34,6 @@ def calc_warp_rate(warping_records, total_sampling_time): See Also -------- - wepy.boundary_conditions.boundary.Boundary : for specs on fields necessary for warping_records @@ -85,7 +82,6 @@ class (wepy.boundary_conditions.boundary.Boundary). Parameters ---------- - contig : analysis.contig_tree.Contig Underlying WepyHDF5 must be open for reading. @@ -98,7 +94,6 @@ class (wepy.boundary_conditions.boundary.Boundary). Returns ------- - run_target_weights_rates : list of dict of int : tuple of (float, float, float) List where each value of a run is a list of outputs from @@ -107,7 +102,6 @@ class (wepy.boundary_conditions.boundary.Boundary). See Also -------- - wepy.analysis.rates.calc_warp_rate wepy.boundary_conditions.boundary.Boundary : for specs on fields diff --git a/src/wepy/analysis/transitions.py b/src/wepy/analysis/transitions.py index a1bfc930..3ed791a0 100644 --- a/src/wepy/analysis/transitions.py +++ b/src/wepy/analysis/transitions.py @@ -62,7 +62,6 @@ def transition_counts(assignments, transitions, weights=None): Parameters ---------- - assignments: mixed array_like of dim (n_run, n_traj, n_cycle) type int Assignment of microstates to macrostate labels, where N_runs is the number of runs, N_traj is the number of trajectories, diff --git a/src/wepy/boundary_conditions/__init__.py b/src/wepy/boundary_conditions/__init__.py index 85db0338..5571c4c1 100644 --- a/src/wepy/boundary_conditions/__init__.py +++ b/src/wepy/boundary_conditions/__init__.py @@ -94,7 +94,6 @@ class does exactly this and modifies the walker state to restart it in Notes ----- - Boundary conditions in wepy simulations are optional. Inherit from the diff --git a/src/wepy/boundary_conditions/boundary.py b/src/wepy/boundary_conditions/boundary.py index 59af01dc..9c4e098d 100644 --- a/src/wepy/boundary_conditions/boundary.py +++ b/src/wepy/boundary_conditions/boundary.py @@ -10,7 +10,6 @@ logger = logging.getLogger(__name__) # Standard Library import random -import sys from collections import defaultdict from copy import deepcopy @@ -276,7 +275,6 @@ def bc_fields(self): Returns ------- - record_specs : list of tuple A list of the specs for each field, a spec is a tuple of type (field_name, shape_spec, dtype_spec) @@ -306,7 +304,6 @@ def warping_fields(self): Returns ------- - record_specs : list of tuple A list of the specs for each field, a spec is a tuple of type (field_name, shape_spec, dtype_spec) @@ -340,7 +337,6 @@ def progress_fields(self): Returns ------- - record_specs : list of tuple A list of the specs for each field, a spec is a tuple of type (field_name, shape_spec, dtype_spec) @@ -516,7 +512,7 @@ def __init__(self, initial_states=None, initial_weights=None, **kwargs): If the initial weights for each initial state are not given uniform weights are assigned to them. - Arguments + Arguments: --------- initial_states : list of objects implementing the State interface The list of possible states that warped walkers will assume. @@ -526,7 +522,7 @@ def __init__(self, initial_states=None, initial_weights=None, **kwargs): provided. If not given, uniform probabilities will be used. - Raises + Raises: ------ AssertionError If any of the following kwargs are not given: @@ -656,17 +652,15 @@ def warp_walkers(self, walkers, cycle): """Test the progress of all the walkers, warp if required, and update the boundary conditions. - Arguments + Arguments: --------- - walkers : list of objects implementing the Walker interface cycle : int The index of the cycle. - Returns + Returns: ------- - new_walkers : list of objects implementing the Walker interface The new set of walkers that may have been warped. @@ -745,13 +739,11 @@ def warping_discontinuity(cls, warping_record): Parameters ---------- - warping_record : tuple The WARPING type record. Returns ------- - is_discontinuous : bool True if a discontinuous warp False if continuous. diff --git a/src/wepy/boundary_conditions/randomwalk.py b/src/wepy/boundary_conditions/randomwalk.py index 3c22674d..7b7395f3 100644 --- a/src/wepy/boundary_conditions/randomwalk.py +++ b/src/wepy/boundary_conditions/randomwalk.py @@ -1,26 +1,16 @@ """Boundary conditions for random walk.""" # Standard Library -import itertools as it import logging logger = logging.getLogger(__name__) # Standard Library -import time -from collections import defaultdict # Third Party Library import numpy as np -from geomm.centering import center_around -from geomm.distance import minimum_distance -from geomm.grouping import group_pair -from geomm.rmsd import calc_rmsd -from geomm.superimpose import superimpose # First Party Library from wepy.boundary_conditions.boundary import WarpBC -from wepy.util.util import box_vectors_to_lengths_angles -from wepy.walker import WalkerState class RandomWalkBC(WarpBC): @@ -59,9 +49,8 @@ def __init__( ): """Constructor for RandomWalkBC. - Arguments + Arguments: --------- - threshold : int The threshold distance for recording a warping event. @@ -81,7 +70,7 @@ def __init__( The indices of the atom positions in the state considered the binding site. - Raises + Raises: ------ AssertionError If any of the following kwargs are not given: diff --git a/src/wepy/boundary_conditions/receptor.py b/src/wepy/boundary_conditions/receptor.py index bfcda72f..8ab7bde2 100644 --- a/src/wepy/boundary_conditions/receptor.py +++ b/src/wepy/boundary_conditions/receptor.py @@ -4,13 +4,10 @@ """ # Standard Library -import itertools as it import logging logger = logging.getLogger(__name__) # Standard Library -import time -from collections import defaultdict # Third Party Library import numpy as np @@ -90,7 +87,7 @@ def __init__( If the initial weights for each initial state are not given uniform weights are assigned to them. - Arguments + Arguments: --------- ligand_idxs : arraylike of int The indices of the atom positions in the state considered @@ -100,7 +97,7 @@ def __init__( The indices of the atom positions in the state considered the receptor. - Raises + Raises: ------ AssertionError If any of the following kwargs are not given: @@ -209,9 +206,8 @@ def __init__( ): """Constructor for RebindingBC. - Arguments + Arguments: --------- - native_state : object implementing the State interface The reference bound state. Will be automatically centered. @@ -234,7 +230,7 @@ def __init__( The indices of the atom positions in the state considered the binding site. - Raises + Raises: ------ AssertionError If any of the following kwargs are not given: @@ -400,7 +396,7 @@ def __init__( The 'initial_state' should be the initial state of your simulation for proper non-equilibrium simulations. - Arguments + Arguments: --------- initial_state : object implementing State interface The state walkers will take on after unbinding. @@ -420,7 +416,7 @@ def __init__( Indices of the atoms in the topology that correspond to the receptor for the ligand. - Raises + Raises: ------ AssertionError If any of the following are not provided: initial_state, @@ -429,7 +425,7 @@ def __init__( AssertionError If the cutoff distance is not a float. - Warnings + Warnings: -------- The 'initial_state' should be the initial state of your simulation for proper non-equilibrium simulations. @@ -463,7 +459,8 @@ def cutoff_distance(self): @property def topology(self): """JSON string topology of the system. - Note: Deprecated and will be removed in future versions.""" + Note: Deprecated and will be removed in future versions. + """ return self._topology def _calc_min_distance(self, walker): diff --git a/src/wepy/core.py b/src/wepy/core.py new file mode 100644 index 00000000..3c3dc324 --- /dev/null +++ b/src/wepy/core.py @@ -0,0 +1,2 @@ +class BugError(Exception): + pass diff --git a/src/wepy/factory.py b/src/wepy/factory.py new file mode 100644 index 00000000..a1dacdd0 --- /dev/null +++ b/src/wepy/factory.py @@ -0,0 +1,12 @@ +# Standard Library +from typing import Generic, Protocol, TypeVar + +GeneratedType_ = TypeVar("GeneratedType_") + + +class Factory(Protocol, Generic[GeneratedType_]): + + @classmethod + def type(cls) -> type[GeneratedType_]: ... + + def __call__(self) -> GeneratedType_: ... diff --git a/src/wepy/hdf5.py b/src/wepy/hdf5.py index 940dc5fe..a69f99e5 100644 --- a/src/wepy/hdf5.py +++ b/src/wepy/hdf5.py @@ -394,33 +394,47 @@ import itertools as it import json import logging - -logger = logging.getLogger(__name__) -# Standard Library import os.path as osp from collections import Counter, defaultdict, namedtuple -from copy import copy +from pathlib import Path +from typing import Any, Generator, Literal, NotRequired, Required, TypedDict, Union from warnings import warn # Third Party Library import h5py -import networkx as nx import numpy as np +from numpy.typing import NDArray # First Party Library -from wepy.analysis.parents import resampling_panel +from wepy.analysis.parents import DecisionPanel, resampling_panel +from wepy.reporter.file import FileMode +from wepy.storage.protocol import ( + ContigTrace, + ContigWalkerTrace, + Record, + RecordFieldDtype, + RecordFieldShape, + RecordFieldShapeSpec, + RecordFieldSpec, + RecordValueDtype, + ResamplingRecordUnstruct, + RunRecord, + RunTrace, + WarpRecordUnstruct, +) +from wepy.typing import IdxArray, Idxs from wepy.util.json_top import json_top_atom_count, json_top_subset from wepy.util.mdtraj import ( json_to_mdtraj_topology, - mdtraj_to_json_topology, traj_fields_to_mdtraj, ) from wepy.util.util import traj_box_vectors_to_lengths_angles +from wepy.walker import Walker, WalkerStateBox # optional dependencies try: # Third Party Library - import mdtraj as mdj + import mdtraj except ModuleNotFoundError: warn("mdtraj is not installed and that functionality will not work", RuntimeWarning) @@ -430,6 +444,19 @@ except ModuleNotFoundError: warn("pandas is not installed and that functionality will not work", RuntimeWarning) +logger = logging.getLogger(__name__) + +H5AttrDtype = str | int | float + +H5Attrs = dict[str, H5AttrDtype] + +H5FieldDtype = Union[ + np.dtype, + type[np.generic], + str, + h5py.Datatype, +] + ## h5py settings # we set the libver to always be the latest (which should be 1.10) so @@ -438,6 +465,8 @@ # in a new virtualenv if this is a problem for you H5PY_LIBVER = "latest" +STRING_ENCODING = "utf-8" + ## Header and settings keywords TOPOLOGY = "topology" @@ -533,6 +562,35 @@ BC = "boundary_conditions" """Record group run field name for the boundary conditions records """ +RunRecordKey = Literal[ + "trajectories", + "init_walkers", + "decision", + "resampling", + "resampler", + "warping", + "progress", + "boundary_conditions", +] +RUN_RECORD_KEYS = frozenset( + { + RESAMPLING, + RESAMPLER, + WARPING, + PROGRESS, + BC, + } +) + + +class RunRecordColumns(TypedDict, total=False): + cycle_idx: Required[list[int]] + step_idx: Required[list[int]] + walker_idx: Required[list[int]] + decision_id: Required[list[int]] + target_idxs: Required[list[tuple[int, ...]]] + + ## Record groups constants # special datatypes strings @@ -604,6 +662,14 @@ OBSERVABLES = "observables" """The field name for the default compound field observables.""" +RESERVED_TRAJ_FIELDS = frozenset( + { + WEIGHTS, + ALT_REPS, + OBSERVABLES, + } +) + ## Trajectory Field Constants WEIGHT_SHAPE = (1,) @@ -613,7 +679,7 @@ """Weights feature vector data type.""" # Default Trajectory Field Constants -FIELD_FEATURE_SHAPES = ( +FIELD_FEATURE_SHAPES: tuple[tuple[str, tuple[int, ...]]] = ( (TIME, (1,)), (BOX_VECTORS, (3, 3)), (BOX_VOLUME, (1,)), @@ -622,7 +688,8 @@ ) """Default shapes for the default fields.""" -FIELD_FEATURE_DTYPES = ( +FieldFeatureDtype = float | int +FIELD_FEATURE_DTYPES: tuple[tuple[str, FieldFeatureDtype]] = ( (POSITIONS, float), (VELOCITIES, float), (FORCES, float), @@ -651,8 +718,31 @@ """Name of the dataset that indexes sparse trajectory fields.""" +# TOREV: this is potentially an interface which should be in the +# storage protocol +# +## Data types for specific data objects + +FieldsData = dict[str, NDArray] +WeightsTrajArray = NDArray[np.float64] + +SparseIdxs = dict[str, list[int]] + + +class WepyHDF5Error(Exception): + pass + + +class WepyHDF5WriteError(WepyHDF5Error): + pass + + +class WepyHDF5ReadError(WepyHDF5Error): + pass + + # utility for paths -def _iter_field_paths(grp): +def _iter_field_paths(grp: h5py.Group) -> list[str]: """Return all subgroup field name paths from a group. Useful for compound fields. For example if you have the group @@ -693,6 +783,41 @@ def _iter_field_paths(grp): return field_paths +class Dtype(TypedDict): + kind: Literal["simple", "structured"] + str: NotRequired[str] + descr: NotRequired[list[tuple[str, str]]] + + +def numpy_dtype_to_json(dtype: np.dtype) -> str: + + payload: Dtype + if dtype.fields is None: + payload = { + "kind": "simple", + "str": dtype.str, + } + + else: + payload = { + "kind": "structured", + "descr": dtype.descr, + } + + # Warning only supports simple data types + return json.dumps(payload) + + +def dtype_json_to_numpy(s: str) -> np.dtype: + + payload = json.loads(s) + + if payload["kind"] == "simple": + return np.dtype(payload["str"]) + else: + return np.dtype(payload["descr"]) + + class WepyHDF5: """Wrapper for h5py interface to an HDF5 file object for creation and access of WepyHDF5 data. @@ -708,22 +833,302 @@ class WepyHDF5: WRITE_MODES = ("r+", "w", "w-", "x", "a") - #### dunder methods + ## Object attribute type declarations + _filename: Path + _swmr_mode: bool + _h5: h5py.File | None + _wepy_mode: FileMode | None + h5py_mode: FileMode | None + closed: bool + + # TODO: These are all temporary fields that should just be removed + # from object state and passed directly to static methods + _topology: str | None + _units: dict[str, str] | None + _n_dims: int | None + _n_coords: int | None + # These are the extra fields that can be declared + _field_feature_shapes_kwarg: dict[str, tuple[int, ...]] + _field_feature_dtypes_kwarg: dict[str, FieldFeatureDtype] + # This is the consolidated field features from the defaults and + # the extra ones. + _field_feature_shapes: dict[str, tuple[int, ...]] | None + _field_feature_dtypes: dict[str, FieldFeatureDtype] | None + + _sparse_fields: tuple[str, Any] + _main_rep_idxs: Idxs + _alt_reps: dict[str, IdxArray] + + ## Partial constructors/initializers + + @staticmethod + def _gen_default_init_field_attributes( + topology: str, + main_rep_idxs: Idxs | None, + n_dims: int | None = None, + ) -> tuple[ + dict[str, tuple[int, ...]], + dict[str, FieldFeatureDtype], + int, # n_dims + int, # n_coords + NDArray[np.integer], + ]: + """Sets the feature_shapes and feature_dtypes to be the default for + this module. These will be used to initialize field datasets when no + given during construction (i.e. for sparse values) + + Parameters + ---------- + n_dims : int + + """ + + # we use the module defaults for the datasets to initialize them + field_feature_shapes = dict(FIELD_FEATURE_SHAPES) + field_feature_dtypes = dict(FIELD_FEATURE_DTYPES) + + # get the number of coordinates of positions. If there is a + # main_reps then we have to set the number of atoms to that, + # if not we count the number of atoms in the topology + if main_rep_idxs is None: + n_coords = json_top_atom_count(topology) + _main_rep_idxs = np.array(range(n_coords)) + else: + n_coords = len(main_rep_idxs) + _main_rep_idxs = np.array(main_rep_idxs) + + # get the number of dimensions as a default + if n_dims is None: + n_dims = N_DIMS + + # feature shapes for positions and positions-like fields are + # not known at the module level due to different number of + # coordinates (number of atoms) and number of dimensions + # (default 3 spatial). We set them now that we know this + # information. + # add the postitions shape + field_feature_shapes[POSITIONS] = (n_coords, n_dims) + # add the positions-like field shapes (velocities and forces) as the same + for poslike_field in POSITIONS_LIKE_FIELDS: + field_feature_shapes[poslike_field] = (n_coords, n_dims) + + return ( + field_feature_shapes, + field_feature_dtypes, + n_dims, + n_coords, + _main_rep_idxs, + ) + + # TODO: make these static and accept the arguments it needs to + # avoid keeping temporary state + + @classmethod + def _init_continuations( + cls, + h5: h5py.File, + ) -> h5py.Dataset: + """This will either create a dataset in the settings for the + continuations or if continuations already exist it will reinitialize + them and delete the data that exists there. + + Returns + ------- + continuation_dset : h5py.Dataset + + """ + + # if the continuations dset already exists we reinitialize the + # data + if CONTINUATIONS in h5[SETTINGS]: + cont_dset = h5[SETTINGS][CONTINUATIONS] + cont_dset.resize((0, 2)) + + # otherwise we just create the data + else: + cont_dset = h5[SETTINGS].create_dataset( + CONTINUATIONS, shape=(0, 2), dtype=int, maxshape=(None, 2) + ) + + return cont_dset + + @classmethod + def _create_init( + cls, + h5: h5py.File, + topology: str, + alt_reps: dict[str, IdxArray] | None = None, + sparse_fields: tuple[str, ...] | None = None, + units: dict[str, str] | None = None, + n_dims: int | None = None, + main_rep_idxs: Idxs | None = None, + field_feature_shapes_overrides: dict[str, tuple[int, ...]] | None = None, + field_feature_dtypes_overrides: dict[str, FieldFeatureDtype] | None = None, + ) -> None: + """Creation mode constructor. + + Completely overwrite the data in the file. Reinitialize the values + and set with the new ones if given. + """ + + if sparse_fields is None: + _sparse_fields = () + else: + _sparse_fields = sparse_fields + + if alt_reps is None: + _alt_reps = {} + else: + _alt_reps = alt_reps + + # initialize the runs group + runs_grp = h5.create_group(RUNS) + + # initialize the settings group + settings_grp = h5.create_group(SETTINGS) + + # create the topology dataset + h5.create_dataset(TOPOLOGY, data=topology) + + # sparse fields + + # make a dataset for the sparse fields allowed. this requires + # a 'special' datatype for variable length strings. This is + # supported by HDF5 but not numpy. + vlen_str_dt = h5py.string_dtype(encoding=STRING_ENCODING) + + # create the dataset with empty values for the length of the + # sparse fields given + sparse_fields_dset = settings_grp.create_dataset( + SPARSE_FIELDS, + (len(_sparse_fields),), + dtype=vlen_str_dt, + maxshape=(None,), + ) + + # set the flags + for i, sparse_field in enumerate(_sparse_fields): + sparse_fields_dset[i] = sparse_field + + # field feature shapes and dtypes + + # initialize to the defaults, this gives values to + # n_coords, n_dims, and field_feature_dtypes, and + # field_feature_shapes + ( + _field_feature_shapes, + _field_feature_dtypes, + _n_dims, + _n_coords, + _main_rep_idxs, + ) = cls._gen_default_init_field_attributes( + topology=topology, + main_rep_idxs=main_rep_idxs, + n_dims=n_dims, + ) + + # save the number of dimensions and number of atoms in settings + settings_grp.create_dataset(N_DIMS_STR, data=np.array(_n_dims)) + settings_grp.create_dataset(N_ATOMS, data=np.array(_n_coords)) + + # the main rep atom idxs + settings_grp.create_dataset(MAIN_REP_IDXS, data=_main_rep_idxs, dtype=int) + + # alt_reps settings + alt_reps_idxs_grp = settings_grp.create_group(ALT_REPS_IDXS) + for alt_rep_name, idxs in _alt_reps.items(): + alt_reps_idxs_grp.create_dataset(alt_rep_name, data=idxs, dtype=int) + + # if both feature shapes and dtypes were specified overwrite + # (or initialize if not set by defaults) the defaults + if (field_feature_shapes_overrides is not None) and ( + field_feature_dtypes_overrides is not None + ): + # check that they have the same keys + if ( + len( + mismatch_keys := ( + set(field_feature_shapes_overrides.keys()).symmetric_difference( + set(field_feature_dtypes_overrides.keys()) + ) + ) + ) + > 0 + ): + raise ValueError( + f"Mismatch in the keys for field feature overrides: {mismatch_keys}" + ) + + _field_feature_shapes.update(field_feature_shapes_overrides) + _field_feature_dtypes.update(field_feature_dtypes_overrides) + + # any sparse field with unspecified shape and dtype must be + # set to None so that it will be set at runtime + for sparse_field in _sparse_fields: + if (sparse_field not in _field_feature_shapes) or ( + sparse_field not in _field_feature_dtypes + ): + _field_feature_shapes[sparse_field] = None + _field_feature_dtypes[sparse_field] = None + + # save the field feature shapes and dtypes in the settings group + shapes_grp = settings_grp.create_group(FIELD_FEATURE_SHAPES_STR) + for field_path, field_shape in _field_feature_shapes.items(): + if field_shape is None: + # set it as a dimensionless array of NaN + field_shape = np.array(np.nan) + + shapes_grp.create_dataset(field_path, data=field_shape) + + dtypes_grp = settings_grp.create_group(FIELD_FEATURE_DTYPES_STR) + for field_path, field_dtype in _field_feature_dtypes.items(): + if field_dtype is None: + dt_str = NONE_STR + else: + dt_str = numpy_dtype_to_json(np.dtype(field_dtype)) + + dtypes_grp.create_dataset(field_path, data=dt_str) + + # initialize the units group + unit_grp = h5.create_group(UNITS) + + # if units were not given set them all to None + if units is None: + units = {} + for field_path in _field_feature_shapes.keys(): + units[field_path] = None + + # set the units + for field_path, unit_value in units.items(): + # ignore the field if not given + if unit_value is None: + continue + + unit_grp.create_dataset(field_path, data=unit_value) + + # create the group for the run data records + records_grp = settings_grp.create_group(RECORD_FIELDS) + + # create a dataset for the continuation run tuples + # (continuation_run, base_run), where the first element + # of the new run that is continuing the run in the second + # position + cls._init_continuations(h5) def __init__( self, - filename, - mode="x", - topology=None, - units=None, - sparse_fields=None, - feature_shapes=None, - feature_dtypes=None, - n_dims=None, - alt_reps=None, - main_rep_idxs=None, - swmr_mode=False, - expert_mode=False, + filename: Path, + mode: FileMode = "x", + swmr_mode: bool = False, + expert_mode: bool = False, + topology: str | None = None, + units: dict[str, str] | None = None, + sparse_fields: tuple[str, ...] = None, + n_dims: int | None = None, + alt_reps: dict[str, IdxArray] | None = None, + main_rep_idxs: IdxArray | None = None, + feature_shapes_overrides: dict[str, RecordFieldShapeSpec] | None = None, + feature_dtypes_overrides: dict[str, RecordFieldDtype] | None = None, ): """Constructor for the WepyHDF5 class. @@ -757,11 +1162,13 @@ def __init__( sparse_fields : list of str, optional List of trajectory fields that should be initialized as sparse. - feature_shapes : dict of str : shape_spec, optional - Mapping of trajectory fields to their shape spec for initialization. + feature_shapes : Mapping of trajectory fields to their shape + spec for initialization. Note that the default OpenMM MD + fields will be generated automatically - feature_dtypes : dict of str : dtype_spec, optional - Mapping of trajectory fields to their shape spec for initialization. + feature_dtypes : Mapping of extra trajectory fields to their + shape spec for initialization. Note that the default + OpenMM MD fields will be generated automatically n_dims : int, default: 3 Set the number of spatial dimensions for the default @@ -780,24 +1187,9 @@ def __init__( If True no initialization is performed other than the setting of the filename. Useful mainly for debugging. - Raises - ------ - - AssertionError - If the mode is not one of the supported mode specs. - - AssertionError - If a topology is not given for a creation mode. - - Warns - ----- - - If initialization data was given but the file was opened in a read mode. - """ - self._filename = filename - self._swmr_mode = swmr_mode + self.closed = None if expert_mode is True: self._h5 = None @@ -808,9 +1200,51 @@ def __init__( # terminate the constructor here return None - assert mode in self.MODES, "mode must be either one of: {}".format( - ", ".join(self.MODES) - ) + # Validate inputs + + if mode not in self.MODES: + raise ValueError(f"mode must be either one of: {self.MODES}") + + _constructor_data = { + "topology": topology, + "units": units, + "sparse_fields": sparse_fields, + "feature_shapes": feature_shapes_overrides, + "feature_dtypes": feature_dtypes_overrides, + "n_dims": n_dims, + "alt_reps": alt_reps, + "main_rep_idxs": main_rep_idxs, + } + + # create file mode: 'w' will create a new file or overwrite, + # 'w-' and 'x' will not overwrite but will create a new file + if mode in {"w-", "x"} and filename.exists(): + raise FileExistsError( + f"WepyHDF5 file already exists and will not be overwritten in mode: {mode}" + ) + + elif mode in {"w", "w-", "x"}: + # check for required args + if topology is None: + raise ValueError( + f"In creation mode ({mode}) you must provide topology." + ) + + elif mode in {"r", "r+"}: + + # if any data was given, warn the user + if any( + _given_data := { + key for key, value in _constructor_data.items() if value is not None + } + ): + raise ValueError( + f"Data was given but opening in read mode: {_given_data}", + ) + + # Object attributes + self._filename = filename + self._swmr_mode = swmr_mode # the top level mode enforced by wepy.hdf5 self._wepy_mode = mode @@ -821,343 +1255,189 @@ def __init__( # used elsewhere and could be a feature in the future. self._h5py_mode = mode - # Temporary metadata: used to initialize the object but not - # used after that - - self._topology = topology - self._units = units - self._n_dims = n_dims - self._n_coords = None - - # set hidden feature shapes and dtype, which are only - # referenced if needed when trajectories are created. These - # will be saved in the settings section in the actual HDF5 - # file - self._field_feature_shapes_kwarg = feature_shapes - self._field_feature_dtypes_kwarg = feature_dtypes - self._field_feature_dtypes = None - self._field_feature_shapes = None - - # save the sparse fields as a private variable for use in the - # create constructor - if sparse_fields is None: - self._sparse_fields = [] - else: - self._sparse_fields = sparse_fields - - # if we specify an atom subset of the main POSITIONS field - # we must save them - self._main_rep_idxs = main_rep_idxs - - # a dictionary specifying other alt_reps to be saved - if alt_reps is not None: - self._alt_reps = alt_reps - # all alt_reps are sparse - alt_rep_keys = [ - "{}/{}".format(ALT_REPS, key) for key in self._alt_reps.keys() - ] - self._sparse_fields.extend(alt_rep_keys) - else: - self._alt_reps = {} + ## Initialize the file # open the file and then run the different constructors based # on the mode - with h5py.File( - filename, mode=self._h5py_mode, libver=H5PY_LIBVER, swmr=self._swmr_mode - ) as h5: - self._h5 = h5 - - # set SWMR mode if asked for if we are in write mode also - if self._swmr_mode is True and mode in self.WRITE_MODES: - self._h5.swmr_mode = swmr_mode - - # create file mode: 'w' will create a new file or overwrite, - # 'w-' and 'x' will not overwrite but will create a new file - if self._wepy_mode in ["w", "w-", "x"]: - self._create_init() - - # read/write mode: in this mode we do not completely overwrite - # the old file and start again but rather write over top of - # values if requested - elif self._wepy_mode in ["r+"]: - self._read_write_init() - - # add mode: read/write create if doesn't exist - elif self._wepy_mode in ["a"]: - if osp.exists(self._filename): - self._read_write_init() - else: - self._create_init() - - # read only mode - elif self._wepy_mode == "r": - # if any data was given, warn the user - if any([ - kwarg is not None - for kwarg in [ - topology, - units, - sparse_fields, - feature_shapes, - feature_dtypes, - n_dims, - alt_reps, - main_rep_idxs, - ] - ]): - warn("Data was given but opening in read-only mode", RuntimeWarning) - - # then run the initialization process - self._read_init() - - # flush the buffers - self._h5.flush() - - # set the h5py mode to the value in the actual h5py.File - # object after creation - self._h5py_mode = self._h5.mode - - # get rid of the temporary variables - del self._topology - del self._units - del self._n_dims - del self._n_coords - del self._field_feature_shapes_kwarg - del self._field_feature_dtypes_kwarg - del self._field_feature_shapes - del self._field_feature_dtypes - del self._sparse_fields - del self._main_rep_idxs - del self._alt_reps - - # variable to reflect if it is closed or not, should be closed - # after initialization - self.closed = True - - # end of the constructor - return None - - # TODO is this right? shouldn't we actually delete the data then close - def __del__(self): - self.close() - - # context manager methods - - def __enter__(self): - self.open() - # self._h5 = h5py.File(self._filename, - # libver=H5PY_LIBVER, swmr=self._swmr_mode) - # self.closed = False - return self - - def __exit__(self, exc_type, exc_value, exc_tb): - self.close() - - @property - def swmr_mode(self): - return self._swmr_mode - - @swmr_mode.setter - def swmr_mode(self, val): - self._swmr_mode = val - - # TODO custom deepcopy to avoid copying the actual HDF5 object - - #### hidden methods (_method_name) - - ### constructors - def _create_init(self): - """Creation mode constructor. - - Completely overwrite the data in the file. Reinitialize the values - and set with the new ones if given. - """ - - assert self._topology is not None, ( - "Topology must be given for a creation constructor" + self._h5 = h5py.File( + filename, + mode=self._h5py_mode, + libver=H5PY_LIBVER, + swmr=self._swmr_mode, ) + self.closed = False - # initialize the runs group - runs_grp = self._h5.create_group(RUNS) + # TOREV: do we need to set this again? + # + # set SWMR mode if asked for if we are in write mode also + if self._swmr_mode is True and mode in self.WRITE_MODES: + self._h5.swmr_mode = swmr_mode - # initialize the settings group - settings_grp = self._h5.create_group(SETTINGS) + if self._wepy_mode in {"w", "x", "w-"}: - # create the topology dataset - self._h5.create_dataset(TOPOLOGY, data=self._topology) + # Expand some of the inputs - # sparse fields - if self._sparse_fields is not None: - # make a dataset for the sparse fields allowed. this requires - # a 'special' datatype for variable length strings. This is - # supported by HDF5 but not numpy. - vlen_str_dt = h5py.special_dtype(vlen=str) - - # create the dataset with empty values for the length of the - # sparse fields given - sparse_fields_ds = settings_grp.create_dataset( - SPARSE_FIELDS, - (len(self._sparse_fields),), - dtype=vlen_str_dt, - maxshape=(None,), + # save the sparse fields as a private variable for use in the + # create constructor + if sparse_fields is None: + _sparse_fields = () + else: + _sparse_fields = sparse_fields + + # a dictionary specifying other alt_reps to be saved + if alt_reps is not None: + _alt_reps = alt_reps + # all alt_reps are sparse + alt_rep_keys = [ + "{}/{}".format(ALT_REPS, key) for key in _alt_reps.keys() + ] + _sparse_fields.extend(alt_rep_keys) + else: + _alt_reps = {} + + self._create_init( + h5=self._h5, + topology=topology, + sparse_fields=sparse_fields, + units=units, + n_dims=n_dims, + main_rep_idxs=main_rep_idxs, + field_feature_shapes_overrides=feature_dtypes_overrides, + field_feature_dtypes_overrides=feature_dtypes_overrides, ) - # set the flags - for i, sparse_field in enumerate(self._sparse_fields): - sparse_fields_ds[i] = sparse_field + # flush the buffers + self._h5.flush() - # field feature shapes and dtypes + # set the h5py mode to the value in the actual h5py.File + # object after creation + self._h5py_mode = self._h5.mode - # initialize to the defaults, this gives values to - # self._n_coords, and self.field_feature_dtypes, and - # self.field_feature_shapes - self._set_default_init_field_attributes(n_dims=self._n_dims) + self._h5.close() - # save the number of dimensions and number of atoms in settings - settings_grp.create_dataset(N_DIMS_STR, data=np.array(self._n_dims)) - settings_grp.create_dataset(N_ATOMS, data=np.array(self._n_coords)) + # variable to reflect if it is closed or not, should be closed + # after initialization + self.closed = True - # the main rep atom idxs - settings_grp.create_dataset(MAIN_REP_IDXS, data=self._main_rep_idxs, dtype=int) + @property + def filename(self) -> Path: + """The path to the underlying HDF5 file.""" + return self._filename - # alt_reps settings - alt_reps_idxs_grp = settings_grp.create_group(ALT_REPS_IDXS) - for alt_rep_name, idxs in self._alt_reps.items(): - alt_reps_idxs_grp.create_dataset(alt_rep_name, data=idxs, dtype=int) + @property + def mode(self) -> FileMode: + """The WepyHDF5 mode this object was created with.""" + return self._wepy_mode - # if both feature shapes and dtypes were specified overwrite - # (or initialize if not set by defaults) the defaults - if (self._field_feature_shapes_kwarg is not None) and ( - self._field_feature_dtypes_kwarg is not None - ): - self._field_feature_shapes.update(self._field_feature_shapes_kwarg) - self._field_feature_dtypes.update(self._field_feature_dtypes_kwarg) + @mode.setter + def mode(self, mode: FileMode) -> None: + """Set the mode for opening the file with.""" + self.set_mode(mode) - # any sparse field with unspecified shape and dtype must be - # set to None so that it will be set at runtime - for sparse_field in self.sparse_fields: - if (not sparse_field in self._field_feature_shapes) or ( - not sparse_field in self._field_feature_dtypes - ): - self._field_feature_shapes[sparse_field] = None - self._field_feature_dtypes[sparse_field] = None + def set_mode(self, mode: FileMode) -> None: + """Set the mode for opening the file with.""" - # save the field feature shapes and dtypes in the settings group - shapes_grp = settings_grp.create_group(FIELD_FEATURE_SHAPES_STR) - for field_path, field_shape in self._field_feature_shapes.items(): - if field_shape is None: - # set it as a dimensionless array of NaN - field_shape = np.array(np.nan) + if not self.closed: + raise RuntimeError("Cannot set the mode while the file is open.") - shapes_grp.create_dataset(field_path, data=field_shape) + self._set_h5_mode(mode) - dtypes_grp = settings_grp.create_group(FIELD_FEATURE_DTYPES_STR) - for field_path, field_dtype in self._field_feature_dtypes.items(): - if field_dtype is None: - dt_str = NONE_STR - else: - # make a json string of the datatype that can be read - # in again, we call np.dtype again because there is no - # np.float.descr attribute - dt_str = json.dumps(np.dtype(field_dtype).descr) + self._wepy_mode = mode - dtypes_grp.create_dataset(field_path, data=dt_str) + @property + def h5_mode(self) -> FileMode: + """The h5py.File mode the HDF5 file currently has.""" + return self._h5.mode - # initialize the units group - unit_grp = self._h5.create_group(UNITS) + def _set_h5_mode(self, h5_mode: FileMode) -> None: + """Set the mode to open the HDF5 file with. - # if units were not given set them all to None - if self._units is None: - self._units = {} - for field_path in self._field_feature_shapes.keys(): - self._units[field_path] = None + This really shouldn't be set without using the main wepy mode + as they need to be aligned. - # set the units - for field_path, unit_value in self._units.items(): - # ignore the field if not given - if unit_value is None: - continue + """ - unit_path = "{}/{}".format(UNITS, field_path) + if not self.closed: + raise AttributeError("Cannot set the mode while the file is open.") - unit_grp.create_dataset(unit_path, data=unit_value) + self._h5py_mode = h5_mode - # create the group for the run data records - records_grp = settings_grp.create_group(RECORD_FIELDS) + def open(self, mode: FileMode | None = None) -> None: + """Open the underlying HDF5 file for access. - # create a dataset for the continuation run tuples - # (continuation_run, base_run), where the first element - # of the new run that is continuing the run in the second - # position - self._init_continuations() + Parameters + ---------- + mode : str + Valid mode spec. Opens the HDF5 file in this mode if given + otherwise uses the existing mode. - def _read_write_init(self): - """Read-write mode constructor.""" + """ - self._read_init() + if mode is None: + mode = self.mode - def _add_init(self): - """The addition mode constructor. + if self.closed: + self.set_mode(mode) - Create the dataset if it doesn't exist and put it in r+ mode, - otherwise, just open in r+ mode. + self._h5 = h5py.File( + self._filename, mode, libver=H5PY_LIBVER, swmr=self.swmr_mode + ) + self.closed = False + else: + raise IOError("This file is already open") - """ + def close(self) -> None: + """Close the underlying HDF5 file.""" - if not any(self._exist_flags): - self._create_init() - else: - self._read_write_init() + # check if the HDF5 is fully initialized yet - def _read_init(self): - """Read mode constructor.""" + # not fully initialized yet + if not hasattr(self, "_h5") or self._h5 is None: + self.closed = True - pass + elif not self.closed: + self._h5.flush() + self._h5.close() + self.closed = True - def _set_default_init_field_attributes(self, n_dims=None): - """Sets the feature_shapes and feature_dtypes to be the default for - this module. These will be used to initialize field datasets when no - given during construction (i.e. for sparse values) + @property + def h5(self) -> h5py.File: + """The underlying h5py.File object.""" + return self._h5 - Parameters - ---------- - n_dims : int + def __del__(self): + self.close() - """ + # context manager methods - # we use the module defaults for the datasets to initialize them - field_feature_shapes = dict(FIELD_FEATURE_SHAPES) - field_feature_dtypes = dict(FIELD_FEATURE_DTYPES) + def __enter__(self): + self.open() + # self._h5 = h5py.File(self._filename, + # libver=H5PY_LIBVER, swmr=self._swmr_mode) + # self.closed = False + return self - # get the number of coordinates of positions. If there is a - # main_reps then we have to set the number of atoms to that, - # if not we count the number of atoms in the topology - if self._main_rep_idxs is None: - self._n_coords = json_top_atom_count(self.topology) - self._main_rep_idxs = list(range(self._n_coords)) - else: - self._n_coords = len(self._main_rep_idxs) + def __exit__(self, exc_type, exc_value, exc_tb): + self.close() - # get the number of dimensions as a default - if n_dims is None: - self._n_dims = N_DIMS + @property + def swmr_mode(self): + return self._swmr_mode - # feature shapes for positions and positions-like fields are - # not known at the module level due to different number of - # coordinates (number of atoms) and number of dimensions - # (default 3 spatial). We set them now that we know this - # information. - # add the postitions shape - field_feature_shapes[POSITIONS] = (self._n_coords, self._n_dims) - # add the positions-like field shapes (velocities and forces) as the same - for poslike_field in POSITIONS_LIKE_FIELDS: - field_feature_shapes[poslike_field] = (self._n_coords, self._n_dims) + @swmr_mode.setter + def swmr_mode(self, val): + self._swmr_mode = val - # set the attributes - self._field_feature_shapes = field_feature_shapes - self._field_feature_dtypes = field_feature_dtypes + #### hidden methods (_method_name) + + ### constructors - def _get_field_path_grp(self, run_idx, traj_idx, field_path): + def _get_field_path_grp( + self, run_idx: int, traj_idx: int, field_path: str + ) -> tuple[ + h5py.Group, + str, + ]: """Given a field path for the trajectory returns the group the field's dataset goes in and the key for the field name in that group. @@ -1186,42 +1466,23 @@ def _get_field_path_grp(self, run_idx, traj_idx, field_path): # split it grp_name, field_name = field_path.split("/") # get the hdf5 group - grp = self.h5[ - "{}/{}/{}/{}/{}".format(RUNS, run_idx, TRAJECTORIES, traj_idx, grp_name) - ] - # its simple so just return the root group and the original path - else: - grp = self.h5 - field_name = field_path - - return grp, field_name - - def _init_continuations(self): - """This will either create a dataset in the settings for the - continuations or if continuations already exist it will reinitialize - them and delete the data that exists there. - - Returns - ------- - continuation_dset : h5py.Dataset - - """ + traj_grp = self.traj(run_idx, traj_idx) + if grp_name not in traj_grp: + raise WepyHDF5ReadError( + f"Field group '{field_path}' not in run={run_idx}, traj={traj_idx}" + ) - # if the continuations dset already exists we reinitialize the - # data - if CONTINUATIONS in self.settings_grp: - cont_dset = self.settings_grp[CONTINUATIONS] - cont_dset.resize((0, 2)) + else: + grp = traj_grp[grp_name] - # otherwise we just create the data + # its simple so just return the root group and the original path else: - cont_dset = self.settings_grp.create_dataset( - CONTINUATIONS, shape=(0, 2), dtype=int, maxshape=(None, 2) - ) + grp = self.h5 + field_name = field_path - return cont_dset + return grp, field_name - def _add_run_init(self, run_idx, continue_run=None): + def _add_run_init(self, run_idx: int, continue_run: int | None = None) -> None: """Routines for creating a run includes updating and setting object global variables, increasing the counter for the number of runs. @@ -1234,15 +1495,19 @@ def _add_run_init(self, run_idx, continue_run=None): """ + run_grp = self.run(run_idx) + # add the run idx as metadata in the run group - self._h5["{}/{}".format(RUNS, run_idx)].attrs[RUN_IDX] = run_idx + run_grp.attrs[RUN_IDX] = run_idx # if this is continuing another run add the tuple (this_run, # continues_run) to the continutations settings if continue_run is not None: self.add_continuation(run_idx, continue_run) - def _add_init_walkers(self, init_walkers_grp, init_walkers): + def _add_init_walkers( + self, init_walkers_grp: h5py.Group, init_walkers: list[Walker[WalkerStateBox]] + ) -> None: """Adds the run field group for the initial walkers. Parameters @@ -1275,7 +1540,12 @@ def _add_init_walkers(self, init_walkers_grp, init_walkers): # (wrapping it in another list) walker_grp.create_dataset(field_key, data=np.array([field_value])) - def _init_run_sporadic_record_grp(self, run_idx, run_record_key, fields): + def _init_run_sporadic_record_grp( + self, + run_idx: int, + run_record_key: str, + fields: list[RecordFieldSpec], + ) -> h5py.Group: """Initialize a sporadic record group for a run. Parameters @@ -1300,7 +1570,7 @@ def _init_run_sporadic_record_grp(self, run_idx, run_record_key, fields): # initialize the cycles dataset that maps when the records # were recorded - record_grp.create_dataset(CYCLE_IDXS, (0,), dtype=int, maxshape=(None,)) + record_grp.create_dataset(CYCLE_IDXS, (0,), dtype=np.int64, maxshape=(None,)) # for each field simply create the dataset for field_name, field_shape, field_dtype in fields: @@ -1311,7 +1581,12 @@ def _init_run_sporadic_record_grp(self, run_idx, run_record_key, fields): return record_grp - def _init_run_continual_record_grp(self, run_idx, run_record_key, fields): + def _init_run_continual_record_grp( + self, + run_idx: int, + run_record_key: RunRecordKey, + fields: list[RecordFieldSpec], + ) -> h5py.Group: """Initialize a continual record group for a run. Parameters @@ -1343,8 +1618,13 @@ def _init_run_continual_record_grp(self, run_idx, run_record_key, fields): return record_grp def _init_run_records_field( - self, run_idx, run_record_key, field_name, field_shape, field_dtype - ): + self, + run_idx: int, + run_record_key: str, + field_name: str, + field_shape: RecordFieldShapeSpec, + field_dtype: RecordFieldDtype, + ) -> h5py.Dataset: """Initialize a single field for a run record group. Parameters @@ -1372,7 +1652,7 @@ def _init_run_records_field( if field_shape is Ellipsis: # make a special dtype that allows it to be # variable length - vlen_dt = h5py.special_dtype(vlen=field_dtype) + vlen_dt = h5py.vlen_dtype(field_dtype) # this is only allowed to be a single dimension # since no real shape was given @@ -1392,7 +1672,8 @@ def _init_run_records_field( return dset - def _is_sporadic_records(self, run_record_key): + @staticmethod + def _is_sporadic_records(run_record_key: str) -> bool: """Tests whether a record group is sporadic or not. Parameters @@ -1413,39 +1694,14 @@ def _is_sporadic_records(self, run_record_key): else: return False - def _init_traj_field(self, run_idx, traj_idx, field_path, feature_shape, dtype): - """Initialize a trajectory field. - - Initialize a data field in the trajectory to be empty but - resizeable. - - Parameters - ---------- - run_idx : int - traj_idx : int - field_path : str - Field name specification. - feature_shape : shape_spec - Specification of shape of a feature vector of the field. - dtype : dtype_spec - Specification of the feature vector datatype. - - """ - - # check whether this is a sparse field and create it - # appropriately - if field_path in self.sparse_fields: - # it is a sparse field - self._init_sparse_traj_field( - run_idx, traj_idx, field_path, feature_shape, dtype - ) - else: - # it is not a sparse field (AKA simple) - self._init_contiguous_traj_field( - run_idx, traj_idx, field_path, feature_shape, dtype - ) - - def _init_contiguous_traj_field(self, run_idx, traj_idx, field_path, shape, dtype): + def _init_contiguous_traj_field( + self, + run_idx: int, + traj_idx: int, + field_path: str, + shape: RecordFieldShape, + dtype: H5FieldDtype, + ) -> None: """Initialize a contiguous (non-sparse) trajectory field. Parameters @@ -1461,7 +1717,7 @@ def _init_contiguous_traj_field(self, run_idx, traj_idx, field_path, shape, dtyp """ - traj_grp = self._h5["{}/{}/{}/{}".format(RUNS, run_idx, TRAJECTORIES, traj_idx)] + traj_grp = self.traj(run_idx, traj_idx) # create the empty dataset in the correct group, setting # maxshape so it can be resized for new feature vectors to be added @@ -1469,10 +1725,15 @@ def _init_contiguous_traj_field(self, run_idx, traj_idx, field_path, shape, dtyp field_path, (0, *[0 for i in shape]), dtype=dtype, maxshape=(None, *shape) ) - def _init_sparse_traj_field(self, run_idx, traj_idx, field_path, shape, dtype): - """ - - Parameters + def _init_sparse_traj_field( + self, + run_idx: int, + traj_idx: int, + field_path: str, + shape: RecordFieldShape, + dtype: H5FieldDtype, + ) -> None: + """Parameters ---------- run_idx : int traj_idx : int @@ -1485,7 +1746,7 @@ def _init_sparse_traj_field(self, run_idx, traj_idx, field_path, shape, dtype): """ - traj_grp = self._h5["{}/{}/{}/{}".format(RUNS, run_idx, TRAJECTORIES, traj_idx)] + traj_grp = self.traj(run_idx, traj_idx) # check to see that neither the shape and dtype are # None which indicates it is a runtime defined value and @@ -1507,8 +1768,52 @@ def _init_sparse_traj_field(self, run_idx, traj_idx, field_path, shape, dtype): # create the dataset for the sparse indices sparse_grp.create_dataset(SPARSE_IDXS, (0,), dtype=int, maxshape=(None,)) + def _init_traj_field( + self, + run_idx: int, + traj_idx: int, + field_path: str, + feature_shape: RecordFieldShape, + dtype: H5FieldDtype, + ) -> None: + """Initialize a trajectory field. + + Initialize a data field in the trajectory to be empty but + resizeable. + + Parameters + ---------- + run_idx : int + traj_idx : int + field_path : str + Field name specification. + feature_shape : shape_spec + Specification of shape of a feature vector of the field. + dtype : dtype_spec + Specification of the feature vector datatype. + + """ + + # check whether this is a sparse field and create it + # appropriately + if field_path in self.sparse_fields: + # it is a sparse field + self._init_sparse_traj_field( + run_idx, traj_idx, field_path, feature_shape, dtype + ) + else: + # it is not a sparse field (AKA simple) + self._init_contiguous_traj_field( + run_idx, traj_idx, field_path, feature_shape, dtype + ) + def _init_traj_fields( - self, run_idx, traj_idx, field_paths, field_feature_shapes, field_feature_dtypes + self, + run_idx: int, + traj_idx: int, + field_paths: list[str], + field_feature_shapes: list[RecordFieldShape], + field_feature_dtypes: list[H5FieldDtype], ): """Initialize a number of fields for a trajectory. @@ -1533,12 +1838,12 @@ def _init_traj_fields( def _add_traj_field_data( self, - run_idx, - traj_idx, - field_path, - field_data, - sparse_idxs=None, - ): + run_idx: int, + traj_idx: int, + field_path: str, + field_data: NDArray, + sparse_idxs: SparseIdxs | None = None, + ) -> None: """Add a trajectory field to a trajectory. If the sparse indices are given the field will be created as a @@ -1562,7 +1867,7 @@ def _add_traj_field_data( """ # get the traj group - traj_grp = self._h5["{}/{}/{}/{}".format(RUNS, run_idx, TRAJECTORIES, traj_idx)] + traj_grp = self.traj(run_idx, traj_idx) # if it is a sparse dataset we need to add the data and add # the idxs in a group @@ -1613,13 +1918,14 @@ def _extend_contiguous_traj_field(self, run_idx, traj_idx, field_path, field_dat """ - traj_grp = self.h5["{}/{}/{}/{}".format(RUNS, run_idx, TRAJECTORIES, traj_idx)] + traj_grp = self.traj(run_idx, traj_idx) + field = traj_grp[field_path] # make sure this is a feature vector - assert len(field_data.shape) > 1, ( - "field_data must be a feature vector with the same number of dimensions as the number" - ) + assert ( + len(field_data.shape) > 1 + ), f"field_data (path={field_path}, shape={field_data.shape}) must be a feature vector with the same number of dimensions as the number." # of datase new frames n_new_frames = field_data.shape[0] @@ -1628,9 +1934,9 @@ def _extend_contiguous_traj_field(self, run_idx, traj_idx, field_path, field_dat if all([i == 0 for i in field.shape]): # check the feature shape against the maxshape which gives # the feature dimensions for an empty dataset - assert field_data.shape[1:] == field.maxshape[1:], ( - "field feature dimensions must be the same, i.e. all but the first dimension" - ) + assert ( + field_data.shape[1:] == field.maxshape[1:] + ), "field feature dimensions must be the same, i.e. all but the first dimension" # if it is empty resize it to make an array the size of # the new field_data with the maxshape for the feature @@ -1644,9 +1950,9 @@ def _extend_contiguous_traj_field(self, run_idx, traj_idx, field_path, field_dat else: # make sure the new data has the right dimensions against # the shape it already has - assert field_data.shape[1:] == field.shape[1:], ( - "field feature dimensions must be the same, i.e. all but the first dimension" - ) + assert ( + field_data.shape[1:] == field.shape[1:] + ), "field feature dimensions must be the same, i.e. all but the first dimension" # append to the dataset on the first dimension, keeping the # others the same, these must be feature vectors and therefore @@ -1674,9 +1980,7 @@ def _extend_sparse_traj_field( """ - field = self.h5[ - "{}/{}/{}/{}/{}".format(RUNS, run_idx, TRAJECTORIES, traj_idx, field_path) - ] + field = self.traj_field_entity(run_idx, traj_idx, field_path) field_data = field[DATA] field_sparse_idxs = field[SPARSE_IDXS] @@ -1688,10 +1992,10 @@ def _extend_sparse_traj_field( if all([i == 0 for i in field_data.shape]): # check the feature shape against the maxshape which gives # the feature dimensions for an empty dataset - assert values.shape[1:] == field_data.maxshape[1:], ( - "input value features have shape {}, expected {}".format( - values.shape[1:], field_data.maxshape[1:] - ) + assert ( + values.shape[1:] == field_data.maxshape[1:] + ), "input value features have shape {}, expected {}".format( + values.shape[1:], field_data.maxshape[1:] ) # if it is empty resize it to make an array the size of @@ -1705,25 +2009,29 @@ def _extend_sparse_traj_field( else: # make sure the new data has the right dimensions - assert values.shape[1:] == field_data.shape[1:], ( - "field feature dimensions must be the same, i.e. all but the first dimension" - ) + assert ( + values.shape[1:] == field_data.shape[1:] + ), "field feature dimensions must be the same, i.e. all but the first dimension" # append to the dataset on the first dimension, keeping the # others the same, these must be feature vectors and therefore # must exist - field_data.resize(( - field_data.shape[0] + n_new_frames, - *field_data.shape[1:], - )) + field_data.resize( + ( + field_data.shape[0] + n_new_frames, + *field_data.shape[1:], + ) + ) # add the new data field_data[-n_new_frames:, ...] = values # add the sparse idxs in the same way - field_sparse_idxs.resize(( - field_sparse_idxs.shape[0] + n_new_frames, - *field_sparse_idxs.shape[1:], - )) + field_sparse_idxs.resize( + ( + field_sparse_idxs.shape[0] + n_new_frames, + *field_sparse_idxs.shape[1:], + ) + ) # add the new data field_sparse_idxs[-n_new_frames:, ...] = sparse_idxs @@ -1736,8 +2044,7 @@ def _add_sparse_field_flag(self, field_path): Name of the trajectory field you want to flag as sparse """ - - sparse_fields_ds = self._h5["{}/{}".format(SETTINGS, SPARSE_FIELDS)] + sparse_fields_ds = self.settings_grp[SPARSE_FIELDS] # make sure it isn't already in the sparse_fields if field_path in sparse_fields_ds[:]: @@ -1757,7 +2064,7 @@ def _add_field_feature_shape(self, field_path, field_feature_shape): The shape spec to serialize as a dataset. """ - shapes_grp = self._h5["{}/{}".format(SETTINGS, FIELD_FEATURE_SHAPES_STR)] + shapes_grp = self.settings_grp[FIELD_FEATURE_SHAPES_STR] shapes_grp.create_dataset(field_path, data=np.array(field_feature_shape)) def _add_field_feature_dtype(self, field_path, field_feature_dtype): @@ -1772,7 +2079,7 @@ def _add_field_feature_dtype(self, field_path, field_feature_dtype): """ feature_dtype_str = json.dumps(field_feature_dtype.descr) - dtypes_grp = self._h5["{}/{}".format(SETTINGS, FIELD_FEATURE_DTYPES_STR)] + dtypes_grp = self.settings_grp[FIELD_FEATURE_DTYPES_STR] dtypes_grp.create_dataset(field_path, data=feature_dtype_str) def _set_field_feature_shape(self, field_path, field_feature_shape): @@ -1791,6 +2098,7 @@ def _set_field_feature_shape(self, field_path, field_feature_shape): # check that the shape was previously saved as "None" as we # won't overwrite anything else if self.field_feature_shapes[field_path] is None: + full_path = "{}/{}/{}".format( SETTINGS, FIELD_FEATURE_SHAPES_STR, field_path ) @@ -1839,7 +2147,6 @@ def _set_field_feature_dtype(self, field_path, field_feature_dtype): field_path, field_feature_dtype, self.field_feature_dtypes[field_path], - NONE_STR, ) ) # it was not previously set so we must create then save it @@ -1847,8 +2154,12 @@ def _set_field_feature_dtype(self, field_path, field_feature_dtype): self._add_field_feature_dtype(field_path, field_feature_dtype) def _extend_run_record_data_field( - self, run_idx, run_record_key, field_name, field_data - ): + self, + run_idx: int, + run_record_key: str, + field_name: str, + field_data: NDArray, + ) -> None: """Primitive record append method. Adds data for a single field dataset in a run records group. This @@ -1867,21 +2178,16 @@ def _extend_run_record_data_field( """ - records_grp = self.h5["{}/{}/{}".format(RUNS, run_idx, run_record_key)] + records_grp = self.records_grp(run_idx, run_record_key) field = records_grp[field_name] - # make sure this is a feature vector - assert len(field_data.shape) > 1, ( - "field_data must be a feature vector with the same number of dimensions as the number" - ) - # of datase new frames n_new_frames = field_data.shape[0] # check whether it is a variable length record, by getting the # record dataset dtype and using the checker to see if it is # the vlen special type in h5py - if h5py.check_dtype(vlen=field.dtype) is not None: + if h5py.check_vlen_dtype(field.dtype) is not None: # if it is we have to treat it differently, since it # cannot be multidimensional @@ -1914,9 +2220,9 @@ def _extend_run_record_data_field( if all([i == 0 for i in field.shape]): # check the feature shape against the maxshape which gives # the feature dimensions for an empty dataset - assert field_data.shape[1:] == field.maxshape[1:], ( - "field feature dimensions must be the same, i.e. all but the first dimension" - ) + assert ( + field_data.shape[1:] == field.maxshape[1:] + ), "field feature dimensions must be the same, i.e. all but the first dimension" # if it is empty resize it to make an array the size of # the new field_data with the maxshape for the feature @@ -1934,9 +2240,9 @@ def _extend_run_record_data_field( # must exist field.resize((field.shape[0] + n_new_frames, *field.shape[1:])) # add the new data - field[-n_new_frames:, ...] = field_data + field[-n_new_frames:, ...] = np.array([field_data]) - def _run_record_namedtuple(self, run_record_key): + def _run_record_namedtuple(self, run_record_key: str): """Generate a namedtuple record type for a record group. The class name will be formatted like '{}_Record' where the {} @@ -1961,9 +2267,13 @@ def _run_record_namedtuple(self, run_record_key): return Record + # TODO: get the tablified value types recorded somewhere def _convert_record_field_to_table_column( - self, run_idx, run_record_key, record_field - ): + self, + run_idx: int, + run_record_key: RunRecordKey, + record_field: str, + ) -> list[RecordValueDtype]: """Converts a dataset of feature vectors to more palatable values for use in external datasets. @@ -1984,13 +2294,11 @@ def _convert_record_field_to_table_column( Returns ------- - record_dset : list Table-ified values Raises ------ - TypeError If the field feature vector shape rank is greater than 1. @@ -2027,7 +2335,9 @@ def _convert_record_field_to_table_column( return rec_dset - def _convert_record_fields_to_table_columns(self, run_idx, run_record_key): + def _convert_record_fields_to_table_columns( + self, run_idx: int, run_record_key: RunRecordKey + ) -> RunRecordColumns: """Convert record group data to truncated namedtuple records. This uses the specified record fields from the header settings @@ -2057,41 +2367,77 @@ def _convert_record_fields_to_table_columns(self, run_idx, run_record_key): return fields - def _make_records(self, run_record_key, cycle_idxs, fields): - """Generate a list of proper (nametuple) records for a record group. + # def _make_records( + # self, + # run_record_key: RunRecordKey, + # cycle_idxs: list[int], + # table_fields: dict[str, RecordValueDtype], + # ) -> list[Record]: + # """Generate a list of record dicts for a record group. - Parameters - ---------- - run_record_key : str - Name of the record group - cycle_idxs : list of int - The cycle indices you want to get records for. - fields : list of str - The fields to make record entries for. + # Parameters + # ---------- + # run_record_key : str + # Name of the record group + # cycle_idxs : list of int + # The cycle indices you want to get records for. + # fields - Returns - ------- - records : list of namedtuple objects + # Returns + # ------- + # records : list of Records - """ - Record = self._run_record_namedtuple(run_record_key) + # """ + # Record = self._run_record_namedtuple(run_record_key) + + # # for each record we make a tuple and yield it + # records = [] + # for record_idx in range(len(cycle_idxs)): + # # make a record for this cycle + # record_d = {CYCLE_IDX: cycle_idxs[record_idx]} + # for record_field, column in table_fields.items(): + # datum = column[record_idx] + # record_d[record_field] = datum + + # record = Record(*(record_d[key] for key in Record._fields)) + + # records.append(record) + + # return records + + def _table_to_run_records( + self, + run_record_key: RunRecordKey, + table_fields: RunRecordColumns, + ) -> list[RunRecord]: + + it_fields = set(table_fields.keys()) + it_fields.remove("cycle_idx") + + field_its = { + field_name: iter(table_fields[field_name]) for field_name in it_fields + } - # for each record we make a tuple and yield it records = [] - for record_idx in range(len(cycle_idxs)): - # make a record for this cycle - record_d = {CYCLE_IDX: cycle_idxs[record_idx]} - for record_field, column in fields.items(): - datum = column[record_idx] - record_d[record_field] = datum + for cycle_idx in table_fields["cycle_idx"]: + + # get the next value from each iterator + record_d = { + field_name: next(field_its[field_name]) for field_name in it_fields + } - record = Record(*(record_d[key] for key in Record._fields)) + record = RunRecord( + cycle_idx=cycle_idx, + record=record_d, + ) records.append(record) return records - def _run_records_sporadic(self, run_idxs, run_record_key): + def _run_records_sporadic( + self, run_idxs: list[int], run_record_key: RunRecordKey + ) -> Record: """Generate records for a sporadic record group for a multi-run contig. @@ -2121,8 +2467,7 @@ def _run_records_sporadic(self, run_idxs, run_record_key): # we loop over the run_idxs in the contig and get the fields # and cycle idxs for the whole contig - fields = None - cycle_idxs = np.array([], dtype=int) + table_fields = defaultdict(list) # keep a cumulative total of the runs cycle idxs prev_run_cycle_total = 0 for run_idx in run_idxs: @@ -2132,20 +2477,6 @@ def _run_records_sporadic(self, run_idxs, run_record_key): run_idx, run_record_key ) - # we need to concatenate each field to the end of the - # field in the master dictionary, first we need to - # initialize it if it isn't already made - if fields is None: - # if it isn't initialized we just set it as this first - # run fields dictionary - fields = run_fields - else: - # if it is already initialized we need to go through - # each field and concatenate - for field_name, field_data in run_fields.items(): - # just add it to the list of fields that will be concatenated later - fields[field_name].extend(field_data) - # get the cycle idxs for this run rec_grp = self.records_grp(run_idx, run_record_key) run_cycle_idxs = rec_grp[CYCLE_IDXS][:] @@ -2155,19 +2486,24 @@ def _run_records_sporadic(self, run_idxs, run_record_key): # of the full contig run_contig_cycle_idxs = run_cycle_idxs + prev_run_cycle_total - # add these cycle indices to the records for the whole contig - cycle_idxs = np.hstack((cycle_idxs, run_contig_cycle_idxs)) + for field_name, field_data in run_fields.items(): + # just add it to the list of fields that will be concatenated later + table_fields[field_name].extend(field_data) + + table_fields["cycle_idx"].extend(list(run_contig_cycle_idxs)) # add the total number of cycle_idxs from this run to the # running total prev_run_cycle_total += self.num_run_cycles(run_idx) - # then make the records from the fields - records = self._make_records(run_record_key, cycle_idxs, fields) + # then make the records from the tablified fields + records = self._table_to_run_records(run_record_key, table_fields) return records - def _run_records_continual(self, run_idxs, run_record_key): + def _run_records_continual( + self, run_idxs: list[int], run_record_key: RunRecordKey + ) -> list[Record]: """Generate records for a continual record group for a multi-run contig. @@ -2195,8 +2531,9 @@ def _run_records_continual(self, run_idxs, run_record_key): """ - cycle_idxs = np.array([], dtype=int) - fields = None + # TODO: revert changes here + + fields = defaultdict(list) prev_run_cycle_total = 0 for run_idx in run_idxs: # get all the value columns from the datasets, and convert @@ -2204,47 +2541,52 @@ def _run_records_continual(self, run_idxs, run_record_key): run_fields = self._convert_record_fields_to_table_columns( run_idx, run_record_key ) + # if it is already initialized we need to go through + # each field and concatenate + for field_name, field_data in run_fields.items(): + # just add it to the list of fields that will be concatenated later + fields[field_name].extend(field_data) - # we need to concatenate each field to the end of the - # field in the master dictionary, first we need to - # initialize it if it isn't already made - if fields is None: - # if it isn't initialized we just set it as this first - # run fields dictionary - fields = run_fields - else: - # if it is already initialized we need to go through - # each field and concatenate - for field_name, field_data in run_fields.items(): - # just add it to the list of fields that will be concatenated later - fields[field_name].extend(field_data) - - # get one of the fields (if any to iterate over) - record_fields = self.record_fields[run_record_key] - main_record_field = record_fields[0] + # use one of the fields as the lead to find how many + # cycles there are since they are not tracked explicitly + # in the data file + lead_record_field = self.record_fields[run_record_key][0] # make the cycle idxs from that - run_rec_grp = self.records_grp(run_idx, run_record_key) - run_cycle_idxs = np.array(range(run_rec_grp[main_record_field].shape[0])) - - # add the total number of cycles that came before this run - # to each of the cycle idxs to get the cycle_idxs in terms - # of the full contig - run_contig_cycle_idxs = run_cycle_idxs + prev_run_cycle_total + run_cycle_idxs = list( + range( + self.records_grp( + run_idx, + run_record_key, + )[ + lead_record_field + ].shape[0] + ) + ) - # add these cycle indices to the records for the whole contig - cycle_idxs = np.hstack((cycle_idxs, run_contig_cycle_idxs)) + reindexed_cycle_idxs = [ + idx + prev_run_cycle_total for idx in run_cycle_idxs + ] # add the total number of cycle_idxs from this run to the # running total prev_run_cycle_total += self.num_run_cycles(run_idx) + # then update the cycle_idxs with the reindexed ones + fields["cycle_idx"] = reindexed_cycle_idxs + # then make the records from the fields - records = self._make_records(run_record_key, cycle_idxs, fields) + records = self._table_to_run_records(run_record_key, fields) return records - def _get_contiguous_traj_field(self, run_idx, traj_idx, field_path, frames=None): + def _get_contiguous_traj_field( + self, + run_idx: int, + traj_idx: int, + field_path: str, + frames: list[int] | None = None, + ) -> FieldsData: """Access actual data for a trajectory field. Parameters @@ -2263,20 +2605,23 @@ def _get_contiguous_traj_field(self, run_idx, traj_idx, field_path, frames=None) """ - full_path = "{}/{}/{}/{}/{}".format( - RUNS, run_idx, TRAJECTORIES, traj_idx, field_path - ) + field_thing = self.traj_field_entity(run_idx, traj_idx, field_path) if frames is None: - field = self._h5[full_path][:] + field = field_thing[:] else: - field = self._h5[full_path][list(frames)] + field = field_thing[list(frames)] return field def _get_sparse_traj_field( - self, run_idx, traj_idx, field_path, frames=None, masked=True - ): + self, + run_idx: int, + traj_idx: int, + field_path: str, + frames: list[int] | None = None, + masked: bool = True, + ) -> FieldsData: """Access actual data for a trajectory field. Parameters @@ -2300,8 +2645,8 @@ def _get_sparse_traj_field( """ - traj_path = "{}/{}/{}/{}".format(RUNS, run_idx, TRAJECTORIES, traj_idx) - traj_grp = self.h5[traj_path] + traj_grp = self.traj(run_idx, traj_idx) + field = traj_grp[field_path] n_frames = traj_grp[POSITIONS].shape[0] @@ -2350,7 +2695,14 @@ def _get_sparse_traj_field( return data - def _add_run_field(self, run_idx, field_path, data, sparse_idxs=None, force=False): + def _add_run_field( + self, + run_idx: int, + field_path: str, + data: NDArray, + sparse_idxs: list[int] | None = None, + force: bool = False, + ) -> None: """Add a trajectory field to all trajectories in a run. By enforcing adding it to all trajectories at one time we @@ -2431,138 +2783,74 @@ def _add_run_field(self, run_idx, field_path, data, sparse_idxs=None, force=Fals "The number of frames provided for traj {}, {}," "was less than the total number of frames, {}," "but sparse_idxs were not supplied.".format( - traj_idx, - traj_data.shape[0], - self.num_run_cycles(run_idx), - ) - ) - - # add it to each traj - for i, idx_tup in enumerate(self.run_traj_idx_tuples([run_idx])): - if sparse_idxs is None: - self._add_traj_field_data(*idx_tup, field_path, data[i]) - else: - self._add_traj_field_data( - *idx_tup, field_path, data[i], sparse_idxs=sparse_idxs[i] - ) - - def _add_field(self, field_path, data, sparse_idxs=None, force=False): - """Add a trajectory field to all runs in a file. - - Parameters - ---------- - field_path : str - Name of trajectory field - data : list of arraylike - Each element of this list corresponds to a single run. The - elements of which are arraylikes of shape (n_trajectories, - n_cycles, feature_vector_shape[0],...) for each run. - sparse_idxs : list of list of int - The list of cycle indices to set for the sparse fields. If - None, no trajectories are set as sparse. - - - """ - - for i, run_idx in enumerate(self.run_idxs): - if sparse_idxs is not None: - self._add_run_field( - run_idx, - field_path, - data[i], - sparse_idxs=sparse_idxs[i], - force=force, - ) - else: - self._add_run_field(run_idx, field_path, data[i], force=force) - - #### Public Methods - - ### File Utilities - - @property - def filename(self): - """The path to the underlying HDF5 file.""" - return self._filename - - def open(self, mode=None): - """Open the underlying HDF5 file for access. - - Parameters - ---------- - - mode : str - Valid mode spec. Opens the HDF5 file in this mode if given - otherwise uses the existing mode. - - """ - - if mode is None: - mode = self.mode - - if self.closed: - self.set_mode(mode) - - self._h5 = h5py.File( - self._filename, mode, libver=H5PY_LIBVER, swmr=self.swmr_mode - ) - self.closed = False - else: - raise IOError("This file is already open") - - def close(self): - """Close the underlying HDF5 file.""" - if not self.closed: - self._h5.flush() - self._h5.close() - self.closed = True - - @property - def mode(self): - """The WepyHDF5 mode this object was created with.""" - return self._wepy_mode - - @mode.setter - def mode(self, mode): - """Set the mode for opening the file with.""" - self.set_mode(mode) - - def set_mode(self, mode): - """Set the mode for opening the file with.""" + traj_idx, + traj_data.shape[0], + self.num_run_cycles(run_idx), + ) + ) - if not self.closed: - raise AttributeError("Cannot set the mode while the file is open.") + # add it to each traj + for i, idx_tup in enumerate(self.run_traj_idx_tuples([run_idx])): + if sparse_idxs is None: + self._add_traj_field_data(*idx_tup, field_path, data[i]) + else: + self._add_traj_field_data( + *idx_tup, field_path, data[i], sparse_idxs=sparse_idxs[i] + ) - self._set_h5_mode(mode) + def _add_field( + self, + field_path: str, + data: list[NDArray], + sparse_idxs: list[int] | None = None, + force: bool = False, + ) -> None: + """Add a trajectory field to all runs in a file. - self._wepy_mode = mode + Parameters + ---------- + field_path : str + Name of trajectory field + data : list of arraylike + Each element of this list corresponds to a single run. The + elements of which are arraylikes of shape (n_trajectories, + n_cycles, feature_vector_shape[0],...) for each run. + sparse_idxs : list of list of int + The list of cycle indices to set for the sparse fields. If + None, no trajectories are set as sparse. - @property - def h5_mode(self): - """The h5py.File mode the HDF5 file currently has.""" - return self._h5.mode - def _set_h5_mode(self, h5_mode): - """Set the mode to open the HDF5 file with. + """ - This really shouldn't be set without using the main wepy mode - as they need to be aligned. + for i, run_idx in enumerate(self.run_idxs): + if sparse_idxs is not None: + self._add_run_field( + run_idx, + field_path, + data[i], + sparse_idxs=sparse_idxs[i], + force=force, + ) + else: + self._add_run_field(run_idx, field_path, data[i], force=force) - """ + #### Public Methods - if not self.closed: - raise AttributeError("Cannot set the mode while the file is open.") + ### File Utilities - self._h5py_mode = h5_mode + ### h5py object access @property - def h5(self): - """The underlying h5py.File object.""" - return self._h5 + def runs(self) -> h5py.Group: + """The runs group.""" - ### h5py object access + if RUNS not in self.h5: + raise WepyHDF5ReadError(f"The '{RUNS}' is not initialized.") + + else: + return self.h5[RUNS] - def run(self, run_idx): + def run(self, run_idx: int) -> h5py.Group: """Get the h5py.Group for a run. Parameters @@ -2574,88 +2862,86 @@ def run(self, run_idx): run_group : h5py.Group """ - return self._h5["{}/{}".format(RUNS, int(run_idx))] - def traj(self, run_idx, traj_idx): - """Get an h5py.Group trajectory group. + run_id = str(run_idx) + if run_id not in self.runs: + raise WepyHDF5ReadError(f"Run '{run_idx}' is not initialized.") + else: + return self.runs[run_id] + + def run_trajs(self, run_idx: int) -> h5py.Group: + """Get the trajectories group for a run. Parameters ---------- run_idx : int - traj_idx : int Returns ------- - traj_group : h5py.Group + trajectories_grp : h5py.Group """ - return self._h5["{}/{}/{}/{}".format(RUNS, run_idx, TRAJECTORIES, traj_idx)] - def run_trajs(self, run_idx): - """Get the trajectories group for a run. + run_grp = self.run(run_idx) + + if TRAJECTORIES not in run_grp: + raise WepyHDF5ReadError( + f"The '{TRAJECTORIES}' group not initialized for run {run_idx}" + ) + else: + return run_grp[TRAJECTORIES] + + def traj(self, run_idx: int, traj_idx: int) -> h5py.Group: + """Get an h5py.Group trajectory group. Parameters ---------- run_idx : int + traj_idx : int Returns ------- - trajectories_grp : h5py.Group + traj_group : h5py.Group """ - return self._h5["{}/{}/{}".format(RUNS, run_idx, TRAJECTORIES)] - - @property - def runs(self): - """The runs group.""" - return self.h5[RUNS] - - def run_grp(self, run_idx): - """A group for a single run.""" - return self.runs["{}".format(run_idx)] - def run_start_snapshot_hash(self, run_idx): - """Hash identifier for the starting snapshot of a run from - orchestration. - - """ - return self.run_grp(run_idx).attrs[RUN_START_SNAPSHOT_HASH] + trajs_grp = self.run_trajs(run_idx) - def run_end_snapshot_hash(self, run_idx): - """Hash identifier for the ending snapshot of a run from - orchestration. + traj_id = str(traj_idx) - """ - return self.run_grp(run_idx).attrs[RUN_END_SNAPSHOT_HASH] + if traj_id not in trajs_grp: + raise WepyHDF5ReadError( + f"Trajectory {traj_idx} not in trajectories of run {run_idx}" + ) - def set_run_start_snapshot_hash(self, run_idx, snaphash): - """Set the starting snapshot hash identifier for a run from - orchestration. + else: - """ + return trajs_grp[traj_id] - if RUN_START_SNAPSHOT_HASH not in self.run_grp(run_idx).attrs: - self.run_grp(run_idx).attrs[RUN_START_SNAPSHOT_HASH] = snaphash - else: - raise AttributeError("The snapshot has already been set.") + def traj_field_entity( + self, run_idx: int, traj_idx: int, field_path: str + ) -> h5py.Dataset | h5py.Group: - def set_run_end_snapshot_hash(self, run_idx, snaphash): - """Set the ending snapshot hash identifier for a run from - orchestration. + traj_grp = self.traj(run_idx, traj_idx) - """ - if RUN_END_SNAPSHOT_HASH not in self.run_grp(run_idx).attrs: - self.run_grp(run_idx).attrs[RUN_END_SNAPSHOT_HASH] = snaphash + if field_path not in traj_grp: + raise WepyHDF5ReadError( + f"The field path '{field_path}' was not found in run={run_idx}, traj={traj_idx}." + ) else: - raise AttributeError("The snapshot has already been set.") + return traj_grp[field_path] @property - def settings_grp(self): + def settings_grp(self) -> h5py.Group: """The header settings group.""" - settings_grp = self.h5[SETTINGS] - return settings_grp + if SETTINGS not in self.h5: + raise WepyHDF5ReadError( + f"The settings group ({SETTINGS}) has not been initialized" + ) + else: + return self.h5[SETTINGS] - def decision_grp(self, run_idx): + def decision_grp(self, run_idx: int) -> h5py.Group: """Get the decision enumeration group for a run. Parameters @@ -2667,9 +2953,15 @@ def decision_grp(self, run_idx): decision_grp : h5py.Group """ - return self.run(run_idx)[DECISION] - def init_walkers_grp(self, run_idx): + run_grp = self.run(run_idx) + if DECISION not in run_grp: + raise WepyHDF5ReadError(f"Decision group not initialized in run {run_idx}") + else: + + return run_grp[DECISION] + + def init_walkers_grp(self, run_idx: int) -> h5py.Group: """Get the group for the initial walkers for a run. Parameters @@ -2684,7 +2976,7 @@ def init_walkers_grp(self, run_idx): return self.run(run_idx)[INIT_WALKERS] - def records_grp(self, run_idx, run_record_key): + def records_grp(self, run_idx: int, run_record_key: RunRecordKey) -> h5py.Group: """Get a record group h5py.Group for a run. Parameters @@ -2698,10 +2990,23 @@ def records_grp(self, run_idx, run_record_key): run_record_group : h5py.Group """ - path = "{}/{}/{}".format(RUNS, run_idx, run_record_key) - return self.h5[path] - def resampling_grp(self, run_idx): + if run_record_key not in RUN_RECORD_KEYS: + raise KeyError( + f"'{run_record_key}' is not valid. Choose from: {RUN_RECORD_KEYS}" + ) + + run_grp = self.run(run_idx) + + if run_record_key not in run_grp: + raise WepyHDF5ReadError( + f"Run record key '{run_record_key}' not in run {run_idx}" + ) + + else: + return run_grp[run_record_key] + + def resampling_grp(self, run_idx: int) -> h5py.Group: """Get this record group for a run. Parameters @@ -2715,7 +3020,7 @@ def resampling_grp(self, run_idx): """ return self.records_grp(run_idx, RESAMPLING) - def resampler_grp(self, run_idx): + def resampler_grp(self, run_idx: int) -> h5py.Group: """Get this record group for a run. Parameters @@ -2729,7 +3034,7 @@ def resampler_grp(self, run_idx): """ return self.records_grp(run_idx, RESAMPLER) - def warping_grp(self, run_idx): + def warping_grp(self, run_idx: int) -> h5py.Group: """Get this record group for a run. Parameters @@ -2743,7 +3048,7 @@ def warping_grp(self, run_idx): """ return self.records_grp(run_idx, WARPING) - def bc_grp(self, run_idx): + def bc_grp(self, run_idx: int) -> h5py.Group: """Get this record group for a run. Parameters @@ -2757,7 +3062,7 @@ def bc_grp(self, run_idx): """ return self.records_grp(run_idx, BC) - def progress_grp(self, run_idx): + def progress_grp(self, run_idx: int) -> h5py.Group: """Get this record group for a run. Parameters @@ -2771,7 +3076,15 @@ def progress_grp(self, run_idx): """ return self.records_grp(run_idx, PROGRESS) - def iter_runs(self, idxs=False, run_sel=None): + def iter_runs( + self, + idxs: bool = False, + run_sel: list[int] | None = None, + ) -> Generator[ + tuple[int, h5py.Group] | h5py.Group, + None, + None, + ]: """Generator for iterating through the runs of a file. Parameters @@ -2802,7 +3115,15 @@ def iter_runs(self, idxs=False, run_sel=None): else: yield run - def iter_trajs(self, idxs=False, traj_sel=None): + def iter_trajs( + self, + idxs: bool = False, + traj_sel: list[int] | None = None, + ) -> Generator[ + tuple[tuple[int, int], h5py.Group] | h5py.Group, + None, + None, + ]: """Generator for iterating over trajectories in a file. Parameters @@ -2836,7 +3157,15 @@ def iter_trajs(self, idxs=False, traj_sel=None): else: yield traj - def iter_run_trajs(self, run_idx, idxs=False): + def iter_run_trajs( + self, + run_idx: int, + idxs: bool = False, + ) -> Generator[ + tuple[tuple[int, int], h5py.Group], + None, + None, + ]: """Iterate over the trajectories of a run. Parameters @@ -2858,13 +3187,13 @@ def iter_run_trajs(self, run_idx, idxs=False): ### Settings @property - def defined_traj_field_names(self): + def defined_traj_field_names(self) -> list[str]: """A list of the settings defined field names all trajectories have in the file.""" return list(self.field_feature_shapes.keys()) @property - def observable_field_names(self): + def observable_field_names(self) -> list[str]: """Returns a list of the names of the observables that all trajectories have. If this encounters observable fields that don't occur in all @@ -2887,13 +3216,12 @@ def observable_field_names(self): # otherwise return the field names for the observables return list(field_names.keys()) - def _check_traj_field_consistency(self, field_names): + def _check_traj_field_consistency(self, field_names: str) -> bool: """Checks that every trajectory has the given fields across the entire dataset. Parameters ---------- - field_names : list of str The field names to check for. @@ -2920,7 +3248,7 @@ def _check_traj_field_consistency(self, field_names): return True @property - def record_fields(self): + def record_fields(self) -> dict[str, list[str]]: """The record fields for each record group which are selected for inclusion in the truncated records. These are the fields which are considered to be table-ified. @@ -2940,40 +3268,43 @@ def record_fields(self): return record_fields_dict @property - def sparse_fields(self): + def sparse_fields(self) -> NDArray: """The trajectory fields that are sparse.""" - return self.h5["{}/{}".format(SETTINGS, SPARSE_FIELDS)].asstr()[:] + + return self.settings_grp[SPARSE_FIELDS].asstr()[:] @property - def main_rep_idxs(self): + def main_rep_idxs(self) -> NDArray | None: """The indices of the atoms included from the full topology in the default 'positions' trajectory""" - if "{}/{}".format(SETTINGS, MAIN_REP_IDXS) in self.h5: - return self.h5["{}/{}".format(SETTINGS, MAIN_REP_IDXS)][:] + if MAIN_REP_IDXS in self.settings_grp: + return self.settings_grp[MAIN_REP_IDXS][:] else: return None @property - def alt_reps_idxs(self): + def alt_reps_idxs(self) -> dict[str, NDArray]: """Mapping of the names of the alt reps to the indices of the atoms - from the topology that they include in their datasets.""" + from the topology that they include in their datasets. + """ - idxs_grp = self.h5["{}/{}".format(SETTINGS, ALT_REPS_IDXS)] + idxs_grp = self.settings_grp[ALT_REPS_IDXS] return {name: ds[:] for name, ds in idxs_grp.items()} @property - def alt_reps(self): + def alt_reps(self) -> set[str]: """Names of the alt reps.""" - idxs_grp = self.h5["{}/{}".format(SETTINGS, ALT_REPS_IDXS)] + idxs_grp = self.settings_grp[ALT_REPS_IDXS] return {name for name in idxs_grp.keys()} @property - def field_feature_shapes(self): + def field_feature_shapes(self) -> dict[str, NDArray | None]: """Mapping of the names of the trajectory fields to their feature - vector shapes.""" + vector shapes. + """ - shapes_grp = self.h5["{}/{}".format(SETTINGS, FIELD_FEATURE_SHAPES_STR)] + shapes_grp = self.settings_grp[FIELD_FEATURE_SHAPES_STR] field_paths = _iter_field_paths(shapes_grp) @@ -2988,16 +3319,17 @@ def field_feature_shapes(self): return shapes @property - def field_feature_dtypes(self): + def field_feature_dtypes(self) -> dict[str, np.dtype]: """Mapping of the names of the trajectory fields to their feature - vector numpy dtypes.""" + vector numpy dtypes. + """ - dtypes_grp = self.h5["{}/{}".format(SETTINGS, FIELD_FEATURE_DTYPES_STR)] + dtypes_grp = self.settings_grp[FIELD_FEATURE_DTYPES_STR] field_paths = _iter_field_paths(dtypes_grp) _NONE_STR = NONE_STR.encode() - + dtypes = {} for field_path in field_paths: dtype_str = dtypes_grp[field_path][()] @@ -3005,24 +3337,22 @@ def field_feature_dtypes(self): if dtype_str == _NONE_STR: dtypes[field_path] = None else: - dtype_obj = json.loads(dtype_str.decode()) - dtype_obj = [tuple(d) for d in dtype_obj] - dtype = np.dtype(dtype_obj) + dtype = dtype_json_to_numpy(dtype_str.decode()) dtypes[field_path] = dtype return dtypes @property - def continuations(self): + def continuations(self) -> NDArray: """The continuation relationships in this file.""" return self.settings_grp[CONTINUATIONS][:] @property - def metadata(self): + def metadata(self) -> H5Attrs: """File metadata (h5py.attrs).""" return dict(self._h5.attrs) - def decision_enum(self, run_idx): + def decision_enum(self, run_idx: int) -> dict[str, int]: """Mapping of decision enumerated names to their integer representations. Parameters @@ -3047,7 +3377,7 @@ def decision_enum(self, run_idx): return enum - def decision_value_names(self, run_idx): + def decision_value_names(self, run_idx: int) -> dict[str, int]: """Mapping of the integer values for decisions to the decision ID strings. Parameters @@ -3074,7 +3404,7 @@ def decision_value_names(self, run_idx): ### Topology - def get_topology(self, alt_rep=POSITIONS): + def get_topology(self, alt_rep: str = POSITIONS) -> str: """Get the JSON topology for a particular represenation of the positions. By default gives the topology for the main 'positions' field @@ -3121,7 +3451,7 @@ def get_topology(self, alt_rep=POSITIONS): return top @property - def topology(self): + def topology(self) -> str: """The topology for the full simulated system. May not be the main representation in the POSITIONS field; for @@ -3135,7 +3465,7 @@ def topology(self): """ return self._h5[TOPOLOGY][()] - def get_mdtraj_topology(self, alt_rep=POSITIONS): + def get_mdtraj_topology(self, alt_rep: str = POSITIONS) -> mdtraj.Topology: """Get an mdtraj.Topology object for a system representation. By default gives the topology for the main 'positions' field @@ -3162,7 +3492,9 @@ def get_mdtraj_topology(self, alt_rep=POSITIONS): ## Initial walkers - def initial_walker_fields(self, run_idx, fields, walker_idxs=None): + def initial_walker_fields( + self, run_idx: int, fields: list[str], walker_idxs: list[int] | None = None + ) -> dict[str, NDArray]: """Get fields from the initial walkers of the simulation. Parameters @@ -3208,7 +3540,12 @@ def initial_walker_fields(self, run_idx, fields, walker_idxs=None): return init_walker_fields - def initial_walkers_to_mdtraj(self, run_idx, walker_idxs=None, alt_rep=POSITIONS): + def initial_walkers_to_mdtraj( + self, + run_idx: int, + walker_idxs: list[int] | None = None, + alt_rep: str = POSITIONS, + ) -> mdtraj.Trajectory: """Generate an mdtraj Trajectory from a trace of frames from the runs. Uses the default fields for positions (unless an alternate @@ -3255,26 +3592,26 @@ def initial_walkers_to_mdtraj(self, run_idx, walker_idxs=None, alt_rep=POSITIONS ### Counts and Indexing @property - def num_atoms(self): + def num_atoms(self) -> int: """The number of atoms in the full topology representation.""" - return self.h5["{}/{}".format(SETTINGS, N_ATOMS)][()] + return self.settings_grp[N_ATOMS][()] @property - def num_dims(self): + def num_dims(self) -> int: """The number of spatial dimensions in the positions and alt_reps trajectory fields.""" - return self.h5["{}/{}".format(SETTINGS, N_DIMS_STR)][()] + return self.settings_grp[N_DIMS_STR][()] @property - def num_runs(self): + def num_runs(self) -> int: """The number of runs in the file.""" - return len(self._h5[RUNS]) + return len(self.runs) @property - def num_trajs(self): + def num_trajs(self) -> int: """The total number of trajectories in the entire file.""" return len(list(self.run_traj_idx_tuples())) - def num_init_walkers(self, run_idx): + def num_init_walkers(self, run_idx: int) -> int: """The number of initial walkers for a run. Parameters @@ -3289,7 +3626,7 @@ def num_init_walkers(self, run_idx): return len(self.init_walkers_grp(run_idx)) - def num_walkers(self, run_idx, cycle_idx): + def num_walkers(self, run_idx: int, cycle_idx: int) -> int: """Get the number of walkers at a given cycle in a run. Parameters @@ -3314,7 +3651,7 @@ def num_walkers(self, run_idx, cycle_idx): # trajectory data so just return the number of trajectories return self.num_run_trajs(run_idx) - def num_run_trajs(self, run_idx): + def num_run_trajs(self, run_idx: int) -> int: """The number of trajectories in a run. Parameters @@ -3326,9 +3663,11 @@ def num_run_trajs(self, run_idx): n_trajs : int """ - return len(self._h5["{}/{}/{}".format(RUNS, run_idx, TRAJECTORIES)]) - def num_run_cycles(self, run_idx): + trajs_grp = self.run_trajs(run_idx) + return len(trajs_grp) + + def num_run_cycles(self, run_idx: int) -> int: """The number of cycles in a run. Parameters @@ -3342,7 +3681,7 @@ def num_run_cycles(self, run_idx): """ return self.num_traj_frames(run_idx, 0) - def num_traj_frames(self, run_idx, traj_idx): + def num_traj_frames(self, run_idx: int, traj_idx: int) -> int: """The number of frames in a given trajectory. Parameters @@ -3358,11 +3697,11 @@ def num_traj_frames(self, run_idx, traj_idx): return self.traj(run_idx, traj_idx)[POSITIONS].shape[0] @property - def run_idxs(self): + def run_idxs(self) -> list[int]: """The indices of the runs in the file.""" return list(range(len(self._h5[RUNS]))) - def run_traj_idxs(self, run_idx): + def run_traj_idxs(self, run_idx: int) -> list[int]: """The indices of trajectories in a run. Parameters @@ -3374,11 +3713,11 @@ def run_traj_idxs(self, run_idx): traj_idxs : list of int """ - return list( - range(len(self._h5["{}/{}/{}".format(RUNS, run_idx, TRAJECTORIES)])) - ) + return list(range(len(self.run_trajs(run_idx)))) - def run_traj_idx_tuples(self, runs=None): + def run_traj_idx_tuples( + self, runs: list[int] | None = None + ) -> list[tuple[int, int]]: """Get identifier tuples (run_idx, traj_idx) for all trajectories in all runs. @@ -3405,7 +3744,9 @@ def run_traj_idx_tuples(self, runs=None): return tups - def get_traj_field_cycle_idxs(self, run_idx, traj_idx, field_path): + def get_traj_field_cycle_idxs( + self, run_idx: int, traj_idx: int, field_path: str + ) -> NDArray[np.integer]: """Returns the cycle indices for a sparse trajectory field. Parameters @@ -3421,21 +3762,18 @@ def get_traj_field_cycle_idxs(self, run_idx, traj_idx, field_path): """ - traj_path = "{}/{}/{}/{}".format(RUNS, run_idx, TRAJECTORIES, traj_idx) - - if not field_path in self._h5[traj_path]: - raise KeyError("key for field {} not found".format(field_path)) + field = self.traj_field_entity(run_idx, traj_idx, field_path) # if the field is not sparse just return the cycle indices for # that run if field_path not in self.sparse_fields: cycle_idxs = np.array(range(self.num_run_cycles(run_idx))) else: - cycle_idxs = self._h5[traj_path][field_path][SPARSE_IDXS][:] + cycle_idxs = field[SPARSE_IDXS][:] return cycle_idxs - def next_run_idx(self): + def next_run_idx(self) -> int: """The index of the next run if it were to be added. Because runs are named as the integer value of the order they @@ -3449,7 +3787,7 @@ def next_run_idx(self): """ return self.num_runs - def next_run_traj_idx(self, run_idx): + def next_run_traj_idx(self, run_idx: int) -> int: """The index of the next trajectory for this run. Parameters @@ -3465,7 +3803,7 @@ def next_run_traj_idx(self, run_idx): ### Aggregation - def is_run_contig(self, run_idxs): + def is_run_contig(self, run_idxs: list[int]) -> bool: """This method checks that if a given list of run indices is a valid contig or not. @@ -3837,7 +4175,9 @@ def add_metadata(self, key, value): """ self._h5.attrs[key] = value - def init_record_fields(self, run_record_key, record_fields): + def init_record_fields( + self, run_record_key: RunRecordKey, record_fields: list[str] + ) -> None: """Initialize the settings record fields for a record group in the settings group. @@ -3927,7 +4267,7 @@ def init_progress_record_fields(self, bc): """ self.init_record_fields(PROGRESS, bc.progress_record_field_names()) - def add_continuation(self, continuation_run, base_run): + def add_continuation(self, continuation_run: int, base_run: int) -> None: """Add a continuation between runs. Parameters @@ -3940,16 +4280,25 @@ def add_continuation(self, continuation_run, base_run): """ continuations_dset = self.settings_grp[CONTINUATIONS] - continuations_dset.resize(( - continuations_dset.shape[0] + 1, - continuations_dset.shape[1], - )) - continuations_dset[continuations_dset.shape[0] - 1] = np.array([ - continuation_run, - base_run, - ]) - - def new_run(self, init_walkers, continue_run=None, **kwargs): + continuations_dset.resize( + ( + continuations_dset.shape[0] + 1, + continuations_dset.shape[1], + ) + ) + continuations_dset[continuations_dset.shape[0] - 1] = np.array( + [ + continuation_run, + base_run, + ] + ) + + def new_run( + self, + init_walkers: Walker[WalkerStateBox], + continue_run: int | None = None, + **kwargs: H5Attrs, + ) -> h5py.Group: """Initialize a new run. Parameters @@ -3973,11 +4322,12 @@ def new_run(self, init_walkers, continue_run=None, **kwargs): if continue_run is not None: if continue_run not in self.run_idxs: raise ValueError( - "The continue_run idx given, {}, is not present in this file".format( - continue_run - ) + f"The continue_run idx given, {continue_run}, is not present in this file." ) + if len(init_walkers) == 0: + raise ValueError("No init_walkers provided.") + # get the index for this run new_run_idx = self.next_run_idx() @@ -4000,8 +4350,8 @@ def new_run(self, init_walkers, continue_run=None, **kwargs): if key != RUN_IDX: run_grp.attrs[key] = val else: - warn( - "run_idx metadata is set by wepy and cannot be used", RuntimeWarning + raise ValueError( + f"'{RUN_IDX}' given as metadata but is reserved and cannot be used" ) return run_grp @@ -4157,7 +4507,11 @@ def init_run_bc(self, run_idx, bc): # application level methods for initializing the run records # groups with just the fields and without the objects - def init_run_fields_resampling(self, run_idx, fields): + def init_run_fields_resampling( + self, + run_idx: int, + fields: list[RecordFieldSpec], + ) -> h5py.Group: """Initialize this record group fields datasets. Parameters @@ -4176,7 +4530,9 @@ def init_run_fields_resampling(self, run_idx, fields): return grp - def init_run_fields_resampling_decision(self, run_idx, decision_enum_dict): + def init_run_fields_resampling_decision( + self, run_idx: int, decision_enum_dict: dict[str, str] + ) -> None: """Initialize the decision group for this run. Parameters @@ -4191,7 +4547,11 @@ def init_run_fields_resampling_decision(self, run_idx, decision_enum_dict): for name, value in decision_enum_dict.items(): decision_grp.create_dataset(name, data=value) - def init_run_fields_resampler(self, run_idx, fields): + def init_run_fields_resampler( + self, + run_idx: int, + fields: list[str], + ) -> h5py.Group: """Initialize this record group fields datasets. Parameters @@ -4267,7 +4627,12 @@ def init_run_fields_bc(self, run_idx, fields): return grp - def init_run_record_grp(self, run_idx, run_record_key, fields): + def init_run_record_grp( + self, + run_idx: int, + run_record_key: str, + fields: list[RecordFieldSpec], + ) -> h5py.Group: """Initialize a record group for a run. Parameters @@ -4286,6 +4651,8 @@ def init_run_record_grp(self, run_idx, run_record_key, fields): else: grp = self._init_run_continual_record_grp(run_idx, run_record_key, fields) + return grp + # TODO: should've been removed already just double checking things are good without it # def traj_n_frames(self, run_idx, traj_idx): # """ @@ -4302,7 +4669,14 @@ def init_run_record_grp(self, run_idx, run_record_key, fields): # """ # return self.traj(run_idx, traj_idx)[POSITIONS].shape[0] - def add_traj(self, run_idx, data, weights=None, sparse_idxs=None, metadata=None): + def add_traj( + self, + run_idx: int, + data: FieldsData, + weights: WeightsTrajArray | None = None, + sparse_idxs: SparseIdxs | None = None, + metadata: H5Attrs | None = None, + ) -> h5py.Group: """Add a full trajectory to a run. Parameters @@ -4313,8 +4687,8 @@ def add_traj(self, run_idx, data, weights=None, sparse_idxs=None, metadata=None) weights : 1-D arraylike of float The weights of each frame. If None defaults all frames to 1.0. - sparse_idxs : list of int - Cycle indices the data corresponds to. + sparse_idxs : Cycle indices the data corresponds to for each + field. metadata : dict of str : value Metadata for the trajectory. @@ -4335,28 +4709,43 @@ def add_traj(self, run_idx, data, weights=None, sparse_idxs=None, metadata=None) if metadata is None: metadata = {} + if ( + len( + _wrong_data_fields := [ + reserved_field + for reserved_field in RESERVED_TRAJ_FIELDS + if reserved_field in data + ] + ) + > 0 + ): + raise ValueError( + f"{_wrong_data_fields} are reserved field names and cannot be given in data" + ) + # positions are mandatory - assert POSITIONS in traj_data, "positions must be given to create a trajectory" - assert isinstance(traj_data[POSITIONS], np.ndarray) + if POSITIONS not in traj_data: + raise ValueError(f"{POSITIONS} field must be given to create a trajectory") + + if not isinstance(traj_data[POSITIONS], np.ndarray): + raise TypeError(f"{POSITIONS} field must be a numpy array") n_frames = traj_data[POSITIONS].shape[0] # if weights are None then we assume they are 1.0 if weights is None: weights = np.ones((n_frames, 1), dtype=float) - else: - assert isinstance(weights, np.ndarray), "weights must be a numpy.ndarray" - assert weights.shape[0] == n_frames, ( - "weights and the number of frames must be the same length" - ) + elif not isinstance(weights, np.ndarray): + raise TypeError("weights must be a numpy.ndarray") + elif weights.shape[0] != n_frames: + raise ValueError("weights and the number of frames must be the same length") # current traj_idx traj_idx = self.next_run_traj_idx(run_idx) # make a group for this trajectory, with the current traj_idx # for this run - traj_grp = self._h5.create_group( - "{}/{}/{}/{}".format(RUNS, run_idx, TRAJECTORIES, traj_idx) - ) + trajs_grp = self.run_trajs(run_idx) + traj_grp = trajs_grp.create_group(str(traj_idx)) # add the run_idx as metadata traj_grp.attrs[RUN_IDX] = run_idx @@ -4365,24 +4754,23 @@ def add_traj(self, run_idx, data, weights=None, sparse_idxs=None, metadata=None) # add the rest of the metadata if given for key, val in metadata.items(): - if not key in [RUN_IDX, TRAJ_IDX]: + if key not in [RUN_IDX, TRAJ_IDX]: traj_grp.attrs[key] = val else: - warn( - "run_idx and traj_idx are used by wepy and cannot be set", - RuntimeWarning, + raise ValueError( + f"'{RUN_IDX}' and '{TRAJ_IDX}' metadata keys are reserved and cannot be set" ) # check to make sure the positions are the right shape - assert traj_data[POSITIONS].shape[1] == self.num_atoms, ( - "positions given have different number of atoms: {}, should be {}".format( - traj_data[POSITIONS].shape[1], self.num_atoms - ) + assert ( + traj_data[POSITIONS].shape[1] == self.num_atoms + ), "positions given have different number of atoms: {}, should be {}".format( + traj_data[POSITIONS].shape[1], self.num_atoms ) - assert traj_data[POSITIONS].shape[2] == self.num_dims, ( - "positions given have different number of dims: {}, should be {}".format( - traj_data[POSITIONS].shape[2], self.num_dims - ) + assert ( + traj_data[POSITIONS].shape[2] == self.num_dims + ), "positions given have different number of dims: {}, should be {}".format( + traj_data[POSITIONS].shape[2], self.num_dims ) # add datasets to the traj group @@ -4437,7 +4825,13 @@ def add_traj(self, run_idx, data, weights=None, sparse_idxs=None, metadata=None) return traj_grp - def extend_traj(self, run_idx, traj_idx, data, weights=None): + def extend_traj( + self, + run_idx: int, + traj_idx: int, + data: FieldsData, + weights: WeightsTrajArray | None = None, + ) -> None: """Extend a trajectory with data for all fields. Parameters @@ -4453,9 +4847,9 @@ def extend_traj(self, run_idx, traj_idx, data, weights=None): """ if self._wepy_mode == "c-": - assert self._append_flags[dataset_key], ( - "dataset is not available for appending to" - ) + assert self._append_flags[ + dataset_key + ], "dataset is not available for appending to" # convenient alias traj_data = data @@ -4470,7 +4864,7 @@ def extend_traj(self, run_idx, traj_idx, data, weights=None): sparse_idxs = np.array(range(n_frames, n_frames + n_new_frames)) # get the trajectory group - traj_grp = self._h5["{}/{}/{}/{}".format(RUNS, run_idx, TRAJECTORIES, traj_idx)] + traj_grp = self.traj(run_idx, traj_idx) ## weights @@ -4479,9 +4873,9 @@ def extend_traj(self, run_idx, traj_idx, data, weights=None): weights = np.ones((n_new_frames, 1), dtype=float) else: assert isinstance(weights, np.ndarray), "weights must be a numpy.ndarray" - assert weights.shape[0] == n_new_frames, ( - "weights and the number of frames must be the same length" - ) + assert ( + weights.shape[0] == n_new_frames + ), "weights and the number of frames must be the same length" # add the weights weights_ds = traj_grp[WEIGHTS] @@ -4489,10 +4883,12 @@ def extend_traj(self, run_idx, traj_idx, data, weights=None): # append to the dataset on the first dimension, keeping the # others the same, if they exist if len(weights_ds.shape) > 1: - weights_ds.resize(( - weights_ds.shape[0] + n_new_frames, - *weights_ds.shape[1:], - )) + weights_ds.resize( + ( + weights_ds.shape[0] + n_new_frames, + *weights_ds.shape[1:], + ) + ) else: weights_ds.resize((weights_ds.shape[0] + n_new_frames,)) @@ -4503,7 +4899,7 @@ def extend_traj(self, run_idx, traj_idx, data, weights=None): for field_path, field_data in traj_data.items(): # if the field hasn't been initialized yet initialize it, # unless we are in SWMR mode - if not field_path in traj_grp: + if field_path not in traj_grp: # if in SWMR mode you cannot create groups so if we # are in SWMR mode raise a warning that the data won't # be recorded @@ -4519,9 +4915,9 @@ def extend_traj(self, run_idx, traj_idx, data, weights=None): # not specified as sparse_field, no settings if ( - (not field_path in self.field_feature_shapes) - and (not field_path in self.field_feature_dtypes) - and not field_path in self.sparse_fields + (field_path not in self.field_feature_shapes) + and (field_path not in self.field_feature_dtypes) + and field_path not in self.sparse_fields ): # only save if it is an observable is_observable = False @@ -4576,7 +4972,9 @@ def extend_traj(self, run_idx, traj_idx, data, weights=None): ## application level append methods for run records groups - def extend_cycle_warping_records(self, run_idx, cycle_idx, warping_data): + def extend_cycle_warping_records( + self, run_idx: int, cycle_idx: int, warping_data: WarpRecordUnstruct + ) -> None: """Add records for each field for this record group. Parameters @@ -4591,7 +4989,9 @@ def extend_cycle_warping_records(self, run_idx, cycle_idx, warping_data): """ self.extend_cycle_run_group_records(run_idx, WARPING, cycle_idx, warping_data) - def extend_cycle_bc_records(self, run_idx, cycle_idx, bc_data): + def extend_cycle_bc_records( + self, run_idx: int, cycle_idx: int, bc_data: Record + ) -> None: """Add records for each field for this record group. Parameters @@ -4607,7 +5007,9 @@ def extend_cycle_bc_records(self, run_idx, cycle_idx, bc_data): self.extend_cycle_run_group_records(run_idx, BC, cycle_idx, bc_data) - def extend_cycle_progress_records(self, run_idx, cycle_idx, progress_data): + def extend_cycle_progress_records( + self, run_idx: int, cycle_idx: int, progress_data: Record + ) -> None: """Add records for each field for this record group. Parameters @@ -4622,7 +5024,12 @@ def extend_cycle_progress_records(self, run_idx, cycle_idx, progress_data): """ self.extend_cycle_run_group_records(run_idx, PROGRESS, cycle_idx, progress_data) - def extend_cycle_resampling_records(self, run_idx, cycle_idx, resampling_data): + def extend_cycle_resampling_records( + self, + run_idx: int, + cycle_idx: int, + resampling_data: list[ResamplingRecordUnstruct], + ) -> None: """Add records for each field for this record group. Parameters @@ -4630,17 +5037,26 @@ def extend_cycle_resampling_records(self, run_idx, cycle_idx, resampling_data): run_idx : int cycle_idx : int The cycle index these records correspond to. - resampling_data : dict of str : arraylike + resampling_data : list[dict of str : arraylike] Mapping of the record group fields to a collection of values for each field. """ + # TODO: we should probably expand the data arrays to the + # feature arrays if that is required so the data types from + # the outside stay cleaner. + self.extend_cycle_run_group_records( - run_idx, RESAMPLING, cycle_idx, resampling_data + run_idx, + RESAMPLING, + cycle_idx, + resampling_data, ) - def extend_cycle_resampler_records(self, run_idx, cycle_idx, resampler_data): + def extend_cycle_resampler_records( + self, run_idx: int, cycle_idx: int, resampler_data: Record + ) -> None: """Add records for each field for this record group. Parameters @@ -4658,8 +5074,12 @@ def extend_cycle_resampler_records(self, run_idx, cycle_idx, resampler_data): ) def extend_cycle_run_group_records( - self, run_idx, run_record_key, cycle_idx, fields_data - ): + self, + run_idx: int, + run_record_key: str, + cycle_idx: int, + fields_data: list[Record], + ) -> None: """Extend data for a whole records group. This must have the cycle index for the data it is appending as @@ -4697,39 +5117,24 @@ def extend_cycle_run_group_records( ) # then add all the data for the field - for record_dict in fields_data: - for field_name, field_data in record_dict.items(): + for record in fields_data: + for field_name, field_data in record.items(): self._extend_run_record_data_field( - run_idx, run_record_key, field_name, np.array([field_data]) + run_idx, + run_record_key, + field_name, + # wrap in an extra dimension to keep them as + # feature vectors + np.array([field_data]), ) ### Analysis Routines ## Record Getters - def run_records(self, run_idx, run_record_key): - """Get the records for a record group for a single run. - - Parameters - ---------- - run_idx : int - run_record_key : str - The name of the record group. - - Returns - ------- - records : list of namedtuple objects - The list of records for the run's record group. - - """ - - # wrap this in a list since the underlying functions accept a - # list of records - run_idxs = [run_idx] - - return self.run_contig_records(run_idxs, run_record_key) - - def run_contig_records(self, run_idxs, run_record_key): + def run_contig_records( + self, run_idxs: list[int], run_record_key: RunRecordKey + ) -> list[Record]: """Get the records for a record group for the contig that is formed by the run indices. @@ -4767,7 +5172,33 @@ def run_contig_records(self, run_idxs, run_record_key): return records - def run_records_dataframe(self, run_idx, run_record_key): + def run_records( + self, run_idx: int, run_record_key: RunRecordKey + ) -> list[RunRecord]: + """Get the records for a record group for a single run. + + Parameters + ---------- + run_idx : int + run_record_key : str + The name of the record group. + + Returns + ------- + records : list of namedtuple objects + The list of records for the run's record group. + + """ + + # wrap this in a list since the underlying functions accept a + # list of records + run_idxs = [run_idx] + + return self.run_contig_records(run_idxs, run_record_key) + + def run_records_dataframe( + self, run_idx: int, run_record_key: RunRecordKey + ) -> pd.DataFrame: """Get the records for a record group for a single run in the form of a pandas DataFrame. @@ -4784,7 +5215,9 @@ def run_records_dataframe(self, run_idx, run_record_key): records = self.run_records(run_idx, run_record_key) return pd.DataFrame(records) - def run_contig_records_dataframe(self, run_idxs, run_record_key): + def run_contig_records_dataframe( + self, run_idxs: list[int], run_record_key: RunRecordKey + ) -> pd.DataFrame: """Get the records for a record group for a contig of runs in the form of a pandas DataFrame. @@ -4807,7 +5240,7 @@ def run_contig_records_dataframe(self, run_idxs, run_record_key): # application level specific methods for each main group # resampling - def resampling_records(self, run_idxs): + def resampling_records(self, run_idxs: list[int]) -> list[RunRecord]: """Get the records this record group for the contig that is formed by the run indices. @@ -4830,7 +5263,7 @@ def resampling_records(self, run_idxs): return self.run_contig_records(run_idxs, RESAMPLING) - def resampling_records_dataframe(self, run_idxs): + def resampling_records_dataframe(self, run_idxs: list[int]) -> pd.DataFrame: """Get the records for this record group for a contig of runs in the form of a pandas DataFrame. @@ -4849,7 +5282,7 @@ def resampling_records_dataframe(self, run_idxs): return pd.DataFrame(self.resampling_records(run_idxs)) # resampler records - def resampler_records(self, run_idxs): + def resampler_records(self, run_idxs: list[int]) -> list[RunRecord]: """Get the records this record group for the contig that is formed by the run indices. @@ -4872,7 +5305,7 @@ def resampler_records(self, run_idxs): return self.run_contig_records(run_idxs, RESAMPLER) - def resampler_records_dataframe(self, run_idxs): + def resampler_records_dataframe(self, run_idxs: list[int]) -> pd.DataFrame: """Get the records for this record group for a contig of runs in the form of a pandas DataFrame. @@ -4891,7 +5324,7 @@ def resampler_records_dataframe(self, run_idxs): return pd.DataFrame(self.resampler_records(run_idxs)) # warping - def warping_records(self, run_idxs): + def warping_records(self, run_idxs: list[int]) -> list[RunRecord]: """Get the records this record group for the contig that is formed by the run indices. @@ -4914,7 +5347,7 @@ def warping_records(self, run_idxs): return self.run_contig_records(run_idxs, WARPING) - def warping_records_dataframe(self, run_idxs): + def warping_records_dataframe(self, run_idxs: list[int]) -> pd.DataFrame: """Get the records for this record group for a contig of runs in the form of a pandas DataFrame. @@ -4933,7 +5366,7 @@ def warping_records_dataframe(self, run_idxs): return pd.DataFrame(self.warping_records(run_idxs)) # boundary conditions - def bc_records(self, run_idxs): + def bc_records(self, run_idxs: list[int]) -> list[RunRecord]: """Get the records this record group for the contig that is formed by the run indices. @@ -4956,7 +5389,7 @@ def bc_records(self, run_idxs): return self.run_contig_records(run_idxs, BC) - def bc_records_dataframe(self, run_idxs): + def bc_records_dataframe(self, run_idxs: list[int]) -> pd.DataFrame: """Get the records for this record group for a contig of runs in the form of a pandas DataFrame. @@ -4975,7 +5408,7 @@ def bc_records_dataframe(self, run_idxs): return pd.DataFrame(self.bc_records(run_idxs)) # progress - def progress_records(self, run_idxs): + def progress_records(self, run_idxs: list[int]) -> list[RunRecord]: """Get the records this record group for the contig that is formed by the run indices. @@ -4998,7 +5431,7 @@ def progress_records(self, run_idxs): return self.run_contig_records(run_idxs, PROGRESS) - def progress_records_dataframe(self, run_idxs): + def progress_records_dataframe(self, run_idxs: list[int]) -> pd.DataFrame: """Get the records for this record group for a contig of runs in the form of a pandas DataFrame. @@ -5016,23 +5449,7 @@ def progress_records_dataframe(self, run_idxs): return pd.DataFrame(self.progress_records(run_idxs)) - def run_resampling_panel(self, run_idx): - """Generate a resampling panel from the resampling records of a run. - - Parameters - ---------- - run_idx : int - - Returns - ------- - resampling_panel : list of list of list of namedtuple records - The panel (list of tables) of resampling records in order - (cycle, step, walker) - - """ - return self.run_contig_resampling_panel([run_idx]) - - def run_contig_resampling_panel(self, run_idxs): + def run_contig_resampling_panel(self, run_idxs: list[int]) -> DecisionPanel: """Generate a resampling panel from the resampling records of a contig, which is a series of runs. @@ -5044,7 +5461,7 @@ def run_contig_resampling_panel(self, run_idxs): Returns ------- - resampling_panel : list of list of list of namedtuple records + resampling_panel : list of list of list of tuples The panel (list of tables) of resampling records in order (cycle, step, walker) @@ -5057,11 +5474,28 @@ def run_contig_resampling_panel(self, run_idxs): # make the resampling panel from the resampling records for the contig contig_resampling_panel = resampling_panel( - self.resampling_records(run_idxs), is_sorted=False + self.resampling_records(run_idxs), + is_sorted=False, ) return contig_resampling_panel + def run_resampling_panel(self, run_idx: int) -> DecisionPanel: + """Generate a resampling panel from the resampling records of a run. + + Parameters + ---------- + run_idx : int + + Returns + ------- + resampling_panel : list of list of list of namedtuple records + The panel (list of tables) of resampling records in order + (cycle, step, walker) + + """ + return self.run_contig_resampling_panel([run_idx]) + # Trajectory Field Setters def add_run_observable(self, run_idx, observable_name, data, sparse_idxs=None): @@ -5214,7 +5648,6 @@ def compute_observable( Returns ------- - traj_id_tuples : list of tuple of int, if 'idxs' option is True A list of the tuple identifiers for each trajectory result. @@ -5285,7 +5718,14 @@ def compute_observable( ## Trajectory Getters - def get_traj_field(self, run_idx, traj_idx, field_path, frames=None, masked=True): + def get_traj_field( + self, + run_idx: int, + traj_idx: int, + field_path: str, + frames: list[int] | None = None, + masked: bool = True, + ) -> FieldsData: """Returns a numpy array for the given trajectory field. You can control how sparse fields are returned using the @@ -5316,12 +5756,7 @@ def get_traj_field(self, run_idx, traj_idx, field_path, frames=None, masked=True """ - traj_path = "{}/{}/{}/{}".format(RUNS, run_idx, TRAJECTORIES, traj_idx) - - # if the field doesn't exist return None - if not field_path in self._h5[traj_path]: - raise KeyError("key for field {} not found".format(field_path)) - # return None + field_thing = self.traj_field_entity(run_idx, traj_idx, field_path) # get the field depending on whether it is sparse or not if field_path in self.sparse_fields: @@ -5335,10 +5770,10 @@ def get_traj_field(self, run_idx, traj_idx, field_path, frames=None, masked=True def get_trace_fields( self, - frame_tups, - fields, - same_order=True, - ): + frame_tups: RunTrace, + fields: list[str], + same_order: bool = True, + ) -> FieldsData: """Get trajectory field data for the frames specified by the trace. Parameters @@ -5443,7 +5878,12 @@ def apply_argsorted(shuffled_seq, sorted_idxs): return frame_fields - def get_run_trace_fields(self, run_idx, frame_tups, fields): + def get_run_trace_fields( + self, + run_idx: int, + frame_tups: ContigWalkerTrace, + fields: str, + ) -> FieldsData: """Get trajectory field data for the frames specified by the trace within a single run. @@ -5481,7 +5921,11 @@ def get_run_trace_fields(self, run_idx, frame_tups, fields): return frame_fields - def get_contig_trace_fields(self, contig_trace, fields): + def get_contig_trace_fields( + self, + contig_trace: ContigTrace, + fields: list[str], + ) -> FieldsData: """Get field data for all trajectories of a contig for the frames specified by the contig trace. @@ -5512,15 +5956,17 @@ def get_contig_trace_fields(self, contig_trace, fields): for run_idx, cycle_idx in contig_trace: runs_frames[run_idx].append(cycle_idx) - if not run_idx in run_idxs: + if run_idx not in run_idxs: run_idxs.append(run_idx) # (there must be the same number of trajectories in each run) n_trajs_test = self.num_run_trajs(run_idxs[0]) - assert all([ - True if n_trajs_test == self.num_run_trajs(run_idx) else False - for run_idx in run_idxs - ]) + assert all( + [ + True if n_trajs_test == self.num_run_trajs(run_idx) else False + for run_idx in run_idxs + ] + ) # then using this we go run by run and get all the # trajectories @@ -5564,7 +6010,16 @@ def get_contig_trace_fields(self, contig_trace, fields): return field_values - def iter_trajs_fields(self, fields, idxs=False, traj_sel=None): + def iter_trajs_fields( + self, + fields: list[str], + idxs: bool = False, + traj_sel: list[tuple[int, int]] | None = None, + ) -> Generator[ + tuple[tuple[int, int], FieldsData] | FieldsData, + None, + None, + ]: """Generator for iterating over fields trajectories in a file. Parameters @@ -5623,7 +6078,13 @@ def iter_trajs_fields(self, fields, idxs=False, traj_sel=None): yield dsets def traj_fields_map( - self, func, fields, args, map_func=map, idxs=False, traj_sel=None + self, + func, + fields: list[str], + args: None | tuple[Any, ...], + map_func=map, + idxs: bool = False, + traj_sel: list[tuple[int, int]] | None = None, ): """Function for mapping work onto field of trajectories. @@ -5696,7 +6157,13 @@ def traj_fields_map( else: return results - def to_mdtraj(self, run_idx, traj_idx, frames=None, alt_rep=None): + def to_mdtraj( + self, + run_idx: int, + traj_idx: int, + frames: list[int] | None = None, + alt_rep: str | None = None, + ) -> mdtraj.Trajectory: """Convert a trajectory to an mdtraj Trajectory object. Works if the right trajectory fields are defined. Minimally @@ -5770,7 +6237,7 @@ def to_mdtraj(self, run_idx, traj_idx, frames=None, alt_rep=None): ) if (box_vectors is not None) and (time is not None): - traj = mdj.Trajectory( + traj = mdtraj.Trajectory( positions, topology, time=time, @@ -5778,20 +6245,22 @@ def to_mdtraj(self, run_idx, traj_idx, frames=None, alt_rep=None): unitcell_angles=unitcell_angles, ) elif box_vectors is not None: - traj = mdj.Trajectory( + traj = mdtraj.Trajectory( positions, topology, unitcell_lengths=unitcell_lengths, unitcell_angles=unitcell_angles, ) elif time is not None: - traj = mdj.Trajectory(positions, topology, time=time) + traj = mdtraj.Trajectory(positions, topology, time=time) else: - traj = mdj.Trajectory(positions, topology) + traj = mdtraj.Trajectory(positions, topology) return traj - def trace_to_mdtraj(self, trace, alt_rep=None): + def trace_to_mdtraj( + self, trace: RunTrace, alt_rep: str | None = None + ) -> mdtraj.Trajectory: """Generate an mdtraj Trajectory from a trace of frames from the runs. Uses the default fields for positions (unless an alternate @@ -5827,7 +6296,12 @@ def trace_to_mdtraj(self, trace, alt_rep=None): return self.traj_fields_to_mdtraj(trace_fields, alt_rep=alt_rep) - def run_trace_to_mdtraj(self, run_idx, trace, alt_rep=None): + def run_trace_to_mdtraj( + self, + run_idx: int, + trace: ContigWalkerTrace, + alt_rep: str | None = None, + ) -> mdtraj.Trajectory: """Generate an mdtraj Trajectory from a trace of frames from the runs. Uses the default fields for positions (unless an alternate @@ -5868,20 +6342,18 @@ def run_trace_to_mdtraj(self, run_idx, trace, alt_rep=None): return self.traj_fields_to_mdtraj(trace_fields, alt_rep=alt_rep) - def _choose_rep_path(self, alt_rep): + def _choose_rep_path(self, alt_rep: str | None) -> str: """Given a positions specification string, gets the field name/path for it. Parameters ---------- - alt_rep : str The short name (non relative path) for a representation of the positions. Returns ------- - rep_path : str The relative field path to that representation. @@ -5914,12 +6386,13 @@ def _choose_rep_path(self, alt_rep): return rep_path - def traj_fields_to_mdtraj(self, traj_fields, alt_rep=POSITIONS): + def traj_fields_to_mdtraj( + self, traj_fields: FieldsData, alt_rep: str = POSITIONS + ) -> mdtraj.Trajectory: """Create an mdtraj.Trajectory from a traj_fields dictionary. Parameters ---------- - traj_fields : dict of str : arraylike Dictionary of the traj fields to their values @@ -5930,7 +6403,6 @@ def traj_fields_to_mdtraj(self, traj_fields, alt_rep=POSITIONS): Returns ------- - traj : mdtraj.Trajectory object This is mainly a convenience function to retrieve the correct @@ -5949,7 +6421,8 @@ def copy_run_slice( self, run_idx, target_file_path, target_grp_path, run_slice=None, mode="x" ): """Copy this run to another HDF5 file (target_file_path) at the group - (target_grp_path)""" + (target_grp_path) + """ assert mode in ["w", "w-", "x", "r+"], "must be opened in write mode" diff --git a/src/wepy/missing.py b/src/wepy/missing.py new file mode 100644 index 00000000..89948a32 --- /dev/null +++ b/src/wepy/missing.py @@ -0,0 +1,10 @@ +class Missing: + # no data allowed + __slots__ = () + + def __repr__(self): + return "" + + +# Single instance +MISSING = Missing() diff --git a/src/wepy/monitor.py b/src/wepy/monitor.py new file mode 100644 index 00000000..95318c2b --- /dev/null +++ b/src/wepy/monitor.py @@ -0,0 +1,13 @@ +"""Interface definition for simulation monitors.""" + +# First Party Library +from wepy.walker import Walker + + +class Monitor: + + def init(self) -> None: ... + + def cycle_monitor(self, walkers: list[Walker]) -> None: ... + + def cleanup(self) -> None: ... diff --git a/src/wepy/orchestration/__init__.py b/src/wepy/orchestration/__init__.py deleted file mode 100644 index 2821c70d..00000000 --- a/src/wepy/orchestration/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -if __name__ == "__main__": - # entry-point to the orchestration CLI - # First Party Library - from wepy.orchestration.cli import cli - - cli() diff --git a/src/wepy/orchestration/cli.py b/src/wepy/orchestration/cli.py deleted file mode 100644 index f4039a27..00000000 --- a/src/wepy/orchestration/cli.py +++ /dev/null @@ -1,1014 +0,0 @@ -# Standard Library -import logging - -logger = logging.getLogger(__name__) -# Standard Library -import os.path as osp -import subprocess -from copy import deepcopy - -# Third Party Library -import click - -# First Party Library -from wepy.hdf5 import WepyHDF5 -from wepy.orchestration.orchestrator import Orchestrator, reconcile_orchestrators -from wepy.reporter.hdf5 import WepyHDF5Reporter -from wepy.util.util import set_loglevel - -ORCHESTRATOR_DEFAULT_FILENAME = Orchestrator.ORCH_FILENAME_TEMPLATE.format( - config=Orchestrator.DEFAULT_CONFIG_NAME, narration=Orchestrator.DEFAULT_NARRATION -) - -START_HASH = "" -CURDIR = "" - - -def settle_run_options( - n_workers=None, - job_dir=None, - job_name=None, - narration=None, - monitor_http_port=None, - tag=None, - configuration=None, - start_hash=None, -): - """ - - Parameters - ---------- - n_workers : - (Default value = None) - job_dir : - (Default value = None) - job_name : - (Default value = None) - narration : - (Default value = None) - - \b - Returns - ------- - - """ - - # the default for the job name is the start hash if none is given - if job_name == START_HASH: - job_name = start_hash - - # if the job_name is given and the default value for the job_dir - # is given (i.e. not specified by the user) we set the job-dir as - # the job_name - if job_name is not None and job_dir == CURDIR: - job_dir = job_name - - # if the special value for curdir is given we get the systems - # current directory, this is the default. - if job_dir == CURDIR: - job_dir = osp.curdir - - # normalize the job_dir - job_dir = osp.realpath(job_dir) - - # if a path for a configuration was given we want to use it so we - # unpickle it and return it, otherwise return None and use the - # default one in the orchestrator - config = None - if configuration is not None: - with open(configuration, "rb") as rf: - config = Orchestrator.deserialize(rf.read()) - - ## Monitoring - - # nothing to do, just use the port or tag if its given - monitor_pkwargs = { - "tag": tag, - "port": monitor_http_port, - } - - # we need to reparametrize the configuration here since the - # orchestrator API will ignore reparametrization values if a - # concrete Configuration is given. - if config is not None: - # if there is a change in the number of workers we need to - # recalculate all of the partial kwargs - - work_mapper_pkwargs = deepcopy(config.work_mapper_partial_kwargs) - - # if the number of workers has changed update all the relevant - # fields, otherwise leave it alone - if work_mapper_pkwargs["num_workers"] != n_workers: - work_mapper_pkwargs["num_workers"] = n_workers - work_mapper_pkwargs["device_ids"] = [str(i) for i in range(n_workers)] - - config = config.reparametrize( - work_dir=job_dir, - config_name=job_name, - narration=narration, - work_mapper_partial_kwargs=work_mapper_pkwargs, - monitor_partial_kwargs=monitor_pkwargs, - ) - - return job_dir, job_name, narration, config - - -@click.option("--log", default="WARNING") -@click.option("--n-workers", type=click.INT) -@click.option("--checkpoint-freq", default=None, type=click.INT) -@click.option("--job-dir", default=CURDIR, type=click.Path(writable=True)) -@click.option("--job-name", default=START_HASH) -@click.option("--narration", default="") -@click.option("--monitor-http-port", default=9001) -@click.option("--tag", default="None") -@click.argument("n_cycle_steps", type=click.INT) -@click.argument("run_time", type=click.FLOAT) -@click.argument("configuration", type=click.Path(exists=True)) -@click.argument("snapshot", type=click.File("rb")) -@click.command() -def run_snapshot( - log, - n_workers, - checkpoint_freq, - job_dir, - job_name, - narration, - monitor_http_port, - tag, - n_cycle_steps, - run_time, - configuration, - snapshot, -): - """ - - \b - Parameters - ---------- - log : - - n_workers : - - checkpoint_freq : - - job_dir : - - job_name : - - narration : - - monitor_http_port : - - n_cycle_steps : - - run_time : - - start_hash : - - orchestrator : - - - \b - Returns - ------- - - """ - - set_loglevel(log) - - logger.info("Loading the starting snapshot file") - # read the config and snapshot in - serial_snapshot = snapshot.read() - - logger.info("Creating orchestrating orch database") - # make the orchestrator for this simulation in memory to start - orch = Orchestrator() - logger.info("Adding the starting snapshot to database") - start_hash = orch.add_serial_snapshot(serial_snapshot) - - # settle what the defaults etc. are for the different options as they are interdependent - job_dir, job_name, narration, config = settle_run_options( - # work mapper - n_workers=n_workers, - # reporters - job_dir=job_dir, - job_name=job_name, - narration=narration, - # monitoring - tag=tag, - monitor_http_port=monitor_http_port, - # other - configuration=configuration, - start_hash=start_hash, - ) - - # add the parametrized configuration to the orchestrator - # config_hash = orch.add_serial_configuration(config) - - logger.info("Orchestrator loaded") - logger.info("Running snapshot by time") - run_orch = orch.orchestrate_snapshot_run_by_time( - start_hash, - run_time, - n_cycle_steps, - checkpoint_freq=checkpoint_freq, - work_dir=job_dir, - config_name=job_name, - narration=narration, - configuration=config, - ) - logger.info("Finished running snapshot by time") - - start_hash, end_hash = run_orch.run_hashes()[0] - - run_orch.close() - logger.info("Closed the resultant orch") - - # write the run tuple out to the log - run_line_str = "Run start and end hashes: {}, {}".format(start_hash, end_hash) - - # log it - logger.info(run_line_str) - - # also put it to the terminal - click.echo(run_line_str) - - orch.close() - logger.info("closed the orchestrating orch database") - - -@click.option("--log", default="WARNING") -@click.option("--n-workers", type=click.INT) -@click.option("--checkpoint-freq", default=None, type=click.INT) -@click.option("--job-dir", default=CURDIR, type=click.Path(writable=True)) -@click.option("--job-name", default=START_HASH) -@click.option("--narration", default="") -@click.option("--configuration", type=click.Path(exists=True), default=None) -@click.argument("n_cycle_steps", type=click.INT) -@click.argument("run_time", type=click.FLOAT) -@click.argument("start_hash") -@click.argument("orchestrator", type=click.Path(exists=True)) -@click.command() -def run_orch( - log, - n_workers, - checkpoint_freq, - job_dir, - job_name, - narration, - configuration, - n_cycle_steps, - run_time, - start_hash, - orchestrator, -): - """ - - \b - Parameters - ---------- - log : - - n_workers : - - checkpoint_freq : - - job_dir : - - job_name : - - narration : - - n_cycle_steps : - - run_time : - - start_hash : - - orchestrator : - - - \b - Returns - ------- - - """ - - set_loglevel(log) - - # settle what the defaults etc. are for the different options as they are interdependent - job_dir, job_name, narration, config = settle_run_options( - n_workers=n_workers, - job_dir=job_dir, - job_name=job_name, - narration=narration, - configuration=configuration, - start_hash=start_hash, - ) - - # Open a wrapper around the orchestrator database that provides - # the inputs for the simulation - orch = Orchestrator(orchestrator, mode="r") - - logger.info("Orchestrator loaded") - - logger.info("Running snapshot by time") - run_orch = orch.orchestrate_snapshot_run_by_time( - start_hash, - run_time, - n_cycle_steps, - checkpoint_freq=checkpoint_freq, - work_dir=job_dir, - config_name=job_name, - narration=narration, - configuration=config, - ) - logger.info("Finished running snapshot by time") - - start_hash, end_hash = run_orch.run_hashes()[0] - - logger.info("Closing the resultant orchestrator") - run_orch.close() - - # write the run tuple out to the log - run_line_str = "Run start and end hashes: {}, {}".format(start_hash, end_hash) - - # log it - logger.info(run_line_str) - - # also put it to the terminal - click.echo(run_line_str) - - logger.info("Closing the orchestrating orch") - orch.close() - - -def combine_orch_wepy_hdf5s(new_orch, new_hdf5_path, run_ids=None): - """ - - \b - Parameters - ---------- - new_orch : - - new_hdf5_path : - - - \b - Returns - ------- - - """ - - if run_ids is None: - run_ids = new_orch.run_hashes() - - # we assume that the run we are interested in is the only run in - # the WepyHDF5 file so it is index 0 - singleton_run_idx = 0 - - # a key-value for the paths for each run - hdf5_paths = {} - - # go through each run in the new orchestrator - for run_id in run_ids: - # get the configuration used for this run - run_config = new_orch.run_configuration(*run_id) - - # from that configuration find the WepyHDF5Reporters - for reporter in run_config.reporters: - if isinstance(reporter, WepyHDF5Reporter): - # and save the path for that run - hdf5_paths[run_id] = reporter.file_path - - click.echo("Combining these HDF5 files:") - click.echo("\n".join(hdf5_paths.values())) - - # now that we have the paths (or lack of paths) for all - # the runs we need to start linking them all - # together. - - # first we need a master linker HDF5 to do this with - - # so load a template WepyHDF5 - template_wepy_h5_path = hdf5_paths[run_ids[singleton_run_idx]] - template_wepy_h5 = WepyHDF5(template_wepy_h5_path, mode="r") - - # clone it - with template_wepy_h5: - master_wepy_h5 = template_wepy_h5.clone(new_hdf5_path, mode="x") - - click.echo("Into a single master hdf5 file: {}".format(new_hdf5_path)) - - # then link all the files to it - run_mapping = {} - for run_id, wepy_h5_path in hdf5_paths.items(): - # in the case where continuations were done from - # checkpoints then the runs data will potentially (and - # most likely) contain extra cycles since checkpoints are - # typically produced on some interval of cycles. So, in - # order for us to actually piece together contigs we need - # to take care of this. - - # There are two ways to deal with this which can both be - # done at the same time. The first is to keep the "nubs", - # which are the small leftover pieces after the checkpoint - # that ended up getting continued, and make a new run from - # the last checkpoint to the end of the nub, in both the - # WepyHDF5 and the orchestrator run collections. - - # The second is to generate a WepyHDF5 run that - # corresponds to the run in the checkpoint orchestrator. - - # To avoid complexity (for now) we opt to simply dispose - # of the nubs and assume that not much will be lost from - # this. For the typical use case of making multiple - # independent and linear contigs this is also the simplest - # mode, since the addition of multiple nubs will introduce - # an extra spanning contig in the contig tree. - - # furthermore the nubs provide a source of problems if - # rnus were abruptly stopped and data is not written some - # of the frames can be corrupted. SO until we know how to - # stop this (probably SWMR mode will help) this is also a - # reason not to deal with nubs. - - # TODO: add option to keep nubs in HDF5, and deal with in - # orch (you won't be able to have an end snapshot...). - - # to do this we simply check whether or not the number of - # cycles for the run_id are less than the number of cycles - # in the corresponding WepyHDF5 run dataset. - orch_run_num_cycles = new_orch.run_last_cycle_idx(*run_id) - - # get the number of cycles that are in the data for the run in - # the HDF5 to compare to the number in the orchestrator run - # record - wepy_h5 = WepyHDF5(wepy_h5_path, mode="r") - with wepy_h5: - h5_run_num_cycles = wepy_h5.num_run_cycles(singleton_run_idx) - - # sanity check for if the number of cycles in the - # orchestrator is greater than the HDF5 - if orch_run_num_cycles > h5_run_num_cycles: - raise ValueError( - "Number of cycles in orch run is more than HDF5." - "This implies missing data" - ) - - # copy the run (with the slice) - with master_wepy_h5: - # TODO: this was the old way of combining where we would - # just link, however due to the above discussion this is - # not tenable now. In the future there might be some more - # complex options taking linking into account but for now - # we just don't use it and all runs will be copied by this - # operation - - # # we just link the whole file then sort out the - # # continuations later since we aren't necessarily doing - # # this in a logical order - # new_run_idxs = master_wepy_h5.link_file_runs(wepy_h5_path) - - # extract the runs from the file (there should only be - # one). This means copy the run, but if we only want a - # truncation of it we will use the run slice to only get - # part of it - - # so first we generate the run slices for this file using - # the number of cycles recorded in the orchestrator - run_slices = {singleton_run_idx: (0, orch_run_num_cycles)} - - click.echo("Extracting Run: {}".format(run_id)) - click.echo( - "Frames 0 to {} out of {}".format( - orch_run_num_cycles, h5_run_num_cycles - ) - ) - - # then perform the extraction, which will open the other - # file on its own - new_run_idxs = master_wepy_h5.extract_file_runs( - wepy_h5_path, run_slices=run_slices - ) - - # map the hash id to the new run idx created. There should - # only be one run in an HDF5 if we are following the - # orchestration workflow. - assert len(new_run_idxs) < 2, ( - "Cannot be more than 1 run per HDF5 file in orchestration workflow" - ) - - run_mapping[run_id] = new_run_idxs[0] - - click.echo("Set as run: {}".format(new_run_idxs[0])) - - click.echo("Done extracting runs, setting continuations") - - with master_wepy_h5: - # now that they are all linked we need to add the snapshot - # hashes identifying the runs as metadata. This is so we can - # map the simple run indices in the HDF5 back to the - # orchestrator defined runs. This will be saved as metadata on - # the run. Also: - - # We need to set the continuations correctly betwen the runs - # in different files, so for each run we find the run it - # continues in the orchestrator - for run_id, run_idx in run_mapping.items(): - # set the run snapshot hash metadata except for if we have - # already done it - try: - master_wepy_h5.set_run_start_snapshot_hash(run_idx, run_id[0]) - except AttributeError: - # it was already set so just move on - pass - try: - master_wepy_h5.set_run_end_snapshot_hash(run_idx, run_id[1]) - except AttributeError: - # it was already set so just move on - pass - - # find the run_id that this one continues - continued_run_id = new_orch.run_continues(*run_id) - - # if a None is returned then there was no continuation - if continued_run_id is None: - # so we go to the next run_id and don't log any - # continuation - continue - - # get the run_idx in the HDF5 that corresponds to this run - continued_run_idx = run_mapping[continued_run_id] - - click.echo("Run {} continued by {}".format(continued_run_id, run_idx)) - - # add the continuation - master_wepy_h5.add_continuation(run_idx, continued_run_idx) - - -@click.command() -@click.argument("orchestrator", nargs=1, type=click.Path(exists=True)) -@click.argument("hdf5", nargs=1, type=click.Path(exists=False)) -@click.argument("run_ids", nargs=-1) -def reconcile_hdf5(orchestrator, hdf5, run_ids): - """For an orchestrator with multiple runs combine the HDF5 results - into a single one. - - This requires that the paths inside of the reporters for the - configurations used for a run still have valid paths to the HDF5 - files. - - \b - Parameters - ---------- - orchestrator : Path - The orchestrator to retrieve HDF5s for - - hdf5 : Path - Path to the resultant HDF5. - - run_ids : str - String specifying a run as start and end hash - e.g. 'd0cb2e6fbcc8c2d66d67c845120c7f6b,b4b96580ae57f133d5f3b6ce25affa6d' - - \b - Returns - ------- - - """ - - # parse the run ids - run_ids = [tuple(run_id.split(",")) for run_id in run_ids] - - orch = Orchestrator(orchestrator, mode="r") - - hdf5_path = osp.realpath(hdf5) - - click.echo("Combining the HDF5s together, saving to:") - click.echo(hdf5_path) - - # combine the HDF5 files from those orchestrators - combine_orch_wepy_hdf5s(orch, hdf5_path, run_ids=run_ids) - - -@click.command() -@click.option("--hdf5", type=click.Path(exists=False)) -@click.argument("output", nargs=1, type=click.Path(exists=False)) -@click.argument("orchestrators", nargs=-1, type=click.Path(exists=True)) -def reconcile_orch(hdf5, output, orchestrators): - """ - - \b - Parameters - ---------- - hdf5 : Path - Path to the resultant HDF5. - output : Path - Path to the resultant orchestrator that is created. - orchestrators : Path - Paths to the orchestrators to reconcile. - - \b - Returns - ------- - - """ - - new_orch = reconcile_orchestrators(output, *orchestrators) - - # if a path for an HDF5 file is given - if hdf5 is not None: - hdf5_path = osp.realpath(hdf5) - - click.echo("Combining the HDF5s together, saving to:") - click.echo(hdf5_path) - - # combine the HDF5 files from those orchestrators - combine_orch_wepy_hdf5s(new_orch, hdf5_path) - - -def hash_listing_formatter(hashes): - """ - - \b - Parameters - ---------- - hashes : - - - \b - Returns - ------- - - """ - hash_listing_str = "\n".join(hashes) - return hash_listing_str - - -@click.argument("orchestrator", type=click.Path(exists=True)) -@click.command() -def ls_snapshots(orchestrator): - """ - - \b - Parameters - ---------- - orchestrator : - - - \b - Returns - ------- - - """ - - orch = Orchestrator(orch_path=orchestrator, mode="r") - - message = hash_listing_formatter(orch.snapshot_hashes) - - orch.close() - - click.echo(message) - - -@click.argument("orchestrator", type=click.Path(exists=True)) -@click.command() -def ls_runs(orchestrator): - """ - - \b - Parameters - ---------- - orchestrator : - - - \b - Returns - ------- - - """ - - orch = Orchestrator(orch_path=orchestrator, mode="r") - - runs = orch.run_hashes() - - orch.close() - - hash_listing_str = "\n".join(["{}, {}".format(start, end) for start, end in runs]) - - click.echo(hash_listing_str) - - -@click.argument("orchestrator", type=click.Path(exists=True)) -@click.command() -def ls_configs(orchestrator): - """ - - \b - Parameters - ---------- - orchestrator : - - - \b - Returns - ------- - - """ - - orch = Orchestrator(orch_path=orchestrator, mode="r") - - message = hash_listing_formatter(orch.configuration_hashes) - - orch.close() - - click.echo(message) - - -@click.command() -@click.option("--no-expand-external", is_flag=True) -@click.argument("source", type=click.Path(exists=True)) -@click.argument("target", type=click.Path(exists=False)) -def hdf5_copy(no_expand_external, source, target): - """Copy a WepyHDF5 file, except links to other runs will optionally be - expanded and truly duplicated if symbolic inter-file links are present.""" - - # arg clusters to pass to subprocess for the files - input_f_args = ["-i", source] - output_f_args = ["-o", target] - - # each invocation calls a different group since we can't call the - # toplevel '/' directly - settings_args = ["-s", "/units", "-d", "/units"] - settings_args = ["-s", "/_settings", "-d", "/_settings"] - topology_args = ["-s", "/topology", "-d", "/topology"] - runs_args = ["-s", "/runs", "-d", "/runs"] - - # by default expand the external links - flags_args = ["-f", "ext"] - - # if the not expand external flag is given get rid of those args - if no_expand_external: - flags_args = [] - - common_args = input_f_args + output_f_args + flags_args - - settings_output = subprocess.check_output(["h5copy"] + common_args + settings_args) - - topology_output = subprocess.check_output(["h5copy"] + common_args + topology_args) - - runs_output = subprocess.check_output(["h5copy"] + common_args + runs_args) - - -@click.option("-O", "--output", type=click.Path(exists=False), default=None) -@click.argument("snapshot_hash") -@click.argument("orchestrator", type=click.Path(exists=True)) -@click.command() -def get_snapshot(output, snapshot_hash, orchestrator): - # first check if the output is None, if it is we automatically - # generate a file in the cwd that is the hash of the snapshot - if output is None: - output = "{}.snap.dill.pkl".format(snapshot_hash) - - # check that it doesn't exist, and fail if it does, since we - # don't want to implicitly overwrite stuff - if osp.exists(output): - raise OSError( - "No output path was specified and default alredy exists, exiting." - ) - - orch = Orchestrator(orchestrator, mode="r") - - serial_snapshot = orch.snapshot_kv[snapshot_hash] - - with open(output, "wb") as wf: - wf.write(serial_snapshot) - - orch.close() - - -@click.option("-O", "--output", type=click.Path(exists=False), default=None) -@click.argument("config_hash") -@click.argument("orchestrator", type=click.Path(exists=True)) -@click.command() -def get_config(output, config_hash, orchestrator): - # first check if the output is None, if it is we automatically - # generate a file in the cwd that is the hash of the snapshot - if output is None: - output = "{}.config.dill.pkl".format(config_hash) - - # check that it doesn't exist, and fail if it does, since we - # don't want to implicitly overwrite stuff - if osp.exists(output): - raise OSError( - "No output path was specified and default alredy exists, exiting." - ) - - orch = Orchestrator(orchestrator, mode="r") - - serial_snapshot = orch.configuration_kv[config_hash] - - with open(output, "wb") as wf: - wf.write(serial_snapshot) - - orch.close() - - -@click.option("-O", "--output", type=click.Path(exists=False), default=None) -@click.argument("end_hash") -@click.argument("start_hash") -@click.argument("orchestrator", type=click.Path(exists=True)) -@click.command() -def get_run(output, end_hash, start_hash, orchestrator): - # first check if the output is None, if it is we automatically - # generate a file in the cwd that is the hash of the snapshot - if output is None: - output = "{}-{}.orch.sqlite".format(start_hash, end_hash) - - # check that it doesn't exist, and fail if it does, since we - # don't want to implicitly overwrite stuff - if osp.exists(output): - raise OSError( - "No output path was specified and default alredy exists, exiting." - ) - - orch = Orchestrator(orchestrator, mode="r") - - start_serial_snapshot = orch.snapshot_kv[start_hash] - end_serial_snapshot = orch.snapshot_kv[end_hash] - - # get the records values for this run - rec_d = { - field: value - for field, value in zip( - Orchestrator.RUN_SELECT_FIELDS, orch.get_run_record(start_hash, end_hash) - ) - } - - config = orch.configuration_kv[rec_d["config_hash"]] - - # create a new orchestrator at the output location - new_orch = Orchestrator(output, mode="w") - - _ = new_orch.add_serial_snapshot(start_serial_snapshot) - _ = new_orch.add_serial_snapshot(end_serial_snapshot) - config_hash = new_orch.add_serial_configuration(config) - - new_orch.register_run(start_hash, end_hash, config_hash, rec_d["last_cycle_idx"]) - - orch.close() - new_orch.close() - - -@click.argument("end_hash") -@click.argument("start_hash") -@click.argument("orchestrator", type=click.Path(exists=True)) -@click.command() -def get_run_cycles(end_hash, start_hash, orchestrator): - orch = Orchestrator(orchestrator, mode="r") - - start_serial_snapshot = orch.snapshot_kv[start_hash] - end_serial_snapshot = orch.snapshot_kv[end_hash] - - # get the records values for this run - rec_d = { - field: value - for field, value in zip( - Orchestrator.RUN_SELECT_FIELDS, orch.get_run_record(start_hash, end_hash) - ) - } - - click.echo(rec_d["last_cycle_idx"]) - - -@click.argument("orchestrator", type=click.Path(exists=False)) -@click.command() -def create_orch(orchestrator): - orch = Orchestrator(orchestrator, mode="x") - - orch.close() - - -@click.argument("snapshot", type=click.File("rb")) -@click.argument("orchestrator", type=click.Path(exists=True)) -@click.command() -def add_snapshot(snapshot, orchestrator): - orch = Orchestrator(orchestrator, mode="r+") - - serial_snapshot = snapshot.read() - - snaphash = orch.add_serial_snapshot(serial_snapshot) - - orch.close() - - click.echo(snaphash) - - -@click.argument("configuration", type=click.File("rb")) -@click.argument("orchestrator", type=click.Path(exists=True)) -@click.command() -def add_config(configuration, orchestrator): - orch = Orchestrator(orchestrator, mode="r+") - - serial_config = configuration.read() - - config_hash = orch.add_serial_snapshot(serial_config) - - orch.close() - - click.echo(config_hash) - - -@click.group() -def cli(): - """ """ - pass - - -@click.group() -def run(): - """ """ - pass - - -@click.group() -def get(): - """ """ - pass - - -@click.group() -def add(): - """ """ - pass - - -@click.group() -def create(): - """ """ - pass - - -@click.group() -def ls(): - """ """ - pass - - -@click.group() -def reconcile(): - """ """ - pass - - -@click.group() -def hdf5(): - """ """ - pass - - -# command groupings - -# run -run.add_command(run_orch, name="orch") -run.add_command(run_snapshot, name="snapshot") - -# ls -ls.add_command(ls_snapshots, name="snapshots") -ls.add_command(ls_runs, name="runs") -ls.add_command(ls_configs, name="configs") - -# get -get.add_command(get_snapshot, name="snapshot") -get.add_command(get_config, name="config") -get.add_command(get_run, name="run") -get.add_command(get_run_cycles, name="run-cycles") - -# add -add.add_command(add_snapshot, name="snapshot") -add.add_command(add_config, name="config") - -# create -create.add_command(create_orch, name="orch") - -# reconcile -reconcile.add_command(reconcile_orch, name="orch") -reconcile.add_command(reconcile_hdf5, name="hdf5") - -# hdf5 -hdf5.add_command(hdf5_copy, name="copy") -# desired commands -# hdf5.add_command(hdf5_copy, name='copy-run') -# hdf5.add_command(hdf5_copy, name='copy-traj') -# hdf5.add_command(hdf5_copy, name='ls-runs') -# hdf5.add_command(hdf5_copy, name='ls-run-hashes') - -# subgroups -subgroups = [run, get, add, create, ls, reconcile, hdf5] - -for subgroup in subgroups: - cli.add_command(subgroup) - -if __name__ == "__main__": - cli() diff --git a/src/wepy/orchestration/configuration.py b/src/wepy/orchestration/configuration.py deleted file mode 100644 index f01c04ed..00000000 --- a/src/wepy/orchestration/configuration.py +++ /dev/null @@ -1,378 +0,0 @@ -# Standard Library -from typing import Final -import itertools as it -import logging - -logger = logging.getLogger(__name__) -# Standard Library -import os.path as osp -from copy import deepcopy - -# First Party Library -from wepy.work_mapper.mapper import Mapper, WorkerMapper -from wepy.work_mapper.worker import Worker - - -class Configuration: - """ """ - - DEFAULT_WORKDIR: Final = osp.realpath(osp.curdir) - DEFAULT_CONFIG_NAME: Final = "root" - DEFAULT_NARRATION: Final = "" - DEFAULT_REPORTER_CLASS: Final = "" - - # if there is to be reporter class in filenames use this template - # to put it into the filename - REPORTER_CLASS_SEG_TEMPLATE: Final = ".{}" - DEFAULT_MODE: Final = "x" - - def __init__( - self, - # reporters - config_name=None, - work_dir=None, - mode=None, - narration=None, - reporter_classes=None, - reporter_partial_kwargs=None, - # work mappers - work_mapper_class=None, - work_mapper_partial_kwargs=None, - # monitors - monitor_class=None, - monitor_partial_kwargs=None, - # apparatus configuration options - apparatus_opts=None, - ): - ## reporter stuff - - # reporters and partial kwargs - if reporter_classes is not None: - self._reporter_classes = reporter_classes - else: - self._reporter_classes = [] - - if reporter_partial_kwargs is not None: - self._reporter_partial_kwargs = reporter_partial_kwargs - else: - self._reporter_partial_kwargs = [] - - # file path localization variables - - # config string - if config_name is not None: - self._config_name = config_name - else: - self._config_name = self.DEFAULT_CONFIG_NAME - - if work_dir is not None: - self._work_dir = work_dir - else: - self._work_dir = self.DEFAULT_WORKDIR - - # narration - if narration is not None: - narration = "_{}".format(narration) if len(narration) > 0 else "" - self._narration = narration - else: - self._narration = self.DEFAULT_NARRATION - - # file modes, if none are given we set to the default, this - # needs to be done before generating the reporters - if mode is not None: - self._mode = mode - else: - self._mode = self.DEFAULT_MODE - - # generate the reporters for this configuration - self._reporters = self._gen_reporters() - - ## work mapper - - # the partial kwargs that will be passed for reparametrization - if work_mapper_partial_kwargs is None: - self._work_mapper_partial_kwargs = {} - else: - self._work_mapper_partial_kwargs = work_mapper_partial_kwargs - - # if the number of workers is not given set it to None - if "num_workers" not in self._work_mapper_partial_kwargs: - self._work_mapper_partial_kwargs["num_workers"] = None - - # same for the worker type - if "worker_type" not in self._work_mapper_partial_kwargs: - self._work_mapper_partial_kwargs["worker_type"] = None - - # if the number of workers was sepcified and no work_mapper - # class was specified default to the WorkerMapper - if (self._work_mapper_partial_kwargs["num_workers"] is not None) and ( - work_mapper_class is None - ): - self._work_mapper_class = WorkerMapper - - # if no number of workers was specified and no work_mapper - # class was specified we default to the serial mapper - elif (self._work_mapper_partial_kwargs["num_workers"] is None) and ( - work_mapper_class is None - ): - self._work_mapper_class = Mapper - - # otherwise if the work_mapper class was given we use it and - # whatever the number of workers was - else: - self._work_mapper_class = work_mapper_class - - # then generate a work mapper - self._work_mapper = self._work_mapper_class(**self._work_mapper_partial_kwargs) - print( - "config mapper ---->", - self._work_mapper.__class__.__name__, - self._work_mapper._attributes, - self._work_mapper_partial_kwargs, - ) - - ### Monitor options - - # get the names of the reporters in the order they are - reporter_order = tuple( - [str(reporter_class.__name__) for reporter_class in self._reporter_classes] - ) - - # init the kwargs for the monitor - if monitor_partial_kwargs is None: - self._monitor_partial_kwargs = {} - else: - self._monitor_partial_kwargs = monitor_partial_kwargs - - # choose the monitor class (None is okay) - self._monitor_class = monitor_class - - # generate the object - if self._monitor_class is not None: - self._monitor = self._monitor_class( - reporter_order=reporter_order, - **self._monitor_partial_kwargs, - ) - - else: - self._monitor = None - - ### Apparatus options - - # the runtime configuration of the apparatus can be configured - # via these options. - self._apparatus_opts = apparatus_opts if apparatus_opts is not None else {} - - @property - def reporter_classes(self): - """ """ - return self._reporter_classes - - @property - def reporter_partial_kwargs(self): - """ """ - return self._reporter_partial_kwargs - - @property - def config_name(self): - """ """ - return self._config_name - - @property - def work_dir(self): - """ """ - return self._work_dir - - @property - def narration(self): - """ """ - return self._narration - - @property - def mode(self): - """ """ - return self._mode - - @property - def reporters(self): - """ """ - return self._reporters - - @property - def work_mapper_class(self): - """ """ - return self._work_mapper_class - - @property - def work_mapper_partial_kwargs(self): - """ """ - return self._work_mapper_partial_kwargs - - @property - def work_mapper(self): - """ """ - return self._work_mapper - - @property - def monitor_class(self): - """ """ - return self._monitor_class - - @property - def monitor_partial_kwargs(self): - """ """ - return self._monitor_partial_kwargs - - @property - def monitor(self): - """ """ - return self._monitor - - @property - def apparatus_opts(self): - """ """ - return self._apparatus_opts - - def _gen_reporters(self): - """ """ - - # check the extensions of all the reporters. If any of them - # are the same raise a flag to add the reporter names to the - # filenames - - # the number of filenames - all_exts = list( - it.chain( - *[ - [ext for ext in rep.SUGGESTED_EXTENSIONS] - for rep in self.reporter_classes - ] - ) - ) - n_exts = len(all_exts) - - # the number of unique ones - n_unique_exts = len(set(all_exts)) - - duplicates = False - if n_unique_exts < n_exts: - duplicates = True - - # then go through and make the inputs for each reporter - reporters = [] - for idx, reporter_class in enumerate(self.reporter_classes): - # first we have to generate the filenames for all the - # files this reporter needs. The number of file names the - # reporter needs is given by the number of suggested - # extensions it has - file_paths = [] - for extension in reporter_class.SUGGESTED_EXTENSIONS: - # if previously found that there are duplicates in the - # extensions we need to name with the reporter class string - if duplicates: - # use the __name__ attribute of the class and put - # it into the template to make a segment out of it - reporter_class_seg_str = self.REPORTER_CLASS_SEG_TEMPLATE.format( - reporter_class.__name__ - ) - - # then make the filename with this - filename = reporter_class.SUGGESTED_FILENAME_TEMPLATE.format( - narration=self.narration, - config=self.config_name, - reporter_class=reporter_class_seg_str, - ext=extension, - ) - - # otherwise don't use the reporter class names to keep it clean - else: - filename = reporter_class.SUGGESTED_FILENAME_TEMPLATE.format( - narration=self.narration, - config=self.config_name, - reporter_class=self.DEFAULT_REPORTER_CLASS, - ext=extension, - ) - - file_path = osp.join(self.work_dir, filename) - - file_paths.append(file_path) - - modes = [self.mode for i in range(len(file_paths))] - - reporter = reporter_class( - file_paths=file_paths, modes=modes, **self.reporter_partial_kwargs[idx] - ) - - reporters.append(reporter) - - return reporters - - # TODO: remove, not used - def _gen_work_mapper(self): - """ """ - - work_mapper = self._work_mapper_class(n_workers=self._default_n_workers) - - return work_mapper - - @property - def reporters(self): - """ """ - return deepcopy(self._reporters) - - @property - def work_mapper(self): - """ """ - return deepcopy(self._work_mapper) - - def reparametrize(self, **kwargs): - """ - - Parameters - ---------- - **kwargs : - - - Returns - ------- - - """ - - # dictionary of the possible reparametrizations from the - # current configuration - params = { - # related to the work mapper - "work_mapper_class": self.work_mapper_class, - "work_mapper_partial_kwargs": self.work_mapper_partial_kwargs, - # monitor - "monitor_class": self.monitor_class, - "monitor_partial_kwargs": self.monitor_partial_kwargs, - # those related to the reporters - "mode": self.mode, - "config_name": self.config_name, - "work_dir": self.work_dir, - "narration": self.narration, - "reporter_classes": self.reporter_classes, - "reporter_partial_kwargs": self.reporter_partial_kwargs, - # apparatus - "apparatus_opts": self.apparatus_opts, - } - - for key, value in kwargs.items(): - # for the partial kwargs we need to update them not - # completely overwrite - if key in [ - "work_mapper_partial_kwargs", - "reporter_partial_kwargs", - "monitor_partial_kwargs", - ]: - if value is not None: - params[key].update(value) - - # if the value is given we replace the old one with it - elif value is not None: - params[key] = value - - new_configuration = type(self)(**params) - - return new_configuration diff --git a/src/wepy/orchestration/orchestrator.py b/src/wepy/orchestration/orchestrator.py deleted file mode 100644 index 14d057a6..00000000 --- a/src/wepy/orchestration/orchestrator.py +++ /dev/null @@ -1,1392 +0,0 @@ -# Standard Library -import logging - -logger = logging.getLogger(__name__) -# Standard Library -import os -import os.path as osp -import sqlite3 -import time -from base64 import b64decode, b64encode -from copy import copy, deepcopy -from hashlib import md5 -from zlib import compress, decompress - -# Third Party Library -# instead of pickle we use dill, so we can save dynamically defined -# classes -import dill - -# First Party Library -from wepy.orchestration.configuration import Configuration -from wepy.orchestration.snapshot import SimApparatus, SimSnapshot -from wepy.sim_manager import Manager -from wepy.util.kv import KV, SQLITE3_INMEMORY_URI, gen_uri - - -class OrchestratorError(Exception): - """ """ - - pass - - -class Orchestrator: - """ """ - - # we freeze the pickle protocol for making hashes, because we care - # more about stability than efficiency of newer versions - HASH_PICKLE_PROTOCOL = 3 - - DEFAULT_WORKDIR = Configuration.DEFAULT_WORKDIR - DEFAULT_CONFIG_NAME = Configuration.DEFAULT_CONFIG_NAME - DEFAULT_NARRATION = Configuration.DEFAULT_NARRATION - DEFAULT_MODE = Configuration.DEFAULT_MODE - - DEFAULT_CHECKPOINT_FILENAME = "checkpoint.orch.sqlite" - ORCH_FILENAME_TEMPLATE = "{config}{narration}.orch.sqlite" - - # the default way to oepn up the whole parent database - DEFAULT_ORCHESTRATION_MODE = "x" - - # mode to open the individual kv stores on the parent database - KV_MODE = "r+" - - # default timeout for connecting to a database - SQLITE3_DEFAULT_TIMEOUT = 5 - - # the fields to return (and their order) as a record for a run - # query - RUN_SELECT_FIELDS = ("last_cycle_idx", "config_hash") - - def __init__( - self, - orch_path=None, - mode="x", - append_only=False, - ): - self._mode = mode - self._append_only = append_only - - # handle the path and convert to a proper URI for the database - # given the path and the mode - self._db_uri = gen_uri(orch_path, mode) - - # run table: start_hash, end_hash, num_cycles, configuration_id - - # get a raw connection to the database - self._db = sqlite3.connect( - self.db_uri, uri=True, timeout=self.SQLITE3_DEFAULT_TIMEOUT - ) - self._closed = False - - # set isolation level to autocommit - self._db.isolation_level = None - - # we can use read_uncommited only in append_only mode (no - # updates) because you never have to worry about dirty reads - # since you can't update - if self.append_only: - self._db.execute("PRAGMA read_uncommited=1") - - # we make a table for the run data, if it doesn't already - # exist - c = self._db.cursor().execute(self.create_run_table_query) - - # initialize or open each of the separate KV-stores (tables in - # the same SQLite3 database) - - # change the mode for the KV stores since we already created the database - - # metadata: default init walkers, default apparatus, default - # configuration - self.metadata_kv = KV( - db_url=self.db_uri, - table="meta", - mode="a", - value_types=None, - append_only=self.append_only, - ) - - # snapshots - self.snapshot_kv = KV( - db_url=self.db_uri, - table="snapshots", - primary_key="snaphash", - value_name="snapshot", - mode="a", - append_only=self.append_only, - ) - - # configurations - self.configuration_kv = KV( - db_url=self.db_uri, - table="configurations", - primary_key="config_hash", - value_name="config", - mode="a", - append_only=self.append_only, - ) - - @property - def mode(self): - return self._mode - - @property - def append_only(self): - return self._append_only - - def close(self): - if self._closed == True: - raise IOError("The database connection is already closed") - - else: - # close all the connections - self.metadata_kv.close() - self.configuration_kv.close() - self.snapshot_kv.close() - self._db.close() - self._closed = True - - @property - def db_uri(self): - return self._db_uri - - @property - def orch_path(self): - # if it is not an in-memory database we parse off the path and - # return that - if self.db_uri == SQLITE3_INMEMORY_URI: - return None - else: - # URIs have the following form: protocol:url?query - # destructure the URI - _, tail = self.db_uri.split(":") - - if len(tail.split("?")) > 1: - url, _ = tail.split("?") - else: - url = tail - - return url - - @classmethod - def serialize(cls, snapshot): - """Serialize a snapshot to a compressed, encoded, pickle string - representation. - - Currently uses the dill module for pickling because the base - pickle module is inadequate. However, it is mostly compatible - and can be read natively with pickle but this usage is - officially not supported. Instead use the deserialize_snapshot. - - Also compresses with default zlib compression and is encoded - in base64. - - The object will always have a deepcopy performed on it so that - all of the extraneous references to it are avoided since there - is no (AFAIK) way to make sure all references to an object are - deleted. - - NOTE: Perhaps there is a way and that should be done (and - tested) to see if it provides stable pickles (i.e. pickles - that always hash to the same value). To avoid the overhead of - copying large objects. - - Parameters - ---------- - snapshot : SimSnapshot object - The snapshot of the simulation you want to serialize. - - Returns - ------- - serial_str : str - Serialized string of the snapshot object - - """ - - serial_str = b64encode( - compress( - dill.dumps( - deepcopy(snapshot), protocol=cls.HASH_PICKLE_PROTOCOL, recurse=True - ) - ) - ) - - return serial_str - - # core methods for serializing python objects, used for snapshots, - # apparatuses, configurations, and the initial walker list - - @classmethod - def deserialize(cls, serial_str): - """Deserialize an unencoded string snapshot to an object. - - Parameters - ---------- - serial_str : str - Serialized string of the snapshot object - - Returns - ------- - snapshot : SimSnapshot object - Simulation snapshot object - - """ - - return dill.loads(decompress(b64decode(serial_str))) - - # defaults getters and setters - def set_default_sim_apparatus(self, sim_apparatus): - # serialize the apparatus and then set it - serial_app = self.serialize(sim_apparatus) - - self.metadata_kv["default_sim_apparatus"] = serial_app - - def set_default_init_walkers(self, init_walkers): - # serialize the apparatus and then set it - serial_walkers = self.serialize(init_walkers) - - self.metadata_kv["default_init_walkers"] = serial_walkers - - def set_default_configuration(self, configuration): - # serialize the apparatus and then set it - serial_config = self.serialize(configuration) - - config_hash = self.hash_snapshot(serial_config) - - self.metadata_kv["default_configuration_hash"] = config_hash - - self.configuration_kv[config_hash] = serial_config - - def set_default_snapshot(self, snapshot): - snaphash = self.add_snapshot(snapshot) - - # then save the hash in the metadata - self.metadata_kv["default_snapshot_hash"] = snaphash - - return snaphash - - def gen_default_snapshot(self): - # generate the snapshot - sim_start_hash = self.gen_start_snapshot(self.get_default_init_walkers()) - - # then save the hash in the metadata - self.metadata_kv["default_snapshot_hash"] = sim_start_hash - - return sim_start_hash - - def get_default_sim_apparatus(self): - return self.deserialize(self.metadata_kv["default_sim_apparatus"]) - - def get_default_init_walkers(self): - return self.deserialize(self.metadata_kv["default_init_walkers"]) - - def get_default_configuration(self): - config_hash = self.metadata_kv["default_configuration_hash"] - - return self.get_configuration(config_hash) - - def get_default_configuration_hash(self): - return self.metadata_kv["default_configuration_hash"] - - def get_default_snapshot(self): - start_hash = self.metadata_kv["default_snapshot_hash"] - - return self.get_snapshot(start_hash) - - def get_default_snapshot_hash(self): - return self.metadata_kv["default_snapshot_hash"] - - @classmethod - def hash_snapshot(cls, serial_str): - """ - - Parameters - ---------- - serial_str : - - - Returns - ------- - - """ - return md5(serial_str).hexdigest() - - def get_snapshot(self, snapshot_hash): - """Returns a copy of a snapshot. - - Parameters - ---------- - snapshot_hash : - - - Returns - ------- - - """ - - return self.deserialize(self.snapshot_kv[snapshot_hash]) - - def get_configuration(self, config_hash): - """Returns a copy of a snapshot. - - Parameters - ---------- - config_hash : - - - Returns - ------- - - """ - - return self.deserialize(self.configuration_kv[config_hash]) - - @property - def snapshot_hashes(self): - """ """ - - # iterate over the snapshot kv - return list(self.snapshot_kv.keys()) - - @property - def configuration_hashes(self): - """ """ - - # iterate over the snapshot kv - return list(self.configuration_kv.keys()) - - def add_snapshot(self, snapshot): - """ - - Parameters - ---------- - snapshot : - - Returns - ------- - - """ - - # serialize the snapshot using the protocol for doing so - serialized_snapshot = self.serialize(snapshot) - - # get the hash of the snapshot - snaphash = self.hash_snapshot(serialized_snapshot) - - # check that the hash is not already in the snapshots - if any([True if snaphash == md5 else False for md5 in self.snapshot_hashes]): - # just skip the rest of the function and return the hash - return snaphash - - # save the snapshot in the KV store - self.snapshot_kv[snaphash] = serialized_snapshot - - return snaphash - - def add_serial_snapshot(self, serial_snapshot): - # get the hash of the snapshot - snaphash = self.hash_snapshot(serial_snapshot) - - # check that the hash is not already in the snapshots - if any([True if snaphash == md5 else False for md5 in self.snapshot_hashes]): - # just skip the rest of the function and return the hash - return snaphash - - # save the snapshot in the KV store - self.snapshot_kv[snaphash] = serial_snapshot - - return snaphash - - def gen_start_snapshot(self, init_walkers): - """ - - Parameters - ---------- - init_walkers : - - - Returns - ------- - - """ - - # make a SimSnapshot object using the initial walkers and - start_snapshot = SimSnapshot(init_walkers, self.get_default_sim_apparatus()) - - # save the snapshot, and generate its hash - sim_start_md5 = self.add_snapshot(start_snapshot) - - return sim_start_md5 - - @property - def default_snapshot_hash(self): - """ """ - return self.metadata_kv["default_snapshot_hash"] - - @property - def default_snapshot(self): - """ """ - return self.get_snapshot(self.default_snapshot_hash) - - def snapshot_registered(self, snapshot): - """Check whether a snapshot is already in the database, based on the - hash of it. - - This serializes the snapshot so may be slow. - - Parameters - ---------- - snapshot : SimSnapshot object - The snapshot object you want to query for. - - Returns - ------- - - """ - - # serialize and hash the snapshot - snaphash = self.hash_snapshot(self.serialize(snapshot)) - - # then check it - return self.snapshot_hash_registered(snaphash) - - def snapshot_hash_registered(self, snapshot_hash): - """Check whether a snapshot hash is already in the database. - - Parameters - ---------- - snapshot_hash : str - The string hash of the snapshot. - - Returns - ------- - - """ - - if any([True if snapshot_hash == h else False for h in self.snapshot_hashes]): - return True - else: - return False - - def configuration_hash_registered(self, config_hash): - """Check whether a snapshot hash is already in the database. - - Parameters - ---------- - snapshot_hash : str - The string hash of the snapshot. - - Returns - ------- - - """ - - if any( - [True if config_hash == h else False for h in self.configuration_hashes] - ): - return True - else: - return False - - ### run methods - - def add_configuration(self, configuration): - serialized_config = self.serialize(configuration) - - config_hash = self.hash_snapshot(serialized_config) - - # check that the hash is not already in the snapshots - if any( - [True if config_hash == md5 else False for md5 in self.configuration_hashes] - ): - # just skip the rest of the function and return the hash - return config_hash - - # save the snapshot in the KV store - self.configuration_kv[config_hash] = serialized_config - - return config_hash - - def add_serial_configuration(self, serial_configuration): - # get the hash of the configuration - snaphash = self.hash_snapshot(serial_configuration) - - # check that the hash is not already in the configurations - if any( - [True if snaphash == md5 else False for md5 in self.configuration_hashes] - ): - # just skip the rest of the function and return the hash - return snaphash - - # save the configuration in the KV store - self.configuration_kv[snaphash] = serial_configuration - - return snaphash - - @property - def create_run_table_query(self): - create_run_table_query = """ - CREATE TABLE IF NOT EXISTS runs - (start_hash TEXT NOT NULL, - end_hash TEXT NOT NULL, - config_hash NOT NULL, - last_cycle_idx INTEGER NOT NULL, - PRIMARY KEY (start_hash, end_hash)) - - """ - - return create_run_table_query - - @property - def add_run_record_query(self): - add_run_row_query = """ - INSERT INTO runs (start_hash, end_hash, config_hash, last_cycle_idx) - VALUES (?, ?, ?, ?) - """ - - return add_run_row_query - - @property - def update_run_record_query(self): - q = """ - UPDATE runs - SET config_hash = ?, - last_cycle_idx = ? - WHERE start_hash=? AND end_hash=? - """ - - return q - - @property - def delete_run_record_query(self): - q = """ - DELETE FROM runs - WHERE start_hash=? AND end_hash=? - """ - - return q - - def _add_run_record(self, start_hash, end_hash, configuration_hash, cycle_idx): - params = (start_hash, end_hash, configuration_hash, cycle_idx) - - # do it as a transaction - c = self._db.cursor() - - # run the insert - c.execute(self.add_run_record_query, params) - - def _delete_run_record(self, start_hash, end_hash): - params = (start_hash, end_hash) - - cursor = self._db.cursor() - - cursor.execute(self.delete_run_record_query, params) - - def _update_run_record( - self, start_hash, end_hash, new_config_hash, new_last_cycle_idx - ): - params = (new_config_hash, new_last_cycle_idx, start_hash, end_hash) - - # do it as a transaction - c = self._db.cursor() - - # run the update - c.execute(self.update_run_record_query, params) - - def register_run(self, start_hash, end_hash, config_hash, cycle_idx): - """ - - Parameters - ---------- - start_hash : - - end_hash : - - config_hash : - - cycle_idx : int - The cycle of the simulation run the checkpoint was generated for. - - Returns - ------- - - """ - - # check that the hashes are for snapshots in the orchestrator - # if one is not registered raise an error - if not self.snapshot_hash_registered(start_hash): - raise OrchestratorError( - "snapshot start_hash {} is not registered with the orchestrator".format( - start_hash - ) - ) - - if not self.snapshot_hash_registered(end_hash): - raise OrchestratorError( - "snapshot end_hash {} is not registered with the orchestrator".format( - end_hash - ) - ) - - if not self.configuration_hash_registered(config_hash): - raise OrchestratorError( - "config hash {} is not registered with the orchestrator".format( - config_hash - ) - ) - - # save the configuration and get it's id - - self._add_run_record(start_hash, end_hash, config_hash, cycle_idx) - - def get_run_records(self): - get_run_record_query = """ - SELECT * - FROM runs - """.format( - fields=", ".join(self.RUN_SELECT_FIELDS) - ) - - cursor = self._db.cursor() - cursor.execute(get_run_record_query) - records = cursor.fetchall() - - return records - - def get_run_record(self, start_hash, end_hash): - get_run_record_query = """ - SELECT {fields} - FROM runs - WHERE start_hash=? AND end_hash=? - """.format( - fields=", ".join(self.RUN_SELECT_FIELDS) - ) - - params = (start_hash, end_hash) - - cursor = self._db.cursor() - cursor.execute(get_run_record_query, params) - record = cursor.fetchone() - - return record - - def run_last_cycle_idx(self, start_hash, end_hash): - record = self.get_run_record(start_hash, end_hash) - - last_cycle_idx = record[self.RUN_SELECT_FIELDS.index("last_cycle_idx")] - - return last_cycle_idx - - def run_configuration(self, start_hash, end_hash): - record = self.get_run_record(start_hash, end_hash) - - config_hash = record[self.RUN_SELECT_FIELDS.index("config_hash")] - - # get the configuration object and deserialize it - return self.deserialize(self.configuration_kv[config_hash]) - - def run_configuration_hash(self, start_hash, end_hash): - record = self.get_run_record(start_hash, end_hash) - - config_hash = record[self.RUN_SELECT_FIELDS.index("config_hash")] - - return config_hash - - def run_hashes(self): - return [(rec[0], rec[1]) for rec in self.get_run_records()] - - def run_continues(self, start_hash, end_hash): - """Given a start hash and end hash for a run, find the run that this - continues. - - Parameters - ---------- - start_hash : - - end_hash : - - - Returns - ------- - run_id - - """ - - # loop through the runs in this orchestrator until we find one - # where the start_hash matches the end hash - runs = self.run_hashes() - run_idx = 0 - while True: - run_start_hash, run_end_hash = runs[run_idx] - - # if the start hash of the queried run is the same as the - # end hash for this run we have found it - if start_hash == run_end_hash: - return (run_start_hash, run_end_hash) - - run_idx += 1 - - # if the index is over the number of runs we quit and - # return None as no match - if run_idx >= len(runs): - return None - - def _init_checkpoint_db(self, start_hash, configuration, checkpoint_dir, mode="x"): - logger.debug("Initializing checkpoint orch database") - - # make the checkpoint with the default filename at the checkpoint directory - checkpoint_path = osp.join(checkpoint_dir, self.DEFAULT_CHECKPOINT_FILENAME) - - # create a new database in the mode specified - logger.debug("Creating checkpoint database") - checkpoint_orch = Orchestrator(checkpoint_path, mode=mode) - - # add the starting snapshot, bypassing the serialization stuff - logger.debug("Setting the starting snapshot") - checkpoint_orch.snapshot_kv[start_hash] = self.snapshot_kv[start_hash] - - # if we have a new configuration at runtime serialize and - # hash it - serialized_config = self.serialize(configuration) - config_hash = self.hash_snapshot(serialized_config) - - # save the configuration as well - checkpoint_orch.configuration_kv[config_hash] = serialized_config - - checkpoint_orch.close() - logger.debug("closing connection to checkpoint database") - - return checkpoint_path, config_hash - - def _save_checkpoint( - self, - checkpoint_snapshot, - config_hash, - checkpoint_db_path, - cycle_idx, - ): - """ - - Parameters - ---------- - checkpoint_snapshot : - - config_hash : - - checkpoint_db_path : - - mode : - (Default value = 'wb') - - Returns - ------- - - """ - - # orchestrator wrapper to the db - logger.debug("Opening the checkpoint orch database") - checkpoint_orch = Orchestrator(checkpoint_db_path, mode="r+") - - # connection to the db - cursor = checkpoint_orch._db.cursor() - - # we replicate the code for adding the snapshot here because - # we want it to occur transactionally the delete and add - - # serialize the snapshot using the protocol for doing so - serialized_snapshot = self.serialize(checkpoint_snapshot) - - # get the hash of the snapshot - snaphash = self.hash_snapshot(serialized_snapshot) - - # the queries for deleting and inserting the new run record - delete_query = """ - DELETE FROM runs - WHERE start_hash=? - AND end_hash=? - """ - - insert_query = """ - INSERT INTO runs (start_hash, end_hash, config_hash, last_cycle_idx) - VALUES (?, ?, ?, ?) - """ - - # if there are any runs in the checkpoint orch remove the - # final snapshot - delete_params = None - if len(checkpoint_orch.run_hashes()) > 0: - start_hash, old_checkpoint_hash = checkpoint_orch.run_hashes()[0] - - delete_params = (start_hash, old_checkpoint_hash) - else: - start_hash = list(checkpoint_orch.snapshot_kv.keys())[0] - - # the config should already be in the orchestrator db - insert_params = (start_hash, snaphash, config_hash, cycle_idx) - - # start this whole process as a transaction so we don't get - # something weird in between - logger.debug("Starting transaction for updating run table in checkpoint") - cursor.execute("BEGIN TRANSACTION") - - # add the new one, using a special method for setting inside - # of a transaction - logger.debug("setting the new checkpoint snapshot into the KV") - cursor = checkpoint_orch.snapshot_kv.set_in_tx( - cursor, snaphash, serialized_snapshot - ) - logger.debug("finished") - - # if we need to delete the old end of the run snapshot and the - # run record for it - if delete_params is not None: - logger.debug("Old run record needs to be removed") - - # remove the old run from the run table - logger.debug("Deleting the old run record") - cursor.execute(delete_query, delete_params) - logger.debug("finished") - - # register the new run in the run table - logger.debug("Inserting the new run record") - cursor.execute(insert_query, insert_params) - logger.debug("finished") - - # end the transaction - logger.debug("Finishing transaction") - cursor.execute("COMMIT") - logger.debug("Transaction committed") - - # we do the removal of the old snapshot outside of the - # transaction since it is slow and can cause timeouts to - # occur. Furthermore, it is okay if it is in the checkpoint as - # the run record is what matters as long as the new checkpoint - # is there. - - # delete the old snapshot if we need to - if delete_params is not None: - # WARN: occasionally and for unknown reasons we have found - # that the final checkpoint hash is the same as the one - # before. (The case where the last snapshot is on the same - # cycle as a backup is already covered). So as a last - # resort, we check that they don't have the same hash. If - # they do we don't delete it! - if snaphash != old_checkpoint_hash: - logger.debug("Deleting the old snapshot") - del checkpoint_orch.snapshot_kv[old_checkpoint_hash] - logger.debug("finished") - else: - logger.warn( - "Final snapshot has same hash as the previous checkpoint. Not deleting the previous one." - ) - - checkpoint_orch.close() - logger.debug("closed the checkpoint orch connection") - - @staticmethod - def gen_sim_manager(start_snapshot, configuration): - """ - - Parameters - ---------- - start_snapshot : - - configuration : - - - Returns - ------- - - """ - - # construct the sim manager, in a wepy specific way - sim_manager = Manager( - start_snapshot.walkers, - runner=start_snapshot.apparatus.filters[0], - boundary_conditions=start_snapshot.apparatus.filters[1], - resampler=start_snapshot.apparatus.filters[2], - # configuration options - work_mapper=configuration.work_mapper, - reporters=configuration.reporters, - sim_monitor=configuration.monitor, - ) - - return sim_manager - - def run_snapshot_by_time( - self, - start_hash, - run_time, - n_steps, - checkpoint_freq=None, - checkpoint_dir=None, - configuration=None, - configuration_hash=None, - checkpoint_mode="x", - ): - """For a finished run continue it but resetting all the state of the - resampler and boundary conditions - - Parameters - ---------- - start_hash : - - run_time : - - n_steps : - - checkpoint_freq : - (Default value = None) - checkpoint_dir : - (Default value = None) - configuration : - (Default value = None) - configuration_hash : - (Default value = None) - checkpoint_mode : - (Default value = None) - - Returns - ------- - - """ - - # you must have a checkpoint dir if you ask for a checkpoint - # frequency - if checkpoint_freq is not None and checkpoint_dir is None: - raise ValueError( - "Must provide a directory for the checkpoint file " - "is a frequency is specified" - ) - - if configuration_hash is not None and configuration is not None: - raise ValueError( - "Cannot specify both a hash of an existing configuration" - "and provide a runtime configuration" - ) - - # if no configuration was specified we use the default one, oth - elif (configuration is None) and (configuration_hash is None): - configuration = self.get_default_configuration() - - # if a configuration hash was given only then we retrieve that - # configuration since we must pass configurations to the - # checkpoint DB initialization - elif configuration_hash is not None: - configuration = self.configuration_kv[configuration_hash] - - # check that the directory for checkpoints exists, and create - # it if it doesn't and isn't already created - if checkpoint_dir is not None: - checkpoint_dir = osp.realpath(checkpoint_dir) - os.makedirs(checkpoint_dir, exist_ok=True) - - # if the checkpoint dir is not specified don't create a - # checkpoint db orch - checkpoint_db_path = None - if checkpoint_dir is not None: - logger.debug("Initialization of checkpoint database is requested") - checkpoint_db_path, configuration_hash = self._init_checkpoint_db( - start_hash, configuration, checkpoint_dir, mode=checkpoint_mode - ) - logger.debug("finished initializing checkpoint database") - - # get the snapshot and the configuration to use for the sim_manager - start_snapshot = self.get_snapshot(start_hash) - - # generate the simulation manager given the snapshot and the - # configuration - sim_manager = self.gen_sim_manager(start_snapshot, configuration) - - # handle and process the optional arguments for running simulation - if "runner" in configuration.apparatus_opts: - runner_opts = configuration.apparatus_opts["runner"] - else: - runner_opts = None - - # run the init subroutine for the simulation manager - logger.debug("Running sim_manager.init") - sim_manager.init() - - # run each cycle manually creating checkpoints when necessary - logger.debug("Starting run loop") - walkers = sim_manager.init_walkers - cycle_idx = 0 - start_time = time.time() - while time.time() - start_time < run_time: - logger.debug("Running cycle {}".format(cycle_idx)) - # run the cycle - walkers, filters = sim_manager.run_cycle( - walkers, - n_steps, - cycle_idx, - runner_opts=runner_opts, - ) - - # check to see if a checkpoint is necessary - if checkpoint_freq is not None: - if cycle_idx % checkpoint_freq == 0: - logger.debug("Checkpoint is required for this cycle") - - # make the checkpoint snapshot - logger.debug("Generating the simulation snapshot") - checkpoint_snapshot = SimSnapshot(walkers, SimApparatus(filters)) - - # save the checkpoint (however that is implemented) - logger.debug("saving the checkpoint to the database") - self._save_checkpoint( - checkpoint_snapshot, - configuration_hash, - checkpoint_db_path, - cycle_idx, - ) - logger.debug("finished saving the checkpoint to the database") - - # increase the cycle index for the next cycle - cycle_idx += 1 - - logger.debug("Finished the run cycle") - - # the cycle index was set for the next cycle which didn't run - # so we decrement it - last_cycle_idx = cycle_idx - 1 - - logger.debug("Running sim_manager.cleanup") - # run the cleanup subroutine - sim_manager.cleanup() - - # run the segment given the sim manager and run parameters - end_snapshot = SimSnapshot(walkers, SimApparatus(filters)) - - logger.debug("Run finished") - # return the things necessary for saving to the checkpoint if - # that is what is wanted later on - return end_snapshot, configuration_hash, checkpoint_db_path, last_cycle_idx - - def orchestrate_snapshot_run_by_time( - self, - snapshot_hash, - run_time, - n_steps, - checkpoint_freq=None, - checkpoint_dir=None, - orchestrator_path=None, - configuration=None, - # these can reparametrize the paths - # for both the orchestrator produced - # files as well as the configuration - work_dir=None, - config_name=None, - narration=None, - mode=None, - # extra kwargs will be passed to the - # configuration.reparametrize method - **kwargs, - ): - """ - - Parameters - ---------- - snapshot_hash : - - run_time : - - n_steps : - - checkpoint_freq : - (Default value = None) - checkpoint_dir : - (Default value = None) - orchestrator_path : - (Default value = None) - configuration : - (Default value = None) - # these can reparametrize the paths# for both the orchestrator produced# files as well as the configurationwork_dir : - (Default value = None) - config_name : - (Default value = None) - narration : - (Default value = None) - mode : - (Default value = None) - # extra kwargs will be passed to the# configuration.reparametrize method**kwargs : - - - Returns - ------- - - """ - - # for writing the orchestration files we set the default mode - # if mode is not given - if mode is None: - # the orchestrator mode is used for pickling the - # orchestrator and so must be in bytes mode - orch_mode = self.DEFAULT_ORCHESTRATION_MODE - - # there are two possible uses for the path reparametrizations: - # the configuration and the orchestrator file paths. If both - # of those are explicitly specified by passing in the whole - # configuration object or both of checkpoint_dir, - # orchestrator_path then those reparametrization kwargs will - # not be used. As this is likely not the intention of the user - # we will raise an error. If there is even one use for them no - # error will be raised. - - # first check if any reparametrizations were even requested - parametrizations_requested = ( - True if work_dir is not None else False, - True if config_name is not None else False, - True if narration is not None else False, - True if mode is not None else False, - ) - - # check if there are any available targets for reparametrization - reparametrization_targets = ( - True if configuration is None else False, - True if checkpoint_dir is None else False, - True if orchestrator_path is None else False, - ) - - # if paramatrizations were requested and there are no targets - # we need to raise an error - if any(parametrizations_requested) and not any(reparametrization_targets): - raise OrchestratorError( - "Reparametrizations were requested but none are possible," - " due to all possible targets being already explicitly given" - ) - - # if any paths were not given and no defaults for path - # parameters we want to fill in the defaults for them. This - # will also fill in any missing parametrizations with defaults - - # we do this by just setting the path parameters if they - # aren't set, then later the parametrization targets will be - # tested for if they have been set or not, and if they haven't - # then these will be used to generate paths for them. - if work_dir is None: - work_dir = self.DEFAULT_WORKDIR - if config_name is None: - config_name = self.DEFAULT_CONFIG_NAME - if narration is None: - narration = self.DEFAULT_NARRATION - if mode is None: - mode = self.DEFAULT_MODE - - # if no configuration was specified use the default one - if configuration is None: - configuration = self.get_default_configuration() - - # reparametrize the configuration with the given path - # parameters and anything else in kwargs. If they are none - # this will have no effect anyhow - logger.debug("Reparametrizing the configuration") - configuration = configuration.reparametrize( - work_dir=work_dir, - config_name=config_name, - narration=narration, - mode=mode, - **kwargs, - ) - - # make parametric paths for the checkpoint directory and the - # orchestrator pickle to be made, unless they are explicitly given - - if checkpoint_dir is None: - # the checkpoint directory will be in the work dir - logger.debug("checkpoint directory defaulted to the work_dir") - checkpoint_dir = work_dir - - logger.debug("In the orchestrate run, calling to run_snapshot by time") - # then actually run the simulation with checkpointing. This - # returns the end snapshot and doesn't write out anything to - # orchestrators other than the checkpointing - ( - end_snapshot, - configuration_hash, - checkpoint_db_path, - last_cycle_idx, - ) = self.run_snapshot_by_time( - snapshot_hash, - run_time, - n_steps, - checkpoint_freq=checkpoint_freq, - checkpoint_dir=checkpoint_dir, - configuration=configuration, - checkpoint_mode=orch_mode, - ) - - logger.debug("Finished running snapshot by time") - - # if the last cycle in the run was a checkpoint skip this step - # of saving a checkpoint - do_final_checkpoint = True - - # make sure the checkpoint_freq is defined before testing it - if checkpoint_freq is not None: - if checkpoint_freq % last_cycle_idx == 0: - logger.debug("Last cycle saved a checkpoint, no need to save one") - do_final_checkpoint = False - - if do_final_checkpoint: - logger.debug("Saving a final checkpoint for the end of the run") - # now that it is finished we save the final snapshot to the - # checkpoint file. This is done transactionally using the - # SQLite transaction functionality (either succeeds or doesn't - # happen) that way we don't have worry about data integrity - # loss. Here we also don't have to worry about other processes - # interacting with the checkpoint which makes it isolated. - self._save_checkpoint( - end_snapshot, configuration_hash, checkpoint_db_path, last_cycle_idx - ) - logger.debug("Finished saving the final checkpoint for the run") - - # then return the final orchestrator - logger.debug("Getting a connection to that orch to retun") - checkpoint_orch = Orchestrator(checkpoint_db_path, mode="r+", append_only=True) - - return checkpoint_orch - - -def reconcile_orchestrators(host_path, *orchestrator_paths): - """ - - Parameters - ---------- - template_orchestrator : - - *orchestrators : - - - Returns - ------- - - """ - - if not osp.exists(host_path): - assert ( - len(orchestrator_paths) > 1 - ), "If the host path is a new orchestrator, must give at least 2 orchestrators to merge." - - # open the host orchestrator at the location which will have all - # of the new things put into it from the other orchestrators. If - # it doesn't already exist it will be created otherwise open - # read-write. - new_orch = Orchestrator(orch_path=host_path, mode="a", append_only=True) - - # TODO deprecate, if there is no defaults we can't set them since - # the mode is append only, we don't really care about these so - # don't set them, otherwise do some mode logic to figure this out - # and open in write mode and set defaults, then change to append - # only - - # # if this is an existing orchestrator copy the default - # # sim_apparatus and init_walkers - # try: - # default_app = new_orch.get_default_sim_apparatus() - # except KeyError: - # # no default apparatus, that is okay - # pass - # else: - # # set it - # new_orch.set_default_sim_apparatus(default_app) - - # # same for the initial walkers - # try: - # default_walkers = new_orch.get_default_init_walkers() - # except KeyError: - # # no default apparatus, that is okay - # pass - # else: - # # set it - # new_orch.set_default_sim_apparatus(default_walkers) - - for orch_path in orchestrator_paths: - # open it in read-write fail if doesn't exist - orch = Orchestrator(orch_path=orch_path, mode="r+", append_only=True) - - # add in all snapshots from each orchestrator, by the hash not the - # snapshots themselves, we trust they are correct - for snaphash in orch.snapshot_hashes: - # check that the hash is not already in the snapshots - if any( - [True if snaphash == md5 else False for md5 in new_orch.snapshot_hashes] - ): - # skip it and move on - continue - - # if it is not copy it over without deserializing - new_orch.snapshot_kv[snaphash] = orch.snapshot_kv[snaphash] - - # add in the configurations for the runs from each - # orchestrator, by the hash not the snapshots themselves, we - # trust they are correct - for run_id in orch.run_hashes(): - config_hash = orch.run_configuration_hash(*run_id) - - # check that the hash is not already in the snapshots - if any( - [ - True if config_hash == md5 else False - for md5 in new_orch.configuration_hashes - ] - ): - # skip it and move on - continue - - # if it is not set it - new_orch.configuration_kv[config_hash] = orch.configuration_kv[config_hash] - - # concatenate the run table with an SQL union from an attached - # database - - attached_table_name = "other" - - # query to attach the foreign database - attach_query = """ - ATTACH '{}' AS {} - """.format( - orch_path, attached_table_name - ) - - # query to update the runs tabel with new unique runs - union_query = """ - INSERT INTO runs - SELECT * FROM ( - SELECT * FROM {}.runs - EXCEPT - SELECT * FROM runs - ) - """.format( - attached_table_name - ) - - # query to detach the table - detach_query = """ - DETACH {} - """.format( - attached_table_name - ) - - # then run the queries - - cursor = new_orch._db.cursor() - try: - cursor.execute("BEGIN TRANSACTION") - cursor.execute(attach_query) - cursor.execute(union_query) - cursor.execute("COMMIT") - cursor.execute(detach_query) - except: - cursor.execute("COMMIT") - # Standard Library - import pdb - - pdb.set_trace() - cursor.execute( - "SELECT * FROM (SELECT * FROM other.runs EXCEPT SELECT * FROM runs)" - ) - recs = cursor.fetchall() - - return new_orch diff --git a/src/wepy/orchestration/snapshot.py b/src/wepy/orchestration/snapshot.py deleted file mode 100644 index ee7e49bc..00000000 --- a/src/wepy/orchestration/snapshot.py +++ /dev/null @@ -1,73 +0,0 @@ -# Standard Library -from copy import copy, deepcopy - - -class SimApparatus: - """The simulation apparatus are the components needed for running a - simulation without the initial conditions for starting the simulation. - - A runner is strictly necessary but a resampler and boundary - conditions are not. - - Parameters - ---------- - - Returns - ------- - - """ - - def __init__(self, filters): - self._filters = deepcopy(filters) - - @property - def filters(self): - """ """ - return self._filters - - -class WepySimApparatus(SimApparatus): - """ """ - - RUNNER_IDX = 0 - BC_IDX = 1 - RESAMPLER_IDX = 2 - - def __init__(self, runner, resampler=None, boundary_conditions=None): - if resampler is None: - raise ValueError("must provide a resampler") - - # add them in the order they are done in Wepy - filters = [runner, boundary_conditions, resampler] - - super().__init__(filters) - - @property - def runner(self): - return self.filters[self.RUNNER_IDX] - - @property - def boundary_conditions(self): - return self.filters[self.BC_IDX] - - @property - def resampler(self): - return self.filters[self.RESAMPLER_IDX] - - -class SimSnapshot: - """ """ - - def __init__(self, walkers, apparatus): - self._walkers = deepcopy(walkers) - self._apparatus = deepcopy(apparatus) - - @property - def walkers(self): - """ """ - return self._walkers - - @property - def apparatus(self): - """ """ - return self._apparatus diff --git a/src/pytest_wepy/openmm.py b/src/wepy/py.typed similarity index 100% rename from src/pytest_wepy/openmm.py rename to src/wepy/py.typed diff --git a/src/wepy/reporter/base.py b/src/wepy/reporter/base.py new file mode 100644 index 00000000..0d8a41a0 --- /dev/null +++ b/src/wepy/reporter/base.py @@ -0,0 +1,151 @@ +# Standard Library +import logging +from typing import Any, Protocol, TypedDict + +# Third Party Library + +# First Party Library +from wepy.boundary_conditions.boundary import BoundaryConditions +from wepy.resampling.resamplers.resampler import Resampler +from wepy.runners.runner import Runner +from wepy.walker import Walker +from wepy.work_mapper.base import WorkMapper + +logger = logging.getLogger(__name__) + + +class ReporterError(Exception): + pass + + +class SimComponentArgs(TypedDict): + init_walkers: list[Walker] + runner: Runner + resampler: Resampler + boundary_conditions: BoundaryConditions | None + work_mapper: WorkMapper + reporters: list["Reporter"] + continue_run: int | None + + +class CycleReportDict(TypedDict): + cycle_idx: int + new_walkers: list[Walker] + # TODO: types for all the Anys + warp_data: list[Any] + bc_data: list[Any] + progress_data: dict[str, Any] + resampling_data: list[list[dict[str, Any]]] + resampler_data: list[dict[str, Any]] + n_segment_steps: int + resampled_walkers: list[Walker] + runner_precycle_time: float + runner_postcycle_time: float + sim_manager_segment_overhead_time: float + runner_splits_time: dict[str, float] | None + worker_segment_times: dict[int, list[float]] | None + cycle_sim_manager_segment_time: float + cycle_runner_time: float + cycle_bc_time: float + cycle_resampling_time: float + + +class Reporter(Protocol): + """Abstract base class for wepy reporters. + + All reporters must customize and override minimally the 'report' + method. Optionally the 'init' and 'cleanup' can be overriden. + + """ + + def init( + self, + **kwargs: SimComponentArgs, + ) -> None: + """Initialization routines for the reporter at simulation runtime. + + Initialize I/O connections including file descriptors, + database connections, timers, stdout/stderr etc. + + Void method for reporter base class. + + Reporters can expect to have the following key word arguments + passed to them during a simulation by the sim_manager in this + call. + + + Parameters + ---------- + init_walkers : list of Walker objects + The initial walkers for the simulation. + + runner : Runner object + The runner that will be used in the simulation. + + resampler : Resampler object + The resampler that will be used in the simulation. + + boundary_conditions : BoundaryConditions object + The boundary conditions taht will be used in the simulation. + + work_mapper : WorkMapper object + The work mapper that will be used in the simulation. + + reporters : list of Reporter objects + The list of reporters that are in the simulation. + + continue_run : int or None + The index of the run that is being continued within this + same file. + + """ + ... + + def report( + self, + **kwargs: CycleReportDict, + ) -> None: + """Given data concerning the main simulation components state, perform + I/O operations to persist that data. + + Void method for reporter base class. + + Reporters can expect to have the following key word arguments + passed to them during a simulation by the sim_manager. + + """ + ... + + def cleanup( + self, + **kwargs: SimComponentArgs, + ) -> None: + """Teardown routines for the reporter at the end of the simulation. + + Use to cleanly and safely close I/O connections or other + cleanup I/O. + + Use to close file descriptors, database connections etc. + + Reporters can expect to have the following key word arguments + passed to them during a simulation by the sim_manager. + + Parameters + ---------- + runner : Runner object + The runner at the end of the simulation + + work_mapper : WorkeMapper object + The work mapper at the end of the simulation + + resampler : Resampler object + The resampler at the end of the simulation + + boundary_conditions : BoundaryConditions object + The boundary conditions at the end of the simulation + + reporters : list of Reporter objects + The list of reporters at the end of the simulation + + """ + ... diff --git a/src/wepy/reporter/dashboard.py b/src/wepy/reporter/dashboard.py index fbe2d759..3d98904d 100644 --- a/src/wepy/reporter/dashboard.py +++ b/src/wepy/reporter/dashboard.py @@ -3,15 +3,13 @@ """ # Standard Library -import itertools as it +import datetime import logging - -logger = logging.getLogger(__name__) -# Standard Library +import textwrap import time -from collections import defaultdict from copy import copy -from datetime import datetime +from pathlib import Path +from typing import TypedDict # Third Party Library import numpy as np @@ -20,79 +18,388 @@ from tabulate import tabulate # First Party Library -from wepy.reporter.reporter import ProgressiveFileReporter +from wepy.reporter.base import CycleReportDict, SimComponentArgs +from wepy.reporter.file import FileMode, ProgressiveFileReporterABC +logger = logging.getLogger(__name__) -class DashboardReporter(ProgressiveFileReporter): - """A text based report of the status of a wepy simulation. - This serves as a container for different dashboard components to - go inside. +class WalkersSummaryReport(TypedDict): + total: float + min: float + max: float - """ - FILE_ORDER = ("dashboard_path",) - SUGGESTED_EXTENSIONS = ("dash.org",) +class WorkerRecord(TypedDict): + cycle_idx: int + n_steps: int + worker_idx: int + segment_time: int - # TODO: add in a section for showing the number of walkers in each - # cycle. This isn't relevant for our constant walker number - # simulations though so I have elided it following YAGNI - SIMULATION_SECTION_TEMPLATE = """ -Init Datetime: {{ init_date_time }} -Last write Datetime: {{ curr_date_time }} -Total Run time: {{ total_run_time }} s -Last Cycle Index: {{ last_cycle_idx }} -Number of Cycles: {{ n_cycles }} +class GenSimSectionReport(TypedDict): -** Walkers Summary -{{ walker_cycle_summary_table }} -""" + init_date_time: datetime.datetime + curr_date_time: datetime.datetime + total_run_time: int + last_cycle_idx: int + n_cycles: int + walker_cycle_summary_table: str - PERFORMANCE_SECTION_TEMPLATE = """ -Average Cycle Time: {{ avg_cycle_time }} -{% if avg_runner_time %}Average Runner Time: {{ avg_runner_time }}{% else %}{% endif %} -{% if avg_bc_time %}Average Boundary Conditions Time: {{ avg_bc_time }}{% else %}{% endif %} -{% if avg_resampling_time %}Average Resampling Time: {{ avg_resampling_time }}{% else %}{% endif %} -** Worker Avg. Segment Times: -{{ worker_avg_segment_time }} +class PerformanceSectionReport(TypedDict): + avg_cycle_time: int + worker_avg_segment_time: int + cycle_log: str + performance_log: str + avg_runner_time: int | None + avg_bc_time: int | None + avg_resampler_time: int | None -** Cycle Performance Log -{{ cycle_log }} -** Worker Performance Log -{{ performance_log }} -""" +class ResamplerFieldReport(TypedDict): + name: str - DASHBOARD_TEMPLATE = """* Simulation -{{ simulation }} +class RunnerFieldReport(TypedDict): + name: str -{% if resampler %}* Resampler{% else %}{% endif %} -{% if resampler %}{{ resampler }}{% else %}{% endif %} -{% if boundary_condition %}* Boundary Condition{% else %}{% endif %} -{% if boundary_condition %}{{ boundary_condition }}{% else %}{% endif %} +class BCFieldReport(TypedDict): + name: str + total_n_walker_segments: int + total_crossings: int + total_crossed_weight: float + progress_summary_table: str + warping_log: str -{% if runner %}* Runner{% else %}{% endif %} -{% if runner %}{{ runner }}{% else %}{% endif %} +class ResamplerDashboardSection: + RESAMPLER_SECTION_TEMPLATE = textwrap.dedent( + """ + Resampler: {{ name }} + """ + ) -* Performance -{{ performance }} -""" + def __init__(self, resampler=None, name=None, **kwargs): + if resampler is not None: + self.resampler_name = type(resampler).__name__ + + elif name is not None: + self.resampler_name = name + + else: + self.resampler_name = "Unknown" + + def update_values(self, **kwargs: CycleReportDict): + pass + + def gen_fields(self, **kwargs) -> ResamplerFieldReport: + fields = ResamplerFieldReport( + { + "name": self.resampler_name, + } + ) + + return fields + + def gen_resampler_section(self, **kwargs: CycleReportDict) -> str: + section_kwargs = self.gen_fields(**kwargs) + + section_str = Template(self.RESAMPLER_SECTION_TEMPLATE).render(**section_kwargs) + + return section_str + + +class RunnerDashboardSection: + RUNNER_SECTION_TEMPLATE = textwrap.dedent( + """ + Runner: {{ name }} + """ + ) + + def __init__(self, runner_factory=None, name=None): + if runner_factory is not None: + self.runner_name = runner_factory.type().__name__ + + elif name is not None: + self.runner_name = name + + else: + self.runner_name = "Unknown" + + def update_values(self, **kwargs): + pass + + def gen_fields(self, **kwargs: CycleReportDict) -> RunnerFieldReport: + fields = {"name": self.runner_name} + + return fields + + def gen_runner_section(self, **kwargs: CycleReportDict) -> str: + section_kwargs = self.gen_fields(**kwargs) + + section_str = Template(self.RUNNER_SECTION_TEMPLATE).render(**section_kwargs) + + return section_str + + +class BCDashboardSection: + BC_SECTION_TEMPLATE = textwrap.dedent( + """ + + Boundary Condition: {{ name }} + + Total Number of Dynamics segments: {{ total_n_walker_segments }} + + Total Number of Crossings: {{ total_crossings }} + + Cumulative Boundary Crossed Weight: {{ total_crossed_weight }} + + ** Progress Log + + {{ progress_summary_table }} + + + ** Warping Log + + {{ warping_log }} + + """ + ) + WARP_RECORD_COLNAMES = ( + "cycle_idx", + "walker_idx", + "weight", + "target_idx", + "discontinuous", + ) + + def __init__(self, bc=None, discontinuities=None, name=None, **kwargs): + if bc is not None: + self.bc_name = type(bc).__name__ + + elif name is not None: + self.bc_name = name + + else: + self.bc_name = "Unknown" + + if bc is not None: + self.bc_discontinuities = copy(bc.DISCONTINUITY_TARGET_IDXS) + + else: + assert ( + discontinuities is not None + ), "If the bc is not given must give parameter: discontinuities" + self.bc_discontinuities = discontinuities + + self.warp_records = [] + self.total_n_walker_segments = 0 + self.total_crossings = 0 + self.total_crossed_weight = 0.0 + + # progress statistics + self.progress_summaries = [] + + def calc_progress_summary(self, **kwargs): + prog_data_dic = kwargs["progress_data"] + + if len(prog_data_dic) == 0: + return { + "min": np.nan, + "max": np.nan, + "mean": np.nan, + } + + else: + prog_data_key = [*prog_data_dic][0] + + prog_data = prog_data_dic[prog_data_key] + + return { + "min": np.min(prog_data), + "max": np.max(prog_data), + "mean": np.mean(prog_data), + } + + def update_values(self, **kwargs): + # keep track of exactly how many walker segments are run, this + # is useful for rate calculations via Hill's relation. + self.total_n_walker_segments += len(kwargs["new_walkers"]) + + # report on the walker progress + self.progress_summaries.append(self.calc_progress_summary(**kwargs)) + + # just create the bare warp records, since we know no more + # domain knowledge, feel free to override and add more data to + # this table + for warp_record in kwargs["warp_data"]: + # the cycle + cycle_idx = kwargs["cycle_idx"] + + # the individual values from the warp record + weight = warp_record["weight"][0] + walker_idx = warp_record["walker_idx"][0] + target_idx = warp_record["target_idx"][0] + + # determine if it was discontinuous + + # all targets are discontinuous + if self.bc_discontinuities is Ellipsis: + discont = True + # none of them are discontinuous + elif self.bc_discontinuities is None: + discont = False + # then it is a list of the discontinuous targets + else: + discont = True if target_idx in self.bc_discontinuities else False + + record = (cycle_idx, walker_idx, weight, target_idx, discont) + self.warp_records.append(record) + + self.total_crossings = len(self.warp_records) + self.total_crossed_weight = np.sum([r[2] for r in self.warp_records]) - def __init__(self, resampler_dash=None, runner_dash=None, bc_dash=None, **kwargs): + def gen_fields(self, **kwargs: CycleReportDict) -> BCFieldReport: + # make the table for the collected warping records + warp_table_df = pd.DataFrame( + self.warp_records, columns=self.WARP_RECORD_COLNAMES + ) + warp_table_str = tabulate( + warp_table_df, headers=warp_table_df.columns, tablefmt="orgtbl" + ) + + prog_df = pd.DataFrame(self.progress_summaries) + prog_summary_tbl_str = tabulate( + prog_df, headers=prog_df.columns, tablefmt="orgtbl" + ) + + fields = { + "name": self.bc_name, + "total_n_walker_segments": self.total_n_walker_segments, + "total_crossings": self.total_crossings, + "total_crossed_weight": self.total_crossed_weight, + "progress_summary_table": prog_summary_tbl_str, + "warping_log": warp_table_str, + } + + return fields + + def gen_bc_section(self, **kwargs: CycleReportDict) -> str: + section_kwargs = self.gen_fields(**kwargs) + + section_str = Template(self.BC_SECTION_TEMPLATE).render(**section_kwargs) + + return section_str + + +class DashboardReporter(ProgressiveFileReporterABC): + """A text based report of the status of a wepy simulation. + + This serves as a container for different dashboard components to + go inside. + + """ + + FILE_ORDER = ("dashboard_path",) + SUGGESTED_EXTENSIONS = ("wepy_dash.org",) + + # TODO: add in a section for showing the number of walkers in each + # cycle. This isn't relevant for our constant walker number + # simulations though so I have elided it following YAGNI + + SIMULATION_SECTION_TEMPLATE = textwrap.dedent( """ - Parameters - ---------- - resampler_dash - runner_dash - bc_dash + Init Datetime: {{ init_date_time }} + Last write Datetime: {{ curr_date_time }} + Total Run time: {{ total_run_time }} s + Last Cycle Index: {{ last_cycle_idx }} + Number of Cycles: {{ n_cycles }} + + ** Walkers Summary + {{ walker_cycle_summary_table }} """ + ) + + PERFORMANCE_SECTION_TEMPLATE = textwrap.dedent( + """ + Average Cycle Time: {{ avg_cycle_time }} + {% if avg_runner_time %}Average Runner Time: {{ avg_runner_time }}{% else %}{% endif %} + {% if avg_bc_time %}Average Boundary Conditions Time: {{ avg_bc_time }}{% else %}{% endif %} + {% if avg_resampling_time %}Average Resampling Time: {{ avg_resampling_time }}{% else %}{% endif %} + + ** Worker Avg. Segment Times: + {{ worker_avg_segment_time }} + + ** Cycle Performance Log + {{ cycle_log }} + + ** Worker Performance Log + {{ performance_log }} + """ + ) + DASHBOARD_TEMPLATE = textwrap.dedent( + """ + * Simulation + {{ simulation }} + {% if resampler -%} + + * Resampler + {{ resampler }} + + {%- endif %} + {% if boundary_condition -%} + + * Boundary Condition + {{ boundary_condition }} + + {%- endif %} + {% if runner -%} + + * Runner + {{ runner }} + + {%- endif %} + * Performance + + {{ performance }} + """ + ) - super().__init__(**kwargs) + file_path: Path + mode: FileMode + + resampler_dash: ResamplerDashboardSection | None + runner_dash: RunnerDashboardSection | None + bc_dash: BCDashboardSection | None + + n_cycles: int + init_date_time: datetime.datetime | None + init_sys_time: int | None + total_run_time: int | None + walker_prob_summaries: list[WalkersSummaryReport] + cycle_compute_times: list[int] + cycle_runner_times: list[int] + cycle_bc_times: list[int] + cycle_resampling_times: list[int] + worker_records: list[WorkerRecord] + worker_agg_table: pd.DataFrame | None + + avg_runner_time: int | None + avg_bc_time: int | None + avg_resampling_time: int | None + avg_cycle_time: int | None + + def __init__( + self, + path: Path, + resampler_dash: ResamplerDashboardSection | None = None, + runner_dash: RunnerDashboardSection | None = None, + bc_dash: BCDashboardSection | None = None, + ) -> None: + + super().__init__(file_paths=[path]) self.resampler_dash = resampler_dash self.runner_dash = runner_dash @@ -121,55 +428,61 @@ def __init__(self, resampler_dash=None, runner_dash=None, bc_dash=None, **kwargs self.cycle_bc_times = [] self.cycle_resampling_times = [] self.worker_records = [] + self.worker_agg_table = None + + self.avg_runner_time = None + self.avg_bc_time = None + self.avg_resampling_time = None + self.avg_cycle_time = None + + @property + def mode(self) -> FileMode: + return self.modes[0] + + @property + def file_path(self) -> Path: + return self.file_paths[0] + + def init(self, **kwargs: SimComponentArgs) -> None: - def init(self, **kwargs): super().init(**kwargs) - self.init_date_time = datetime.today() + logger.info(f"DashboardReporter will write to: {self.file_path}") + + self.init_date_time = datetime.datetime.today() self.total_run_time = self.init_date_time self.init_sys_time = time.time() - def calc_walker_summary(self, **kwargs): + def calc_walker_summary(self, **kwargs: CycleReportDict) -> WalkersSummaryReport: walker_weights = [walker.weight for walker in kwargs["new_walkers"]] - summary = { - "total": np.sum(walker_weights), - "min": np.min(walker_weights), - "max": np.max(walker_weights), - } + summary = WalkersSummaryReport( + { + "total": np.sum(walker_weights), + "min": np.min(walker_weights), + "max": np.max(walker_weights), + } + ) return summary - def update_values(self, **kwargs): - ### simulation - - self.n_cycles += 1 - self.walker_prob_summaries.append(self.calc_walker_summary(**kwargs)) - - self.update_performance_values(**kwargs) - - # update all the sections values - if self.resampler_dash is not None: - self.resampler_dash.update_values(**kwargs) - if self.runner_dash is not None: - self.runner_dash.update_values(**kwargs) - if self.bc_dash is not None: - self.bc_dash.update_values(**kwargs) - - def update_performance_values(self, **kwargs): + def update_performance_values(self, **kwargs: CycleReportDict) -> None: ## worker specific performance # only do this part if there were any workers - if len(kwargs["worker_segment_times"]) > 0: + if ( + kwargs["worker_segment_times"] is not None + and len(kwargs["worker_segment_times"]) > 0 + ): # log of segment times for workers for worker_idx, segment_times in kwargs["worker_segment_times"].items(): for segment_time in segment_times: - record = ( - kwargs["cycle_idx"], - kwargs["n_segment_steps"], - worker_idx, - segment_time, + record = WorkerRecord( + cycle_idx=kwargs["cycle_idx"], + n_steps=kwargs["n_segment_steps"], + worker_idx=worker_idx, + segment_time=segment_time, ) self.worker_records.append(record) @@ -217,14 +530,29 @@ def update_performance_values(self, **kwargs): # average cycle time self.avg_cycle_time = np.mean(self.cycle_compute_times) - def write_dashboard(self, report_str): + def update_values(self, **kwargs: CycleReportDict) -> None: + ### simulation + + self.n_cycles += 1 + self.walker_prob_summaries.append(self.calc_walker_summary(**kwargs)) + + self.update_performance_values(**kwargs) + + # update all the sections values + if self.resampler_dash is not None: + self.resampler_dash.update_values(**kwargs) + if self.runner_dash is not None: + self.runner_dash.update_values(**kwargs) + if self.bc_dash is not None: + self.bc_dash.update_values(**kwargs) + + def write_dashboard(self, report_str: str) -> None: """Write the dashboard to the file.""" with open(self.file_path, mode=self.mode) as dashboard_file: dashboard_file.write(report_str) - def gen_sim_section(self, **kwargs): - """""" + def gen_sim_section(self, **kwargs: CycleReportDict) -> str: walker_df = pd.DataFrame(self.walker_prob_summaries) walker_summary_tbl_str = tabulate( @@ -232,14 +560,16 @@ def gen_sim_section(self, **kwargs): ) # render the simulation section - sim_section_d = { - "init_date_time": self.init_date_time, - "curr_date_time": datetime.today().isoformat(), - "total_run_time": time.time() - self.init_sys_time, - "last_cycle_idx": kwargs["cycle_idx"], - "n_cycles": self.n_cycles, - "walker_cycle_summary_table": walker_summary_tbl_str, - } + sim_section_d = GenSimSectionReport( + { + "init_date_time": self.init_date_time, + "curr_date_time": datetime.datetime.today().isoformat(), + "total_run_time": time.time() - self.init_sys_time, + "last_cycle_idx": kwargs["cycle_idx"], + "n_cycles": self.n_cycles, + "walker_cycle_summary_table": walker_summary_tbl_str, + } + ) sim_section_str = Template(self.SIMULATION_SECTION_TEMPLATE).render( **sim_section_d @@ -247,7 +577,7 @@ def gen_sim_section(self, **kwargs): return sim_section_str - def gen_performance_section(self, **kwargs): + def gen_performance_section(self, **kwargs: CycleReportDict) -> str: # log of cycle times cycle_table_colnames = ( "cycle_time (s)", @@ -258,7 +588,7 @@ def gen_performance_section(self, **kwargs): cycle_table_df = pd.DataFrame( { - "cycle_times (s)": self.cycle_compute_times, + "cycle_time (s)": self.cycle_compute_times, "runner_time (s)": self.cycle_runner_times, "boundary_conditions_time (s)": self.cycle_bc_times, "resampling_time (s)": self.cycle_resampling_times, @@ -267,7 +597,9 @@ def gen_performance_section(self, **kwargs): ) cycle_table_str = tabulate( - cycle_table_df, headers=cycle_table_df.columns, tablefmt="orgtbl" + cycle_table_df, + headers=cycle_table_df.columns, + tablefmt="orgtbl", ) # log of workers performance @@ -275,10 +607,11 @@ def gen_performance_section(self, **kwargs): "cycle_idx", "n_steps", "worker_idx", - "segment_time (s)", + "segment_time", ) worker_table_df = pd.DataFrame( - self.worker_records, columns=worker_table_colnames + self.worker_records, + columns=worker_table_colnames, ) worker_table_str = tabulate( worker_table_df, @@ -294,16 +627,18 @@ def gen_performance_section(self, **kwargs): tablefmt="orgtbl", ) - performance_section_d = { - "avg_cycle_time": self.avg_cycle_time, - "worker_avg_segment_time": worker_agg_table_str, - "cycle_log": cycle_table_str, - "performance_log": worker_table_str, - # optionals - "avg_runner_time": self.avg_runner_time, - "avg_bc_time": self.avg_bc_time, - "avg_resampling_time": self.avg_resampling_time, - } + performance_section_d = PerformanceSectionReport( + { + "avg_cycle_time": self.avg_cycle_time, + "worker_avg_segment_time": worker_agg_table_str, + "cycle_log": cycle_table_str, + "performance_log": worker_table_str, + # optionals + "avg_runner_time": self.avg_runner_time, + "avg_bc_time": self.avg_bc_time, + "avg_resampling_time": self.avg_resampling_time, + } + ) performance_section_str = Template(self.PERFORMANCE_SECTION_TEMPLATE).render( **performance_section_d @@ -311,9 +646,10 @@ def gen_performance_section(self, **kwargs): return performance_section_str - def report(self, **kwargs): + def report(self, **kwargs: CycleReportDict) -> None: # update the values that update each call to report + logger.debug("Updating values") self.update_values(**kwargs) # the two sections that are always there @@ -350,215 +686,5 @@ def report(self, **kwargs): ) # write the thing + logger.info(f"Writing dashboard at: {self.file_path}") self.write_dashboard(report_str) - - -class ResamplerDashboardSection: - RESAMPLER_SECTION_TEMPLATE = """ -Resampler: {{ name }} -""" - - def __init__(self, resampler=None, name=None, **kwargs): - if resampler is not None: - self.resampler_name = type(resampler).__name__ - - elif name is not None: - self.resampler_name = name - - else: - self.resampler_name = "Unknown" - - def update_values(self, **kwargs): - pass - - def gen_fields(self, **kwargs): - fields = {"name": self.resampler_name} - - return fields - - def gen_resampler_section(self, **kwargs): - section_kwargs = self.gen_fields(**kwargs) - - section_str = Template(self.RESAMPLER_SECTION_TEMPLATE).render(**section_kwargs) - - return section_str - - -class RunnerDashboardSection: - RUNNER_SECTION_TEMPLATE = """ -Runner: {{ name }} -""" - - def __init__(self, runner=None, name=None, **kwargs): - if runner is not None: - self.runner_name = type(runner).__name__ - - elif name is not None: - self.runner_name = name - - else: - self.runner_name = "Unknown" - - def update_values(self, **kwargs): - pass - - def gen_fields(self, **kwargs): - fields = {"name": self.runner_name} - - return fields - - def gen_runner_section(self, **kwargs): - section_kwargs = self.gen_fields(**kwargs) - - section_str = Template(self.RUNNER_SECTION_TEMPLATE).render(**section_kwargs) - - return section_str - - -class BCDashboardSection: - BC_SECTION_TEMPLATE = """ - -Boundary Condition: {{ name }} - -Total Number of Dynamics segments: {{ total_n_walker_segments }} - -Total Number of Crossings: {{ total_crossings }} - -Cumulative Boundary Crossed Weight: {{ total_crossed_weight }} - -** Progress Log - -{{ progress_summary_table }} - - -** Warping Log - -{{ warping_log }} - -""" - - WARP_RECORD_COLNAMES = ( - "cycle_idx", - "walker_idx", - "weight", - "target_idx", - "discontinuous", - ) - - def __init__(self, bc=None, discontinuities=None, name=None, **kwargs): - if bc is not None: - self.bc_name = type(bc).__name__ - - elif name is not None: - self.bc_name = name - - else: - self.bc_name = "Unknown" - - if bc is not None: - self.bc_discontinuities = copy(bc.DISCONTINUITY_TARGET_IDXS) - - else: - assert discontinuities is not None, ( - "If the bc is not given must give parameter: discontinuities" - ) - self.bc_discontinuities = discontinuities - - self.warp_records = [] - self.total_n_walker_segments = 0 - self.total_crossings = 0 - self.total_crossed_weight = 0.0 - - # progress statistics - self.progress_summaries = [] - - def calc_progress_summary(self, **kwargs): - prog_data_dic = kwargs["progress_data"] - - if len(prog_data_dic) == 0: - return { - "min": np.nan, - "max": np.nan, - "mean": np.nan, - } - - else: - prog_data_key = [*prog_data_dic][0] - - prog_data = prog_data_dic[prog_data_key] - - return { - "min": np.min(prog_data), - "max": np.max(prog_data), - "mean": np.mean(prog_data), - } - - def update_values(self, **kwargs): - # keep track of exactly how many walker segments are run, this - # is useful for rate calculations via Hill's relation. - self.total_n_walker_segments += len(kwargs["new_walkers"]) - - # report on the walker progress - self.progress_summaries.append(self.calc_progress_summary(**kwargs)) - - # just create the bare warp records, since we know no more - # domain knowledge, feel free to override and add more data to - # this table - for warp_record in kwargs["warp_data"]: - # the cycle - cycle_idx = kwargs["cycle_idx"] - - # the individual values from the warp record - weight = warp_record["weight"][0] - walker_idx = warp_record["walker_idx"][0] - target_idx = warp_record["target_idx"][0] - - # determine if it was discontinuous - - # all targets are discontinuous - if self.bc_discontinuities is Ellipsis: - discont = True - # none of them are discontinuous - elif self.bc_discontinuities is None: - discont = False - # then it is a list of the discontinuous targets - else: - discont = True if target_idx in self.bc_discontinuities else False - - record = (cycle_idx, walker_idx, weight, target_idx, discont) - self.warp_records.append(record) - - self.total_crossings = len(self.warp_records) - self.total_crossed_weight = np.sum([r[2] for r in self.warp_records]) - - def gen_fields(self, **kwargs): - # make the table for the collected warping records - warp_table_df = pd.DataFrame( - self.warp_records, columns=self.WARP_RECORD_COLNAMES - ) - warp_table_str = tabulate( - warp_table_df, headers=warp_table_df.columns, tablefmt="orgtbl" - ) - - prog_df = pd.DataFrame(self.progress_summaries) - prog_summary_tbl_str = tabulate( - prog_df, headers=prog_df.columns, tablefmt="orgtbl" - ) - - fields = { - "name": self.bc_name, - "total_n_walker_segments": self.total_n_walker_segments, - "total_crossings": self.total_crossings, - "total_crossed_weight": self.total_crossed_weight, - "progress_summary_table": prog_summary_tbl_str, - "warping_log": warp_table_str, - } - - return fields - - def gen_bc_section(self, **kwargs): - section_kwargs = self.gen_fields(**kwargs) - - section_str = Template(self.BC_SECTION_TEMPLATE).render(**section_kwargs) - - return section_str diff --git a/src/wepy/reporter/file.py b/src/wepy/reporter/file.py new file mode 100644 index 00000000..c748364f --- /dev/null +++ b/src/wepy/reporter/file.py @@ -0,0 +1,254 @@ +# Standard Library +import logging +from abc import ABC +from pathlib import Path +from typing import Literal, get_args + +# Local Modules +from .base import ReporterError, SimComponentArgs + +logger = logging.getLogger(__name__) + + +class FileReporterError(ReporterError): + pass + + +FileMode = Literal["x", "w", "w-", "r", "r+"] + + +class FileReporterABC(ABC): + """Abstract reporter that handles specifying file paths for a + reporter. + + This abstract class doesn't perform any operations that involve + actually opening file descriptors, but only the validation and + organization of file paths. + + This provides a uniform API for retrieving file paths from all + reporters inheriting from it. + + Additionally, FileReporter implements an interface for performing + a so-called reparametrization of the relevant values associated + with each file specification (i.e. file path and mode). + + A reparametrization can be performed by calling the + 'reparametrize' method, and can be customized. + + Additionally, there are some customizable class constants than can + be used in subclasses to control this process including: + DEFAULT_MODE, SUGGESTED_FILENAME_TEMPLATE, + DEFAULT_SUGGESTED_EXTENSION, FILE_ORDER, and SUGGESTED_EXTENSIONS. + + The intention is to allow the redefinition of file paths + dynamically to adapt to changing runtime requirements. Such as + execution on a separate subtree of a directory hierarchy. + + """ + + MODES = tuple(get_args(FileMode)) + """Valid modes accepted for files.""" + + DEFAULT_MODE: FileMode = "x" + """The default mode to set for opening files if none is specified + (create if doesn't exist, fail if it does.)""" + + SUGGESTED_FILENAME_TEMPLATE: str = "{config}{narration}{reporter_class}.{ext}" + """Template to use for dynamic reparametrization of file path names. + + The fields in the template are: + + config : indicator of the runtime configuration used + + narration : freeform description of the instance + + reporter_class : the name of the class that produced the + output. When no specific name is given for a file report generated + from a reporter this is used to disambiguate, along with the + extension. + + ext : The file extension, for multiple files produced from one + reporter this should be sufficient to disambiguate the files. + + The 'config' and 'narration' should be the same across all + reporters in the same simulation manager, and the 'narration' is + considered optional. + + """ + + DEFAULT_SUGGESTED_EXTENSION: str = "report" + """The default file extension used for files during dynamic + reparametrization, if none is specified""" + + FILE_ORDER: tuple[str, ...] = () + """Specify an ordering of file paths. Should be customized.""" + + SUGGESTED_EXTENSIONS: tuple[str, ...] = () + """Suggested extensions for file paths for use with the automatic + reparametrization feature. Should be customized.""" + + @classmethod + def _validate_mode(cls, mode: FileMode) -> bool: + """Check if the mode spec is a valid one. + + Parameters + ---------- + mode : str + + Returns + ------- + valid : bool + + """ + if mode in cls.MODES: + return True + else: + return False + + def __init__( + self, + file_paths: list[Path], + modes: list[FileMode] | None = None, + ) -> None: + """Constructor for FileReporter. + + This constructor allows the specification of either a list of + file names (and modes) via 'file_paths' and 'modes' key-word + arguments or a single 'file_path' and 'mode'. + + The access API though is always a list of file paths and modes + where order is important for associating other features. + + Parameters + ---------- + file_paths : list of str + The list of file paths (in order) to use. + + modes : list of str + The list of mode specs (in order) to use. + + """ + + # file paths + self._file_paths = file_paths + + # modes + + # if modes is None we make modes, from defaults if we have to + if modes is None: + # if mode is None set it to the default + if modes is None: + mode = self.DEFAULT_MODE + + # if only one mode is given copy it for each file given + modes = [mode for i in range(len(self._file_paths))] + + for mode in modes: + if not self._validate_mode(mode): + raise FileReporterError(f"Invalid file mode: {mode}") + + self._modes = modes + + @property + def file_paths(self) -> list[Path]: + """The file paths for this reporter, in order.""" + return self._file_paths + + @property + def modes(self) -> list[FileMode]: + """The modes for the files, in order.""" + return self._modes + + def set_path(self, file_idx, path): + """Set the path for a single indexed file. + + Parameters + ---------- + file_idx : int + Index in the listing of files. + path : str + The new path to set for this file + + """ + self._paths[file_idx] = path + + # TOREV: shouldn't need this. Can move to using attrs class and + # evolve if this is an issue elsewhere. + + # @modes.setter + # def modes(self, modes): + # """Setter for the modes. + + # Parameters + # ---------- + # modes : list of str + + # """ + # for i, mode in enumerate(modes): + # self.set_mode(i, mode) + + def set_mode(self, file_idx: int, mode: FileMode) -> None: + """Set the mode for a single indexed file. + + Parameters + ---------- + file_idx : int + Index in the listing of files. + mode : str + The new mode spec. + + """ + + if self._validate_mode(mode): + self._modes[file_idx] = mode + else: + raise FileReporterError(f"Incorrect mode {mode}") + + # def reparametrize(self, file_paths, modes): + # """Set the file paths and modes for all files in the reporter. + + # Parameters + # ---------- + # file_paths : list of str + # New file paths for each file, in order. + # modes : list of str + # New modes for each file, in order. + + # """ + + # self.file_paths = file_paths + # self.modes = modes + + +class ProgressiveFileReporterABC(FileReporterABC, ABC): + """Super class for a reporter that will successively overwrite the + same file over and over again. The base FileReporter really only + supports creation of file one time. + + """ + + def init(self, **kwargs: SimComponentArgs) -> None: + + # because we want to overwrite the file at every cycle we + # need to change the modes to write with truncate. This allows + # the file to first be opened in 'x' or 'w-' and check whether + # the file already exists (say from another run), and warn the + # user. However, once the file has been created for this run + # we need to overwrite it many times forcefully. + logger.info("Initializing ProgressiveFileReporter") + + # go thourgh each file managed by this reporter + for file_idx, mode in enumerate(self.modes): + # if the mode is 'x' or 'w-' we check to make sure the file + # doesn't exist + if mode in ["x", "w-"]: + file_path = self.file_paths[file_idx] + if file_path.exists(): + raise FileExistsError(f"File exists: {file_path}") + + # now that we have checked if the file exists we set it into + # overwrite mode + self.set_mode(file_idx, "w") + + def cleanup(self, **kwargs: SimComponentArgs) -> None: + logger.info("Nothing to do for ProgressiveFileReporterABC.cleanup.") diff --git a/src/wepy/reporter/hdf5.py b/src/wepy/reporter/hdf5.py index 0d35c275..558980ab 100644 --- a/src/wepy/reporter/hdf5.py +++ b/src/wepy/reporter/hdf5.py @@ -1,21 +1,63 @@ # Standard Library +import builtins import logging - -logger = logging.getLogger(__name__) -# Standard Library -from copy import deepcopy +from pathlib import Path +from typing import Generic, Literal, Self, TypeVar # Third Party Library import numpy as np +import openmm.unit # First Party Library +from wepy.boundary_conditions.boundary import BoundaryConditions from wepy.hdf5 import WepyHDF5 -from wepy.reporter.reporter import FileReporter +from wepy.reporter.base import ( + CycleReportDict, + SimComponentArgs, +) +from wepy.reporter.file import FileMode, FileReporterABC +from wepy.resampling.resamplers.resampler import Resampler +from wepy.runners.openmm import OPENMM_DEFAULT_UNITS +from wepy.storage.protocol import ( + Record, + RecordFieldDtype, + RecordFieldShapeSpec, + ResamplingRecord, +) +from wepy.typing import IdxArray, Idxs from wepy.util.json_top import json_top_atom_count -from wepy.walker import Walker, WalkerState +from wepy.walker import Walker, WalkerState, WalkerStateBox + +logger = logging.getLogger(__name__) + +WalkerState_ = TypeVar("WalkerState_", bound=WalkerState) +ResamplingRecord_ = TypeVar("ResamplingRecord_", bound=ResamplingRecord) +ResamplerRecord_ = TypeVar("ResamplerRecord_", bound=Record) + +WarpingRecord_ = TypeVar("WarpingRecord_", bound=Record) +BCRecord_ = TypeVar("BCRecord_", bound=Record) +ProgressRecord_ = TypeVar("ProgressRecord_", bound=Record) -class WepyHDF5Reporter(FileReporter): +class UnitError(Exception): + pass + + +# TODO: support for pint +Quantity = openmm.unit.Quantity + + +class WepyHDF5Reporter( + FileReporterABC, + Generic[ + WalkerState_, + ResamplingRecord_, + ResamplerRecord_, + WarpingRecord_, + BCRecord_, + ProgressRecord_, + ], +): """Reporter for generating an HDF5 format (WepyHDF5) data file from simulations. @@ -27,7 +69,6 @@ class WepyHDF5Reporter(FileReporter): See Also -------- - wepy.hdf5.WepyHDF5 @@ -43,65 +84,106 @@ class WepyHDF5Reporter(FileReporter): # by this reporter, e.g. results.wepy.h5 SUGGESTED_EXTENSIONS = ("wepy.h5",) + # static attributes + swmr_mode: bool + save_fields: tuple[str, ...] | None + init_walker_save_fields: tuple[str, ...] | None | Literal[Ellipsis] + _sparse_fields: dict[str, int] + _feature_shapes: dict[str, RecordFieldShapeSpec] | None + _feature_dtypes: dict[str, RecordFieldDtype] | None + _n_dims: int + resampling_fields: tuple[str, ...] + decision_enum_dict: dict[str, int] + resampler_fields: tuple[str, ...] | None + warping_fields: tuple[str, ...] | None + progress_fields: tuple[str, ...] | None + bc_fields: tuple[str, ...] | None + resampling_records: tuple[str, ...] | None + resampler_records: tuple[str, ...] | None + bc_records: tuple[str, ...] | None + warping_records: tuple[str, ...] | None + progress_records: tuple[str, ...] | None + main_rep_idxs: IdxArray | None + alt_reps_to_save: list[str] + alt_reps_idxs: dict[str, IdxArray] + _n_atoms: int + _all_atom_idxs: IdxArray + _sparse_fields: dict[str, int] + units: dict[str, openmm.unit.Unit] + + # stateful attributes + wepy_h5: WepyHDF5 | None + wepy_run_idx: int | None + + _tmp_topology: str | None + def __init__( self, - save_fields=None, - topology=None, - units=None, - sparse_fields=None, - feature_shapes=None, - feature_dtypes=None, - n_dims=None, - main_rep_idxs=None, - all_atoms_rep_freq=None, - # dictionary of alt_rep keys and a tuple of (idxs, freq) - alt_reps=None, - # pass in the resampler and boundary - # conditions classes to automatically extract the - # needed data, the objects themselves are not saves - resampler=None, - boundary_conditions=None, - # or pass the things we need from them in manually - resampling_fields=None, - decision_enum_dict=None, - resampler_fields=None, - warping_fields=None, - progress_fields=None, - bc_fields=None, - resampling_records=None, - resampler_records=None, - warping_records=None, - bc_records=None, - progress_records=None, - # other settings - swmr_mode=False, - **kwargs, + file_path: Path, + topology: str, + # Resampling features + decision_enum_dict: dict[str, int], + resampling_fields: tuple[str, ...], + swmr_mode: bool = False, + save_fields: tuple[str, ...] | None = None, + init_walker_save_fields: tuple[str, ...] | None | Literal[Ellipsis] = None, + units: dict[str, openmm.unit.Unit] | None = None, + sparse_fields: dict[str, int | Literal[Ellipsis]] | None = None, + n_dims: int = 3, + main_rep_idxs: Idxs | None = None, + all_atoms_rep_freq: int | None = None, + alt_reps: dict[str, tuple[Idxs, int | Literal[Ellipsis]]] | None = None, + # TOREV: are these feature fields actually needed for the main + # trajectories? I never used them. If they are useful they + # should be derived from runner metadata in the common case. I + # think in most cases they are determined dynamically, so this + # needs to be amended. + feature_shapes: dict[str, RecordFieldShapeSpec] | None = None, + feature_dtypes: dict[str, RecordFieldDtype] | None = None, + # Resampling optionals + resampling_records: tuple[str, ...] | None = None, + # Resampler fields are optional + resampler_fields: tuple[str, ...] | None = None, + resampler_records: tuple[str, ...] | None = None, + # BC features, optional + warping_fields: tuple[str, ...] | None = None, + progress_fields: tuple[str, ...] | None = None, + bc_fields: tuple[str, ...] | None = None, + warping_records: tuple[str, ...] | None = None, + bc_records: tuple[str, ...] | None = None, + progress_records: tuple[str, ...] | None = None, ): """Constructor for the WepyHDF5Reporter. Parameters ---------- + save_fields : A selection of fields from the walker states to + be stored. Allows for the ignoring of some states. If None + all fields from states will attempted to be saved. To not + save anything provide an empty tuple (). - save_fields : tuple of str, default: None - A selection of fields from the walker states to be - stored. Allows for the ignoring of some states. If None all - fields from states will attempted to be saved. + init_walker_save_fields : A selection of fields to require for + the initial walkers. If None this will require the same as + the 'save_fields' argument. If Ellipsis this will accept + whatever fields the init_walkers have without error. topology : str JSON string representing topology of system being simulated. - units : dict of str: str, optional - Mapping of trajectory field names to string specs - for units. + units : Mapping of trajectory field names to Unit objects. If + None the defaults will be used. If not all units are defined + then the missing ones will be filled in by the defaults. sparse_fields : dict of str: int, optional List of trajectory fields that should be initialized as sparse. - feature_shapes : dict of str: shape_spec, optional - Mapping of trajectory fields to their shape spec for initialization. + feature_shapes : Mapping of trajectory fields to their shape + spec for initialization. Note that these are extras and the + defaults for OpenMM MD will automatically be configured. - feature_dtypes : dict of str: dtype_spec, optional - Mapping of trajectory fields to their shape spec for initialization. + feature_dtypes : Mapping of trajectory fields to their shape + spec for initialization. Note that these are extras and the + defaults for OpenMM MD will automatically be configured. n_dims : int, default: 3 Set the number of spatial dimensions for the default @@ -112,8 +194,9 @@ def __init__( named by the keys of this mapping and containing the indices in each value list as the first value of the tuple and the second value being the frequency at which this - field gets saved. Setting `all_atoms_rep_freq` is the - equivalent of setting an entry {'all_atoms' : ([...], + field gets saved. If frequency is 0, 1, or Ellipsis then + all frames will be saved. Setting `all_atoms_rep_freq` is + the equivalent of setting an entry {'all_atoms' : ([...], `all_atoms_rep_freq`)}. main_rep_idxs : list of int, optional @@ -125,23 +208,6 @@ def __init__( atoms in a simulation. Will be set as the field 'alt_rep/all_atoms'. - resampler : Resampler object, optional but recommended - The resampler being used for the simulation. Is used as a - convenient container for a variety of constants needed for - specifying data for the resampling records. If this is not - given then these of the Other Parameters below must be - specified manually: resampling_fields, decision_enum_dict, - resampler_fields, resampling_records, resampler_records. - - boundary_conditions : BoundaryConditions object, optional but recommended - The boundary conditions being used for the simulation. Is - used as a convenient container for a variety of constants - needed for specifying data for the warping and progress - records. If this is not given then these of the Other - Parameters below must be specified manually: - warping_fields, progress_fields, bc_fields, - warping_records, bc_records, progress_records - swmr_mode : bool Whether to write to open the HDF5 in single-writer multi-reader (SWMR) mode. @@ -149,7 +215,6 @@ def __init__( Other Parameters ---------------- - resampling_fields : list of str The names of the fields for resampling records @@ -192,117 +257,77 @@ def __init__( """ # initialize inherited attributes - super().__init__(**kwargs) + super().__init__( + file_paths=[file_path], + # hardcode creation mode + modes=["x"], + ) # set the preference for swmr mode, True or False, if this is # True then SWMR mode will be turned on when the file is # written to during reporting self.swmr_mode = swmr_mode - # do all the WepyHDF5 specific stuff - self.wepy_run_idx = None self._tmp_topology = topology + # which fields from the walker to save, if None then save all of them self.save_fields = save_fields - # dictionary of sparse_field_name -> int : frequency of cycles - # to save the field - - # TODO: refine requirements of sparse fields. Do they need to - # be in the 'save_fields'? - - self._sparse_fields = ( - { - field_name : freq - for field_name, freq - in sparse_fields.items() - } - if sparse_fields is not None - else {} - ) - self._feature_shapes = feature_shapes - self._feature_dtypes = feature_dtypes - self._n_dims = n_dims + self.init_walker_save_fields = init_walker_save_fields - # get and set the record fields (naems, shapes, dtypes) for - # the resampler and the boundary conditions - if (resampling_fields is not None) and (decision_enum_dict is not None): - self.resampling_fields = resampling_fields - self.decision_enum = decision_enum_dict - elif resampler is not None: - self.resampling_fields = resampler.resampling_fields() - self.decision_enum = resampler.DECISION.enum_dict_by_name() - else: - self.resampling_fields = None - self.decision_enum = None + # check sparse fields + if sparse_fields is not None: - if resampler_fields is not None: - self.resampler_fields = resampler_fields() - elif resampler is not None: - self.resampler_fields = resampler.resampler_fields() - else: - self.resampler_fields = None + if self.save_fields is None: + raise ValueError( + f"The sparse fields were requested ({set(sparse_fields.keys())}) but no save fields requested." + ) - if warping_fields is not None: - self.warping_fields = warping_fields() - elif boundary_conditions is not None: - self.warping_fields = boundary_conditions.warping_fields() - else: - self.warping_fields = None + _missing_save_fields = set( + sparse_key + for sparse_key in sparse_fields.keys() + if sparse_key not in self.save_fields + ) - if progress_fields is not None: - self.progress_fields = progress_fields() - elif boundary_conditions is not None: - self.progress_fields = boundary_conditions.progress_fields() - else: - self.progress_fields = None + if len(_missing_save_fields) > 0: + raise ValueError( + f"The sparse fields were requested ({_missing_save_fields}) but are not in" + f" requested save fields ({self.save_fields})" + ) - if bc_fields is not None: - self.bc_fields = bc_fields() - elif boundary_conditions is not None: - self.bc_fields = boundary_conditions.bc_fields() - else: - self.bc_fields = None + self._sparse_fields = sparse_fields - # the fields which are records for table like reports - if resampling_records is not None: - self.resampling_records = resampling_records - elif resampler is not None: - self.resampling_records = resampler.resampling_record_field_names() else: - self.resampling_records = None + self._sparse_fields = {} - if resampler_records is not None: - self.resampler_records = resampler_records - elif resampler is not None: - self.resampler_records = resampler.resampler_record_field_names() - else: - self.resampler_records = None + self._feature_shapes = feature_shapes + self._feature_dtypes = feature_dtypes + self._n_dims = n_dims - if bc_records is not None: - self.bc_records = bc_records - elif boundary_conditions is not None: - self.bc_records = boundary_conditions.bc_record_field_names() - else: - self.bc_records = None + # required resampling fields + self.resampling_fields = resampling_fields + self.decision_enum_dict = decision_enum_dict - if warping_records is not None: - self.warping_records = warping_records - elif boundary_conditions is not None: - self.warping_records = boundary_conditions.warping_record_field_names() - else: - self.warping_records = None + # optional resampler fields + self.resampler_fields = resampler_fields + self.resampling_records = resampling_records + self.resampler_records = resampler_records - if progress_records is not None: - self.progress_records = progress_records - elif boundary_conditions is not None: - self.progress_records = boundary_conditions.progress_record_field_names() - else: - self.progress_records = None + # BC fields, optional + self.warping_fields = warping_fields + self.progress_fields = progress_fields + self.bc_fields = bc_fields + + # the fields which are records for table like reports + self.bc_records = bc_records + self.warping_records = warping_records + self.progress_records = progress_records # the atom indices of the whole system that will be saved as # the main positions representation - self.main_rep_idxs = main_rep_idxs + self.main_rep_idxs = ( + np.array(main_rep_idxs) if main_rep_idxs is not None else None + ) # the idxs for alternate representations of the system # positions @@ -310,15 +335,17 @@ def __init__( # this is a record of which alt_reps to actually save in the simulation self.alt_reps_to_save = [] if alt_reps is not None: - self.alt_reps_idxs = {key: list(tup[0]) for key, tup in alt_reps.items()} - + self.alt_reps_idxs = { + key: np.array(idxs) for key, (idxs, _) in alt_reps.items() + } # add the frequencies for these alt_reps to the # sparse_fields frequency dictionary for key, (idxs, freq) in alt_reps.items(): - self.alt_reps_to_save.append(key) - + if len(idxs) == 0: + raise ValueError(f"No indices given for sparse field: {key}") + alt_rep_key = "alt_reps/{}".format(key) # if the frequency is Ellipsis or 1 then we save it @@ -326,10 +353,16 @@ def __init__( # very innefficient in comparison if freq is Ellipsis or freq == 1 or freq == 0: pass - else: + elif freq > 0: self._sparse_fields[alt_rep_key] = freq - self.alt_reps_idxs[key] = list(idxs) + else: + raise ValueError( + f"Invalid frequency specifier ({freq}) for sparse field '{key}'" + ) + + self.alt_reps_to_save.append(key) + self.alt_reps_idxs[key] = np.array(idxs) else: self.alt_reps_idxs = {} @@ -368,47 +401,314 @@ def __init__( # if units were given add them otherwise set as an empty dictionary if units is None: - self.units = {} + self.units = OPENMM_DEFAULT_UNITS else: - self.units = units + self.units = dict(OPENMM_DEFAULT_UNITS) | units - def init(self, continue_run=None, init_walkers=None, **kwargs): - # do the inherited stuff - super().init(**kwargs) + @classmethod + def from_components( + self, + file_path: Path, + topology: str, + resampler_class: type[Resampler], + feature_shapes: dict[str, RecordFieldShapeSpec] | None = None, + feature_dtypes: dict[str, RecordFieldDtype] | None = None, + boundary_conditions_class: type[BoundaryConditions] | None = None, + swmr_mode: bool = False, + save_fields: tuple[str, ...] | None = None, + init_walker_save_fields: tuple[str, ...] | None | Literal[Ellipsis] = None, + units: dict[str, openmm.unit.Unit] | None = None, + sparse_fields: dict[str, int] | None = None, + n_dims: int = 3, + main_rep_idxs: Idxs | None = None, + all_atoms_rep_freq: int | None = None, + alt_reps: dict[str, tuple[Idxs, int]] = None, + ) -> Self: + """Construct reporter from simulation components. + + Does introspection on components to get information. Does not + save these objects as state. - # open and initialize the HDF5 file - logger.info("Initializing HDF5 file at {}".format(self.file_path)) + Parameters + ---------- + resampler : Resampler object, optional but recommended + The resampler being used for the simulation. Is used as a + convenient container for a variety of constants needed for + specifying data for the resampling records. If this is not + given then these of the Other Parameters below must be + specified manually: resampling_fields, decision_enum_dict, + resampler_fields, resampling_records, resampler_records. - self.wepy_h5 = WepyHDF5( - self.file_path, - mode=self.mode, - topology=self._tmp_topology, - units=self.units, - sparse_fields=list(self._sparse_fields.keys()), - feature_shapes=self._feature_shapes, - feature_dtypes=self._feature_dtypes, - n_dims=self._n_dims, - main_rep_idxs=self.main_rep_idxs, - alt_reps=self.alt_reps_idxs, + boundary_conditions : BoundaryConditions object, optional but recommended + The boundary conditions being used for the simulation. Is + used as a convenient container for a variety of constants + needed for specifying data for the warping and progress + records. If this is not given then these of the Other + Parameters below must be specified manually: + warping_fields, progress_fields, bc_fields, + warping_records, bc_records, progress_records + + """ + + if boundary_conditions_class is not None: + warping_fields = boundary_conditions_class.warping_fields() + progress_fields = boundary_conditions_class.progress_fields() + bc_fields = boundary_conditions_class.bc_fields() + bc_records = boundary_conditions_class.bc_record_field_names() + warping_records = boundary_conditions_class.warping_record_field_names() + progress_records = boundary_conditions_class.progress_record_field_names() + + else: + warping_fields = None + progress_fields = None + bc_fields = None + bc_records = None + warping_records = None + progress_records = None + + return WepyHDF5Reporter( + file_path=file_path, + topology=topology, + feature_shapes=feature_shapes, + feature_dtypes=feature_dtypes, + swmr_mode=swmr_mode, + save_fields=save_fields, + init_walker_save_fields=init_walker_save_fields, + units=units, + sparse_fields=sparse_fields, + n_dims=n_dims, + main_rep_idxs=main_rep_idxs, + all_atoms_rep_freq=all_atoms_rep_freq, + alt_reps=alt_reps, + # components + resampling_fields=resampler_class.resampling_fields(), + decision_enum_dict=resampler_class.DECISION.enum_dict_by_name(), + resampler_fields=resampler_class.resampler_fields(), + resampling_records=resampler_class.resampling_record_field_names(), + resampler_records=resampler_class.resampler_record_field_names(), + warping_fields=warping_fields, + progress_fields=progress_fields, + bc_fields=bc_fields, + bc_records=bc_records, + warping_records=warping_records, + progress_records=progress_records, ) + @property + def file_path(self) -> Path: + return self.file_paths[0] + + @property + def mode(self) -> FileMode: + return self.modes[0] + + @staticmethod + def _initialize_h5_run( + wepy_h5: WepyHDF5, + init_walkers: list[Walker[WalkerState_]], + resampling_fields: tuple[str, ...], + decision_enum_dict: dict[str, int], + continue_run: int | None = None, + resampler_fields: tuple[str, ...] | None = None, + warping_fields: tuple[str, ...] | None = None, + progress_fields: tuple[str, ...] | None = None, + bc_fields: tuple[str, ...] | None = None, + resampling_records: tuple[str, ...] | None = None, + resampler_records: tuple[str, ...] | None = None, + bc_records: tuple[str, ...] | None = None, + warping_records: tuple[str, ...] | None = None, + progress_records: tuple[str, ...] | None = None, + ) -> int: + """Initialize the WepyHDF5 data structures.""" + + if wepy_h5.mode != "r+": + raise IOError( + f"wepy_h5 must be in non-creation read-write mode (r+), in '{wepy_h5.mode}'" + ) + + if not wepy_h5.closed: + raise IOError("WepyHDF5 is already open, must be closed.") + + with wepy_h5: + # if this is a continuation run of another run we want to + # initialize it as such + + # initialize a new run, we don't know which run it will be + # until it is created. + run_grp = wepy_h5.new_run( + init_walkers, + continue_run=continue_run, + ) + wepy_run_idx = run_grp.attrs["run_idx"] + + # initialize the run record groups using their fields + wepy_h5.init_run_fields_resampling( + wepy_run_idx, + resampling_fields, + ) + # the enumeration for the values of resampling + wepy_h5.init_run_fields_resampling_decision( + wepy_run_idx, + decision_enum_dict, + ) + + if resampler_fields is not None: + wepy_h5.init_run_fields_resampler( + wepy_run_idx, + resampler_fields, + ) + # set the fields that are records for tables etc. unless + # they are already set + if ( + resampling_records is not None + and "resampling" not in wepy_h5.record_fields + ): + wepy_h5.init_record_fields( + "resampling", + resampling_records, + ) + if ( + resampler_records is not None + and "resampler" not in wepy_h5.record_fields + ): + wepy_h5.init_record_fields( + "resampler", + resampler_records, + ) + + # if there were no warping fields set there is no boundary + # conditions and we don't initialize them + if warping_fields is not None: + wepy_h5.init_run_fields_warping( + wepy_run_idx, + warping_fields, + ) + wepy_h5.init_run_fields_progress( + wepy_run_idx, + progress_fields, + ) + wepy_h5.init_run_fields_bc( + wepy_run_idx, + bc_fields, + ) + # table records + if "warping" not in wepy_h5.record_fields: + wepy_h5.init_record_fields("warping", warping_records) + if "boundary_conditions" not in wepy_h5.record_fields: + wepy_h5.init_record_fields("boundary_conditions", bc_records) + if "progress" not in wepy_h5.record_fields: + wepy_h5.init_record_fields("progress", progress_records) + + return wepy_run_idx + + @staticmethod + def _resolve_state_units( + units: dict[str, openmm.unit.Unit], + state: WalkerStateBox, + ) -> tuple[WalkerStateBox, dict[str, openmm.unit.Unit]]: + """For walker states convert all quantity field values to plain values. + + Currently only supports openmm.unit. + + Returns the units used. If these were dynamically discovered + from the quantity it will be that, otherwise it will be the + unit that was passed in. + + """ + + units_used = {} + new_walker_fields = {} + for field_key, field_value in state.dict().items(): + if not isinstance(field_value, Quantity): + new_walker_fields[field_key] = field_value + elif isinstance(field_value, openmm.unit.Quantity): + + # if there is a configured unit, convert to that + if field_key in units: + + unit = units[field_key] + + units_used[field_key] = unit + + new_walker_fields[field_key] = field_value.value_in_unit(unit) + # If there is no unit for it, just get the + # magnitude in the current units + else: + new_walker_fields[field_key] = field_value.value_in_unit( + field_value.unit + ) + + units_used[field_key] = field_value.unit + + return WalkerStateBox(**new_walker_fields), units_used + + def init(self, **kwargs: SimComponentArgs) -> None: + + # TODO: remove dynamic configuration. Instead replace with + # static configuration from the Runner for good defaults. + + ## Do checks on the inputs and figure out runtime field metadata + # if we specify save fields only save these for the initial walkers - if self.save_fields is not None: - state_fields = list(init_walkers[0].state.dict().keys()) + state_fields = set(kwargs["init_walkers"][0].state.dict().keys()) + match (self.save_fields, self.init_walker_save_fields): + + # NOTE: the builtins.Ellipsis is needed to avoid matching + # anything + case (_, builtins.Ellipsis): + _save_fields = state_fields + logger.info( + f"Accepting and saving all fields found in init_walkers: {state_fields}" + ) + logger.warning( + "To ensure all required data is in a simulation these fields should be explicit." + ) - # make sure all the save_fields are present in the state - assert all([ + case (None, None): + _save_fields = state_fields + logger.info( + f"Accepting and saving all fields found in init_walkers: {state_fields}" + ) + logger.warning( + "'save_fields' is None and 'init_walker_save_fields' is None. " + "Any found fields will be saved. This is inadvisable and fields should be " + "declared to avoid spurious data outputs." + ) + case (fields, None): + _save_fields = set(fields) + logger.info( + f"Initial walker fields being saved determined from 'save_fields': {_save_fields}" + ) + + case (_, fields): + _save_fields = set(fields) + logger.info( + f"Initial walker fields being saved determined from 'init_walker_save_fields': {_save_fields}" + ) + + if _save_fields == state_fields: + filtered_init_walkers = kwargs["init_walkers"] + + elif not all( + [ True if save_field in state_fields else False - for save_field in self.save_fields - ]), "Not all specified save_fields present in walker states" + for save_field in _save_fields + ] + ): + + # make sure all the save_fields are present in the state + raise ValueError( + f"init_walkers should have all fields as required: {_save_fields}. " + f"Found: {state_fields}" + ) + + else: filtered_init_walkers = [] - for walker in init_walkers: + for walker in kwargs["init_walkers"]: # make a new state by filtering the attributes of the old ones state_d = { - k: v - for k, v in walker.state.dict().items() - if k in self.save_fields + k: v for k, v in walker.state.dict().items() if k in _save_fields } # and saving alternate representations as we would @@ -425,7 +725,6 @@ def init(self, continue_run=None, init_walkers=None, **kwargs): else: state_d[alt_rep_path] = state_d["positions"][alt_rep_idxs] - # always store a copy of the all_atoms rep for the init_walkers state_d[f"alt_reps/{self.ALL_ATOMS_REP_KEY}"] = state_d["positions"] @@ -434,111 +733,97 @@ def init(self, continue_run=None, init_walkers=None, **kwargs): if self.main_rep_idxs is not None: state_d["positions"] = state_d["positions"][self.main_rep_idxs] + # TODO: reusing the state infrastructure here is not + # the right thing. Currently just using a Box type to + # get around this but it really should just be it's + # own standalone type. + # then making the new state - new_state = WalkerState(**state_d) + new_state = WalkerStateBox(**state_d) filtered_init_walkers.append(Walker(new_state, walker.weight)) - # otherwise save the full state - else: - filtered_init_walkers = init_walkers - - self.wepy_h5.set_mode(mode="r+") - with self.wepy_h5: - # if this is a continuation run of another run we want to - # initialize it as such - # initialize a new run - run_grp = self.wepy_h5.new_run( - filtered_init_walkers, - continue_run=continue_run, + # If the state field values are quantities convert them to + # plain values. + converted_filtered_init_walkers = [] + for walker_idx, init_walker in enumerate(filtered_init_walkers): + _state, units_used = self._resolve_state_units( + self.units, init_walker.state ) - self.wepy_run_idx = run_grp.attrs["run_idx"] - # initialize the run record groups using their fields - self.wepy_h5.init_run_fields_resampling( - self.wepy_run_idx, - self.resampling_fields, - ) - # the enumeration for the values of resampling - self.wepy_h5.init_run_fields_resampling_decision( - self.wepy_run_idx, - self.decision_enum, - ) - self.wepy_h5.init_run_fields_resampler( - self.wepy_run_idx, - self.resampler_fields, + # If no self.units were given, use the first + # init walker to determine the units for a field overall, set + # this and use for the rest of the walkers + + if walker_idx == 0: + for unit_name, unit in units_used.items(): + if unit_name not in self.units: + self.units.update(units_used) + + converted_filtered_init_walkers.append( + Walker(state=_state, weight=init_walker.weight) ) - # set the fields that are records for tables etc. unless - # they are already set - if "resampling" not in self.wepy_h5.record_fields: - self.wepy_h5.init_record_fields( - "resampling", - self.resampling_records, - ) - if "resampler" not in self.wepy_h5.record_fields: - self.wepy_h5.init_record_fields( - "resampler", - self.resampler_records, - ) - # if there were no warping fields set there is no boundary - # conditions and we don't initialize them - if self.warping_fields is not None: - self.wepy_h5.init_run_fields_warping( - self.wepy_run_idx, - self.warping_fields, - ) - self.wepy_h5.init_run_fields_progress( - self.wepy_run_idx, - self.progress_fields, - ) - self.wepy_h5.init_run_fields_bc( - self.wepy_run_idx, - self.bc_fields, - ) - # table records - if "warping" not in self.wepy_h5.record_fields: - self.wepy_h5.init_record_fields("warping", self.warping_records) - if "boundary_conditions" not in self.wepy_h5.record_fields: - self.wepy_h5.init_record_fields( - "boundary_conditions", self.bc_records - ) - if "progress" not in self.wepy_h5.record_fields: - self.wepy_h5.init_record_fields("progress", self.progress_records) + # convert units to strings + _str_units = {key: str(unit) for key, unit in self.units.items()} + logger.info(f"Serialized units: {_str_units}") - # if this was opened in a truncation mode, we don't want to - # overwrite old runs with future calls to init(). so we - # change the mode to read/write 'r+' - if self.mode == "w": - self.set_mode(0, "r+") + # Run the constructor intialization + logger.info(f"Initializing HDF5 file at {self.file_path}") - def cleanup(self, **kwargs): - # it should be already closed at this point but just in case - if not self.wepy_h5.closed: - self.wepy_h5.close() + init_wepy_h5 = WepyHDF5( + self.file_path, + mode="x", + topology=self._tmp_topology, + units=_str_units, + sparse_fields=list(self._sparse_fields.keys()), + feature_shapes_overrides=self._feature_shapes, + feature_dtypes_overrides=self._feature_dtypes, + n_dims=self._n_dims, + main_rep_idxs=self.main_rep_idxs, + alt_reps=self.alt_reps_idxs, + ) - # remove reference to the WepyHDF5 file so we can serialize this object - del self.wepy_h5 + # delete the topology as it isn't needed anymore and we can + # get it from the HDF5. This will alleviate some memory + # pressure for large topologies + del self._tmp_topology + self._tmp_topology = None - super().cleanup(**kwargs) + # then the file that is the actual attribute is opened in + # read-write non-create mode + self.wepy_h5 = WepyHDF5( + self.file_path, + mode="r+", + ) + + self.wepy_run_idx = self._initialize_h5_run( + self.wepy_h5, + init_walkers=converted_filtered_init_walkers, + continue_run=kwargs["continue_run"], + resampling_fields=self.resampling_fields, + decision_enum_dict=self.decision_enum_dict, + resampler_fields=self.resampler_fields, + warping_fields=self.warping_fields, + progress_fields=self.progress_fields, + bc_fields=self.bc_fields, + resampling_records=self.resampling_records, + resampler_records=self.resampler_records, + bc_records=self.bc_records, + warping_records=self.warping_records, + progress_records=self.progress_records, + ) def report( self, - new_walkers=None, - cycle_idx=None, - warp_data=None, - bc_data=None, - progress_data=None, - resampling_data=None, - resampler_data=None, - **kwargs, - ): - n_walkers = len(new_walkers) + **kwargs: CycleReportDict, + ) -> None: + n_walkers = len(kwargs["new_walkers"]) # determine which fields to save. If there were none specified # save all of them if self.save_fields is None: - save_fields = list(new_walkers[0].state.dict().keys()) + save_fields = list(kwargs["new_walkers"][0].state.dict().keys()) else: save_fields = self.save_fields @@ -548,7 +833,7 @@ def report( self.wepy_h5.swmr_mode = True # add trajectory data for the walkers - for walker_idx, walker in enumerate(new_walkers): + for walker_idx, walker in enumerate(kwargs["new_walkers"]): walker_weight = walker.weight walker_data = walker.state.dict() @@ -568,12 +853,24 @@ def report( # if this is a sparse field we decide # whether it is a valid cycle to save on if field_path in self._sparse_fields: - if cycle_idx % self._sparse_fields[field_path] != 0: + if kwargs["cycle_idx"] % self._sparse_fields[field_path] != 0: # this is not a valid cycle so we # remove from the walker_data walker_data.pop(field_path) continue + # Convert the walker data to plain non-quantity values. Only + # do this for the save fields to avoid expensive conversions + # for unused fields (e.g. forces) + # + # NOTE: do this before creating derived fields so the + # Quantities don't propagate to them + _state_noq, _units_used = self._resolve_state_units( + units=self.units, + state=WalkerStateBox(**walker_data), + ) + _walker_data_noq = _state_noq.dict() + # Add the alt_reps fields by slicing the positions for alt_rep_key in self.alt_reps_to_save: @@ -585,30 +882,40 @@ def report( # check to make sure this is a cycle this is # to be saved to, if it is not continue on to # the next field without saving this one - if cycle_idx % self._sparse_fields[alt_rep_path] != 0: + if kwargs["cycle_idx"] % self._sparse_fields[alt_rep_path] != 0: continue # slice them and save them # if the idxs are None we want all of the atoms if alt_rep_idxs is None: - alt_rep_data = walker_data["positions"][:] + alt_rep_data = _walker_data_noq["positions"][:] # otherwise get only th atoms we want else: - alt_rep_data = walker_data["positions"][alt_rep_idxs] - walker_data[alt_rep_path] = alt_rep_data + alt_rep_data = _walker_data_noq["positions"][alt_rep_idxs] + + _walker_data_noq[alt_rep_path] = alt_rep_data # lastly reduce the atoms for the main representation # if this option was given if self.main_rep_idxs is not None: - walker_data["positions"] = walker_data["positions"][ + _walker_data_noq["positions"] = _walker_data_noq["positions"][ self.main_rep_idxs ] - # for all of these fields we wrap them in another - # dimension to make them feature vectors + # for all of these fields we wrap them in additional + # dimensions to make them feature vectors for field_path in list(walker_data.keys()): - walker_data[field_path] = np.array([walker_data[field_path]]) + + # first if its a scalar wrap in the first layer + _val = _walker_data_noq[field_path] + if np.isscalar(_val): + _val = np.array([_val]) + + # then reshape to feature vector + _val = _val.reshape((1, *_val.shape)) + + _walker_data_noq[field_path] = _val # save the data to the HDF5 file for this walker @@ -619,37 +926,47 @@ def report( self.wepy_run_idx, walker_idx, weights=np.array([[walker_weight]]), - data=walker_data, + data=_walker_data_noq, ) # start a new trajectory else: # add the traj for the walker with the data - traj_grp = self.wepy_h5.add_traj( self.wepy_run_idx, weights=np.array([[walker_weight]]), - data=walker_data, + data=_walker_data_noq, ) # add as metadata the cycle idx where this walker started - traj_grp.attrs["cycle_idx"] = cycle_idx + traj_grp.attrs["cycle_idx"] = kwargs["cycle_idx"] # report the boundary conditions records data, if boundary # conditions were initialized if self.warping_fields is not None: - self._report_warping(cycle_idx, warp_data) - self._report_bc(cycle_idx, bc_data) - self._report_progress(cycle_idx, progress_data) + self._report_warping(kwargs["cycle_idx"], kwargs["warp_data"]) + self._report_bc(kwargs["cycle_idx"], kwargs["bc_data"]) + self._report_progress(kwargs["cycle_idx"], kwargs["progress_data"]) # report the resampling records data - self._report_resampling(cycle_idx, resampling_data) + self._report_resampling( + kwargs["cycle_idx"], + kwargs["resampling_data"], + ) - self._report_resampler(cycle_idx, resampler_data) + self._report_resampler(kwargs["cycle_idx"], kwargs["resampler_data"]) - super().report(**kwargs) + def cleanup(self, **kwargs: SimComponentArgs) -> None: + # # it should be already closed at this point but just in case + # if not self.wepy_h5.closed: + # self.wepy_h5.close() + + # remove reference to the WepyHDF5 file so we can serialize this object + del self.wepy_h5 # sporadic - def _report_warping(self, cycle_idx, warping_data): + def _report_warping( + self, cycle_idx: int, warping_data: list[WarpingRecord_] + ) -> None: """Method to write warping specific information. Parameters @@ -667,7 +984,7 @@ def _report_warping(self, cycle_idx, warping_data): self.wepy_run_idx, cycle_idx, warping_data ) - def _report_bc(self, cycle_idx, bc_data): + def _report_bc(self, cycle_idx: int, bc_data: list[BCRecord_]) -> None: """Method to write boundary condition update specific information. Parameters @@ -683,7 +1000,11 @@ def _report_bc(self, cycle_idx, bc_data): if len(bc_data) > 0: self.wepy_h5.extend_cycle_bc_records(self.wepy_run_idx, cycle_idx, bc_data) - def _report_resampler(self, cycle_idx, resampler_data): + def _report_resampler( + self, + cycle_idx: int, + resampler_data: list[ResamplerRecord_], + ) -> None: """Method to write resampler update specific information. Parameters @@ -698,12 +1019,18 @@ def _report_resampler(self, cycle_idx, resampler_data): if len(resampler_data) > 0: self.wepy_h5.extend_cycle_resampler_records( - self.wepy_run_idx, cycle_idx, resampler_data + self.wepy_run_idx, + cycle_idx, + resampler_data, ) # the resampling records are provided every cycle but they need to # be saved as sporadic because of the variable number of walkers - def _report_resampling(self, cycle_idx, resampling_data): + def _report_resampling( + self, + cycle_idx: int, + resampling_records: list[ResamplingRecord_], + ) -> None: """Method to write resampling specific information. Parameters @@ -717,11 +1044,11 @@ def _report_resampling(self, cycle_idx, resampling_data): """ self.wepy_h5.extend_cycle_resampling_records( - self.wepy_run_idx, cycle_idx, resampling_data + self.wepy_run_idx, cycle_idx, resampling_records ) # continual - def _report_progress(self, cycle_idx, progress_data): + def _report_progress(self, cycle_idx: int, progress_data: ProgressRecord_) -> None: """Method to write progress specific information. Parameters diff --git a/src/wepy/reporter/openmm.py b/src/wepy/reporter/openmm.py index b3d16f46..a848cb2f 100644 --- a/src/wepy/reporter/openmm.py +++ b/src/wepy/reporter/openmm.py @@ -1,12 +1,9 @@ # Third Party Library -from pint import UnitRegistry +import openmm.unit # First Party Library from wepy.reporter.dashboard import RunnerDashboardSection -# initialize the unit registry -units = UnitRegistry() - class OpenMMRunnerDashboardSection(RunnerDashboardSection): RUNNER_SECTION_TEMPLATE = """ @@ -20,38 +17,44 @@ class OpenMMRunnerDashboardSection(RunnerDashboardSection): Total Sampling Time: {{ total_sampling_time }} """ - def __init__(self, runner=None, step_time=None, **kwargs): - if "name" not in kwargs: - kwargs["name"] = "OpenMMRunner" + def __init__(self, runner_factory=None, step_time=None): - super().__init__(runner=runner, step_time=step_time, **kwargs) + super().__init__( + runner_factory=runner_factory, + name="OpenMMRunner", + ) - if runner is None: - assert step_time is not None, ( - "If no complete runner is given must give parameters: step_time" - ) + if runner_factory is None: + assert ( + step_time is not None + ), "If no complete runner is given must give parameters: step_time" # assume it has units self.step_time = step_time else: - simtk_step_time = runner.integrator.getStepSize() - simtk_val = simtk_step_time.value_in_unit(simtk_step_time.unit) - + self.step_time = runner_factory.integrator.getStepSize() + + # HACK,TODO: this conversion would likely not work in + # general so I'm just removing it and using the plain + # openmm on until there is a better conversion system + # between them. + # + # simtk_val = simtk_step_time.value_in_unit(simtk_step_time.unit) + # # convert to a more general purpose pint unit, which will be # used for the dashboards so we don't have the simtk # dependency - self.step_time = simtk_val * units(simtk_step_time.unit.get_name()) + # self.step_time = simtk_val * units(simtk_step_time.unit.get_name()) # TODO - # integrator and params # FF and params # updatables - self.walker_total_sampling_time = 0.0 * units("microsecond") - self.total_sampling_time = 0.0 * units("microsecond") + self.walker_total_sampling_time = 0.0 * openmm.unit.microsecond + self.total_sampling_time = 0.0 * openmm.unit.microsecond def update_values(self, **kwargs): super().update_values(**kwargs) diff --git a/src/wepy/reporter/receptor/dashboard.py b/src/wepy/reporter/receptor/dashboard.py index 7729392d..2c7ff847 100644 --- a/src/wepy/reporter/receptor/dashboard.py +++ b/src/wepy/reporter/receptor/dashboard.py @@ -80,9 +80,9 @@ def __init__(self, bc=None, cutoff_distance=None, **kwargs): if bc is not None: self.cutoff_distance = bc.cutoff_distance else: - assert cutoff_distance is not None, ( - "If no bc is given must give parameters: cutoff_distance" - ) + assert ( + cutoff_distance is not None + ), "If no bc is given must give parameters: cutoff_distance" self.cutoff_distance = cutoff_distance def gen_fields(self, **kwargs): @@ -113,9 +113,9 @@ def __init__(self, bc=None, cutoff_rmsd=None, **kwargs): if bc is not None: self.cutoff_rmsd = bc.cutoff_rmsd else: - assert cutoff_rmsd is not None, ( - "If no bc is given must give parameters: cutoff_rmsd" - ) + assert ( + cutoff_rmsd is not None + ), "If no bc is given must give parameters: cutoff_rmsd" self.cutoff_rmsd = cutoff_rmsd def gen_fields(self, **kwargs): diff --git a/src/wepy/reporter/reporter.py b/src/wepy/reporter/reporter.py deleted file mode 100644 index 26630b82..00000000 --- a/src/wepy/reporter/reporter.py +++ /dev/null @@ -1,552 +0,0 @@ -# Standard Library -import logging - -logger = logging.getLogger(__name__) -# Standard Library -import os.path as osp - - -class ReporterError(Exception): - pass - - -class Reporter: - """Abstract base class for wepy reporters. - - All reporters must customize and override minimally the 'report' - method. Optionally the 'init' and 'cleanup' can be overriden. - - See Also - -------- - - wepy.sim_manager : details of calls to reporter methods. - - """ - - def __init__(self, **kwargs): - """Construct a reporter. - - Void constructor for the Reporter base class. - - Parameters - ---------- - **kwargs : key-value pairs - Ignored kwargs, but accepts them from subclass calls for - compatibility. - - """ - pass - - def init(self, **kwargs): - """Initialization routines for the reporter at simulation runtime. - - Initialize I/O connections including file descriptors, - database connections, timers, stdout/stderr etc. - - Void method for reporter base class. - - Reporters can expect to have the following key word arguments - passed to them during a simulation by the sim_manager in this - call. - - - Parameters - ---------- - - init_walkers : list of Walker objects - The initial walkers for the simulation. - - runner : Runner object - The runner that will be used in the simulation. - - resampler : Resampler object - The resampler that will be used in the simulation. - - boundary_conditions : BoundaryConditions object - The boundary conditions taht will be used in the simulation. - - work_mapper : WorkMapper object - The work mapper that will be used in the simulation. - - reporters : list of Reporter objects - The list of reporters that are in the simulation. - - continue_run : int - The index of the run that is being continued within this - same file. - - """ - method_name = "init" - assert not hasattr( - super(), method_name - ), f"Superclass with method {method_name} is masked" - - def report(self, **kwargs): - """Given data concerning the main simulation components state, perform - I/O operations to persist that data. - - Void method for reporter base class. - - Reporters can expect to have the following key word arguments - passed to them during a simulation by the sim_manager. - - Parameters - ---------- - - cycle_idx : int - - new_walkers : list of Walker objects - List of walkers that were produced from running their - dynamics by the runner. - - warp_data : list of dict of str : value - List of dict-like records for each warping event from the - last cycle. - - bc_data : list of dict of str : value - List of dict-like records specifying the changes to the - state of the boundary conditions in the last cycle. - - progress_data : dict str : list - A record indicating the progress values for each walker in - the last cycle. - - resampling_data : list of dict of str : value - List of records specifying the resampling to occur at this - cycle. - - resampler_data : list of dict of str : value - List of records specifying the changes to the state of the - resampler in the last cycle. - - n_segment_steps : int - The number of dynamics steps that were completed in the last cycle - - worker_segment_times : dict of int : list of float - Mapping worker index to the times they took for each - segment they processed. - - cycle_runner_time : float - Total time runner took in last cycle. - - cycle_bc_time : float - Total time boundary conditions took in last cycle. - - cycle_resampling_time : float - Total time resampler took in last cycle. - - resampled_walkers : list of Walker objects - List of walkers that were produced from the new_walkers - from applying resampling and boundary conditions. - - """ - - method_name = "report" - assert not hasattr( - super(), method_name - ), "Superclass with method {} is masked".format(method_name) - - def cleanup(self, **kwargs): - """Teardown routines for the reporter at the end of the simulation. - - Use to cleanly and safely close I/O connections or other - cleanup I/O. - - Use to close file descriptors, database connections etc. - - Reporters can expect to have the following key word arguments - passed to them during a simulation by the sim_manager. - - Parameters - ---------- - - runner : Runner object - The runner at the end of the simulation - - work_mapper : WorkeMapper object - The work mapper at the end of the simulation - - resampler : Resampler object - The resampler at the end of the simulation - - boundary_conditions : BoundaryConditions object - The boundary conditions at the end of the simulation - - reporters : list of Reporter objects - The list of reporters at the end of the simulation - - """ - method_name = "cleanup" - assert not hasattr( - super(), method_name - ), "Superclass with method {} is masked".format(method_name) - - -class FileReporter(Reporter): - """Abstract reporter that handles specifying file paths for a - reporter. - - This abstract class doesn't perform any operations that involve - actually opening file descriptors, but only the validation and - organization of file paths. - - This provides a uniform API for retrieving file paths from all - reporters inheriting from it. - - Additionally, FileReporter implements an interface for performing - a so-called reparametrization of the relevant values associated - with each file specification (i.e. file path and mode). - - A reparametrization can be performed by calling the - 'reparametrize' method, and can be customized. - - Additionally, there are some customizable class constants than can - be used in subclasses to control this process including: - DEFAULT_MODE, SUGGESTED_FILENAME_TEMPLATE, - DEFAULT_SUGGESTED_EXTENSION, FILE_ORDER, and SUGGESTED_EXTENSIONS. - - The intention is to allow the redefinition of file paths - dynamically to adapt to changing runtime requirements. Such as - execution on a separate subtree of a directory hierarchy. - - """ - - MODES = ( - "x", - "w", - "w-", - "r", - "r+", - ) - """Valid modes accepted for files.""" - - DEFAULT_MODE = "x" - """The default mode to set for opening files if none is specified - (create if doesn't exist, fail if it does.)""" - - SUGGESTED_FILENAME_TEMPLATE = "{config}{narration}{reporter_class}.{ext}" - """Template to use for dynamic reparametrization of file path names. - - The fields in the template are: - - config : indicator of the runtime configuration used - - narration : freeform description of the instance - - reporter_class : the name of the class that produced the - output. When no specific name is given for a file report generated - from a reporter this is used to disambiguate, along with the - extension. - - ext : The file extension, for multiple files produced from one - reporter this should be sufficient to disambiguate the files. - - The 'config' and 'narration' should be the same across all - reporters in the same simulation manager, and the 'narration' is - considered optional. - - """ - - DEFAULT_SUGGESTED_EXTENSION = "report" - """The default file extension used for files during dynamic - reparametrization, if none is specified""" - - FILE_ORDER = () - """Specify an ordering of file paths. Should be customized.""" - - SUGGESTED_EXTENSIONS = () - """Suggested extensions for file paths for use with the automatic - reparametrization feature. Should be customized.""" - - def __init__( - self, file_paths=None, modes=None, file_path=None, mode=None, **kwargs - ): - """Constructor for FileReporter. - - This constructor allows the specification of either a list of - file names (and modes) via 'file_paths' and 'modes' key-word - arguments or a single 'file_path' and 'mode'. - - The access API though is always a list of file paths and modes - where order is important for associating other features. - - Parameters - ---------- - - file_paths : list of str - The list of file paths (in order) to use. - - modes : list of str - The list of mode specs (in order) to use. - - file_path : str - If 'file_paths' not specified, the single file path to use. - - mode : str - If 'file_path' option used, this is the mode for that file. - - """ - - # file paths - - assert not ( - (file_paths is not None) and (file_path is not None) - ), "only file_paths or file_path kwargs can be specified" - - # if only one file path is given then we handle it as multiple - if file_path is not None: - file_paths = [file_path] - - # if any of the explicit paths are given (from the FILE_ORDER - # constant) in the kwargs then we automatically add those to - # the file_paths being sent to the super class constructor. - - # we use a flag to condition this, initialize and fall back to - # using the 'file_paths' kwarg - use_explicit_path_kwargs = False - - # we check the kwargs for the explicit file kwargs, and if - # they are given then we check whether they are valid and if - # they are, use them to set the 'file_paths' kwarg - - # make a list of the presence of the given explicit keys - given_explicit_kwargs = [ - (True if file_key in kwargs else False) for file_key in self.FILE_ORDER - ] - - # check that all the keys are present, if they aren't all - # present then the flag will stay false and the fallback of - # using the 'file_paths' kwarg will be used - if all(given_explicit_kwargs): - # then get the values and check them - valid_explicit_kwargs = [ - (True if kwargs[file_key] is not None else False) - for file_key in self.FILE_ORDER - ] - - # if they are all valid then we can use them - if not all(valid_explicit_kwargs): - use_explicit_path_kwargs = True - - # if only some were given this is wrong - elif any(given_explicit_kwargs): - raise ValueError( - "If you explicitly pass in the paths, all must be given explicitly" - ) - - # if we use the explicit path kwargs, then we need to put them - # into the 'file_paths' for superclass initialization - if use_explicit_path_kwargs: - file_paths = [] - for file_key in self.FILE_ORDER: - # add it to the file paths for superclass initialization - file_paths.append(kwargs[file_key]) - - # otherwise we need to use the file_paths argument that should - # have been given - else: - # make sure it is in kwargs and valid - assert ( - file_paths is not None - ), "if no explicit file path is given the 'file_paths' must have a value" - - assert len(file_paths) == len( - self.FILE_ORDER - ), "you must give file_paths {} paths".format(len(self.FILE_ORDER)) - - # using the file_path paths we got above we set them as - # attributes in this object - for i, file_key in enumerate(self.FILE_ORDER): - setattr(self, file_key, file_paths[i]) - - # set the underlying file paths - self._file_paths = file_paths - - # modes - - assert not ( - (modes is not None) and (mode is not None) - ), "only modes or mode kwargs can be specified" - - # if modes is None we make modes, from defaults if we have to - if modes is None: - # if mode is None set it to the default - if modes is None and mode is None: - mode = self.DEFAULT_MODE - - # if only one mode is given copy it for each file given - modes = [mode for i in range(len(self._file_paths))] - - self._modes = modes - - super().__init__(**kwargs) - - def _validate_mode(self, mode): - """Check if the mode spec is a valid one. - - Parameters - ---------- - mode : str - - Returns - ------- - valid : bool - - """ - if mode in self.MODES: - return True - else: - return False - - @property - def mode(self): - """For single file path reporters the mode of that file.""" - if len(self._file_paths) > 1: - raise ReporterError("there are multiple files and modes defined") - - return self._modes[0] - - @property - def file_path(self): - """For single file path reporters the file path to that file spec.""" - if len(self._file_paths) > 1: - raise ReporterError("there are multiple files and modes defined") - - return self._file_paths[0] - - @property - def file_paths(self): - """The file paths for this reporter, in order.""" - return self._file_paths - - @file_paths.setter - def file_paths(self, file_paths): - """Setter for the file paths. - - Parameters - ---------- - file_paths : list of str - - """ - for i, file_path in enumerate(file_paths): - self.set_path(i, file_path) - - def set_path(self, file_idx, path): - """Set the path for a single indexed file. - - Parameters - ---------- - file_idx : int - Index in the listing of files. - path : str - The new path to set for this file - - """ - self._paths[file_idx] = path - - @property - def modes(self): - """The modes for the files, in order.""" - return self._modes - - @modes.setter - def modes(self, modes): - """Setter for the modes. - - Parameters - ---------- - modes : list of str - - """ - for i, mode in enumerate(modes): - self.set_mode(i, mode) - - def set_mode(self, file_idx, mode): - """Set the mode for a single indexed file. - - Parameters - ---------- - file_idx : int - Index in the listing of files. - mode : str - The new mode spec. - - """ - - if self._validate_mode(mode): - self._modes[file_idx] = mode - else: - raise ValueError("Incorrect mode {}".format(mode)) - - def reparametrize(self, file_paths, modes): - """Set the file paths and modes for all files in the reporter. - - Parameters - ---------- - file_paths : list of str - New file paths for each file, in order. - modes : list of str - New modes for each file, in order. - - """ - - self.file_paths = file_paths - self.modes = modes - - -class ProgressiveFileReporter(FileReporter): - """Super class for a reporter that will successively overwrite the - same file over and over again. The base FileReporter really only - supports creation of file one time. - - """ - - def init(self, **kwargs): - """Construct a ProgressiveFileReporter. - - This is exactly the same as the FileReporter. - - - Parameters - ---------- - - file_paths : list of str - The list of file paths (in order) to use. - - modes : list of str - The list of mode specs (in order) to use. - - file_path : str - If 'file_paths' not specified, the single file path to use. - - mode : str - If 'file_path' option used, this is the mode for that file. - - See Also - -------- - - wepy.reporter.reporter.FileReporter - - """ - - super().init(**kwargs) - - # because we want to overwrite the file at every cycle we - # need to change the modes to write with truncate. This allows - # the file to first be opened in 'x' or 'w-' and check whether - # the file already exists (say from another run), and warn the - # user. However, once the file has been created for this run - # we need to overwrite it many times forcefully. - - # go thourgh each file managed by this reporter - for file_i, mode in enumerate(self.modes): - # if the mode is 'x' or 'w-' we check to make sure the file - # doesn't exist - if mode in ["x", "w-"]: - file_path = self.file_paths[file_i] - if osp.exists(file_path): - raise FileExistsError("File exists: '{}'".format(file_path)) - - # now that we have checked if the file exists we set it into - # overwrite mode - self.set_mode(file_i, "w") diff --git a/src/wepy/reporter/restree.py b/src/wepy/reporter/restree.py index 112527d8..774c2b93 100644 --- a/src/wepy/reporter/restree.py +++ b/src/wepy/reporter/restree.py @@ -3,6 +3,7 @@ """ # Standard Library +import warnings from collections import namedtuple # Third Party Library @@ -27,12 +28,13 @@ parent_panel, resampling_panel, ) -from wepy.reporter.reporter import ProgressiveFileReporter +from wepy.reporter.file import ProgressiveFileReporterABC -class ResTreeReporter(ProgressiveFileReporter): +class ResTreeReporter(ProgressiveFileReporterABC): """Reporter that generates resampling parent trees in the GEXF - format.""" + format. + """ FILE_ORDER = ("gexf_restree_path",) @@ -62,7 +64,6 @@ def __init__( Parameters ---------- - resampler : Resampler Used to generate parental relations from resampling records. @@ -177,7 +178,6 @@ def _make_resampling_record(self, record_d, cycle_idx): Returns ------- - record : namedtuple """ @@ -211,7 +211,6 @@ def _make_warping_record(self, record_d, cycle_idx): Returns ------- - record : namedtuple """ @@ -264,7 +263,6 @@ def _make_record( Returns ------- - record : namedtuple object """ diff --git a/src/wepy/reporter/revo/dashboard.py b/src/wepy/reporter/revo/dashboard.py index 378529f5..12e3492a 100644 --- a/src/wepy/reporter/revo/dashboard.py +++ b/src/wepy/reporter/revo/dashboard.py @@ -1,19 +1,14 @@ # Standard Library -import itertools as it import logging logger = logging.getLogger(__name__) # Standard Library -import os.path as osp -from collections import defaultdict # Third Party Library import numpy as np -import pandas as pd # First Party Library from wepy.reporter.dashboard import ResamplerDashboardSection -from wepy.resampling.decisions.clone_merge import MultiCloneMergeDecision class REVODashboardSection(ResamplerDashboardSection): @@ -70,22 +65,22 @@ def __init__( self.decision = resampler.DECISION else: - assert dist_exponent is not None, ( - "if no resampler given must give parameters: dist_exponent" - ) - assert merge_dist is not None, ( - "if no resampler given must give parameters: merge_dist" - ) - assert lpmin is not None, ( - "if no resampler given must give parameters: lpmin" - ) - assert char_dist is not None, ( - "if no resampler given must give parameters: char_dist" - ) + assert ( + dist_exponent is not None + ), "if no resampler given must give parameters: dist_exponent" + assert ( + merge_dist is not None + ), "if no resampler given must give parameters: merge_dist" + assert ( + lpmin is not None + ), "if no resampler given must give parameters: lpmin" + assert ( + char_dist is not None + ), "if no resampler given must give parameters: char_dist" assert seed is not None, "if no resampler given must give parameters: seed" - assert decision is not None, ( - "if no resampler given must give parameters: decision" - ) + assert ( + decision is not None + ), "if no resampler given must give parameters: decision" self.dist_exponent = dist_exponent self.merge_dist = merge_dist diff --git a/src/wepy/reporter/walker.py b/src/wepy/reporter/walker.py index b27a0f6b..c61b8266 100644 --- a/src/wepy/reporter/walker.py +++ b/src/wepy/reporter/walker.py @@ -16,16 +16,16 @@ import numpy as np # First Party Library -from wepy.reporter.reporter import ProgressiveFileReporter +from wepy.reporter.reporter import ProgressiveFileReporterABC from wepy.util.json_top import json_top_subset -from wepy.util.mdtraj import json_to_mdtraj_topology, mdtraj_to_json_topology +from wepy.util.mdtraj import json_to_mdtraj_topology from wepy.util.util import ( box_vectors_to_lengths_angles, traj_box_vectors_to_lengths_angles, ) -class WalkerReporter(ProgressiveFileReporter): +class WalkerReporter(ProgressiveFileReporterABC): """Reporter for generating 3D molecular structure files of the walkers produced by a cycle. @@ -63,7 +63,6 @@ def __init__( Parameters ---------- - init_state : object implementing WalkerState An initial state, only used for writing the PDB topology. @@ -141,9 +140,9 @@ def report(self, cycle_idx=None, new_walkers=None, **kwargs): # slice off the main_rep indices because that is all we want # to write for these - main_rep_positions = np.array([ - walker.state["positions"][self.main_rep_idxs] for walker in new_walkers - ]) + main_rep_positions = np.array( + [walker.state["positions"][self.main_rep_idxs] for walker in new_walkers] + ) # convert the box vectors unitcell_lengths, unitcell_angles = traj_box_vectors_to_lengths_angles( diff --git a/src/wepy/reporter/wexplore/dashboard.py b/src/wepy/reporter/wexplore/dashboard.py index 7cfccb09..a64d694a 100644 --- a/src/wepy/reporter/wexplore/dashboard.py +++ b/src/wepy/reporter/wexplore/dashboard.py @@ -4,12 +4,9 @@ logger = logging.getLogger(__name__) # Standard Library -import os.path as osp from collections import defaultdict -from warnings import warn # Third Party Library -import numpy as np import pandas as pd from tabulate import tabulate @@ -67,12 +64,12 @@ def __init__( self.max_n_regions = resampler.max_n_regions self.max_region_sizes = resampler.max_region_sizes else: - assert max_n_regions is not None, ( - "If a resampler is not given must give parameters: max_n_regions" - ) - assert max_region_sizes is not None, ( - "If a resampler is not given must give parameters: max_n_regions" - ) + assert ( + max_n_regions is not None + ), "If a resampler is not given must give parameters: max_n_regions" + assert ( + max_region_sizes is not None + ), "If a resampler is not given must give parameters: max_n_regions" self.max_n_regions = max_n_regions self.max_region_sizes = max_region_sizes diff --git a/src/wepy/resampling/decisions/clone_merge.py b/src/wepy/resampling/decisions/clone_merge.py index c97cc584..3c459196 100644 --- a/src/wepy/resampling/decisions/clone_merge.py +++ b/src/wepy/resampling/decisions/clone_merge.py @@ -1,22 +1,26 @@ # Standard Library import logging - -logger = logging.getLogger(__name__) -# Standard Library -from collections import defaultdict, namedtuple -from enum import Enum +from collections import defaultdict +from enum import IntEnum +from typing import TypedDict # Third Party Library -import numpy as np +import attrs # First Party Library -from wepy.resampling.decisions.decision import Decision -from wepy.walker import keep_merge, split +from wepy.resampling.decisions.decision import BaseDecisionABC, BaseDecisionRecord +from wepy.walker import Walker, keep_merge, split + +logger = logging.getLogger(__name__) + + +class CloneMergeDecisionError(Exception): + pass # the possible types of decisions that can be made enumerated for # storage, these each correspond to specific instruction type -class CloneMergeDecisionEnum(Enum): +class CloneMergeDecisionEnum(IntEnum): """Enum definition for cloning and merging decision values." - NOTHING : 1 @@ -41,7 +45,58 @@ class CloneMergeDecisionEnum(Enum): donate their weight to it.""" -class MultiCloneMergeDecision(Decision): +# TODO: get this automatically +CLONE_MERGE_DECISION_ENUM_VALUES = {1, 2, 3, 4} + + +class CloneMergeDecisionRecordDict(TypedDict): + decision_id: int + target_idxs: tuple[int, ...] + + +@attrs.define +class CloneMergeDecisionRecord(BaseDecisionRecord): + # TODO: get types correct + decision_id: int = attrs.field() + target_idxs: tuple[int, ...] = attrs.field() + + @decision_id.validator + def _check_decision_id(self, attribute, value) -> None: + if value not in CLONE_MERGE_DECISION_ENUM_VALUES: + raise ValueError( + f"Invalid decision_id ({value}) must be one of {CLONE_MERGE_DECISION_ENUM_VALUES}" + ) + + @target_idxs.validator + def _check_decision_id(self, attribute, value) -> None: + + if len(value) == 0: + raise ValueError("Must provide at least one target index in target_idxs.") + + if any(idx < 0 for idx in value): + raise ValueError("All target_idx values must be >= 0") + + def __attrs_post_init__(self) -> None: + + if self.decision_id in {1, 3, 4}: + if len(self.target_idxs) != 1: + raise CloneMergeDecisionError( + f"For decision_id ({CloneMergeDecisionEnum(self.decision_id).name}:{self.decision_id}) " + f"only a single target_idx is allowed." + ) + + else: + if len(self.target_idxs) < 2: + raise CloneMergeDecisionError( + f"For decision_id ({CloneMergeDecisionEnum(self.decision_id).name}:{self.decision_id}) " + f"more than one target_idx must be given." + ) + + def to_dict(self) -> CloneMergeDecisionRecordDict: + return attrs.asdict(self) + + +class MultiCloneMergeDecision(BaseDecisionABC): """Decision encoding cloning and merging decisions for weighted ensemble. The decision records have in addition to the 'decision_id' a field @@ -66,11 +121,13 @@ class MultiCloneMergeDecision(Decision): DEFAULT_DECISION = ENUM.NOTHING - FIELDS = Decision.FIELDS + ("target_idxs",) - SHAPES = Decision.SHAPES + (Ellipsis,) - DTYPES = Decision.DTYPES + (int,) + DECISION_RECORD = CloneMergeDecisionRecord - RECORD_FIELDS = Decision.RECORD_FIELDS + ("target_idxs",) + FIELDS = BaseDecisionABC.FIELDS + SHAPES = BaseDecisionABC.SHAPES + DTYPES = BaseDecisionABC.DTYPES + + RECORD_FIELDS = BaseDecisionABC.RECORD_FIELDS # the decision types that pass on their state ANCESTOR_DECISION_IDS = ( @@ -79,16 +136,12 @@ class MultiCloneMergeDecision(Decision): ENUM.CLONE.value, ) - # TODO deprecate in favor of Decision implementation - @classmethod - def record(cls, enum_value, target_idxs): - record = super().record(enum_value) - record["target_idxs"] = target_idxs - - return record - @classmethod - def action(cls, walkers, decisions): + def action( + cls, + walkers: list[Walker], + decisions: list[list[CloneMergeDecisionRecord]], + ) -> list[Walker]: # list for the modified walkers mod_walkers = [None for i in range(len(walkers))] @@ -101,8 +154,8 @@ def action(cls, walkers, decisions): # go through each decision and perform the decision # instructions for walker_idx, walker_rec in enumerate(step_recs): - decision_value = walker_rec["decision_id"] - instruction = walker_rec["target_idxs"] + decision_value = walker_rec.decision_id + instruction = walker_rec.target_idxs if decision_value == cls.ENUM.NOTHING.value: # check to make sure a walker doesn't already exist @@ -183,3 +236,38 @@ def action(cls, walkers, decisions): raise ValueError("Some walkers were not created") return mod_walkers + + @classmethod + def parents(cls, step: list[CloneMergeDecisionRecord]) -> list[int]: + """Given a step of resampling records (for a single resampling step) + returns the parents of the children of this step. + + Parameters + ---------- + step : list of decision records + The decision records for a step of resampling for each walker. + + Returns + ------- + walker_step_parents : list of int + For each element, the index of it in the list corresponds + to the child index and the value of the element is the + index of it's parent before the decision action. + + """ + + # initialize a list for the parents of this stages walkers + step_parents = [None for i in range(len(step))] + + # the rest of the stages parents are based on the previous stage + for parent_idx, parent_rec in enumerate(step): + # if the decision is an ancestor then the instruction + # values will be the children + if parent_rec.decision_id in cls.ANCESTOR_DECISION_IDS: + # the first value of the parent record is the target + # idxs + child_idxs = parent_rec.target_idxs + for child_idx in child_idxs: + step_parents[child_idx] = parent_idx + + return step_parents diff --git a/src/wepy/resampling/decisions/decision.py b/src/wepy/resampling/decisions/decision.py index a7263a05..e665395f 100644 --- a/src/wepy/resampling/decisions/decision.py +++ b/src/wepy/resampling/decisions/decision.py @@ -47,75 +47,116 @@ # Standard Library import logging +from typing import Any, Generic, Protocol, TypedDict, TypeVar, Union + +# Third Party Library +import attrs + +# First Party Library +from wepy.walker import Walker logger = logging.getLogger(__name__) -# Standard Library -from collections import namedtuple -from enum import Enum -from string import ascii_lowercase -# Third Party Library -import numpy as np +DecisionFieldDtype = Union[int,] +DecisionFieldShapeSpec = tuple[int | type(Ellipsis), ...] + + +class DecisionRecord(Protocol): + + def to_dict(self) -> dict[str, DecisionFieldDtype]: ... + + +class BaseDecisionRecordDict(TypedDict): + decision_id: int + target_idxs: tuple[int, ...] + + +@attrs.define +class BaseDecisionRecord: + decision_id: int + target_idxs: tuple[int, ...] + + def to_dict(self) -> BaseDecisionRecordDict: + return attrs.asdict(self) + + +DecisionEnum_ = TypeVar("DecisionEnum_") +DecisionRecord_ = TypeVar("DecisionRecord", bound=DecisionRecord) # ABC for the Decision class -class Decision: +class BaseDecisionABC(Generic[DecisionEnum_, DecisionRecord_]): """Represents and provides methods for a set of decision values.""" - ENUM = None + ENUM: type[DecisionEnum_] """The enumeration of the decision types. Maps them to integers.""" - DEFAULT_DECISION = None + DEFAULT_DECISION: int """The default decision to choose.""" - FIELDS = ("decision_id",) - """The names of the fields that go into the decision record.""" + DECISION_RECORD: DecisionRecord_ = BaseDecisionRecord - # suggestion for subclassing, FIELDS and others - # FIELDS = super().FIELDS + ('target_idxs',) - # etc. + FIELDS: tuple[str, ...] = ( + "decision_id", + "target_idxs", + ) + """The names of the fields that go into the decision record.""" # An Ellipsis instead of fields indicate there is a variable # number of fields. - SHAPES = ((1,),) + SHAPES: tuple[DecisionFieldShapeSpec, ...] = ( + (1,), + Ellipsis, + ) """Field data shapes.""" - DTYPES = (int,) + DTYPES: tuple[DecisionFieldDtype, ...] = ( + int, + int, + ) """Field data types.""" - RECORD_FIELDS = ("decision_id",) + RECORD_FIELDS: tuple[str, ...] = ( + "decision_id", + "target_idxs", + ) """The fields that could be used in a reduced table-like representation.""" - ANCESTOR_DECISION_IDS = None + ANCESTOR_DECISION_IDS: tuple[int, ...] """Specify the enum values where their walker state sample value is passed on in the next generation, i.e. after performing the action.""" @classmethod - def default_decision(cls): + def default_decision(cls) -> int: return cls.DEFAULT_DECISION @classmethod - def field_names(cls): + def field_names(cls) -> tuple[str, ...]: """Names of the decision record fields.""" return cls.FIELDS @classmethod - def field_shapes(cls): + def field_shapes(cls) -> tuple[DecisionFieldShapeSpec, ...]: """Field data shapes.""" return cls.SHAPES @classmethod - def field_dtypes(cls): + def field_dtypes(cls) -> tuple[DecisionFieldDtype, ...]: """Field data types.""" return cls.DTYPES @classmethod - def fields(cls): + def fields(cls) -> list[ + tuple[ + str, + DecisionFieldShapeSpec, + DecisionFieldDtype, + ] + ]: """Specs for each field. Returns ------- - fields : list of tuples Field specs each spec is of the form (name, shape, dtype). @@ -123,12 +164,12 @@ def fields(cls): return list(zip(cls.field_names(), cls.field_shapes(), cls.field_dtypes())) @classmethod - def record_field_names(cls): + def record_field_names(cls) -> tuple[str, ...]: """The fields that could be used in a reduced table-like representation.""" return cls.RECORD_FIELDS @classmethod - def enum_dict_by_name(cls): + def enum_dict_by_name(cls) -> dict[str, int]: """Get the decision enumeration as a dict mapping name to integer.""" if cls.ENUM is None: raise NotImplementedError @@ -139,7 +180,7 @@ def enum_dict_by_name(cls): return d @classmethod - def enum_dict_by_value(cls): + def enum_dict_by_value(cls) -> dict[int, DecisionEnum_]: """Get the decision enumeration as a dict mapping integer to name.""" if cls.ENUM is None: @@ -151,7 +192,7 @@ def enum_dict_by_value(cls): return d @classmethod - def enum_by_value(cls, enum_value): + def enum_by_value(cls, enum_value: int) -> DecisionEnum_: """Get the enum name for an enum_value. Parameters @@ -167,7 +208,7 @@ def enum_by_value(cls, enum_value): return d[enum_value] @classmethod - def enum_by_name(cls, enum_name): + def enum_by_name(cls, enum_name: str) -> DecisionEnum_: """Get the enum name for an enum_value. Parameters @@ -184,7 +225,7 @@ def enum_by_name(cls, enum_name): return d[enum_name] @classmethod - def record(cls, enum_value, **fields): + def record(cls, enum_value: int, **fields: dict[str, Any]) -> DecisionRecord_: """Generate a record for the enum_value and the other fields. Parameters @@ -207,13 +248,19 @@ def record(cls, enum_value, **fields): ), "The field {} is not a field for that decision".format(field_key) assert field_key != "decision_id", "'decision_id' cannot be an extra field" - rec = {"decision_id": enum_value} - rec.update(fields) + rec_d = {"decision_id": enum_value} + rec_d.update(fields) + + rec = cls.DECISION_RECORD(**rec_d) return rec @classmethod - def action(cls, walkers, decisions): + def action( + cls, + walkers: list[Walker], + decisions: list[list[DecisionRecord_]], + ) -> list[Walker]: """Perform the instructions for a set of resampling records on walkers. @@ -239,7 +286,6 @@ def action(cls, walkers, decisions): Returns ------- - resampled_walkers : list of Walker objects The resampled walkers. @@ -249,84 +295,3 @@ def action(cls, walkers, decisions): """ raise NotImplementedError - - @classmethod - def parents(cls, step): - """Given a step of resampling records (for a single resampling step) - returns the parents of the children of this step. - - Parameters - ---------- - step : list of decision records - The decision records for a step of resampling for each walker. - - Returns - ------- - walker_step_parents : list of int - For each element, the index of it in the list corresponds - to the child index and the value of the element is the - index of it's parent before the decision action. - - """ - - # initialize a list for the parents of this stages walkers - step_parents = [None for i in range(len(step))] - - # the rest of the stages parents are based on the previous stage - for parent_idx, parent_rec in enumerate(step): - # if the decision is an ancestor then the instruction - # values will be the children - if parent_rec[0] in cls.ANCESTOR_DECISION_IDS: - # the first value of the parent record is the target - # idxs - child_idxs = parent_rec[1] - for child_idx in child_idxs: - step_parents[child_idx] = parent_idx - - return step_parents - - -class NothingDecisionEnum(Enum): - """Enumeration of the decision values for doing nothing.""" - - NOTHING = 0 - """Do nothing with the walker.""" - - -class NoDecision(Decision): - """Decision for a resampling process that does no resampling.""" - - ENUM = NothingDecisionEnum - DEFAULT_DECISION = ENUM.NOTHING - - FIELDS = Decision.FIELDS + ("target_idxs",) - SHAPES = Decision.SHAPES + (Ellipsis,) - DTYPES = Decision.DTYPES + (int,) - - RECORD_FIELDS = Decision.RECORD_FIELDS + ("target_idxs",) - - ANCESTOR_DECISION_IDS = (ENUM.NOTHING.value,) - - @classmethod - def action(cls, walkers, decisions): - # list for the modified walkers - mod_walkers = [None for i in range(len(walkers))] - # go through each decision and perform the decision - # instructions - for walker_idx, decision in enumerate(decisions): - decision_value, instruction = decision - if decision_value == cls.ENUM.NOTHING.value: - # check to make sure a walker doesn't already exist - # where you are going to put it - if mod_walkers[instruction[0]] is not None: - raise ValueError( - "Multiple walkers assigned to position {}".format( - instruction[0] - ) - ) - - # put the walker in the position specified by the - # instruction - mod_walkers[instruction[0]] = walkers[walker_idx] - - return mod_walkers diff --git a/src/wepy/resampling/decisions/no_decision.py b/src/wepy/resampling/decisions/no_decision.py new file mode 100644 index 00000000..7f547b37 --- /dev/null +++ b/src/wepy/resampling/decisions/no_decision.py @@ -0,0 +1,107 @@ +# Standard Library +from enum import IntEnum +from typing import TypedDict + +# Third Party Library +import attrs + +# First Party Library +from wepy.resampling.decisions.decision import BaseDecisionABC, BaseDecisionRecord +from wepy.walker import Walker + + +class NothingDecisionEnum(IntEnum): + """Enumeration of the decision values for doing nothing.""" + + NOTHING = 0 + """Do nothing with the walker.""" + + +class NoDecisionRecordDict(TypedDict): + decision_id: int + target_idx: tuple[int, ...] + + +@attrs.define +class NoDecisionRecord(BaseDecisionRecord): + decision_id: int = attrs.field() + target_idxs: tuple[int, ...] = attrs.field() + + @decision_id.validator + def _check_decision_id(self, attribute, value) -> None: + + if value != NothingDecisionEnum.NOTHING.value: + raise ValueError( + f"Invalid decision_id ({value}) must be {NothingDecisionEnum.NOTHING.value}" + ) + + @target_idxs.validator + def _check_target_idxs(self, attribute, value) -> None: + + if len(value) < 1: + raise ValueError("'target_idxs' must have at least one entry.") + + if any(idx < 0 for idx in value): + + raise ValueError( + f"'target_idxs' values must be non-negative, received: {value}" + ) + + def to_dict(self) -> NoDecisionRecordDict: + return attrs.asdict(self) + + +class NoDecision(BaseDecisionABC): + """Decision for a resampling process that does no resampling.""" + + ENUM = NothingDecisionEnum + DEFAULT_DECISION = ENUM.NOTHING + DECISION_RECORD = NoDecisionRecord + + FIELDS = BaseDecisionABC.FIELDS + SHAPES = BaseDecisionABC.SHAPES + DTYPES = BaseDecisionABC.DTYPES + + RECORD_FIELDS = BaseDecisionABC.RECORD_FIELDS + + ANCESTOR_DECISION_IDS = (ENUM.NOTHING.value,) + + @classmethod + def action( + cls, + walkers: list[Walker], + decisions: list[NoDecisionRecord], + ) -> list[Walker]: + # list for the modified walkers + mod_walkers: list[Walker] = [None for i in range(len(walkers))] + # go through each decision and perform the decision + # instructions + for step_idx, step_recs in enumerate(decisions): + for walker_idx, decision_record in enumerate(step_recs): + + if decision_record.decision_id == cls.ENUM.NOTHING.value: + + target_idx = decision_record.target_idxs[0] + + # check to make sure a walker doesn't already exist + # where you are going to put it + if mod_walkers[target_idx] is not None: + raise ValueError( + f"Multiple walkers assigned to position {target_idx}" + ) + + # put the walker in the position specified by the + # instruction + mod_walkers[target_idx] = walkers[walker_idx] + + return mod_walkers + + @classmethod + def parents(cls, step: list[NoDecisionRecord]) -> list[int]: + + step_parents = [None for i in range(len(step))] + for parent_idx, parent_rec in enumerate(step): + + step_parents[parent_rec.target_idxs[0]] = parent_idx + + return step_parents diff --git a/src/wepy/resampling/distances/distance.py b/src/wepy/resampling/distances/base.py similarity index 52% rename from src/wepy/resampling/distances/distance.py rename to src/wepy/resampling/distances/base.py index 41954eb8..7c21924f 100644 --- a/src/wepy/resampling/distances/distance.py +++ b/src/wepy/resampling/distances/base.py @@ -24,22 +24,20 @@ # Standard Library import logging +from abc import ABC +from typing import Generic, Protocol, TypeVar -logger = logging.getLogger(__name__) - -# Third Party Library -import numpy as np -from wepy.util.util import box_vectors_to_lengths_angles +# First Party Library +from wepy.walker import WalkerState +logger = logging.getLogger(__name__) -class Distance(object): - """Abstract Base class for Distance classes.""" +WalkerState_ = TypeVar("WalkerState_", bound=WalkerState, covariant=True) +DistanceImage_ = TypeVar("DistanceImage_") - def __init__(self): - """Constructor for Distance class.""" - pass - def image(self, state): +class Distance(Protocol[DistanceImage_, WalkerState_]): + def image(self, state: WalkerState_) -> DistanceImage_: """Compute the 'image' of a walker state which should be some transformation of the walker state that is more convenient. E.g. for precomputation of expensive operations or @@ -60,9 +58,9 @@ def image(self, state): """ - return state + ... - def image_distance(self, image_a, image_b): + def image_distance(self, image_a: DistanceImage_, image_b: DistanceImage_) -> float: """Compute the distance between two images of walker states. Parameters @@ -73,19 +71,17 @@ def image_distance(self, image_a, image_b): Returns ------- - distance : float The distance between the two images Raises ------ - NotImplementedError : always because this is abstract """ - raise NotImplementedError + ... - def distance(self, state_a, state_b): + def distance(self, state_a: WalkerState_, state_b: WalkerState_) -> float: """Compute the distance between two states. Parameters @@ -96,82 +92,23 @@ def distance(self, state_a, state_b): Returns ------- - distance : float The distance between the two walker states """ - return self.image_distance(self.image(state_a), self.image(state_b)) - - -class XYEuclideanDistance(Distance): - """2 dimensional euclidean distance between points. + ... - States have the attributes 'x' and 'y'. - """ - - def image(self, state): - return np.array([state["x"], state["y"]]) - - def image_distance(self, image_a, image_b): - return np.sqrt((image_a[0] - image_b[0]) ** 2 + (image_a[1] - image_b[1]) ** 2) - - -class AtomPairDistance(Distance): - """Constructs a vector of atomic distances for each state. - Distance is the root mean squared distance between the vectors. - """ - - def __init__(self, pair_list, periodic=True): - """Construct a distance metric. +class DistanceABC(ABC, Generic[DistanceImage_, WalkerState_]): + """Abstract Base class for Distance classes.""" - Parameters - ---------- + def image(self, state: WalkerState_) -> DistanceImage_: + return state - pair_list : arraylike of tuples - The indices of the atom pairs between which to compute - distances. + def image_distance(self, image_a: DistanceImage_, image_b: DistanceImage_) -> float: + raise NotImplementedError - """ - self.pair_list = pair_list - self.periodic = periodic - - def _adjust_disp_vector(self, disp, box_lengths): - edited = True - while edited: - edited = False - for i in range(3): - if disp[i] > box_lengths[i] / 2: - disp[i] -= box_lengths[i] - edited = True - elif disp[i] < -box_lengths[i] / 2: - disp[i] += box_lengths[i] - edited = True - return disp - - def image(self, state): - if self.periodic: - # get the box lengths from the vectors - box_lengths, box_angles = box_vectors_to_lengths_angles( - state["box_vectors"] - ) - - dist_list = np.zeros((len(self.pair_list))) - for i, p in enumerate(self.pair_list): - disp_vector = state["positions"][p[0]] - state["positions"][p[1]] - if self.periodic: - dist_list[i] = np.sqrt( - np.sum( - np.square(self._adjust_disp_vector(disp_vector, box_lengths)) - ) - ) - else: - dist_list[i] = np.sqrt(np.sum(np.square(disp_vector))) - - return dist_list - - def image_distance(self, image_a, image_b): - return np.sqrt(np.mean(np.square(image_a - image_b))) + def distance(self, state_a: WalkerState_, state_b: WalkerState_) -> float: + return self.image_distance(self.image(state_a), self.image(state_b)) diff --git a/src/wepy/resampling/distances/mock.py b/src/wepy/resampling/distances/mock.py new file mode 100644 index 00000000..d3f69e12 --- /dev/null +++ b/src/wepy/resampling/distances/mock.py @@ -0,0 +1,19 @@ +# Standard Library +import math + +# Third Party Library +import attrs + +# First Party Library +from wepy.runners.mock import MockState + +# Local Modules +from .base import DistanceABC + + +@attrs.define +class MockDistance(DistanceABC): + + def image_distance(self, image_a: MockState, image_b: MockState) -> float: + + return math.sqrt((image_a.a - image_b.a) ** 2) diff --git a/src/wepy/resampling/distances/randomwalk.py b/src/wepy/resampling/distances/randomwalk.py deleted file mode 100644 index 81808f1b..00000000 --- a/src/wepy/resampling/distances/randomwalk.py +++ /dev/null @@ -1,72 +0,0 @@ -"""This module here is part of the RandomWalk object that implements -the distance metric for the RandomWalk walk system. This distance -metric is a scaled version of the Manhattan Norm. - -""" - -# Standard Library -import logging - -logger = logging.getLogger(__name__) - -# Third Party Library -import numpy as np - -# First Party Library -from wepy.resampling.distances.distance import Distance - - -class RandomWalkDistance(Distance): - """A class to implement the RandomWalkDistance metric for measuring - differences between walker states. This is a normalized Manhattan - distance measured between the difference in positions of the walkers. - - """ - - def __init__(self): - """Construct a RandomWalkDistance metric.""" - pass - - def image(self, state): - """Transform a state into a random walk image. - - A random walk image is just the position of a walker in the - N-dimensional space. - - Parameters - ---------- - - state : object implementing WalkerState - A walker state object with positions in a numpy array - of shape (N), where N is the the dimension of the random - walk system. - - Returns - ------- - - randomwalk_image : array of floats of shape (N) - The positions of a walker in the N-dimensional space. - - """ - return state["positions"] - - def image_distance(self, image_a, image_b): - """Compute the distance between the image of the two walkers. - - Parameters - ---------- - - image_a : array of float of shape (1, N) - Position of the first walker's state. - - image_b: array of float of shape (1, N) - Position of the second walker's state. - - Returns - ------- - - distance: float - The normalized Manhattan distance. - - """ - return np.average(np.abs(image_a - image_b)) diff --git a/src/wepy/resampling/distances/receptor.py b/src/wepy/resampling/distances/receptor.py index 81c2300c..71f03954 100644 --- a/src/wepy/resampling/distances/receptor.py +++ b/src/wepy/resampling/distances/receptor.py @@ -54,7 +54,6 @@ def __init__(self, ligand_idxs, binding_site_idxs, ref_state): Parameters ---------- - ligand_idxs : arraylike of int The indices of the atoms from the 'positions' attribute of states that correspond to the ligand molecule. @@ -148,7 +147,6 @@ def image(self, state): Returns ------- - receptor_image : array of float The positions of binding site and ligand after preprocessing. diff --git a/src/wepy/resampling/distances/simple.py b/src/wepy/resampling/distances/simple.py new file mode 100644 index 00000000..3a5dc8bf --- /dev/null +++ b/src/wepy/resampling/distances/simple.py @@ -0,0 +1,31 @@ +# Standard Library +import logging + +# Third Party Library +import attrs +import numpy as np + +# Local Modules +from .base import DistanceABC + +logger = logging.getLogger(__name__) + + +@attrs.define +class XYDistanceState: + coord: tuple[int, int] + + +@attrs.define +class XYEuclideanDistance(DistanceABC): + """2 dimensional euclidean distance between points. + + States have the attributes 'x' and 'y'. + + """ + + def image_distance(self, image_a, image_b): + return np.sqrt( + (image_a.coord[0] - image_b.coord[0]) ** 2 + + (image_a.coord[1] - image_b.coord[1]) ** 2 + ) diff --git a/src/wepy/resampling/resamplers/clone_merge.py b/src/wepy/resampling/resamplers/clone_merge.py index 541812d8..5c636587 100644 --- a/src/wepy/resampling/resamplers/clone_merge.py +++ b/src/wepy/resampling/resamplers/clone_merge.py @@ -1,12 +1,53 @@ +# Standard Library +from typing import Annotated, Generic, TypeVar + # Third Party Library +import attrs import numpy as np +from numpy.typing import NDArray # First Party Library -from wepy.resampling.decisions.clone_merge import MultiCloneMergeDecision -from wepy.resampling.resamplers.resampler import Resampler, ResamplerError - - -class CloneMergeResampler(Resampler): +from wepy.resampling.decisions.clone_merge import ( + CloneMergeDecisionRecord, + MultiCloneMergeDecision, +) +from wepy.resampling.resamplers.resampler import ( + ResamplerABC, + ResamplerError, +) +from wepy.storage.protocol import ResamplingRecord +from wepy.typing import Shape +from wepy.util.attrs import AttrsMappingMixin +from wepy.walker import Walker, WalkerState + + +@attrs.define +class CloneMergeResamplingRecord(AttrsMappingMixin, ResamplingRecord): + # from the Decision + decision_id: Annotated[ + NDArray[np.int64], + Shape((1,)), + ] + target_idxs: Annotated[ + NDArray[np.int64], + Shape((Ellipsis,)), + ] + + # extra for the resampler + step_idx: Annotated[ + NDArray[np.int64], + Shape((1,)), + ] + walker_idx: Annotated[ + NDArray[np.int64], + Shape((1,)), + ] + + +WalkerState_ = TypeVar("WalkerState_", bound=WalkerState) + + +class CloneMergeResampler(ResamplerABC, Generic[WalkerState_]): """Abstract base class for resamplers using the clone-merge decision class. @@ -23,11 +64,11 @@ class CloneMergeResampler(Resampler): DECISION = MultiCloneMergeDecision - RESAMPLING_FIELDS = DECISION.FIELDS + Resampler.CYCLE_FIELDS - RESAMPLING_SHAPES = DECISION.SHAPES + Resampler.CYCLE_SHAPES - RESAMPLING_DTYPES = DECISION.DTYPES + Resampler.CYCLE_DTYPES + RESAMPLING_FIELDS = DECISION.FIELDS + ResamplerABC.CYCLE_FIELDS + RESAMPLING_SHAPES = DECISION.SHAPES + ResamplerABC.CYCLE_SHAPES + RESAMPLING_DTYPES = DECISION.DTYPES + ResamplerABC.CYCLE_DTYPES - RESAMPLING_RECORD_FIELDS = DECISION.RECORD_FIELDS + Resampler.CYCLE_RECORD_FIELDS + RESAMPLING_RECORD_FIELDS = DECISION.RECORD_FIELDS + ResamplerABC.CYCLE_RECORD_FIELDS def __init__( self, @@ -36,12 +77,11 @@ def __init__( min_num_walkers=Ellipsis, max_num_walkers=Ellipsis, **kwargs, - ): + ) -> None: """Constructor for CloneMegerResampler class. Parameters ---------- - pmin : float The minimum probability any walker is allowed to have. @@ -54,30 +94,36 @@ def __init__( min_num_walkers=min_num_walkers, max_num_walkers=max_num_walkers, **kwargs ) + if pmin >= 1.0: + raise ResamplerError(f"pmin ({pmin}) must be less 1.0") + if pmax >= 1.0: + raise ResamplerError(f"pmax ({pmax}) must be less 1.0") + + if pmin > pmax: + raise ResamplerError(f"pmin ({pmin}) must be less than pmax ({pmax})") + self._pmin = pmin self._pmax = pmax @property - def pmin(self): + def pmin(self) -> float: return self._pmin @property - def pmax(self): + def pmax(self) -> float: return self._pmax - def _init_walker_actions(self, n_walkers): + def _init_walker_actions(self, n_walkers: int) -> list[CloneMergeDecisionRecord]: """Returns a list of default resampling records for a single resampling step. Parameters ---------- - n_walkers : int The number of walkers to generate records for Returns ------- - decision_records : list of dict of str: value A list of default decision records for one step of resampling. @@ -86,15 +132,17 @@ def _init_walker_actions(self, n_walkers): # determine resampling actions walker_actions = [ - self.decision.record( - enum_value=self.decision.default_decision().value, target_idxs=(i,) + self.decision().record( + enum_value=self.decision().default_decision().value, target_idxs=(i,) ) for i in range(n_walkers) ] return walker_actions - def _check_resampled_walkers(self, resampled_walkers): + def _check_resampled_walkers( + self, resampled_walkers: list[Walker[WalkerState_]] + ) -> None: """Check constraints on resampled walkers. Raises errors when constraints are violated. @@ -105,6 +153,8 @@ def _check_resampled_walkers(self, resampled_walkers): """ + # TODO: should we check that the sums are unity here? + walker_weights = np.array([walker.weight for walker in resampled_walkers]) # check that all of the weights are less than or equal to the pmax @@ -127,7 +177,11 @@ def _check_resampled_walkers(self, resampled_walkers): ) ) - def assign_clones(self, merge_groups, walker_clone_nums): + def assign_clones( + self, + merge_groups: list[list[int]], + walker_clone_nums: list[int], + ) -> list[CloneMergeDecisionRecord]: """Convert two convenient data structures to a list of almost normalized resampling records. @@ -159,7 +213,6 @@ def assign_clones(self, merge_groups, walker_clone_nums): Returns ------- - walker_actions : list of dict of str: values List of resampling record like dictionaries. These are not completely normalized for consumption by reporters, since @@ -167,6 +220,11 @@ def assign_clones(self, merge_groups, walker_clone_nums): """ + if len(merge_groups) != len(walker_clone_nums): + raise ResamplerError( + f"Size of merge_groups ({len(merge_groups)}) and walker_clone_nums ({len(walker_clone_nums)}) must be equal." + ) + n_walkers = len(walker_clone_nums) walker_actions = self._init_walker_actions(n_walkers) @@ -187,20 +245,20 @@ def assign_clones(self, merge_groups, walker_clone_nums): # for each squashed walker write a record and save it # in the walker actions for squash_idx in merge_group: - walker_actions[squash_idx] = self.decision.record( - self.decision.ENUM.SQUASH.value, target_idxs=(walker_idx,) + walker_actions[squash_idx] = self.decision().record( + self.decision().ENUM.SQUASH.value, target_idxs=(walker_idx,) ) # make the record for the keep merge walker - walker_actions[walker_idx] = self.decision.record( - self.decision.ENUM.KEEP_MERGE.value, target_idxs=(walker_idx,) + walker_actions[walker_idx] = self.decision().record( + self.decision().ENUM.KEEP_MERGE.value, target_idxs=(walker_idx,) ) # for each walker, if it is to be cloned assign open slots for it for walker_idx, num_clones in enumerate(walker_clone_nums): if num_clones > 0 and len(merge_groups[walker_idx]) > 0: raise ResamplerError( - "Error! cloning and merging occuring with the same walker" + f"Cloning and merging occuring with the same walker: {walker_idx}" ) # if this walker is to be cloned do so and consume the free @@ -217,9 +275,9 @@ def assign_clones(self, merge_groups, walker_clone_nums): # if there are any free slots, then we use those first if len(free_slots) > 0: - clone_targets.extend([ - free_slots.pop() for clone in range(num_clones) - ]) + clone_targets.extend( + [free_slots.pop() for clone in range(num_clones)] + ) # if there are more slots needed then we will have to # create them @@ -238,8 +296,11 @@ def assign_clones(self, merge_groups, walker_clone_nums): clone_targets.extend(new_slots) # make a record for this clone - walker_actions[walker_idx] = self.decision.record( - self.decision.ENUM.CLONE.value, target_idxs=tuple(clone_targets) + walker_actions[walker_idx] = self.decision().record( + self.decision().ENUM.CLONE.value, + target_idxs=tuple( + clone_targets, + ), ) return walker_actions diff --git a/src/wepy/resampling/resamplers/noresampler.py b/src/wepy/resampling/resamplers/noresampler.py new file mode 100644 index 00000000..66598163 --- /dev/null +++ b/src/wepy/resampling/resamplers/noresampler.py @@ -0,0 +1,108 @@ +# Standard Library + +# Third Party Library +import attrs + +# First Party Library +from wepy.resampling.decisions.no_decision import ( + NoDecision, + NothingDecisionEnum, +) +from wepy.resampling.resamplers.resampler import ResamplerABC +from wepy.storage.protocol import ResamplingRecord +from wepy.util.attrs import AttrsMappingMixin +from wepy.walker import Walker + + +@attrs.define +class NoResamplerResamplingRecord(AttrsMappingMixin, ResamplingRecord): + + # TODO: remove this once new interface is stable + # + # decision_id: Annotated[ + # NDArray[np.int64], + # Shape((1,)), + # ] + # target_idxs: Annotated[ + # NDArray[np.int64], + # Shape((1,1,)), + # ] + # step_idx: Annotated[ + # NDArray[np.int64], + # Shape((1,)), + # ] + # walker_idx: Annotated[ + # NDArray[np.int64], + # Shape((1,)), + # ] + + decision_id: int + target_idxs: tuple[int, ...] + step_idx: int + walker_idx: int + + +@attrs.define +class NoResamplerResamplerRecord(AttrsMappingMixin): + pass + + +class NoResampler(ResamplerABC): + """The resampler which does nothing.""" + + DECISION = NoDecision + + # must reset these when you change the decision + RESAMPLING_FIELDS = DECISION.FIELDS + ResamplerABC.CYCLE_FIELDS + RESAMPLING_SHAPES = DECISION.SHAPES + ResamplerABC.CYCLE_SHAPES + RESAMPLING_DTYPES = DECISION.DTYPES + ResamplerABC.CYCLE_DTYPES + + RESAMPLING_RECORD_FIELDS = DECISION.RECORD_FIELDS + ResamplerABC.CYCLE_RECORD_FIELDS + + def resample( + self, + walkers: list[Walker], + ) -> tuple[ + list[Walker], + list[NoDecision], + list[NoResamplerResamplerRecord], + ]: + + # normally decide is only for a single step and so does not + # include the step_idx, so we add this to the records, and + # convert the target idxs and decision_id to feature vector + # arrays + _resampling_data = [] + for walker_idx in range(len(walkers)): + + # UGLY: we need to wrap the field data into the shape + walker_record = NoResamplerResamplingRecord( + decision_id=NothingDecisionEnum.NOTHING.value, + # NOTE: two dimensions to match the target_idxs shape + target_idxs=(walker_idx,), + walker_idx=walker_idx, + step_idx=0, + ) + + _resampling_data.append(walker_record) + + # only a single step of decisions + resampling_data = _resampling_data + + # there is no change in state in the resampler so there are no + # resampler records + resampler_data = [NoResamplerResamplerRecord()] + + # the resampled walkers are just the walkers + return walkers, resampling_data, resampler_data + + +@attrs.define +class NoResamplerFactory: + + @classmethod + def type(cls) -> type[NoResampler]: + return NoResampler + + def __call__(self, num_cores: int) -> NoResampler: + return NoResampler() diff --git a/src/wepy/resampling/resamplers/resampler.py b/src/wepy/resampling/resamplers/resampler.py index 028ee394..4e2acf86 100644 --- a/src/wepy/resampling/resamplers/resampler.py +++ b/src/wepy/resampling/resamplers/resampler.py @@ -1,27 +1,72 @@ # Standard Library -import itertools as it import logging - -logger = logging.getLogger(__name__) -# Standard Library -from collections import defaultdict +from typing import Generic, Protocol, TypeVar from warnings import warn # Third Party Library -import numpy as np # First Party Library -from wepy.resampling.decisions.decision import Decision, NoDecision +from wepy.resampling.decisions.decision import BaseDecisionABC +from wepy.storage.protocol import Record, RecordFieldDtype, RecordFieldShapeSpec +from wepy.walker import Walker, WalkerState + +logger = logging.getLogger(__name__) + +WalkerState_ = TypeVar("WalkerState_", bound=WalkerState) class ResamplerError(Exception): """Error raised when some constraint on resampling properties is - violated.""" + violated. + """ pass -class Resampler: +ResamplingRecord_ = TypeVar("ResamplingRecord_", bound=Record) +ResamplerRecord_ = TypeVar("ResamplerRecord_", bound=Record) + + +class Resampler(Protocol, Generic[WalkerState_, ResamplingRecord_, ResamplerRecord_]): + + DECISION: BaseDecisionABC + CYCLE_FIELDS: tuple[str, ...] + CYCLE_SHAPES: tuple[RecordFieldShapeSpec, ...] + CYCLE_DTYPES: tuple[RecordFieldDtype, ...] + CYCLE_RECORD_FIELDS: tuple[str, ...] | None + RESAMPLING_FIELDS: tuple[str, ...] + RESAMPLING_SHAPES: tuple[RecordFieldShapeSpec | None, ...] + RESAMPLING_DTYPES: tuple[RecordFieldDtype | None, ...] + RESAMPLING_RECORD_FIELDS: None | tuple[str, ...] + RESAMPLER_FIELDS: tuple[str, ...] + RESAMPLER_SHAPES: tuple[RecordFieldShapeSpec | None, ...] + + RESAMPLER_DTYPES: tuple[RecordFieldDtype | None, ...] + RESAMPLER_RECORD_FIELDS: tuple[str, ...] | None + + @classmethod + def resampling_fields(cls) -> tuple[ + tuple[str, ...], + tuple[RecordFieldShapeSpec, ...], + tuple[RecordFieldDtype, ...], + ]: ... + + @classmethod + def resampling_record_field_names(cls) -> None | tuple[str, ...]: ... + + @classmethod + def resampler_record_field_names(cls) -> None | tuple[str, ...]: ... + + def resample(self, walkers: list[Walker[WalkerState_]]) -> tuple[ + list[Walker[WalkerState_]], + list[ResamplingRecord_], + list[ResamplerRecord_], + ]: ... + + +class ResamplerABC( + Resampler, Generic[WalkerState_, ResamplingRecord_, ResamplerRecord_] +): """Abstract base class for implementing resamplers. All subclasses of Resampler must implement the 'resample' method. @@ -42,7 +87,7 @@ class Resampler: - RESAMPLER_RECORD_FIELDS The DECISION constant should be a - wepy.resampling.decisions.decision.Decision subclass. + wepy.resampling.decisions.decision.BaseDecisionABC subclass. This base class provides some hidden methods that are useful for various purposes. @@ -82,10 +127,10 @@ class Resampler: """ - DECISION = Decision + DECISION: BaseDecisionABC = BaseDecisionABC """The decision class for this resampler.""" - CYCLE_FIELDS = ( + CYCLE_FIELDS: tuple[str, ...] = ( "step_idx", "walker_idx", ) @@ -95,19 +140,19 @@ class Resampler: walker index. """ - CYCLE_SHAPES = ( + CYCLE_SHAPES: tuple[tuple[int, ...], ...] = ( (1,), (1,), ) """Data shapes of the cycle fields.""" - CYCLE_DTYPES = ( + CYCLE_DTYPES: tuple[int | float] = ( int, int, ) """Data types of the cycle fields """ - CYCLE_RECORD_FIELDS = ( + CYCLE_RECORD_FIELDS: tuple[str, ...] = ( "step_idx", "walker_idx", ) @@ -281,16 +326,15 @@ class Resampler: def __init__( self, - min_num_walkers=Ellipsis, - max_num_walkers=Ellipsis, - debug_mode=False, + min_num_walkers: int | None | type(Ellipsis) = Ellipsis, + max_num_walkers: int | None | type(Ellipsis) = Ellipsis, + debug_mode: bool = False, **kwargs, - ): + ) -> None: """Constructor for Resampler class Parameters ---------- - min_num_walkers : int or None or Ellipsis The minimum number of walkers allowed to have. None is unbounded, and Ellipsis preserves whatever number of @@ -307,7 +351,7 @@ def __init__( """ - # the min and max number of walkers that can be generated in + # The min and max number of walkers that can be generated in # resampling. # Ellipsis means to keep bound it by the number of @@ -322,12 +366,20 @@ def __init__( # min_num_walkers of None in practice is 1 since there must # always be at least 1 walker - if min_num_walkers not in (Ellipsis, None): + if min_num_walkers not in {Ellipsis, None}: if min_num_walkers < 1: raise ResamplerError( "The minimum number of walkers should be at least 1" ) + if ( + max_num_walkers not in {Ellipsis, None} + and min_num_walkers > max_num_walkers + ): + raise ResamplerError( + f"min_num_walkers ({min_num_walkers}) must be less than or equal to max_num_walkers ({max_num_walkers})" + ) + self._min_num_walkers = min_num_walkers self._max_num_walkers = max_num_walkers @@ -341,88 +393,94 @@ def __init__( # set them to the args given self.set_debug_mode(debug_mode) - @property - def decision(self): + @classmethod + def decision(cls) -> BaseDecisionABC: """The decision class for this resampler.""" - return self.DECISION + return cls.DECISION - def resampling_field_names(self): + @classmethod + def resampling_field_names(cls): """Access the class level FIELDS constant for this record group.""" - return self.RESAMPLING_FIELDS + return cls.RESAMPLING_FIELDS - def resampling_field_shapes(self): + @classmethod + def resampling_field_shapes(cls): """Access the class level SHAPES constant for this record group.""" - return self.RESAMPLING_SHAPES + return cls.RESAMPLING_SHAPES - def resampling_field_dtypes(self): + @classmethod + def resampling_field_dtypes(cls): """Access the class level DTYPES constant for this record group.""" - return self.RESAMPLING_DTYPES + return cls.RESAMPLING_DTYPES - def resampling_fields(self): + @classmethod + def resampling_fields(cls): """Returns a list of zipped field specs. Returns ------- - record_specs : list of tuple A list of the specs for each field, a spec is a tuple of type (field_name, shape_spec, dtype_spec) """ return list( zip( - self.resampling_field_names(), - self.resampling_field_shapes(), - self.resampling_field_dtypes(), + cls.resampling_field_names(), + cls.resampling_field_shapes(), + cls.resampling_field_dtypes(), ) ) - def resampling_record_field_names(self): + @classmethod + def resampling_record_field_names(cls): """Access the class level RECORD_FIELDS constant for this record group.""" - return self.RESAMPLING_RECORD_FIELDS + return cls.RESAMPLING_RECORD_FIELDS - def resampler_field_names(self): + @classmethod + def resampler_field_names(cls): """Access the class level FIELDS constant for this record group.""" - return self.RESAMPLER_FIELDS + return cls.RESAMPLER_FIELDS - def resampler_field_shapes(self): + @classmethod + def resampler_field_shapes(cls): """Access the class level SHAPES constant for this record group.""" - return self.RESAMPLER_SHAPES + return cls.RESAMPLER_SHAPES - def resampler_field_dtypes(self): + @classmethod + def resampler_field_dtypes(cls): """Access the class level DTYPES constant for this record group.""" - return self.RESAMPLER_DTYPES + return cls.RESAMPLER_DTYPES - def resampler_fields(self): + @classmethod + def resampler_fields(cls): """Returns a list of zipped field specs. Returns ------- - record_specs : list of tuple A list of the specs for each field, a spec is a tuple of type (field_name, shape_spec, dtype_spec) """ return list( zip( - self.resampler_field_names(), - self.resampler_field_shapes(), - self.resampler_field_dtypes(), + cls.resampler_field_names(), + cls.resampler_field_shapes(), + cls.resampler_field_dtypes(), ) ) - def resampler_record_field_names(self): + @classmethod + def resampler_record_field_names(cls): """Access the class level RECORD_FIELDS constant for this record group.""" - return self.RESAMPLER_RECORD_FIELDS + return cls.RESAMPLER_RECORD_FIELDS @property - def is_debug_on(self): + def is_debug_on(self) -> bool: """ """ return self._debug_mode - def set_debug_mode(self, mode): - """ - - Parameters + def set_debug_mode(self, mode: bool) -> None: + """Parameters ---------- mode @@ -446,14 +504,14 @@ def set_debug_mode(self, mode): "You must have ipdb installed to use the debug feature" ) - def debug_on(self): + def debug_on(self) -> None: """ """ if self.is_debug_on: warn("Debug mode is already on") self.set_debug_mode(True) - def debug_off(self): + def debug_off(self) -> None: """ """ if not self.is_debug_on: warn("Debug mode is already off") @@ -461,16 +519,16 @@ def debug_off(self): self.set_debug_mode(False) @property - def max_num_walkers_setting(self): + def max_num_walkers_setting(self) -> int: """The specification for the maximum number of walkers for the resampler.""" return self._max_num_walkers @property - def min_num_walkers_setting(self): + def min_num_walkers_setting(self) -> int: """The specification for the minimum number of walkers for the resampler.""" return self._min_num_walkers - def max_num_walkers(self): + def max_num_walkers(self) -> int | None: """ " Get the max number of walkers allowed currently""" # first check to make sure that a resampling is occuring and @@ -499,7 +557,7 @@ def max_num_walkers(self): else: return self.max_num_walkers_setting - def min_num_walkers(self): + def min_num_walkers(self) -> int | None: """ " Get the min number of walkers allowed currently""" # first check to make sure that a resampling is occuring and @@ -528,7 +586,7 @@ def min_num_walkers(self): else: return self.min_num_walkers_setting - def _set_resampling_num_walkers(self, num_walkers): + def _set_resampling_num_walkers(self, num_walkers: int) -> None: """Sets the concrete number of walkers constraints given a number of walkers and the settings for max and min. @@ -560,10 +618,13 @@ def _set_resampling_num_walkers(self, num_walkers): "The number of walkers given to resample is less than the maximum" ) - def _unset_resampling_num_walkers(self): + def _unset_resampling_num_walkers(self) -> None: self._resampling_num_walkers = None - def _resample_init(self, walkers, **kwargs): + def _resample_init( + self, + walkers: list[Walker[WalkerState_]], + ) -> None: """Common initialization stuff for resamplers. Sets the number of walkers in this round of resampling. @@ -577,7 +638,7 @@ def _resample_init(self, walkers, **kwargs): # first set how many walkers there are in this resampling self._set_resampling_num_walkers(len(walkers)) - def _resample_cleanup(self, **kwargs): + def _resample_cleanup(self, **kwargs) -> None: """Common cleanup stuff for resamplers. Unsets the number of walkers for this round of resampling. @@ -587,7 +648,15 @@ def _resample_cleanup(self, **kwargs): # unset the number of walkers for this resampling self._unset_resampling_num_walkers() - def resample(self, walkers, debug_mode=False): + def resample( + self, + walkers: list[Walker[WalkerState_]], + debug_mode: bool = False, + ) -> tuple[ + list[Walker[WalkerState_]], + list[ResamplingRecord_], + list[ResamplerRecord_], + ]: """Perform resampling on the set of walkers. Parameters @@ -601,7 +670,6 @@ def resample(self, walkers, debug_mode=False): Returns ------- - resampled_walkers : list of Walker objects The set of resampled walkers @@ -615,83 +683,4 @@ def resample(self, walkers, debug_mode=False): """ - raise NotImplemented - - self._resample_init(walkers, debug_mode=debug_mode) - - -class NoResampler(Resampler): - """The resampler which does nothing.""" - - DECISION = NoDecision - - # must reset these when you change the decision - RESAMPLING_FIELDS = DECISION.FIELDS + Resampler.CYCLE_FIELDS - RESAMPLING_SHAPES = DECISION.SHAPES + Resampler.CYCLE_SHAPES - RESAMPLING_DTYPES = DECISION.DTYPES + Resampler.CYCLE_DTYPES - - RESAMPLING_RECORD_FIELDS = DECISION.RECORD_FIELDS + Resampler.CYCLE_RECORD_FIELDS - - def resample(self, walkers, **kwargs): - self._resample_init(walkers=walkers) - - n_walkers = len(walkers) - - # the walker actions are all nothings with the same walker - # index which is the default initialization - resampling_data = self._init_walker_actions(n_walkers) - - # normally decide is only for a single step and so does not - # include the step_idx, so we add this to the records, and - # convert the target idxs and decision_id to feature vector - # arrays - for walker_idx, walker_record in enumerate(resampling_data): - walker_record["walker_idx"] = np.array([walker_idx]) - walker_record["step_idx"] = np.array([0]) - walker_record["walker_idx"] = np.array([walker_record["walker_idx"]]) - walker_record["decision_id"] = np.array([walker_record["decision_id"]]) - walker_record["target_idxs"] = np.array([walker_record["walker_idx"]]) - - # we only have one step so our resampling_records are just the - # single list of walker actions - resampling_data = resampling_data - - # there is no change in state in the resampler so there are no - # resampler records - resampler_data = [{}] - - # the resampled walkers are just the walkers - - self._resample_cleanup( - resampling_data=resampling_data, - resampler_data=resampler_data, - walkers=walkers, - ) - - return walkers, resampling_data, resampler_data - - def _init_walker_actions(self, n_walkers): - """Returns a list of default resampling records for a single - resampling step. - - Parameters - ---------- - - n_walkers : int - The number of walkers to generate records for - - Returns - ------- - - decision_records : list of dict of str: value - A list of default decision records for one step of - resampling. - - """ - # determine resampling actions - walker_actions = [ - self.decision.record(enum_value=self.decision.default_decision().value) - for i in range(n_walkers) - ] - - return walker_actions + raise NotImplementedError diff --git a/src/wepy/resampling/resamplers/revo.py b/src/wepy/resampling/resamplers/revo.py index 247f0597..5696d710 100644 --- a/src/wepy/resampling/resamplers/revo.py +++ b/src/wepy/resampling/resamplers/revo.py @@ -1,22 +1,82 @@ # Standard Library import itertools as it import logging - -logger = logging.getLogger(__name__) -# Standard Library -import multiprocessing as mulproc +import multiprocessing as mp import random as rand +import time +from typing import Annotated, Callable, Generic, Literal, TypeVar # Third Party Library +import attrs import numpy as np +from numpy.typing import NDArray # First Party Library -from wepy.resampling.decisions.clone_merge import MultiCloneMergeDecision -from wepy.resampling.resamplers.clone_merge import CloneMergeResampler -from wepy.resampling.resamplers.resampler import Resampler +from wepy.resampling.decisions.clone_merge import CloneMergeDecisionRecord +from wepy.resampling.distances.base import Distance +from wepy.resampling.resamplers.clone_merge import ( + CloneMergeResampler, + CloneMergeResamplingRecord, +) +from wepy.typing import Shape +from wepy.util.attrs import AttrsMappingMixin +from wepy.util.multiprocessing import proc_pool_worker_setup, queue_listener_context +from wepy.walker import Walker, WalkerState + +logger = logging.getLogger(__name__) + +DistanceMetric_ = TypeVar("DistanceMetric_", bound=Distance) +DistanceImage_ = TypeVar("DistanceImage_") +WalkerState_ = TypeVar("WalkerState_", bound=WalkerState) + +MergeAlgorithm = Literal["pairs", "greedy"] + + +class REVOResamplerError(Exception): + pass -class REVOResampler(CloneMergeResampler): +class _ImageWrapper(Generic[WalkerState_, DistanceImage_]): + """Wrapper callable to inject a few log messages to image + computation. + + Useful for if the image function does not log anything and this + will guarantee some logs are generated which is useful for + troubleshooting process pool problem. + + """ + + def __init__(self, image_func: Callable[[WalkerState_], DistanceImage_]) -> None: + self.image_func = image_func + + def __call__(self, state: WalkerState_) -> DistanceImage_: + logger.info("Starting image computation") + result = self.image_func(state) + logger.info("Finished image computation") + return result + + +@attrs.define +class REVOResamplerResamplerRecord(AttrsMappingMixin): + distance_matrix: Annotated[ + NDArray[np.float32], + Shape( + ( + Ellipsis, + Ellipsis, + ) + ), + ] + variation: Annotated[ + NDArray[np.float32], + Shape((1,)), + ] + + +class REVOResampler( + CloneMergeResampler, + Generic[DistanceMetric_, DistanceImage_, WalkerState_], +): r"""Resampler implementing the REVO algorithm. You can find more detailed information in the paper "REVO: @@ -95,27 +155,31 @@ class REVOResampler(CloneMergeResampler): """ - # fields for resampler data + distance_metric: DistanceMetric_ + merge_dist: float + char_dist: float + dist_exponent: int + weights: bool + merge_alg: MergeAlgorithm + pmin: float + pmax: float + seed: int | None + num_proc: int + lpmin: float + RESAMPLING_FIELDS = CloneMergeResampler.RESAMPLING_FIELDS - RESAMPLING_SHAPES = CloneMergeResampler.RESAMPLING_SHAPES # + (Ellipsis,) - RESAMPLING_DTYPES = CloneMergeResampler.RESAMPLING_DTYPES # + (np.int,) + RESAMPLING_SHAPES = CloneMergeResampler.RESAMPLING_SHAPES + RESAMPLING_DTYPES = CloneMergeResampler.RESAMPLING_DTYPES - # fields that can be used for a table like representation RESAMPLING_RECORD_FIELDS = CloneMergeResampler.RESAMPLING_RECORD_FIELDS - # fields for resampling data RESAMPLER_FIELDS = CloneMergeResampler.RESAMPLER_FIELDS + ( - "num_walkers", "distance_matrix", "variation", ) - RESAMPLER_SHAPES = CloneMergeResampler.RESAMPLER_SHAPES + ( - (1,), - Ellipsis, - (1,), - ) + + RESAMPLER_SHAPES = CloneMergeResampler.RESAMPLER_SHAPES + (Ellipsis, (1,)) RESAMPLER_DTYPES = CloneMergeResampler.RESAMPLER_DTYPES + ( - int, float, float, ) @@ -127,24 +191,21 @@ class REVOResampler(CloneMergeResampler): def __init__( self, - merge_dist=None, - char_dist=None, - distance=None, - init_state=None, - weights=True, - merge_alg="pairs", - pmin=1e-12, - pmax=0.1, - dist_exponent=4, - seed=None, - num_proc=1, - **kwargs, - ): + merge_dist: float, + char_dist: float, + distance: DistanceMetric_, + weights: bool, + merge_alg: MergeAlgorithm, + pmin: float, + pmax: float, + dist_exponent: int, + seed: int | None, + num_proc: int = 1, + ) -> None: """Constructor for the REVO Resampler. Parameters ---------- - dist_exponent : int The distance exponent that modifies distance and weight novelty relative to each other in the variation equation. @@ -198,14 +259,8 @@ def __init__( pmax=pmax, min_num_walkers=Ellipsis, max_num_walkers=Ellipsis, - **kwargs, ) - assert merge_dist is not None, "Merge distance must be given." - assert distance is not None, "Distance object must be given." - assert char_dist is not None, "Characteristic distance value (d0) must be given" - assert init_state is not None, "An initial state must be given." - # ln(probability_min) self.lpmin = np.log(self.pmin / 100) self.dist_exponent = dist_exponent @@ -230,12 +285,11 @@ def __init__( # setting the number of processors self.num_proc = num_proc - def _novelty(self, walker_weight, num_walker_copy): + def _novelty(self, walker_weight: float, num_walker_copy: int) -> float: """Calculates the novelty function value. Parameters ---------- - walker_weight : float The weight of the walker. @@ -249,30 +303,34 @@ def _novelty(self, walker_weight, num_walker_copy): """ - novelty = 0 + novelty = 0.0 if walker_weight > 0 and num_walker_copy > 0: if self.weights: novelty = np.log(walker_weight / num_walker_copy) - self.lpmin else: - novelty = 1 + novelty = 1.0 if novelty < 0: - novelty = 0 + novelty = 0.0 return novelty - def _calc_variation(self, walker_weights, num_walker_copies, distance_matrix): + def _calc_variation( + self, + walker_weights: list[float], + num_walker_copies: list[int], + distance_matrix: list[list[float]], + ) -> tuple[float, list[float]]: """Calculates the variation value. Parameters ---------- - walker_weights : list of float The weights of all walkers. The sum of all weights should be 1.0. num_walker_copies : list of int The number of copies of each walker. - 0 means the walker is not exists anymore. + 0 means the walker does not exist anymore. 1 means there is one of the this walker. >1 means it should be cloned to this number of walkers. @@ -291,13 +349,15 @@ def _calc_variation(self, walker_weights, num_walker_copies, distance_matrix): num_walkers = len(walker_weights) # set the novelty values - walker_novelties = np.array([ - self._novelty(walker_weights[i], num_walker_copies[i]) - for i in range(num_walkers) - ]) + walker_novelties = np.array( + [ + self._novelty(walker_weights[i], num_walker_copies[i]) + for i in range(num_walkers) + ] + ) # the value to be optimized - variation = 0 + variation: float = 0.0 # the walker variation values (Vi values) walker_variations = np.zeros(num_walkers) @@ -326,12 +386,16 @@ def _calc_variation(self, walker_weights, num_walker_copies, distance_matrix): return variation, walker_variations - def _calc_variation_loss(self, walker_variation, weights, eligible_pairs): + def _calc_variation_loss( + self, + walker_variation: list[float], + weights: list[float], + eligible_pairs: list[tuple[int, int]], + ) -> tuple[int, int] | None: """Calculates the loss to variation through merging of eligible walkers. Parameters ---------- - walker_variations : arraylike of shape (num_walkers) The Vi value of each walker. @@ -343,15 +407,14 @@ def _calc_variation_loss(self, walker_variation, weights, eligible_pairs): Returns ------- - - variation_loss_list : tuple + variation_loss_list : tuple or None A tuple of the walker merge pair indicies that meet the criteria - for merging and minimize variation loss. + for merging and minimize variation loss. If none is found returns None """ v_loss_min = np.inf - min_loss_pair = () + min_loss_pair: tuple[int, int] | None = None for pair in eligible_pairs: walker_i = pair[0] @@ -373,29 +436,33 @@ def _calc_variation_loss(self, walker_variation, weights, eligible_pairs): return min_loss_pair def _find_eligible_merge_pairs( - self, weights, distance_matrix, max_var_idx, num_walker_copies - ): + self, + weights: list[float], + distance_matrix: list[list[float]], + max_var_idx: int, + num_walker_copies: list[int], + ) -> list[tuple[int, int]]: """Find pairs of walkers that are eligible to be merged. Parameters ---------- - weights : list of float The weights of all walkers. The sum of all weights should be 1.0. distance_matrix : list of arraylike of shape (num_walkers) The distance between every walker according to the distance metric. - max_var_idx : float + max_var_idx : int The index of the walker that had the highest walker variance and is a candidate for cloning. - num_walker_copies : list of int The number of copies of each walker. - 0 means the walker is not exists anymore. - 1 means there is one of the this walker. >1 means it should be cloned to this number of walkers. + num_walker_copies : list of int + 0 means the walker does not exist anymore. + 1 means there is one of the this walker. + >1 means it should be cloned to this number of walkers. + Returns ------- - eligible_pairs : list of tuples Pairs of walker indexes that meet the criteria for merging. @@ -413,12 +480,19 @@ def _find_eligible_merge_pairs( return eligible_pairs - def decide(self, walker_weights, num_walker_copies, distance_matrix): + def decide( + self, + walker_weights: list[float], + num_walker_copies: list[int], + distance_matrix: list[list[float]], + ) -> tuple[ + list[CloneMergeDecisionRecord], + float, + ]: """Optimize the trajectory variation by making decisions for resampling. Parameters ---------- - walker_weights : list of float The weights of all walkers. The sum of all weights should be 1.0. @@ -432,11 +506,10 @@ def decide(self, walker_weights, num_walker_copies, distance_matrix): Returns ------- - variation : float - The optimized value of the trajectory variation. - resampling_data : list of dict of str: value The resampling records resulting from the decisions. + variation : float + The optimized value of the trajectory variation. """ num_walkers = len(walker_weights) @@ -456,10 +529,16 @@ def decide(self, walker_weights, num_walker_copies, distance_matrix): variations.append(variation) # maximize the variance through cloning and merging - logger.info("Starting variance optimization: {}".format(variation)) + logger.info(f"Starting variance optimization: {variation}") + _count = 1 productive = True while productive: + _log = False + if _count == 1 or _count % 10 == 0: + _log = True + logger.info(f"Optimization iteration: {_count}") + _count += 1 productive = False # find min and max walker_variationss, alter new_amp @@ -489,12 +568,12 @@ def decide(self, walker_weights, num_walker_copies, distance_matrix): if len(max_tups) > 0: max_value, max_idx = max(max_tups) - merge_pair = [] + maybe_merge_pair: tuple[int, int] | None = None if self.merge_alg == "pairs": pot_merge_pairs = self._find_eligible_merge_pairs( new_walker_weights, distance_matrix, max_idx, new_num_walker_copies ) - merge_pair = self._calc_variation_loss( + maybe_merge_pair = self._calc_variation_loss( walker_variations, new_walker_weights, pot_merge_pairs ) elif self.merge_alg == "greedy": @@ -543,15 +622,15 @@ def decide(self, walker_weights, num_walker_copies, distance_matrix): # if any were found set this as the closewalk if len(closewalks_dists) > 0: closedist, closewalk = min(closewalks_dists) - merge_pair = [min_idx, closewalk] + maybe_merge_pair = (min_idx, closewalk) else: - raise ValueError("Unrecognized value for merge_alg in REVO") + raise ValueError(f"Unrecognized value for merge_alg: {self.merge_alg}") # did we find a suitable pair to merge? - if len(merge_pair) != 0: - min_idx = merge_pair[0] - closewalk = merge_pair[1] + if maybe_merge_pair is not None: + min_idx = maybe_merge_pair[0] + closewalk = maybe_merge_pair[1] # change new_amp tempsum = new_walker_weights[min_idx] + new_walker_weights[closewalk] @@ -569,7 +648,8 @@ def decide(self, walker_weights, num_walker_copies, distance_matrix): if new_variation > variation: variations.append(new_variation) - logger.info("Variance move to {} accepted".format(new_variation)) + if _log: + logger.info(f"Variance move to {new_variation} accepted") productive = True variation = new_variation @@ -620,7 +700,10 @@ def decide(self, walker_weights, num_walker_copies, distance_matrix): ) variations.append(new_variation) - logger.info("variance after selection: {}".format(new_variation)) + if _log: + logger.info( + "variance after selection: {}".format(new_variation) + ) # if not productive else: @@ -628,21 +711,21 @@ def decide(self, walker_weights, num_walker_copies, distance_matrix): new_num_walker_copies[closewalk] = 1 new_num_walker_copies[max_idx] -= 1 - # given we know what we want to clone to specific slots - # (squashing other walkers) we need to determine where these - # squashed walkers will be merged - walker_actions = self.assign_clones(merge_groups, walker_clone_nums) + final_variation = variations[-1] + logger.info(f"Finished optimization: {final_variation}") - # because there is only one step in resampling here we just - # add another field for the step as 0 and add the walker index - # to its record as well - for walker_idx, walker_record in enumerate(walker_actions): - walker_record["step_idx"] = np.array([0]) - walker_record["walker_idx"] = np.array([walker_idx]) + logger.info("Assigning clones") + decision_records = self.assign_clones(merge_groups, walker_clone_nums) - return walker_actions, variations[-1] + return decision_records, final_variation - def _all_to_all_distance(self, walkers): + def _all_to_all_distance( + self, + walkers: list[Walker[WalkerState_]], + ) -> tuple[ + list[list[float]], + list[DistanceImage_], + ]: """Calculate the pairwise all-to-all distances between walkers. Parameters @@ -658,18 +741,65 @@ def _all_to_all_distance(self, walkers): """ # initialize an all-to-all matrix, with 0.0 for self distances - dist_mat = np.zeros((len(walkers), len(walkers))) + dist_mat = [[0.0 for _ in range(len(walkers))] for _ in range(len(walkers))] + + logger.info("Starting calculation of walker images") + start_time = time.time() # make images for all the walker states for us to compute distances on if self.num_proc > 1: - with mulproc.Pool(self.num_proc) as pool: - images = pool.map(self.distance.image, [walker.state for walker in walkers]) + logger.info( + f"Multiple processes requested ({self.num_proc}) will run in Pool." + ) + + _distance_image = _ImageWrapper(self.distance.image) + + # NOTE: Must use spawn here, otherwise there are problems + # with deadlocking in the sub-processes + mp_ctx = mp.get_context(method="spawn") + + # TODO: This should be part of some setup phase + logger.info("Starting multiprocessing.Pool") + with ( + queue_listener_context(mp_ctx) as log_queue, + mp_ctx.Pool( + self.num_proc, + initializer=proc_pool_worker_setup, + initargs=(log_queue,), + # Set some upper bound so that it gets cleaned up + # in case of leaks + maxtasksperchild=4, + ) as pool, + ): + + logger.info( + f"Running parallel map calculation on {len(walkers)} walkers" + ) + images = pool.map( + _distance_image, + [walker.state for walker in walkers], + ) + logger.info("Finished running parallel map calculation") + logger.info("Shutting down Pool") + + logger.info("Pool shutdown complete") + else: + logger.info("Calculating images without parallelism") images = [] for walker in walkers: image = self.distance.image(walker.state) images.append(image) + end_time = time.time() + + _image_time = end_time - start_time + + logger.info(f"Calculating walker state images took: {_image_time} s") + + logger.info("Calculating image distances") + start_time = time.time() + # get the combinations of indices for all walker pairs for i, j in it.combinations(range(len(images)), 2): # calculate the distance between the two walkers @@ -679,9 +809,22 @@ def _all_to_all_distance(self, walkers): dist_mat[i][j] = dist dist_mat[j][i] = dist - return [walker_dists for walker_dists in dist_mat], images + end_time = time.time() - def resample(self, walkers): + _dist_time = end_time - start_time + + logger.info(f"Calculating image distances took: {_dist_time} s") + + return dist_mat, images + + def resample( + self, + walkers: list[Walker[WalkerState_]], + ) -> tuple[ + list[Walker[WalkerState_]], + list[CloneMergeResamplingRecord], + list[REVOResamplerResamplerRecord], + ]: """Resamples walkers based on REVO algorithm Parameters @@ -709,33 +852,87 @@ def resample(self, walkers): num_walker_copies = np.ones(num_walkers) # calculate distance matrix + logger.info("Calculating walker distances") distance_matrix, images = self._all_to_all_distance(walkers) + logger.info("Finished calculating distances") - logger.info("distance_matrix") + logger.info("Distance_matrix: ") logger.info("\n{}".format(str(np.array(distance_matrix)))) # determine cloning and merging actions to be performed, by # maximizing the variation, i.e. the Decider - resampling_data, variation = self.decide( + logger.info("Making resampling decisions") + decision_records, variation = self.decide( walker_weights, num_walker_copies, distance_matrix ) - - # convert the target idxs and decision_id to feature vector arrays - for record in resampling_data: - record["target_idxs"] = np.array(record["target_idxs"]) - record["decision_id"] = np.array([record["decision_id"]]) + logger.info("Finished resampling decisions") # actually do the cloning and merging of the walkers - resampled_walkers = self.DECISION.action(walkers, [resampling_data]) + resampled_walkers = self.DECISION.action(walkers, [decision_records]) + + ## Generate the full resampling records + + # because there is only one step in resampling here we just + # add another field for the step as 0 and add the walker index + # to its record as well + resampling_records = [] + for walker_idx, decision_record in enumerate(decision_records): + # UGLY: we need to wrap the field data into the shape + # declared in the CloneMergeResampler, see other notes on + # why + resampling_record = CloneMergeResamplingRecord( + # The decision record fields are simple, so we wrap + # them here as well + decision_id=decision_record.decision_id, + target_idxs=decision_record.target_idxs, + step_idx=0, + walker_idx=walker_idx, + ) + resampling_records.append(resampling_record) # flatten the distance matrix and give the number of walkers # as well for the resampler data, there is just one per cycle - resampler_data = [ - { - "distance_matrix": np.ravel(np.array(distance_matrix)), - "num_walkers": np.array([len(walkers)]), - "variation": np.array([variation]), - } + resampler_records = [ + REVOResamplerResamplerRecord( + distance_matrix=np.ravel(np.array(distance_matrix)), + variation=variation, + ) ] - return resampled_walkers, resampling_data, resampler_data + return resampled_walkers, resampling_records, resampler_records + + +@attrs.define +class REVOResamplerFactory(Generic[DistanceMetric_]): + + distance_metric: DistanceMetric_ + merge_dist: float + char_dist: float + dist_exponent: int = 4 + weights: bool = True + merge_alg: MergeAlgorithm = "pairs" + pmin: float = 1e-12 + pmax: float = 0.1 + seed: int | None = None + + @classmethod + def type(cls) -> type[REVOResampler]: + return REVOResampler + + def __call__( + self, + num_cores: int | None = None, + ) -> REVOResampler: + + return REVOResampler( + distance=self.distance_metric, + merge_dist=self.merge_dist, + char_dist=self.char_dist, + dist_exponent=self.dist_exponent, + weights=self.weights, + merge_alg=self.merge_alg, + pmin=self.pmin, + pmax=self.pmax, + seed=self.seed, + num_proc=num_cores, + ) diff --git a/src/wepy/resampling/resamplers/wexplore.py b/src/wepy/resampling/resamplers/wexplore.py index c35566c1..28f4365d 100644 --- a/src/wepy/resampling/resamplers/wexplore.py +++ b/src/wepy/resampling/resamplers/wexplore.py @@ -1,21 +1,27 @@ # Standard Library import itertools as it import logging - -logger = logging.getLogger(__name__) -# Standard Library import math import random as rand from collections import defaultdict from copy import copy, deepcopy +from typing import Generic, TypeVar # Third Party Library +import attrs import networkx as nx import numpy as np # First Party Library +from wepy.resampling.distances.base import Distance from wepy.resampling.resamplers.clone_merge import CloneMergeResampler from wepy.resampling.resamplers.resampler import ResamplerError +from wepy.walker import WalkerState + +logger = logging.getLogger(__name__) + +DistanceMetric_ = TypeVar("DistanceMetric_", bound=Distance) +WalkerState_ = TypeVar("WalkerState_", bound=WalkerState) class RegionTreeError(Exception): @@ -47,7 +53,6 @@ def calc_squashable_walkers_single_method(walker_weights, max_weight): Returns ------- - n_squashable : int The maximum number of squashable walkers. @@ -125,7 +130,6 @@ def decide_merge_groups_single_method(walker_weights, balance, max_weight): Returns ------- - merge_groups : list of list of int The merge group solution. @@ -156,9 +160,7 @@ def decide_merge_groups_single_method(walker_weights, balance, max_weight): ## Clone methods def calc_max_num_clones(walker_weight, min_weight, max_num_walkers): - """ - - Parameters + """Parameters ---------- walker_weight : @@ -196,7 +198,8 @@ def calc_max_num_clones(walker_weight, min_weight, max_num_walkers): class RegionTree(nx.DiGraph): """Used internally in the WExploreResampler module. Not really - intended to be used outside this module.""" + intended to be used outside this module. + """ # the strings for choosing a method of solving how deciding how # many walkers can be merged together given a group of walkers and @@ -356,9 +359,7 @@ def regions(self): return self._regions def add_child(self, parent_id, image_idx): - """ - - Parameters + """Parameters ---------- parent_id : @@ -390,9 +391,7 @@ def add_child(self, parent_id, image_idx): return child_id def children(self, parent_id): - """ - - Parameters + """Parameters ---------- parent_id : @@ -429,9 +428,7 @@ def leaf_nodes(self): return self.level_nodes(self.n_levels) def branch_tree(self, parent_id, image): - """ - - Parameters + """Parameters ---------- parent_id : @@ -532,9 +529,7 @@ def min_num_walkers(self, min_num_walkers): self._min_num_walkers = None def assign(self, state): - """ - - Parameters + """Parameters ---------- state : @@ -625,9 +620,7 @@ def clear_walkers(self): self.nodes[node_id]["balance"] = 0 def place_walkers(self, walkers): - """ - - Parameters + """Parameters ---------- walkers : @@ -737,9 +730,7 @@ def place_walkers(self, walkers): @classmethod def _max_n_merges(cls, pmax, root, weights): - """ - - Parameters + """Parameters ---------- pmax : @@ -809,9 +800,7 @@ def _max_n_merges(cls, pmax, root, weights): return max_n_merges def _calc_squashable_walkers(self, walker_weights): - """ - - Parameters + """Parameters ---------- walker_weights : @@ -831,9 +820,7 @@ def _calc_squashable_walkers(self, walker_weights): return n_squashable def _calc_max_num_clones(self, walker_weight): - """ - - Parameters + """Parameters ---------- walker_weight : @@ -846,9 +833,7 @@ def _calc_max_num_clones(self, walker_weight): return calc_max_num_clones(walker_weight, self.pmin, self.max_num_walkers) def _propagate_and_balance_shares(self, parental_balance, children_node_ids): - """ - - Parameters + """Parameters ---------- parental_balance : @@ -1079,9 +1064,7 @@ def _dispense_debit_shares( def _dispense_credit_shares( self, parental_balance, children_shares, children_receivable_shares ): - """ - - Parameters + """Parameters ---------- parental_balance : @@ -1484,9 +1467,7 @@ def _calc_share_donation( donor_donatable_shares, acceptor_receivable_shares, ): - """ - - Parameters + """Parameters ---------- donor_n_shares : @@ -1521,9 +1502,7 @@ def _calc_share_donation( return actual_donation def _decide_merge_leaf(self, leaf, merge_groups): - """ - - Parameters + """Parameters ---------- leaf : @@ -1605,7 +1584,7 @@ def _decide_merge_leaf(self, leaf, merge_groups): ] # choose the one to keep the state of (e.g. KEEP_MERGE - # in the Decision) based on their weights + # in the BaseDecisionABC) based on their weights # normalize weights to the sum of all the chosen weights chosen_pdist = np.array(chosen_weights) / sum(chosen_weights) @@ -1627,9 +1606,7 @@ def _decide_merge_leaf(self, leaf, merge_groups): return merge_groups def _solve_merge_groupings(self, walker_weights, balance): - """ - - Parameters + """Parameters ---------- walker_weights : @@ -1671,9 +1648,7 @@ def _solve_merge_groupings(self, walker_weights, balance): return full_merge_groups def _decide_clone_leaf(self, leaf, merge_groups, walkers_num_clones): - """ - - Parameters + """Parameters ---------- leaf : @@ -2108,7 +2083,10 @@ def balance_tree(self, delta_walkers=0): return merge_groups, walkers_num_clones -class WExploreResampler(CloneMergeResampler): +class WExploreResampler( + CloneMergeResampler, + Generic[DistanceMetric_, WalkerState_], +): """Resampler implementing the WExplore algorithm. See the paper for a full description of the algorithm, but @@ -2319,20 +2297,19 @@ class WExploreResampler(CloneMergeResampler): def __init__( self, - seed=None, - distance=None, - max_region_sizes=None, - init_state=None, - pmin=1e-12, - pmax=0.1, - max_n_regions=(10, 10, 10, 10), + distance: Distance, + max_region_sizes: tuple[float, ...], + init_state: WalkerState_, + pmin: float = 1e-12, + pmax: float = 0.1, + max_n_regions: tuple[int, ...] = (10, 10, 10, 10), + seed: int | None = None, **kwargs, ): """Constructor for the WExploreResampler. Parameters ---------- - seed : None or int The random seed. If None the system (random) one will be used. @@ -2421,7 +2398,6 @@ def assign(self, walkers): Returns ------- - assignments : list of tuple of int The leaf_id for each walker that it was assigned to. @@ -2446,7 +2422,7 @@ def assign(self, walkers): # resampler state, which is addition of new regions return assignments, resampler_data - def decide(self, delta_walkers=0): + def decide(self, delta_walkers: int = 0): """Make decisions for resampling for a single step. Parameters @@ -2457,7 +2433,6 @@ def decide(self, delta_walkers=0): Returns ------- - resampling_data : list of dict of str: value The resampling records resulting from the decisions. @@ -2504,9 +2479,7 @@ def decide(self, delta_walkers=0): @staticmethod def _check_resampling_data(resampling_data): - """ - - Parameters + """Parameters ---------- resampling_data : @@ -2560,9 +2533,7 @@ def _check_resampling_data(resampling_data): raise ResamplerError("Not all squashes are assigned to keep_merge slots") def _resample_init(self, walkers=None): - """ - - Parameters + """Parameters ---------- walkers : @@ -2590,9 +2561,7 @@ def _resample_init(self, walkers=None): def _resample_cleanup( self, resampling_data=None, resampler_data=None, resampled_walkers=None ): - """ - - Parameters + """Parameters ---------- resampling_data : @@ -2688,3 +2657,44 @@ def resample(self, walkers): ) return resampled_walkers, resampling_data, resampler_data + + +@attrs.define +class WExploreResamplerFactory(Generic[DistanceMetric_, WalkerState_]): + + distance_metric: DistanceMetric_ + init_state: WalkerState_ + max_region_sizes: tuple[float, ...] + max_n_regions: tuple[int, ...] + pmin: float = 1e-12 + pmax: float = 0.1 + seed: int | None = None + + def __attrs_post_init__(self) -> None: + + if len(self.max_region_sizes) != len(self.max_n_regions): + + raise ValueError( + "The number of levels must be the same. Received: " + f"max_region_sizes={len(self.max_region_sizes)} " + f"max_n_regions={len(self.max_n_regions)}" + ) + + @classmethod + def type(cls) -> type[WExploreResampler]: + return WExploreResampler + + def __call__( + self, + num_cores: int | None = None, + ) -> WExploreResampler: + + return WExploreResampler( + distance=self.distance_metric, + init_state=self.init_state, + max_n_regions=self.max_n_regions, + max_region_sizes=self.max_region_sizes, + pmin=self.pmin, + pmax=self.pmax, + seed=self.seed, + ) diff --git a/src/wepy/runners/mock.py b/src/wepy/runners/mock.py new file mode 100644 index 00000000..fd3c4099 --- /dev/null +++ b/src/wepy/runners/mock.py @@ -0,0 +1,105 @@ +"""Realistic mock runners useful mostly for testing.""" + +# Standard Library +import logging +import time + +# Third Party Library +import attrs + +# First Party Library +from wepy.runners.runner import ( + Runner, + RunnerEvent, + RunnerStateError, + RunnerStateMachine, + RunnerStatus, + RunSegmentData, +) +from wepy.walker import AttrsWalkerStateMixin, WalkerState + +logger = logging.getLogger(__name__) + + +@attrs.define +class MockState(AttrsWalkerStateMixin, WalkerState): + a: int + + +class MockError(Exception): + pass + + +@attrs.define +class MockRunner(Runner): + fail: bool = False + + state_machine: RunnerStateMachine = attrs.field( + default=attrs.Factory( + RunnerStateMachine, + ) + ) + + @property + def status(self) -> RunnerStatus: + return self.state_machine.state + + def init(self) -> None: + + # NOTE: showing example of validating the event before doing + # potentially expensive calculations and then transitioning + # the actual state when it is done + self.state_machine.validate_event(RunnerEvent.INIT) + # do something... + logger.info("INIT stuff") + self.state_machine.send(RunnerEvent.INIT) + + def pre_cycle(self) -> None: + self.state_machine.send(RunnerEvent.PRE_CYCLE) + + def post_cycle(self, segments_data: list[RunSegmentData]) -> None: + self.state_machine.send(RunnerEvent.POST_SEGMENT) + self.state_machine.send(RunnerEvent.POST_CYCLE) + + def run_segment( + self, + state: MockState, + segment_length: int, + ) -> tuple[MockState, RunSegmentData]: + + if self.status != RunnerStatus.PRE_CYCLE: + raise RunnerStateError( + f"Cannot run a segment in state ({self.status.name}:{self.status.value})" + ) + + seg_start_time = time.time() + + if self.fail: + logger.critical("Error requested in MockRunner.run_segment, raising.") + raise MockError("Error requested") + + logger.info("Evolving the MockState in MockRunner.run_segment") + new_state = attrs.evolve( + state, + a=(state.a + segment_length), + ) + + seg_end_time = time.time() + + split_time = seg_end_time - seg_start_time + + segment_data = RunSegmentData(segment_split_time=split_time) + + return new_state, segment_data + + +@attrs.define +class MockRunnerFactory: + fail: bool = False + + @classmethod + def type(cls) -> type[MockRunner]: + return MockRunner + + def __call__(self) -> MockRunner: + return MockRunner(fail=self.fail) diff --git a/src/wepy/runners/openmm.py b/src/wepy/runners/openmm.py deleted file mode 100644 index 9edb5e21..00000000 --- a/src/wepy/runners/openmm.py +++ /dev/null @@ -1,1443 +0,0 @@ -"""OpenMM molecular dynamics runner with accessory classes. - -OpenMM is a library with support for running molecular dynamics -simulations with specific support for fast GPU calculations. The -component based architecture of OpenMM makes it a perfect fit with -wepy. - -In addition to the principle OpenMMRunner class there are a few -classes here that make using OpenMM runner more efficient. - -First is a WalkerState class (OpenMMState) that wraps the openmm state -object directly, itself is a wrapper around the C++ -datastructures. This gives better performance by not performing copies -to a WalkerState dictionary. - -Second, is the OpenMMWalker which is identical to the Walker class -except that it enforces the state is an actual instantiation of -OpenMMState. Use of this is optional. - -Finally, is the OpenMMGPUWorker class. This is to be used as the -worker type for the WorkerMapper work mapper. This is necessary to -allow passing of the device index to OpenMM for which GPU device to -use. - -""" - -# Standard Library -import logging - -logger = logging.getLogger(__name__) -# Standard Library -import time -from copy import copy -from warnings import warn - -# Third Party Library -import numpy as np - -try: - # Third Party Library - import openmm as omm - import openmm.app as omma - import openmm.unit as unit -except ModuleNotFoundError: - raise ModuleNotFoundError( - "OpenMM has not been installed, which this runner requires." - ) - -# First Party Library -from wepy.reporter.reporter import Reporter -from wepy.runners.runner import Runner -from wepy.util.util import box_vectors_to_lengths_angles -from wepy.walker import Walker, WalkerState -from wepy.work_mapper.task_mapper import WalkerTaskProcess -from wepy.work_mapper.worker import Worker - -## Constants - -KEYS = ( - "positions", - "velocities", - "forces", - "kinetic_energy", - "potential_energy", - "time", - "box_vectors", - "box_volume", - "parameters", - "parameter_derivatives", -) -"""Names of the fields of the OpenMMState.""" - -# when we use the get_state function from the simulation context we -# can pass options for what kind of data to get, this is the default -# to get all the data. TODO not really sure what the 'groups' keyword -# is for though -GET_STATE_KWARG_DEFAULTS = ( - ("getPositions", True), - ("getVelocities", True), - ("getForces", True), - ("getEnergy", True), - ("getParameters", True), - ("getParameterDerivatives", False), - ("enforcePeriodicBox", True), -) -"""Mapping of key word arguments to the simulation.context.getState -method for retrieving data for a simulation state. By default we set -each as True to retrieve all information. The presence or absence of -them is handled by the OpenMMState. - -""" - -STATE_DATA_TYPE_ENUM_NAMES = { - "positions": "Positions", - "velocities": "Velocities", - "forces": "Forces", - "energy": "Energy", - "parameters": "Parameters", - "parameter_derivatives": "ParameterDerivatives", - "integrator_parameters": "IntegratorParameters", -} - -# STATE_DATA_TYPE_ENUM_VALUES = ( -# ("positions", 1), -# ("velocities", 2), -# ("forces", 4), -# ("energy", 8), -# ("parameters", 16), -# ("parameter_derivatives", 32), -# ("integrator_parameters", 64), -# ) -# """Enum values for the state data field flags.""" - - -def resolve_state_data_type_enum_values(): - enum_values = {} - for our_name, enum_name in STATE_DATA_TYPE_ENUM_NAMES.items(): - enum_values[our_name] = getattr(omm.State, enum_name) - - return enum_values - - -# reversed since that is the order we check them in and is a frequent operation -STATE_DATA_TYPE_ENUM_VALUES = list( - sorted( - [(k, v) for k, v in resolve_state_data_type_enum_values().items()], - key=lambda x: x[1], - reverse=True, - ) -) - - -def get_state_fields_present(sim_state): - """For a state returns a set of the field data types present in it.""" - - flag_sum = sim_state.getDataTypes() - - flag_fields = [] - flag_values = [] - flag_cum = flag_sum - for field_name, flag_value in STATE_DATA_TYPE_ENUM_VALUES: - if flag_value > flag_cum: - continue - elif flag_value == flag_cum: - flag_fields.append(field_name) - flag_values.append(flag_value) - break - - else: - flag_fields.append(field_name) - flag_values.append(flag_value) - flag_cum -= flag_value - - # double check they sum up - assert sum(flag_values) == flag_sum - - return flag_fields - - -# the Units objects that OpenMM uses internally and are returned from -# simulation data - -# TODO: this is never used and we only need the unit names. Its okay -# to use simtk.units here but other runners should use a units sytem -# like pint which is easier to install. So we should remove this since -# its not used. - -# UNITS = (('positions_unit', unit.nanometer), -# ('time_unit', unit.picosecond), -# ('box_vectors_unit', unit.nanometer), -# ('velocities_unit', unit.nanometer/unit.picosecond), -# ('forces_unit', unit.kilojoule / (unit.nanometer * unit.mole)), -# ('box_volume_unit', unit.nanometer), -# ('kinetic_energy_unit', unit.kilojoule / unit.mole), -# ('potential_energy_unit', unit.kilojoule / unit.mole), -# ) -# """Mapping of units identifiers to the corresponding simtk.units Unit objects.""" - -# the names of the units from the units objects above. This is used -# for saving them to files -UNIT_NAMES = ( - ("positions_unit", unit.nanometer.get_name()), - ("time_unit", unit.picosecond.get_name()), - ("box_vectors_unit", unit.nanometer.get_name()), - ("velocities_unit", (unit.nanometer / unit.picosecond).get_name()), - ("forces_unit", (unit.kilojoule / (unit.nanometer * unit.mole)).get_name()), - ("box_volume_unit", unit.nanometer.get_name()), - ("kinetic_energy_unit", (unit.kilojoule / unit.mole).get_name()), - ("potential_energy_unit", (unit.kilojoule / unit.mole).get_name()), -) -"""Mapping of unit identifier strings to the serialized string spec of the unit.""" - -# a random seed will be chosen from 1 to RAND_SEED_RANGE_MAX when the -# Langevin integrator is created. 0 is the default and special value -# which will then choose a random value when the integrator is created - -# TODO: test this isn't needed -# RAND_SEED_RANGE_MAX = 1000000 - - -# the runner for the simulation which runs the actual dynamics -class OpenMMRunner(Runner): - """Runner for OpenMM simulations.""" - - def __init__( - self, - system, - topology, - integrator, - platform=None, - platform_kwargs=None, - enforce_box=False, - get_state_kwargs=None, - ): - """Constructor for OpenMMRunner. - - Parameters - ---------- - system : simtk.openmm.System object - The system (forcefields) for the simulation. - - topology : simtk.openmm.app.Topology object - The topology for you system. - - integrator : subclass simtk.openmm.Integrator object - Integrator for propagating dynamics. - - platform : str - The specification for the default computational platform - to use. Platform can also be set when run_segment is - called. If None uses OpenMM default platform, see OpenMM - documentation for all value but typical ones are: - Reference, CUDA, OpenCL. If value is None the automatic - platform determining mechanism in OpenMM will be used. - - platform_kwargs : dict of str : bool, optional - key-values to set for a platform with - platform.setPropertyDefaultValue as the default for this - runner. - - enforce_box : bool - Calls 'context.getState' with 'enforcePeriodicBox' if True. - (Default value = False) - - get_state_kwargs : dict of str : bool, optional - key-values to set for getting the state from the OpenMM context. - keys not included will use the values in GET_STATE_KWARG_DEFAULTS. - Will override the enforce_box flag. - - Warnings - -------- - - Regarding the enforce_box option. - - When retrieving states from an OpenMM simulation Context, you - have the option to enforce periodic boundary conditions in the - resulting atomic positions in a topology aware way that - doesn't break bonds through boundaries. This is convenient for - post-processing as this can be a complex task and is not - readily exposed in the OpenMM API as a standalone function. - - However, in some types of simulations the periodic box vectors - are ignored (such as implicit solvent ones) despite there - being no option to not have periodic boundaries in the context - itself. Likely if you are running one of these kinds of - simulations you will not pay attention to the box vectors at - all and the random defaults that exist will be very wrong but - this incorrectness will not show in a non-wepy simulation with - openmm unless you are handling the context states - yourself. Then when you run in wepy the default of True to - enforce the boxes will be applied and confusingly wrong - answers will result that are difficult to find root cause of. - - """ - - if platform is not None: - assert isinstance( - platform, str - ), f"platform should be a string, not {type(platform)}" - - # we save the different components. However, if we are to make - # this runner picklable we have to convert the SWIG objects to - # a picklable form - self.system = system - self.integrator = integrator - - # these are not SWIG objects - self.topology = topology - self.platform_name = platform - self.platform_kwargs = platform_kwargs - - self.enforce_box = enforce_box - - self.getState_kwargs = {} - if get_state_kwargs is not None: - for k in get_state_kwargs: - self.getState_kwargs[k] = get_state_kwargs[k] - - # override enforce_box option if specified in get_state_kwargs - if "enforce_box" in get_state_kwargs: - self.enforce_box = get_state_kwargs["enforce_box"] - - else: - self.getState_kwargs = dict(GET_STATE_KWARG_DEFAULTS) - - - self._cycle_platform = None - self._cycle_platform_kwargs = None - - # for special monitoring purposes to get split times to debug - # performance - self._last_cycle_segments_split_times = [] - - def pre_cycle(self, platform=None, platform_kwargs=None, **kwargs): - # choose to use the platform spec in this function call or to - # use the default one saved in the runner - - # if the platform is given locally use this one - if platform is not None: - logger.info( - f"Setting the platform ({platform}) in the 'pre_cycle' OpenMM Runner call" - f"with platform kwargs: {platform_kwargs}" - ) - # set the platform and kwargs for this cycle - self._cycle_platform = platform - self._cycle_platform_kwargs = platform_kwargs - - # otherwise we just don't set this and let resolution of - # platform happen at run segment. - - super().pre_cycle(**kwargs) - - # each segment split times will get appended to this - self._last_cycle_segments_split_times = [] - - def post_cycle(self, **kwargs): - super().post_cycle(**kwargs) - - # remove the platform and kwargs for this cycle - self._cycle_platform = None - self._cycle_platform_kwargs = None - - def _resolve_platform( - self, - platform, - platform_kwargs, - ): - # resolve which platform to use - - # force usage of environmental one - if platform is Ellipsis: - platform_name = None - platform_kwargs = None - - # use the runtime given one - elif platform is not None: - platform_name = platform - platform_kwargs = platform_kwargs - - # if the pre_cycle configured platform is set use this over - # the default - elif self._cycle_platform is not None: - platform_name = self._cycle_platform - platform_kwargs = self._cycle_platform_kwargs - - # use the default one - elif self.platform_name is not None: - platform_name = self.platform_name - platform_kwargs = self.platform_kwargs - - # if the default is not set fall back to the environmental one - else: - platform_name = None - platform_kwargs = None - - return ( - platform_name, - platform_kwargs, - ) - - def run_segment( - self, - walker, - segment_length, - getState_kwargs=None, - platform=None, - platform_kwargs=None, - **kwargs, - ): - """Run dynamics for the walker. - - Parameters - ---------- - walker : object implementing the Walker interface - The walker for which dynamics will be propagated. - - segment_length : int or float - The numerical value that specifies how much dynamics are to be run. - - getState_kwargs : dict of str : bool, optional - Specify the key-word arguments to pass to - simulation.context.getState when getting simulation - states. If None defaults object values. - - platform : str or None or Ellipsis - The specification for the computational platform to - use. If None will use the default for the runner and - ignore platform_kwargs. If Ellipsis forces the use of the - OpenMM default or environmentally defined platform. See - OpenMM documentation for all value but typical ones are: - Reference, CUDA, OpenCL. If value is None the automatic - platform determining mechanism in OpenMM will be used. - - platform_kwargs : dict of str : bool, optional - key-values to set for a platform with - platform.setPropertyDefaultValue for this segment only. - - - Returns - ------- - new_walker : object implementing the Walker interface - Walker after dynamics was run, only the state should be modified. - - """ - - run_segment_start = time.time() - - # set the kwargs that will be passed to getState - tmp_getState_kwargs = getState_kwargs - - logger.info(f"Default 'getState_kwargs' in runner: {self.getState_kwargs}") - - logger.info(f"'getState_kwargs' passed to 'run_segment' : {getState_kwargs}") - - # start with the object value - getState_kwargs = copy(self.getState_kwargs) - if tmp_getState_kwargs is not None: - getState_kwargs.update(tmp_getState_kwargs) - - logger.info( - "After resolving 'getState_kwargs' that will be used are: " - f"{getState_kwargs}" - ) - - gen_sim_start = time.time() - - # make a copy of the integrator for this particular segment - new_integrator = copy(self.integrator) - # force setting of random seed to 0, which is a special - # value that forces the integrator to choose another - # random number - new_integrator.setRandomNumberSeed(0) - - ## Platform - - logger.info(f"Default 'platform' in runner: {self.platform_name}") - - logger.info(f"pre_cycle set 'platform' in runner: {self._cycle_platform}") - - logger.info(f"'platform' passed to 'run_segment' : {platform}") - - logger.info(f"Default 'platform_kwargs' in runner: {self.platform_kwargs}") - - logger.info( - f"pre_cycle set 'platform_kwargs' in runner: {self._cycle_platform_kwargs}" - ) - - logger.info(f"'platform_kwargs' passed to 'run_segment' : {platform_kwargs}") - - platform_name, platform_kwargs = self._resolve_platform( - platform, platform_kwargs - ) - - logger.info(f"Resolved 'platform' : {platform_name}") - - logger.info(f"Resolved 'platform_kwargs' : {platform_kwargs}") - - # create simulation object - - ## create the platform and customize - - # if a platform was given we use it to make a Simulation object - if platform_name is not None: - logger.info("Using platform configured in code.") - - # get the platform by its name to use - platform = omm.Platform.getPlatformByName(platform_name) - logger.info(f"Platform object created: {platform}") - - if platform_kwargs is None: - platform_kwargs = {} - - # set properties from the kwargs if they apply to the platform - for key, value in platform_kwargs.items(): - if key in platform.getPropertyNames(): - logger.info(f"Setting platform property: {key} : {value}") - platform.setPropertyDefaultValue(key, value) - - else: - warn( - f"Platform kwargs given ({key} : {value}) " - f"but is not valid for this platform ({platform_name})" - ) - - # make a new simulation object - simulation = omma.Simulation( - self.topology, self.system, new_integrator, platform - ) - - # otherwise just use the default or environmentally defined one - else: - logger.info("Using environmental platform.") - simulation = omma.Simulation(self.topology, self.system, new_integrator) - - # set the state to the context from the walker - simulation.context.setState(walker.state.sim_state) - - gen_sim_end = time.time() - gen_sim_time = gen_sim_end - gen_sim_start - - logger.info("Time to generate the system: {}".format(gen_sim_time)) - - # actually run the simulation - - steps_start = time.time() - - # Run the simulation segment for the number of time steps - simulation.step(segment_length) - - steps_end = time.time() - steps_time = steps_end - steps_start - - logger.info("Time to run {} sim steps: {}".format(segment_length, steps_time)) - - get_state_start = time.time() - - get_state_end = time.time() - get_state_time = get_state_end - get_state_start - logger.info("Getting context state time: {}".format(get_state_time)) - - # generate the new state/walker - new_state = self.generate_state( - simulation, segment_length, walker, getState_kwargs - ) - - # create a new walker for this - new_walker = OpenMMWalker(new_state, walker.weight) - - run_segment_end = time.time() - run_segment_time = run_segment_end - run_segment_start - logger.info("Total internal run_segment time: {}".format(run_segment_time)) - - segment_split_times = { - "gen_sim_time": gen_sim_time, - "steps_time": steps_time, - "get_state_time": get_state_time, - "run_segment_time": run_segment_time, - } - - self._last_cycle_segments_split_times.append(segment_split_times) - - return new_walker - - def generate_state( - self, simulation, segment_length, starting_walker, getState_kwargs - ): - """Method for generating a wepy compliant state from an OpenMM - simulation object and data about the last segment of dynamics run. - - Parameters - ---------- - - simulation : simtk.openmm.app.Simulation object - A complete simulation object from which the state will be extracted. - - segment_length : int - The number of integration steps run in a segment of simulation. - - starting_walker : wepy.walker.Walker subclass object - The walker that was the beginning of this segment of simyulation. - - getState_kwargs : dict of str : bool - Specify the key-word arguments to pass to - simulation.context.getState when getting simulation - states. - - Returns - ------- - - new_state : wepy.runners.openmm.OpenMMState object - A new state from the simulation state. - - This method is meant to be called from within the - `run_segment` method during a simulation. It can be customized - in subclasses to allow for the addition of custom attributes - for a state, in addition to the base ones implemented in the - interface to the openmm simulation state in OpenMMState. - - The extra arguments to this function are data that would allow - for the calculation of integral values over the duration of - the segment, such as time elapsed and differences from the - starting state. - - """ - - # save the state of the system with all possible values - new_sim_state = simulation.context.getState(**getState_kwargs) - - # make an OpenMMState wrapper with this - new_state = OpenMMState(new_sim_state) - - return new_state - - -class OpenMMState(WalkerState): - """Walker state that wraps an simtk.openmm.State object. - - The keys for which values in the state are available are given by - the KEYS module constant (accessible through the class constant of - the same name as well). - - Additional fields can be added to these states through passing - extra kwargs to the constructor. These will be automatically given - a suffix of "_OTHER" to avoid name clashes. - - """ - - KEYS = KEYS - """The provided attribute keys for the state.""" - - OTHER_KEY_TEMPLATE = "{}_OTHER" - """String formatting template for attributes not set in KEYS.""" - - def __init__(self, sim_state, **kwargs): - """Constructor for OpenMMState. - - Parameters - ---------- - state : simtk.openmm.State object - The simulation state retrieved from the simulation constant. - - kwargs : optional - - Additional attributes to set for the state. Will add the - "_OTHER" suffix to the keys - - """ - - # save the simulation state - self._sim_state = sim_state - - # probe which data fields it has - self._sim_state_fields_present = get_state_fields_present(self.sim_state) - - # save additional data if given - self._data = {} - for key, value in kwargs.items(): - # if the key is already in the sim_state keys we need to - # modify it and raise a warning - if key in self.KEYS: - warn( - "Key {} in kwargs is already taken by this class, renaming to {}".format( - self.OTHER_KEY_TEMPLATE - ).format( - key - ) - ) - - # make a new key - new_key = self.OTHER_KEY_TEMPLATE.format(key) - - # set it in the data - self._data[new_key] = value - - # otherwise just set it - else: - self._data[key] = value - - @property - def sim_state(self): - """The underlying simtk.openmm.State object this is wrapping.""" - return self._sim_state - - def __getitem__(self, key): - # if this was a key for data not mapped from the OpenMM.State - # object we use the _data attribute - if (key not in self.KEYS) and ( - (not key.startswith("parameters")) - and (not key.startswith("parameter_derivatives")) - ): - return self._data[key] - - # otherwise we have to specifically get the correct data and - # process it into an array from the OpenMM.State - else: - if key == "positions": - return self.positions_values() - elif key == "velocities": - return self.velocities_values() - elif key == "forces": - return self.forces_values() - elif key == "kinetic_energy": - return self.kinetic_energy_value() - elif key == "potential_energy": - return self.potential_energy_value() - elif key == "time": - return self.time_value() - elif key == "box_vectors": - return self.box_vectors_values() - elif key == "box_volume": - return self.box_volume_value() - - # handle the parameters differently since they are dictionaries of values - elif key.startswith("parameters"): - parameters_dict = self.parameters_values() - if parameters_dict is None: - return None - else: - # TODO: this was an attempt at a general way to do - # this but it doesn't work and I only ever need - # one nested level, so for now we just implement it that way - # return self._get_nested_attr_from_compound_key(key, parameters_dict) - - param_key = key.split("/")[-1] - return parameters_dict[param_key] - - elif key.startswith("parameter_derivatives"): - pd_dict = self.parameter_derivatives_values() - if pd_dict is None: - return None - else: - return self._get_nested_attr_from_compound_key(key, pd_dict) - - ## Array properties - - # Positions - @property - def positions(self): - """The positions of the state as a numpy array simtk.units.Quantity object.""" - - if "positions" in self._sim_state_fields_present: - return self.sim_state.getPositions(asNumpy=True) - else: - return None - - @property - def positions_unit(self): - """The units (as a simtk.units.Unit object) the positions are in.""" - return self.positions.unit - - def positions_values(self): - """The positions of the state as a numpy array in the positions_unit - simtk.units.Unit. This is what is returned by the __getitem__ - accessor. - - """ - return self.positions.value_in_unit(self.positions_unit) - - # Velocities - @property - def velocities(self): - """The velocities of the state as a numpy array simtk.units.Quantity object.""" - - if "velocities" in self._sim_state_fields_present: - return self.sim_state.getVelocities(asNumpy=True) - else: - return None - - @property - def velocities_unit(self): - """The units (as a simtk.units.Unit object) the velocities are in.""" - return self.velocities.unit - - def velocities_values(self): - """The velocities of the state as a numpy array in the velocities_unit - simtk.units.Unit. This is what is returned by the __getitem__ - accessor. - - """ - - velocities = self.velocities - if velocities is None: - return None - else: - return self.velocities.value_in_unit(self.velocities_unit) - - # Forces - @property - def forces(self): - """The forces of the state as a numpy array simtk.units.Quantity object.""" - - if "forces" in self._sim_state_fields_present: - return self.sim_state.getForces(asNumpy=True) - else: - return None - - @property - def forces_unit(self): - """The units (as a simtk.units.Unit object) the forces are in.""" - return self.forces.unit - - def forces_values(self): - """The forces of the state as a numpy array in the forces_unit - simtk.units.Unit. This is what is returned by the __getitem__ - accessor. - - """ - - forces = self.forces - if forces is None: - return None - else: - return self.forces.value_in_unit(self.forces_unit) - - # Box Vectors - @property - def box_vectors(self): - """The box vectors of the state as a numpy array simtk.units.Quantity object.""" - try: - return self.sim_state.getPeriodicBoxVectors(asNumpy=True) - except: - warn( - "Unknown exception handled from `self.sim_state.getPeriodicBoxVectors()`, " - "this is probably because this attribute is not in the State." - ) - return None - - @property - def box_vectors_unit(self): - """The units (as a simtk.units.Unit object) the box vectors are in.""" - return self.box_vectors.unit - - def box_vectors_values(self): - """The box vectors of the state as a numpy array in the - box_vectors_unit simtk.units.Unit. This is what is returned by - the __getitem__ accessor. - - """ - - box_vectors = self.box_vectors - if box_vectors is None: - return None - else: - return self.box_vectors.value_in_unit(self.box_vectors_unit) - - ## non-array properties - - # Kinetic Energy - @property - def kinetic_energy(self): - """The kinetic energy of the state as a numpy array simtk.units.Quantity object.""" - try: - return self.sim_state.getKineticEnergy() - except: - warn( - "Unknown exception handled from `self.sim_state.getKineticEnergy()`, " - "this is probably because this attribute is not in the State." - ) - return None - - @property - def kinetic_energy_unit(self): - """The units (as a simtk.units.Unit object) the kinetic energy is in.""" - return self.kinetic_energy.unit - - def kinetic_energy_value(self): - """The kinetic energy of the state as a numpy array in the kinetic_energy_unit - simtk.units.Unit. This is what is returned by the __getitem__ - accessor. - - """ - - kinetic_energy = self.kinetic_energy - if kinetic_energy is None: - return None - else: - return np.array( - [self.kinetic_energy.value_in_unit(self.kinetic_energy_unit)] - ) - - # Potential Energy - @property - def potential_energy(self): - """The potential energy of the state as a numpy array simtk.units.Quantity object.""" - try: - return self.sim_state.getPotentialEnergy() - except: - warn( - "Unknown exception handled from `self.sim_state.getPotentialEnergy()`, " - "this is probably because this attribute is not in the State." - ) - return None - - @property - def potential_energy_unit(self): - """The units (as a simtk.units.Unit object) the potential energy is in.""" - return self.potential_energy.unit - - def potential_energy_value(self): - """The potential energy of the state as a numpy array in the potential_energy_unit - simtk.units.Unit. This is what is returned by the __getitem__ - accessor. - - """ - - potential_energy = self.potential_energy - if potential_energy is None: - return None - else: - return np.array( - [self.potential_energy.value_in_unit(self.potential_energy_unit)] - ) - - # Time - @property - def time(self): - """The time of the state as a numpy array simtk.units.Quantity object.""" - try: - return self.sim_state.getTime() - except: - warn( - "Unknown exception handled from `self.sim_state.getTime()`, " - "this is probably because this attribute is not in the State." - ) - return None - - @property - def time_unit(self): - """The units (as a simtk.units.Unit object) the time is in.""" - return self.time.unit - - def time_value(self): - """The time of the state as a numpy array in the time_unit - simtk.units.Unit. This is what is returned by the __getitem__ - accessor. - - """ - - time = self.time - if time is None: - return None - else: - return np.array([self.time.value_in_unit(self.time_unit)]) - - # Box Volume - @property - def box_volume(self): - """The box volume of the state as a numpy array simtk.units.Quantity object.""" - try: - return self.sim_state.getPeriodicBoxVolume() - except: - warn( - "Unknown exception handled from `self.sim_state.getPeriodicBoxVolume()`, " - "this is probably because this attribute is not in the State." - ) - return None - - @property - def box_volume_unit(self): - """The units (as a simtk.units.Unit object) the box volume is in.""" - return self.box_volume.unit - - def box_volume_value(self): - """The box volume of the state as a numpy array in the box_volume_unit - simtk.units.Unit. This is what is returned by the __getitem__ - accessor. - - """ - - box_volume = self.box_volume - if box_volume is None: - return None - else: - return np.array([self.box_volume.value_in_unit(self.box_volume_unit)]) - - ## Dictionary properties - ## Unitless - - # Parameters - @property - def parameters(self): - """The parameters of the state as a dictionary mapping the names of - the parameters to their values which are numpy array - simtk.units.Quantity objects. - - """ - - if "parameters" in self._sim_state_fields_present: - return self.sim_state.getParameters() - else: - return None - - @property - def parameters_unit(self): - """The units for each parameter as a dictionary mapping parameter - names to their corresponding unit as a simtk.units.Unit - object. - - """ - param_units = {key: None for key, val in self.parameters.items()} - return param_units - - def parameters_values(self): - """The parameters of the state as a dictionary mapping the name of the - parameter to a numpy array in the unit for the parameter of the - same name in the parameters_unit corresponding - simtk.units.Unit object. This is what is returned by the - __getitem__ accessor using the compound key syntax with the - prefix 'parameters', e.g. state['parameter/paramA'] for the - parameter 'paramA'. - - """ - - if self.parameters is None: - return None - - param_arrs = {key: np.array(val) for key, val in self.parameters.items()} - - # return None if there is nothing in this - if len(param_arrs) == 0: - return None - else: - return param_arrs - - # Parameter Derivatives - @property - def parameter_derivatives(self): - """The parameter derivatives of the state as a dictionary mapping the - names of the parameters to their values which are numpy array - simtk.units.Quantity objects. - - """ - - if "parameter_derivatives" in self._sim_state_fields_present: - return self.sim_state.getEnergyParameterDerivatives() - else: - return None - - @property - def parameter_derivatives_unit(self): - """The units for each parameter derivative as a dictionary mapping - parameter names to their corresponding unit as a - simtk.units.Unit object. - - """ - - param_units = {key: None for key, val in self.parameter_derivatives.items()} - return param_units - - def parameter_derivatives_values(self): - """The parameter derivatives of the state as a dictionary mapping the - name of the parameter to a numpy array in the unit for the - parameter of the same name in the parameters_unit - corresponding simtk.units.Unit object. This is what is - returned by the __getitem__ accessor using the compound key - syntax with the prefix 'parameter_derivatives', - e.g. state['parameter_derivatives/paramA'] for the parameter - 'paramA'. - - """ - - if self.parameter_derivatives is None: - return None - - param_arrs = { - key: np.array(val) for key, val in self.parameter_derivatives.items() - } - - # return None if there is nothing in this - if len(param_arrs) == 0: - return None - else: - return param_arrs - - # for the dict attributes we need to transform the keys for making - # a proper state where all __getitem__ things are arrays - def _dict_attr_to_compound_key_dict(self, root_key, attr_dict): - """Transform a dictionary of values within the compound key 'root_key' - to a dictionary mapping compound keys to values. - - For example give the root_key 'parameters' and the parameters - dictionary {'paramA' : 1.234} returns {'parameters/paramA' : 1.234}. - - Parameters - ---------- - root_key : str - The compound key prefix - attr_dict : dict of str : value - The dictionary with simple keys within the root key namespace. - - Returns - ------- - compound_key_dict : dict of str : value - The dictionary with the compound keys. - - """ - - key_template = "{}/{}" - cmpd_key_d = {} - for key, value in attr_dict.items(): - new_key = key_template.format(root_key, key) - # if this is a proper feature - if type(value) == np.ndarray: - cmpd_key_d[new_key] = value - elif hasattr(value, "__getitem__"): - cmpd_key_d.update(self._dict_attr_to_compound_key_dict(new_key, value)) - else: - raise TypeError("Unsupported attribute type") - - return cmpd_key_d - - def _get_nested_attr_from_compound_key(self, compound_key, compound_feat_dict): - """Get arbitrarily deeply nested compound keys from the full - dictionary tree. - - Parameters - ---------- - compound_key : str - Compound key separated by '/' characters - - compound_feat_dict : dict - Dictionary of arbitrary depth - - Returns - ------- - value - Value requested by the key. - - """ - - key_components = compound_key.split("/") - - # if there is only one component of the key then it is not - # really compound, we won't complain just return the - # "dictionary" if it is not actually a dict like - if not hasattr(compound_feat_dict, "__getitem__"): - raise TypeError("Must provide a dict-like with the compound key") - - value = compound_feat_dict[key_components[0]] - - # if the value itself is compound recursively fetch the value - if hasattr(value, "__getitem__") and len(key_components[1:]) > 0: - subgroup_key = "/".join(key_components[1:]) - - return self._get_nested_attr_from_compound_key(subgroup_key, value) - - elif hasattr(value, "__getitem__") and len(key_components[1:]) < 1: - raise ValueError("Key does not reference a leaf node of attribute") - - # otherwise we have the right key so return the object - else: - return value - - def parameters_features(self): - """Returns a dictionary of the parameters with their appropriate - compound keys. This can be used for placing them in the same namespace - as the rest of the attributes.""" - - parameters = self.parameters_values() - if parameters is None: - return None - else: - return self._dict_attr_to_compound_key_dict("parameters", parameters) - - def parameter_derivatives_features(self): - """Returns a dictionary of the parameter derivatives with their appropriate - compound keys. This can be used for placing them in the same namespace - as the rest of the attributes.""" - - parameter_derivatives = self.parameter_derivatives_values() - if parameter_derivatives is None: - return None - else: - return self._dict_attr_to_compound_key_dict( - "parameter_derivatives", parameter_derivatives - ) - - def omm_state_dict(self): - """Return a dictionary with all of the default keys from the wrapped - simtk.openmm.State object""" - - feature_d = { - "positions": self.positions_values(), - "velocities": self.velocities_values(), - "forces": self.forces_values(), - "kinetic_energy": self.kinetic_energy_value(), - "potential_energy": self.potential_energy_value(), - "time": self.time_value(), - "box_vectors": self.box_vectors_values(), - "box_volume": self.box_volume_value(), - } - - params = self.parameters_features() - if params is not None: - feature_d.update(params) - - param_derivs = self.parameter_derivatives_features() - if param_derivs is not None: - feature_d.update(param_derivs) - - return feature_d - - def dict(self): - # documented in superclass - - d = {} - for key, value in self._data.items(): - d[key] = value - for key, value in self.omm_state_dict().items(): - d[key] = value - return d - - def to_mdtraj(self, topology): - """Returns an mdtraj.Trajectory object from this walker's state. - - Parameters - ---------- - topology : mdtraj.Topology object - Topology for the state. - - Returns - ------- - state_traj : mdtraj.Trajectory object - - """ - - # Third Party Library - import mdtraj as mdj - - # resize the time to a 1D vector - unitcell_lengths, unitcell_angles = box_vectors_to_lengths_angles( - self.box_vectors - ) - return mdj.Trajectory( - np.array([self.positions_values()]), - unitcell_lengths=[unitcell_lengths], - unitcell_angles=[unitcell_angles], - topology=topology, - ) - - -def gen_sim_state(positions, system, integrator, getState_kwargs=None): - """Convenience function for generating an omm.State object. - - Parameters - ---------- - - positions : arraylike of float - The positions for the system you want to set - - system : openmm.app.System object - - integrator : openmm.Integrator object - - Returns - ------- - - sim_state : openmm.State object - - """ - - # handle the getState_kwargs - tmp_getState_kwargs = getState_kwargs - - # start with the defaults - getState_kwargs = dict(GET_STATE_KWARG_DEFAULTS) - - # if there were customizations use them - if tmp_getState_kwargs is not None: - getState_kwargs.update(tmp_getState_kwargs) - - # generate a throwaway context, using the reference platform so we - # don't screw up other platform stuff later in the same process - platform = omm.Platform.getPlatformByName("Reference") - context = omm.Context(system, copy(integrator), platform) - - # set the positions - context.setPositions(positions) - - # then just retrieve it as a state using the default kwargs - sim_state = context.getState(**getState_kwargs) - - return sim_state - - -def gen_walker_state(positions, system, integrator, getState_kwargs=None): - """Convenience function for generating a wepy walker State object for - an openmm simulation state. - - Parameters - ---------- - - positions : arraylike of float - The positions for the system you want to set - - system : openmm.app.System object - - integrator : openmm.Integrator object - - Returns - ------- - - walker_state : wepy.runners.openmm.OpenMMState object - - """ - - state = OpenMMState( - gen_sim_state(positions, system, integrator, getState_kwargs=getState_kwargs) - ) - - return state - - -class OpenMMWalker(Walker): - """Walker for OpenMMRunner simulations. - - This simply enforces the use of an OpenMMState object for the - walker state attribute. - - """ - - def __init__(self, state, weight): - # documented in superclass - - assert isinstance( - state, OpenMMState - ), "state must be an instance of class OpenMMState not {}".format(type(state)) - - super().__init__(state, weight) - - -class OpenMMCPUWorker(Worker): - """Worker for OpenMM GPU simulations (CUDA or OpenCL platforms). - - This is intended to be used with the wepy.work_mapper.WorkerMapper - work mapper class. - - This class must be used in order to ensure OpenMM runs jobs on the - appropriate GPU device. - - """ - - NAME_TEMPLATE = "OpenMMCPUWorker-{}" - """The name template the worker processes are named to substituting in - the process number.""" - - DEFAULT_NUM_THREADS = 1 - - def __init__(self, *args, **kwargs): - if "num_threads" not in kwargs: - num_threads = self.DEFAULT_NUM_THREADS - else: - num_threads = kwargs.pop("num_threads") - - super().__init__(*args, num_threads=num_threads, **kwargs) - - def run_task(self, task): - # documented in superclass - - # make the platform kwargs dictionary - platform_options = {"Threads": str(self.attributes["num_threads"])} - - # run the task and pass in the DeviceIndex for OpenMM to - # assign work to the correct GPU - return task(platform_kwargs=platform_options) - - -class OpenMMGPUWorker(Worker): - """Worker for OpenMM GPU simulations (CUDA or OpenCL platforms). - - This is intended to be used with the wepy.work_mapper.WorkerMapper - work mapper class. - - This class must be used in order to ensure OpenMM runs jobs on the - appropriate GPU device. - - """ - - NAME_TEMPLATE = "OpenMMGPUWorker-{}" - """The name template the worker processes are named to substituting in - the process number.""" - - def run_task(self, task): - # get the platform - platform = self.mapper_attributes["platform"] - - # get the device index from the attributes - device_id = self.mapper_attributes["device_ids"][self._worker_idx] - - # make the platform kwargs dictionary - platform_options = {"DeviceIndex": str(device_id)} - - logger.info(f"platform={platform}, platform_options={platform_options}") - - return task( - platform=platform, - platform_kwargs=platform_options, - ) - - -class OpenMMCPUWalkerTaskProcess(WalkerTaskProcess): - NAME_TEMPLATE = "OpenMM_CPU_Walker_Task-{}" - - def run_task(self, task): - print("CPU Walker Task ---->", self.mapper_attributes, task, task.func) - if "num_threads" in self.mapper_attributes: - num_threads = self.mapper_attributes["num_threads"] - - # make the platform kwargs dictionary - platform_options = {"Threads": str(num_threads)} - - logger.info(f"Threads={num_threads}") - - else: - platform_options = {} - - return task( - platform_kwargs=platform_options, - ) - - -class OpenMMGPUWalkerTaskProcess(WalkerTaskProcess): - NAME_TEMPLATE = "OpenMM_GPU_Walker_Task-{}" - - def run_task(self, task): - logger.info(f"Starting to run a task as worker {self._worker_idx}") - - logger.info(f"GPU Walker Task ----> {self.mapper_attributes}") - # get the platform - platform = self.mapper_attributes["platform"] - - # get the device index from the attributes - device_id = self.mapper_attributes["device_ids"][self._worker_idx] - - # make the platform kwargs dictionary - platform_options = {"DeviceIndex": str(device_id)} - - logger.info(f"platform={platform}, platform_options={platform_options}") - - return task( - platform=platform, - platform_kwargs=platform_options, - ) diff --git a/src/wepy/runners/openmm/__init__.py b/src/wepy/runners/openmm/__init__.py new file mode 100644 index 00000000..846d53dd --- /dev/null +++ b/src/wepy/runners/openmm/__init__.py @@ -0,0 +1,40 @@ +# Local Modules +from .logger import ( + EnergyLoggingReporterFactory, + HeartBeatLoggingReporterFactory, + UnitCellLoggingReporterFactory, +) +from .runner import ( + GPU_PLATFORMS, + OpenMMPlatformName, + OpenMMRunner, + OpenMMRunnerFactory, + PlatformKwargs, +) +from .state import ( + OPENMM_DEFAULT_UNITS, + OpenMMState, + OpenMMStateValidationError, + OpenMMStateWrapper, + dummy_context, + get_context_state, + state_to_xml, +) + +__all__ = [ + "HeartBeatLoggingReporterFactory", + "GPU_PLATFORMS", + "OpenMMRunnerFactory", + "OpenMMPlatformName", + "OpenMMRunner", + "OpenMMState", + "OpenMMStateWrapper", + "OpenMMStateValidationError", + "dummy_context", + "get_context_state", + "state_to_xml", + "PlatformKwargs", + "UnitCellLoggingReporterFactory", + "EnergyLoggingReporterFactory", + "OPENMM_DEFAULT_UNITS", +] diff --git a/src/wepy/runners/openmm/logger.py b/src/wepy/runners/openmm/logger.py new file mode 100644 index 00000000..eca69fb7 --- /dev/null +++ b/src/wepy/runners/openmm/logger.py @@ -0,0 +1,335 @@ +# Standard Library +import logging +import time +from collections.abc import Collection +from typing import Callable + +# Third Party Library +import attrs +import openmm +import openmm.app +import openmm.unit + +# First Party Library +from wepy.util.openmm import format_box_vectors_line + +# Local Modules +from .reporter import ( + OpenMMGetStateKeys, + OpenMMReporter, + OpenMMReporterNextReport, +) + +LoggingReporterCallback = Callable[ + [ + logging.Logger, + openmm.app.Simulation, + openmm.State, + ], + None, +] + + +class LoggingReporter(OpenMMReporter): + logger: logging.Logger + callback: LoggingReporterCallback + state_includes: list[OpenMMGetStateKeys] + + def __init__( + self, + logger: logging.Logger, + callback: LoggingReporterCallback, + state_includes: Collection[OpenMMGetStateKeys], + ) -> None: + + self.logger = logger + self.callback = callback + self.state_includes = list(state_includes) + + def report( + self, + simulation: openmm.app.Simulation, + state: openmm.State, + ) -> None: + + self.callback( + self.logger, + simulation, + state, + ) + + +LoggingReporterFactory = Callable[ + [ + logging.Logger, + # start_time + int, + ], + LoggingReporter, +] + + +class StepIntervalLoggingReporter(LoggingReporter): + """Reporter that reports at intervals in steps.""" + + logger: logging.Logger + callback: LoggingReporterCallback + state_includes: list[OpenMMGetStateKeys] + step_interval: int + + def __init__( + self, + logger: logging.Logger, + callback: LoggingReporterCallback, + state_includes: Collection[OpenMMGetStateKeys], + step_interval: int, + start_time: int, + ) -> None: + + super().__init__( + logger=logger, + callback=callback, + state_includes=state_includes, + ) + self.step_interval = step_interval + self.start_time = start_time + + def describeNextReport( + self, simulation: openmm.app.Simulation + ) -> OpenMMReporterNextReport: + + steps_left = self.step_interval - simulation.currentStep % self.step_interval + + return OpenMMReporterNextReport( + steps=steps_left, + include=list(self.state_includes), + periodic=False, + ) + + +class SamplingTimeIntervalLoggingReporter(LoggingReporter): + """Reporter that reports at intervals in sampling time. + + Does not work for variable step integrators currently. + + """ + + logger: logging.Logger + callback: LoggingReporterCallback + state_includes: list[OpenMMGetStateKeys] + sampling_time_interval: openmm.unit.Quantity + + def __init__( + self, + logger: logging.Logger, + callback: LoggingReporterCallback, + state_includes: Collection[OpenMMGetStateKeys], + sampling_time_interval: openmm.unit.Quantity, + start_time: int, + ) -> None: + + super().__init__(logger, callback, state_includes) + self.sampling_time_interval = sampling_time_interval + self.start_time = start_time + + def describeNextReport( + self, + simulation: openmm.app.Simulation, + ) -> OpenMMReporterNextReport: + + _unit = openmm.unit.attosecond + + curr_sampling_time: openmm.unit.Quantity = simulation.context.getTime() + + # get the step size from the integrator + step_size = simulation.context.getIntegrator().getStepSize() + + sampling_time_left = ( + self.sampling_time_interval.value_in_unit(_unit) + - ( + curr_sampling_time.value_in_unit(_unit) + % self.sampling_time_interval.value_in_unit(_unit) + ) + ) * _unit + + if sampling_time_left < (0.0 * _unit): + estimated_steps_left = 0 + + else: + estimated_steps_left = round( + sampling_time_left.value_in_unit(_unit) + ) // round(step_size.value_in_unit(_unit)) + + return OpenMMReporterNextReport( + steps=estimated_steps_left, + include=list(self.state_includes), + periodic=False, + ) + + +class HeartBeatLoggingReporter(StepIntervalLoggingReporter): + + def __init__( + self, + logger: logging.Logger, + step_interval: int, + start_time: int, + ) -> None: + + super().__init__( + logger=logger, + callback=self.logging_callback, + state_includes=[], + step_interval=step_interval, + start_time=start_time, + ) + + def logging_callback( + self, + logger: logging.Logger, + simulation: openmm.app.Simulation, + state: openmm.State, + ) -> None: + + current_time = time.time() + elapsed_time = current_time - self.start_time + + # TODO: make this adaptive to reduce zeros etc. Currently just + # padded to the standard 1-2 fs step time shown in picoseconds + sim_time = simulation.context.getTime() + sim_time_mag = sim_time.value_in_unit(openmm.unit.picosecond) + sim_steps = simulation.context.getStepCount() + + logger.info( + f"OpenMM simulation progress: clock_time={current_time:.4f} s, elapsed_time={elapsed_time:.4f} s, sim_time={sim_time_mag:.4f} ps, sim_steps={sim_steps}", + ) + + +@attrs.define +class HeartBeatLoggingReporterFactory: + + step_interval: int + + def __call__( + self, + logger: logging.Logger, + start_time: int, + ) -> HeartBeatLoggingReporter: + + return HeartBeatLoggingReporter( + logger=logger, + step_interval=self.step_interval, + start_time=start_time, + ) + + +class EnergyLoggingReporter(SamplingTimeIntervalLoggingReporter): + + def __init__( + self, + logger: logging.Logger, + sampling_time_interval: openmm.unit.Quantity, + start_time: int, + ) -> None: + + super().__init__( + logger=logger, + callback=self.logging_callback, + state_includes=["energy"], + sampling_time_interval=sampling_time_interval, + start_time=start_time, + ) + + def logging_callback( + self, + logger: logging.Logger, + simulation: openmm.app.Simulation, + state: openmm.State, + ) -> None: + + sim_time = simulation.context.getTime() + sim_time_mag = sim_time.value_in_unit(openmm.unit.picosecond) + sim_steps = simulation.context.getStepCount() + + _pot_e = state.getPotentialEnergy() + _kin_e = state.getKineticEnergy() + _tot_e = _pot_e + _kin_e + + logger.info( + f"OpenMM simulation energy (steps={sim_steps}, sim_time={sim_time_mag:.4f} ps): " + f"kinetic={_kin_e}, potential={_pot_e}, total={_tot_e}" + ) + + +@attrs.define +class EnergyLoggingReporterFactory: + + sampling_time_interval: int + + def __call__( + self, + logger: logging.Logger, + start_time: int, + ) -> EnergyLoggingReporter: + + return EnergyLoggingReporter( + logger=logger, + sampling_time_interval=self.sampling_time_interval, + start_time=start_time, + ) + + +class UnitCellLoggingReporter(SamplingTimeIntervalLoggingReporter): + + def __init__( + self, + logger: logging.Logger, + sampling_time_interval: openmm.unit.Quantity, + start_time: int, + ) -> None: + + super().__init__( + logger=logger, + callback=self.logging_callback, + state_includes=[], + sampling_time_interval=sampling_time_interval, + start_time=start_time, + ) + + def logging_callback( + self, + logger: logging.Logger, + simulation: openmm.app.Simulation, + state: openmm.State, + ) -> None: + + sim_time = simulation.context.getTime() + sim_time_mag = sim_time.value_in_unit(openmm.unit.picosecond) + sim_steps = simulation.context.getStepCount() + + _box_volume = state.getPeriodicBoxVolume() + _bvs = state.getPeriodicBoxVectors() + _bvs_line = format_box_vectors_line(_bvs) + + logger.info( + f"OpenMM simulation unitcell (steps={sim_steps}, sim_time={sim_time_mag:.4f} ps): " + f"volume={_box_volume}, vectors={_bvs_line}" + ) + + +@attrs.define +class UnitCellLoggingReporterFactory: + + sampling_time_interval: int + + def __call__( + self, + logger: logging.Logger, + start_time: int, + ) -> EnergyLoggingReporter: + + return UnitCellLoggingReporter( + logger=logger, + sampling_time_interval=self.sampling_time_interval, + start_time=start_time, + ) diff --git a/src/wepy/runners/openmm/reporter.py b/src/wepy/runners/openmm/reporter.py new file mode 100644 index 00000000..773db6cb --- /dev/null +++ b/src/wepy/runners/openmm/reporter.py @@ -0,0 +1,53 @@ +"""OpenMM reporters used in wepy.""" + +# Standard Library +import abc +import logging +from typing import Literal, NotRequired, TypedDict, get_args + +# Third Party Library +import openmm as omm +import openmm.app as omma + +logger = logging.getLogger(__name__) + + +class OpenMMReporterNextReport(TypedDict): + + steps: int + include: list[str] + periodic: NotRequired[bool | None] = False + + +OpenMMGetStateKeys = Literal[ + "positions", + "velocities", + "forces", + "energy", + "parameters", + "parameterDerivatives", + "integratorParameters", +] +OPENMM_GET_STATE_KEYS: frozenset[OpenMMGetStateKeys] = frozenset( + get_args(OpenMMGetStateKeys) +) + + +class OpenMMReporter(metaclass=abc.ABCMeta): + """ABC for openmm.app Reporter. + + Documents the interface for openmm.app Reporter compatible classes. + """ + + def describeNextReport( + self, simulation: omma.Simulation + ) -> OpenMMReporterNextReport: + raise NotImplementedError + + def report( + self, + simulation: omma.Simulation, + state: omm.State, + ) -> None: + + raise NotImplementedError diff --git a/src/wepy/runners/openmm/runner.py b/src/wepy/runners/openmm/runner.py new file mode 100644 index 00000000..c6dc6b73 --- /dev/null +++ b/src/wepy/runners/openmm/runner.py @@ -0,0 +1,550 @@ +# Standard Library +import copy +import logging +import time +from typing import ( + Literal, +) +from warnings import warn + +# Third Party Library +import attrs +import numpy as np + +# Local Modules +from .reporter import OpenMMReporter + +logger = logging.getLogger(__name__) + +try: + # Third Party Library + import mdtraj +except ModuleNotFoundError: + warn("Module 'mdtraj' not found, those features will not be available.") + +try: + # Third Party Library + import openmm + import openmm.app + import openmm.unit +except ModuleNotFoundError: + raise ModuleNotFoundError( + "OpenMM has not been installed, which this runner requires." + ) + +# First Party Library +from wepy.runners.runner import ( + Runner, + RunnerEvent, + RunnerStateError, + RunnerStateMachine, + RunnerStatus, + RunSegmentData, +) +from wepy.util.openmm import format_box_vectors_line, triclinic_volume_vec3_quantity + +# Local Modules +from .logger import ( + EnergyLoggingReporterFactory, + HeartBeatLoggingReporterFactory, + LoggingReporterFactory, + UnitCellLoggingReporterFactory, +) +from .state import OpenMMState, OpenMMStateWrapper, get_context_state + +PlatformKwargs = dict[str, str] + +OpenMMPlatformName = Literal["Reference", "CPU", "CUDA", "OpenCL", "HIP"] +GPU_PLATFORMS = frozenset({"CUDA", "OpenCL", "HIP"}) + + +GET_STATE_DEFAULT_KEYS = frozenset( + { + "positions", + "velocities", + "forces", + "parameters", + "parameter_derivatives", + "kinetic_energy", + "potential_energy", + "time", + "box_vectors", + "box_volume", + } +) + +# default heart beat every 50 steps +_DEFAULT_HEARTBEAT_INTERVAL = 50 +_DEFAULT_STATE_TIME_INTERVAL = 1 * openmm.unit.picosecond + +DEFAULT_OPENMM_REPORTER_FACTORIES = [ + HeartBeatLoggingReporterFactory(step_interval=_DEFAULT_HEARTBEAT_INTERVAL), + UnitCellLoggingReporterFactory(sampling_time_interval=_DEFAULT_STATE_TIME_INTERVAL), + EnergyLoggingReporterFactory(sampling_time_interval=_DEFAULT_STATE_TIME_INTERVAL), +] + + +@attrs.define +class OpenMMRunnerSegmentSplitTime: + gen_sim_time: float + steps_time: float + get_state_time: float + + +@attrs.define +class OpenMMRunnerSegmentData(RunSegmentData): + segment_split_time: float + openmm_segment_split_time: OpenMMRunnerSegmentSplitTime + + +def _report_simulation(simulation: openmm.app.Simulation) -> tuple[ + openmm.unit.Quantity, + tuple[openmm.unit.Quantity, openmm.unit.Quantity, openmm.unit.Quantity], + openmm.unit.Quantity, + openmm.unit.Quantity, + openmm.unit.Quantity, +]: + + # log some info on the constructed simulation + _step_count = simulation.context.getStepCount() + _time = simulation.context.getTime() + _num_molecules = len(simulation.context.getMolecules()) + _platform = simulation.context.getPlatform() + _platform_name = _platform.getName() + _platform_prop_names = _platform.getPropertyNames() + _props = { + name: _platform.getPropertyValue( + simulation.context, + name, + ) + for name in _platform_prop_names + } + _openmm_version = _platform.getOpenMMVersion() + logger.info( + f"Simulation Context: current_step={_step_count}, sampling_time={_time}, num_molecules={_num_molecules}" + ) + logger.info( + f"Simulation Context Platform: openmm_version={_openmm_version}, name={_platform_name}, properties={_props}" + ) + + # report on the initial state as well + _init_state = simulation.context.getState( + positions=False, + velocities=True, + forces=False, + energy=True, + ) + _velocities = _init_state.getVelocities() + _vel0 = _velocities[0] + _vel0_mag = _vel0.value_in_unit(_vel0.unit) + _vels_zeroed = ( + np.isclose(_vel0_mag[0], 0.0) + and np.isclose(_vel0_mag[1], 0.0) + and np.isclose(_vel0_mag[2], 0.0) + ) + + if _vels_zeroed: + logger.info("Context state velocities are zeroed.") + else: + logger.info("Context state velocities are set.") + + _pot_e = _init_state.getPotentialEnergy() + _kin_e = _init_state.getKineticEnergy() + _tot_e = _pot_e + _kin_e + + logger.info( + f"Context state energies: kinetic={_kin_e}, potential={_pot_e}, total={_tot_e}" + ) + + _box_volume = _init_state.getPeriodicBoxVolume() + _bvs = _init_state.getPeriodicBoxVectors() + _bvs_line = format_box_vectors_line(_bvs) + + logger.info(f"Context state box: volume={_box_volume}, vectors={_bvs_line}") + + return _box_volume, _bvs, _pot_e, _kin_e, _tot_e + + +# the runner for the simulation which runs the actual dynamics +class OpenMMRunner(Runner): + """Runner for OpenMM simulations.""" + + system: openmm.System + topology: openmm.app.Topology + integrator: openmm.Integrator + enforce_box: bool + get_state_keys: frozenset[str] + openmm_reporter_factories: list[LoggingReporterFactory] | None + + state_machine: RunnerStateMachine + + _openmm_reporters: list[OpenMMReporter] | None + _init_time: int | None + _pre_cycle_time: int | None + + def __init__( + self, + system: openmm.System, + topology: openmm.app.Topology, + integrator: openmm.Integrator, + enforce_box: bool = False, + get_state_keys: frozenset[str] = GET_STATE_DEFAULT_KEYS, + openmm_reporter_factories: list[LoggingReporterFactory] | None = None, + ) -> None: + + self.system = system + self.topology = topology + self.integrator = integrator + self.enforce_box = enforce_box + self.get_state_keys = get_state_keys + + if openmm_reporter_factories is None or len(openmm_reporter_factories) == 0: + logger.warning("No OpenMM reporter factories configured.") + self.openmm_reporter_factories = ( + openmm_reporter_factories if openmm_reporter_factories is not None else [] + ) + + self._openmm_reporters = None + self._init_time = None + self._pre_cycle_time = None + + self.state_machine = RunnerStateMachine() + + self._report_configuration() + + def _report_configuration(self) -> None: + logger.info("Details of OpenMMRunner initial configuration") + + logger.info( + f"OpenMM logging reporters: {', '.join(str(v) for v in self.openmm_reporter_factories)}" + ) + + logger.info(f"Enforce PBCs in getState: {self.enforce_box}") + logger.info(f"Get state keys: {self.get_state_keys}") + + # system + num_particles = self.system.getNumParticles() + num_forces = self.system.getNumForces() + uses_pbcs = self.system.usesPeriodicBoundaryConditions() + default_bvs = self.system.getDefaultPeriodicBoxVectors() + + default_bv_volume = triclinic_volume_vec3_quantity(default_bvs) + default_bv_line = format_box_vectors_line(default_bvs) + + logger.info( + f"System: num_particles={num_particles}, num_forces={num_forces}, uses_pbcs={uses_pbcs}" + ) + logger.info( + f"System default box vectors: volume={default_bv_volume}, vectors={default_bv_line}" + ) + + # topology + num_chains = self.topology.getNumChains() + num_residues = self.topology.getNumResidues() + num_atoms = self.topology.getNumAtoms() + num_bonds = self.topology.getNumBonds() + + top_bvs = self.topology.getPeriodicBoxVectors() + + logger.info( + f"Topology: num_chains={num_chains}, num_residues={num_residues}, num_atoms={num_atoms}, num_bonds={num_bonds}" + ) + if top_bvs is not None: + top_bv_volume = triclinic_volume_vec3_quantity(default_bvs) + bv_line = format_box_vectors_line(default_bvs) + logger.info( + f"Topology box vectors: volume={top_bv_volume} vectors={bv_line}" + ) + else: + logger.info("Topology box vectors not set.") + + chain_ids = [chain.id for chain in self.topology.chains()] + logger.info(f"Topology Chains (IDs): {','.join(chain_ids)}") + + for chain in self.topology.chains(): + num_residues = len(list(chain.residues())) + num_atoms = len(list(chain.atoms())) + logger.info( + f"Chain {chain.index}: id={chain.id}, num_residues={num_residues}, num_atoms={num_atoms}" + ) + + # integrator + # UGLY: just dump the XML for simplicity + integrator_xml = openmm.XmlSerializer.serialize(self.integrator).replace( + "\n", " " + ) + logger.info(f"Integrator: {integrator_xml}") + + @property + def status(self) -> RunnerStatus: + return self.state_machine.state + + def init(self) -> None: + + self.state_machine.validate_event(RunnerEvent.INIT) + + self._init_time = time.time() + logger.info(f"Initialized runner at time: {self._init_time} s") + + self.state_machine.send(RunnerEvent.INIT) + + def pre_cycle( + self, + ) -> None: + + self.state_machine.validate_event(RunnerEvent.PRE_CYCLE) + + self._pre_cycle_time = time.time() + logger.info(f"Runner pre_cycle time: {self._pre_cycle_time} s") + + self.state_machine.send(RunnerEvent.PRE_CYCLE) + + def run_segment( + self, + walker_state: OpenMMState, + segment_length: int, + platform_name: OpenMMPlatformName | None = None, + platform_kwargs: PlatformKwargs | None = None, + ) -> tuple[ + OpenMMState, + OpenMMRunnerSegmentData, + ]: + """Run dynamics for the walker. + + Parameters + ---------- + walker : The walker for which dynamics will be propagated. + + segment_length : The numerical value that specifies how much dynamics are to be run. + + platform_kwargs : Key-values to set for a platform with + platform.setPropertyDefaultValue for this segment only. + + + Returns + ------- + new_walker_state : Walker after dynamics was run, only the state should be modified. + + """ + + if self.status != RunnerStatus.PRE_CYCLE: + raise RunnerStateError( + f"Cannot run a segment in state ({self.status.name}:{self.status.value})" + ) + + logger.info("Running OpenMM MD segment") + + run_segment_start = time.time() + + # set the kwargs that will be passed to getState + gen_sim_start = time.time() + + # TODO: refactor this as an integrator spec as the object + # attribute to avoid needing to do this and make this + # interface explicit + + # make a copy of the integrator for this particular segment, + # otherwise the object attribute will get bound to the context + new_integrator = copy.copy(self.integrator) + # force setting of random seed to 0, which is a special + # value that forces the integrator to choose another + # random number + logger.info("Setting random seed to special value: 0") + new_integrator.setRandomNumberSeed(0) + + ## Platform + + logger.info(f"'platform_kwargs' passed to 'run_segment' : {platform_kwargs}") + + # create simulation object + + ## create the platform and customize + + # if a platform was given we use it to make a Simulation object + if platform_name is not None: + logger.info("Using platform configured in code.") + + # get the platform by its name to use + platform = openmm.Platform.getPlatformByName(platform_name) + logger.info("Platform instantiated.") + + # set properties from the kwargs if they apply to the platform + for key, value in platform_kwargs.items(): + if key in platform.getPropertyNames(): + logger.info(f"Setting platform property: {key} : {value}") + platform.setPropertyDefaultValue(key, value) + + else: + logger.warning( + f"Platform kwargs given ({key} : {value}) " + f"but is not valid for this platform ({platform_name})" + ) + + # make a new simulation object + logger.info("Construction Simulation and context") + simulation = openmm.app.Simulation( + self.topology, self.system, new_integrator, platform + ) + + # otherwise just use the default or environmentally defined one + else: + logger.info("Using OpenMM default platform resolution.") + simulation = openmm.app.Simulation( + self.topology, self.system, new_integrator + ) + + # Generate new reporters for each segment so they don't step + # on each other's state + logger.info("Generating OpenMM reporters for this segment.") + openmm_reporters = [] + for omm_reporter_factory in self.openmm_reporter_factories: + logger.info( + f"Generating and configuring reporter for factory: {omm_reporter_factory}" + ) + openmm_reporters.append( + omm_reporter_factory( + logger, + start_time=run_segment_start, + ) + ) + + logger.info("Registering OpenMM Simulation reporters") + simulation.reporters = openmm_reporters + + # generate a sim state + logger.info("Generating openmm.State from input OpenMMState") + state_wrapper = walker_state.to_state_wrapper() + + # set in the context + logger.info("Setting openmm.State into current context") + simulation.context.setState(state_wrapper.state) + + gen_sim_end = time.time() + gen_sim_time = gen_sim_end - gen_sim_start + + logger.info(f"Time to generate the system: {gen_sim_time:.4f} s") + + logger.info("Information on initial simulation state") + before_volume, before_bvs, before_pot_e, before_kin_e, before_tot_e = ( + _report_simulation(simulation) + ) + + # actually run the simulation + + steps_start = time.time() + + # Run the simulation segment for the number of time steps + logger.info("Running MD steps") + simulation.step(segment_length) + + steps_end = time.time() + steps_time = steps_end - steps_start + + logger.info(f"Time to run {segment_length} sim steps: {steps_time:.4f} s") + + logger.info("Information on final simulation state") + after_volume, after_bvs, after_pot_e, after_kin_e, after_tot_e = ( + _report_simulation(simulation) + ) + + _before_lengths = ( + np.linalg.norm(before_bvs[0]), + np.linalg.norm(before_bvs[1]), + np.linalg.norm(before_bvs[2]), + ) + _after_lengths = ( + np.linalg.norm(after_bvs[0]), + np.linalg.norm(after_bvs[1]), + np.linalg.norm(after_bvs[2]), + ) + + _delta_lengths = [ + after_length - before_length + for after_length, before_length in zip( + _after_lengths, _before_lengths, strict=True + ) + ] + _delta_lengths_line = ( + f"({_delta_lengths[0]}, {_delta_lengths[1]}, {_delta_lengths[2]})" + ) + + # report on the change in energies and box volume + _delta_volume = after_volume - before_volume + _delta_pot_e = after_pot_e - before_pot_e + _delta_kin_e = after_kin_e - before_kin_e + _delta_tot_e = after_tot_e - before_tot_e + + logger.info( + f"State changes in Unitcell: box_volume={_delta_volume}, lengths={_delta_lengths_line}, " + ) + + logger.info( + f"State changes in Energy: potential_E={_delta_pot_e}, kinetic_E={_delta_kin_e}, total_E={_delta_tot_e}" + ) + + get_state_start = time.time() + + # generate the new state + + logger.info(f"Fetching fields {self.get_state_keys} from context state") + + new_omm_state = get_context_state( + simulation.context, + self.get_state_keys, + ) + + new_state_wrapper = OpenMMStateWrapper(new_omm_state) + new_state = OpenMMState.from_state_wrapper(new_state_wrapper) + + get_state_end = time.time() + get_state_time = get_state_end - get_state_start + logger.info(f"Getting context state time: {get_state_time:.4f} s") + + run_segment_end = time.time() + run_segment_time = run_segment_end - run_segment_start + logger.info(f"Total internal run_segment time: {run_segment_time:.4f} s") + + segment_data = OpenMMRunnerSegmentData( + segment_split_time=run_segment_time, + openmm_segment_split_time=OpenMMRunnerSegmentSplitTime( + gen_sim_time=gen_sim_time, + steps_time=steps_time, + get_state_time=get_state_time, + ), + ) + + return new_state, segment_data + + def post_cycle(self, segments_data: list[OpenMMRunnerSegmentData]) -> None: + + self.state_machine.send(RunnerEvent.POST_SEGMENT) + logger.info("Nothing to do") + self.state_machine.send(RunnerEvent.POST_CYCLE) + + +@attrs.define +class OpenMMRunnerFactory: + + system: openmm.System + topology: openmm.app.Topology + integrator: openmm.Integrator + enforce_box: bool = False + get_state_keys: frozenset[str] = attrs.field(default=GET_STATE_DEFAULT_KEYS) + openmm_reporter_factories: list[LoggingReporterFactory] | None = attrs.field( + default=DEFAULT_OPENMM_REPORTER_FACTORIES + ) + + @classmethod + def type(cls) -> type[OpenMMRunner]: + return OpenMMRunner + + def __call__(self) -> OpenMMRunner: + + return OpenMMRunner( + system=copy.deepcopy(self.system), + topology=copy.deepcopy(self.topology), + integrator=copy.deepcopy(self.integrator), + enforce_box=self.enforce_box, + get_state_keys=self.get_state_keys, + openmm_reporter_factories=self.openmm_reporter_factories, + ) diff --git a/src/wepy/runners/openmm/state.py b/src/wepy/runners/openmm/state.py new file mode 100644 index 00000000..fbb7a123 --- /dev/null +++ b/src/wepy/runners/openmm/state.py @@ -0,0 +1,982 @@ +# Standard Library +import logging +from collections.abc import Collection +from typing import ( + Any, + ClassVar, + Literal, + NotRequired, + Self, + TypeAlias, + TypedDict, + get_args, +) + +# Third Party Library +import attrs +import numpy as np +import numpy.typing +import openmm +import openmm.unit +from immutables import Map as frozenmap +from lxml import etree + +# First Party Library +from wepy.core import BugError +from wepy.missing import MISSING +from wepy.util.openmm import array3d_to_vec3 +from wepy.walker import WalkerState + +logger = logging.getLogger(__name__) + + +class OpenMMStateValidationError(Exception): + pass + + +PREFERRED_UNITS_LUT = frozenmap( + { + "length": openmm.unit.nanometer, + "time": openmm.unit.picosecond, + "temperature": openmm.unit.kelvin, + "angle": openmm.unit.degrees, + "molar_mass": openmm.unit.amu, + "charge": openmm.unit.elementary_charge, + "molar_energy": openmm.unit.kilojoule / openmm.unit.mole, + "energy": openmm.unit.kilojoule, + "subtance": openmm.unit.mole, + "velocity": openmm.unit.nanometer / openmm.unit.picosecond, + "molar_force": (openmm.unit.kilojoule / openmm.unit.nanometer) + / openmm.unit.mole, + "molar_energy_density": openmm.unit.kilojoule / openmm.unit.mole, + } +) + +PREFERRED_UNITS = [val for val in PREFERRED_UNITS_LUT.values()] + + +StateFieldName: TypeAlias = Literal[ + "time", + "box_vectors", + "box_volume", + "positions", + "velocities", + "forces", + "parameters", + "parameter_derivatives", + "kinetic_energy", + "potential_energy", +] + +STATE_FIELD_NAMES: frozenset[StateFieldName] = frozenset(get_args(StateFieldName)) + +CORE_FIELDS: frozenset[StateFieldName] = frozenset( + { + "positions", + "velocities", + "forces", + "parameters", + "parameter_derivatives", + } +) + +BOX_FIELDS: frozenset[StateFieldName] = frozenset( + { + "box_vectors", + "box_volume", + } +) + +ACCESSORY_FIELDS: frozenset[StateFieldName] = frozenset( + { + "time", + } +) + +FieldDataType: TypeAlias = openmm.unit.Quantity | frozenmap[str, Any] + +STATE_FIELD_TYPES: frozenmap[str, type[FieldDataType]] = frozenmap( + time=openmm.unit.Quantity, + box_vectors=openmm.unit.Quantity, + box_volume=openmm.unit.Quantity, + positions=openmm.unit.Quantity, + velocities=openmm.unit.Quantity, + forces=openmm.unit.Quantity, + kinetic_energy=openmm.unit.Quantity, + potential_energy=openmm.unit.Quantity, + parameters=frozenmap[str, Any], + parameter_derivatives=frozenmap[str, Any], +) + +StateDataTypeName: TypeAlias = Literal[ + "positions", + "velocities", + "forces", + "energy", + "parameters", + "parameter_derivatives", + # NOTE: integrator_parameters show up here but are not accessible + # as fields on the state + "integrator_parameters", +] + +STATE_DATA_TYPE_ENUM_NAMES: frozenmap[str, str] = frozenmap( + positions="Positions", + velocities="Velocities", + forces="Forces", + energy="Energy", + parameters="Parameters", + parameter_derivatives="ParameterDerivatives", + integrator_parameters="IntegratorParameters", +) + +FIELD_GETTER_NAMES: frozenmap[str, str] = frozenmap( + positions="getPositions", + velocities="getVelocities", + forces="getForces", + kinetic_energy="getKineticEnergy", + potential_energy="getPotentialEnergy", + time="getTime", + box_vectors="getPeriodicBoxVectors", + box_volume="getPeriodicBoxVolume", + parameters="getParameters", + parameter_derivatives="getEnergyParameterDerivatives", +) + +UNREQUESTED_FIELDS: frozenset[StateFieldName] = frozenset( + { + "time", + "box_volume", + "box_vectors", + } +) + +ARRAYLIKE_FIELDS: frozenset[StateFieldName] = frozenset( + { + "positions", + "velocities", + "forces", + "box_vectors", + } +) + +SCALAR_FIELDS: frozenset[StateFieldName] = frozenset( + { + "kinetic_energy", + "potential_energy", + "time", + "box_volume", + } +) + +MAPPING_FIELDS: frozenset[StateFieldName] = frozenset( + { + "parameters", + "parameter_derivatives", + } +) + +ENERGY_FIELDS: frozenset[StateFieldName] = frozenset( + { + "kinetic_energy", + "potential_energy", + } +) + +GetStateKeyWords = Literal[ + "positions", + "velocities", + "forces", + "energy", + "parameters", + "parameterDerivatives", +] + +GET_STATE_KEYWORDS: frozenmap[str, GetStateKeyWords | None] = frozenmap( + positions="positions", + velocities="velocities", + forces="forces", + kinetic_energy="energy", + potential_energy="energy", + time=None, + box_vectors=None, + box_volume=None, + parameters="parameters", + parameter_derivatives="parameterDerivatives", +) + + +GET_STATE_DEFAULT_ENFORCE_PERIODIC_BOX = False + +OPENMM_DEFAULT_DIMENSION_UNITS: frozenmap[str, openmm.unit.Unit] = frozenmap( + length=openmm.unit.nanometer, + time=openmm.unit.picosecond, + energy=openmm.unit.kilojoule, + substance=openmm.unit.mole, +) + +OPENMM_DEFAULT_UNITS: frozenmap[str, openmm.unit.Unit] = frozenmap( + positions=openmm.unit.nanometer, + time=openmm.unit.picosecond, + box_vectors=openmm.unit.nanometer, + box_volume=openmm.unit.nanometer**3, + velocities=openmm.unit.nanometer / openmm.unit.picosecond, + forces=openmm.unit.kilojoule / openmm.unit.nanometer, + kinetic_energy=(openmm.unit.kilojoule / openmm.unit.mole), + potential_energy=(openmm.unit.kilojoule / openmm.unit.mole), +) + + +class StateFieldData(TypedDict): + time: openmm.unit.Quantity + box_vectors: openmm.unit.Quantity + box_volume: openmm.unit.Quantity + + positions: NotRequired[openmm.unit.Quantity] + velocities: NotRequired[openmm.unit.Quantity] + forces: NotRequired[openmm.unit.Quantity] + kinetic_energy: NotRequired[openmm.unit.Quantity] + potential_energy: NotRequired[openmm.unit.Quantity] + parameters: NotRequired[frozenmap[str, Any]] + parameter_derivatives: NotRequired[frozenmap[str, Any]] + + +class StateFieldDataInput(TypedDict): + time: openmm.unit.Quantity + box_vectors: openmm.unit.Quantity + + positions: NotRequired[openmm.unit.Quantity] + velocities: NotRequired[openmm.unit.Quantity] + parameters: NotRequired[frozenmap[str, Any]] + + +STATE_REQUIRED_INPUT_FIELDS: frozenset[StateFieldName] = frozenset( + {"time", "box_vectors"} +) + + +def dummy_context( + system: openmm.System, + positions: openmm.unit.Quantity, + unitcell: openmm.unit.Quantity | None = None, +) -> openmm.Context: + """Create a throwaway OpenMM context. + + This uses some hardcoded integrators, etc. to be able to get a + context which is useful for generating OpenMM objects without + running any calculations. You can also use it for a simulation but + it won't do anything meaningful. + """ + + platform = openmm.Platform.getPlatformByName("Reference") + integrator = openmm.VerletIntegrator(1.0 * openmm.unit.femtoseconds) + context = openmm.Context(system, integrator, platform) + context.setPositions(positions) + + if unitcell is not None: + context.setPeriodicBoxVectors(*unitcell) + + return context + + +def get_context_state( + context: openmm.Context, + fields: frozenset[StateFieldName] | None = None, +) -> openmm.State: + """Retrieve a state from a context using field names.""" + + if fields is None: + _fields = STATE_FIELD_NAMES + else: + _fields = fields + + kwargs = {} + for field_name in _fields: + + kwarg = GET_STATE_KEYWORDS[field_name] + if kwarg is not None: + kwargs[kwarg] = True + + return context.getState( + **kwargs, + enforcePeriodicBox=GET_STATE_DEFAULT_ENFORCE_PERIODIC_BOX, + ) + + +def resolve_state_data_type_enum_values() -> frozenmap[str, int]: + """Gets the enum values for each field in the state. + + These are int values which are used for bitflag operations. + """ + enum_values = {} + for our_name, enum_name in STATE_DATA_TYPE_ENUM_NAMES.items(): + enum_values[our_name] = getattr(openmm.State, enum_name) + + return frozenmap(enum_values) + + +# reversed since that is the order we check them in and is a frequent operation +STATE_DATA_TYPE_ENUM_VALUES: tuple[tuple[str, int], ...] = tuple( + sorted( + [(k, v) for k, v in resolve_state_data_type_enum_values().items()], + key=lambda x: x[1], + reverse=True, + ) +) + + +def get_state_core_fields_present( + sim_state: openmm.State, +) -> frozenset[str]: + """Figure out which core data fields are present in the State. + + This does not include accessory attributes: + - time + - box_vectors and box volume + - energies + + Note that this also includes the 'integrator_parameters' which are + not accessible from the state getters. + """ + + flag_sum = sim_state.getDataTypes() + + flag_fields = [] + flag_values = [] + flag_cum = flag_sum + for field_name, flag_value in STATE_DATA_TYPE_ENUM_VALUES: + if flag_value > flag_cum: + continue + elif flag_value == flag_cum: + flag_fields.append(field_name) + flag_values.append(flag_value) + break + + else: + flag_fields.append(field_name) + flag_values.append(flag_value) + flag_cum -= flag_value + + # double check they sum up + assert sum(flag_values) == flag_sum + + return frozenset(flag_fields) + + +def get_state_fields_present(sim_state: openmm.State) -> frozenset[StateFieldName]: + """Figure out which accessible state fields are present in a State. + + This includes all the accessible fields that have getters + associated with them. + + Notably this excludes the 'integrator_parameters'. + """ + + # get which core fields are present + present_core_fields = get_state_core_fields_present(sim_state) + + present_fields = set() + + # core fields + for field in { + "positions", + "velocities", + "forces", + "parameters", + "parameter_derivatives", + }: + + if field in present_core_fields: + present_fields.add(field) + + # energy is a little different + if "energy" in present_core_fields: + present_fields = present_fields | ENERGY_FIELDS + + # handle box fields efficiently + try: + sim_state.getPeriodicBoxVectors() + except openmm.OpenMMException: + pass + else: + present_fields.add("box_vectors") + present_fields.add("box_volume") + + # then get whether the rest of the accessory fields are available + try: + sim_state.getTime() + except openmm.OpenMMException: + pass + else: + present_fields.add("time") + + return frozenset(present_fields) + + +## Wrapper for a openmm.State + + +class OpenMMStateWrapper(WalkerState): + """Useful wrapper around an openmm.State object. + + This object is meant to be an alternative interface to the + openmm.State object and thus supports the same use case of efficient + and fine grained data transfer between the openmm.Context (possible + GPU memory) and local memory. + """ + + state: openmm.State + core_fields: frozenset[StateDataTypeName] + fields: frozenset[StateFieldName] + + def __init__(self, state: openmm.State) -> None: + + self.state = state + + # probe which data fields it has + self.core_fields = get_state_core_fields_present(self.state) + self.fields = get_state_fields_present(self.state) + + def fields_in(self, fields: Collection[StateFieldName]) -> bool: + """Checks if all the fields specified are in the state.""" + + return len(set(fields) - self.fields) == 0 + + def __str__(self) -> str: + + fields_s = ", ".join(self.fields) + return f"StateWrapper[{fields_s}]" + + def __len__(self) -> int: + return len(self.fields) + + def __contains__(self, item: str) -> bool: + return self.fields_in((item,)) + + def __getitem__(self, key) -> FieldDataType: + # if this was a key for data not mapped from the OpenMM.State + # object we use the _data attribute + if key not in STATE_FIELD_NAMES: + raise KeyError(f"Key '{key}' is not a valid key for a state.") + + elif key in STATE_DATA_TYPE_ENUM_NAMES and key not in self.fields: + raise KeyError(f"Core data field '{key}' is not present in this state") + else: + + # resolve the getter function for the field + getter_name = FIELD_GETTER_NAMES[key] + + getter_method = getattr(self.state, getter_name, None) + + if getter_method is None: + raise BugError(f"No getter ('{getter_name}') for key '{key}'") + + elif key in ARRAYLIKE_FIELDS: + field_val = getter_method(asNumpy=True) + elif key in SCALAR_FIELDS: + field_val = getter_method() + elif key in MAPPING_FIELDS: + field_val = frozenmap(dict(getter_method())) + else: + raise BugError(f"Unhandled field key {key}, getter '{getter_name}'") + + return field_val + + def __eq__(self, other: Any) -> bool: + + if not isinstance(other, type(self)): + return False + + if self.fields == other.fields: + for field_name in self.fields & (SCALAR_FIELDS | MAPPING_FIELDS): + if self[field_name] != other[field_name]: + return False + + for field_name in self.fields & (ARRAYLIKE_FIELDS - {"box_vectors"}): + if not np.array_equal(self[field_name], other[field_name]): + return False + + else: + return False + + return True + + # Methods for the OpenMMBasicStateProtocol + def get_positions(self) -> openmm.unit.Quantity | None: + return self["positions"] + + def get_unitcell(self) -> openmm.unit.Quantity | None: + return self["unitcell"] + + def to_dict(self) -> StateFieldData: + + return StateFieldData({field: self[field] for field in self.fields}) + + def serialize_xml(self) -> str: + """Serialize the openmm.State to openmm XML format.""" + + return openmm.XmlSerializer.serialize(self.state) + + @classmethod + def from_xml(cls, xml_str: str) -> Self: + """Serialize the openmm.State to openmm XML format.""" + + state = openmm.XmlSerializer.deserialize(xml_str) + return cls(state) + + @classmethod + def from_dict( + cls, + system: openmm.System, + state_dict: StateFieldDataInput, + ) -> Self: + """Convert an input dictionary to a state wrapper and State object. + + Note that the input data structure is slightly different than + the state output. + + For example the box_volume cannot be provided as an input + since it is a derived value from the box vectors. + + See the data structure type for inputs. + + For parameters the parameter must be defined in the system forces. + """ + + if ( + len(missing_fields := STATE_REQUIRED_INPUT_FIELDS - set(state_dict.keys())) + > 0 + ): + + missing_fields_str = ", ".join(missing_fields) + + raise OpenMMStateValidationError( + f"Missing required fields: {missing_fields_str}" + ) + + # a dummy context used to generate a state only + ctx = openmm.Context( + system, + openmm.VerletIntegrator(1.0 * openmm.unit.femtoseconds), + openmm.Platform.getPlatformByName("Reference"), + ) + + # the fields which are always in a state dict + ctx.setTime(state_dict["time"].in_units_of(OPENMM_DEFAULT_UNITS["time"])) + + bvs_vec3 = ( + tuple(v for v in array3d_to_vec3(state_dict["box_vectors"])) + * state_dict["box_vectors"].unit + ) + ctx.setPeriodicBoxVectors(*bvs_vec3) + + if "positions" in state_dict: + + ctx.setPositions(state_dict["positions"]) + + if "velocities" in state_dict: + + ctx.setVelocities(state_dict["velocities"]) + + if "parameters" in state_dict: + for name, value in state_dict["parameters"].items(): + try: + ctx.setParameter(name, value) + except openmm.OpenMMException: + raise OpenMMStateValidationError( + f"Could not set parameter '{name}' as there is no matching parameter in the system forces." + ) + + state = get_context_state(ctx, frozenset(state_dict.keys())) + + return cls(state) + + +# A plain data structure state + + +def _maybe_array_equal( + arr0: openmm.unit.Quantity | None, + arr1: openmm.unit.Quantity | None, +) -> bool: + """Similar to numpy.array_equal for quantities.""" + + if arr0 is None and arr1 is None: + return True + elif arr0 is None or arr1 is None: + return False + else: + return np.array_equal(arr0, arr1) + + +def _gen_unit_cube() -> numpy.typing.ArrayLike: + return np.array( + [ + [1.0, 0.0, 0.0], + [0.0, 1.0, 0.0], + [0.0, 0.0, 1.0], + ] + ) + + +@attrs.define +class OpenMMState(WalkerState): + """Pure data type for an OpenMM state. + + This type does no wrapping and should be serializable as is. + + This is not mapped to a particular openmm.State object but can be + converted to it. + """ + + time: openmm.unit.Quantity = attrs.field( + eq=attrs.cmp_using(np.array_equal, require_same_type=False), + ) + box_vectors: openmm.unit.Quantity = attrs.field( + eq=attrs.cmp_using(np.array_equal, require_same_type=False), + ) + + box_volume: openmm.unit.Quantity | None = None + + positions: openmm.unit.Quantity | None = attrs.field( + default=None, + eq=attrs.cmp_using(_maybe_array_equal, require_same_type=False), + ) + velocities: openmm.unit.Quantity | None = attrs.field( + default=None, + eq=attrs.cmp_using(_maybe_array_equal, require_same_type=False), + ) + forces: openmm.unit.Quantity | None = attrs.field( + default=None, + eq=attrs.cmp_using(_maybe_array_equal, require_same_type=False), + ) + kinetic_energy: openmm.unit.Quantity | None = None + potential_energy: openmm.unit.Quantity | None = None + parameters: frozenmap[str, Any] | None = None + parameter_derivatives: frozenmap[str, Any] | None = None + + # DWIM: "do what I mean" which corresponds to a common default + # that is somewhat arbitrary but typically the ergonomic single + # way to do something + DWIM_DEFAULT_TIME: ClassVar[openmm.unit.Quantity] = 0 * openmm.unit.picosecond + DWIM_DEFAULT_UNITCELL: ClassVar[openmm.unit.Quantity] = ( + _gen_unit_cube() * openmm.unit.nanometer + ) + DWIM_DEFAULT_BOX_VOLUME: ClassVar[openmm.unit.Quantity] = 0.0 * ( + openmm.unit.nanometer**3 + ) + + @staticmethod + def _validate_array3ds( + positions: openmm.unit.Quantity | None, + velocities: openmm.unit.Quantity | None, + forces: openmm.unit.Quantity | None, + ) -> bool: + + maybe_nums = { + "positions": positions.shape[0] if positions is not None else None, + "velocities": velocities.shape[0] if velocities is not None else None, + "forces": forces.shape[0] if forces is not None else None, + } + + # check dependending on which attributes are actually given + which_given = [name for name, e in maybe_nums.items() if e is not None] + if len(which_given) < 2: + return True + + elif ( + len(which_given) == 2 + and maybe_nums[which_given[0]] != maybe_nums[which_given[1]] + ): + report = ", ".join(f"{name}={maybe_nums[name]}" for name in which_given) + + raise OpenMMStateValidationError( + f"The number of particles do not match: {report}" + ) + + elif len(which_given) == 3 and ( + maybe_nums[which_given[0]] != maybe_nums[which_given[1]] + or maybe_nums[which_given[0]] != maybe_nums[which_given[2]] + ): + + report = ", ".join(f"{name}={maybe_nums[name]}" for name in which_given) + + raise OpenMMStateValidationError( + f"The number of particles do not match: {report}" + ) + + else: + return True + + def __attrs_post_init__(self) -> None: + + # validate that at least one field is given + if not any(f is not None for f in attrs.astuple(self)): + raise OpenMMStateValidationError( + "At least one field must be provided to construct the object." + ) + + # validate that coordinates all have the same number of particles + self._validate_array3ds(self.positions, self.velocities, self.forces) + + # TODO: These aren't critical so avoiding this for now. + # + # validate that the box_volume matches the vectors + # + # validate that the parameters and derivatives have the same keys + + def __len__(self) -> int: + + count = 0 + for key in STATE_FIELD_NAMES: + if getattr(self, key, MISSING) is not None: + count += 1 + + return count + + def __contains__(self, key: str) -> bool: + + if key not in STATE_FIELD_NAMES: + return False + + elif getattr(self, key, MISSING) is None: + return False + + else: + return True + + def __getitem__(self, key: str) -> openmm.unit.Quantity: + + if key not in STATE_FIELD_NAMES: + raise KeyError(f"Field {key} is not a valid OpenMMState key") + + elif getattr(self, key, MISSING) is None: + raise ValueError(f"Field {key} has no value.") + + else: + return getattr(self, key, None) + + @classmethod + def from_dict(cls, data_dict: StateFieldData) -> Self: + return cls(**data_dict) + + @classmethod + def from_dwim( + cls, + positions: openmm.unit.Quantity, + time: openmm.unit.Quantity | None = None, + box_volume: openmm.unit.Quantity | None = None, + box_vectors: openmm.unit.Quantity | None = None, + velocities: openmm.unit.Quantity | None = None, + forces: openmm.unit.Quantity | None = None, + kinetic_energy: openmm.unit.Quantity | None = None, + potential_energy: openmm.unit.Quantity | None = None, + parameters: frozenmap[str, Any] | None = None, + parameter_derivatives: frozenmap[str, Any] | None = None, + ): + + return cls( + time=(cls.DWIM_DEFAULT_TIME if time is None else time), + box_volume=( + cls.DWIM_DEFAULT_BOX_VOLUME if box_volume is None else box_volume + ), + positions=positions, + box_vectors=( + cls.DWIM_DEFAULT_UNITCELL if box_vectors is None else box_vectors + ), + velocities=velocities, + forces=forces, + kinetic_energy=kinetic_energy, + potential_energy=potential_energy, + parameters=parameters, + parameter_derivatives=parameter_derivatives, + ) + + @classmethod + def from_state_wrapper(cls, state_wrapper: OpenMMStateWrapper) -> Self: + return cls.from_dict(state_wrapper.to_dict()) + + @classmethod + def from_state(cls, state: openmm.State) -> Self: + return cls.from_state_wrapper(OpenMMStateWrapper(state)) + + def dict(self) -> StateFieldData: + return StateFieldData( + { + k: v + for k, v in attrs.asdict(self, recurse=False).items() + if v is not None + } + ) + + def to_state_wrapper( + self, + system: openmm.System | None = None, + ) -> OpenMMStateWrapper: + """Convert this state to a real wrapped openmm.State. + + Notes: + If the optional system is provided this provides a more direct + route for translation. + + If not the state will be serialized and deserialized in memory + to get a state. + """ + + if system is not None: + wrapper = OpenMMStateWrapper.from_dict(system, self.dict()) + + else: + + xml_str = state_to_xml(self) + state = openmm.XmlSerializer.deserialize(xml_str) + wrapper = OpenMMStateWrapper(state) + + return wrapper + + +def _gen_vec3_element( + name: str, parent: etree.Element, vec: tuple[int, int, int] +) -> etree.Element: + + return etree.SubElement( + parent, + name, + x=str(vec[0]), + y=str(vec[1]), + z=str(vec[2]), + ) + + +def state_to_xml( + state: OpenMMState, + step_count: int = 0, +) -> str: + """Convert to an OpenMM State XML without the need of a system/context.""" + + _time_str = str(state.time.in_units_of(PREFERRED_UNITS_LUT["time"])) + _step_count_str = str(step_count) + + state_el = etree.Element( + "State", + openmmVersion=openmm.version.short_version, + stepCount=_step_count_str, + time=_time_str, + type="State", + version="1", + ) + + # box vectors + box_vectors_el = etree.SubElement( + state_el, + "PeriodicBoxVectors", + ) + + _bvecs = [ + tuple(vec.in_units_of(PREFERRED_UNITS_LUT["length"])) + for vec in tuple(state.box_vectors[i] for i in range(3)) + ] + + for name, vec in zip(("A", "B", "C"), _bvecs, strict=True): + + _gen_vec3_element( + name, + box_vectors_el, + vec, + ) + + if state.positions is not None: + + positions_el = etree.SubElement( + state_el, + "Positions", + ) + + _positions = state.positions.in_units_of(PREFERRED_UNITS_LUT["length"]) + + for atom_vec in _positions: + + _gen_vec3_element( + "Position", + positions_el, + tuple(atom_vec), + ) + + if state.velocities is not None: + + velocities_el = etree.SubElement( + state_el, + "Velocities", + ) + + _velocities = state.velocities.in_units_of(PREFERRED_UNITS_LUT["velocity"]) + + for atom_vec in _velocities: + + _gen_vec3_element( + "Velocity", + velocities_el, + tuple(atom_vec), + ) + + if state.forces is not None: + + forces_el = etree.SubElement( + state_el, + "Forces", + ) + + _forces = state.forces.in_units_of(PREFERRED_UNITS_LUT["molar_force"]) + + for atom_vec in _forces: + + _gen_vec3_element( + "Force", + forces_el, + tuple(atom_vec), + ) + + if state.kinetic_energy is not None or state.potential_energy is not None: + + energies_el = etree.SubElement( + state_el, + "Energies", + **( + { + "KineticEnergy": str( + state.kinetic_energy.in_units_of( + PREFERRED_UNITS_LUT["molar_energy_density"] + ) + ) + } + if state.kinetic_energy is not None + else {} + ), + **( + { + "PotentialEnergy": str( + state.potential_energy.in_units_of( + PREFERRED_UNITS_LUT["molar_energy_density"] + ) + ) + } + if state.potential_energy is not None + else {} + ), + ) + + # TODO: parameters, parameter derivatives. I have no working + # example on how to do this so I am eliding them. + if state.parameters is not None or state.parameter_derivatives is not None: + + logger.warning( + "A state was provided to the XML serializer with parameters or parameter_derivatives, but these are currently not serialized." + ) + + xml_str = etree.tostring( + state_el, + xml_declaration=True, + pretty_print=True, + ) + + return xml_str.decode() diff --git a/src/wepy/runners/randomwalk.py b/src/wepy/runners/randomwalk.py deleted file mode 100644 index 0a499b52..00000000 --- a/src/wepy/runners/randomwalk.py +++ /dev/null @@ -1,145 +0,0 @@ -"""The random walk dynamics runner. - -In this system, the state of the walkers is defined as an -N-dimensional vector of non-negative values. The walkers start at -position zero (in N-dimensional space) and randomly move a step either -forward or backward with the given probabilities. This is done in each -dimension at each dynamic step. All moves that result in a negative -position are rejected. - -One potentioanl use of the random walk system is to test the -performance of differnt resamplers as seen in these papers: - -"WExplore: Hierarchical Exploration of High-Dimensional Spaces -Using the Weighted Ensemble Algorithm" and -"REVO: Resampling of Ensembles by Variation Optimization". - -""" - -# Standard Library -import logging - -logger = logging.getLogger(__name__) -# Standard Library -import random as rand - -# Third Party Library -import numpy as np -from pint import UnitRegistry - -# First Party Library -from wepy.runners.runner import Runner -from wepy.walker import Walker, WalkerState - -units = UnitRegistry() - -# the names of the units. We pass them through pint just to validate -# them -UNIT_NAMES = ( - ("positions_unit", str(units("microsecond").units)), - ("time_unit", str(units("picosecond").units)), -) - -"""Mapping of units identifiers to the corresponding pint units.""" - - -class RandomWalkRunner(Runner): - """RandomWalk runner for random walk simulations.""" - - def __init__(self, probability=0.25): - """Constructor for RandomWalkRunner. - - Parameters - ---------- - - probabilty : float - "Probability" is defined here as the forward-move - probability only. The backward-move probability is - 1-probability.(Default = 0.25) - - """ - - self._probability = probability - - @property - def probability(self): - """The probability of forward-move in an N-dimensional space""" - return self._probability - - def _walk(self, positions): - """Run dynamics for the RandomWalk system for one step. - - Parameters - ---------- - positions : arraylike of shape (1, dimension) - Current position of the walker. - - Returns - ------- - new_positions : arraylike of shape (1, dimension) - The positions of the walker after one dynamic step. - - """ - - # make the deep copy of current posiotion - new_positions = positions.copy() - - # get the dimension of the random walk space - dimension = new_positions.shape[1] - - # iterates over each dimension - for dim_idx in range(dimension): - # Generates an uniform random number to choose between - # moving forward or backward. - rand_num = rand.uniform(0, 1) - - # make a forward movement - if rand_num < self.probability: - new_positions[0][dim_idx] += 1 - # make a backward movement - else: - new_positions[0][dim_idx] -= 1 - - # implement the boundary condition for movement, movements - # to -1 are rejected - if new_positions[0][dim_idx] < 0: - new_positions[0][dim_idx] = 0 - - return new_positions - - def run_segment(self, walker, segment_length, **kwargs): - """Runs a random walk simulation for the given number of steps. - - Parameters - ---------- - walker : object implementing the Walker interface - The walker for which dynamics will be propagated. - - - segment_length : int - The numerical value that specifies how much dynamical steps - are to be run. - - Returns - ------- - new_walker : object implementing the Walker interface - Walker after dynamics was run, only the state should be modified. - - """ - - # Gets the current posiotion of RandomWalk Walker - positions = walker.state["positions"] - - # Make movements for the segment_length steps - for _ in range(segment_length): - # calls walk function for one step movement - new_positions = self._walk(positions) - positions = new_positions - - # makes new state form new positions - new_state = WalkerState(positions=new_positions, time=0.0) - - # creates new_walker from new state and current weight - new_walker = Walker(new_state, walker.weight) - - return new_walker diff --git a/src/wepy/runners/runner.py b/src/wepy/runners/runner.py index f0cb756a..34dccd8e 100644 --- a/src/wepy/runners/runner.py +++ b/src/wepy/runners/runner.py @@ -17,72 +17,222 @@ """ -from typing import Protocol +# Standard Library +import logging +from enum import IntEnum +from typing import Literal, Protocol, TypeVar + +# Third Party Library +import attrs +from immutables import Map as frozenmap + +# First Party Library +from wepy.walker import WalkerState + +logger = logging.getLogger(__name__) + + +@attrs.define +class RunSegmentData: + segment_split_time: float + + +WalkerState_ = TypeVar("WalkerState_", bound=WalkerState) +RunSegmentData_ = TypeVar("RunSegmentData_", bound=RunSegmentData) + + +class RunnerStatus(IntEnum): + PRE_INITIALIZATION = 0 + INITIALIZED = 1 + PRE_CYCLE = 2 + POST_SEGMENT = 3 + POST_CYCLE = 4 -class Runner(Protocol): - """Abstract base class for the Runner interface.""" +class RunnerEvent(IntEnum): + INIT = 0 + PRE_CYCLE = 1 + POST_SEGMENT = 2 + POST_CYCLE = 3 - def pre_cycle(self, **kwargs): - """Perform pre-cycle behavior. run_segment will be called for each - walker so this allows you to perform changes of state on a - per-cycle basis. - Parameters - ---------- +class RunnerStateMachineError(Exception): + pass - kwargs : key-word arguments - Key-value pairs to be interpreted by each runner implementation. - """ +class RunnerStateTransitionError(RunnerStateMachineError): + """Indicates an error with the runner state machine transition.""" - # by default just pass since subclasses need not implement this - # TODO: this should not be run if it is an abstract class. But it is running - # raise NotImplementedError(f"In {self.__class__.__name__}") - pass + pass - def post_cycle(self, **kwargs): - """Perform post-cycle behavior. run_segment will be called for each - walker so this allows you to perform changes of state on a - per-cycle basis. - Parameters - ---------- +class RunnerStateError(RunnerStateMachineError): + """Indicates an error relating to the current state of the runner.""" - kwargs : key-word arguments - Key-value pairs to be interpreted by each runner implementation. + pass - """ - # by default just pass since subclasses need not implement this - pass +# State machine table that defines what are the valid states to +# transition to another state. None for the initial states +RUNNER_STATE_TRANSITION_TABLE: frozenmap[ + RunnerStatus, + frozenmap[RunnerEvent, RunnerStatus], +] = frozenmap( + { + RunnerStatus.PRE_INITIALIZATION: frozenmap( + { + RunnerEvent.INIT: RunnerStatus.INITIALIZED, + } + ), + RunnerStatus.INITIALIZED: frozenmap( + { + RunnerEvent.PRE_CYCLE: RunnerStatus.PRE_CYCLE, + } + ), + RunnerStatus.PRE_CYCLE: frozenmap( + { + RunnerEvent.POST_SEGMENT: RunnerStatus.POST_SEGMENT, + } + ), + RunnerStatus.POST_SEGMENT: frozenmap( + { + RunnerEvent.POST_CYCLE: RunnerStatus.POST_CYCLE, + } + ), + RunnerStatus.POST_CYCLE: frozenmap( + { + RunnerEvent.PRE_CYCLE: RunnerStatus.PRE_CYCLE, + } + ), + } +) + + +@attrs.define +class RunnerStateMachine: + state: RunnerStatus = attrs.field( + default=RunnerStatus.PRE_INITIALIZATION, + ) + + def validate_event(self, event: RunnerEvent) -> Literal[True]: + state_transitions = RUNNER_STATE_TRANSITION_TABLE[self.state] + if event not in state_transitions: + raise RunnerStateTransitionError( + f"Runner is in state {self.state.name}:{self.state.value}," + f"event {event.name}:{event.value} is not a valid." + f" Choose from: {set(state_transitions.keys())}" + ) + else: + return True + + def send(self, event: RunnerEvent) -> RunnerStatus: + + logger.info(f"Received event: {event.name}:{event.value}") + self.validate_event(event) + + state_transitions = RUNNER_STATE_TRANSITION_TABLE[self.state] + new_state = state_transitions[event] + + logger.info( + "Transitioning runner state:" + f" {self.state.name}:{self.state.value} -> {new_state.name}:{new_state.value}" + ) + self.state = new_state + + return self.state + + +class Runner(Protocol[WalkerState_, RunSegmentData_]): + """Abstract base class for the Runner interface.""" + + @property + def status(self) -> RunnerStatus: ... - def run_segment(self, walker, segment_length, **kwargs): + def init(self) -> None: ... + + def pre_cycle(self) -> None: + """Perform pre-cycle behavior.""" + ... + + def run_segment( + self, + walker: WalkerState_, + segment_length: int, + ) -> tuple[WalkerState_, RunSegmentData_ | None]: """Run dynamics for the walker. Parameters ---------- - walker : object implementing the Walker interface - The walker for which dynamics will be propagated. - segment_length : int or float - The numerical value that specifies how much dynamics are to be run. + walker : The walker for which dynamics will be propagated. + segment_length : The numerical value that specifies how much dynamics are to be run. + Returns ------- - new_walker : object implementing the Walker interface - Walker after dynamics was run, only the state should be modified. + new_walker : Walker after dynamics was run, only the state should be modified. + run_segment_data: Arbitrary data type that is used internally + in the runner and managers for runner specific data, + e.g. segment performance metrics. """ + ... - raise NotImplementedError + def post_cycle( + self, + segments_data: list[RunSegmentData_] | None, + ) -> None: + """Perform post-cycle behavior.""" + ... +@attrs.define class NoRunner(Runner): """Stub Runner that just returns the walkers back with the same state. May be useful for testing. """ - def run_segment(self, walker, segment_length, **kwargs): - # documented in superclass - return walker + state_machine: RunnerStateMachine = attrs.field( + default=attrs.Factory( + RunnerStateMachine, + ) + ) + + @property + def status(self) -> RunnerStatus: + return self.state_machine.state + + def init(self) -> None: + self.state_machine.send(RunnerEvent.INIT) + + def pre_cycle(self) -> None: + self.state_machine.send(RunnerEvent.PRE_CYCLE) + + def run_segment( + self, + state: WalkerState_, + segment_length: int | float, + ) -> tuple[WalkerState_, None]: + + if self.status != RunnerStatus.PRE_CYCLE: + raise RunnerStateError( + f"Cannot run a segment in state ({self.status.name}:{self.status.value})" + ) + + return state, None + + def post_cycle(self, segments_data: None) -> None: + + self.state_machine.send(RunnerEvent.POST_SEGMENT) + self.state_machine.send(RunnerEvent.POST_CYCLE) + + +@attrs.define +class NoRunnerFactory: + + @classmethod + def type(cls) -> type[NoRunner]: + return NoRunner + + def __call__(self) -> NoRunner: + return NoRunner() diff --git a/src/wepy/sim_manager.py b/src/wepy/sim_manager.py index f9413aa6..eeb252eb 100644 --- a/src/wepy/sim_manager.py +++ b/src/wepy/sim_manager.py @@ -43,25 +43,312 @@ """ # Standard Library -from typing import Final, Any +import copy +import enum import logging - -logger = logging.getLogger(__name__) -# Standard Library import time -from copy import deepcopy +from typing import Final, Generic, Literal, TypeVar + +# Third Party Library +import attrs +import psutil +from immutables import Map as frozenmap # First Party Library -from wepy.walker import Walker -from wepy.work_mapper.mapper import Mapper -from wepy.runners.runner import Runner -from wepy.resampling.resamplers.resampler import Resampler -from wepy.reporter.reporter import Reporter -from wepy.work_mapper.mapper import WorkerMapper from wepy.boundary_conditions.boundary import BoundaryConditions +from wepy.factory import Factory +from wepy.monitor import Monitor +from wepy.reporter.base import CycleReportDict, Reporter +from wepy.resampling.resamplers.resampler import Resampler +from wepy.runners.runner import Runner, RunSegmentData +from wepy.walker import Walker +from wepy.work_mapper.base import WorkMapper +from wepy.work_mapper.serial import SerialMapper + +logger = logging.getLogger(__name__) + + +class ManagerStatus(enum.IntEnum): + CONSTRUCTED = enum.auto() + PRE_SIMULATION = enum.auto() + INITIALIZING = enum.auto() + INITIALIZED = enum.auto() + SIM_STARTED = enum.auto() + RUNNING_CYCLE = enum.auto() + RUNNING_PRE_SEGMENT = enum.auto() + PRE_SEGMENT_FINISHED = enum.auto() + RUNNING_SEGMENT = enum.auto() + SEGMENT_FINISHED = enum.auto() + RUNNING_POST_SEGMENT = enum.auto() + POST_SEGMENT_FINISHED = enum.auto() + BC_WARPING = enum.auto() + POST_BC_WARPING = enum.auto() + RESAMPLING = enum.auto() + POST_RESAMPLING = enum.auto() + REPORT_GENERATION = enum.auto() + REPORTING = enum.auto() + POST_REPORTING = enum.auto() + CYCLE_MONITORING = enum.auto() + POST_CYCLE_MONITORING = enum.auto() + POST_CYCLE = enum.auto() + POST_SIMULATION = enum.auto() + CLEANING = enum.auto() + CLEANUP_FINISHED = enum.auto() + FINISHED = enum.auto() + + +class ManagerEvent(enum.IntEnum): + START_PRE_SIM = enum.auto() + START_INITIALIZATION = enum.auto() + FINISH_INITIALIZATION = enum.auto() + START_SIM = enum.auto() + START_CYCLE = enum.auto() + START_PRE_SEGMENT = enum.auto() + FINISH_PRE_SEGMENT = enum.auto() + START_SEGMENT = enum.auto() + FINISH_SEGMENT = enum.auto() + START_POST_SEGMENT = enum.auto() + FINISH_POST_SEGMENT = enum.auto() + START_BC_WARPING = enum.auto() + FINISH_BC_WARPING = enum.auto() + START_RESAMPLING = enum.auto() + FINISH_RESAMPLING = enum.auto() + GENERATE_REPORT = enum.auto() + START_REPORTING = enum.auto() + FINISH_REPORTING = enum.auto() + START_CYCLE_MONITORING = enum.auto() + FINISH_CYCLE_MONITORING = enum.auto() + FINISH_CYCLE = enum.auto() + FINISH_SIMULATION = enum.auto() + START_CLEANUP = enum.auto() + FINISH_CLEANUP = enum.auto() + SHUTDOWN = enum.auto() + + +# State machine table that defines what are the valid states to +# transition to another state. None for the initial states +MANAGER_STATE_TRANSITION_TABLE: frozenmap[ + ManagerStatus, + frozenmap[ManagerEvent, ManagerStatus], +] = frozenmap( + { + ManagerStatus.CONSTRUCTED: frozenmap( + { + ManagerEvent.START_PRE_SIM: ManagerStatus.PRE_SIMULATION, + } + ), + ManagerStatus.PRE_SIMULATION: frozenmap( + { + ManagerEvent.START_INITIALIZATION: ManagerStatus.INITIALIZING, + } + ), + ManagerStatus.INITIALIZING: frozenmap( + { + ManagerEvent.FINISH_INITIALIZATION: ManagerStatus.INITIALIZED, + } + ), + ManagerStatus.INITIALIZED: frozenmap( + { + ManagerEvent.START_SIM: ManagerStatus.SIM_STARTED, + ManagerEvent.START_CLEANUP: ManagerStatus.CLEANING, + } + ), + ManagerStatus.SIM_STARTED: frozenmap( + { + ManagerEvent.START_CYCLE: ManagerStatus.RUNNING_CYCLE, + ManagerEvent.START_CLEANUP: ManagerStatus.CLEANING, + } + ), + ManagerStatus.RUNNING_CYCLE: frozenmap( + { + ManagerEvent.START_PRE_SEGMENT: ManagerStatus.RUNNING_PRE_SEGMENT, + ManagerEvent.START_CLEANUP: ManagerStatus.CLEANING, + } + ), + ManagerStatus.RUNNING_PRE_SEGMENT: frozenmap( + { + ManagerEvent.FINISH_PRE_SEGMENT: ManagerStatus.PRE_SEGMENT_FINISHED, + ManagerEvent.START_CLEANUP: ManagerStatus.CLEANING, + } + ), + ManagerStatus.PRE_SEGMENT_FINISHED: frozenmap( + { + ManagerEvent.START_SEGMENT: ManagerStatus.RUNNING_SEGMENT, + ManagerEvent.START_CLEANUP: ManagerStatus.CLEANING, + } + ), + ManagerStatus.RUNNING_SEGMENT: frozenmap( + { + ManagerEvent.FINISH_SEGMENT: ManagerStatus.SEGMENT_FINISHED, + ManagerEvent.START_CLEANUP: ManagerStatus.CLEANING, + } + ), + ManagerStatus.SEGMENT_FINISHED: frozenmap( + { + ManagerEvent.START_POST_SEGMENT: ManagerStatus.RUNNING_POST_SEGMENT, + ManagerEvent.START_CLEANUP: ManagerStatus.CLEANING, + } + ), + ManagerStatus.RUNNING_POST_SEGMENT: frozenmap( + { + ManagerEvent.FINISH_POST_SEGMENT: ManagerStatus.POST_SEGMENT_FINISHED, + ManagerEvent.START_CLEANUP: ManagerStatus.CLEANING, + } + ), + ManagerStatus.POST_SEGMENT_FINISHED: frozenmap( + { + ManagerEvent.START_BC_WARPING: ManagerStatus.BC_WARPING, + ManagerEvent.START_RESAMPLING: ManagerStatus.RESAMPLING, + ManagerEvent.START_CLEANUP: ManagerStatus.CLEANING, + } + ), + ManagerStatus.BC_WARPING: frozenmap( + { + ManagerEvent.FINISH_BC_WARPING: ManagerStatus.POST_BC_WARPING, + ManagerEvent.START_CLEANUP: ManagerStatus.CLEANING, + } + ), + ManagerStatus.POST_BC_WARPING: frozenmap( + { + ManagerEvent.START_RESAMPLING: ManagerStatus.RESAMPLING, + ManagerEvent.START_CLEANUP: ManagerStatus.CLEANING, + } + ), + ManagerStatus.RESAMPLING: frozenmap( + { + ManagerEvent.FINISH_RESAMPLING: ManagerStatus.POST_RESAMPLING, + ManagerEvent.START_CLEANUP: ManagerStatus.CLEANING, + } + ), + ManagerStatus.POST_RESAMPLING: frozenmap( + { + ManagerEvent.GENERATE_REPORT: ManagerStatus.REPORT_GENERATION, + ManagerEvent.START_CLEANUP: ManagerStatus.CLEANING, + } + ), + ManagerStatus.REPORT_GENERATION: frozenmap( + { + ManagerEvent.START_REPORTING: ManagerStatus.REPORTING, + ManagerEvent.START_CLEANUP: ManagerStatus.CLEANING, + } + ), + ManagerStatus.REPORTING: frozenmap( + { + ManagerEvent.FINISH_REPORTING: ManagerStatus.POST_REPORTING, + ManagerEvent.START_CLEANUP: ManagerStatus.CLEANING, + } + ), + ManagerStatus.POST_REPORTING: frozenmap( + { + ManagerEvent.START_CYCLE_MONITORING: ManagerStatus.CYCLE_MONITORING, + ManagerEvent.FINISH_CYCLE: ManagerStatus.POST_CYCLE, + ManagerEvent.START_CLEANUP: ManagerStatus.CLEANING, + } + ), + ManagerStatus.CYCLE_MONITORING: frozenmap( + { + ManagerEvent.FINISH_CYCLE_MONITORING: ManagerStatus.POST_CYCLE_MONITORING, + ManagerEvent.START_CLEANUP: ManagerStatus.CLEANING, + } + ), + ManagerStatus.CYCLE_MONITORING: frozenmap( + { + ManagerEvent.FINISH_CYCLE_MONITORING: ManagerStatus.POST_CYCLE_MONITORING, + ManagerEvent.START_CLEANUP: ManagerStatus.CLEANING, + } + ), + ManagerStatus.POST_CYCLE_MONITORING: frozenmap( + { + ManagerEvent.FINISH_CYCLE: ManagerStatus.POST_CYCLE, + ManagerEvent.START_CLEANUP: ManagerStatus.CLEANING, + } + ), + ManagerStatus.POST_CYCLE: frozenmap( + { + ManagerEvent.START_CYCLE: ManagerStatus.RUNNING_CYCLE, + ManagerEvent.FINISH_SIMULATION: ManagerStatus.POST_SIMULATION, + ManagerEvent.START_CLEANUP: ManagerStatus.CLEANING, + } + ), + ManagerStatus.POST_SIMULATION: frozenmap( + { + ManagerEvent.START_CLEANUP: ManagerStatus.CLEANING, + } + ), + ManagerStatus.CLEANING: frozenmap( + { + ManagerEvent.FINISH_CLEANUP: ManagerStatus.CLEANUP_FINISHED, + } + ), + ManagerStatus.CLEANUP_FINISHED: frozenmap( + { + ManagerEvent.SHUTDOWN: ManagerStatus.FINISHED, + } + ), + ManagerStatus.FINISHED: frozenmap({}), + } +) + + +class ManagerStateMachineError(Exception): + pass + + +class ManagerStateTransitionError(ManagerStateMachineError): + """Indicates an error with the runner state machine transition.""" + + pass + + +class ManagerStateError(ManagerStateMachineError): + """Indicates an error relating to the current state of the runner.""" + + pass + + +@attrs.define +class ManagerStateMachine: + state: ManagerStatus = attrs.field( + default=ManagerStatus.CONSTRUCTED, + ) + + def validate_event(self, event: ManagerEvent) -> Literal[True]: + state_transitions = MANAGER_STATE_TRANSITION_TABLE[self.state] + if event not in state_transitions: + raise ManagerStateTransitionError( + f"Manager is in state {self.state.name}:{self.state.value}," + f" event {event.name}:{event.value} is not a valid." + f" Choose from: {set(state_transitions.keys())}" + ) + else: + return True + + def send(self, event: ManagerEvent) -> ManagerStatus: + + logger.info(f"Received event: {event.name}:{event.value}") + self.validate_event(event) + + state_transitions = MANAGER_STATE_TRANSITION_TABLE[self.state] + new_state = state_transitions[event] + + logger.info( + "Transitioning runner state:" + f" {self.state.name}:{self.state.value} -> {new_state.name}:{new_state.value}" + ) + self.state = new_state + + return self.state + + +ResamplerFactory = Factory[Resampler] +WorkMapperFactory = Factory[WorkMapper] +RunnerFactory = Factory[Runner] + +State_ = TypeVar("State_") +RunSegmentData_ = TypeVar("RunSegmentData_", bound=RunSegmentData, covariant=True) -class Manager: +class Manager(Generic[State_]): """The class that coordinates wepy simulations. The Manager class is the lynchpin of wepy simulations and is where @@ -93,7 +380,25 @@ class Manager: """ - REPORT_ITEM_KEYS: Final = ( + state_machine: ManagerStateMachine + init_walkers: list[Walker[State_]] + n_init_walkers: int + runner_factory: RunnerFactory + resampler_factory: ResamplerFactory + boundary_conditions: BoundaryConditions | None + work_mapper_factory: type[WorkMapper] + reporters: list[Reporter] + monitor: Monitor | None + + num_cores: int + + _runner: Runner | None + _resampler: Resampler | None + _work_mapper: WorkMapper | None + + _last_report: CycleReportDict | None + + REPORT_ITEM_KEYS: Final[tuple[str, ...]] = ( "cycle_idx", "n_segment_steps", "new_walkers", @@ -115,31 +420,31 @@ class Manager: def __init__( self, - init_walkers: list[Walker], - runner: Runner | None = None, - work_mapper=None, - resampler: Resampler | None = None, + init_walkers: list[Walker[State_]], + runner_factory: RunnerFactory, + resampler_factory: ResamplerFactory, + work_mapper_factory: WorkMapperFactory | None = None, boundary_conditions: BoundaryConditions | None = None, - reporters: Reporter | None = None, - sim_monitor=None, - ): + reporters: list[Reporter] | None = None, + sim_monitor: Monitor | None = None, + num_cores: int | None = None, + ) -> None: """Constructor for Manager. - Arguments + Arguments: --------- - init_walkers : list of walkers The list of the initial walkers that will be run. runner : object implementing the Runner interface The runner to be used for propagating sampling segments of walkers. - work_mapper : object implementing the WorkMapper interface - The object that will be used to perform a set of runner - segments in a cycle. + work_mapper_factory : Class for a work mapper, will be + instantiated by simulation manager. If None will default + to a serial mapper. - resampler : object implementing the Resampler interface - The resampler to be used in the simulation + resampler_factory : Callable that instantiates a stateful + Resampler used in the simulation. boundary_conditions : object implementing BoundaryCondition interface, optional The boundary conditions to apply to walkers @@ -147,59 +452,219 @@ def __init__( reporters : list of objects implenting the Reporter interface, optional Reporters to be used. You should provide these if you want to keep data. - Warnings - -------- + sim_monitor: Monitoring object. Can be used to report metrics + outside of simulation data flow. + Warnings: + -------- While reporters are strictly optional, you probably want to provide some because the simulation manager provides no utilities for saving data from the simulations except for the walkers at the end of a cycle or simulation. - See Also + See Also: -------- wepy.reporter.hdf5 : The standard reporter for molecular simulations in wepy. - wepy.orchestration.orchestrator.Orchestrator : for running simulations with - checkpointing, restarting, reporter localization, and configuration hotswapping - with command line interface. - """ - self.init_walkers = init_walkers + self.init_walkers = copy.deepcopy(init_walkers) self.n_init_walkers = len(init_walkers) # the runner is the object that runs dynamics - self.runner = runner + self.runner_factory = runner_factory # the resampler - self.resampler = resampler + self.resampler_factory = resampler_factory # object for boundary conditions - self.boundary_conditions = boundary_conditions + self.boundary_conditions = copy.deepcopy(boundary_conditions) # the method for writing output if reporters is None: self.reporters = [] else: - self.reporters = reporters + self.reporters = copy.deepcopy(reporters) - if work_mapper is None: - self.work_mapper = Mapper() + if work_mapper_factory is None: + self.work_mapper_factory = SerialMapper else: - self.work_mapper = work_mapper + self.work_mapper_factory = work_mapper_factory ## Monitor - self.monitor = None + self.monitor = sim_monitor + + # OPT,IDEA: figure out a general resource allocation scheme + # that lets you customize more than just the number of cores + # and lets you configure per component how many resources they + # can have during the simulation. Currently we just tell each + # component what we have. This isn't too bad as nothing else + # is CPU bound and they run sequentially so they wouldn't be + # competing for them at runtime. + + # figure out how many cores we have at our disposal if not + # already given + if num_cores is None: + self.num_cores = len(psutil.Process().cpu_affinity()) + + else: + self.num_cores = num_cores # used to have a record of the last report for the simulation # monitor without breaking the API. Ugly but I don't want to # break it and no one cares about this anyhow self._last_report = None + # initialize the uncreated attributes + self._runner = None + self._resampler = None + self._work_mapper = None + + self.state_machine = ManagerStateMachine() + + @property + def status(self) -> ManagerStatus: + return self.state_machine.state + + def init( + self, + continue_run: int | None = None, + ) -> None: + """Initialize wepy configuration components for use at runtime. + + This `init` method is different than the constructor + `__init__` method and instead calls the special `init` method + on all wepy components (runner, resampler, boundary + conditions, and reporters) at runtime. + + This allows for a things that need to be done at runtime before a + simulation begins, e.g. opening files, that you don't want done at + construction time. + + It calls the `init` methods on: + + - work_mapper + - reporters + + Passes the segment_func of the runner and the number of + workers to the work_mapper. + + Passes the following things to each reporter `init` method: + + - init_walkers + - runner + - resampler + - boundary_conditions + - work_mapper + - reporters + - continue_run + + Parameters + ---------- + continue_run : int + Index of a run this one is continuing. + (Default value = None) + + """ + self.state_machine.send(ManagerEvent.START_INITIALIZATION) + + logger.info("Running sim_manager.init hooks") + + logger.info("Generating runner from factory") + self._runner = self.runner_factory() + logger.info("Running runner.init hook") + self._runner.init() + + # initialize resampler + self._resampler = self.resampler_factory(num_cores=self.num_cores) + + # initialize the monitoring object + + # TODO: do we need to supply the port here? I don't want to + # add it to the interface... Should be pre-parametrized + if self.monitor is not None: + logger.info("Initializing monitoring") + self.monitor.init() + + # initialize the work_mapper with the function it will be + # mapping and the number of workers, this may include things like starting processes + # etc. + logger.info("Instantiating work mapper") + self._work_mapper = self.work_mapper_factory() + logger.info("Running WorkMapper.init hook") + self._work_mapper.init() + logger.info("Finished WorkMapper.init hook") + + # init the reporter + for reporter in self.reporters: + logger.info(f"Initializing reporter: {reporter}") + reporter.init( + init_walkers=self.init_walkers, + runner=self._runner, + resampler=self._resampler, + boundary_conditions=self.boundary_conditions, + work_mapper=self._work_mapper, + reporters=self.reporters, + continue_run=continue_run, + ) + + logger.info("Finished sim_manager initialization") + self.state_machine.send(ManagerEvent.FINISH_INITIALIZATION) + + def cleanup(self) -> None: + """Perform cleanup actions for wepy configuration components. + + Allow components to perform actions before ending the main + simulation manager process. + + Calls the `cleanup` method on: + + - work_mapper + - reporters + + Passes nothing to the work mapper. + + Passes the following to each reporter: + + - runner + - work_mapper + - resampler + - boundary_conditions + - reporters + + """ + + self.state_machine.send(ManagerEvent.START_CLEANUP) + + if self.monitor is not None: + logger.info("Cleaning up monitoring") + self.monitor.cleanup() + + # cleanup the mapper + logger.info("Cleaning up work_mapper") + self._work_mapper.cleanup() + + # cleanup things associated with the reporter + for reporter in self.reporters: + logger.info(f"Cleaning up reporter: {reporter}") + reporter.cleanup( + init_walkers=self.init_walkers, + runner=self._runner, + work_mapper=self._work_mapper, + resampler=self._resampler, + boundary_conditions=self.boundary_conditions, + reporters=self.reporters, + ) + + self.state_machine.send(ManagerEvent.FINISH_CLEANUP) + def run_segment( self, - walkers: list[Walker], + states: list[State_], segment_length: int, cycle_idx: int, - ) -> list[Walker]: + ) -> tuple[ + list[State_], + list[RunSegmentData_], + ]: """Run a time segment for all walkers using the available workers. Maps the work for running each segment for each walker using @@ -209,9 +674,7 @@ def run_segment( Parameters ---------- - walkers : list[Walker] - List of walkers - + states segment_length : int Number of steps to run in each segment. @@ -220,46 +683,62 @@ def run_segment( Returns ------- - new_walkers : list[Walker] The walkers after the segment of sampling simulation. """ - num_walkers = len(walkers) - - logger.info("Starting segment") + self.state_machine.send(ManagerEvent.START_SEGMENT) + segment_lengths = [segment_length for i in range(len(states))] try: - new_walkers = list( - self.work_mapper.map( - # args, which must be supported by the map function - walkers, - (segment_length for i in range(num_walkers)), - # kwargs which are optionally recognized by the map function - cycle_idx=(cycle_idx for i in range(num_walkers)), - walker_idx=(walker_idx for walker_idx in range(num_walkers)), + map_results = list( + self._work_mapper.map( + self._runner.run_segment, + states, + segment_lengths, ) ) except Exception as exception: + logger.info( + "Exception encountered in segment calculations. Cleaning up before raising." + ) # get the errors from the work mapper error queue self.cleanup() + logger.info("Failure cleanup complete, reraising error.") + # report on all of the errors that occured raise exception - logger.info("Ending segment") + self.state_machine.send(ManagerEvent.FINISH_SEGMENT) + + # transpose + new_states, segments_data = zip(*map_results, strict=True) + + return new_states, segments_data + + def pre_segment(self) -> None: + + self.state_machine.send(ManagerEvent.START_PRE_SEGMENT) + self._runner.pre_cycle() + self.state_machine.send(ManagerEvent.FINISH_PRE_SEGMENT) - return new_walkers + def post_segment(self, segments_data: RunSegmentData_) -> None: + self.state_machine.send(ManagerEvent.START_POST_SEGMENT) + self._runner.post_cycle(segments_data) + self.state_machine.send(ManagerEvent.FINISH_POST_SEGMENT) def run_cycle( self, - walkers: list[Walker], + walkers: list[Walker[State_]], n_segment_steps: int, cycle_idx: int, runner_opts=None, - ) -> tuple[list[Walker], list[Runner | BoundaryConditions | Resampler]]: - # TODO: Replace list of Runner | BoundaryConditions | Resampler with tuple + ) -> tuple[ + list[Walker[State_]], + tuple[Runner, BoundaryConditions | None, Resampler], + ]: """Run a full cycle of weighted ensemble simulation using each component. @@ -312,7 +791,6 @@ def run_cycle( Returns ------- - new_walkers : list of walkers The resulting walkers of the cycle @@ -327,68 +805,41 @@ def run_cycle( """ - # this one is called to just easily be able to catch all the - # errors from it so we can cleanup if an error is caught - - return self._run_cycle( - walkers, - n_segment_steps, - cycle_idx, - runner_opts=runner_opts, - ) - - def _run_cycle( - self, - walkers, - n_segment_steps, - cycle_idx, - runner_opts=None, - ): - """See run_cycle.""" - - if runner_opts is None: - runner_opts = {} - - if self.runner is None: - raise RuntimeError(f"'runner' is None") + self.state_machine.send(ManagerEvent.START_CYCLE) # run the runner pre-cycle hook start = time.time() - - self.runner.pre_cycle( - walkers=walkers, - n_segment_steps=n_segment_steps, - cycle_idx=cycle_idx, - **runner_opts, - ) - + self.pre_segment() end = time.time() - runner_precycle_time = end - start + presegment_time = end - start + logger.info(f"Presegment time: {presegment_time}") # run the segment start = time.time() - new_walkers = self.run_segment(walkers, n_segment_steps, cycle_idx) + new_states, segments_data = self.run_segment( + [walker.state for walker in walkers], + n_segment_steps, + cycle_idx, + ) + + new_walkers = [ + Walker(state=new_state, weight=walker.weight) + for walker, new_state in zip(walkers, new_states, strict=True) + ] end = time.time() sim_manager_segment_time = end - start + logger.info(f"Segment duration: {sim_manager_segment_time}") - if hasattr(self.runner, "_last_cycle_segments_split_times"): - runner_splits = deepcopy(self.runner._last_cycle_segments_split_times) - - else: - runner_splits = None - - logger.info("Starting post cycle") # run post-cycle hook start = time.time() - self.runner.post_cycle() + self.post_segment(segments_data) end = time.time() - runner_postcycle_time = end - start - - logger.info("End cycle {}".format(cycle_idx)) + post_segment_time = end - start + logger.info(f"Post segment duration: {post_segment_time}") # boundary conditions should be optional; @@ -400,12 +851,16 @@ def _run_cycle( progress_data = {} bc_time = 0.0 if self.boundary_conditions is not None: + logger.info("Boundary conditions were provided, applying.") + + self.state_machine.send(ManagerEvent.START_BC_WARPING) + # apply rules of boundary conditions and warp walkers through space start = time.time() - logger.info("Starting boundary conditions") bc_results = self.boundary_conditions.warp_walkers(new_walkers, cycle_idx) end = time.time() bc_time = end - start + self.state_machine.send(ManagerEvent.FINISH_BC_WARPING) # warping results warped_walkers = bc_results[0] @@ -413,302 +868,116 @@ def _run_cycle( bc_data = bc_results[2] progress_data = bc_results[3] + logger.info(f"Boundary condition duration: {bc_time}") + if len(warp_data) > 0: - logger.info("Returned warp record in cycle {}".format(cycle_idx)) + logger.info(f"Returned warp record in cycle {cycle_idx}") # resample walkers + self.state_machine.send(ManagerEvent.START_RESAMPLING) start = time.time() - logger.info("Starting resampler") - resampling_results = self.resampler.resample(warped_walkers) + resampling_results = self._resampler.resample(warped_walkers) + + self.state_machine.send(ManagerEvent.FINISH_RESAMPLING) end = time.time() resampling_time = end - start + logger.info(f"Resampling duration: {resampling_time}") + + self.state_machine.send(ManagerEvent.GENERATE_REPORT) + resampled_walkers = resampling_results[0] resampling_data = resampling_results[1] resampler_data = resampling_results[2] - # log the weights of the walkers after resampling - - # DEBUG: commenting this out to make mocking easier. But really I - # don't even care if it stays. as its mostly noise. - # result_template_str = "|".join(["{:^5}" for i in range(self.n_init_walkers + 1)]) - # walker_weight_str = result_template_str.format("weight", - # *[round(walker.weight, 3) for walker in resampled_walkers]) - # logger.info(walker_weight_str) - # make a dictionary of all the results that will be reported seg_times = {} sampling_time = None - if hasattr(self.work_mapper, "worker_segment_times"): - seg_times = deepcopy(self.work_mapper.worker_segment_times) + if (seg_times := self._work_mapper.get_worker_segment_times()) is not None: + logger.info("Segment timings provided by work mapper, recording.") # count up the total sampling time from the segments - sampling_time = 0 + sampling_time = 0.0 for ( worker_id, segments_times, - ) in self.work_mapper.worker_segment_times.items(): + ) in seg_times.items(): for seg_time in segments_times: sampling_time += seg_time # calculate the overhead for logging sim_manager_segment_overhead_time = sim_manager_segment_time - sampling_time - # logger.info( - # "Runner time = {}; Sampling = ({}); Overhead = ({})".format( - # runner_time, sampling_time, overhead_time)) + logger.info( + f"Simulation manager overhead time: {sim_manager_segment_overhead_time}" + ) else: + logger.info("Worker segment times not provided") sim_manager_segment_overhead_time = 0.0 - # logger.info("No worker segment times given") - # logger.info("Runner time = {}".format(runner_time)) - - report = { - "cycle_idx": cycle_idx, - "new_walkers": new_walkers, - "warp_data": warp_data, - "bc_data": bc_data, - "progress_data": progress_data, - "resampling_data": resampling_data, - "resampler_data": resampler_data, - "n_segment_steps": n_segment_steps, - "resampled_walkers": resampled_walkers, - # timings - "runner_precycle_time": runner_precycle_time, - "runner_postcycle_time": runner_postcycle_time, - "sim_manager_segment_overhead_time": sim_manager_segment_overhead_time, - "runner_splits_time": runner_splits, - "worker_segment_times": seg_times, - "cycle_sim_manager_segment_time": sim_manager_segment_time, - "cycle_runner_time": sim_manager_segment_time, - "cycle_bc_time": bc_time, - "cycle_resampling_time": resampling_time, - } + + report = CycleReportDict( + { + "cycle_idx": cycle_idx, + "new_walkers": new_walkers, + "warp_data": warp_data, + "bc_data": bc_data, + "progress_data": progress_data, + "resampling_data": resampling_data, + "resampler_data": resampler_data, + "n_segment_steps": n_segment_steps, + "resampled_walkers": resampled_walkers, + # timings + "runner_precycle_time": presegment_time, + "runner_postcycle_time": post_segment_time, + "sim_manager_segment_overhead_time": sim_manager_segment_overhead_time, + # TODO: fix this + "runner_splits_time": segments_data, + "worker_segment_times": seg_times, + "cycle_sim_manager_segment_time": sim_manager_segment_time, + "cycle_runner_time": sim_manager_segment_time, + "cycle_bc_time": bc_time, + "cycle_resampling_time": resampling_time, + } + ) self._last_report = report - # check that all of the keys that are specified for this sim - # manager are present - assert all( - [True if rep_key in report else False for rep_key in self.REPORT_ITEM_KEYS] - ) + self.state_machine.send(ManagerEvent.START_REPORTING) - logger.info("Starting reporting") # report results to the reporters for reporter in self.reporters: logger.info(f"Reporting with reporter: {reporter}") reporter.report(**report) - # prepare resampled walkers for running new state changes - walkers = resampled_walkers + self.state_machine.send(ManagerEvent.FINISH_REPORTING) # run the simulation monitor to get metrics on everything if self.monitor is not None: - logger.info("Running monitoring") - self.monitor.cycle_monitor(self, walkers) - - # we also return a list of the "filters" which are the - # classes that are run on the initial walkers to produce - # the final walkers. THis is to satisfy a future looking - # interface in which the order and components of these - # filters are completely parametrizable. This may or may - # not be implemented in a future release of wepy but this - # interface is assumed by the orchestration classes for - # making snapshots of the simulations. The receiver of - # these should perform the copy to make sure they aren't - # mutated. We don't do this here for efficiency. - filters = [self.runner, self.boundary_conditions, self.resampler] - - logger.info("Done: returning walkers") - return walkers, filters + self.state_machine.send(ManagerEvent.START_CYCLE_MONITORING) + self.monitor.cycle_monitor(self, resampled_walkers) + self.state_machine.send(ManagerEvent.FINISH_CYCLE_MONITORING) - def init( - self, - num_workers=None, - continue_run=None, - ): - """Initialize wepy configuration components for use at runtime. + self.state_machine.send(ManagerEvent.FINISH_CYCLE) - This `init` method is different than the constructor - `__init__` method and instead calls the special `init` method - on all wepy components (runner, resampler, boundary - conditions, and reporters) at runtime. - - This allows for a things that need to be done at runtime before a - simulation begins, e.g. opening files, that you don't want done at - construction time. - - It calls the `init` methods on: - - - work_mapper - - reporters - - Passes the segment_func of the runner and the number of - workers to the work_mapper. - - Passes the following things to each reporter `init` method: - - - init_walkers - - runner - - resampler - - boundary_conditions - - work_mapper - - reporters - - continue_run - - Parameters - ---------- - num_workers : int - The number of workers to use in the work mapper. - (Default value = None) - continue_run : int - Index of a run this one is continuing. - (Default value = None) - - """ - - logger.info("Starting simulation") - - # initialize the monitoring object - - # TODO: do we need to supply the port here? I don't want to - # add it to the interface... Should be pre-parametrized - if self.monitor is not None: - self.monitor.init() - - # initialize the work_mapper with the function it will be - # mapping and the number of workers, this may include things like starting processes - # etc. - self.work_mapper.init( - segment_func=self.runner.run_segment, - num_workers=num_workers, + return resampled_walkers, ( + self._runner, + self.boundary_conditions, + self._resampler, ) - # init the reporter - for reporter in self.reporters: - reporter.init( - init_walkers=self.init_walkers, - runner=self.runner, - resampler=self.resampler, - boundary_conditions=self.boundary_conditions, - work_mapper=self.work_mapper, - reporters=self.reporters, - continue_run=continue_run, - ) - - def cleanup(self): - """Perform cleanup actions for wepy configuration components. - - Allow components to perform actions before ending the main - simulation manager process. - - Calls the `cleanup` method on: - - - work_mapper - - reporters - - Passes nothing to the work mapper. - - Passes the following to each reporter: - - - runner - - work_mapper - - resampler - - boundary_conditions - - reporters - - """ - - if self.monitor is not None: - self.monitor.cleanup() - - # cleanup the mapper - self.work_mapper.cleanup() - - # cleanup things associated with the reporter - for reporter in self.reporters: - reporter.cleanup( - runner=self.runner, - work_mapper=self.work_mapper, - resampler=self.resampler, - boundary_conditions=self.boundary_conditions, - reporters=self.reporters, - ) - - def run_simulation_by_time(self, run_time, segments_length, num_workers=None): - """Run a simulation for a certain amount of time. - - This starts timing as soon as this is called. If the time - before running a new cycle is greater than the runtime the run - will exit after cleaning up. Once a cycle is started it may - also run over the wall time. - - All this does is provide a run idx to the reporters, which is - the run that is intended to be continued. This simulation - manager knows no details and is left up to the reporters to - handle this appropriately. - - Parameters - ---------- - run_time : float - The time to run in seconds. - - segments_length : int - The number of steps for each runner segment. - - num_workers : int - The number of workers to use for the work mapper. - (Default value = None) - - Returns - ------- - new_walkers : list of walkers - The resulting walkers of the cycle - - sim_components : list - Deep copies of the runner, resampler, and boundary - conditions objects at the end of the cycle. - - See Also - -------- - wepy.orchestration.orchestrator.Orchestrator : for running simulations with - checkpointing, restarting, reporter localization, and configuration hotswapping - with command line interface. - - """ - start_time = time.time() - self.init(num_workers=num_workers) - cycle_idx = 0 - walkers = self.init_walkers - while time.time() - start_time < run_time: - logger.info( - "starting cycle {} at time {}".format( - cycle_idx, time.time() - start_time - ) - ) - - walkers, filters = self.run_cycle(walkers, segments_length, cycle_idx) - - logger.info( - "ending cycle {} at time {}".format(cycle_idx, time.time() - start_time) - ) - - cycle_idx += 1 - - logger.info("Cleaning up simulation") - self.cleanup() - - return walkers, deepcopy(filters) - def run_simulation( self, - n_cycles, - segment_lengths, - num_workers=None, - ): + n_cycles: int, + segment_lengths: int, + continue_run_idx: int | None = None, + ) -> tuple[ + list[Walker[State_]], + tuple[Runner, BoundaryConditions, Resampler], + ]: """Run a simulation for an explicit number of cycles. Parameters @@ -719,9 +988,7 @@ def run_simulation( segment_lengths : int The number of steps for each runner segment. - num_workers : int - The number of workers to use for the work mapper. - (Default value = None) + continue_run_idx: Index of the run you are continuing, optional. Returns @@ -733,99 +1000,49 @@ def run_simulation( Deep copies of the runner, boundary conditions, and resampler objects at the end of the simulation. - See Also - -------- - wepy.orchestration.orchestrator.Orchestrator : for running simulations with - checkpointing, restarting, reporter localization, and configuration hotswapping - with command line interface. - """ - self.init(num_workers=num_workers) + self.state_machine.send(ManagerEvent.START_PRE_SIM) if type(segment_lengths) == int: + logger.info( + "Single number of steps provided for simulation, using this for all cycles." + ) segment_lengths = [segment_lengths for _ in range(n_cycles)] walkers = self.init_walkers - # the main cycle loop - for cycle_idx in range(n_cycles): - walkers, filters = self.run_cycle( - walkers, segment_lengths[cycle_idx], cycle_idx - ) - - # run the simulation monitor to get metrics on everything - if self.monitor is not None: - self.monitor.cycle_monitor(self, walkers) - - self.cleanup() - - return walkers, deepcopy(filters) - - def continue_run_simulation( - self, - run_idx, - n_cycles, - segment_lengths, - num_workers=None, - ): - """Continue a simulation. All this does is provide a run idx to the - reporters, which is the run that is intended to be - continued. This simulation manager knows no details and is - left up to the reporters to handle this appropriately. - - Parameters - ---------- - run_idx : int - Index of the run you are continuing. - - n_cycles : int - Number of cycles to perform. - - segment_lengths : int - The number of steps for each runner segment. - - num_workers : int - The number of workers to use for the work mapper. - (Default value = None) - - Returns - ------- - new_walkers : list of walkers - The resulting walkers of the cycle - - sim_components : list - Deep copies of the runner, resampler, and boundary - conditions objects at the end of the cycle. - - See Also - -------- - wepy.orchestration.orchestrator.Orchestrator : for running simulations with - checkpointing, restarting, reporter localization, and configuration hotswapping - with command line interface. - - """ + self.init(continue_run=continue_run_idx) - self.init(num_workers=num_workers, continue_run=run_idx) - - walkers = self.init_walkers + self.state_machine.send(ManagerEvent.START_SIM) # the main cycle loop for cycle_idx in range(n_cycles): + + logger.info(f"Running cycle: {cycle_idx}") walkers, filters = self.run_cycle( - walkers, segment_lengths[cycle_idx], cycle_idx + walkers, + segment_lengths[cycle_idx], + cycle_idx, ) + logger.info(f"Finished running cycle: {cycle_idx}") - # run the simulation monitor to get metrics on everything - if self.monitor is not None: - self.monitor.cycle_monitor(self, walkers) + self.state_machine.send(ManagerEvent.FINISH_SIMULATION) self.cleanup() - return walkers, filters + self.state_machine.send(ManagerEvent.SHUTDOWN) + + return walkers, copy.deepcopy(tuple(filters)) - def continue_run_simulation_by_time( - self, run_idx, run_time, segments_length, num_workers=None - ): + def run_simulation_by_time( + self, + run_time: int, + segments_length: int, + continue_run_idx: int | None = None, + ) -> tuple[ + list[Walker[State_]], + tuple[Runner, BoundaryConditions | None, Resampler], + ]: """Continue a simulation with a separate run by time. This starts timing as soon as this is called. If the time @@ -838,28 +1055,21 @@ def continue_run_simulation_by_time( manager knows no details and is left up to the reporters to handle this appropriately. - Parameters - ---------- - run_idx : int - Deep copies of the runner, resampler, and boundary - conditions objects at the end of the cycle. - - - See Also - -------- - wepy.orchestration.orchestrator.Orchestrator : for running simulations with - checkpointing, restarting, reporter localization, and configuration hotswapping - with command line interface. - """ + self.state_machine.send(ManagerEvent.START_PRE_SIM) + start_time = time.time() + logger.info(f"Simulation start time: {start_time}") + + self.init(continue_run=continue_run_idx) - self.init(num_workers=num_workers, continue_run=run_idx) + self.state_machine.send(ManagerEvent.START_SIM) cycle_idx = 0 walkers = self.init_walkers - while time.time() - start_time < run_time: + # run until time is elapsed, but guarantee to run at least one cycle + while time.time() - start_time < run_time or cycle_idx < 1: logger.info( "starting cycle {} at time {}".format( cycle_idx, time.time() - start_time @@ -872,12 +1082,14 @@ def continue_run_simulation_by_time( "ending cycle {} at time {}".format(cycle_idx, time.time() - start_time) ) - # run the simulation monitor to get metrics on everything - if self.monitor is not None: - self.monitor.cycle_monitor(self, walkers) - cycle_idx += 1 + self.state_machine.send(ManagerEvent.FINISH_SIMULATION) + + logger.info("Running simulation cleanup") self.cleanup() + logger.info("Simulation cleanup complete") + + self.state_machine.send(ManagerEvent.SHUTDOWN) return walkers, filters diff --git a/src/wepy/storage/__init__.py b/src/wepy/storage/__init__.py new file mode 100644 index 00000000..509d9132 --- /dev/null +++ b/src/wepy/storage/__init__.py @@ -0,0 +1 @@ +"""Storage protocol and methods.""" diff --git a/src/wepy/storage/protocol.py b/src/wepy/storage/protocol.py new file mode 100644 index 00000000..30e41e17 --- /dev/null +++ b/src/wepy/storage/protocol.py @@ -0,0 +1,152 @@ +"""Defines a generic protocol for storage of data. + +This should only define the interfaces between the generating +components (resamplers, runner, boundary conditions, sim_manager) and +the reporting and storage backends should utilize. + +""" + +# Standard Library +from collections.abc import Mapping +from typing import Literal, Required, TypedDict, Union + +# Third Party Library +import attrs +import numpy as np +from numpy.typing import NDArray + +RecordValueDtype = int | float | NDArray + +Record = Mapping[str, RecordValueDtype] + + +@attrs.define +class RunRecord: + cycle_idx: Required[int] + record: Record + + +# Numpy-style shapes of all fields produced in records. +# +# There should be the same number of elements as there are in the +# corresponding 'FIELDS' class constant. +# +# Each entry should either be: +# +# A. A tuple of ints that specify the shape of the field element +# array. +# +# B. Ellipsis, indicating that the field is variable length and +# limited to being a rank one array (e.g. (3,) or (1,)). +# Note that the shapes must be tuple and not simple integers for rank-1 +# arrays. +# +# Option B will result in the special h5py datatype 'vlen' and +# should not be used for large datasets for efficiency reasons. +RecordFieldShape = tuple[int, ...] +RecordFieldShapeSpec = Union[ + RecordFieldShape, + Literal[Ellipsis], +] + + +# There should be the same number of elements as there are in the +# corresponding 'FIELDS' class constant. +# +# Each entry should either be: +# +# A. A `numpy.dtype` object. + +RecordFieldDtype = Union[ + np.int16, + np.int32, + np.int64, + np.uint8, + np.uint16, + np.uint32, + np.uint64, + np.float16, + np.float32, + np.float64, + bool, +] + + +RecordFieldSpec = tuple[ + str, # name + RecordFieldShapeSpec, # shape + RecordFieldDtype, # dtype +] + + +# Specific record types guaranteed + +DECISION_RECORD_FIELDS = frozenset( + { + "decision_id", + "target_idxs", + } +) + + +class DecisionRecordUnstruct(TypedDict, total=False): + decision_id: Required[int] + target_idxs: Required[tuple[int, ...]] + + +RESAMPLING_RECORD_FIELDS = frozenset( + { + "step_idx", + "walker_idx", + "decision_id", + "target_idxs", + } +) + + +@attrs.define +class ResamplingRecord: + step_idx: int + walker_idx: int + decision_id: int + target_idxs: tuple[int, ...] + + +class ResamplingRecordUnstruct(TypedDict, total=False): + step_idx: Required[int] + walker_idx: Required[int] + decision_id: Required[int] + target_idxs: Required[tuple[int, ...]] + + +WARPING_RECORD_FIELDS = frozenset( + { + "walker_idx", + "target_idx", + "weight", + } +) + + +@attrs.define +class WarpRecord: + walker_idx: int + target_idx: int + weight: float + + +class WarpRecordUnstruct(TypedDict, total=False): + walker_idx: Required[int] + target_idx: Required[int] + weight: Required[float] + + +# Trace types used in data access +# (traj_idx, cycle_idx) +ContigWalkerTrace = list[tuple[int, int], ...] + +# (run_idx, traj_idx, cycle_idx) +RunTrace = list[tuple[int, int, int], ...] + +# (run_idx, cycle_idx) +ContigTrace = list[tuple[int, int], ...] diff --git a/src/wepy/typing.py b/src/wepy/typing.py new file mode 100644 index 00000000..b9526483 --- /dev/null +++ b/src/wepy/typing.py @@ -0,0 +1,29 @@ +"""Some type helpers. + +Provides some Numpy array specifiers useful in Annotated that won't +actually be checked in a checker. + +""" + +# Standard Library +from typing import Annotated, Literal, Union + +# Third Party Library +import attrs +import numpy as np +from numpy.typing import NDArray + + +@attrs.define +class Shape: + dims: tuple[int | Literal[Ellipsis], ...] + + +IdxArray = Annotated[ + NDArray[np.integer], + Shape((...,)), +] +Idxs = Union[ + list[int], + IdxArray, +] diff --git a/src/wepy/util/__init__.py b/src/wepy/util/__init__.py index dc17a1ca..ae051ddc 100644 --- a/src/wepy/util/__init__.py +++ b/src/wepy/util/__init__.py @@ -2,7 +2,6 @@ Warnings -------- - Deprecation warning! Try not to rely on these functions as part of your stable code. These diff --git a/src/wepy/util/attrs.py b/src/wepy/util/attrs.py new file mode 100644 index 00000000..c3f315e9 --- /dev/null +++ b/src/wepy/util/attrs.py @@ -0,0 +1,30 @@ +# Standard Library +from collections.abc import Mapping +from typing import Any, Iterator + +# Third Party Library +import attrs + +# First Party Library +from wepy.missing import MISSING + + +class AttrsMappingMixin(Mapping[str, object]): + """A convenient mixin for implementing the WalkerState interface + for attrs classes. + """ + + def __len__(self) -> int: + + return len(attrs.fields(type(self))) + + def __getitem__(self, key: str) -> Any: + if (value := getattr(self, key, MISSING)) is MISSING: + raise KeyError(f"'key' '{key}' not found") + else: + return value + + def __iter__(self) -> Iterator[str]: + + for field in attrs.fields(type(self)): + yield field.name diff --git a/src/wepy/util/json_top.py b/src/wepy/util/json_top.py index d795de10..d103e3cc 100644 --- a/src/wepy/util/json_top.py +++ b/src/wepy/util/json_top.py @@ -17,13 +17,11 @@ def json_top_chain_fields(json_topology): Parameters ---------- - json_topology : str JSON format topology Returns ------- - chain_cols : dict of str: list """ @@ -44,13 +42,11 @@ def json_top_chain_df(json_topology): Parameters ---------- - json_topology : str JSON format topology Returns ------- - chain_df : pandas.DataFrame """ @@ -63,13 +59,11 @@ def json_top_residue_fields(json_topology): Parameters ---------- - json_topology : str JSON format topology Returns ------- - residue_cols : dict of str: list """ @@ -93,13 +87,11 @@ def json_top_residue_df(json_topology): Parameters ---------- - json_topology : str JSON format topology Returns ------- - residue_df : pandas.DataFrame """ @@ -112,13 +104,11 @@ def json_top_atom_fields(json_topology): Parameters ---------- - json_topology : str JSON format topology Returns ------- - atom_cols : dict of str: list """ @@ -145,13 +135,11 @@ def json_top_atom_df(json_topology): Parameters ---------- - json_topology : str JSON format topology Returns ------- - atoms_df : pandas.DataFrame """ @@ -193,7 +181,6 @@ def json_top_subset(json_str, atom_idxs): Parameters ---------- - json_str : str A string of valid JSON in the format of JSON used in WepyHDF5 and mdtraj HDF5 format. @@ -203,7 +190,6 @@ def json_top_subset(json_str, atom_idxs): Returns ------- - subset_json_str : str JSON string of the subset of atoms. Ordering preserved. @@ -284,7 +270,6 @@ def json_top_subset(json_str, atom_idxs): residue_idx_map[old_res_idx] = new_res_idx_counter new_res_idx_counter += 1 - # do the same but for the chain old_chain_idx = res_chain_idxs[old_res_idx] @@ -332,9 +317,9 @@ def json_top_subset(json_str, atom_idxs): new_chain_res_idx = chain_res_idx_map[old_res_idx] atom_data["index"] = new_atom_idx - top_subset["chains"][new_chain_idx]["residues"][new_chain_res_idx]["atoms"].append( - atom_data - ) + top_subset["chains"][new_chain_idx]["residues"][new_chain_res_idx][ + "atoms" + ].append(atom_data) # then translate the atom indices in the bonds new_bonds = [] diff --git a/src/wepy/util/kv.py b/src/wepy/util/kv.py deleted file mode 100644 index d8e36408..00000000 --- a/src/wepy/util/kv.py +++ /dev/null @@ -1,436 +0,0 @@ -"""Implement a key-value store on top of sqlite3 database.""" - -# Copyright (c) 2012, Alex Morega -# All rights reserved. - -# Redistribution and use in source and binary forms, with or without -# modification, are permitted provided that the following conditions are -# met: - -# * Redistributions of source code must retain the above copyright notice, -# this list of conditions and the following disclaimer. - -# * Redistributions in binary form must reproduce the above copyright -# notice, this list of conditions and the following disclaimer in the -# documentation and/or other materials provided with the distribution. - -# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS -# IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED -# TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A -# PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT -# HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, -# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED -# TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR -# PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF -# LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING -# NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS -# SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - -# Software copied and modified heavily from this source - -# Standard Library -import logging - -logger = logging.getLogger(__name__) -# Standard Library -import os -import os.path as osp -import sqlite3 -from collections.abc import MutableMapping -from contextlib import contextmanager - -# mapping of the modes we support and the modes that SQLite provides -# KV mode -> sqlite3 mode -MODE_MAPPING = ( - ("r", "ro"), - ("r+", "rw"), - ("a", "rwc"), - ("x", None), - ("w", None), - ("w-", None), -) - -# modes that sqlite3 itself doesn't support and have to manual -# processing for -TRUNCATE_MODES = ("w",) -FAIL_IF_EXISTS_CREATE_MODES = ("w-", "x") - -SQLITE3_URI_TEMPLATE = "{protocol}:{url}" -SQLITE3_QUERY_URI_TEMPLATE = "{protocol}:{url}?{query}" - -SQLITE3_QUERY_JOIN_CHAR = "&" - -SQLITE3_INMEMORY_URI = "file::memory:?cache=shared" - - -# the default types for the values that is checked, is bytes -DEFAULT_VALUE_TYPES = (bytes, bytearray) - - -def gen_uri(db_url, mode_spec): - # if the db url is the in memory special string or None or the - # :memory: identifier, use the full in-memory URI - if db_url == SQLITE3_INMEMORY_URI or db_url is None or db_url == ":memory:": - db_uri = SQLITE3_INMEMORY_URI - - return db_uri - - # check for and split the URI into the components: protocol, URL, query - if len(db_url.split(":")) > 1: - # for a query - if len(db_url.split("?")) > 1: - # protocol and queries - ((protocol,), (url, query)) = [ - comp.split("?") for comp in db_url.split(":") - ] - - else: - # no query - protocol, url = db_url.split(":") - query = "" - else: - protocol = "" - if len(db_url.split("?")) > 1: - url, query = db_url.split("?") - else: - query = "" - url = db_url - - # process and build the URI given this information - - # if the protocol is not given set it to the default - if len(protocol) == 0: - protocol = "file" - - # split the query up into sections if it has them - if len(query.split("?")) > 1: - queries = query.split("?") - - # split the "key=value" pairs in each query - queries = {q.split("=")[0]: q.split("=")[0] for q in queries} - else: - queries = {} - - # now handle the mode. If it was given as an argument then we have - # to set the appropriate query in the URI. If it was given in the - # URI that takes precedence however. - - # check for a mode option in the given query section, if no mode - # is given then we set it depending on what mode_spec was given - if "mode" not in queries: - # default to rwc mode - mode_query_value = "rwc" - - if mode_spec is not None: - # if the mode is one of the modes in the mode mapping use that to - # generate the URI, otherwise raise an error - if mode_spec not in dict(MODE_MAPPING): - raise ValueError("kv mode spec '{}' not recognized".format(mode_spec)) - - else: - sqlite_mode = dict(MODE_MAPPING)[mode_spec] - - # if the sqlite_mode is recognized as a mode that can - # be given in the query section do that - if sqlite_mode is not None: - mode_query_value = sqlite_mode - - # otherwise we handle these special modes ourselves - # here, checking file properties and raising errors as - # necessary - else: - # if the mode is either 'x' or 'w-' check to see if the db - # already exists. If it does raise an error. - if mode_spec in FAIL_IF_EXISTS_CREATE_MODES: - if osp.exists(url): - raise OSError("File exists") - - # if it is 'w' we want to delete the old file - elif mode_spec in TRUNCATE_MODES: - # if it exists remove it - if osp.exists(url): - os.remove(url) - - # the sqlite_mode for these is rwc (a), since we - # need to create it - mode_query_value = "rwc" - - # add the mode query to queries list - queries["mode"] = mode_query_value - - # if thw queries are empty just use the protocol and URL - if not queries: - db_uri = SQLITE3_URI_TEMPLATE.format(protocol=protocol, url=url) - # otherwise do the whole thing - else: - # build the query substring - query = SQLITE3_QUERY_JOIN_CHAR.join([ - "{}={}".format(key, value) for key, value in queries.items() - ]) - - # build the URI string - db_uri = SQLITE3_QUERY_URI_TEMPLATE.format( - protocol=protocol, url=url, query=query - ) - - return db_uri - - -class KV(MutableMapping): - def __init__( - self, - db_url=None, - table="data", - primary_key="key", - value_name="value", - timeout=5, - mode="x", - append_only=False, - value_types=DEFAULT_VALUE_TYPES, - ): - # generate a good URI from the url and the mode - db_uri = gen_uri(db_url, mode) - - self._mode = mode - self._append_only = append_only - - # set the value types for this kv - self._kv_types = value_types - - self._db_uri = db_uri - - # connect to the db - self._db = sqlite3.connect(self._db_uri, timeout=timeout, uri=True) - self._closed = False - - # set the isolation level to autocommit - self._db.isolation_level = None - - # we can use read_uncommited only in append_only mode (no - # updates) because you never have to worry about dirty reads - # since you can't update - if self.append_only: - self._execute("PRAGMA read_uncommited=1") - - self._table = table - self._primary_key = primary_key - self._value_name = value_name - - # create the table if it doesn't exist and set the key names - create_table_query = """ - CREATE TABLE IF NOT EXISTS {table_name} - ({key_name} PRIMARY KEY, {value_name}) - """.format( - table_name=self.table, key_name=self.primary_key, value_name=self.value_name - ) - self._execute(create_table_query) - - self._locks = 0 - - @property - def mode(self): - return self._mode - - @property - def append_only(self): - return self._append_only - - def close(self): - if self._closed == True: - raise IOError("The database connection is already closed") - - else: - self._db.close() - self._closed = True - - @property - def db_uri(self): - return self._db_uri - - @property - def db(self): - return self._db - - @property - def table(self): - return self._table - - @property - def primary_key(self): - return self._primary_key - - @property - def value_name(self): - return self._value_name - - @property - def value_types(self): - return self._kv_types - - def _execute(self, *args): - return self._db.cursor().execute(*args) - - def __len__(self): - [[n]] = self._execute("SELECT COUNT(*) FROM {table}".format(table=self.table)) - return n - - def __getitem__(self, key): - if key is None: - query = ( - "SELECT {value} FROM {table} WHERE {key} is NULL".format( - value=self.value_name, table=self.table, key=self.primary_key - ), - (), - ) - else: - query = ( - "SELECT {value} FROM {table} WHERE {key}=?".format( - value=self.value_name, table=self.table, key=self.primary_key - ), - (key,), - ) - - cursor = self._execute(*query) - result = cursor.fetchone() - - if result is None: - raise KeyError - else: - return result[0] - - def __iter__(self): - return ( - key - for [key] in self._execute( - "SELECT {key} FROM {table}".format( - key=self.primary_key, table=self.table - ), - (), - ) - ) - - def __setitem__(self, key, value): - """Set a value, must be in bytes format.""" - - # check the type of the value to make sure it is what this KV - # supports, if it is None then it is the standard python type - # translation - - if self.value_types is not None: - assert isinstance(value, self.value_types), ( - "Value must be a value supported by this kv" - ) - - self.lockless_set(key, value) - - def __delitem__(self, key): - logger.debug("Deleting the snapshot {}".format(key)) - - # no deletions in append only mode - if self.append_only: - raise sqlite3.IntegrityError( - "DB is opened in append only mode, and {} has already been set".format( - key - ) - ) - - # delete it if it exists - elif key in self: - logger.debug("executing delete query") - self._execute(self.del_query, (key,)) - logger.debug("finished") - - else: - raise KeyError - - @property - def insert_query(self): - query = "INSERT INTO {table} VALUES (?, ?)".format(table=self.table) - - return query - - @property - def update_query(self): - query = "UPDATE {table} SET {value}=? WHERE {key}=?".format( - key=self.primary_key, value=self.value_name, table=self.table - ) - - return query - - @property - def del_query(self): - query = "DELETE FROM {table} WHERE {key}=?".format( - key=self.primary_key, table=self.table - ) - - return query - - def lockless_set(self, key, value): - """an implementation of the __setitem__ without the lock context - manager which turns on the DEFERRED isolation level. The - isolation level of the KV is set to autocommit so now lock is - needed anyhow. - - """ - - # insert the key-value pair if the key isn't in the db - try: - self._execute(self.insert_query, (key, value)) - - # otherwise update the keys value - except sqlite3.IntegrityError: - # if we are in append only mode don't allow updates - if self.append_only: - raise sqlite3.IntegrityError( - "DB is opened in append only mode, " - "and {} has already been set".format(key) - ) - else: - self._execute(self.update_query, (value, key)) - - def set_in_tx(self, cursor, key, value): - """Do a set with a cursor, this allows it to be done in a transaction.""" - - try: - cursor.execute(self.insert_query, (key, value)) - except sqlite3.IntegrityError: - # if we are in append only mode don't allow updates - if self.append_only: - raise sqlite3.IntegrityError( - "DB is opened in append only mode, " - "and {} has already been set".format(key) - ) - - else: - cursor.execute(self.update_query, (key, value)) - - return cursor - - def del_in_tx(self, cursor, key): - # no deletions in append only mode - if self.append_only: - raise sqlite3.IntegrityError( - "DB is opened in append only mode, and {} has already been set".format( - key - ) - ) - - elif key in self: - cursor.execute(self.del_query, (key,)) - - else: - raise KeyError - - return cursor - - @contextmanager - def lock(self): - if not self._locks: - self._execute("BEGIN TRANSACTION") - self._locks = True - try: - yield - finally: - self._locks = False - if not self._locks: - self._execute("COMMIT") diff --git a/src/wepy/util/mdtraj.py b/src/wepy/util/mdtraj.py index 4fc3419a..42fcc1e1 100644 --- a/src/wepy/util/mdtraj.py +++ b/src/wepy/util/mdtraj.py @@ -12,7 +12,6 @@ # Third Party Library import mdtraj as mdj import mdtraj.core.element as elem -import numpy as np # First Party Library from wepy.util.util import traj_box_vectors_to_lengths_angles @@ -112,11 +111,13 @@ def mdtraj_to_json_topology(mdj_top): except AttributeError: element_symbol_string = "" - residue_dict["atoms"].append({ - "index": int(atom.index), - "name": str(atom.name), - "element": element_symbol_string, - }) + residue_dict["atoms"].append( + { + "index": int(atom.index), + "name": str(atom.name), + "element": element_symbol_string, + } + ) chain_dict["residues"].append(residue_dict) topology_dict["chains"].append(chain_dict) @@ -270,7 +271,6 @@ def traj_fields_to_mdtraj(traj_fields, json_topology, rep_key="positions"): Parameters ---------- - traj_fields : dict of str: values The values for the trajectory, must have a positions field specified by the `rep_key` kwarg, and a 'box_vectors' field. @@ -288,9 +288,9 @@ def traj_fields_to_mdtraj(traj_fields, json_topology, rep_key="positions"): topology = json_to_mdtraj_topology(json_topology) req_fields = ["box_vectors", rep_key] - assert [field in traj_fields for field in req_fields], ( - "Fields must have the fields: {}".format(",".join(req_fields)) - ) + assert [ + field in traj_fields for field in req_fields + ], "Fields must have the fields: {}".format(",".join(req_fields)) unitcell_lengths, unitcell_angles = traj_box_vectors_to_lengths_angles( traj_fields["box_vectors"] diff --git a/src/wepy/util/multiprocessing.py b/src/wepy/util/multiprocessing.py new file mode 100644 index 00000000..d7d73e45 --- /dev/null +++ b/src/wepy/util/multiprocessing.py @@ -0,0 +1,159 @@ +# Standard Library +import contextlib +import copy +import logging +import logging.config +import logging.handlers +import multiprocessing as mp +import os +from typing import Generator + +logger = logging.getLogger(__name__) + +_BASE_WORKER_LOGGING_CONFIG = { + "version": 1, + "disable_existing_loggers": False, + "handlers": { + "queue": { + "class": "logging.handlers.QueueHandler", + "queue": None, # injected at runtime + } + }, + "root": { + "handlers": ["queue"], + "level": "NOTSET", # defer filtering to parent + }, +} + + +def proc_pool_worker_setup(log_queue: mp.Queue) -> None: + """Pool(initializer=) function that handles logging properly. + + Does its best to inherit log settings from a parent + process. Requires a logging queue that the log messages are sent + on. + + Works properly with 'spawn' start method. + + + + """ + + config = copy.deepcopy(_BASE_WORKER_LOGGING_CONFIG) + + config["handlers"]["queue"]["queue"] = log_queue + parent_loggers = logging.root.manager.loggerDict + for name, logger in parent_loggers.items(): + if isinstance(logger, logging.Logger): + config.setdefault("loggers", {})[name] = { + "level": logging.getLevelName(logger.level), + "propagate": logger.propagate, + "handlers": [], + "filters": [f.__class__.__name__ for f in logger.filters], + } + + logging.config.dictConfig(config) + + logger = logging.getLogger(__name__) + logger.info("Configured logging in worker process") + + +def _dummy_task(foo: int) -> int: + """Just a dummy function used for testing. + + For 'spawn' we need to have it importable thus it is defined here + and not in a test. + + """ + + logger = logging.getLogger("dummy-task") + logger.info("Executing dummy task") + + return foo + 1 + + +class WorkerFormatter(logging.Formatter): + def __init__(self, base_formatter: logging.Formatter): + self.base_formatter = base_formatter + + def format(self, record): + # Ensure process info exists + if not hasattr(record, "processName"): + record.processName = mp.current_process().name + if not hasattr(record, "process"): + record.process = mp.current_process().pid + + # Prepend process info to the actual message + + # the formatted msg string + original_msg = record.getMessage() + record.msg = f"[{record.processName} | PID {record.process}] {original_msg}" + # ensure no old args are re-applied + record.args = () + + # Use the base formatter for the rest + formatted = self.base_formatter.format(record) + + # Restore original message so we don't mutate it permanently + record.msg = original_msg + return formatted + + +@contextlib.contextmanager +def queue_listener_context(mp_ctx) -> Generator[None, None, None]: + + logger.info("Setting up queue logging infrastructure") + root_logger = logging.getLogger() + old_factory = logging.getLogRecordFactory() + + def record_factory(*args, **kwargs): + + record = old_factory(*args, **kwargs) + if not hasattr(record, "processName"): + record.processName = mp.current_process().name + + if not hasattr(record, "process"): + record.process = os.getpid() + + old_formatters = [handler.formatter for handler in root_logger.handlers] + + listener_handlers = [] + for handler in root_logger.handlers: + new_handler = copy.copy(handler) + new_handler.setFormatter(WorkerFormatter(handler.formatter)) + listener_handlers.append(new_handler) + + logger.info("Starting Queue") + log_queue = mp_ctx.Queue() + listener = logging.handlers.QueueListener(log_queue, *listener_handlers) + logger.info("Starting QueueListener") + listener.start() + logger.info("Listener started") + + try: + yield log_queue + finally: + logger.info("Shutting down log listener resources") + + logger.info("Stopping listener") + listener.stop() + logger.info("Listener stopped") + + for handler, formatter in zip( + root_logger.handlers, old_formatters, strict=True + ): + handler.setFormatter(formatter) + + logging.setLogRecordFactory(old_factory) + + logger.info("Closing logging Queue") + try: + log_queue.close() + log_queue.join_thread() + except Exception as exc: + logger.error(f"Exception in log Queue closing, continuing: {exc}") + pass + except: + logger.info("Logger Queue shut down cleanly") + + logger.info("Finished context cleanup") diff --git a/src/wepy/util/openmm.py b/src/wepy/util/openmm.py new file mode 100644 index 00000000..6712042d --- /dev/null +++ b/src/wepy/util/openmm.py @@ -0,0 +1,52 @@ +# Standard Library +from collections.abc import Iterable +from typing import Generator + +# Third Party Library +import numpy as np +import numpy.typing +import openmm + + +def array3d_to_vec3( + array: numpy.typing.ArrayLike, +) -> Generator[openmm.Vec3, None, None]: + + for row in array: + yield openmm.Vec3(*row.tolist()) + + +def vec3_to_array3d(vec3s: Iterable[openmm.Vec3]) -> numpy.typing.ArrayLike: + + vs = [] + for vec3 in vec3s: + vs.append( + ( + vec3.x, + vec3.y, + vec3.z, + ) + ) + + return np.array(vs) + + +def triclinic_volume_vec3_quantity( + box_vectors: list[openmm.unit.Quantity], +) -> openmm.unit.Quantity: + + return np.dot(box_vectors[0], np.cross(box_vectors[1], box_vectors[2])) + + +def format_box_vectors_line(box_vectors: list[openmm.unit.Quantity]) -> str: + unit = box_vectors[0].unit + + vec_strs = [] + for vec in box_vectors: + mags = [q.value_in_unit(q.unit) for q in vec] + vec_s = f"{mags[0]:.3f}, {mags[1]:.3f}, {mags[2]:.3f}" + vec_strs.append(vec_s) + + s = f"({vec_strs[0]}) ({vec_strs[1]}) ({vec_strs[2]}) {unit}" + + return s diff --git a/src/wepy/util/util.py b/src/wepy/util/util.py index c71d1d77..7f894046 100644 --- a/src/wepy/util/util.py +++ b/src/wepy/util/util.py @@ -1,7 +1,6 @@ """Miscellaneous functions needed by wepy.""" # Standard Library -import json import logging logger = logging.getLogger(__name__) @@ -12,16 +11,16 @@ import numpy as np -def set_loglevel(loglevel): - """ +def set_loglevel(loglevel: int | str) -> None: + """\b - \b Parameters ---------- loglevel : \b + Returns ------- @@ -69,15 +68,17 @@ def traj_box_vectors_to_lengths_angles(traj_box_vectors): traj_unitcell_angles = [] for vs in traj_box_vectors: - angles = np.array([ - np.degrees( - np.arccos( - np.dot(vs[i], vs[j]) - / (np.linalg.norm(vs[i]) * np.linalg.norm(vs[j])) + angles = np.array( + [ + np.degrees( + np.arccos( + np.dot(vs[i], vs[j]) + / (np.linalg.norm(vs[i]) * np.linalg.norm(vs[j])) + ) ) - ) - for i, j in [(0, 1), (1, 2), (2, 0)] - ]) + for i, j in [(0, 1), (1, 2), (2, 0)] + ] + ) traj_unitcell_angles.append(angles) @@ -112,15 +113,17 @@ def box_vectors_to_lengths_angles(box_vectors): unitcell_lengths = np.array(unitcell_lengths) # calculate the angles for the vectors - unitcell_angles = np.array([ - np.degrees( - np.arccos( - np.dot(box_vectors[i], box_vectors[j]) - / (np.linalg.norm(box_vectors[i]) * np.linalg.norm(box_vectors[j])) + unitcell_angles = np.array( + [ + np.degrees( + np.arccos( + np.dot(box_vectors[i], box_vectors[j]) + / (np.linalg.norm(box_vectors[i]) * np.linalg.norm(box_vectors[j])) + ) ) - ) - for i, j in [(0, 1), (1, 2), (2, 0)] - ]) + for i, j in [(0, 1), (1, 2), (2, 0)] + ] + ) return unitcell_lengths, unitcell_angles @@ -185,6 +188,7 @@ def lengths_and_angles_to_box_vectors(a_length, b_length, c_length, alpha, beta, Examples -------- + Notes ----- This code is adapted from gyroid, which is licensed under the BSD @@ -206,11 +210,13 @@ def lengths_and_angles_to_box_vectors(a_length, b_length, c_length, alpha, beta, gamma = gamma * np.pi / 180 a = np.array([a_length, np.zeros_like(a_length), np.zeros_like(a_length)]) - b = np.array([ - b_length * np.cos(gamma), - b_length * np.sin(gamma), - np.zeros_like(b_length), - ]) + b = np.array( + [ + b_length * np.cos(gamma), + b_length * np.sin(gamma), + np.zeros_like(b_length), + ] + ) cx = c_length * np.cos(beta) cy = c_length * (np.cos(alpha) - np.cos(beta) * np.cos(gamma)) / np.sin(gamma) cz = np.sqrt(c_length * c_length - cx * cx - cy * cy) @@ -235,8 +241,8 @@ def concat_traj_fields(trajs_fields): cum_traj_fields = {} for field in fields: - cum_traj_fields[field] = np.concatenate([ - traj_fields[field] for traj_fields in trajs_fields - ]) + cum_traj_fields[field] = np.concatenate( + [traj_fields[field] for traj_fields in trajs_fields] + ) return cum_traj_fields diff --git a/src/wepy/walker.py b/src/wepy/walker.py index 1c8119d8..0a24248a 100644 --- a/src/wepy/walker.py +++ b/src/wepy/walker.py @@ -27,15 +27,138 @@ """ # Standard Library +import copy import logging +import math +import random as rand +from typing import Any, Generic, Protocol, TypeVar + +# Third Party Library +import attrs + +# First Party Library +from wepy.util.attrs import AttrsMappingMixin logger = logging.getLogger(__name__) -# Standard Library -import random as rand -from copy import deepcopy + +T = TypeVar("T") + + +class WalkerState(Protocol[T]): + + def __getitem__(self, key: str) -> T: ... + + def __eq__(self, other: Any) -> bool: ... + + def dict(self) -> dict[str, T]: ... + + +# TODO: merge with the AttrsMappingMixin +class AttrsWalkerStateMixin(AttrsMappingMixin): + """A convenient mixin for implementing the WalkerState interface + for attrs classes. + """ + + def dict(self) -> dict[str, Any]: + return attrs.asdict(self) + + +class WalkerStateBox: + """A type black box walker state, useful in reporting when you + need polymorphism in communicating walker data. + """ + + def __init__(self, **kwargs: dict[str, Any]) -> None: + """Constructor for WalkerState. + + All key-word arguments passed in will be set as the key-value + pairs for the state. + + """ + self._data = copy.deepcopy(kwargs) + + def __getitem__(self, key: str) -> Any: + return self._data[key] + + def dict(self) -> dict[str, Any]: + """Return all key-value pairs as a dictionary.""" + return self._data + + def __eq__(self, other: Any) -> bool: + + if not isinstance(other, WalkerStateBox): + return False + else: + return self.dict() == other.dict() + + +WalkerState_ = TypeVar("WalkerState_") -def split(walker, number=2): +@attrs.define +class Walker(Generic[WalkerState_]): + """Reference implementation of the Walker interface. + + A container for: + + - state + - weight + + """ + + state: WalkerState_ + weight: float = attrs.field(eq=attrs.cmp_using(eq=math.isclose)) + + +def clone(walker: Walker, number: int = 1) -> list[Walker]: + """Clone this walker by making a copy with the same state and split + the probability uniformly between clones. + + The number is the increase in the number of walkers. + + e.g. number=1 will return 2 walkers with the same state as + this object but with probability split 50/50 between them + + Parameters + ---------- + number : int + Number of extra clones to make + (Default value = 1) + + Returns + ------- + cloned_walkers : list of objects implementing the Walker interface + + """ + + # calculate the weight of all child walkers split uniformly + split_prob = walker.weight / (number + 1) + # make the clones + clones = [] + for i in range(number + 1): + clones.append(Walker(walker.state, split_prob)) + + return clones + + +def squash(walker: Walker, merge_target: Walker) -> Walker: + """Add the weight of this walker to another. + + Parameters + ---------- + merge_target : object implementing the Walker interface + The walker to add this one's weight to. + + Returns + ------- + merged_walker : object implementing the Walker interface + + """ + new_weight = walker.weight + merge_target.weight + return Walker(merge_target.state, new_weight) + + +def split(walker: Walker, number: int = 2) -> list[Walker]: """Split (AKA make multiple clones) of a single walker. Creates multiple new walkers that have the same state as the given @@ -64,7 +187,7 @@ def split(walker, number=2): return clones -def keep_merge(walkers, keep_idx): +def keep_merge(walkers: list[Walker], keep_idx: int) -> Walker: """Merge a set of walkers using the state of one of them. Parameters @@ -90,7 +213,7 @@ def keep_merge(walkers, keep_idx): return new_walker -def merge(walkers): +def merge(walkers: list[Walker]) -> tuple[Walker, int]: """Merge this walker with another keeping the state of one of them and adding the weights. @@ -99,18 +222,18 @@ def merge(walkers): Parameters ---------- - walkers : list of objects implementing the Walker interface - The walkers that will be merged together + walkers : The walkers that will be merged together Returns ------- - merged_walker : object implementing the Walker interface + merged_walker : Final merged walker + keep_idx: Index of the walker whose state was retained. """ weights = [walker.weight for walker in walkers] # choose a walker according to their weights to keep its state - keep_walker = rand.choices(walkers, weights=weights) + keep_walker = next(iter(rand.choices(walkers, weights=weights))) keep_idx = walkers.index(keep_walker) # TODO do we need this? @@ -123,119 +246,3 @@ def merge(walkers): new_walker = type(walkers[0])(keep_walker.state, new_weight) return new_walker, keep_idx - - -class Walker(object): - """Reference implementation of the Walker interface. - - A container for: - - - state - - weight - - """ - - def __init__(self, state, weight): - """Constructor for Walker. - - Parameters - ---------- - state : object implementing the WalkerState interface - - weight : float - - """ - - self.state = state - self.weight = weight - - def clone(self, number=1): - """Clone this walker by making a copy with the same state and split - the probability uniformly between clones. - - The number is the increase in the number of walkers. - - e.g. number=1 will return 2 walkers with the same state as - this object but with probability split 50/50 between them - - Parameters - ---------- - number : int - Number of extra clones to make - (Default value = 1) - - Returns - ------- - cloned_walkers : list of objects implementing the Walker interface - - """ - - # calculate the weight of all child walkers split uniformly - split_prob = self.weight / (number + 1) - # make the clones - clones = [] - for i in range(number + 1): - clones.append(type(self)(self.state, split_prob)) - - return clones - - def squash(self, merge_target): - """Add the weight of this walker to another. - - Parameters - ---------- - merge_target : object implementing the Walker interface - The walker to add this one's weight to. - - Returns - ------- - merged_walker : object implementing the Walker interface - - """ - new_weight = self.weight + merge_target.weight - return type(self)(merge_target.state, new_weight) - - def merge(self, other_walkers): - """Merge a set of other walkers into this one using the merge function. - - Parameters - ---------- - other_walkers : list of objects implementing the Walker interface - The walkers that will be merged together - - Returns - ------- - merged_walker : object implementing the Walker interface - - """ - return merge([self] + other_walkers) - - -class WalkerState(object): - """Reference implementation of the WalkerState interface. - - Access all key-value pairs as a dictionary with the dict() method. - - Access individual values using the accessor syntax similar to - dictionaries: - - >>> WalkerState(my_key='value')['my_key'] - 'value' - - """ - - def __init__(self, **kwargs): - """Constructor for WalkerState. - - All key-word arguments passed in will be set as the key-value - pairs for the state. - - """ - self._data = kwargs - - def __getitem__(self, key): - return self._data[key] - - def dict(self): - """Return all key-value pairs as a dictionary.""" - return deepcopy(self._data) diff --git a/src/wepy/work_mapper/base.py b/src/wepy/work_mapper/base.py new file mode 100644 index 00000000..97945f33 --- /dev/null +++ b/src/wepy/work_mapper/base.py @@ -0,0 +1,39 @@ +"""Base classes and definitions for all work mappers.""" + +# Standard Library +import logging +from typing import ( + Callable, + Protocol, + TypeVar, +) + +# First Party Library +from wepy.runners.runner import RunSegmentData +from wepy.walker import WalkerState + +logger = logging.getLogger(__name__) + +WalkerState_ = TypeVar("WalkerState_", bound=WalkerState) +RunSegmentData_ = TypeVar("RunSegmentData_", bound=RunSegmentData) + + +class WorkMapper(Protocol[WalkerState_, RunSegmentData_]): + + def init(self) -> None: ... + + def map( + self, + task: Callable[[WalkerState_, int], tuple[WalkerState_, RunSegmentData_]], + walker_states: list[WalkerState_], + segment_lengths: list[int], + ) -> list[ + tuple[ + WalkerState_, + RunSegmentData_, + ] + ]: ... + + def get_worker_segment_times(self) -> dict[int, list[float]] | None: ... + + def cleanup(self) -> None: ... diff --git a/src/wepy/work_mapper/mapper.py b/src/wepy/work_mapper/mapper.py deleted file mode 100644 index e60f9404..00000000 --- a/src/wepy/work_mapper/mapper.py +++ /dev/null @@ -1,1129 +0,0 @@ -"""Reference implementations, abstract base classes, and a production -ready worker style mapper for mapping runner dynamics to walkers for -wepy simulation cycles. - -""" - -# Standard Library -import logging - -logger = logging.getLogger(__name__) -# Standard Library -import multiprocessing as mp -import queue as pyq -import signal -import sys -import time -import traceback -from warnings import warn - -# First Party Library -from wepy.util.util import set_loglevel - -PY_MAP = map - - -class ABCMapper(object): - """Abstract base class for a Mapper.""" - - def __init__(self, segment_func=None, **kwargs): - """Constructor for the Mapper class. No arguments are required. - - Parameters - ---------- - segment_func : callable, optional - Set a default segment_func. Typically set at runtime. - - """ - - self._func = segment_func - - self._attributes = kwargs - - @property - def attributes(self): - return self._attributes - - def init(self, segment_func=None, **kwargs): - """Runtime initialization and setting of function to map over walkers. - - Parameters - ---------- - segment_func : callable implementing the Runner.run_segment interface - - """ - - if self.segment_func is not None and segment_func is not None: - logger.info( - "overriding default segment_func {} with {}".format( - self._func, segment_func - ) - ) - self._func = segment_func - - elif self.segment_func is None and segment_func is None: - ValueError("segment_func must be given since no default specified") - - elif self.segment_func is None and segment_func is not None: - self._func = segment_func - - @property - def segment_func(self): - """The function that will be called for new data in the `map` method.""" - return self._func - - def cleanup(self, **kwargs): - """Runtime post-simulation tasks. - - This is run either at the end of a successful simulation or - upon an error in the main process of the simulation manager - call to `run_cycle`. - - The Mapper class performs no actions here and all arguments - are ignored. - - """ - - # nothing to do - pass - - def map(self, *args, **kwargs): - raise NotImplementedError - - -class Mapper(ABCMapper): - """Basic non-parallel reference implementation of a mapper.""" - - def __init__(self, segment_func=None, **kwargs): - """Constructor for the Mapper class. No arguments are required. - - Parameters - ---------- - segment_func : callable, optional - Set a default segment_func. Typically set at runtime. - - """ - - super().__init__(segment_func=segment_func, **kwargs) - - self._worker_segment_times = {0: []} - - def map(self, *args, **kwargs): - """Map the 'segment_func' to args. - - Parameters - ---------- - *args : list of list - Each element is the argument to one call of 'segment_func'. - - Returns - ------- - results : list - The results of each call to 'segment_func' in the same order as input. - - Examples - -------- - - >>> Mapper(segment_func=sum).map([(0,1,2), (3,4,5)]) - [3, 12] - - """ - - # expand the generators for the args and kwargs - args = [list(arg) for arg in args] - kwargs = {key: list(kwarg) for key, kwarg in kwargs.items()} - - segment_times = [] - results = [] - for arg_idx in range(len(args[0])): - start = time.time() - - # get just the args for this call to func - call_args = [arg[arg_idx] for arg in args] - call_kwargs = {key: value[arg_idx] for key, value in kwargs.items()} - - # run the task, catch any errors and reraise as a - # TaskException to satisfy the pattern - try: - result = self._func(*call_args, **call_kwargs) - - except Exception as task_exception: - # get the traceback for the exception - tb = sys.exc_info()[2] - - msg = "Exception '{}({})' caught in a task.".format( - type(task_exception).__name__, task_exception - ) - traceback_log_msg = """Traceback: --------------------------------------------------------------------------------- -{} --------------------------------------------------------------------------------- - """.format( - "".join( - traceback.format_exception( - type(task_exception), task_exception, tb - ) - ), - ) - - logger.critical(msg + "\n" + traceback_log_msg) - - # raise a TaskException to distinguish it from the worker - # errors with the metadata about the original exception - - raise TaskException( - "Error occured during task execution, recovery not possible.", - wrapped_exception=task_exception, - tb=tb, - ) - - end = time.time() - segment_time = end - start - segment_times.append(segment_time) - - results.append(result) - - self._worker_segment_times[0] = segment_times - - return results - - @property - def worker_segment_times(self): - """The run timings for each segment for each walker. - - Returns - ------- - worker_seg_times : dict of int : list of float - Dictionary mapping worker indices to a list of times in - seconds for each segment run. - - """ - return self._worker_segment_times - - -class Task: - """Class that composes a function and arguments.""" - - def __init__(self, func, *args, **kwargs): - """Constructor for Task. - - Parameters - ---------- - func : callable - Function to be called on the arguments. - - *args - The arguments to pass to func - - """ - self.args = args - self.kwargs = kwargs - self.func = func - - def __call__(self, **worker_kwargs): - """Makes the Task itself callable.""" - - # run the function passing in the args for running it and any - # worker information in the worker kwargs. - return self.func(*self.args, **self.kwargs, **worker_kwargs) - - -class WrapperException(Exception): - """Exception used for wrapping another exception. - - Since tracebacks can't be pickled we format it and save that - instead. - - """ - - def __init__( - self, - message, - # must be kwargs so we can pickle it (I know weird...) - wrapped_exception=None, - tb=None, - ): - super().__init__(message) - - # save the exception with the traceback - self.wrapped_exception = wrapped_exception - self.formatted_tb = traceback.format_tb(tb) - - -class TaskException(WrapperException): - pass - - -class ABCWorkerMapper(ABCMapper): - def __init__( - self, num_workers=None, segment_func=None, proc_start_method="fork", **kwargs - ): - """Constructor for WorkerMapper. - - - Parameters - ---------- - num_workers : int - The number of worker processes to spawn. - - segment_func : callable, optional - Set a default segment_func. Typically set at runtime. - - proc_start_method : str or None - A string indicating the type of process start method to - use from python multiprocessing typically 'fork', 'spawn', - or 'forkserver', or the platform default for None. See - documentation. Generates a context with the method - multiprocessing.get_context(proc_start_method) on `init`. - - """ - - super().__init__(segment_func=segment_func, **kwargs) - - self._proc_start_method = proc_start_method - - self._num_workers = num_workers - self._worker_segment_times = None - - if num_workers is not None: - self._worker_segment_times = {i: [] for i in range(self.num_workers)} - - def init(self, num_workers=None, segment_func=None, **kwargs): - """Runtime initialization and setting of function to map over walkers. - - Parameters - ---------- - num_workers : int - The number of worker processes to spawn - - segment_func : callable implementing the Runner.run_segment interface - - """ - - super().init(segment_func=segment_func) - - # create the multiprocessing context to use for spawning - # processes here - self._mp_ctx = mp.get_context(method=self._proc_start_method) - - # the number of workers must be given here or set as an object attribute - if num_workers is None and self.num_workers is None: - raise ValueError( - "The number of workers must be given, received {}".format(num_workers) - ) - - # if the number of walkers was given for this init() call use - # that, otherwise we use the default that was specified when - # the object was created - elif num_workers is not None and self.num_workers is None: - self._num_workers = num_workers - - # update the worker segment times - self._worker_segment_times = {i: [] for i in range(self.num_workers)} - - def cleanup(self, **kwargs): - # ALERT: is this all we need to do? I have a hunch there is - # more caveats, but these context objects are not really - # documented - - # make sure the context for this work mapper is destroyed - del self._mp_ctx - - @property - def num_workers(self): - """The number of worker processes.""" - return self._num_workers - - @property - def worker_segment_times(self): - """The run timings for each segment for each walker. - - Returns - ------- - worker_seg_times : dict of int : list of float - Dictionary mapping worker indices to a list of times in - seconds for each segment run. - - """ - return self._worker_segment_times - - def _make_task(self, *args, **kwargs): - """Generate a task from 'segment_func' attribute. - - Similar to partial evaluation (or currying). - - Args will be eventually used as the arguments to the call of - 'segment_func' by the worker processes when they receive the - task from the queue. - - Returns - ------- - task : Task object - - """ - return Task(self._func, *args, **kwargs) - - -# ---------------------------------- -# everything below this logically belongs in worker.py and should be imported from there - - -class WorkerException(WrapperException): - pass - - -class WorkerKilledError(ChildProcessError): - pass - - -# TODO: move this class to the wepy.work_mapper.worker class where it -# belongs. It shouldn't be in this namespace, but we will leave it -# here. Furthermore I would like to rename it since we now have -# different worker mapper implementations with different concurrency -# models -class WorkerMapper(ABCWorkerMapper): - """Work mapper implementation using multiple worker processes and task - queue. - - Uses the python multiprocessing module to spawn multiple worker - processes which watch a task queue of walker segments. - """ - - def __init__( - self, - num_workers=None, - worker_type=None, - worker_attributes=None, - segment_func=None, - **kwargs, - ): - """Constructor for WorkerMapper. - - - Parameters - ---------- - num_workers : int - The number of worker processes to spawn. - - worker_type : callable, optional - Callable that generates an object implementing the Worker - interface, typically a type from a Worker class. - - worker_attributes : dictionary - A dictionary of values that are passed to the worker - constructor as key-word arguments. - - segment_func : callable, optional - Set a default segment_func. Typically set at runtime. - - """ - - super().__init__(num_workers=num_workers, segment_func=segment_func, **kwargs) - - # since the workers will be their own process classes we - # handle this data - - # attributes that will be passed to the worker constructors - if worker_attributes is not None: - self._worker_attributes = worker_attributes - else: - self._worker_attributes = {} - - # choose the type of the worker - if worker_type is None: - self._worker_type = Worker - warn("worker_type not given using the default base class") - logger.warn("worker_type not given using the default base class") - else: - self._worker_type = worker_type - - @property - def worker_type(self): - """The callable that generates a worker object. - - Typically this is just the type from the class definition of - the Worker where the constructor is called. - - """ - return self._worker_type - - def init(self, num_workers=None, segment_func=None, **kwargs): - """Runtime initialization and setting of function to map over walkers. - - Parameters - ---------- - num_workers : int - The number of worker processes to spawn - - segment_func : callable implementing the Runner.run_segment interface - - """ - - super().init(num_workers=num_workers, segment_func=segment_func, **kwargs) - - manager = self._mp_ctx.Manager() - - # Establish communication queues - - # A queue for errors - self._exception_queue = manager.Queue() - - # queue for the tasks we know the batch size so we don't need - # a JoinableQueue - self._task_queue = manager.Queue() - - # results queue - self._result_queue = manager.Queue() - - # use pipes for communication channels between this parent - # process and the children for sending specific interrupts - # such as the signal to kill them. Note that the clean way to - # end the process is to send poison pills on the task queue, - # this is for other stuff. IRQ is a common abbreviation for - # interrupts - self._irq_parent_conns = [] - - # Start workers, giving them all the queues - self._workers = [] - for i in range(self.num_workers): - # make a pipe to communicate with this worker for the int - parent_conn, child_conn = self._mp_ctx.Pipe() - self._irq_parent_conns.append(parent_conn) - - # create the worker giving it all of the communication - # channels - worker = self.worker_type( - i, - self._task_queue, - self._result_queue, - self._exception_queue, - child_conn, - mapper_attributes=self._attributes, - **self._worker_attributes, - ) - self._workers.append(worker) - - # start the worker processes - for worker in self._workers: - worker.start() - - logger.info( - "Worker process started as name: {}; PID: {}".format( - worker.name, worker.pid - ) - ) - - # now that we have started the processes register the handler - # for SIGTERM signals that will clean up our children cleanly - signal.signal(signal.SIGTERM, self._sigterm_shutdown) - - def _sigterm_shutdown(self, signum, frame): - logger.critical("Received external SIGTERM, forcing shutdown.") - - self.force_shutdown() - - def force_shutdown(self, **kwargs): - logger.critical("Forcing shutdown") - - # our primary job is to shut down all of the running processes - # without just shutting down the queues and breaking the pipes - - # to do this we send the kill signals to them on the kill - # channel. - - for worker_idx, worker in enumerate(self._workers): - logger.critical( - "Sending SIGTERM message on {} to worker {}".format( - self._irq_parent_conns[worker_idx].fileno(), worker_idx - ) - ) - - # send a kill message to the worker - self._irq_parent_conns[worker_idx].send(signal.SIGTERM) - - logger.critical("All kill messages sent to workers") - - # check that all have exited - alive_workers = [worker.is_alive() for worker in self._workers] - worker_acks = {} - worker_exitcodes = {} - premature_exit = False - while any(alive_workers) and not premature_exit: - for worker_idx, worker in enumerate(self._workers): - # ignore already known dead workers - if not alive_workers[worker_idx]: - continue - - if worker.is_alive(): - # if it is still alive and we have an ack from it - # just terminate. There is a bug in the code and - # is out of our control - if worker_idx in worker_acks: - logger.debug( - "Ack received from {} but has not shut down".format( - worker.name - ) - ) - premature_exit = True - - # otherwise we need to try and receive the ack - elif self._irq_parent_conns[worker_idx].poll(1): - # receive the acknowledgement - ack = self._irq_parent_conns[worker_idx].recv() - - logger.debug( - "Received {} acknowledgement from {}".format( - ack, worker.name - ) - ) - - # make sure the ack is affirmative - if ack is True: - worker_acks[worker_idx] = ack - - # if it is an exeption wrap it as a worker - # error and use the os to kill the process - elif issubclass(type(ack), Exception): - # wrap it as a worker exception - exception = WorkerException(wrapped_exception=ack) - worker_acks[worker_idx] = exception - - logger.critical( - "{} not responding, terminating with SIGTERM".format( - worker.name - ) - ) - - worker.terminate() - - else: - alive_workers[worker_idx] = False - worker_exitcodes[worker_idx] = worker.exitcode - - if any(alive_workers): - logger.critical( - "Terminating main process with running workers {}".format( - ",".join( - [ - str(worker_idx) - for worker_idx in range(len(self._workers)) - if alive_workers[worker_idx] - ] - ) - ) - ) - - def cleanup(self, **kwargs): - """Runtime post-simulation tasks. - - This is run either at the end of a successful simulation or - upon an error in the main process of the simulation manager - call to `run_cycle`. - - The Mapper class performs no actions here and all arguments - are ignored. - - """ - - super().cleanup(**kwargs) - - # send poison pills (Stop signals) to the queues to stop them in a nice way - # and let them finish up - for i in range(self.num_workers): - self._task_queue.put((None, None)) - - # delete the queues and workers - self._task_queue = None - self._result_queue = None - self._workers = None - - def map(self, *args, **kwargs): - # docstring in superclass - - map_process = self._mp_ctx.current_process() - logger.info( - "Mapping from process {}; PID {}".format(map_process.name, map_process.pid) - ) - - # make tuples for the arguments to each function call - task_args = zip(*args) - kwargs = {key: list(kwarg) for key, kwarg in kwargs.items()} - - num_tasks = len(args[0]) - # Enqueue the jobs - for task_idx, task_arg in enumerate(task_args): - task_kwargs = {key: value[task_idx] for key, value in kwargs.items()} - - # a task will be the actual task and its task idx so we can - # sort them later - self._task_queue.put((task_idx, self._make_task(*task_arg, **task_kwargs))) - - logger.info("Waiting for tasks to be run") - - # poll the exception and result queues for results - n_results_left = num_tasks - results = [] - while n_results_left > 0: - # first check if any errors came back but don't wait, - # since the methods for querying whether it is empty or - # not are not reliable we just try and if we don't get - # anything we will come back around - try: - proc_name, pid, exception = self._exception_queue.get_nowait() - except pyq.Empty: - pass - - else: - logger.error( - "Exception occured in process {}; pid {}.".format(proc_name, pid) - ) - - # we can handle Task and Worker exceptions differently - if type(exception) == TaskException: - logger.critical( - "Exception encountered in a task which is unrecoverable." - "You will need to reconfigure your components in a stable manner." - ) - - self.force_shutdown() - - logger.critical("Shutdown complete.") - raise exception - - elif type(exception) == WorkerException: - # we make just an error message to say that errors - # in the worker may be due to the network or - # something and could recover - logger.error( - "Exception encountered in the work mapper worker process." - "Recovery possible, see further messages." - ) - - # However, the current implementation doesn't - # support retries or whatever so we issue a - # critical log informing that it has been elevated - # to critical and will force shutdown - logger.critical( - "Worker error mode resiliency not supported at this time." - "Performing force shutdown and simulation ending." - ) - - self.force_shutdown() - - logger.critical("Shutdown complete.") - raise exception - - else: - logger.critical("Unknown exception encountered.") - - self.force_shutdown() - - logger.critical("Shutdown complete.") - - raise exception - - # attempt to get something off of the results queue - try: - result = self._result_queue.get_nowait() - except pyq.Empty: - pass - - # if we get something handle it - else: - logger.info("Retrieved result: {}".format(result)) - results.append(result) - - # reduce the counter so we know when we are done - n_results_left -= 1 - - # sort the results according to their task_idx - results.sort() - - # save the task run times, so they can be accessed if desired, - # after clearing the task times from the last mapping - - # DEBUG: removing this because it should be set on init() - # self._worker_segment_times = {i : [] for i in range(self.num_workers)} - - for task_idx, worker_idx, task_time, result in results: - self._worker_segment_times[worker_idx].append(task_time) - - # then just return the values of the function - return [result for task_idx, worker_idx, task_time, result in results] - - -# same for the worker in terms of refactoring -class Worker(mp.Process): - """Worker process. - - This is a subclass of process with an overriden `__init__` - constructor that will automatically generate the Process. - - When this class is constructed a new process will be formed. - - """ - - NAME_TEMPLATE = "Worker-{}" - """A string formatting template to identify worker processes in - logs. The field will be filled with the worker index.""" - - def __init__( - self, - worker_idx, - task_queue, - result_queue, - exception_queue, - interrupt_connection, - mapper_attributes=None, - log_level="INFO", - **kwargs, - ): - """Constructor for the Worker class. - - Parameters - ---------- - worker_idx : int - The index of the worker. Should be unique. - - task_queue : multiprocessing.JoinableQueue - The shared task queue the worker will watch for new tasks to complete. - - result_queue : multiprocessing.Queue - The shared queue that completed task results will be placed on. - - interrupt_connection : multiprocessing.Connection - One end of a pipe to listen for messages specific to this worker. - - mapper_attributes : None or dict - A dictionary of the attributes of the mapper for reference in workers. - - kwargs : - The worker specific attributes - - """ - - # call the Process constructor - mp.Process.__init__(self, name=self.NAME_TEMPLATE.format(worker_idx)) - - self._exception_queue = exception_queue - self._exception = None - self._traceback = None - - # the queue that will trigger a shutdown in the event of failure - self._irq_channel = interrupt_connection - - # also register the SIGTERM signal handler for graceful - # shutdown with reporting to mapper - signal.signal(signal.SIGTERM, self._sigterm_shutdown) - - self._worker_idx = worker_idx - - self._mapper_attributes = mapper_attributes - - # set all the kwargs into an attributes dictionary - self._attributes = kwargs - - # the queues for work to be done and work done - self._task_queue = task_queue - self._result_queue = result_queue - - logger.debug("{} process created".format(self.name)) - - @property - def worker_idx(self): - """Dictionary of attributes of the worker.""" - return self._worker_idx - - @property - def attributes(self): - """Dictionary of attributes of the worker.""" - return self._attributes - - @property - def mapper_attributes(self): - """Dictionary of attributes of the worker.""" - return self._mapper_attributes - - def run(self): - logger.debug("{}: starting to run".format(self.name)) - - # try to run the worker and it's task, except either class of - # error that can come from it either from the worker - # (WorkerException) or the task (TaskException) and communicate it - # back to the main process - - # if we get an exception there is some cleanup logic - run_exception = None - - try: - # run the worker, which will retrieve its task from the - # queue attempt to run the task, and if it succeeds will - # put the results on the result queue, if the task fails - # it will catch it and wrap it as a task exception - self._run_worker() - - except TaskException as task_exception: - logger.error("{}: TaskException caught".format(self.name)) - - run_exception = task_exception - - # anything else is considered a WorkerException so take the - # original exception and generate a worker exception from that - except Exception as exception: - logger.debug("{}: WorkerError caught".format(self.name)) - - # get the traceback - tb = sys.exc_info()[2] - - msg = "Exception '{}({})' caught in a worker.".format( - type(exception).__name__, exception - ) - traceback_log_msg = """Traceback: --------------------------------------------------------------------------------- -{} --------------------------------------------------------------------------------- - """.format( - "".join(traceback.format_exception(type(exception), exception, tb)), - ) - - logger.error("{}:".format(self.name) + msg + "\n" + traceback_log_msg) - - # raise a TaskError to distinguish it from the worker - # errors with the metadata about the original exception - - worker_exception = WorkerException( - "Error occured during worker execution.", - wrapped_exception=exception, - tb=tb, - ) - - run_exception = worker_exception - - # raise worker_exception - if run_exception is not None: - logger.debug("{}: Putting exception on exception queue".format(self.name)) - - # then put the exception and the traceback onto the queue - # so we can communicate back to the parent process - try: - self._exception_queue.put((self.name, self.pid, run_exception)) - except BrokenPipeError as exc: - logger.error( - "Pipe is broken indicating the root process has already exited:\n{}".format( - exc - ) - ) - - # TODO: not sure if this is good or not - # then reraise the exception so it can be caught - # raise run_exception - - def _sigterm_shutdown(self, signum, frame): - logger.debug("Received external SIGTERM kill command.") - - logger.debug("Alerting mapper that this will be honored.") - - # send an error to the mapper that the worker has been killed - self._irq_channel.send( - WorkerKilledError( - "{} (pid: {}) killed by external SIGTERM signal".format( - self.name, self.pid - ) - ) - ) - - logger.debug("Acknowledgment sent") - - logger.debug("Shutting down process") - - def _shutdown(self): - logger.debug("Received SIGTERM kill command from mapper") - - logger.debug("Acknowledging kill request will be honored") - - # report back that we are shutting down with a True - self._irq_channel.send(True) - - logger.debug("Acknowledgment sent") - - logger.debug("Shutting down process") - - def _run_worker(self): - # run the logic associated with communication and liveness of - # the worker process itself, this is not necessarily a fatal - # (critical) error and restarting a worker might resolve the - # problem. This calls the _run_task method though which is - # always critical since the logic in the code cannot be - # disputed - - # TODO remove when confirmed that this works - # worker_process = mp.current_process() - logger.info( - "{}: Worker process started as name: {}; PID: {}".format( - self.name, self.name, self.pid - ) - ) - - while True: - # check to see if there is any signals in the interrupt channel - if self._irq_channel.poll(): - # get the message - message = self._irq_channel.recv() - - logger.debug( - "{}: Received message from mapper on filehandle {}: {}".format( - self.name, self._irq_channel.fileno(), message - ) - ) - - # handle the message - - # a SIGTERM is a signal to kill the process - # unconditionally - if message is signal.SIGTERM: - self._shutdown() - - # break from the event (while) loop and shut down - break - - # anything is not recognized and we will continue and - # report back that we don't recognize the message with - # a ValueError object - else: - logger.error( - "{}: Message not recognized, continuing operations and" - " sending error to mapper".format(self.name) - ) - self._irq_channel.send( - ValueError( - "Message: {} not recognized continuing operations".format( - message - ) - ) - ) - - # get the next task - try: - task_idx, next_task = self._task_queue.get(block=False, timeout=None) - - logger.debug("{}: Got task {}".format(self.name, task_idx)) - - except pyq.Empty: - task_idx = None - next_task = Ellipsis - - # # check for the poison pill which is the signal to stop - if next_task is None: - logger.info( - "{}: received {} {}: FINISHED".format( - self.name, task_idx, next_task - ) - ) - - # TODO remove since we aren't using joinble queue anymore - # mark the poison pill task as done - # self.task_queue.task_done() - - # and exit the loop - break - - # only execute this if a task was actually receieved from - # the queue; an Ellipsis indicates continue the loop - elif next_task is not Ellipsis: - logger.info( - "{}; task_idx : {}; args : {} ".format( - self.name, task_idx, next_task.args - ) - ) - - # run the task - start = time.time() - - answer = self._run_task(next_task) - - end = time.time() - task_time = end - start - - logger.info( - "{}: task_idx : {}; COMPLETED in {} s".format( - self.name, task_idx, task_time - ) - ) - - # put the results into the results queue with it's task - # index so we can sort them later - self._result_queue.put((task_idx, self.worker_idx, task_time, answer)) - - def run_task(self, task): - """Actually executes the task. - - This default runner simply executes the task thunk. - - This can be customized by subclasses in order to allow for - injection of worker specific data. - - Parameters - ---------- - task : Task object - The partially evaluated task; function plus arguments - - Returns - ------- - task_result - Results of running the task. - - """ - - return task() - - def _run_task(self, task): - """Runs the given task and returns the results. - - This manages handling exceptions and tracebacks from the - actual `run_task` function which is intended to be specialized - by different workers to inject worker specific arguments to - tasks. Such as node and device identification. - - Parameters - ---------- - task : Task object - The partially evaluated task; function plus arguments - - Returns - ------- - task_result - Results of running the task. - - """ - - logger.info("Running task") - try: - return self.run_task(task) - - except Exception as task_exception: - # get the traceback for the exception - tb = sys.exc_info()[2] - - msg = "Exception '{}({})' caught in a task.".format( - type(task_exception).__name__, task_exception - ) - traceback_log_msg = """Traceback: --------------------------------------------------------------------------------- -{} --------------------------------------------------------------------------------- - """.format( - "".join( - traceback.format_exception(type(task_exception), task_exception, tb) - ), - ) - - logger.critical(msg + "\n" + traceback_log_msg) - - # raise a TaskException to distinguish it from the worker - # errors with the metadata about the original exception - - raise TaskException( - "Error occured during task execution, recovery not possible.", - wrapped_exception=task_exception, - tb=tb, - ) diff --git a/src/wepy/work_mapper/openmm/__init__.py b/src/wepy/work_mapper/openmm/__init__.py new file mode 100644 index 00000000..7cfe904d --- /dev/null +++ b/src/wepy/work_mapper/openmm/__init__.py @@ -0,0 +1,8 @@ +# Local Modules +from .proc_pool import OpenMMProcPoolWorkMapperFactory +from .serial import OpenMMSerialWorkMapperFactory + +__all__ = [ + "OpenMMProcPoolWorkMapperFactory", + "OpenMMSerialWorkMapperFactory", +] diff --git a/src/wepy/work_mapper/openmm/proc_pool.py b/src/wepy/work_mapper/openmm/proc_pool.py new file mode 100644 index 00000000..dd1cc044 --- /dev/null +++ b/src/wepy/work_mapper/openmm/proc_pool.py @@ -0,0 +1,259 @@ +"""Special OpenMM mappers.""" + +# Standard Library +import logging +import multiprocessing as mp +from typing import Callable + +# Third Party Library +import attrs +import more_itertools + +# First Party Library +from wepy.runners.openmm import ( + GPU_PLATFORMS, + OpenMMPlatformName, + OpenMMState, +) +from wepy.util.multiprocessing import proc_pool_worker_setup, queue_listener_context +from wepy.work_mapper.base import WorkMapper + +logger = logging.getLogger(__name__) + + +class OpenMMProcPoolWorkMapper(WorkMapper): + + def __init__( + self, + platform: OpenMMPlatformName, + num_procs: int, + device_ids: list[int] | None = None, + global_platform_properties: dict[str, str] | None = None, + device_platform_properties: list[dict[str, str]] | None = None, + ): + + if platform in {"CUDA", "HIP", "OpenCL"}: + if device_ids is None: + raise ValueError( + f"For accelerator platforms ({platform} requested) device_ids must be given." + ) + + if device_platform_properties is not None: + + if len(device_platform_properties) != len(device_ids): + raise ValueError( + f"{len(device_ids)} devices requested, but only {len(device_platform_properties)} device platform property dicts given." + ) + + else: + self._device_platform_properties = { + idx: props for idx, props in enumerate(device_platform_properties) + } + + else: + self._device_platform_properties = None + + self._platform = platform + self._global_platform_properties = global_platform_properties + + if device_ids is not None and num_procs != len(device_ids): + + raise ValueError( + f"When device_ids is given ({device_ids}) it must be the same length as the number of processes: {num_procs}" + ) + + self._device_ids: dict[int, int] = ( + {idx: device_id for idx, device_id in enumerate(device_ids)} + if device_ids is not None + else None + ) + self._num_procs = num_procs + + def init( + self, + ) -> None: + + logger.info("Initializing ProcPoolMapper") + + logger.info( + "Initializing local multiprocessing context with start method: spawn" + ) + self._mp_ctx = mp.get_context(method="spawn") + + def cleanup(self) -> None: + + logger.info("Running ProcPoolMapper cleanup") + logger.info("Nothing to do") + + def map( + self, + task: Callable[[OpenMMState, int], OpenMMState], + walker_states: list[OpenMMState], + segment_lengths: list[int], + ) -> list[OpenMMState]: + + logger.info( + f"Running map on {len(walker_states)} in batches of {self._num_procs}" + ) + + # spin up a new pool for each map + logger.info(f"Starting process Pool with {self._num_procs}") + + with ( + queue_listener_context(self._mp_ctx) as log_queue, + self._mp_ctx.Pool( + processes=self._num_procs, + # only run one thing per task, just to make sure + # everything is cleaned up + maxtasksperchild=1, + initializer=proc_pool_worker_setup, + initargs=(log_queue,), + ) as pool, + ): + + results = [] + for batch_idx, batch in enumerate( + more_itertools.chunked( + zip(walker_states, segment_lengths, strict=True), + self._num_procs, + strict=False, + ) + ): + + logger.info(f"Submitting batch: {batch_idx}") + + batch_results = [] + for batch_task_idx, task_args in enumerate(batch): + + task_idx = batch_idx + batch_task_idx + # for our purposes each element in this batch + # should be associated with a worker. + worker_idx = batch_task_idx + + if self._device_ids is not None: + logger.info( + "device_ids have been given, setting up special platform properties for each task." + ) + device_id = str(self._device_ids[worker_idx]) + worker_platform_props = { + **( + {"DeviceIndex": device_id} + if self._platform in GPU_PLATFORMS + else {} + ), + **( + self._device_platform_properties[worker_idx] + if ( + self._device_platform_properties is not None + and worker_idx in self._device_platform_properties + ) + else {} + ), + } + logger.info( + f"Device IDs given, resolved to using worker specific platform properties: {worker_platform_props}" + ) + else: + logger.info("No Device IDs given.") + worker_platform_props = None + + match (self._global_platform_properties, worker_platform_props): + case (None, None): + logger.info("No platform properties provided") + _platform_kwargs = {} + case (global_kwargs, None): + logger.info("Only global platform properties provided") + _platform_kwargs = global_kwargs + case (None, local_kwargs): + logger.info( + "Only device specific platform properties provided" + ) + _platform_kwargs = worker_platform_props + case (global_kwargs, local_kwargs): + logger.info( + "Both global and device specific platform properties provided" + ) + _platform_kwargs = global_kwargs | worker_platform_props + + logger.info(f"Resolved 'platform_kwargs' : {_platform_kwargs}") + + logger.info(f"Submitting task {task_idx} to worker {worker_idx}") + result = pool.apply_async( + task, + args=task_args, + kwds=dict( + platform_name=self._platform, + platform_kwargs=_platform_kwargs, + ), + ) + logger.info(f"Task {task_idx} submitted") + batch_results.append(result) + + logger.info(f"Batch {batch_idx} submitted, awaiting results.") + for batch_task_idx, task_result in enumerate(batch_results): + + task_idx = batch_idx + batch_task_idx + logger.info(f"Awaiting task {task_idx}") + + try: + real_result = task_result.get() + # TODO: add timeouts and retries + except TimeoutError as exc: + raise exc + except Exception as exc: + raise exc + + results.append(real_result) + + logger.info(f"Retrieved completed results for task: {task_idx}") + + logger.info(f"Batch {batch_idx} completed") + + logger.info("Completed all batches, terminating Pool") + + return results + + +@attrs.define +class OpenMMProcPoolWorkMapperFactory: + + platform: OpenMMPlatformName + num_procs: int + device_ids: list[int] | None = None + global_platform_properties: dict[str, str] | None = None + device_platform_properties: list[dict[str, str]] | None = None + + def __attrs_post_init__(self) -> None: + + if self.platform in {"CUDA", "HIP", "OpenCL"}: + if self.device_ids is None: + raise ValueError( + f"For accelerator platforms ({self.platform} requested) device_ids must be given." + ) + + if self.device_platform_properties is not None: + + if len(self.device_platform_properties) != len(self.device_ids): + raise ValueError( + f"{len(self.device_ids)} devices requested, but only {len(self.device_platform_properties)} device platform property dicts given." + ) + + if self.device_ids is not None and self.num_procs != len(self.device_ids): + + raise ValueError( + f"When device_ids is given ({self.device_ids}) it must be the same length as the number of processes: {self.num_procs}" + ) + + @classmethod + def type(cls) -> type[OpenMMProcPoolWorkMapper]: + return OpenMMProcPoolWorkMapper + + def __call__(self) -> OpenMMProcPoolWorkMapper: + + return OpenMMProcPoolWorkMapper( + platform=self.platform, + num_procs=self.num_procs, + device_ids=self.device_ids, + global_platform_properties=self.global_platform_properties, + device_platform_properties=self.device_platform_properties, + ) diff --git a/src/wepy/work_mapper/openmm/serial.py b/src/wepy/work_mapper/openmm/serial.py new file mode 100644 index 00000000..5d9fad8f --- /dev/null +++ b/src/wepy/work_mapper/openmm/serial.py @@ -0,0 +1,91 @@ +# Standard Library +import time +from typing import Callable + +# Third Party Library +import attrs + +# First Party Library +from wepy.runners.openmm import ( + OpenMMPlatformName, + OpenMMState, +) +from wepy.work_mapper.base import WorkMapper + + +class OpenMMSerialWorkMapper(WorkMapper): + + def __init__( + self, + platform: OpenMMPlatformName, + global_platform_properties: dict[str, str] | None = None, + ) -> None: + self._worker_segment_times: dict[int, list[float]] = {0: []} + + self._platform = platform + self._global_platform_properties = ( + global_platform_properties if global_platform_properties is not None else {} + ) + + def get_worker_segment_times(self) -> dict[int, list[float]]: + """The run timings for each segment for each walker. + + Returns + ------- + worker_seg_times : Dictionary mapping worker indices to a list of times in + seconds for each segment run. + + """ + return self._worker_segment_times + + def init(self) -> None: + pass + + def cleanup(self) -> None: + pass + + def map( + self, + task: Callable[[OpenMMState, int], OpenMMState], + walker_states: list[OpenMMState], + segment_lengths: list[int], + ) -> list[OpenMMState]: + + segment_times: list[float] = [] + results: list[OpenMMState] = [] + for task_idx, task_args in enumerate( + zip(walker_states, segment_lengths, strict=True) + ): + + tic = time.time() + result = task( + *task_args, + platform_name=self._platform, + platform_kwargs=self._global_platform_properties, + ) + toc = time.time() + + segment_times.append(toc - tic) + results.append(result) + + self._worker_segment_times[0] = segment_times + + return results + + +@attrs.define +class OpenMMSerialWorkMapperFactory: + + platform: OpenMMPlatformName + global_platform_properties: dict[str, str] | None = None + + @classmethod + def type(cls) -> type[OpenMMSerialWorkMapper]: + return OpenMMSerialWorkMapper + + def __call__(self) -> OpenMMSerialWorkMapper: + + return OpenMMSerialWorkMapper( + platform=self.platform, + global_platform_properties=self.global_platform_properties, + ) diff --git a/src/wepy/work_mapper/serial.py b/src/wepy/work_mapper/serial.py new file mode 100644 index 00000000..56fe896b --- /dev/null +++ b/src/wepy/work_mapper/serial.py @@ -0,0 +1,95 @@ +"""Reference implementation of a serial WorkMapper""" + +# Standard Library +import logging +import time +from typing import ( + Callable, + Generic, + TypeVar, +) + +# First Party Library +from wepy.factory import Factory +from wepy.runners.runner import RunSegmentData +from wepy.walker import WalkerState + +logger = logging.getLogger(__name__) + + +WalkerState_ = TypeVar("WalkerState_", bound=WalkerState) +RunSegmentData_ = TypeVar("RunSegmentData_", bound=RunSegmentData) + + +class SerialMapper( + Generic[ + WalkerState_, + RunSegmentData_, + ] +): + """Basic non-parallel reference implementation of a mapper.""" + + def __init__( + self, + ) -> None: + self._worker_segment_times: dict[int, list[float]] = {0: []} + + def get_worker_segment_times(self) -> dict[int, list[float]]: + """The run timings for each segment for each walker. + + Returns + ------- + worker_seg_times : Dictionary mapping worker indices to a list of times in + seconds for each segment run. + + """ + return self._worker_segment_times + + def init(self) -> None: + pass + + def cleanup(self) -> None: + pass + + def map( + self, + task: Callable[ + [ + WalkerState_, + int, + ], + WalkerState_, + ], + walker_states: list[WalkerState_], + segment_lengths: list[int], + ) -> list[tuple[WalkerState_, RunSegmentData_]]: + segment_times: list[float] = [] + results: list[WalkerState_] = [] + for task_idx, task_args in enumerate( + zip( + walker_states, + segment_lengths, + strict=True, + ) + ): + + tic = time.time() + result = task(*task_args) + toc = time.time() + + segment_times.append(toc - tic) + results.append(result) + + self._worker_segment_times[0] = segment_times + + return results + + +class SerialMapperFactory(Factory[SerialMapper]): + + @classmethod + def type(cls) -> type[SerialMapper]: + return SerialMapper + + def __call__(self) -> SerialMapper: + return SerialMapper() diff --git a/src/wepy/work_mapper/task_mapper.py b/src/wepy/work_mapper/task_mapper.py deleted file mode 100644 index faeb7c5c..00000000 --- a/src/wepy/work_mapper/task_mapper.py +++ /dev/null @@ -1,666 +0,0 @@ -# Standard Library -import logging - -logger = logging.getLogger(__name__) -# Standard Library -import multiprocessing as mp -import pickle -import queue as pyq -import signal -import sys -import time -import traceback -from warnings import warn - -# First Party Library -from wepy.work_mapper.mapper import ( - ABCWorkerMapper, - Task, - TaskException, - WrapperException, -) - - -class TaskProcessException(WrapperException): - pass - - -class TaskProcessKilledError(ChildProcessError): - pass - - -class TaskMapper(ABCWorkerMapper): - """Process-per-task mapper. - - This method of work mapper starts new processes for each runner - segment task that needs to be run. This allows cheap copying of - shared state using the operating system primitives. On linux this - would be either 'fork' (default) or 'spawn'. Fork is cheap but - doesn't initialize certain process namespace things, whereas spawn - is much more expensive but properly cleans things up. Fork should - be sufficient in most cases, however spawn may be needed when you - have some special contexts in the parent process. This is the case - with starting CUDA contexts in the main parent process and then - forking new processes from it. We suggest using fork and avoiding - making these kinds of contexts in the main process. - - This method avoids using shared memory or sending objects through - interprocess communication (that has a serialization and - deserialization cost associated with them) by using OS copying - mechanism. However, a new process will be created each cycle for - each walker in the simulation. So if you want a large number of - walkers you may experience a large overhead. If your walker states - are very small or a very fast serializer is available you may also - not benefit from full process address space copies. Instead the - WorkerMapper may be better suited. - - """ - - def __init__( - self, walker_task_type=None, num_workers=None, segment_func=None, **kwargs - ): - super().__init__(num_workers=num_workers, segment_func=segment_func, **kwargs) - - # choose the type of the worker - if walker_task_type is None: - self._walker_task_type = WalkerTaskProcess - warn("walker_task_type not given using the default base class") - logger.warning("walker_task_type not given using the default base class") - else: - self._walker_task_type = walker_task_type - - # initialize a list to put results in - self.results = None - - # this is meant to be a transient variable, will be initialized and deinitialized - self._walker_processes = None - - def init(self, **kwargs): - super().init(**kwargs) - - # now that we have started the processes register the handler - # for SIGTERM signals that will clean up our children cleanly - signal.signal(signal.SIGTERM, self._sigterm_shutdown) - - def _sigterm_shutdown(self, signum, frame): - logger.critical("Received external SIGTERM, forcing shutdown.") - - self.force_shutdown() - - logger.critical("Shutdown complete.") - - @property - def walker_task_type(self): - """The callable that generates a worker object. - - Typically this is just the type from the class definition of - the Worker where the constructor is called. - - """ - return self._walker_task_type - - def force_shutdown(self): - # send sigterm signals to processes to kill them - for walker_idx, walker_process in enumerate(self._walker_processes): - logger.critical( - "Sending SIGTERM message on {} to worker {}".format( - self._irq_parent_conns[walker_idx].fileno(), walker_idx - ) - ) - - # send a kill message to the worker - self._irq_parent_conns[walker_idx].send(signal.SIGTERM) - - logger.critical("All kill messages sent to workers") - - # wait for the walkers to finish and handle errors in them - # appropriately - alive_walkers = [walker.is_alive() for walker in self._walker_processes] - walker_exitcodes = {} - premature_exit = False - while any(alive_walkers): - for walker_idx, walker in enumerate(self._walker_processes): - if not alive_walkers[walker_idx]: - continue - - if walker.is_alive(): - pass - - # otherwise the walker is done - else: - alive_walkers[walker_idx] = False - walker_exitcodes[walker_idx] = walker.exitcode - - def map(self, *args, **kwargs): - # run computations in a Manager context - with self._mp_ctx.Manager() as manager: - num_walkers = len(args[0]) - - # to manage access to worker resources we use a queue with - # the index of the worker - worker_queue = manager.Queue() - - # put the workers onto the queue - for worker_idx in range(self.num_workers): - worker_queue.put(worker_idx) - - # initialize segment times for workers to - # fill in - worker_segment_times = manager.dict() - - # initialize for the number of workers, since these will be - # the slots to put timing results in - for i in range(self.num_workers): - worker_segment_times[i] = [] - - # make a shared list for the walker results - results = manager.list() - - # since this will be indexed by walker index initialize the - # length of the array - for walker in range(num_walkers): - results.append(None) - - # use pipes for communication channels between this parent - # process and the children for sending specific interrupts - # such as the signal to kill them. Note that the clean way to - # end the process is to send poison pills on the task queue, - # this is for other stuff. IRQ is a common abbreviation for - # interrupts - self._irq_parent_conns = [] - - # unpack the generator for the kwargs - kwargs = {key: list(kwarg) for key, kwarg in kwargs.items()} - - # create the task based processes - self._walker_processes = [] - for walker_idx, task_args in enumerate(zip(*args)): - task_kwargs = {key: value[walker_idx] for key, value in kwargs.items()} - - # make the interrupt pipe - parent_conn, child_conn = self._mp_ctx.Pipe() - self._irq_parent_conns.append(parent_conn) - - # start a process for this walker - walker_process = self.walker_task_type( - walker_idx, - self._attributes, - self._func, - task_args, - task_kwargs, - worker_queue, - results, - worker_segment_times, - child_conn, - ) - - walker_process.start() - - self._walker_processes.append(walker_process) - - new_walkers = [None for _ in range(num_walkers)] - results_found = [False for _ in range(num_walkers)] - while not all(results_found): - # go through the results list and handle the values that may be there - for walker_idx, result in enumerate(results): - if results_found[walker_idx]: - continue - - # logger.info("Checking for walker {}".format(walker_idx)) - - # first check to see if any of the task processes were - # terminated from the system - if self._irq_parent_conns[walker_idx].poll(): - irq = self._irq_parent_conns[walker_idx].recv() - - if issubclass(type(irq), TaskProcessKilledError): - # just terminate if a worker goes down. We - # could handle this better but it is not implemented now - logger.critical( - "Process {} was killed by sigterm, shutting down.".format( - walker_process[walker_idx].name - ) - ) - - logger.info( - "Recovery is possible here, but is not implemented " - "so we opt to fail fast and let you know a problem exists." - "Please use checkpointing to avoid lost data." - ) - - self.force_shutdown() - logger.critical("Shutdown complete.") - - logger.debug( - "Received {} acknowledgement from {}".format( - ack, worker.name - ) - ) - - # if no interrupts were handled we continue - - # if it is None no response has been made at all - # yet, this is the initialized value - if result is None: - pass - - # walker results are returned serialized as - # pickles, they are packed into a tuple so that we - # can associate them with an explicit marker, if - # we have a tuple then we can handle that - # appropriately - elif type(result) == tuple: - logger.debug("Received a results tuple") - - assert ( - len(result) == 2 - ), "Result tuples should be only be (ID, pickle)" - - result_id, payload = result - - # there was a walker successfully returned - if result_id == "Walker": - logger.debug("Received a serialized results walker") - - # deserialize - logger.debug("deserializing") - new_walker = pickle.loads(payload) - - logger.info("Got result for walker {}".format(walker_idx)) - - new_walkers[walker_idx] = new_walker - results_found[walker_idx] = True - - else: - raise ValueError("Unkown result ID: {}".format(result_id)) - - elif issubclass(type(result), TaskException): - logger.critical( - "Exception encountered in a task which is unrecoverable." - "You will need to reconfigure your components in a stable manner." - ) - - self.force_shutdown() - - logger.critical("Shutdown complete.") - raise result - - elif issubclass(type(result), TaskProcessException): - # we make just an error message to say that errors - # in the worker may be due to the network or - # something and could recover - logger.error( - "Exception encountered in the work mapper task process." - "Recovery possible, see further messages." - ) - - # However, the current implementation doesn't - # support retries or whatever so we issue a - # critical log informing that it has been elevated - # to critical and will force shutdown - logger.critical( - "Task process error mode resiliency not supported at this time." - "Performing force shutdown and simulation ending." - ) - - self.force_shutdown() - - logger.critical("Shutdown complete.") - raise result - - elif issubclass(type(result), Exception): - logger.critical( - "Unknown exception {} encountered.".format(result) - ) - - self.force_shutdown() - - logger.critical("Shutdown complete.") - - raise result - - else: - logger.critical( - "Unknown result value {} encountered.".format(result) - ) - - self.force_shutdown() - - logger.critical("Shutdown complete.") - - # save the managed list of the recorded worker times locally - for key, val in worker_segment_times.items(): - self._worker_segment_times[key] = val - - # wait for the processes to end - # for walker in self._walker_processes: - # walker.join() - # logger.info("Joined {}".format(walker.name)) - - # deinitialize the current walker processes - self._walker_processes = None - - return new_walkers - - -class WalkerTaskProcess(mp.Process): - NAME_TEMPLATE = "Walker-{}" - - def __init__( - self, - walker_idx, - mapper_attributes, - func, - task_args, - task_kwargs, - worker_queue, - results_list, - worker_segment_times, - interrupt_connection, - **kwargs, - ): - # initialize the process customizing the name - mp.Process.__init__(self, name=self.NAME_TEMPLATE.format(walker_idx), **kwargs) - - # the idea with this TaskProcess thing is that we pass in all - # the data to the constructor to create a "thunk" (a closure - # that is ready to be run without arguments) and then when run - # is called there will be no arguments to be passed. This - # simplifies the flow of data and underscores that the task is - # the process. - - # task arguments - self._func = func - self._task_args = task_args - self._task_kwargs = task_kwargs - - self.walker_idx = walker_idx - self._worker_idx = None - self.mapper_attributes = mapper_attributes - - # set the managed datastructure proxies as an attribute so we - self._worker_queue = worker_queue - self._results_list = results_list - self._worker_segment_times = worker_segment_times - self._irq_channel = interrupt_connection - - # also register the SIGTERM signal handler for graceful - # shutdown with reporting to mapper - signal.signal(signal.SIGTERM, self._external_sigterm_shutdown) - - def _external_sigterm_shutdown(self, signum, frame): - logger.debug("Received external SIGTERM kill command.") - - logger.debug("Alerting mapper that this will be honored.") - - # send an error to the mapper that the worker has been killed - self._irq_channel.send( - TaskProcessKilledError( - "{} (pid: {}) killed by external SIGTERM signal".format( - self.name, self.pid - ) - ) - ) - - logger.debug("Acknowledgment sent") - - logger.debug("Shutting down process") - - def _shutdown(self): - """The normal shutdown which can be ordered by the work mapper.""" - - logger.debug("Received SIGTERM kill command from mapper") - - logger.debug("Acknowledging kill request will be honored") - - # report back that we are shutting down with a True - self._irq_channel.send(True) - - logger.debug("Acknowledgment sent") - - logger.debug("Shutting down process") - - @property - def attributes(self, key): - return self._attributes - - @attributes.getter - def attributes(self, key): - return self._attributes[key] - - def _run_task(self, task): - # run the task thunk - logger.info("{}: Running task".format(self.name)) - try: - result = self.run_task(task) - except Exception as task_exception: - # get the traceback for the exception - tb = sys.exc_info()[2] - - msg = "Exception '{}({})' caught in a task.".format( - type(task_exception).__name__, task_exception - ) - traceback_log_msg = """Traceback: --------------------------------------------------------------------------------- -{} --------------------------------------------------------------------------------- - """.format( - "".join( - traceback.format_exception(type(task_exception), task_exception, tb) - ), - ) - - logger.critical("{}: ".format(self.name) + msg + "\n" + traceback_log_msg) - - # raise a TaskException to distinguish it from the worker - # errors with the metadata about the original exception - - raise TaskException( - "Error occured during task execution, recovery not possible.", - wrapped_exception=task_exception, - tb=tb, - ) - - return result - - def run_task(self, task): - logger.info("Running an unspecialized task") - - return task() - - def run(self): - logger.debug("{}: starting to run".format(self.name)) - - # try to run the worker and it's task, except either class of - # error that can come from it either from the worker - # (WorkerException) or the task (TaskException) and communicate it - # back to the main process - - # if we get an exception there is some cleanup logic - run_exception = None - - try: - # run the worker, which will retrieve its task from the - # queue attempt to run the task, and if it succeeds will - # put the results on the result queue, if the task fails - # it will catch it and wrap it as a task exception - self._run_walker() - - except TaskException as task_exception: - logger.error("{}: TaskException caught".format(self.name)) - - run_exception = task_exception - - # anything else is considered a WorkerException so take the - # original exception and generate a worker exception from that - except Exception as exception: - logger.debug("{}: TaskProcessException Error caught".format(self.name)) - - # get the traceback - tb = sys.exc_info()[2] - - msg = "Exception '{}({})' caught in a task process.".format( - type(exception).__name__, exception - ) - traceback_log_msg = """Traceback: --------------------------------------------------------------------------------- -{} --------------------------------------------------------------------------------- - """.format( - "".join(traceback.format_exception(type(exception), exception, tb)), - ) - - logger.error(msg + "\n" + traceback_log_msg) - - # raise a TaskError to distinguish it from the worker - # errors with the metadata about the original exception - - walker_exception = TaskProcessException( - "Error occured during task process execution.", - wrapped_exception=exception, - tb=tb, - ) - - run_exception = walker_exception - - # raise worker_exception - if run_exception is not None: - logger.debug( - "{}: Putting exception in managed results list".format(self.name) - ) - - # then put the exception and the traceback onto the queue - # so we can communicate back to the parent process - try: - self._results_list[self.walker_idx] = run_exception - except BrokenPipeError as exc: - logger.error( - "{}: Pipe is broken indicating the root process has already exited:\n{}".format( - self.name, exc - ) - ) - - def _run_walker(self): - logger.info( - "Walker process started as name: {}; PID: {}".format(self.name, self.pid) - ) - - # lock a worker, then pop it off the queue so no other process - # tries to use it - worker_received = False - while not worker_received: - # pop off a worker to use it, this will block until it - # receives a worker - try: - worker_idx = self._worker_queue.get_nowait() - except pyq.Empty: - pass - - # always do on a successful get - else: - if type(worker_idx) == int: - worker_received = True - logger.info("{}: acquired worker {}".format(self.name, worker_idx)) - - # if it is a shutdown signal we do so - elif worker_idx is signal.SIGTERM: - logger.info( - "{}: SIGTERM signal received from mapper. Shutting down.".format( - self.name - ) - ) - - self._shutdown() - - return None - - # check to see if there is any signals on the interrupt channel - if self._irq_channel.poll(): - # get the message - message = self._irq_channel.recv() - - logger.debug( - "{}: Received message from mapper on filehandle {}: {}".format( - self.name, self._irq_channel.fileno(), message - ) - ) - - # handle the message - - # check for signals to die - if message is signal.SIGTERM: - logger.critical( - f"{self.name}: SIGTERM signal received from mapper. Shutting down." - ) - - self._shutdown() - - return None - - else: - logger.error( - "{}: Message not recognized, continuing operations and" - " sending error to mapper".format(self.name) - ) - self._irq_channel.send( - ValueError( - "Message: {} not recognized continuing operations".format( - message - ) - ) - ) - - # after the wait loop we can now perform our work - - self._worker_idx = worker_idx - - # generate the task thunk - task = Task(self._func, *self._task_args, **self._task_kwargs) - - # run the task - start = time.time() - logger.info("{}: running function. Time: {}".format(self.name, time.time())) - - # run the task doing the proper handling of the task - # exception, this can raise a task exception - result = self._run_task(task) - - logger.info( - "{}: finished running function. Time {}".format(self.name, time.time()) - ) - end = time.time() - - # we separately set the result to the results list. This is so - # we can debug performance problems, and dissect what is due - # to computation time and what is due to communication - logger.info( - "{}: Setting value to results list. Time {}".format(self.name, time.time()) - ) - - logger.debug("Serializing the result walker") - - serial_result = pickle.dumps(result) - logger.debug("Putting tagged serialized walker tuple on managed results list") - self._results_list[self.walker_idx] = ("Walker", serial_result) - - logger.info( - "{}: Finished setting value to results list. Time {}".format( - self.name, time.time() - ) - ) - - # put the worker back onto the queue since we are done using it - self._worker_queue.put(worker_idx) - - logger.info("{}: released worker {}".format(self.name, worker_idx)) - - # add the time for this segment to the collection of the worker times - segment_time = end - start - seg_times = self._worker_segment_times[worker_idx] + [segment_time] - - # we must explicitly set the new value in total to trigger an - # update of the real dictionary. In place modification of - # proxy objects has no effect - self._worker_segment_times[worker_idx] = seg_times - - logger.info("{}: Exiting normally having completed the task".format(self.name)) diff --git a/src/wepy/work_mapper/worker.py b/src/wepy/work_mapper/worker.py deleted file mode 100644 index 91fa7fb9..00000000 --- a/src/wepy/work_mapper/worker.py +++ /dev/null @@ -1,26 +0,0 @@ -"""Classes for workers and tasks for use with WorkerMapper.""" - -# Standard Library -import logging - -logger = logging.getLogger(__name__) -# Standard Library -import multiprocessing as mp -import time - -# First Party Library -# we can't move the WorkerMapper here until some of the pickles I have -# laying around don't expect it to be here. In the meantime, new -# software can expect it to be here so we import it here. -from wepy.work_mapper.mapper import ( - ABCWorkerMapper, - TaskException, - Worker, - WorkerException, - WorkerMapper, - WrapperException, -) - -# this whole thing should get refactored into a better name which -# should be something like ConsumerMapper because our workers act like -# consumers diff --git a/src/wepy_test_drive.py b/src/wepy_test_drive.py index 50578dcd..bf05346c 100644 --- a/src/wepy_test_drive.py +++ b/src/wepy_test_drive.py @@ -50,9 +50,9 @@ def cli( """Run a pre-parametrized wepy simulation. \b + Parameters ---------- - \b SYSTEM : str Which pre-parametrized simulation to run should have the format: System/Runner-Platform @@ -119,15 +119,15 @@ def cli( REVO : Stateless and Binless algorithm that rewards in-ensemble novelty. \b + Examples -------- - python -m wepy_test_drive LennardJonesPair/OpenMM-CPU 20 10 2 4 \b + Notes ----- - When using a GPU platform your number of workers should be the number of GPUs you want to use. diff --git a/src/wepy_tools/monitoring/prometheus.py b/src/wepy_tools/monitoring/prometheus.py index 8a925fb5..94256a27 100644 --- a/src/wepy_tools/monitoring/prometheus.py +++ b/src/wepy_tools/monitoring/prometheus.py @@ -7,8 +7,11 @@ import prometheus_client as prom from pympler.asizeof import asizeof +# First Party Library +from wepy.monitor import Monitor -class SimMonitor: + +class SimMonitor(Monitor): """A simulation monitor using a prometheus http server""" DEFAULT_PORT = 9001 diff --git a/src/wepy_tools/sim_makers/openmm/__init__.py b/src/wepy_tools/sim_makers/openmm/__init__.py index d87b57e1..af34d177 100644 --- a/src/wepy_tools/sim_makers/openmm/__init__.py +++ b/src/wepy_tools/sim_makers/openmm/__init__.py @@ -1,4 +1,4 @@ -# First Party Library +# Local Modules from .lennard_jones import LennardJonesPairOpenMMSimMaker from .lysozyme import LysozymeImplicitOpenMMSimMaker from .sim_maker import ( diff --git a/src/wepy_tools/sim_makers/openmm/lennard_jones.py b/src/wepy_tools/sim_makers/openmm/lennard_jones.py index 9e7a56c7..65683243 100644 --- a/src/wepy_tools/sim_makers/openmm/lennard_jones.py +++ b/src/wepy_tools/sim_makers/openmm/lennard_jones.py @@ -7,6 +7,8 @@ from wepy.boundary_conditions.receptor import UnbindingBC from wepy.resampling.distances.distance import Distance from wepy.runners.openmm import GET_STATE_KWARG_DEFAULTS + +# Local Modules from .sim_maker import OpenMMToolsTestSysSimMaker diff --git a/src/wepy_tools/sim_makers/openmm/lysozyme.py b/src/wepy_tools/sim_makers/openmm/lysozyme.py index e5d65397..8a18da2e 100644 --- a/src/wepy_tools/sim_makers/openmm/lysozyme.py +++ b/src/wepy_tools/sim_makers/openmm/lysozyme.py @@ -1,5 +1,4 @@ # Standard Library -from copy import copy # Third Party Library import numpy as np @@ -15,9 +14,11 @@ json_top_atom_df, json_top_residue_df, ) -from .sim_maker import OpenMMToolsTestSysSimMaker from wepy_tools.systems import receptor as receptor_tools +# Local Modules +from .sim_maker import OpenMMToolsTestSysSimMaker + class LysozymeImplicitOpenMMSimMaker(OpenMMToolsTestSysSimMaker): TEST_SYS = LysozymeImplicit @@ -166,7 +167,6 @@ def binding_site_idxs(cls, cutoff): Parameters ---------- - cutoff : Quantity """ diff --git a/src/wepy_tools/sim_makers/openmm/sim_maker.py b/src/wepy_tools/sim_makers/openmm/sim_maker.py index 651de1a9..1fd5f109 100644 --- a/src/wepy_tools/sim_makers/openmm/sim_maker.py +++ b/src/wepy_tools/sim_makers/openmm/sim_maker.py @@ -403,7 +403,7 @@ def resolve_reporter_params(self, apparatus, reporter_specs, reporters_kwargs=No "WepyHDF5Reporter", "DashboardReporter", # DEBUG: this isn't compatible right now, needs refactoring - #'ResTreeReporter', + # 'ResTreeReporter', "WalkerReporter", ] diff --git a/src/wepy_tools/sim_makers/toys/randomwalk.py b/src/wepy_tools/sim_makers/toys/randomwalk.py index 0bdca0a9..4de3c32d 100644 --- a/src/wepy_tools/sim_makers/toys/randomwalk.py +++ b/src/wepy_tools/sim_makers/toys/randomwalk.py @@ -27,12 +27,8 @@ """ # Standard Library -import json -import os -import sys # Third Party Library -import h5py import mdtraj as mdj import numpy as np import pandas as pd @@ -40,7 +36,6 @@ # First Party Library from wepy.hdf5 import WepyHDF5 from wepy.reporter.hdf5 import WepyHDF5Reporter -from wepy.resampling.resamplers.resampler import NoResampler from wepy.runners.randomwalk import UNIT_NAMES, RandomWalkRunner from wepy.sim_manager import Manager from wepy.util.mdtraj import mdtraj_to_json_topology @@ -56,7 +51,7 @@ np.set_printoptions(precision=PRECISION) -class RandomwalkProfiler(object): +class RandomwalkProfiler: """A class to implement RandomWalkProfilier.""" RANDOM_WALK_TEMPLATE = """* Random walk simulation: @@ -285,7 +280,6 @@ def accuracy(self, x, Px): Returns ------- - accuracy: float The value that specifies how accurate the resampler is at point x. The highest accuracy is achived when P(X) = Pt(x). @@ -305,13 +299,11 @@ def Pt(self, x): Parameters ---------- - x: int The position. Returns ------- - accuracy : float The value of the target probability when the forward-move probability p. @@ -341,7 +333,6 @@ def get_max_range(self, wepy_h5, run_idx=0): Returns ------- - max_range: int The maximum range that is visited by all walkers in all dimensions. diff --git a/src/wepy_tools/systems/alanine_dipeptide.py b/src/wepy_tools/systems/alanine_dipeptide.py new file mode 100644 index 00000000..be72573b --- /dev/null +++ b/src/wepy_tools/systems/alanine_dipeptide.py @@ -0,0 +1,114 @@ +# Standard Library +import importlib.resources + +# Third Party Library +import attrs +import mdtraj +import numpy as np +import numpy.typing +import openmm +import openmm.app + +# First Party Library +from wepy.resampling.distances.base import Distance +from wepy.runners.openmm import OpenMMState, OpenMMStateWrapper +from wepy.util.mdtraj import json_to_mdtraj_topology, traj_fields_to_mdtraj +from wepy.walker import WalkerState + + +class AlanineDipeptideExplicitSystem: + + system: openmm.System + topology: openmm.app.Topology + mdtraj_top: mdtraj.Topology + json_top: str + state_wrapper: OpenMMStateWrapper + state: OpenMMState + + def __init__(self) -> None: + + # load the system and state information for the simulation + ala_files = importlib.resources.files( + "wepy_tools.systems.data.alanine_dipeptide_explicit" + ) + system_xml_path = ala_files / "alanine-dipeptide-explicit.system.omm.xml" + state_xml_path = ala_files / "alanine-dipeptide-explicit.state.omm.xml" + top_json_path = ala_files / "alanine-dipeptide-explicit.top.json" + + self.system = openmm.XmlSerializer.deserialize(system_xml_path.read_text()) + + self.state_wrapper = OpenMMStateWrapper.from_xml(state_xml_path.read_text()) + self.state = OpenMMState.from_state_wrapper(self.state_wrapper) + + self.json_top = top_json_path.read_text() + self.mdj_top = json_to_mdtraj_topology(self.json_top) + self.topology = self.mdj_top.to_openmm() + + +@attrs.define +class AlanineDipeptideRamachandranDistanceImage(WalkerState): + + phis: numpy.typing.ArrayLike + psis: numpy.typing.ArrayLike + + +@attrs.define +class AlanineDipeptideRamachandranDistance(Distance): + # the parsed JSON topology of plain python objects + topology: str + + def image(self, state: OpenMMState) -> AlanineDipeptideRamachandranDistanceImage: + + _unit = state["positions"].unit + state_dict = { + # traj shape to match interface requirements + key: np.array([quantity.value_in_unit(_unit)]) + for key, quantity in state.dict().items() + if key in {"positions", "box_vectors"} + } + traj = traj_fields_to_mdtraj( + state_dict, + self.topology, + ) + + is_periodic = "box_vectors" in state + + _, phis = mdtraj.compute_phi( + traj, + periodic=is_periodic, + opt=True, + ) + _, psis = mdtraj.compute_psi( + traj, + periodic=is_periodic, + opt=True, + ) + + return AlanineDipeptideRamachandranDistanceImage( + phis=phis, + psis=psis, + ) + + def image_distance( + self, + image_a: AlanineDipeptideRamachandranDistanceImage, + image_b: AlanineDipeptideRamachandranDistanceImage, + ) -> float: + + angles_a = np.concatenate((image_a.phis, image_a.psis)) + angles_b = np.concatenate((image_b.phis, image_b.psis)) + + # TODO: which one to use? + + # compute the circular difference + deltas = np.arctan2( + np.sin(angles_a - angles_b), + np.cos(angles_a - angles_b), + ) + + # deltas = np.atan2( + # np.sin(angles_a - angles_b), + # np.cos(angles_a - angles_b), + # ) + + return np.sqrt(np.sum(deltas**2)) diff --git a/src/wepy_tools/systems/openmm/__init__.py b/src/wepy_tools/systems/data/__init__.py similarity index 100% rename from src/wepy_tools/systems/openmm/__init__.py rename to src/wepy_tools/systems/data/__init__.py diff --git a/templates/examples/org/input/.keep b/src/wepy_tools/systems/data/alanine_dipeptide_explicit/__init__.py similarity index 100% rename from templates/examples/org/input/.keep rename to src/wepy_tools/systems/data/alanine_dipeptide_explicit/__init__.py diff --git a/src/wepy_tools/systems/data/alanine_dipeptide_explicit/alanine-dipeptide-explicit.state.omm.xml b/src/wepy_tools/systems/data/alanine_dipeptide_explicit/alanine-dipeptide-explicit.state.omm.xml new file mode 100644 index 00000000..d8de0d4f --- /dev/null +++ b/src/wepy_tools/systems/data/alanine_dipeptide_explicit/alanine-dipeptide-explicit.state.omm.xml @@ -0,0 +1,2279 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/wepy_tools/systems/data/alanine_dipeptide_explicit/alanine-dipeptide-explicit.system.omm.xml b/src/wepy_tools/systems/data/alanine_dipeptide_explicit/alanine-dipeptide-explicit.system.omm.xml new file mode 100644 index 00000000..84874379 --- /dev/null +++ b/src/wepy_tools/systems/data/alanine_dipeptide_explicit/alanine-dipeptide-explicit.system.omm.xml @@ -0,0 +1,9275 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/wepy_tools/systems/data/alanine_dipeptide_explicit/alanine-dipeptide-explicit.top.json b/src/wepy_tools/systems/data/alanine_dipeptide_explicit/alanine-dipeptide-explicit.top.json new file mode 100644 index 00000000..368b22c2 --- /dev/null +++ b/src/wepy_tools/systems/data/alanine_dipeptide_explicit/alanine-dipeptide-explicit.top.json @@ -0,0 +1 @@ +{"chains": [{"residues": [{"index": 0, "name": "ACE", "atoms": [{"index": 0, "name": "H1", "element": "H"}, {"index": 1, "name": "CH3", "element": "C"}, {"index": 2, "name": "H2", "element": "H"}, {"index": 3, "name": "H3", "element": "H"}, {"index": 4, "name": "C", "element": "C"}, {"index": 5, "name": "O", "element": "O"}], "resSeq": 1, "segmentID": ""}, {"index": 1, "name": "ALA", "atoms": [{"index": 6, "name": "N", "element": "N"}, {"index": 7, "name": "H", "element": "H"}, {"index": 8, "name": "CA", "element": "C"}, {"index": 9, "name": "HA", "element": "H"}, {"index": 10, "name": "CB", "element": "C"}, {"index": 11, "name": "HB1", "element": "H"}, {"index": 12, "name": "HB2", "element": "H"}, {"index": 13, "name": "HB3", "element": "H"}, {"index": 14, "name": "C", "element": "C"}, {"index": 15, "name": "O", "element": "O"}], "resSeq": 2, "segmentID": ""}, {"index": 2, "name": "NME", "atoms": [{"index": 16, "name": "N", "element": "N"}, {"index": 17, "name": "H", "element": "H"}, {"index": 18, "name": "C", "element": "C"}, {"index": 19, "name": "H1", "element": "H"}, {"index": 20, "name": "H2", "element": "H"}, {"index": 21, "name": "H3", "element": "H"}], "resSeq": 3, "segmentID": ""}, {"index": 3, "name": "HOH", "atoms": [{"index": 22, "name": "O", "element": "O"}, {"index": 23, "name": "H1", "element": "H"}, {"index": 24, "name": "H2", "element": "H"}], "resSeq": 4, "segmentID": ""}, {"index": 4, "name": "HOH", "atoms": [{"index": 25, "name": "O", "element": "O"}, {"index": 26, "name": "H1", "element": "H"}, {"index": 27, "name": "H2", "element": "H"}], "resSeq": 5, "segmentID": ""}, {"index": 5, "name": "HOH", "atoms": [{"index": 28, "name": "O", "element": "O"}, {"index": 29, "name": "H1", "element": "H"}, {"index": 30, "name": "H2", "element": "H"}], "resSeq": 6, "segmentID": ""}, {"index": 6, "name": "HOH", "atoms": [{"index": 31, "name": "O", "element": "O"}, {"index": 32, "name": "H1", "element": "H"}, {"index": 33, "name": "H2", "element": "H"}], "resSeq": 7, "segmentID": ""}, {"index": 7, "name": "HOH", "atoms": [{"index": 34, "name": "O", "element": "O"}, {"index": 35, "name": "H1", "element": "H"}, {"index": 36, "name": "H2", "element": "H"}], "resSeq": 8, "segmentID": ""}, {"index": 8, "name": "HOH", "atoms": [{"index": 37, "name": "O", "element": "O"}, {"index": 38, "name": "H1", "element": "H"}, {"index": 39, "name": "H2", "element": "H"}], "resSeq": 9, "segmentID": ""}, {"index": 9, "name": "HOH", "atoms": [{"index": 40, "name": "O", "element": "O"}, {"index": 41, "name": "H1", "element": "H"}, {"index": 42, "name": "H2", "element": "H"}], "resSeq": 10, "segmentID": ""}, {"index": 10, "name": "HOH", "atoms": [{"index": 43, "name": "O", "element": "O"}, {"index": 44, "name": "H1", "element": "H"}, {"index": 45, "name": "H2", "element": "H"}], "resSeq": 11, "segmentID": ""}, {"index": 11, "name": "HOH", "atoms": [{"index": 46, "name": "O", "element": "O"}, {"index": 47, "name": "H1", "element": "H"}, {"index": 48, "name": "H2", "element": "H"}], "resSeq": 12, "segmentID": ""}, {"index": 12, "name": "HOH", "atoms": [{"index": 49, "name": "O", "element": "O"}, {"index": 50, "name": "H1", "element": "H"}, {"index": 51, "name": "H2", "element": "H"}], "resSeq": 13, "segmentID": ""}, {"index": 13, "name": "HOH", "atoms": [{"index": 52, "name": "O", "element": "O"}, {"index": 53, "name": "H1", "element": "H"}, {"index": 54, "name": "H2", "element": "H"}], "resSeq": 14, "segmentID": ""}, {"index": 14, "name": "HOH", "atoms": [{"index": 55, "name": "O", "element": "O"}, {"index": 56, "name": "H1", "element": "H"}, {"index": 57, "name": "H2", "element": "H"}], "resSeq": 15, "segmentID": ""}, {"index": 15, "name": "HOH", "atoms": [{"index": 58, "name": "O", "element": "O"}, {"index": 59, "name": "H1", "element": "H"}, {"index": 60, "name": "H2", "element": "H"}], "resSeq": 16, "segmentID": ""}, {"index": 16, "name": "HOH", "atoms": [{"index": 61, "name": "O", "element": "O"}, {"index": 62, "name": "H1", "element": "H"}, {"index": 63, "name": "H2", "element": "H"}], "resSeq": 17, "segmentID": ""}, {"index": 17, "name": "HOH", "atoms": [{"index": 64, "name": "O", "element": "O"}, {"index": 65, "name": "H1", "element": "H"}, {"index": 66, "name": "H2", "element": "H"}], "resSeq": 18, "segmentID": ""}, {"index": 18, "name": "HOH", "atoms": [{"index": 67, "name": "O", "element": "O"}, {"index": 68, "name": "H1", "element": "H"}, {"index": 69, "name": "H2", "element": "H"}], "resSeq": 19, "segmentID": ""}, {"index": 19, "name": "HOH", "atoms": [{"index": 70, "name": "O", "element": "O"}, {"index": 71, "name": "H1", "element": "H"}, {"index": 72, "name": "H2", "element": "H"}], "resSeq": 20, "segmentID": ""}, {"index": 20, "name": "HOH", "atoms": [{"index": 73, "name": "O", "element": "O"}, {"index": 74, "name": "H1", "element": "H"}, {"index": 75, "name": "H2", "element": "H"}], "resSeq": 21, "segmentID": ""}, {"index": 21, "name": "HOH", "atoms": [{"index": 76, "name": "O", "element": "O"}, {"index": 77, "name": "H1", "element": "H"}, {"index": 78, "name": "H2", "element": "H"}], "resSeq": 22, "segmentID": ""}, {"index": 22, "name": "HOH", "atoms": [{"index": 79, "name": "O", "element": "O"}, {"index": 80, "name": "H1", "element": "H"}, {"index": 81, "name": "H2", "element": "H"}], "resSeq": 23, "segmentID": ""}, {"index": 23, "name": "HOH", "atoms": [{"index": 82, "name": "O", "element": "O"}, {"index": 83, "name": "H1", "element": "H"}, {"index": 84, "name": "H2", "element": "H"}], "resSeq": 24, "segmentID": ""}, {"index": 24, "name": "HOH", "atoms": [{"index": 85, "name": "O", "element": "O"}, {"index": 86, "name": "H1", "element": "H"}, {"index": 87, "name": "H2", "element": "H"}], "resSeq": 25, "segmentID": ""}, {"index": 25, "name": "HOH", "atoms": [{"index": 88, "name": "O", "element": "O"}, {"index": 89, "name": "H1", "element": "H"}, {"index": 90, "name": "H2", "element": "H"}], "resSeq": 26, "segmentID": ""}, {"index": 26, "name": "HOH", "atoms": [{"index": 91, "name": "O", "element": "O"}, {"index": 92, "name": "H1", "element": "H"}, {"index": 93, "name": "H2", "element": "H"}], "resSeq": 27, "segmentID": ""}, {"index": 27, "name": "HOH", "atoms": [{"index": 94, "name": "O", "element": "O"}, {"index": 95, "name": "H1", "element": "H"}, {"index": 96, "name": "H2", "element": "H"}], "resSeq": 28, "segmentID": ""}, {"index": 28, "name": "HOH", "atoms": [{"index": 97, "name": "O", "element": "O"}, {"index": 98, "name": "H1", "element": "H"}, {"index": 99, "name": "H2", "element": "H"}], "resSeq": 29, "segmentID": ""}, {"index": 29, "name": "HOH", "atoms": [{"index": 100, "name": "O", "element": "O"}, {"index": 101, "name": "H1", "element": "H"}, {"index": 102, "name": "H2", "element": "H"}], "resSeq": 30, "segmentID": ""}, {"index": 30, "name": "HOH", "atoms": [{"index": 103, "name": "O", "element": "O"}, {"index": 104, "name": "H1", "element": "H"}, {"index": 105, "name": "H2", "element": "H"}], "resSeq": 31, "segmentID": ""}, {"index": 31, "name": "HOH", "atoms": [{"index": 106, "name": "O", "element": "O"}, {"index": 107, "name": "H1", "element": "H"}, {"index": 108, "name": "H2", "element": "H"}], "resSeq": 32, "segmentID": ""}, {"index": 32, "name": "HOH", "atoms": [{"index": 109, "name": "O", "element": "O"}, {"index": 110, "name": "H1", "element": "H"}, {"index": 111, "name": "H2", "element": "H"}], "resSeq": 33, "segmentID": ""}, {"index": 33, "name": "HOH", "atoms": [{"index": 112, "name": "O", "element": "O"}, {"index": 113, "name": "H1", "element": "H"}, {"index": 114, "name": "H2", "element": "H"}], "resSeq": 34, "segmentID": ""}, {"index": 34, "name": "HOH", "atoms": [{"index": 115, "name": "O", "element": "O"}, {"index": 116, "name": "H1", "element": "H"}, {"index": 117, "name": "H2", "element": "H"}], "resSeq": 35, "segmentID": ""}, {"index": 35, "name": "HOH", "atoms": [{"index": 118, "name": "O", "element": "O"}, {"index": 119, "name": "H1", "element": "H"}, {"index": 120, "name": "H2", "element": "H"}], "resSeq": 36, "segmentID": ""}, {"index": 36, "name": "HOH", "atoms": [{"index": 121, "name": "O", "element": "O"}, {"index": 122, "name": "H1", "element": "H"}, {"index": 123, "name": "H2", "element": "H"}], "resSeq": 37, "segmentID": ""}, {"index": 37, "name": "HOH", "atoms": [{"index": 124, "name": "O", "element": "O"}, {"index": 125, "name": "H1", "element": "H"}, {"index": 126, "name": "H2", "element": "H"}], "resSeq": 38, "segmentID": ""}, {"index": 38, "name": "HOH", "atoms": [{"index": 127, "name": "O", "element": "O"}, {"index": 128, "name": "H1", "element": "H"}, {"index": 129, "name": "H2", "element": "H"}], "resSeq": 39, "segmentID": ""}, {"index": 39, "name": "HOH", "atoms": [{"index": 130, "name": "O", "element": "O"}, {"index": 131, "name": "H1", "element": "H"}, {"index": 132, "name": "H2", "element": "H"}], "resSeq": 40, "segmentID": ""}, {"index": 40, "name": "HOH", "atoms": [{"index": 133, "name": "O", "element": "O"}, {"index": 134, "name": "H1", "element": "H"}, {"index": 135, "name": "H2", "element": "H"}], "resSeq": 41, "segmentID": ""}, {"index": 41, "name": "HOH", "atoms": [{"index": 136, "name": "O", "element": "O"}, {"index": 137, "name": "H1", "element": "H"}, {"index": 138, "name": "H2", "element": "H"}], "resSeq": 42, "segmentID": ""}, {"index": 42, "name": "HOH", "atoms": [{"index": 139, "name": "O", "element": "O"}, {"index": 140, "name": "H1", "element": "H"}, {"index": 141, "name": "H2", "element": "H"}], "resSeq": 43, "segmentID": ""}, {"index": 43, "name": "HOH", "atoms": [{"index": 142, "name": "O", "element": "O"}, {"index": 143, "name": "H1", "element": "H"}, {"index": 144, "name": "H2", "element": "H"}], "resSeq": 44, "segmentID": ""}, {"index": 44, "name": "HOH", "atoms": [{"index": 145, "name": "O", "element": "O"}, {"index": 146, "name": "H1", "element": "H"}, {"index": 147, "name": "H2", "element": "H"}], "resSeq": 45, "segmentID": ""}, {"index": 45, "name": "HOH", "atoms": [{"index": 148, "name": "O", "element": "O"}, {"index": 149, "name": "H1", "element": "H"}, {"index": 150, "name": "H2", "element": "H"}], "resSeq": 46, "segmentID": ""}, {"index": 46, "name": "HOH", "atoms": [{"index": 151, "name": "O", "element": "O"}, {"index": 152, "name": "H1", "element": "H"}, {"index": 153, "name": "H2", "element": "H"}], "resSeq": 47, "segmentID": ""}, {"index": 47, "name": "HOH", "atoms": [{"index": 154, "name": "O", "element": "O"}, {"index": 155, "name": "H1", "element": "H"}, {"index": 156, "name": "H2", "element": "H"}], "resSeq": 48, "segmentID": ""}, {"index": 48, "name": "HOH", "atoms": [{"index": 157, "name": "O", "element": "O"}, {"index": 158, "name": "H1", "element": "H"}, {"index": 159, "name": "H2", "element": "H"}], "resSeq": 49, "segmentID": ""}, {"index": 49, "name": "HOH", "atoms": [{"index": 160, "name": "O", "element": "O"}, {"index": 161, "name": "H1", "element": "H"}, {"index": 162, "name": "H2", "element": "H"}], "resSeq": 50, "segmentID": ""}, {"index": 50, "name": "HOH", "atoms": [{"index": 163, "name": "O", "element": "O"}, {"index": 164, "name": "H1", "element": "H"}, {"index": 165, "name": "H2", "element": "H"}], "resSeq": 51, "segmentID": ""}, {"index": 51, "name": "HOH", "atoms": [{"index": 166, "name": "O", "element": "O"}, {"index": 167, "name": "H1", "element": "H"}, {"index": 168, "name": "H2", "element": "H"}], "resSeq": 52, "segmentID": ""}, {"index": 52, "name": "HOH", "atoms": [{"index": 169, "name": "O", "element": "O"}, {"index": 170, "name": "H1", "element": "H"}, {"index": 171, "name": "H2", "element": "H"}], "resSeq": 53, "segmentID": ""}, {"index": 53, "name": "HOH", "atoms": [{"index": 172, "name": "O", "element": "O"}, {"index": 173, "name": "H1", "element": "H"}, {"index": 174, "name": "H2", "element": "H"}], "resSeq": 54, "segmentID": ""}, {"index": 54, "name": "HOH", "atoms": [{"index": 175, "name": "O", "element": "O"}, {"index": 176, "name": "H1", "element": "H"}, {"index": 177, "name": "H2", "element": "H"}], "resSeq": 55, "segmentID": ""}, {"index": 55, "name": "HOH", "atoms": [{"index": 178, "name": "O", "element": "O"}, {"index": 179, "name": "H1", "element": "H"}, {"index": 180, "name": "H2", "element": "H"}], "resSeq": 56, "segmentID": ""}, {"index": 56, "name": "HOH", "atoms": [{"index": 181, "name": "O", "element": "O"}, {"index": 182, "name": "H1", "element": "H"}, {"index": 183, "name": "H2", "element": "H"}], "resSeq": 57, "segmentID": ""}, {"index": 57, "name": "HOH", "atoms": [{"index": 184, "name": "O", "element": "O"}, {"index": 185, "name": "H1", "element": "H"}, {"index": 186, "name": "H2", "element": "H"}], "resSeq": 58, "segmentID": ""}, {"index": 58, "name": "HOH", "atoms": [{"index": 187, "name": "O", "element": "O"}, {"index": 188, "name": "H1", "element": "H"}, {"index": 189, "name": "H2", "element": "H"}], "resSeq": 59, "segmentID": ""}, {"index": 59, "name": "HOH", "atoms": [{"index": 190, "name": "O", "element": "O"}, {"index": 191, "name": "H1", "element": "H"}, {"index": 192, "name": "H2", "element": "H"}], "resSeq": 60, "segmentID": ""}, {"index": 60, "name": "HOH", "atoms": [{"index": 193, "name": "O", "element": "O"}, {"index": 194, "name": "H1", "element": "H"}, {"index": 195, "name": "H2", "element": "H"}], "resSeq": 61, "segmentID": ""}, {"index": 61, "name": "HOH", "atoms": [{"index": 196, "name": "O", "element": "O"}, {"index": 197, "name": "H1", "element": "H"}, {"index": 198, "name": "H2", "element": "H"}], "resSeq": 62, "segmentID": ""}, {"index": 62, "name": "HOH", "atoms": [{"index": 199, "name": "O", "element": "O"}, {"index": 200, "name": "H1", "element": "H"}, {"index": 201, "name": "H2", "element": "H"}], "resSeq": 63, "segmentID": ""}, {"index": 63, "name": "HOH", "atoms": [{"index": 202, "name": "O", "element": "O"}, {"index": 203, "name": "H1", "element": "H"}, {"index": 204, "name": "H2", "element": "H"}], "resSeq": 64, "segmentID": ""}, {"index": 64, "name": "HOH", "atoms": [{"index": 205, "name": "O", "element": "O"}, {"index": 206, "name": "H1", "element": "H"}, {"index": 207, "name": "H2", "element": "H"}], "resSeq": 65, "segmentID": ""}, {"index": 65, "name": "HOH", "atoms": [{"index": 208, "name": "O", "element": "O"}, {"index": 209, "name": "H1", "element": "H"}, {"index": 210, "name": "H2", "element": "H"}], "resSeq": 66, "segmentID": ""}, {"index": 66, "name": "HOH", "atoms": [{"index": 211, "name": "O", "element": "O"}, {"index": 212, "name": "H1", "element": "H"}, {"index": 213, "name": "H2", "element": "H"}], "resSeq": 67, "segmentID": ""}, {"index": 67, "name": "HOH", "atoms": [{"index": 214, "name": "O", "element": "O"}, {"index": 215, "name": "H1", "element": "H"}, {"index": 216, "name": "H2", "element": "H"}], "resSeq": 68, "segmentID": ""}, {"index": 68, "name": "HOH", "atoms": [{"index": 217, "name": "O", "element": "O"}, {"index": 218, "name": "H1", "element": "H"}, {"index": 219, "name": "H2", "element": "H"}], "resSeq": 69, "segmentID": ""}, {"index": 69, "name": "HOH", "atoms": [{"index": 220, "name": "O", "element": "O"}, {"index": 221, "name": "H1", "element": "H"}, {"index": 222, "name": "H2", "element": "H"}], "resSeq": 70, "segmentID": ""}, {"index": 70, "name": "HOH", "atoms": [{"index": 223, "name": "O", "element": "O"}, {"index": 224, "name": "H1", "element": "H"}, {"index": 225, "name": "H2", "element": "H"}], "resSeq": 71, "segmentID": ""}, {"index": 71, "name": "HOH", "atoms": [{"index": 226, "name": "O", "element": "O"}, {"index": 227, "name": "H1", "element": "H"}, {"index": 228, "name": "H2", "element": "H"}], "resSeq": 72, "segmentID": ""}, {"index": 72, "name": "HOH", "atoms": [{"index": 229, "name": "O", "element": "O"}, {"index": 230, "name": "H1", "element": "H"}, {"index": 231, "name": "H2", "element": "H"}], "resSeq": 73, "segmentID": ""}, {"index": 73, "name": "HOH", "atoms": [{"index": 232, "name": "O", "element": "O"}, {"index": 233, "name": "H1", "element": "H"}, {"index": 234, "name": "H2", "element": "H"}], "resSeq": 74, "segmentID": ""}, {"index": 74, "name": "HOH", "atoms": [{"index": 235, "name": "O", "element": "O"}, {"index": 236, "name": "H1", "element": "H"}, {"index": 237, "name": "H2", "element": "H"}], "resSeq": 75, "segmentID": ""}, {"index": 75, "name": "HOH", "atoms": [{"index": 238, "name": "O", "element": "O"}, {"index": 239, "name": "H1", "element": "H"}, {"index": 240, "name": "H2", "element": "H"}], "resSeq": 76, "segmentID": ""}, {"index": 76, "name": "HOH", "atoms": [{"index": 241, "name": "O", "element": "O"}, {"index": 242, "name": "H1", "element": "H"}, {"index": 243, "name": "H2", "element": "H"}], "resSeq": 77, "segmentID": ""}, {"index": 77, "name": "HOH", "atoms": [{"index": 244, "name": "O", "element": "O"}, {"index": 245, "name": "H1", "element": "H"}, {"index": 246, "name": "H2", "element": "H"}], "resSeq": 78, "segmentID": ""}, {"index": 78, "name": "HOH", "atoms": [{"index": 247, "name": "O", "element": "O"}, {"index": 248, "name": "H1", "element": "H"}, {"index": 249, "name": "H2", "element": "H"}], "resSeq": 79, "segmentID": ""}, {"index": 79, "name": "HOH", "atoms": [{"index": 250, "name": "O", "element": "O"}, {"index": 251, "name": "H1", "element": "H"}, {"index": 252, "name": "H2", "element": "H"}], "resSeq": 80, "segmentID": ""}, {"index": 80, "name": "HOH", "atoms": [{"index": 253, "name": "O", "element": "O"}, {"index": 254, "name": "H1", "element": "H"}, {"index": 255, "name": "H2", "element": "H"}], "resSeq": 81, "segmentID": ""}, {"index": 81, "name": "HOH", "atoms": [{"index": 256, "name": "O", "element": "O"}, {"index": 257, "name": "H1", "element": "H"}, {"index": 258, "name": "H2", "element": "H"}], "resSeq": 82, "segmentID": ""}, {"index": 82, "name": "HOH", "atoms": [{"index": 259, "name": "O", "element": "O"}, {"index": 260, "name": "H1", "element": "H"}, {"index": 261, "name": "H2", "element": "H"}], "resSeq": 83, "segmentID": ""}, {"index": 83, "name": "HOH", "atoms": [{"index": 262, "name": "O", "element": "O"}, {"index": 263, "name": "H1", "element": "H"}, {"index": 264, "name": "H2", "element": "H"}], "resSeq": 84, "segmentID": ""}, {"index": 84, "name": "HOH", "atoms": [{"index": 265, "name": "O", "element": "O"}, {"index": 266, "name": "H1", "element": "H"}, {"index": 267, "name": "H2", "element": "H"}], "resSeq": 85, "segmentID": ""}, {"index": 85, "name": "HOH", "atoms": [{"index": 268, "name": "O", "element": "O"}, {"index": 269, "name": "H1", "element": "H"}, {"index": 270, "name": "H2", "element": "H"}], "resSeq": 86, "segmentID": ""}, {"index": 86, "name": "HOH", "atoms": [{"index": 271, "name": "O", "element": "O"}, {"index": 272, "name": "H1", "element": "H"}, {"index": 273, "name": "H2", "element": "H"}], "resSeq": 87, "segmentID": ""}, {"index": 87, "name": "HOH", "atoms": [{"index": 274, "name": "O", "element": "O"}, {"index": 275, "name": "H1", "element": "H"}, {"index": 276, "name": "H2", "element": "H"}], "resSeq": 88, "segmentID": ""}, {"index": 88, "name": "HOH", "atoms": [{"index": 277, "name": "O", "element": "O"}, {"index": 278, "name": "H1", "element": "H"}, {"index": 279, "name": "H2", "element": "H"}], "resSeq": 89, "segmentID": ""}, {"index": 89, "name": "HOH", "atoms": [{"index": 280, "name": "O", "element": "O"}, {"index": 281, "name": "H1", "element": "H"}, {"index": 282, "name": "H2", "element": "H"}], "resSeq": 90, "segmentID": ""}, {"index": 90, "name": "HOH", "atoms": [{"index": 283, "name": "O", "element": "O"}, {"index": 284, "name": "H1", "element": "H"}, {"index": 285, "name": "H2", "element": "H"}], "resSeq": 91, "segmentID": ""}, {"index": 91, "name": "HOH", "atoms": [{"index": 286, "name": "O", "element": "O"}, {"index": 287, "name": "H1", "element": "H"}, {"index": 288, "name": "H2", "element": "H"}], "resSeq": 92, "segmentID": ""}, {"index": 92, "name": "HOH", "atoms": [{"index": 289, "name": "O", "element": "O"}, {"index": 290, "name": "H1", "element": "H"}, {"index": 291, "name": "H2", "element": "H"}], "resSeq": 93, "segmentID": ""}, {"index": 93, "name": "HOH", "atoms": [{"index": 292, "name": "O", "element": "O"}, {"index": 293, "name": "H1", "element": "H"}, {"index": 294, "name": "H2", "element": "H"}], "resSeq": 94, "segmentID": ""}, {"index": 94, "name": "HOH", "atoms": [{"index": 295, "name": "O", "element": "O"}, {"index": 296, "name": "H1", "element": "H"}, {"index": 297, "name": "H2", "element": "H"}], "resSeq": 95, "segmentID": ""}, {"index": 95, "name": "HOH", "atoms": [{"index": 298, "name": "O", "element": "O"}, {"index": 299, "name": "H1", "element": "H"}, {"index": 300, "name": "H2", "element": "H"}], "resSeq": 96, "segmentID": ""}, {"index": 96, "name": "HOH", "atoms": [{"index": 301, "name": "O", "element": "O"}, {"index": 302, "name": "H1", "element": "H"}, {"index": 303, "name": "H2", "element": "H"}], "resSeq": 97, "segmentID": ""}, {"index": 97, "name": "HOH", "atoms": [{"index": 304, "name": "O", "element": "O"}, {"index": 305, "name": "H1", "element": "H"}, {"index": 306, "name": "H2", "element": "H"}], "resSeq": 98, "segmentID": ""}, {"index": 98, "name": "HOH", "atoms": [{"index": 307, "name": "O", "element": "O"}, {"index": 308, "name": "H1", "element": "H"}, {"index": 309, "name": "H2", "element": "H"}], "resSeq": 99, "segmentID": ""}, {"index": 99, "name": "HOH", "atoms": [{"index": 310, "name": "O", "element": "O"}, {"index": 311, "name": "H1", "element": "H"}, {"index": 312, "name": "H2", "element": "H"}], "resSeq": 100, "segmentID": ""}, {"index": 100, "name": "HOH", "atoms": [{"index": 313, "name": "O", "element": "O"}, {"index": 314, "name": "H1", "element": "H"}, {"index": 315, "name": "H2", "element": "H"}], "resSeq": 101, "segmentID": ""}, {"index": 101, "name": "HOH", "atoms": [{"index": 316, "name": "O", "element": "O"}, {"index": 317, "name": "H1", "element": "H"}, {"index": 318, "name": "H2", "element": "H"}], "resSeq": 102, "segmentID": ""}, {"index": 102, "name": "HOH", "atoms": [{"index": 319, "name": "O", "element": "O"}, {"index": 320, "name": "H1", "element": "H"}, {"index": 321, "name": "H2", "element": "H"}], "resSeq": 103, "segmentID": ""}, {"index": 103, "name": "HOH", "atoms": [{"index": 322, "name": "O", "element": "O"}, {"index": 323, "name": "H1", "element": "H"}, {"index": 324, "name": "H2", "element": "H"}], "resSeq": 104, "segmentID": ""}, {"index": 104, "name": "HOH", "atoms": [{"index": 325, "name": "O", "element": "O"}, {"index": 326, "name": "H1", "element": "H"}, {"index": 327, "name": "H2", "element": "H"}], "resSeq": 105, "segmentID": ""}, {"index": 105, "name": "HOH", "atoms": [{"index": 328, "name": "O", "element": "O"}, {"index": 329, "name": "H1", "element": "H"}, {"index": 330, "name": "H2", "element": "H"}], "resSeq": 106, "segmentID": ""}, {"index": 106, "name": "HOH", "atoms": [{"index": 331, "name": "O", "element": "O"}, {"index": 332, "name": "H1", "element": "H"}, {"index": 333, "name": "H2", "element": "H"}], "resSeq": 107, "segmentID": ""}, {"index": 107, "name": "HOH", "atoms": [{"index": 334, "name": "O", "element": "O"}, {"index": 335, "name": "H1", "element": "H"}, {"index": 336, "name": "H2", "element": "H"}], "resSeq": 108, "segmentID": ""}, {"index": 108, "name": "HOH", "atoms": [{"index": 337, "name": "O", "element": "O"}, {"index": 338, "name": "H1", "element": "H"}, {"index": 339, "name": "H2", "element": "H"}], "resSeq": 109, "segmentID": ""}, {"index": 109, "name": "HOH", "atoms": [{"index": 340, "name": "O", "element": "O"}, {"index": 341, "name": "H1", "element": "H"}, {"index": 342, "name": "H2", "element": "H"}], "resSeq": 110, "segmentID": ""}, {"index": 110, "name": "HOH", "atoms": [{"index": 343, "name": "O", "element": "O"}, {"index": 344, "name": "H1", "element": "H"}, {"index": 345, "name": "H2", "element": "H"}], "resSeq": 111, "segmentID": ""}, {"index": 111, "name": "HOH", "atoms": [{"index": 346, "name": "O", "element": "O"}, {"index": 347, "name": "H1", "element": "H"}, {"index": 348, "name": "H2", "element": "H"}], "resSeq": 112, "segmentID": ""}, {"index": 112, "name": "HOH", "atoms": [{"index": 349, "name": "O", "element": "O"}, {"index": 350, "name": "H1", "element": "H"}, {"index": 351, "name": "H2", "element": "H"}], "resSeq": 113, "segmentID": ""}, {"index": 113, "name": "HOH", "atoms": [{"index": 352, "name": "O", "element": "O"}, {"index": 353, "name": "H1", "element": "H"}, {"index": 354, "name": "H2", "element": "H"}], "resSeq": 114, "segmentID": ""}, {"index": 114, "name": "HOH", "atoms": [{"index": 355, "name": "O", "element": "O"}, {"index": 356, "name": "H1", "element": "H"}, {"index": 357, "name": "H2", "element": "H"}], "resSeq": 115, "segmentID": ""}, {"index": 115, "name": "HOH", "atoms": [{"index": 358, "name": "O", "element": "O"}, {"index": 359, "name": "H1", "element": "H"}, {"index": 360, "name": "H2", "element": "H"}], "resSeq": 116, "segmentID": ""}, {"index": 116, "name": "HOH", "atoms": [{"index": 361, "name": "O", "element": "O"}, {"index": 362, "name": "H1", "element": "H"}, {"index": 363, "name": "H2", "element": "H"}], "resSeq": 117, "segmentID": ""}, {"index": 117, "name": "HOH", "atoms": [{"index": 364, "name": "O", "element": "O"}, {"index": 365, "name": "H1", "element": "H"}, {"index": 366, "name": "H2", "element": "H"}], "resSeq": 118, "segmentID": ""}, {"index": 118, "name": "HOH", "atoms": [{"index": 367, "name": "O", "element": "O"}, {"index": 368, "name": "H1", "element": "H"}, {"index": 369, "name": "H2", "element": "H"}], "resSeq": 119, "segmentID": ""}, {"index": 119, "name": "HOH", "atoms": [{"index": 370, "name": "O", "element": "O"}, {"index": 371, "name": "H1", "element": "H"}, {"index": 372, "name": "H2", "element": "H"}], "resSeq": 120, "segmentID": ""}, {"index": 120, "name": "HOH", "atoms": [{"index": 373, "name": "O", "element": "O"}, {"index": 374, "name": "H1", "element": "H"}, {"index": 375, "name": "H2", "element": "H"}], "resSeq": 121, "segmentID": ""}, {"index": 121, "name": "HOH", "atoms": [{"index": 376, "name": "O", "element": "O"}, {"index": 377, "name": "H1", "element": "H"}, {"index": 378, "name": "H2", "element": "H"}], "resSeq": 122, "segmentID": ""}, {"index": 122, "name": "HOH", "atoms": [{"index": 379, "name": "O", "element": "O"}, {"index": 380, "name": "H1", "element": "H"}, {"index": 381, "name": "H2", "element": "H"}], "resSeq": 123, "segmentID": ""}, {"index": 123, "name": "HOH", "atoms": [{"index": 382, "name": "O", "element": "O"}, {"index": 383, "name": "H1", "element": "H"}, {"index": 384, "name": "H2", "element": "H"}], "resSeq": 124, "segmentID": ""}, {"index": 124, "name": "HOH", "atoms": [{"index": 385, "name": "O", "element": "O"}, {"index": 386, "name": "H1", "element": "H"}, {"index": 387, "name": "H2", "element": "H"}], "resSeq": 125, "segmentID": ""}, {"index": 125, "name": "HOH", "atoms": [{"index": 388, "name": "O", "element": "O"}, {"index": 389, "name": "H1", "element": "H"}, {"index": 390, "name": "H2", "element": "H"}], "resSeq": 126, "segmentID": ""}, {"index": 126, "name": "HOH", "atoms": [{"index": 391, "name": "O", "element": "O"}, {"index": 392, "name": "H1", "element": "H"}, {"index": 393, "name": "H2", "element": "H"}], "resSeq": 127, "segmentID": ""}, {"index": 127, "name": "HOH", "atoms": [{"index": 394, "name": "O", "element": "O"}, {"index": 395, "name": "H1", "element": "H"}, {"index": 396, "name": "H2", "element": "H"}], "resSeq": 128, "segmentID": ""}, {"index": 128, "name": "HOH", "atoms": [{"index": 397, "name": "O", "element": "O"}, {"index": 398, "name": "H1", "element": "H"}, {"index": 399, "name": "H2", "element": "H"}], "resSeq": 129, "segmentID": ""}, {"index": 129, "name": "HOH", "atoms": [{"index": 400, "name": "O", "element": "O"}, {"index": 401, "name": "H1", "element": "H"}, {"index": 402, "name": "H2", "element": "H"}], "resSeq": 130, "segmentID": ""}, {"index": 130, "name": "HOH", "atoms": [{"index": 403, "name": "O", "element": "O"}, {"index": 404, "name": "H1", "element": "H"}, {"index": 405, "name": "H2", "element": "H"}], "resSeq": 131, "segmentID": ""}, {"index": 131, "name": "HOH", "atoms": [{"index": 406, "name": "O", "element": "O"}, {"index": 407, "name": "H1", "element": "H"}, {"index": 408, "name": "H2", "element": "H"}], "resSeq": 132, "segmentID": ""}, {"index": 132, "name": "HOH", "atoms": [{"index": 409, "name": "O", "element": "O"}, {"index": 410, "name": "H1", "element": "H"}, {"index": 411, "name": "H2", "element": "H"}], "resSeq": 133, "segmentID": ""}, {"index": 133, "name": "HOH", "atoms": [{"index": 412, "name": "O", "element": "O"}, {"index": 413, "name": "H1", "element": "H"}, {"index": 414, "name": "H2", "element": "H"}], "resSeq": 134, "segmentID": ""}, {"index": 134, "name": "HOH", "atoms": [{"index": 415, "name": "O", "element": "O"}, {"index": 416, "name": "H1", "element": "H"}, {"index": 417, "name": "H2", "element": "H"}], "resSeq": 135, "segmentID": ""}, {"index": 135, "name": "HOH", "atoms": [{"index": 418, "name": "O", "element": "O"}, {"index": 419, "name": "H1", "element": "H"}, {"index": 420, "name": "H2", "element": "H"}], "resSeq": 136, "segmentID": ""}, {"index": 136, "name": "HOH", "atoms": [{"index": 421, "name": "O", "element": "O"}, {"index": 422, "name": "H1", "element": "H"}, {"index": 423, "name": "H2", "element": "H"}], "resSeq": 137, "segmentID": ""}, {"index": 137, "name": "HOH", "atoms": [{"index": 424, "name": "O", "element": "O"}, {"index": 425, "name": "H1", "element": "H"}, {"index": 426, "name": "H2", "element": "H"}], "resSeq": 138, "segmentID": ""}, {"index": 138, "name": "HOH", "atoms": [{"index": 427, "name": "O", "element": "O"}, {"index": 428, "name": "H1", "element": "H"}, {"index": 429, "name": "H2", "element": "H"}], "resSeq": 139, "segmentID": ""}, {"index": 139, "name": "HOH", "atoms": [{"index": 430, "name": "O", "element": "O"}, {"index": 431, "name": "H1", "element": "H"}, {"index": 432, "name": "H2", "element": "H"}], "resSeq": 140, "segmentID": ""}, {"index": 140, "name": "HOH", "atoms": [{"index": 433, "name": "O", "element": "O"}, {"index": 434, "name": "H1", "element": "H"}, {"index": 435, "name": "H2", "element": "H"}], "resSeq": 141, "segmentID": ""}, {"index": 141, "name": "HOH", "atoms": [{"index": 436, "name": "O", "element": "O"}, {"index": 437, "name": "H1", "element": "H"}, {"index": 438, "name": "H2", "element": "H"}], "resSeq": 142, "segmentID": ""}, {"index": 142, "name": "HOH", "atoms": [{"index": 439, "name": "O", "element": "O"}, {"index": 440, "name": "H1", "element": "H"}, {"index": 441, "name": "H2", "element": "H"}], "resSeq": 143, "segmentID": ""}, {"index": 143, "name": "HOH", "atoms": [{"index": 442, "name": "O", "element": "O"}, {"index": 443, "name": "H1", "element": "H"}, {"index": 444, "name": "H2", "element": "H"}], "resSeq": 144, "segmentID": ""}, {"index": 144, "name": "HOH", "atoms": [{"index": 445, "name": "O", "element": "O"}, {"index": 446, "name": "H1", "element": "H"}, {"index": 447, "name": "H2", "element": "H"}], "resSeq": 145, "segmentID": ""}, {"index": 145, "name": "HOH", "atoms": [{"index": 448, "name": "O", "element": "O"}, {"index": 449, "name": "H1", "element": "H"}, {"index": 450, "name": "H2", "element": "H"}], "resSeq": 146, "segmentID": ""}, {"index": 146, "name": "HOH", "atoms": [{"index": 451, "name": "O", "element": "O"}, {"index": 452, "name": "H1", "element": "H"}, {"index": 453, "name": "H2", "element": "H"}], "resSeq": 147, "segmentID": ""}, {"index": 147, "name": "HOH", "atoms": [{"index": 454, "name": "O", "element": "O"}, {"index": 455, "name": "H1", "element": "H"}, {"index": 456, "name": "H2", "element": "H"}], "resSeq": 148, "segmentID": ""}, {"index": 148, "name": "HOH", "atoms": [{"index": 457, "name": "O", "element": "O"}, {"index": 458, "name": "H1", "element": "H"}, {"index": 459, "name": "H2", "element": "H"}], "resSeq": 149, "segmentID": ""}, {"index": 149, "name": "HOH", "atoms": [{"index": 460, "name": "O", "element": "O"}, {"index": 461, "name": "H1", "element": "H"}, {"index": 462, "name": "H2", "element": "H"}], "resSeq": 150, "segmentID": ""}, {"index": 150, "name": "HOH", "atoms": [{"index": 463, "name": "O", "element": "O"}, {"index": 464, "name": "H1", "element": "H"}, {"index": 465, "name": "H2", "element": "H"}], "resSeq": 151, "segmentID": ""}, {"index": 151, "name": "HOH", "atoms": [{"index": 466, "name": "O", "element": "O"}, {"index": 467, "name": "H1", "element": "H"}, {"index": 468, "name": "H2", "element": "H"}], "resSeq": 152, "segmentID": ""}, {"index": 152, "name": "HOH", "atoms": [{"index": 469, "name": "O", "element": "O"}, {"index": 470, "name": "H1", "element": "H"}, {"index": 471, "name": "H2", "element": "H"}], "resSeq": 153, "segmentID": ""}, {"index": 153, "name": "HOH", "atoms": [{"index": 472, "name": "O", "element": "O"}, {"index": 473, "name": "H1", "element": "H"}, {"index": 474, "name": "H2", "element": "H"}], "resSeq": 154, "segmentID": ""}, {"index": 154, "name": "HOH", "atoms": [{"index": 475, "name": "O", "element": "O"}, {"index": 476, "name": "H1", "element": "H"}, {"index": 477, "name": "H2", "element": "H"}], "resSeq": 155, "segmentID": ""}, {"index": 155, "name": "HOH", "atoms": [{"index": 478, "name": "O", "element": "O"}, {"index": 479, "name": "H1", "element": "H"}, {"index": 480, "name": "H2", "element": "H"}], "resSeq": 156, "segmentID": ""}, {"index": 156, "name": "HOH", "atoms": [{"index": 481, "name": "O", "element": "O"}, {"index": 482, "name": "H1", "element": "H"}, {"index": 483, "name": "H2", "element": "H"}], "resSeq": 157, "segmentID": ""}, {"index": 157, "name": "HOH", "atoms": [{"index": 484, "name": "O", "element": "O"}, {"index": 485, "name": "H1", "element": "H"}, {"index": 486, "name": "H2", "element": "H"}], "resSeq": 158, "segmentID": ""}, {"index": 158, "name": "HOH", "atoms": [{"index": 487, "name": "O", "element": "O"}, {"index": 488, "name": "H1", "element": "H"}, {"index": 489, "name": "H2", "element": "H"}], "resSeq": 159, "segmentID": ""}, {"index": 159, "name": "HOH", "atoms": [{"index": 490, "name": "O", "element": "O"}, {"index": 491, "name": "H1", "element": "H"}, {"index": 492, "name": "H2", "element": "H"}], "resSeq": 160, "segmentID": ""}, {"index": 160, "name": "HOH", "atoms": [{"index": 493, "name": "O", "element": "O"}, {"index": 494, "name": "H1", "element": "H"}, {"index": 495, "name": "H2", "element": "H"}], "resSeq": 161, "segmentID": ""}, {"index": 161, "name": "HOH", "atoms": [{"index": 496, "name": "O", "element": "O"}, {"index": 497, "name": "H1", "element": "H"}, {"index": 498, "name": "H2", "element": "H"}], "resSeq": 162, "segmentID": ""}, {"index": 162, "name": "HOH", "atoms": [{"index": 499, "name": "O", "element": "O"}, {"index": 500, "name": "H1", "element": "H"}, {"index": 501, "name": "H2", "element": "H"}], "resSeq": 163, "segmentID": ""}, {"index": 163, "name": "HOH", "atoms": [{"index": 502, "name": "O", "element": "O"}, {"index": 503, "name": "H1", "element": "H"}, {"index": 504, "name": "H2", "element": "H"}], "resSeq": 164, "segmentID": ""}, {"index": 164, "name": "HOH", "atoms": [{"index": 505, "name": "O", "element": "O"}, {"index": 506, "name": "H1", "element": "H"}, {"index": 507, "name": "H2", "element": "H"}], "resSeq": 165, "segmentID": ""}, {"index": 165, "name": "HOH", "atoms": [{"index": 508, "name": "O", "element": "O"}, {"index": 509, "name": "H1", "element": "H"}, {"index": 510, "name": "H2", "element": "H"}], "resSeq": 166, "segmentID": ""}, {"index": 166, "name": "HOH", "atoms": [{"index": 511, "name": "O", "element": "O"}, {"index": 512, "name": "H1", "element": "H"}, {"index": 513, "name": "H2", "element": "H"}], "resSeq": 167, "segmentID": ""}, {"index": 167, "name": "HOH", "atoms": [{"index": 514, "name": "O", "element": "O"}, {"index": 515, "name": "H1", "element": "H"}, {"index": 516, "name": "H2", "element": "H"}], "resSeq": 168, "segmentID": ""}, {"index": 168, "name": "HOH", "atoms": [{"index": 517, "name": "O", "element": "O"}, {"index": 518, "name": "H1", "element": "H"}, {"index": 519, "name": "H2", "element": "H"}], "resSeq": 169, "segmentID": ""}, {"index": 169, "name": "HOH", "atoms": [{"index": 520, "name": "O", "element": "O"}, {"index": 521, "name": "H1", "element": "H"}, {"index": 522, "name": "H2", "element": "H"}], "resSeq": 170, "segmentID": ""}, {"index": 170, "name": "HOH", "atoms": [{"index": 523, "name": "O", "element": "O"}, {"index": 524, "name": "H1", "element": "H"}, {"index": 525, "name": "H2", "element": "H"}], "resSeq": 171, "segmentID": ""}, {"index": 171, "name": "HOH", "atoms": [{"index": 526, "name": "O", "element": "O"}, {"index": 527, "name": "H1", "element": "H"}, {"index": 528, "name": "H2", "element": "H"}], "resSeq": 172, "segmentID": ""}, {"index": 172, "name": "HOH", "atoms": [{"index": 529, "name": "O", "element": "O"}, {"index": 530, "name": "H1", "element": "H"}, {"index": 531, "name": "H2", "element": "H"}], "resSeq": 173, "segmentID": ""}, {"index": 173, "name": "HOH", "atoms": [{"index": 532, "name": "O", "element": "O"}, {"index": 533, "name": "H1", "element": "H"}, {"index": 534, "name": "H2", "element": "H"}], "resSeq": 174, "segmentID": ""}, {"index": 174, "name": "HOH", "atoms": [{"index": 535, "name": "O", "element": "O"}, {"index": 536, "name": "H1", "element": "H"}, {"index": 537, "name": "H2", "element": "H"}], "resSeq": 175, "segmentID": ""}, {"index": 175, "name": "HOH", "atoms": [{"index": 538, "name": "O", "element": "O"}, {"index": 539, "name": "H1", "element": "H"}, {"index": 540, "name": "H2", "element": "H"}], "resSeq": 176, "segmentID": ""}, {"index": 176, "name": "HOH", "atoms": [{"index": 541, "name": "O", "element": "O"}, {"index": 542, "name": "H1", "element": "H"}, {"index": 543, "name": "H2", "element": "H"}], "resSeq": 177, "segmentID": ""}, {"index": 177, "name": "HOH", "atoms": [{"index": 544, "name": "O", "element": "O"}, {"index": 545, "name": "H1", "element": "H"}, {"index": 546, "name": "H2", "element": "H"}], "resSeq": 178, "segmentID": ""}, {"index": 178, "name": "HOH", "atoms": [{"index": 547, "name": "O", "element": "O"}, {"index": 548, "name": "H1", "element": "H"}, {"index": 549, "name": "H2", "element": "H"}], "resSeq": 179, "segmentID": ""}, {"index": 179, "name": "HOH", "atoms": [{"index": 550, "name": "O", "element": "O"}, {"index": 551, "name": "H1", "element": "H"}, {"index": 552, "name": "H2", "element": "H"}], "resSeq": 180, "segmentID": ""}, {"index": 180, "name": "HOH", "atoms": [{"index": 553, "name": "O", "element": "O"}, {"index": 554, "name": "H1", "element": "H"}, {"index": 555, "name": "H2", "element": "H"}], "resSeq": 181, "segmentID": ""}, {"index": 181, "name": "HOH", "atoms": [{"index": 556, "name": "O", "element": "O"}, {"index": 557, "name": "H1", "element": "H"}, {"index": 558, "name": "H2", "element": "H"}], "resSeq": 182, "segmentID": ""}, {"index": 182, "name": "HOH", "atoms": [{"index": 559, "name": "O", "element": "O"}, {"index": 560, "name": "H1", "element": "H"}, {"index": 561, "name": "H2", "element": "H"}], "resSeq": 183, "segmentID": ""}, {"index": 183, "name": "HOH", "atoms": [{"index": 562, "name": "O", "element": "O"}, {"index": 563, "name": "H1", "element": "H"}, {"index": 564, "name": "H2", "element": "H"}], "resSeq": 184, "segmentID": ""}, {"index": 184, "name": "HOH", "atoms": [{"index": 565, "name": "O", "element": "O"}, {"index": 566, "name": "H1", "element": "H"}, {"index": 567, "name": "H2", "element": "H"}], "resSeq": 185, "segmentID": ""}, {"index": 185, "name": "HOH", "atoms": [{"index": 568, "name": "O", "element": "O"}, {"index": 569, "name": "H1", "element": "H"}, {"index": 570, "name": "H2", "element": "H"}], "resSeq": 186, "segmentID": ""}, {"index": 186, "name": "HOH", "atoms": [{"index": 571, "name": "O", "element": "O"}, {"index": 572, "name": "H1", "element": "H"}, {"index": 573, "name": "H2", "element": "H"}], "resSeq": 187, "segmentID": ""}, {"index": 187, "name": "HOH", "atoms": [{"index": 574, "name": "O", "element": "O"}, {"index": 575, "name": "H1", "element": "H"}, {"index": 576, "name": "H2", "element": "H"}], "resSeq": 188, "segmentID": ""}, {"index": 188, "name": "HOH", "atoms": [{"index": 577, "name": "O", "element": "O"}, {"index": 578, "name": "H1", "element": "H"}, {"index": 579, "name": "H2", "element": "H"}], "resSeq": 189, "segmentID": ""}, {"index": 189, "name": "HOH", "atoms": [{"index": 580, "name": "O", "element": "O"}, {"index": 581, "name": "H1", "element": "H"}, {"index": 582, "name": "H2", "element": "H"}], "resSeq": 190, "segmentID": ""}, {"index": 190, "name": "HOH", "atoms": [{"index": 583, "name": "O", "element": "O"}, {"index": 584, "name": "H1", "element": "H"}, {"index": 585, "name": "H2", "element": "H"}], "resSeq": 191, "segmentID": ""}, {"index": 191, "name": "HOH", "atoms": [{"index": 586, "name": "O", "element": "O"}, {"index": 587, "name": "H1", "element": "H"}, {"index": 588, "name": "H2", "element": "H"}], "resSeq": 192, "segmentID": ""}, {"index": 192, "name": "HOH", "atoms": [{"index": 589, "name": "O", "element": "O"}, {"index": 590, "name": "H1", "element": "H"}, {"index": 591, "name": "H2", "element": "H"}], "resSeq": 193, "segmentID": ""}, {"index": 193, "name": "HOH", "atoms": [{"index": 592, "name": "O", "element": "O"}, {"index": 593, "name": "H1", "element": "H"}, {"index": 594, "name": "H2", "element": "H"}], "resSeq": 194, "segmentID": ""}, {"index": 194, "name": "HOH", "atoms": [{"index": 595, "name": "O", "element": "O"}, {"index": 596, "name": "H1", "element": "H"}, {"index": 597, "name": "H2", "element": "H"}], "resSeq": 195, "segmentID": ""}, {"index": 195, "name": "HOH", "atoms": [{"index": 598, "name": "O", "element": "O"}, {"index": 599, "name": "H1", "element": "H"}, {"index": 600, "name": "H2", "element": "H"}], "resSeq": 196, "segmentID": ""}, {"index": 196, "name": "HOH", "atoms": [{"index": 601, "name": "O", "element": "O"}, {"index": 602, "name": "H1", "element": "H"}, {"index": 603, "name": "H2", "element": "H"}], "resSeq": 197, "segmentID": ""}, {"index": 197, "name": "HOH", "atoms": [{"index": 604, "name": "O", "element": "O"}, {"index": 605, "name": "H1", "element": "H"}, {"index": 606, "name": "H2", "element": "H"}], "resSeq": 198, "segmentID": ""}, {"index": 198, "name": "HOH", "atoms": [{"index": 607, "name": "O", "element": "O"}, {"index": 608, "name": "H1", "element": "H"}, {"index": 609, "name": "H2", "element": "H"}], "resSeq": 199, "segmentID": ""}, {"index": 199, "name": "HOH", "atoms": [{"index": 610, "name": "O", "element": "O"}, {"index": 611, "name": "H1", "element": "H"}, {"index": 612, "name": "H2", "element": "H"}], "resSeq": 200, "segmentID": ""}, {"index": 200, "name": "HOH", "atoms": [{"index": 613, "name": "O", "element": "O"}, {"index": 614, "name": "H1", "element": "H"}, {"index": 615, "name": "H2", "element": "H"}], "resSeq": 201, "segmentID": ""}, {"index": 201, "name": "HOH", "atoms": [{"index": 616, "name": "O", "element": "O"}, {"index": 617, "name": "H1", "element": "H"}, {"index": 618, "name": "H2", "element": "H"}], "resSeq": 202, "segmentID": ""}, {"index": 202, "name": "HOH", "atoms": [{"index": 619, "name": "O", "element": "O"}, {"index": 620, "name": "H1", "element": "H"}, {"index": 621, "name": "H2", "element": "H"}], "resSeq": 203, "segmentID": ""}, {"index": 203, "name": "HOH", "atoms": [{"index": 622, "name": "O", "element": "O"}, {"index": 623, "name": "H1", "element": "H"}, {"index": 624, "name": "H2", "element": "H"}], "resSeq": 204, "segmentID": ""}, {"index": 204, "name": "HOH", "atoms": [{"index": 625, "name": "O", "element": "O"}, {"index": 626, "name": "H1", "element": "H"}, {"index": 627, "name": "H2", "element": "H"}], "resSeq": 205, "segmentID": ""}, {"index": 205, "name": "HOH", "atoms": [{"index": 628, "name": "O", "element": "O"}, {"index": 629, "name": "H1", "element": "H"}, {"index": 630, "name": "H2", "element": "H"}], "resSeq": 206, "segmentID": ""}, {"index": 206, "name": "HOH", "atoms": [{"index": 631, "name": "O", "element": "O"}, {"index": 632, "name": "H1", "element": "H"}, {"index": 633, "name": "H2", "element": "H"}], "resSeq": 207, "segmentID": ""}, {"index": 207, "name": "HOH", "atoms": [{"index": 634, "name": "O", "element": "O"}, {"index": 635, "name": "H1", "element": "H"}, {"index": 636, "name": "H2", "element": "H"}], "resSeq": 208, "segmentID": ""}, {"index": 208, "name": "HOH", "atoms": [{"index": 637, "name": "O", "element": "O"}, {"index": 638, "name": "H1", "element": "H"}, {"index": 639, "name": "H2", "element": "H"}], "resSeq": 209, "segmentID": ""}, {"index": 209, "name": "HOH", "atoms": [{"index": 640, "name": "O", "element": "O"}, {"index": 641, "name": "H1", "element": "H"}, {"index": 642, "name": "H2", "element": "H"}], "resSeq": 210, "segmentID": ""}, {"index": 210, "name": "HOH", "atoms": [{"index": 643, "name": "O", "element": "O"}, {"index": 644, "name": "H1", "element": "H"}, {"index": 645, "name": "H2", "element": "H"}], "resSeq": 211, "segmentID": ""}, {"index": 211, "name": "HOH", "atoms": [{"index": 646, "name": "O", "element": "O"}, {"index": 647, "name": "H1", "element": "H"}, {"index": 648, "name": "H2", "element": "H"}], "resSeq": 212, "segmentID": ""}, {"index": 212, "name": "HOH", "atoms": [{"index": 649, "name": "O", "element": "O"}, {"index": 650, "name": "H1", "element": "H"}, {"index": 651, "name": "H2", "element": "H"}], "resSeq": 213, "segmentID": ""}, {"index": 213, "name": "HOH", "atoms": [{"index": 652, "name": "O", "element": "O"}, {"index": 653, "name": "H1", "element": "H"}, {"index": 654, "name": "H2", "element": "H"}], "resSeq": 214, "segmentID": ""}, {"index": 214, "name": "HOH", "atoms": [{"index": 655, "name": "O", "element": "O"}, {"index": 656, "name": "H1", "element": "H"}, {"index": 657, "name": "H2", "element": "H"}], "resSeq": 215, "segmentID": ""}, {"index": 215, "name": "HOH", "atoms": [{"index": 658, "name": "O", "element": "O"}, {"index": 659, "name": "H1", "element": "H"}, {"index": 660, "name": "H2", "element": "H"}], "resSeq": 216, "segmentID": ""}, {"index": 216, "name": "HOH", "atoms": [{"index": 661, "name": "O", "element": "O"}, {"index": 662, "name": "H1", "element": "H"}, {"index": 663, "name": "H2", "element": "H"}], "resSeq": 217, "segmentID": ""}, {"index": 217, "name": "HOH", "atoms": [{"index": 664, "name": "O", "element": "O"}, {"index": 665, "name": "H1", "element": "H"}, {"index": 666, "name": "H2", "element": "H"}], "resSeq": 218, "segmentID": ""}, {"index": 218, "name": "HOH", "atoms": [{"index": 667, "name": "O", "element": "O"}, {"index": 668, "name": "H1", "element": "H"}, {"index": 669, "name": "H2", "element": "H"}], "resSeq": 219, "segmentID": ""}, {"index": 219, "name": "HOH", "atoms": [{"index": 670, "name": "O", "element": "O"}, {"index": 671, "name": "H1", "element": "H"}, {"index": 672, "name": "H2", "element": "H"}], "resSeq": 220, "segmentID": ""}, {"index": 220, "name": "HOH", "atoms": [{"index": 673, "name": "O", "element": "O"}, {"index": 674, "name": "H1", "element": "H"}, {"index": 675, "name": "H2", "element": "H"}], "resSeq": 221, "segmentID": ""}, {"index": 221, "name": "HOH", "atoms": [{"index": 676, "name": "O", "element": "O"}, {"index": 677, "name": "H1", "element": "H"}, {"index": 678, "name": "H2", "element": "H"}], "resSeq": 222, "segmentID": ""}, {"index": 222, "name": "HOH", "atoms": [{"index": 679, "name": "O", "element": "O"}, {"index": 680, "name": "H1", "element": "H"}, {"index": 681, "name": "H2", "element": "H"}], "resSeq": 223, "segmentID": ""}, {"index": 223, "name": "HOH", "atoms": [{"index": 682, "name": "O", "element": "O"}, {"index": 683, "name": "H1", "element": "H"}, {"index": 684, "name": "H2", "element": "H"}], "resSeq": 224, "segmentID": ""}, {"index": 224, "name": "HOH", "atoms": [{"index": 685, "name": "O", "element": "O"}, {"index": 686, "name": "H1", "element": "H"}, {"index": 687, "name": "H2", "element": "H"}], "resSeq": 225, "segmentID": ""}, {"index": 225, "name": "HOH", "atoms": [{"index": 688, "name": "O", "element": "O"}, {"index": 689, "name": "H1", "element": "H"}, {"index": 690, "name": "H2", "element": "H"}], "resSeq": 226, "segmentID": ""}, {"index": 226, "name": "HOH", "atoms": [{"index": 691, "name": "O", "element": "O"}, {"index": 692, "name": "H1", "element": "H"}, {"index": 693, "name": "H2", "element": "H"}], "resSeq": 227, "segmentID": ""}, {"index": 227, "name": "HOH", "atoms": [{"index": 694, "name": "O", "element": "O"}, {"index": 695, "name": "H1", "element": "H"}, {"index": 696, "name": "H2", "element": "H"}], "resSeq": 228, "segmentID": ""}, {"index": 228, "name": "HOH", "atoms": [{"index": 697, "name": "O", "element": "O"}, {"index": 698, "name": "H1", "element": "H"}, {"index": 699, "name": "H2", "element": "H"}], "resSeq": 229, "segmentID": ""}, {"index": 229, "name": "HOH", "atoms": [{"index": 700, "name": "O", "element": "O"}, {"index": 701, "name": "H1", "element": "H"}, {"index": 702, "name": "H2", "element": "H"}], "resSeq": 230, "segmentID": ""}, {"index": 230, "name": "HOH", "atoms": [{"index": 703, "name": "O", "element": "O"}, {"index": 704, "name": "H1", "element": "H"}, {"index": 705, "name": "H2", "element": "H"}], "resSeq": 231, "segmentID": ""}, {"index": 231, "name": "HOH", "atoms": [{"index": 706, "name": "O", "element": "O"}, {"index": 707, "name": "H1", "element": "H"}, {"index": 708, "name": "H2", "element": "H"}], "resSeq": 232, "segmentID": ""}, {"index": 232, "name": "HOH", "atoms": [{"index": 709, "name": "O", "element": "O"}, {"index": 710, "name": "H1", "element": "H"}, {"index": 711, "name": "H2", "element": "H"}], "resSeq": 233, "segmentID": ""}, {"index": 233, "name": "HOH", "atoms": [{"index": 712, "name": "O", "element": "O"}, {"index": 713, "name": "H1", "element": "H"}, {"index": 714, "name": "H2", "element": "H"}], "resSeq": 234, "segmentID": ""}, {"index": 234, "name": "HOH", "atoms": [{"index": 715, "name": "O", "element": "O"}, {"index": 716, "name": "H1", "element": "H"}, {"index": 717, "name": "H2", "element": "H"}], "resSeq": 235, "segmentID": ""}, {"index": 235, "name": "HOH", "atoms": [{"index": 718, "name": "O", "element": "O"}, {"index": 719, "name": "H1", "element": "H"}, {"index": 720, "name": "H2", "element": "H"}], "resSeq": 236, "segmentID": ""}, {"index": 236, "name": "HOH", "atoms": [{"index": 721, "name": "O", "element": "O"}, {"index": 722, "name": "H1", "element": "H"}, {"index": 723, "name": "H2", "element": "H"}], "resSeq": 237, "segmentID": ""}, {"index": 237, "name": "HOH", "atoms": [{"index": 724, "name": "O", "element": "O"}, {"index": 725, "name": "H1", "element": "H"}, {"index": 726, "name": "H2", "element": "H"}], "resSeq": 238, "segmentID": ""}, {"index": 238, "name": "HOH", "atoms": [{"index": 727, "name": "O", "element": "O"}, {"index": 728, "name": "H1", "element": "H"}, {"index": 729, "name": "H2", "element": "H"}], "resSeq": 239, "segmentID": ""}, {"index": 239, "name": "HOH", "atoms": [{"index": 730, "name": "O", "element": "O"}, {"index": 731, "name": "H1", "element": "H"}, {"index": 732, "name": "H2", "element": "H"}], "resSeq": 240, "segmentID": ""}, {"index": 240, "name": "HOH", "atoms": [{"index": 733, "name": "O", "element": "O"}, {"index": 734, "name": "H1", "element": "H"}, {"index": 735, "name": "H2", "element": "H"}], "resSeq": 241, "segmentID": ""}, {"index": 241, "name": "HOH", "atoms": [{"index": 736, "name": "O", "element": "O"}, {"index": 737, "name": "H1", "element": "H"}, {"index": 738, "name": "H2", "element": "H"}], "resSeq": 242, "segmentID": ""}, {"index": 242, "name": "HOH", "atoms": [{"index": 739, "name": "O", "element": "O"}, {"index": 740, "name": "H1", "element": "H"}, {"index": 741, "name": "H2", "element": "H"}], "resSeq": 243, "segmentID": ""}, {"index": 243, "name": "HOH", "atoms": [{"index": 742, "name": "O", "element": "O"}, {"index": 743, "name": "H1", "element": "H"}, {"index": 744, "name": "H2", "element": "H"}], "resSeq": 244, "segmentID": ""}, {"index": 244, "name": "HOH", "atoms": [{"index": 745, "name": "O", "element": "O"}, {"index": 746, "name": "H1", "element": "H"}, {"index": 747, "name": "H2", "element": "H"}], "resSeq": 245, "segmentID": ""}, {"index": 245, "name": "HOH", "atoms": [{"index": 748, "name": "O", "element": "O"}, {"index": 749, "name": "H1", "element": "H"}, {"index": 750, "name": "H2", "element": "H"}], "resSeq": 246, "segmentID": ""}, {"index": 246, "name": "HOH", "atoms": [{"index": 751, "name": "O", "element": "O"}, {"index": 752, "name": "H1", "element": "H"}, {"index": 753, "name": "H2", "element": "H"}], "resSeq": 247, "segmentID": ""}, {"index": 247, "name": "HOH", "atoms": [{"index": 754, "name": "O", "element": "O"}, {"index": 755, "name": "H1", "element": "H"}, {"index": 756, "name": "H2", "element": "H"}], "resSeq": 248, "segmentID": ""}, {"index": 248, "name": "HOH", "atoms": [{"index": 757, "name": "O", "element": "O"}, {"index": 758, "name": "H1", "element": "H"}, {"index": 759, "name": "H2", "element": "H"}], "resSeq": 249, "segmentID": ""}, {"index": 249, "name": "HOH", "atoms": [{"index": 760, "name": "O", "element": "O"}, {"index": 761, "name": "H1", "element": "H"}, {"index": 762, "name": "H2", "element": "H"}], "resSeq": 250, "segmentID": ""}, {"index": 250, "name": "HOH", "atoms": [{"index": 763, "name": "O", "element": "O"}, {"index": 764, "name": "H1", "element": "H"}, {"index": 765, "name": "H2", "element": "H"}], "resSeq": 251, "segmentID": ""}, {"index": 251, "name": "HOH", "atoms": [{"index": 766, "name": "O", "element": "O"}, {"index": 767, "name": "H1", "element": "H"}, {"index": 768, "name": "H2", "element": "H"}], "resSeq": 252, "segmentID": ""}, {"index": 252, "name": "HOH", "atoms": [{"index": 769, "name": "O", "element": "O"}, {"index": 770, "name": "H1", "element": "H"}, {"index": 771, "name": "H2", "element": "H"}], "resSeq": 253, "segmentID": ""}, {"index": 253, "name": "HOH", "atoms": [{"index": 772, "name": "O", "element": "O"}, {"index": 773, "name": "H1", "element": "H"}, {"index": 774, "name": "H2", "element": "H"}], "resSeq": 254, "segmentID": ""}, {"index": 254, "name": "HOH", "atoms": [{"index": 775, "name": "O", "element": "O"}, {"index": 776, "name": "H1", "element": "H"}, {"index": 777, "name": "H2", "element": "H"}], "resSeq": 255, "segmentID": ""}, {"index": 255, "name": "HOH", "atoms": [{"index": 778, "name": "O", "element": "O"}, {"index": 779, "name": "H1", "element": "H"}, {"index": 780, "name": "H2", "element": "H"}], "resSeq": 256, "segmentID": ""}, {"index": 256, "name": "HOH", "atoms": [{"index": 781, "name": "O", "element": "O"}, {"index": 782, "name": "H1", "element": "H"}, {"index": 783, "name": "H2", "element": "H"}], "resSeq": 257, "segmentID": ""}, {"index": 257, "name": "HOH", "atoms": [{"index": 784, "name": "O", "element": "O"}, {"index": 785, "name": "H1", "element": "H"}, {"index": 786, "name": "H2", "element": "H"}], "resSeq": 258, "segmentID": ""}, {"index": 258, "name": "HOH", "atoms": [{"index": 787, "name": "O", "element": "O"}, {"index": 788, "name": "H1", "element": "H"}, {"index": 789, "name": "H2", "element": "H"}], "resSeq": 259, "segmentID": ""}, {"index": 259, "name": "HOH", "atoms": [{"index": 790, "name": "O", "element": "O"}, {"index": 791, "name": "H1", "element": "H"}, {"index": 792, "name": "H2", "element": "H"}], "resSeq": 260, "segmentID": ""}, {"index": 260, "name": "HOH", "atoms": [{"index": 793, "name": "O", "element": "O"}, {"index": 794, "name": "H1", "element": "H"}, {"index": 795, "name": "H2", "element": "H"}], "resSeq": 261, "segmentID": ""}, {"index": 261, "name": "HOH", "atoms": [{"index": 796, "name": "O", "element": "O"}, {"index": 797, "name": "H1", "element": "H"}, {"index": 798, "name": "H2", "element": "H"}], "resSeq": 262, "segmentID": ""}, {"index": 262, "name": "HOH", "atoms": [{"index": 799, "name": "O", "element": "O"}, {"index": 800, "name": "H1", "element": "H"}, {"index": 801, "name": "H2", "element": "H"}], "resSeq": 263, "segmentID": ""}, {"index": 263, "name": "HOH", "atoms": [{"index": 802, "name": "O", "element": "O"}, {"index": 803, "name": "H1", "element": "H"}, {"index": 804, "name": "H2", "element": "H"}], "resSeq": 264, "segmentID": ""}, {"index": 264, "name": "HOH", "atoms": [{"index": 805, "name": "O", "element": "O"}, {"index": 806, "name": "H1", "element": "H"}, {"index": 807, "name": "H2", "element": "H"}], "resSeq": 265, "segmentID": ""}, {"index": 265, "name": "HOH", "atoms": [{"index": 808, "name": "O", "element": "O"}, {"index": 809, "name": "H1", "element": "H"}, {"index": 810, "name": "H2", "element": "H"}], "resSeq": 266, "segmentID": ""}, {"index": 266, "name": "HOH", "atoms": [{"index": 811, "name": "O", "element": "O"}, {"index": 812, "name": "H1", "element": "H"}, {"index": 813, "name": "H2", "element": "H"}], "resSeq": 267, "segmentID": ""}, {"index": 267, "name": "HOH", "atoms": [{"index": 814, "name": "O", "element": "O"}, {"index": 815, "name": "H1", "element": "H"}, {"index": 816, "name": "H2", "element": "H"}], "resSeq": 268, "segmentID": ""}, {"index": 268, "name": "HOH", "atoms": [{"index": 817, "name": "O", "element": "O"}, {"index": 818, "name": "H1", "element": "H"}, {"index": 819, "name": "H2", "element": "H"}], "resSeq": 269, "segmentID": ""}, {"index": 269, "name": "HOH", "atoms": [{"index": 820, "name": "O", "element": "O"}, {"index": 821, "name": "H1", "element": "H"}, {"index": 822, "name": "H2", "element": "H"}], "resSeq": 270, "segmentID": ""}, {"index": 270, "name": "HOH", "atoms": [{"index": 823, "name": "O", "element": "O"}, {"index": 824, "name": "H1", "element": "H"}, {"index": 825, "name": "H2", "element": "H"}], "resSeq": 271, "segmentID": ""}, {"index": 271, "name": "HOH", "atoms": [{"index": 826, "name": "O", "element": "O"}, {"index": 827, "name": "H1", "element": "H"}, {"index": 828, "name": "H2", "element": "H"}], "resSeq": 272, "segmentID": ""}, {"index": 272, "name": "HOH", "atoms": [{"index": 829, "name": "O", "element": "O"}, {"index": 830, "name": "H1", "element": "H"}, {"index": 831, "name": "H2", "element": "H"}], "resSeq": 273, "segmentID": ""}, {"index": 273, "name": "HOH", "atoms": [{"index": 832, "name": "O", "element": "O"}, {"index": 833, "name": "H1", "element": "H"}, {"index": 834, "name": "H2", "element": "H"}], "resSeq": 274, "segmentID": ""}, {"index": 274, "name": "HOH", "atoms": [{"index": 835, "name": "O", "element": "O"}, {"index": 836, "name": "H1", "element": "H"}, {"index": 837, "name": "H2", "element": "H"}], "resSeq": 275, "segmentID": ""}, {"index": 275, "name": "HOH", "atoms": [{"index": 838, "name": "O", "element": "O"}, {"index": 839, "name": "H1", "element": "H"}, {"index": 840, "name": "H2", "element": "H"}], "resSeq": 276, "segmentID": ""}, {"index": 276, "name": "HOH", "atoms": [{"index": 841, "name": "O", "element": "O"}, {"index": 842, "name": "H1", "element": "H"}, {"index": 843, "name": "H2", "element": "H"}], "resSeq": 277, "segmentID": ""}, {"index": 277, "name": "HOH", "atoms": [{"index": 844, "name": "O", "element": "O"}, {"index": 845, "name": "H1", "element": "H"}, {"index": 846, "name": "H2", "element": "H"}], "resSeq": 278, "segmentID": ""}, {"index": 278, "name": "HOH", "atoms": [{"index": 847, "name": "O", "element": "O"}, {"index": 848, "name": "H1", "element": "H"}, {"index": 849, "name": "H2", "element": "H"}], "resSeq": 279, "segmentID": ""}, {"index": 279, "name": "HOH", "atoms": [{"index": 850, "name": "O", "element": "O"}, {"index": 851, "name": "H1", "element": "H"}, {"index": 852, "name": "H2", "element": "H"}], "resSeq": 280, "segmentID": ""}, {"index": 280, "name": "HOH", "atoms": [{"index": 853, "name": "O", "element": "O"}, {"index": 854, "name": "H1", "element": "H"}, {"index": 855, "name": "H2", "element": "H"}], "resSeq": 281, "segmentID": ""}, {"index": 281, "name": "HOH", "atoms": [{"index": 856, "name": "O", "element": "O"}, {"index": 857, "name": "H1", "element": "H"}, {"index": 858, "name": "H2", "element": "H"}], "resSeq": 282, "segmentID": ""}, {"index": 282, "name": "HOH", "atoms": [{"index": 859, "name": "O", "element": "O"}, {"index": 860, "name": "H1", "element": "H"}, {"index": 861, "name": "H2", "element": "H"}], "resSeq": 283, "segmentID": ""}, {"index": 283, "name": "HOH", "atoms": [{"index": 862, "name": "O", "element": "O"}, {"index": 863, "name": "H1", "element": "H"}, {"index": 864, "name": "H2", "element": "H"}], "resSeq": 284, "segmentID": ""}, {"index": 284, "name": "HOH", "atoms": [{"index": 865, "name": "O", "element": "O"}, {"index": 866, "name": "H1", "element": "H"}, {"index": 867, "name": "H2", "element": "H"}], "resSeq": 285, "segmentID": ""}, {"index": 285, "name": "HOH", "atoms": [{"index": 868, "name": "O", "element": "O"}, {"index": 869, "name": "H1", "element": "H"}, {"index": 870, "name": "H2", "element": "H"}], "resSeq": 286, "segmentID": ""}, {"index": 286, "name": "HOH", "atoms": [{"index": 871, "name": "O", "element": "O"}, {"index": 872, "name": "H1", "element": "H"}, {"index": 873, "name": "H2", "element": "H"}], "resSeq": 287, "segmentID": ""}, {"index": 287, "name": "HOH", "atoms": [{"index": 874, "name": "O", "element": "O"}, {"index": 875, "name": "H1", "element": "H"}, {"index": 876, "name": "H2", "element": "H"}], "resSeq": 288, "segmentID": ""}, {"index": 288, "name": "HOH", "atoms": [{"index": 877, "name": "O", "element": "O"}, {"index": 878, "name": "H1", "element": "H"}, {"index": 879, "name": "H2", "element": "H"}], "resSeq": 289, "segmentID": ""}, {"index": 289, "name": "HOH", "atoms": [{"index": 880, "name": "O", "element": "O"}, {"index": 881, "name": "H1", "element": "H"}, {"index": 882, "name": "H2", "element": "H"}], "resSeq": 290, "segmentID": ""}, {"index": 290, "name": "HOH", "atoms": [{"index": 883, "name": "O", "element": "O"}, {"index": 884, "name": "H1", "element": "H"}, {"index": 885, "name": "H2", "element": "H"}], "resSeq": 291, "segmentID": ""}, {"index": 291, "name": "HOH", "atoms": [{"index": 886, "name": "O", "element": "O"}, {"index": 887, "name": "H1", "element": "H"}, {"index": 888, "name": "H2", "element": "H"}], "resSeq": 292, "segmentID": ""}, {"index": 292, "name": "HOH", "atoms": [{"index": 889, "name": "O", "element": "O"}, {"index": 890, "name": "H1", "element": "H"}, {"index": 891, "name": "H2", "element": "H"}], "resSeq": 293, "segmentID": ""}, {"index": 293, "name": "HOH", "atoms": [{"index": 892, "name": "O", "element": "O"}, {"index": 893, "name": "H1", "element": "H"}, {"index": 894, "name": "H2", "element": "H"}], "resSeq": 294, "segmentID": ""}, {"index": 294, "name": "HOH", "atoms": [{"index": 895, "name": "O", "element": "O"}, {"index": 896, "name": "H1", "element": "H"}, {"index": 897, "name": "H2", "element": "H"}], "resSeq": 295, "segmentID": ""}, {"index": 295, "name": "HOH", "atoms": [{"index": 898, "name": "O", "element": "O"}, {"index": 899, "name": "H1", "element": "H"}, {"index": 900, "name": "H2", "element": "H"}], "resSeq": 296, "segmentID": ""}, {"index": 296, "name": "HOH", "atoms": [{"index": 901, "name": "O", "element": "O"}, {"index": 902, "name": "H1", "element": "H"}, {"index": 903, "name": "H2", "element": "H"}], "resSeq": 297, "segmentID": ""}, {"index": 297, "name": "HOH", "atoms": [{"index": 904, "name": "O", "element": "O"}, {"index": 905, "name": "H1", "element": "H"}, {"index": 906, "name": "H2", "element": "H"}], "resSeq": 298, "segmentID": ""}, {"index": 298, "name": "HOH", "atoms": [{"index": 907, "name": "O", "element": "O"}, {"index": 908, "name": "H1", "element": "H"}, {"index": 909, "name": "H2", "element": "H"}], "resSeq": 299, "segmentID": ""}, {"index": 299, "name": "HOH", "atoms": [{"index": 910, "name": "O", "element": "O"}, {"index": 911, "name": "H1", "element": "H"}, {"index": 912, "name": "H2", "element": "H"}], "resSeq": 300, "segmentID": ""}, {"index": 300, "name": "HOH", "atoms": [{"index": 913, "name": "O", "element": "O"}, {"index": 914, "name": "H1", "element": "H"}, {"index": 915, "name": "H2", "element": "H"}], "resSeq": 301, "segmentID": ""}, {"index": 301, "name": "HOH", "atoms": [{"index": 916, "name": "O", "element": "O"}, {"index": 917, "name": "H1", "element": "H"}, {"index": 918, "name": "H2", "element": "H"}], "resSeq": 302, "segmentID": ""}, {"index": 302, "name": "HOH", "atoms": [{"index": 919, "name": "O", "element": "O"}, {"index": 920, "name": "H1", "element": "H"}, {"index": 921, "name": "H2", "element": "H"}], "resSeq": 303, "segmentID": ""}, {"index": 303, "name": "HOH", "atoms": [{"index": 922, "name": "O", "element": "O"}, {"index": 923, "name": "H1", "element": "H"}, {"index": 924, "name": "H2", "element": "H"}], "resSeq": 304, "segmentID": ""}, {"index": 304, "name": "HOH", "atoms": [{"index": 925, "name": "O", "element": "O"}, {"index": 926, "name": "H1", "element": "H"}, {"index": 927, "name": "H2", "element": "H"}], "resSeq": 305, "segmentID": ""}, {"index": 305, "name": "HOH", "atoms": [{"index": 928, "name": "O", "element": "O"}, {"index": 929, "name": "H1", "element": "H"}, {"index": 930, "name": "H2", "element": "H"}], "resSeq": 306, "segmentID": ""}, {"index": 306, "name": "HOH", "atoms": [{"index": 931, "name": "O", "element": "O"}, {"index": 932, "name": "H1", "element": "H"}, {"index": 933, "name": "H2", "element": "H"}], "resSeq": 307, "segmentID": ""}, {"index": 307, "name": "HOH", "atoms": [{"index": 934, "name": "O", "element": "O"}, {"index": 935, "name": "H1", "element": "H"}, {"index": 936, "name": "H2", "element": "H"}], "resSeq": 308, "segmentID": ""}, {"index": 308, "name": "HOH", "atoms": [{"index": 937, "name": "O", "element": "O"}, {"index": 938, "name": "H1", "element": "H"}, {"index": 939, "name": "H2", "element": "H"}], "resSeq": 309, "segmentID": ""}, {"index": 309, "name": "HOH", "atoms": [{"index": 940, "name": "O", "element": "O"}, {"index": 941, "name": "H1", "element": "H"}, {"index": 942, "name": "H2", "element": "H"}], "resSeq": 310, "segmentID": ""}, {"index": 310, "name": "HOH", "atoms": [{"index": 943, "name": "O", "element": "O"}, {"index": 944, "name": "H1", "element": "H"}, {"index": 945, "name": "H2", "element": "H"}], "resSeq": 311, "segmentID": ""}, {"index": 311, "name": "HOH", "atoms": [{"index": 946, "name": "O", "element": "O"}, {"index": 947, "name": "H1", "element": "H"}, {"index": 948, "name": "H2", "element": "H"}], "resSeq": 312, "segmentID": ""}, {"index": 312, "name": "HOH", "atoms": [{"index": 949, "name": "O", "element": "O"}, {"index": 950, "name": "H1", "element": "H"}, {"index": 951, "name": "H2", "element": "H"}], "resSeq": 313, "segmentID": ""}, {"index": 313, "name": "HOH", "atoms": [{"index": 952, "name": "O", "element": "O"}, {"index": 953, "name": "H1", "element": "H"}, {"index": 954, "name": "H2", "element": "H"}], "resSeq": 314, "segmentID": ""}, {"index": 314, "name": "HOH", "atoms": [{"index": 955, "name": "O", "element": "O"}, {"index": 956, "name": "H1", "element": "H"}, {"index": 957, "name": "H2", "element": "H"}], "resSeq": 315, "segmentID": ""}, {"index": 315, "name": "HOH", "atoms": [{"index": 958, "name": "O", "element": "O"}, {"index": 959, "name": "H1", "element": "H"}, {"index": 960, "name": "H2", "element": "H"}], "resSeq": 316, "segmentID": ""}, {"index": 316, "name": "HOH", "atoms": [{"index": 961, "name": "O", "element": "O"}, {"index": 962, "name": "H1", "element": "H"}, {"index": 963, "name": "H2", "element": "H"}], "resSeq": 317, "segmentID": ""}, {"index": 317, "name": "HOH", "atoms": [{"index": 964, "name": "O", "element": "O"}, {"index": 965, "name": "H1", "element": "H"}, {"index": 966, "name": "H2", "element": "H"}], "resSeq": 318, "segmentID": ""}, {"index": 318, "name": "HOH", "atoms": [{"index": 967, "name": "O", "element": "O"}, {"index": 968, "name": "H1", "element": "H"}, {"index": 969, "name": "H2", "element": "H"}], "resSeq": 319, "segmentID": ""}, {"index": 319, "name": "HOH", "atoms": [{"index": 970, "name": "O", "element": "O"}, {"index": 971, "name": "H1", "element": "H"}, {"index": 972, "name": "H2", "element": "H"}], "resSeq": 320, "segmentID": ""}, {"index": 320, "name": "HOH", "atoms": [{"index": 973, "name": "O", "element": "O"}, {"index": 974, "name": "H1", "element": "H"}, {"index": 975, "name": "H2", "element": "H"}], "resSeq": 321, "segmentID": ""}, {"index": 321, "name": "HOH", "atoms": [{"index": 976, "name": "O", "element": "O"}, {"index": 977, "name": "H1", "element": "H"}, {"index": 978, "name": "H2", "element": "H"}], "resSeq": 322, "segmentID": ""}, {"index": 322, "name": "HOH", "atoms": [{"index": 979, "name": "O", "element": "O"}, {"index": 980, "name": "H1", "element": "H"}, {"index": 981, "name": "H2", "element": "H"}], "resSeq": 323, "segmentID": ""}, {"index": 323, "name": "HOH", "atoms": [{"index": 982, "name": "O", "element": "O"}, {"index": 983, "name": "H1", "element": "H"}, {"index": 984, "name": "H2", "element": "H"}], "resSeq": 324, "segmentID": ""}, {"index": 324, "name": "HOH", "atoms": [{"index": 985, "name": "O", "element": "O"}, {"index": 986, "name": "H1", "element": "H"}, {"index": 987, "name": "H2", "element": "H"}], "resSeq": 325, "segmentID": ""}, {"index": 325, "name": "HOH", "atoms": [{"index": 988, "name": "O", "element": "O"}, {"index": 989, "name": "H1", "element": "H"}, {"index": 990, "name": "H2", "element": "H"}], "resSeq": 326, "segmentID": ""}, {"index": 326, "name": "HOH", "atoms": [{"index": 991, "name": "O", "element": "O"}, {"index": 992, "name": "H1", "element": "H"}, {"index": 993, "name": "H2", "element": "H"}], "resSeq": 327, "segmentID": ""}, {"index": 327, "name": "HOH", "atoms": [{"index": 994, "name": "O", "element": "O"}, {"index": 995, "name": "H1", "element": "H"}, {"index": 996, "name": "H2", "element": "H"}], "resSeq": 328, "segmentID": ""}, {"index": 328, "name": "HOH", "atoms": [{"index": 997, "name": "O", "element": "O"}, {"index": 998, "name": "H1", "element": "H"}, {"index": 999, "name": "H2", "element": "H"}], "resSeq": 329, "segmentID": ""}, {"index": 329, "name": "HOH", "atoms": [{"index": 1000, "name": "O", "element": "O"}, {"index": 1001, "name": "H1", "element": "H"}, {"index": 1002, "name": "H2", "element": "H"}], "resSeq": 330, "segmentID": ""}, {"index": 330, "name": "HOH", "atoms": [{"index": 1003, "name": "O", "element": "O"}, {"index": 1004, "name": "H1", "element": "H"}, {"index": 1005, "name": "H2", "element": "H"}], "resSeq": 331, "segmentID": ""}, {"index": 331, "name": "HOH", "atoms": [{"index": 1006, "name": "O", "element": "O"}, {"index": 1007, "name": "H1", "element": "H"}, {"index": 1008, "name": "H2", "element": "H"}], "resSeq": 332, "segmentID": ""}, {"index": 332, "name": "HOH", "atoms": [{"index": 1009, "name": "O", "element": "O"}, {"index": 1010, "name": "H1", "element": "H"}, {"index": 1011, "name": "H2", "element": "H"}], "resSeq": 333, "segmentID": ""}, {"index": 333, "name": "HOH", "atoms": [{"index": 1012, "name": "O", "element": "O"}, {"index": 1013, "name": "H1", "element": "H"}, {"index": 1014, "name": "H2", "element": "H"}], "resSeq": 334, "segmentID": ""}, {"index": 334, "name": "HOH", "atoms": [{"index": 1015, "name": "O", "element": "O"}, {"index": 1016, "name": "H1", "element": "H"}, {"index": 1017, "name": "H2", "element": "H"}], "resSeq": 335, "segmentID": ""}, {"index": 335, "name": "HOH", "atoms": [{"index": 1018, "name": "O", "element": "O"}, {"index": 1019, "name": "H1", "element": "H"}, {"index": 1020, "name": "H2", "element": "H"}], "resSeq": 336, "segmentID": ""}, {"index": 336, "name": "HOH", "atoms": [{"index": 1021, "name": "O", "element": "O"}, {"index": 1022, "name": "H1", "element": "H"}, {"index": 1023, "name": "H2", "element": "H"}], "resSeq": 337, "segmentID": ""}, {"index": 337, "name": "HOH", "atoms": [{"index": 1024, "name": "O", "element": "O"}, {"index": 1025, "name": "H1", "element": "H"}, {"index": 1026, "name": "H2", "element": "H"}], "resSeq": 338, "segmentID": ""}, {"index": 338, "name": "HOH", "atoms": [{"index": 1027, "name": "O", "element": "O"}, {"index": 1028, "name": "H1", "element": "H"}, {"index": 1029, "name": "H2", "element": "H"}], "resSeq": 339, "segmentID": ""}, {"index": 339, "name": "HOH", "atoms": [{"index": 1030, "name": "O", "element": "O"}, {"index": 1031, "name": "H1", "element": "H"}, {"index": 1032, "name": "H2", "element": "H"}], "resSeq": 340, "segmentID": ""}, {"index": 340, "name": "HOH", "atoms": [{"index": 1033, "name": "O", "element": "O"}, {"index": 1034, "name": "H1", "element": "H"}, {"index": 1035, "name": "H2", "element": "H"}], "resSeq": 341, "segmentID": ""}, {"index": 341, "name": "HOH", "atoms": [{"index": 1036, "name": "O", "element": "O"}, {"index": 1037, "name": "H1", "element": "H"}, {"index": 1038, "name": "H2", "element": "H"}], "resSeq": 342, "segmentID": ""}, {"index": 342, "name": "HOH", "atoms": [{"index": 1039, "name": "O", "element": "O"}, {"index": 1040, "name": "H1", "element": "H"}, {"index": 1041, "name": "H2", "element": "H"}], "resSeq": 343, "segmentID": ""}, {"index": 343, "name": "HOH", "atoms": [{"index": 1042, "name": "O", "element": "O"}, {"index": 1043, "name": "H1", "element": "H"}, {"index": 1044, "name": "H2", "element": "H"}], "resSeq": 344, "segmentID": ""}, {"index": 344, "name": "HOH", "atoms": [{"index": 1045, "name": "O", "element": "O"}, {"index": 1046, "name": "H1", "element": "H"}, {"index": 1047, "name": "H2", "element": "H"}], "resSeq": 345, "segmentID": ""}, {"index": 345, "name": "HOH", "atoms": [{"index": 1048, "name": "O", "element": "O"}, {"index": 1049, "name": "H1", "element": "H"}, {"index": 1050, "name": "H2", "element": "H"}], "resSeq": 346, "segmentID": ""}, {"index": 346, "name": "HOH", "atoms": [{"index": 1051, "name": "O", "element": "O"}, {"index": 1052, "name": "H1", "element": "H"}, {"index": 1053, "name": "H2", "element": "H"}], "resSeq": 347, "segmentID": ""}, {"index": 347, "name": "HOH", "atoms": [{"index": 1054, "name": "O", "element": "O"}, {"index": 1055, "name": "H1", "element": "H"}, {"index": 1056, "name": "H2", "element": "H"}], "resSeq": 348, "segmentID": ""}, {"index": 348, "name": "HOH", "atoms": [{"index": 1057, "name": "O", "element": "O"}, {"index": 1058, "name": "H1", "element": "H"}, {"index": 1059, "name": "H2", "element": "H"}], "resSeq": 349, "segmentID": ""}, {"index": 349, "name": "HOH", "atoms": [{"index": 1060, "name": "O", "element": "O"}, {"index": 1061, "name": "H1", "element": "H"}, {"index": 1062, "name": "H2", "element": "H"}], "resSeq": 350, "segmentID": ""}, {"index": 350, "name": "HOH", "atoms": [{"index": 1063, "name": "O", "element": "O"}, {"index": 1064, "name": "H1", "element": "H"}, {"index": 1065, "name": "H2", "element": "H"}], "resSeq": 351, "segmentID": ""}, {"index": 351, "name": "HOH", "atoms": [{"index": 1066, "name": "O", "element": "O"}, {"index": 1067, "name": "H1", "element": "H"}, {"index": 1068, "name": "H2", "element": "H"}], "resSeq": 352, "segmentID": ""}, {"index": 352, "name": "HOH", "atoms": [{"index": 1069, "name": "O", "element": "O"}, {"index": 1070, "name": "H1", "element": "H"}, {"index": 1071, "name": "H2", "element": "H"}], "resSeq": 353, "segmentID": ""}, {"index": 353, "name": "HOH", "atoms": [{"index": 1072, "name": "O", "element": "O"}, {"index": 1073, "name": "H1", "element": "H"}, {"index": 1074, "name": "H2", "element": "H"}], "resSeq": 354, "segmentID": ""}, {"index": 354, "name": "HOH", "atoms": [{"index": 1075, "name": "O", "element": "O"}, {"index": 1076, "name": "H1", "element": "H"}, {"index": 1077, "name": "H2", "element": "H"}], "resSeq": 355, "segmentID": ""}, {"index": 355, "name": "HOH", "atoms": [{"index": 1078, "name": "O", "element": "O"}, {"index": 1079, "name": "H1", "element": "H"}, {"index": 1080, "name": "H2", "element": "H"}], "resSeq": 356, "segmentID": ""}, {"index": 356, "name": "HOH", "atoms": [{"index": 1081, "name": "O", "element": "O"}, {"index": 1082, "name": "H1", "element": "H"}, {"index": 1083, "name": "H2", "element": "H"}], "resSeq": 357, "segmentID": ""}, {"index": 357, "name": "HOH", "atoms": [{"index": 1084, "name": "O", "element": "O"}, {"index": 1085, "name": "H1", "element": "H"}, {"index": 1086, "name": "H2", "element": "H"}], "resSeq": 358, "segmentID": ""}, {"index": 358, "name": "HOH", "atoms": [{"index": 1087, "name": "O", "element": "O"}, {"index": 1088, "name": "H1", "element": "H"}, {"index": 1089, "name": "H2", "element": "H"}], "resSeq": 359, "segmentID": ""}, {"index": 359, "name": "HOH", "atoms": [{"index": 1090, "name": "O", "element": "O"}, {"index": 1091, "name": "H1", "element": "H"}, {"index": 1092, "name": "H2", "element": "H"}], "resSeq": 360, "segmentID": ""}, {"index": 360, "name": "HOH", "atoms": [{"index": 1093, "name": "O", "element": "O"}, {"index": 1094, "name": "H1", "element": "H"}, {"index": 1095, "name": "H2", "element": "H"}], "resSeq": 361, "segmentID": ""}, {"index": 361, "name": "HOH", "atoms": [{"index": 1096, "name": "O", "element": "O"}, {"index": 1097, "name": "H1", "element": "H"}, {"index": 1098, "name": "H2", "element": "H"}], "resSeq": 362, "segmentID": ""}, {"index": 362, "name": "HOH", "atoms": [{"index": 1099, "name": "O", "element": "O"}, {"index": 1100, "name": "H1", "element": "H"}, {"index": 1101, "name": "H2", "element": "H"}], "resSeq": 363, "segmentID": ""}, {"index": 363, "name": "HOH", "atoms": [{"index": 1102, "name": "O", "element": "O"}, {"index": 1103, "name": "H1", "element": "H"}, {"index": 1104, "name": "H2", "element": "H"}], "resSeq": 364, "segmentID": ""}, {"index": 364, "name": "HOH", "atoms": [{"index": 1105, "name": "O", "element": "O"}, {"index": 1106, "name": "H1", "element": "H"}, {"index": 1107, "name": "H2", "element": "H"}], "resSeq": 365, "segmentID": ""}, {"index": 365, "name": "HOH", "atoms": [{"index": 1108, "name": "O", "element": "O"}, {"index": 1109, "name": "H1", "element": "H"}, {"index": 1110, "name": "H2", "element": "H"}], "resSeq": 366, "segmentID": ""}, {"index": 366, "name": "HOH", "atoms": [{"index": 1111, "name": "O", "element": "O"}, {"index": 1112, "name": "H1", "element": "H"}, {"index": 1113, "name": "H2", "element": "H"}], "resSeq": 367, "segmentID": ""}, {"index": 367, "name": "HOH", "atoms": [{"index": 1114, "name": "O", "element": "O"}, {"index": 1115, "name": "H1", "element": "H"}, {"index": 1116, "name": "H2", "element": "H"}], "resSeq": 368, "segmentID": ""}, {"index": 368, "name": "HOH", "atoms": [{"index": 1117, "name": "O", "element": "O"}, {"index": 1118, "name": "H1", "element": "H"}, {"index": 1119, "name": "H2", "element": "H"}], "resSeq": 369, "segmentID": ""}, {"index": 369, "name": "HOH", "atoms": [{"index": 1120, "name": "O", "element": "O"}, {"index": 1121, "name": "H1", "element": "H"}, {"index": 1122, "name": "H2", "element": "H"}], "resSeq": 370, "segmentID": ""}, {"index": 370, "name": "HOH", "atoms": [{"index": 1123, "name": "O", "element": "O"}, {"index": 1124, "name": "H1", "element": "H"}, {"index": 1125, "name": "H2", "element": "H"}], "resSeq": 371, "segmentID": ""}, {"index": 371, "name": "HOH", "atoms": [{"index": 1126, "name": "O", "element": "O"}, {"index": 1127, "name": "H1", "element": "H"}, {"index": 1128, "name": "H2", "element": "H"}], "resSeq": 372, "segmentID": ""}, {"index": 372, "name": "HOH", "atoms": [{"index": 1129, "name": "O", "element": "O"}, {"index": 1130, "name": "H1", "element": "H"}, {"index": 1131, "name": "H2", "element": "H"}], "resSeq": 373, "segmentID": ""}, {"index": 373, "name": "HOH", "atoms": [{"index": 1132, "name": "O", "element": "O"}, {"index": 1133, "name": "H1", "element": "H"}, {"index": 1134, "name": "H2", "element": "H"}], "resSeq": 374, "segmentID": ""}, {"index": 374, "name": "HOH", "atoms": [{"index": 1135, "name": "O", "element": "O"}, {"index": 1136, "name": "H1", "element": "H"}, {"index": 1137, "name": "H2", "element": "H"}], "resSeq": 375, "segmentID": ""}, {"index": 375, "name": "HOH", "atoms": [{"index": 1138, "name": "O", "element": "O"}, {"index": 1139, "name": "H1", "element": "H"}, {"index": 1140, "name": "H2", "element": "H"}], "resSeq": 376, "segmentID": ""}, {"index": 376, "name": "HOH", "atoms": [{"index": 1141, "name": "O", "element": "O"}, {"index": 1142, "name": "H1", "element": "H"}, {"index": 1143, "name": "H2", "element": "H"}], "resSeq": 377, "segmentID": ""}, {"index": 377, "name": "HOH", "atoms": [{"index": 1144, "name": "O", "element": "O"}, {"index": 1145, "name": "H1", "element": "H"}, {"index": 1146, "name": "H2", "element": "H"}], "resSeq": 378, "segmentID": ""}, {"index": 378, "name": "HOH", "atoms": [{"index": 1147, "name": "O", "element": "O"}, {"index": 1148, "name": "H1", "element": "H"}, {"index": 1149, "name": "H2", "element": "H"}], "resSeq": 379, "segmentID": ""}, {"index": 379, "name": "HOH", "atoms": [{"index": 1150, "name": "O", "element": "O"}, {"index": 1151, "name": "H1", "element": "H"}, {"index": 1152, "name": "H2", "element": "H"}], "resSeq": 380, "segmentID": ""}, {"index": 380, "name": "HOH", "atoms": [{"index": 1153, "name": "O", "element": "O"}, {"index": 1154, "name": "H1", "element": "H"}, {"index": 1155, "name": "H2", "element": "H"}], "resSeq": 381, "segmentID": ""}, {"index": 381, "name": "HOH", "atoms": [{"index": 1156, "name": "O", "element": "O"}, {"index": 1157, "name": "H1", "element": "H"}, {"index": 1158, "name": "H2", "element": "H"}], "resSeq": 382, "segmentID": ""}, {"index": 382, "name": "HOH", "atoms": [{"index": 1159, "name": "O", "element": "O"}, {"index": 1160, "name": "H1", "element": "H"}, {"index": 1161, "name": "H2", "element": "H"}], "resSeq": 383, "segmentID": ""}, {"index": 383, "name": "HOH", "atoms": [{"index": 1162, "name": "O", "element": "O"}, {"index": 1163, "name": "H1", "element": "H"}, {"index": 1164, "name": "H2", "element": "H"}], "resSeq": 384, "segmentID": ""}, {"index": 384, "name": "HOH", "atoms": [{"index": 1165, "name": "O", "element": "O"}, {"index": 1166, "name": "H1", "element": "H"}, {"index": 1167, "name": "H2", "element": "H"}], "resSeq": 385, "segmentID": ""}, {"index": 385, "name": "HOH", "atoms": [{"index": 1168, "name": "O", "element": "O"}, {"index": 1169, "name": "H1", "element": "H"}, {"index": 1170, "name": "H2", "element": "H"}], "resSeq": 386, "segmentID": ""}, {"index": 386, "name": "HOH", "atoms": [{"index": 1171, "name": "O", "element": "O"}, {"index": 1172, "name": "H1", "element": "H"}, {"index": 1173, "name": "H2", "element": "H"}], "resSeq": 387, "segmentID": ""}, {"index": 387, "name": "HOH", "atoms": [{"index": 1174, "name": "O", "element": "O"}, {"index": 1175, "name": "H1", "element": "H"}, {"index": 1176, "name": "H2", "element": "H"}], "resSeq": 388, "segmentID": ""}, {"index": 388, "name": "HOH", "atoms": [{"index": 1177, "name": "O", "element": "O"}, {"index": 1178, "name": "H1", "element": "H"}, {"index": 1179, "name": "H2", "element": "H"}], "resSeq": 389, "segmentID": ""}, {"index": 389, "name": "HOH", "atoms": [{"index": 1180, "name": "O", "element": "O"}, {"index": 1181, "name": "H1", "element": "H"}, {"index": 1182, "name": "H2", "element": "H"}], "resSeq": 390, "segmentID": ""}, {"index": 390, "name": "HOH", "atoms": [{"index": 1183, "name": "O", "element": "O"}, {"index": 1184, "name": "H1", "element": "H"}, {"index": 1185, "name": "H2", "element": "H"}], "resSeq": 391, "segmentID": ""}, {"index": 391, "name": "HOH", "atoms": [{"index": 1186, "name": "O", "element": "O"}, {"index": 1187, "name": "H1", "element": "H"}, {"index": 1188, "name": "H2", "element": "H"}], "resSeq": 392, "segmentID": ""}, {"index": 392, "name": "HOH", "atoms": [{"index": 1189, "name": "O", "element": "O"}, {"index": 1190, "name": "H1", "element": "H"}, {"index": 1191, "name": "H2", "element": "H"}], "resSeq": 393, "segmentID": ""}, {"index": 393, "name": "HOH", "atoms": [{"index": 1192, "name": "O", "element": "O"}, {"index": 1193, "name": "H1", "element": "H"}, {"index": 1194, "name": "H2", "element": "H"}], "resSeq": 394, "segmentID": ""}, {"index": 394, "name": "HOH", "atoms": [{"index": 1195, "name": "O", "element": "O"}, {"index": 1196, "name": "H1", "element": "H"}, {"index": 1197, "name": "H2", "element": "H"}], "resSeq": 395, "segmentID": ""}, {"index": 395, "name": "HOH", "atoms": [{"index": 1198, "name": "O", "element": "O"}, {"index": 1199, "name": "H1", "element": "H"}, {"index": 1200, "name": "H2", "element": "H"}], "resSeq": 396, "segmentID": ""}, {"index": 396, "name": "HOH", "atoms": [{"index": 1201, "name": "O", "element": "O"}, {"index": 1202, "name": "H1", "element": "H"}, {"index": 1203, "name": "H2", "element": "H"}], "resSeq": 397, "segmentID": ""}, {"index": 397, "name": "HOH", "atoms": [{"index": 1204, "name": "O", "element": "O"}, {"index": 1205, "name": "H1", "element": "H"}, {"index": 1206, "name": "H2", "element": "H"}], "resSeq": 398, "segmentID": ""}, {"index": 398, "name": "HOH", "atoms": [{"index": 1207, "name": "O", "element": "O"}, {"index": 1208, "name": "H1", "element": "H"}, {"index": 1209, "name": "H2", "element": "H"}], "resSeq": 399, "segmentID": ""}, {"index": 399, "name": "HOH", "atoms": [{"index": 1210, "name": "O", "element": "O"}, {"index": 1211, "name": "H1", "element": "H"}, {"index": 1212, "name": "H2", "element": "H"}], "resSeq": 400, "segmentID": ""}, {"index": 400, "name": "HOH", "atoms": [{"index": 1213, "name": "O", "element": "O"}, {"index": 1214, "name": "H1", "element": "H"}, {"index": 1215, "name": "H2", "element": "H"}], "resSeq": 401, "segmentID": ""}, {"index": 401, "name": "HOH", "atoms": [{"index": 1216, "name": "O", "element": "O"}, {"index": 1217, "name": "H1", "element": "H"}, {"index": 1218, "name": "H2", "element": "H"}], "resSeq": 402, "segmentID": ""}, {"index": 402, "name": "HOH", "atoms": [{"index": 1219, "name": "O", "element": "O"}, {"index": 1220, "name": "H1", "element": "H"}, {"index": 1221, "name": "H2", "element": "H"}], "resSeq": 403, "segmentID": ""}, {"index": 403, "name": "HOH", "atoms": [{"index": 1222, "name": "O", "element": "O"}, {"index": 1223, "name": "H1", "element": "H"}, {"index": 1224, "name": "H2", "element": "H"}], "resSeq": 404, "segmentID": ""}, {"index": 404, "name": "HOH", "atoms": [{"index": 1225, "name": "O", "element": "O"}, {"index": 1226, "name": "H1", "element": "H"}, {"index": 1227, "name": "H2", "element": "H"}], "resSeq": 405, "segmentID": ""}, {"index": 405, "name": "HOH", "atoms": [{"index": 1228, "name": "O", "element": "O"}, {"index": 1229, "name": "H1", "element": "H"}, {"index": 1230, "name": "H2", "element": "H"}], "resSeq": 406, "segmentID": ""}, {"index": 406, "name": "HOH", "atoms": [{"index": 1231, "name": "O", "element": "O"}, {"index": 1232, "name": "H1", "element": "H"}, {"index": 1233, "name": "H2", "element": "H"}], "resSeq": 407, "segmentID": ""}, {"index": 407, "name": "HOH", "atoms": [{"index": 1234, "name": "O", "element": "O"}, {"index": 1235, "name": "H1", "element": "H"}, {"index": 1236, "name": "H2", "element": "H"}], "resSeq": 408, "segmentID": ""}, {"index": 408, "name": "HOH", "atoms": [{"index": 1237, "name": "O", "element": "O"}, {"index": 1238, "name": "H1", "element": "H"}, {"index": 1239, "name": "H2", "element": "H"}], "resSeq": 409, "segmentID": ""}, {"index": 409, "name": "HOH", "atoms": [{"index": 1240, "name": "O", "element": "O"}, {"index": 1241, "name": "H1", "element": "H"}, {"index": 1242, "name": "H2", "element": "H"}], "resSeq": 410, "segmentID": ""}, {"index": 410, "name": "HOH", "atoms": [{"index": 1243, "name": "O", "element": "O"}, {"index": 1244, "name": "H1", "element": "H"}, {"index": 1245, "name": "H2", "element": "H"}], "resSeq": 411, "segmentID": ""}, {"index": 411, "name": "HOH", "atoms": [{"index": 1246, "name": "O", "element": "O"}, {"index": 1247, "name": "H1", "element": "H"}, {"index": 1248, "name": "H2", "element": "H"}], "resSeq": 412, "segmentID": ""}, {"index": 412, "name": "HOH", "atoms": [{"index": 1249, "name": "O", "element": "O"}, {"index": 1250, "name": "H1", "element": "H"}, {"index": 1251, "name": "H2", "element": "H"}], "resSeq": 413, "segmentID": ""}, {"index": 413, "name": "HOH", "atoms": [{"index": 1252, "name": "O", "element": "O"}, {"index": 1253, "name": "H1", "element": "H"}, {"index": 1254, "name": "H2", "element": "H"}], "resSeq": 414, "segmentID": ""}, {"index": 414, "name": "HOH", "atoms": [{"index": 1255, "name": "O", "element": "O"}, {"index": 1256, "name": "H1", "element": "H"}, {"index": 1257, "name": "H2", "element": "H"}], "resSeq": 415, "segmentID": ""}, {"index": 415, "name": "HOH", "atoms": [{"index": 1258, "name": "O", "element": "O"}, {"index": 1259, "name": "H1", "element": "H"}, {"index": 1260, "name": "H2", "element": "H"}], "resSeq": 416, "segmentID": ""}, {"index": 416, "name": "HOH", "atoms": [{"index": 1261, "name": "O", "element": "O"}, {"index": 1262, "name": "H1", "element": "H"}, {"index": 1263, "name": "H2", "element": "H"}], "resSeq": 417, "segmentID": ""}, {"index": 417, "name": "HOH", "atoms": [{"index": 1264, "name": "O", "element": "O"}, {"index": 1265, "name": "H1", "element": "H"}, {"index": 1266, "name": "H2", "element": "H"}], "resSeq": 418, "segmentID": ""}, {"index": 418, "name": "HOH", "atoms": [{"index": 1267, "name": "O", "element": "O"}, {"index": 1268, "name": "H1", "element": "H"}, {"index": 1269, "name": "H2", "element": "H"}], "resSeq": 419, "segmentID": ""}, {"index": 419, "name": "HOH", "atoms": [{"index": 1270, "name": "O", "element": "O"}, {"index": 1271, "name": "H1", "element": "H"}, {"index": 1272, "name": "H2", "element": "H"}], "resSeq": 420, "segmentID": ""}, {"index": 420, "name": "HOH", "atoms": [{"index": 1273, "name": "O", "element": "O"}, {"index": 1274, "name": "H1", "element": "H"}, {"index": 1275, "name": "H2", "element": "H"}], "resSeq": 421, "segmentID": ""}, {"index": 421, "name": "HOH", "atoms": [{"index": 1276, "name": "O", "element": "O"}, {"index": 1277, "name": "H1", "element": "H"}, {"index": 1278, "name": "H2", "element": "H"}], "resSeq": 422, "segmentID": ""}, {"index": 422, "name": "HOH", "atoms": [{"index": 1279, "name": "O", "element": "O"}, {"index": 1280, "name": "H1", "element": "H"}, {"index": 1281, "name": "H2", "element": "H"}], "resSeq": 423, "segmentID": ""}, {"index": 423, "name": "HOH", "atoms": [{"index": 1282, "name": "O", "element": "O"}, {"index": 1283, "name": "H1", "element": "H"}, {"index": 1284, "name": "H2", "element": "H"}], "resSeq": 424, "segmentID": ""}, {"index": 424, "name": "HOH", "atoms": [{"index": 1285, "name": "O", "element": "O"}, {"index": 1286, "name": "H1", "element": "H"}, {"index": 1287, "name": "H2", "element": "H"}], "resSeq": 425, "segmentID": ""}, {"index": 425, "name": "HOH", "atoms": [{"index": 1288, "name": "O", "element": "O"}, {"index": 1289, "name": "H1", "element": "H"}, {"index": 1290, "name": "H2", "element": "H"}], "resSeq": 426, "segmentID": ""}, {"index": 426, "name": "HOH", "atoms": [{"index": 1291, "name": "O", "element": "O"}, {"index": 1292, "name": "H1", "element": "H"}, {"index": 1293, "name": "H2", "element": "H"}], "resSeq": 427, "segmentID": ""}, {"index": 427, "name": "HOH", "atoms": [{"index": 1294, "name": "O", "element": "O"}, {"index": 1295, "name": "H1", "element": "H"}, {"index": 1296, "name": "H2", "element": "H"}], "resSeq": 428, "segmentID": ""}, {"index": 428, "name": "HOH", "atoms": [{"index": 1297, "name": "O", "element": "O"}, {"index": 1298, "name": "H1", "element": "H"}, {"index": 1299, "name": "H2", "element": "H"}], "resSeq": 429, "segmentID": ""}, {"index": 429, "name": "HOH", "atoms": [{"index": 1300, "name": "O", "element": "O"}, {"index": 1301, "name": "H1", "element": "H"}, {"index": 1302, "name": "H2", "element": "H"}], "resSeq": 430, "segmentID": ""}, {"index": 430, "name": "HOH", "atoms": [{"index": 1303, "name": "O", "element": "O"}, {"index": 1304, "name": "H1", "element": "H"}, {"index": 1305, "name": "H2", "element": "H"}], "resSeq": 431, "segmentID": ""}, {"index": 431, "name": "HOH", "atoms": [{"index": 1306, "name": "O", "element": "O"}, {"index": 1307, "name": "H1", "element": "H"}, {"index": 1308, "name": "H2", "element": "H"}], "resSeq": 432, "segmentID": ""}, {"index": 432, "name": "HOH", "atoms": [{"index": 1309, "name": "O", "element": "O"}, {"index": 1310, "name": "H1", "element": "H"}, {"index": 1311, "name": "H2", "element": "H"}], "resSeq": 433, "segmentID": ""}, {"index": 433, "name": "HOH", "atoms": [{"index": 1312, "name": "O", "element": "O"}, {"index": 1313, "name": "H1", "element": "H"}, {"index": 1314, "name": "H2", "element": "H"}], "resSeq": 434, "segmentID": ""}, {"index": 434, "name": "HOH", "atoms": [{"index": 1315, "name": "O", "element": "O"}, {"index": 1316, "name": "H1", "element": "H"}, {"index": 1317, "name": "H2", "element": "H"}], "resSeq": 435, "segmentID": ""}, {"index": 435, "name": "HOH", "atoms": [{"index": 1318, "name": "O", "element": "O"}, {"index": 1319, "name": "H1", "element": "H"}, {"index": 1320, "name": "H2", "element": "H"}], "resSeq": 436, "segmentID": ""}, {"index": 436, "name": "HOH", "atoms": [{"index": 1321, "name": "O", "element": "O"}, {"index": 1322, "name": "H1", "element": "H"}, {"index": 1323, "name": "H2", "element": "H"}], "resSeq": 437, "segmentID": ""}, {"index": 437, "name": "HOH", "atoms": [{"index": 1324, "name": "O", "element": "O"}, {"index": 1325, "name": "H1", "element": "H"}, {"index": 1326, "name": "H2", "element": "H"}], "resSeq": 438, "segmentID": ""}, {"index": 438, "name": "HOH", "atoms": [{"index": 1327, "name": "O", "element": "O"}, {"index": 1328, "name": "H1", "element": "H"}, {"index": 1329, "name": "H2", "element": "H"}], "resSeq": 439, "segmentID": ""}, {"index": 439, "name": "HOH", "atoms": [{"index": 1330, "name": "O", "element": "O"}, {"index": 1331, "name": "H1", "element": "H"}, {"index": 1332, "name": "H2", "element": "H"}], "resSeq": 440, "segmentID": ""}, {"index": 440, "name": "HOH", "atoms": [{"index": 1333, "name": "O", "element": "O"}, {"index": 1334, "name": "H1", "element": "H"}, {"index": 1335, "name": "H2", "element": "H"}], "resSeq": 441, "segmentID": ""}, {"index": 441, "name": "HOH", "atoms": [{"index": 1336, "name": "O", "element": "O"}, {"index": 1337, "name": "H1", "element": "H"}, {"index": 1338, "name": "H2", "element": "H"}], "resSeq": 442, "segmentID": ""}, {"index": 442, "name": "HOH", "atoms": [{"index": 1339, "name": "O", "element": "O"}, {"index": 1340, "name": "H1", "element": "H"}, {"index": 1341, "name": "H2", "element": "H"}], "resSeq": 443, "segmentID": ""}, {"index": 443, "name": "HOH", "atoms": [{"index": 1342, "name": "O", "element": "O"}, {"index": 1343, "name": "H1", "element": "H"}, {"index": 1344, "name": "H2", "element": "H"}], "resSeq": 444, "segmentID": ""}, {"index": 444, "name": "HOH", "atoms": [{"index": 1345, "name": "O", "element": "O"}, {"index": 1346, "name": "H1", "element": "H"}, {"index": 1347, "name": "H2", "element": "H"}], "resSeq": 445, "segmentID": ""}, {"index": 445, "name": "HOH", "atoms": [{"index": 1348, "name": "O", "element": "O"}, {"index": 1349, "name": "H1", "element": "H"}, {"index": 1350, "name": "H2", "element": "H"}], "resSeq": 446, "segmentID": ""}, {"index": 446, "name": "HOH", "atoms": [{"index": 1351, "name": "O", "element": "O"}, {"index": 1352, "name": "H1", "element": "H"}, {"index": 1353, "name": "H2", "element": "H"}], "resSeq": 447, "segmentID": ""}, {"index": 447, "name": "HOH", "atoms": [{"index": 1354, "name": "O", "element": "O"}, {"index": 1355, "name": "H1", "element": "H"}, {"index": 1356, "name": "H2", "element": "H"}], "resSeq": 448, "segmentID": ""}, {"index": 448, "name": "HOH", "atoms": [{"index": 1357, "name": "O", "element": "O"}, {"index": 1358, "name": "H1", "element": "H"}, {"index": 1359, "name": "H2", "element": "H"}], "resSeq": 449, "segmentID": ""}, {"index": 449, "name": "HOH", "atoms": [{"index": 1360, "name": "O", "element": "O"}, {"index": 1361, "name": "H1", "element": "H"}, {"index": 1362, "name": "H2", "element": "H"}], "resSeq": 450, "segmentID": ""}, {"index": 450, "name": "HOH", "atoms": [{"index": 1363, "name": "O", "element": "O"}, {"index": 1364, "name": "H1", "element": "H"}, {"index": 1365, "name": "H2", "element": "H"}], "resSeq": 451, "segmentID": ""}, {"index": 451, "name": "HOH", "atoms": [{"index": 1366, "name": "O", "element": "O"}, {"index": 1367, "name": "H1", "element": "H"}, {"index": 1368, "name": "H2", "element": "H"}], "resSeq": 452, "segmentID": ""}, {"index": 452, "name": "HOH", "atoms": [{"index": 1369, "name": "O", "element": "O"}, {"index": 1370, "name": "H1", "element": "H"}, {"index": 1371, "name": "H2", "element": "H"}], "resSeq": 453, "segmentID": ""}, {"index": 453, "name": "HOH", "atoms": [{"index": 1372, "name": "O", "element": "O"}, {"index": 1373, "name": "H1", "element": "H"}, {"index": 1374, "name": "H2", "element": "H"}], "resSeq": 454, "segmentID": ""}, {"index": 454, "name": "HOH", "atoms": [{"index": 1375, "name": "O", "element": "O"}, {"index": 1376, "name": "H1", "element": "H"}, {"index": 1377, "name": "H2", "element": "H"}], "resSeq": 455, "segmentID": ""}, {"index": 455, "name": "HOH", "atoms": [{"index": 1378, "name": "O", "element": "O"}, {"index": 1379, "name": "H1", "element": "H"}, {"index": 1380, "name": "H2", "element": "H"}], "resSeq": 456, "segmentID": ""}, {"index": 456, "name": "HOH", "atoms": [{"index": 1381, "name": "O", "element": "O"}, {"index": 1382, "name": "H1", "element": "H"}, {"index": 1383, "name": "H2", "element": "H"}], "resSeq": 457, "segmentID": ""}, {"index": 457, "name": "HOH", "atoms": [{"index": 1384, "name": "O", "element": "O"}, {"index": 1385, "name": "H1", "element": "H"}, {"index": 1386, "name": "H2", "element": "H"}], "resSeq": 458, "segmentID": ""}, {"index": 458, "name": "HOH", "atoms": [{"index": 1387, "name": "O", "element": "O"}, {"index": 1388, "name": "H1", "element": "H"}, {"index": 1389, "name": "H2", "element": "H"}], "resSeq": 459, "segmentID": ""}, {"index": 459, "name": "HOH", "atoms": [{"index": 1390, "name": "O", "element": "O"}, {"index": 1391, "name": "H1", "element": "H"}, {"index": 1392, "name": "H2", "element": "H"}], "resSeq": 460, "segmentID": ""}, {"index": 460, "name": "HOH", "atoms": [{"index": 1393, "name": "O", "element": "O"}, {"index": 1394, "name": "H1", "element": "H"}, {"index": 1395, "name": "H2", "element": "H"}], "resSeq": 461, "segmentID": ""}, {"index": 461, "name": "HOH", "atoms": [{"index": 1396, "name": "O", "element": "O"}, {"index": 1397, "name": "H1", "element": "H"}, {"index": 1398, "name": "H2", "element": "H"}], "resSeq": 462, "segmentID": ""}, {"index": 462, "name": "HOH", "atoms": [{"index": 1399, "name": "O", "element": "O"}, {"index": 1400, "name": "H1", "element": "H"}, {"index": 1401, "name": "H2", "element": "H"}], "resSeq": 463, "segmentID": ""}, {"index": 463, "name": "HOH", "atoms": [{"index": 1402, "name": "O", "element": "O"}, {"index": 1403, "name": "H1", "element": "H"}, {"index": 1404, "name": "H2", "element": "H"}], "resSeq": 464, "segmentID": ""}, {"index": 464, "name": "HOH", "atoms": [{"index": 1405, "name": "O", "element": "O"}, {"index": 1406, "name": "H1", "element": "H"}, {"index": 1407, "name": "H2", "element": "H"}], "resSeq": 465, "segmentID": ""}, {"index": 465, "name": "HOH", "atoms": [{"index": 1408, "name": "O", "element": "O"}, {"index": 1409, "name": "H1", "element": "H"}, {"index": 1410, "name": "H2", "element": "H"}], "resSeq": 466, "segmentID": ""}, {"index": 466, "name": "HOH", "atoms": [{"index": 1411, "name": "O", "element": "O"}, {"index": 1412, "name": "H1", "element": "H"}, {"index": 1413, "name": "H2", "element": "H"}], "resSeq": 467, "segmentID": ""}, {"index": 467, "name": "HOH", "atoms": [{"index": 1414, "name": "O", "element": "O"}, {"index": 1415, "name": "H1", "element": "H"}, {"index": 1416, "name": "H2", "element": "H"}], "resSeq": 468, "segmentID": ""}, {"index": 468, "name": "HOH", "atoms": [{"index": 1417, "name": "O", "element": "O"}, {"index": 1418, "name": "H1", "element": "H"}, {"index": 1419, "name": "H2", "element": "H"}], "resSeq": 469, "segmentID": ""}, {"index": 469, "name": "HOH", "atoms": [{"index": 1420, "name": "O", "element": "O"}, {"index": 1421, "name": "H1", "element": "H"}, {"index": 1422, "name": "H2", "element": "H"}], "resSeq": 470, "segmentID": ""}, {"index": 470, "name": "HOH", "atoms": [{"index": 1423, "name": "O", "element": "O"}, {"index": 1424, "name": "H1", "element": "H"}, {"index": 1425, "name": "H2", "element": "H"}], "resSeq": 471, "segmentID": ""}, {"index": 471, "name": "HOH", "atoms": [{"index": 1426, "name": "O", "element": "O"}, {"index": 1427, "name": "H1", "element": "H"}, {"index": 1428, "name": "H2", "element": "H"}], "resSeq": 472, "segmentID": ""}, {"index": 472, "name": "HOH", "atoms": [{"index": 1429, "name": "O", "element": "O"}, {"index": 1430, "name": "H1", "element": "H"}, {"index": 1431, "name": "H2", "element": "H"}], "resSeq": 473, "segmentID": ""}, {"index": 473, "name": "HOH", "atoms": [{"index": 1432, "name": "O", "element": "O"}, {"index": 1433, "name": "H1", "element": "H"}, {"index": 1434, "name": "H2", "element": "H"}], "resSeq": 474, "segmentID": ""}, {"index": 474, "name": "HOH", "atoms": [{"index": 1435, "name": "O", "element": "O"}, {"index": 1436, "name": "H1", "element": "H"}, {"index": 1437, "name": "H2", "element": "H"}], "resSeq": 475, "segmentID": ""}, {"index": 475, "name": "HOH", "atoms": [{"index": 1438, "name": "O", "element": "O"}, {"index": 1439, "name": "H1", "element": "H"}, {"index": 1440, "name": "H2", "element": "H"}], "resSeq": 476, "segmentID": ""}, {"index": 476, "name": "HOH", "atoms": [{"index": 1441, "name": "O", "element": "O"}, {"index": 1442, "name": "H1", "element": "H"}, {"index": 1443, "name": "H2", "element": "H"}], "resSeq": 477, "segmentID": ""}, {"index": 477, "name": "HOH", "atoms": [{"index": 1444, "name": "O", "element": "O"}, {"index": 1445, "name": "H1", "element": "H"}, {"index": 1446, "name": "H2", "element": "H"}], "resSeq": 478, "segmentID": ""}, {"index": 478, "name": "HOH", "atoms": [{"index": 1447, "name": "O", "element": "O"}, {"index": 1448, "name": "H1", "element": "H"}, {"index": 1449, "name": "H2", "element": "H"}], "resSeq": 479, "segmentID": ""}, {"index": 479, "name": "HOH", "atoms": [{"index": 1450, "name": "O", "element": "O"}, {"index": 1451, "name": "H1", "element": "H"}, {"index": 1452, "name": "H2", "element": "H"}], "resSeq": 480, "segmentID": ""}, {"index": 480, "name": "HOH", "atoms": [{"index": 1453, "name": "O", "element": "O"}, {"index": 1454, "name": "H1", "element": "H"}, {"index": 1455, "name": "H2", "element": "H"}], "resSeq": 481, "segmentID": ""}, {"index": 481, "name": "HOH", "atoms": [{"index": 1456, "name": "O", "element": "O"}, {"index": 1457, "name": "H1", "element": "H"}, {"index": 1458, "name": "H2", "element": "H"}], "resSeq": 482, "segmentID": ""}, {"index": 482, "name": "HOH", "atoms": [{"index": 1459, "name": "O", "element": "O"}, {"index": 1460, "name": "H1", "element": "H"}, {"index": 1461, "name": "H2", "element": "H"}], "resSeq": 483, "segmentID": ""}, {"index": 483, "name": "HOH", "atoms": [{"index": 1462, "name": "O", "element": "O"}, {"index": 1463, "name": "H1", "element": "H"}, {"index": 1464, "name": "H2", "element": "H"}], "resSeq": 484, "segmentID": ""}, {"index": 484, "name": "HOH", "atoms": [{"index": 1465, "name": "O", "element": "O"}, {"index": 1466, "name": "H1", "element": "H"}, {"index": 1467, "name": "H2", "element": "H"}], "resSeq": 485, "segmentID": ""}, {"index": 485, "name": "HOH", "atoms": [{"index": 1468, "name": "O", "element": "O"}, {"index": 1469, "name": "H1", "element": "H"}, {"index": 1470, "name": "H2", "element": "H"}], "resSeq": 486, "segmentID": ""}, {"index": 486, "name": "HOH", "atoms": [{"index": 1471, "name": "O", "element": "O"}, {"index": 1472, "name": "H1", "element": "H"}, {"index": 1473, "name": "H2", "element": "H"}], "resSeq": 487, "segmentID": ""}, {"index": 487, "name": "HOH", "atoms": [{"index": 1474, "name": "O", "element": "O"}, {"index": 1475, "name": "H1", "element": "H"}, {"index": 1476, "name": "H2", "element": "H"}], "resSeq": 488, "segmentID": ""}, {"index": 488, "name": "HOH", "atoms": [{"index": 1477, "name": "O", "element": "O"}, {"index": 1478, "name": "H1", "element": "H"}, {"index": 1479, "name": "H2", "element": "H"}], "resSeq": 489, "segmentID": ""}, {"index": 489, "name": "HOH", "atoms": [{"index": 1480, "name": "O", "element": "O"}, {"index": 1481, "name": "H1", "element": "H"}, {"index": 1482, "name": "H2", "element": "H"}], "resSeq": 490, "segmentID": ""}, {"index": 490, "name": "HOH", "atoms": [{"index": 1483, "name": "O", "element": "O"}, {"index": 1484, "name": "H1", "element": "H"}, {"index": 1485, "name": "H2", "element": "H"}], "resSeq": 491, "segmentID": ""}, {"index": 491, "name": "HOH", "atoms": [{"index": 1486, "name": "O", "element": "O"}, {"index": 1487, "name": "H1", "element": "H"}, {"index": 1488, "name": "H2", "element": "H"}], "resSeq": 492, "segmentID": ""}, {"index": 492, "name": "HOH", "atoms": [{"index": 1489, "name": "O", "element": "O"}, {"index": 1490, "name": "H1", "element": "H"}, {"index": 1491, "name": "H2", "element": "H"}], "resSeq": 493, "segmentID": ""}, {"index": 493, "name": "HOH", "atoms": [{"index": 1492, "name": "O", "element": "O"}, {"index": 1493, "name": "H1", "element": "H"}, {"index": 1494, "name": "H2", "element": "H"}], "resSeq": 494, "segmentID": ""}, {"index": 494, "name": "HOH", "atoms": [{"index": 1495, "name": "O", "element": "O"}, {"index": 1496, "name": "H1", "element": "H"}, {"index": 1497, "name": "H2", "element": "H"}], "resSeq": 495, "segmentID": ""}, {"index": 495, "name": "HOH", "atoms": [{"index": 1498, "name": "O", "element": "O"}, {"index": 1499, "name": "H1", "element": "H"}, {"index": 1500, "name": "H2", "element": "H"}], "resSeq": 496, "segmentID": ""}, {"index": 496, "name": "HOH", "atoms": [{"index": 1501, "name": "O", "element": "O"}, {"index": 1502, "name": "H1", "element": "H"}, {"index": 1503, "name": "H2", "element": "H"}], "resSeq": 497, "segmentID": ""}, {"index": 497, "name": "HOH", "atoms": [{"index": 1504, "name": "O", "element": "O"}, {"index": 1505, "name": "H1", "element": "H"}, {"index": 1506, "name": "H2", "element": "H"}], "resSeq": 498, "segmentID": ""}, {"index": 498, "name": "HOH", "atoms": [{"index": 1507, "name": "O", "element": "O"}, {"index": 1508, "name": "H1", "element": "H"}, {"index": 1509, "name": "H2", "element": "H"}], "resSeq": 499, "segmentID": ""}, {"index": 499, "name": "HOH", "atoms": [{"index": 1510, "name": "O", "element": "O"}, {"index": 1511, "name": "H1", "element": "H"}, {"index": 1512, "name": "H2", "element": "H"}], "resSeq": 500, "segmentID": ""}, {"index": 500, "name": "HOH", "atoms": [{"index": 1513, "name": "O", "element": "O"}, {"index": 1514, "name": "H1", "element": "H"}, {"index": 1515, "name": "H2", "element": "H"}], "resSeq": 501, "segmentID": ""}, {"index": 501, "name": "HOH", "atoms": [{"index": 1516, "name": "O", "element": "O"}, {"index": 1517, "name": "H1", "element": "H"}, {"index": 1518, "name": "H2", "element": "H"}], "resSeq": 502, "segmentID": ""}, {"index": 502, "name": "HOH", "atoms": [{"index": 1519, "name": "O", "element": "O"}, {"index": 1520, "name": "H1", "element": "H"}, {"index": 1521, "name": "H2", "element": "H"}], "resSeq": 503, "segmentID": ""}, {"index": 503, "name": "HOH", "atoms": [{"index": 1522, "name": "O", "element": "O"}, {"index": 1523, "name": "H1", "element": "H"}, {"index": 1524, "name": "H2", "element": "H"}], "resSeq": 504, "segmentID": ""}, {"index": 504, "name": "HOH", "atoms": [{"index": 1525, "name": "O", "element": "O"}, {"index": 1526, "name": "H1", "element": "H"}, {"index": 1527, "name": "H2", "element": "H"}], "resSeq": 505, "segmentID": ""}, {"index": 505, "name": "HOH", "atoms": [{"index": 1528, "name": "O", "element": "O"}, {"index": 1529, "name": "H1", "element": "H"}, {"index": 1530, "name": "H2", "element": "H"}], "resSeq": 506, "segmentID": ""}, {"index": 506, "name": "HOH", "atoms": [{"index": 1531, "name": "O", "element": "O"}, {"index": 1532, "name": "H1", "element": "H"}, {"index": 1533, "name": "H2", "element": "H"}], "resSeq": 507, "segmentID": ""}, {"index": 507, "name": "HOH", "atoms": [{"index": 1534, "name": "O", "element": "O"}, {"index": 1535, "name": "H1", "element": "H"}, {"index": 1536, "name": "H2", "element": "H"}], "resSeq": 508, "segmentID": ""}, {"index": 508, "name": "HOH", "atoms": [{"index": 1537, "name": "O", "element": "O"}, {"index": 1538, "name": "H1", "element": "H"}, {"index": 1539, "name": "H2", "element": "H"}], "resSeq": 509, "segmentID": ""}, {"index": 509, "name": "HOH", "atoms": [{"index": 1540, "name": "O", "element": "O"}, {"index": 1541, "name": "H1", "element": "H"}, {"index": 1542, "name": "H2", "element": "H"}], "resSeq": 510, "segmentID": ""}, {"index": 510, "name": "HOH", "atoms": [{"index": 1543, "name": "O", "element": "O"}, {"index": 1544, "name": "H1", "element": "H"}, {"index": 1545, "name": "H2", "element": "H"}], "resSeq": 511, "segmentID": ""}, {"index": 511, "name": "HOH", "atoms": [{"index": 1546, "name": "O", "element": "O"}, {"index": 1547, "name": "H1", "element": "H"}, {"index": 1548, "name": "H2", "element": "H"}], "resSeq": 512, "segmentID": ""}, {"index": 512, "name": "HOH", "atoms": [{"index": 1549, "name": "O", "element": "O"}, {"index": 1550, "name": "H1", "element": "H"}, {"index": 1551, "name": "H2", "element": "H"}], "resSeq": 513, "segmentID": ""}, {"index": 513, "name": "HOH", "atoms": [{"index": 1552, "name": "O", "element": "O"}, {"index": 1553, "name": "H1", "element": "H"}, {"index": 1554, "name": "H2", "element": "H"}], "resSeq": 514, "segmentID": ""}, {"index": 514, "name": "HOH", "atoms": [{"index": 1555, "name": "O", "element": "O"}, {"index": 1556, "name": "H1", "element": "H"}, {"index": 1557, "name": "H2", "element": "H"}], "resSeq": 515, "segmentID": ""}, {"index": 515, "name": "HOH", "atoms": [{"index": 1558, "name": "O", "element": "O"}, {"index": 1559, "name": "H1", "element": "H"}, {"index": 1560, "name": "H2", "element": "H"}], "resSeq": 516, "segmentID": ""}, {"index": 516, "name": "HOH", "atoms": [{"index": 1561, "name": "O", "element": "O"}, {"index": 1562, "name": "H1", "element": "H"}, {"index": 1563, "name": "H2", "element": "H"}], "resSeq": 517, "segmentID": ""}, {"index": 517, "name": "HOH", "atoms": [{"index": 1564, "name": "O", "element": "O"}, {"index": 1565, "name": "H1", "element": "H"}, {"index": 1566, "name": "H2", "element": "H"}], "resSeq": 518, "segmentID": ""}, {"index": 518, "name": "HOH", "atoms": [{"index": 1567, "name": "O", "element": "O"}, {"index": 1568, "name": "H1", "element": "H"}, {"index": 1569, "name": "H2", "element": "H"}], "resSeq": 519, "segmentID": ""}, {"index": 519, "name": "HOH", "atoms": [{"index": 1570, "name": "O", "element": "O"}, {"index": 1571, "name": "H1", "element": "H"}, {"index": 1572, "name": "H2", "element": "H"}], "resSeq": 520, "segmentID": ""}, {"index": 520, "name": "HOH", "atoms": [{"index": 1573, "name": "O", "element": "O"}, {"index": 1574, "name": "H1", "element": "H"}, {"index": 1575, "name": "H2", "element": "H"}], "resSeq": 521, "segmentID": ""}, {"index": 521, "name": "HOH", "atoms": [{"index": 1576, "name": "O", "element": "O"}, {"index": 1577, "name": "H1", "element": "H"}, {"index": 1578, "name": "H2", "element": "H"}], "resSeq": 522, "segmentID": ""}, {"index": 522, "name": "HOH", "atoms": [{"index": 1579, "name": "O", "element": "O"}, {"index": 1580, "name": "H1", "element": "H"}, {"index": 1581, "name": "H2", "element": "H"}], "resSeq": 523, "segmentID": ""}, {"index": 523, "name": "HOH", "atoms": [{"index": 1582, "name": "O", "element": "O"}, {"index": 1583, "name": "H1", "element": "H"}, {"index": 1584, "name": "H2", "element": "H"}], "resSeq": 524, "segmentID": ""}, {"index": 524, "name": "HOH", "atoms": [{"index": 1585, "name": "O", "element": "O"}, {"index": 1586, "name": "H1", "element": "H"}, {"index": 1587, "name": "H2", "element": "H"}], "resSeq": 525, "segmentID": ""}, {"index": 525, "name": "HOH", "atoms": [{"index": 1588, "name": "O", "element": "O"}, {"index": 1589, "name": "H1", "element": "H"}, {"index": 1590, "name": "H2", "element": "H"}], "resSeq": 526, "segmentID": ""}, {"index": 526, "name": "HOH", "atoms": [{"index": 1591, "name": "O", "element": "O"}, {"index": 1592, "name": "H1", "element": "H"}, {"index": 1593, "name": "H2", "element": "H"}], "resSeq": 527, "segmentID": ""}, {"index": 527, "name": "HOH", "atoms": [{"index": 1594, "name": "O", "element": "O"}, {"index": 1595, "name": "H1", "element": "H"}, {"index": 1596, "name": "H2", "element": "H"}], "resSeq": 528, "segmentID": ""}, {"index": 528, "name": "HOH", "atoms": [{"index": 1597, "name": "O", "element": "O"}, {"index": 1598, "name": "H1", "element": "H"}, {"index": 1599, "name": "H2", "element": "H"}], "resSeq": 529, "segmentID": ""}, {"index": 529, "name": "HOH", "atoms": [{"index": 1600, "name": "O", "element": "O"}, {"index": 1601, "name": "H1", "element": "H"}, {"index": 1602, "name": "H2", "element": "H"}], "resSeq": 530, "segmentID": ""}, {"index": 530, "name": "HOH", "atoms": [{"index": 1603, "name": "O", "element": "O"}, {"index": 1604, "name": "H1", "element": "H"}, {"index": 1605, "name": "H2", "element": "H"}], "resSeq": 531, "segmentID": ""}, {"index": 531, "name": "HOH", "atoms": [{"index": 1606, "name": "O", "element": "O"}, {"index": 1607, "name": "H1", "element": "H"}, {"index": 1608, "name": "H2", "element": "H"}], "resSeq": 532, "segmentID": ""}, {"index": 532, "name": "HOH", "atoms": [{"index": 1609, "name": "O", "element": "O"}, {"index": 1610, "name": "H1", "element": "H"}, {"index": 1611, "name": "H2", "element": "H"}], "resSeq": 533, "segmentID": ""}, {"index": 533, "name": "HOH", "atoms": [{"index": 1612, "name": "O", "element": "O"}, {"index": 1613, "name": "H1", "element": "H"}, {"index": 1614, "name": "H2", "element": "H"}], "resSeq": 534, "segmentID": ""}, {"index": 534, "name": "HOH", "atoms": [{"index": 1615, "name": "O", "element": "O"}, {"index": 1616, "name": "H1", "element": "H"}, {"index": 1617, "name": "H2", "element": "H"}], "resSeq": 535, "segmentID": ""}, {"index": 535, "name": "HOH", "atoms": [{"index": 1618, "name": "O", "element": "O"}, {"index": 1619, "name": "H1", "element": "H"}, {"index": 1620, "name": "H2", "element": "H"}], "resSeq": 536, "segmentID": ""}, {"index": 536, "name": "HOH", "atoms": [{"index": 1621, "name": "O", "element": "O"}, {"index": 1622, "name": "H1", "element": "H"}, {"index": 1623, "name": "H2", "element": "H"}], "resSeq": 537, "segmentID": ""}, {"index": 537, "name": "HOH", "atoms": [{"index": 1624, "name": "O", "element": "O"}, {"index": 1625, "name": "H1", "element": "H"}, {"index": 1626, "name": "H2", "element": "H"}], "resSeq": 538, "segmentID": ""}, {"index": 538, "name": "HOH", "atoms": [{"index": 1627, "name": "O", "element": "O"}, {"index": 1628, "name": "H1", "element": "H"}, {"index": 1629, "name": "H2", "element": "H"}], "resSeq": 539, "segmentID": ""}, {"index": 539, "name": "HOH", "atoms": [{"index": 1630, "name": "O", "element": "O"}, {"index": 1631, "name": "H1", "element": "H"}, {"index": 1632, "name": "H2", "element": "H"}], "resSeq": 540, "segmentID": ""}, {"index": 540, "name": "HOH", "atoms": [{"index": 1633, "name": "O", "element": "O"}, {"index": 1634, "name": "H1", "element": "H"}, {"index": 1635, "name": "H2", "element": "H"}], "resSeq": 541, "segmentID": ""}, {"index": 541, "name": "HOH", "atoms": [{"index": 1636, "name": "O", "element": "O"}, {"index": 1637, "name": "H1", "element": "H"}, {"index": 1638, "name": "H2", "element": "H"}], "resSeq": 542, "segmentID": ""}, {"index": 542, "name": "HOH", "atoms": [{"index": 1639, "name": "O", "element": "O"}, {"index": 1640, "name": "H1", "element": "H"}, {"index": 1641, "name": "H2", "element": "H"}], "resSeq": 543, "segmentID": ""}, {"index": 543, "name": "HOH", "atoms": [{"index": 1642, "name": "O", "element": "O"}, {"index": 1643, "name": "H1", "element": "H"}, {"index": 1644, "name": "H2", "element": "H"}], "resSeq": 544, "segmentID": ""}, {"index": 544, "name": "HOH", "atoms": [{"index": 1645, "name": "O", "element": "O"}, {"index": 1646, "name": "H1", "element": "H"}, {"index": 1647, "name": "H2", "element": "H"}], "resSeq": 545, "segmentID": ""}, {"index": 545, "name": "HOH", "atoms": [{"index": 1648, "name": "O", "element": "O"}, {"index": 1649, "name": "H1", "element": "H"}, {"index": 1650, "name": "H2", "element": "H"}], "resSeq": 546, "segmentID": ""}, {"index": 546, "name": "HOH", "atoms": [{"index": 1651, "name": "O", "element": "O"}, {"index": 1652, "name": "H1", "element": "H"}, {"index": 1653, "name": "H2", "element": "H"}], "resSeq": 547, "segmentID": ""}, {"index": 547, "name": "HOH", "atoms": [{"index": 1654, "name": "O", "element": "O"}, {"index": 1655, "name": "H1", "element": "H"}, {"index": 1656, "name": "H2", "element": "H"}], "resSeq": 548, "segmentID": ""}, {"index": 548, "name": "HOH", "atoms": [{"index": 1657, "name": "O", "element": "O"}, {"index": 1658, "name": "H1", "element": "H"}, {"index": 1659, "name": "H2", "element": "H"}], "resSeq": 549, "segmentID": ""}, {"index": 549, "name": "HOH", "atoms": [{"index": 1660, "name": "O", "element": "O"}, {"index": 1661, "name": "H1", "element": "H"}, {"index": 1662, "name": "H2", "element": "H"}], "resSeq": 550, "segmentID": ""}, {"index": 550, "name": "HOH", "atoms": [{"index": 1663, "name": "O", "element": "O"}, {"index": 1664, "name": "H1", "element": "H"}, {"index": 1665, "name": "H2", "element": "H"}], "resSeq": 551, "segmentID": ""}, {"index": 551, "name": "HOH", "atoms": [{"index": 1666, "name": "O", "element": "O"}, {"index": 1667, "name": "H1", "element": "H"}, {"index": 1668, "name": "H2", "element": "H"}], "resSeq": 552, "segmentID": ""}, {"index": 552, "name": "HOH", "atoms": [{"index": 1669, "name": "O", "element": "O"}, {"index": 1670, "name": "H1", "element": "H"}, {"index": 1671, "name": "H2", "element": "H"}], "resSeq": 553, "segmentID": ""}, {"index": 553, "name": "HOH", "atoms": [{"index": 1672, "name": "O", "element": "O"}, {"index": 1673, "name": "H1", "element": "H"}, {"index": 1674, "name": "H2", "element": "H"}], "resSeq": 554, "segmentID": ""}, {"index": 554, "name": "HOH", "atoms": [{"index": 1675, "name": "O", "element": "O"}, {"index": 1676, "name": "H1", "element": "H"}, {"index": 1677, "name": "H2", "element": "H"}], "resSeq": 555, "segmentID": ""}, {"index": 555, "name": "HOH", "atoms": [{"index": 1678, "name": "O", "element": "O"}, {"index": 1679, "name": "H1", "element": "H"}, {"index": 1680, "name": "H2", "element": "H"}], "resSeq": 556, "segmentID": ""}, {"index": 556, "name": "HOH", "atoms": [{"index": 1681, "name": "O", "element": "O"}, {"index": 1682, "name": "H1", "element": "H"}, {"index": 1683, "name": "H2", "element": "H"}], "resSeq": 557, "segmentID": ""}, {"index": 557, "name": "HOH", "atoms": [{"index": 1684, "name": "O", "element": "O"}, {"index": 1685, "name": "H1", "element": "H"}, {"index": 1686, "name": "H2", "element": "H"}], "resSeq": 558, "segmentID": ""}, {"index": 558, "name": "HOH", "atoms": [{"index": 1687, "name": "O", "element": "O"}, {"index": 1688, "name": "H1", "element": "H"}, {"index": 1689, "name": "H2", "element": "H"}], "resSeq": 559, "segmentID": ""}, {"index": 559, "name": "HOH", "atoms": [{"index": 1690, "name": "O", "element": "O"}, {"index": 1691, "name": "H1", "element": "H"}, {"index": 1692, "name": "H2", "element": "H"}], "resSeq": 560, "segmentID": ""}, {"index": 560, "name": "HOH", "atoms": [{"index": 1693, "name": "O", "element": "O"}, {"index": 1694, "name": "H1", "element": "H"}, {"index": 1695, "name": "H2", "element": "H"}], "resSeq": 561, "segmentID": ""}, {"index": 561, "name": "HOH", "atoms": [{"index": 1696, "name": "O", "element": "O"}, {"index": 1697, "name": "H1", "element": "H"}, {"index": 1698, "name": "H2", "element": "H"}], "resSeq": 562, "segmentID": ""}, {"index": 562, "name": "HOH", "atoms": [{"index": 1699, "name": "O", "element": "O"}, {"index": 1700, "name": "H1", "element": "H"}, {"index": 1701, "name": "H2", "element": "H"}], "resSeq": 563, "segmentID": ""}, {"index": 563, "name": "HOH", "atoms": [{"index": 1702, "name": "O", "element": "O"}, {"index": 1703, "name": "H1", "element": "H"}, {"index": 1704, "name": "H2", "element": "H"}], "resSeq": 564, "segmentID": ""}, {"index": 564, "name": "HOH", "atoms": [{"index": 1705, "name": "O", "element": "O"}, {"index": 1706, "name": "H1", "element": "H"}, {"index": 1707, "name": "H2", "element": "H"}], "resSeq": 565, "segmentID": ""}, {"index": 565, "name": "HOH", "atoms": [{"index": 1708, "name": "O", "element": "O"}, {"index": 1709, "name": "H1", "element": "H"}, {"index": 1710, "name": "H2", "element": "H"}], "resSeq": 566, "segmentID": ""}, {"index": 566, "name": "HOH", "atoms": [{"index": 1711, "name": "O", "element": "O"}, {"index": 1712, "name": "H1", "element": "H"}, {"index": 1713, "name": "H2", "element": "H"}], "resSeq": 567, "segmentID": ""}, {"index": 567, "name": "HOH", "atoms": [{"index": 1714, "name": "O", "element": "O"}, {"index": 1715, "name": "H1", "element": "H"}, {"index": 1716, "name": "H2", "element": "H"}], "resSeq": 568, "segmentID": ""}, {"index": 568, "name": "HOH", "atoms": [{"index": 1717, "name": "O", "element": "O"}, {"index": 1718, "name": "H1", "element": "H"}, {"index": 1719, "name": "H2", "element": "H"}], "resSeq": 569, "segmentID": ""}, {"index": 569, "name": "HOH", "atoms": [{"index": 1720, "name": "O", "element": "O"}, {"index": 1721, "name": "H1", "element": "H"}, {"index": 1722, "name": "H2", "element": "H"}], "resSeq": 570, "segmentID": ""}, {"index": 570, "name": "HOH", "atoms": [{"index": 1723, "name": "O", "element": "O"}, {"index": 1724, "name": "H1", "element": "H"}, {"index": 1725, "name": "H2", "element": "H"}], "resSeq": 571, "segmentID": ""}, {"index": 571, "name": "HOH", "atoms": [{"index": 1726, "name": "O", "element": "O"}, {"index": 1727, "name": "H1", "element": "H"}, {"index": 1728, "name": "H2", "element": "H"}], "resSeq": 572, "segmentID": ""}, {"index": 572, "name": "HOH", "atoms": [{"index": 1729, "name": "O", "element": "O"}, {"index": 1730, "name": "H1", "element": "H"}, {"index": 1731, "name": "H2", "element": "H"}], "resSeq": 573, "segmentID": ""}, {"index": 573, "name": "HOH", "atoms": [{"index": 1732, "name": "O", "element": "O"}, {"index": 1733, "name": "H1", "element": "H"}, {"index": 1734, "name": "H2", "element": "H"}], "resSeq": 574, "segmentID": ""}, {"index": 574, "name": "HOH", "atoms": [{"index": 1735, "name": "O", "element": "O"}, {"index": 1736, "name": "H1", "element": "H"}, {"index": 1737, "name": "H2", "element": "H"}], "resSeq": 575, "segmentID": ""}, {"index": 575, "name": "HOH", "atoms": [{"index": 1738, "name": "O", "element": "O"}, {"index": 1739, "name": "H1", "element": "H"}, {"index": 1740, "name": "H2", "element": "H"}], "resSeq": 576, "segmentID": ""}, {"index": 576, "name": "HOH", "atoms": [{"index": 1741, "name": "O", "element": "O"}, {"index": 1742, "name": "H1", "element": "H"}, {"index": 1743, "name": "H2", "element": "H"}], "resSeq": 577, "segmentID": ""}, {"index": 577, "name": "HOH", "atoms": [{"index": 1744, "name": "O", "element": "O"}, {"index": 1745, "name": "H1", "element": "H"}, {"index": 1746, "name": "H2", "element": "H"}], "resSeq": 578, "segmentID": ""}, {"index": 578, "name": "HOH", "atoms": [{"index": 1747, "name": "O", "element": "O"}, {"index": 1748, "name": "H1", "element": "H"}, {"index": 1749, "name": "H2", "element": "H"}], "resSeq": 579, "segmentID": ""}, {"index": 579, "name": "HOH", "atoms": [{"index": 1750, "name": "O", "element": "O"}, {"index": 1751, "name": "H1", "element": "H"}, {"index": 1752, "name": "H2", "element": "H"}], "resSeq": 580, "segmentID": ""}, {"index": 580, "name": "HOH", "atoms": [{"index": 1753, "name": "O", "element": "O"}, {"index": 1754, "name": "H1", "element": "H"}, {"index": 1755, "name": "H2", "element": "H"}], "resSeq": 581, "segmentID": ""}, {"index": 581, "name": "HOH", "atoms": [{"index": 1756, "name": "O", "element": "O"}, {"index": 1757, "name": "H1", "element": "H"}, {"index": 1758, "name": "H2", "element": "H"}], "resSeq": 582, "segmentID": ""}, {"index": 582, "name": "HOH", "atoms": [{"index": 1759, "name": "O", "element": "O"}, {"index": 1760, "name": "H1", "element": "H"}, {"index": 1761, "name": "H2", "element": "H"}], "resSeq": 583, "segmentID": ""}, {"index": 583, "name": "HOH", "atoms": [{"index": 1762, "name": "O", "element": "O"}, {"index": 1763, "name": "H1", "element": "H"}, {"index": 1764, "name": "H2", "element": "H"}], "resSeq": 584, "segmentID": ""}, {"index": 584, "name": "HOH", "atoms": [{"index": 1765, "name": "O", "element": "O"}, {"index": 1766, "name": "H1", "element": "H"}, {"index": 1767, "name": "H2", "element": "H"}], "resSeq": 585, "segmentID": ""}, {"index": 585, "name": "HOH", "atoms": [{"index": 1768, "name": "O", "element": "O"}, {"index": 1769, "name": "H1", "element": "H"}, {"index": 1770, "name": "H2", "element": "H"}], "resSeq": 586, "segmentID": ""}, {"index": 586, "name": "HOH", "atoms": [{"index": 1771, "name": "O", "element": "O"}, {"index": 1772, "name": "H1", "element": "H"}, {"index": 1773, "name": "H2", "element": "H"}], "resSeq": 587, "segmentID": ""}, {"index": 587, "name": "HOH", "atoms": [{"index": 1774, "name": "O", "element": "O"}, {"index": 1775, "name": "H1", "element": "H"}, {"index": 1776, "name": "H2", "element": "H"}], "resSeq": 588, "segmentID": ""}, {"index": 588, "name": "HOH", "atoms": [{"index": 1777, "name": "O", "element": "O"}, {"index": 1778, "name": "H1", "element": "H"}, {"index": 1779, "name": "H2", "element": "H"}], "resSeq": 589, "segmentID": ""}, {"index": 589, "name": "HOH", "atoms": [{"index": 1780, "name": "O", "element": "O"}, {"index": 1781, "name": "H1", "element": "H"}, {"index": 1782, "name": "H2", "element": "H"}], "resSeq": 590, "segmentID": ""}, {"index": 590, "name": "HOH", "atoms": [{"index": 1783, "name": "O", "element": "O"}, {"index": 1784, "name": "H1", "element": "H"}, {"index": 1785, "name": "H2", "element": "H"}], "resSeq": 591, "segmentID": ""}, {"index": 591, "name": "HOH", "atoms": [{"index": 1786, "name": "O", "element": "O"}, {"index": 1787, "name": "H1", "element": "H"}, {"index": 1788, "name": "H2", "element": "H"}], "resSeq": 592, "segmentID": ""}, {"index": 592, "name": "HOH", "atoms": [{"index": 1789, "name": "O", "element": "O"}, {"index": 1790, "name": "H1", "element": "H"}, {"index": 1791, "name": "H2", "element": "H"}], "resSeq": 593, "segmentID": ""}, {"index": 593, "name": "HOH", "atoms": [{"index": 1792, "name": "O", "element": "O"}, {"index": 1793, "name": "H1", "element": "H"}, {"index": 1794, "name": "H2", "element": "H"}], "resSeq": 594, "segmentID": ""}, {"index": 594, "name": "HOH", "atoms": [{"index": 1795, "name": "O", "element": "O"}, {"index": 1796, "name": "H1", "element": "H"}, {"index": 1797, "name": "H2", "element": "H"}], "resSeq": 595, "segmentID": ""}, {"index": 595, "name": "HOH", "atoms": [{"index": 1798, "name": "O", "element": "O"}, {"index": 1799, "name": "H1", "element": "H"}, {"index": 1800, "name": "H2", "element": "H"}], "resSeq": 596, "segmentID": ""}, {"index": 596, "name": "HOH", "atoms": [{"index": 1801, "name": "O", "element": "O"}, {"index": 1802, "name": "H1", "element": "H"}, {"index": 1803, "name": "H2", "element": "H"}], "resSeq": 597, "segmentID": ""}, {"index": 597, "name": "HOH", "atoms": [{"index": 1804, "name": "O", "element": "O"}, {"index": 1805, "name": "H1", "element": "H"}, {"index": 1806, "name": "H2", "element": "H"}], "resSeq": 598, "segmentID": ""}, {"index": 598, "name": "HOH", "atoms": [{"index": 1807, "name": "O", "element": "O"}, {"index": 1808, "name": "H1", "element": "H"}, {"index": 1809, "name": "H2", "element": "H"}], "resSeq": 599, "segmentID": ""}, {"index": 599, "name": "HOH", "atoms": [{"index": 1810, "name": "O", "element": "O"}, {"index": 1811, "name": "H1", "element": "H"}, {"index": 1812, "name": "H2", "element": "H"}], "resSeq": 600, "segmentID": ""}, {"index": 600, "name": "HOH", "atoms": [{"index": 1813, "name": "O", "element": "O"}, {"index": 1814, "name": "H1", "element": "H"}, {"index": 1815, "name": "H2", "element": "H"}], "resSeq": 601, "segmentID": ""}, {"index": 601, "name": "HOH", "atoms": [{"index": 1816, "name": "O", "element": "O"}, {"index": 1817, "name": "H1", "element": "H"}, {"index": 1818, "name": "H2", "element": "H"}], "resSeq": 602, "segmentID": ""}, {"index": 602, "name": "HOH", "atoms": [{"index": 1819, "name": "O", "element": "O"}, {"index": 1820, "name": "H1", "element": "H"}, {"index": 1821, "name": "H2", "element": "H"}], "resSeq": 603, "segmentID": ""}, {"index": 603, "name": "HOH", "atoms": [{"index": 1822, "name": "O", "element": "O"}, {"index": 1823, "name": "H1", "element": "H"}, {"index": 1824, "name": "H2", "element": "H"}], "resSeq": 604, "segmentID": ""}, {"index": 604, "name": "HOH", "atoms": [{"index": 1825, "name": "O", "element": "O"}, {"index": 1826, "name": "H1", "element": "H"}, {"index": 1827, "name": "H2", "element": "H"}], "resSeq": 605, "segmentID": ""}, {"index": 605, "name": "HOH", "atoms": [{"index": 1828, "name": "O", "element": "O"}, {"index": 1829, "name": "H1", "element": "H"}, {"index": 1830, "name": "H2", "element": "H"}], "resSeq": 606, "segmentID": ""}, {"index": 606, "name": "HOH", "atoms": [{"index": 1831, "name": "O", "element": "O"}, {"index": 1832, "name": "H1", "element": "H"}, {"index": 1833, "name": "H2", "element": "H"}], "resSeq": 607, "segmentID": ""}, {"index": 607, "name": "HOH", "atoms": [{"index": 1834, "name": "O", "element": "O"}, {"index": 1835, "name": "H1", "element": "H"}, {"index": 1836, "name": "H2", "element": "H"}], "resSeq": 608, "segmentID": ""}, {"index": 608, "name": "HOH", "atoms": [{"index": 1837, "name": "O", "element": "O"}, {"index": 1838, "name": "H1", "element": "H"}, {"index": 1839, "name": "H2", "element": "H"}], "resSeq": 609, "segmentID": ""}, {"index": 609, "name": "HOH", "atoms": [{"index": 1840, "name": "O", "element": "O"}, {"index": 1841, "name": "H1", "element": "H"}, {"index": 1842, "name": "H2", "element": "H"}], "resSeq": 610, "segmentID": ""}, {"index": 610, "name": "HOH", "atoms": [{"index": 1843, "name": "O", "element": "O"}, {"index": 1844, "name": "H1", "element": "H"}, {"index": 1845, "name": "H2", "element": "H"}], "resSeq": 611, "segmentID": ""}, {"index": 611, "name": "HOH", "atoms": [{"index": 1846, "name": "O", "element": "O"}, {"index": 1847, "name": "H1", "element": "H"}, {"index": 1848, "name": "H2", "element": "H"}], "resSeq": 612, "segmentID": ""}, {"index": 612, "name": "HOH", "atoms": [{"index": 1849, "name": "O", "element": "O"}, {"index": 1850, "name": "H1", "element": "H"}, {"index": 1851, "name": "H2", "element": "H"}], "resSeq": 613, "segmentID": ""}, {"index": 613, "name": "HOH", "atoms": [{"index": 1852, "name": "O", "element": "O"}, {"index": 1853, "name": "H1", "element": "H"}, {"index": 1854, "name": "H2", "element": "H"}], "resSeq": 614, "segmentID": ""}, {"index": 614, "name": "HOH", "atoms": [{"index": 1855, "name": "O", "element": "O"}, {"index": 1856, "name": "H1", "element": "H"}, {"index": 1857, "name": "H2", "element": "H"}], "resSeq": 615, "segmentID": ""}, {"index": 615, "name": "HOH", "atoms": [{"index": 1858, "name": "O", "element": "O"}, {"index": 1859, "name": "H1", "element": "H"}, {"index": 1860, "name": "H2", "element": "H"}], "resSeq": 616, "segmentID": ""}, {"index": 616, "name": "HOH", "atoms": [{"index": 1861, "name": "O", "element": "O"}, {"index": 1862, "name": "H1", "element": "H"}, {"index": 1863, "name": "H2", "element": "H"}], "resSeq": 617, "segmentID": ""}, {"index": 617, "name": "HOH", "atoms": [{"index": 1864, "name": "O", "element": "O"}, {"index": 1865, "name": "H1", "element": "H"}, {"index": 1866, "name": "H2", "element": "H"}], "resSeq": 618, "segmentID": ""}, {"index": 618, "name": "HOH", "atoms": [{"index": 1867, "name": "O", "element": "O"}, {"index": 1868, "name": "H1", "element": "H"}, {"index": 1869, "name": "H2", "element": "H"}], "resSeq": 619, "segmentID": ""}, {"index": 619, "name": "HOH", "atoms": [{"index": 1870, "name": "O", "element": "O"}, {"index": 1871, "name": "H1", "element": "H"}, {"index": 1872, "name": "H2", "element": "H"}], "resSeq": 620, "segmentID": ""}, {"index": 620, "name": "HOH", "atoms": [{"index": 1873, "name": "O", "element": "O"}, {"index": 1874, "name": "H1", "element": "H"}, {"index": 1875, "name": "H2", "element": "H"}], "resSeq": 621, "segmentID": ""}, {"index": 621, "name": "HOH", "atoms": [{"index": 1876, "name": "O", "element": "O"}, {"index": 1877, "name": "H1", "element": "H"}, {"index": 1878, "name": "H2", "element": "H"}], "resSeq": 622, "segmentID": ""}, {"index": 622, "name": "HOH", "atoms": [{"index": 1879, "name": "O", "element": "O"}, {"index": 1880, "name": "H1", "element": "H"}, {"index": 1881, "name": "H2", "element": "H"}], "resSeq": 623, "segmentID": ""}, {"index": 623, "name": "HOH", "atoms": [{"index": 1882, "name": "O", "element": "O"}, {"index": 1883, "name": "H1", "element": "H"}, {"index": 1884, "name": "H2", "element": "H"}], "resSeq": 624, "segmentID": ""}, {"index": 624, "name": "HOH", "atoms": [{"index": 1885, "name": "O", "element": "O"}, {"index": 1886, "name": "H1", "element": "H"}, {"index": 1887, "name": "H2", "element": "H"}], "resSeq": 625, "segmentID": ""}, {"index": 625, "name": "HOH", "atoms": [{"index": 1888, "name": "O", "element": "O"}, {"index": 1889, "name": "H1", "element": "H"}, {"index": 1890, "name": "H2", "element": "H"}], "resSeq": 626, "segmentID": ""}, {"index": 626, "name": "HOH", "atoms": [{"index": 1891, "name": "O", "element": "O"}, {"index": 1892, "name": "H1", "element": "H"}, {"index": 1893, "name": "H2", "element": "H"}], "resSeq": 627, "segmentID": ""}, {"index": 627, "name": "HOH", "atoms": [{"index": 1894, "name": "O", "element": "O"}, {"index": 1895, "name": "H1", "element": "H"}, {"index": 1896, "name": "H2", "element": "H"}], "resSeq": 628, "segmentID": ""}, {"index": 628, "name": "HOH", "atoms": [{"index": 1897, "name": "O", "element": "O"}, {"index": 1898, "name": "H1", "element": "H"}, {"index": 1899, "name": "H2", "element": "H"}], "resSeq": 629, "segmentID": ""}, {"index": 629, "name": "HOH", "atoms": [{"index": 1900, "name": "O", "element": "O"}, {"index": 1901, "name": "H1", "element": "H"}, {"index": 1902, "name": "H2", "element": "H"}], "resSeq": 630, "segmentID": ""}, {"index": 630, "name": "HOH", "atoms": [{"index": 1903, "name": "O", "element": "O"}, {"index": 1904, "name": "H1", "element": "H"}, {"index": 1905, "name": "H2", "element": "H"}], "resSeq": 631, "segmentID": ""}, {"index": 631, "name": "HOH", "atoms": [{"index": 1906, "name": "O", "element": "O"}, {"index": 1907, "name": "H1", "element": "H"}, {"index": 1908, "name": "H2", "element": "H"}], "resSeq": 632, "segmentID": ""}, {"index": 632, "name": "HOH", "atoms": [{"index": 1909, "name": "O", "element": "O"}, {"index": 1910, "name": "H1", "element": "H"}, {"index": 1911, "name": "H2", "element": "H"}], "resSeq": 633, "segmentID": ""}, {"index": 633, "name": "HOH", "atoms": [{"index": 1912, "name": "O", "element": "O"}, {"index": 1913, "name": "H1", "element": "H"}, {"index": 1914, "name": "H2", "element": "H"}], "resSeq": 634, "segmentID": ""}, {"index": 634, "name": "HOH", "atoms": [{"index": 1915, "name": "O", "element": "O"}, {"index": 1916, "name": "H1", "element": "H"}, {"index": 1917, "name": "H2", "element": "H"}], "resSeq": 635, "segmentID": ""}, {"index": 635, "name": "HOH", "atoms": [{"index": 1918, "name": "O", "element": "O"}, {"index": 1919, "name": "H1", "element": "H"}, {"index": 1920, "name": "H2", "element": "H"}], "resSeq": 636, "segmentID": ""}, {"index": 636, "name": "HOH", "atoms": [{"index": 1921, "name": "O", "element": "O"}, {"index": 1922, "name": "H1", "element": "H"}, {"index": 1923, "name": "H2", "element": "H"}], "resSeq": 637, "segmentID": ""}, {"index": 637, "name": "HOH", "atoms": [{"index": 1924, "name": "O", "element": "O"}, {"index": 1925, "name": "H1", "element": "H"}, {"index": 1926, "name": "H2", "element": "H"}], "resSeq": 638, "segmentID": ""}, {"index": 638, "name": "HOH", "atoms": [{"index": 1927, "name": "O", "element": "O"}, {"index": 1928, "name": "H1", "element": "H"}, {"index": 1929, "name": "H2", "element": "H"}], "resSeq": 639, "segmentID": ""}, {"index": 639, "name": "HOH", "atoms": [{"index": 1930, "name": "O", "element": "O"}, {"index": 1931, "name": "H1", "element": "H"}, {"index": 1932, "name": "H2", "element": "H"}], "resSeq": 640, "segmentID": ""}, {"index": 640, "name": "HOH", "atoms": [{"index": 1933, "name": "O", "element": "O"}, {"index": 1934, "name": "H1", "element": "H"}, {"index": 1935, "name": "H2", "element": "H"}], "resSeq": 641, "segmentID": ""}, {"index": 641, "name": "HOH", "atoms": [{"index": 1936, "name": "O", "element": "O"}, {"index": 1937, "name": "H1", "element": "H"}, {"index": 1938, "name": "H2", "element": "H"}], "resSeq": 642, "segmentID": ""}, {"index": 642, "name": "HOH", "atoms": [{"index": 1939, "name": "O", "element": "O"}, {"index": 1940, "name": "H1", "element": "H"}, {"index": 1941, "name": "H2", "element": "H"}], "resSeq": 643, "segmentID": ""}, {"index": 643, "name": "HOH", "atoms": [{"index": 1942, "name": "O", "element": "O"}, {"index": 1943, "name": "H1", "element": "H"}, {"index": 1944, "name": "H2", "element": "H"}], "resSeq": 644, "segmentID": ""}, {"index": 644, "name": "HOH", "atoms": [{"index": 1945, "name": "O", "element": "O"}, {"index": 1946, "name": "H1", "element": "H"}, {"index": 1947, "name": "H2", "element": "H"}], "resSeq": 645, "segmentID": ""}, {"index": 645, "name": "HOH", "atoms": [{"index": 1948, "name": "O", "element": "O"}, {"index": 1949, "name": "H1", "element": "H"}, {"index": 1950, "name": "H2", "element": "H"}], "resSeq": 646, "segmentID": ""}, {"index": 646, "name": "HOH", "atoms": [{"index": 1951, "name": "O", "element": "O"}, {"index": 1952, "name": "H1", "element": "H"}, {"index": 1953, "name": "H2", "element": "H"}], "resSeq": 647, "segmentID": ""}, {"index": 647, "name": "HOH", "atoms": [{"index": 1954, "name": "O", "element": "O"}, {"index": 1955, "name": "H1", "element": "H"}, {"index": 1956, "name": "H2", "element": "H"}], "resSeq": 648, "segmentID": ""}, {"index": 648, "name": "HOH", "atoms": [{"index": 1957, "name": "O", "element": "O"}, {"index": 1958, "name": "H1", "element": "H"}, {"index": 1959, "name": "H2", "element": "H"}], "resSeq": 649, "segmentID": ""}, {"index": 649, "name": "HOH", "atoms": [{"index": 1960, "name": "O", "element": "O"}, {"index": 1961, "name": "H1", "element": "H"}, {"index": 1962, "name": "H2", "element": "H"}], "resSeq": 650, "segmentID": ""}, {"index": 650, "name": "HOH", "atoms": [{"index": 1963, "name": "O", "element": "O"}, {"index": 1964, "name": "H1", "element": "H"}, {"index": 1965, "name": "H2", "element": "H"}], "resSeq": 651, "segmentID": ""}, {"index": 651, "name": "HOH", "atoms": [{"index": 1966, "name": "O", "element": "O"}, {"index": 1967, "name": "H1", "element": "H"}, {"index": 1968, "name": "H2", "element": "H"}], "resSeq": 652, "segmentID": ""}, {"index": 652, "name": "HOH", "atoms": [{"index": 1969, "name": "O", "element": "O"}, {"index": 1970, "name": "H1", "element": "H"}, {"index": 1971, "name": "H2", "element": "H"}], "resSeq": 653, "segmentID": ""}, {"index": 653, "name": "HOH", "atoms": [{"index": 1972, "name": "O", "element": "O"}, {"index": 1973, "name": "H1", "element": "H"}, {"index": 1974, "name": "H2", "element": "H"}], "resSeq": 654, "segmentID": ""}, {"index": 654, "name": "HOH", "atoms": [{"index": 1975, "name": "O", "element": "O"}, {"index": 1976, "name": "H1", "element": "H"}, {"index": 1977, "name": "H2", "element": "H"}], "resSeq": 655, "segmentID": ""}, {"index": 655, "name": "HOH", "atoms": [{"index": 1978, "name": "O", "element": "O"}, {"index": 1979, "name": "H1", "element": "H"}, {"index": 1980, "name": "H2", "element": "H"}], "resSeq": 656, "segmentID": ""}, {"index": 656, "name": "HOH", "atoms": [{"index": 1981, "name": "O", "element": "O"}, {"index": 1982, "name": "H1", "element": "H"}, {"index": 1983, "name": "H2", "element": "H"}], "resSeq": 657, "segmentID": ""}, {"index": 657, "name": "HOH", "atoms": [{"index": 1984, "name": "O", "element": "O"}, {"index": 1985, "name": "H1", "element": "H"}, {"index": 1986, "name": "H2", "element": "H"}], "resSeq": 658, "segmentID": ""}, {"index": 658, "name": "HOH", "atoms": [{"index": 1987, "name": "O", "element": "O"}, {"index": 1988, "name": "H1", "element": "H"}, {"index": 1989, "name": "H2", "element": "H"}], "resSeq": 659, "segmentID": ""}, {"index": 659, "name": "HOH", "atoms": [{"index": 1990, "name": "O", "element": "O"}, {"index": 1991, "name": "H1", "element": "H"}, {"index": 1992, "name": "H2", "element": "H"}], "resSeq": 660, "segmentID": ""}, {"index": 660, "name": "HOH", "atoms": [{"index": 1993, "name": "O", "element": "O"}, {"index": 1994, "name": "H1", "element": "H"}, {"index": 1995, "name": "H2", "element": "H"}], "resSeq": 661, "segmentID": ""}, {"index": 661, "name": "HOH", "atoms": [{"index": 1996, "name": "O", "element": "O"}, {"index": 1997, "name": "H1", "element": "H"}, {"index": 1998, "name": "H2", "element": "H"}], "resSeq": 662, "segmentID": ""}, {"index": 662, "name": "HOH", "atoms": [{"index": 1999, "name": "O", "element": "O"}, {"index": 2000, "name": "H1", "element": "H"}, {"index": 2001, "name": "H2", "element": "H"}], "resSeq": 663, "segmentID": ""}, {"index": 663, "name": "HOH", "atoms": [{"index": 2002, "name": "O", "element": "O"}, {"index": 2003, "name": "H1", "element": "H"}, {"index": 2004, "name": "H2", "element": "H"}], "resSeq": 664, "segmentID": ""}, {"index": 664, "name": "HOH", "atoms": [{"index": 2005, "name": "O", "element": "O"}, {"index": 2006, "name": "H1", "element": "H"}, {"index": 2007, "name": "H2", "element": "H"}], "resSeq": 665, "segmentID": ""}, {"index": 665, "name": "HOH", "atoms": [{"index": 2008, "name": "O", "element": "O"}, {"index": 2009, "name": "H1", "element": "H"}, {"index": 2010, "name": "H2", "element": "H"}], "resSeq": 666, "segmentID": ""}, {"index": 666, "name": "HOH", "atoms": [{"index": 2011, "name": "O", "element": "O"}, {"index": 2012, "name": "H1", "element": "H"}, {"index": 2013, "name": "H2", "element": "H"}], "resSeq": 667, "segmentID": ""}, {"index": 667, "name": "HOH", "atoms": [{"index": 2014, "name": "O", "element": "O"}, {"index": 2015, "name": "H1", "element": "H"}, {"index": 2016, "name": "H2", "element": "H"}], "resSeq": 668, "segmentID": ""}, {"index": 668, "name": "HOH", "atoms": [{"index": 2017, "name": "O", "element": "O"}, {"index": 2018, "name": "H1", "element": "H"}, {"index": 2019, "name": "H2", "element": "H"}], "resSeq": 669, "segmentID": ""}, {"index": 669, "name": "HOH", "atoms": [{"index": 2020, "name": "O", "element": "O"}, {"index": 2021, "name": "H1", "element": "H"}, {"index": 2022, "name": "H2", "element": "H"}], "resSeq": 670, "segmentID": ""}, {"index": 670, "name": "HOH", "atoms": [{"index": 2023, "name": "O", "element": "O"}, {"index": 2024, "name": "H1", "element": "H"}, {"index": 2025, "name": "H2", "element": "H"}], "resSeq": 671, "segmentID": ""}, {"index": 671, "name": "HOH", "atoms": [{"index": 2026, "name": "O", "element": "O"}, {"index": 2027, "name": "H1", "element": "H"}, {"index": 2028, "name": "H2", "element": "H"}], "resSeq": 672, "segmentID": ""}, {"index": 672, "name": "HOH", "atoms": [{"index": 2029, "name": "O", "element": "O"}, {"index": 2030, "name": "H1", "element": "H"}, {"index": 2031, "name": "H2", "element": "H"}], "resSeq": 673, "segmentID": ""}, {"index": 673, "name": "HOH", "atoms": [{"index": 2032, "name": "O", "element": "O"}, {"index": 2033, "name": "H1", "element": "H"}, {"index": 2034, "name": "H2", "element": "H"}], "resSeq": 674, "segmentID": ""}, {"index": 674, "name": "HOH", "atoms": [{"index": 2035, "name": "O", "element": "O"}, {"index": 2036, "name": "H1", "element": "H"}, {"index": 2037, "name": "H2", "element": "H"}], "resSeq": 675, "segmentID": ""}, {"index": 675, "name": "HOH", "atoms": [{"index": 2038, "name": "O", "element": "O"}, {"index": 2039, "name": "H1", "element": "H"}, {"index": 2040, "name": "H2", "element": "H"}], "resSeq": 676, "segmentID": ""}, {"index": 676, "name": "HOH", "atoms": [{"index": 2041, "name": "O", "element": "O"}, {"index": 2042, "name": "H1", "element": "H"}, {"index": 2043, "name": "H2", "element": "H"}], "resSeq": 677, "segmentID": ""}, {"index": 677, "name": "HOH", "atoms": [{"index": 2044, "name": "O", "element": "O"}, {"index": 2045, "name": "H1", "element": "H"}, {"index": 2046, "name": "H2", "element": "H"}], "resSeq": 678, "segmentID": ""}, {"index": 678, "name": "HOH", "atoms": [{"index": 2047, "name": "O", "element": "O"}, {"index": 2048, "name": "H1", "element": "H"}, {"index": 2049, "name": "H2", "element": "H"}], "resSeq": 679, "segmentID": ""}, {"index": 679, "name": "HOH", "atoms": [{"index": 2050, "name": "O", "element": "O"}, {"index": 2051, "name": "H1", "element": "H"}, {"index": 2052, "name": "H2", "element": "H"}], "resSeq": 680, "segmentID": ""}, {"index": 680, "name": "HOH", "atoms": [{"index": 2053, "name": "O", "element": "O"}, {"index": 2054, "name": "H1", "element": "H"}, {"index": 2055, "name": "H2", "element": "H"}], "resSeq": 681, "segmentID": ""}, {"index": 681, "name": "HOH", "atoms": [{"index": 2056, "name": "O", "element": "O"}, {"index": 2057, "name": "H1", "element": "H"}, {"index": 2058, "name": "H2", "element": "H"}], "resSeq": 682, "segmentID": ""}, {"index": 682, "name": "HOH", "atoms": [{"index": 2059, "name": "O", "element": "O"}, {"index": 2060, "name": "H1", "element": "H"}, {"index": 2061, "name": "H2", "element": "H"}], "resSeq": 683, "segmentID": ""}, {"index": 683, "name": "HOH", "atoms": [{"index": 2062, "name": "O", "element": "O"}, {"index": 2063, "name": "H1", "element": "H"}, {"index": 2064, "name": "H2", "element": "H"}], "resSeq": 684, "segmentID": ""}, {"index": 684, "name": "HOH", "atoms": [{"index": 2065, "name": "O", "element": "O"}, {"index": 2066, "name": "H1", "element": "H"}, {"index": 2067, "name": "H2", "element": "H"}], "resSeq": 685, "segmentID": ""}, {"index": 685, "name": "HOH", "atoms": [{"index": 2068, "name": "O", "element": "O"}, {"index": 2069, "name": "H1", "element": "H"}, {"index": 2070, "name": "H2", "element": "H"}], "resSeq": 686, "segmentID": ""}, {"index": 686, "name": "HOH", "atoms": [{"index": 2071, "name": "O", "element": "O"}, {"index": 2072, "name": "H1", "element": "H"}, {"index": 2073, "name": "H2", "element": "H"}], "resSeq": 687, "segmentID": ""}, {"index": 687, "name": "HOH", "atoms": [{"index": 2074, "name": "O", "element": "O"}, {"index": 2075, "name": "H1", "element": "H"}, {"index": 2076, "name": "H2", "element": "H"}], "resSeq": 688, "segmentID": ""}, {"index": 688, "name": "HOH", "atoms": [{"index": 2077, "name": "O", "element": "O"}, {"index": 2078, "name": "H1", "element": "H"}, {"index": 2079, "name": "H2", "element": "H"}], "resSeq": 689, "segmentID": ""}, {"index": 689, "name": "HOH", "atoms": [{"index": 2080, "name": "O", "element": "O"}, {"index": 2081, "name": "H1", "element": "H"}, {"index": 2082, "name": "H2", "element": "H"}], "resSeq": 690, "segmentID": ""}, {"index": 690, "name": "HOH", "atoms": [{"index": 2083, "name": "O", "element": "O"}, {"index": 2084, "name": "H1", "element": "H"}, {"index": 2085, "name": "H2", "element": "H"}], "resSeq": 691, "segmentID": ""}, {"index": 691, "name": "HOH", "atoms": [{"index": 2086, "name": "O", "element": "O"}, {"index": 2087, "name": "H1", "element": "H"}, {"index": 2088, "name": "H2", "element": "H"}], "resSeq": 692, "segmentID": ""}, {"index": 692, "name": "HOH", "atoms": [{"index": 2089, "name": "O", "element": "O"}, {"index": 2090, "name": "H1", "element": "H"}, {"index": 2091, "name": "H2", "element": "H"}], "resSeq": 693, "segmentID": ""}, {"index": 693, "name": "HOH", "atoms": [{"index": 2092, "name": "O", "element": "O"}, {"index": 2093, "name": "H1", "element": "H"}, {"index": 2094, "name": "H2", "element": "H"}], "resSeq": 694, "segmentID": ""}, {"index": 694, "name": "HOH", "atoms": [{"index": 2095, "name": "O", "element": "O"}, {"index": 2096, "name": "H1", "element": "H"}, {"index": 2097, "name": "H2", "element": "H"}], "resSeq": 695, "segmentID": ""}, {"index": 695, "name": "HOH", "atoms": [{"index": 2098, "name": "O", "element": "O"}, {"index": 2099, "name": "H1", "element": "H"}, {"index": 2100, "name": "H2", "element": "H"}], "resSeq": 696, "segmentID": ""}, {"index": 696, "name": "HOH", "atoms": [{"index": 2101, "name": "O", "element": "O"}, {"index": 2102, "name": "H1", "element": "H"}, {"index": 2103, "name": "H2", "element": "H"}], "resSeq": 697, "segmentID": ""}, {"index": 697, "name": "HOH", "atoms": [{"index": 2104, "name": "O", "element": "O"}, {"index": 2105, "name": "H1", "element": "H"}, {"index": 2106, "name": "H2", "element": "H"}], "resSeq": 698, "segmentID": ""}, {"index": 698, "name": "HOH", "atoms": [{"index": 2107, "name": "O", "element": "O"}, {"index": 2108, "name": "H1", "element": "H"}, {"index": 2109, "name": "H2", "element": "H"}], "resSeq": 699, "segmentID": ""}, {"index": 699, "name": "HOH", "atoms": [{"index": 2110, "name": "O", "element": "O"}, {"index": 2111, "name": "H1", "element": "H"}, {"index": 2112, "name": "H2", "element": "H"}], "resSeq": 700, "segmentID": ""}, {"index": 700, "name": "HOH", "atoms": [{"index": 2113, "name": "O", "element": "O"}, {"index": 2114, "name": "H1", "element": "H"}, {"index": 2115, "name": "H2", "element": "H"}], "resSeq": 701, "segmentID": ""}, {"index": 701, "name": "HOH", "atoms": [{"index": 2116, "name": "O", "element": "O"}, {"index": 2117, "name": "H1", "element": "H"}, {"index": 2118, "name": "H2", "element": "H"}], "resSeq": 702, "segmentID": ""}, {"index": 702, "name": "HOH", "atoms": [{"index": 2119, "name": "O", "element": "O"}, {"index": 2120, "name": "H1", "element": "H"}, {"index": 2121, "name": "H2", "element": "H"}], "resSeq": 703, "segmentID": ""}, {"index": 703, "name": "HOH", "atoms": [{"index": 2122, "name": "O", "element": "O"}, {"index": 2123, "name": "H1", "element": "H"}, {"index": 2124, "name": "H2", "element": "H"}], "resSeq": 704, "segmentID": ""}, {"index": 704, "name": "HOH", "atoms": [{"index": 2125, "name": "O", "element": "O"}, {"index": 2126, "name": "H1", "element": "H"}, {"index": 2127, "name": "H2", "element": "H"}], "resSeq": 705, "segmentID": ""}, {"index": 705, "name": "HOH", "atoms": [{"index": 2128, "name": "O", "element": "O"}, {"index": 2129, "name": "H1", "element": "H"}, {"index": 2130, "name": "H2", "element": "H"}], "resSeq": 706, "segmentID": ""}, {"index": 706, "name": "HOH", "atoms": [{"index": 2131, "name": "O", "element": "O"}, {"index": 2132, "name": "H1", "element": "H"}, {"index": 2133, "name": "H2", "element": "H"}], "resSeq": 707, "segmentID": ""}, {"index": 707, "name": "HOH", "atoms": [{"index": 2134, "name": "O", "element": "O"}, {"index": 2135, "name": "H1", "element": "H"}, {"index": 2136, "name": "H2", "element": "H"}], "resSeq": 708, "segmentID": ""}, {"index": 708, "name": "HOH", "atoms": [{"index": 2137, "name": "O", "element": "O"}, {"index": 2138, "name": "H1", "element": "H"}, {"index": 2139, "name": "H2", "element": "H"}], "resSeq": 709, "segmentID": ""}, {"index": 709, "name": "HOH", "atoms": [{"index": 2140, "name": "O", "element": "O"}, {"index": 2141, "name": "H1", "element": "H"}, {"index": 2142, "name": "H2", "element": "H"}], "resSeq": 710, "segmentID": ""}, {"index": 710, "name": "HOH", "atoms": [{"index": 2143, "name": "O", "element": "O"}, {"index": 2144, "name": "H1", "element": "H"}, {"index": 2145, "name": "H2", "element": "H"}], "resSeq": 711, "segmentID": ""}, {"index": 711, "name": "HOH", "atoms": [{"index": 2146, "name": "O", "element": "O"}, {"index": 2147, "name": "H1", "element": "H"}, {"index": 2148, "name": "H2", "element": "H"}], "resSeq": 712, "segmentID": ""}, {"index": 712, "name": "HOH", "atoms": [{"index": 2149, "name": "O", "element": "O"}, {"index": 2150, "name": "H1", "element": "H"}, {"index": 2151, "name": "H2", "element": "H"}], "resSeq": 713, "segmentID": ""}, {"index": 713, "name": "HOH", "atoms": [{"index": 2152, "name": "O", "element": "O"}, {"index": 2153, "name": "H1", "element": "H"}, {"index": 2154, "name": "H2", "element": "H"}], "resSeq": 714, "segmentID": ""}, {"index": 714, "name": "HOH", "atoms": [{"index": 2155, "name": "O", "element": "O"}, {"index": 2156, "name": "H1", "element": "H"}, {"index": 2157, "name": "H2", "element": "H"}], "resSeq": 715, "segmentID": ""}, {"index": 715, "name": "HOH", "atoms": [{"index": 2158, "name": "O", "element": "O"}, {"index": 2159, "name": "H1", "element": "H"}, {"index": 2160, "name": "H2", "element": "H"}], "resSeq": 716, "segmentID": ""}, {"index": 716, "name": "HOH", "atoms": [{"index": 2161, "name": "O", "element": "O"}, {"index": 2162, "name": "H1", "element": "H"}, {"index": 2163, "name": "H2", "element": "H"}], "resSeq": 717, "segmentID": ""}, {"index": 717, "name": "HOH", "atoms": [{"index": 2164, "name": "O", "element": "O"}, {"index": 2165, "name": "H1", "element": "H"}, {"index": 2166, "name": "H2", "element": "H"}], "resSeq": 718, "segmentID": ""}, {"index": 718, "name": "HOH", "atoms": [{"index": 2167, "name": "O", "element": "O"}, {"index": 2168, "name": "H1", "element": "H"}, {"index": 2169, "name": "H2", "element": "H"}], "resSeq": 719, "segmentID": ""}, {"index": 719, "name": "HOH", "atoms": [{"index": 2170, "name": "O", "element": "O"}, {"index": 2171, "name": "H1", "element": "H"}, {"index": 2172, "name": "H2", "element": "H"}], "resSeq": 720, "segmentID": ""}, {"index": 720, "name": "HOH", "atoms": [{"index": 2173, "name": "O", "element": "O"}, {"index": 2174, "name": "H1", "element": "H"}, {"index": 2175, "name": "H2", "element": "H"}], "resSeq": 721, "segmentID": ""}, {"index": 721, "name": "HOH", "atoms": [{"index": 2176, "name": "O", "element": "O"}, {"index": 2177, "name": "H1", "element": "H"}, {"index": 2178, "name": "H2", "element": "H"}], "resSeq": 722, "segmentID": ""}, {"index": 722, "name": "HOH", "atoms": [{"index": 2179, "name": "O", "element": "O"}, {"index": 2180, "name": "H1", "element": "H"}, {"index": 2181, "name": "H2", "element": "H"}], "resSeq": 723, "segmentID": ""}, {"index": 723, "name": "HOH", "atoms": [{"index": 2182, "name": "O", "element": "O"}, {"index": 2183, "name": "H1", "element": "H"}, {"index": 2184, "name": "H2", "element": "H"}], "resSeq": 724, "segmentID": ""}, {"index": 724, "name": "HOH", "atoms": [{"index": 2185, "name": "O", "element": "O"}, {"index": 2186, "name": "H1", "element": "H"}, {"index": 2187, "name": "H2", "element": "H"}], "resSeq": 725, "segmentID": ""}, {"index": 725, "name": "HOH", "atoms": [{"index": 2188, "name": "O", "element": "O"}, {"index": 2189, "name": "H1", "element": "H"}, {"index": 2190, "name": "H2", "element": "H"}], "resSeq": 726, "segmentID": ""}, {"index": 726, "name": "HOH", "atoms": [{"index": 2191, "name": "O", "element": "O"}, {"index": 2192, "name": "H1", "element": "H"}, {"index": 2193, "name": "H2", "element": "H"}], "resSeq": 727, "segmentID": ""}, {"index": 727, "name": "HOH", "atoms": [{"index": 2194, "name": "O", "element": "O"}, {"index": 2195, "name": "H1", "element": "H"}, {"index": 2196, "name": "H2", "element": "H"}], "resSeq": 728, "segmentID": ""}, {"index": 728, "name": "HOH", "atoms": [{"index": 2197, "name": "O", "element": "O"}, {"index": 2198, "name": "H1", "element": "H"}, {"index": 2199, "name": "H2", "element": "H"}], "resSeq": 729, "segmentID": ""}, {"index": 729, "name": "HOH", "atoms": [{"index": 2200, "name": "O", "element": "O"}, {"index": 2201, "name": "H1", "element": "H"}, {"index": 2202, "name": "H2", "element": "H"}], "resSeq": 730, "segmentID": ""}, {"index": 730, "name": "HOH", "atoms": [{"index": 2203, "name": "O", "element": "O"}, {"index": 2204, "name": "H1", "element": "H"}, {"index": 2205, "name": "H2", "element": "H"}], "resSeq": 731, "segmentID": ""}, {"index": 731, "name": "HOH", "atoms": [{"index": 2206, "name": "O", "element": "O"}, {"index": 2207, "name": "H1", "element": "H"}, {"index": 2208, "name": "H2", "element": "H"}], "resSeq": 732, "segmentID": ""}, {"index": 732, "name": "HOH", "atoms": [{"index": 2209, "name": "O", "element": "O"}, {"index": 2210, "name": "H1", "element": "H"}, {"index": 2211, "name": "H2", "element": "H"}], "resSeq": 733, "segmentID": ""}, {"index": 733, "name": "HOH", "atoms": [{"index": 2212, "name": "O", "element": "O"}, {"index": 2213, "name": "H1", "element": "H"}, {"index": 2214, "name": "H2", "element": "H"}], "resSeq": 734, "segmentID": ""}, {"index": 734, "name": "HOH", "atoms": [{"index": 2215, "name": "O", "element": "O"}, {"index": 2216, "name": "H1", "element": "H"}, {"index": 2217, "name": "H2", "element": "H"}], "resSeq": 735, "segmentID": ""}, {"index": 735, "name": "HOH", "atoms": [{"index": 2218, "name": "O", "element": "O"}, {"index": 2219, "name": "H1", "element": "H"}, {"index": 2220, "name": "H2", "element": "H"}], "resSeq": 736, "segmentID": ""}, {"index": 736, "name": "HOH", "atoms": [{"index": 2221, "name": "O", "element": "O"}, {"index": 2222, "name": "H1", "element": "H"}, {"index": 2223, "name": "H2", "element": "H"}], "resSeq": 737, "segmentID": ""}, {"index": 737, "name": "HOH", "atoms": [{"index": 2224, "name": "O", "element": "O"}, {"index": 2225, "name": "H1", "element": "H"}, {"index": 2226, "name": "H2", "element": "H"}], "resSeq": 738, "segmentID": ""}, {"index": 738, "name": "HOH", "atoms": [{"index": 2227, "name": "O", "element": "O"}, {"index": 2228, "name": "H1", "element": "H"}, {"index": 2229, "name": "H2", "element": "H"}], "resSeq": 739, "segmentID": ""}, {"index": 739, "name": "HOH", "atoms": [{"index": 2230, "name": "O", "element": "O"}, {"index": 2231, "name": "H1", "element": "H"}, {"index": 2232, "name": "H2", "element": "H"}], "resSeq": 740, "segmentID": ""}, {"index": 740, "name": "HOH", "atoms": [{"index": 2233, "name": "O", "element": "O"}, {"index": 2234, "name": "H1", "element": "H"}, {"index": 2235, "name": "H2", "element": "H"}], "resSeq": 741, "segmentID": ""}, {"index": 741, "name": "HOH", "atoms": [{"index": 2236, "name": "O", "element": "O"}, {"index": 2237, "name": "H1", "element": "H"}, {"index": 2238, "name": "H2", "element": "H"}], "resSeq": 742, "segmentID": ""}, {"index": 742, "name": "HOH", "atoms": [{"index": 2239, "name": "O", "element": "O"}, {"index": 2240, "name": "H1", "element": "H"}, {"index": 2241, "name": "H2", "element": "H"}], "resSeq": 743, "segmentID": ""}, {"index": 743, "name": "HOH", "atoms": [{"index": 2242, "name": "O", "element": "O"}, {"index": 2243, "name": "H1", "element": "H"}, {"index": 2244, "name": "H2", "element": "H"}], "resSeq": 744, "segmentID": ""}, {"index": 744, "name": "HOH", "atoms": [{"index": 2245, "name": "O", "element": "O"}, {"index": 2246, "name": "H1", "element": "H"}, {"index": 2247, "name": "H2", "element": "H"}], "resSeq": 745, "segmentID": ""}, {"index": 745, "name": "HOH", "atoms": [{"index": 2248, "name": "O", "element": "O"}, {"index": 2249, "name": "H1", "element": "H"}, {"index": 2250, "name": "H2", "element": "H"}], "resSeq": 746, "segmentID": ""}, {"index": 746, "name": "HOH", "atoms": [{"index": 2251, "name": "O", "element": "O"}, {"index": 2252, "name": "H1", "element": "H"}, {"index": 2253, "name": "H2", "element": "H"}], "resSeq": 747, "segmentID": ""}, {"index": 747, "name": "HOH", "atoms": [{"index": 2254, "name": "O", "element": "O"}, {"index": 2255, "name": "H1", "element": "H"}, {"index": 2256, "name": "H2", "element": "H"}], "resSeq": 748, "segmentID": ""}, {"index": 748, "name": "HOH", "atoms": [{"index": 2257, "name": "O", "element": "O"}, {"index": 2258, "name": "H1", "element": "H"}, {"index": 2259, "name": "H2", "element": "H"}], "resSeq": 749, "segmentID": ""}, {"index": 749, "name": "HOH", "atoms": [{"index": 2260, "name": "O", "element": "O"}, {"index": 2261, "name": "H1", "element": "H"}, {"index": 2262, "name": "H2", "element": "H"}], "resSeq": 750, "segmentID": ""}, {"index": 750, "name": "HOH", "atoms": [{"index": 2263, "name": "O", "element": "O"}, {"index": 2264, "name": "H1", "element": "H"}, {"index": 2265, "name": "H2", "element": "H"}], "resSeq": 751, "segmentID": ""}, {"index": 751, "name": "HOH", "atoms": [{"index": 2266, "name": "O", "element": "O"}, {"index": 2267, "name": "H1", "element": "H"}, {"index": 2268, "name": "H2", "element": "H"}], "resSeq": 752, "segmentID": ""}], "index": 0}], "bonds": [[1, 2], [1, 3], [0, 1], [10, 11], [10, 12], [10, 13], [8, 9], [6, 7], [18, 19], [18, 20], [18, 21], [16, 17], [22, 23], [22, 24], [25, 26], [25, 27], [28, 29], [28, 30], [31, 32], [31, 33], [34, 35], [34, 36], [37, 38], [37, 39], [40, 41], [40, 42], [43, 44], [43, 45], [46, 47], [46, 48], [49, 50], [49, 51], [52, 53], [52, 54], [55, 56], [55, 57], [58, 59], [58, 60], [61, 62], [61, 63], [64, 65], [64, 66], [67, 68], [67, 69], [70, 71], [70, 72], [73, 74], [73, 75], [76, 77], [76, 78], [79, 80], [79, 81], [82, 83], [82, 84], [85, 86], [85, 87], [88, 89], [88, 90], [91, 92], [91, 93], [94, 95], [94, 96], [97, 98], [97, 99], [100, 101], [100, 102], [103, 104], [103, 105], [106, 107], [106, 108], [109, 110], [109, 111], [112, 113], [112, 114], [115, 116], [115, 117], [118, 119], [118, 120], [121, 122], [121, 123], [124, 125], [124, 126], [127, 128], [127, 129], [130, 131], [130, 132], [133, 134], [133, 135], [136, 137], [136, 138], [139, 140], [139, 141], [142, 143], [142, 144], [145, 146], [145, 147], [148, 149], [148, 150], [151, 152], [151, 153], [154, 155], [154, 156], [157, 158], [157, 159], [160, 161], [160, 162], [163, 164], [163, 165], [166, 167], [166, 168], [169, 170], [169, 171], [172, 173], [172, 174], [175, 176], [175, 177], [178, 179], [178, 180], [181, 182], [181, 183], [184, 185], [184, 186], [187, 188], [187, 189], [190, 191], [190, 192], [193, 194], [193, 195], [196, 197], [196, 198], [199, 200], [199, 201], [202, 203], [202, 204], [205, 206], [205, 207], [208, 209], [208, 210], [211, 212], [211, 213], [214, 215], [214, 216], [217, 218], [217, 219], [220, 221], [220, 222], [223, 224], [223, 225], [226, 227], [226, 228], [229, 230], [229, 231], [232, 233], [232, 234], [235, 236], [235, 237], [238, 239], [238, 240], [241, 242], [241, 243], [244, 245], [244, 246], [247, 248], [247, 249], [250, 251], [250, 252], [253, 254], [253, 255], [256, 257], [256, 258], [259, 260], [259, 261], [262, 263], [262, 264], [265, 266], [265, 267], [268, 269], [268, 270], [271, 272], [271, 273], [274, 275], [274, 276], [277, 278], [277, 279], [280, 281], [280, 282], [283, 284], [283, 285], [286, 287], [286, 288], [289, 290], [289, 291], [292, 293], [292, 294], [295, 296], [295, 297], [298, 299], [298, 300], [301, 302], [301, 303], [304, 305], [304, 306], [307, 308], [307, 309], [310, 311], [310, 312], [313, 314], [313, 315], [316, 317], [316, 318], [319, 320], [319, 321], [322, 323], [322, 324], [325, 326], [325, 327], [328, 329], [328, 330], [331, 332], [331, 333], [334, 335], [334, 336], [337, 338], [337, 339], [340, 341], [340, 342], [343, 344], [343, 345], [346, 347], [346, 348], [349, 350], [349, 351], [352, 353], [352, 354], [355, 356], [355, 357], [358, 359], [358, 360], [361, 362], [361, 363], [364, 365], [364, 366], [367, 368], [367, 369], [370, 371], [370, 372], [373, 374], [373, 375], [376, 377], [376, 378], [379, 380], [379, 381], [382, 383], [382, 384], [385, 386], [385, 387], [388, 389], [388, 390], [391, 392], [391, 393], [394, 395], [394, 396], [397, 398], [397, 399], [400, 401], [400, 402], [403, 404], [403, 405], [406, 407], [406, 408], [409, 410], [409, 411], [412, 413], [412, 414], [415, 416], [415, 417], [418, 419], [418, 420], [421, 422], [421, 423], [424, 425], [424, 426], [427, 428], [427, 429], [430, 431], [430, 432], [433, 434], [433, 435], [436, 437], [436, 438], [439, 440], [439, 441], [442, 443], [442, 444], [445, 446], [445, 447], [448, 449], [448, 450], [451, 452], [451, 453], [454, 455], [454, 456], [457, 458], [457, 459], [460, 461], [460, 462], [463, 464], [463, 465], [466, 467], [466, 468], [469, 470], [469, 471], [472, 473], [472, 474], [475, 476], [475, 477], [478, 479], [478, 480], [481, 482], [481, 483], [484, 485], [484, 486], [487, 488], [487, 489], [490, 491], [490, 492], [493, 494], [493, 495], [496, 497], [496, 498], [499, 500], [499, 501], [502, 503], [502, 504], [505, 506], [505, 507], [508, 509], [508, 510], [511, 512], [511, 513], [514, 515], [514, 516], [517, 518], [517, 519], [520, 521], [520, 522], [523, 524], [523, 525], [526, 527], [526, 528], [529, 530], [529, 531], [532, 533], [532, 534], [535, 536], [535, 537], [538, 539], [538, 540], [541, 542], [541, 543], [544, 545], [544, 546], [547, 548], [547, 549], [550, 551], [550, 552], [553, 554], [553, 555], [556, 557], [556, 558], [559, 560], [559, 561], [562, 563], [562, 564], [565, 566], [565, 567], [568, 569], [568, 570], [571, 572], [571, 573], [574, 575], [574, 576], [577, 578], [577, 579], [580, 581], [580, 582], [583, 584], [583, 585], [586, 587], [586, 588], [589, 590], [589, 591], [592, 593], [592, 594], [595, 596], [595, 597], [598, 599], [598, 600], [601, 602], [601, 603], [604, 605], [604, 606], [607, 608], [607, 609], [610, 611], [610, 612], [613, 614], [613, 615], [616, 617], [616, 618], [619, 620], [619, 621], [622, 623], [622, 624], [625, 626], [625, 627], [628, 629], [628, 630], [631, 632], [631, 633], [634, 635], [634, 636], [637, 638], [637, 639], [640, 641], [640, 642], [643, 644], [643, 645], [646, 647], [646, 648], [649, 650], [649, 651], [652, 653], [652, 654], [655, 656], [655, 657], [658, 659], [658, 660], [661, 662], [661, 663], [664, 665], [664, 666], [667, 668], [667, 669], [670, 671], [670, 672], [673, 674], [673, 675], [676, 677], [676, 678], [679, 680], [679, 681], [682, 683], [682, 684], [685, 686], [685, 687], [688, 689], [688, 690], [691, 692], [691, 693], [694, 695], [694, 696], [697, 698], [697, 699], [700, 701], [700, 702], [703, 704], [703, 705], [706, 707], [706, 708], [709, 710], [709, 711], [712, 713], [712, 714], [715, 716], [715, 717], [718, 719], [718, 720], [721, 722], [721, 723], [724, 725], [724, 726], [727, 728], [727, 729], [730, 731], [730, 732], [733, 734], [733, 735], [736, 737], [736, 738], [739, 740], [739, 741], [742, 743], [742, 744], [745, 746], [745, 747], [748, 749], [748, 750], [751, 752], [751, 753], [754, 755], [754, 756], [757, 758], [757, 759], [760, 761], [760, 762], [763, 764], [763, 765], [766, 767], [766, 768], [769, 770], [769, 771], [772, 773], [772, 774], [775, 776], [775, 777], [778, 779], [778, 780], [781, 782], [781, 783], [784, 785], [784, 786], [787, 788], [787, 789], [790, 791], [790, 792], [793, 794], [793, 795], [796, 797], [796, 798], [799, 800], [799, 801], [802, 803], [802, 804], [805, 806], [805, 807], [808, 809], [808, 810], [811, 812], [811, 813], [814, 815], [814, 816], [817, 818], [817, 819], [820, 821], [820, 822], [823, 824], [823, 825], [826, 827], [826, 828], [829, 830], [829, 831], [832, 833], [832, 834], [835, 836], [835, 837], [838, 839], [838, 840], [841, 842], [841, 843], [844, 845], [844, 846], [847, 848], [847, 849], [850, 851], [850, 852], [853, 854], [853, 855], [856, 857], [856, 858], [859, 860], [859, 861], [862, 863], [862, 864], [865, 866], [865, 867], [868, 869], [868, 870], [871, 872], [871, 873], [874, 875], [874, 876], [877, 878], [877, 879], [880, 881], [880, 882], [883, 884], [883, 885], [886, 887], [886, 888], [889, 890], [889, 891], [892, 893], [892, 894], [895, 896], [895, 897], [898, 899], [898, 900], [901, 902], [901, 903], [904, 905], [904, 906], [907, 908], [907, 909], [910, 911], [910, 912], [913, 914], [913, 915], [916, 917], [916, 918], [919, 920], [919, 921], [922, 923], [922, 924], [925, 926], [925, 927], [928, 929], [928, 930], [931, 932], [931, 933], [934, 935], [934, 936], [937, 938], [937, 939], [940, 941], [940, 942], [943, 944], [943, 945], [946, 947], [946, 948], [949, 950], [949, 951], [952, 953], [952, 954], [955, 956], [955, 957], [958, 959], [958, 960], [961, 962], [961, 963], [964, 965], [964, 966], [967, 968], [967, 969], [970, 971], [970, 972], [973, 974], [973, 975], [976, 977], [976, 978], [979, 980], [979, 981], [982, 983], [982, 984], [985, 986], [985, 987], [988, 989], [988, 990], [991, 992], [991, 993], [994, 995], [994, 996], [997, 998], [997, 999], [1000, 1001], [1000, 1002], [1003, 1004], [1003, 1005], [1006, 1007], [1006, 1008], [1009, 1010], [1009, 1011], [1012, 1013], [1012, 1014], [1015, 1016], [1015, 1017], [1018, 1019], [1018, 1020], [1021, 1022], [1021, 1023], [1024, 1025], [1024, 1026], [1027, 1028], [1027, 1029], [1030, 1031], [1030, 1032], [1033, 1034], [1033, 1035], [1036, 1037], [1036, 1038], [1039, 1040], [1039, 1041], [1042, 1043], [1042, 1044], [1045, 1046], [1045, 1047], [1048, 1049], [1048, 1050], [1051, 1052], [1051, 1053], [1054, 1055], [1054, 1056], [1057, 1058], [1057, 1059], [1060, 1061], [1060, 1062], [1063, 1064], [1063, 1065], [1066, 1067], [1066, 1068], [1069, 1070], [1069, 1071], [1072, 1073], [1072, 1074], [1075, 1076], [1075, 1077], [1078, 1079], [1078, 1080], [1081, 1082], [1081, 1083], [1084, 1085], [1084, 1086], [1087, 1088], [1087, 1089], [1090, 1091], [1090, 1092], [1093, 1094], [1093, 1095], [1096, 1097], [1096, 1098], [1099, 1100], [1099, 1101], [1102, 1103], [1102, 1104], [1105, 1106], [1105, 1107], [1108, 1109], [1108, 1110], [1111, 1112], [1111, 1113], [1114, 1115], [1114, 1116], [1117, 1118], [1117, 1119], [1120, 1121], [1120, 1122], [1123, 1124], [1123, 1125], [1126, 1127], [1126, 1128], [1129, 1130], [1129, 1131], [1132, 1133], [1132, 1134], [1135, 1136], [1135, 1137], [1138, 1139], [1138, 1140], [1141, 1142], [1141, 1143], [1144, 1145], [1144, 1146], [1147, 1148], [1147, 1149], [1150, 1151], [1150, 1152], [1153, 1154], [1153, 1155], [1156, 1157], [1156, 1158], [1159, 1160], [1159, 1161], [1162, 1163], [1162, 1164], [1165, 1166], [1165, 1167], [1168, 1169], [1168, 1170], [1171, 1172], [1171, 1173], [1174, 1175], [1174, 1176], [1177, 1178], [1177, 1179], [1180, 1181], [1180, 1182], [1183, 1184], [1183, 1185], [1186, 1187], [1186, 1188], [1189, 1190], [1189, 1191], [1192, 1193], [1192, 1194], [1195, 1196], [1195, 1197], [1198, 1199], [1198, 1200], [1201, 1202], [1201, 1203], [1204, 1205], [1204, 1206], [1207, 1208], [1207, 1209], [1210, 1211], [1210, 1212], [1213, 1214], [1213, 1215], [1216, 1217], [1216, 1218], [1219, 1220], [1219, 1221], [1222, 1223], [1222, 1224], [1225, 1226], [1225, 1227], [1228, 1229], [1228, 1230], [1231, 1232], [1231, 1233], [1234, 1235], [1234, 1236], [1237, 1238], [1237, 1239], [1240, 1241], [1240, 1242], [1243, 1244], [1243, 1245], [1246, 1247], [1246, 1248], [1249, 1250], [1249, 1251], [1252, 1253], [1252, 1254], [1255, 1256], [1255, 1257], [1258, 1259], [1258, 1260], [1261, 1262], [1261, 1263], [1264, 1265], [1264, 1266], [1267, 1268], [1267, 1269], [1270, 1271], [1270, 1272], [1273, 1274], [1273, 1275], [1276, 1277], [1276, 1278], [1279, 1280], [1279, 1281], [1282, 1283], [1282, 1284], [1285, 1286], [1285, 1287], [1288, 1289], [1288, 1290], [1291, 1292], [1291, 1293], [1294, 1295], [1294, 1296], [1297, 1298], [1297, 1299], [1300, 1301], [1300, 1302], [1303, 1304], [1303, 1305], [1306, 1307], [1306, 1308], [1309, 1310], [1309, 1311], [1312, 1313], [1312, 1314], [1315, 1316], [1315, 1317], [1318, 1319], [1318, 1320], [1321, 1322], [1321, 1323], [1324, 1325], [1324, 1326], [1327, 1328], [1327, 1329], [1330, 1331], [1330, 1332], [1333, 1334], [1333, 1335], [1336, 1337], [1336, 1338], [1339, 1340], [1339, 1341], [1342, 1343], [1342, 1344], [1345, 1346], [1345, 1347], [1348, 1349], [1348, 1350], [1351, 1352], [1351, 1353], [1354, 1355], [1354, 1356], [1357, 1358], [1357, 1359], [1360, 1361], [1360, 1362], [1363, 1364], [1363, 1365], [1366, 1367], [1366, 1368], [1369, 1370], [1369, 1371], [1372, 1373], [1372, 1374], [1375, 1376], [1375, 1377], [1378, 1379], [1378, 1380], [1381, 1382], [1381, 1383], [1384, 1385], [1384, 1386], [1387, 1388], [1387, 1389], [1390, 1391], [1390, 1392], [1393, 1394], [1393, 1395], [1396, 1397], [1396, 1398], [1399, 1400], [1399, 1401], [1402, 1403], [1402, 1404], [1405, 1406], [1405, 1407], [1408, 1409], [1408, 1410], [1411, 1412], [1411, 1413], [1414, 1415], [1414, 1416], [1417, 1418], [1417, 1419], [1420, 1421], [1420, 1422], [1423, 1424], [1423, 1425], [1426, 1427], [1426, 1428], [1429, 1430], [1429, 1431], [1432, 1433], [1432, 1434], [1435, 1436], [1435, 1437], [1438, 1439], [1438, 1440], [1441, 1442], [1441, 1443], [1444, 1445], [1444, 1446], [1447, 1448], [1447, 1449], [1450, 1451], [1450, 1452], [1453, 1454], [1453, 1455], [1456, 1457], [1456, 1458], [1459, 1460], [1459, 1461], [1462, 1463], [1462, 1464], [1465, 1466], [1465, 1467], [1468, 1469], [1468, 1470], [1471, 1472], [1471, 1473], [1474, 1475], [1474, 1476], [1477, 1478], [1477, 1479], [1480, 1481], [1480, 1482], [1483, 1484], [1483, 1485], [1486, 1487], [1486, 1488], [1489, 1490], [1489, 1491], [1492, 1493], [1492, 1494], [1495, 1496], [1495, 1497], [1498, 1499], [1498, 1500], [1501, 1502], [1501, 1503], [1504, 1505], [1504, 1506], [1507, 1508], [1507, 1509], [1510, 1511], [1510, 1512], [1513, 1514], [1513, 1515], [1516, 1517], [1516, 1518], [1519, 1520], [1519, 1521], [1522, 1523], [1522, 1524], [1525, 1526], [1525, 1527], [1528, 1529], [1528, 1530], [1531, 1532], [1531, 1533], [1534, 1535], [1534, 1536], [1537, 1538], [1537, 1539], [1540, 1541], [1540, 1542], [1543, 1544], [1543, 1545], [1546, 1547], [1546, 1548], [1549, 1550], [1549, 1551], [1552, 1553], [1552, 1554], [1555, 1556], [1555, 1557], [1558, 1559], [1558, 1560], [1561, 1562], [1561, 1563], [1564, 1565], [1564, 1566], [1567, 1568], [1567, 1569], [1570, 1571], [1570, 1572], [1573, 1574], [1573, 1575], [1576, 1577], [1576, 1578], [1579, 1580], [1579, 1581], [1582, 1583], [1582, 1584], [1585, 1586], [1585, 1587], [1588, 1589], [1588, 1590], [1591, 1592], [1591, 1593], [1594, 1595], [1594, 1596], [1597, 1598], [1597, 1599], [1600, 1601], [1600, 1602], [1603, 1604], [1603, 1605], [1606, 1607], [1606, 1608], [1609, 1610], [1609, 1611], [1612, 1613], [1612, 1614], [1615, 1616], [1615, 1617], [1618, 1619], [1618, 1620], [1621, 1622], [1621, 1623], [1624, 1625], [1624, 1626], [1627, 1628], [1627, 1629], [1630, 1631], [1630, 1632], [1633, 1634], [1633, 1635], [1636, 1637], [1636, 1638], [1639, 1640], [1639, 1641], [1642, 1643], [1642, 1644], [1645, 1646], [1645, 1647], [1648, 1649], [1648, 1650], [1651, 1652], [1651, 1653], [1654, 1655], [1654, 1656], [1657, 1658], [1657, 1659], [1660, 1661], [1660, 1662], [1663, 1664], [1663, 1665], [1666, 1667], [1666, 1668], [1669, 1670], [1669, 1671], [1672, 1673], [1672, 1674], [1675, 1676], [1675, 1677], [1678, 1679], [1678, 1680], [1681, 1682], [1681, 1683], [1684, 1685], [1684, 1686], [1687, 1688], [1687, 1689], [1690, 1691], [1690, 1692], [1693, 1694], [1693, 1695], [1696, 1697], [1696, 1698], [1699, 1700], [1699, 1701], [1702, 1703], [1702, 1704], [1705, 1706], [1705, 1707], [1708, 1709], [1708, 1710], [1711, 1712], [1711, 1713], [1714, 1715], [1714, 1716], [1717, 1718], [1717, 1719], [1720, 1721], [1720, 1722], [1723, 1724], [1723, 1725], [1726, 1727], [1726, 1728], [1729, 1730], [1729, 1731], [1732, 1733], [1732, 1734], [1735, 1736], [1735, 1737], [1738, 1739], [1738, 1740], [1741, 1742], [1741, 1743], [1744, 1745], [1744, 1746], [1747, 1748], [1747, 1749], [1750, 1751], [1750, 1752], [1753, 1754], [1753, 1755], [1756, 1757], [1756, 1758], [1759, 1760], [1759, 1761], [1762, 1763], [1762, 1764], [1765, 1766], [1765, 1767], [1768, 1769], [1768, 1770], [1771, 1772], [1771, 1773], [1774, 1775], [1774, 1776], [1777, 1778], [1777, 1779], [1780, 1781], [1780, 1782], [1783, 1784], [1783, 1785], [1786, 1787], [1786, 1788], [1789, 1790], [1789, 1791], [1792, 1793], [1792, 1794], [1795, 1796], [1795, 1797], [1798, 1799], [1798, 1800], [1801, 1802], [1801, 1803], [1804, 1805], [1804, 1806], [1807, 1808], [1807, 1809], [1810, 1811], [1810, 1812], [1813, 1814], [1813, 1815], [1816, 1817], [1816, 1818], [1819, 1820], [1819, 1821], [1822, 1823], [1822, 1824], [1825, 1826], [1825, 1827], [1828, 1829], [1828, 1830], [1831, 1832], [1831, 1833], [1834, 1835], [1834, 1836], [1837, 1838], [1837, 1839], [1840, 1841], [1840, 1842], [1843, 1844], [1843, 1845], [1846, 1847], [1846, 1848], [1849, 1850], [1849, 1851], [1852, 1853], [1852, 1854], [1855, 1856], [1855, 1857], [1858, 1859], [1858, 1860], [1861, 1862], [1861, 1863], [1864, 1865], [1864, 1866], [1867, 1868], [1867, 1869], [1870, 1871], [1870, 1872], [1873, 1874], [1873, 1875], [1876, 1877], [1876, 1878], [1879, 1880], [1879, 1881], [1882, 1883], [1882, 1884], [1885, 1886], [1885, 1887], [1888, 1889], [1888, 1890], [1891, 1892], [1891, 1893], [1894, 1895], [1894, 1896], [1897, 1898], [1897, 1899], [1900, 1901], [1900, 1902], [1903, 1904], [1903, 1905], [1906, 1907], [1906, 1908], [1909, 1910], [1909, 1911], [1912, 1913], [1912, 1914], [1915, 1916], [1915, 1917], [1918, 1919], [1918, 1920], [1921, 1922], [1921, 1923], [1924, 1925], [1924, 1926], [1927, 1928], [1927, 1929], [1930, 1931], [1930, 1932], [1933, 1934], [1933, 1935], [1936, 1937], [1936, 1938], [1939, 1940], [1939, 1941], [1942, 1943], [1942, 1944], [1945, 1946], [1945, 1947], [1948, 1949], [1948, 1950], [1951, 1952], [1951, 1953], [1954, 1955], [1954, 1956], [1957, 1958], [1957, 1959], [1960, 1961], [1960, 1962], [1963, 1964], [1963, 1965], [1966, 1967], [1966, 1968], [1969, 1970], [1969, 1971], [1972, 1973], [1972, 1974], [1975, 1976], [1975, 1977], [1978, 1979], [1978, 1980], [1981, 1982], [1981, 1983], [1984, 1985], [1984, 1986], [1987, 1988], [1987, 1989], [1990, 1991], [1990, 1992], [1993, 1994], [1993, 1995], [1996, 1997], [1996, 1998], [1999, 2000], [1999, 2001], [2002, 2003], [2002, 2004], [2005, 2006], [2005, 2007], [2008, 2009], [2008, 2010], [2011, 2012], [2011, 2013], [2014, 2015], [2014, 2016], [2017, 2018], [2017, 2019], [2020, 2021], [2020, 2022], [2023, 2024], [2023, 2025], [2026, 2027], [2026, 2028], [2029, 2030], [2029, 2031], [2032, 2033], [2032, 2034], [2035, 2036], [2035, 2037], [2038, 2039], [2038, 2040], [2041, 2042], [2041, 2043], [2044, 2045], [2044, 2046], [2047, 2048], [2047, 2049], [2050, 2051], [2050, 2052], [2053, 2054], [2053, 2055], [2056, 2057], [2056, 2058], [2059, 2060], [2059, 2061], [2062, 2063], [2062, 2064], [2065, 2066], [2065, 2067], [2068, 2069], [2068, 2070], [2071, 2072], [2071, 2073], [2074, 2075], [2074, 2076], [2077, 2078], [2077, 2079], [2080, 2081], [2080, 2082], [2083, 2084], [2083, 2085], [2086, 2087], [2086, 2088], [2089, 2090], [2089, 2091], [2092, 2093], [2092, 2094], [2095, 2096], [2095, 2097], [2098, 2099], [2098, 2100], [2101, 2102], [2101, 2103], [2104, 2105], [2104, 2106], [2107, 2108], [2107, 2109], [2110, 2111], [2110, 2112], [2113, 2114], [2113, 2115], [2116, 2117], [2116, 2118], [2119, 2120], [2119, 2121], [2122, 2123], [2122, 2124], [2125, 2126], [2125, 2127], [2128, 2129], [2128, 2130], [2131, 2132], [2131, 2133], [2134, 2135], [2134, 2136], [2137, 2138], [2137, 2139], [2140, 2141], [2140, 2142], [2143, 2144], [2143, 2145], [2146, 2147], [2146, 2148], [2149, 2150], [2149, 2151], [2152, 2153], [2152, 2154], [2155, 2156], [2155, 2157], [2158, 2159], [2158, 2160], [2161, 2162], [2161, 2163], [2164, 2165], [2164, 2166], [2167, 2168], [2167, 2169], [2170, 2171], [2170, 2172], [2173, 2174], [2173, 2175], [2176, 2177], [2176, 2178], [2179, 2180], [2179, 2181], [2182, 2183], [2182, 2184], [2185, 2186], [2185, 2187], [2188, 2189], [2188, 2190], [2191, 2192], [2191, 2193], [2194, 2195], [2194, 2196], [2197, 2198], [2197, 2199], [2200, 2201], [2200, 2202], [2203, 2204], [2203, 2205], [2206, 2207], [2206, 2208], [2209, 2210], [2209, 2211], [2212, 2213], [2212, 2214], [2215, 2216], [2215, 2217], [2218, 2219], [2218, 2220], [2221, 2222], [2221, 2223], [2224, 2225], [2224, 2226], [2227, 2228], [2227, 2229], [2230, 2231], [2230, 2232], [2233, 2234], [2233, 2235], [2236, 2237], [2236, 2238], [2239, 2240], [2239, 2241], [2242, 2243], [2242, 2244], [2245, 2246], [2245, 2247], [2248, 2249], [2248, 2250], [2251, 2252], [2251, 2253], [2254, 2255], [2254, 2256], [2257, 2258], [2257, 2259], [2260, 2261], [2260, 2262], [2263, 2264], [2263, 2265], [2266, 2267], [2266, 2268], [4, 5], [4, 6], [1, 4], [14, 15], [14, 16], [8, 10], [8, 14], [6, 8], [16, 18]]} \ No newline at end of file diff --git a/src/wepy_tools/systems/lennard_jones.py b/src/wepy_tools/systems/lennard_jones.py index 1e8bdeb7..5645ccc7 100644 --- a/src/wepy_tools/systems/lennard_jones.py +++ b/src/wepy_tools/systems/lennard_jones.py @@ -1,19 +1,132 @@ # Third Party Library +import attrs +import mdtraj import numpy as np +import numpy.typing +import openmm +import openmm.app +import openmm.unit from scipy.spatial.distance import euclidean # First Party Library -from wepy.resampling.distances.distance import Distance +from wepy.resampling.distances.base import Distance +from wepy.runners.openmm import OpenMMState +from wepy.util.mdtraj import mdtraj_to_json_topology + + +class LennardJonesPair: + """Create a pair of Lennard-Jones particles. + + Parameters + ---------- + mass : simtk.unit.Quantity with units compatible with amu, optional, default=39.9*amu + The mass of each particle. + epsilon : simtk.unit.Quantity with units compatible with kilojoules_per_mole, optional, default=1.0*kilocalories_per_mole + The effective Lennard-Jones sigma parameter. + sigma : simtk.unit.Quantity with units compatible with nanometers, optional, default=3.350*angstroms + The effective Lennard-Jones sigma parameter. + + Examples + -------- + Create Lennard-Jones pair. + + >>> test = LennardJonesPair() + >>> system, positions = test.system, test.positions + >>> thermodynamic_state = ThermodynamicState(temperature=300.0*unit.kelvin) + >>> binding_free_energy = test.get_binding_free_energy(thermodynamic_state) + + Create Lennard-Jones pair with different well depth. + + >>> test = LennardJonesPair(epsilon=11.0*unit.kilocalories_per_mole) + >>> system, positions = test.system, test.positions + >>> thermodynamic_state = ThermodynamicState(temperature=300.0*unit.kelvin) + >>> binding_free_energy = test.get_binding_free_energy(thermodynamic_state) + + Create Lennard-Jones pair with different well depth and sigma. + + >>> test = LennardJonesPair(epsilon=7.0*unit.kilocalories_per_mole, sigma=4.5*unit.angstroms) + >>> system, positions = test.system, test.positions + >>> thermodynamic_state = ThermodynamicState(temperature=300.0*unit.kelvin) + >>> binding_free_energy = test.get_binding_free_energy(thermodynamic_state) + + """ + + def __init__( + self, + mass=39.9 * openmm.unit.amu, + sigma=3.350 * openmm.unit.angstrom, + epsilon=10.0 * openmm.unit.kilocalories_per_mole, + ): + + # Store parameters + self.mass = mass + self.sigma = sigma + self.epsilon = epsilon + + # Charge must be zero. + charge = 0.0 * openmm.unit.elementary_charge + + # Create an empty system object. + system = openmm.System() + + # Create a NonbondedForce object with no cutoff. + force = openmm.NonbondedForce() + force.setNonbondedMethod(openmm.NonbondedForce.NoCutoff) + + # Create positions. + positions = openmm.unit.Quantity( + np.zeros([2, 3], np.float32), openmm.unit.angstrom + ) + # Move the second particle along the x axis to be at the potential minimum. + positions[1, 0] = 2.0 ** (1.0 / 6.0) * sigma + + # Create first particle. + system.addParticle(mass) + force.addParticle(charge, sigma, epsilon) + + # Create second particle. + system.addParticle(mass) + force.addParticle(charge, sigma, epsilon) + + # Add the nonbonded force. + system.addForce(force) + + # Store system and positions. + self.system, self.positions = system, positions + + # Store ligand and receptor particle indices. + self.ligand_indices = [0] + self.receptor_indices = [1] + + # Create topology. + topology = openmm.app.Topology() + element = openmm.app.Element.getBySymbol("Ar") + chain = topology.addChain() + residue = topology.addResidue("Ar", chain) + topology.addAtom("Ar", element, residue) + residue = topology.addResidue("Ar", chain) + topology.addAtom("Ar", element, residue) + self.topology = topology + + self.mdj_top = mdtraj.Topology.from_openmm(self.topology) + self.json_top = mdtraj_to_json_topology(self.mdj_top) + + +@attrs.define +class PairDistanceImage: + positions: numpy.typing.ArrayLike class PairDistance(Distance): def __init__(self, metric=euclidean): self.metric = metric - def image(self, state): - return state["positions"] + def image(self, state: OpenMMState) -> PairDistanceImage: + return state.positions - def image_distance(self, image_a, image_b): + def image_distance( + self, image_a: PairDistanceImage, image_b: PairDistanceImage + ) -> float: dist_a = self.metric(image_a[0], image_a[1]) dist_b = self.metric(image_b[0], image_b[1]) diff --git a/src/wepy_tools/systems/mock.py b/src/wepy_tools/systems/mock.py deleted file mode 100644 index b69406cd..00000000 --- a/src/wepy_tools/systems/mock.py +++ /dev/null @@ -1 +0,0 @@ -"""Mock systems""" diff --git a/src/wepy_tools/systems/openmm/base.py b/src/wepy_tools/systems/openmm/base.py deleted file mode 100644 index 428a3192..00000000 --- a/src/wepy_tools/systems/openmm/base.py +++ /dev/null @@ -1,139 +0,0 @@ -import os -import os.path -import numpy as np - -import scipy -import scipy.special -import scipy.integrate - -import openmm -import openmm.unit as unit -import openmm.app as omma - - -class TestSystem(object): - """Abstract base class for test systems, demonstrating how to implement a test system. - - Parameters - ---------- - - Attributes - ---------- - system : openmm.System - System object for the test system - positions : list - positions of test system - topology : list - topology of the test system - - Notes - ----- - - Unimplemented methods will default to the base class methods, which raise a NotImplementedException. - - Examples - -------- - - Create a test system. - - >>> testsystem = TestSystem() - - Retrieve a deep copy of the System object. - - >>> system = testsystem.system - - Retrieve a deep copy of the positions. - - >>> positions = testsystem.positions - - Retrieve a deep copy of the topology. - - >>> topology = testsystem.topology - - Serialize system and positions to XML (to aid in debugging). - - >>> (system_xml, positions_xml) = testsystem.serialize() - - """ - - def __init__(self, **kwargs): - """Abstract base class for test system. - - Parameters - ---------- - - """ - - # Create an empty system object. - self._system = openmm.System() - - # Store positions. - self._positions = unit.Quantity(np.zeros([0, 3], float), unit.nanometers) - - # Empty topology. - self._topology = omma.Topology() - # MDTraj Topology is built on demand. - self._mdtraj_topology = None - - @property - def system(self): - """The openmm.System object corresponding to the test system.""" - return self._system - - @system.setter - def system(self, value): - self._system = value - - @system.deleter - def system(self): - del self._system - - @property - def positions(self): - """The openmm.unit.Quantity object containing the particle positions, with units compatible with openmm.unit.nanometers.""" - return self._positions - - @positions.setter - def positions(self, value): - self._positions = value - - @positions.deleter - def positions(self): - del self._positions - - @property - def topology(self): - """The openmm.app.Topology object corresponding to the test system.""" - return self._topology - - @topology.setter - def topology(self, value): - self._topology = value - self._mdtraj_topology = None - - @topology.deleter - def topology(self): - del self._topology - - @property - def mdtraj_topology(self): - """The mdtraj.Topology object corresponding to the test system (read-only).""" - import mdtraj as md - - if self._mdtraj_topology is None: - self._mdtraj_topology = md.Topology.from_openmm(self._topology) - return self._mdtraj_topology - - def construct_restraining_potential(self, particle_indices, K): - """Make a CustomExternalForce that puts an origin-centered spring on the chosen particles""" - - # Add a restraining potential centered at the origin. - energy_expression = "(K/2.0) * (x^2 + y^2 + z^2);" - energy_expression += "K = %f;" % ( - K / (unit.kilojoules_per_mole / unit.nanometers**2) - ) # in OpenMM units - force = openmm.CustomExternalForce(energy_expression) - for particle_index in particle_indices: - force.addParticle(particle_index, []) - - return force diff --git a/src/wepy_tools/systems/openmm/nacl_pair.py b/src/wepy_tools/systems/openmm/nacl_pair.py deleted file mode 100644 index f247d883..00000000 --- a/src/wepy_tools/systems/openmm/nacl_pair.py +++ /dev/null @@ -1,144 +0,0 @@ -from wepy_tools.systems.openmm.base import TestSystem - -import numpy as np - -import openmm -import openmm.unit as unit -import openmm.app as omma - - -class NaClPair(TestSystem): - """Create a non-periodic rectilinear grid of NaCl pair in a harmonic restraining potential. - - Parameters - ---------- - nx : int, optional, default=3 - number of particles in the x direction - ny : int, optional, default=3 - number of particles in the y direction - nz : int, optional, default=3 - number of particles in the z direction - - Attributes - ---------- - MASS_Na : openmm.unit.Quantity - Mass of a Na atom. - MASS_Cl : openmm.unit.Quantity - Mass of a Cl atom. - Q_Na : openmm.unit.Quantity - Charge of a Na ion. - Q_Cl : openmm.unit.Quantity - Charge of a Cl ion. - SIGMA_Na : openmm.unit.Quantity - Lennard-Jones sigma parameter for Na. - SIGMA_Cl : openmm.unit.Quantity - Lennard-Jones sigma parameter for Cl. - EPSILON_Na : openmm.unit.Quantity - Lennard-Jones epsilon parameter for Na. - EPSILON_Cl : openmm.unit.Quantity - Lennard-Jones epsilon parameter for Cl. - CUTOFF : openmm.unit.Quantity or None - Class-level default cutoff distance. If None, no cutoff is used. - SWITCH_WIDTH : openmm.unit.Quantity or None - Class-level default switching width. - SCALE_STEP_SIZE_X : float - Step size scaling in the x dimension (default=1.0). - SCALE_STEP_SIZE_Y : float - Step size scaling in the y dimension (default=1.0). - SCALE_STEP_SIZE_Z : float - Step size scaling in the z dimension (default=1.0). - - """ - - MASS_Na = 22.99 * unit.amu - MASS_Cl = 35.45 * unit.amu - Q_Na = 1.0 * unit.elementary_charge - Q_Cl = -1.0 * unit.elementary_charge - SIGMA_Na = 2.0 * unit.angstrom - SIGMA_Cl = 4.0 * unit.angstrom - EPSILON_Na = 0.1 * unit.kilojoule_per_mole - EPSILON_Cl = 0.2 * unit.kilojoule_per_mole - K = 1.0 * unit.kilojoules_per_mole / unit.nanometer**2 - - CUTOFF = None - SWITCH_WIDTH = None - - SCALE_STEP_SIZE_X = 1.0 - SCALE_STEP_SIZE_Y = 1.0 - SCALE_STEP_SIZE_Z = 1.0 - - def __init__(self, nx=3, ny=3, nz=3, **kwargs): - super().__init__(**kwargs) - - self.nx = nx - self.ny = ny - self.nz = nz - self.natoms = nx * ny * nz - - self.construct_system() - - def construct_system(self): - system = openmm.System() - - nb = openmm.NonbondedForce() - - if self.CUTOFF is None: - nb.setNonbondedMethod(openmm.NonbondedForce.NoCutoff) - else: - nb.setNonbondedMethod(openmm.NonbondedForce.CutoffNonPeriodic) - nb.setCutoffDistance(self.CUTOFF) - nb.setUseDispersionCorrection(False) - nb.setUseSwitchingFunction(False) - if self.SWITCH_WIDTH is not None: - nb.setUseSwitchingFunction(True) - nb.setSwitchingDistance(self.CUTOFF - self.SWITCH_WIDTH) - - positions = unit.Quantity(np.zeros([self.natoms, 3], np.float32), unit.angstrom) - - atom_index = 0 - for ii in range(self.nx): - for jj in range(self.ny): - for kk in range(self.nz): - if (atom_index % 2) == 0: # Alternating Na and Cl - mass = self.MASS_Na - q = self.Q_Na - sigma = self.SIGMA_Na - epsilon = self.EPSILON_Na - element = omma.Element.getBySymbol("Na") - atom_name = "Na" - else: - mass = self.MASS_Cl - q = self.Q_Cl - sigma = self.SIGMA_Cl - epsilon = self.EPSILON_Cl - element = omma.Element.getBySymbol("Cl") - atom_name = "Cl" - - system.addParticle(mass) - nb.addParticle(q, sigma, epsilon) - x = sigma * self.SCALE_STEP_SIZE_X * (ii - self.nx / 2.0) - y = sigma * self.SCALE_STEP_SIZE_Y * (jj - self.ny / 2.0) - z = sigma * self.SCALE_STEP_SIZE_Z * (kk - self.nz / 2.0) - - positions[atom_index, 0] = x - positions[atom_index, 1] = y - positions[atom_index, 2] = z - atom_index += 1 - - system.addForce(nb) - - topology = omma.Topology() - chain = topology.addChain() - for _ in range(system.getNumParticles()): - residue = topology.addResidue(atom_name, chain) - topology.addAtom(atom_name, element, residue) - self.topology = topology - - # Add a restraining potential centered at the origin. - system.addForce( - self.construct_restraining_potential( - particle_indices=range(self.natoms), K=self.K - ) - ) - - self.system, self.positions = system, positions diff --git a/src/wepy_tools/systems/receptor.py b/src/wepy_tools/systems/receptor.py index 0ad9e4da..f163810e 100644 --- a/src/wepy_tools/systems/receptor.py +++ b/src/wepy_tools/systems/receptor.py @@ -4,7 +4,7 @@ import openmm.unit as unit # First Party Library -from wepy.util.mdtraj import json_to_mdtraj_topology, mdtraj_to_json_topology +from wepy.util.mdtraj import json_to_mdtraj_topology from wepy.util.util import box_vectors_to_lengths_angles @@ -40,7 +40,6 @@ def binding_site_idxs( Returns ------- - binding_site_idxs : arraylike (1,) """ diff --git a/tasks/__init__.py b/tasks/__init__.py deleted file mode 100644 index 97fd9f49..00000000 --- a/tasks/__init__.py +++ /dev/null @@ -1,58 +0,0 @@ -from invoke import Collection, Task, task - -import inspect - - -## Utilities - -# these helper functions are for automatically listing all of the -# functions defined in the tasks module - -def _is_mod_task(mod, func): - return issubclass(type(func), Task) and inspect.getmodule(func) == mod - -def _get_functions(mod): - """get only the functions that aren't module functions and that - aren't private (i.e. start with a '_')""" - - return {func.__name__ : func for func in mod.__dict__.values() - if _is_mod_task(mod, func) } - - -## Namespace - -# add all of the modules to the CLI -ns = Collection() - -## Top-level - -from . import toplevel -for func in _get_functions(toplevel).values(): - ns.add_task(func) - - -## STUB: User-added modules -# from user_modules import MODULES as user_modules - -# for module in user_modules: -# ns.add_collection(module) - -## Upstream - -from .modules import MODULES as modules - -for module in modules: - ns.add_collection(module) - -## Plugins - -try: - # import all the user defined stuff and override - from .plugins import PLUGIN_MODULES as plugins - - for module in plugins: - ns.add_collection(module) - -except Exception as e: - print("Loading plugins failed with error ignoring:") - print(e) diff --git a/tasks/config.py b/tasks/config.py deleted file mode 100644 index 99404313..00000000 --- a/tasks/config.py +++ /dev/null @@ -1,14 +0,0 @@ -"""User settings for a project.""" - -# load the system configuration. You can override them in this module, -# but beware it might break stuff -from .sysconfig import * - -## Customize these for all features - -PROJECT_SLUG = "wepy" - -VERSION = '1.1.0' - - -ENV_METHOD = 'conda' diff --git a/tasks/modules/__init__.py b/tasks/modules/__init__.py deleted file mode 100644 index 85f07906..00000000 --- a/tasks/modules/__init__.py +++ /dev/null @@ -1,23 +0,0 @@ - -# SNIPPET: add this to import modules - -# should be copied in by the installation process -from . import core -from . import clean -from . import env -from . import git -from . import py -from . import docs -from . import lxd -from . import containers - -MODULES = [ - core, - clean, - env, - git, - py, - docs, - lxd, - containers, -] diff --git a/tasks/modules/clean.py b/tasks/modules/clean.py deleted file mode 100644 index f239991d..00000000 --- a/tasks/modules/clean.py +++ /dev/null @@ -1,26 +0,0 @@ -from invoke import task - -from ..config import ( - CLEAN_EXPRESSIONS, -) - -### User config examples - -# SNIPPET: expecting something like this -# CLEAN_EXPRESSIONS = [ -# "\"*~\"", -# ] - -@task -def ls(cx): - - for clean_expr in CLEAN_EXPRESSIONS: - cx.run('find . -type f -name {} -print'.format(clean_expr)) - -@task(pre=[ls], default=True) -def clean(cx): - - print("Deleting Targets") - for clean_expr in CLEAN_EXPRESSIONS: - cx.run('find . -type f -name {} -delete'.format(clean_expr)) - diff --git a/tasks/modules/containers.py b/tasks/modules/containers.py deleted file mode 100644 index dedac2fc..00000000 --- a/tasks/modules/containers.py +++ /dev/null @@ -1,115 +0,0 @@ -"""Tasks for managing containers and clusters. - -While a lot of this might be able to be done with special purpose -tools we try to cover as many things that we have tried. - -Warning -------- - -This does not cover best practices at this time. - -""" -import os -from pathlib import Path - -from invoke import task - - -from ..config import ( - PROJECT_SLUG, - CONTAINER_TOOL, -) - -## Container definitions - -@task -def build(cx, root=None): - """Build all containers in dir `containers` using Dockerfiles.""" - - assert root is not None, \ - "Must provide a root directory with expected structure." - - jig_name = Path(root).stem - - containers_dir = Path(root) / "input/containers" - - cx.run(f"mkdir -p {root}/_output/containers") - - print(containers_dir) - for container in os.listdir(containers_dir): - - container_dir = containers_dir / container - - image_name = f"{PROJECT_SLUG}-{jig_name}-{container}" - - print(f"making: {image_name}") - - # remove if already in there - cx.run(f"{CONTAINER_TOOL} rmi {image_name}", warn=True) - - # rebuild - cx.run(f"{CONTAINER_TOOL} build -t {image_name} {container_dir}") - - cx.run(f"{CONTAINER_TOOL} image save {image_name} > {root}/_output/containers/{image_name}.tar") - - # remove from the index - cx.run(f"{CONTAINER_TOOL} rmi {image_name}") - -@task -def list_built(cx, root=None): - """List the built containers in dirs (not container tool memory).""" - - assert root is not None, \ - "Must provide a root directory with expected structure." - - images_dir = Path(root) / "_output/containers" - - image_names = [] - for image_fname in os.listdir(images_dir): - print(image_fname) - - image_name = Path(image_fname).stem - - image_names.append(image_name) - - return image_names - - -@task -def load(cx): - """Load the containers into container tool local memory.""" - - assert root is not None, \ - "Must provide a root directory with expected structure." - - containers_list_built(cx) - - jig_name = Path(root).stem - - images_dir = Path(root) / "_output/containers" - - image_names = list_built(cx) - - for image_name in image_names: - cx.run(f"{CONTAINER_TOOL} load < {images_dir}/{image_name}.tar {image_name}") - -@task -def unload(cx): - - raise NotImplementedError - - assert root is not None, \ - "Must provide a root directory with expected structure." - - list_built(cx) - - jig_name = Path(root).stem - - images_dir = Path(root) / "_output/containers" - - image_names = list_built(cx) - - for image_name in image_names: - cx.run(f"{CONTAINER_TOOL} rm {image_name}", warn=True) - - diff --git a/tasks/modules/core.py b/tasks/modules/core.py deleted file mode 100644 index 7b4d9240..00000000 --- a/tasks/modules/core.py +++ /dev/null @@ -1,33 +0,0 @@ -from invoke import task - -import os.path as osp -import os -from pathlib import Path - -@task -def sanity(cx): - """Perform sanity check for jubeo""" - - print("All systems go!") - - -@task -def pin_tool_deps(cx): - """Pins or upgrades the requirements.txt for the jubeo tooling from - the requirements.in (from the upstream repo) and the - local.requirements.in (for project specific tooling dependencies) - files.""" - - req_in = Path('.jubeo') / "requirements.in" - local_req_in = Path('.jubeo') / "local.requirements.in" - req_txt = Path('.jubeo') / "requirements.txt" - - assert osp.exists(req_in), "No 'requirements.in' file" - - # add the local reqs if given - if osp.exists(local_req_in): - req_str = f"{req_in} {local_req_in}" - else: - req_str = req_in - - cx.run(f"pip-compile --upgrade --output-file={req_txt} {req_str}") diff --git a/tasks/modules/docs.py b/tasks/modules/docs.py deleted file mode 100644 index bb2721f9..00000000 --- a/tasks/modules/docs.py +++ /dev/null @@ -1,625 +0,0 @@ -from invoke import task - -# from ..config import () - -import os -import os.path as osp -from pathlib import Path -import shutil as sh -from warnings import warn - -## Paths for the different things - -DOCS_TEST_DIR = "tests/test_docs/_tangled_docs" -DOCS_EXAMPLES_DIR = "tests/test_docs/_examples" -DOCS_TUTORIALS_DIR = "tests/test_docs/_tutorials" - -DOCS_SPEC = { - 'LANDING_PAGE' : "README.org", - - 'INFO_INDEX' : "info/README.org", - 'QUICK_START' : "info/quick_start.org", - 'INTRODUCTION' : "info/introduction.org", - 'INSTALLATION' : "info/installation.org", - 'USERS_GUIDE' : "info/users_guide.org", - 'HOWTOS' : "info/howtos.org", - 'REFERENCE' : "info/reference.org", - 'TROUBLESHOOTING' : "info/troubleshooting.org", - - 'GLOSSARY' : "info/glossary.rst", - 'BIBLIOGRAPHY' : "info/docs.bib", - - 'DEV_GUIDE' : "info/dev_guide.org", - 'GENERAL' : "info/general_info.org", - 'NEWS' : "info/news.org", - 'CHANGELOG' : "info/changelog.org", - - 'EXAMPLES_DIR' : "info/examples", - 'EXAMPLES_LISTING_INDEX' : "info/examples/README.org", - - # Other examples must be in a directory in the EXAMPLES_DIR and have - # their own structure: - - # potentially literate document with source code. If not literate then - # code should be in the EXAMPLE_SOURCE directory. This index should - # still exist and give instructions on how to use and run etc. tangled - # source will go in the EXAMPLE_TANGLE_SOURCE folder. - 'EXAMPLE_INDEX' : "README.org", - - 'EXAMPLE_TASKS' : "tasks.py", - 'EXAMPLE_BUILD' : "dodo.py", - - # Source code for example that is not literately included in the - # README.org - 'EXAMPLE_SOURCE' : "source", - - # included in the source tree - 'EXAMPLE_INPUT' : "input", - - # values are automatically excluded from the source tree via - # .gitignore - 'EXAMPLE_OUTPUT' : "_output", - - # the directory that tangled source files will go, separate from the - # source dir, this folder will be ignored by VCS - 'EXAMPLE_TANGLE_SOURCE' : "_tangle_source", - - # the actual dir the env will be built into - 'EXAMPLE_ENV' : "_env", - - 'TUTORIALS_DIR' : "info/tutorials", - 'TUTORIALS_LISTING_INDEX' : "info/tutorials/README.org", - - # Other tutorials must be in a directory in the TUTORIALS_DIR and have - # their own structure: - - # the main document for the tutorial can be *one* of any of the - # values supporting: org, Jupyter Notebooks. In order of - # precedence. - 'TUTORIAL_INDEX' : ( - "README.org", - "README.ipynb", - ), - - 'TUTORIAL_TASKS' : "tasks.py", - 'TUTORIAl_BUILD' : "dodo.py", - - # Source code for tutorial that is not literately included in the - # README.org - 'TUTORIAL_SOURCE' : "source", - - # included in the source tree - 'TUTORIAL_INPUT' : "input", - - # values are automatically excluded from the source tree via - # .gitignore - 'TUTORIAL_OUTPUT' : "_output", - - # the directory that tangled source files will go, separate from the - # source dir, this folder will be ignored by VCS - 'TUTORIAL_TANGLE_SOURCE' : "_tangle_source", - - # the actual dir the env will be built into - 'TUTORIAL_ENV' : "_env", - -} - -# here for reference potentially could be applied with an init function -GITIGNORE_LINES = [ - "info/examples/*/_output", - "info/examples/*/_tangle_source", - "info/examples/*/_env", - "info/tutorials/*/_output", - "info/tutorials/*/_tangle_source", - "info/tutorials/*/_env", -] - -# TODO: add a docs init task that generates all the files and adds to -# the gitignore. - -def visit_docs(): - """Returns a list of all the doc pages with their relative paths to - the root of the project. Not including examples and tutorials - which are tested differently. - - """ - - # get the pages which are always there - page_keys = [ - 'LANDING_PAGE', - 'INFO_INDEX', - 'QUICK_START', - 'INTRODUCTION', - 'INSTALLATION', - 'USERS_GUIDE', - 'HOWTOS', - 'REFERENCE', - 'TROUBLESHOOTING', - 'GLOSSARY', - 'DEV_GUIDE', - 'GENERAL', - 'NEWS', - 'CHANGELOG', - 'EXAMPLES_LISTING_INDEX', - 'TUTORIALS_LISTING_INDEX', - ] - - # dereference their paths - page_paths = [DOCS_SPEC[key] for key in page_keys] - - return page_paths - -def visit_examples(): - """Get the relative paths to all of the example dirs.""" - - # get the pages for the tutorials and examples - examples = [ex for ex in os.listdir(DOCS_SPEC['EXAMPLES_DIR']) - if ( - ex != Path(DOCS_SPEC['EXAMPLES_LISTING_INDEX']).parts[-1] and - ex != '.keep' and - not ex.endswith("~") - ) - ] - - example_dirs = [Path(DOCS_SPEC['EXAMPLES_DIR']) / example for example in examples] - - return example_dirs - -def visit_example_contents(example): - - example_pages = [] - if osp.exists(DOCS_SPEC['EXAMPLE_INDEX']): - example_index = example_dir / DOCS_SPEC['EXAMPLE_INDEX'] - example_pages.append(example_index) - else: - warn(f"No example index page for {example}") - - page_paths.extend(example_pages) - -def visit_tutorials(): - """Get the relative paths to all of the tutorial dirs.""" - - # get the pages for the tutorials and tutorials - tutorials = [tut for tut in os.listdir(DOCS_SPEC['TUTORIALS_DIR']) - if ( - tut != Path(DOCS_SPEC['TUTORIALS_LISTING_INDEX']).parts[-1] and - tut != 'index.rst' and - tut != '.keep' and - not tut.endswith("~") - ) - ] - - tutorial_dirs = [Path(DOCS_SPEC['TUTORIALS_DIR']) / tutorial for tutorial in tutorials] - - return tutorial_dirs - -def tangle_orgfile(cx, file_path): - """Tangle the target file using emacs in batch mode. Implicitly dumps - things relative to the file.""" - - cx.run(f"emacs -Q --batch -l org {file_path} -f org-babel-tangle") - -def tangle_jupyter(cx, file_path): - """Tangle the target file using jupyter-nbconvert to a python - script. Implicitly dumps things relative to the file. Only can - make a single script from the notebook with the same name. - - """ - - cx.run(f"jupyter-nbconvert --to 'python' {file_path}") - - -@task -def list_docs(cx): - """List paths relative to this context""" - - print('\n'.join([str(Path(cx.cwd) / p) for p in visit_docs()])) - -@task -def list_examples(cx): - """List paths relative to this context""" - - print('\n'.join([str(Path(cx.cwd) / ex) for ex in visit_examples()])) - -@task -def list_tutorials(cx): - """List paths relative to this context""" - - print('\n'.join([str(Path(cx.cwd) / tut) for tut in visit_tutorials()])) - -@task() -def clean_tangle(cx): - """remove the tangle dirs""" - - sh.rmtree(Path(cx.cwd) / DOCS_TEST_DIR, - ignore_errors=True) - - sh.rmtree(Path(cx.cwd) / DOCS_EXAMPLES_DIR, - ignore_errors=True) - - sh.rmtree(Path(cx.cwd) / DOCS_TUTORIALS_DIR, - ignore_errors=True) - - -@task(pre=[clean_tangle]) -def tangle_pages(cx): - """Tangle the docs into the docs testing directory.""" - - docs_test_dir = Path(cx.cwd) / DOCS_TEST_DIR - - os.makedirs( - docs_test_dir, - exist_ok=True, - ) - - doc_pages = visit_docs() - for page_path in doc_pages: - - page_path = Path(page_path) - - page_name_parts = page_path.parts[0:-1] + (page_path.stem,) - page_name = Path(*page_name_parts) - page_type = page_path.suffix.strip('.') - - page_tangle_dir = docs_test_dir / page_name - # make a directory for this file to have it's own tangle environment - os.makedirs(page_tangle_dir, - exist_ok=False) - - # copy the page to its directory - target_orgfile = docs_test_dir / page_name / f"{page_name.stem}.{page_type}" - sh.copyfile(page_path, - target_orgfile) - - # then tangle them - tangle_orgfile(cx, target_orgfile) - -@task(pre=[clean_tangle]) -def tangle_examples(cx): - - examples_test_dir = Path(cx.cwd) / DOCS_EXAMPLES_DIR - - os.makedirs( - examples_test_dir, - exist_ok=True, - ) - - for example_dir in visit_examples(): - - example = example_dir.stem - - # ignore if there are any built files at the start location, - # need to build fresh for tests - sh.copytree( - example_dir, - examples_test_dir / example, - ignore=sh.ignore_patterns("_*"), - ) - - with cx.cd(str(examples_test_dir / example)): - cx.run("inv clean") - cx.run("inv tangle") - - -@task(pre=[clean_tangle]) -def tangle_tutorials(cx): - - tutorials_test_dir = Path(cx.cwd) / DOCS_TUTORIALS_DIR - - os.makedirs( - tutorials_test_dir, - exist_ok=True, - ) - - for tutorial_dir in visit_tutorials(): - - tutorial = tutorial_dir.stem - - # ignore if there are any built files at the start location, - # need to build fresh for tests - sh.copytree( - tutorial_dir, - tutorials_test_dir / tutorial, - ignore=sh.ignore_patterns("_*"), - ) - - - with cx.cd(str(tutorials_test_dir / tutorial)): - cx.run("inv clean") - cx.run("inv tangle") - - -@task(pre=[clean_tangle, tangle_pages, tangle_examples, tangle_tutorials]) -def tangle(cx): - """Tangle the doc pages, examples, and tutorials into the docs testing - directories.""" - - pass - - -@task -def new_example(cx, name=None, template="org", env='venv_blank'): - """Create a new example in the info/examples directory. - - Can choose between the following templates: - - - 'org' :: org mode notebook - - Choose from the following env templates: - - - None - - venv_blank - - venv_dev - - conda_blank - - conda_dev - - - """ - - assert name is not None, "Must provide a name" - - template_path = Path(f"templates/examples/{template}") - - # check if the template exists - if not template_path.is_dir(): - - raise ValueError( - f"Unkown template {template}. Check the 'templates/examples' folder") - - # check if the env exists - if env is not None: - env_tmpl_path = Path(f"templates/envs/{env}") - - if not env_tmpl_path.is_dir(): - - raise ValueError( - f"Unkown env template {env}. Check the 'templates/envs' folder") - - target_path = Path(f"info/examples/{name}") - - if target_path.exists(): - raise FileExistsError(f"Example with name {name} already exists. Not overwriting.") - - # copy the template - cx.run(f"cp -r {template_path} {target_path}") - - # copy the env - cx.run(f"cp -r {env_tmpl_path} {target_path / 'env'}") - - print(f"New example created at: {target_path}") - -@task -def new_tutorial(cx, name=None, template="org", env='venv_blank'): - """Create a new tutorial in the info/tutorials directory. - - Can choose between the following templates: - - 'org' :: org mode notebook - - 'jupyter' :: Jupyter notebook - - - Choose from the following env templates: - - - None - - venv_blank - - venv_dev - - conda_blank - - conda_dev - - """ - - assert name is not None, "Must provide a name" - - template_path = Path(f"templates/tutorials/{template}") - - # check if the template exists - if not template_path.is_dir(): - - raise ValueError( - f"Unkown template {template}. Check the 'templates/tutorials' folder") - - # check if the env exists - if env is not None: - env_tmpl_path = Path(f"templates/envs/{env}") - - if not env_tmpl_path.is_dir(): - - raise ValueError( - f"Unkown env template {env}. Check the 'templates/envs' folder") - - - target_path = Path(f"info/tutorials/{name}") - - if target_path.exists(): - raise FileExistsError(f"Tutorial with name {name} already exists. Not overwriting.") - - # copy the template - cx.run(f"cp -r {template_path} {target_path}") - - # copy the env - cx.run(f"cp -r {env_tmpl_path} {target_path / 'env'}") - - print(f"New tutorial created at: {target_path}") - - -@task -def test_example(cx, - name=None, - tag=None, -): - """Test a specific doc example in the current virtual environment.""" - - if name is None: - examples = visit_examples() - else: - examples = [Path("info/examples") / name] - - for example in examples: - - path = example - - assert path.exists() and path.is_dir(), \ - f"Example {example.stem} doesn't exist at {path}" - - # TODO: add support for reports and such - print("tag is ignored") - - cx.run(f"pytest tests/test_docs/test_examples/test_{example.stem}.py", - warn=True) - -@task -def test_examples_nox(cx, - name=None): - """Test either a specific example when 'name' is given or all of them, - using the nox test matrix specified in the noxfile.py file for - 'test_example' session. - - """ - - if name is None: - examples = [example.stem for example in visit_examples()] - else: - examples = [name] - - for example in examples: - cx.run(f"nox -s test_example -- {example}", - warn=True) - -@task -def test_tutorial(cx, - name=None, - tag=None, -): - """Test a specific doc tutorial in the current virtual environment.""" - - - if name is None: - tutorials = visit_tutorials() - else: - tutorials = [Path("info/tutorials") / name] - - for tutorial in tutorials: - - path = tutorial - - assert path.exists() and path.is_dir(), \ - f"Tutorial {tutorial} doesn't exist at {path}" - - # TODO: add support for reports and such - print("tag is ignored") - - cx.run(f"pytest tests/test_docs/test_tutorials/test_{tutorial.stem}.py", - warn=True) - -@task -def test_tutorials_nox(cx, - name=None): - """Test either a specific tutorial when 'name' is given or all of them, - using the nox test matrix specified in the noxfile.py file for - 'test_tutorial' session. - - """ - - if name is None: - tutorials = [tutorial.stem for tutorial in visit_tutorials()] - else: - tutorials = [name] - - for tutorial in tutorials: - cx.run(f"nox -s test_tutorial -- {tutorial}") - - -@task -def test_pages(cx, tag=None): - """Test the doc pages in the current virtual environment.""" - - if tag is None: - cx.run("pytest tests/test_docs/test_pages", - warn=True) - - else: - cx.run(f"pytest --html=reports/pytest/{tag}/docs/report.html tests/test_docs/test_pages", - warn=True) - - -@task -def test_pages_nox(cx, tag=None): - """Test the doc pages in the nox test matrix session.""" - - cx.run(f"nox -s test_doc_pages") - -@task -def pin_example(cx, name=None): - """Pin the deps for an example or all of them if 'name' is None.""" - - if name is None: - examples = visit_examples() - else: - examples = [Path(DOCS_SPEC['EXAMPLES_DIR']) / name] - - print(examples) - for example in examples: - - path = example / 'env' - - assert path.exists() and path.is_dir(), \ - f"Env for Example {example} doesn't exist" - - cx.run(f"inv env.deps-pin-path -p {path}") - -@task -def pin_tutorial(cx, name=None): - - if name is None: - tutorials = visit_tutorials() - else: - tutorials = [name] - - for tutorial in tutorials: - - path = tutorial / 'env' - - assert path.exists() and path.is_dir(), \ - f"Env for Tutorial {tutorial} doesn't exist" - - cx.run(f"inv env.deps-pin-path -p {path}") - -@task -def env_example(cx, name=None): - """Make a the example env in its local dir.""" - - if name is None: - examples = visit_examples() - else: - examples = [name] - - for example in examples: - - - spec_path = Path(DOCS_SPEC['EXAMPLES_DIR']) / example / 'env' - env_path = Path(DOCS_SPEC['EXAMPLES_DIR']) / example / DOCS_SPEC['EXAMPLE_ENV'] - - assert spec_path.exists() and spec_path.is_dir(), \ - f"Tutorial {example} doesn't exist" - - cx.run(f"inv env.make-env -s {spec_path} -p {env_path}") - -@task -def env_tutorial(cx, name=None): - """Make a the tutorial env in its local dir.""" - - if name is None: - tutorials = visit_tutorials() - else: - tutorials = [name] - - for tutorial in tutorials: - - - spec_path = Path(DOCS_SPEC['TUTORIALS_DIR']) / tutorial / 'env' - env_path = Path(DOCS_SPEC['TUTORIALS_DIR']) / tutorial / DOCS_SPEC['TUTORIAL_ENV'] - - assert spec_path.exists() and spec_path.is_dir(), \ - f"Tutorial {tutorial} doesn't exist" - - cx.run(f"inv env.make-env -s {spec_path} -p {env_path}") - diff --git a/tasks/modules/env.py b/tasks/modules/env.py deleted file mode 100644 index 9dfacbcb..00000000 --- a/tasks/modules/env.py +++ /dev/null @@ -1,611 +0,0 @@ -from invoke import task - -import sys -import os -import os.path as osp -from pathlib import Path -from warnings import warn -import shutil - -from ..config import ( - ENV_METHOD, - DEFAULT_ENV, - ENVS_DIR, - PYTHON_VERSION_SOURCE, - PYTHON_VERSIONS, -) - -## user config examples - -# SNIPPET: - -# # which virtual environment tool to use: pyenv-virtualenv, venv, or -# conda. pyenv-virtualenv and conda works for all versions, venv only -# works with python3.3 and above ENV_METHOD = 'venv' - -# # which env spec to use by default -# DEFAULT_ENV = 'dev' - -# # directory where env specs are read from -# ENVS_DIR = 'envs' - -# Python version source, this is how we get the different python -# versions - -# PYTHON_VERSION_SOURCE = "pyenv" - -# which versions to install - -# PYTHON_VERSIONS = ( -# '3.8.1', -# '3.7.6', -# ) - - -## Constants - -# directories the actual environments are stored -VENV_DIR = "_venv" -CONDA_ENVS_DIR = "_conda_envs" -# this will be set to the PYENV_PREFIX with the path to the project -# dir -PYENV_DIR = "_pyenv" - - - -# specified names of env specs files -SELF_REQUIREMENTS = 'self.requirements.txt' -"""How to install the work piece""" - -# requirements for specific packaging tooling, like pip -ENV_TOOLS_REQUIREMENTS = 'tools.requirements.txt' - -PYTHON_VERSION_FILE = 'pyversion.txt' -"""Specify which version of python to use for the env""" - -DEV_REQUIREMENTS_LIST = 'dev.requirements.list' -"""Multi-development mode repos to read dependencies from.""" - -# pip specific -PIP_ABSTRACT_REQUIREMENTS = 'requirements.in' -PIP_COMPILED_REQUIREMENTS = 'requirements.txt' - -# conda specific -CONDA_ABSTRACT_REQUIREMENTS = 'env.yaml' -CONDA_COMPILED_REQUIREMENTS = 'env.pinned.yaml' - -### Util - -def parse_list_format(list_str): - - return [line for line in list_str.split('\n') - if not line.startswith("#") and line.strip()] - - -def read_pyversion_file(py_version_path: Path): - - with open(py_version_path, 'r') as rf: - py_version = rf.read().strip() - - return py_version - - -def get_current_pyversion(): - return f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}" - -### Dependencies -# managing dependencies for the project at runtime - -## pip: things that can be controlled by pip - -def deps_pip_pin(cx, - path=None, - upgrade=False): - - assert path is not None - - path = Path(path) - - # gather any development repos that are colocated on this machine - # and solve the dependencies together - - specs = [ - str(path / PIP_ABSTRACT_REQUIREMENTS), - str(path / ENV_TOOLS_REQUIREMENTS) - ] - - # to get the development repos read the DEV list - if osp.exists(path / DEV_REQUIREMENTS_LIST): - with open(path / DEV_REQUIREMENTS_LIST) as rf: - dev_repo_specs = parse_list_format(rf.read()) - - # for each repo spec add this to the list of specs to evaluate for - for dev_repo_spec in dev_repo_specs: - # expand env vars - dev_repo_spec = osp.expandvars(osp.expanduser(dev_repo_spec)) - - assert osp.exists(dev_repo_spec), f"Repo spec {dev_repo_spec} doesn't exist" - - specs.append(dev_repo_spec) - - - spec_str = " ".join(specs) - - print("Using simultaneous dev specs:") - print(spec_str) - - upgrade_str = '' - if upgrade: - upgrade_str = "--upgrade" - - - cx.run("pip-compile " - f"{upgrade_str} " - f"--output-file={path}/{PIP_COMPILED_REQUIREMENTS} " - f"{spec_str}") - - # SNIPPET: generate hashes is not working right, or just confusing me - # cx.run("python -m piptools compile " - # "--generate-hashes " - # "--output-file={PIP_COMPILED_REQUIREMENTS} " - # f"{PIP_ABSTRACT_REQUIREMENTS}") - - -## conda: managing conda dependencies -def deps_conda_pin(cx, - path=None, - upgrade=False, - optional=False, -): - - # STUB: currently upgrade does nothing - - assert path is not None - - env_spec_path = Path(path) - - if not optional: - assert osp.exists(env_spec_path / CONDA_ABSTRACT_REQUIREMENTS), \ - "There must be an 'env.yaml' file to compile from" - - else: - if not osp.exists(env_spec_path / CONDA_ABSTRACT_REQUIREMENTS): - return None - - # delete the pinned file - if osp.exists(env_spec_path / CONDA_COMPILED_REQUIREMENTS): - os.remove(env_spec_path / CONDA_COMPILED_REQUIREMENTS) - - # make the environment under a mangled name so we don't screw with - # the other one - mangled_name = f"__mangled_tmp_env" - - mangled_env_spec_path = Path(ENVS_DIR) / mangled_name - - # remove if there is one already there - if osp.exists(mangled_env_spec_path): - shutil.rmtree(mangled_env_spec_path) - - # make sure the new dir is initialized - os.makedirs(mangled_env_spec_path, - exist_ok=True - ) - - # TODO: simultaneous dev requirements from other projects that - # require a conda install - - # copy the 'env.yaml' and 'pyversion.txt' files to the new env. If - # we include the requirements.txt file it will screw with the - # pinning process - shutil.copyfile( - env_spec_path / CONDA_ABSTRACT_REQUIREMENTS, - mangled_env_spec_path / CONDA_ABSTRACT_REQUIREMENTS, - ) - - if osp.exists(env_spec_path / PYTHON_VERSION_FILE): - shutil.copyfile( - env_spec_path / PYTHON_VERSION_FILE, - mangled_env_spec_path / PYTHON_VERSION_FILE, - ) - - - # then create the mangled env - env_dir = conda_env(cx, - spec=mangled_env_spec_path, - path=Path(CONDA_ENVS_DIR) / mangled_name, - ) - - # then install the packages so we can export them - with cx.prefix(f'eval "$(conda shell.bash hook)" && conda activate {env_dir}'): - - # only install the declared dependencies - cx.run(f"conda env update " - f"--prefix {env_dir} " - f"--file {env_spec_path}/{CONDA_ABSTRACT_REQUIREMENTS}") - - # pin to a 'env.pinned.yaml' file - cx.run(f"conda env export " - f"-p {env_dir} " - f"-f {env_spec_path}/{CONDA_COMPILED_REQUIREMENTS}") - - # then destroy the temporary mangled env and spec - shutil.rmtree(env_dir) - shutil.rmtree(mangled_env_spec_path) - - print("--------------------------------------------------------------------------------") - print(f"This is an automated process do not attempt to activate the '__mangled' environment") - -@task -def deps_pin_path(cx, - path=None, - upgrade=False): - """Pin an environment given by the path.""" - - deps_pip_pin(cx, - path=path, - upgrade=False) - - if ENV_METHOD == 'conda': - - # WKRD, FIXME: disabled for now since the end result isn't useful anymore - pass - # deps_conda_pin(cx, - # path=path, - # upgrade=False, - # optional=True,) - - -# altogether -@task -def deps_pin(cx, name=DEFAULT_ENV): - """Pin an environment in the 'envs' directory.""" - - path = Path(ENVS_DIR) / name - - deps_pin_path(cx, path=path) - - -@task -def deps_pin_update(cx, name=DEFAULT_ENV): - """Update the pinned environment in the 'envs' directory.""" - - path = Path(ENVS_DIR) / name - - deps_pin_path(cx, - path=path, - upgrade=True, - ) - - -### Environments - -def conda_env(cx, - spec=None, - path=None, -): - - # where the specs of the environment are - env_spec_path = Path(spec) - - # using the local envs dir - env_dir = Path(path) - - # ensure the directory - cx.run(f"mkdir -p {env_dir}") - - - # clean up old envs if they weren't already - if osp.exists(env_dir): - shutil.rmtree(env_dir) - - # figure out which python version to use, if the 'pyversion.txt' - # file exists read it - py_version_path = env_spec_path / PYTHON_VERSION_FILE - if osp.exists(py_version_path): - - print("Using specified python version") - - py_version = read_pyversion_file(py_version_path) - - # otherwise use the one you are currently using - else: - print("Using current envs python version") - py_version = get_current_pyversion() - - print(f"Using python version: {py_version}") - - # create the environment - cx.run(f"conda create -y " - f"--prefix {env_dir} " - f"python={py_version}", - pty=True) - - with cx.prefix(f'eval "$(conda shell.bash hook)" && conda activate {env_dir}'): - - # install the conda dependencies. choose a specification file - # based on these priorities of most pinned to least frozen. - - # WKRD, FIXME: this is disabled because this is super-platform - # dependent and not reliable - # - # if osp.exists(env_spec_path / CONDA_COMPILED_REQUIREMENTS): - # cx.run(f"conda env update " - # f"--prefix {env_dir} " - # f"--file {env_spec_path}/{CONDA_COMPILED_REQUIREMENTS}") - - - if osp.exists(env_spec_path / CONDA_ABSTRACT_REQUIREMENTS): - - cx.run(f"conda env update " - f"--prefix {env_dir} " - f"--file {env_spec_path}/{CONDA_ABSTRACT_REQUIREMENTS}") - - else: - print("No conda dependencies specified") - # don't do a conda env pin - pass - - - # install the tooling, like pip version etc. - if osp.exists(env_spec_path / ENV_TOOLS_REQUIREMENTS): - cx.run(f"{env_dir}/bin/pip install -r {env_spec_path}/{ENV_TOOLS_REQUIREMENTS}") - - # install the extra pip dependencies. - - # this ignores anything already installed by the conda env - # which can cause problem if this tries to install it again. - if osp.exists(env_spec_path / PIP_COMPILED_REQUIREMENTS): - cx.run(f"{env_dir}/bin/pip install " - "--ignore-installed " - f"-r {env_spec_path}/{PIP_COMPILED_REQUIREMENTS}") - - # install the package itself - if osp.exists(env_spec_path / SELF_REQUIREMENTS): - cx.run(f"{env_dir}/bin/pip install -r {env_spec_path}/{SELF_REQUIREMENTS}") - - print("--------------------------------------------------------------------------------") - print(f"run: conda activate {env_dir}") - - return env_dir - - -def venv_env(cx, - spec=None, - path=None, -): - - assert spec is not None - assert path is not None - - venv_path = Path(path) - env_spec_path = Path(spec) - - # ensure the directory - cx.run(f"mkdir -p {venv_path}") - - py_version_path = env_spec_path / PYTHON_VERSION_FILE - - py_version = get_current_pyversion() - if osp.exists(py_version_path): - - spec_py_version = read_pyversion_file(py_version_path) - if spec_py_version != py_version: - raise ValueError( - f"Python version {spec_py_version} was specified in {PYTHON_VERSION_FILE} " - f"but Python {py_version} is activated. For the venv method you must have " - f"the desired python version already activated" - ) - - - print(f"Using python version: {py_version}") - - # create the env requested - cx.run(f"python -m venv {venv_path}") - - # then install the things we need - with cx.prefix(f"source {venv_path}/bin/activate"): - - # install the tooling, like pip version etc. - if osp.exists(env_spec_path / ENV_TOOLS_REQUIREMENTS): - cx.run(f"{env_dir}/bin/pip install -r {env_spec_path}/{ENV_TOOLS_REQUIREMENTS}") - - # install the pip pinned requirements - if osp.exists(env_spec_path / PIP_COMPILED_REQUIREMENTS): - cx.run(f"pip install -r {env_spec_path}/{PIP_COMPILED_REQUIREMENTS}") - - else: - print("No requirements.txt found") - - # if there is a 'self.requirements.txt' file specifying how to - # install the package that is being worked on install it - if osp.exists(env_spec_path / SELF_REQUIREMENTS): - cx.run(f"pip install -r {env_spec_path}/{SELF_REQUIREMENTS}") - - else: - print("No self.requirements.txt found") - - print("----------------------------------------") - print("to activate run:") - print(f"source {venv_path}/bin/activate") - - return venv_path - -def pyenv_env(cx, - spec=None, - path=None, -): - - assert spec is not None - assert path is not None - - # project has its own pyenv root - pyenv_local_dir = Path(path) - - env_spec_path = Path(spec) - - # ensure the directory - cx.run(f"mkdir -p {pyenv_local_dir}") - - - env_path = f"{pyenv_local_dir}/{name}" - cx.run("rm -rf {env_path}") - - py_version_path = env_spec_path / PYTHON_VERSION_FILE - - if osp.exists(py_version_path): - - py_version = read_pyversion_file(py_version_path) - - # otherwise use the one you are currently using - else: - py_version = get_current_pyversion() - - print(f"Using python version: {py_version}") - - # TODO: use pyenv to make the virtualenv with the right version - - # if you already have pyenv installed and there are versions of - # python installed there we will preferentially use them while - # ignoring the envs there since that is a lot of unnecessary files - # to have all the pythons installed separately. - - pyenv_root = Path(osp.expandvars("$PYENV_ROOT")) - - # go ahead and use the pyenv-virtual-local command - if pyenv_root.exists(): - cx.run(f"pyenv virtualenv-local \\" - f"--alt-dir {pyenv_local_dir} \\" - f"{py_version} \\" - f"{name}" - ) - - - # currently we dont' support installing it - else: - raise FileNotFoundError( - f"pyenv not installed" - ) - - # then install the things we need - with cx.prefix(f"source {env_path}/bin/activate"): - - # install the tooling, like pip version etc. - if osp.exists(env_spec_path / ENV_TOOLS_REQUIREMENTS): - cx.run(f"{env_dir}/bin/pip install -r {env_spec_path}/{ENV_TOOLS_REQUIREMENTS}") - - # install the pinned packages - if osp.exists(env_spec_path / SELF_REQUIREMENTS): - cx.run(f"pip install -r {env_spec_path}/{PIP_COMPILED_REQUIREMENTS}") - - else: - print("No requirements.txt found") - - # if there is a 'self.requirements.txt' file specifying how to - # install the package that is being worked on install it - if osp.exists(env_spec_path / SELF_REQUIREMENTS): - cx.run(f"pip install -r {env_spec_path}/{SELF_REQUIREMENTS}") - - else: - print("No self.requirements.txt found") - - print("----------------------------------------") - print("to activate run:") - print(f"source {env_path}/bin/activate") - - return env_path - -@task -def make_env(cx, - spec=None, - path=None, - venv=ENV_METHOD, -): - - assert spec is not None - assert path is not None - - # choose your method: - if venv == 'conda': - conda_env(cx, - spec=spec, - path=path, - ) - - elif venv == 'venv': - venv_env(cx, - spec=spec, - path=path, - ) - - - elif venv == 'pyenv': - pyenv_env(cx, - spec=spec, - path=path, - ) - - -@task(default=True) -def make(cx, name=DEFAULT_ENV): - - spec_path = Path(ENVS_DIR) / name - - # get the path to your envs based on the method - if ENV_METHOD == 'conda': - env_path = Path(CONDA_ENVS_DIR) / name - - elif ENV_METHOD == 'venv': - env_path = Path(VENV_DIR) / name - - elif ENV_METHOD == 'pyenv': - env_path = Path(PYENV_DIR) / name - - else: - raise ValueError(f"Unrecognized venv type: {ENV_METHOD}") - - make_env(cx, - spec=spec_path, - path=env_path, - venv = ENV_METHOD, - ) - -@task -def ls_conda(cx): - print('\n'.join(os.listdir(CONDA_ENVS_DIR))) - -@task -def ls_venv(cx): - - print('\n'.join(os.listdir(VENV_DIR))) - -@task -def ls_specs(cx): - - print('\n'.join(os.listdir(ENV_SPEC_DIR))) - -@task -def ls(cx): - - # choose your method: - if ENV_METHOD == 'conda': - ls_conda(cx) - - elif ENV_METHOD == 'venv': - ls_venv(cx) - -@task -def clean(cx): - cx.run(f"rm -rf {VENV_DIR}") - - -@task -def install_pythons(cx): - """Install different python versions.""" - - assert PYTHON_VERSION_SOURCE == 'pyenv', \ - "Only pyenv is supported for different python versions" - - with cx.prefix("unset PYENV_VERSION"): - for version in PYTHON_VERSIONS: - cx.run(f"pyenv install --skip-existing {version}", - warn=True) diff --git a/tasks/modules/git.py b/tasks/modules/git.py deleted file mode 100644 index e161884d..00000000 --- a/tasks/modules/git.py +++ /dev/null @@ -1,48 +0,0 @@ -from invoke import task - -from ..config import ( - INITIAL_VERSION, - GIT_LFS_TARGETS, - VERSION, -) - -## Constants - -VCS_RELEASE_TAG_TEMPLATE = "v{}" - -@task -def lfs_track(cx): - """Update all the files that need tracking via git-lfs.""" - - for lfs_target in GIT_LFS_TARGETS: - cx.run("git lfs track {}".format(lfs_target)) - - -@task -def init(cx): - - tag_string = VCS_RELEASE_TAG_TEMPLATE.format(INITIAL_VERSION) - - cx.run("git init && " - "git add -A && " - "git commit -m 'initial commit' && " - f"git tag -a {tag_string} -m 'initialization release'") - - - -@task -def publish(cx): - - tag_string = VCS_RELEASE_TAG_TEMPLATE.format(VERSION) - - cx.run(f"git push origin {tag_string}") - - -@task -def release(cx): - - tag_string = VCS_RELEASE_TAG_TEMPLATE.format(VERSION) - - print("Releasing: ", VERSION, "with tag: ", tag_string) - - cx.run(f"git tag -a {tag_string} -m 'See the changelog for details'") diff --git a/tasks/modules/lxd.py b/tasks/modules/lxd.py deleted file mode 100644 index d481c7e8..00000000 --- a/tasks/modules/lxd.py +++ /dev/null @@ -1,48 +0,0 @@ -from invoke import task - -from ..config import ( - PROJECT_SLUG, -) - -@task -def copy_ssh(cx, name='dev'): - """Copy SSH keys to a container.""" - - cx.run(f'ssh-keygen -f "$HOME/.ssh/known_hosts" -R "{PROJECT_SLUG}.dev.lxd"') - cx.run(f"ssh-copy-id {PROJECT_SLUG}.{name}.lxd") - -@task -def push_profile(cx, name='dev'): - """Update your dotfiles in a container.""" - - cx.run(f"fab -H {PROJECT_SLUG}.{name} push-profile") - -@task -def bootstrap(cx, name='dev'): - """Bootstrap the container from a bare image. - - Not necessary if you started from a premade dev env container. - - """ - - cx.run(f"fab -H {PROJECT_SLUG}.{name} bootstrap") - -@task -def push(cx, name='dev'): - """Push the files for this project - - Ignores according to the gitignore file - - """ - - cx.run(f"fab -H {PROJECT_SLUG}.{name} push-project") - -@task -def pull(cx, name='dev'): - """Pull the files for this project - - Ignores according to the gitignore file - - """ - - cx.run(f"fab -H {PROJECT_SLUG}.{name} pull-project") diff --git a/tasks/modules/py.py b/tasks/modules/py.py deleted file mode 100644 index 9c0eb87d..00000000 --- a/tasks/modules/py.py +++ /dev/null @@ -1,905 +0,0 @@ -from invoke import task - -from ..config import ( - VERSION, - REPORTS_DIR, - ORG_DOCS_SOURCES, - RST_DOCS_SOURCES, - BIB_DOCS_SOURCES, - LOGO_DIR, - TESTING_PYPIRC, - PYPIRC, - PYENV_CONDA_NAME, - ENV_METHOD, - TESTS_DIR, - BENCHMARKS_DIR, -) - -import sys -import os -import os.path as osp -from pathlib import Path -import shutil as sh - -## User config examples - -# SNIPPET: -# REPORTS_DIR = "reports" -# ORG_DOCS_SOURCES = [ -# 'changelog', -# 'dev_guide', -# 'general_info', -# 'installation', -# 'introduction', -# 'news', -# 'quick_start', -# 'troubleshooting', -# 'users_guide', -# 'reference', -# ] -# RST_DOCS_SOURCES = [ -# 'glossary', -# 'tutorials/index', -# ] -# PYPIRC = "$HOME/.pypirc" -# TESTING_PYPIRC = "$HOME/.test-pypirc" - -# PYENV_CONDA_NAME = 'miniconda3-latest' - -## CONSTANTS - - -BENCHMARK_STORAGE_URI = f"\"file://{REPORTS_DIR}/benchmarks\"" - - -def project_slug(): - - try: - from ..config import PROJECT_SLUG - except ImportError: - print("You must set the 'PROJECT_SLUG' in conifig.py to use this") - else: - return PROJECT_SLUG - -@task -def init(cx): - - # install the versioneer files - - # this always exits in an annoying way so we just warn here. - cx.run("versioneer install", - warn=True) - -@task -def clean_dist(cx): - """Remove all build products.""" - - cx.run("python setup.py clean") - cx.run("rm -rf dist build */*.egg-info *.egg-info") - -@task -def clean_cache(cx): - """Remove all of the __pycache__ files in the packages.""" - cx.run('find . -name "__pycache__" -exec rm -r {} +') - -@task -def clean_docs(cx): - """Remove all documentation build products""" - - docs_clean(cx) - -@task -def clean_website(cx): - """Remove all local website build products""" - cx.run("rm -rf docs/*") - - # if the website accidentally got onto the main branch we remove - # that crap too - for thing in [ - '_images', - '_modules', - '_sources', - '_static', - 'api', - 'genindex.html', - 'index.html', - 'invoke.html', - 'objects.inv', - 'py-modindex.html', - 'search.html', - 'searchindex.js', - 'source', - 'tutorials', - ]: - - cx.run(f"rm -rf {thing}") - -@task(pre=[clean_cache, clean_dist, clean_docs, clean_website]) -def clean(cx): - pass - - -### Docs - -@task -def docs_clean(cx): - - cx.run("cd sphinx && make clean") - cx.run("rm -rf sphinx/_build") - cx.run("rm -rf sphinx/_source") - cx.run("rm -rf sphinx/_api") - cx.run("rm -rf sphinx/_static") - - # reports - cx.run("rm -rf reports/benchmarks/asv/_html") - -@task -def docs_regressions(cx): - - with cx.cd("benchmarks"): - cx.run("asv publish", warn=True) - -@task -def docs_coverage(cx): - cx.run("coverage html -d reports/coverage/_html/index.html", - warn=True) - -@task -def docs_complexity(cx): - - os.makedirs(f"{REPORTS_DIR}/code_quality/_html", - exist_ok=True) - cx.run(f"lizard -o {REPORTS_DIR}/code_quality/_html/index.html src/{project_slug()}", - warn=True) - -@task(pre=[ - docs_regressions, - docs_coverage, - docs_complexity, -]) -def docs_reports(cx): - """Build all of the reports from source.""" - pass - -@task(pre=[docs_clean, docs_reports]) -def docs_build(cx): - """Buld the documentation""" - - # make sure the 'source' folder exists - cx.run("mkdir -p sphinx/_source") - cx.run("mkdir -p sphinx/_source/tutorials") - cx.run("mkdir -p sphinx/_source/tutorials/data_analysis") - cx.run("mkdir -p sphinx/_source/tutorials/multiple_runs") - cx.run("mkdir -p sphinx/_source/examples") - cx.run("mkdir -p sphinx/_static") - - # copy the logo over - cx.run(f"cp {LOGO_DIR}/* sphinx/_static/") - - # and the other theming things - cx.run(f"cp sphinx/static/* sphinx/_static/") - - # copy the plain RST files over to the sources - for source in RST_DOCS_SOURCES: - - source_path = Path('info') / source - - # glob expand if it is a directory - if source_path.is_dir(): - sources = [path.stem for path in source_path.glob("*.rst")] - else: - sources = [source] - targets = [source] - - for source, target in zip(sources, targets): - - cx.run(f"cp info/{source}.rst sphinx/_source/{target}.rst") - - # copy the Bibtex files over - for source in BIB_DOCS_SOURCES: - cx.run(f"cp info/{source}.bib sphinx/_source/{source}.bib") - - # convert the org mode to rst in the source folder - for source in ORG_DOCS_SOURCES: - - source_dir = Path("info") - target_dir = Path("sphinx/_source") - - source_path = source_dir / source - - # glob expand if it is a directory - if source_path.is_dir(): - sources = [f"{source}/{path.stem}" for path in source_path.glob("*.org")] - targets = sources - - # also make sure the directory exists at the target - os.makedirs(target_dir / source, - exist_ok=True) - else: - sources = [source] - targets = sources - - for source, target in zip(sources, targets): - - cx.run("pandoc " - "-f org " - "-t rst " - f"-o {target_dir}/{target}.rst " - f"{source_dir}/{source}.org") - - ## Examples - - # examples don't get put into the documentation and rendered like - # the tutorials do, but we do copy the README as the index - - # copy the tutorials_index.rst file to the tutorials in _source - # TODO: remove, don't think I will use this - # sh.copyfile( - # "sphinx/examples_index.rst", - # "sphinx/_source/examples/index.rst", - # ) - - - ## Tutorials - - # convert the README - sh.copyfile( - "sphinx/tutorials_index.rst", - "sphinx/_source/tutorials/index.rst", - ) - - sh.copyfile( - "sphinx/data_analysis_index.rst", - "sphinx/_source/tutorials/data_analysis/index.rst", - ) - - sh.copyfile( - "sphinx/multiple_runs_index.rst", - "sphinx/_source/tutorials/multiple_runs/index.rst", - ) - - sh.copyfile( - "sphinx/quick_start_index.rst", - "sphinx/_source/quick_start/index.rst", - ) - - # convert any of the tutorials that exist with an org mode extension as well - for item in os.listdir('info/tutorials'): - item = Path('info/tutorials') / item - - # tutorials are in their own dirs - if item.is_dir(): - docs = list(item.glob("README.org")) + \ - list(item.glob("README.ipynb")) + \ - list(item.glob("README.rst")) - - if len(docs) > 1: - raise ValueError(f"Multiple tutorial files for {item}") - else: - readme_path = docs[0] - - tutorial = item.stem - - os.makedirs(f"sphinx/_source/tutorials/{tutorial}", - exist_ok=True) - - # we must convert org mode files to rst - if readme_path.suffix == '.org': - - cx.run("pandoc " - "-f org " - "-t rst " - f"-o sphinx/_source/tutorials/{tutorial}/README.rst " - f"info/tutorials/{tutorial}/README.org") - - # just copy notebooks since teh sphinx extension handles - # them - elif readme_path.suffix in ('.ipynb', '.rst',): - - sh.copyfile( - readme_path, - f"sphinx/_source/tutorials/{tutorial}/{readme_path.stem}{readme_path.suffix}", - ) - - # otherwise just move it - else: - raise ValueError(f"Unkown tutorial type for file: {readme_path.stem}{readme_path.suffix}") - - - - # convert any of the data analysis files that exist with an org mode extension as well - for item in os.listdir('info/tutorials/data_analysis'): - item = Path('info/tutorials/data_analysis') / item - - # quick starts are in their own dirs - if item.is_dir(): - - docs = list(item.glob("README.org")) + \ - list(item.glob("README.ipynb")) + \ - list(item.glob("README.rst")) - - if len(docs) > 1: - raise ValueError(f"Multiple quick start files for {item}") - else: - readme_path = docs[0] - - tutorial = item.stem - - os.makedirs(f"sphinx/_source/tutorials/data_analysis/{tutorial}", - exist_ok=True) - - # we must convert org mode files to rst - if readme_path.suffix == '.org': - - cx.run("pandoc " - "-f org " - "-t rst " - f"-o sphinx/_source/tutorials/data_analysis/{tutorial}/README.rst " - f"info/tutorials/data_analysis/{tutorial}/README.org") - - # just copy notebooks since teh sphinx extension handles - # them - elif readme_path.suffix in ('.ipynb', '.rst',): - - sh.copyfile( - readme_path, - f"sphinx/_source/tutorials/data_analysis/{tutorial}/{readme_path.stem}{readme_path.suffix}", - ) - - # otherwise just move it - else: - raise ValueError(f"Unkown tutorial type for file: {readme_path.stem}{readme_path.suffix}") - - - # convert any of the multiple runs files that exist with an org mode extension as well - for item in os.listdir('info/tutorials/multiple_runs'): - item = Path('info/tutorials/multiple_runs') / item - - # quick starts are in their own dirs - if item.is_dir(): - - docs = list(item.glob("README.org")) + \ - list(item.glob("README.ipynb")) + \ - list(item.glob("README.rst")) - - if len(docs) > 1: - raise ValueError(f"Multiple quick start files for {item}") - else: - readme_path = docs[0] - - tutorial = item.stem - - os.makedirs(f"sphinx/_source/tutorials/multiple_runs/{tutorial}", - exist_ok=True) - - # we must convert org mode files to rst - if readme_path.suffix == '.org': - - cx.run("pandoc " - "-f org " - "-t rst " - f"-o sphinx/_source/tutorials/multiple_runs/{tutorial}/README.rst " - f"info/tutorials/multiple_runs/{tutorial}/README.org") - - # just copy notebooks since teh sphinx extension handles - # them - elif readme_path.suffix in ('.ipynb', '.rst',): - - sh.copyfile( - readme_path, - f"sphinx/_source/tutorials/multiple_runs/{tutorial}/{readme_path.stem}{readme_path.suffix}", - ) - - # otherwise just move it - else: - raise ValueError(f"Unkown tutorial type for file: {readme_path.stem}{readme_path.suffix}") - - - - # convert any of the quick start files that exist with an org mode extension as well - for item in os.listdir('info/quick_start'): - item = Path('info/quick_start') / item - - # quick starts are in their own dirs - if item.is_dir(): - - docs = list(item.glob("README.org")) + \ - list(item.glob("README.ipynb")) + \ - list(item.glob("README.rst")) - - if len(docs) > 1: - raise ValueError(f"Multiple quick start files for {item}") - else: - readme_path = docs[0] - - tutorial = item.stem - - os.makedirs(f"sphinx/_source/quick_start/{tutorial}", - exist_ok=True) - - # we must convert org mode files to rst - if readme_path.suffix == '.org': - - cx.run("pandoc " - "-f org " - "-t rst " - f"-o sphinx/_source/quick_start/{tutorial}/README.rst " - f"info/quick_start/{tutorial}/README.org") - - # just copy notebooks since teh sphinx extension handles - # them - elif readme_path.suffix in ('.ipynb', '.rst',): - - sh.copyfile( - readme_path, - f"sphinx/_source/quick_start/{tutorial}/{readme_path.stem}{readme_path.suffix}", - ) - - # otherwise just move it - else: - raise ValueError(f"Unkown tutorial type for file: {readme_path.stem}{readme_path.suffix}") - - # run the build steps for sphinx - with cx.cd('sphinx'): - - # build the API Documentation - cx.run(f"sphinx-apidoc -f --separate --private --ext-autodoc --module-first --maxdepth 1 -o _api ../src/{project_slug()}") - - # then do the sphinx build process - cx.run("sphinx-build -b html -E -a -j 6 -c . . ./_build/html/") - - - - ## Post Sphinx - - # add things like adding in metrics etc. here - # copy the benchmark regressions over if available - - # asv regressions - regression_pages = Path("reports/benchmarks/asv/_html") - - if regression_pages.exists() and regression_pages.is_dir(): - sh.copytree( - regression_pages, - "sphinx/_build/html/regressions" - ) - - quality_pages = Path("reports/code_quality/_html") - - if quality_pages.exists() and quality_pages.is_dir(): - sh.copytree( - quality_pages, - "sphinx/_build/html/quality" - ) - - # coverage - coverage_pages = Path("reports/coverage/_html") - - if coverage_pages.exists() and coverage_pages.is_dir(): - sh.copytree( - coverage_pages, - "sphinx/_build/html/coverage" - ) - -@task(pre=[docs_build]) -def docs_serve(cx): - """Local server for documenation""" - cx.run("python -m http.server -d sphinx/_build/html 8022") - -### Website - -@task(pre=[clean_docs, clean_website, docs_build]) -def website_serve(cx): - """Serve the main web page locally for development.""" - - # TODO: implement using Nikola - - # STUB: just use the docs for this right now - docs_serve(cx) - - -# STUB: @task(pre=[clean_docs, docs_build]) -@task -def website_deploy(cx): - """Deploy the documentation onto the internet.""" - - # use the ghp-import tool which handles the branch switching to - # `gh-pages` for you - cx.run("ghp-import --no-jekyll --push --force sphinx/_build/html") - - -### Jigs - -# jigs are for that kind of in between work of not in module, not -# documentation etc. Could be prototypes, troubleshooting, or anything -# that needs non-trivial setup but isn't part of a "framework". Uses -# the same schema as examples to give some order to it. - -@task -def new_jig(cx, name=None, template="org", env='venv_blank'): - """Create a new jig. - - Can choose between the following templates: - - - 'org' :: org mode notebook - - Choose from the following env templates: - - - None - - venv_blank - - venv_dev - - conda_blank - - conda_dev - - """ - - assert name is not None, "Must provide a name" - - template_path = Path(f"templates/jigs/{template}") - - # check if the template exists - if not template_path.is_dir(): - - raise ValueError( - f"Unkown template {template}. Check the 'templates/jigs' folder") - - # check if the env exists - if env is not None: - env_tmpl_path = Path(f"templates/envs/{env}") - - if not env_tmpl_path.is_dir(): - - raise ValueError( - f"Unkown env template {env}. Check the 'templates/envs' folder") - - - target_path = Path(f"jigs/{name}") - - if target_path.exists(): - raise FileExistsError(f"Jig with name {name} already exists. Not overwriting.") - - # copy the template - cx.run(f"cp -r {template_path} {target_path}") - - # copy the env - cx.run(f"cp -r {env_tmpl_path} {target_path / 'env'}") - - print(f"New example created at: {target_path}") - -@task -def pin_jig(cx, name=None): - """Pin the deps for an example or all of them if 'name' is None.""" - - path = Path('jigs') / name / 'env' - - assert path.exists() and path.is_dir(), \ - f"Env for Jig {name} doesn't exist" - - cx.run(f"inv env.deps-pin-path -p {path}") - -@task -def env_jig(cx, name=None): - """Make a the example env in its local dir.""" - - if name is None: - raise ValueError("Must specify which jig to use") - - spec_path = Path('jigs') / name / 'env' - env_path = Path('jigs') / name / '_env' - - assert spec_path.exists() and spec_path.is_dir(), \ - f"Jig {name} doesn't exist" - - cx.run(f"inv env.make-env -s {spec_path} -p {env_path}") - - -### Tests - - -@task -def tests_benchmarks(cx): - cx.run("pytest -m 'not interactive' tests/test_benchmarks", - warn=True) - -@task -def tests_integration(cx, tag=None): - - if tag is None: - cx.run(f"coverage run -m pytest -m 'not interactive' tests/test_integration", - warn=True) - else: - cx.run(f"coverage run -m pytest --html=reports/pytest/{tag}/integration/report.html -m 'not interactive' tests/test_integration", - warn=True) - - -@task -def tests_unit(cx, tag=None): - - if tag is None: - cx.run(f"coverage run -m pytest -m 'not interactive' tests/test_unit", - warn=True) - else: - cx.run(f"coverage run -m pytest --html=reports/pytest/{tag}/unit/report.html -m 'not interactive' tests/test_unit", - warn=True) - - -@task -def tests_interactive(cx): - """Run the interactive tests so we can play with things.""" - cx.run("pytest -m 'interactive'", - warn=True) - -@task() -def tests_all(cx, tag=None): - """Run all the automated tests. No benchmarks. - - There are different kinds of nodes that we can run on that - different kinds of tests are available for. - - - minor : does not have a GPU, can still test most other code paths - - - dev : has at least 1 GPU, enough for small tests of all code paths - - - production : has multiple GPUs, good for running benchmarks - and full stress tests - - """ - - tests_unit(cx, tag=tag) - tests_integration(cx, tag=tag) - -@task -def tests_nox(cx): - - if ENV_METHOD == 'pyenv': - - # run with base venv maker - with cx.prefix("unset PYENV_VERSION"): - cx.run("nox -s test") - - elif ENV_METHOD == 'conda': - - # test running with conda - with cx.prefix(f"pyenv shell {PYENV_CONDA_NAME}"): - cx.run("nox -s test") - - else: - - raise ValueError(f"Unsupported ENV_METHOD: {ENV_METHOD}") - - -### Code & Test Quality - -@task -def docstrings_report(cx): - - cx.run("mkdir -p reports/docstring_coverage") - cx.run("interrogate -o reports/docstring_coverage/src.interrogate.txt -vv src") - - # TODO add it for tests and docs etc. - -@task -def coverage_report(cx): - # cx.run("coverage report") - cx.run("coverage xml -o reports/coverage/coverage.xml", - warn=True) - cx.run("coverage json -o reports/coverage/coverage.json", - warn=True - ) - -@task -def coverage_serve(cx): - cx.run("python -m http.server -d reports/coverage/html 8020", - asynchronous=True) - - -@task -def lint(cx): - - cx.run(f"mkdir -p {REPORTS_DIR}/lint") - - cx.run(f"rm -f {REPORTS_DIR}/lint/flake8.txt") - cx.run(f"flake8 --output-file={REPORTS_DIR}/lint/flake8.txt src/{project_slug()}", - warn=True) - -@task -def complexity(cx): - """Analyze the complexity of the project.""" - - cx.run(f"mkdir -p {REPORTS_DIR}/code_quality") - - cx.run(f"lizard -o {REPORTS_DIR}/code_quality/lizard.csv src/{project_slug()}", - warn=True) - - # SNIPPET: annoyingly opens the browser - - # make a cute word cloud of the things used - # cx.run(f"(cd {REPORTS_DIR}/code_quality; lizard -EWordCount src/project_slug() > /dev/null)") - -@task -def complexity_serve(cx): - cx.run("python -m http.server -d reports/conde_quality/lizard.html 8021", - asynchronous=True) - -@task(pre=[coverage_report, complexity, lint]) -def quality(cx): - pass - -@task(pre=[coverage_serve, complexity_serve]) -def quality_serve(cx): - pass - - -### Profiling - -@task -def profile(cx): - NotImplemented - -### Performance Benchmarks - -## regressions - -@task -def regressions_all(cx): - """Run regression benchmarks for all of the hashes/tags in the - benchmarks/benchmark_selection.list file""" - - with cx.cd("benchmarks"): - cx.run("asv run HASHFILE:benchmark_selection.list") - -@task -def regression_current(cx): - - with cx.cd("benchmarks"): - cx.run("asv run") - -# @task -# def asv_update - -@task -def benchmark_adhoc(cx): - """An ad hoc benchmark that will not be saved.""" - - cx.run("pytest benchmarks/pytest_benchmark/test_benchmarks") - -@task -def benchmark_save(cx): - """Run a proper benchmark that will be saved into the metrics for regression testing etc.""" - - run_command = \ -f"""pytest --benchmark-autosave --benchmark-save-data \ - --benchmark-storage={BENCHMARK_STORAGE_URI} \ - tests/test_benchmarks -""" - - cx.run(run_command) - -@task -def benchmark_compare(cx): - - # TODO logic for comparing across the last two - - run_command = \ -"""pytest-benchmark \ - --storage {storage} \ - compare 'Linux-CPython-3.6-64bit/*' \ - --csv=\"{csv}\" \ - > {output} -""".format(storage=BENCHMARK_STORAGE_URI, - csv="{}/Linux-CPython-3.6-64bit/comparison.csv".format(BENCHMARK_STORAGE_URL), - output="{}/Linux-CPython-3.6-64bit/report.pytest.txt".format(BENCHMARK_STORAGE_URL), -) - - cx.run(run_command) - -@task -def version_which(cx): - """Tell me what version the project is at.""" - - # get the current version - cx.run(f"python -m {project_slug()}._print_version") - -### Packaging - -## Building - -# IDEA here are some ideas I want to do - -# Source Distribution - -# Wheel: Binary Distribution - -# Beeware cross-patform - -# Debian Package (with `dh_virtualenv`) - -@task -def update_tools(cx): - - # WKRD, FIXME: because new versions of pip are incompatible with - # pip-tools we can't blindly update in envs. Want to make a - # mechanism for installing the tools in a separate - # requirements.txt file since we can't put these in the pip tools - # input files. In short should be taken care of in the 'env' module - print("Disable 'update_tools' use the envs 'tools.requirements.txt' instead") - pass - # cx.run("pip install --upgrade pip setuptools wheel twine") - -@task(pre=[update_tools]) -def build_sdist(cx): - """Make a source distribution""" - cx.run("python setup.py sdist") - -@task(pre=[update_tools]) -def build_bdist(cx): - """Make a binary wheel distribution.""" - - cx.run("python setup.py bdist_wheel") - -# STUB -@task -def conda_build(cx): - - cx.run("conda-build conda-recipe") - -@task(pre=[build_sdist, build_bdist,]) -def build(cx): - """Build all the python distributions supported.""" - pass - - -# IDEA: add a 'test_builds' target, that opens a clean environment and -# installs each build - - -## Publishing - -# testing publishing - - - -@task(pre=[clean_dist, build_sdist]) -def publish_test_pypi(cx, - version=None, -): - - assert version is not None - - cx.run("twine upload " - "--non-interactive " - f"--repository pypi_test " - f"--config-file {TESTING_PYPIRC} " - "dist/*") - -@task(pre=[clean_dist, update_tools, build_sdist]) -def publish_test(cx): - - publish_test_pypi(cx, - version=VERSION) - -# PyPI - - -@task(pre=[clean_dist, build]) -def publish_pypi(cx, version=None): - - assert version is not None - - cx.run(f"twine upload " - "--non-interactive " - "--repository pypi " - f"--config-file {PYPIRC} " - f"dist/*") - - -# TODO, SNIPPET, STUB: this is a desired target for uploading dists to github -# @task -# def publish_github_dists(cx, release=None): -# assert release is not None, "Release tag string must be given" -# pass - -@task(pre=[clean_dist, update_tools, build]) -def publish(cx): - - publish_pypi(cx, version=VERSION) diff --git a/tasks/plugins/__init__.py b/tasks/plugins/__init__.py deleted file mode 100644 index f9a6eb80..00000000 --- a/tasks/plugins/__init__.py +++ /dev/null @@ -1,13 +0,0 @@ -"""Specify which plugins to load""" - -# import plugins: - -from . import custom -from . import tests - -# specify which plugins to install, the custom one is included by -# default to get users going -PLUGIN_MODULES = [ - custom, - tests, -] diff --git a/tasks/plugins/custom.py b/tasks/plugins/custom.py deleted file mode 100644 index 8bbc62ff..00000000 --- a/tasks/plugins/custom.py +++ /dev/null @@ -1,6 +0,0 @@ -"""Put user defined tasks in the plugins folder. You can start with -some customizations in this file which is included by default.""" - -from invoke import task - - diff --git a/tasks/plugins/tests.py b/tasks/plugins/tests.py deleted file mode 100644 index 26505124..00000000 --- a/tasks/plugins/tests.py +++ /dev/null @@ -1,62 +0,0 @@ -from invoke import task - -from ..config import ( - REPORTS_DIR, -) - -import sys -import os -import os.path as osp -from pathlib import Path - -import pytest - - -# TODO: this should be done better -@task -def integration(cx, tag=None, node='node_minor'): - """Run the integration tests. - - This is a large test suite and needs specific hardware resources - in order to run all of them. For this reason there are different - test objects which are tagged as different grades of nodes. The - idea is that depending on the machine you are able to test on you - will still be able to run some of the tests to test pure-python - code paths or code paths that involve GPUs etc. - - The node types are: - - - minor :: no GPUs - - dev :: at least 1 GPU - - production :: more than 1 GPU - - You can use these as a 'mark' selection when running pytest or use - it as the option for this command. - - """ - - lines = [ - f"coverage run -m pytest ", - f"-m 'not interactive' ", - f"tests/test_integration", - ] - - if node == 'minor': - node = '' - - options = { - "html" : ( - "--html=reports/pytest/{tag}/integration/report.html" - if tag is not None - else "" - ), - - "node" : node, - } - - if tag is None: - cx.run('heerr', - warn=True) - else: - cx.run(f"coverage run -m pytest -m 'not interactive' tests/test_integration", - warn=True) diff --git a/tasks/sysconfig.py b/tasks/sysconfig.py deleted file mode 100644 index 9c26abd6..00000000 --- a/tasks/sysconfig.py +++ /dev/null @@ -1,100 +0,0 @@ -"""Configuration managed by the system. All changes here will be -overwrote upon update. - -Typically gives a collection of good defaults. Override in config.py - -""" - -### Cleaning - -CLEAN_EXPRESSIONS = [ - "\"*~\"", -] - - -### Envs - -# which virtual environment tool to use: venv or conda -ENV_METHOD = 'venv' - -# which env spec to use by default -DEFAULT_ENV = 'dev' - -# directory where env specs are read from -ENVS_DIR = 'envs' - -# Python version source, this is how we get the different python -# versions. This is a keyword not a path -PYTHON_VERSION_SOURCE = "pyenv" - -# which versions will be requested to be installed, in the order of -# precendence for interactive work -PYTHON_VERSIONS = ( - '3.8.1', - '3.7.6', - '3.6.10', -) - - -### Git - -INITIAL_VERSION = '0.0.0a0.dev0' -GIT_LFS_TARGETS = [] -VERSION = '0.0.0a0.dev0' - - -### Python Code base - -REPORTS_DIR = "reports" - -## docs - -LOGO_DIR = "info/logo" - -ORG_DOCS_SOURCES = [ - 'changelog', - 'dev_guide', - 'general_info', - 'installation', - 'introduction', - 'news', - 'quick_start', - 'troubleshooting', - 'users_guide', - 'reference', - 'news-articles', -] - -RST_DOCS_SOURCES = [ - 'glossary', - 'api', -] - -BIB_DOCS_SOURCES = [ - 'docs', -] - -PYPIRC="$HOME/.pypirc" -TESTING_PYPIRC="$HOME/.pypirc" - -# this is the name of the pyenv "version" to use for creating and -# activating conda -PYENV_CONDA_NAME = 'miniconda3-latest' - -## tests -TESTS_DIR = "tests" - -## benchmarks -BENCHMARKS_DIR = "benchmarks" - -# the range of commits to use for running all of the asv regression -# benchmarks. See documentation in `asv run --help` for details. -# Defaults to using the HASHFILE. -ASV_RANGE = "HASHFILE:benchmark_selection.list" - - -### Containers - -# choose the container tool, options really are just: docker or podman -# since these two are compatible -CONTAINER_TOOL = "podman" diff --git a/tasks/toplevel.py b/tasks/toplevel.py deleted file mode 100644 index e3710f3b..00000000 --- a/tasks/toplevel.py +++ /dev/null @@ -1,6 +0,0 @@ -"""User editable top-level commands""" - -from invoke import task - -from .config import * - diff --git a/templates/envs/conda_blank/dev.requirements.list b/templates/envs/conda_blank/dev.requirements.list deleted file mode 100644 index 8b137891..00000000 --- a/templates/envs/conda_blank/dev.requirements.list +++ /dev/null @@ -1 +0,0 @@ - diff --git a/templates/envs/conda_blank/env.yaml b/templates/envs/conda_blank/env.yaml deleted file mode 100644 index f1438cbc..00000000 --- a/templates/envs/conda_blank/env.yaml +++ /dev/null @@ -1,6 +0,0 @@ -name: wepy-dev -channels: - - conda-forge - - defaults -dependencies: - diff --git a/templates/envs/conda_blank/pyversion.txt b/templates/envs/conda_blank/pyversion.txt deleted file mode 100644 index 8b137891..00000000 --- a/templates/envs/conda_blank/pyversion.txt +++ /dev/null @@ -1 +0,0 @@ - diff --git a/templates/envs/conda_blank/requirements.in b/templates/envs/conda_blank/requirements.in deleted file mode 100644 index 11deb9f2..00000000 --- a/templates/envs/conda_blank/requirements.in +++ /dev/null @@ -1 +0,0 @@ ---index-url https://pypi.python.org/simple/ diff --git a/templates/envs/conda_blank/self.requirements.txt b/templates/envs/conda_blank/self.requirements.txt deleted file mode 100644 index 8b137891..00000000 --- a/templates/envs/conda_blank/self.requirements.txt +++ /dev/null @@ -1 +0,0 @@ - diff --git a/templates/examples/org/README.org b/templates/examples/org/README.org deleted file mode 100644 index f309c256..00000000 --- a/templates/examples/org/README.org +++ /dev/null @@ -1 +0,0 @@ -* Example of an example diff --git a/templates/examples/org/source/.keep b/templates/examples/org/source/.keep deleted file mode 100644 index e69de29b..00000000 diff --git a/templates/examples/org/tasks.py b/templates/examples/org/tasks.py deleted file mode 100644 index 149239c6..00000000 --- a/templates/examples/org/tasks.py +++ /dev/null @@ -1,44 +0,0 @@ -from invoke import task - -import os -import os.path as osp -from pathlib import Path - -def tangle_orgfile(cx, file_path): - """Tangle the target file using emacs in batch mode. Implicitly dumps - things relative to the file.""" - - cx.run(f"emacs -Q --batch -l org {file_path} -f org-babel-tangle") - -@task -def init(cx): - cx.run("mkdir -p _tangle_source") - cx.run("mkdir -p _output") - -@task -def clean(cx): - cx.run("rm -rf _tangle_source") - cx.run("rm -rf _output") - -@task(pre=[init]) -def tangle(cx): - tangle_orgfile(cx, "README.org") - cx.run(f"chmod ug+x ./_tangle_source/*.bash", warn=True) - cx.run(f"chmod ug+x ./_tangle_source/*.sh", warn=True) - cx.run(f"chmod ug+x ./_tangle_source/*.py", warn=True) - -@task -def clean_env(cx): - cx.run("rm -rf _env") - -@task(pre=[init]) -def env(cx): - """Create the environment from the specs in 'env'. Must have the - entire repository available as it uses the tooling from it. - - """ - - example_name = Path(os.getcwd()).stem - - with cx.cd("../../../"): - cx.run(f"inv docs.env-example -n {example_name}") diff --git a/templates/jigs/org/README.org b/templates/jigs/org/README.org deleted file mode 100644 index f309c256..00000000 --- a/templates/jigs/org/README.org +++ /dev/null @@ -1 +0,0 @@ -* Example of an example diff --git a/templates/jigs/org/input/.keep b/templates/jigs/org/input/.keep deleted file mode 100644 index e69de29b..00000000 diff --git a/templates/jigs/org/source/.keep b/templates/jigs/org/source/.keep deleted file mode 100644 index e69de29b..00000000 diff --git a/templates/jigs/org/tasks.py b/templates/jigs/org/tasks.py deleted file mode 100644 index 07dfda44..00000000 --- a/templates/jigs/org/tasks.py +++ /dev/null @@ -1,44 +0,0 @@ -from invoke import task - -import os -import os.path as osp -from pathlib import Path - -def tangle_orgfile(cx, file_path): - """Tangle the target file using emacs in batch mode. Implicitly dumps - things relative to the file.""" - - cx.run(f"emacs -Q --batch -l org {file_path} -f org-babel-tangle") - -@task -def init(cx): - cx.run("mkdir -p _tangle_source") - cx.run("mkdir -p _output") - -@task -def clean(cx): - cx.run("rm -rf _tangle_source") - cx.run("rm -rf _output") - -@task(pre=[init]) -def tangle(cx): - tangle_orgfile(cx, "README.org") - cx.run(f"chmod ug+x ./_tangle_source/*.bash", warn=True) - cx.run(f"chmod ug+x ./_tangle_source/*.sh", warn=True) - cx.run(f"chmod ug+x ./_tangle_source/*.py", warn=True) - -@task -def clean_env(cx): - cx.run("rm -rf _env") - -@task(pre=[init]) -def env(cx): - """Create the environment from the specs in 'env'. Must have the - entire repository available as it uses the tooling from it. - - """ - - jig_name = Path(os.getcwd()).stem - - with cx.cd("../../../"): - cx.run(f"inv py.env-jig -n {jig_name}") diff --git a/templates/tutorials/jupyter/README.ipynb b/templates/tutorials/jupyter/README.ipynb deleted file mode 100644 index cccdcaeb..00000000 --- a/templates/tutorials/jupyter/README.ipynb +++ /dev/null @@ -1,32 +0,0 @@ -{ - "cells": [ - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [] - } - ], - "metadata": { - "kernelspec": { - "display_name": "Python 3", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.8.1" - } - }, - "nbformat": 4, - "nbformat_minor": 4 -} diff --git a/templates/tutorials/jupyter/input/.keep b/templates/tutorials/jupyter/input/.keep deleted file mode 100644 index e69de29b..00000000 diff --git a/templates/tutorials/jupyter/tasks.py b/templates/tutorials/jupyter/tasks.py deleted file mode 100644 index b271a2d1..00000000 --- a/templates/tutorials/jupyter/tasks.py +++ /dev/null @@ -1,36 +0,0 @@ -from invoke import task - -import os -import os.path as osp -from pathlib import Path - -@task -def init(cx): - cx.run("mkdir -p _output") - cx.run("mkdir -p _tangle_source") - -@task -def clean(cx): - cx.run("rm -rf _output/*") - cx.run("rm -rf _tangle_source/*") - -@task(pre=[init]) -def tangle(cx): - cx.run("jupyter-nbconvert --to 'python' --output-dir=_tangle_source README.ipynb") - - -@task -def clean_env(cx): - cx.run("rm -rf _env") - -@task(pre=[init]) -def env(cx): - """Create the environment from the specs in 'env'. Must have the - entire repository available as it uses the tooling from it. - - """ - - example_name = Path(os.getcwd()).stem - - with cx.cd("../../../"): - cx.run(f"inv docs.env-tutorial -n {example_name}") diff --git a/templates/tutorials/org/README.org b/templates/tutorials/org/README.org deleted file mode 100644 index 7bd900c8..00000000 --- a/templates/tutorials/org/README.org +++ /dev/null @@ -1,2 +0,0 @@ - -* Your Tutorial Here diff --git a/templates/tutorials/org/input/.keep b/templates/tutorials/org/input/.keep deleted file mode 100644 index e69de29b..00000000 diff --git a/templates/tutorials/org/source/.keep b/templates/tutorials/org/source/.keep deleted file mode 100644 index e69de29b..00000000 diff --git a/templates/tutorials/org/tasks.py b/templates/tutorials/org/tasks.py deleted file mode 100644 index d4b9361f..00000000 --- a/templates/tutorials/org/tasks.py +++ /dev/null @@ -1,44 +0,0 @@ -from invoke import task - -import os -import os.path as osp -from pathlib import Path - -def tangle_orgfile(cx, file_path): - """Tangle the target file using emacs in batch mode. Implicitly dumps - things relative to the file.""" - - cx.run(f"emacs -Q --batch -l org {file_path} -f org-babel-tangle") - -@task -def init(cx): - cx.run("mkdir -p _tangle_source") - cx.run("mkdir -p _output") - -@task -def clean(cx): - cx.run("rm -rf _tangle_source") - cx.run("rm -rf _output") - -@task(pre=[init]) -def tangle(cx): - tangle_orgfile(cx, "README.org") - cx.run(f"chmod ug+x ./_tangle_source/*.bash", warn=True) - cx.run(f"chmod ug+x ./_tangle_source/*.sh", warn=True) - cx.run(f"chmod ug+x ./_tangle_source/*.py", warn=True) - -@task -def clean_env(cx): - cx.run("rm -rf _env") - -@task(pre=[init]) -def env(cx): - """Create the environment from the specs in 'env'. Must have the - entire repository available as it uses the tooling from it. - - """ - - example_name = Path(os.getcwd()).stem - - with cx.cd("../../../"): - cx.run(f"inv docs.env-tutorial -n {example_name}") diff --git a/tests/benchmarks/test_mappers.py b/tests/benchmarks/test_mappers.py index 305b7914..99ecc144 100644 --- a/tests/benchmarks/test_mappers.py +++ b/tests/benchmarks/test_mappers.py @@ -3,33 +3,19 @@ logger = logging.getLogger(__name__) # Standard Library -import multiprocessing as mp import time -from copy import deepcopy # Third Party Library import pytest # First Party Library -from wepy.resampling.resamplers.resampler import NoResampler -from wepy.runners.openmm import ( - OpenMMCPUWalkerTaskProcess, - OpenMMCPUWorker, - OpenMMGPUWalkerTaskProcess, - OpenMMGPUWorker, - OpenMMRunner, - OpenMMState, - OpenMMWalker, -) -from wepy.sim_manager import Manager from wepy.walker import Walker, WalkerState -from wepy.work_mapper.mapper import Mapper, TaskException +from wepy.work_mapper.mapper import Mapper from wepy.work_mapper.task_mapper import ( TaskMapper, - TaskProcessException, WalkerTaskProcess, ) -from wepy.work_mapper.worker import Worker, WorkerException, WorkerMapper +from wepy.work_mapper.worker import Worker, WorkerMapper from wepy_tools.sim_makers.openmm.lennard_jones import LennardJonesPairOpenMMSimMaker from wepy_tools.sim_makers.openmm.lysozyme import LysozymeImplicitOpenMMSimMaker diff --git a/tests/data/.gitignore b/tests/data/.gitignore new file mode 100644 index 00000000..9a9d0d27 --- /dev/null +++ b/tests/data/.gitignore @@ -0,0 +1 @@ +/alanine_dipeptide_revo.wepy.hdf5 diff --git a/tests/data/alanine_dipeptide_revo.wepy.hdf5.dvc b/tests/data/alanine_dipeptide_revo.wepy.hdf5.dvc new file mode 100644 index 00000000..1f46ee16 --- /dev/null +++ b/tests/data/alanine_dipeptide_revo.wepy.hdf5.dvc @@ -0,0 +1,5 @@ +outs: +- md5: 09ee4a41e14774d554f487c5f6396121 + size: 6626397 + hash: md5 + path: alanine_dipeptide_revo.wepy.hdf5 diff --git a/tests/data/synthesize.py b/tests/data/synthesize.py new file mode 100644 index 00000000..085cce6f --- /dev/null +++ b/tests/data/synthesize.py @@ -0,0 +1,139 @@ +# Standard Library +import copy +import logging +from pathlib import Path + +# Third Party Library +import cyclopts +import mdtraj +import openmm +import psutil + +# First Party Library +import wepy +from wepy_tools.systems.alanine_dipeptide import ( + AlanineDipeptideExplicitSystem, + AlanineDipeptideRamachandranDistance, +) + +logging.basicConfig(level=logging.INFO) + +_logger = logging.getLogger("tests") + + +DEFAULT_SAVE_FIELDS = ( + "positions", + "box_vectors", + "box_volume", + "potential_energy", + "kinetic_energy", +) + + +app = cyclopts.App() + + +@app.command +def realistic_hdf5_dialanine_explicit(out_path: Path): + + STEP_SIZE = 2.0 * openmm.unit.femtosecond + TEMPERATURE = 300.0 * openmm.unit.kelvin + + ala_sys = AlanineDipeptideExplicitSystem() + + integrator = openmm.LangevinIntegrator(TEMPERATURE, 0.1, STEP_SIZE) + + # add the pseudo forces like barostat + barostat = openmm.MonteCarloBarostat( + 1.0 * openmm.unit.atmosphere, + TEMPERATURE, + ) + ala_sys.system.addForce(barostat) + + runner_factory = wepy.OpenMMRunnerFactory( + system=ala_sys.system, + topology=ala_sys.topology, + integrator=integrator, + ) + + num_walkers = 10 + + # TODO: remove the need to deepcopy and have the components make + # their own copies if necessary + walker_states = [copy.deepcopy(ala_sys.state) for _ in range(num_walkers)] + + init_walker_weight = 1 / num_walkers + init_walkers = [ + wepy.Walker( + state=walker_state, + weight=init_walker_weight, + ) + for walker_state in walker_states + ] + + # number of walkers if less then total cores, otherwise the total + # number of cores + num_cores = len(psutil.Process().cpu_affinity()) + if num_cores < num_walkers: + num_workers = num_cores + cores_per_worker = 1 + else: + num_workers = num_walkers + cores_per_worker = num_workers // num_walkers + + distance_metric = AlanineDipeptideRamachandranDistance(ala_sys.json_top) + + resampler_factory = wepy.REVOResamplerFactory( + merge_dist=4, + char_dist=0.1, + distance_metric=distance_metric, + ) + + mdj_top = mdtraj.Topology.from_openmm(ala_sys.topology) + protein_idxs = mdj_top.select("protein") + water_idxs = mdj_top.select("water") + + hdf5_reporter = wepy.WepyHDF5Reporter.from_components( + file_path=out_path, + topology=ala_sys.json_top, + resampler_class=wepy.REVOResampler, + save_fields=DEFAULT_SAVE_FIELDS + ("velocities",), + # only require these fields for the initial walkers + init_walker_save_fields=( + "positions", + "box_vectors", + ), + sparse_fields={ + "velocities": 2, + }, + main_rep_idxs=protein_idxs, + all_atoms_rep_freq=2, + alt_reps={ + "water": (water_idxs, 2), + }, + ) + + sim_manager = wepy.Manager( + init_walkers=init_walkers, + runner_factory=runner_factory, + # resampler=NoResampler(), + resampler_factory=resampler_factory, + work_mapper_factory=wepy.OpenMMProcPoolWorkMapperFactory( + platform="CPU", + num_procs=num_workers, + global_platform_properties={"Threads": str(cores_per_worker)}, + ), + reporters=[hdf5_reporter], + ) + + cycle_time = 10.0 * openmm.unit.picosecond + cycle_steps = round(cycle_time / STEP_SIZE) + + new_walkers, sim_components = sim_manager.run_simulation( + n_cycles=3, + segment_lengths=cycle_steps, + ) + + +if __name__ == "__main__": + app() diff --git a/tests/docs/.keep b/tests/docs/.keep deleted file mode 100644 index e69de29b..00000000 diff --git a/tests/docs/test_examples/test_Lennard_Jones_Pair.py b/tests/docs/test_examples/test_Lennard_Jones_Pair.py index 29a178db..ba83e81a 100644 --- a/tests/docs/test_examples/test_Lennard_Jones_Pair.py +++ b/tests/docs/test_examples/test_Lennard_Jones_Pair.py @@ -1,12 +1,9 @@ # Standard Library import os -import os.path as osp -from pathlib import Path # Third Party Library -import pytest from pytest_shutil.cmdline import chdir -from pytest_shutil.run import run, run_as_main +from pytest_shutil.run import run ### Tests diff --git a/tests/docs/test_examples/test_Lysozyme.py b/tests/docs/test_examples/test_Lysozyme.py index 166e31af..8804c285 100644 --- a/tests/docs/test_examples/test_Lysozyme.py +++ b/tests/docs/test_examples/test_Lysozyme.py @@ -1,12 +1,9 @@ # Standard Library -import os -import os.path as osp -from pathlib import Path # Third Party Library from pytest_check import check from pytest_shutil.cmdline import chdir -from pytest_shutil.run import run, run_as_main +from pytest_shutil.run import run ### Tests diff --git a/tests/docs/test_examples/test_RandomWalk.py b/tests/docs/test_examples/test_RandomWalk.py index e8b56490..b264b983 100644 --- a/tests/docs/test_examples/test_RandomWalk.py +++ b/tests/docs/test_examples/test_RandomWalk.py @@ -1,11 +1,8 @@ # Standard Library -import os -import os.path as osp -from pathlib import Path # Third Party Library from pytest_shutil.cmdline import chdir -from pytest_shutil.run import run, run_as_main +from pytest_shutil.run import run ### Tests diff --git a/tests/docs/test_pages/test_pages.py b/tests/docs/test_pages/test_pages.py index 4368e781..7aa3d46e 100644 --- a/tests/docs/test_pages/test_pages.py +++ b/tests/docs/test_pages/test_pages.py @@ -1,13 +1,11 @@ """Test the main documentation pages.""" # Standard Library -import os -import os.path as osp from pathlib import Path # Third Party Library from pytest_shutil.cmdline import chdir -from pytest_shutil.run import run, run_as_main +from pytest_shutil.run import run def test_dir_structure(datadir_factory): diff --git a/tests/docs/test_tutorials/test_Orchestrator.py b/tests/docs/test_tutorials/test_Orchestrator.py index 46778987..ff3ef3f6 100644 --- a/tests/docs/test_tutorials/test_Orchestrator.py +++ b/tests/docs/test_tutorials/test_Orchestrator.py @@ -1,11 +1,8 @@ # Standard Library -import os -import os.path as osp -from pathlib import Path # Third Party Library from pytest_shutil.cmdline import chdir -from pytest_shutil.run import run, run_as_main +from pytest_shutil.run import run ### Tests diff --git a/tests/docs/test_tutorials/test_data_analysis.py b/tests/docs/test_tutorials/test_data_analysis.py index 0793e8c2..d7092faf 100644 --- a/tests/docs/test_tutorials/test_data_analysis.py +++ b/tests/docs/test_tutorials/test_data_analysis.py @@ -1,11 +1,8 @@ # Standard Library -import os -import os.path as osp -from pathlib import Path # Third Party Library from pytest_shutil.cmdline import chdir -from pytest_shutil.run import run, run_as_main +from pytest_shutil.run import run ### Tests diff --git a/tests/docs/test_tutorials/test_extended_test_drive.py b/tests/docs/test_tutorials/test_extended_test_drive.py index a889bbe3..469a4a95 100644 --- a/tests/docs/test_tutorials/test_extended_test_drive.py +++ b/tests/docs/test_tutorials/test_extended_test_drive.py @@ -1,12 +1,9 @@ # Standard Library -import os -import os.path as osp -from pathlib import Path # Third Party Library from pytest_check import check from pytest_shutil.cmdline import chdir -from pytest_shutil.run import run, run_as_main +from pytest_shutil.run import run ### Tests diff --git a/tests/docs/test_tutorials/test_tutorials.py b/tests/docs/test_tutorials/test_tutorials.py index 03a60d4f..b06cf702 100644 --- a/tests/docs/test_tutorials/test_tutorials.py +++ b/tests/docs/test_tutorials/test_tutorials.py @@ -1,14 +1,9 @@ """Test the examples library.""" # Standard Library -import os -import os.path as osp -from pathlib import Path # Third Party Library -import delegator # the helper modules for testing -from myutils import cd ## write one test per example diff --git a/tests/integration/.keep b/tests/integration/.keep deleted file mode 100644 index e69de29b..00000000 diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py deleted file mode 100644 index 35fa4bc8..00000000 --- a/tests/integration/conftest.py +++ /dev/null @@ -1,8 +0,0 @@ -# Third Party Library -import pytest - - -# using this to get rid of the warning without having to put it in my -# config file -def pytest_configure(config): - config.addinivalue_line("markers", "interactive: tests which give you the debugger") diff --git a/tests/integration/test_cli.py b/tests/integration/test_cli.py deleted file mode 100644 index 5b3c043d..00000000 --- a/tests/integration/test_cli.py +++ /dev/null @@ -1,214 +0,0 @@ -# Standard Library -import os -import os.path as osp -import pdb - -# Third Party Library -import pytest -from click.testing import CliRunner - -# First Party Library -from wepy.orchestration.cli import cli as wepy_cli -from wepy.orchestration.orchestrator import Orchestrator - -lj_fixtures = [ - "lj_orchestrator_defaults_file", - "lj_orch_file_orchestrated_run", - "lj_orchestrator_defaults_file_other", - "lj_orch_file_other_orchestrated_run", -] - - -@pytest.mark.interactive -def test_orch_workdir(lj_orchestrator_defaults_file): - pdb.set_trace() - - -@pytest.mark.usefixtures(*lj_fixtures) -class TestCLI: - def test_ls_runs(self, lj_orch_file_orchestrated_run): - orch_path = lj_orch_file_orchestrated_run.orch_path - - runner = CliRunner() - - result = runner.invoke(wepy_cli, ["ls", "runs", orch_path]) - - assert result.exit_code == 0 - - def test_ls_snapshots(self, lj_orch_file_orchestrated_run): - orch_path = lj_orch_file_orchestrated_run.orch_path - - runner = CliRunner() - - result = runner.invoke(wepy_cli, ["ls", "snapshots", orch_path]) - - assert result.exit_code == 0 - - def test_ls_configs(self, lj_orch_file_orchestrated_run): - orch_path = lj_orch_file_orchestrated_run.orch_path - - runner = CliRunner() - - result = runner.invoke(wepy_cli, ["ls", "configs", orch_path]) - - assert result.exit_code == 0 - - def test_run_orch(self, function_tmp_path_factory, lj_orchestrator_defaults_file): - workdir = str(function_tmp_path_factory.mktemp("test_run")) - - n_steps = str(100) - n_seconds = str(5) - - orch_path = lj_orchestrator_defaults_file.orch_path - start_hash = lj_orchestrator_defaults_file.get_default_snapshot_hash() - - runner = CliRunner() - - result = runner.invoke( - wepy_cli, - [ - "run", - "orch", - "--job-dir", - workdir, - orch_path, - start_hash, - n_seconds, - n_steps, - ], - catch_exceptions=False, - ) - - assert result.exit_code == 0 - - def test_run_snapshot( - self, function_tmp_path_factory, lj_snapshot, lj_configuration - ): - workdir = str(function_tmp_path_factory.mktemp("test_run")) - - # write the snapshot to the file system - serial_snap = Orchestrator.serialize(lj_snapshot) - snap_path = osp.join(workdir, "snapshot.snap.dill.pkl") - with open(snap_path, "wb") as wf: - wf.write(serial_snap) - - # write the configuration to the file system - serial_config = Orchestrator.serialize(lj_configuration) - config_path = osp.join(workdir, "config.config.dill.pkl") - with open(config_path, "wb") as wf: - wf.write(serial_config) - - n_steps = str(100) - n_seconds = str(5) - - runner = CliRunner() - - result = runner.invoke( - wepy_cli, - [ - "run", - "snapshot", - "--job-dir", - workdir, - snap_path, - config_path, - n_seconds, - n_steps, - ], - catch_exceptions=False, - ) - - assert result.exit_code == 0 - - def test_get_snapshot( - self, function_tmp_path_factory, lj_orchestrator_defaults_file - ): - workdir = str(function_tmp_path_factory.mktemp("test_run")) - - orch_path = lj_orchestrator_defaults_file.orch_path - start_hash = lj_orchestrator_defaults_file.get_default_snapshot_hash() - - os.chdir(workdir) - - runner = CliRunner() - - result = runner.invoke( - wepy_cli, - ["get", "snapshot", orch_path, start_hash], - catch_exceptions=False, - ) - - assert result.exit_code == 0 - assert osp.exists("{}.snap.dill.pkl".format(start_hash)) - - def test_get_config(self, function_tmp_path_factory, lj_orchestrator_defaults_file): - workdir = str(function_tmp_path_factory.mktemp("test_run")) - - orch_path = lj_orchestrator_defaults_file.orch_path - config_hash = lj_orchestrator_defaults_file.get_default_configuration_hash() - - os.chdir(workdir) - - runner = CliRunner() - - result = runner.invoke( - wepy_cli, - ["get", "config", orch_path, config_hash], - catch_exceptions=False, - ) - - assert result.exit_code == 0 - assert osp.exists("{}.config.dill.pkl".format(config_hash)) - - def test_get_run(self, function_tmp_path_factory, lj_orch_file_orchestrated_run): - workdir = str(function_tmp_path_factory.mktemp("test_run")) - - orch_path = lj_orch_file_orchestrated_run.orch_path - start_hash, end_hash = lj_orch_file_orchestrated_run.run_hashes()[0] - - os.chdir(workdir) - - runner = CliRunner() - - result = runner.invoke( - wepy_cli, - ["get", "run", orch_path, start_hash, end_hash], - catch_exceptions=False, - ) - - assert result.exit_code == 0 - assert osp.exists("{}-{}.orch.sqlite".format(start_hash, end_hash)) - - def test_reconcile( - self, - function_tmp_path_factory, - lj_orch_file_orchestrated_run, - lj_orch_file_other_orchestrated_run, - ): - savedir = function_tmp_path_factory.mktemp("reconciliation") - h5_target = str(savedir / "reconciled.wepy.h5") - orch_target = str(savedir / "reconciled.orch.sqlite") - - orch_path = lj_orch_file_orchestrated_run.orch_path - other_orch_path = lj_orch_file_other_orchestrated_run.orch_path - - runner = CliRunner() - - result = runner.invoke( - wepy_cli, - [ - "reconcile", - "orch", - "--hdf5", - h5_target, - orch_target, - orch_path, - other_orch_path, - ], - catch_exceptions=False, - ) - - assert result.exit_code == 0 - - def test_copy_h5(self, lj_orch_file_orchestrated_run): - assert False diff --git a/tests/integration/test_lj_combinations.py b/tests/integration/test_lj_combinations.py deleted file mode 100644 index d0ec7e79..00000000 --- a/tests/integration/test_lj_combinations.py +++ /dev/null @@ -1,183 +0,0 @@ -# Standard Library -import logging - -logger = logging.getLogger(__name__) -# Standard Library -import multiprocessing as mp -import time -from copy import deepcopy - -# Third Party Library -import pytest - -# First Party Library -from wepy.resampling.resamplers.resampler import NoResampler -from wepy.runners.openmm import ( - OpenMMCPUWalkerTaskProcess, - OpenMMCPUWorker, - OpenMMGPUWalkerTaskProcess, - OpenMMGPUWorker, - OpenMMRunner, - OpenMMState, - OpenMMWalker, -) -from wepy.sim_manager import Manager -from wepy.walker import Walker, WalkerState -from wepy.work_mapper.mapper import Mapper, TaskException -from wepy.work_mapper.task_mapper import ( - TaskMapper, - TaskProcessException, - WalkerTaskProcess, -) -from wepy.work_mapper.worker import Worker, WorkerException, WorkerMapper -from wepy_tools.sim_makers.openmm.lennard_jones import LennardJonesPairOpenMMSimMaker -from wepy_tools.sim_makers.openmm.lysozyme import LysozymeImplicitOpenMMSimMaker - - -def get_sim_maker(spec): - if spec == "LennardJonesPair": - sim_maker = LennardJonesPairOpenMMSimMaker() - elif spec == "LysozymeImplicit": - sim_maker = LysozymeImplicitOpenMMSimMaker() - else: - raise ValueError("Unknown system spec: {}".format(spec)) - - return sim_maker - - -# testing on a node in HPCC means we will have 8 GPUS -BIG_NODE_N_WORKERS = 8 -DEV_NODE_N_WORKERS = 1 - -# number of walkers in multiples of 8 since that is how many GPUs we -# have -BIG_NODE_N_WALKER_TESTS = [i * BIG_NODE_N_WORKERS for i in (1, 2, 4, 8)] -DEV_NODE_N_WALKER_TESTS = [i * DEV_NODE_N_WORKERS for i in (5, 10, 20)] -DEV_NODE_N_WALKER_TESTS = [i for i in (10,)] - -# 1 ps, 5 ps, 10 ps, #20 ps -N_STEPS_TEST = [10, 50, 100, 200] # [1000, 5000, 10000] -N_CYCLES_TEST = [1, 10, 100] -SYSTEMS_TEST = [ - "LennardJonesPair", -] # 'LysozymeImplicit',] -PLATFORMS_TEST = ["OpenCL"] -RESAMPLERS_TEST = ["NoResampler", "REVOResampler", "WExploreResampler"] -WORK_MAPPERS_TEST = ["WorkerMapper", "TaskMapper"] - - -class TestCombinationsMinorNode: - @pytest.mark.parametrize( - "n_walkers", - [ - 5, - ], - ) - @pytest.mark.parametrize( - "n_cycles", - [ - 3, - ], - ) - @pytest.mark.parametrize( - "n_steps", - [ - 10, - ], - ) - @pytest.mark.parametrize( - "platform", - [ - "CPU", - ], - ) - @pytest.mark.parametrize("system", SYSTEMS_TEST) - @pytest.mark.parametrize("resampler", RESAMPLERS_TEST) - @pytest.mark.parametrize("work_mapper", WORK_MAPPERS_TEST) - def test_combinations( - self, n_walkers, n_cycles, n_steps, platform, system, resampler, work_mapper - ): - sim_maker = get_sim_maker(system) - - apparatus = sim_maker.make_apparatus(platform=platform, resampler=resampler) - - config = sim_maker.make_configuration( - apparatus, work_mapper_spec=work_mapper, platform=platform, reporters=None - ) - - sim_manager = sim_maker.make_sim_manager(n_walkers, apparatus, config) - - result = sim_manager.run_simulation( - n_cycles, n_steps, num_workers=DEV_NODE_N_WORKERS - ) - - -class TestCombinationsDevNode: - @pytest.mark.parametrize( - "n_walkers", - [ - 5, - ], - ) - @pytest.mark.parametrize( - "n_cycles", - [ - 3, - ], - ) - @pytest.mark.parametrize( - "n_steps", - [ - 10, - ], - ) - @pytest.mark.parametrize( - "platform", - [ - "OpenCL", - ], - ) - @pytest.mark.parametrize("system", SYSTEMS_TEST) - @pytest.mark.parametrize("resampler", RESAMPLERS_TEST) - @pytest.mark.parametrize("work_mapper", WORK_MAPPERS_TEST) - def test_combinations( - self, n_walkers, n_cycles, n_steps, platform, system, resampler, work_mapper - ): - sim_maker = get_sim_maker(system) - - apparatus = sim_maker.make_apparatus(platform=platform, resampler=resampler) - - config = sim_maker.make_configuration( - apparatus, work_mapper_spec="TaskMapper", platform=platform, reporters=None - ) - - sim_manager = sim_maker.make_sim_manager(n_walkers, apparatus, config) - - result = sim_manager.run_simulation( - n_cycles, n_steps, num_workers=DEV_NODE_N_WORKERS - ) - - -class TestCombinationsBigNode: - @pytest.mark.parametrize("n_walkers", BIG_NODE_N_WALKER_TESTS) - @pytest.mark.parametrize("n_cycles", N_CYCLES_TEST) - @pytest.mark.parametrize("n_steps", N_STEPS_TEST) - @pytest.mark.parametrize("platform", PLATFORMS_TEST) - @pytest.mark.parametrize("system", SYSTEMS_TEST) - @pytest.mark.parametrize("resampler", RESAMPLERS_TEST) - def test_combinations( - self, n_walkers, n_cycles, n_steps, platform, system, resampler - ): - sim_maker = get_sim_maker(system) - - apparatus = sim_maker.make_apparatus(platform=platform, resampler=resampler) - - config = sim_maker.make_configuration( - work_mapper_spec="TaskMapper", platform=platform - ) - - sim_manager = sim_maker.make_sim_manager(n_walkers, apparatus, config) - - result = sim_manager.run_simulation( - n_cycles, n_steps, num_workers=BIG_NODE_N_WORKERS - ) diff --git a/tests/integration/test_lj_fixture.py b/tests/integration/test_lj_fixture.py deleted file mode 100644 index 0664e993..00000000 --- a/tests/integration/test_lj_fixture.py +++ /dev/null @@ -1,344 +0,0 @@ -# Standard Library -import logging - -logger = logging.getLogger(__name__) -# Standard Library -import multiprocessing as mp -import pdb - -# Third Party Library -import pytest - -# testing helpers -from multiprocessing_logging import install_mp_handler - -# First Party Library -from wepy.sim_manager import Manager - -# we define a fixture for a fixture for all the components of a -# simulation of the openmmtools Lennard-Jones pair. We test the -# fixtures by requiring them one by one. - -# the fixtures are class scoped so we make a class for this - -lj_fixtures = [ - "lj_params", - "lj_omm_sys", - "lj_integrator", - "lj_init_sim_state", - "lj_init_state", - "lj_openmm_runner", - "lj_distance_metric", - "lj_resampler", - "lj_topology", - "lj_boundary_condition", - "lj_reporter_kwargs", - "lj_reporter_classes", - "lj_init_walkers", - "lj_apparatus", - "lj_snapshot", - "lj_configuration", - "lj_work_mapper", - "lj_reporters", - "lj_orchestrator", - "lj_orchestrator_defaults", - "lj_orchestrator_file", - "lj_orchestrator_file_other", - "lj_orchestrator_defaults_file", - "lj_sim_manager", - "lj_sim_manager_run_results", - "lj_orch_run_by_time_results", - "lj_orch_run_end_snapshot", - "lj_orch_orchestrated_run", - "lj_orch_file_orchestrated_run", - "lj_orch_file_other_orchestrated_run", - "lj_orch_reconciled_orchs", - "lj_sim_manager_null_run_results", -] - - -@pytest.mark.interactive -def test_init_state(lj_init_state): - pdb.set_trace() - pass - - -@pytest.mark.usefixtures(*lj_fixtures) -class TestLJPairNewOrch: - # just an empty thing to get the fixtures made and catch errors - # there - def test_fixtures(self): - pass - - @pytest.mark.interactive - def test_orch_interactive(self, lj_orchestrator_defaults): - pdb.set_trace() - - pass - - @pytest.mark.interactive - def test_reconciled_orch(self, lj_orch_reconciled_orchs): - host_orch, other_orch, reconciled_orch = lj_orch_reconciled_orchs - pdb.set_trace() - - pass - - -@pytest.mark.usefixtures( - "lj_reporters", - "lj_init_walkers", - "lj_openmm_runner", - "lj_unbinding_bc", - "lj_wexplore_resampler", - "lj_revo_resampler", - "lj_work_mapper", - "lj_work_mapper_worker", - "lj_work_mapper_task", -) -class TestLJSimIntegration: - # TODO: add revo back in after all combinations with WExplore are passing - # @pytest.mark.parametrize('resampler_class', ['WExploreResampler', 'REVOResampler',]) - - # NOTE: CUDA has issues but OpenCL tests the code path that we - # need so we will just use it here - - # order matters here for the platforms and the work mapper classes - # since there is issues with that and typically aren't being all - # used in the same place like we do here. Basically do the - # 'Mapper' last since it doesn't use it's own multiprocessing - # context. - @pytest.mark.parametrize( - "boundary_condition_class", - [ - "UnbindingBC", - ], - ) - @pytest.mark.parametrize( - "resampler_class", - [ - "WExploreResampler", - ], - ) - @pytest.mark.parametrize( - "platform", - [ - "Reference", - "CPU", - "OpenCL", - ], - ) # 'CUDA' - @pytest.mark.parametrize( - "work_mapper_class", - [ - "WorkerMapper", - "TaskMapper", - "Mapper", - ], - ) - def test_lj_sim_manager_openmm_integration_run( - self, - class_tmp_path_factory, - boundary_condition_class, - resampler_class, - work_mapper_class, - platform, - lj_params, - lj_omm_sys, - lj_integrator, - lj_reporter_classes, - lj_reporter_kwargs, - lj_init_walkers, - lj_openmm_runner, - lj_unbinding_bc, - lj_wexplore_resampler, - lj_revo_resampler, - ): - """Run all combinations of components in the fixtures for the smallest - amount of time, just to make sure they all work together and don't give errors. - """ - - logger = logging.getLogger("testing").setLevel(logging.DEBUG) - install_mp_handler() - logger.debug("Starting the test") - - print("starting the test") - - # the configuration class gives us a convenient way to - # parametrize our reporters for the locale - # First Party Library - from wepy.orchestration.configuration import Configuration - - # the runner - from wepy.runners.openmm import ( - OpenMMCPUWalkerTaskProcess, - OpenMMCPUWorker, - OpenMMGPUWalkerTaskProcess, - OpenMMGPUWorker, - OpenMMRunner, - ) - - # mappers - from wepy.work_mapper.mapper import Mapper - - # the walker task types for the TaskMapper - from wepy.work_mapper.task_mapper import TaskMapper, WalkerTaskProcess - - # the worker types for the WorkerMapper - from wepy.work_mapper.worker import Worker, WorkerMapper - - n_cycles = 1 - n_steps = 2 - num_workers = 2 - - # generate the reporters and temporary directory for this test - # combination - - tmpdir_template = "lj_fixture_{plat}-{wm}-{res}-{bc}" - tmpdir_name = tmpdir_template.format( - plat=platform, - wm=work_mapper_class, - res=resampler_class, - bc=boundary_condition_class, - ) - - # make a temporary directory for this configuration to work with - tmpdir = str(class_tmp_path_factory.mktemp(tmpdir_name)) - - # make a config so that the reporters get parametrized properly - reporters = Configuration( - work_dir=tmpdir, - reporter_classes=lj_reporter_classes, - reporter_partial_kwargs=lj_reporter_kwargs, - ).reporters - - steps = [n_steps for _ in range(n_cycles)] - - # choose the components based on the parametrization - boundary_condition = None - resampler = None - - walker_fixtures = [lj_init_walkers] - runner_fixtures = [lj_openmm_runner] - boundary_condition_fixtures = [lj_unbinding_bc] - resampler_fixtures = [lj_wexplore_resampler, lj_revo_resampler] - - walkers = lj_init_walkers - - boundary_condition = [ - boundary_condition - for boundary_condition in boundary_condition_fixtures - if type(boundary_condition).__name__ == boundary_condition_class - ][0] - resampler = [ - resampler - for resampler in resampler_fixtures - if type(resampler).__name__ == resampler_class - ][0] - - assert boundary_condition is not None - assert resampler is not None - - # generate the work mapper given the type and the platform - - work_mapper_classes = { - mapper_class.__name__: mapper_class - for mapper_class in [Mapper, WorkerMapper, TaskMapper] - } - - # # select the right one given the option - # work_mapper_type = [mapper_type for mapper_type in work_mapper_classes - # if type(mapper_type).__name__ == work_mapper_class][0] - - # decide based on the platform and the work mapper which - # platform dependent components to build - if work_mapper_class == "Mapper": - # then there is no settings - work_mapper = Mapper() - - elif work_mapper_class == "WorkerMapper": - if platform == "CUDA" or platform == "OpenCL": - work_mapper = WorkerMapper( - num_workers=num_workers, - worker_type=OpenMMGPUWorker, - device_ids={"0": 0, "1": 1}, - proc_start_method="spawn", - ) - if platform == "OpenCL": - work_mapper = WorkerMapper( - num_workers=num_workers, - worker_type=OpenMMGPUWorker, - device_ids={"0": 0, "1": 1}, - ) - - elif platform == "CPU": - work_mapper = WorkerMapper( - num_workers=num_workers, - worker_type=OpenMMCPUWorker, - worker_attributes={"num_threads": 1}, - ) - - elif platform == "Reference": - work_mapper = WorkerMapper( - num_workers=num_workers, - worker_type=Worker, - ) - - elif work_mapper_class == "TaskMapper": - if platform == "CUDA": - work_mapper = TaskMapper( - num_workers=num_workers, - walker_task_type=OpenMMGPUWalkerTaskProcess, - device_ids={"0": 0, "1": 1}, - proc_start_method="spawn", - ) - - elif platform == "OpenCL": - work_mapper = TaskMapper( - num_workers=num_workers, - walker_task_type=OpenMMGPUWalkerTaskProcess, - device_ids={"0": 0, "1": 1}, - ) - - elif platform == "CPU": - work_mapper = TaskMapper( - num_workers=num_workers, - walker_task_type=OpenMMCPUWalkerTaskProcess, - worker_attributes={"num_threads": 1}, - ) - - elif platform == "Reference": - work_mapper = TaskMapper( - num_workers=num_workers, - worker_type=WalkerTaskProcess, - ) - - else: - raise ValueError("Platform {} not recognized".format(platform)) - - # initialize the runner with the platform - runner = OpenMMRunner( - lj_omm_sys.system, lj_omm_sys.topology, lj_integrator, platform=platform - ) - - logger.debug("Constructing the manager") - - manager = Manager( - walkers, - runner=runner, - boundary_conditions=boundary_condition, - resampler=resampler, - worker_mapper=work_mapper, - reporters=reporters, - ) - - # since different work mappers need different process start - # methods for different platforms i.e. CUDA and linux fork - # vs. spawn we choose the appropriate one for each method. - - logger.debug("Starting the simulation") - - walkers, filters = manager.run_simulation( - n_cycles, steps, num_workers=num_workers - ) - - # no assert if it runs we are happy for now diff --git a/tests/integration/test_openmm/test_realistic.py b/tests/integration/test_openmm/test_realistic.py new file mode 100644 index 00000000..ea418783 --- /dev/null +++ b/tests/integration/test_openmm/test_realistic.py @@ -0,0 +1,281 @@ +"""Tests for a realistic end to end use case. + +OpenMM Runner, REVO and WExplore resamplers, paralell work mappers. + +Configurable platforms. + +""" + +# Standard Library +import copy +import logging + +# Third Party Library +import mdtraj +import openmm +import psutil +import pytest + +# First Party Library +import wepy +from wepy.runners.openmm.runner import ( + _DEFAULT_HEARTBEAT_INTERVAL, + _DEFAULT_STATE_TIME_INTERVAL, +) +from wepy_tools.systems.alanine_dipeptide import ( + AlanineDipeptideExplicitSystem, + AlanineDipeptideRamachandranDistance, +) +from wepy_tools.systems.lennard_jones import LennardJonesPair, PairDistance + +_LOGGER = logging.getLogger("tests") + +STEP_SIZE = 2.0 * openmm.unit.femtosecond +TEMPERATURE = 300.0 * openmm.unit.kelvin + +# minimum number of steps to hit the logging reporters, useful just +# for testing the defaults +TIME_INTERVAL_STEPS = round(_DEFAULT_STATE_TIME_INTERVAL / STEP_SIZE) +MIN_INTERVAL_STEPS = ( + TIME_INTERVAL_STEPS + if TIME_INTERVAL_STEPS > _DEFAULT_HEARTBEAT_INTERVAL + else _DEFAULT_HEARTBEAT_INTERVAL +) + +DEFAULT_CYCLE_TIME = 10.0 * openmm.unit.picosecond +DEFAULT_CYCLE_STEPS = round(DEFAULT_CYCLE_TIME / STEP_SIZE) + +DEFAULT_SAVE_FIELDS = ( + "positions", + "box_vectors", + "box_volume", + "potential_energy", + "kinetic_energy", +) + + +def test_lennard_jones_revo_procpool(tmp_path_factory): + + outputs_dir = tmp_path_factory.mktemp("outputs") + + test_sys = LennardJonesPair() + + integrator = openmm.LangevinIntegrator(TEMPERATURE, 0.1, STEP_SIZE) + + runner_factory = wepy.OpenMMRunnerFactory( + system=test_sys.system, + topology=test_sys.topology, + integrator=integrator, + ) + + # num_walkers = 48 + num_walkers = 4 + + init_state = wepy.OpenMMState.from_dwim( + positions=test_sys.positions, + ) + + # TODO: remove the need to deepcopy and have the components make + # their own copies if necessary + walker_states = [copy.deepcopy(init_state) for _ in range(num_walkers)] + + init_walker_weight = 1 / num_walkers + init_walkers = [ + wepy.Walker( + state=walker_state, + weight=init_walker_weight, + ) + for walker_state in walker_states + ] + + # number of walkers if less then total cores, otherwise the total + # number of cores + num_cores = len(psutil.Process().cpu_affinity()) + if num_cores < num_walkers: + num_workers = num_cores + cores_per_worker = 1 + else: + num_workers = num_walkers + cores_per_worker = num_workers // num_walkers + + distance_metric = PairDistance() + + resampler_factory = wepy.REVOResamplerFactory( + merge_dist=4, + char_dist=0.1, + distance_metric=distance_metric, + ) + + dashboard_path = outputs_dir / "main.wepy_dash.org" + dashboard_reporter = wepy.DashboardReporter(dashboard_path) + + hdf5_path = outputs_dir / "main.wepy.h5" + hdf5_reporter = wepy.WepyHDF5Reporter.from_components( + file_path=hdf5_path, + topology=test_sys.json_top, + resampler_class=wepy.REVOResamplerFactory.type(), + save_fields=DEFAULT_SAVE_FIELDS + ("velocities",), + # only require these fields for the initial walkers + init_walker_save_fields=("positions",), + sparse_fields={ + "velocities": 2, + }, + ) + + reporters = [dashboard_reporter, hdf5_reporter] + + sim_manager = wepy.Manager( + init_walkers=init_walkers, + runner_factory=runner_factory, + resampler_factory=resampler_factory, + # resampler_factory=NoResampler, + work_mapper_factory=wepy.OpenMMProcPoolWorkMapperFactory( + platform="CPU", + num_procs=num_workers, + global_platform_properties={"Threads": str(cores_per_worker)}, + ), + reporters=reporters, + ) + + # new_walkers, sim_components = sim_manager.run_simulation( + # n_cycles=2, + # segment_lengths=10, + # ) + new_walkers, sim_components = sim_manager.run_simulation( + n_cycles=2, + segment_lengths=DEFAULT_CYCLE_STEPS, + ) + + assert dashboard_path.exists() + _LOGGER.info("\n" + dashboard_path.read_text()) + assert hdf5_path.exists() + # TODO: add some tests for HDF5 data + + +# disable timeout for this one +@pytest.mark.timeout(timeout=0) +def test_alanine_dipeptide_revo_procpool(tmp_path_factory): + + outputs_dir = tmp_path_factory.mktemp("outputs") + + ala_sys = AlanineDipeptideExplicitSystem() + + integrator = openmm.LangevinIntegrator(TEMPERATURE, 0.1, STEP_SIZE) + + # add the pseudo forces like barostat + barostat = openmm.MonteCarloBarostat( + 1.0 * openmm.unit.atmosphere, + TEMPERATURE, + ) + ala_sys.system.addForce(barostat) + + runner_factory = wepy.OpenMMRunnerFactory( + system=ala_sys.system, + topology=ala_sys.topology, + integrator=integrator, + ) + + num_walkers = 10 + + # TODO: remove the need to deepcopy and have the components make + # their own copies if necessary + walker_states = [copy.deepcopy(ala_sys.state) for _ in range(num_walkers)] + + init_walker_weight = 1 / num_walkers + init_walkers = [ + wepy.Walker( + state=walker_state, + weight=init_walker_weight, + ) + for walker_state in walker_states + ] + + # number of walkers if less then total cores, otherwise the total + # number of cores + num_cores = len(psutil.Process().cpu_affinity()) + if num_cores < num_walkers: + num_workers = num_cores + cores_per_worker = 1 + else: + num_workers = num_walkers + cores_per_worker = num_workers // num_walkers + + distance_metric = AlanineDipeptideRamachandranDistance(ala_sys.json_top) + + resampler_factory = wepy.REVOResamplerFactory( + merge_dist=4, + char_dist=0.1, + distance_metric=distance_metric, + ) + + # TODO: + # openmm_dashboard_section = wepy.OpenMMRunnerDashboardSection(runner_factory) + dashboard_path = outputs_dir / "main.wepy_dash.org" + dashboard_reporter = wepy.DashboardReporter(dashboard_path) + + mdj_top = mdtraj.Topology.from_openmm(ala_sys.topology) + protein_idxs = mdj_top.select("protein") + water_idxs = mdj_top.select("water") + + hdf5_path = outputs_dir / "main.wepy.h5" + hdf5_reporter = wepy.WepyHDF5Reporter.from_components( + file_path=hdf5_path, + topology=ala_sys.json_top, + resampler_class=wepy.REVOResampler, + save_fields=DEFAULT_SAVE_FIELDS + ("velocities",), + # only require these fields for the initial walkers + init_walker_save_fields=( + "positions", + "box_vectors", + ), + sparse_fields={ + "velocities": 2, + }, + main_rep_idxs=protein_idxs, + all_atoms_rep_freq=2, + alt_reps={ + "water": (water_idxs, 2), + }, + ) + + reporters = [dashboard_reporter, hdf5_reporter] + + sim_manager = wepy.Manager( + init_walkers=init_walkers, + runner_factory=runner_factory, + # resampler=NoResampler(), + resampler_factory=resampler_factory, + work_mapper_factory=wepy.OpenMMProcPoolWorkMapperFactory( + platform="CPU", + num_procs=num_workers, + # NOTE,TOREV: in practice not limiting this is just faster + # and gets better utilization. But for tests we don't want + # it to eat up all the CPU so we limit it and take + # longer. In CI we probably want it to use everything + # though so review this later. This is only true when + # there are very few walkers though and with more CPU + # utilization goes way up + global_platform_properties={"Threads": str(cores_per_worker)}, + ), + reporters=reporters, + ) + + # new_walkers, sim_components = sim_manager.run_simulation( + # n_cycles=2, + # segment_lengths=MIN_INTERVAL_STEPS * 2 + 10, + # ) + + # short number of steps but many cycles to exercise the pools + # new_walkers, sim_components = sim_manager.run_simulation( + # n_cycles=100, + # segment_lengths=10, + # ) + + new_walkers, sim_components = sim_manager.run_simulation( + n_cycles=3, + segment_lengths=DEFAULT_CYCLE_STEPS, + ) + + assert dashboard_path.exists() + _LOGGER.info("\n" + dashboard_path.read_text()) + assert hdf5_path.exists() diff --git a/tests/integration/test_openmm/test_sim_manager.py b/tests/integration/test_openmm/test_sim_manager.py new file mode 100644 index 00000000..a50a446a --- /dev/null +++ b/tests/integration/test_openmm/test_sim_manager.py @@ -0,0 +1,254 @@ +# Standard Library + +# Third Party Library +import openmm +import openmm.unit +import psutil + +# First Party Library +from wepy.resampling.resamplers.noresampler import NoResampler +from wepy.runners.openmm import ( + HeartBeatLoggingReporterFactory, + OpenMMRunnerFactory, + OpenMMState, +) +from wepy.sim_manager import Manager +from wepy.walker import Walker +from wepy.work_mapper.openmm import ( + OpenMMProcPoolWorkMapperFactory, + OpenMMSerialWorkMapperFactory, +) +from wepy_tools.systems.lennard_jones import LennardJonesPair + +STEP_SIZE = 2 * openmm.unit.femtosecond + + +def test_serial_mapper(): + + lj_sys = LennardJonesPair() + integrator = openmm.LangevinIntegrator( + 300.0, + 0.1, + STEP_SIZE, + ) + + runner_factory = OpenMMRunnerFactory( + system=lj_sys.system, + topology=lj_sys.topology, + integrator=integrator, + # specialized reporters for testing + openmm_reporter_factories=[ + # heart beat every step + HeartBeatLoggingReporterFactory(step_interval=1) + ], + ) + + num_walkers = 4 + + walker_states = [ + OpenMMState.from_dwim( + positions=lj_sys.positions, + ) + for _ in range(num_walkers) + ] + + init_walker_weight = 1 / num_walkers + init_walkers = [ + Walker( + state=walker_state, + weight=init_walker_weight, + ) + for walker_state in walker_states + ] + + sim_manager = Manager( + init_walkers=init_walkers, + runner_factory=runner_factory, + resampler_factory=NoResampler, + work_mapper_factory=OpenMMSerialWorkMapperFactory( + platform="Reference", + ), + ) + + new_walkers, sim_components = sim_manager.run_simulation( + n_cycles=1, + segment_lengths=100, + ) + + sim_manager = Manager( + init_walkers=init_walkers, + runner_factory=runner_factory, + resampler_factory=NoResampler, + work_mapper_factory=OpenMMSerialWorkMapperFactory( + platform="CPU", + global_platform_properties={"Threads": "1"}, + ), + ) + + new_walkers, sim_components = sim_manager.run_simulation( + n_cycles=1, + segment_lengths=100, + ) + + sim_manager = Manager( + init_walkers=init_walkers, + runner_factory=runner_factory, + resampler_factory=NoResampler, + work_mapper_factory=OpenMMSerialWorkMapperFactory( + platform="CPU", + global_platform_properties={"Threads": "4"}, + ), + ) + + new_walkers, sim_components = sim_manager.run_simulation( + n_cycles=1, + segment_lengths=100, + ) + + +def test_proc_pool_mapper(): + + lj_sys = LennardJonesPair() + integrator = openmm.LangevinIntegrator( + 300.0, + 0.1, + STEP_SIZE, + ) + + runner_factory = OpenMMRunnerFactory( + system=lj_sys.system, + topology=lj_sys.topology, + integrator=integrator, + openmm_reporter_factories=[ + # heart beat every step + HeartBeatLoggingReporterFactory(step_interval=10) + ], + ) + + num_walkers = 4 + + walker_states = [ + OpenMMState.from_dwim( + positions=lj_sys.positions, + ) + for _ in range(num_walkers) + ] + + init_walker_weight = 1 / num_walkers + init_walkers = [ + Walker( + state=walker_state, + weight=init_walker_weight, + ) + for walker_state in walker_states + ] + + # As an example of a useful configuration for Reference + # platform. There are 4 walkers in each cycle so that is the max + # number of processes that should be used. + sim_manager = Manager( + init_walkers=init_walkers, + runner_factory=runner_factory, + resampler_factory=NoResampler, + work_mapper_factory=OpenMMProcPoolWorkMapperFactory( + platform="Reference", + num_procs=4, + ), + ) + + new_walkers, sim_components = sim_manager.run_simulation( + n_cycles=1, + segment_lengths=100, + ) + + # As an example of a useful configuration. There are 4 walkers in + # each cycle so that is the max number of processes that should be + # used, however for each worker we can assign more threads based + # on how many CPUs you have. Here we use psutil to reliably get + # the number of cores and divide that by the number of walkers. + num_cores = len(psutil.Process().cpu_affinity()) + cores_per_worker = num_cores // num_walkers + sim_manager = Manager( + init_walkers=init_walkers, + runner_factory=runner_factory, + resampler_factory=NoResampler, + work_mapper_factory=OpenMMProcPoolWorkMapperFactory( + platform="CPU", + num_procs=len(walker_states), + global_platform_properties={"Threads": str(cores_per_worker)}, + ), + ) + + new_walkers, sim_components = sim_manager.run_simulation( + n_cycles=2, + segment_lengths=100, + ) + + # Just as way of example of how GPU device specific arguments + # would work, we assign 1 thread as global but then override that + # for one specific worker. Note that you need to explicitly + # enumerate the device IDs then. + sim_manager = Manager( + init_walkers=init_walkers, + runner_factory=runner_factory, + resampler_factory=NoResampler, + work_mapper_factory=OpenMMProcPoolWorkMapperFactory( + platform="CPU", + num_procs=len(walker_states), + global_platform_properties={"Threads": "1"}, + device_ids=[0, 1, 2, 3], + device_platform_properties=[{}, {}, {}, {"Threads": "3"}], + ), + ) + + new_walkers, sim_components = sim_manager.run_simulation( + n_cycles=2, + segment_lengths=100, + ) + + +# def test_ray_mapper(): +# lj_sys = LennardJonesPair() +# integrator = openmm.LangevinIntegrator(300.0, 0.002, 0.1) + + +# runner_factory = OpenMMRunnerFactory( +# system=lj_sys.system, +# topology=lj_sys.topology, +# integrator=integrator, +# ) + +# num_walkers = 4 + +# walker_states = [ +# OpenMMState.from_dwim( +# positions=lj_sys.positions, +# ) +# for _ +# in range(num_walkers) +# ] + +# init_walker_weight = 1 / num_walkers +# init_walkers = [ +# Walker( +# state=walker_state, +# weight=init_walker_weight, +# ) +# for walker_state +# in walker_states +# ] + +# sim_manager = Manager( +# init_walkers=init_walkers, +# runner_factory=runner_factory, +# resampler_factory=NoResampler, +# work_mapper_factory=OpenMMRayWorkMapperFactory( +# platform="Reference", +# num_procs=1, +# ), +# ) + +# new_walkers, sim_components = sim_manager.run_simulation( +# n_cycles=2, +# segment_lengths=10, +# ) diff --git a/tests/unit/.keep b/tests/unit/.keep deleted file mode 100644 index e69de29b..00000000 diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py new file mode 100644 index 00000000..77bcfd51 --- /dev/null +++ b/tests/unit/conftest.py @@ -0,0 +1,832 @@ +# Standard Library +import shutil +import subprocess +import sys +from pathlib import Path +from typing import Callable + +# Third Party Library +import numpy as np +import pytest + +# First Party Library +from wepy.hdf5 import WepyHDF5 +from wepy.resampling.decisions.no_decision import NoDecision +from wepy.resampling.resamplers.noresampler import ( + NoResampler, + NoResamplerResamplingRecord, +) +from wepy.typing import IdxArray +from wepy.walker import Walker, WalkerStateBox +from wepy_tools.systems.lennard_jones import LennardJonesPair + + +def reflink_or_copy(src: Path, dst: Path) -> None: + """Create a copy-on-write reflink if supported. + Fall back to a full copy otherwise. + """ + try: + if sys.platform.startswith("linux"): + subprocess.run( + ["cp", "--reflink=auto", src, dst], + check=True, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + elif sys.platform == "darwin": + subprocess.run( + ["cp", "-c", src, dst], # APFS clone + check=True, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + else: + raise RuntimeError("No reflink support") + except Exception: + shutil.copy2(src, dst) + + +DATA_DIR = Path(__file__).parent.parent / "data" + + +@pytest.fixture(scope="function") +def alanine_dipeptide_revo_wepy_hdf5(tmp_path: Path) -> Path: + + src = DATA_DIR / "alanine_dipeptide_revo.wepy.hdf5" + dst = tmp_path / "data.wepy.hdf5" + + reflink_or_copy(src, dst) + return dst + + +@pytest.fixture(scope="session") +def wepy_h5_factory() -> Callable[[Path], Path]: + + test_sys = LennardJonesPair() + + def _factory( + path: Path, + sparse_fields: tuple[str, ...] | None = None, + alt_reps: dict[str, IdxArray] | None = None, + main_rep_idxs: IdxArray | None = None, + ) -> Path: + + # create the file + WepyHDF5( + path, + mode="x", + topology=test_sys.json_top, + sparse_fields=sparse_fields, + alt_reps=alt_reps, + main_rep_idxs=main_rep_idxs, + ) + + return path + + return _factory + + +_INIT_WALKERS = [ + Walker( + WalkerStateBox( + positions=np.array( + [ + [1.0, 1.0, 1.0], + [2.0, 2.0, 2.0], + ] + ), + box_vectors=np.array( + [ + [1.0, 0.0, 0.0], + [0.0, 1.0, 0.0], + [0.0, 0.0, 1.0], + ] + ), + kinetic_energy=3.455, + ), + 0.1, + ), +] + + +@pytest.fixture(scope="session") +def _wepy_h5_run_init(wepy_h5_factory, tmp_path_factory) -> Path: + """Data generation fixture, should not be used by individual tests + as it is slow. Instead use the fixture that makes a copy for + read/write in each test function. + + """ + + d = tmp_path_factory.mktemp("wepy_h5_run_init") + + # initialize a file + path = wepy_h5_factory( + d / "main.wepy.h5", + sparse_fields={"velocities"}, + ) + + # initialize the file + with WepyHDF5(path, mode="r+") as wepy_h5: + + run_grp = wepy_h5.new_run(init_walkers=_INIT_WALKERS) + + wepy_h5.init_run_fields_resampling_decision( + 0, + NoDecision.enum_dict_by_name(), + ) + wepy_h5.init_run_fields_resampling( + 0, + NoResampler.resampling_fields(), + ) + wepy_h5.init_record_fields( + "resampling", + [name for name, _, _ in NoResampler.resampling_fields()], + ) + + # UGLY: synthetic examples of warping without a real class to use + + # warping as it has hardcoded behavior that should be tested + warp_fields = [ + ("walker_idx", (1,), int), + ("target_idx", (1,), int), + ("weight", (1,), float), + ] + wepy_h5.init_run_fields_warping( + 0, + warp_fields, + ) + wepy_h5.init_record_fields( + "warping", + [name for name, _, _ in warp_fields], + ) + + # minimal progress fields for testing continual records + progress_fields = [ + # single number for a cycle + ("ensemble_average", (1,), float), + # per-walker data + ("walker_distances", Ellipsis, float), + ] + wepy_h5.init_run_fields_progress( + 0, + progress_fields, + ) + wepy_h5.init_record_fields( + "progress", + [name for name, _, _ in progress_fields], + ) + + # TODO: more fields for resampler records and BC + # records. These are always optional and strictly accessory so + # holding off on writing more test cases on these. + + return path + + +@pytest.fixture(scope="function") +def wepy_h5_run_init(_wepy_h5_run_init, tmpdir) -> Path: + + path = tmpdir / "main.wepy.h5" + + reflink_or_copy(_wepy_h5_run_init, path) + + return path + + +@pytest.fixture(scope="session") +def _wepy_h5_traj_init(_wepy_h5_run_init, tmp_path_factory) -> Path: + """Data generation fixture, should not be used by individual tests + as it is slow. Instead use the fixture that makes a copy for + read/write in each test function. + + This generates an HDF5 with everything in the run init fixture as + well as trajectories in the run with a single cycle's worth of + data. + + """ + + # make a copy of the run init H5 file and then mutate to add stuff + d = tmp_path_factory.mktemp("wepy_h5_traj_init") + + path = d / "main.wepy.h5" + shutil.copy( + _wepy_h5_run_init, + path, + ) + + # Add the new data + with WepyHDF5(path, mode="r+") as wepy_h5: + + traj0_grp = wepy_h5.add_traj( + 0, + data={ + "positions": np.array( + [ + [ + [ + 2.0, + 2.0, + 2.0, + ], + [ + 1.0, + 1.0, + 1.0, + ], + ] + ] + ), + "box_vectors": np.array( + [ + [ + [1.0, 0.0, 0.0], + [0.0, 1.0, 0.0], + [0.0, 0.0, 1.0], + ] + ] + ), + "kinetic_energy": np.array( + [ + [4.87], + ] + ), + }, + weights=np.array([[0.2]]), + metadata={"foo": "hello"}, + ) + + traj1_grp = wepy_h5.add_traj( + 0, + data={ + "positions": np.array( + [ + [ + [ + 2.0, + 2.0, + 2.0, + ], + [ + 1.0, + 1.0, + 1.0, + ], + ] + ] + ), + "box_vectors": np.array( + [ + [ + [1.0, 0.0, 0.0], + [0.0, 1.0, 0.0], + [0.0, 0.0, 1.0], + ] + ] + ), + "kinetic_energy": np.array( + [ + [4.87], + ] + ), + }, + weights=np.array([[0.2]]), + metadata={"foo": "hello"}, + ) + + wepy_h5.extend_cycle_resampling_records( + 0, + 0, + [ + # NOTE: that this function requires mappings, and + # these record types double as mappings via the mixin, + # so we just use them + NoResamplerResamplingRecord( + decision_id=0, + target_idxs=[0], + walker_idx=0, + step_idx=0, + ), + NoResamplerResamplingRecord( + decision_id=0, + target_idxs=[1], + walker_idx=1, + step_idx=0, + ), + ], + ) + + wepy_h5.extend_cycle_progress_records( + 0, + 0, + [ + # only a single record for the cycle + { + "ensemble_average": 1.2, + "walker_distances": [ + 1.0, + 1.0, + ], + }, + ], + ) + + # TODO: the other record groups + + return path + + +@pytest.fixture(scope="function") +def wepy_h5_traj_init(_wepy_h5_traj_init, tmpdir) -> Path: + + path = tmpdir / "main.wepy.h5" + + reflink_or_copy(_wepy_h5_traj_init, path) + + return path + + +@pytest.fixture(scope="session") +def _wepy_h5_full_init(_wepy_h5_traj_init, tmp_path_factory) -> Path: + """Data generation fixture, should not be used by individual tests + as it is slow. Instead use the fixture that makes a copy for + read/write in each test function. + + This generates an HDF5 with everything in the traj init fixture as + well as extending those trajectories for a few cycles and adding + another 1 run. This run is a continuation of the first. + + For each trajectory it also includes sparse data and alternate reps. + + This should be sufficient for testing of all HDF5 related methods + and analysis. + + """ + + # make a copy of the run init H5 file and then mutate to add stuff + d = tmp_path_factory.mktemp("wepy_h5_full") + + path = d / "main.wepy.h5" + shutil.copy( + _wepy_h5_traj_init, + path, + ) + + # Add the new data + with WepyHDF5(path, mode="r+") as wepy_h5: + + # extend run 0 + wepy_h5.extend_traj( + 0, + 0, + weights=np.array([[0.2]]), + data={ + "positions": np.array( + [ + [ + [ + 2.0, + 2.0, + 2.0, + ], + [ + 1.0, + 1.0, + 1.0, + ], + ] + ] + ), + "box_vectors": np.array( + [ + [ + [1.0, 0.0, 0.0], + [0.0, 1.0, 0.0], + [0.0, 0.0, 1.0], + ] + ] + ), + "kinetic_energy": np.array( + [ + [4.87], + ] + ), + "velocities": np.array( + [ + [ + [ + 2.0, + 2.0, + 2.0, + ], + [ + 1.0, + 1.0, + 1.0, + ], + ] + ] + ), + }, + ) + + wepy_h5.extend_traj( + 0, + 1, + weights=np.array([[0.2]]), + data={ + "positions": np.array( + [ + [ + [ + 2.0, + 2.0, + 2.0, + ], + [ + 1.0, + 1.0, + 1.0, + ], + ] + ] + ), + "box_vectors": np.array( + [ + [ + [1.0, 0.0, 0.0], + [0.0, 1.0, 0.0], + [0.0, 0.0, 1.0], + ] + ] + ), + "kinetic_energy": np.array( + [ + [4.87], + ] + ), + "velocities": np.array( + [ + [ + [ + 2.0, + 2.0, + 2.0, + ], + [ + 1.0, + 1.0, + 1.0, + ], + ] + ] + ), + }, + ) + + wepy_h5.extend_cycle_resampling_records( + 0, + 1, + [ + # NOTE: that this function requires mappings, and + # these record types double as mappings via the mixin, + # so we just use them + NoResamplerResamplingRecord( + decision_id=0, + target_idxs=[0], + walker_idx=0, + step_idx=0, + ), + NoResamplerResamplingRecord( + decision_id=0, + target_idxs=[1], + walker_idx=1, + step_idx=0, + ), + ], + ) + + wepy_h5.extend_cycle_progress_records( + 0, + 1, + [ + # only a single record for the cycle + { + "ensemble_average": 1.2, + "walker_distances": [ + 1.0, + 1.0, + ], + }, + ], + ) + + # run 1, a continuation of run 0 + wepy_h5.new_run( + init_walkers=_INIT_WALKERS, + continue_run=0, + ) + + wepy_h5.init_run_fields_resampling_decision( + 1, + NoDecision.enum_dict_by_name(), + ) + wepy_h5.init_run_fields_resampling( + 1, + NoResampler.resampling_fields(), + ) + + # UGLY: synthetic examples of warping without a real class to use + + # warping as it has hardcoded behavior that should be tested + warp_fields = [ + ("walker_idx", (1,), int), + ("target_idx", (1,), int), + ("weight", (1,), float), + ] + wepy_h5.init_run_fields_warping( + 1, + warp_fields, + ) + + # minimal progress fields for testing continual records + progress_fields = [ + # single number for a cycle + ("ensemble_average", (1,), float), + # per-walker data + ("walker_distances", Ellipsis, float), + ] + wepy_h5.init_run_fields_progress( + 1, + progress_fields, + ) + + wepy_h5.add_traj( + 1, + data={ + "positions": np.array( + [ + [ + [ + 2.0, + 2.0, + 2.0, + ], + [ + 1.0, + 1.0, + 1.0, + ], + ] + ] + ), + "box_vectors": np.array( + [ + [ + [1.0, 0.0, 0.0], + [0.0, 1.0, 0.0], + [0.0, 0.0, 1.0], + ] + ] + ), + "kinetic_energy": np.array( + [ + [4.87], + ] + ), + }, + weights=np.array([[0.2]]), + metadata={"foo": "hello"}, + ) + + wepy_h5.add_traj( + 1, + data={ + "positions": np.array( + [ + [ + [ + 2.0, + 2.0, + 2.0, + ], + [ + 1.0, + 1.0, + 1.0, + ], + ] + ] + ), + "box_vectors": np.array( + [ + [ + [1.0, 0.0, 0.0], + [0.0, 1.0, 0.0], + [0.0, 0.0, 1.0], + ] + ] + ), + "kinetic_energy": np.array( + [ + [4.87], + ] + ), + }, + weights=np.array([[0.2]]), + metadata={"foo": "hello"}, + ) + + wepy_h5.extend_cycle_resampling_records( + 1, + 0, + [ + # NOTE: that this function requires mappings, and + # these record types double as mappings via the mixin, + # so we just use them + NoResamplerResamplingRecord( + decision_id=0, + target_idxs=[0], + walker_idx=0, + step_idx=0, + ), + NoResamplerResamplingRecord( + decision_id=0, + target_idxs=[1], + walker_idx=1, + step_idx=0, + ), + ], + ) + + wepy_h5.extend_cycle_progress_records( + 1, + 0, + [ + # only a single record for the cycle + { + "ensemble_average": 1.2, + "walker_distances": [ + 1.0, + 1.0, + ], + }, + ], + ) + + # extend run 1 + wepy_h5.extend_traj( + 1, + 0, + weights=np.array([[0.2]]), + data={ + "positions": np.array( + [ + [ + [ + 2.0, + 2.0, + 2.0, + ], + [ + 1.0, + 1.0, + 1.0, + ], + ] + ] + ), + "box_vectors": np.array( + [ + [ + [1.0, 0.0, 0.0], + [0.0, 1.0, 0.0], + [0.0, 0.0, 1.0], + ] + ] + ), + "kinetic_energy": np.array( + [ + [4.87], + ] + ), + "velocities": np.array( + [ + [ + [ + 2.0, + 2.0, + 2.0, + ], + [ + 1.0, + 1.0, + 1.0, + ], + ] + ] + ), + }, + ) + + wepy_h5.extend_traj( + 1, + 1, + weights=np.array([[0.2]]), + data={ + "positions": np.array( + [ + [ + [ + 2.0, + 2.0, + 2.0, + ], + [ + 1.0, + 1.0, + 1.0, + ], + ] + ] + ), + "box_vectors": np.array( + [ + [ + [1.0, 0.0, 0.0], + [0.0, 1.0, 0.0], + [0.0, 0.0, 1.0], + ] + ] + ), + "kinetic_energy": np.array( + [ + [4.87], + ] + ), + "velocities": np.array( + [ + [ + [ + 2.0, + 2.0, + 2.0, + ], + [ + 1.0, + 1.0, + 1.0, + ], + ] + ] + ), + }, + ) + + wepy_h5.extend_cycle_resampling_records( + 1, + 1, + [ + # NOTE: that this function requires mappings, and + # these record types double as mappings via the mixin, + # so we just use them + NoResamplerResamplingRecord( + decision_id=0, + target_idxs=[0], + walker_idx=0, + step_idx=0, + ), + NoResamplerResamplingRecord( + decision_id=0, + target_idxs=[1], + walker_idx=1, + step_idx=0, + ), + ], + ) + + wepy_h5.extend_cycle_progress_records( + 1, + 1, + [ + # only a single record for the cycle + { + "ensemble_average": 1.2, + "walker_distances": [ + 1.0, + 1.0, + ], + }, + ], + ) + + return path + + +@pytest.fixture(scope="function") +def wepy_h5_full_init(_wepy_h5_full_init, tmpdir) -> Path: + + path = tmpdir / "main.wepy.h5" + + reflink_or_copy(_wepy_h5_full_init, path) + + return path diff --git a/tests/unit/test_analysis/test_contig_tree.py b/tests/unit/test_analysis/test_contig_tree.py new file mode 100644 index 00000000..9f1903a9 --- /dev/null +++ b/tests/unit/test_analysis/test_contig_tree.py @@ -0,0 +1,527 @@ +# Standard Library +from pathlib import Path + +# Third Party Library +import networkx as nx + +# First Party Library +import wepy +from wepy.analysis.contig_tree import ( + BaseContigTree, + Contig, + ContigTree, +) + + +class Test_BaseContigTree: + + def test___init__(self, wepy_h5_full_init: Path): + + wepy_h5 = wepy.WepyHDF5(wepy_h5_full_init, mode="r") + + bct = BaseContigTree( + wepy_h5, + decision_class=wepy.NoDecision, + ) + + assert bct.continuations == {(1, 0)} + assert bct.run_idxs == {0, 1} + + assert set(bct.graph.nodes) == { + (0, 0), + (0, 1), + (1, 0), + (1, 1), + } + + assert set(bct.graph.edges) == { + ((0, 1), (0, 0)), + ((1, 0), (0, 1)), + ((1, 1), (1, 0)), + } + + for node_id in bct.graph.nodes: + node = bct.graph.nodes[node_id] + assert set(node.keys()) == { + "resampling_steps", + "parent_idxs", + "discontinuities", + } + + bct = BaseContigTree( + wepy_h5, + decision_class=wepy.NoDecision, + runs=[0], + ) + + assert bct.continuations == set() + assert bct.run_idxs == {0} + + bct = BaseContigTree( + wepy_h5, + decision_class=wepy.NoDecision, + runs=[0, 1], + ) + + assert bct.continuations == {(1, 0)} + assert bct.run_idxs == {0, 1} + + bct = BaseContigTree( + wepy_h5, + decision_class=wepy.NoDecision, + runs=[0, 1], + continuations=[(1, 0)], + ) + + assert bct.continuations == {(1, 0)} + assert bct.run_idxs == {0, 1} + + bct = BaseContigTree( + wepy_h5, + decision_class=wepy.NoDecision, + runs=[0, 1], + # NOTE: manually overriding, even if incorrect + continuations=[(0, 1)], + ) + + assert bct.continuations == {(0, 1)} + assert bct.run_idxs == {0, 1} + + def test_contig_to_run_trace(self): + + assert BaseContigTree.contig_trace_to_run_trace( + [ + (0, 0), + (0, 1), + (1, 0), + (1, 1), + ], + [ + (0, 0), + (0, 1), + (0, 2), + (0, 3), + ], + ) == [ + (0, 0, 0), + (0, 0, 1), + (1, 0, 0), + (1, 0, 1), + ] + + def test_run_trace_to_contig_trace(self, wepy_h5_full_init): + wepy_h5 = wepy.WepyHDF5(wepy_h5_full_init, mode="r") + + bct = BaseContigTree( + wepy_h5, + decision_class=wepy.NoDecision, + ) + + assert bct.run_trace_to_contig_trace( + [ + (0, 0, 0), + (0, 0, 1), + (1, 0, 0), + (1, 0, 1), + ] + ) == [ + (0, 0), + (0, 1), + (0, 2), + (0, 3), + ] + + def test_contig_cycle_idx(self, wepy_h5_full_init): + wepy_h5 = wepy.WepyHDF5(wepy_h5_full_init, mode="r") + + bct = BaseContigTree( + wepy_h5, + decision_class=wepy.NoDecision, + ) + + assert bct.contig_cycle_idx(0, 0) == 0 + assert bct.contig_cycle_idx(0, 1) == 1 + assert bct.contig_cycle_idx(1, 0) == 2 + assert bct.contig_cycle_idx(1, 1) == 3 + + def test_get_branch_trace(self, wepy_h5_full_init): + wepy_h5 = wepy.WepyHDF5(wepy_h5_full_init, mode="r") + + bct = BaseContigTree( + wepy_h5, + decision_class=wepy.NoDecision, + ) + + assert bct.get_branch_trace( + run_idx=0, + cycle_idx=0, + start_contig_idx=0, + ) == [(0, 0)] + + assert bct.get_branch_trace( + run_idx=0, + cycle_idx=3, + start_contig_idx=0, + ) == [ + (0, 0), + (0, 1), + (1, 0), + (1, 1), + ] + + def test_trace_parent_table(self, wepy_h5_full_init): + wepy_h5 = wepy.WepyHDF5(wepy_h5_full_init, mode="r") + + bct = BaseContigTree( + wepy_h5, + decision_class=wepy.NoDecision, + ) + + assert bct.trace_parent_table( + [ + (0, 0), + (0, 1), + (1, 0), + (1, 1), + ], + discontinuities=False, + ) == [ + [0, 1], + [0, 1], + [0, 1], + [0, 1], + ] + + # TODO: discontinuities + + def test__tree_leaves(self): + + assert set( + BaseContigTree._tree_leaves( + (0, 0), + # NOTE: the tree is reversed from what is in the BaseContigTree + nx.DiGraph( + [ + ( + (0, 0), + (0, 1), + ), + ( + (0, 1), + (1, 0), + ), + # leaves + ( + (1, 0), + (1, 1), + ), + ( + (1, 0), + (2, 0), + ), + ] + ), + ) + ) == { + (1, 1), + (2, 0), + } + + def test__subtree_leaves(self, wepy_h5_full_init): + wepy_h5 = wepy.WepyHDF5(wepy_h5_full_init, mode="r") + + bct = BaseContigTree( + wepy_h5, + decision_class=wepy.NoDecision, + ) + + assert set(bct._subtree_leaves((0, 0))) == {(1, 1)} + + def test_leaves(self, wepy_h5_full_init): + wepy_h5 = wepy.WepyHDF5(wepy_h5_full_init, mode="r") + + bct = BaseContigTree( + wepy_h5, + decision_class=wepy.NoDecision, + ) + + assert set(bct.leaves()) == {(1, 1)} + + def test_root_leaves(self, wepy_h5_full_init): + wepy_h5 = wepy.WepyHDF5(wepy_h5_full_init, mode="r") + + bct = BaseContigTree( + wepy_h5, + decision_class=wepy.NoDecision, + ) + + root_leaves = bct.root_leaves() + assert (0, 0) in root_leaves + assert set(root_leaves[(0, 0)]) == { + (1, 1), + } + + def test_subtrees(self, wepy_h5_full_init): + wepy_h5 = wepy.WepyHDF5(wepy_h5_full_init, mode="r") + + bct = BaseContigTree( + wepy_h5, + decision_class=wepy.NoDecision, + ) + + # for one tree its the same + assert len(bct.subtrees()) == 1 + + def test_get_subtree(self, wepy_h5_full_init): + wepy_h5 = wepy.WepyHDF5(wepy_h5_full_init, mode="r") + + bct = BaseContigTree( + wepy_h5, + decision_class=wepy.NoDecision, + ) + + # TODO: this method is fallacious and need reworked, so just a + # minimal test + bct.get_subtree((0, 0)).nodes + + def test__subtree_root(self, wepy_h5_full_init): + wepy_h5 = wepy.WepyHDF5(wepy_h5_full_init, mode="r") + + bct = BaseContigTree( + wepy_h5, + decision_class=wepy.NoDecision, + ) + + assert bct._subtree_root((0, 0)) == (0, 0) + assert bct._subtree_root((0, 1)) == (0, 0) + assert bct._subtree_root((1, 0)) == (0, 0) + assert bct._subtree_root((1, 1)) == (0, 0) + + def test_roots(self, wepy_h5_full_init): + wepy_h5 = wepy.WepyHDF5(wepy_h5_full_init, mode="r") + + bct = BaseContigTree( + wepy_h5, + decision_class=wepy.NoDecision, + ) + + assert bct.roots() == [(0, 0)] + + def test_span_traces(self, wepy_h5_full_init): + + wepy_h5 = wepy.WepyHDF5(wepy_h5_full_init, mode="r") + + bct = BaseContigTree( + wepy_h5, + decision_class=wepy.NoDecision, + ) + + assert bct.span_traces == { + 0: [ + (0, 0), + (0, 1), + (1, 0), + (1, 1), + ] + } + + def test__root_spanning_contig_traces(self, wepy_h5_full_init): + wepy_h5 = wepy.WepyHDF5(wepy_h5_full_init, mode="r") + + bct = BaseContigTree( + wepy_h5, + decision_class=wepy.NoDecision, + ) + + assert bct._root_spanning_contig_traces() == { + (0, 0): [ + [ + (0, 0), + (0, 1), + (1, 0), + (1, 1), + ], + ], + } + + def test_spanning_contig_traces(self, wepy_h5_full_init): + wepy_h5 = wepy.WepyHDF5(wepy_h5_full_init, mode="r") + + bct = BaseContigTree( + wepy_h5, + decision_class=wepy.NoDecision, + ) + + assert bct.spanning_contig_traces() == [ + [ + (0, 0), + (0, 1), + (1, 0), + (1, 1), + ], + ] + + def test__spanning_paths(self, wepy_h5_full_init): + + wepy_h5 = wepy.WepyHDF5(wepy_h5_full_init, mode="r") + + bct = BaseContigTree( + wepy_h5, + decision_class=wepy.NoDecision, + ) + + assert bct._spanning_paths((0, 0)) == { + (1, 1): [ + (0, 0), + (0, 1), + (1, 0), + (1, 1), + ] + } + + def test__contig_trace_to_contig_runs(self): + + assert BaseContigTree._contig_trace_to_contig_runs( + [ + (0, 0), + (0, 1), + (1, 0), + (1, 1), + ], + ) == [0, 1] + + def test__contig_runs_to_continuations(self): + + assert BaseContigTree._contig_runs_to_continuations([0, 1]) == [[1, 0]] + + def test__continuations_to_contig_runs(self): + + assert BaseContigTree._continuations_to_contig_runs([[1, 0]]) == [0, 1] + + # TODO: requires Contig + def test_span_contig(self, wepy_h5_full_init): + wepy_h5 = wepy.WepyHDF5(wepy_h5_full_init, mode="r") + + bct = BaseContigTree( + wepy_h5, + decision_class=wepy.NoDecision, + ) + + assert False + + assert bct.span_traces == {0: []} + + +class Test_ContigTree: + + def test___init__(self, wepy_h5_full_init): + + wepy_h5 = wepy.WepyHDF5(wepy_h5_full_init, mode="r") + + ct = ContigTree( + wepy_h5, + decision_class=wepy.NoDecision, + ) + + assert ct.wepy_h5.closed + + assert ct.base_contigtree is not None + + def test_make_contig(self, wepy_h5_full_init): + + wepy_h5 = wepy.WepyHDF5(wepy_h5_full_init, mode="r") + + ct = ContigTree( + wepy_h5, + decision_class=wepy.NoDecision, + ) + + contig = ct.make_contig([(0, 0), (0, 1), (1, 0), (1, 1)]) + + # TODO: + # def test_resampling_trace(self, wepy_h5_full_init): + # wepy_h5 = wepy.WepyHDF5(wepy_h5_full_init, mode='r') + + # ct = ContigTree( + # wepy_h5, + # decision_class=wepy.NoDecision, + # ) + + def test_final_trace(self, wepy_h5_full_init): + wepy_h5 = wepy.WepyHDF5(wepy_h5_full_init, mode="r") + + ct = ContigTree( + wepy_h5, + decision_class=wepy.NoDecision, + ) + + assert ct.final_trace() == [ + (1, 0, 1), + (1, 1, 1), + ] + + def test_lineages(self, wepy_h5_full_init): + wepy_h5 = wepy.WepyHDF5(wepy_h5_full_init, mode="r") + + ct = ContigTree( + wepy_h5, + decision_class=wepy.NoDecision, + ) + + assert ct.lineages( + [ + (1, 0, 1), + ], # (1, 1, 1)], + discontinuities=False, + ) == [ + [ + (0, 0, 0), + (0, 0, 1), + (1, 0, 0), + (1, 0, 1), + ], + ] + + +class Test_Contig: + + def test___init__(self, wepy_h5_full_init): + + wepy_h5 = wepy.WepyHDF5(wepy_h5_full_init, mode="r") + + contig = Contig( + wepy_h5, + decision_class=wepy.NoDecision, + ) + + assert contig.contig_trace == [ + (0, 0), + (0, 1), + (1, 0), + (1, 1), + ] + + assert contig.num_cycles == 4 + + def test_walker_trace_to_run_trace(self, wepy_h5_full_init): + + wepy_h5 = wepy.WepyHDF5(wepy_h5_full_init, mode="r") + + bct = Contig( + wepy_h5, + decision_class=wepy.NoDecision, + ) + + assert bct.walker_trace_to_run_trace( + [ + (0, 0), + (0, 1), + (0, 2), + (0, 3), + ], + ) == [ + (0, 0, 0), + (0, 0, 1), + (1, 0, 0), + (1, 0, 1), + ] diff --git a/tests/unit/test_analysis/test_parents.py b/tests/unit/test_analysis/test_parents.py new file mode 100644 index 00000000..18cc69ce --- /dev/null +++ b/tests/unit/test_analysis/test_parents.py @@ -0,0 +1,288 @@ +# Standard Library + +# First Party Library +from wepy.analysis.parents import ( + ancestors, + net_parent_table, + parent_panel, + resampling_panel, +) +from wepy.resampling.decisions.no_decision import NoDecision +from wepy.storage.protocol import RunRecord + + +def test_resampling_panel(): + + # # simple case + # assert resampling_panel( + # [ + # RunRecord( + # cycle_idx=0, + # record=dict( + # step_idx=0, + # walker_idx=0, + # decision_id=0, + # target_idxs=(0,), + # ) + # ), + # RunRecord( + # cycle_idx=0, + # record=dict( + # step_idx=0, + # walker_idx=1, + # decision_id=0, + # target_idxs=(1,), + # ) + # ), + # ] + # ) == [ + # # cycle 0 + # [ + # # step 0 + # [ + # # walker 0 + # { + # "decision_id" : 0, + # "target_idxs" : (0,) + # }, + # # walker 1 + # { + # "decision_id" : 0, + # "target_idxs" : (1,) + # }, + # ] + # ] + # ] + + # TODO: failing + # with multiple steps + assert resampling_panel( + [ + # step 0 + RunRecord( + cycle_idx=0, + record=dict( + step_idx=0, + walker_idx=0, + decision_id=0, + target_idxs=(0,), + ), + ), + RunRecord( + cycle_idx=0, + record=dict( + step_idx=0, + walker_idx=1, + decision_id=0, + target_idxs=(1,), + ), + ), + # step 1 + RunRecord( + cycle_idx=0, + record=dict( + step_idx=1, + walker_idx=0, + decision_id=0, + target_idxs=(1,), + ), + ), + RunRecord( + cycle_idx=0, + record=dict( + step_idx=1, + walker_idx=1, + decision_id=0, + target_idxs=(0,), + ), + ), + ] + ) == [ + # cycle 0 + [ + # step 0 + [ + # walker 0 + {"decision_id": 0, "target_idxs": (0,)}, + # walker 1 + {"decision_id": 0, "target_idxs": (1,)}, + ], + # step 1 + [ + # walker 0 + {"decision_id": 0, "target_idxs": (1,)}, + # walker 1 + {"decision_id": 0, "target_idxs": (0,)}, + ], + ] + ] + + +def test_parent_panel(): + + assert parent_panel( + NoDecision, + [ + # cycle 0 + [ + # step 0 + [ + # walker 0 + {"decision_id": 0, "target_idxs": (0,)}, + # walker 1 + {"decision_id": 0, "target_idxs": (1,)}, + ], + ], + ], + ) == [ + [ + [0, 1], + ] + ] + + assert parent_panel( + NoDecision, + [ + # cycle 0 + [ + # step 0 + [ + # walker 0 + {"decision_id": 0, "target_idxs": (0,)}, + # walker 1 + {"decision_id": 0, "target_idxs": (1,)}, + ], + # step 1 + [ + # walker 0 + {"decision_id": 0, "target_idxs": (1,)}, + # walker 1 + {"decision_id": 0, "target_idxs": (0,)}, + ], + ], + ], + ) == [ + [ + [0, 1], + [1, 0], + ] + ] + + +def test_net_parent_table(): + + net_parent_table( + [ + [ + [0, 1], + ] + ] + ) == [ + # cycle 0 + [0, 1] + ] + + net_parent_table( + [ + [ + [0, 1], + [1, 0], + ] + ] + ) == [ + # cycle 0 + [1, 0] + ] + + +# TODO: tests for discontinuities +# +# def test_parent_table_discontinuities(): +# pass + +# def test_parent_cycle_discontinuities(): +# pass + + +def test_ancestors(): + + assert ancestors( + [ + [0, 1], + ], + cycle_idx=0, + walker_idx=0, + ancestor_cycle=0, + ) == [ + (0, 0), + ] + + assert ancestors( + [ + [0, 1], + [0, 1], + ], + cycle_idx=1, + walker_idx=0, + ancestor_cycle=0, + ) == [ + (0, 0), + (0, 1), + ] + + assert ancestors( + [ + [0, 1], + [0, 1], + ], + cycle_idx=1, + walker_idx=0, + ancestor_cycle=1, + ) == [ + (0, 1), + ] + + assert ancestors( + [ + [0, 1], + [0, 1], + [1, 0], + [1, 0], + ], + cycle_idx=3, + walker_idx=0, + ancestor_cycle=0, + ) == [ + (1, 0), + (1, 1), + (1, 2), + (0, 3), + ] + + assert ancestors( + [ + [0, 1], + [0, 1], + [1, 0], + [1, 0], + ], + cycle_idx=3, + walker_idx=1, + ancestor_cycle=0, + ) == [ + (0, 0), + (0, 1), + (0, 2), + (1, 3), + ] + + +# TODO: test this +# +# def test_sliding_window(): +# pass + + +# TODO: need Contig for this to work +class Test_ParentForest: + pass diff --git a/tests/unit/test_fixtures.py b/tests/unit/test_fixtures.py new file mode 100644 index 00000000..096c3d85 --- /dev/null +++ b/tests/unit/test_fixtures.py @@ -0,0 +1,137 @@ +# Third Party Library +import numpy as np + +# First Party Library +from wepy.hdf5 import WepyHDF5 + + +def test__wepy_h5_run_init(_wepy_h5_run_init): + + with WepyHDF5(_wepy_h5_run_init, mode="r") as wepy_h5: + + wepy_h5.run(0) + + assert wepy_h5.num_run_trajs(0) == 0 + + wepy_h5.resampling_grp(0) + wepy_h5.decision_grp(0) + wepy_h5.warping_grp(0) + wepy_h5.progress_grp(0) + + assert "resampling" in wepy_h5.record_fields + assert wepy_h5.record_fields["resampling"] == [ + "decision_id", + "target_idxs", + "step_idx", + "walker_idx", + ] + + assert "warping" in wepy_h5.record_fields + assert wepy_h5.record_fields["warping"] == [ + "walker_idx", + "target_idx", + "weight", + ] + + assert "progress" in wepy_h5.record_fields + assert wepy_h5.record_fields["progress"] == [ + "ensemble_average", + "walker_distances", + ] + + +# test that each test gets its own copy +def test_wepy_h5_run_init_1(wepy_h5_run_init): + + with WepyHDF5(wepy_h5_run_init, mode="r") as wepy_h5: + assert "mutation_flag" not in wepy_h5.h5 + + # mutate + with WepyHDF5(wepy_h5_run_init, mode="r+") as wepy_h5: + wepy_h5.h5["mutation_flag"] = np.array([0]) + + +def test_wepy_h5_run_init_2(wepy_h5_run_init): + + with WepyHDF5(wepy_h5_run_init, mode="r") as wepy_h5: + assert "mutation_flag" not in wepy_h5.h5 + + # mutate + with WepyHDF5(wepy_h5_run_init, mode="r+") as wepy_h5: + wepy_h5.h5["mutation_flag"] = np.array([0]) + + +def test__wepy_h5_traj_init(_wepy_h5_traj_init): + + with WepyHDF5(_wepy_h5_traj_init, mode="r") as wepy_h5: + + assert "velocities" in wepy_h5.sparse_fields + + wepy_h5.run(0) + + assert len(wepy_h5.resampling_records([0])) == 2 + assert len(wepy_h5.progress_records([0])) == 1 + + assert wepy_h5.num_run_trajs(0) == 2 + + for traj_idx in (0, 1): + assert "weights" in wepy_h5.traj(0, traj_idx) + assert "positions" in wepy_h5.traj(0, traj_idx) + assert "box_vectors" in wepy_h5.traj(0, traj_idx) + assert "kinetic_energy" in wepy_h5.traj(0, traj_idx) + assert "velocities" in wepy_h5.traj(0, traj_idx) + + assert wepy_h5.traj_field_entity(0, traj_idx, "weights").shape == (1, 1) + assert wepy_h5.traj_field_entity(0, traj_idx, "positions").shape == ( + 1, + 2, + 3, + ) + assert wepy_h5.traj_field_entity(0, traj_idx, "box_vectors").shape == ( + 1, + 3, + 3, + ) + assert wepy_h5.traj_field_entity(0, traj_idx, "kinetic_energy").shape == ( + 1, + 1, + ) + + assert "data" in wepy_h5.traj_field_entity(0, traj_idx, "velocities") + assert "_sparse_idxs" in wepy_h5.traj_field_entity( + 0, traj_idx, "velocities" + ) + + assert wepy_h5.traj_field_entity(0, traj_idx, "velocities")[ + "data" + ].shape == (0, 0, 0) + assert wepy_h5.traj_field_entity(0, traj_idx, "velocities")[ + "data" + ].maxshape == (None, 2, 3) + + assert wepy_h5.traj_field_entity(0, traj_idx, "velocities")[ + "_sparse_idxs" + ].shape == (0,) + + +def test__wepy_h5_full_init(_wepy_h5_full_init): + + with WepyHDF5(_wepy_h5_full_init, mode="r") as wepy_h5: + + assert wepy_h5.num_runs == 2 + + # run 0 + assert wepy_h5.num_traj_frames(0, 0) == 2 + assert wepy_h5.num_traj_frames(0, 1) == 2 + + assert len(wepy_h5.resampling_records([0])) == 4 + assert len(wepy_h5.progress_records([0])) == 2 + + # run 1 + assert len(wepy_h5.continuations) == 1 + assert tuple(wepy_h5.continuations[0]) == (1, 0) + assert wepy_h5.num_traj_frames(1, 0) == 2 + assert wepy_h5.num_traj_frames(1, 1) == 2 + + assert len(wepy_h5.resampling_records([1])) == 4 + assert len(wepy_h5.progress_records([1])) == 2 diff --git a/tests/unit/test_hdf5.py b/tests/unit/test_hdf5.py new file mode 100644 index 00000000..8ae4e1db --- /dev/null +++ b/tests/unit/test_hdf5.py @@ -0,0 +1,2300 @@ +# Standard Library +import json +from pathlib import Path +from unittest.mock import PropertyMock, patch + +# Third Party Library +import h5py +import numpy as np +import pytest + +# First Party Library +from wepy.hdf5 import ( + WepyHDF5, + WepyHDF5ReadError, + dtype_json_to_numpy, + numpy_dtype_to_json, +) +from wepy.resampling.decisions.no_decision import ( + NoDecision, +) +from wepy.storage.protocol import RunRecord +from wepy.walker import Walker, WalkerStateBox +from wepy_tools.systems.lennard_jones import LennardJonesPair + +_INIT_WALKERS = [ + Walker( + WalkerStateBox( + positions=np.array( + [ + [1.0, 1.0, 1.0], + [2.0, 2.0, 2.0], + ] + ), + box_vectors=np.array( + [ + [1.0, 0.0, 0.0], + [0.0, 1.0, 0.0], + [0.0, 0.0, 1.0], + ] + ), + kinetic_energy=3.455, + ), + 0.1, + ), +] + +# # TODO: this will be easier once we have a fixture for a full WepyHDF5 +# def test__iter_field_paths(wepy_h5_file_ro): +# pass + + +def test_numpy_dtype_to_json(): + assert json.loads(numpy_dtype_to_json(np.dtype(np.int32))) == { + "kind": "simple", + "str": " ( + tuple[openmm.System, openmm.app.Topology, openmm.LangevinIntegrator] +): + + lj_sys = LennardJonesPair() + + integrator = openmm.LangevinIntegrator(300.0, 0.1, STEP_SIZE) + + return lj_sys.system, lj_sys.topology, integrator + + +class Test_OpenMMRunnerDashboardSection: + + def test___init__(self, runner_components): + + system, topology, integrator = runner_components + + section = OpenMMRunnerDashboardSection( + runner_factory=OpenMMRunnerFactory( + system=system, + topology=topology, + integrator=integrator, + ) + ) + + assert section.runner_name == "OpenMMRunner" + assert section.step_time == STEP_SIZE + assert section.walker_total_sampling_time == 0.0 * openmm.unit.microsecond + assert section.total_sampling_time == 0.0 * openmm.unit.microsecond diff --git a/tests/unit/test_resampling/test_decisions/test_clone_merge.py b/tests/unit/test_resampling/test_decisions/test_clone_merge.py new file mode 100644 index 00000000..8a0558d8 --- /dev/null +++ b/tests/unit/test_resampling/test_decisions/test_clone_merge.py @@ -0,0 +1,321 @@ +# Third Party Library +import attrs +import pytest + +# First Party Library +from wepy.resampling.decisions.clone_merge import ( + CloneMergeDecisionEnum, + CloneMergeDecisionError, + CloneMergeDecisionRecord, + MultiCloneMergeDecision, +) +from wepy.runners.mock import MockState +from wepy.walker import Walker + + +class Test_CloneMergeDecisionRecord: + + def test___init__(self): + + # single target records + CloneMergeDecisionRecord( + decision_id=1, + target_idxs=(0,), + ) + CloneMergeDecisionRecord( + decision_id=3, + target_idxs=(0,), + ) + CloneMergeDecisionRecord( + decision_id=4, + target_idxs=(0,), + ) + + # clone + CloneMergeDecisionRecord( + decision_id=2, + target_idxs=(0, 1), + ) + + with pytest.raises(ValueError): + CloneMergeDecisionRecord( + decision_id=7, + target_idxs=(0,), + ) + + with pytest.raises(ValueError): + CloneMergeDecisionRecord( + decision_id=1, + target_idxs=(), + ) + + with pytest.raises(ValueError): + CloneMergeDecisionRecord( + decision_id=1, + target_idxs=(-1,), + ) + + with pytest.raises(CloneMergeDecisionError): + CloneMergeDecisionRecord( + decision_id=1, + target_idxs=( + 0, + 1, + ), + ) + + with pytest.raises(CloneMergeDecisionError): + CloneMergeDecisionRecord( + decision_id=3, + target_idxs=( + 0, + 1, + ), + ) + with pytest.raises(CloneMergeDecisionError): + CloneMergeDecisionRecord( + decision_id=4, + target_idxs=( + 0, + 1, + ), + ) + + with pytest.raises(CloneMergeDecisionError): + CloneMergeDecisionRecord( + decision_id=2, + target_idxs=(0,), + ) + + def test_to_dict(self): + + assert CloneMergeDecisionRecord(decision_id=1, target_idxs=(0,)).to_dict() == { + "decision_id": 1, + "target_idxs": (0,), + } + + +class TestMultiCloneMergeDecision: + + def test_action(self): + walker_1 = Walker( + state=MockState(a=1), + weight=1.0, + ) + + walker_2 = Walker( + state=MockState(a=2), + weight=1.0, + ) + walker_3 = Walker( + state=MockState(a=3), + weight=1.0, + ) + + walkers = [ + walker_1, + walker_2, + ] + + # unknown decision number + with pytest.raises(ValueError): + MultiCloneMergeDecision.action( + walkers, + [ + [ + CloneMergeDecisionRecord( + **{ + "decision_id": 1000, + "target_idxs": [0], + } + ), + CloneMergeDecisionRecord( + **{ + "decision_id": CloneMergeDecisionEnum.NOTHING, + "target_idxs": [1], + } + ), + ] + ], + ) + + assert ( + MultiCloneMergeDecision.action( + walkers, + [ + [ + CloneMergeDecisionRecord( + **{ + "decision_id": CloneMergeDecisionEnum.NOTHING, + "target_idxs": [0], + } + ), + CloneMergeDecisionRecord( + **{ + "decision_id": CloneMergeDecisionEnum.NOTHING, + "target_idxs": [1], + } + ), + ] + ], + ) + == walkers + ) + + # reorder + assert MultiCloneMergeDecision.action( + walkers, + [ + [ + CloneMergeDecisionRecord( + **{ + "decision_id": CloneMergeDecisionEnum.NOTHING, + "target_idxs": [1], + } + ), + CloneMergeDecisionRecord( + **{ + "decision_id": CloneMergeDecisionEnum.NOTHING, + "target_idxs": [0], + } + ), + ] + ], + ) == [walker_2, walker_1] + + # multiple assignment to same slot + with pytest.raises(ValueError): + MultiCloneMergeDecision.action( + walkers, + [ + [ + CloneMergeDecisionRecord( + **{ + "decision_id": CloneMergeDecisionEnum.NOTHING, + "target_idxs": [0], + } + ), + CloneMergeDecisionRecord( + **{ + "decision_id": CloneMergeDecisionEnum.NOTHING, + "target_idxs": [0], + } + ), + ] + ], + ) + + # TODO: this should be a more explicit error + # + # squashing without filling a slot is an error + with pytest.raises(KeyError): + MultiCloneMergeDecision.action( + walkers, + [ + [ + CloneMergeDecisionRecord( + **{ + "decision_id": CloneMergeDecisionEnum.NOTHING, + "target_idxs": [0], + } + ), + CloneMergeDecisionRecord( + **{ + "decision_id": CloneMergeDecisionEnum.SQUASH, + "target_idxs": [1], + } + ), + ] + ], + ) + with pytest.raises(KeyError): + MultiCloneMergeDecision.action( + walkers, + [ + [ + CloneMergeDecisionRecord( + **{ + "decision_id": CloneMergeDecisionEnum.NOTHING, + "target_idxs": [0], + } + ), + CloneMergeDecisionRecord( + **{ + "decision_id": CloneMergeDecisionEnum.SQUASH, + "target_idxs": [0], + } + ), + ] + ], + ) + + # provide a keep merge target, but leave a slot open... + with pytest.raises(ValueError): + MultiCloneMergeDecision.action( + walkers, + [ + [ + CloneMergeDecisionRecord( + **{ + "decision_id": CloneMergeDecisionEnum.KEEP_MERGE, + "target_idxs": [0], + } + ), + CloneMergeDecisionRecord( + **{ + "decision_id": CloneMergeDecisionEnum.SQUASH, + "target_idxs": [0], + } + ), + ] + ], + ) + + assert MultiCloneMergeDecision.action( + [ + walker_1, + walker_2, + walker_3, + ], + [ + [ + CloneMergeDecisionRecord( + **{ + "decision_id": CloneMergeDecisionEnum.CLONE, + "target_idxs": [0, 2], + } + ), + CloneMergeDecisionRecord( + **{ + "decision_id": CloneMergeDecisionEnum.KEEP_MERGE, + "target_idxs": [1], + } + ), + CloneMergeDecisionRecord( + **{ + "decision_id": CloneMergeDecisionEnum.SQUASH, + "target_idxs": [1], + } + ), + ] + ], + ) == [ + attrs.evolve(walker_1, weight=0.5), + attrs.evolve( + walker_2, + weight=2.0, + ), + attrs.evolve(walker_1, weight=0.5), + ] + + def test_parents(self): + + MultiCloneMergeDecision.parents( + [ + CloneMergeDecisionRecord( + decision_id=1, + target_idxs=(idx,), + ) + for idx in range(4) + ], + ) diff --git a/tests/unit/test_resampling/test_decisions/test_decision.py b/tests/unit/test_resampling/test_decisions/test_decision.py new file mode 100644 index 00000000..29121657 --- /dev/null +++ b/tests/unit/test_resampling/test_decisions/test_decision.py @@ -0,0 +1,103 @@ +# Standard Library +from enum import IntEnum + +# Third Party Library +import pytest + +# First Party Library +from wepy.resampling.decisions.decision import BaseDecisionABC, BaseDecisionRecord +from wepy.runners.mock import MockState +from wepy.walker import Walker + + +# minimal implementation of the ABC for testing +class MockDecisionEnum(IntEnum): + NOTHING = 0 + + +class MockDecision(BaseDecisionABC): + + ENUM = MockDecisionEnum + DEFAULT_DECISION = ENUM.NOTHING + ANCESTOR_DECISION_IDS = (ENUM.NOTHING.value,) + + +class Test_BaseDecisionRecord: + + def test_to_dict(self): + assert BaseDecisionRecord(decision_id=1, target_idxs=(0,)).to_dict() == { + "decision_id": 1, + "target_idxs": (0,), + } + + +class Test_Decision: + + def test_default_decision(self): + assert MockDecision.default_decision() == MockDecisionEnum.NOTHING + + def test_field_names(self): + assert MockDecision.field_names() == ( + "decision_id", + "target_idxs", + ) + + def test_field_shapes(self): + assert MockDecision.field_shapes() == ((1,), Ellipsis) + + def test_field_dtypes(self): + assert MockDecision.field_dtypes() == (int, int) + + def test_fields(self): + assert MockDecision.fields() == [ + ( + "decision_id", + (1,), + int, + ), + ( + "target_idxs", + Ellipsis, + int, + ), + ] + + def test_record_field_names(self): + assert MockDecision.record_field_names() == ( + "decision_id", + "target_idxs", + ) + + def test_enum_dict_by_name(self): + assert MockDecision.enum_dict_by_name() == { + "NOTHING": 0, + } + + def test_enum_dict_by_value(self): + assert MockDecision.enum_dict_by_value() == { + 0: MockDecisionEnum.NOTHING, + } + + def test_enum_by_value(self): + assert MockDecision.enum_by_value(0) == MockDecisionEnum.NOTHING + + def test_enum_by_name(self): + assert MockDecision.enum_by_name("NOTHING") == MockDecisionEnum.NOTHING + + def test_record(self): + + assert MockDecision.record(0, target_idxs=(0,)) == BaseDecisionRecord( + decision_id=0, + target_idxs=(0,), + ) + + def test_action(self): + + with pytest.raises(NotImplementedError): + MockDecision.action( + [Walker(MockState(1), 0.1) for _ in range(4)], + [ + BaseDecisionRecord(decision_id=0, target_idxs=(idx,)) + for idx in range(4) + ], + ) diff --git a/tests/unit/test_resampling/test_decisions/test_no_decision.py b/tests/unit/test_resampling/test_decisions/test_no_decision.py new file mode 100644 index 00000000..1c99eb06 --- /dev/null +++ b/tests/unit/test_resampling/test_decisions/test_no_decision.py @@ -0,0 +1,139 @@ +# Third Party Library +import pytest + +# First Party Library +from wepy.resampling.decisions.no_decision import ( + NoDecision, + NoDecisionRecord, + NothingDecisionEnum, +) +from wepy.runners.mock import MockState +from wepy.walker import Walker + + +class Test_NoDecisionRecord: + + def test___init__(self): + + NoDecisionRecord( + decision_id=0, + target_idxs=(1,), + ) + + with pytest.raises(ValueError): + NoDecisionRecord( + decision_id=1, + target_idxs=(1,), + ) + + with pytest.raises(ValueError): + NoDecisionRecord( + decision_id=0, + target_idxs=(-1,), + ) + + def test_to_dict(self): + + assert NoDecisionRecord(decision_id=0, target_idxs=(0,)).to_dict() == { + "decision_id": 0, + "target_idxs": (0,), + } + + +class Test_NoDecision: + + def test_action(self): + + walker_1 = Walker( + state=MockState(a=1), + weight=1.0, + ) + + walker_2 = Walker( + state=MockState(a=2), + weight=1.0, + ) + + walkers = [ + walker_1, + walker_2, + ] + + assert ( + NoDecision.action( + walkers, + [ + [ + NoDecisionRecord( + **{ + "decision_id": NothingDecisionEnum.NOTHING, + "target_idxs": (0,), + } + ), + NoDecisionRecord( + **{ + "decision_id": NothingDecisionEnum.NOTHING, + "target_idxs": (1,), + } + ), + ] + ], + ) + == walkers + ) + + NoDecision.action( + walkers, + [ + [ + NoDecisionRecord( + **{ + "decision_id": NothingDecisionEnum.NOTHING, + "target_idxs": (1,), + } + ), + NoDecisionRecord( + **{ + "decision_id": NothingDecisionEnum.NOTHING, + "target_idxs": (0,), + } + ), + ] + ], + ) == [walker_2, walker_1] + + def test_parents(self): + + assert NoDecision.parents( + [ + NoDecisionRecord( + **{ + "decision_id": NothingDecisionEnum.NOTHING.value, + "target_idxs": (0,), + } + ), + NoDecisionRecord( + **{ + "decision_id": NothingDecisionEnum.NOTHING.value, + "target_idxs": (1,), + } + ), + ] + ) == [0, 1] + + assert NoDecision.parents( + [ + NoDecisionRecord( + **{ + "decision_id": NothingDecisionEnum.NOTHING, + "target_idxs": (1,), + } + ), + NoDecisionRecord( + **{ + "decision_id": NothingDecisionEnum.NOTHING, + "target_idxs": (0,), + } + ), + ] + ) == [1, 0] diff --git a/tests/unit/test_resampling/test_distances/test_base.py b/tests/unit/test_resampling/test_distances/test_base.py new file mode 100644 index 00000000..f8382d5b --- /dev/null +++ b/tests/unit/test_resampling/test_distances/test_base.py @@ -0,0 +1,34 @@ +# Standard Library +import math + +# First Party Library +from wepy.resampling.distances.base import DistanceABC +from wepy.resampling.distances.mock import MockDistance +from wepy.runners.mock import MockState + +# minimal implementation of a Distance from the ABC, in this case the +# image and state are the same + + +class Test_DistanceABC: + + def test_image(self): + assert DistanceABC().image(MockState(1)) == MockState(1) + + def test_image_distance(self): + assert math.isclose( + MockDistance().image_distance( + MockState(1), + MockState(3), + ), + 2.0, + ) + + def test_image_distance(self): + assert math.isclose( + MockDistance().distance( + MockState(1), + MockState(3), + ), + 2.0, + ) diff --git a/tests/unit/test_resampling/test_distances/test_mock.py b/tests/unit/test_resampling/test_distances/test_mock.py new file mode 100644 index 00000000..bae99144 --- /dev/null +++ b/tests/unit/test_resampling/test_distances/test_mock.py @@ -0,0 +1,35 @@ +# Standard Library +import math + +# First Party Library +from wepy.resampling.distances.base import DistanceABC +from wepy.resampling.distances.mock import MockDistance +from wepy.runners.mock import MockState + +# minimal implementation of a Distance from the ABC, in this case the +# image and state are the same + + +class Test_DistanceABC: + + def test_image(self): + assert DistanceABC().image(MockState(1)) == MockState(1) + assert MockDistance().image(MockState(1)) == MockState(1) + + def test_image_distance(self): + assert math.isclose( + MockDistance().image_distance( + MockState(1), + MockState(3), + ), + 2.0, + ) + + def test_image_distance(self): + assert math.isclose( + MockDistance().distance( + MockState(1), + MockState(3), + ), + 2.0, + ) diff --git a/tests/unit/test_resampling/test_distances/test_simple.py b/tests/unit/test_resampling/test_distances/test_simple.py new file mode 100644 index 00000000..78098239 --- /dev/null +++ b/tests/unit/test_resampling/test_distances/test_simple.py @@ -0,0 +1,31 @@ +# Standard Library +import math + +# First Party Library +from wepy.resampling.distances.simple import ( + XYDistanceState, + XYEuclideanDistance, +) + + +class Test_XYEuclideanDistance: + + def test_image_distance(self): + + assert math.isclose( + XYEuclideanDistance().image_distance( + XYDistanceState((0, 0)), + XYDistanceState((0, 2)), + ), + 2.0, + ) + + def test_distance(self): + + assert math.isclose( + XYEuclideanDistance().distance( + XYDistanceState((0, 0)), + XYDistanceState((0, 2)), + ), + 2.0, + ) diff --git a/tests/unit/test_resampling/test_resamplers/test_clone_merge.py b/tests/unit/test_resampling/test_resamplers/test_clone_merge.py new file mode 100644 index 00000000..7387e6c7 --- /dev/null +++ b/tests/unit/test_resampling/test_resamplers/test_clone_merge.py @@ -0,0 +1,147 @@ +# Third Party Library +import pytest + +# First Party Library +from wepy.resampling.decisions.clone_merge import ( + CloneMergeDecisionRecord, +) +from wepy.resampling.resamplers.clone_merge import CloneMergeResampler +from wepy.resampling.resamplers.resampler import ResamplerError +from wepy.runners.mock import MockState +from wepy.walker import Walker + +# class MockCloneMergeResampler(ResamplerABC): + + +class Test_CloneMergeResampler: + + def test___init__(self): + + CloneMergeResampler() + + with pytest.raises(ResamplerError): + CloneMergeResampler( + pmin=0.1, + pmax=0.01, + ) + + with pytest.raises(ResamplerError): + CloneMergeResampler( + pmin=1.0, + pmax=0.01, + ) + + with pytest.raises(ResamplerError): + CloneMergeResampler( + pmin=0.1, + pmax=1.0, + ) + + def test__init_walker_actions(self): + + assert CloneMergeResampler()._init_walker_actions(4) == [ + CloneMergeDecisionRecord(decision_id=1, target_idxs=(0,)), + CloneMergeDecisionRecord(decision_id=1, target_idxs=(1,)), + CloneMergeDecisionRecord(decision_id=1, target_idxs=(2,)), + CloneMergeDecisionRecord(decision_id=1, target_idxs=(3,)), + ] + + def test__check_resampled_walkers(self): + + CloneMergeResampler( + pmin=0.1, + pmax=0.4, + )._check_resampled_walkers( + [ + Walker( + MockState(1), + weight=0.1, + ), + Walker( + MockState(1), + weight=0.4, + ), + ] + ) + + with pytest.raises(ResamplerError): + CloneMergeResampler( + pmin=0.1, + pmax=0.4, + )._check_resampled_walkers( + [ + Walker( + MockState(1), + weight=0.01, + ), + Walker( + MockState(1), + weight=0.4, + ), + ] + ) + + with pytest.raises(ResamplerError): + CloneMergeResampler( + pmin=0.1, + pmax=0.4, + )._check_resampled_walkers( + [ + Walker( + MockState(1), + weight=0.1, + ), + Walker( + MockState(1), + weight=0.5, + ), + ] + ) + + def test_assign_clones(self): + + resampler = CloneMergeResampler( + pmin=0.1, + pmax=0.4, + ) + + with pytest.raises(ResamplerError): + resampler.assign_clones( + merge_groups=[ + [], + [], + ], + walker_clone_nums=[0, 0, 0], + ) + + assert resampler.assign_clones( + merge_groups=[[], []], + walker_clone_nums=[0, 0], + ) == [ + CloneMergeDecisionRecord(decision_id=1, target_idxs=(0,)), + CloneMergeDecisionRecord(decision_id=1, target_idxs=(1,)), + ] + + assert resampler.assign_clones( + merge_groups=[ + [], + [2], + [], + ], + walker_clone_nums=[1, 0, 0], + ) == [ + CloneMergeDecisionRecord(decision_id=2, target_idxs=(0, 2)), + CloneMergeDecisionRecord(decision_id=4, target_idxs=(1,)), + CloneMergeDecisionRecord(decision_id=3, target_idxs=(1,)), + ] + + with pytest.raises(ResamplerError): + + resampler.assign_clones( + merge_groups=[ + [2], + [], + [], + ], + walker_clone_nums=[1, 0, 0], + ) diff --git a/tests/unit/test_resampling/test_resamplers/test_noresampler.py b/tests/unit/test_resampling/test_resamplers/test_noresampler.py new file mode 100644 index 00000000..90807dcc --- /dev/null +++ b/tests/unit/test_resampling/test_resamplers/test_noresampler.py @@ -0,0 +1,73 @@ +# Third Party Library + +# First Party Library +from wepy.resampling.decisions.no_decision import NothingDecisionEnum +from wepy.resampling.resamplers.noresampler import ( + NoResampler, + NoResamplerFactory, + NoResamplerResamplerRecord, + NoResamplerResamplingRecord, +) +from wepy.runners.mock import MockState +from wepy.walker import Walker + + +def test_NoResamplerResamplingRecord(): + + NoResamplerResamplingRecord( + decision_id=0, target_idxs=(1,), walker_idx=1, step_idx=0 + ) + + +class Test_NoResampler: + + def test_resample(self): + + walker_1 = Walker( + state=MockState(a=1), + weight=1.0, + ) + + walker_2 = Walker( + state=MockState(a=2), + weight=1.0, + ) + walker_3 = Walker( + state=MockState(a=3), + weight=1.0, + ) + + walkers = [walker_1, walker_2, walker_3] + + resampler = NoResampler() + + assert resampler.resample(walkers) == ( + walkers, + [ + NoResamplerResamplingRecord( + decision_id=NothingDecisionEnum.NOTHING.value, + target_idxs=(0,), + walker_idx=0, + step_idx=0, + ), + NoResamplerResamplingRecord( + decision_id=NothingDecisionEnum.NOTHING.value, + target_idxs=(1,), + walker_idx=1, + step_idx=0, + ), + NoResamplerResamplingRecord( + decision_id=NothingDecisionEnum.NOTHING.value, + target_idxs=(2,), + walker_idx=2, + step_idx=0, + ), + ], + [NoResamplerResamplerRecord()], + ) + + +def test_NoResamplerFactory(): + + factory = NoResamplerFactory() + factory(num_cores=1) diff --git a/tests/unit/test_resampling/test_resamplers/test_resampler.py b/tests/unit/test_resampling/test_resamplers/test_resampler.py new file mode 100644 index 00000000..64bbfcb0 --- /dev/null +++ b/tests/unit/test_resampling/test_resamplers/test_resampler.py @@ -0,0 +1,31 @@ +# Third Party Library +import pytest + +# First Party Library +from wepy.resampling.resamplers.resampler import ResamplerABC, ResamplerError + + +class Test_ResamplerABC: + + def test___init__(self): + + ResamplerABC() + + ResamplerABC( + min_num_walkers=None, + max_num_walkers=None, + ) + + ResamplerABC( + min_num_walkers=3, + max_num_walkers=3, + ) + + with pytest.raises(ResamplerError): + ResamplerABC( + min_num_walkers=4, + max_num_walkers=3, + ) + + with pytest.raises(ResamplerError): + ResamplerABC(min_num_walkers=0) diff --git a/tests/unit/test_resampling/test_resamplers/test_revo.py b/tests/unit/test_resampling/test_resamplers/test_revo.py new file mode 100644 index 00000000..d932eba3 --- /dev/null +++ b/tests/unit/test_resampling/test_resamplers/test_revo.py @@ -0,0 +1,348 @@ +# Standard Library +import pickle + +# Third Party Library +import numpy as np +import pytest + +# First Party Library +from wepy.resampling.distances.mock import MockDistance +from wepy.resampling.resamplers.revo import ( + REVOResampler, + REVOResamplerFactory, + _ImageWrapper, +) +from wepy.runners.mock import MockState +from wepy.walker import Walker + + +def test__ImageWrapper(): + + wrapped = _ImageWrapper(MockDistance().image) + + assert wrapped(MockState(1)) == MockState(1) + + # check that it is pickleable for sending to subprocesses + pickle.loads(pickle.dumps(wrapped)) + + +class Test_REVOResamplerFactory: + + resampler = REVOResamplerFactory( + distance_metric=MockDistance(), + merge_dist=1, + char_dist=1, + ) + + +class Test_REVOResampler: + + def test___init__(self): + + resampler = REVOResampler( + merge_dist=1.0, + char_dist=1.0, + dist_exponent=3, + distance=MockDistance(), + weights=True, + merge_alg="pairs", + pmin=0.1, + pmax=0.4, + seed=1, + num_proc=1, + ) + + assert resampler.seed == 1 + assert np.isclose(resampler.lpmin, np.log(0.1 / 100)) + + assert ( + REVOResampler( + merge_dist=1.0, + char_dist=1.0, + dist_exponent=3, + distance=MockDistance(), + weights=True, + merge_alg="pairs", + pmin=0.1, + pmax=0.4, + seed=None, + num_proc=1, + ).seed + is None + ) + + def test__novelty(self): + + resampler = REVOResampler( + merge_dist=1.0, + char_dist=1.0, + dist_exponent=3, + distance=MockDistance(), + weights=False, + merge_alg="pairs", + pmin=0.1, + pmax=0.4, + seed=1, + num_proc=1, + ) + + # UGLY,TOREV: there shouldn't be the possibility of negatives + # of these values but current code accepts them. + assert resampler._novelty(-1, 1) == 0.0 + assert resampler._novelty(0.1, -1) == 0.0 + assert resampler._novelty(0.0, 0) == 0.0 + assert resampler._novelty(0.1, 0) == 0.0 + assert resampler._novelty(0.0, 1) == 0.0 + + assert resampler._novelty(0.1, 1) == 1.0 + + # with weights + resampler = REVOResampler( + merge_dist=1.0, + char_dist=1.0, + dist_exponent=3, + distance=MockDistance(), + weights=True, + merge_alg="pairs", + pmin=0.1, + pmax=0.4, + seed=1, + num_proc=1, + ) + + assert resampler._novelty(0.4, 1) > 0.0 + assert resampler._novelty(0.1, 1) > 0.0 + + assert np.isclose(resampler._novelty(0.4, 1000), 0.0) + + def test__calc_variation(self): + + resampler = REVOResampler( + merge_dist=1.0, + char_dist=1.0, + dist_exponent=3, + distance=MockDistance(), + weights=False, + merge_alg="pairs", + pmin=0.1, + pmax=0.4, + seed=1, + num_proc=1, + ) + + variation, walker_variations = resampler._calc_variation( + [0.4, 0.1], + [1, 1], + [ + [ + 0.0, + 1.0, + ], + [ + 1.0, + 0.0, + ], + ], + ) + + def test__calc_variation_loss(self): + + resampler = REVOResampler( + merge_dist=1.0, + char_dist=1.0, + dist_exponent=3, + distance=MockDistance(), + weights=False, + merge_alg="pairs", + pmin=0.1, + pmax=0.4, + seed=1, + num_proc=1, + ) + + # only one option + assert resampler._calc_variation_loss( + [0.1, 0.4, 0.3], + [0.01, 0.02, 0.03], + [ + (0, 1), + ], + ) == (0, 1) + + resampler._calc_variation_loss( + [0.1, 0.4, 0.3], + [0.01, 0.02, 0.03], + [ + (0, 1), + (1, 2), + ], + ) + + # if no suitable pairs are found returns None + assert ( + resampler._calc_variation_loss( + [0.1, 0.4, 0.3], + [0.01, 0.02, 0.03], + [], + ) + is None + ) + + def test__find_eligible_merge_pairs(self): + + resampler = REVOResampler( + merge_dist=2.0, + char_dist=1.0, + dist_exponent=3, + distance=MockDistance(), + weights=False, + merge_alg="pairs", + pmin=0.1, + pmax=0.9, + seed=1, + num_proc=1, + ) + + # TODO: figure out some combinations of outputs that generates + # some eligible pairs + assert ( + resampler._find_eligible_merge_pairs( + [0.1, 0.4, 0.3], + [ + [0.0, 1.0, 2.0], + [1.0, 0.0, 1.5], + [2.0, 1.5, 0.0], + ], + 2, + [2, 2, 2], + ) + == [] + ) + + def test_decide(self): + + resampler = REVOResampler( + merge_dist=2.0, + char_dist=1.0, + dist_exponent=3, + distance=MockDistance(), + weights=False, + merge_alg="pairs", + pmin=0.1, + pmax=0.9, + seed=1, + num_proc=1, + ) + + recs, variation = resampler.decide( + [0.1, 0.4, 0.3], + [1, 1, 1], + [ + [0.0, 1.0, 2.0], + [1.0, 0.0, 1.5], + [2.0, 1.5, 0.0], + ], + ) + + def test__all_to_all_distance(self): + + resampler = REVOResampler( + merge_dist=2.0, + char_dist=1.0, + dist_exponent=3, + distance=MockDistance(), + weights=False, + merge_alg="pairs", + pmin=0.1, + pmax=0.9, + seed=1, + num_proc=1, + ) + + assert resampler._all_to_all_distance( + [ + Walker( + MockState(0), + 0.1, + ), + Walker( + MockState(2), + 0.1, + ), + ] + ) == ( + [ + [0.0, 2.0], + [2.0, 0.0], + ], + [MockState(0), MockState(2)], + ) + + @pytest.mark.flaky(reruns=4) + @pytest.mark.timeout(5) + def test__all_to_all_distance_pool(self): + + # test with pool + resampler = REVOResampler( + merge_dist=2.0, + char_dist=1.0, + dist_exponent=3, + distance=MockDistance(), + weights=False, + merge_alg="pairs", + pmin=0.1, + pmax=0.9, + seed=1, + num_proc=2, + ) + + assert resampler._all_to_all_distance( + [ + Walker( + MockState(0), + 0.1, + ), + Walker( + MockState(2), + 0.1, + ), + ] + ) == ( + [ + [0.0, 2.0], + [2.0, 0.0], + ], + [MockState(0), MockState(2)], + ) + + def test_resample(self): + + resampler = REVOResampler( + merge_dist=1.0, + char_dist=1.0, + dist_exponent=3, + distance=MockDistance(), + weights=True, + merge_alg="pairs", + pmin=0.1, + pmax=0.4, + seed=1, + num_proc=1, + ) + + resampled_walkers, resampling_records, resampler_records = resampler.resample( + [ + Walker( + MockState(1), + 0.1, + ), + Walker( + MockState(2), + 0.1, + ), + ] + ) + + assert len(resampled_walkers) == 2 + assert len(resampling_records) == 2 + assert len(resampler_records) == 1 diff --git a/tests/unit/test_runners/test_mock.py b/tests/unit/test_runners/test_mock.py new file mode 100644 index 00000000..6970e088 --- /dev/null +++ b/tests/unit/test_runners/test_mock.py @@ -0,0 +1,78 @@ +# Third Party Library +import pytest + +# First Party Library +from wepy.runners.mock import MockRunner, MockState +from wepy.runners.runner import ( + RunnerStateMachineError, + RunnerStateTransitionError, + RunnerStatus, +) + + +class TestMockRunner: + + def test_init(self): + + runner = MockRunner() + assert runner.status == RunnerStatus.PRE_INITIALIZATION + runner.init() + assert runner.status == RunnerStatus.INITIALIZED + + with pytest.raises(RunnerStateTransitionError): + runner.init() + + def test_pre_cycle(self): + runner = MockRunner() + + with pytest.raises(RunnerStateTransitionError): + runner.pre_cycle() + + runner.init() + runner.pre_cycle() + assert runner.status == RunnerStatus.PRE_CYCLE + + with pytest.raises(RunnerStateTransitionError): + runner.pre_cycle() + + runner.post_cycle(None) + + assert runner.status == RunnerStatus.POST_CYCLE + runner.pre_cycle() + assert runner.status == RunnerStatus.PRE_CYCLE + + def test_post_cycle(self): + runner = MockRunner() + + with pytest.raises(RunnerStateTransitionError): + runner.pre_cycle() + + runner.init() + runner.pre_cycle() + + with pytest.raises(RunnerStateTransitionError): + runner.pre_cycle() + + runner.post_cycle(None) + runner.pre_cycle() + runner.post_cycle(None) + + def test_run_segment(self): + + runner = MockRunner() + + with pytest.raises(RunnerStateMachineError): + runner.run_segment( + MockState(0), + 10, + ) + + runner.init() + runner.pre_cycle() + + assert runner.run_segment( + MockState(0), + 10, + )[ + 0 + ] == MockState(10) diff --git a/tests/unit/test_runners/test_openmm/test_logger.py b/tests/unit/test_runners/test_openmm/test_logger.py new file mode 100644 index 00000000..8f297382 --- /dev/null +++ b/tests/unit/test_runners/test_openmm/test_logger.py @@ -0,0 +1,576 @@ +# Standard Library +import copy +import logging +import time + +# Third Party Library +import openmm +import openmm.app +import openmm.unit +import pytest + +# First Party Library +from wepy.runners.openmm.logger import ( + EnergyLoggingReporter, + HeartBeatLoggingReporter, + LoggingReporter, + SamplingTimeIntervalLoggingReporter, + StepIntervalLoggingReporter, + UnitCellLoggingReporter, +) +from wepy.runners.openmm.reporter import OpenMMReporterNextReport +from wepy.runners.openmm.state import OpenMMState +from wepy_tools.systems.lennard_jones import LennardJonesPair + +STEP_TIME = 1 * openmm.unit.femtosecond + + +@pytest.fixture(scope="function") +def sim_components() -> tuple[ + openmm.State, + openmm.app.Topology, + openmm.System, + openmm.LangevinIntegrator, + openmm.Platform, +]: + + lj_sys = LennardJonesPair() + integrator = openmm.VerletIntegrator(STEP_TIME) + omm_state = ( + OpenMMState.from_dwim(positions=lj_sys.positions).to_state_wrapper().state + ) + + platform = openmm.Platform.getPlatformByName("Reference") + + return omm_state, lj_sys.topology, lj_sys.system, integrator, platform + + +class Test_LoggingReporter: + + def test_report(self, caplog): + + logger = logging.getLogger("test-LoggingReporter") + + def hello_log( + logger: logging.Logger, + simulation: openmm.app.Simulation, + state: openmm.State, + ) -> None: + + logger.info("Hello") + + hello_log_reporter = LoggingReporter( + logger, + callback=hello_log, + state_includes=["energy"], + ) + + # NOTE: dummy inputs since they aren't used in the hello_log callback + with caplog.at_level(logging.INFO, logger="test-LoggingReporter"): + hello_log_reporter.report(None, None) + + assert len(caplog.records) == 1 + assert caplog.records[0].levelname == "INFO" + assert caplog.records[0].msg == "Hello" + + caplog.clear() + + +class Test_StepIntervalLoggingReporter: + + def test_describeNextReport(self, sim_components): + + omm_state, sim_args = sim_components[0], sim_components[1:] + + logger = logging.getLogger("test-StepIntervalLoggingReporter") + + def hello_log( + logger: logging.Logger, + simulation: openmm.app.Simulation, + state: openmm.State, + ) -> None: + + logger.info("Hello") + + state_includes = ["energy"] + + step_logger = StepIntervalLoggingReporter( + logger, + callback=hello_log, + state_includes=state_includes, + step_interval=10, + start_time=time.time(), + ) + + simulation = openmm.app.Simulation(*sim_args) + simulation.context.setState(omm_state) + + assert step_logger.describeNextReport(simulation) == OpenMMReporterNextReport( + steps=10, + include=list(state_includes), + periodic=False, + ) + + simulation.step(1) + assert step_logger.describeNextReport(simulation) == OpenMMReporterNextReport( + steps=9, + include=list(state_includes), + periodic=False, + ) + + simulation.step(2) + assert step_logger.describeNextReport(simulation) == OpenMMReporterNextReport( + steps=7, + include=list(state_includes), + periodic=False, + ) + + # wraps back around at 0 + simulation.step(7) + assert step_logger.describeNextReport(simulation) == OpenMMReporterNextReport( + steps=10, + include=list(state_includes), + periodic=False, + ) + + def test_simulation(self, sim_components, caplog): + omm_state, sim_args = sim_components[0], sim_components[1:] + + simulation = openmm.app.Simulation(*sim_args) + simulation.context.setState(omm_state) + + logger_name = "test-StepIntervalLoggingReporter" + logger = logging.getLogger(logger_name) + + def hello_log( + logger: logging.Logger, + simulation: openmm.app.Simulation, + state: openmm.State, + ) -> None: + + logger.info("Hello") + + state_includes = ["energy"] + + step_logger = StepIntervalLoggingReporter( + logger, + callback=hello_log, + state_includes=state_includes, + step_interval=10, + start_time=time.time(), + ) + + simulation.reporters.append(step_logger) + + with caplog.at_level(logging.INFO, logger_name): + simulation.step(1) + + assert len(caplog.records) == 0 + caplog.clear() + + with caplog.at_level(logging.INFO, logger_name): + simulation.step(9) + + assert len(caplog.records) == 1 + assert caplog.records[0].msg == "Hello" + + caplog.clear() + + +class Test_SamplingTimeIntervalLoggingReporter: + + def test_describeNextReport(self, sim_components): + + omm_state, sim_args = sim_components[0], sim_components[1:] + topology, system, integrator, platform = sim_args + + logger = logging.getLogger("test-SamplingTimeIntervalLoggingReporter") + + def hello_log( + logger: logging.Logger, + simulation: openmm.app.Simulation, + state: openmm.State, + ) -> None: + + logger.info("Hello") + + state_includes = ["energy"] + + step_logger = SamplingTimeIntervalLoggingReporter( + logger, + callback=hello_log, + state_includes=state_includes, + sampling_time_interval=(10 * openmm.unit.femtosecond), + start_time=time.time(), + ) + + simulation = openmm.app.Simulation( + topology, + system, + copy.deepcopy(integrator), + platform, + ) + simulation.context.setState(omm_state) + + assert step_logger.describeNextReport(simulation) == OpenMMReporterNextReport( + steps=10, + include=list(state_includes), + periodic=False, + ) + + simulation.step(1) + assert step_logger.describeNextReport(simulation) == OpenMMReporterNextReport( + steps=9, + include=list(state_includes), + periodic=False, + ) + + simulation.step(2) + assert step_logger.describeNextReport(simulation) == OpenMMReporterNextReport( + steps=7, + include=list(state_includes), + periodic=False, + ) + + simulation.step(7) + assert step_logger.describeNextReport(simulation) == OpenMMReporterNextReport( + steps=10, + include=list(state_includes), + periodic=False, + ) + + simulation.step(3) + assert step_logger.describeNextReport(simulation) == OpenMMReporterNextReport( + steps=7, + include=list(state_includes), + periodic=False, + ) + + simulation = openmm.app.Simulation( + topology, + system, + copy.deepcopy(integrator), + platform, + ) + simulation.context.setState(omm_state) + + simulation.step(10) + assert step_logger.describeNextReport(simulation) == OpenMMReporterNextReport( + steps=10, + include=list(state_includes), + periodic=False, + ) + + # test wrapping around behavior + simulation = openmm.app.Simulation( + topology, + system, + copy.deepcopy(integrator), + platform, + ) + simulation.context.setState(omm_state) + + simulation.step(11) + assert step_logger.describeNextReport(simulation) == OpenMMReporterNextReport( + steps=9, + include=list(state_includes), + periodic=False, + ) + + def test_simulation(self, sim_components, caplog): + omm_state, sim_args = sim_components[0], sim_components[1:] + + topology, system, integrator, platform = sim_args + + logger_name = "test-SamplingTimeIntevalLoggingReporter" + logger = logging.getLogger(logger_name) + + def hello_log( + logger: logging.Logger, + simulation: openmm.app.Simulation, + state: openmm.State, + ) -> None: + + logger.info(f"Step: {state.getStepCount()}") + + state_includes = ["energy"] + + time_logger = SamplingTimeIntervalLoggingReporter( + logger, + callback=hello_log, + state_includes=state_includes, + sampling_time_interval=(10 * openmm.unit.femtosecond), + start_time=time.time(), + ) + + simulation = openmm.app.Simulation( + topology, + system, + copy.deepcopy(integrator), + platform, + ) + simulation.context.setState(omm_state) + simulation.reporters.append(time_logger) + + with caplog.at_level(logging.INFO, logger_name): + simulation.step(1) + + assert len(caplog.records) == 0 + logger.info("Next test") + caplog.clear() + + with caplog.at_level(logging.INFO, logger_name): + simulation.step(9) + + assert len(caplog.records) == 1 + assert caplog.records[0].msg == "Step: 10" + + logger.info("Next test") + caplog.clear() + + # test something longer + time_logger = SamplingTimeIntervalLoggingReporter( + logger, + callback=hello_log, + state_includes=state_includes, + sampling_time_interval=(2 * openmm.unit.femtosecond), + start_time=time.time(), + ) + + simulation = openmm.app.Simulation( + topology, + system, + copy.deepcopy(integrator), + platform, + ) + simulation.context.setState(omm_state) + simulation.reporters.append(time_logger) + + with caplog.at_level(logging.INFO, logger_name): + simulation.step(2) + assert len(caplog.records) == 1 + simulation.step(2) + simulation.step(2) + simulation.step(2) + simulation.step(2) + + assert len(caplog.records) == 5 + caplog.clear() + + +class Test_HeartBeatLoggingReporter: + + def test_logging_callback(self, sim_components, caplog): + + omm_state, sim_args = sim_components[0], sim_components[1:] + + topology, system, integrator, platform = sim_args + + logger_name = "test-HeartBeatLoggingReporter" + logger = logging.getLogger(logger_name) + + simulation = openmm.app.Simulation( + topology, + system, + copy.deepcopy(integrator), + platform, + ) + simulation.context.setState(omm_state) + + reporter = HeartBeatLoggingReporter( + logger, + step_interval=2, + start_time=time.time(), + ) + with caplog.at_level(logging.INFO, logger_name): + reporter.logging_callback( + logger, + simulation, + omm_state, + ) + + assert len(caplog.records) == 1 + + def test_simulation(self, sim_components, caplog): + + omm_state, sim_args = sim_components[0], sim_components[1:] + + topology, system, integrator, platform = sim_args + + logger_name = "test-HeartBeatLoggingReporter" + logger = logging.getLogger(logger_name) + + time_logger = HeartBeatLoggingReporter( + logger, + step_interval=2, + start_time=time.time(), + ) + + simulation = openmm.app.Simulation( + topology, + system, + copy.deepcopy(integrator), + platform, + ) + simulation.context.setState(omm_state) + simulation.reporters.append(time_logger) + + with caplog.at_level(logging.INFO, logger_name): + simulation.step(1) + + assert len(caplog.records) == 0 + caplog.clear() + + with caplog.at_level(logging.INFO, logger_name): + simulation.step(1) + + assert len(caplog.records) == 1 + caplog.clear() + + with caplog.at_level(logging.INFO, logger_name): + simulation.step(10) + + assert len(caplog.records) == 5 + caplog.clear() + + +class Test_EnergyLoggingReporter: + + def test_logging_callback(self, sim_components, caplog): + + omm_state, sim_args = sim_components[0], sim_components[1:] + + topology, system, integrator, platform = sim_args + + logger_name = "test-EnergyLoggingReporter" + logger = logging.getLogger(logger_name) + + simulation = openmm.app.Simulation( + topology, + system, + copy.deepcopy(integrator), + platform, + ) + simulation.context.setState(omm_state) + # do a step to compute energies + simulation.context.getIntegrator().step(1) + _omm_state = simulation.context.getState(energy=True) + + reporter = EnergyLoggingReporter( + logger, + sampling_time_interval=(1 * openmm.unit.femtosecond), + start_time=time.time(), + ) + with caplog.at_level(logging.INFO, logger_name): + reporter.logging_callback( + logger, + simulation, + _omm_state, + ) + + assert len(caplog.records) == 1 + + def test_simulation(self, sim_components, caplog): + omm_state, sim_args = sim_components[0], sim_components[1:] + + topology, system, integrator, platform = sim_args + + logger_name = "test-EnergyLoggingReporter" + logger = logging.getLogger(logger_name) + + energy_logger = EnergyLoggingReporter( + logger, + sampling_time_interval=(STEP_TIME * 2), + start_time=time.time(), + ) + + simulation = openmm.app.Simulation( + topology, + system, + copy.deepcopy(integrator), + platform, + ) + simulation.context.setState(omm_state) + simulation.reporters.append(energy_logger) + + with caplog.at_level(logging.INFO, logger_name): + simulation.step(2) + assert len(caplog.records) == 1 + simulation.step(2) + simulation.step(2) + simulation.step(2) + simulation.step(2) + + assert len(caplog.records) == 5 + caplog.clear() + + +class Test_UnitCellLoggingReporter: + + def test_logging_callback(self, sim_components, caplog): + + omm_state, sim_args = sim_components[0], sim_components[1:] + + topology, system, integrator, platform = sim_args + + logger_name = "test-UnitCellLoggingReporter" + logger = logging.getLogger(logger_name) + + simulation = openmm.app.Simulation( + topology, + system, + copy.deepcopy(integrator), + platform, + ) + simulation.context.setState(omm_state) + # do a step to compute energies + simulation.context.getIntegrator().step(1) + _omm_state = simulation.context.getState(energy=True) + + reporter = UnitCellLoggingReporter( + logger, + sampling_time_interval=(1 * openmm.unit.femtosecond), + start_time=time.time(), + ) + with caplog.at_level(logging.INFO, logger_name): + reporter.logging_callback( + logger, + simulation, + _omm_state, + ) + + assert len(caplog.records) == 1 + + def test_simulation(self, sim_components, caplog): + omm_state, sim_args = sim_components[0], sim_components[1:] + + topology, system, integrator, platform = sim_args + + logger_name = "test-UnitCellLoggingReporter" + logger = logging.getLogger(logger_name) + + energy_logger = UnitCellLoggingReporter( + logger, + sampling_time_interval=(STEP_TIME * 2), + start_time=time.time(), + ) + + simulation = openmm.app.Simulation( + topology, + system, + copy.deepcopy(integrator), + platform, + ) + simulation.context.setState(omm_state) + simulation.reporters.append(energy_logger) + + with caplog.at_level(logging.INFO, logger_name): + simulation.step(2) + assert len(caplog.records) == 1 + simulation.step(2) + simulation.step(2) + simulation.step(2) + simulation.step(2) + + assert len(caplog.records) == 5 + caplog.clear() diff --git a/tests/unit/test_runners/test_openmm/test_runner.py b/tests/unit/test_runners/test_openmm/test_runner.py new file mode 100644 index 00000000..be837bab --- /dev/null +++ b/tests/unit/test_runners/test_openmm/test_runner.py @@ -0,0 +1,351 @@ +# Standard Library +import copy +import logging + +# Third Party Library +import attrs +import numpy as np +import openmm +import openmm.app +import openmm.unit +import pytest + +# First Party Library +from wepy.runners.openmm.logger import StepIntervalLoggingReporter +from wepy.runners.openmm.runner import ( + _DEFAULT_HEARTBEAT_INTERVAL, + _DEFAULT_STATE_TIME_INTERVAL, + OpenMMRunner, + OpenMMRunnerFactory, + OpenMMRunnerSegmentData, +) +from wepy.runners.openmm.state import ( + OpenMMState, + dummy_context, +) +from wepy.runners.runner import ( + RunnerStateError, + RunnerStateTransitionError, + RunnerStatus, +) +from wepy_tools.systems.lennard_jones import LennardJonesPair + +UNIT_CUBE = np.array( + [ + [1.0, 0.0, 0.0], + [0.0, 1.0, 0.0], + [0.0, 0.0, 1.0], + ] +) + +STEP_SIZE = 2 * openmm.unit.femtoseconds + + +@pytest.fixture +def runner_components() -> ( + tuple[openmm.System, openmm.app.Topology, openmm.LangevinIntegrator] +): + + lj_sys = LennardJonesPair() + + integrator = openmm.LangevinIntegrator(300.0, 0.1, STEP_SIZE) + + return lj_sys.system, lj_sys.topology, integrator + + +@pytest.fixture +def omm_context() -> openmm.Context: + + lj_sys = LennardJonesPair() + + ctx = dummy_context(lj_sys.system, lj_sys.positions) + + return ctx + + +@attrs.define +class Spy: + touched: bool = False + + def touch(self) -> None: + self.touched = True + + +class TouchGlobalStepIntervalLoggingReporter(StepIntervalLoggingReporter): + + def __init__( + self, + logger: logging.Logger, + start_time: int, + spy: Spy, + ) -> None: + + self.spy = spy + + super().__init__( + logger=logger, + callback=self.touch, + state_includes=[], + # NOTE: hardcoded + step_interval=1, + start_time=start_time, + ) + + def touch(self, *args) -> None: + self.spy.touch() + + +class Test_OpenMMRunner: + + def test___init__(self, runner_components): + + system, topology, integrator = runner_components + + runner = OpenMMRunner( + system=copy.deepcopy(system), + topology=copy.deepcopy(topology), + integrator=copy.deepcopy(integrator), + openmm_reporter_factories=None, + ) + assert runner.openmm_reporter_factories == [] + assert runner._openmm_reporters is None + assert runner._init_time is None + + runner = OpenMMRunner( + system=copy.deepcopy(system), + topology=copy.deepcopy(topology), + integrator=copy.deepcopy(integrator), + ) + assert runner.openmm_reporter_factories == [] + + assert runner.status == RunnerStatus.PRE_INITIALIZATION + + SPY = Spy() + + def _mock_factory( + logger: logging.Logger, start_time: int + ) -> TouchGlobalStepIntervalLoggingReporter: + return TouchGlobalStepIntervalLoggingReporter( + logger=logger, start_time=start_time, spy=SPY + ) + + runner = OpenMMRunner( + system=copy.deepcopy(system), + topology=copy.deepcopy(topology), + integrator=copy.deepcopy(integrator), + openmm_reporter_factories=[_mock_factory], + ) + assert len(runner.openmm_reporter_factories) == 1 + + def test_init(self, runner_components): + + system, topology, integrator = runner_components + runner = OpenMMRunner( + system=copy.deepcopy(system), + topology=copy.deepcopy(topology), + integrator=copy.deepcopy(integrator), + ) + + assert runner._openmm_reporters is None + + runner.init() + assert runner.status == RunnerStatus.INITIALIZED + + # check the default openmm reporters were constructed + assert runner._init_time is not None + + # test status, can't init twice + with pytest.raises(RunnerStateTransitionError): + runner.init() + + def test_pre_cycle(self, runner_components): + system, topology, integrator = runner_components + + runner = OpenMMRunner( + system=copy.deepcopy(system), + topology=copy.deepcopy(topology), + integrator=copy.deepcopy(integrator), + ) + + with pytest.raises(RunnerStateTransitionError): + runner.pre_cycle() + + runner.init() + assert runner.status == RunnerStatus.INITIALIZED + + runner.pre_cycle() + assert runner.status == RunnerStatus.PRE_CYCLE + assert runner._pre_cycle_time is not None + + def test_run_segment(self, runner_components): + + system, topology, integrator = runner_components + + lj_sys = LennardJonesPair() + + state = OpenMMState.from_dwim(positions=lj_sys.positions) + + runner = OpenMMRunner( + system=copy.deepcopy(system), + topology=copy.deepcopy(topology), + integrator=copy.deepcopy(integrator), + ) + + with pytest.raises(RunnerStateError): + runner.run_segment(state, 2) + + runner.init() + with pytest.raises(RunnerStateError): + runner.run_segment(state, 2) + + runner.pre_cycle() + + new_state, segment_data = runner.run_segment(state, 2) + + assert "positions" in new_state + assert "velocities" in new_state + + runner = OpenMMRunner( + system=copy.deepcopy(system), + topology=copy.deepcopy(topology), + integrator=copy.deepcopy(integrator), + get_state_keys={ + "positions", + }, + ) + runner.init() + runner.pre_cycle() + + new_state, segment_data = runner.run_segment( + new_state, + 2, + ) + assert new_state["positions"] is not None + assert "velocities" not in new_state + + assert isinstance(segment_data, OpenMMRunnerSegmentData) + + # test that openmm reporters are being called + SPY = Spy() + + def _mock_factory( + logger: logging.Logger, start_time: int + ) -> TouchGlobalStepIntervalLoggingReporter: + return TouchGlobalStepIntervalLoggingReporter( + logger=logger, start_time=start_time, spy=SPY + ) + + runner = OpenMMRunner( + system=copy.deepcopy(system), + topology=copy.deepcopy(topology), + integrator=copy.deepcopy(integrator), + openmm_reporter_factories=[_mock_factory], + ) + runner.init() + runner.pre_cycle() + + assert not SPY.touched + new_state, segment_data = runner.run_segment( + state, + 10, + ) + + assert SPY.touched + + def test_post_cycle(self, runner_components): + + system, topology, integrator = runner_components + + lj_sys = LennardJonesPair() + + state = OpenMMState.from_dwim(positions=lj_sys.positions) + + runner = OpenMMRunner( + system=copy.deepcopy(system), + topology=copy.deepcopy(topology), + integrator=copy.deepcopy(integrator), + ) + + with pytest.raises(RunnerStateTransitionError): + runner.post_cycle(None) + + runner.init() + with pytest.raises(RunnerStateTransitionError): + runner.post_cycle(None) + + runner.pre_cycle() + + # NOTE: that you don't need to call run_segment, because in a + # standard use case this would be done in a subprocess. Any + # state changes must be reified in the RunSegmentData + + runner.post_cycle(None) + + assert runner.status == RunnerStatus.POST_CYCLE + + # with a run_segment + runner = OpenMMRunner( + system=copy.deepcopy(system), + topology=copy.deepcopy(topology), + integrator=copy.deepcopy(integrator), + ) + + with pytest.raises(RunnerStateTransitionError): + runner.post_cycle(None) + + runner.init() + with pytest.raises(RunnerStateTransitionError): + runner.post_cycle(None) + + runner.pre_cycle() + new_state, segment_data = runner.run_segment( + state, + 10, + ) + + runner.post_cycle([segment_data]) + + assert runner.status == RunnerStatus.POST_CYCLE + + +def test_OpenMMRunnerFactory(runner_components): + + system, topology, integrator = runner_components + + # NOTE: no need to copy at this level because the factory handles + # that for you + omm_factory = OpenMMRunnerFactory( + system=system, + topology=topology, + integrator=integrator, + ) + + assert isinstance(omm_factory(), OpenMMRunner) + + +def test_defaults(runner_components): + """A test that exercises the default settings of the logging reporters.""" + + system, topology, integrator = runner_components + + lj_sys = LennardJonesPair() + + state = OpenMMState.from_dwim(positions=lj_sys.positions) + + runner = OpenMMRunnerFactory( + system=system, + topology=topology, + integrator=integrator, + )() + + time_interval_steps = _DEFAULT_STATE_TIME_INTERVAL / STEP_SIZE + + _steps = ( + time_interval_steps + if time_interval_steps > _DEFAULT_HEARTBEAT_INTERVAL + else _DEFAULT_HEARTBEAT_INTERVAL + ) + + runner.init() + runner.pre_cycle() + new_state, segment_data = runner.run_segment(state, 2 * _steps) diff --git a/tests/unit/test_runners/test_openmm/test_state.py b/tests/unit/test_runners/test_openmm/test_state.py new file mode 100644 index 00000000..68182004 --- /dev/null +++ b/tests/unit/test_runners/test_openmm/test_state.py @@ -0,0 +1,1064 @@ +# Third Party Library +import numpy as np +import openmm +import openmm.unit +import pytest +from immutables import Map as frozenmap +from lxml import etree + +# First Party Library +from wepy.runners.openmm.state import ( + OpenMMState, + OpenMMStateValidationError, + OpenMMStateWrapper, + _gen_vec3_element, + dummy_context, + get_context_state, + get_state_core_fields_present, + get_state_fields_present, + resolve_state_data_type_enum_values, + state_to_xml, +) +from wepy_tools.systems.lennard_jones import LennardJonesPair + +UNIT_CUBE = np.array( + [ + [1.0, 0.0, 0.0], + [0.0, 1.0, 0.0], + [0.0, 0.0, 1.0], + ] +) + + +def test_dummy_context(): + lj_sys = LennardJonesPair() + + dummy_context( + lj_sys.system, + np.array( + [ + [0.0, 0.0, 0.0], + [1.0, 0.0, 0.0], + ] + ) + * openmm.unit.nanometer, + ) + + dummy_context( + lj_sys.system, + np.array( + [ + [0.0, 0.0, 0.0], + [1.0, 0.0, 0.0], + ] + ) + * openmm.unit.angstrom, + unitcell=UNIT_CUBE * openmm.unit.nanometer, + ) + + +def test_resolve_state_data_type_enum_values(): + + assert resolve_state_data_type_enum_values() == frozenmap( + { + "positions": 1, + "velocities": 2, + "forces": 4, + "energy": 8, + "parameters": 16, + "parameter_derivatives": 32, + "integrator_parameters": 64, + } + ) + + +@pytest.fixture +def omm_context() -> openmm.Context: + + lj_sys = LennardJonesPair() + + ctx = dummy_context(lj_sys.system, lj_sys.positions) + + return ctx + + +def test_get_context_state(omm_context): + + get_context_state(omm_context, {}) + get_context_state( + omm_context, + { + "positions", + "velocities", + "forces", + "parameters", + "parameter_derivatives", + "kinetic_energy", + "potential_energy", + "time", + "box_vectors", + "box_volume", + }, + ) + + +def test_get_state_core_fields_present(omm_context): + + state = omm_context.getState(positions=True) + assert get_state_core_fields_present(state) == frozenset({"positions"}) + + state = omm_context.getState(positions=True, velocities=True, forces=True) + assert get_state_core_fields_present(state) == frozenset( + {"positions", "velocities", "forces"} + ) + + state = omm_context.getState(parameters=True, parameterDerivatives=True) + assert get_state_core_fields_present(state) == frozenset( + { + "parameters", + "parameter_derivatives", + } + ) + + state = omm_context.getState(energy=True) + assert get_state_core_fields_present(state) == frozenset( + { + "energy", + } + ) + + +def test_get_state_fields_present(omm_context): + + state = omm_context.getState() + assert get_state_fields_present(state) == frozenset( + { + "time", + "box_vectors", + "box_volume", + } + ) + + state = omm_context.getState(positions=True) + assert get_state_fields_present(state) == frozenset( + { + "time", + "box_vectors", + "box_volume", + "positions", + } + ) + + state = omm_context.getState(positions=True, velocities=True, forces=True) + assert get_state_fields_present(state) == frozenset( + { + "time", + "box_vectors", + "box_volume", + "positions", + "velocities", + "forces", + } + ) + + state = omm_context.getState(parameters=True, parameterDerivatives=True) + assert get_state_fields_present(state) == frozenset( + { + "time", + "box_vectors", + "box_volume", + "parameters", + "parameter_derivatives", + } + ) + + state = omm_context.getState(energy=True) + assert get_state_fields_present(state) == frozenset( + { + "time", + "box_vectors", + "box_volume", + "kinetic_energy", + "potential_energy", + } + ) + + +@pytest.mark.usefixtures("omm_context") +class Test_OpenMMStateWrapper: + + def test___init__(self, omm_context): + + state = omm_context.getState( + positions=True, + velocities=True, + forces=True, + ) + + state_wrapper = OpenMMStateWrapper(state) + + assert state_wrapper.core_fields == frozenset( + { + "positions", + "velocities", + "forces", + } + ) + + assert state_wrapper.fields == frozenset( + { + "time", + "box_vectors", + "box_volume", + "positions", + "velocities", + "forces", + } + ) + + # test after running some MD + omm_context.getIntegrator().step(1) + + state = omm_context.getState( + positions=True, + velocities=True, + forces=True, + energy=True, + parameters=True, + parameterDerivatives=True, + ) + + state_wrapper = OpenMMStateWrapper(state) + + assert state_wrapper.fields == frozenset( + { + "time", + "box_vectors", + "box_volume", + "positions", + "velocities", + "forces", + "kinetic_energy", + "potential_energy", + "parameters", + "parameter_derivatives", + } + ) + + def test_fields_in(self, omm_context): + + state = omm_context.getState( + positions=True, + velocities=True, + forces=True, + ) + + state_wrapper = OpenMMStateWrapper(state) + + assert state_wrapper.fields_in( + { + "time", + "box_vectors", + "box_volume", + "positions", + "velocities", + "forces", + } + ) + + assert not state_wrapper.fields_in({"something"}) + assert not state_wrapper.fields_in({"parameters"}) + + def test___contains__(self, omm_context): + + state = omm_context.getState( + positions=True, + velocities=True, + forces=True, + ) + + state_wrapper = OpenMMStateWrapper(state) + + assert "time" in state_wrapper + assert "box_vectors" in state_wrapper + assert "positions" in state_wrapper + + def test___len__(self, omm_context): + + state = omm_context.getState( + positions=True, + velocities=True, + forces=True, + ) + + state_wrapper = OpenMMStateWrapper(state) + assert len(state_wrapper) == 6 + + def test___getitem__(self, omm_context): + + omm_context.getIntegrator().step(1) + + state = omm_context.getState( + positions=True, + velocities=True, + forces=True, + energy=True, + parameters=True, + parameterDerivatives=True, + integratorParameters=True, + ) + + state_wrapper = OpenMMStateWrapper(state) + + # always there + + assert isinstance(state_wrapper["time"], openmm.unit.Quantity) + + # core data + assert isinstance(state_wrapper["positions"], openmm.unit.Quantity) + assert isinstance(state_wrapper["velocities"], openmm.unit.Quantity) + assert isinstance(state_wrapper["forces"], openmm.unit.Quantity) + assert isinstance(state_wrapper["parameters"], frozenmap) + assert isinstance(state_wrapper["parameter_derivatives"], frozenmap) + + # extras + assert isinstance(state_wrapper["potential_energy"], openmm.unit.Quantity) + assert isinstance(state_wrapper["kinetic_energy"], openmm.unit.Quantity) + + # only when there is a box + assert isinstance(state_wrapper["box_vectors"], openmm.unit.Quantity) + assert isinstance(state_wrapper["box_volume"], openmm.unit.Quantity) + + def test___eq__(self, omm_context): + state1 = omm_context.getState() + state_wrapper1 = OpenMMStateWrapper(state1) + state2 = omm_context.getState() + state_wrapper2 = OpenMMStateWrapper(state2) + + assert state_wrapper1 == state_wrapper2 + + def test_dict(self, omm_context): + + state = omm_context.getState() + + state_wrapper = OpenMMStateWrapper(state) + assert set(state_wrapper.to_dict().keys()) == { + "time", + "box_vectors", + "box_volume", + } + + state = omm_context.getState( + positions=True, + velocities=True, + forces=True, + energy=True, + parameters=True, + parameterDerivatives=True, + integratorParameters=True, + ) + + state_wrapper = OpenMMStateWrapper(state) + assert set(state_wrapper.to_dict().keys()) == { + "time", + "box_vectors", + "box_volume", + "positions", + "velocities", + "forces", + "parameters", + "parameter_derivatives", + "kinetic_energy", + "potential_energy", + } + + def test_serialize_xml(self, omm_context): + + state = omm_context.getState() + + state_wrapper = OpenMMStateWrapper(state) + + assert openmm.XmlSerializer.serialize(state) == state_wrapper.serialize_xml() + + def test_from_dict(self): + + time = 0.0 * openmm.unit.picosecond + bvs = UNIT_CUBE * openmm.unit.nanometer + + positions = ( + np.array( + [ + [1.0, 0.0, 0.0], + [1.0, 0.0, 0.0], + ] + ) + * openmm.unit.nanometer + ) + velocities = ( + np.array( + [ + [1.0, 0.0, 0.0], + [1.0, 0.0, 0.0], + ] + ) + * openmm.unit.nanometer + / openmm.unit.picosecond + ) + forces = ( + np.array( + [ + [1.0, 0.0, 0.0], + [1.0, 0.0, 0.0], + ] + ) + * openmm.unit.kilojoule + / (openmm.unit.nanometer * openmm.unit.mole) + ) + + d = dict( + time=time, + box_vectors=bvs, + positions=positions, + velocities=velocities, + forces=forces, + ) + assert OpenMMState.from_dict(d) == OpenMMState(**d) + + def test_from_xml(self, omm_context): + + state = omm_context.getState() + + state_xml = openmm.XmlSerializer.serialize(state) + + state_wrapper = OpenMMStateWrapper.from_xml(state_xml) + + assert state_wrapper == OpenMMStateWrapper(state) + + def test_from_dict(self, omm_context): + + lj_sys = LennardJonesPair() + system = lj_sys.system + + state_d = { + "time": 0.0 * openmm.unit.seconds, + "box_vectors": UNIT_CUBE * openmm.unit.nanometer, + } + + sw = OpenMMStateWrapper.from_dict(system, state_d) + assert sw is not None + assert "time" in sw + assert "box_vectors" in sw + assert "box_volume" in sw + + state_d = { + "time": 0.0 * openmm.unit.seconds, + "box_vectors": UNIT_CUBE * openmm.unit.nanometer, + "positions": lj_sys.positions, + "velocities": np.array( + [ + [1.0, 0.0, 0.0], + [1.0, 0.0, 0.0], + ] + ) + * openmm.unit.nanometer + / openmm.unit.picosecond, + # TODO: to tes this I need a system with a parametrizable + # force + # + # "parameters" : { + # "a" : 1.0, + # "b" : 2.0 + # } + } + + sw = OpenMMStateWrapper.from_dict(system, state_d) + assert sw is not None + assert "time" in sw + assert "box_vectors" in sw + assert "box_volume" in sw + assert "positions" in sw + + with pytest.raises(OpenMMStateValidationError): + OpenMMStateWrapper.from_dict(system, {}) + + # invalid parameter + with pytest.raises(OpenMMStateValidationError): + OpenMMStateWrapper.from_dict( + system, + { + "parameters": { + "a": 1.0, + } + }, + ) + + +class Test_OpenMMState: + + def test__validate_array3ds(self): + + pos1 = ( + np.array( + [ + [1.0, 0.0, 0.0], + ] + ) + * openmm.unit.nanometer + ) + vel1 = ( + np.array( + [ + [1.0, 0.0, 0.0], + ] + ) + * openmm.unit.nanometer + / openmm.unit.picosecond + ) + force1 = ( + np.array( + [ + [1.0, 0.0, 0.0], + ] + ) + * openmm.unit.kilojoule + / (openmm.unit.nanometer * openmm.unit.mole) + ) + + good_cases = [ + (None, None, None), + (pos1, None, None), + (None, vel1, None), + (None, None, force1), + (pos1, vel1, None), + (pos1, None, force1), + (None, vel1, force1), + (pos1, vel1, force1), + ] + + for c in good_cases: + assert OpenMMState._validate_array3ds(*c) is True + + pos2 = ( + np.array( + [ + [1.0, 0.0, 0.0], + [1.0, 0.0, 0.0], + ] + ) + * openmm.unit.nanometer + ) + vel2 = ( + np.array( + [ + [1.0, 0.0, 0.0], + [1.0, 0.0, 0.0], + ] + ) + * openmm.unit.nanometer + / openmm.unit.picosecond + ) + force2 = ( + np.array( + [ + [1.0, 0.0, 0.0], + [1.0, 0.0, 0.0], + ] + ) + * openmm.unit.kilojoule + / (openmm.unit.nanometer * openmm.unit.mole) + ) + + bad_cases = [ + (pos1, vel2, None), + (pos1, None, force2), + (pos1, vel2, force2), + ] + + for c in bad_cases: + with pytest.raises(OpenMMStateValidationError): + OpenMMState._validate_array3ds(*c) + + def test___init__(self): + + bvs = UNIT_CUBE * openmm.unit.nanometer + + OpenMMState( + time=0.0 * openmm.unit.picosecond, + box_vectors=bvs, + ) + OpenMMState( + time=0.0 * openmm.unit.picosecond, + box_vectors=bvs, + positions=np.array( + [ + [1.0, 0.0, 0.0], + [1.0, 0.0, 0.0], + ] + ) + * openmm.unit.nanometer, + velocities=np.array( + [ + [1.0, 0.0, 0.0], + [1.0, 0.0, 0.0], + ] + ) + * openmm.unit.nanometer + / openmm.unit.picosecond, + forces=np.array( + [ + [1.0, 0.0, 0.0], + [1.0, 0.0, 0.0], + ] + ) + * openmm.unit.kilojoule + / (openmm.unit.nanometer * openmm.unit.mole), + ) + + def test___len__(self): + assert ( + len( + OpenMMState( + time=0.0 * openmm.unit.picosecond, + box_vectors=UNIT_CUBE * openmm.unit.nanometer, + ) + ) + == 2 + ) + + assert ( + len( + OpenMMState( + time=0.0 * openmm.unit.picosecond, + box_vectors=UNIT_CUBE * openmm.unit.nanometer, + positions=np.array( + [ + [1.0, 0.0, 0.0], + [1.0, 0.0, 0.0], + ] + ) + * openmm.unit.nanometer, + velocities=np.array( + [ + [1.0, 0.0, 0.0], + [1.0, 0.0, 0.0], + ] + ) + * openmm.unit.nanometer + / openmm.unit.picosecond, + forces=np.array( + [ + [1.0, 0.0, 0.0], + [1.0, 0.0, 0.0], + ] + ) + * openmm.unit.kilojoule + / (openmm.unit.nanometer * openmm.unit.mole), + ) + ) + == 5 + ) + + def test___contains__(self): + small_state = OpenMMState( + time=0.0 * openmm.unit.picosecond, + box_vectors=UNIT_CUBE * openmm.unit.nanometer, + ) + + assert "time" in small_state + assert "box_vectors" in small_state + assert "box_volume" not in small_state + assert "positions" not in small_state + assert "velocities" not in small_state + assert "forces" not in small_state + assert "kinetic_energy" not in small_state + assert "potential_energy" not in small_state + assert "parameters" not in small_state + assert "parameter_derivatives" not in small_state + + large_state = OpenMMState( + time=0.0 * openmm.unit.picosecond, + box_vectors=UNIT_CUBE * openmm.unit.nanometer, + positions=np.array( + [ + [1.0, 0.0, 0.0], + [1.0, 0.0, 0.0], + ] + ) + * openmm.unit.nanometer, + velocities=np.array( + [ + [1.0, 0.0, 0.0], + [1.0, 0.0, 0.0], + ] + ) + * openmm.unit.nanometer + / openmm.unit.picosecond, + forces=np.array( + [ + [1.0, 0.0, 0.0], + [1.0, 0.0, 0.0], + ] + ) + * openmm.unit.kilojoule + / (openmm.unit.nanometer * openmm.unit.mole), + ) + + assert "time" in large_state + assert "box_vectors" in large_state + assert "box_volume" not in large_state + assert "positions" in large_state + assert "velocities" in large_state + assert "forces" in large_state + assert "kinetic_energy" not in large_state + assert "potential_energy" not in large_state + assert "parameters" not in large_state + assert "parameter_derivatives" not in large_state + + def test___getitem__(self): + + bvs = UNIT_CUBE * openmm.unit.nanometer + + state = OpenMMState( + time=0.0 * openmm.unit.picosecond, + box_vectors=bvs, + ) + + assert state["time"] == 0.0 * openmm.unit.picosecond + assert state["box_vectors"] is not None + + with pytest.raises(KeyError): + state["invalid"] + + with pytest.raises(ValueError): + state["positions"] + + state = OpenMMState( + time=0.0 * openmm.unit.picosecond, + box_vectors=bvs, + positions=np.array( + [ + [1.0, 0.0, 0.0], + [1.0, 0.0, 0.0], + ] + ) + * openmm.unit.nanometer, + velocities=np.array( + [ + [1.0, 0.0, 0.0], + [1.0, 0.0, 0.0], + ] + ) + * openmm.unit.nanometer + / openmm.unit.picosecond, + forces=np.array( + [ + [1.0, 0.0, 0.0], + [1.0, 0.0, 0.0], + ] + ) + * openmm.unit.kilojoule + / (openmm.unit.nanometer * openmm.unit.mole), + ) + + assert state["positions"] is not None + assert state["velocities"] is not None + assert state["forces"] is not None + + def test_from_state(self, omm_context): + s = OpenMMState.from_state(omm_context.getState()) + + assert s.time is not None + assert s.box_vectors is not None + assert s.box_volume is not None + + s = OpenMMState.from_state(omm_context.getState(positions=True)) + assert s.positions is not None + + omm_context.setVelocitiesToTemperature(300.0 * openmm.unit.kelvin) + s = OpenMMState.from_state( + omm_context.getState( + positions=True, + velocities=True, + ) + ) + + assert s.velocities is not None + + # get some forces to actually use + omm_context.getIntegrator().step(0) + + s = OpenMMState.from_state( + omm_context.getState( + positions=True, + velocities=True, + forces=True, + ) + ) + assert s.forces is not None + + s = OpenMMState.from_state( + omm_context.getState( + positions=True, + velocities=True, + forces=True, + energy=True, + parameters=True, + parameterDerivatives=True, + ) + ) + + assert s.kinetic_energy is not None + assert s.potential_energy is not None + + assert s.parameters is not None + assert s.parameter_derivatives is not None + + def test_from_state_wrapper(self, omm_context): + + sw = OpenMMStateWrapper( + omm_context.getState( + positions=True, + velocities=True, + forces=True, + energy=True, + parameters=True, + parameterDerivatives=True, + ) + ) + OpenMMState.from_state_wrapper(sw) + + def test_from_dwim(self): + positions = ( + np.array( + [ + [1.0, 0.0, 0.0], + [1.0, 0.0, 0.0], + ] + ) + * openmm.unit.nanometer + ) + velocities = ( + np.array( + [ + [1.0, 0.0, 0.0], + [1.0, 0.0, 0.0], + ] + ) + * openmm.unit.nanometer + / openmm.unit.picosecond + ) + forces = ( + np.array( + [ + [1.0, 0.0, 0.0], + [1.0, 0.0, 0.0], + ] + ) + * openmm.unit.kilojoule + / (openmm.unit.nanometer * openmm.unit.mole) + ) + + assert OpenMMState.from_dwim( + box_vectors=None, + positions=positions, + velocities=velocities, + forces=forces, + ) == OpenMMState( + time=OpenMMState.DWIM_DEFAULT_TIME, + box_volume=OpenMMState.DWIM_DEFAULT_BOX_VOLUME, + box_vectors=OpenMMState.DWIM_DEFAULT_UNITCELL, + positions=positions, + velocities=velocities, + forces=forces, + ) + + bv = np.array( + [ + [2.0, 0.0, 0.0], + [0.0, 2.0, 0.0], + [0.0, 0.0, 2.0], + ] + ) + + assert OpenMMState.from_dwim( + box_vectors=bv, + positions=positions, + ) == OpenMMState( + time=OpenMMState.DWIM_DEFAULT_TIME, + box_volume=OpenMMState.DWIM_DEFAULT_BOX_VOLUME, + box_vectors=bv, + positions=positions, + ) + + def test_dict(self): + + time = 0.0 * openmm.unit.picosecond + bvs = UNIT_CUBE * openmm.unit.nanometer + + positions = ( + np.array( + [ + [1.0, 0.0, 0.0], + [1.0, 0.0, 0.0], + ] + ) + * openmm.unit.nanometer + ) + velocities = ( + np.array( + [ + [1.0, 0.0, 0.0], + [1.0, 0.0, 0.0], + ] + ) + * openmm.unit.nanometer + / openmm.unit.picosecond + ) + forces = ( + np.array( + [ + [1.0, 0.0, 0.0], + [1.0, 0.0, 0.0], + ] + ) + * openmm.unit.kilojoule + / (openmm.unit.nanometer * openmm.unit.mole) + ) + + os = OpenMMState( + time=time, + box_vectors=bvs, + positions=positions, + velocities=velocities, + forces=forces, + ) + + osd = os.dict() + assert set(osd.keys()) == { + "time", + "box_vectors", + "positions", + "velocities", + "forces", + } + + def test_to_state_wrapper(self): + + time = 0.0 * openmm.unit.picosecond + positions = ( + np.array( + [ + [1.0, 0.0, 0.0], + [1.0, 0.0, 0.0], + ] + ) + * openmm.unit.nanometer + ) + velocities = ( + np.array( + [ + [1.0, 0.0, 0.0], + [1.0, 0.0, 0.0], + ] + ) + * openmm.unit.nanometer + / openmm.unit.picosecond + ) + forces = ( + np.array( + [ + [1.0, 0.0, 0.0], + [1.0, 0.0, 0.0], + ] + ) + * openmm.unit.kilojoule + / (openmm.unit.nanometer * openmm.unit.mole) + ) + + unitcell = UNIT_CUBE * openmm.unit.nanometer + + # with optional system + lj_sys = LennardJonesPair() + system = lj_sys.system + os = OpenMMState( + time=time, + box_vectors=unitcell, + positions=positions, + velocities=velocities, + forces=forces, + ) + + os.to_state_wrapper(system) + + # without the system + os = OpenMMState( + time=time, + box_vectors=unitcell, + positions=positions, + velocities=velocities, + forces=forces, + ) + + os.to_state_wrapper() + + +def test__gen_vec3_element(): + + root = etree.Element("root") + + el = _gen_vec3_element("position", root, (0, 1, 2)) + + assert el.tag == "position" + assert dict(el.attrib) == { + "x": "0", + "y": "1", + "z": "2", + } + + +def test_state_to_xml(): + + bvs = UNIT_CUBE * openmm.unit.nanometer + + simple_state = OpenMMState( + time=0.0 * openmm.unit.picosecond, + box_vectors=bvs, + ) + + simple_xml = state_to_xml(simple_state) + + assert isinstance(openmm.XmlSerializer.deserialize(simple_xml), openmm.State) + + full_state = OpenMMState( + time=0.0 * openmm.unit.picosecond, + box_vectors=bvs, + positions=np.array( + [ + [1.0, 0.0, 0.0], + [1.0, 0.0, 0.0], + ] + ) + * openmm.unit.nanometer, + velocities=np.array( + [ + [1.0, 0.0, 0.0], + [1.0, 0.0, 0.0], + ] + ) + * openmm.unit.nanometer + / openmm.unit.picosecond, + forces=np.array( + [ + [1.0, 0.0, 0.0], + [1.0, 0.0, 0.0], + ] + ) + * openmm.unit.kilojoule + / (openmm.unit.nanometer * openmm.unit.mole), + potential_energy=(1.0 * (openmm.unit.kilojoule / openmm.unit.mole)), + kinetic_energy=(1.0 * (openmm.unit.kilojoule / openmm.unit.mole)), + ) + + full_xml = state_to_xml(full_state) + + assert isinstance(openmm.XmlSerializer.deserialize(full_xml), openmm.State) diff --git a/tests/unit/test_runners/test_runner.py b/tests/unit/test_runners/test_runner.py new file mode 100644 index 00000000..c1787800 --- /dev/null +++ b/tests/unit/test_runners/test_runner.py @@ -0,0 +1,140 @@ +# Third Party Library +import attrs +import pytest + +# First Party Library +from wepy.runners.runner import ( + NoRunner, + RunnerEvent, + RunnerStateError, + RunnerStateMachine, + RunnerStateTransitionError, + RunnerStatus, +) +from wepy.walker import Walker, WalkerState + + +class Test_RunnerStateMachine: + + def test_validate_event(self): + + sm = RunnerStateMachine(state=RunnerStatus.PRE_INITIALIZATION) + assert sm.validate_event(RunnerEvent.INIT) + + sm = RunnerStateMachine(state=RunnerStatus.PRE_INITIALIZATION) + with pytest.raises(RunnerStateTransitionError): + sm.validate_event(RunnerEvent.PRE_CYCLE) + + def test_send(self): + + sm = RunnerStateMachine(state=RunnerStatus.PRE_INITIALIZATION) + # make sure the return and state are consistent + assert sm.send(RunnerEvent.INIT) == RunnerStatus.INITIALIZED + assert sm.state == RunnerStatus.INITIALIZED + + # default construction + assert RunnerStateMachine().state == RunnerStatus.PRE_INITIALIZATION + + sm = RunnerStateMachine(state=RunnerStatus.PRE_INITIALIZATION) + with pytest.raises(RunnerStateTransitionError): + sm.send(RunnerEvent.PRE_CYCLE) + + # test the rest of the transitions + assert ( + RunnerStateMachine(RunnerStatus.INITIALIZED).send(RunnerEvent.PRE_CYCLE) + == RunnerStatus.PRE_CYCLE + ) + + assert ( + RunnerStateMachine(RunnerStatus.PRE_CYCLE).send(RunnerEvent.POST_SEGMENT) + == RunnerStatus.POST_SEGMENT + ) + + assert ( + RunnerStateMachine(RunnerStatus.POST_SEGMENT).send(RunnerEvent.POST_CYCLE) + == RunnerStatus.POST_CYCLE + ) + + assert ( + RunnerStateMachine(RunnerStatus.POST_CYCLE).send(RunnerEvent.PRE_CYCLE) + == RunnerStatus.PRE_CYCLE + ) + + +class Test_NoRunner: + + def test___init__(self): + + assert NoRunner().state_machine.state == RunnerStatus.PRE_INITIALIZATION + + def test_status(self): + assert NoRunner().status == RunnerStatus.PRE_INITIALIZATION + + def test_init(self): + runner = NoRunner() + runner.init() + assert runner.status == RunnerStatus.INITIALIZED + + def test_pre_cycle(self): + runner = NoRunner() + runner.init() + runner.pre_cycle() + assert runner.status == RunnerStatus.PRE_CYCLE + + def test_post_cycle(self): + runner = NoRunner() + runner.init() + runner.pre_cycle() + runner.post_cycle(None) + assert runner.status == RunnerStatus.POST_CYCLE + + def test_run_segment(self): + + # concrete state to use + @attrs.define + class SomeState(WalkerState): + a: int + + def __getitem__(self, key: str) -> int: + + if key != "a": + raise KeyError(f"Invalid key '{key}'") + + return self.a + + def dict(self) -> dict[str, int]: + return {"a": self.a} + + runner = NoRunner() + + walker = Walker( + state=SomeState(a=1), + weight=0.1, + ) + + with pytest.raises(RunnerStateError): + runner.run_segment( + walker, + 10, + ) + + runner.init() + + with pytest.raises(RunnerStateError): + runner.run_segment( + walker, + 10, + ) + + runner.pre_cycle() + assert runner.run_segment( + walker, + 10, + ) == (walker, None) + + runner.post_cycle(None) + with pytest.raises(RunnerStateError): + runner.run_segment( + walker, + 10, + ) diff --git a/tests/unit/test_sim_manager.py b/tests/unit/test_sim_manager.py new file mode 100644 index 00000000..4845d438 --- /dev/null +++ b/tests/unit/test_sim_manager.py @@ -0,0 +1,288 @@ +# Standard Library +import copy + +# Third Party Library +import pytest + +# First Party Library +from wepy.resampling.resamplers.noresampler import NoResamplerFactory +from wepy.runners.mock import MockError, MockRunnerFactory, MockState +from wepy.runners.runner import RunnerStatus, RunSegmentData +from wepy.sim_manager import ( + Manager, + ManagerEvent, + ManagerStateMachine, + ManagerStateTransitionError, + ManagerStatus, +) +from wepy.walker import Walker +from wepy.work_mapper.serial import SerialMapper + + +@pytest.fixture +def sim_components() -> tuple[ + list[Walker], + MockRunnerFactory, + NoResamplerFactory, +]: + + num_walkers = 4 + + init_walker_weight = 1 / num_walkers + init_walkers = [ + Walker( + state=MockState(1), + weight=init_walker_weight, + ) + for walker_state in range(num_walkers) + ] + + return init_walkers, MockRunnerFactory(fail=False), NoResamplerFactory() + + +class Test_ManagerStateMachine: + + def test_validate_event(self): + + sm = ManagerStateMachine() + assert sm.validate_event(ManagerEvent.START_PRE_SIM) + + sm = ManagerStateMachine() + with pytest.raises(ManagerStateTransitionError): + sm.validate_event(ManagerEvent.FINISH_SIMULATION) + + def test_send(self): + + sm = ManagerStateMachine(state=ManagerStatus.CONSTRUCTED) + # make sure the return and state are consistent + assert sm.send(ManagerEvent.START_PRE_SIM) == ManagerStatus.PRE_SIMULATION + assert sm.state == ManagerStatus.PRE_SIMULATION + + # default construction + assert ManagerStateMachine().state == ManagerStatus.CONSTRUCTED + + # TODO: this should be its own test as it is a behavioural test + + # Test all the transitions are what we expect + # sm = ManagerStateMachine(state=ManagerStatus.PRE_INITIALIZATION) + + +class Test_Manager: + + def test___init__(self, sim_components): + + manager = Manager(*sim_components) + + assert manager.status == ManagerStatus.CONSTRUCTED + assert len(manager.reporters) == 0 + assert manager.work_mapper_factory == SerialMapper + assert manager._work_mapper is None + + def test_init(self, sim_components): + + manager = Manager(*sim_components) + + with pytest.raises(ManagerStateTransitionError): + manager.init() + + manager.state_machine.send(ManagerEvent.START_PRE_SIM) + + manager.init() + assert manager.status == ManagerStatus.INITIALIZED + assert manager._work_mapper is not None + assert manager._runner.status == RunnerStatus.INITIALIZED + + with pytest.raises(ManagerStateTransitionError): + manager.init() + + def test_pre_segment(self, sim_components): + + manager = Manager(*sim_components) + + with pytest.raises(ManagerStateTransitionError): + manager.pre_segment() + + manager.state_machine.send(ManagerEvent.START_PRE_SIM) + + manager.init() + + manager.state_machine.send(ManagerEvent.START_SIM) + manager.state_machine.send(ManagerEvent.START_CYCLE) + + manager.pre_segment() + + assert manager.status == ManagerStatus.PRE_SEGMENT_FINISHED + assert manager._runner.status == RunnerStatus.PRE_CYCLE + + def test_post_segment(self, sim_components): + + manager = Manager(*sim_components) + + with pytest.raises(ManagerStateTransitionError): + manager.post_segment(None) + + manager.state_machine.send(ManagerEvent.START_PRE_SIM) + manager.init() + manager.state_machine.send(ManagerEvent.START_SIM) + manager.state_machine.send(ManagerEvent.START_CYCLE) + manager.pre_segment() + + manager.run_segment( + [copy.deepcopy(walker.state) for walker in sim_components[0]], + 5, + 0, + ) + + manager.post_segment( + [ + RunSegmentData( + segment_split_time=2.0, + ) + for _ in range(len(sim_components[0])) + ] + ) + + assert manager.status == ManagerStatus.POST_SEGMENT_FINISHED + assert manager._runner.status == RunnerStatus.POST_CYCLE + + def test_cleanup(self, sim_components): + + manager = Manager(*sim_components) + + # cleanup can be run in any state after init, but not after cleanup + + with pytest.raises(ManagerStateTransitionError): + manager.cleanup() + + manager.state_machine.send(ManagerEvent.START_PRE_SIM) + manager.init() + assert manager._runner.status == RunnerStatus.INITIALIZED + manager.cleanup() + + with pytest.raises(ManagerStateTransitionError): + manager.cleanup() + + def test_run_segment(self, sim_components): + + init_walkers, runner_factory, resampler = sim_components + + manager = Manager(*sim_components) + manager.state_machine.send(ManagerEvent.START_PRE_SIM) + manager.init() + + # UGLY,TODO: that this is ugly as there might be some implicit + # state around that needs to go in tandem. So there should be + # some state coupled data that should be introduced to make + # this more robust + + # all the state changes needed for this to work + manager.state_machine.send(ManagerEvent.START_SIM) + manager.state_machine.send(ManagerEvent.START_CYCLE) + manager.pre_segment() + + new_states = manager.run_segment( + [walker.state for walker in init_walkers], + 1, + 0, + ) + assert manager.status == ManagerStatus.SEGMENT_FINISHED + + # test if something fails + manager = Manager( + init_walkers, + MockRunnerFactory(fail=True), + resampler, + ) + manager.state_machine.send(ManagerEvent.START_PRE_SIM) + manager.init() + manager.state_machine.send(ManagerEvent.START_SIM) + manager.state_machine.send(ManagerEvent.START_CYCLE) + manager.pre_segment() + + with pytest.raises(MockError): + manager.run_segment( + [walker.state for walker in init_walkers], + 1, + 0, + ) + + assert manager.status == ManagerStatus.CLEANUP_FINISHED + + def test_run_cycle(self, sim_components): + + init_walkers, runner, resampler = sim_components + + manager = Manager(*sim_components) + manager.state_machine.send(ManagerEvent.START_PRE_SIM) + manager.init() + manager.state_machine.send(ManagerEvent.START_SIM) + + new_states = manager.run_cycle( + init_walkers, + 1, + 0, + ) + + assert manager.status == ManagerStatus.POST_CYCLE + + # test if something fails + manager = Manager( + init_walkers, + MockRunnerFactory(fail=True), + resampler, + ) + manager.state_machine.send(ManagerEvent.START_PRE_SIM) + manager.init() + manager.state_machine.send(ManagerEvent.START_SIM) + + with pytest.raises(MockError): + manager.run_cycle( + init_walkers, + 1, + 0, + ) + + assert manager.status == ManagerStatus.CLEANUP_FINISHED + + def test_run_simulation(self, sim_components): + + manager = Manager(*sim_components) + + new_walkers, _ = manager.run_simulation(2, 2) + assert manager.status == ManagerStatus.FINISHED + + manager = Manager(*sim_components) + + new_walkers, _ = manager.run_simulation( + 2, + 2, + continue_run_idx=0, + ) + assert manager.status == ManagerStatus.FINISHED + + def test_run_simulation_by_time(self, sim_components): + + manager = Manager(*sim_components) + + new_walkers, _ = manager.run_simulation_by_time( + 0.001, + 2, + ) + assert manager.status == ManagerStatus.FINISHED + + manager = Manager(*sim_components) + new_walkers, _ = manager.run_simulation_by_time( + 0.001, + 2, + continue_run_idx=0, + ) + assert manager.status == ManagerStatus.FINISHED + + # make sure it runs at least one cycle + manager = Manager(*sim_components) + new_walkers, _ = manager.run_simulation_by_time( + 0.0000001, + 2, + continue_run_idx=0, + ) + assert manager.status == ManagerStatus.FINISHED diff --git a/tests/unit/test_util/test_attrs.py b/tests/unit/test_util/test_attrs.py new file mode 100644 index 00000000..237915f2 --- /dev/null +++ b/tests/unit/test_util/test_attrs.py @@ -0,0 +1,69 @@ +# Third Party Library +import attrs + +# First Party Library +from wepy.util.attrs import AttrsMappingMixin + + +@attrs.define +class Thing(AttrsMappingMixin): + a: int + b: str + + +class Test_AttrsMappingMixin: + + def test___init__(self): + + Thing(a=1, b="hello") + + def test___len__(self): + + assert len(Thing(a=1, b="hello")) == 2 + + def test___getitem__(self): + + assert Thing(a=1, b="hello")["a"] == 1 + assert Thing(a=1, b="hello")["b"] == "hello" + + def test___eq__(self): + + assert Thing(a=1, b="hello") == Thing(a=1, b="hello") + + def test__ne__(self): + assert Thing(a=1, b="hello") != Thing(a=100, b="hello") + assert Thing(a=1, b="hello") != Thing(a=1, b="goodbye") + + def test___contains__(self): + + t = Thing(a=1, b="hello") + assert "a" in t + assert "b" in t + + def test___iter__(self): + t = Thing(a=1, b="hello") + t_it = iter(t) + _vs = set() + _vs.add(next(t_it)) + _vs.add(next(t_it)) + assert _vs == {"a", "b"} + + def test_keys(self): + t = Thing(a=1, b="hello") + assert set(t.keys()) == {"a", "b"} + + def test_values(self): + t = Thing(a=1, b="hello") + assert set(t.values()) == {1, "hello"} + + def test_items(self): + t = Thing(a=1, b="hello") + assert set(t.items()) == { + ("a", 1), + ("b", "hello"), + } + + def test_get(self): + t = Thing(a=1, b="hello") + assert t.get("a") == 1 + assert t.get("b") == "hello" diff --git a/tests/unit/test_util/test_multiprocessing.py b/tests/unit/test_util/test_multiprocessing.py new file mode 100644 index 00000000..16b45a5d --- /dev/null +++ b/tests/unit/test_util/test_multiprocessing.py @@ -0,0 +1,39 @@ +# Standard Library +import logging +import multiprocessing as mp + +# First Party Library +from wepy.util.multiprocessing import _dummy_task, proc_pool_worker_setup + + +def test__dummy_task(): + + assert _dummy_task(1) == 2 + + +def test_proc_pool_worker_setup(caplog): + + mp_ctx = mp.get_context(method="spawn") + + log_queue = mp_ctx.Queue() + handlers = list(logging.getLogger().handlers) + listener = logging.handlers.QueueListener(log_queue, *handlers) + listener.start() + + with mp_ctx.Pool( + processes=1, + initializer=proc_pool_worker_setup, + initargs=(log_queue,), + ) as pool: + + # NOTE: this is the hardcoded logger in the dummy function + with caplog.at_level(logging.INFO, logger="dummy-task"): + result = pool.apply(_dummy_task, (1,)) + + assert result == 2 + + assert len(caplog.records) == 2 + assert caplog.records[0].levelname == "INFO" + assert caplog.records[0].msg == "Configured logging in worker process" + assert caplog.records[1].levelname == "INFO" + assert caplog.records[1].msg == "Executing dummy task" diff --git a/tests/unit/test_util/test_openmm.py b/tests/unit/test_util/test_openmm.py new file mode 100644 index 00000000..f8eaab80 --- /dev/null +++ b/tests/unit/test_util/test_openmm.py @@ -0,0 +1,44 @@ +# Third Party Library +import numpy as np +import openmm + +# First Party Library +from wepy.util.openmm import array3d_to_vec3, vec3_to_array3d + + +def test_array3d_to_vec3(): + assert tuple( + array3d_to_vec3( + np.array( + [ + [0.0, 0.0, 0.0], + [0.0, 0.0, 0.0], + ] + ) + ) + ) == tuple( + [ + openmm.Vec3(0.0, 0.0, 0.0), + openmm.Vec3(0.0, 0.0, 0.0), + ] + ) + + +def test_vec3_to_array3d(): + + assert np.array_equal( + vec3_to_array3d( + tuple( + [ + openmm.Vec3(0.0, 0.0, 0.0), + openmm.Vec3(0.0, 0.0, 0.0), + ] + ) + ), + np.array( + [ + [0.0, 0.0, 0.0], + [0.0, 0.0, 0.0], + ] + ), + ) diff --git a/tests/unit/test_walker.py b/tests/unit/test_walker.py new file mode 100644 index 00000000..d984a37c --- /dev/null +++ b/tests/unit/test_walker.py @@ -0,0 +1,266 @@ +# Standard Library +from typing import Literal, TypedDict + +# Third Party Library +import attrs + +# First Party Library +from wepy.missing import MISSING +from wepy.walker import ( + AttrsWalkerStateMixin, + Walker, + WalkerState, + WalkerStateBox, + clone, + keep_merge, + merge, + split, + squash, +) + +# attrs provide the __eq__ method + +MockKeys = Literal["a", "b"] +MockDataValue = int | str + + +# Example of writing a state from scratch +class MockData(TypedDict): + a: int + b: str + + +@attrs.define +class MockWalkerState(WalkerState): + a: int + b: str + + def __getitem__(self, key: MockKeys) -> MockDataValue: + if (value := getattr(self, key, MISSING)) is MISSING: + raise KeyError(f"'key' '{key}' not found") + else: + return value + + def dict(self) -> MockData: + return attrs.asdict(self) + + +# example of using the Attrs mixin to write those methods for you +@attrs.define +class MockWalkerStateMixin(AttrsWalkerStateMixin, WalkerState): + a: int + b: str + + +class Test_WalkerState: + + def test___init__(self): + + MockWalkerState(a=1, b="hello") + + def test___getitem__(self): + + assert MockWalkerState(a=1, b="hello")["a"] == 1 + assert MockWalkerState(a=1, b="hello")["b"] == "hello" + + def test___eq__(self): + + assert MockWalkerState(a=1, b="hello") == MockWalkerState(a=1, b="hello") + assert MockWalkerState(a=1, b="hello") != MockWalkerState(a=100, b="hello") + + def test_dict(self): + assert MockWalkerState(a=1, b="hello").dict() == { + "a": 1, + "b": "hello", + } + + +class Test_AttrsWalkerStateMixin: + + def test___init__(self): + + MockWalkerStateMixin(a=1, b="hello") + + def test___getitem__(self): + + assert MockWalkerStateMixin(a=1, b="hello")["a"] == 1 + assert MockWalkerStateMixin(a=1, b="hello")["b"] == "hello" + + def test___eq__(self): + + assert MockWalkerStateMixin(a=1, b="hello") == MockWalkerStateMixin( + a=1, b="hello" + ) + assert MockWalkerStateMixin(a=1, b="hello") != MockWalkerStateMixin( + a=100, b="hello" + ) + + def test_dict(self): + assert MockWalkerStateMixin(a=1, b="hello").dict() == { + "a": 1, + "b": "hello", + } + + +class Test_WalkerStateBox: + + def test___init__(self): + + assert WalkerStateBox(a=1)._data == {"a": 1} + + def test___getitem__(self): + + assert WalkerStateBox(a=1)["a"] == 1 + + def test_dict(self): + + assert WalkerStateBox(a=1).dict() == {"a": 1} + + def test___eq__(self): + + s = WalkerStateBox(a=1) + assert s == s + + assert WalkerStateBox(a=1) == WalkerStateBox(a=1) + + +class TestWalker: + + def test___init__(self): + + state = MockWalkerState(a=1, b="hello") + walker = Walker( + state=state, + weight=0.1, + ) + + assert walker.weight == 0.1 + assert walker.state is state + + def test___eq__(self): + assert Walker( + state=MockWalkerState(a=1, b="hello"), + weight=0.1, + ) == Walker( + state=MockWalkerState(a=1, b="hello"), + weight=0.1, + ) + + assert Walker( + state=MockWalkerState(a=1, b="hello"), + weight=0.1, + ) != Walker( + state=MockWalkerState(a=100, b="hello"), + weight=0.1, + ) + + assert Walker( + state=MockWalkerState(a=1, b="hello"), + weight=0.1, + ) != Walker( + state=MockWalkerState(a=1, b="hello"), + weight=0.05, + ) + + +def test_clone(): + state = MockWalkerState(a=1, b="hello") + walker = Walker( + state=state, + weight=0.1, + ) + + clones = clone(walker, 1) + assert len(clones) == 2 + + assert clones[0] == Walker( + state=state, + weight=0.05, + ) + assert clones[1] == Walker( + state=state, + weight=0.05, + ) + + +def test_squash(): + + walker_a = Walker( + state=MockWalkerState(a=1, b="hello"), + weight=0.1, + ) + + walker_b = Walker( + state=MockWalkerState(a=10, b="hello"), + weight=0.1, + ) + + assert squash(walker_a, walker_b) == Walker( + state=MockWalkerState(a=10, b="hello"), + weight=0.2, + ) + + assert squash(walker_b, walker_a) == Walker( + state=MockWalkerState(a=1, b="hello"), + weight=0.2, + ) + + +def test_split(): + + walker = Walker( + state=MockWalkerState(a=1, b="hello"), + weight=0.1, + ) + + assert split(walker, 2) == [ + Walker( + state=MockWalkerState(a=1, b="hello"), + weight=0.05, + ), + Walker( + state=MockWalkerState(a=1, b="hello"), + weight=0.05, + ), + ] + + +def test_keep_merge(): + walkers = [ + Walker( + state=MockWalkerState(a=10, b="hello"), + weight=0.1, + ), + Walker( + state=MockWalkerState(a=20, b="hello"), + weight=0.1, + ), + ] + + assert keep_merge(walkers, 0) == Walker( + state=MockWalkerState(a=10, b="hello"), + weight=0.2, + ) + + assert keep_merge(walkers, 1) == Walker( + state=MockWalkerState(a=20, b="hello"), + weight=0.2, + ) + + +def test_merge(): + walkers = [ + Walker( + state=MockWalkerState(a=10, b="hello"), + weight=0.1, + ), + Walker( + state=MockWalkerState(a=20, b="hello"), + weight=0.1, + ), + ] + + merged_walker, keep_idx = merge(walkers) + assert merged_walker.weight == 0.2 + + assert walkers[keep_idx].state == merged_walker.state diff --git a/tests/unit/test_wepy_tools/test_systems/test_alanine_dipeptide.py b/tests/unit/test_wepy_tools/test_systems/test_alanine_dipeptide.py new file mode 100644 index 00000000..275b2389 --- /dev/null +++ b/tests/unit/test_wepy_tools/test_systems/test_alanine_dipeptide.py @@ -0,0 +1,76 @@ +# Standard Library + +# Third Party Library +import numpy as np + +# First Party Library +from wepy.runners.openmm import OpenMMState +from wepy_tools.systems.alanine_dipeptide import ( + AlanineDipeptideExplicitSystem, + AlanineDipeptideRamachandranDistance, +) + + +def test_AlanineDipeptideExplicitSystem(): + + AlanineDipeptideExplicitSystem() + + +class Test_AlanineDipeptideRamachandranDistance: + + def test_image(self): + + ala_sys = AlanineDipeptideExplicitSystem() + + distance = AlanineDipeptideRamachandranDistance(topology=ala_sys.json_top) + + image = distance.image(ala_sys.state) + + def test_image_distance(self): + + ala_sys = AlanineDipeptideExplicitSystem() + + distance = AlanineDipeptideRamachandranDistance(topology=ala_sys.json_top) + + image_a = distance.image(ala_sys.state) + + assert np.isclose(distance.image_distance(image_a, image_a), 0.0) + + # then make a jittered atom positions to get something a little + # different to compare + jitter_positions = ( + ala_sys.state.positions + + np.random.uniform( + -0.01, + 0.01, + size=ala_sys.state.positions.shape, + ) + * ala_sys.state.positions.unit + ) + + jitter_state = OpenMMState.from_dwim( + positions=jitter_positions, + box_vectors=ala_sys.state.box_vectors, + ) + + jitter_image = distance.image(jitter_state) + + assert not np.isclose( + distance.image_distance( + image_a, + jitter_image, + ), + 0.0, + ) + + # test it is symmetric + assert np.isclose( + distance.image_distance( + image_a, + jitter_image, + ), + distance.image_distance( + jitter_image, + image_a, + ), + ) diff --git a/tests/unit/test_wepy_tools/test_systems/test_lennard_jones.py b/tests/unit/test_wepy_tools/test_systems/test_lennard_jones.py new file mode 100644 index 00000000..259b00d3 --- /dev/null +++ b/tests/unit/test_wepy_tools/test_systems/test_lennard_jones.py @@ -0,0 +1,7 @@ +# First Party Library +from wepy_tools.systems.lennard_jones import LennardJonesPair + + +def test_LennardJonesPair(): + + LennardJonesPair() diff --git a/tests/unit/test_work_mapper/test_mapper.py b/tests/unit/test_work_mapper/test_mapper.py deleted file mode 100644 index 4364f11c..00000000 --- a/tests/unit/test_work_mapper/test_mapper.py +++ /dev/null @@ -1,127 +0,0 @@ -# Standard Library -import logging - -logger = logging.getLogger(__name__) -# Standard Library -import multiprocessing as mp -import time -from copy import deepcopy - -# Third Party Library -import pytest - -# First Party Library -from wepy.walker import Walker, WalkerState -from wepy.work_mapper.mapper import Mapper, TaskException -from wepy.work_mapper.task_mapper import ( - TaskMapper, - TaskProcessException, - WalkerTaskProcess, -) -from wepy.work_mapper.worker import Worker, WorkerException, WorkerMapper - -ARGS = (0, 1, 2) - - -def gen_walkers(): - return [Walker(WalkerState(**{"num": arg}), 1 / len(ARGS)) for arg in ARGS] - - -# test basic functionality -def task_pass(walker): - # simulate it actually taking some time - n = walker.state["num"] - return Walker(WalkerState(**{"num": n + 1}), walker.weight) - - -TASK_PASS_ANSWER = [n + 1 for n in ARGS] - - -class TestWorkMappers: - def test_mapper(self): - mapper = Mapper(segment_func=task_pass) - - mapper.init() - - results = mapper.map(gen_walkers()) - - assert all( - [res.state["num"] == TASK_PASS_ANSWER[i] for i, res in enumerate(results)] - ) - - mapper.cleanup() - - def test_worker_mapper(self): - mapper = WorkerMapper(segment_func=task_pass, num_workers=3, worker_type=Worker) - - mapper.init() - - results = mapper.map(gen_walkers()) - - assert all( - [res.state["num"] == TASK_PASS_ANSWER[i] for i, res in enumerate(results)] - ) - - mapper.cleanup() - - def test_task_mapper(self): - mapper = TaskMapper( - segment_func=task_pass, num_workers=3, walker_task_type=WalkerTaskProcess - ) - - mapper.init() - - results = mapper.map(gen_walkers()) - - assert all( - [res.state["num"] == TASK_PASS_ANSWER[i] for i, res in enumerate(results)] - ) - - mapper.cleanup() - - time.sleep(1) - - -# test that task failures are passed up properly -def task_fail(walker): - n = walker.state["num"] - if n == 1: - raise ValueError("No soup for you!!") - else: - return Walker(WalkerState(**{"num": n + 1}), walker.weight) - - -class TestTaskFail: - ARGS = ((0, 1, 2),) - - def test_mapper(self): - mapper = Mapper(segment_func=task_fail) - - mapper.init() - - with pytest.raises(TaskException) as task_exc_info: - results = mapper.map(gen_walkers()) - - mapper.cleanup() - - def test_worker_mapper(self): - mapper = WorkerMapper(segment_func=task_fail, num_workers=3, worker_type=Worker) - - mapper.init() - - with pytest.raises(TaskException) as task_exc_info: - results = mapper.map(gen_walkers()) - - mapper.cleanup() - - def test_task_mapper(self): - mapper = TaskMapper( - segment_func=task_fail, num_workers=3, walker_task_type=WalkerTaskProcess - ) - - mapper.init() - - with pytest.raises(TaskException) as task_exc_info: - results = mapper.map(gen_walkers()) - - mapper.cleanup() diff --git a/tests/unit/test_work_mapper/test_openmm/test_proc_pool.py b/tests/unit/test_work_mapper/test_openmm/test_proc_pool.py new file mode 100644 index 00000000..587edbed --- /dev/null +++ b/tests/unit/test_work_mapper/test_openmm/test_proc_pool.py @@ -0,0 +1,72 @@ +# Third Party Library +import openmm +import pytest + +# First Party Library +from wepy.runners.openmm import ( + OpenMMRunner, + OpenMMState, +) +from wepy.work_mapper.openmm.proc_pool import ( + OpenMMProcPoolWorkMapper, +) +from wepy_tools.systems.lennard_jones import LennardJonesPair + + +@pytest.fixture(scope="function") +def openmm_runner() -> OpenMMRunner: + + lj_sys = LennardJonesPair() + integrator = openmm.LangevinIntegrator(300.0, 0.002, 0.1) + + runner = OpenMMRunner( + system=lj_sys.system, + topology=lj_sys.topology, + integrator=integrator, + ) + return runner + + +@pytest.fixture(scope="function") +def openmm_state() -> OpenMMState: + + lj_sys = LennardJonesPair() + + state = OpenMMState.from_dwim( + positions=lj_sys.positions, + ) + + return state + + +class Test_OpenMMProcPoolWorkMapper: + + @pytest.mark.timeout(5) + @pytest.mark.flaky(reruns=20) + def test_all(self, openmm_runner): + + lj_sys = LennardJonesPair() + + init_states = [ + OpenMMState.from_dwim( + positions=lj_sys.positions, + ) + for _ in range(4) + ] + + mapper = OpenMMProcPoolWorkMapper( + platform="CPU", + num_procs=2, + device_ids=[0, 1], + global_platform_properties={"Threads": "1"}, + ) + mapper.init() + + openmm_runner.init() + openmm_runner.pre_cycle() + + new_states = mapper.map( + openmm_runner.run_segment, + init_states, + [10 for _ in range(len(init_states))], + ) diff --git a/tests/unit/test_work_mapper/test_serial.py b/tests/unit/test_work_mapper/test_serial.py new file mode 100644 index 00000000..ab7d042a --- /dev/null +++ b/tests/unit/test_work_mapper/test_serial.py @@ -0,0 +1,64 @@ +# Standard Library + +# Third Party Library +import attrs + +# First Party Library +from wepy.work_mapper.serial import SerialMapper + +# some minimal definitions for testing a concrete work mapper + + +@attrs.define +class RizzWalkerState: + rizz: int + + +def rizz_run(walker_state: RizzWalkerState, segment_length: int) -> RizzWalkerState: + + return attrs.evolve( + walker_state, + rizz=(walker_state.rizz + segment_length), + ) + + +@attrs.define +class RizzTask: + + multiple: int + + def __call__(self, state: RizzWalkerState, segment_length: int) -> RizzWalkerState: + + return rizz_run(state, segment_length=segment_length, multiple=self.multiple) + + +def test_rizz_walker(): + assert rizz_run( + RizzWalkerState(rizz=1), + 1, + ) == RizzWalkerState(rizz=2) + + +class TestMapper: + + def test_map(self): + + mapper = SerialMapper() + + mapper.init() + + assert mapper.map( + rizz_run, + [ + RizzWalkerState(1), + RizzWalkerState(1), + RizzWalkerState(2), + ], + [1, 2, 2], + ) == [ + RizzWalkerState(2), + RizzWalkerState(3), + RizzWalkerState(4), + ] + + assert len(mapper.get_worker_segment_times()[0]) == 3 diff --git a/tests/utils/README.org b/tests/utils/README.org deleted file mode 100644 index d284d43e..00000000 --- a/tests/utils/README.org +++ /dev/null @@ -1,3 +0,0 @@ -Put individual modules here that can be imported into tests. Choose -names that don't clash. Typically you can just add a 'my' to the -beginning of them. diff --git a/tests/utils/myutils.py b/tests/utils/myutils.py deleted file mode 100644 index f7164364..00000000 --- a/tests/utils/myutils.py +++ /dev/null @@ -1,17 +0,0 @@ -"""Generic modules that help with running tests more smoothly.""" - -# Standard Library -import os -import os.path as osp -from contextlib import contextmanager - - -@contextmanager -def cd(newdir): - """Change directories use as a context manager.""" - prevdir = os.getcwd() - os.chdir(osp.expanduser(newdir)) - try: - yield - finally: - os.chdir(prevdir) diff --git a/uv.lock b/uv.lock new file mode 100644 index 00000000..8dc64da9 --- /dev/null +++ b/uv.lock @@ -0,0 +1,3403 @@ +version = 1 +revision = 3 +requires-python = ">=3.11, <3.15" +resolution-markers = [ + "python_full_version >= '3.14'", + "python_full_version >= '3.12' and python_full_version < '3.14'", + "python_full_version < '3.12'", +] + +[[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 = "anyio" +version = "4.11.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "sniffio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c6/78/7d432127c41b50bccba979505f272c16cbcadcc33645d5fa3a738110ae75/anyio-4.11.0.tar.gz", hash = "sha256:82a8d0b81e318cc5ce71a5f1f8b5c4e63619620b63141ef8c995fa0db95a57c4", size = 219094, upload-time = "2025-09-23T09:19:12.58Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/15/b3/9b1a8074496371342ec1e796a96f99c82c945a339cd81a8e73de28b4cf9e/anyio-4.11.0-py3-none-any.whl", hash = "sha256:0287e96f4d26d4149305414d4e3bc32f0dcd0862365a4bddea19d7a1ec38c4fc", size = 109097, upload-time = "2025-09-23T09:19:10.601Z" }, +] + +[[package]] +name = "appnope" +version = "0.1.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/35/5d/752690df9ef5b76e169e68d6a129fa6d08a7100ca7f754c89495db3c6019/appnope-0.1.4.tar.gz", hash = "sha256:1de3860566df9caf38f01f86f65e0e13e379af54f9e4bee1e66b48f2efffd1ee", size = 4170, upload-time = "2024-02-06T09:43:11.258Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/29/5ecc3a15d5a33e31b26c11426c45c501e439cb865d0bff96315d86443b78/appnope-0.1.4-py2.py3-none-any.whl", hash = "sha256:502575ee11cd7a28c0205f379b525beefebab9d161b7c964670864014ed7213c", size = 4321, upload-time = "2024-02-06T09:43:09.663Z" }, +] + +[[package]] +name = "argon2-cffi" +version = "25.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "argon2-cffi-bindings" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0e/89/ce5af8a7d472a67cc819d5d998aa8c82c5d860608c4db9f46f1162d7dab9/argon2_cffi-25.1.0.tar.gz", hash = "sha256:694ae5cc8a42f4c4e2bf2ca0e64e51e23a040c6a517a85074683d3959e1346c1", size = 45706, upload-time = "2025-06-03T06:55:32.073Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4f/d3/a8b22fa575b297cd6e3e3b0155c7e25db170edf1c74783d6a31a2490b8d9/argon2_cffi-25.1.0-py3-none-any.whl", hash = "sha256:fdc8b074db390fccb6eb4a3604ae7231f219aa669a2652e0f20e16ba513d5741", size = 14657, upload-time = "2025-06-03T06:55:30.804Z" }, +] + +[[package]] +name = "argon2-cffi-bindings" +version = "25.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5c/2d/db8af0df73c1cf454f71b2bbe5e356b8c1f8041c979f505b3d3186e520a9/argon2_cffi_bindings-25.1.0.tar.gz", hash = "sha256:b957f3e6ea4d55d820e40ff76f450952807013d361a65d7f28acc0acbf29229d", size = 1783441, upload-time = "2025-07-30T10:02:05.147Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/60/97/3c0a35f46e52108d4707c44b95cfe2afcafc50800b5450c197454569b776/argon2_cffi_bindings-25.1.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:3d3f05610594151994ca9ccb3c771115bdb4daef161976a266f0dd8aa9996b8f", size = 54393, upload-time = "2025-07-30T10:01:40.97Z" }, + { url = "https://files.pythonhosted.org/packages/9d/f4/98bbd6ee89febd4f212696f13c03ca302b8552e7dbf9c8efa11ea4a388c3/argon2_cffi_bindings-25.1.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8b8efee945193e667a396cbc7b4fb7d357297d6234d30a489905d96caabde56b", size = 29328, upload-time = "2025-07-30T10:01:41.916Z" }, + { url = "https://files.pythonhosted.org/packages/43/24/90a01c0ef12ac91a6be05969f29944643bc1e5e461155ae6559befa8f00b/argon2_cffi_bindings-25.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3c6702abc36bf3ccba3f802b799505def420a1b7039862014a65db3205967f5a", size = 31269, upload-time = "2025-07-30T10:01:42.716Z" }, + { url = "https://files.pythonhosted.org/packages/d4/d3/942aa10782b2697eee7af5e12eeff5ebb325ccfb86dd8abda54174e377e4/argon2_cffi_bindings-25.1.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a1c70058c6ab1e352304ac7e3b52554daadacd8d453c1752e547c76e9c99ac44", size = 86558, upload-time = "2025-07-30T10:01:43.943Z" }, + { url = "https://files.pythonhosted.org/packages/0d/82/b484f702fec5536e71836fc2dbc8c5267b3f6e78d2d539b4eaa6f0db8bf8/argon2_cffi_bindings-25.1.0-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e2fd3bfbff3c5d74fef31a722f729bf93500910db650c925c2d6ef879a7e51cb", size = 92364, upload-time = "2025-07-30T10:01:44.887Z" }, + { url = "https://files.pythonhosted.org/packages/c9/c1/a606ff83b3f1735f3759ad0f2cd9e038a0ad11a3de3b6c673aa41c24bb7b/argon2_cffi_bindings-25.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c4f9665de60b1b0e99bcd6be4f17d90339698ce954cfd8d9cf4f91c995165a92", size = 85637, upload-time = "2025-07-30T10:01:46.225Z" }, + { url = "https://files.pythonhosted.org/packages/44/b4/678503f12aceb0262f84fa201f6027ed77d71c5019ae03b399b97caa2f19/argon2_cffi_bindings-25.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ba92837e4a9aa6a508c8d2d7883ed5a8f6c308c89a4790e1e447a220deb79a85", size = 91934, upload-time = "2025-07-30T10:01:47.203Z" }, + { url = "https://files.pythonhosted.org/packages/f0/c7/f36bd08ef9bd9f0a9cff9428406651f5937ce27b6c5b07b92d41f91ae541/argon2_cffi_bindings-25.1.0-cp314-cp314t-win32.whl", hash = "sha256:84a461d4d84ae1295871329b346a97f68eade8c53b6ed9a7ca2d7467f3c8ff6f", size = 28158, upload-time = "2025-07-30T10:01:48.341Z" }, + { url = "https://files.pythonhosted.org/packages/b3/80/0106a7448abb24a2c467bf7d527fe5413b7fdfa4ad6d6a96a43a62ef3988/argon2_cffi_bindings-25.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b55aec3565b65f56455eebc9b9f34130440404f27fe21c3b375bf1ea4d8fbae6", size = 32597, upload-time = "2025-07-30T10:01:49.112Z" }, + { url = "https://files.pythonhosted.org/packages/05/b8/d663c9caea07e9180b2cb662772865230715cbd573ba3b5e81793d580316/argon2_cffi_bindings-25.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:87c33a52407e4c41f3b70a9c2d3f6056d88b10dad7695be708c5021673f55623", size = 28231, upload-time = "2025-07-30T10:01:49.92Z" }, + { url = "https://files.pythonhosted.org/packages/1d/57/96b8b9f93166147826da5f90376e784a10582dd39a393c99bb62cfcf52f0/argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:aecba1723ae35330a008418a91ea6cfcedf6d31e5fbaa056a166462ff066d500", size = 54121, upload-time = "2025-07-30T10:01:50.815Z" }, + { url = "https://files.pythonhosted.org/packages/0a/08/a9bebdb2e0e602dde230bdde8021b29f71f7841bd54801bcfd514acb5dcf/argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:2630b6240b495dfab90aebe159ff784d08ea999aa4b0d17efa734055a07d2f44", size = 29177, upload-time = "2025-07-30T10:01:51.681Z" }, + { url = "https://files.pythonhosted.org/packages/b6/02/d297943bcacf05e4f2a94ab6f462831dc20158614e5d067c35d4e63b9acb/argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:7aef0c91e2c0fbca6fc68e7555aa60ef7008a739cbe045541e438373bc54d2b0", size = 31090, upload-time = "2025-07-30T10:01:53.184Z" }, + { url = "https://files.pythonhosted.org/packages/c1/93/44365f3d75053e53893ec6d733e4a5e3147502663554b4d864587c7828a7/argon2_cffi_bindings-25.1.0-cp39-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e021e87faa76ae0d413b619fe2b65ab9a037f24c60a1e6cc43457ae20de6dc6", size = 81246, upload-time = "2025-07-30T10:01:54.145Z" }, + { url = "https://files.pythonhosted.org/packages/09/52/94108adfdd6e2ddf58be64f959a0b9c7d4ef2fa71086c38356d22dc501ea/argon2_cffi_bindings-25.1.0-cp39-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d3e924cfc503018a714f94a49a149fdc0b644eaead5d1f089330399134fa028a", size = 87126, upload-time = "2025-07-30T10:01:55.074Z" }, + { url = "https://files.pythonhosted.org/packages/72/70/7a2993a12b0ffa2a9271259b79cc616e2389ed1a4d93842fac5a1f923ffd/argon2_cffi_bindings-25.1.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c87b72589133f0346a1cb8d5ecca4b933e3c9b64656c9d175270a000e73b288d", size = 80343, upload-time = "2025-07-30T10:01:56.007Z" }, + { url = "https://files.pythonhosted.org/packages/78/9a/4e5157d893ffc712b74dbd868c7f62365618266982b64accab26bab01edc/argon2_cffi_bindings-25.1.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:1db89609c06afa1a214a69a462ea741cf735b29a57530478c06eb81dd403de99", size = 86777, upload-time = "2025-07-30T10:01:56.943Z" }, + { url = "https://files.pythonhosted.org/packages/74/cd/15777dfde1c29d96de7f18edf4cc94c385646852e7c7b0320aa91ccca583/argon2_cffi_bindings-25.1.0-cp39-abi3-win32.whl", hash = "sha256:473bcb5f82924b1becbb637b63303ec8d10e84c8d241119419897a26116515d2", size = 27180, upload-time = "2025-07-30T10:01:57.759Z" }, + { url = "https://files.pythonhosted.org/packages/e2/c6/a759ece8f1829d1f162261226fbfd2c6832b3ff7657384045286d2afa384/argon2_cffi_bindings-25.1.0-cp39-abi3-win_amd64.whl", hash = "sha256:a98cd7d17e9f7ce244c0803cad3c23a7d379c301ba618a5fa76a67d116618b98", size = 31715, upload-time = "2025-07-30T10:01:58.56Z" }, + { url = "https://files.pythonhosted.org/packages/42/b9/f8d6fa329ab25128b7e98fd83a3cb34d9db5b059a9847eddb840a0af45dd/argon2_cffi_bindings-25.1.0-cp39-abi3-win_arm64.whl", hash = "sha256:b0fdbcf513833809c882823f98dc2f931cf659d9a1429616ac3adebb49f5db94", size = 27149, upload-time = "2025-07-30T10:01:59.329Z" }, +] + +[[package]] +name = "arrow" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "python-dateutil" }, + { name = "tzdata" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b9/33/032cdc44182491aa708d06a68b62434140d8c50820a087fac7af37703357/arrow-1.4.0.tar.gz", hash = "sha256:ed0cc050e98001b8779e84d461b0098c4ac597e88704a655582b21d116e526d7", size = 152931, upload-time = "2025-10-18T17:46:46.761Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ed/c9/d7977eaacb9df673210491da99e6a247e93df98c715fc43fd136ce1d3d33/arrow-1.4.0-py3-none-any.whl", hash = "sha256:749f0769958ebdc79c173ff0b0670d59051a535fa26e8eba02953dc19eb43205", size = 68797, upload-time = "2025-10-18T17:46:45.663Z" }, +] + +[[package]] +name = "asttokens" +version = "3.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/be/a5/8e3f9b6771b0b408517c82d97aed8f2036509bc247d46114925e32fe33f0/asttokens-3.0.1.tar.gz", hash = "sha256:71a4ee5de0bde6a31d64f6b13f2293ac190344478f081c3d1bccfcf5eacb0cb7", size = 62308, upload-time = "2025-11-15T16:43:48.578Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/39/e7eaf1799466a4aef85b6a4fe7bd175ad2b1c6345066aa33f1f58d4b18d0/asttokens-3.0.1-py3-none-any.whl", hash = "sha256:15a3ebc0f43c2d0a50eeafea25e19046c68398e487b9f1f5b517f7c0f40f976a", size = 27047, upload-time = "2025-11-15T16:43:16.109Z" }, +] + +[[package]] +name = "async-lru" +version = "2.0.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b2/4d/71ec4d3939dc755264f680f6c2b4906423a304c3d18e96853f0a595dfe97/async_lru-2.0.5.tar.gz", hash = "sha256:481d52ccdd27275f42c43a928b4a50c3bfb2d67af4e78b170e3e0bb39c66e5bb", size = 10380, upload-time = "2025-03-16T17:25:36.919Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/49/d10027df9fce941cb8184e78a02857af36360d33e1721df81c5ed2179a1a/async_lru-2.0.5-py3-none-any.whl", hash = "sha256:ab95404d8d2605310d345932697371a5f40def0487c03d6d0ad9138de52c9943", size = 6069, upload-time = "2025-03-16T17:25:35.422Z" }, +] + +[[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.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/6b/d52e42361e1aa00709585ecc30b3f9684b3ab62530771402248b1b1d6240/babel-2.17.0.tar.gz", hash = "sha256:0c54cffb19f690cdcc52a3b50bcbf71e07a808d1c80d549f2459b9d2cf0afb9d", size = 9951852, upload-time = "2025-02-01T15:17:41.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/b8/3fe70c75fe32afc4bb507f75563d39bc5642255d1d94f1f23604725780bf/babel-2.17.0-py3-none-any.whl", hash = "sha256:4d0b53093fdfb4b21c92b5213dba5a1b23885afa8383709427046b21c366e5f2", size = 10182537, upload-time = "2025-02-01T15:17:37.39Z" }, +] + +[[package]] +name = "beautifulsoup4" +version = "4.14.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "soupsieve" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/77/e9/df2358efd7659577435e2177bfa69cba6c33216681af51a707193dec162a/beautifulsoup4-4.14.2.tar.gz", hash = "sha256:2a98ab9f944a11acee9cc848508ec28d9228abfd522ef0fad6a02a72e0ded69e", size = 625822, upload-time = "2025-09-29T10:05:42.613Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/fe/3aed5d0be4d404d12d36ab97e2f1791424d9ca39c2f754a6285d59a3b01d/beautifulsoup4-4.14.2-py3-none-any.whl", hash = "sha256:5ef6fa3a8cbece8488d66985560f97ed091e22bbc4e9c2338508a9d5de6d4515", size = 106392, upload-time = "2025-09-29T10:05:43.771Z" }, +] + +[[package]] +name = "black" +version = "25.11.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "mypy-extensions" }, + { name = "packaging" }, + { name = "pathspec" }, + { name = "platformdirs" }, + { name = "pytokens" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8c/ad/33adf4708633d047950ff2dfdea2e215d84ac50ef95aff14a614e4b6e9b2/black-25.11.0.tar.gz", hash = "sha256:9a323ac32f5dc75ce7470501b887250be5005a01602e931a15e45593f70f6e08", size = 655669, upload-time = "2025-11-10T01:53:50.558Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c4/62/d304786b75ab0c530b833a89ce7d997924579fb7484ecd9266394903e394/black-25.11.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:895571922a35434a9d8ca67ef926da6bc9ad464522a5fe0db99b394ef1c0675a", size = 1727891, upload-time = "2025-11-10T02:01:40.507Z" }, + { url = "https://files.pythonhosted.org/packages/82/5d/ffe8a006aa522c9e3f430e7b93568a7b2163f4b3f16e8feb6d8c3552761a/black-25.11.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:cb4f4b65d717062191bdec8e4a442539a8ea065e6af1c4f4d36f0cdb5f71e170", size = 1581875, upload-time = "2025-11-10T01:57:51.192Z" }, + { url = "https://files.pythonhosted.org/packages/cb/c8/7c8bda3108d0bb57387ac41b4abb5c08782b26da9f9c4421ef6694dac01a/black-25.11.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d81a44cbc7e4f73a9d6ae449ec2317ad81512d1e7dce7d57f6333fd6259737bc", size = 1642716, upload-time = "2025-11-10T01:56:51.589Z" }, + { url = "https://files.pythonhosted.org/packages/34/b9/f17dea34eecb7cc2609a89627d480fb6caea7b86190708eaa7eb15ed25e7/black-25.11.0-cp311-cp311-win_amd64.whl", hash = "sha256:7eebd4744dfe92ef1ee349dc532defbf012a88b087bb7ddd688ff59a447b080e", size = 1352904, upload-time = "2025-11-10T01:59:26.252Z" }, + { url = "https://files.pythonhosted.org/packages/7f/12/5c35e600b515f35ffd737da7febdb2ab66bb8c24d88560d5e3ef3d28c3fd/black-25.11.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:80e7486ad3535636657aa180ad32a7d67d7c273a80e12f1b4bfa0823d54e8fac", size = 1772831, upload-time = "2025-11-10T02:03:47Z" }, + { url = "https://files.pythonhosted.org/packages/1a/75/b3896bec5a2bb9ed2f989a970ea40e7062f8936f95425879bbe162746fe5/black-25.11.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6cced12b747c4c76bc09b4db057c319d8545307266f41aaee665540bc0e04e96", size = 1608520, upload-time = "2025-11-10T01:58:46.895Z" }, + { url = "https://files.pythonhosted.org/packages/f3/b5/2bfc18330eddbcfb5aab8d2d720663cd410f51b2ed01375f5be3751595b0/black-25.11.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6cb2d54a39e0ef021d6c5eef442e10fd71fcb491be6413d083a320ee768329dd", size = 1682719, upload-time = "2025-11-10T01:56:55.24Z" }, + { url = "https://files.pythonhosted.org/packages/96/fb/f7dc2793a22cdf74a72114b5ed77fe3349a2e09ef34565857a2f917abdf2/black-25.11.0-cp312-cp312-win_amd64.whl", hash = "sha256:ae263af2f496940438e5be1a0c1020e13b09154f3af4df0835ea7f9fe7bfa409", size = 1362684, upload-time = "2025-11-10T01:57:07.639Z" }, + { url = "https://files.pythonhosted.org/packages/ad/47/3378d6a2ddefe18553d1115e36aea98f4a90de53b6a3017ed861ba1bd3bc/black-25.11.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0a1d40348b6621cc20d3d7530a5b8d67e9714906dfd7346338249ad9c6cedf2b", size = 1772446, upload-time = "2025-11-10T02:02:16.181Z" }, + { url = "https://files.pythonhosted.org/packages/ba/4b/0f00bfb3d1f7e05e25bfc7c363f54dc523bb6ba502f98f4ad3acf01ab2e4/black-25.11.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:51c65d7d60bb25429ea2bf0731c32b2a2442eb4bd3b2afcb47830f0b13e58bfd", size = 1607983, upload-time = "2025-11-10T02:02:52.502Z" }, + { url = "https://files.pythonhosted.org/packages/99/fe/49b0768f8c9ae57eb74cc10a1f87b4c70453551d8ad498959721cc345cb7/black-25.11.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:936c4dd07669269f40b497440159a221ee435e3fddcf668e0c05244a9be71993", size = 1682481, upload-time = "2025-11-10T01:57:12.35Z" }, + { url = "https://files.pythonhosted.org/packages/55/17/7e10ff1267bfa950cc16f0a411d457cdff79678fbb77a6c73b73a5317904/black-25.11.0-cp313-cp313-win_amd64.whl", hash = "sha256:f42c0ea7f59994490f4dccd64e6b2dd49ac57c7c84f38b8faab50f8759db245c", size = 1363869, upload-time = "2025-11-10T01:58:24.608Z" }, + { url = "https://files.pythonhosted.org/packages/67/c0/cc865ce594d09e4cd4dfca5e11994ebb51604328489f3ca3ae7bb38a7db5/black-25.11.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:35690a383f22dd3e468c85dc4b915217f87667ad9cce781d7b42678ce63c4170", size = 1771358, upload-time = "2025-11-10T02:03:33.331Z" }, + { url = "https://files.pythonhosted.org/packages/37/77/4297114d9e2fd2fc8ab0ab87192643cd49409eb059e2940391e7d2340e57/black-25.11.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:dae49ef7369c6caa1a1833fd5efb7c3024bb7e4499bf64833f65ad27791b1545", size = 1612902, upload-time = "2025-11-10T01:59:33.382Z" }, + { url = "https://files.pythonhosted.org/packages/de/63/d45ef97ada84111e330b2b2d45e1dd163e90bd116f00ac55927fb6bf8adb/black-25.11.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5bd4a22a0b37401c8e492e994bce79e614f91b14d9ea911f44f36e262195fdda", size = 1680571, upload-time = "2025-11-10T01:57:04.239Z" }, + { url = "https://files.pythonhosted.org/packages/ff/4b/5604710d61cdff613584028b4cb4607e56e148801ed9b38ee7970799dab6/black-25.11.0-cp314-cp314-win_amd64.whl", hash = "sha256:aa211411e94fdf86519996b7f5f05e71ba34835d8f0c0f03c00a26271da02664", size = 1382599, upload-time = "2025-11-10T01:57:57.427Z" }, + { url = "https://files.pythonhosted.org/packages/00/5d/aed32636ed30a6e7f9efd6ad14e2a0b0d687ae7c8c7ec4e4a557174b895c/black-25.11.0-py3-none-any.whl", hash = "sha256:e3f562da087791e96cefcd9dda058380a442ab322a02e222add53736451f604b", size = 204918, upload-time = "2025-11-10T01:53:48.917Z" }, +] + +[[package]] +name = "bleach" +version = "6.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "webencodings" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/07/18/3c8523962314be6bf4c8989c79ad9531c825210dd13a8669f6b84336e8bd/bleach-6.3.0.tar.gz", hash = "sha256:6f3b91b1c0a02bb9a78b5a454c92506aa0fdf197e1d5e114d2e00c6f64306d22", size = 203533, upload-time = "2025-10-27T17:57:39.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cd/3a/577b549de0cc09d95f11087ee63c739bba856cd3952697eec4c4bb91350a/bleach-6.3.0-py3-none-any.whl", hash = "sha256:fe10ec77c93ddf3d13a73b035abaac7a9f5e436513864ccdad516693213c65d6", size = 164437, upload-time = "2025-10-27T17:57:37.538Z" }, +] + +[package.optional-dependencies] +css = [ + { name = "tinycss2" }, +] + +[[package]] +name = "certifi" +version = "2025.11.12" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/8c/58f469717fa48465e4a50c014a0400602d3c437d7c0c468e17ada824da3a/certifi-2025.11.12.tar.gz", hash = "sha256:d8ab5478f2ecd78af242878415affce761ca6bc54a22a27e026d7c25357c3316", size = 160538, upload-time = "2025-11-12T02:54:51.517Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/70/7d/9bc192684cea499815ff478dfcdc13835ddf401365057044fb721ec6bddb/certifi-2025.11.12-py3-none-any.whl", hash = "sha256:97de8790030bbd5c2d96b7ec782fc2f7820ef8dba6db909ccf95449f2d062d4b", size = 159438, upload-time = "2025-11-12T02:54:49.735Z" }, +] + +[[package]] +name = "cffi" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/4a/3dfd5f7850cbf0d06dc84ba9aa00db766b52ca38d8b86e3a38314d52498c/cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe", size = 184344, upload-time = "2025-09-08T23:22:26.456Z" }, + { url = "https://files.pythonhosted.org/packages/4f/8b/f0e4c441227ba756aafbe78f117485b25bb26b1c059d01f137fa6d14896b/cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c", size = 180560, upload-time = "2025-09-08T23:22:28.197Z" }, + { url = "https://files.pythonhosted.org/packages/b1/b7/1200d354378ef52ec227395d95c2576330fd22a869f7a70e88e1447eb234/cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92", size = 209613, upload-time = "2025-09-08T23:22:29.475Z" }, + { url = "https://files.pythonhosted.org/packages/b8/56/6033f5e86e8cc9bb629f0077ba71679508bdf54a9a5e112a3c0b91870332/cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93", size = 216476, upload-time = "2025-09-08T23:22:31.063Z" }, + { url = "https://files.pythonhosted.org/packages/dc/7f/55fecd70f7ece178db2f26128ec41430d8720f2d12ca97bf8f0a628207d5/cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5", size = 203374, upload-time = "2025-09-08T23:22:32.507Z" }, + { url = "https://files.pythonhosted.org/packages/84/ef/a7b77c8bdc0f77adc3b46888f1ad54be8f3b7821697a7b89126e829e676a/cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664", size = 202597, upload-time = "2025-09-08T23:22:34.132Z" }, + { url = "https://files.pythonhosted.org/packages/d7/91/500d892b2bf36529a75b77958edfcd5ad8e2ce4064ce2ecfeab2125d72d1/cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26", size = 215574, upload-time = "2025-09-08T23:22:35.443Z" }, + { url = "https://files.pythonhosted.org/packages/44/64/58f6255b62b101093d5df22dcb752596066c7e89dd725e0afaed242a61be/cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9", size = 218971, upload-time = "2025-09-08T23:22:36.805Z" }, + { url = "https://files.pythonhosted.org/packages/ab/49/fa72cebe2fd8a55fbe14956f9970fe8eb1ac59e5df042f603ef7c8ba0adc/cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414", size = 211972, upload-time = "2025-09-08T23:22:38.436Z" }, + { url = "https://files.pythonhosted.org/packages/0b/28/dd0967a76aab36731b6ebfe64dec4e981aff7e0608f60c2d46b46982607d/cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743", size = 217078, upload-time = "2025-09-08T23:22:39.776Z" }, + { url = "https://files.pythonhosted.org/packages/2b/c0/015b25184413d7ab0a410775fdb4a50fca20f5589b5dab1dbbfa3baad8ce/cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5", size = 172076, upload-time = "2025-09-08T23:22:40.95Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8f/dc5531155e7070361eb1b7e4c1a9d896d0cb21c49f807a6c03fd63fc877e/cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5", size = 182820, upload-time = "2025-09-08T23:22:42.463Z" }, + { url = "https://files.pythonhosted.org/packages/95/5c/1b493356429f9aecfd56bc171285a4c4ac8697f76e9bbbbb105e537853a1/cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d", size = 177635, upload-time = "2025-09-08T23:22:43.623Z" }, + { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" }, + { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" }, + { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, + { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" }, + { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" }, + { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" }, + { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" }, + { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" }, + { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, + { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, + { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, + { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, + { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, + { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, + { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, + { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, + { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, + { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, + { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, + { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, + { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, + { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, + { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, + { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, + { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, + { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, + { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, + { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, + { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, + { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, + { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, + { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, +] + +[[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/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/97/45/4b3a1239bbacd321068ea6e7ac28875b03ab8bc0aa0966452db17cd36714/charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794", size = 208091, upload-time = "2025-10-14T04:41:13.346Z" }, + { url = "https://files.pythonhosted.org/packages/7d/62/73a6d7450829655a35bb88a88fca7d736f9882a27eacdca2c6d505b57e2e/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed", size = 147936, upload-time = "2025-10-14T04:41:14.461Z" }, + { url = "https://files.pythonhosted.org/packages/89/c5/adb8c8b3d6625bef6d88b251bbb0d95f8205831b987631ab0c8bb5d937c2/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72", size = 144180, upload-time = "2025-10-14T04:41:15.588Z" }, + { url = "https://files.pythonhosted.org/packages/91/ed/9706e4070682d1cc219050b6048bfd293ccf67b3d4f5a4f39207453d4b99/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328", size = 161346, upload-time = "2025-10-14T04:41:16.738Z" }, + { url = "https://files.pythonhosted.org/packages/d5/0d/031f0d95e4972901a2f6f09ef055751805ff541511dc1252ba3ca1f80cf5/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede", size = 158874, upload-time = "2025-10-14T04:41:17.923Z" }, + { url = "https://files.pythonhosted.org/packages/f5/83/6ab5883f57c9c801ce5e5677242328aa45592be8a00644310a008d04f922/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894", size = 153076, upload-time = "2025-10-14T04:41:19.106Z" }, + { url = "https://files.pythonhosted.org/packages/75/1e/5ff781ddf5260e387d6419959ee89ef13878229732732ee73cdae01800f2/charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1", size = 150601, upload-time = "2025-10-14T04:41:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/d7/57/71be810965493d3510a6ca79b90c19e48696fb1ff964da319334b12677f0/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490", size = 150376, upload-time = "2025-10-14T04:41:21.398Z" }, + { url = "https://files.pythonhosted.org/packages/e5/d5/c3d057a78c181d007014feb7e9f2e65905a6c4ef182c0ddf0de2924edd65/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44", size = 144825, upload-time = "2025-10-14T04:41:22.583Z" }, + { url = "https://files.pythonhosted.org/packages/e6/8c/d0406294828d4976f275ffbe66f00266c4b3136b7506941d87c00cab5272/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133", size = 162583, upload-time = "2025-10-14T04:41:23.754Z" }, + { url = "https://files.pythonhosted.org/packages/d7/24/e2aa1f18c8f15c4c0e932d9287b8609dd30ad56dbe41d926bd846e22fb8d/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3", size = 150366, upload-time = "2025-10-14T04:41:25.27Z" }, + { url = "https://files.pythonhosted.org/packages/e4/5b/1e6160c7739aad1e2df054300cc618b06bf784a7a164b0f238360721ab86/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e", size = 160300, upload-time = "2025-10-14T04:41:26.725Z" }, + { url = "https://files.pythonhosted.org/packages/7a/10/f882167cd207fbdd743e55534d5d9620e095089d176d55cb22d5322f2afd/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc", size = 154465, upload-time = "2025-10-14T04:41:28.322Z" }, + { url = "https://files.pythonhosted.org/packages/89/66/c7a9e1b7429be72123441bfdbaf2bc13faab3f90b933f664db506dea5915/charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", size = 99404, upload-time = "2025-10-14T04:41:29.95Z" }, + { url = "https://files.pythonhosted.org/packages/c4/26/b9924fa27db384bdcd97ab83b4f0a8058d96ad9626ead570674d5e737d90/charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", size = 107092, upload-time = "2025-10-14T04:41:31.188Z" }, + { url = "https://files.pythonhosted.org/packages/af/8f/3ed4bfa0c0c72a7ca17f0380cd9e4dd842b09f664e780c13cff1dcf2ef1b/charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", size = 100408, upload-time = "2025-10-14T04:41:32.624Z" }, + { url = "https://files.pythonhosted.org/packages/2a/35/7051599bd493e62411d6ede36fd5af83a38f37c4767b92884df7301db25d/charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", size = 207746, upload-time = "2025-10-14T04:41:33.773Z" }, + { url = "https://files.pythonhosted.org/packages/10/9a/97c8d48ef10d6cd4fcead2415523221624bf58bcf68a802721a6bc807c8f/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", size = 147889, upload-time = "2025-10-14T04:41:34.897Z" }, + { url = "https://files.pythonhosted.org/packages/10/bf/979224a919a1b606c82bd2c5fa49b5c6d5727aa47b4312bb27b1734f53cd/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", size = 143641, upload-time = "2025-10-14T04:41:36.116Z" }, + { url = "https://files.pythonhosted.org/packages/ba/33/0ad65587441fc730dc7bd90e9716b30b4702dc7b617e6ba4997dc8651495/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14", size = 160779, upload-time = "2025-10-14T04:41:37.229Z" }, + { url = "https://files.pythonhosted.org/packages/67/ed/331d6b249259ee71ddea93f6f2f0a56cfebd46938bde6fcc6f7b9a3d0e09/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191", size = 159035, upload-time = "2025-10-14T04:41:38.368Z" }, + { url = "https://files.pythonhosted.org/packages/67/ff/f6b948ca32e4f2a4576aa129d8bed61f2e0543bf9f5f2b7fc3758ed005c9/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838", size = 152542, upload-time = "2025-10-14T04:41:39.862Z" }, + { url = "https://files.pythonhosted.org/packages/16/85/276033dcbcc369eb176594de22728541a925b2632f9716428c851b149e83/charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6", size = 149524, upload-time = "2025-10-14T04:41:41.319Z" }, + { url = "https://files.pythonhosted.org/packages/9e/f2/6a2a1f722b6aba37050e626530a46a68f74e63683947a8acff92569f979a/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e", size = 150395, upload-time = "2025-10-14T04:41:42.539Z" }, + { url = "https://files.pythonhosted.org/packages/60/bb/2186cb2f2bbaea6338cad15ce23a67f9b0672929744381e28b0592676824/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c", size = 143680, upload-time = "2025-10-14T04:41:43.661Z" }, + { url = "https://files.pythonhosted.org/packages/7d/a5/bf6f13b772fbb2a90360eb620d52ed8f796f3c5caee8398c3b2eb7b1c60d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090", size = 162045, upload-time = "2025-10-14T04:41:44.821Z" }, + { url = "https://files.pythonhosted.org/packages/df/c5/d1be898bf0dc3ef9030c3825e5d3b83f2c528d207d246cbabe245966808d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152", size = 149687, upload-time = "2025-10-14T04:41:46.442Z" }, + { url = "https://files.pythonhosted.org/packages/a5/42/90c1f7b9341eef50c8a1cb3f098ac43b0508413f33affd762855f67a410e/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828", size = 160014, upload-time = "2025-10-14T04:41:47.631Z" }, + { url = "https://files.pythonhosted.org/packages/76/be/4d3ee471e8145d12795ab655ece37baed0929462a86e72372fd25859047c/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec", size = 154044, upload-time = "2025-10-14T04:41:48.81Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6f/8f7af07237c34a1defe7defc565a9bc1807762f672c0fde711a4b22bf9c0/charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9", size = 99940, upload-time = "2025-10-14T04:41:49.946Z" }, + { url = "https://files.pythonhosted.org/packages/4b/51/8ade005e5ca5b0d80fb4aff72a3775b325bdc3d27408c8113811a7cbe640/charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c", size = 107104, upload-time = "2025-10-14T04:41:51.051Z" }, + { url = "https://files.pythonhosted.org/packages/da/5f/6b8f83a55bb8278772c5ae54a577f3099025f9ade59d0136ac24a0df4bde/charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2", size = 100743, upload-time = "2025-10-14T04:41:52.122Z" }, + { 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 = "comm" +version = "0.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4c/13/7d740c5849255756bc17888787313b61fd38a0a8304fc4f073dfc46122aa/comm-0.2.3.tar.gz", hash = "sha256:2dc8048c10962d55d7ad693be1e7045d891b7ce8d999c97963a5e3e99c055971", size = 6319, upload-time = "2025-07-25T14:02:04.452Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/60/97/891a0971e1e4a8c5d2b20bbe0e524dc04548d2307fee33cdeba148fd4fc7/comm-0.2.3-py3-none-any.whl", hash = "sha256:c615d91d75f7f04f095b30d1c1711babd43bdc6419c1be9886a85f2f4e489417", size = 7294, upload-time = "2025-07-25T14:02:02.896Z" }, +] + +[[package]] +name = "contourpy" +version = "1.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/58/01/1253e6698a07380cd31a736d248a3f2a50a7c88779a1813da27503cadc2a/contourpy-1.3.3.tar.gz", hash = "sha256:083e12155b210502d0bca491432bb04d56dc3432f95a979b429f2848c3dbe880", size = 13466174, upload-time = "2025-07-26T12:03:12.549Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/91/2e/c4390a31919d8a78b90e8ecf87cd4b4c4f05a5b48d05ec17db8e5404c6f4/contourpy-1.3.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:709a48ef9a690e1343202916450bc48b9e51c049b089c7f79a267b46cffcdaa1", size = 288773, upload-time = "2025-07-26T12:01:02.277Z" }, + { url = "https://files.pythonhosted.org/packages/0d/44/c4b0b6095fef4dc9c420e041799591e3b63e9619e3044f7f4f6c21c0ab24/contourpy-1.3.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:23416f38bfd74d5d28ab8429cc4d63fa67d5068bd711a85edb1c3fb0c3e2f381", size = 270149, upload-time = "2025-07-26T12:01:04.072Z" }, + { url = "https://files.pythonhosted.org/packages/30/2e/dd4ced42fefac8470661d7cb7e264808425e6c5d56d175291e93890cce09/contourpy-1.3.3-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:929ddf8c4c7f348e4c0a5a3a714b5c8542ffaa8c22954862a46ca1813b667ee7", size = 329222, upload-time = "2025-07-26T12:01:05.688Z" }, + { url = "https://files.pythonhosted.org/packages/f2/74/cc6ec2548e3d276c71389ea4802a774b7aa3558223b7bade3f25787fafc2/contourpy-1.3.3-cp311-cp311-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9e999574eddae35f1312c2b4b717b7885d4edd6cb46700e04f7f02db454e67c1", size = 377234, upload-time = "2025-07-26T12:01:07.054Z" }, + { url = "https://files.pythonhosted.org/packages/03/b3/64ef723029f917410f75c09da54254c5f9ea90ef89b143ccadb09df14c15/contourpy-1.3.3-cp311-cp311-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0bf67e0e3f482cb69779dd3061b534eb35ac9b17f163d851e2a547d56dba0a3a", size = 380555, upload-time = "2025-07-26T12:01:08.801Z" }, + { url = "https://files.pythonhosted.org/packages/5f/4b/6157f24ca425b89fe2eb7e7be642375711ab671135be21e6faa100f7448c/contourpy-1.3.3-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:51e79c1f7470158e838808d4a996fa9bac72c498e93d8ebe5119bc1e6becb0db", size = 355238, upload-time = "2025-07-26T12:01:10.319Z" }, + { url = "https://files.pythonhosted.org/packages/98/56/f914f0dd678480708a04cfd2206e7c382533249bc5001eb9f58aa693e200/contourpy-1.3.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:598c3aaece21c503615fd59c92a3598b428b2f01bfb4b8ca9c4edeecc2438620", size = 1326218, upload-time = "2025-07-26T12:01:12.659Z" }, + { url = "https://files.pythonhosted.org/packages/fb/d7/4a972334a0c971acd5172389671113ae82aa7527073980c38d5868ff1161/contourpy-1.3.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:322ab1c99b008dad206d406bb61d014cf0174df491ae9d9d0fac6a6fda4f977f", size = 1392867, upload-time = "2025-07-26T12:01:15.533Z" }, + { url = "https://files.pythonhosted.org/packages/75/3e/f2cc6cd56dc8cff46b1a56232eabc6feea52720083ea71ab15523daab796/contourpy-1.3.3-cp311-cp311-win32.whl", hash = "sha256:fd907ae12cd483cd83e414b12941c632a969171bf90fc937d0c9f268a31cafff", size = 183677, upload-time = "2025-07-26T12:01:17.088Z" }, + { url = "https://files.pythonhosted.org/packages/98/4b/9bd370b004b5c9d8045c6c33cf65bae018b27aca550a3f657cdc99acdbd8/contourpy-1.3.3-cp311-cp311-win_amd64.whl", hash = "sha256:3519428f6be58431c56581f1694ba8e50626f2dd550af225f82fb5f5814d2a42", size = 225234, upload-time = "2025-07-26T12:01:18.256Z" }, + { url = "https://files.pythonhosted.org/packages/d9/b6/71771e02c2e004450c12b1120a5f488cad2e4d5b590b1af8bad060360fe4/contourpy-1.3.3-cp311-cp311-win_arm64.whl", hash = "sha256:15ff10bfada4bf92ec8b31c62bf7c1834c244019b4a33095a68000d7075df470", size = 193123, upload-time = "2025-07-26T12:01:19.848Z" }, + { url = "https://files.pythonhosted.org/packages/be/45/adfee365d9ea3d853550b2e735f9d66366701c65db7855cd07621732ccfc/contourpy-1.3.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b08a32ea2f8e42cf1d4be3169a98dd4be32bafe4f22b6c4cb4ba810fa9e5d2cb", size = 293419, upload-time = "2025-07-26T12:01:21.16Z" }, + { url = "https://files.pythonhosted.org/packages/53/3e/405b59cfa13021a56bba395a6b3aca8cec012b45bf177b0eaf7a202cde2c/contourpy-1.3.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:556dba8fb6f5d8742f2923fe9457dbdd51e1049c4a43fd3986a0b14a1d815fc6", size = 273979, upload-time = "2025-07-26T12:01:22.448Z" }, + { url = "https://files.pythonhosted.org/packages/d4/1c/a12359b9b2ca3a845e8f7f9ac08bdf776114eb931392fcad91743e2ea17b/contourpy-1.3.3-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92d9abc807cf7d0e047b95ca5d957cf4792fcd04e920ca70d48add15c1a90ea7", size = 332653, upload-time = "2025-07-26T12:01:24.155Z" }, + { url = "https://files.pythonhosted.org/packages/63/12/897aeebfb475b7748ea67b61e045accdfcf0d971f8a588b67108ed7f5512/contourpy-1.3.3-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b2e8faa0ed68cb29af51edd8e24798bb661eac3bd9f65420c1887b6ca89987c8", size = 379536, upload-time = "2025-07-26T12:01:25.91Z" }, + { url = "https://files.pythonhosted.org/packages/43/8a/a8c584b82deb248930ce069e71576fc09bd7174bbd35183b7943fb1064fd/contourpy-1.3.3-cp312-cp312-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:626d60935cf668e70a5ce6ff184fd713e9683fb458898e4249b63be9e28286ea", size = 384397, upload-time = "2025-07-26T12:01:27.152Z" }, + { url = "https://files.pythonhosted.org/packages/cc/8f/ec6289987824b29529d0dfda0d74a07cec60e54b9c92f3c9da4c0ac732de/contourpy-1.3.3-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4d00e655fcef08aba35ec9610536bfe90267d7ab5ba944f7032549c55a146da1", size = 362601, upload-time = "2025-07-26T12:01:28.808Z" }, + { url = "https://files.pythonhosted.org/packages/05/0a/a3fe3be3ee2dceb3e615ebb4df97ae6f3828aa915d3e10549ce016302bd1/contourpy-1.3.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:451e71b5a7d597379ef572de31eeb909a87246974d960049a9848c3bc6c41bf7", size = 1331288, upload-time = "2025-07-26T12:01:31.198Z" }, + { url = "https://files.pythonhosted.org/packages/33/1d/acad9bd4e97f13f3e2b18a3977fe1b4a37ecf3d38d815333980c6c72e963/contourpy-1.3.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:459c1f020cd59fcfe6650180678a9993932d80d44ccde1fa1868977438f0b411", size = 1403386, upload-time = "2025-07-26T12:01:33.947Z" }, + { url = "https://files.pythonhosted.org/packages/cf/8f/5847f44a7fddf859704217a99a23a4f6417b10e5ab1256a179264561540e/contourpy-1.3.3-cp312-cp312-win32.whl", hash = "sha256:023b44101dfe49d7d53932be418477dba359649246075c996866106da069af69", size = 185018, upload-time = "2025-07-26T12:01:35.64Z" }, + { url = "https://files.pythonhosted.org/packages/19/e8/6026ed58a64563186a9ee3f29f41261fd1828f527dd93d33b60feca63352/contourpy-1.3.3-cp312-cp312-win_amd64.whl", hash = "sha256:8153b8bfc11e1e4d75bcb0bff1db232f9e10b274e0929de9d608027e0d34ff8b", size = 226567, upload-time = "2025-07-26T12:01:36.804Z" }, + { url = "https://files.pythonhosted.org/packages/d1/e2/f05240d2c39a1ed228d8328a78b6f44cd695f7ef47beb3e684cf93604f86/contourpy-1.3.3-cp312-cp312-win_arm64.whl", hash = "sha256:07ce5ed73ecdc4a03ffe3e1b3e3c1166db35ae7584be76f65dbbe28a7791b0cc", size = 193655, upload-time = "2025-07-26T12:01:37.999Z" }, + { url = "https://files.pythonhosted.org/packages/68/35/0167aad910bbdb9599272bd96d01a9ec6852f36b9455cf2ca67bd4cc2d23/contourpy-1.3.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:177fb367556747a686509d6fef71d221a4b198a3905fe824430e5ea0fda54eb5", size = 293257, upload-time = "2025-07-26T12:01:39.367Z" }, + { url = "https://files.pythonhosted.org/packages/96/e4/7adcd9c8362745b2210728f209bfbcf7d91ba868a2c5f40d8b58f54c509b/contourpy-1.3.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d002b6f00d73d69333dac9d0b8d5e84d9724ff9ef044fd63c5986e62b7c9e1b1", size = 274034, upload-time = "2025-07-26T12:01:40.645Z" }, + { url = "https://files.pythonhosted.org/packages/73/23/90e31ceeed1de63058a02cb04b12f2de4b40e3bef5e082a7c18d9c8ae281/contourpy-1.3.3-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:348ac1f5d4f1d66d3322420f01d42e43122f43616e0f194fc1c9f5d830c5b286", size = 334672, upload-time = "2025-07-26T12:01:41.942Z" }, + { url = "https://files.pythonhosted.org/packages/ed/93/b43d8acbe67392e659e1d984700e79eb67e2acb2bd7f62012b583a7f1b55/contourpy-1.3.3-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:655456777ff65c2c548b7c454af9c6f33f16c8884f11083244b5819cc214f1b5", size = 381234, upload-time = "2025-07-26T12:01:43.499Z" }, + { url = "https://files.pythonhosted.org/packages/46/3b/bec82a3ea06f66711520f75a40c8fc0b113b2a75edb36aa633eb11c4f50f/contourpy-1.3.3-cp313-cp313-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:644a6853d15b2512d67881586bd03f462c7ab755db95f16f14d7e238f2852c67", size = 385169, upload-time = "2025-07-26T12:01:45.219Z" }, + { url = "https://files.pythonhosted.org/packages/4b/32/e0f13a1c5b0f8572d0ec6ae2f6c677b7991fafd95da523159c19eff0696a/contourpy-1.3.3-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4debd64f124ca62069f313a9cb86656ff087786016d76927ae2cf37846b006c9", size = 362859, upload-time = "2025-07-26T12:01:46.519Z" }, + { url = "https://files.pythonhosted.org/packages/33/71/e2a7945b7de4e58af42d708a219f3b2f4cff7386e6b6ab0a0fa0033c49a9/contourpy-1.3.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a15459b0f4615b00bbd1e91f1b9e19b7e63aea7483d03d804186f278c0af2659", size = 1332062, upload-time = "2025-07-26T12:01:48.964Z" }, + { url = "https://files.pythonhosted.org/packages/12/fc/4e87ac754220ccc0e807284f88e943d6d43b43843614f0a8afa469801db0/contourpy-1.3.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ca0fdcd73925568ca027e0b17ab07aad764be4706d0a925b89227e447d9737b7", size = 1403932, upload-time = "2025-07-26T12:01:51.979Z" }, + { url = "https://files.pythonhosted.org/packages/a6/2e/adc197a37443f934594112222ac1aa7dc9a98faf9c3842884df9a9d8751d/contourpy-1.3.3-cp313-cp313-win32.whl", hash = "sha256:b20c7c9a3bf701366556e1b1984ed2d0cedf999903c51311417cf5f591d8c78d", size = 185024, upload-time = "2025-07-26T12:01:53.245Z" }, + { url = "https://files.pythonhosted.org/packages/18/0b/0098c214843213759692cc638fce7de5c289200a830e5035d1791d7a2338/contourpy-1.3.3-cp313-cp313-win_amd64.whl", hash = "sha256:1cadd8b8969f060ba45ed7c1b714fe69185812ab43bd6b86a9123fe8f99c3263", size = 226578, upload-time = "2025-07-26T12:01:54.422Z" }, + { url = "https://files.pythonhosted.org/packages/8a/9a/2f6024a0c5995243cd63afdeb3651c984f0d2bc727fd98066d40e141ad73/contourpy-1.3.3-cp313-cp313-win_arm64.whl", hash = "sha256:fd914713266421b7536de2bfa8181aa8c699432b6763a0ea64195ebe28bff6a9", size = 193524, upload-time = "2025-07-26T12:01:55.73Z" }, + { url = "https://files.pythonhosted.org/packages/c0/b3/f8a1a86bd3298513f500e5b1f5fd92b69896449f6cab6a146a5d52715479/contourpy-1.3.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:88df9880d507169449d434c293467418b9f6cbe82edd19284aa0409e7fdb933d", size = 306730, upload-time = "2025-07-26T12:01:57.051Z" }, + { url = "https://files.pythonhosted.org/packages/3f/11/4780db94ae62fc0c2053909b65dc3246bd7cecfc4f8a20d957ad43aa4ad8/contourpy-1.3.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:d06bb1f751ba5d417047db62bca3c8fde202b8c11fb50742ab3ab962c81e8216", size = 287897, upload-time = "2025-07-26T12:01:58.663Z" }, + { url = "https://files.pythonhosted.org/packages/ae/15/e59f5f3ffdd6f3d4daa3e47114c53daabcb18574a26c21f03dc9e4e42ff0/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e4e6b05a45525357e382909a4c1600444e2a45b4795163d3b22669285591c1ae", size = 326751, upload-time = "2025-07-26T12:02:00.343Z" }, + { url = "https://files.pythonhosted.org/packages/0f/81/03b45cfad088e4770b1dcf72ea78d3802d04200009fb364d18a493857210/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ab3074b48c4e2cf1a960e6bbeb7f04566bf36b1861d5c9d4d8ac04b82e38ba20", size = 375486, upload-time = "2025-07-26T12:02:02.128Z" }, + { url = "https://files.pythonhosted.org/packages/0c/ba/49923366492ffbdd4486e970d421b289a670ae8cf539c1ea9a09822b371a/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6c3d53c796f8647d6deb1abe867daeb66dcc8a97e8455efa729516b997b8ed99", size = 388106, upload-time = "2025-07-26T12:02:03.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/52/5b00ea89525f8f143651f9f03a0df371d3cbd2fccd21ca9b768c7a6500c2/contourpy-1.3.3-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50ed930df7289ff2a8d7afeb9603f8289e5704755c7e5c3bbd929c90c817164b", size = 352548, upload-time = "2025-07-26T12:02:05.165Z" }, + { url = "https://files.pythonhosted.org/packages/32/1d/a209ec1a3a3452d490f6b14dd92e72280c99ae3d1e73da74f8277d4ee08f/contourpy-1.3.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4feffb6537d64b84877da813a5c30f1422ea5739566abf0bd18065ac040e120a", size = 1322297, upload-time = "2025-07-26T12:02:07.379Z" }, + { url = "https://files.pythonhosted.org/packages/bc/9e/46f0e8ebdd884ca0e8877e46a3f4e633f6c9c8c4f3f6e72be3fe075994aa/contourpy-1.3.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:2b7e9480ffe2b0cd2e787e4df64270e3a0440d9db8dc823312e2c940c167df7e", size = 1391023, upload-time = "2025-07-26T12:02:10.171Z" }, + { url = "https://files.pythonhosted.org/packages/b9/70/f308384a3ae9cd2209e0849f33c913f658d3326900d0ff5d378d6a1422d2/contourpy-1.3.3-cp313-cp313t-win32.whl", hash = "sha256:283edd842a01e3dcd435b1c5116798d661378d83d36d337b8dde1d16a5fc9ba3", size = 196157, upload-time = "2025-07-26T12:02:11.488Z" }, + { url = "https://files.pythonhosted.org/packages/b2/dd/880f890a6663b84d9e34a6f88cded89d78f0091e0045a284427cb6b18521/contourpy-1.3.3-cp313-cp313t-win_amd64.whl", hash = "sha256:87acf5963fc2b34825e5b6b048f40e3635dd547f590b04d2ab317c2619ef7ae8", size = 240570, upload-time = "2025-07-26T12:02:12.754Z" }, + { url = "https://files.pythonhosted.org/packages/80/99/2adc7d8ffead633234817ef8e9a87115c8a11927a94478f6bb3d3f4d4f7d/contourpy-1.3.3-cp313-cp313t-win_arm64.whl", hash = "sha256:3c30273eb2a55024ff31ba7d052dde990d7d8e5450f4bbb6e913558b3d6c2301", size = 199713, upload-time = "2025-07-26T12:02:14.4Z" }, + { url = "https://files.pythonhosted.org/packages/72/8b/4546f3ab60f78c514ffb7d01a0bd743f90de36f0019d1be84d0a708a580a/contourpy-1.3.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fde6c716d51c04b1c25d0b90364d0be954624a0ee9d60e23e850e8d48353d07a", size = 292189, upload-time = "2025-07-26T12:02:16.095Z" }, + { url = "https://files.pythonhosted.org/packages/fd/e1/3542a9cb596cadd76fcef413f19c79216e002623158befe6daa03dbfa88c/contourpy-1.3.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:cbedb772ed74ff5be440fa8eee9bd49f64f6e3fc09436d9c7d8f1c287b121d77", size = 273251, upload-time = "2025-07-26T12:02:17.524Z" }, + { url = "https://files.pythonhosted.org/packages/b1/71/f93e1e9471d189f79d0ce2497007731c1e6bf9ef6d1d61b911430c3db4e5/contourpy-1.3.3-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:22e9b1bd7a9b1d652cd77388465dc358dafcd2e217d35552424aa4f996f524f5", size = 335810, upload-time = "2025-07-26T12:02:18.9Z" }, + { url = "https://files.pythonhosted.org/packages/91/f9/e35f4c1c93f9275d4e38681a80506b5510e9327350c51f8d4a5a724d178c/contourpy-1.3.3-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a22738912262aa3e254e4f3cb079a95a67132fc5a063890e224393596902f5a4", size = 382871, upload-time = "2025-07-26T12:02:20.418Z" }, + { url = "https://files.pythonhosted.org/packages/b5/71/47b512f936f66a0a900d81c396a7e60d73419868fba959c61efed7a8ab46/contourpy-1.3.3-cp314-cp314-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:afe5a512f31ee6bd7d0dda52ec9864c984ca3d66664444f2d72e0dc4eb832e36", size = 386264, upload-time = "2025-07-26T12:02:21.916Z" }, + { url = "https://files.pythonhosted.org/packages/04/5f/9ff93450ba96b09c7c2b3f81c94de31c89f92292f1380261bd7195bea4ea/contourpy-1.3.3-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f64836de09927cba6f79dcd00fdd7d5329f3fccc633468507079c829ca4db4e3", size = 363819, upload-time = "2025-07-26T12:02:23.759Z" }, + { url = "https://files.pythonhosted.org/packages/3e/a6/0b185d4cc480ee494945cde102cb0149ae830b5fa17bf855b95f2e70ad13/contourpy-1.3.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1fd43c3be4c8e5fd6e4f2baeae35ae18176cf2e5cced681cca908addf1cdd53b", size = 1333650, upload-time = "2025-07-26T12:02:26.181Z" }, + { url = "https://files.pythonhosted.org/packages/43/d7/afdc95580ca56f30fbcd3060250f66cedbde69b4547028863abd8aa3b47e/contourpy-1.3.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6afc576f7b33cf00996e5c1102dc2a8f7cc89e39c0b55df93a0b78c1bd992b36", size = 1404833, upload-time = "2025-07-26T12:02:28.782Z" }, + { url = "https://files.pythonhosted.org/packages/e2/e2/366af18a6d386f41132a48f033cbd2102e9b0cf6345d35ff0826cd984566/contourpy-1.3.3-cp314-cp314-win32.whl", hash = "sha256:66c8a43a4f7b8df8b71ee1840e4211a3c8d93b214b213f590e18a1beca458f7d", size = 189692, upload-time = "2025-07-26T12:02:30.128Z" }, + { url = "https://files.pythonhosted.org/packages/7d/c2/57f54b03d0f22d4044b8afb9ca0e184f8b1afd57b4f735c2fa70883dc601/contourpy-1.3.3-cp314-cp314-win_amd64.whl", hash = "sha256:cf9022ef053f2694e31d630feaacb21ea24224be1c3ad0520b13d844274614fd", size = 232424, upload-time = "2025-07-26T12:02:31.395Z" }, + { url = "https://files.pythonhosted.org/packages/18/79/a9416650df9b525737ab521aa181ccc42d56016d2123ddcb7b58e926a42c/contourpy-1.3.3-cp314-cp314-win_arm64.whl", hash = "sha256:95b181891b4c71de4bb404c6621e7e2390745f887f2a026b2d99e92c17892339", size = 198300, upload-time = "2025-07-26T12:02:32.956Z" }, + { url = "https://files.pythonhosted.org/packages/1f/42/38c159a7d0f2b7b9c04c64ab317042bb6952b713ba875c1681529a2932fe/contourpy-1.3.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:33c82d0138c0a062380332c861387650c82e4cf1747aaa6938b9b6516762e772", size = 306769, upload-time = "2025-07-26T12:02:34.2Z" }, + { url = "https://files.pythonhosted.org/packages/c3/6c/26a8205f24bca10974e77460de68d3d7c63e282e23782f1239f226fcae6f/contourpy-1.3.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ea37e7b45949df430fe649e5de8351c423430046a2af20b1c1961cae3afcda77", size = 287892, upload-time = "2025-07-26T12:02:35.807Z" }, + { url = "https://files.pythonhosted.org/packages/66/06/8a475c8ab718ebfd7925661747dbb3c3ee9c82ac834ccb3570be49d129f4/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d304906ecc71672e9c89e87c4675dc5c2645e1f4269a5063b99b0bb29f232d13", size = 326748, upload-time = "2025-07-26T12:02:37.193Z" }, + { url = "https://files.pythonhosted.org/packages/b4/a3/c5ca9f010a44c223f098fccd8b158bb1cb287378a31ac141f04730dc49be/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ca658cd1a680a5c9ea96dc61cdbae1e85c8f25849843aa799dfd3cb370ad4fbe", size = 375554, upload-time = "2025-07-26T12:02:38.894Z" }, + { url = "https://files.pythonhosted.org/packages/80/5b/68bd33ae63fac658a4145088c1e894405e07584a316738710b636c6d0333/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ab2fd90904c503739a75b7c8c5c01160130ba67944a7b77bbf36ef8054576e7f", size = 388118, upload-time = "2025-07-26T12:02:40.642Z" }, + { url = "https://files.pythonhosted.org/packages/40/52/4c285a6435940ae25d7410a6c36bda5145839bc3f0beb20c707cda18b9d2/contourpy-1.3.3-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b7301b89040075c30e5768810bc96a8e8d78085b47d8be6e4c3f5a0b4ed478a0", size = 352555, upload-time = "2025-07-26T12:02:42.25Z" }, + { url = "https://files.pythonhosted.org/packages/24/ee/3e81e1dd174f5c7fefe50e85d0892de05ca4e26ef1c9a59c2a57e43b865a/contourpy-1.3.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:2a2a8b627d5cc6b7c41a4beff6c5ad5eb848c88255fda4a8745f7e901b32d8e4", size = 1322295, upload-time = "2025-07-26T12:02:44.668Z" }, + { url = "https://files.pythonhosted.org/packages/3c/b2/6d913d4d04e14379de429057cd169e5e00f6c2af3bb13e1710bcbdb5da12/contourpy-1.3.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:fd6ec6be509c787f1caf6b247f0b1ca598bef13f4ddeaa126b7658215529ba0f", size = 1391027, upload-time = "2025-07-26T12:02:47.09Z" }, + { url = "https://files.pythonhosted.org/packages/93/8a/68a4ec5c55a2971213d29a9374913f7e9f18581945a7a31d1a39b5d2dfe5/contourpy-1.3.3-cp314-cp314t-win32.whl", hash = "sha256:e74a9a0f5e3fff48fb5a7f2fd2b9b70a3fe014a67522f79b7cca4c0c7e43c9ae", size = 202428, upload-time = "2025-07-26T12:02:48.691Z" }, + { url = "https://files.pythonhosted.org/packages/fa/96/fd9f641ffedc4fa3ace923af73b9d07e869496c9cc7a459103e6e978992f/contourpy-1.3.3-cp314-cp314t-win_amd64.whl", hash = "sha256:13b68d6a62db8eafaebb8039218921399baf6e47bf85006fd8529f2a08ef33fc", size = 250331, upload-time = "2025-07-26T12:02:50.137Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8c/469afb6465b853afff216f9528ffda78a915ff880ed58813ba4faf4ba0b6/contourpy-1.3.3-cp314-cp314t-win_arm64.whl", hash = "sha256:b7448cb5a725bb1e35ce88771b86fba35ef418952474492cf7c764059933ff8b", size = 203831, upload-time = "2025-07-26T12:02:51.449Z" }, + { url = "https://files.pythonhosted.org/packages/a5/29/8dcfe16f0107943fa92388c23f6e05cff0ba58058c4c95b00280d4c75a14/contourpy-1.3.3-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:cd5dfcaeb10f7b7f9dc8941717c6c2ade08f587be2226222c12b25f0483ed497", size = 278809, upload-time = "2025-07-26T12:02:52.74Z" }, + { url = "https://files.pythonhosted.org/packages/85/a9/8b37ef4f7dafeb335daee3c8254645ef5725be4d9c6aa70b50ec46ef2f7e/contourpy-1.3.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:0c1fc238306b35f246d61a1d416a627348b5cf0648648a031e14bb8705fcdfe8", size = 261593, upload-time = "2025-07-26T12:02:54.037Z" }, + { url = "https://files.pythonhosted.org/packages/0a/59/ebfb8c677c75605cc27f7122c90313fd2f375ff3c8d19a1694bda74aaa63/contourpy-1.3.3-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:70f9aad7de812d6541d29d2bbf8feb22ff7e1c299523db288004e3157ff4674e", size = 302202, upload-time = "2025-07-26T12:02:55.947Z" }, + { url = "https://files.pythonhosted.org/packages/3c/37/21972a15834d90bfbfb009b9d004779bd5a07a0ec0234e5ba8f64d5736f4/contourpy-1.3.3-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5ed3657edf08512fc3fe81b510e35c2012fbd3081d2e26160f27ca28affec989", size = 329207, upload-time = "2025-07-26T12:02:57.468Z" }, + { url = "https://files.pythonhosted.org/packages/0c/58/bd257695f39d05594ca4ad60df5bcb7e32247f9951fd09a9b8edb82d1daa/contourpy-1.3.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:3d1a3799d62d45c18bafd41c5fa05120b96a28079f2393af559b843d1a966a77", size = 225315, upload-time = "2025-07-26T12:02:58.801Z" }, +] + +[[package]] +name = "coverage" +version = "7.12.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/89/26/4a96807b193b011588099c3b5c89fbb05294e5b90e71018e065465f34eb6/coverage-7.12.0.tar.gz", hash = "sha256:fc11e0a4e372cb5f282f16ef90d4a585034050ccda536451901abfb19a57f40c", size = 819341, upload-time = "2025-11-18T13:34:20.766Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/0c/0dfe7f0487477d96432e4815537263363fb6dd7289743a796e8e51eabdf2/coverage-7.12.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:aa124a3683d2af98bd9d9c2bfa7a5076ca7e5ab09fdb96b81fa7d89376ae928f", size = 217535, upload-time = "2025-11-18T13:32:08.812Z" }, + { url = "https://files.pythonhosted.org/packages/9b/f5/f9a4a053a5bbff023d3bec259faac8f11a1e5a6479c2ccf586f910d8dac7/coverage-7.12.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d93fbf446c31c0140208dcd07c5d882029832e8ed7891a39d6d44bd65f2316c3", size = 218044, upload-time = "2025-11-18T13:32:10.329Z" }, + { url = "https://files.pythonhosted.org/packages/95/c5/84fc3697c1fa10cd8571919bf9693f693b7373278daaf3b73e328d502bc8/coverage-7.12.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:52ca620260bd8cd6027317bdd8b8ba929be1d741764ee765b42c4d79a408601e", size = 248440, upload-time = "2025-11-18T13:32:12.536Z" }, + { url = "https://files.pythonhosted.org/packages/f4/36/2d93fbf6a04670f3874aed397d5a5371948a076e3249244a9e84fb0e02d6/coverage-7.12.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f3433ffd541380f3a0e423cff0f4926d55b0cc8c1d160fdc3be24a4c03aa65f7", size = 250361, upload-time = "2025-11-18T13:32:13.852Z" }, + { url = "https://files.pythonhosted.org/packages/5d/49/66dc65cc456a6bfc41ea3d0758c4afeaa4068a2b2931bf83be6894cf1058/coverage-7.12.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f7bbb321d4adc9f65e402c677cd1c8e4c2d0105d3ce285b51b4d87f1d5db5245", size = 252472, upload-time = "2025-11-18T13:32:15.068Z" }, + { url = "https://files.pythonhosted.org/packages/35/1f/ebb8a18dffd406db9fcd4b3ae42254aedcaf612470e8712f12041325930f/coverage-7.12.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:22a7aade354a72dff3b59c577bfd18d6945c61f97393bc5fb7bd293a4237024b", size = 248592, upload-time = "2025-11-18T13:32:16.328Z" }, + { url = "https://files.pythonhosted.org/packages/da/a8/67f213c06e5ea3b3d4980df7dc344d7fea88240b5fe878a5dcbdfe0e2315/coverage-7.12.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:3ff651dcd36d2fea66877cd4a82de478004c59b849945446acb5baf9379a1b64", size = 250167, upload-time = "2025-11-18T13:32:17.687Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/e52aef68154164ea40cc8389c120c314c747fe63a04b013a5782e989b77f/coverage-7.12.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:31b8b2e38391a56e3cea39d22a23faaa7c3fc911751756ef6d2621d2a9daf742", size = 248238, upload-time = "2025-11-18T13:32:19.2Z" }, + { url = "https://files.pythonhosted.org/packages/1f/a4/4d88750bcf9d6d66f77865e5a05a20e14db44074c25fd22519777cb69025/coverage-7.12.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:297bc2da28440f5ae51c845a47c8175a4db0553a53827886e4fb25c66633000c", size = 247964, upload-time = "2025-11-18T13:32:21.027Z" }, + { url = "https://files.pythonhosted.org/packages/a7/6b/b74693158899d5b47b0bf6238d2c6722e20ba749f86b74454fac0696bb00/coverage-7.12.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6ff7651cc01a246908eac162a6a86fc0dbab6de1ad165dfb9a1e2ec660b44984", size = 248862, upload-time = "2025-11-18T13:32:22.304Z" }, + { url = "https://files.pythonhosted.org/packages/18/de/6af6730227ce0e8ade307b1cc4a08e7f51b419a78d02083a86c04ccceb29/coverage-7.12.0-cp311-cp311-win32.whl", hash = "sha256:313672140638b6ddb2c6455ddeda41c6a0b208298034544cfca138978c6baed6", size = 220033, upload-time = "2025-11-18T13:32:23.714Z" }, + { url = "https://files.pythonhosted.org/packages/e2/a1/e7f63021a7c4fe20994359fcdeae43cbef4a4d0ca36a5a1639feeea5d9e1/coverage-7.12.0-cp311-cp311-win_amd64.whl", hash = "sha256:a1783ed5bd0d5938d4435014626568dc7f93e3cb99bc59188cc18857c47aa3c4", size = 220966, upload-time = "2025-11-18T13:32:25.599Z" }, + { url = "https://files.pythonhosted.org/packages/77/e8/deae26453f37c20c3aa0c4433a1e32cdc169bf415cce223a693117aa3ddd/coverage-7.12.0-cp311-cp311-win_arm64.whl", hash = "sha256:4648158fd8dd9381b5847622df1c90ff314efbfc1df4550092ab6013c238a5fc", size = 219637, upload-time = "2025-11-18T13:32:27.265Z" }, + { url = "https://files.pythonhosted.org/packages/02/bf/638c0427c0f0d47638242e2438127f3c8ee3cfc06c7fdeb16778ed47f836/coverage-7.12.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:29644c928772c78512b48e14156b81255000dcfd4817574ff69def189bcb3647", size = 217704, upload-time = "2025-11-18T13:32:28.906Z" }, + { url = "https://files.pythonhosted.org/packages/08/e1/706fae6692a66c2d6b871a608bbde0da6281903fa0e9f53a39ed441da36a/coverage-7.12.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8638cbb002eaa5d7c8d04da667813ce1067080b9a91099801a0053086e52b736", size = 218064, upload-time = "2025-11-18T13:32:30.161Z" }, + { url = "https://files.pythonhosted.org/packages/a9/8b/eb0231d0540f8af3ffda39720ff43cb91926489d01524e68f60e961366e4/coverage-7.12.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:083631eeff5eb9992c923e14b810a179798bb598e6a0dd60586819fc23be6e60", size = 249560, upload-time = "2025-11-18T13:32:31.835Z" }, + { url = "https://files.pythonhosted.org/packages/e9/a1/67fb52af642e974d159b5b379e4d4c59d0ebe1288677fbd04bbffe665a82/coverage-7.12.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:99d5415c73ca12d558e07776bd957c4222c687b9f1d26fa0e1b57e3598bdcde8", size = 252318, upload-time = "2025-11-18T13:32:33.178Z" }, + { url = "https://files.pythonhosted.org/packages/41/e5/38228f31b2c7665ebf9bdfdddd7a184d56450755c7e43ac721c11a4b8dab/coverage-7.12.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e949ebf60c717c3df63adb4a1a366c096c8d7fd8472608cd09359e1bd48ef59f", size = 253403, upload-time = "2025-11-18T13:32:34.45Z" }, + { url = "https://files.pythonhosted.org/packages/ec/4b/df78e4c8188f9960684267c5a4897836f3f0f20a20c51606ee778a1d9749/coverage-7.12.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6d907ddccbca819afa2cd014bc69983b146cca2735a0b1e6259b2a6c10be1e70", size = 249984, upload-time = "2025-11-18T13:32:35.747Z" }, + { url = "https://files.pythonhosted.org/packages/ba/51/bb163933d195a345c6f63eab9e55743413d064c291b6220df754075c2769/coverage-7.12.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b1518ecbad4e6173f4c6e6c4a46e49555ea5679bf3feda5edb1b935c7c44e8a0", size = 251339, upload-time = "2025-11-18T13:32:37.352Z" }, + { url = "https://files.pythonhosted.org/packages/15/40/c9b29cdb8412c837cdcbc2cfa054547dd83affe6cbbd4ce4fdb92b6ba7d1/coverage-7.12.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:51777647a749abdf6f6fd8c7cffab12de68ab93aab15efc72fbbb83036c2a068", size = 249489, upload-time = "2025-11-18T13:32:39.212Z" }, + { url = "https://files.pythonhosted.org/packages/c8/da/b3131e20ba07a0de4437a50ef3b47840dfabf9293675b0cd5c2c7f66dd61/coverage-7.12.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:42435d46d6461a3b305cdfcad7cdd3248787771f53fe18305548cba474e6523b", size = 249070, upload-time = "2025-11-18T13:32:40.598Z" }, + { url = "https://files.pythonhosted.org/packages/70/81/b653329b5f6302c08d683ceff6785bc60a34be9ae92a5c7b63ee7ee7acec/coverage-7.12.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5bcead88c8423e1855e64b8057d0544e33e4080b95b240c2a355334bb7ced937", size = 250929, upload-time = "2025-11-18T13:32:42.915Z" }, + { url = "https://files.pythonhosted.org/packages/a3/00/250ac3bca9f252a5fb1338b5ad01331ebb7b40223f72bef5b1b2cb03aa64/coverage-7.12.0-cp312-cp312-win32.whl", hash = "sha256:dcbb630ab034e86d2a0f79aefd2be07e583202f41e037602d438c80044957baa", size = 220241, upload-time = "2025-11-18T13:32:44.665Z" }, + { url = "https://files.pythonhosted.org/packages/64/1c/77e79e76d37ce83302f6c21980b45e09f8aa4551965213a10e62d71ce0ab/coverage-7.12.0-cp312-cp312-win_amd64.whl", hash = "sha256:2fd8354ed5d69775ac42986a691fbf68b4084278710cee9d7c3eaa0c28fa982a", size = 221051, upload-time = "2025-11-18T13:32:46.008Z" }, + { url = "https://files.pythonhosted.org/packages/31/f5/641b8a25baae564f9e52cac0e2667b123de961985709a004e287ee7663cc/coverage-7.12.0-cp312-cp312-win_arm64.whl", hash = "sha256:737c3814903be30695b2de20d22bcc5428fdae305c61ba44cdc8b3252984c49c", size = 219692, upload-time = "2025-11-18T13:32:47.372Z" }, + { url = "https://files.pythonhosted.org/packages/b8/14/771700b4048774e48d2c54ed0c674273702713c9ee7acdfede40c2666747/coverage-7.12.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:47324fffca8d8eae7e185b5bb20c14645f23350f870c1649003618ea91a78941", size = 217725, upload-time = "2025-11-18T13:32:49.22Z" }, + { url = "https://files.pythonhosted.org/packages/17/a7/3aa4144d3bcb719bf67b22d2d51c2d577bf801498c13cb08f64173e80497/coverage-7.12.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ccf3b2ede91decd2fb53ec73c1f949c3e034129d1e0b07798ff1d02ea0c8fa4a", size = 218098, upload-time = "2025-11-18T13:32:50.78Z" }, + { url = "https://files.pythonhosted.org/packages/fc/9c/b846bbc774ff81091a12a10203e70562c91ae71badda00c5ae5b613527b1/coverage-7.12.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b365adc70a6936c6b0582dc38746b33b2454148c02349345412c6e743efb646d", size = 249093, upload-time = "2025-11-18T13:32:52.554Z" }, + { url = "https://files.pythonhosted.org/packages/76/b6/67d7c0e1f400b32c883e9342de4a8c2ae7c1a0b57c5de87622b7262e2309/coverage-7.12.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bc13baf85cd8a4cfcf4a35c7bc9d795837ad809775f782f697bf630b7e200211", size = 251686, upload-time = "2025-11-18T13:32:54.862Z" }, + { url = "https://files.pythonhosted.org/packages/cc/75/b095bd4b39d49c3be4bffbb3135fea18a99a431c52dd7513637c0762fecb/coverage-7.12.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:099d11698385d572ceafb3288a5b80fe1fc58bf665b3f9d362389de488361d3d", size = 252930, upload-time = "2025-11-18T13:32:56.417Z" }, + { url = "https://files.pythonhosted.org/packages/6e/f3/466f63015c7c80550bead3093aacabf5380c1220a2a93c35d374cae8f762/coverage-7.12.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:473dc45d69694069adb7680c405fb1e81f60b2aff42c81e2f2c3feaf544d878c", size = 249296, upload-time = "2025-11-18T13:32:58.074Z" }, + { url = "https://files.pythonhosted.org/packages/27/86/eba2209bf2b7e28c68698fc13437519a295b2d228ba9e0ec91673e09fa92/coverage-7.12.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:583f9adbefd278e9de33c33d6846aa8f5d164fa49b47144180a0e037f0688bb9", size = 251068, upload-time = "2025-11-18T13:32:59.646Z" }, + { url = "https://files.pythonhosted.org/packages/ec/55/ca8ae7dbba962a3351f18940b359b94c6bafdd7757945fdc79ec9e452dc7/coverage-7.12.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b2089cc445f2dc0af6f801f0d1355c025b76c24481935303cf1af28f636688f0", size = 249034, upload-time = "2025-11-18T13:33:01.481Z" }, + { url = "https://files.pythonhosted.org/packages/7a/d7/39136149325cad92d420b023b5fd900dabdd1c3a0d1d5f148ef4a8cedef5/coverage-7.12.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:950411f1eb5d579999c5f66c62a40961f126fc71e5e14419f004471957b51508", size = 248853, upload-time = "2025-11-18T13:33:02.935Z" }, + { url = "https://files.pythonhosted.org/packages/fe/b6/76e1add8b87ef60e00643b0b7f8f7bb73d4bf5249a3be19ebefc5793dd25/coverage-7.12.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b1aab7302a87bafebfe76b12af681b56ff446dc6f32ed178ff9c092ca776e6bc", size = 250619, upload-time = "2025-11-18T13:33:04.336Z" }, + { url = "https://files.pythonhosted.org/packages/95/87/924c6dc64f9203f7a3c1832a6a0eee5a8335dbe5f1bdadcc278d6f1b4d74/coverage-7.12.0-cp313-cp313-win32.whl", hash = "sha256:d7e0d0303c13b54db495eb636bc2465b2fb8475d4c8bcec8fe4b5ca454dfbae8", size = 220261, upload-time = "2025-11-18T13:33:06.493Z" }, + { url = "https://files.pythonhosted.org/packages/91/77/dd4aff9af16ff776bf355a24d87eeb48fc6acde54c907cc1ea89b14a8804/coverage-7.12.0-cp313-cp313-win_amd64.whl", hash = "sha256:ce61969812d6a98a981d147d9ac583a36ac7db7766f2e64a9d4d059c2fe29d07", size = 221072, upload-time = "2025-11-18T13:33:07.926Z" }, + { url = "https://files.pythonhosted.org/packages/70/49/5c9dc46205fef31b1b226a6e16513193715290584317fd4df91cdaf28b22/coverage-7.12.0-cp313-cp313-win_arm64.whl", hash = "sha256:bcec6f47e4cb8a4c2dc91ce507f6eefc6a1b10f58df32cdc61dff65455031dfc", size = 219702, upload-time = "2025-11-18T13:33:09.631Z" }, + { url = "https://files.pythonhosted.org/packages/9b/62/f87922641c7198667994dd472a91e1d9b829c95d6c29529ceb52132436ad/coverage-7.12.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:459443346509476170d553035e4a3eed7b860f4fe5242f02de1010501956ce87", size = 218420, upload-time = "2025-11-18T13:33:11.153Z" }, + { url = "https://files.pythonhosted.org/packages/85/dd/1cc13b2395ef15dbb27d7370a2509b4aee77890a464fb35d72d428f84871/coverage-7.12.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:04a79245ab2b7a61688958f7a855275997134bc84f4a03bc240cf64ff132abf6", size = 218773, upload-time = "2025-11-18T13:33:12.569Z" }, + { url = "https://files.pythonhosted.org/packages/74/40/35773cc4bb1e9d4658d4fb669eb4195b3151bef3bbd6f866aba5cd5dac82/coverage-7.12.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:09a86acaaa8455f13d6a99221d9654df249b33937b4e212b4e5a822065f12aa7", size = 260078, upload-time = "2025-11-18T13:33:14.037Z" }, + { url = "https://files.pythonhosted.org/packages/ec/ee/231bb1a6ffc2905e396557585ebc6bdc559e7c66708376d245a1f1d330fc/coverage-7.12.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:907e0df1b71ba77463687a74149c6122c3f6aac56c2510a5d906b2f368208560", size = 262144, upload-time = "2025-11-18T13:33:15.601Z" }, + { url = "https://files.pythonhosted.org/packages/28/be/32f4aa9f3bf0b56f3971001b56508352c7753915345d45fab4296a986f01/coverage-7.12.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9b57e2d0ddd5f0582bae5437c04ee71c46cd908e7bc5d4d0391f9a41e812dd12", size = 264574, upload-time = "2025-11-18T13:33:17.354Z" }, + { url = "https://files.pythonhosted.org/packages/68/7c/00489fcbc2245d13ab12189b977e0cf06ff3351cb98bc6beba8bd68c5902/coverage-7.12.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:58c1c6aa677f3a1411fe6fb28ec3a942e4f665df036a3608816e0847fad23296", size = 259298, upload-time = "2025-11-18T13:33:18.958Z" }, + { url = "https://files.pythonhosted.org/packages/96/b4/f0760d65d56c3bea95b449e02570d4abd2549dc784bf39a2d4721a2d8ceb/coverage-7.12.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4c589361263ab2953e3c4cd2a94db94c4ad4a8e572776ecfbad2389c626e4507", size = 262150, upload-time = "2025-11-18T13:33:20.644Z" }, + { url = "https://files.pythonhosted.org/packages/c5/71/9a9314df00f9326d78c1e5a910f520d599205907432d90d1c1b7a97aa4b1/coverage-7.12.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:91b810a163ccad2e43b1faa11d70d3cf4b6f3d83f9fd5f2df82a32d47b648e0d", size = 259763, upload-time = "2025-11-18T13:33:22.189Z" }, + { url = "https://files.pythonhosted.org/packages/10/34/01a0aceed13fbdf925876b9a15d50862eb8845454301fe3cdd1df08b2182/coverage-7.12.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:40c867af715f22592e0d0fb533a33a71ec9e0f73a6945f722a0c85c8c1cbe3a2", size = 258653, upload-time = "2025-11-18T13:33:24.239Z" }, + { url = "https://files.pythonhosted.org/packages/8d/04/81d8fd64928acf1574bbb0181f66901c6c1c6279c8ccf5f84259d2c68ae9/coverage-7.12.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:68b0d0a2d84f333de875666259dadf28cc67858bc8fd8b3f1eae84d3c2bec455", size = 260856, upload-time = "2025-11-18T13:33:26.365Z" }, + { url = "https://files.pythonhosted.org/packages/f2/76/fa2a37bfaeaf1f766a2d2360a25a5297d4fb567098112f6517475eee120b/coverage-7.12.0-cp313-cp313t-win32.whl", hash = "sha256:73f9e7fbd51a221818fd11b7090eaa835a353ddd59c236c57b2199486b116c6d", size = 220936, upload-time = "2025-11-18T13:33:28.165Z" }, + { url = "https://files.pythonhosted.org/packages/f9/52/60f64d932d555102611c366afb0eb434b34266b1d9266fc2fe18ab641c47/coverage-7.12.0-cp313-cp313t-win_amd64.whl", hash = "sha256:24cff9d1f5743f67db7ba46ff284018a6e9aeb649b67aa1e70c396aa1b7cb23c", size = 222001, upload-time = "2025-11-18T13:33:29.656Z" }, + { url = "https://files.pythonhosted.org/packages/77/df/c303164154a5a3aea7472bf323b7c857fed93b26618ed9fc5c2955566bb0/coverage-7.12.0-cp313-cp313t-win_arm64.whl", hash = "sha256:c87395744f5c77c866d0f5a43d97cc39e17c7f1cb0115e54a2fe67ca75c5d14d", size = 220273, upload-time = "2025-11-18T13:33:31.415Z" }, + { url = "https://files.pythonhosted.org/packages/bf/2e/fc12db0883478d6e12bbd62d481210f0c8daf036102aa11434a0c5755825/coverage-7.12.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a1c59b7dc169809a88b21a936eccf71c3895a78f5592051b1af8f4d59c2b4f92", size = 217777, upload-time = "2025-11-18T13:33:32.86Z" }, + { url = "https://files.pythonhosted.org/packages/1f/c1/ce3e525d223350c6ec16b9be8a057623f54226ef7f4c2fee361ebb6a02b8/coverage-7.12.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8787b0f982e020adb732b9f051f3e49dd5054cebbc3f3432061278512a2b1360", size = 218100, upload-time = "2025-11-18T13:33:34.532Z" }, + { url = "https://files.pythonhosted.org/packages/15/87/113757441504aee3808cb422990ed7c8bcc2d53a6779c66c5adef0942939/coverage-7.12.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5ea5a9f7dc8877455b13dd1effd3202e0bca72f6f3ab09f9036b1bcf728f69ac", size = 249151, upload-time = "2025-11-18T13:33:36.135Z" }, + { url = "https://files.pythonhosted.org/packages/d9/1d/9529d9bd44049b6b05bb319c03a3a7e4b0a8a802d28fa348ad407e10706d/coverage-7.12.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fdba9f15849534594f60b47c9a30bc70409b54947319a7c4fd0e8e3d8d2f355d", size = 251667, upload-time = "2025-11-18T13:33:37.996Z" }, + { url = "https://files.pythonhosted.org/packages/11/bb/567e751c41e9c03dc29d3ce74b8c89a1e3396313e34f255a2a2e8b9ebb56/coverage-7.12.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a00594770eb715854fb1c57e0dea08cce6720cfbc531accdb9850d7c7770396c", size = 253003, upload-time = "2025-11-18T13:33:39.553Z" }, + { url = "https://files.pythonhosted.org/packages/e4/b3/c2cce2d8526a02fb9e9ca14a263ca6fc074449b33a6afa4892838c903528/coverage-7.12.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5560c7e0d82b42eb1951e4f68f071f8017c824ebfd5a6ebe42c60ac16c6c2434", size = 249185, upload-time = "2025-11-18T13:33:42.086Z" }, + { url = "https://files.pythonhosted.org/packages/0e/a7/967f93bb66e82c9113c66a8d0b65ecf72fc865adfba5a145f50c7af7e58d/coverage-7.12.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d6c2e26b481c9159c2773a37947a9718cfdc58893029cdfb177531793e375cfc", size = 251025, upload-time = "2025-11-18T13:33:43.634Z" }, + { url = "https://files.pythonhosted.org/packages/b9/b2/f2f6f56337bc1af465d5b2dc1ee7ee2141b8b9272f3bf6213fcbc309a836/coverage-7.12.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:6e1a8c066dabcde56d5d9fed6a66bc19a2883a3fe051f0c397a41fc42aedd4cc", size = 248979, upload-time = "2025-11-18T13:33:46.04Z" }, + { url = "https://files.pythonhosted.org/packages/f4/7a/bf4209f45a4aec09d10a01a57313a46c0e0e8f4c55ff2965467d41a92036/coverage-7.12.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:f7ba9da4726e446d8dd8aae5a6cd872511184a5d861de80a86ef970b5dacce3e", size = 248800, upload-time = "2025-11-18T13:33:47.546Z" }, + { url = "https://files.pythonhosted.org/packages/b8/b7/1e01b8696fb0521810f60c5bbebf699100d6754183e6cc0679bf2ed76531/coverage-7.12.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e0f483ab4f749039894abaf80c2f9e7ed77bbf3c737517fb88c8e8e305896a17", size = 250460, upload-time = "2025-11-18T13:33:49.537Z" }, + { url = "https://files.pythonhosted.org/packages/71/ae/84324fb9cb46c024760e706353d9b771a81b398d117d8c1fe010391c186f/coverage-7.12.0-cp314-cp314-win32.whl", hash = "sha256:76336c19a9ef4a94b2f8dc79f8ac2da3f193f625bb5d6f51a328cd19bfc19933", size = 220533, upload-time = "2025-11-18T13:33:51.16Z" }, + { url = "https://files.pythonhosted.org/packages/e2/71/1033629deb8460a8f97f83e6ac4ca3b93952e2b6f826056684df8275e015/coverage-7.12.0-cp314-cp314-win_amd64.whl", hash = "sha256:7c1059b600aec6ef090721f8f633f60ed70afaffe8ecab85b59df748f24b31fe", size = 221348, upload-time = "2025-11-18T13:33:52.776Z" }, + { url = "https://files.pythonhosted.org/packages/0a/5f/ac8107a902f623b0c251abdb749be282dc2ab61854a8a4fcf49e276fce2f/coverage-7.12.0-cp314-cp314-win_arm64.whl", hash = "sha256:172cf3a34bfef42611963e2b661302a8931f44df31629e5b1050567d6b90287d", size = 219922, upload-time = "2025-11-18T13:33:54.316Z" }, + { url = "https://files.pythonhosted.org/packages/79/6e/f27af2d4da367f16077d21ef6fe796c874408219fa6dd3f3efe7751bd910/coverage-7.12.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:aa7d48520a32cb21c7a9b31f81799e8eaec7239db36c3b670be0fa2403828d1d", size = 218511, upload-time = "2025-11-18T13:33:56.343Z" }, + { url = "https://files.pythonhosted.org/packages/67/dd/65fd874aa460c30da78f9d259400d8e6a4ef457d61ab052fd248f0050558/coverage-7.12.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:90d58ac63bc85e0fb919f14d09d6caa63f35a5512a2205284b7816cafd21bb03", size = 218771, upload-time = "2025-11-18T13:33:57.966Z" }, + { url = "https://files.pythonhosted.org/packages/55/e0/7c6b71d327d8068cb79c05f8f45bf1b6145f7a0de23bbebe63578fe5240a/coverage-7.12.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ca8ecfa283764fdda3eae1bdb6afe58bf78c2c3ec2b2edcb05a671f0bba7b3f9", size = 260151, upload-time = "2025-11-18T13:33:59.597Z" }, + { url = "https://files.pythonhosted.org/packages/49/ce/4697457d58285b7200de6b46d606ea71066c6e674571a946a6ea908fb588/coverage-7.12.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:874fe69a0785d96bd066059cd4368022cebbec1a8958f224f0016979183916e6", size = 262257, upload-time = "2025-11-18T13:34:01.166Z" }, + { url = "https://files.pythonhosted.org/packages/2f/33/acbc6e447aee4ceba88c15528dbe04a35fb4d67b59d393d2e0d6f1e242c1/coverage-7.12.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5b3c889c0b8b283a24d721a9eabc8ccafcfc3aebf167e4cd0d0e23bf8ec4e339", size = 264671, upload-time = "2025-11-18T13:34:02.795Z" }, + { url = "https://files.pythonhosted.org/packages/87/ec/e2822a795c1ed44d569980097be839c5e734d4c0c1119ef8e0a073496a30/coverage-7.12.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8bb5b894b3ec09dcd6d3743229dc7f2c42ef7787dc40596ae04c0edda487371e", size = 259231, upload-time = "2025-11-18T13:34:04.397Z" }, + { url = "https://files.pythonhosted.org/packages/72/c5/a7ec5395bb4a49c9b7ad97e63f0c92f6bf4a9e006b1393555a02dae75f16/coverage-7.12.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:79a44421cd5fba96aa57b5e3b5a4d3274c449d4c622e8f76882d76635501fd13", size = 262137, upload-time = "2025-11-18T13:34:06.068Z" }, + { url = "https://files.pythonhosted.org/packages/67/0c/02c08858b764129f4ecb8e316684272972e60777ae986f3865b10940bdd6/coverage-7.12.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:33baadc0efd5c7294f436a632566ccc1f72c867f82833eb59820ee37dc811c6f", size = 259745, upload-time = "2025-11-18T13:34:08.04Z" }, + { url = "https://files.pythonhosted.org/packages/5a/04/4fd32b7084505f3829a8fe45c1a74a7a728cb251aaadbe3bec04abcef06d/coverage-7.12.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:c406a71f544800ef7e9e0000af706b88465f3573ae8b8de37e5f96c59f689ad1", size = 258570, upload-time = "2025-11-18T13:34:09.676Z" }, + { url = "https://files.pythonhosted.org/packages/48/35/2365e37c90df4f5342c4fa202223744119fe31264ee2924f09f074ea9b6d/coverage-7.12.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e71bba6a40883b00c6d571599b4627f50c360b3d0d02bfc658168936be74027b", size = 260899, upload-time = "2025-11-18T13:34:11.259Z" }, + { url = "https://files.pythonhosted.org/packages/05/56/26ab0464ca733fa325e8e71455c58c1c374ce30f7c04cebb88eabb037b18/coverage-7.12.0-cp314-cp314t-win32.whl", hash = "sha256:9157a5e233c40ce6613dead4c131a006adfda70e557b6856b97aceed01b0e27a", size = 221313, upload-time = "2025-11-18T13:34:12.863Z" }, + { url = "https://files.pythonhosted.org/packages/da/1c/017a3e1113ed34d998b27d2c6dba08a9e7cb97d362f0ec988fcd873dcf81/coverage-7.12.0-cp314-cp314t-win_amd64.whl", hash = "sha256:e84da3a0fd233aeec797b981c51af1cabac74f9bd67be42458365b30d11b5291", size = 222423, upload-time = "2025-11-18T13:34:15.14Z" }, + { url = "https://files.pythonhosted.org/packages/4c/36/bcc504fdd5169301b52568802bb1b9cdde2e27a01d39fbb3b4b508ab7c2c/coverage-7.12.0-cp314-cp314t-win_arm64.whl", hash = "sha256:01d24af36fedda51c2b1aca56e4330a3710f83b02a5ff3743a6b015ffa7c9384", size = 220459, upload-time = "2025-11-18T13:34:17.222Z" }, + { url = "https://files.pythonhosted.org/packages/ce/a3/43b749004e3c09452e39bb56347a008f0a0668aad37324a99b5c8ca91d9e/coverage-7.12.0-py3-none-any.whl", hash = "sha256:159d50c0b12e060b15ed3d39f87ed43d4f7f7ad40b8a534f4dd331adbb51104a", size = 209503, upload-time = "2025-11-18T13:34:18.892Z" }, +] + +[package.optional-dependencies] +toml = [ + { name = "tomli", marker = "python_full_version <= '3.11'" }, +] + +[[package]] +name = "cycler" +version = "0.12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a9/95/a3dbbb5028f35eafb79008e7522a75244477d2838f38cbb722248dabc2a8/cycler-0.12.1.tar.gz", hash = "sha256:88bb128f02ba341da8ef447245a9e138fae777f6a23943da4540077d3601eb1c", size = 7615, upload-time = "2023-10-07T05:32:18.335Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl", hash = "sha256:85cef7cff222d8644161529808465972e51340599459b8ac3ccbac5a854e0d30", size = 8321, upload-time = "2023-10-07T05:32:16.783Z" }, +] + +[[package]] +name = "cyclopts" +version = "4.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "docstring-parser" }, + { name = "rich" }, + { name = "rich-rst" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/13/7b/663f3285c1ac0e5d0854bd9db2c87caa6fa3d1a063185e3394a6cdca9151/cyclopts-4.5.0.tar.gz", hash = "sha256:717ac4235548b58d500baf7e688aa4d024caf0ee68f61a012ffd5e29db3099f9", size = 161980, upload-time = "2026-01-16T02:07:16.171Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/a3/2e00fececc34a99ae3a5d5702a5dd29c5371e4ed016647301a2b9bcc1976/cyclopts-4.5.0-py3-none-any.whl", hash = "sha256:305b9aa90a9cd0916f0a450b43e50ad5df9c252680731a0719edfb9b20381bf5", size = 199772, upload-time = "2026-01-16T02:07:14.707Z" }, +] + +[[package]] +name = "debugpy" +version = "1.8.17" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/15/ad/71e708ff4ca377c4230530d6a7aa7992592648c122a2cd2b321cf8b35a76/debugpy-1.8.17.tar.gz", hash = "sha256:fd723b47a8c08892b1a16b2c6239a8b96637c62a59b94bb5dab4bac592a58a8e", size = 1644129, upload-time = "2025-09-17T16:33:20.633Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d8/53/3af72b5c159278c4a0cf4cffa518675a0e73bdb7d1cac0239b815502d2ce/debugpy-1.8.17-cp311-cp311-macosx_15_0_universal2.whl", hash = "sha256:d3fce3f0e3de262a3b67e69916d001f3e767661c6e1ee42553009d445d1cd840", size = 2207154, upload-time = "2025-09-17T16:33:29.457Z" }, + { url = "https://files.pythonhosted.org/packages/8f/6d/204f407df45600e2245b4a39860ed4ba32552330a0b3f5f160ae4cc30072/debugpy-1.8.17-cp311-cp311-manylinux_2_34_x86_64.whl", hash = "sha256:c6bdf134457ae0cac6fb68205776be635d31174eeac9541e1d0c062165c6461f", size = 3170322, upload-time = "2025-09-17T16:33:30.837Z" }, + { url = "https://files.pythonhosted.org/packages/f2/13/1b8f87d39cf83c6b713de2620c31205299e6065622e7dd37aff4808dd410/debugpy-1.8.17-cp311-cp311-win32.whl", hash = "sha256:e79a195f9e059edfe5d8bf6f3749b2599452d3e9380484cd261f6b7cd2c7c4da", size = 5155078, upload-time = "2025-09-17T16:33:33.331Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c5/c012c60a2922cc91caa9675d0ddfbb14ba59e1e36228355f41cab6483469/debugpy-1.8.17-cp311-cp311-win_amd64.whl", hash = "sha256:b532282ad4eca958b1b2d7dbcb2b7218e02cb934165859b918e3b6ba7772d3f4", size = 5179011, upload-time = "2025-09-17T16:33:35.711Z" }, + { url = "https://files.pythonhosted.org/packages/08/2b/9d8e65beb2751876c82e1aceb32f328c43ec872711fa80257c7674f45650/debugpy-1.8.17-cp312-cp312-macosx_15_0_universal2.whl", hash = "sha256:f14467edef672195c6f6b8e27ce5005313cb5d03c9239059bc7182b60c176e2d", size = 2549522, upload-time = "2025-09-17T16:33:38.466Z" }, + { url = "https://files.pythonhosted.org/packages/b4/78/eb0d77f02971c05fca0eb7465b18058ba84bd957062f5eec82f941ac792a/debugpy-1.8.17-cp312-cp312-manylinux_2_34_x86_64.whl", hash = "sha256:24693179ef9dfa20dca8605905a42b392be56d410c333af82f1c5dff807a64cc", size = 4309417, upload-time = "2025-09-17T16:33:41.299Z" }, + { url = "https://files.pythonhosted.org/packages/37/42/c40f1d8cc1fed1e75ea54298a382395b8b937d923fcf41ab0797a554f555/debugpy-1.8.17-cp312-cp312-win32.whl", hash = "sha256:6a4e9dacf2cbb60d2514ff7b04b4534b0139facbf2abdffe0639ddb6088e59cf", size = 5277130, upload-time = "2025-09-17T16:33:43.554Z" }, + { url = "https://files.pythonhosted.org/packages/72/22/84263b205baad32b81b36eac076de0cdbe09fe2d0637f5b32243dc7c925b/debugpy-1.8.17-cp312-cp312-win_amd64.whl", hash = "sha256:e8f8f61c518952fb15f74a302e068b48d9c4691768ade433e4adeea961993464", size = 5319053, upload-time = "2025-09-17T16:33:53.033Z" }, + { url = "https://files.pythonhosted.org/packages/50/76/597e5cb97d026274ba297af8d89138dfd9e695767ba0e0895edb20963f40/debugpy-1.8.17-cp313-cp313-macosx_15_0_universal2.whl", hash = "sha256:857c1dd5d70042502aef1c6d1c2801211f3ea7e56f75e9c335f434afb403e464", size = 2538386, upload-time = "2025-09-17T16:33:54.594Z" }, + { url = "https://files.pythonhosted.org/packages/5f/60/ce5c34fcdfec493701f9d1532dba95b21b2f6394147234dce21160bd923f/debugpy-1.8.17-cp313-cp313-manylinux_2_34_x86_64.whl", hash = "sha256:3bea3b0b12f3946e098cce9b43c3c46e317b567f79570c3f43f0b96d00788088", size = 4292100, upload-time = "2025-09-17T16:33:56.353Z" }, + { url = "https://files.pythonhosted.org/packages/e8/95/7873cf2146577ef71d2a20bf553f12df865922a6f87b9e8ee1df04f01785/debugpy-1.8.17-cp313-cp313-win32.whl", hash = "sha256:e34ee844c2f17b18556b5bbe59e1e2ff4e86a00282d2a46edab73fd7f18f4a83", size = 5277002, upload-time = "2025-09-17T16:33:58.231Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/18c79a1cee5ff539a94ec4aa290c1c069a5580fd5cfd2fb2e282f8e905da/debugpy-1.8.17-cp313-cp313-win_amd64.whl", hash = "sha256:6c5cd6f009ad4fca8e33e5238210dc1e5f42db07d4b6ab21ac7ffa904a196420", size = 5319047, upload-time = "2025-09-17T16:34:00.586Z" }, + { url = "https://files.pythonhosted.org/packages/de/45/115d55b2a9da6de812696064ceb505c31e952c5d89c4ed1d9bb983deec34/debugpy-1.8.17-cp314-cp314-macosx_15_0_universal2.whl", hash = "sha256:045290c010bcd2d82bc97aa2daf6837443cd52f6328592698809b4549babcee1", size = 2536899, upload-time = "2025-09-17T16:34:02.657Z" }, + { url = "https://files.pythonhosted.org/packages/5a/73/2aa00c7f1f06e997ef57dc9b23d61a92120bec1437a012afb6d176585197/debugpy-1.8.17-cp314-cp314-manylinux_2_34_x86_64.whl", hash = "sha256:b69b6bd9dba6a03632534cdf67c760625760a215ae289f7489a452af1031fe1f", size = 4268254, upload-time = "2025-09-17T16:34:04.486Z" }, + { url = "https://files.pythonhosted.org/packages/86/b5/ed3e65c63c68a6634e3ba04bd10255c8e46ec16ebed7d1c79e4816d8a760/debugpy-1.8.17-cp314-cp314-win32.whl", hash = "sha256:5c59b74aa5630f3a5194467100c3b3d1c77898f9ab27e3f7dc5d40fc2f122670", size = 5277203, upload-time = "2025-09-17T16:34:06.65Z" }, + { url = "https://files.pythonhosted.org/packages/b0/26/394276b71c7538445f29e792f589ab7379ae70fd26ff5577dfde71158e96/debugpy-1.8.17-cp314-cp314-win_amd64.whl", hash = "sha256:893cba7bb0f55161de4365584b025f7064e1f88913551bcd23be3260b231429c", size = 5318493, upload-time = "2025-09-17T16:34:08.483Z" }, + { url = "https://files.pythonhosted.org/packages/b0/d0/89247ec250369fc76db477720a26b2fce7ba079ff1380e4ab4529d2fe233/debugpy-1.8.17-py2.py3-none-any.whl", hash = "sha256:60c7dca6571efe660ccb7a9508d73ca14b8796c4ed484c2002abba714226cfef", size = 5283210, upload-time = "2025-09-17T16:34:25.835Z" }, +] + +[[package]] +name = "decorator" +version = "5.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/fa/6d96a0978d19e17b68d634497769987b16c8f4cd0a7a05048bec693caa6b/decorator-5.2.1.tar.gz", hash = "sha256:65f266143752f734b0a7cc83c46f4618af75b8c5911b00ccb61d0ac9b6da0360", size = 56711, upload-time = "2025-02-24T04:41:34.073Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4e/8c/f3147f5c4b73e7550fe5f9352eaa956ae838d5c51eb58e7a25b9f3e2643b/decorator-5.2.1-py3-none-any.whl", hash = "sha256:d316bb415a2d9e2d2b3abcc4084c6502fc09240e292cd76a76afc106a1c8e04a", size = 9190, upload-time = "2025-02-24T04:41:32.565Z" }, +] + +[[package]] +name = "defusedxml" +version = "0.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/d5/c66da9b79e5bdb124974bfe172b4daf3c984ebd9c2a06e2b8a4dc7331c72/defusedxml-0.7.1.tar.gz", hash = "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69", size = 75520, upload-time = "2021-03-08T10:59:26.269Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61", size = 25604, upload-time = "2021-03-08T10:59:24.45Z" }, +] + +[[package]] +name = "docstring-parser" +version = "0.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b2/9d/c3b43da9515bd270df0f80548d9944e389870713cc1fe2b8fb35fe2bcefd/docstring_parser-0.17.0.tar.gz", hash = "sha256:583de4a309722b3315439bb31d64ba3eebada841f2e2cee23b99df001434c912", size = 27442, upload-time = "2025-07-21T07:35:01.868Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/55/e2/2537ebcff11c1ee1ff17d8d0b6f4db75873e3b0fb32c2d4a2ee31ecb310a/docstring_parser-0.17.0-py3-none-any.whl", hash = "sha256:cf2569abd23dce8099b300f9b4fa8191e9582dda731fd533daf54c4551658708", size = 36896, upload-time = "2025-07-21T07:35:00.684Z" }, +] + +[[package]] +name = "docutils" +version = "0.21.2" +source = { registry = "https://pypi.org/simple" } +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 = "execnet" +version = "2.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bf/89/780e11f9588d9e7128a3f87788354c7946a9cbb1401ad38a48c4db9a4f07/execnet-2.1.2.tar.gz", hash = "sha256:63d83bfdd9a23e35b9c6a3261412324f964c2ec8dcd8d3c6916ee9373e0befcd", size = 166622, upload-time = "2025-11-12T09:56:37.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ab/84/02fc1827e8cdded4aa65baef11296a9bbe595c474f0d6d758af082d849fd/execnet-2.1.2-py3-none-any.whl", hash = "sha256:67fba928dd5a544b783f6056f449e5e3931a5c378b128bc18501f7ea79e296ec", size = 40708, upload-time = "2025-11-12T09:56:36.333Z" }, +] + +[[package]] +name = "executing" +version = "2.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cc/28/c14e053b6762b1044f34a13aab6859bbf40456d37d23aa286ac24cfd9a5d/executing-2.2.1.tar.gz", hash = "sha256:3632cc370565f6648cc328b32435bd120a1e4ebb20c77e3fdde9a13cd1e533c4", size = 1129488, upload-time = "2025-09-01T09:48:10.866Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/ea/53f2148663b321f21b5a606bd5f191517cf40b7072c0497d3c92c4a13b1e/executing-2.2.1-py2.py3-none-any.whl", hash = "sha256:760643d3452b4d777d295bb167ccc74c64a81df23fb5e08eff250c425a4b2017", size = 28317, upload-time = "2025-09-01T09:48:08.5Z" }, +] + +[[package]] +name = "fancycompleter" +version = "0.11.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyreadline3", marker = "python_full_version < '3.13' and sys_platform == 'win32'" }, + { name = "pyrepl", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4e/4c/d11187dee93eff89d082afda79b63c79320ae1347e49485a38f05ad359d0/fancycompleter-0.11.1.tar.gz", hash = "sha256:5b4ad65d76b32b1259251516d0f1cb2d82832b1ff8506697a707284780757f69", size = 341776, upload-time = "2025-05-26T12:59:11.045Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/30/c3/6f0e3896f193528bbd2b4d2122d4be8108a37efab0b8475855556a8c4afa/fancycompleter-0.11.1-py3-none-any.whl", hash = "sha256:44243d7fab37087208ca5acacf8f74c0aa4d733d04d593857873af7513cdf8a6", size = 11207, upload-time = "2025-05-26T12:59:09.857Z" }, +] + +[[package]] +name = "fastjsonschema" +version = "2.21.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/20/b5/23b216d9d985a956623b6bd12d4086b60f0059b27799f23016af04a74ea1/fastjsonschema-2.21.2.tar.gz", hash = "sha256:b1eb43748041c880796cd077f1a07c3d94e93ae84bba5ed36800a33554ae05de", size = 374130, upload-time = "2025-08-14T18:49:36.666Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/a8/20d0723294217e47de6d9e2e40fd4a9d2f7c4b6ef974babd482a59743694/fastjsonschema-2.21.2-py3-none-any.whl", hash = "sha256:1c797122d0a86c5cace2e54bf4e819c36223b552017172f32c5c024a6b77e463", size = 24024, upload-time = "2025-08-14T18:49:34.776Z" }, +] + +[[package]] +name = "flexcache" +version = "0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/b0/8a21e330561c65653d010ef112bf38f60890051d244ede197ddaa08e50c1/flexcache-0.3.tar.gz", hash = "sha256:18743bd5a0621bfe2cf8d519e4c3bfdf57a269c15d1ced3fb4b64e0ff4600656", size = 15816, upload-time = "2024-03-09T03:21:07.555Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/cd/c883e1a7c447479d6e13985565080e3fea88ab5a107c21684c813dba1875/flexcache-0.3-py3-none-any.whl", hash = "sha256:d43c9fea82336af6e0115e308d9d33a185390b8346a017564611f1466dcd2e32", size = 13263, upload-time = "2024-03-09T03:21:05.635Z" }, +] + +[[package]] +name = "flexparser" +version = "0.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/82/99/b4de7e39e8eaf8207ba1a8fa2241dd98b2ba72ae6e16960d8351736d8702/flexparser-0.4.tar.gz", hash = "sha256:266d98905595be2ccc5da964fe0a2c3526fbbffdc45b65b3146d75db992ef6b2", size = 31799, upload-time = "2024-11-07T02:00:56.249Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fe/5e/3be305568fe5f34448807976dc82fc151d76c3e0e03958f34770286278c1/flexparser-0.4-py3-none-any.whl", hash = "sha256:3738b456192dcb3e15620f324c447721023c0293f6af9955b481e91d00179846", size = 27625, upload-time = "2024-11-07T02:00:54.523Z" }, +] + +[[package]] +name = "fonttools" +version = "4.60.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4b/42/97a13e47a1e51a5a7142475bbcf5107fe3a68fc34aef331c897d5fb98ad0/fonttools-4.60.1.tar.gz", hash = "sha256:ef00af0439ebfee806b25f24c8f92109157ff3fac5731dc7867957812e87b8d9", size = 3559823, upload-time = "2025-09-29T21:13:27.129Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ea/85/639aa9bface1537e0fb0f643690672dde0695a5bbbc90736bc571b0b1941/fonttools-4.60.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7b4c32e232a71f63a5d00259ca3d88345ce2a43295bb049d21061f338124246f", size = 2831872, upload-time = "2025-09-29T21:11:20.329Z" }, + { url = "https://files.pythonhosted.org/packages/6b/47/3c63158459c95093be9618794acb1067b3f4d30dcc5c3e8114b70e67a092/fonttools-4.60.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3630e86c484263eaac71d117085d509cbcf7b18f677906824e4bace598fb70d2", size = 2356990, upload-time = "2025-09-29T21:11:22.754Z" }, + { url = "https://files.pythonhosted.org/packages/94/dd/1934b537c86fcf99f9761823f1fc37a98fbd54568e8e613f29a90fed95a9/fonttools-4.60.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5c1015318e4fec75dd4943ad5f6a206d9727adf97410d58b7e32ab644a807914", size = 5042189, upload-time = "2025-09-29T21:11:25.061Z" }, + { url = "https://files.pythonhosted.org/packages/d2/d2/9f4e4c4374dd1daa8367784e1bd910f18ba886db1d6b825b12edf6db3edc/fonttools-4.60.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e6c58beb17380f7c2ea181ea11e7db8c0ceb474c9dd45f48e71e2cb577d146a1", size = 4978683, upload-time = "2025-09-29T21:11:27.693Z" }, + { url = "https://files.pythonhosted.org/packages/cc/c4/0fb2dfd1ecbe9a07954cc13414713ed1eab17b1c0214ef07fc93df234a47/fonttools-4.60.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ec3681a0cb34c255d76dd9d865a55f260164adb9fa02628415cdc2d43ee2c05d", size = 5021372, upload-time = "2025-09-29T21:11:30.257Z" }, + { url = "https://files.pythonhosted.org/packages/0c/d5/495fc7ae2fab20223cc87179a8f50f40f9a6f821f271ba8301ae12bb580f/fonttools-4.60.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f4b5c37a5f40e4d733d3bbaaef082149bee5a5ea3156a785ff64d949bd1353fa", size = 5132562, upload-time = "2025-09-29T21:11:32.737Z" }, + { url = "https://files.pythonhosted.org/packages/bc/fa/021dab618526323c744e0206b3f5c8596a2e7ae9aa38db5948a131123e83/fonttools-4.60.1-cp311-cp311-win32.whl", hash = "sha256:398447f3d8c0c786cbf1209711e79080a40761eb44b27cdafffb48f52bcec258", size = 2230288, upload-time = "2025-09-29T21:11:35.015Z" }, + { url = "https://files.pythonhosted.org/packages/bb/78/0e1a6d22b427579ea5c8273e1c07def2f325b977faaf60bb7ddc01456cb1/fonttools-4.60.1-cp311-cp311-win_amd64.whl", hash = "sha256:d066ea419f719ed87bc2c99a4a4bfd77c2e5949cb724588b9dd58f3fd90b92bf", size = 2278184, upload-time = "2025-09-29T21:11:37.434Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f7/a10b101b7a6f8836a5adb47f2791f2075d044a6ca123f35985c42edc82d8/fonttools-4.60.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:7b0c6d57ab00dae9529f3faf187f2254ea0aa1e04215cf2f1a8ec277c96661bc", size = 2832953, upload-time = "2025-09-29T21:11:39.616Z" }, + { url = "https://files.pythonhosted.org/packages/ed/fe/7bd094b59c926acf2304d2151354ddbeb74b94812f3dc943c231db09cb41/fonttools-4.60.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:839565cbf14645952d933853e8ade66a463684ed6ed6c9345d0faf1f0e868877", size = 2352706, upload-time = "2025-09-29T21:11:41.826Z" }, + { url = "https://files.pythonhosted.org/packages/c0/ca/4bb48a26ed95a1e7eba175535fe5805887682140ee0a0d10a88e1de84208/fonttools-4.60.1-cp312-cp312-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8177ec9676ea6e1793c8a084a90b65a9f778771998eb919d05db6d4b1c0b114c", size = 4923716, upload-time = "2025-09-29T21:11:43.893Z" }, + { url = "https://files.pythonhosted.org/packages/b8/9f/2cb82999f686c1d1ddf06f6ae1a9117a880adbec113611cc9d22b2fdd465/fonttools-4.60.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:996a4d1834524adbb423385d5a629b868ef9d774670856c63c9a0408a3063401", size = 4968175, upload-time = "2025-09-29T21:11:46.439Z" }, + { url = "https://files.pythonhosted.org/packages/18/79/be569699e37d166b78e6218f2cde8c550204f2505038cdd83b42edc469b9/fonttools-4.60.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a46b2f450bc79e06ef3b6394f0c68660529ed51692606ad7f953fc2e448bc903", size = 4911031, upload-time = "2025-09-29T21:11:48.977Z" }, + { url = "https://files.pythonhosted.org/packages/cc/9f/89411cc116effaec5260ad519162f64f9c150e5522a27cbb05eb62d0c05b/fonttools-4.60.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6ec722ee589e89a89f5b7574f5c45604030aa6ae24cb2c751e2707193b466fed", size = 5062966, upload-time = "2025-09-29T21:11:54.344Z" }, + { url = "https://files.pythonhosted.org/packages/62/a1/f888221934b5731d46cb9991c7a71f30cb1f97c0ef5fcf37f8da8fce6c8e/fonttools-4.60.1-cp312-cp312-win32.whl", hash = "sha256:b2cf105cee600d2de04ca3cfa1f74f1127f8455b71dbad02b9da6ec266e116d6", size = 2218750, upload-time = "2025-09-29T21:11:56.601Z" }, + { url = "https://files.pythonhosted.org/packages/88/8f/a55b5550cd33cd1028601df41acd057d4be20efa5c958f417b0c0613924d/fonttools-4.60.1-cp312-cp312-win_amd64.whl", hash = "sha256:992775c9fbe2cf794786fa0ffca7f09f564ba3499b8fe9f2f80bd7197db60383", size = 2267026, upload-time = "2025-09-29T21:11:58.852Z" }, + { url = "https://files.pythonhosted.org/packages/7c/5b/cdd2c612277b7ac7ec8c0c9bc41812c43dc7b2d5f2b0897e15fdf5a1f915/fonttools-4.60.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6f68576bb4bbf6060c7ab047b1574a1ebe5c50a17de62830079967b211059ebb", size = 2825777, upload-time = "2025-09-29T21:12:01.22Z" }, + { url = "https://files.pythonhosted.org/packages/d6/8a/de9cc0540f542963ba5e8f3a1f6ad48fa211badc3177783b9d5cadf79b5d/fonttools-4.60.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:eedacb5c5d22b7097482fa834bda0dafa3d914a4e829ec83cdea2a01f8c813c4", size = 2348080, upload-time = "2025-09-29T21:12:03.785Z" }, + { url = "https://files.pythonhosted.org/packages/2d/8b/371ab3cec97ee3fe1126b3406b7abd60c8fec8975fd79a3c75cdea0c3d83/fonttools-4.60.1-cp313-cp313-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b33a7884fabd72bdf5f910d0cf46be50dce86a0362a65cfc746a4168c67eb96c", size = 4903082, upload-time = "2025-09-29T21:12:06.382Z" }, + { url = "https://files.pythonhosted.org/packages/04/05/06b1455e4bc653fcb2117ac3ef5fa3a8a14919b93c60742d04440605d058/fonttools-4.60.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2409d5fb7b55fd70f715e6d34e7a6e4f7511b8ad29a49d6df225ee76da76dd77", size = 4960125, upload-time = "2025-09-29T21:12:09.314Z" }, + { url = "https://files.pythonhosted.org/packages/8e/37/f3b840fcb2666f6cb97038793606bdd83488dca2d0b0fc542ccc20afa668/fonttools-4.60.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c8651e0d4b3bdeda6602b85fdc2abbefc1b41e573ecb37b6779c4ca50753a199", size = 4901454, upload-time = "2025-09-29T21:12:11.931Z" }, + { url = "https://files.pythonhosted.org/packages/fd/9e/eb76f77e82f8d4a46420aadff12cec6237751b0fb9ef1de373186dcffb5f/fonttools-4.60.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:145daa14bf24824b677b9357c5e44fd8895c2a8f53596e1b9ea3496081dc692c", size = 5044495, upload-time = "2025-09-29T21:12:15.241Z" }, + { url = "https://files.pythonhosted.org/packages/f8/b3/cede8f8235d42ff7ae891bae8d619d02c8ac9fd0cfc450c5927a6200c70d/fonttools-4.60.1-cp313-cp313-win32.whl", hash = "sha256:2299df884c11162617a66b7c316957d74a18e3758c0274762d2cc87df7bc0272", size = 2217028, upload-time = "2025-09-29T21:12:17.96Z" }, + { url = "https://files.pythonhosted.org/packages/75/4d/b022c1577807ce8b31ffe055306ec13a866f2337ecee96e75b24b9b753ea/fonttools-4.60.1-cp313-cp313-win_amd64.whl", hash = "sha256:a3db56f153bd4c5c2b619ab02c5db5192e222150ce5a1bc10f16164714bc39ac", size = 2266200, upload-time = "2025-09-29T21:12:20.14Z" }, + { url = "https://files.pythonhosted.org/packages/9a/83/752ca11c1aa9a899b793a130f2e466b79ea0cf7279c8d79c178fc954a07b/fonttools-4.60.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:a884aef09d45ba1206712c7dbda5829562d3fea7726935d3289d343232ecb0d3", size = 2822830, upload-time = "2025-09-29T21:12:24.406Z" }, + { url = "https://files.pythonhosted.org/packages/57/17/bbeab391100331950a96ce55cfbbff27d781c1b85ebafb4167eae50d9fe3/fonttools-4.60.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8a44788d9d91df72d1a5eac49b31aeb887a5f4aab761b4cffc4196c74907ea85", size = 2345524, upload-time = "2025-09-29T21:12:26.819Z" }, + { url = "https://files.pythonhosted.org/packages/3d/2e/d4831caa96d85a84dd0da1d9f90d81cec081f551e0ea216df684092c6c97/fonttools-4.60.1-cp314-cp314-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:e852d9dda9f93ad3651ae1e3bb770eac544ec93c3807888798eccddf84596537", size = 4843490, upload-time = "2025-09-29T21:12:29.123Z" }, + { url = "https://files.pythonhosted.org/packages/49/13/5e2ea7c7a101b6fc3941be65307ef8df92cbbfa6ec4804032baf1893b434/fonttools-4.60.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:154cb6ee417e417bf5f7c42fe25858c9140c26f647c7347c06f0cc2d47eff003", size = 4944184, upload-time = "2025-09-29T21:12:31.414Z" }, + { url = "https://files.pythonhosted.org/packages/0c/2b/cf9603551c525b73fc47c52ee0b82a891579a93d9651ed694e4e2cd08bb8/fonttools-4.60.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:5664fd1a9ea7f244487ac8f10340c4e37664675e8667d6fee420766e0fb3cf08", size = 4890218, upload-time = "2025-09-29T21:12:33.936Z" }, + { url = "https://files.pythonhosted.org/packages/fd/2f/933d2352422e25f2376aae74f79eaa882a50fb3bfef3c0d4f50501267101/fonttools-4.60.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:583b7f8e3c49486e4d489ad1deacfb8d5be54a8ef34d6df824f6a171f8511d99", size = 4999324, upload-time = "2025-09-29T21:12:36.637Z" }, + { url = "https://files.pythonhosted.org/packages/38/99/234594c0391221f66216bc2c886923513b3399a148defaccf81dc3be6560/fonttools-4.60.1-cp314-cp314-win32.whl", hash = "sha256:66929e2ea2810c6533a5184f938502cfdaea4bc3efb7130d8cc02e1c1b4108d6", size = 2220861, upload-time = "2025-09-29T21:12:39.108Z" }, + { url = "https://files.pythonhosted.org/packages/3e/1d/edb5b23726dde50fc4068e1493e4fc7658eeefcaf75d4c5ffce067d07ae5/fonttools-4.60.1-cp314-cp314-win_amd64.whl", hash = "sha256:f3d5be054c461d6a2268831f04091dc82753176f6ea06dc6047a5e168265a987", size = 2270934, upload-time = "2025-09-29T21:12:41.339Z" }, + { url = "https://files.pythonhosted.org/packages/fb/da/1392aaa2170adc7071fe7f9cfd181a5684a7afcde605aebddf1fb4d76df5/fonttools-4.60.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:b6379e7546ba4ae4b18f8ae2b9bc5960936007a1c0e30b342f662577e8bc3299", size = 2894340, upload-time = "2025-09-29T21:12:43.774Z" }, + { url = "https://files.pythonhosted.org/packages/bf/a7/3b9f16e010d536ce567058b931a20b590d8f3177b2eda09edd92e392375d/fonttools-4.60.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9d0ced62b59e0430b3690dbc5373df1c2aa7585e9a8ce38eff87f0fd993c5b01", size = 2375073, upload-time = "2025-09-29T21:12:46.437Z" }, + { url = "https://files.pythonhosted.org/packages/9b/b5/e9bcf51980f98e59bb5bb7c382a63c6f6cac0eec5f67de6d8f2322382065/fonttools-4.60.1-cp314-cp314t-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:875cb7764708b3132637f6c5fb385b16eeba0f7ac9fa45a69d35e09b47045801", size = 4849758, upload-time = "2025-09-29T21:12:48.694Z" }, + { url = "https://files.pythonhosted.org/packages/e3/dc/1d2cf7d1cba82264b2f8385db3f5960e3d8ce756b4dc65b700d2c496f7e9/fonttools-4.60.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a184b2ea57b13680ab6d5fbde99ccef152c95c06746cb7718c583abd8f945ccc", size = 5085598, upload-time = "2025-09-29T21:12:51.081Z" }, + { url = "https://files.pythonhosted.org/packages/5d/4d/279e28ba87fb20e0c69baf72b60bbf1c4d873af1476806a7b5f2b7fac1ff/fonttools-4.60.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:026290e4ec76583881763fac284aca67365e0be9f13a7fb137257096114cb3bc", size = 4957603, upload-time = "2025-09-29T21:12:53.423Z" }, + { url = "https://files.pythonhosted.org/packages/78/d4/ff19976305e0c05aa3340c805475abb00224c954d3c65e82c0a69633d55d/fonttools-4.60.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f0e8817c7d1a0c2eedebf57ef9a9896f3ea23324769a9a2061a80fe8852705ed", size = 4974184, upload-time = "2025-09-29T21:12:55.962Z" }, + { url = "https://files.pythonhosted.org/packages/63/22/8553ff6166f5cd21cfaa115aaacaa0dc73b91c079a8cfd54a482cbc0f4f5/fonttools-4.60.1-cp314-cp314t-win32.whl", hash = "sha256:1410155d0e764a4615774e5c2c6fc516259fe3eca5882f034eb9bfdbee056259", size = 2282241, upload-time = "2025-09-29T21:12:58.179Z" }, + { url = "https://files.pythonhosted.org/packages/8a/cb/fa7b4d148e11d5a72761a22e595344133e83a9507a4c231df972e657579b/fonttools-4.60.1-cp314-cp314t-win_amd64.whl", hash = "sha256:022beaea4b73a70295b688f817ddc24ed3e3418b5036ffcd5658141184ef0d0c", size = 2345760, upload-time = "2025-09-29T21:13:00.375Z" }, + { url = "https://files.pythonhosted.org/packages/c7/93/0dd45cd283c32dea1545151d8c3637b4b8c53cdb3a625aeb2885b184d74d/fonttools-4.60.1-py3-none-any.whl", hash = "sha256:906306ac7afe2156fcf0042173d6ebbb05416af70f6b370967b47f8f00103bbb", size = 1143175, upload-time = "2025-09-29T21:13:24.134Z" }, +] + +[[package]] +name = "fqdn" +version = "1.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/30/3e/a80a8c077fd798951169626cde3e239adeba7dab75deb3555716415bd9b0/fqdn-1.5.1.tar.gz", hash = "sha256:105ed3677e767fb5ca086a0c1f4bb66ebc3c100be518f0e0d755d9eae164d89f", size = 6015, upload-time = "2021-03-11T07:16:29.08Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cf/58/8acf1b3e91c58313ce5cb67df61001fc9dcd21be4fadb76c1a2d540e09ed/fqdn-1.5.1-py3-none-any.whl", hash = "sha256:3a179af3761e4df6eb2e026ff9e1a3033d3587bf980a0b1b2e1e5d08d7358014", size = 9121, upload-time = "2021-03-11T07:16:28.351Z" }, +] + +[[package]] +name = "geomm" +version = "0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "pint" }, + { name = "scipy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4b/b3/1a961038e36961522062fbb1799f731392b3cc6c34e22c724e5c68a97d36/geomm-0.3.tar.gz", hash = "sha256:c074e29e4e0d967807792f72a5e764e136aad65e9d5322ecca5fcdffdfb90336", size = 201199, upload-time = "2025-07-07T19:56:18.842Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/aa/5f/a38ea01eb6f6e1d62509667c0d3919c0e3d573d582c62fc96a1e949fc7cb/geomm-0.3-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:c4087441b8d74c45005d6b63bc1c40b63a79240b6854d4454faa73d978305e88", size = 155587, upload-time = "2025-07-07T19:56:17.819Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "h5py" +version = "3.15.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4d/6a/0d79de0b025aa85dc8864de8e97659c94cf3d23148394a954dc5ca52f8c8/h5py-3.15.1.tar.gz", hash = "sha256:c86e3ed45c4473564de55aa83b6fc9e5ead86578773dfbd93047380042e26b69", size = 426236, upload-time = "2025-10-16T10:35:27.404Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/fd/8349b48b15b47768042cff06ad6e1c229f0a4bd89225bf6b6894fea27e6d/h5py-3.15.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5aaa330bcbf2830150c50897ea5dcbed30b5b6d56897289846ac5b9e529ec243", size = 3434135, upload-time = "2025-10-16T10:33:47.954Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b0/1c628e26a0b95858f54aba17e1599e7f6cd241727596cc2580b72cb0a9bf/h5py-3.15.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c970fb80001fffabb0109eaf95116c8e7c0d3ca2de854e0901e8a04c1f098509", size = 2870958, upload-time = "2025-10-16T10:33:50.907Z" }, + { url = "https://files.pythonhosted.org/packages/f9/e3/c255cafc9b85e6ea04e2ad1bba1416baa1d7f57fc98a214be1144087690c/h5py-3.15.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:80e5bb5b9508d5d9da09f81fd00abbb3f85da8143e56b1585d59bc8ceb1dba8b", size = 4504770, upload-time = "2025-10-16T10:33:54.357Z" }, + { url = "https://files.pythonhosted.org/packages/8b/23/4ab1108e87851ccc69694b03b817d92e142966a6c4abd99e17db77f2c066/h5py-3.15.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5b849ba619a066196169763c33f9f0f02e381156d61c03e000bb0100f9950faf", size = 4700329, upload-time = "2025-10-16T10:33:57.616Z" }, + { url = "https://files.pythonhosted.org/packages/a4/e4/932a3a8516e4e475b90969bf250b1924dbe3612a02b897e426613aed68f4/h5py-3.15.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e7f6c841efd4e6e5b7e82222eaf90819927b6d256ab0f3aca29675601f654f3c", size = 4152456, upload-time = "2025-10-16T10:34:00.843Z" }, + { url = "https://files.pythonhosted.org/packages/2a/0a/f74d589883b13737021b2049ac796328f188dbb60c2ed35b101f5b95a3fc/h5py-3.15.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ca8a3a22458956ee7b40d8e39c9a9dc01f82933e4c030c964f8b875592f4d831", size = 4617295, upload-time = "2025-10-16T10:34:04.154Z" }, + { url = "https://files.pythonhosted.org/packages/23/95/499b4e56452ef8b6c95a271af0dde08dac4ddb70515a75f346d4f400579b/h5py-3.15.1-cp311-cp311-win_amd64.whl", hash = "sha256:550e51131376889656feec4aff2170efc054a7fe79eb1da3bb92e1625d1ac878", size = 2882129, upload-time = "2025-10-16T10:34:06.886Z" }, + { url = "https://files.pythonhosted.org/packages/ce/bb/cfcc70b8a42222ba3ad4478bcef1791181ea908e2adbd7d53c66395edad5/h5py-3.15.1-cp311-cp311-win_arm64.whl", hash = "sha256:b39239947cb36a819147fc19e86b618dcb0953d1cd969f5ed71fc0de60392427", size = 2477121, upload-time = "2025-10-16T10:34:09.579Z" }, + { url = "https://files.pythonhosted.org/packages/62/b8/c0d9aa013ecfa8b7057946c080c0c07f6fa41e231d2e9bd306a2f8110bdc/h5py-3.15.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:316dd0f119734f324ca7ed10b5627a2de4ea42cc4dfbcedbee026aaa361c238c", size = 3399089, upload-time = "2025-10-16T10:34:12.135Z" }, + { url = "https://files.pythonhosted.org/packages/a4/5e/3c6f6e0430813c7aefe784d00c6711166f46225f5d229546eb53032c3707/h5py-3.15.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b51469890e58e85d5242e43aab29f5e9c7e526b951caab354f3ded4ac88e7b76", size = 2847803, upload-time = "2025-10-16T10:34:14.564Z" }, + { url = "https://files.pythonhosted.org/packages/00/69/ba36273b888a4a48d78f9268d2aee05787e4438557450a8442946ab8f3ec/h5py-3.15.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a33bfd5dfcea037196f7778534b1ff7e36a7f40a89e648c8f2967292eb6898e", size = 4914884, upload-time = "2025-10-16T10:34:18.452Z" }, + { url = "https://files.pythonhosted.org/packages/3a/30/d1c94066343a98bb2cea40120873193a4fed68c4ad7f8935c11caf74c681/h5py-3.15.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:25c8843fec43b2cc368aa15afa1cdf83fc5e17b1c4e10cd3771ef6c39b72e5ce", size = 5109965, upload-time = "2025-10-16T10:34:21.853Z" }, + { url = "https://files.pythonhosted.org/packages/81/3d/d28172116eafc3bc9f5991b3cb3fd2c8a95f5984f50880adfdf991de9087/h5py-3.15.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a308fd8681a864c04423c0324527237a0484e2611e3441f8089fd00ed56a8171", size = 4561870, upload-time = "2025-10-16T10:34:26.69Z" }, + { url = "https://files.pythonhosted.org/packages/a5/83/393a7226024238b0f51965a7156004eaae1fcf84aa4bfecf7e582676271b/h5py-3.15.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f4a016df3f4a8a14d573b496e4d1964deb380e26031fc85fb40e417e9131888a", size = 5037161, upload-time = "2025-10-16T10:34:30.383Z" }, + { url = "https://files.pythonhosted.org/packages/cf/51/329e7436bf87ca6b0fe06dd0a3795c34bebe4ed8d6c44450a20565d57832/h5py-3.15.1-cp312-cp312-win_amd64.whl", hash = "sha256:59b25cf02411bf12e14f803fef0b80886444c7fe21a5ad17c6a28d3f08098a1e", size = 2874165, upload-time = "2025-10-16T10:34:33.461Z" }, + { url = "https://files.pythonhosted.org/packages/09/a8/2d02b10a66747c54446e932171dd89b8b4126c0111b440e6bc05a7c852ec/h5py-3.15.1-cp312-cp312-win_arm64.whl", hash = "sha256:61d5a58a9851e01ee61c932bbbb1c98fe20aba0a5674776600fb9a361c0aa652", size = 2458214, upload-time = "2025-10-16T10:34:35.733Z" }, + { url = "https://files.pythonhosted.org/packages/88/b3/40207e0192415cbff7ea1d37b9f24b33f6d38a5a2f5d18a678de78f967ae/h5py-3.15.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c8440fd8bee9500c235ecb7aa1917a0389a2adb80c209fa1cc485bd70e0d94a5", size = 3376511, upload-time = "2025-10-16T10:34:38.596Z" }, + { url = "https://files.pythonhosted.org/packages/31/96/ba99a003c763998035b0de4c299598125df5fc6c9ccf834f152ddd60e0fb/h5py-3.15.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ab2219dbc6fcdb6932f76b548e2b16f34a1f52b7666e998157a4dfc02e2c4123", size = 2826143, upload-time = "2025-10-16T10:34:41.342Z" }, + { url = "https://files.pythonhosted.org/packages/6a/c2/fc6375d07ea3962df7afad7d863fe4bde18bb88530678c20d4c90c18de1d/h5py-3.15.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8cb02c3a96255149ed3ac811eeea25b655d959c6dd5ce702c9a95ff11859eb5", size = 4908316, upload-time = "2025-10-16T10:34:44.619Z" }, + { url = "https://files.pythonhosted.org/packages/d9/69/4402ea66272dacc10b298cca18ed73e1c0791ff2ae9ed218d3859f9698ac/h5py-3.15.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:121b2b7a4c1915d63737483b7bff14ef253020f617c2fb2811f67a4bed9ac5e8", size = 5103710, upload-time = "2025-10-16T10:34:48.639Z" }, + { url = "https://files.pythonhosted.org/packages/e0/f6/11f1e2432d57d71322c02a97a5567829a75f223a8c821764a0e71a65cde8/h5py-3.15.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:59b0d63b318bf3cc06687def2b45afd75926bbc006f7b8cd2b1a231299fc8599", size = 4556042, upload-time = "2025-10-16T10:34:51.841Z" }, + { url = "https://files.pythonhosted.org/packages/18/88/3eda3ef16bfe7a7dbc3d8d6836bbaa7986feb5ff091395e140dc13927bcc/h5py-3.15.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e02fe77a03f652500d8bff288cbf3675f742fc0411f5a628fa37116507dc7cc0", size = 5030639, upload-time = "2025-10-16T10:34:55.257Z" }, + { url = "https://files.pythonhosted.org/packages/e5/ea/fbb258a98863f99befb10ed727152b4ae659f322e1d9c0576f8a62754e81/h5py-3.15.1-cp313-cp313-win_amd64.whl", hash = "sha256:dea78b092fd80a083563ed79a3171258d4a4d307492e7cf8b2313d464c82ba52", size = 2864363, upload-time = "2025-10-16T10:34:58.099Z" }, + { url = "https://files.pythonhosted.org/packages/5d/c9/35021cc9cd2b2915a7da3026e3d77a05bed1144a414ff840953b33937fb9/h5py-3.15.1-cp313-cp313-win_arm64.whl", hash = "sha256:c256254a8a81e2bddc0d376e23e2a6d2dc8a1e8a2261835ed8c1281a0744cd97", size = 2449570, upload-time = "2025-10-16T10:35:00.473Z" }, + { url = "https://files.pythonhosted.org/packages/a0/2c/926eba1514e4d2e47d0e9eb16c784e717d8b066398ccfca9b283917b1bfb/h5py-3.15.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:5f4fb0567eb8517c3ecd6b3c02c4f4e9da220c8932604960fd04e24ee1254763", size = 3380368, upload-time = "2025-10-16T10:35:03.117Z" }, + { url = "https://files.pythonhosted.org/packages/65/4b/d715ed454d3baa5f6ae1d30b7eca4c7a1c1084f6a2edead9e801a1541d62/h5py-3.15.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:954e480433e82d3872503104f9b285d369048c3a788b2b1a00e53d1c47c98dd2", size = 2833793, upload-time = "2025-10-16T10:35:05.623Z" }, + { url = "https://files.pythonhosted.org/packages/ef/d4/ef386c28e4579314610a8bffebbee3b69295b0237bc967340b7c653c6c10/h5py-3.15.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fd125c131889ebbef0849f4a0e29cf363b48aba42f228d08b4079913b576bb3a", size = 4903199, upload-time = "2025-10-16T10:35:08.972Z" }, + { url = "https://files.pythonhosted.org/packages/33/5d/65c619e195e0b5e54ea5a95c1bb600c8ff8715e0d09676e4cce56d89f492/h5py-3.15.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:28a20e1a4082a479b3d7db2169f3a5034af010b90842e75ebbf2e9e49eb4183e", size = 5097224, upload-time = "2025-10-16T10:35:12.808Z" }, + { url = "https://files.pythonhosted.org/packages/30/30/5273218400bf2da01609e1292f562c94b461fcb73c7a9e27fdadd43abc0a/h5py-3.15.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa8df5267f545b4946df8ca0d93d23382191018e4cda2deda4c2cedf9a010e13", size = 4551207, upload-time = "2025-10-16T10:35:16.24Z" }, + { url = "https://files.pythonhosted.org/packages/d3/39/a7ef948ddf4d1c556b0b2b9559534777bccc318543b3f5a1efdf6b556c9c/h5py-3.15.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99d374a21f7321a4c6ab327c4ab23bd925ad69821aeb53a1e75dd809d19f67fa", size = 5025426, upload-time = "2025-10-16T10:35:19.831Z" }, + { url = "https://files.pythonhosted.org/packages/b6/d8/7368679b8df6925b8415f9dcc9ab1dab01ddc384d2b2c24aac9191bd9ceb/h5py-3.15.1-cp314-cp314-win_amd64.whl", hash = "sha256:9c73d1d7cdb97d5b17ae385153472ce118bed607e43be11e9a9deefaa54e0734", size = 2865704, upload-time = "2025-10-16T10:35:22.658Z" }, + { url = "https://files.pythonhosted.org/packages/d3/b7/4a806f85d62c20157e62e58e03b27513dc9c55499768530acc4f4c5ce4be/h5py-3.15.1-cp314-cp314-win_arm64.whl", hash = "sha256:a6d8c5a05a76aca9a494b4c53ce8a9c29023b7f64f625c6ce1841e92a362ccdf", size = 2465544, upload-time = "2025-10-16T10:35:25.695Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[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 = "immutables" +version = "0.21" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/69/41/0ccaa6ef9943c0609ec5aa663a3b3e681c1712c1007147b84590cec706a0/immutables-0.21.tar.gz", hash = "sha256:b55ffaf0449790242feb4c56ab799ea7af92801a0a43f9e2f4f8af2ab24dfc4a", size = 89008, upload-time = "2024-10-10T00:55:01.434Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/85/39/2d7d54f6cf33f6fcb3f78e985142166d2ec6ff93be761a17dfee31a372cd/immutables-0.21-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5d780c38067047911a2e06a86ba063ba0055618ab5573c8198ef3f368e321303", size = 31227, upload-time = "2024-10-10T00:54:09.884Z" }, + { url = "https://files.pythonhosted.org/packages/ca/4b/260f632d0fb83b8cdabb0c1fc14657a9bdead456f3399c4eb5a0c0697989/immutables-0.21-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9aab9d0f0016f6e0bfe7e4a4cb831ef20063da6468b1bbc71d06ef285781ee9e", size = 31081, upload-time = "2024-10-10T00:54:11.325Z" }, + { url = "https://files.pythonhosted.org/packages/d2/8d/0f4a1d10fac2700874680dad4d1d7b0d0bf31225cb21f3a804d085bfb603/immutables-0.21-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7ff83390b05d3372acb9a0c928f6cc20c78e74ca20ed88eb941f84a63b65e444", size = 99334, upload-time = "2024-10-10T00:54:12.229Z" }, + { url = "https://files.pythonhosted.org/packages/ca/fc/20f4a5d5fcd4d17b46027d7d542190f5084f3d74abc6bdc2c0fab4d3deb3/immutables-0.21-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d01497713e71509c4481ffccdbe3a47b94969345f4e92f814d6626f7c0a4c304", size = 99511, upload-time = "2024-10-10T00:54:13.202Z" }, + { url = "https://files.pythonhosted.org/packages/7e/52/f929eaee4b247b1b71823d784734e51761685d6e69d7ad000459c3f2b84d/immutables-0.21-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:bc7844c9fbb5bece5bfdf2bf8ea74d308f42f40b0665fd25c58abf56d7db024a", size = 97474, upload-time = "2024-10-10T00:54:14.961Z" }, + { url = "https://files.pythonhosted.org/packages/85/b1/690aeaa4acc1db13e8356888bc929c8d0c9363c1409ffd8ab74d65d8c49c/immutables-0.21-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:984106fa4345efd9f96de22e9949fc97bac8598bdebee03c20b2497a88bff3b7", size = 97940, upload-time = "2024-10-10T00:54:15.957Z" }, + { url = "https://files.pythonhosted.org/packages/dd/de/78bd53b69ee99e034c2b69f46063abf600f81417f28de6120d430f523c98/immutables-0.21-cp311-cp311-win32.whl", hash = "sha256:1bdb5200518518601377e4877d5034e7c535e9ea8a9d601ed8b0eedef0c7becd", size = 30503, upload-time = "2024-10-10T00:54:16.891Z" }, + { url = "https://files.pythonhosted.org/packages/1f/20/999ecf398412c9ad174da083c6ffea56d027ef62cf892f695b43210c4721/immutables-0.21-cp311-cp311-win_amd64.whl", hash = "sha256:dd00c34f431c54c95e7b84bfdbdeacb4f039a6a24eb0c1f7aa4b168bb9a6ad0a", size = 34395, upload-time = "2024-10-10T00:54:18.481Z" }, + { url = "https://files.pythonhosted.org/packages/4d/f9/0c46f600702b815182212453f5514c0070ee168b817cdf7c3767554c8489/immutables-0.21-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ef1ed262094b755903122c3c3a83ad0e0d5c3ab7887cda12b2fe878769d1ee0d", size = 31885, upload-time = "2024-10-10T00:54:19.406Z" }, + { url = "https://files.pythonhosted.org/packages/29/34/7608d2eab6179aa47e8f59ab0fbd5b3eeb2333d78c9dc2da0de8de4ed322/immutables-0.21-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce604f81d9d8f26e60b52ebcb56bb5c0462c8ea50fb17868487d15f048a2f13e", size = 31537, upload-time = "2024-10-10T00:54:20.998Z" }, + { url = "https://files.pythonhosted.org/packages/f7/52/cb9e2bb7a69338155ffabbd2f993c968c750dd2d5c6c6eaa6ebb7bfcbdfa/immutables-0.21-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b48b116aaca4500398058b5a87814857a60c4cb09417fecc12d7da0f5639b73d", size = 104270, upload-time = "2024-10-10T00:54:21.912Z" }, + { url = "https://files.pythonhosted.org/packages/0f/a4/25df835a9b9b372a4a869a8a1ac30a32199f2b3f581ad0e249f7e3d19eed/immutables-0.21-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dad7c0c74b285cc0e555ec0e97acbdc6f1862fcd16b99abd612df3243732e741", size = 104864, upload-time = "2024-10-10T00:54:22.956Z" }, + { url = "https://files.pythonhosted.org/packages/4a/51/b548fbc657134d658e179ee8d201ae82d9049aba5c3cb2d858ed2ecb7e3f/immutables-0.21-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e44346e2221a5a676c880ca8e0e6429fa24d1a4ae562573f5c04d7f2e759b030", size = 99733, upload-time = "2024-10-10T00:54:23.99Z" }, + { url = "https://files.pythonhosted.org/packages/47/db/d7b1e0e88faf07fe9a88579a86f58078a9a37fff871f4b3dbcf28cad9a12/immutables-0.21-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8b10139b529a460e53fe8be699ebd848c54c8a33ebe67763bcfcc809a475a26f", size = 101698, upload-time = "2024-10-10T00:54:25.734Z" }, + { url = "https://files.pythonhosted.org/packages/69/2d/6fe42a1a053dd8cfb9f45e91d5246522637c7287dc6bd347f67aedf7aedb/immutables-0.21-cp312-cp312-win32.whl", hash = "sha256:fc512d808662614feb17d2d92e98f611d69669a98c7af15910acf1dc72737038", size = 30977, upload-time = "2024-10-10T00:54:27.436Z" }, + { url = "https://files.pythonhosted.org/packages/63/45/d062aca6971e99454ce3ae42a7430037227fee961644ed1f8b6c9b99e0a5/immutables-0.21-cp312-cp312-win_amd64.whl", hash = "sha256:461dcb0f58a131045155e52a2c43de6ec2fe5ba19bdced6858a3abb63cee5111", size = 35088, upload-time = "2024-10-10T00:54:28.388Z" }, + { url = "https://files.pythonhosted.org/packages/5e/db/60da6f5a3c3f64e0b3940c4ad86e1971d9d2eb8b13a179c26eda5ec6a298/immutables-0.21-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:79674b51aa8dd983f9ac55f7f67b433b1df84a6b4f28ab860588389a5659485b", size = 31922, upload-time = "2024-10-10T00:54:29.305Z" }, + { url = "https://files.pythonhosted.org/packages/9b/89/5420f1d16a652024fcccc9c07d46d4157fcaf33ff37c82412c83fc16ef36/immutables-0.21-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:93c8350f8f7d0d9693f708229d9d0578e6f3b785ce6da4bced1da97137aacfad", size = 31552, upload-time = "2024-10-10T00:54:30.282Z" }, + { url = "https://files.pythonhosted.org/packages/d2/d0/a5fb7c164ddb298ec37537e618b70dfa30c7cae9fac01de374c36489cbc9/immutables-0.21-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:583d2a63e444ce1538cc2bda56ae1f4a1a11473dbc0377c82b516bc7eec3b81e", size = 104334, upload-time = "2024-10-10T00:54:31.284Z" }, + { url = "https://files.pythonhosted.org/packages/f3/a5/5fda0ee4a261a85124011ac0750fec678f00e1b2d4a5502b149a3b4d86d9/immutables-0.21-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b274a52da9b106db55eceb93fc1aea858c4e6f4740189e3548e38613eafc2021", size = 104898, upload-time = "2024-10-10T00:54:32.295Z" }, + { url = "https://files.pythonhosted.org/packages/93/fa/d46bfe92f2c66d35916344176ff87fa839aac9c16849652947e722b7a15f/immutables-0.21-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:338bede057250b33716a3e4892e15df0bf5a5ddbf1d67ead996b3e680b49ef9e", size = 99966, upload-time = "2024-10-10T00:54:34.046Z" }, + { url = "https://files.pythonhosted.org/packages/d7/f5/2a19e2e095f7a39d8d77dcc10669734d2d99773ce00c99bdcfeeb7d714e6/immutables-0.21-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8781c89583b68f604cf30f0978b722165824c3075888639fde771bf1a3e12dc0", size = 101773, upload-time = "2024-10-10T00:54:35.851Z" }, + { url = "https://files.pythonhosted.org/packages/86/80/5b6ee53f836cf2067ced997efbf2ce20890627f150c3089ea50cf607e783/immutables-0.21-cp313-cp313-win32.whl", hash = "sha256:e97ea83befad873712f283c0cccd630f70cba753e207b4868af28d5b85e9dc54", size = 30988, upload-time = "2024-10-10T00:54:37.618Z" }, + { url = "https://files.pythonhosted.org/packages/ff/07/f623e6da78368fc0b1772f4877afbf60f34c4cc93f1a8f1006507afa21ec/immutables-0.21-cp313-cp313-win_amd64.whl", hash = "sha256:cfcb23bd898f5a4ef88692b42c51f52ca7373a35ba4dcc215060a668639eb5da", size = 35147, upload-time = "2024-10-10T00:54:38.558Z" }, +] + +[[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 = "interrogate" +version = "1.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "click" }, + { name = "colorama" }, + { name = "py" }, + { name = "tabulate" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8b/22/74f7fcc96280eea46cf2bcbfa1354ac31de0e60a4be6f7966f12cef20893/interrogate-1.7.0.tar.gz", hash = "sha256:a320d6ec644dfd887cc58247a345054fc4d9f981100c45184470068f4b3719b0", size = 159636, upload-time = "2024-04-07T22:30:46.217Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/c9/6869a1dcf4aaf309b9543ec070be3ec3adebee7c9bec9af8c230494134b9/interrogate-1.7.0-py3-none-any.whl", hash = "sha256:b13ff4dd8403369670e2efe684066de9fcb868ad9d7f2b4095d8112142dc9d12", size = 46982, upload-time = "2024-04-07T22:30:44.277Z" }, +] + +[[package]] +name = "ipykernel" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "appnope", marker = "sys_platform == 'darwin'" }, + { name = "comm" }, + { name = "debugpy" }, + { name = "ipython" }, + { name = "jupyter-client" }, + { name = "jupyter-core" }, + { name = "matplotlib-inline" }, + { name = "nest-asyncio" }, + { name = "packaging" }, + { name = "psutil" }, + { name = "pyzmq" }, + { name = "tornado" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b9/a4/4948be6eb88628505b83a1f2f40d90254cab66abf2043b3c40fa07dfce0f/ipykernel-7.1.0.tar.gz", hash = "sha256:58a3fc88533d5930c3546dc7eac66c6d288acde4f801e2001e65edc5dc9cf0db", size = 174579, upload-time = "2025-10-27T09:46:39.471Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/17/20c2552266728ceba271967b87919664ecc0e33efca29c3efc6baf88c5f9/ipykernel-7.1.0-py3-none-any.whl", hash = "sha256:763b5ec6c5b7776f6a8d7ce09b267693b4e5ce75cb50ae696aaefb3c85e1ea4c", size = 117968, upload-time = "2025-10-27T09:46:37.805Z" }, +] + +[[package]] +name = "ipython" +version = "9.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "decorator" }, + { name = "ipython-pygments-lexers" }, + { name = "jedi" }, + { name = "matplotlib-inline" }, + { name = "pexpect", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "prompt-toolkit" }, + { name = "pygments" }, + { name = "stack-data" }, + { name = "traitlets" }, + { name = "typing-extensions", marker = "python_full_version < '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/29/e6/48c74d54039241a456add616464ea28c6ebf782e4110d419411b83dae06f/ipython-9.7.0.tar.gz", hash = "sha256:5f6de88c905a566c6a9d6c400a8fed54a638e1f7543d17aae2551133216b1e4e", size = 4422115, upload-time = "2025-11-05T12:18:54.646Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/05/aa/62893d6a591d337aa59dcc4c6f6c842f1fe20cd72c8c5c1f980255243252/ipython-9.7.0-py3-none-any.whl", hash = "sha256:bce8ac85eb9521adc94e1845b4c03d88365fd6ac2f4908ec4ed1eb1b0a065f9f", size = 618911, upload-time = "2025-11-05T12:18:52.484Z" }, +] + +[[package]] +name = "ipython-pygments-lexers" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ef/4c/5dd1d8af08107f88c7f741ead7a40854b8ac24ddf9ae850afbcf698aa552/ipython_pygments_lexers-1.1.1.tar.gz", hash = "sha256:09c0138009e56b6854f9535736f4171d855c8c08a563a0dcd8022f78355c7e81", size = 8393, upload-time = "2025-01-17T11:24:34.505Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d9/33/1f075bf72b0b747cb3288d011319aaf64083cf2efef8354174e3ed4540e2/ipython_pygments_lexers-1.1.1-py3-none-any.whl", hash = "sha256:a9462224a505ade19a605f71f8fa63c2048833ce50abc86768a0d81d876dc81c", size = 8074, upload-time = "2025-01-17T11:24:33.271Z" }, +] + +[[package]] +name = "isoduration" +version = "20.11.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "arrow" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7c/1a/3c8edc664e06e6bd06cce40c6b22da5f1429aa4224d0c590f3be21c91ead/isoduration-20.11.0.tar.gz", hash = "sha256:ac2f9015137935279eac671f94f89eb00584f940f5dc49462a0c4ee692ba1bd9", size = 11649, upload-time = "2020-11-01T11:00:00.312Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/55/e5326141505c5d5e34c5e0935d2908a74e4561eca44108fbfb9c13d2911a/isoduration-20.11.0-py3-none-any.whl", hash = "sha256:b2904c2a4228c3d44f409c8ae8e2370eb21a26f7ac2ec5446df141dde3452042", size = 11321, upload-time = "2020-11-01T10:59:58.02Z" }, +] + +[[package]] +name = "isort" +version = "7.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/63/53/4f3c058e3bace40282876f9b553343376ee687f3c35a525dc79dbd450f88/isort-7.0.0.tar.gz", hash = "sha256:5513527951aadb3ac4292a41a16cbc50dd1642432f5e8c20057d414bdafb4187", size = 805049, upload-time = "2025-10-11T13:30:59.107Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/ed/e3705d6d02b4f7aea715a353c8ce193efd0b5db13e204df895d38734c244/isort-7.0.0-py3-none-any.whl", hash = "sha256:1bcabac8bc3c36c7fb7b98a76c8abb18e0f841a3ba81decac7691008592499c1", size = 94672, upload-time = "2025-10-11T13:30:57.665Z" }, +] + +[[package]] +name = "jedi" +version = "0.19.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "parso" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/72/3a/79a912fbd4d8dd6fbb02bf69afd3bb72cf0c729bb3063c6f4498603db17a/jedi-0.19.2.tar.gz", hash = "sha256:4770dc3de41bde3966b02eb84fbcf557fb33cce26ad23da12c742fb50ecb11f0", size = 1231287, upload-time = "2024-11-11T01:41:42.873Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/5a/9cac0c82afec3d09ccd97c8b6502d48f165f9124db81b4bcb90b4af974ee/jedi-0.19.2-py2.py3-none-any.whl", hash = "sha256:a8ef22bde8490f57fe5c7681a3c83cb58874daf72b4784de3cce5b6ef6edb5b9", size = 1572278, upload-time = "2024-11-11T01:41:40.175Z" }, +] + +[[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 = "json5" +version = "0.12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/12/ae/929aee9619e9eba9015207a9d2c1c54db18311da7eb4dcf6d41ad6f0eb67/json5-0.12.1.tar.gz", hash = "sha256:b2743e77b3242f8d03c143dd975a6ec7c52e2f2afe76ed934e53503dd4ad4990", size = 52191, upload-time = "2025-08-12T19:47:42.583Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/85/e2/05328bd2621be49a6fed9e3030b1e51a2d04537d3f816d211b9cc53c5262/json5-0.12.1-py3-none-any.whl", hash = "sha256:d9c9b3bc34a5f54d43c35e11ef7cb87d8bdd098c6ace87117a7b7e83e705c1d5", size = 36119, upload-time = "2025-08-12T19:47:41.131Z" }, +] + +[[package]] +name = "jsonpointer" +version = "3.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6a/0a/eebeb1fa92507ea94016a2a790b93c2ae41a7e18778f85471dc54475ed25/jsonpointer-3.0.0.tar.gz", hash = "sha256:2b2d729f2091522d61c3b31f82e11870f60b68f43fbc705cb76bf4b832af59ef", size = 9114, upload-time = "2024-06-10T19:24:42.462Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/92/5e77f98553e9e75130c78900d000368476aed74276eb8ae8796f65f00918/jsonpointer-3.0.0-py2.py3-none-any.whl", hash = "sha256:13e088adc14fca8b6aa8177c044e12701e6ad4b28ff10e65f2267a90109c9942", size = 7595, upload-time = "2024-06-10T19:24:40.698Z" }, +] + +[[package]] +name = "jsonschema" +version = "4.25.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "jsonschema-specifications" }, + { name = "referencing" }, + { name = "rpds-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/74/69/f7185de793a29082a9f3c7728268ffb31cb5095131a9c139a74078e27336/jsonschema-4.25.1.tar.gz", hash = "sha256:e4a9655ce0da0c0b67a085847e00a3a51449e1157f4f75e9fb5aa545e122eb85", size = 357342, upload-time = "2025-08-18T17:03:50.038Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bf/9c/8c95d856233c1f82500c2450b8c68576b4cf1c871db3afac5c34ff84e6fd/jsonschema-4.25.1-py3-none-any.whl", hash = "sha256:3fba0169e345c7175110351d456342c364814cfcf3b964ba4587f22915230a63", size = 90040, upload-time = "2025-08-18T17:03:48.373Z" }, +] + +[package.optional-dependencies] +format-nongpl = [ + { name = "fqdn" }, + { name = "idna" }, + { name = "isoduration" }, + { name = "jsonpointer" }, + { name = "rfc3339-validator" }, + { name = "rfc3986-validator" }, + { name = "rfc3987-syntax" }, + { name = "uri-template" }, + { name = "webcolors" }, +] + +[[package]] +name = "jsonschema-specifications" +version = "2025.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "referencing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, +] + +[[package]] +name = "jupyter-client" +version = "8.6.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jupyter-core" }, + { name = "python-dateutil" }, + { name = "pyzmq" }, + { name = "tornado" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/22/bf9f12fdaeae18019a468b68952a60fe6dbab5d67cd2a103cac7659b41ca/jupyter_client-8.6.3.tar.gz", hash = "sha256:35b3a0947c4a6e9d589eb97d7d4cd5e90f910ee73101611f01283732bd6d9419", size = 342019, upload-time = "2024-09-17T10:44:17.613Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/11/85/b0394e0b6fcccd2c1eeefc230978a6f8cb0c5df1e4cd3e7625735a0d7d1e/jupyter_client-8.6.3-py3-none-any.whl", hash = "sha256:e8a19cc986cc45905ac3362915f410f3af85424b4c0905e94fa5f2cb08e8f23f", size = 106105, upload-time = "2024-09-17T10:44:15.218Z" }, +] + +[[package]] +name = "jupyter-core" +version = "5.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "platformdirs" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/02/49/9d1284d0dc65e2c757b74c6687b6d319b02f822ad039e5c512df9194d9dd/jupyter_core-5.9.1.tar.gz", hash = "sha256:4d09aaff303b9566c3ce657f580bd089ff5c91f5f89cf7d8846c3cdf465b5508", size = 89814, upload-time = "2025-10-16T19:19:18.444Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/e7/80988e32bf6f73919a113473a604f5a8f09094de312b9d52b79c2df7612b/jupyter_core-5.9.1-py3-none-any.whl", hash = "sha256:ebf87fdc6073d142e114c72c9e29a9d7ca03fad818c5d300ce2adc1fb0743407", size = 29032, upload-time = "2025-10-16T19:19:16.783Z" }, +] + +[[package]] +name = "jupyter-events" +version = "0.12.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jsonschema", extra = ["format-nongpl"] }, + { name = "packaging" }, + { name = "python-json-logger" }, + { name = "pyyaml" }, + { name = "referencing" }, + { name = "rfc3339-validator" }, + { name = "rfc3986-validator" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/c3/306d090461e4cf3cd91eceaff84bede12a8e52cd821c2d20c9a4fd728385/jupyter_events-0.12.0.tar.gz", hash = "sha256:fc3fce98865f6784c9cd0a56a20644fc6098f21c8c33834a8d9fe383c17e554b", size = 62196, upload-time = "2025-02-03T17:23:41.485Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e2/48/577993f1f99c552f18a0428731a755e06171f9902fa118c379eb7c04ea22/jupyter_events-0.12.0-py3-none-any.whl", hash = "sha256:6464b2fa5ad10451c3d35fabc75eab39556ae1e2853ad0c0cc31b656731a97fb", size = 19430, upload-time = "2025-02-03T17:23:38.643Z" }, +] + +[[package]] +name = "jupyter-lsp" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jupyter-server" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/5a/9066c9f8e94ee517133cd98dba393459a16cd48bba71a82f16a65415206c/jupyter_lsp-2.3.0.tar.gz", hash = "sha256:458aa59339dc868fb784d73364f17dbce8836e906cd75fd471a325cba02e0245", size = 54823, upload-time = "2025-08-27T17:47:34.671Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1a/60/1f6cee0c46263de1173894f0fafcb3475ded276c472c14d25e0280c18d6d/jupyter_lsp-2.3.0-py3-none-any.whl", hash = "sha256:e914a3cb2addf48b1c7710914771aaf1819d46b2e5a79b0f917b5478ec93f34f", size = 76687, upload-time = "2025-08-27T17:47:33.15Z" }, +] + +[[package]] +name = "jupyter-server" +version = "2.17.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "argon2-cffi" }, + { name = "jinja2" }, + { name = "jupyter-client" }, + { name = "jupyter-core" }, + { name = "jupyter-events" }, + { name = "jupyter-server-terminals" }, + { name = "nbconvert" }, + { name = "nbformat" }, + { name = "overrides", marker = "python_full_version < '3.12'" }, + { name = "packaging" }, + { name = "prometheus-client" }, + { name = "pywinpty", marker = "os_name == 'nt'" }, + { name = "pyzmq" }, + { name = "send2trash" }, + { name = "terminado" }, + { name = "tornado" }, + { name = "traitlets" }, + { name = "websocket-client" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5b/ac/e040ec363d7b6b1f11304cc9f209dac4517ece5d5e01821366b924a64a50/jupyter_server-2.17.0.tar.gz", hash = "sha256:c38ea898566964c888b4772ae1ed58eca84592e88251d2cfc4d171f81f7e99d5", size = 731949, upload-time = "2025-08-21T14:42:54.042Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/92/80/a24767e6ca280f5a49525d987bf3e4d7552bf67c8be07e8ccf20271f8568/jupyter_server-2.17.0-py3-none-any.whl", hash = "sha256:e8cb9c7db4251f51ed307e329b81b72ccf2056ff82d50524debde1ee1870e13f", size = 388221, upload-time = "2025-08-21T14:42:52.034Z" }, +] + +[[package]] +name = "jupyter-server-terminals" +version = "0.5.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pywinpty", marker = "os_name == 'nt'" }, + { name = "terminado" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fc/d5/562469734f476159e99a55426d697cbf8e7eb5efe89fb0e0b4f83a3d3459/jupyter_server_terminals-0.5.3.tar.gz", hash = "sha256:5ae0295167220e9ace0edcfdb212afd2b01ee8d179fe6f23c899590e9b8a5269", size = 31430, upload-time = "2024-03-12T14:37:03.049Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/2d/2b32cdbe8d2a602f697a649798554e4f072115438e92249624e532e8aca6/jupyter_server_terminals-0.5.3-py3-none-any.whl", hash = "sha256:41ee0d7dc0ebf2809c668e0fc726dfaf258fcd3e769568996ca731b6194ae9aa", size = 13656, upload-time = "2024-03-12T14:37:00.708Z" }, +] + +[[package]] +name = "jupyterlab" +version = "4.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "async-lru" }, + { name = "httpx" }, + { name = "ipykernel" }, + { name = "jinja2" }, + { name = "jupyter-core" }, + { name = "jupyter-lsp" }, + { name = "jupyter-server" }, + { name = "jupyterlab-server" }, + { name = "notebook-shim" }, + { name = "packaging" }, + { name = "setuptools" }, + { name = "tornado" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/e5/4fa382a796a6d8e2cd867816b64f1ff27f906e43a7a83ad9eb389e448cd8/jupyterlab-4.5.0.tar.gz", hash = "sha256:aec33d6d8f1225b495ee2cf20f0514f45e6df8e360bdd7ac9bace0b7ac5177ea", size = 23989880, upload-time = "2025-11-18T13:19:00.365Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6c/1e/5a4d5498eba382fee667ed797cf64ae5d1b13b04356df62f067f48bb0f61/jupyterlab-4.5.0-py3-none-any.whl", hash = "sha256:88e157c75c1afff64c7dc4b801ec471450b922a4eae4305211ddd40da8201c8a", size = 12380641, upload-time = "2025-11-18T13:18:56.252Z" }, +] + +[[package]] +name = "jupyterlab-pygments" +version = "0.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/90/51/9187be60d989df97f5f0aba133fa54e7300f17616e065d1ada7d7646b6d6/jupyterlab_pygments-0.3.0.tar.gz", hash = "sha256:721aca4d9029252b11cfa9d185e5b5af4d54772bb8072f9b7036f4170054d35d", size = 512900, upload-time = "2023-11-23T09:26:37.44Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b1/dd/ead9d8ea85bf202d90cc513b533f9c363121c7792674f78e0d8a854b63b4/jupyterlab_pygments-0.3.0-py3-none-any.whl", hash = "sha256:841a89020971da1d8693f1a99997aefc5dc424bb1b251fd6322462a1b8842780", size = 15884, upload-time = "2023-11-23T09:26:34.325Z" }, +] + +[[package]] +name = "jupyterlab-server" +version = "2.28.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "babel" }, + { name = "jinja2" }, + { name = "json5" }, + { name = "jsonschema" }, + { name = "jupyter-server" }, + { name = "packaging" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d6/2c/90153f189e421e93c4bb4f9e3f59802a1f01abd2ac5cf40b152d7f735232/jupyterlab_server-2.28.0.tar.gz", hash = "sha256:35baa81898b15f93573e2deca50d11ac0ae407ebb688299d3a5213265033712c", size = 76996, upload-time = "2025-10-22T13:59:18.37Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/07/a000fe835f76b7e1143242ab1122e6362ef1c03f23f83a045c38859c2ae0/jupyterlab_server-2.28.0-py3-none-any.whl", hash = "sha256:e4355b148fdcf34d312bbbc80f22467d6d20460e8b8736bf235577dd18506968", size = 59830, upload-time = "2025-10-22T13:59:16.767Z" }, +] + +[[package]] +name = "kiwisolver" +version = "1.4.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5c/3c/85844f1b0feb11ee581ac23fe5fce65cd049a200c1446708cc1b7f922875/kiwisolver-1.4.9.tar.gz", hash = "sha256:c3b22c26c6fd6811b0ae8363b95ca8ce4ea3c202d3d0975b2914310ceb1bcc4d", size = 97564, upload-time = "2025-08-10T21:27:49.279Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6f/ab/c80b0d5a9d8a1a65f4f815f2afff9798b12c3b9f31f1d304dd233dd920e2/kiwisolver-1.4.9-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:eb14a5da6dc7642b0f3a18f13654847cd8b7a2550e2645a5bda677862b03ba16", size = 124167, upload-time = "2025-08-10T21:25:53.403Z" }, + { url = "https://files.pythonhosted.org/packages/a0/c0/27fe1a68a39cf62472a300e2879ffc13c0538546c359b86f149cc19f6ac3/kiwisolver-1.4.9-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:39a219e1c81ae3b103643d2aedb90f1ef22650deb266ff12a19e7773f3e5f089", size = 66579, upload-time = "2025-08-10T21:25:54.79Z" }, + { url = "https://files.pythonhosted.org/packages/31/a2/a12a503ac1fd4943c50f9822678e8015a790a13b5490354c68afb8489814/kiwisolver-1.4.9-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2405a7d98604b87f3fc28b1716783534b1b4b8510d8142adca34ee0bc3c87543", size = 65309, upload-time = "2025-08-10T21:25:55.76Z" }, + { url = "https://files.pythonhosted.org/packages/66/e1/e533435c0be77c3f64040d68d7a657771194a63c279f55573188161e81ca/kiwisolver-1.4.9-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:dc1ae486f9abcef254b5618dfb4113dd49f94c68e3e027d03cf0143f3f772b61", size = 1435596, upload-time = "2025-08-10T21:25:56.861Z" }, + { url = "https://files.pythonhosted.org/packages/67/1e/51b73c7347f9aabdc7215aa79e8b15299097dc2f8e67dee2b095faca9cb0/kiwisolver-1.4.9-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a1f570ce4d62d718dce3f179ee78dac3b545ac16c0c04bb363b7607a949c0d1", size = 1246548, upload-time = "2025-08-10T21:25:58.246Z" }, + { url = "https://files.pythonhosted.org/packages/21/aa/72a1c5d1e430294f2d32adb9542719cfb441b5da368d09d268c7757af46c/kiwisolver-1.4.9-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb27e7b78d716c591e88e0a09a2139c6577865d7f2e152488c2cc6257f460872", size = 1263618, upload-time = "2025-08-10T21:25:59.857Z" }, + { url = "https://files.pythonhosted.org/packages/a3/af/db1509a9e79dbf4c260ce0cfa3903ea8945f6240e9e59d1e4deb731b1a40/kiwisolver-1.4.9-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:15163165efc2f627eb9687ea5f3a28137217d217ac4024893d753f46bce9de26", size = 1317437, upload-time = "2025-08-10T21:26:01.105Z" }, + { url = "https://files.pythonhosted.org/packages/e0/f2/3ea5ee5d52abacdd12013a94130436e19969fa183faa1e7c7fbc89e9a42f/kiwisolver-1.4.9-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:bdee92c56a71d2b24c33a7d4c2856bd6419d017e08caa7802d2963870e315028", size = 2195742, upload-time = "2025-08-10T21:26:02.675Z" }, + { url = "https://files.pythonhosted.org/packages/6f/9b/1efdd3013c2d9a2566aa6a337e9923a00590c516add9a1e89a768a3eb2fc/kiwisolver-1.4.9-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:412f287c55a6f54b0650bd9b6dce5aceddb95864a1a90c87af16979d37c89771", size = 2290810, upload-time = "2025-08-10T21:26:04.009Z" }, + { url = "https://files.pythonhosted.org/packages/fb/e5/cfdc36109ae4e67361f9bc5b41323648cb24a01b9ade18784657e022e65f/kiwisolver-1.4.9-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:2c93f00dcba2eea70af2be5f11a830a742fe6b579a1d4e00f47760ef13be247a", size = 2461579, upload-time = "2025-08-10T21:26:05.317Z" }, + { url = "https://files.pythonhosted.org/packages/62/86/b589e5e86c7610842213994cdea5add00960076bef4ae290c5fa68589cac/kiwisolver-1.4.9-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f117e1a089d9411663a3207ba874f31be9ac8eaa5b533787024dc07aeb74f464", size = 2268071, upload-time = "2025-08-10T21:26:06.686Z" }, + { url = "https://files.pythonhosted.org/packages/3b/c6/f8df8509fd1eee6c622febe54384a96cfaf4d43bf2ccec7a0cc17e4715c9/kiwisolver-1.4.9-cp311-cp311-win_amd64.whl", hash = "sha256:be6a04e6c79819c9a8c2373317d19a96048e5a3f90bec587787e86a1153883c2", size = 73840, upload-time = "2025-08-10T21:26:07.94Z" }, + { url = "https://files.pythonhosted.org/packages/e2/2d/16e0581daafd147bc11ac53f032a2b45eabac897f42a338d0a13c1e5c436/kiwisolver-1.4.9-cp311-cp311-win_arm64.whl", hash = "sha256:0ae37737256ba2de764ddc12aed4956460277f00c4996d51a197e72f62f5eec7", size = 65159, upload-time = "2025-08-10T21:26:09.048Z" }, + { url = "https://files.pythonhosted.org/packages/86/c9/13573a747838aeb1c76e3267620daa054f4152444d1f3d1a2324b78255b5/kiwisolver-1.4.9-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ac5a486ac389dddcc5bef4f365b6ae3ffff2c433324fb38dd35e3fab7c957999", size = 123686, upload-time = "2025-08-10T21:26:10.034Z" }, + { url = "https://files.pythonhosted.org/packages/51/ea/2ecf727927f103ffd1739271ca19c424d0e65ea473fbaeea1c014aea93f6/kiwisolver-1.4.9-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f2ba92255faa7309d06fe44c3a4a97efe1c8d640c2a79a5ef728b685762a6fd2", size = 66460, upload-time = "2025-08-10T21:26:11.083Z" }, + { url = "https://files.pythonhosted.org/packages/5b/5a/51f5464373ce2aeb5194508298a508b6f21d3867f499556263c64c621914/kiwisolver-1.4.9-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4a2899935e724dd1074cb568ce7ac0dce28b2cd6ab539c8e001a8578eb106d14", size = 64952, upload-time = "2025-08-10T21:26:12.058Z" }, + { url = "https://files.pythonhosted.org/packages/70/90/6d240beb0f24b74371762873e9b7f499f1e02166a2d9c5801f4dbf8fa12e/kiwisolver-1.4.9-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f6008a4919fdbc0b0097089f67a1eb55d950ed7e90ce2cc3e640abadd2757a04", size = 1474756, upload-time = "2025-08-10T21:26:13.096Z" }, + { url = "https://files.pythonhosted.org/packages/12/42/f36816eaf465220f683fb711efdd1bbf7a7005a2473d0e4ed421389bd26c/kiwisolver-1.4.9-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:67bb8b474b4181770f926f7b7d2f8c0248cbcb78b660fdd41a47054b28d2a752", size = 1276404, upload-time = "2025-08-10T21:26:14.457Z" }, + { url = "https://files.pythonhosted.org/packages/2e/64/bc2de94800adc830c476dce44e9b40fd0809cddeef1fde9fcf0f73da301f/kiwisolver-1.4.9-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2327a4a30d3ee07d2fbe2e7933e8a37c591663b96ce42a00bc67461a87d7df77", size = 1294410, upload-time = "2025-08-10T21:26:15.73Z" }, + { url = "https://files.pythonhosted.org/packages/5f/42/2dc82330a70aa8e55b6d395b11018045e58d0bb00834502bf11509f79091/kiwisolver-1.4.9-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7a08b491ec91b1d5053ac177afe5290adacf1f0f6307d771ccac5de30592d198", size = 1343631, upload-time = "2025-08-10T21:26:17.045Z" }, + { url = "https://files.pythonhosted.org/packages/22/fd/f4c67a6ed1aab149ec5a8a401c323cee7a1cbe364381bb6c9c0d564e0e20/kiwisolver-1.4.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d8fc5c867c22b828001b6a38d2eaeb88160bf5783c6cb4a5e440efc981ce286d", size = 2224963, upload-time = "2025-08-10T21:26:18.737Z" }, + { url = "https://files.pythonhosted.org/packages/45/aa/76720bd4cb3713314677d9ec94dcc21ced3f1baf4830adde5bb9b2430a5f/kiwisolver-1.4.9-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:3b3115b2581ea35bb6d1f24a4c90af37e5d9b49dcff267eeed14c3893c5b86ab", size = 2321295, upload-time = "2025-08-10T21:26:20.11Z" }, + { url = "https://files.pythonhosted.org/packages/80/19/d3ec0d9ab711242f56ae0dc2fc5d70e298bb4a1f9dfab44c027668c673a1/kiwisolver-1.4.9-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:858e4c22fb075920b96a291928cb7dea5644e94c0ee4fcd5af7e865655e4ccf2", size = 2487987, upload-time = "2025-08-10T21:26:21.49Z" }, + { url = "https://files.pythonhosted.org/packages/39/e9/61e4813b2c97e86b6fdbd4dd824bf72d28bcd8d4849b8084a357bc0dd64d/kiwisolver-1.4.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ed0fecd28cc62c54b262e3736f8bb2512d8dcfdc2bcf08be5f47f96bf405b145", size = 2291817, upload-time = "2025-08-10T21:26:22.812Z" }, + { url = "https://files.pythonhosted.org/packages/a0/41/85d82b0291db7504da3c2defe35c9a8a5c9803a730f297bd823d11d5fb77/kiwisolver-1.4.9-cp312-cp312-win_amd64.whl", hash = "sha256:f68208a520c3d86ea51acf688a3e3002615a7f0238002cccc17affecc86a8a54", size = 73895, upload-time = "2025-08-10T21:26:24.37Z" }, + { url = "https://files.pythonhosted.org/packages/e2/92/5f3068cf15ee5cb624a0c7596e67e2a0bb2adee33f71c379054a491d07da/kiwisolver-1.4.9-cp312-cp312-win_arm64.whl", hash = "sha256:2c1a4f57df73965f3f14df20b80ee29e6a7930a57d2d9e8491a25f676e197c60", size = 64992, upload-time = "2025-08-10T21:26:25.732Z" }, + { url = "https://files.pythonhosted.org/packages/31/c1/c2686cda909742ab66c7388e9a1a8521a59eb89f8bcfbee28fc980d07e24/kiwisolver-1.4.9-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a5d0432ccf1c7ab14f9949eec60c5d1f924f17c037e9f8b33352fa05799359b8", size = 123681, upload-time = "2025-08-10T21:26:26.725Z" }, + { url = "https://files.pythonhosted.org/packages/ca/f0/f44f50c9f5b1a1860261092e3bc91ecdc9acda848a8b8c6abfda4a24dd5c/kiwisolver-1.4.9-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efb3a45b35622bb6c16dbfab491a8f5a391fe0e9d45ef32f4df85658232ca0e2", size = 66464, upload-time = "2025-08-10T21:26:27.733Z" }, + { url = "https://files.pythonhosted.org/packages/2d/7a/9d90a151f558e29c3936b8a47ac770235f436f2120aca41a6d5f3d62ae8d/kiwisolver-1.4.9-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1a12cf6398e8a0a001a059747a1cbf24705e18fe413bc22de7b3d15c67cffe3f", size = 64961, upload-time = "2025-08-10T21:26:28.729Z" }, + { url = "https://files.pythonhosted.org/packages/e9/e9/f218a2cb3a9ffbe324ca29a9e399fa2d2866d7f348ec3a88df87fc248fc5/kiwisolver-1.4.9-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b67e6efbf68e077dd71d1a6b37e43e1a99d0bff1a3d51867d45ee8908b931098", size = 1474607, upload-time = "2025-08-10T21:26:29.798Z" }, + { url = "https://files.pythonhosted.org/packages/d9/28/aac26d4c882f14de59041636292bc838db8961373825df23b8eeb807e198/kiwisolver-1.4.9-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5656aa670507437af0207645273ccdfee4f14bacd7f7c67a4306d0dcaeaf6eed", size = 1276546, upload-time = "2025-08-10T21:26:31.401Z" }, + { url = "https://files.pythonhosted.org/packages/8b/ad/8bfc1c93d4cc565e5069162f610ba2f48ff39b7de4b5b8d93f69f30c4bed/kiwisolver-1.4.9-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bfc08add558155345129c7803b3671cf195e6a56e7a12f3dde7c57d9b417f525", size = 1294482, upload-time = "2025-08-10T21:26:32.721Z" }, + { url = "https://files.pythonhosted.org/packages/da/f1/6aca55ff798901d8ce403206d00e033191f63d82dd708a186e0ed2067e9c/kiwisolver-1.4.9-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:40092754720b174e6ccf9e845d0d8c7d8e12c3d71e7fc35f55f3813e96376f78", size = 1343720, upload-time = "2025-08-10T21:26:34.032Z" }, + { url = "https://files.pythonhosted.org/packages/d1/91/eed031876c595c81d90d0f6fc681ece250e14bf6998c3d7c419466b523b7/kiwisolver-1.4.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:497d05f29a1300d14e02e6441cf0f5ee81c1ff5a304b0d9fb77423974684e08b", size = 2224907, upload-time = "2025-08-10T21:26:35.824Z" }, + { url = "https://files.pythonhosted.org/packages/e9/ec/4d1925f2e49617b9cca9c34bfa11adefad49d00db038e692a559454dfb2e/kiwisolver-1.4.9-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:bdd1a81a1860476eb41ac4bc1e07b3f07259e6d55bbf739b79c8aaedcf512799", size = 2321334, upload-time = "2025-08-10T21:26:37.534Z" }, + { url = "https://files.pythonhosted.org/packages/43/cb/450cd4499356f68802750c6ddc18647b8ea01ffa28f50d20598e0befe6e9/kiwisolver-1.4.9-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:e6b93f13371d341afee3be9f7c5964e3fe61d5fa30f6a30eb49856935dfe4fc3", size = 2488313, upload-time = "2025-08-10T21:26:39.191Z" }, + { url = "https://files.pythonhosted.org/packages/71/67/fc76242bd99f885651128a5d4fa6083e5524694b7c88b489b1b55fdc491d/kiwisolver-1.4.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d75aa530ccfaa593da12834b86a0724f58bff12706659baa9227c2ccaa06264c", size = 2291970, upload-time = "2025-08-10T21:26:40.828Z" }, + { url = "https://files.pythonhosted.org/packages/75/bd/f1a5d894000941739f2ae1b65a32892349423ad49c2e6d0771d0bad3fae4/kiwisolver-1.4.9-cp313-cp313-win_amd64.whl", hash = "sha256:dd0a578400839256df88c16abddf9ba14813ec5f21362e1fe65022e00c883d4d", size = 73894, upload-time = "2025-08-10T21:26:42.33Z" }, + { url = "https://files.pythonhosted.org/packages/95/38/dce480814d25b99a391abbddadc78f7c117c6da34be68ca8b02d5848b424/kiwisolver-1.4.9-cp313-cp313-win_arm64.whl", hash = "sha256:d4188e73af84ca82468f09cadc5ac4db578109e52acb4518d8154698d3a87ca2", size = 64995, upload-time = "2025-08-10T21:26:43.889Z" }, + { url = "https://files.pythonhosted.org/packages/e2/37/7d218ce5d92dadc5ebdd9070d903e0c7cf7edfe03f179433ac4d13ce659c/kiwisolver-1.4.9-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:5a0f2724dfd4e3b3ac5a82436a8e6fd16baa7d507117e4279b660fe8ca38a3a1", size = 126510, upload-time = "2025-08-10T21:26:44.915Z" }, + { url = "https://files.pythonhosted.org/packages/23/b0/e85a2b48233daef4b648fb657ebbb6f8367696a2d9548a00b4ee0eb67803/kiwisolver-1.4.9-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:1b11d6a633e4ed84fc0ddafd4ebfd8ea49b3f25082c04ad12b8315c11d504dc1", size = 67903, upload-time = "2025-08-10T21:26:45.934Z" }, + { url = "https://files.pythonhosted.org/packages/44/98/f2425bc0113ad7de24da6bb4dae1343476e95e1d738be7c04d31a5d037fd/kiwisolver-1.4.9-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:61874cdb0a36016354853593cffc38e56fc9ca5aa97d2c05d3dcf6922cd55a11", size = 66402, upload-time = "2025-08-10T21:26:47.101Z" }, + { url = "https://files.pythonhosted.org/packages/98/d8/594657886df9f34c4177cc353cc28ca7e6e5eb562d37ccc233bff43bbe2a/kiwisolver-1.4.9-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:60c439763a969a6af93b4881db0eed8fadf93ee98e18cbc35bc8da868d0c4f0c", size = 1582135, upload-time = "2025-08-10T21:26:48.665Z" }, + { url = "https://files.pythonhosted.org/packages/5c/c6/38a115b7170f8b306fc929e166340c24958347308ea3012c2b44e7e295db/kiwisolver-1.4.9-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92a2f997387a1b79a75e7803aa7ded2cfbe2823852ccf1ba3bcf613b62ae3197", size = 1389409, upload-time = "2025-08-10T21:26:50.335Z" }, + { url = "https://files.pythonhosted.org/packages/bf/3b/e04883dace81f24a568bcee6eb3001da4ba05114afa622ec9b6fafdc1f5e/kiwisolver-1.4.9-cp313-cp313t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a31d512c812daea6d8b3be3b2bfcbeb091dbb09177706569bcfc6240dcf8b41c", size = 1401763, upload-time = "2025-08-10T21:26:51.867Z" }, + { url = "https://files.pythonhosted.org/packages/9f/80/20ace48e33408947af49d7d15c341eaee69e4e0304aab4b7660e234d6288/kiwisolver-1.4.9-cp313-cp313t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:52a15b0f35dad39862d376df10c5230155243a2c1a436e39eb55623ccbd68185", size = 1453643, upload-time = "2025-08-10T21:26:53.592Z" }, + { url = "https://files.pythonhosted.org/packages/64/31/6ce4380a4cd1f515bdda976a1e90e547ccd47b67a1546d63884463c92ca9/kiwisolver-1.4.9-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:a30fd6fdef1430fd9e1ba7b3398b5ee4e2887783917a687d86ba69985fb08748", size = 2330818, upload-time = "2025-08-10T21:26:55.051Z" }, + { url = "https://files.pythonhosted.org/packages/fa/e9/3f3fcba3bcc7432c795b82646306e822f3fd74df0ee81f0fa067a1f95668/kiwisolver-1.4.9-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:cc9617b46837c6468197b5945e196ee9ca43057bb7d9d1ae688101e4e1dddf64", size = 2419963, upload-time = "2025-08-10T21:26:56.421Z" }, + { url = "https://files.pythonhosted.org/packages/99/43/7320c50e4133575c66e9f7dadead35ab22d7c012a3b09bb35647792b2a6d/kiwisolver-1.4.9-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:0ab74e19f6a2b027ea4f845a78827969af45ce790e6cb3e1ebab71bdf9f215ff", size = 2594639, upload-time = "2025-08-10T21:26:57.882Z" }, + { url = "https://files.pythonhosted.org/packages/65/d6/17ae4a270d4a987ef8a385b906d2bdfc9fce502d6dc0d3aea865b47f548c/kiwisolver-1.4.9-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dba5ee5d3981160c28d5490f0d1b7ed730c22470ff7f6cc26cfcfaacb9896a07", size = 2391741, upload-time = "2025-08-10T21:26:59.237Z" }, + { url = "https://files.pythonhosted.org/packages/2a/8f/8f6f491d595a9e5912971f3f863d81baddccc8a4d0c3749d6a0dd9ffc9df/kiwisolver-1.4.9-cp313-cp313t-win_arm64.whl", hash = "sha256:0749fd8f4218ad2e851e11cc4dc05c7cbc0cbc4267bdfdb31782e65aace4ee9c", size = 68646, upload-time = "2025-08-10T21:27:00.52Z" }, + { url = "https://files.pythonhosted.org/packages/6b/32/6cc0fbc9c54d06c2969faa9c1d29f5751a2e51809dd55c69055e62d9b426/kiwisolver-1.4.9-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:9928fe1eb816d11ae170885a74d074f57af3a0d65777ca47e9aeb854a1fba386", size = 123806, upload-time = "2025-08-10T21:27:01.537Z" }, + { url = "https://files.pythonhosted.org/packages/b2/dd/2bfb1d4a4823d92e8cbb420fe024b8d2167f72079b3bb941207c42570bdf/kiwisolver-1.4.9-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:d0005b053977e7b43388ddec89fa567f43d4f6d5c2c0affe57de5ebf290dc552", size = 66605, upload-time = "2025-08-10T21:27:03.335Z" }, + { url = "https://files.pythonhosted.org/packages/f7/69/00aafdb4e4509c2ca6064646cba9cd4b37933898f426756adb2cb92ebbed/kiwisolver-1.4.9-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:2635d352d67458b66fd0667c14cb1d4145e9560d503219034a18a87e971ce4f3", size = 64925, upload-time = "2025-08-10T21:27:04.339Z" }, + { url = "https://files.pythonhosted.org/packages/43/dc/51acc6791aa14e5cb6d8a2e28cefb0dc2886d8862795449d021334c0df20/kiwisolver-1.4.9-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:767c23ad1c58c9e827b649a9ab7809fd5fd9db266a9cf02b0e926ddc2c680d58", size = 1472414, upload-time = "2025-08-10T21:27:05.437Z" }, + { url = "https://files.pythonhosted.org/packages/3d/bb/93fa64a81db304ac8a246f834d5094fae4b13baf53c839d6bb6e81177129/kiwisolver-1.4.9-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72d0eb9fba308b8311685c2268cf7d0a0639a6cd027d8128659f72bdd8a024b4", size = 1281272, upload-time = "2025-08-10T21:27:07.063Z" }, + { url = "https://files.pythonhosted.org/packages/70/e6/6df102916960fb8d05069d4bd92d6d9a8202d5a3e2444494e7cd50f65b7a/kiwisolver-1.4.9-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f68e4f3eeca8fb22cc3d731f9715a13b652795ef657a13df1ad0c7dc0e9731df", size = 1298578, upload-time = "2025-08-10T21:27:08.452Z" }, + { url = "https://files.pythonhosted.org/packages/7c/47/e142aaa612f5343736b087864dbaebc53ea8831453fb47e7521fa8658f30/kiwisolver-1.4.9-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d84cd4061ae292d8ac367b2c3fa3aad11cb8625a95d135fe93f286f914f3f5a6", size = 1345607, upload-time = "2025-08-10T21:27:10.125Z" }, + { url = "https://files.pythonhosted.org/packages/54/89/d641a746194a0f4d1a3670fb900d0dbaa786fb98341056814bc3f058fa52/kiwisolver-1.4.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a60ea74330b91bd22a29638940d115df9dc00af5035a9a2a6ad9399ffb4ceca5", size = 2230150, upload-time = "2025-08-10T21:27:11.484Z" }, + { url = "https://files.pythonhosted.org/packages/aa/6b/5ee1207198febdf16ac11f78c5ae40861b809cbe0e6d2a8d5b0b3044b199/kiwisolver-1.4.9-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:ce6a3a4e106cf35c2d9c4fa17c05ce0b180db622736845d4315519397a77beaf", size = 2325979, upload-time = "2025-08-10T21:27:12.917Z" }, + { url = "https://files.pythonhosted.org/packages/fc/ff/b269eefd90f4ae14dcc74973d5a0f6d28d3b9bb1afd8c0340513afe6b39a/kiwisolver-1.4.9-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:77937e5e2a38a7b48eef0585114fe7930346993a88060d0bf886086d2aa49ef5", size = 2491456, upload-time = "2025-08-10T21:27:14.353Z" }, + { url = "https://files.pythonhosted.org/packages/fc/d4/10303190bd4d30de547534601e259a4fbf014eed94aae3e5521129215086/kiwisolver-1.4.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:24c175051354f4a28c5d6a31c93906dc653e2bf234e8a4bbfb964892078898ce", size = 2294621, upload-time = "2025-08-10T21:27:15.808Z" }, + { url = "https://files.pythonhosted.org/packages/28/e0/a9a90416fce5c0be25742729c2ea52105d62eda6c4be4d803c2a7be1fa50/kiwisolver-1.4.9-cp314-cp314-win_amd64.whl", hash = "sha256:0763515d4df10edf6d06a3c19734e2566368980d21ebec439f33f9eb936c07b7", size = 75417, upload-time = "2025-08-10T21:27:17.436Z" }, + { url = "https://files.pythonhosted.org/packages/1f/10/6949958215b7a9a264299a7db195564e87900f709db9245e4ebdd3c70779/kiwisolver-1.4.9-cp314-cp314-win_arm64.whl", hash = "sha256:0e4e2bf29574a6a7b7f6cb5fa69293b9f96c928949ac4a53ba3f525dffb87f9c", size = 66582, upload-time = "2025-08-10T21:27:18.436Z" }, + { url = "https://files.pythonhosted.org/packages/ec/79/60e53067903d3bc5469b369fe0dfc6b3482e2133e85dae9daa9527535991/kiwisolver-1.4.9-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:d976bbb382b202f71c67f77b0ac11244021cfa3f7dfd9e562eefcea2df711548", size = 126514, upload-time = "2025-08-10T21:27:19.465Z" }, + { url = "https://files.pythonhosted.org/packages/25/d1/4843d3e8d46b072c12a38c97c57fab4608d36e13fe47d47ee96b4d61ba6f/kiwisolver-1.4.9-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2489e4e5d7ef9a1c300a5e0196e43d9c739f066ef23270607d45aba368b91f2d", size = 67905, upload-time = "2025-08-10T21:27:20.51Z" }, + { url = "https://files.pythonhosted.org/packages/8c/ae/29ffcbd239aea8b93108de1278271ae764dfc0d803a5693914975f200596/kiwisolver-1.4.9-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e2ea9f7ab7fbf18fffb1b5434ce7c69a07582f7acc7717720f1d69f3e806f90c", size = 66399, upload-time = "2025-08-10T21:27:21.496Z" }, + { url = "https://files.pythonhosted.org/packages/a1/ae/d7ba902aa604152c2ceba5d352d7b62106bedbccc8e95c3934d94472bfa3/kiwisolver-1.4.9-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b34e51affded8faee0dfdb705416153819d8ea9250bbbf7ea1b249bdeb5f1122", size = 1582197, upload-time = "2025-08-10T21:27:22.604Z" }, + { url = "https://files.pythonhosted.org/packages/f2/41/27c70d427eddb8bc7e4f16420a20fefc6f480312122a59a959fdfe0445ad/kiwisolver-1.4.9-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8aacd3d4b33b772542b2e01beb50187536967b514b00003bdda7589722d2a64", size = 1390125, upload-time = "2025-08-10T21:27:24.036Z" }, + { url = "https://files.pythonhosted.org/packages/41/42/b3799a12bafc76d962ad69083f8b43b12bf4fe78b097b12e105d75c9b8f1/kiwisolver-1.4.9-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7cf974dd4e35fa315563ac99d6287a1024e4dc2077b8a7d7cd3d2fb65d283134", size = 1402612, upload-time = "2025-08-10T21:27:25.773Z" }, + { url = "https://files.pythonhosted.org/packages/d2/b5/a210ea073ea1cfaca1bb5c55a62307d8252f531beb364e18aa1e0888b5a0/kiwisolver-1.4.9-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:85bd218b5ecfbee8c8a82e121802dcb519a86044c9c3b2e4aef02fa05c6da370", size = 1453990, upload-time = "2025-08-10T21:27:27.089Z" }, + { url = "https://files.pythonhosted.org/packages/5f/ce/a829eb8c033e977d7ea03ed32fb3c1781b4fa0433fbadfff29e39c676f32/kiwisolver-1.4.9-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0856e241c2d3df4efef7c04a1e46b1936b6120c9bcf36dd216e3acd84bc4fb21", size = 2331601, upload-time = "2025-08-10T21:27:29.343Z" }, + { url = "https://files.pythonhosted.org/packages/e0/4b/b5e97eb142eb9cd0072dacfcdcd31b1c66dc7352b0f7c7255d339c0edf00/kiwisolver-1.4.9-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:9af39d6551f97d31a4deebeac6f45b156f9755ddc59c07b402c148f5dbb6482a", size = 2422041, upload-time = "2025-08-10T21:27:30.754Z" }, + { url = "https://files.pythonhosted.org/packages/40/be/8eb4cd53e1b85ba4edc3a9321666f12b83113a178845593307a3e7891f44/kiwisolver-1.4.9-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:bb4ae2b57fc1d8cbd1cf7b1d9913803681ffa903e7488012be5b76dedf49297f", size = 2594897, upload-time = "2025-08-10T21:27:32.803Z" }, + { url = "https://files.pythonhosted.org/packages/99/dd/841e9a66c4715477ea0abc78da039832fbb09dac5c35c58dc4c41a407b8a/kiwisolver-1.4.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:aedff62918805fb62d43a4aa2ecd4482c380dc76cd31bd7c8878588a61bd0369", size = 2391835, upload-time = "2025-08-10T21:27:34.23Z" }, + { url = "https://files.pythonhosted.org/packages/0c/28/4b2e5c47a0da96896fdfdb006340ade064afa1e63675d01ea5ac222b6d52/kiwisolver-1.4.9-cp314-cp314t-win_amd64.whl", hash = "sha256:1fa333e8b2ce4d9660f2cda9c0e1b6bafcfb2457a9d259faa82289e73ec24891", size = 79988, upload-time = "2025-08-10T21:27:35.587Z" }, + { url = "https://files.pythonhosted.org/packages/80/be/3578e8afd18c88cdf9cb4cffde75a96d2be38c5a903f1ed0ceec061bd09e/kiwisolver-1.4.9-cp314-cp314t-win_arm64.whl", hash = "sha256:4a48a2ce79d65d363597ef7b567ce3d14d68783d2b2263d98db3d9477805ba32", size = 70260, upload-time = "2025-08-10T21:27:36.606Z" }, + { url = "https://files.pythonhosted.org/packages/a3/0f/36d89194b5a32c054ce93e586d4049b6c2c22887b0eb229c61c68afd3078/kiwisolver-1.4.9-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:720e05574713db64c356e86732c0f3c5252818d05f9df320f0ad8380641acea5", size = 60104, upload-time = "2025-08-10T21:27:43.287Z" }, + { url = "https://files.pythonhosted.org/packages/52/ba/4ed75f59e4658fd21fe7dde1fee0ac397c678ec3befba3fe6482d987af87/kiwisolver-1.4.9-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:17680d737d5335b552994a2008fab4c851bcd7de33094a82067ef3a576ff02fa", size = 58592, upload-time = "2025-08-10T21:27:44.314Z" }, + { url = "https://files.pythonhosted.org/packages/33/01/a8ea7c5ea32a9b45ceeaee051a04c8ed4320f5add3c51bfa20879b765b70/kiwisolver-1.4.9-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:85b5352f94e490c028926ea567fc569c52ec79ce131dadb968d3853e809518c2", size = 80281, upload-time = "2025-08-10T21:27:45.369Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/dbd2ecdce306f1d07a1aaf324817ee993aab7aee9db47ceac757deabafbe/kiwisolver-1.4.9-pp311-pypy311_pp73-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:464415881e4801295659462c49461a24fb107c140de781d55518c4b80cb6790f", size = 78009, upload-time = "2025-08-10T21:27:46.376Z" }, + { url = "https://files.pythonhosted.org/packages/da/e9/0d4add7873a73e462aeb45c036a2dead2562b825aa46ba326727b3f31016/kiwisolver-1.4.9-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:fb940820c63a9590d31d88b815e7a3aa5915cad3ce735ab45f0c730b39547de1", size = 73929, upload-time = "2025-08-10T21:27:48.236Z" }, +] + +[[package]] +name = "lark" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/da/34/28fff3ab31ccff1fd4f6c7c7b0ceb2b6968d8ea4950663eadcb5720591a0/lark-1.3.1.tar.gz", hash = "sha256:b426a7a6d6d53189d318f2b6236ab5d6429eaf09259f1ca33eb716eed10d2905", size = 382732, upload-time = "2025-10-27T18:25:56.653Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/3d/14ce75ef66813643812f3093ab17e46d3a206942ce7376d31ec2d36229e7/lark-1.3.1-py3-none-any.whl", hash = "sha256:c629b661023a014c37da873b4ff58a817398d12635d3bbb2c5a03be7fe5d1e12", size = 113151, upload-time = "2025-10-27T18:25:54.882Z" }, +] + +[[package]] +name = "latexcodec" +version = "3.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/27/dd/4270b2c5e2ee49316c3859e62293bd2ea8e382339d63ab7bbe9f39c0ec3b/latexcodec-3.0.1.tar.gz", hash = "sha256:e78a6911cd72f9dec35031c6ec23584de6842bfbc4610a9678868d14cdfb0357", size = 31222, upload-time = "2025-06-17T18:47:34.051Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b5/40/23569737873cc9637fd488606347e9dd92b9fa37ba4fcda1f98ee5219a97/latexcodec-3.0.1-py3-none-any.whl", hash = "sha256:a9eb8200bff693f0437a69581f7579eb6bca25c4193515c09900ce76451e452e", size = 18532, upload-time = "2025-06-17T18:47:30.726Z" }, +] + +[[package]] +name = "lxml" +version = "6.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/88/262177de60548e5a2bfc46ad28232c9e9cbde697bd94132aeb80364675cb/lxml-6.0.2.tar.gz", hash = "sha256:cd79f3367bd74b317dda655dc8fcfa304d9eb6e4fb06b7168c5cf27f96e0cd62", size = 4073426, upload-time = "2025-09-22T04:04:59.287Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/d5/becbe1e2569b474a23f0c672ead8a29ac50b2dc1d5b9de184831bda8d14c/lxml-6.0.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:13e35cbc684aadf05d8711a5d1b5857c92e5e580efa9a0d2be197199c8def607", size = 8634365, upload-time = "2025-09-22T04:00:45.672Z" }, + { url = "https://files.pythonhosted.org/packages/28/66/1ced58f12e804644426b85d0bb8a4478ca77bc1761455da310505f1a3526/lxml-6.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3b1675e096e17c6fe9c0e8c81434f5736c0739ff9ac6123c87c2d452f48fc938", size = 4650793, upload-time = "2025-09-22T04:00:47.783Z" }, + { url = "https://files.pythonhosted.org/packages/11/84/549098ffea39dfd167e3f174b4ce983d0eed61f9d8d25b7bf2a57c3247fc/lxml-6.0.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8ac6e5811ae2870953390452e3476694196f98d447573234592d30488147404d", size = 4944362, upload-time = "2025-09-22T04:00:49.845Z" }, + { url = "https://files.pythonhosted.org/packages/ac/bd/f207f16abf9749d2037453d56b643a7471d8fde855a231a12d1e095c4f01/lxml-6.0.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5aa0fc67ae19d7a64c3fe725dc9a1bb11f80e01f78289d05c6f62545affec438", size = 5083152, upload-time = "2025-09-22T04:00:51.709Z" }, + { url = "https://files.pythonhosted.org/packages/15/ae/bd813e87d8941d52ad5b65071b1affb48da01c4ed3c9c99e40abb266fbff/lxml-6.0.2-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:de496365750cc472b4e7902a485d3f152ecf57bd3ba03ddd5578ed8ceb4c5964", size = 5023539, upload-time = "2025-09-22T04:00:53.593Z" }, + { url = "https://files.pythonhosted.org/packages/02/cd/9bfef16bd1d874fbe0cb51afb00329540f30a3283beb9f0780adbb7eec03/lxml-6.0.2-cp311-cp311-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:200069a593c5e40b8f6fc0d84d86d970ba43138c3e68619ffa234bc9bb806a4d", size = 5344853, upload-time = "2025-09-22T04:00:55.524Z" }, + { url = "https://files.pythonhosted.org/packages/b8/89/ea8f91594bc5dbb879734d35a6f2b0ad50605d7fb419de2b63d4211765cc/lxml-6.0.2-cp311-cp311-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7d2de809c2ee3b888b59f995625385f74629707c9355e0ff856445cdcae682b7", size = 5225133, upload-time = "2025-09-22T04:00:57.269Z" }, + { url = "https://files.pythonhosted.org/packages/b9/37/9c735274f5dbec726b2db99b98a43950395ba3d4a1043083dba2ad814170/lxml-6.0.2-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:b2c3da8d93cf5db60e8858c17684c47d01fee6405e554fb55018dd85fc23b178", size = 4677944, upload-time = "2025-09-22T04:00:59.052Z" }, + { url = "https://files.pythonhosted.org/packages/20/28/7dfe1ba3475d8bfca3878365075abe002e05d40dfaaeb7ec01b4c587d533/lxml-6.0.2-cp311-cp311-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:442de7530296ef5e188373a1ea5789a46ce90c4847e597856570439621d9c553", size = 5284535, upload-time = "2025-09-22T04:01:01.335Z" }, + { url = "https://files.pythonhosted.org/packages/e7/cf/5f14bc0de763498fc29510e3532bf2b4b3a1c1d5d0dff2e900c16ba021ef/lxml-6.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2593c77efde7bfea7f6389f1ab249b15ed4aa5bc5cb5131faa3b843c429fbedb", size = 5067343, upload-time = "2025-09-22T04:01:03.13Z" }, + { url = "https://files.pythonhosted.org/packages/1c/b0/bb8275ab5472f32b28cfbbcc6db7c9d092482d3439ca279d8d6fa02f7025/lxml-6.0.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:3e3cb08855967a20f553ff32d147e14329b3ae70ced6edc2f282b94afbc74b2a", size = 4725419, upload-time = "2025-09-22T04:01:05.013Z" }, + { url = "https://files.pythonhosted.org/packages/25/4c/7c222753bc72edca3b99dbadba1b064209bc8ed4ad448af990e60dcce462/lxml-6.0.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:2ed6c667fcbb8c19c6791bbf40b7268ef8ddf5a96940ba9404b9f9a304832f6c", size = 5275008, upload-time = "2025-09-22T04:01:07.327Z" }, + { url = "https://files.pythonhosted.org/packages/6c/8c/478a0dc6b6ed661451379447cdbec77c05741a75736d97e5b2b729687828/lxml-6.0.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b8f18914faec94132e5b91e69d76a5c1d7b0c73e2489ea8929c4aaa10b76bbf7", size = 5248906, upload-time = "2025-09-22T04:01:09.452Z" }, + { url = "https://files.pythonhosted.org/packages/2d/d9/5be3a6ab2784cdf9accb0703b65e1b64fcdd9311c9f007630c7db0cfcce1/lxml-6.0.2-cp311-cp311-win32.whl", hash = "sha256:6605c604e6daa9e0d7f0a2137bdc47a2e93b59c60a65466353e37f8272f47c46", size = 3610357, upload-time = "2025-09-22T04:01:11.102Z" }, + { url = "https://files.pythonhosted.org/packages/e2/7d/ca6fb13349b473d5732fb0ee3eec8f6c80fc0688e76b7d79c1008481bf1f/lxml-6.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:e5867f2651016a3afd8dd2c8238baa66f1e2802f44bc17e236f547ace6647078", size = 4036583, upload-time = "2025-09-22T04:01:12.766Z" }, + { url = "https://files.pythonhosted.org/packages/ab/a2/51363b5ecd3eab46563645f3a2c3836a2fc67d01a1b87c5017040f39f567/lxml-6.0.2-cp311-cp311-win_arm64.whl", hash = "sha256:4197fb2534ee05fd3e7afaab5d8bfd6c2e186f65ea7f9cd6a82809c887bd1285", size = 3680591, upload-time = "2025-09-22T04:01:14.874Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c8/8ff2bc6b920c84355146cd1ab7d181bc543b89241cfb1ebee824a7c81457/lxml-6.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a59f5448ba2ceccd06995c95ea59a7674a10de0810f2ce90c9006f3cbc044456", size = 8661887, upload-time = "2025-09-22T04:01:17.265Z" }, + { url = "https://files.pythonhosted.org/packages/37/6f/9aae1008083bb501ef63284220ce81638332f9ccbfa53765b2b7502203cf/lxml-6.0.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e8113639f3296706fbac34a30813929e29247718e88173ad849f57ca59754924", size = 4667818, upload-time = "2025-09-22T04:01:19.688Z" }, + { url = "https://files.pythonhosted.org/packages/f1/ca/31fb37f99f37f1536c133476674c10b577e409c0a624384147653e38baf2/lxml-6.0.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a8bef9b9825fa8bc816a6e641bb67219489229ebc648be422af695f6e7a4fa7f", size = 4950807, upload-time = "2025-09-22T04:01:21.487Z" }, + { url = "https://files.pythonhosted.org/packages/da/87/f6cb9442e4bada8aab5ae7e1046264f62fdbeaa6e3f6211b93f4c0dd97f1/lxml-6.0.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:65ea18d710fd14e0186c2f973dc60bb52039a275f82d3c44a0e42b43440ea534", size = 5109179, upload-time = "2025-09-22T04:01:23.32Z" }, + { url = "https://files.pythonhosted.org/packages/c8/20/a7760713e65888db79bbae4f6146a6ae5c04e4a204a3c48896c408cd6ed2/lxml-6.0.2-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c371aa98126a0d4c739ca93ceffa0fd7a5d732e3ac66a46e74339acd4d334564", size = 5023044, upload-time = "2025-09-22T04:01:25.118Z" }, + { url = "https://files.pythonhosted.org/packages/a2/b0/7e64e0460fcb36471899f75831509098f3fd7cd02a3833ac517433cb4f8f/lxml-6.0.2-cp312-cp312-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:700efd30c0fa1a3581d80a748157397559396090a51d306ea59a70020223d16f", size = 5359685, upload-time = "2025-09-22T04:01:27.398Z" }, + { url = "https://files.pythonhosted.org/packages/b9/e1/e5df362e9ca4e2f48ed6411bd4b3a0ae737cc842e96877f5bf9428055ab4/lxml-6.0.2-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c33e66d44fe60e72397b487ee92e01da0d09ba2d66df8eae42d77b6d06e5eba0", size = 5654127, upload-time = "2025-09-22T04:01:29.629Z" }, + { url = "https://files.pythonhosted.org/packages/c6/d1/232b3309a02d60f11e71857778bfcd4acbdb86c07db8260caf7d008b08f8/lxml-6.0.2-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90a345bbeaf9d0587a3aaffb7006aa39ccb6ff0e96a57286c0cb2fd1520ea192", size = 5253958, upload-time = "2025-09-22T04:01:31.535Z" }, + { url = "https://files.pythonhosted.org/packages/35/35/d955a070994725c4f7d80583a96cab9c107c57a125b20bb5f708fe941011/lxml-6.0.2-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:064fdadaf7a21af3ed1dcaa106b854077fbeada827c18f72aec9346847cd65d0", size = 4711541, upload-time = "2025-09-22T04:01:33.801Z" }, + { url = "https://files.pythonhosted.org/packages/1e/be/667d17363b38a78c4bd63cfd4b4632029fd68d2c2dc81f25ce9eb5224dd5/lxml-6.0.2-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fbc74f42c3525ac4ffa4b89cbdd00057b6196bcefe8bce794abd42d33a018092", size = 5267426, upload-time = "2025-09-22T04:01:35.639Z" }, + { url = "https://files.pythonhosted.org/packages/ea/47/62c70aa4a1c26569bc958c9ca86af2bb4e1f614e8c04fb2989833874f7ae/lxml-6.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6ddff43f702905a4e32bc24f3f2e2edfe0f8fde3277d481bffb709a4cced7a1f", size = 5064917, upload-time = "2025-09-22T04:01:37.448Z" }, + { url = "https://files.pythonhosted.org/packages/bd/55/6ceddaca353ebd0f1908ef712c597f8570cc9c58130dbb89903198e441fd/lxml-6.0.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:6da5185951d72e6f5352166e3da7b0dc27aa70bd1090b0eb3f7f7212b53f1bb8", size = 4788795, upload-time = "2025-09-22T04:01:39.165Z" }, + { url = "https://files.pythonhosted.org/packages/cf/e8/fd63e15da5e3fd4c2146f8bbb3c14e94ab850589beab88e547b2dbce22e1/lxml-6.0.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:57a86e1ebb4020a38d295c04fc79603c7899e0df71588043eb218722dabc087f", size = 5676759, upload-time = "2025-09-22T04:01:41.506Z" }, + { url = "https://files.pythonhosted.org/packages/76/47/b3ec58dc5c374697f5ba37412cd2728f427d056315d124dd4b61da381877/lxml-6.0.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:2047d8234fe735ab77802ce5f2297e410ff40f5238aec569ad7c8e163d7b19a6", size = 5255666, upload-time = "2025-09-22T04:01:43.363Z" }, + { url = "https://files.pythonhosted.org/packages/19/93/03ba725df4c3d72afd9596eef4a37a837ce8e4806010569bedfcd2cb68fd/lxml-6.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6f91fd2b2ea15a6800c8e24418c0775a1694eefc011392da73bc6cef2623b322", size = 5277989, upload-time = "2025-09-22T04:01:45.215Z" }, + { url = "https://files.pythonhosted.org/packages/c6/80/c06de80bfce881d0ad738576f243911fccf992687ae09fd80b734712b39c/lxml-6.0.2-cp312-cp312-win32.whl", hash = "sha256:3ae2ce7d6fedfb3414a2b6c5e20b249c4c607f72cb8d2bb7cc9c6ec7c6f4e849", size = 3611456, upload-time = "2025-09-22T04:01:48.243Z" }, + { url = "https://files.pythonhosted.org/packages/f7/d7/0cdfb6c3e30893463fb3d1e52bc5f5f99684a03c29a0b6b605cfae879cd5/lxml-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:72c87e5ee4e58a8354fb9c7c84cbf95a1c8236c127a5d1b7683f04bed8361e1f", size = 4011793, upload-time = "2025-09-22T04:01:50.042Z" }, + { url = "https://files.pythonhosted.org/packages/ea/7b/93c73c67db235931527301ed3785f849c78991e2e34f3fd9a6663ffda4c5/lxml-6.0.2-cp312-cp312-win_arm64.whl", hash = "sha256:61cb10eeb95570153e0c0e554f58df92ecf5109f75eacad4a95baa709e26c3d6", size = 3672836, upload-time = "2025-09-22T04:01:52.145Z" }, + { url = "https://files.pythonhosted.org/packages/53/fd/4e8f0540608977aea078bf6d79f128e0e2c2bba8af1acf775c30baa70460/lxml-6.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:9b33d21594afab46f37ae58dfadd06636f154923c4e8a4d754b0127554eb2e77", size = 8648494, upload-time = "2025-09-22T04:01:54.242Z" }, + { url = "https://files.pythonhosted.org/packages/5d/f4/2a94a3d3dfd6c6b433501b8d470a1960a20ecce93245cf2db1706adf6c19/lxml-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6c8963287d7a4c5c9a432ff487c52e9c5618667179c18a204bdedb27310f022f", size = 4661146, upload-time = "2025-09-22T04:01:56.282Z" }, + { url = "https://files.pythonhosted.org/packages/25/2e/4efa677fa6b322013035d38016f6ae859d06cac67437ca7dc708a6af7028/lxml-6.0.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1941354d92699fb5ffe6ed7b32f9649e43c2feb4b97205f75866f7d21aa91452", size = 4946932, upload-time = "2025-09-22T04:01:58.989Z" }, + { url = "https://files.pythonhosted.org/packages/ce/0f/526e78a6d38d109fdbaa5049c62e1d32fdd70c75fb61c4eadf3045d3d124/lxml-6.0.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bb2f6ca0ae2d983ded09357b84af659c954722bbf04dea98030064996d156048", size = 5100060, upload-time = "2025-09-22T04:02:00.812Z" }, + { url = "https://files.pythonhosted.org/packages/81/76/99de58d81fa702cc0ea7edae4f4640416c2062813a00ff24bd70ac1d9c9b/lxml-6.0.2-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb2a12d704f180a902d7fa778c6d71f36ceb7b0d317f34cdc76a5d05aa1dd1df", size = 5019000, upload-time = "2025-09-22T04:02:02.671Z" }, + { url = "https://files.pythonhosted.org/packages/b5/35/9e57d25482bc9a9882cb0037fdb9cc18f4b79d85df94fa9d2a89562f1d25/lxml-6.0.2-cp313-cp313-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:6ec0e3f745021bfed19c456647f0298d60a24c9ff86d9d051f52b509663feeb1", size = 5348496, upload-time = "2025-09-22T04:02:04.904Z" }, + { url = "https://files.pythonhosted.org/packages/a6/8e/cb99bd0b83ccc3e8f0f528e9aa1f7a9965dfec08c617070c5db8d63a87ce/lxml-6.0.2-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:846ae9a12d54e368933b9759052d6206a9e8b250291109c48e350c1f1f49d916", size = 5643779, upload-time = "2025-09-22T04:02:06.689Z" }, + { url = "https://files.pythonhosted.org/packages/d0/34/9e591954939276bb679b73773836c6684c22e56d05980e31d52a9a8deb18/lxml-6.0.2-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ef9266d2aa545d7374938fb5c484531ef5a2ec7f2d573e62f8ce722c735685fd", size = 5244072, upload-time = "2025-09-22T04:02:08.587Z" }, + { url = "https://files.pythonhosted.org/packages/8d/27/b29ff065f9aaca443ee377aff699714fcbffb371b4fce5ac4ca759e436d5/lxml-6.0.2-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:4077b7c79f31755df33b795dc12119cb557a0106bfdab0d2c2d97bd3cf3dffa6", size = 4718675, upload-time = "2025-09-22T04:02:10.783Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9f/f756f9c2cd27caa1a6ef8c32ae47aadea697f5c2c6d07b0dae133c244fbe/lxml-6.0.2-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a7c5d5e5f1081955358533be077166ee97ed2571d6a66bdba6ec2f609a715d1a", size = 5255171, upload-time = "2025-09-22T04:02:12.631Z" }, + { url = "https://files.pythonhosted.org/packages/61/46/bb85ea42d2cb1bd8395484fd72f38e3389611aa496ac7772da9205bbda0e/lxml-6.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8f8d0cbd0674ee89863a523e6994ac25fd5be9c8486acfc3e5ccea679bad2679", size = 5057175, upload-time = "2025-09-22T04:02:14.718Z" }, + { url = "https://files.pythonhosted.org/packages/95/0c/443fc476dcc8e41577f0af70458c50fe299a97bb6b7505bb1ae09aa7f9ac/lxml-6.0.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:2cbcbf6d6e924c28f04a43f3b6f6e272312a090f269eff68a2982e13e5d57659", size = 4785688, upload-time = "2025-09-22T04:02:16.957Z" }, + { url = "https://files.pythonhosted.org/packages/48/78/6ef0b359d45bb9697bc5a626e1992fa5d27aa3f8004b137b2314793b50a0/lxml-6.0.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:dfb874cfa53340009af6bdd7e54ebc0d21012a60a4e65d927c2e477112e63484", size = 5660655, upload-time = "2025-09-22T04:02:18.815Z" }, + { url = "https://files.pythonhosted.org/packages/ff/ea/e1d33808f386bc1339d08c0dcada6e4712d4ed8e93fcad5f057070b7988a/lxml-6.0.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:fb8dae0b6b8b7f9e96c26fdd8121522ce5de9bb5538010870bd538683d30e9a2", size = 5247695, upload-time = "2025-09-22T04:02:20.593Z" }, + { url = "https://files.pythonhosted.org/packages/4f/47/eba75dfd8183673725255247a603b4ad606f4ae657b60c6c145b381697da/lxml-6.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:358d9adae670b63e95bc59747c72f4dc97c9ec58881d4627fe0120da0f90d314", size = 5269841, upload-time = "2025-09-22T04:02:22.489Z" }, + { url = "https://files.pythonhosted.org/packages/76/04/5c5e2b8577bc936e219becb2e98cdb1aca14a4921a12995b9d0c523502ae/lxml-6.0.2-cp313-cp313-win32.whl", hash = "sha256:e8cd2415f372e7e5a789d743d133ae474290a90b9023197fd78f32e2dc6873e2", size = 3610700, upload-time = "2025-09-22T04:02:24.465Z" }, + { url = "https://files.pythonhosted.org/packages/fe/0a/4643ccc6bb8b143e9f9640aa54e38255f9d3b45feb2cbe7ae2ca47e8782e/lxml-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:b30d46379644fbfc3ab81f8f82ae4de55179414651f110a1514f0b1f8f6cb2d7", size = 4010347, upload-time = "2025-09-22T04:02:26.286Z" }, + { url = "https://files.pythonhosted.org/packages/31/ef/dcf1d29c3f530577f61e5fe2f1bd72929acf779953668a8a47a479ae6f26/lxml-6.0.2-cp313-cp313-win_arm64.whl", hash = "sha256:13dcecc9946dca97b11b7c40d29fba63b55ab4170d3c0cf8c0c164343b9bfdcf", size = 3671248, upload-time = "2025-09-22T04:02:27.918Z" }, + { url = "https://files.pythonhosted.org/packages/03/15/d4a377b385ab693ce97b472fe0c77c2b16ec79590e688b3ccc71fba19884/lxml-6.0.2-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:b0c732aa23de8f8aec23f4b580d1e52905ef468afb4abeafd3fec77042abb6fe", size = 8659801, upload-time = "2025-09-22T04:02:30.113Z" }, + { url = "https://files.pythonhosted.org/packages/c8/e8/c128e37589463668794d503afaeb003987373c5f94d667124ffd8078bbd9/lxml-6.0.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4468e3b83e10e0317a89a33d28f7aeba1caa4d1a6fd457d115dd4ffe90c5931d", size = 4659403, upload-time = "2025-09-22T04:02:32.119Z" }, + { url = "https://files.pythonhosted.org/packages/00/ce/74903904339decdf7da7847bb5741fc98a5451b42fc419a86c0c13d26fe2/lxml-6.0.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:abd44571493973bad4598a3be7e1d807ed45aa2adaf7ab92ab7c62609569b17d", size = 4966974, upload-time = "2025-09-22T04:02:34.155Z" }, + { url = "https://files.pythonhosted.org/packages/1f/d3/131dec79ce61c5567fecf82515bd9bc36395df42501b50f7f7f3bd065df0/lxml-6.0.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:370cd78d5855cfbffd57c422851f7d3864e6ae72d0da615fca4dad8c45d375a5", size = 5102953, upload-time = "2025-09-22T04:02:36.054Z" }, + { url = "https://files.pythonhosted.org/packages/3a/ea/a43ba9bb750d4ffdd885f2cd333572f5bb900cd2408b67fdda07e85978a0/lxml-6.0.2-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:901e3b4219fa04ef766885fb40fa516a71662a4c61b80c94d25336b4934b71c0", size = 5055054, upload-time = "2025-09-22T04:02:38.154Z" }, + { url = "https://files.pythonhosted.org/packages/60/23/6885b451636ae286c34628f70a7ed1fcc759f8d9ad382d132e1c8d3d9bfd/lxml-6.0.2-cp314-cp314-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:a4bf42d2e4cf52c28cc1812d62426b9503cdb0c87a6de81442626aa7d69707ba", size = 5352421, upload-time = "2025-09-22T04:02:40.413Z" }, + { url = "https://files.pythonhosted.org/packages/48/5b/fc2ddfc94ddbe3eebb8e9af6e3fd65e2feba4967f6a4e9683875c394c2d8/lxml-6.0.2-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b2c7fdaa4d7c3d886a42534adec7cfac73860b89b4e5298752f60aa5984641a0", size = 5673684, upload-time = "2025-09-22T04:02:42.288Z" }, + { url = "https://files.pythonhosted.org/packages/29/9c/47293c58cc91769130fbf85531280e8cc7868f7fbb6d92f4670071b9cb3e/lxml-6.0.2-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:98a5e1660dc7de2200b00d53fa00bcd3c35a3608c305d45a7bbcaf29fa16e83d", size = 5252463, upload-time = "2025-09-22T04:02:44.165Z" }, + { url = "https://files.pythonhosted.org/packages/9b/da/ba6eceb830c762b48e711ded880d7e3e89fc6c7323e587c36540b6b23c6b/lxml-6.0.2-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:dc051506c30b609238d79eda75ee9cab3e520570ec8219844a72a46020901e37", size = 4698437, upload-time = "2025-09-22T04:02:46.524Z" }, + { url = "https://files.pythonhosted.org/packages/a5/24/7be3f82cb7990b89118d944b619e53c656c97dc89c28cfb143fdb7cd6f4d/lxml-6.0.2-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8799481bbdd212470d17513a54d568f44416db01250f49449647b5ab5b5dccb9", size = 5269890, upload-time = "2025-09-22T04:02:48.812Z" }, + { url = "https://files.pythonhosted.org/packages/1b/bd/dcfb9ea1e16c665efd7538fc5d5c34071276ce9220e234217682e7d2c4a5/lxml-6.0.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9261bb77c2dab42f3ecd9103951aeca2c40277701eb7e912c545c1b16e0e4917", size = 5097185, upload-time = "2025-09-22T04:02:50.746Z" }, + { url = "https://files.pythonhosted.org/packages/21/04/a60b0ff9314736316f28316b694bccbbabe100f8483ad83852d77fc7468e/lxml-6.0.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:65ac4a01aba353cfa6d5725b95d7aed6356ddc0a3cd734de00124d285b04b64f", size = 4745895, upload-time = "2025-09-22T04:02:52.968Z" }, + { url = "https://files.pythonhosted.org/packages/d6/bd/7d54bd1846e5a310d9c715921c5faa71cf5c0853372adf78aee70c8d7aa2/lxml-6.0.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:b22a07cbb82fea98f8a2fd814f3d1811ff9ed76d0fc6abc84eb21527596e7cc8", size = 5695246, upload-time = "2025-09-22T04:02:54.798Z" }, + { url = "https://files.pythonhosted.org/packages/fd/32/5643d6ab947bc371da21323acb2a6e603cedbe71cb4c99c8254289ab6f4e/lxml-6.0.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:d759cdd7f3e055d6bc8d9bec3ad905227b2e4c785dc16c372eb5b5e83123f48a", size = 5260797, upload-time = "2025-09-22T04:02:57.058Z" }, + { url = "https://files.pythonhosted.org/packages/33/da/34c1ec4cff1eea7d0b4cd44af8411806ed943141804ac9c5d565302afb78/lxml-6.0.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:945da35a48d193d27c188037a05fec5492937f66fb1958c24fc761fb9d40d43c", size = 5277404, upload-time = "2025-09-22T04:02:58.966Z" }, + { url = "https://files.pythonhosted.org/packages/82/57/4eca3e31e54dc89e2c3507e1cd411074a17565fa5ffc437c4ae0a00d439e/lxml-6.0.2-cp314-cp314-win32.whl", hash = "sha256:be3aaa60da67e6153eb15715cc2e19091af5dc75faef8b8a585aea372507384b", size = 3670072, upload-time = "2025-09-22T04:03:38.05Z" }, + { url = "https://files.pythonhosted.org/packages/e3/e0/c96cf13eccd20c9421ba910304dae0f619724dcf1702864fd59dd386404d/lxml-6.0.2-cp314-cp314-win_amd64.whl", hash = "sha256:fa25afbadead523f7001caf0c2382afd272c315a033a7b06336da2637d92d6ed", size = 4080617, upload-time = "2025-09-22T04:03:39.835Z" }, + { url = "https://files.pythonhosted.org/packages/d5/5d/b3f03e22b3d38d6f188ef044900a9b29b2fe0aebb94625ce9fe244011d34/lxml-6.0.2-cp314-cp314-win_arm64.whl", hash = "sha256:063eccf89df5b24e361b123e257e437f9e9878f425ee9aae3144c77faf6da6d8", size = 3754930, upload-time = "2025-09-22T04:03:41.565Z" }, + { url = "https://files.pythonhosted.org/packages/5e/5c/42c2c4c03554580708fc738d13414801f340c04c3eff90d8d2d227145275/lxml-6.0.2-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:6162a86d86893d63084faaf4ff937b3daea233e3682fb4474db07395794fa80d", size = 8910380, upload-time = "2025-09-22T04:03:01.645Z" }, + { url = "https://files.pythonhosted.org/packages/bf/4f/12df843e3e10d18d468a7557058f8d3733e8b6e12401f30b1ef29360740f/lxml-6.0.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:414aaa94e974e23a3e92e7ca5b97d10c0cf37b6481f50911032c69eeb3991bba", size = 4775632, upload-time = "2025-09-22T04:03:03.814Z" }, + { url = "https://files.pythonhosted.org/packages/e4/0c/9dc31e6c2d0d418483cbcb469d1f5a582a1cd00a1f4081953d44051f3c50/lxml-6.0.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48461bd21625458dd01e14e2c38dd0aea69addc3c4f960c30d9f59d7f93be601", size = 4975171, upload-time = "2025-09-22T04:03:05.651Z" }, + { url = "https://files.pythonhosted.org/packages/e7/2b/9b870c6ca24c841bdd887504808f0417aa9d8d564114689266f19ddf29c8/lxml-6.0.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:25fcc59afc57d527cfc78a58f40ab4c9b8fd096a9a3f964d2781ffb6eb33f4ed", size = 5110109, upload-time = "2025-09-22T04:03:07.452Z" }, + { url = "https://files.pythonhosted.org/packages/bf/0c/4f5f2a4dd319a178912751564471355d9019e220c20d7db3fb8307ed8582/lxml-6.0.2-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5179c60288204e6ddde3f774a93350177e08876eaf3ab78aa3a3649d43eb7d37", size = 5041061, upload-time = "2025-09-22T04:03:09.297Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/554eed290365267671fe001a20d72d14f468ae4e6acef1e179b039436967/lxml-6.0.2-cp314-cp314t-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:967aab75434de148ec80597b75062d8123cadf2943fb4281f385141e18b21338", size = 5306233, upload-time = "2025-09-22T04:03:11.651Z" }, + { url = "https://files.pythonhosted.org/packages/7a/31/1d748aa275e71802ad9722df32a7a35034246b42c0ecdd8235412c3396ef/lxml-6.0.2-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d100fcc8930d697c6561156c6810ab4a508fb264c8b6779e6e61e2ed5e7558f9", size = 5604739, upload-time = "2025-09-22T04:03:13.592Z" }, + { url = "https://files.pythonhosted.org/packages/8f/41/2c11916bcac09ed561adccacceaedd2bf0e0b25b297ea92aab99fd03d0fa/lxml-6.0.2-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2ca59e7e13e5981175b8b3e4ab84d7da57993eeff53c07764dcebda0d0e64ecd", size = 5225119, upload-time = "2025-09-22T04:03:15.408Z" }, + { url = "https://files.pythonhosted.org/packages/99/05/4e5c2873d8f17aa018e6afde417c80cc5d0c33be4854cce3ef5670c49367/lxml-6.0.2-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:957448ac63a42e2e49531b9d6c0fa449a1970dbc32467aaad46f11545be9af1d", size = 4633665, upload-time = "2025-09-22T04:03:17.262Z" }, + { url = "https://files.pythonhosted.org/packages/0f/c9/dcc2da1bebd6275cdc723b515f93edf548b82f36a5458cca3578bc899332/lxml-6.0.2-cp314-cp314t-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b7fc49c37f1786284b12af63152fe1d0990722497e2d5817acfe7a877522f9a9", size = 5234997, upload-time = "2025-09-22T04:03:19.14Z" }, + { url = "https://files.pythonhosted.org/packages/9c/e2/5172e4e7468afca64a37b81dba152fc5d90e30f9c83c7c3213d6a02a5ce4/lxml-6.0.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e19e0643cc936a22e837f79d01a550678da8377d7d801a14487c10c34ee49c7e", size = 5090957, upload-time = "2025-09-22T04:03:21.436Z" }, + { url = "https://files.pythonhosted.org/packages/a5/b3/15461fd3e5cd4ddcb7938b87fc20b14ab113b92312fc97afe65cd7c85de1/lxml-6.0.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:1db01e5cf14345628e0cbe71067204db658e2fb8e51e7f33631f5f4735fefd8d", size = 4764372, upload-time = "2025-09-22T04:03:23.27Z" }, + { url = "https://files.pythonhosted.org/packages/05/33/f310b987c8bf9e61c4dd8e8035c416bd3230098f5e3cfa69fc4232de7059/lxml-6.0.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:875c6b5ab39ad5291588aed6925fac99d0097af0dd62f33c7b43736043d4a2ec", size = 5634653, upload-time = "2025-09-22T04:03:25.767Z" }, + { url = "https://files.pythonhosted.org/packages/70/ff/51c80e75e0bc9382158133bdcf4e339b5886c6ee2418b5199b3f1a61ed6d/lxml-6.0.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:cdcbed9ad19da81c480dfd6dd161886db6096083c9938ead313d94b30aadf272", size = 5233795, upload-time = "2025-09-22T04:03:27.62Z" }, + { url = "https://files.pythonhosted.org/packages/56/4d/4856e897df0d588789dd844dbed9d91782c4ef0b327f96ce53c807e13128/lxml-6.0.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:80dadc234ebc532e09be1975ff538d154a7fa61ea5031c03d25178855544728f", size = 5257023, upload-time = "2025-09-22T04:03:30.056Z" }, + { url = "https://files.pythonhosted.org/packages/0f/85/86766dfebfa87bea0ab78e9ff7a4b4b45225df4b4d3b8cc3c03c5cd68464/lxml-6.0.2-cp314-cp314t-win32.whl", hash = "sha256:da08e7bb297b04e893d91087df19638dc7a6bb858a954b0cc2b9f5053c922312", size = 3911420, upload-time = "2025-09-22T04:03:32.198Z" }, + { url = "https://files.pythonhosted.org/packages/fe/1a/b248b355834c8e32614650b8008c69ffeb0ceb149c793961dd8c0b991bb3/lxml-6.0.2-cp314-cp314t-win_amd64.whl", hash = "sha256:252a22982dca42f6155125ac76d3432e548a7625d56f5a273ee78a5057216eca", size = 4406837, upload-time = "2025-09-22T04:03:34.027Z" }, + { url = "https://files.pythonhosted.org/packages/92/aa/df863bcc39c5e0946263454aba394de8a9084dbaff8ad143846b0d844739/lxml-6.0.2-cp314-cp314t-win_arm64.whl", hash = "sha256:bb4c1847b303835d89d785a18801a883436cdfd5dc3d62947f9c49e24f0f5a2c", size = 3822205, upload-time = "2025-09-22T04:03:36.249Z" }, + { url = "https://files.pythonhosted.org/packages/0b/11/29d08bc103a62c0eba8016e7ed5aeebbf1e4312e83b0b1648dd203b0e87d/lxml-6.0.2-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:1c06035eafa8404b5cf475bb37a9f6088b0aca288d4ccc9d69389750d5543700", size = 3949829, upload-time = "2025-09-22T04:04:45.608Z" }, + { url = "https://files.pythonhosted.org/packages/12/b3/52ab9a3b31e5ab8238da241baa19eec44d2ab426532441ee607165aebb52/lxml-6.0.2-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c7d13103045de1bdd6fe5d61802565f1a3537d70cd3abf596aa0af62761921ee", size = 4226277, upload-time = "2025-09-22T04:04:47.754Z" }, + { url = "https://files.pythonhosted.org/packages/a0/33/1eaf780c1baad88224611df13b1c2a9dfa460b526cacfe769103ff50d845/lxml-6.0.2-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0a3c150a95fbe5ac91de323aa756219ef9cf7fde5a3f00e2281e30f33fa5fa4f", size = 4330433, upload-time = "2025-09-22T04:04:49.907Z" }, + { url = "https://files.pythonhosted.org/packages/7a/c1/27428a2ff348e994ab4f8777d3a0ad510b6b92d37718e5887d2da99952a2/lxml-6.0.2-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:60fa43be34f78bebb27812ed90f1925ec99560b0fa1decdb7d12b84d857d31e9", size = 4272119, upload-time = "2025-09-22T04:04:51.801Z" }, + { url = "https://files.pythonhosted.org/packages/f0/d0/3020fa12bcec4ab62f97aab026d57c2f0cfd480a558758d9ca233bb6a79d/lxml-6.0.2-pp311-pypy311_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:21c73b476d3cfe836be731225ec3421fa2f048d84f6df6a8e70433dff1376d5a", size = 4417314, upload-time = "2025-09-22T04:04:55.024Z" }, + { url = "https://files.pythonhosted.org/packages/6c/77/d7f491cbc05303ac6801651aabeb262d43f319288c1ea96c66b1d2692ff3/lxml-6.0.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:27220da5be049e936c3aca06f174e8827ca6445a4353a1995584311487fc4e3e", size = 3518768, upload-time = "2025-09-22T04:04:57.097Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, +] + +[[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/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" }, + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, +] + +[[package]] +name = "matplotlib" +version = "3.10.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "contourpy" }, + { name = "cycler" }, + { name = "fonttools" }, + { name = "kiwisolver" }, + { name = "numpy" }, + { name = "packaging" }, + { name = "pillow" }, + { name = "pyparsing" }, + { name = "python-dateutil" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ae/e2/d2d5295be2f44c678ebaf3544ba32d20c1f9ef08c49fe47f496180e1db15/matplotlib-3.10.7.tar.gz", hash = "sha256:a06ba7e2a2ef9131c79c49e63dad355d2d878413a0376c1727c8b9335ff731c7", size = 34804865, upload-time = "2025-10-09T00:28:00.669Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/bc/0fb489005669127ec13f51be0c6adc074d7cf191075dab1da9fe3b7a3cfc/matplotlib-3.10.7-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:53b492410a6cd66c7a471de6c924f6ede976e963c0f3097a3b7abfadddc67d0a", size = 8257507, upload-time = "2025-10-09T00:26:19.073Z" }, + { url = "https://files.pythonhosted.org/packages/e2/6a/d42588ad895279ff6708924645b5d2ed54a7fb2dc045c8a804e955aeace1/matplotlib-3.10.7-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d9749313deb729f08207718d29c86246beb2ea3fdba753595b55901dee5d2fd6", size = 8119565, upload-time = "2025-10-09T00:26:21.023Z" }, + { url = "https://files.pythonhosted.org/packages/10/b7/4aa196155b4d846bd749cf82aa5a4c300cf55a8b5e0dfa5b722a63c0f8a0/matplotlib-3.10.7-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2222c7ba2cbde7fe63032769f6eb7e83ab3227f47d997a8453377709b7fe3a5a", size = 8692668, upload-time = "2025-10-09T00:26:22.967Z" }, + { url = "https://files.pythonhosted.org/packages/e6/e7/664d2b97016f46683a02d854d730cfcf54ff92c1dafa424beebef50f831d/matplotlib-3.10.7-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e91f61a064c92c307c5a9dc8c05dc9f8a68f0a3be199d9a002a0622e13f874a1", size = 9521051, upload-time = "2025-10-09T00:26:25.041Z" }, + { url = "https://files.pythonhosted.org/packages/a8/a3/37aef1404efa615f49b5758a5e0261c16dd88f389bc1861e722620e4a754/matplotlib-3.10.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6f1851eab59ca082c95df5a500106bad73672645625e04538b3ad0f69471ffcc", size = 9576878, upload-time = "2025-10-09T00:26:27.478Z" }, + { url = "https://files.pythonhosted.org/packages/33/cd/b145f9797126f3f809d177ca378de57c45413c5099c5990de2658760594a/matplotlib-3.10.7-cp311-cp311-win_amd64.whl", hash = "sha256:6516ce375109c60ceec579e699524e9d504cd7578506f01150f7a6bc174a775e", size = 8115142, upload-time = "2025-10-09T00:26:29.774Z" }, + { url = "https://files.pythonhosted.org/packages/2e/39/63bca9d2b78455ed497fcf51a9c71df200a11048f48249038f06447fa947/matplotlib-3.10.7-cp311-cp311-win_arm64.whl", hash = "sha256:b172db79759f5f9bc13ef1c3ef8b9ee7b37b0247f987fbbbdaa15e4f87fd46a9", size = 7992439, upload-time = "2025-10-09T00:26:40.32Z" }, + { url = "https://files.pythonhosted.org/packages/be/b3/09eb0f7796932826ec20c25b517d568627754f6c6462fca19e12c02f2e12/matplotlib-3.10.7-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7a0edb7209e21840e8361e91ea84ea676658aa93edd5f8762793dec77a4a6748", size = 8272389, upload-time = "2025-10-09T00:26:42.474Z" }, + { url = "https://files.pythonhosted.org/packages/11/0b/1ae80ddafb8652fd8046cb5c8460ecc8d4afccb89e2c6d6bec61e04e1eaf/matplotlib-3.10.7-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c380371d3c23e0eadf8ebff114445b9f970aff2010198d498d4ab4c3b41eea4f", size = 8128247, upload-time = "2025-10-09T00:26:44.77Z" }, + { url = "https://files.pythonhosted.org/packages/7d/18/95ae2e242d4a5c98bd6e90e36e128d71cf1c7e39b0874feaed3ef782e789/matplotlib-3.10.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d5f256d49fea31f40f166a5e3131235a5d2f4b7f44520b1cf0baf1ce568ccff0", size = 8696996, upload-time = "2025-10-09T00:26:46.792Z" }, + { url = "https://files.pythonhosted.org/packages/7e/3d/5b559efc800bd05cb2033aa85f7e13af51958136a48327f7c261801ff90a/matplotlib-3.10.7-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:11ae579ac83cdf3fb72573bb89f70e0534de05266728740d478f0f818983c695", size = 9530153, upload-time = "2025-10-09T00:26:49.07Z" }, + { url = "https://files.pythonhosted.org/packages/88/57/eab4a719fd110312d3c220595d63a3c85ec2a39723f0f4e7fa7e6e3f74ba/matplotlib-3.10.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4c14b6acd16cddc3569a2d515cfdd81c7a68ac5639b76548cfc1a9e48b20eb65", size = 9593093, upload-time = "2025-10-09T00:26:51.067Z" }, + { url = "https://files.pythonhosted.org/packages/31/3c/80816f027b3a4a28cd2a0a6ef7f89a2db22310e945cd886ec25bfb399221/matplotlib-3.10.7-cp312-cp312-win_amd64.whl", hash = "sha256:0d8c32b7ea6fb80b1aeff5a2ceb3fb9778e2759e899d9beff75584714afcc5ee", size = 8122771, upload-time = "2025-10-09T00:26:53.296Z" }, + { url = "https://files.pythonhosted.org/packages/de/77/ef1fc78bfe99999b2675435cc52120887191c566b25017d78beaabef7f2d/matplotlib-3.10.7-cp312-cp312-win_arm64.whl", hash = "sha256:5f3f6d315dcc176ba7ca6e74c7768fb7e4cf566c49cb143f6bc257b62e634ed8", size = 7992812, upload-time = "2025-10-09T00:26:54.882Z" }, + { url = "https://files.pythonhosted.org/packages/02/9c/207547916a02c78f6bdd83448d9b21afbc42f6379ed887ecf610984f3b4e/matplotlib-3.10.7-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1d9d3713a237970569156cfb4de7533b7c4eacdd61789726f444f96a0d28f57f", size = 8273212, upload-time = "2025-10-09T00:26:56.752Z" }, + { url = "https://files.pythonhosted.org/packages/bc/d0/b3d3338d467d3fc937f0bb7f256711395cae6f78e22cef0656159950adf0/matplotlib-3.10.7-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:37a1fea41153dd6ee061d21ab69c9cf2cf543160b1b85d89cd3d2e2a7902ca4c", size = 8128713, upload-time = "2025-10-09T00:26:59.001Z" }, + { url = "https://files.pythonhosted.org/packages/22/ff/6425bf5c20d79aa5b959d1ce9e65f599632345391381c9a104133fe0b171/matplotlib-3.10.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b3c4ea4948d93c9c29dc01c0c23eef66f2101bf75158c291b88de6525c55c3d1", size = 8698527, upload-time = "2025-10-09T00:27:00.69Z" }, + { url = "https://files.pythonhosted.org/packages/d0/7f/ccdca06f4c2e6c7989270ed7829b8679466682f4cfc0f8c9986241c023b6/matplotlib-3.10.7-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:22df30ffaa89f6643206cf13877191c63a50e8f800b038bc39bee9d2d4957632", size = 9529690, upload-time = "2025-10-09T00:27:02.664Z" }, + { url = "https://files.pythonhosted.org/packages/b8/95/b80fc2c1f269f21ff3d193ca697358e24408c33ce2b106a7438a45407b63/matplotlib-3.10.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b69676845a0a66f9da30e87f48be36734d6748024b525ec4710be40194282c84", size = 9593732, upload-time = "2025-10-09T00:27:04.653Z" }, + { url = "https://files.pythonhosted.org/packages/e1/b6/23064a96308b9aeceeffa65e96bcde459a2ea4934d311dee20afde7407a0/matplotlib-3.10.7-cp313-cp313-win_amd64.whl", hash = "sha256:744991e0cc863dd669c8dc9136ca4e6e0082be2070b9d793cbd64bec872a6815", size = 8122727, upload-time = "2025-10-09T00:27:06.814Z" }, + { url = "https://files.pythonhosted.org/packages/b3/a6/2faaf48133b82cf3607759027f82b5c702aa99cdfcefb7f93d6ccf26a424/matplotlib-3.10.7-cp313-cp313-win_arm64.whl", hash = "sha256:fba2974df0bf8ce3c995fa84b79cde38326e0f7b5409e7a3a481c1141340bcf7", size = 7992958, upload-time = "2025-10-09T00:27:08.567Z" }, + { url = "https://files.pythonhosted.org/packages/4a/f0/b018fed0b599bd48d84c08794cb242227fe3341952da102ee9d9682db574/matplotlib-3.10.7-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:932c55d1fa7af4423422cb6a492a31cbcbdbe68fd1a9a3f545aa5e7a143b5355", size = 8316849, upload-time = "2025-10-09T00:27:10.254Z" }, + { url = "https://files.pythonhosted.org/packages/b0/b7/bb4f23856197659f275e11a2a164e36e65e9b48ea3e93c4ec25b4f163198/matplotlib-3.10.7-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5e38c2d581d62ee729a6e144c47a71b3f42fb4187508dbbf4fe71d5612c3433b", size = 8178225, upload-time = "2025-10-09T00:27:12.241Z" }, + { url = "https://files.pythonhosted.org/packages/62/56/0600609893ff277e6f3ab3c0cef4eafa6e61006c058e84286c467223d4d5/matplotlib-3.10.7-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:786656bb13c237bbcebcd402f65f44dd61ead60ee3deb045af429d889c8dbc67", size = 8711708, upload-time = "2025-10-09T00:27:13.879Z" }, + { url = "https://files.pythonhosted.org/packages/d8/1a/6bfecb0cafe94d6658f2f1af22c43b76cf7a1c2f0dc34ef84cbb6809617e/matplotlib-3.10.7-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:09d7945a70ea43bf9248f4b6582734c2fe726723204a76eca233f24cffc7ef67", size = 9541409, upload-time = "2025-10-09T00:27:15.684Z" }, + { url = "https://files.pythonhosted.org/packages/08/50/95122a407d7f2e446fd865e2388a232a23f2b81934960ea802f3171518e4/matplotlib-3.10.7-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:d0b181e9fa8daf1d9f2d4c547527b167cb8838fc587deabca7b5c01f97199e84", size = 9594054, upload-time = "2025-10-09T00:27:17.547Z" }, + { url = "https://files.pythonhosted.org/packages/13/76/75b194a43b81583478a81e78a07da8d9ca6ddf50dd0a2ccabf258059481d/matplotlib-3.10.7-cp313-cp313t-win_amd64.whl", hash = "sha256:31963603041634ce1a96053047b40961f7a29eb8f9a62e80cc2c0427aa1d22a2", size = 8200100, upload-time = "2025-10-09T00:27:20.039Z" }, + { url = "https://files.pythonhosted.org/packages/f5/9e/6aefebdc9f8235c12bdeeda44cc0383d89c1e41da2c400caf3ee2073a3ce/matplotlib-3.10.7-cp313-cp313t-win_arm64.whl", hash = "sha256:aebed7b50aa6ac698c90f60f854b47e48cd2252b30510e7a1feddaf5a3f72cbf", size = 8042131, upload-time = "2025-10-09T00:27:21.608Z" }, + { url = "https://files.pythonhosted.org/packages/0d/4b/e5bc2c321b6a7e3a75638d937d19ea267c34bd5a90e12bee76c4d7c7a0d9/matplotlib-3.10.7-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:d883460c43e8c6b173fef244a2341f7f7c0e9725c7fe68306e8e44ed9c8fb100", size = 8273787, upload-time = "2025-10-09T00:27:23.27Z" }, + { url = "https://files.pythonhosted.org/packages/86/ad/6efae459c56c2fbc404da154e13e3a6039129f3c942b0152624f1c621f05/matplotlib-3.10.7-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:07124afcf7a6504eafcb8ce94091c5898bbdd351519a1beb5c45f7a38c67e77f", size = 8131348, upload-time = "2025-10-09T00:27:24.926Z" }, + { url = "https://files.pythonhosted.org/packages/a6/5a/a4284d2958dee4116359cc05d7e19c057e64ece1b4ac986ab0f2f4d52d5a/matplotlib-3.10.7-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c17398b709a6cce3d9fdb1595c33e356d91c098cd9486cb2cc21ea2ea418e715", size = 9533949, upload-time = "2025-10-09T00:27:26.704Z" }, + { url = "https://files.pythonhosted.org/packages/de/ff/f3781b5057fa3786623ad8976fc9f7b0d02b2f28534751fd5a44240de4cf/matplotlib-3.10.7-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7146d64f561498764561e9cd0ed64fcf582e570fc519e6f521e2d0cfd43365e1", size = 9804247, upload-time = "2025-10-09T00:27:28.514Z" }, + { url = "https://files.pythonhosted.org/packages/47/5a/993a59facb8444efb0e197bf55f545ee449902dcee86a4dfc580c3b61314/matplotlib-3.10.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:90ad854c0a435da3104c01e2c6f0028d7e719b690998a2333d7218db80950722", size = 9595497, upload-time = "2025-10-09T00:27:30.418Z" }, + { url = "https://files.pythonhosted.org/packages/0d/a5/77c95aaa9bb32c345cbb49626ad8eb15550cba2e6d4c88081a6c2ac7b08d/matplotlib-3.10.7-cp314-cp314-win_amd64.whl", hash = "sha256:4645fc5d9d20ffa3a39361fcdbcec731382763b623b72627806bf251b6388866", size = 8252732, upload-time = "2025-10-09T00:27:32.332Z" }, + { url = "https://files.pythonhosted.org/packages/74/04/45d269b4268d222390d7817dae77b159651909669a34ee9fdee336db5883/matplotlib-3.10.7-cp314-cp314-win_arm64.whl", hash = "sha256:9257be2f2a03415f9105c486d304a321168e61ad450f6153d77c69504ad764bb", size = 8124240, upload-time = "2025-10-09T00:27:33.94Z" }, + { url = "https://files.pythonhosted.org/packages/4b/c7/ca01c607bb827158b439208c153d6f14ddb9fb640768f06f7ca3488ae67b/matplotlib-3.10.7-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1e4bbad66c177a8fdfa53972e5ef8be72a5f27e6a607cec0d8579abd0f3102b1", size = 8316938, upload-time = "2025-10-09T00:27:35.534Z" }, + { url = "https://files.pythonhosted.org/packages/84/d2/5539e66e9f56d2fdec94bb8436f5e449683b4e199bcc897c44fbe3c99e28/matplotlib-3.10.7-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d8eb7194b084b12feb19142262165832fc6ee879b945491d1c3d4660748020c4", size = 8178245, upload-time = "2025-10-09T00:27:37.334Z" }, + { url = "https://files.pythonhosted.org/packages/77/b5/e6ca22901fd3e4fe433a82e583436dd872f6c966fca7e63cf806b40356f8/matplotlib-3.10.7-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b4d41379b05528091f00e1728004f9a8d7191260f3862178b88e8fd770206318", size = 9541411, upload-time = "2025-10-09T00:27:39.387Z" }, + { url = "https://files.pythonhosted.org/packages/9e/99/a4524db57cad8fee54b7237239a8f8360bfcfa3170d37c9e71c090c0f409/matplotlib-3.10.7-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4a74f79fafb2e177f240579bc83f0b60f82cc47d2f1d260f422a0627207008ca", size = 9803664, upload-time = "2025-10-09T00:27:41.492Z" }, + { url = "https://files.pythonhosted.org/packages/e6/a5/85e2edf76ea0ad4288d174926d9454ea85f3ce5390cc4e6fab196cbf250b/matplotlib-3.10.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:702590829c30aada1e8cef0568ddbffa77ca747b4d6e36c6d173f66e301f89cc", size = 9594066, upload-time = "2025-10-09T00:27:43.694Z" }, + { url = "https://files.pythonhosted.org/packages/39/69/9684368a314f6d83fe5c5ad2a4121a3a8e03723d2e5c8ea17b66c1bad0e7/matplotlib-3.10.7-cp314-cp314t-win_amd64.whl", hash = "sha256:f79d5de970fc90cd5591f60053aecfce1fcd736e0303d9f0bf86be649fa68fb8", size = 8342832, upload-time = "2025-10-09T00:27:45.543Z" }, + { url = "https://files.pythonhosted.org/packages/04/5f/e22e08da14bc1a0894184640d47819d2338b792732e20d292bf86e5ab785/matplotlib-3.10.7-cp314-cp314t-win_arm64.whl", hash = "sha256:cb783436e47fcf82064baca52ce748af71725d0352e1d31564cbe9c95df92b9c", size = 8172585, upload-time = "2025-10-09T00:27:47.185Z" }, + { url = "https://files.pythonhosted.org/packages/58/8f/76d5dc21ac64a49e5498d7f0472c0781dae442dd266a67458baec38288ec/matplotlib-3.10.7-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:15112bcbaef211bd663fa935ec33313b948e214454d949b723998a43357b17b0", size = 8252283, upload-time = "2025-10-09T00:27:54.739Z" }, + { url = "https://files.pythonhosted.org/packages/27/0d/9c5d4c2317feb31d819e38c9f947c942f42ebd4eb935fc6fd3518a11eaa7/matplotlib-3.10.7-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d2a959c640cdeecdd2ec3136e8ea0441da59bcaf58d67e9c590740addba2cb68", size = 8116733, upload-time = "2025-10-09T00:27:56.406Z" }, + { url = "https://files.pythonhosted.org/packages/9a/cc/3fe688ff1355010937713164caacf9ed443675ac48a997bab6ed23b3f7c0/matplotlib-3.10.7-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3886e47f64611046bc1db523a09dd0a0a6bed6081e6f90e13806dd1d1d1b5e91", size = 8693919, upload-time = "2025-10-09T00:27:58.41Z" }, +] + +[[package]] +name = "matplotlib-inline" +version = "0.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c7/74/97e72a36efd4ae2bccb3463284300f8953f199b5ffbc04cbbb0ec78f74b1/matplotlib_inline-0.2.1.tar.gz", hash = "sha256:e1ee949c340d771fc39e241ea75683deb94762c8fa5f2927ec57c83c4dffa9fe", size = 8110, upload-time = "2025-10-23T09:00:22.126Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/af/33/ee4519fa02ed11a94aef9559552f3b17bb863f2ecfe1a35dc7f548cde231/matplotlib_inline-0.2.1-py3-none-any.whl", hash = "sha256:d56ce5156ba6085e00a9d54fead6ed29a9c47e215cd1bba2e976ef39f5710a76", size = 9516, upload-time = "2025-10-23T09:00:20.675Z" }, +] + +[[package]] +name = "mdtraj" +version = "1.11.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "packaging" }, + { name = "pyparsing" }, + { name = "scipy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/49/87/04f21b321e7bb6f3e09b5339df51699b8caae4846796175845bbc44f298b/mdtraj-1.11.0.tar.gz", hash = "sha256:2cf0ed2ee9a603dc4599743b1806e1387c655e2a1e9d013841b99fb137ad852e", size = 2860049, upload-time = "2025-06-25T02:06:19.716Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f8/bc/1a6d273467a24dad5048bf527288a7bbe997ecee8342f6e8fdd7465c9132/mdtraj-1.11.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:a6bbf2bc03a2ed23fd1fe696f6f6f970f495e843f3baaa71a0e99da086420de1", size = 2463123, upload-time = "2025-06-25T02:03:06.602Z" }, + { url = "https://files.pythonhosted.org/packages/09/dc/37bd8a22c66d05a293979d8d8cf1de5e6c03d21ebd6950b6a839c8d8a16b/mdtraj-1.11.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c34002c49282d208fdd34cd1b2578859bc7377a22ebd66a194efa1fc17395e5", size = 8023237, upload-time = "2025-06-25T02:03:28.425Z" }, + { url = "https://files.pythonhosted.org/packages/4c/48/6fc195907c056df9b6ff6442bb39c5dd433b971b9deaecf6d18a0761efa8/mdtraj-1.11.0-cp311-cp311-win_amd64.whl", hash = "sha256:4c794aac3db13937ed7e4f8c2b773564a89b2b539c15f43bae9dab6e1ffa5de1", size = 1355146, upload-time = "2025-06-25T02:03:31.524Z" }, + { url = "https://files.pythonhosted.org/packages/21/71/56adb24577052f2cd6f9b678da908e8e2d7963db3a88faff39792be43903/mdtraj-1.11.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:878fb8e62962b881ba75d4200637280b518e891e4aa5f213052ddb1905704558", size = 2448664, upload-time = "2025-06-25T02:03:43.052Z" }, + { url = "https://files.pythonhosted.org/packages/50/af/7d65be36619befb29b51b6118a97121de6685720af7f4cfd6ff1902ca7eb/mdtraj-1.11.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:01eb00d9b62f22056303fb8e4be0f85257a2ce1003f1e6274393bbcc977badaf", size = 8028112, upload-time = "2025-06-25T02:04:12.949Z" }, + { url = "https://files.pythonhosted.org/packages/d2/8f/fd05aa3613fad29b3a779e3384797d2b64aa329bcfd18d25f8fb9c0d725e/mdtraj-1.11.0-cp312-cp312-win_amd64.whl", hash = "sha256:f87117f7e6742b11a3a39e55fe5c9b4fc013d27691ac671bcc6dfb8193beae0f", size = 1341985, upload-time = "2025-06-25T02:04:19.133Z" }, + { url = "https://files.pythonhosted.org/packages/9c/8f/adbeabf14f27a2befd021548c5d8e696ca129924e0473863fb0e9e546f27/mdtraj-1.11.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:125db8d7b8f795ed2a771d5d6f419461e2f9dd21ef74d43311570b9dadbf0d46", size = 2432588, upload-time = "2025-06-25T02:04:25.409Z" }, + { url = "https://files.pythonhosted.org/packages/b5/c4/4a2e8db0f24c1deb85f4719d54a0a52c56403e17db61ddd72a95d39e07d6/mdtraj-1.11.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:46def167a429af5834f4f26a80bca372518952519847c1ceffd0163814b1164e", size = 7983336, upload-time = "2025-06-25T02:04:57.022Z" }, + { url = "https://files.pythonhosted.org/packages/ac/17/239ae1869fa0f6fe11e42388f5d1e094ac774f4d122767722f0451523989/mdtraj-1.11.0-cp313-cp313-win_amd64.whl", hash = "sha256:8ad81b0aabf090b1b0e60ee7974bd676046e48d35a7efdc9a37a8eacaf5b70e4", size = 1340489, upload-time = "2025-06-25T02:05:04.776Z" }, + { url = "https://files.pythonhosted.org/packages/ca/56/20981603c4c06f598ec655a373ef95598dbcbf769ead833f74ed2c6b3d64/mdtraj-1.11.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:18d237661deeb49e4b56c43426ef7e4824051ae5ee21df976cb70e2baeafb8fa", size = 1341102, upload-time = "2025-10-22T20:20:52.799Z" }, + { url = "https://files.pythonhosted.org/packages/3e/cc/8ddab5179e62c52f98d942dc51b181c8ffdddc874eab1885c351bca3850a/mdtraj-1.11.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9d9e2b44e6139d40525cb78b3a1d79dbe0504256a133621bfdeec11a22f44519", size = 1313566, upload-time = "2025-10-22T20:20:54.912Z" }, + { url = "https://files.pythonhosted.org/packages/d2/a6/658751c60c0484e2ec7e31ae8bb811885a2d7ca847cd004cc67da5535a01/mdtraj-1.11.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:18433b12a7b08848589b459961e1ccfcf3152afbbbad8ca847783075ba79e9e1", size = 7754412, upload-time = "2025-10-22T20:20:58.404Z" }, + { url = "https://files.pythonhosted.org/packages/c7/a5/82af73a397434488042fa52ad8922028e1221d06ec31535ed13fff63cb57/mdtraj-1.11.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d4d682faf2dd2eaf1d9cbba53f8e04504e80f0710580d6cd95f05286bdbeb755", size = 7914822, upload-time = "2025-10-22T20:21:00.39Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "mistune" +version = "3.1.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/02/a7fb8b21d4d55ac93cdcde9d3638da5dd0ebdd3a4fed76c7725e10b81cbe/mistune-3.1.4.tar.gz", hash = "sha256:b5a7f801d389f724ec702840c11d8fc48f2b33519102fc7ee739e8177b672164", size = 94588, upload-time = "2025-08-29T07:20:43.594Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7a/f0/8282d9641415e9e33df173516226b404d367a0fc55e1a60424a152913abc/mistune-3.1.4-py3-none-any.whl", hash = "sha256:93691da911e5d9d2e23bc54472892aff676df27a75274962ff9edc210364266d", size = 53481, upload-time = "2025-08-29T07:20:42.218Z" }, +] + +[[package]] +name = "more-itertools" +version = "10.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ea/5d/38b681d3fce7a266dd9ab73c66959406d565b3e85f21d5e66e1181d93721/more_itertools-10.8.0.tar.gz", hash = "sha256:f638ddf8a1a0d134181275fb5d58b086ead7c6a72429ad725c67503f13ba30bd", size = 137431, upload-time = "2025-09-02T15:23:11.018Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/8e/469e5a4a2f5855992e425f3cb33804cc07bf18d48f2db061aec61ce50270/more_itertools-10.8.0-py3-none-any.whl", hash = "sha256:52d4362373dcf7c52546bc4af9a86ee7c4579df9a8dc268be0a2f949d376cc9b", size = 69667, upload-time = "2025-09-02T15:23:09.635Z" }, +] + +[[package]] +name = "mypy" +version = "1.18.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/77/8f0d0001ffad290cef2f7f216f96c814866248a0b92a722365ed54648e7e/mypy-1.18.2.tar.gz", hash = "sha256:06a398102a5f203d7477b2923dda3634c36727fa5c237d8f859ef90c42a9924b", size = 3448846, upload-time = "2025-09-19T00:11:10.519Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/87/cafd3ae563f88f94eec33f35ff722d043e09832ea8530ef149ec1efbaf08/mypy-1.18.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:807d9315ab9d464125aa9fcf6d84fde6e1dc67da0b6f80e7405506b8ac72bc7f", size = 12731198, upload-time = "2025-09-19T00:09:44.857Z" }, + { url = "https://files.pythonhosted.org/packages/0f/e0/1e96c3d4266a06d4b0197ace5356d67d937d8358e2ee3ffac71faa843724/mypy-1.18.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:776bb00de1778caf4db739c6e83919c1d85a448f71979b6a0edd774ea8399341", size = 11817879, upload-time = "2025-09-19T00:09:47.131Z" }, + { url = "https://files.pythonhosted.org/packages/72/ef/0c9ba89eb03453e76bdac5a78b08260a848c7bfc5d6603634774d9cd9525/mypy-1.18.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1379451880512ffce14505493bd9fe469e0697543717298242574882cf8cdb8d", size = 12427292, upload-time = "2025-09-19T00:10:22.472Z" }, + { url = "https://files.pythonhosted.org/packages/1a/52/ec4a061dd599eb8179d5411d99775bec2a20542505988f40fc2fee781068/mypy-1.18.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1331eb7fd110d60c24999893320967594ff84c38ac6d19e0a76c5fd809a84c86", size = 13163750, upload-time = "2025-09-19T00:09:51.472Z" }, + { url = "https://files.pythonhosted.org/packages/c4/5f/2cf2ceb3b36372d51568f2208c021870fe7834cf3186b653ac6446511839/mypy-1.18.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3ca30b50a51e7ba93b00422e486cbb124f1c56a535e20eff7b2d6ab72b3b2e37", size = 13351827, upload-time = "2025-09-19T00:09:58.311Z" }, + { url = "https://files.pythonhosted.org/packages/c8/7d/2697b930179e7277529eaaec1513f8de622818696857f689e4a5432e5e27/mypy-1.18.2-cp311-cp311-win_amd64.whl", hash = "sha256:664dc726e67fa54e14536f6e1224bcfce1d9e5ac02426d2326e2bb4e081d1ce8", size = 9757983, upload-time = "2025-09-19T00:10:09.071Z" }, + { url = "https://files.pythonhosted.org/packages/07/06/dfdd2bc60c66611dd8335f463818514733bc763e4760dee289dcc33df709/mypy-1.18.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:33eca32dd124b29400c31d7cf784e795b050ace0e1f91b8dc035672725617e34", size = 12908273, upload-time = "2025-09-19T00:10:58.321Z" }, + { url = "https://files.pythonhosted.org/packages/81/14/6a9de6d13a122d5608e1a04130724caf9170333ac5a924e10f670687d3eb/mypy-1.18.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a3c47adf30d65e89b2dcd2fa32f3aeb5e94ca970d2c15fcb25e297871c8e4764", size = 11920910, upload-time = "2025-09-19T00:10:20.043Z" }, + { url = "https://files.pythonhosted.org/packages/5f/a9/b29de53e42f18e8cc547e38daa9dfa132ffdc64f7250e353f5c8cdd44bee/mypy-1.18.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d6c838e831a062f5f29d11c9057c6009f60cb294fea33a98422688181fe2893", size = 12465585, upload-time = "2025-09-19T00:10:33.005Z" }, + { url = "https://files.pythonhosted.org/packages/77/ae/6c3d2c7c61ff21f2bee938c917616c92ebf852f015fb55917fd6e2811db2/mypy-1.18.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01199871b6110a2ce984bde85acd481232d17413868c9807e95c1b0739a58914", size = 13348562, upload-time = "2025-09-19T00:10:11.51Z" }, + { url = "https://files.pythonhosted.org/packages/4d/31/aec68ab3b4aebdf8f36d191b0685d99faa899ab990753ca0fee60fb99511/mypy-1.18.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a2afc0fa0b0e91b4599ddfe0f91e2c26c2b5a5ab263737e998d6817874c5f7c8", size = 13533296, upload-time = "2025-09-19T00:10:06.568Z" }, + { url = "https://files.pythonhosted.org/packages/9f/83/abcb3ad9478fca3ebeb6a5358bb0b22c95ea42b43b7789c7fb1297ca44f4/mypy-1.18.2-cp312-cp312-win_amd64.whl", hash = "sha256:d8068d0afe682c7c4897c0f7ce84ea77f6de953262b12d07038f4d296d547074", size = 9828828, upload-time = "2025-09-19T00:10:28.203Z" }, + { url = "https://files.pythonhosted.org/packages/5f/04/7f462e6fbba87a72bc8097b93f6842499c428a6ff0c81dd46948d175afe8/mypy-1.18.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:07b8b0f580ca6d289e69209ec9d3911b4a26e5abfde32228a288eb79df129fcc", size = 12898728, upload-time = "2025-09-19T00:10:01.33Z" }, + { url = "https://files.pythonhosted.org/packages/99/5b/61ed4efb64f1871b41fd0b82d29a64640f3516078f6c7905b68ab1ad8b13/mypy-1.18.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ed4482847168439651d3feee5833ccedbf6657e964572706a2adb1f7fa4dfe2e", size = 11910758, upload-time = "2025-09-19T00:10:42.607Z" }, + { url = "https://files.pythonhosted.org/packages/3c/46/d297d4b683cc89a6e4108c4250a6a6b717f5fa96e1a30a7944a6da44da35/mypy-1.18.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c3ad2afadd1e9fea5cf99a45a822346971ede8685cc581ed9cd4d42eaf940986", size = 12475342, upload-time = "2025-09-19T00:11:00.371Z" }, + { url = "https://files.pythonhosted.org/packages/83/45/4798f4d00df13eae3bfdf726c9244bcb495ab5bd588c0eed93a2f2dd67f3/mypy-1.18.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a431a6f1ef14cf8c144c6b14793a23ec4eae3db28277c358136e79d7d062f62d", size = 13338709, upload-time = "2025-09-19T00:11:03.358Z" }, + { url = "https://files.pythonhosted.org/packages/d7/09/479f7358d9625172521a87a9271ddd2441e1dab16a09708f056e97007207/mypy-1.18.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7ab28cc197f1dd77a67e1c6f35cd1f8e8b73ed2217e4fc005f9e6a504e46e7ba", size = 13529806, upload-time = "2025-09-19T00:10:26.073Z" }, + { url = "https://files.pythonhosted.org/packages/71/cf/ac0f2c7e9d0ea3c75cd99dff7aec1c9df4a1376537cb90e4c882267ee7e9/mypy-1.18.2-cp313-cp313-win_amd64.whl", hash = "sha256:0e2785a84b34a72ba55fb5daf079a1003a34c05b22238da94fcae2bbe46f3544", size = 9833262, upload-time = "2025-09-19T00:10:40.035Z" }, + { url = "https://files.pythonhosted.org/packages/5a/0c/7d5300883da16f0063ae53996358758b2a2df2a09c72a5061fa79a1f5006/mypy-1.18.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:62f0e1e988ad41c2a110edde6c398383a889d95b36b3e60bcf155f5164c4fdce", size = 12893775, upload-time = "2025-09-19T00:10:03.814Z" }, + { url = "https://files.pythonhosted.org/packages/50/df/2cffbf25737bdb236f60c973edf62e3e7b4ee1c25b6878629e88e2cde967/mypy-1.18.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8795a039bab805ff0c1dfdb8cd3344642c2b99b8e439d057aba30850b8d3423d", size = 11936852, upload-time = "2025-09-19T00:10:51.631Z" }, + { url = "https://files.pythonhosted.org/packages/be/50/34059de13dd269227fb4a03be1faee6e2a4b04a2051c82ac0a0b5a773c9a/mypy-1.18.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6ca1e64b24a700ab5ce10133f7ccd956a04715463d30498e64ea8715236f9c9c", size = 12480242, upload-time = "2025-09-19T00:11:07.955Z" }, + { url = "https://files.pythonhosted.org/packages/5b/11/040983fad5132d85914c874a2836252bbc57832065548885b5bb5b0d4359/mypy-1.18.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d924eef3795cc89fecf6bedc6ed32b33ac13e8321344f6ddbf8ee89f706c05cb", size = 13326683, upload-time = "2025-09-19T00:09:55.572Z" }, + { url = "https://files.pythonhosted.org/packages/e9/ba/89b2901dd77414dd7a8c8729985832a5735053be15b744c18e4586e506ef/mypy-1.18.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:20c02215a080e3a2be3aa50506c67242df1c151eaba0dcbc1e4e557922a26075", size = 13514749, upload-time = "2025-09-19T00:10:44.827Z" }, + { url = "https://files.pythonhosted.org/packages/25/bc/cc98767cffd6b2928ba680f3e5bc969c4152bf7c2d83f92f5a504b92b0eb/mypy-1.18.2-cp314-cp314-win_amd64.whl", hash = "sha256:749b5f83198f1ca64345603118a6f01a4e99ad4bf9d103ddc5a3200cc4614adf", size = 9982959, upload-time = "2025-09-19T00:10:37.344Z" }, + { url = "https://files.pythonhosted.org/packages/87/e3/be76d87158ebafa0309946c4a73831974d4d6ab4f4ef40c3b53a385a66fd/mypy-1.18.2-py3-none-any.whl", hash = "sha256:22a1748707dd62b58d2ae53562ffc4d7f8bcc727e8ac7cbc69c053ddc874d47e", size = 2352367, upload-time = "2025-09-19T00:10:15.489Z" }, +] + +[[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 = "nbclient" +version = "0.10.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jupyter-client" }, + { name = "jupyter-core" }, + { name = "nbformat" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/87/66/7ffd18d58eae90d5721f9f39212327695b749e23ad44b3881744eaf4d9e8/nbclient-0.10.2.tar.gz", hash = "sha256:90b7fc6b810630db87a6d0c2250b1f0ab4cf4d3c27a299b0cde78a4ed3fd9193", size = 62424, upload-time = "2024-12-19T10:32:27.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/34/6d/e7fa07f03a4a7b221d94b4d586edb754a9b0dc3c9e2c93353e9fa4e0d117/nbclient-0.10.2-py3-none-any.whl", hash = "sha256:4ffee11e788b4a27fabeb7955547e4318a5298f34342a4bfd01f2e1faaeadc3d", size = 25434, upload-time = "2024-12-19T10:32:24.139Z" }, +] + +[[package]] +name = "nbconvert" +version = "7.16.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "beautifulsoup4" }, + { name = "bleach", extra = ["css"] }, + { name = "defusedxml" }, + { name = "jinja2" }, + { name = "jupyter-core" }, + { name = "jupyterlab-pygments" }, + { name = "markupsafe" }, + { name = "mistune" }, + { name = "nbclient" }, + { name = "nbformat" }, + { name = "packaging" }, + { name = "pandocfilters" }, + { name = "pygments" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/59/f28e15fc47ffb73af68a8d9b47367a8630d76e97ae85ad18271b9db96fdf/nbconvert-7.16.6.tar.gz", hash = "sha256:576a7e37c6480da7b8465eefa66c17844243816ce1ccc372633c6b71c3c0f582", size = 857715, upload-time = "2025-01-28T09:29:14.724Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cc/9a/cd673b2f773a12c992f41309ef81b99da1690426bd2f96957a7ade0d3ed7/nbconvert-7.16.6-py3-none-any.whl", hash = "sha256:1375a7b67e0c2883678c48e506dc320febb57685e5ee67faa51b18a90f3a712b", size = 258525, upload-time = "2025-01-28T09:29:12.551Z" }, +] + +[[package]] +name = "nbformat" +version = "5.10.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "fastjsonschema" }, + { name = "jsonschema" }, + { name = "jupyter-core" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6d/fd/91545e604bc3dad7dca9ed03284086039b294c6b3d75c0d2fa45f9e9caf3/nbformat-5.10.4.tar.gz", hash = "sha256:322168b14f937a5d11362988ecac2a4952d3d8e3a2cbeb2319584631226d5b3a", size = 142749, upload-time = "2024-04-04T11:20:37.371Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a9/82/0340caa499416c78e5d8f5f05947ae4bc3cba53c9f038ab6e9ed964e22f1/nbformat-5.10.4-py3-none-any.whl", hash = "sha256:3b48d6c8fbca4b299bf3982ea7db1af21580e4fec269ad087b9e81588891200b", size = 78454, upload-time = "2024-04-04T11:20:34.895Z" }, +] + +[[package]] +name = "nbsphinx" +version = "0.9.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "docutils" }, + { name = "jinja2" }, + { name = "nbconvert" }, + { name = "nbformat" }, + { name = "sphinx" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1e/84/b1856b7651ac34e965aa567a158714c7f3bd42a1b1ce76bf423ffb99872c/nbsphinx-0.9.7.tar.gz", hash = "sha256:abd298a686d55fa894ef697c51d44f24e53aa312dadae38e82920f250a5456fe", size = 180479, upload-time = "2025-03-03T19:46:08.069Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/2d/8c8e635bcc6757573d311bb3c5445426382f280da32b8cd6d82d501ef4a4/nbsphinx-0.9.7-py3-none-any.whl", hash = "sha256:7292c3767fea29e405c60743eee5393682a83982ab202ff98f5eb2db02629da8", size = 31660, upload-time = "2025-03-03T19:46:06.581Z" }, +] + +[[package]] +name = "nest-asyncio" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/83/f8/51569ac65d696c8ecbee95938f89d4abf00f47d58d48f6fbabfe8f0baefe/nest_asyncio-1.6.0.tar.gz", hash = "sha256:6f172d5449aca15afd6c646851f4e31e02c598d553a667e38cafa997cfec55fe", size = 7418, upload-time = "2024-01-21T14:25:19.227Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/c4/c2971a3ba4c6103a3d10c4b0f24f461ddc027f0f09763220cf35ca1401b3/nest_asyncio-1.6.0-py3-none-any.whl", hash = "sha256:87af6efd6b5e897c81050477ef65c62e2b2f35d51703cae01aff2905b1852e1c", size = 5195, upload-time = "2024-01-21T14:25:17.223Z" }, +] + +[[package]] +name = "networkx" +version = "3.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/fc/7b6fd4d22c8c4dc5704430140d8b3f520531d4fe7328b8f8d03f5a7950e8/networkx-3.6.tar.gz", hash = "sha256:285276002ad1f7f7da0f7b42f004bcba70d381e936559166363707fdad3d72ad", size = 2511464, upload-time = "2025-11-24T03:03:47.158Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/c7/d64168da60332c17d24c0d2f08bdf3987e8d1ae9d84b5bbd0eec2eb26a55/networkx-3.6-py3-none-any.whl", hash = "sha256:cdb395b105806062473d3be36458d8f1459a4e4b98e236a66c3a48996e07684f", size = 2063713, upload-time = "2025-11-24T03:03:45.21Z" }, +] + +[[package]] +name = "notebook" +version = "7.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jupyter-server" }, + { name = "jupyterlab" }, + { name = "jupyterlab-server" }, + { name = "notebook-shim" }, + { name = "tornado" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/89/ac/a97041621250a4fc5af379fb377942841eea2ca146aab166b8fcdfba96c2/notebook-7.5.0.tar.gz", hash = "sha256:3b27eaf9913033c28dde92d02139414c608992e1df4b969c843219acf2ff95e4", size = 14052074, upload-time = "2025-11-19T08:36:20.093Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/73/96/00df2a4760f10f5af0f45c4955573cae6189931f9a30265a35865f8c1031/notebook-7.5.0-py3-none-any.whl", hash = "sha256:3300262d52905ca271bd50b22617681d95f08a8360d099e097726e6d2efb5811", size = 14460968, upload-time = "2025-11-19T08:36:15.869Z" }, +] + +[[package]] +name = "notebook-shim" +version = "0.2.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jupyter-server" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/54/d2/92fa3243712b9a3e8bafaf60aac366da1cada3639ca767ff4b5b3654ec28/notebook_shim-0.2.4.tar.gz", hash = "sha256:b4b2cfa1b65d98307ca24361f5b30fe785b53c3fd07b7a47e89acb5e6ac638cb", size = 13167, upload-time = "2024-02-14T23:35:18.353Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f9/33/bd5b9137445ea4b680023eb0469b2bb969d61303dedb2aac6560ff3d14a1/notebook_shim-0.2.4-py3-none-any.whl", hash = "sha256:411a5be4e9dc882a074ccbcae671eda64cceb068767e9a3419096986560e1cef", size = 13307, upload-time = "2024-02-14T23:35:16.286Z" }, +] + +[[package]] +name = "numpy" +version = "1.26.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/65/6e/09db70a523a96d25e115e71cc56a6f9031e7b8cd166c1ac8438307c14058/numpy-1.26.4.tar.gz", hash = "sha256:2a02aba9ed12e4ac4eb3ea9421c420301a0c6460d9830d74a9df87efa4912010", size = 15786129, upload-time = "2024-02-06T00:26:44.495Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/11/57/baae43d14fe163fa0e4c47f307b6b2511ab8d7d30177c491960504252053/numpy-1.26.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4c66707fabe114439db9068ee468c26bbdf909cac0fb58686a42a24de1760c71", size = 20630554, upload-time = "2024-02-05T23:51:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/1a/2e/151484f49fd03944c4a3ad9c418ed193cfd02724e138ac8a9505d056c582/numpy-1.26.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:edd8b5fe47dab091176d21bb6de568acdd906d1887a4584a15a9a96a1dca06ef", size = 13997127, upload-time = "2024-02-05T23:52:15.314Z" }, + { url = "https://files.pythonhosted.org/packages/79/ae/7e5b85136806f9dadf4878bf73cf223fe5c2636818ba3ab1c585d0403164/numpy-1.26.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7ab55401287bfec946ced39700c053796e7cc0e3acbef09993a9ad2adba6ca6e", size = 14222994, upload-time = "2024-02-05T23:52:47.569Z" }, + { url = "https://files.pythonhosted.org/packages/3a/d0/edc009c27b406c4f9cbc79274d6e46d634d139075492ad055e3d68445925/numpy-1.26.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:666dbfb6ec68962c033a450943ded891bed2d54e6755e35e5835d63f4f6931d5", size = 18252005, upload-time = "2024-02-05T23:53:15.637Z" }, + { url = "https://files.pythonhosted.org/packages/09/bf/2b1aaf8f525f2923ff6cfcf134ae5e750e279ac65ebf386c75a0cf6da06a/numpy-1.26.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:96ff0b2ad353d8f990b63294c8986f1ec3cb19d749234014f4e7eb0112ceba5a", size = 13885297, upload-time = "2024-02-05T23:53:42.16Z" }, + { url = "https://files.pythonhosted.org/packages/df/a0/4e0f14d847cfc2a633a1c8621d00724f3206cfeddeb66d35698c4e2cf3d2/numpy-1.26.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:60dedbb91afcbfdc9bc0b1f3f402804070deed7392c23eb7a7f07fa857868e8a", size = 18093567, upload-time = "2024-02-05T23:54:11.696Z" }, + { url = "https://files.pythonhosted.org/packages/d2/b7/a734c733286e10a7f1a8ad1ae8c90f2d33bf604a96548e0a4a3a6739b468/numpy-1.26.4-cp311-cp311-win32.whl", hash = "sha256:1af303d6b2210eb850fcf03064d364652b7120803a0b872f5211f5234b399f20", size = 5968812, upload-time = "2024-02-05T23:54:26.453Z" }, + { url = "https://files.pythonhosted.org/packages/3f/6b/5610004206cf7f8e7ad91c5a85a8c71b2f2f8051a0c0c4d5916b76d6cbb2/numpy-1.26.4-cp311-cp311-win_amd64.whl", hash = "sha256:cd25bcecc4974d09257ffcd1f098ee778f7834c3ad767fe5db785be9a4aa9cb2", size = 15811913, upload-time = "2024-02-05T23:54:53.933Z" }, + { url = "https://files.pythonhosted.org/packages/95/12/8f2020a8e8b8383ac0177dc9570aad031a3beb12e38847f7129bacd96228/numpy-1.26.4-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:b3ce300f3644fb06443ee2222c2201dd3a89ea6040541412b8fa189341847218", size = 20335901, upload-time = "2024-02-05T23:55:32.801Z" }, + { url = "https://files.pythonhosted.org/packages/75/5b/ca6c8bd14007e5ca171c7c03102d17b4f4e0ceb53957e8c44343a9546dcc/numpy-1.26.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:03a8c78d01d9781b28a6989f6fa1bb2c4f2d51201cf99d3dd875df6fbd96b23b", size = 13685868, upload-time = "2024-02-05T23:55:56.28Z" }, + { url = "https://files.pythonhosted.org/packages/79/f8/97f10e6755e2a7d027ca783f63044d5b1bc1ae7acb12afe6a9b4286eac17/numpy-1.26.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9fad7dcb1aac3c7f0584a5a8133e3a43eeb2fe127f47e3632d43d677c66c102b", size = 13925109, upload-time = "2024-02-05T23:56:20.368Z" }, + { url = "https://files.pythonhosted.org/packages/0f/50/de23fde84e45f5c4fda2488c759b69990fd4512387a8632860f3ac9cd225/numpy-1.26.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:675d61ffbfa78604709862923189bad94014bef562cc35cf61d3a07bba02a7ed", size = 17950613, upload-time = "2024-02-05T23:56:56.054Z" }, + { url = "https://files.pythonhosted.org/packages/4c/0c/9c603826b6465e82591e05ca230dfc13376da512b25ccd0894709b054ed0/numpy-1.26.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:ab47dbe5cc8210f55aa58e4805fe224dac469cde56b9f731a4c098b91917159a", size = 13572172, upload-time = "2024-02-05T23:57:21.56Z" }, + { url = "https://files.pythonhosted.org/packages/76/8c/2ba3902e1a0fc1c74962ea9bb33a534bb05984ad7ff9515bf8d07527cadd/numpy-1.26.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:1dda2e7b4ec9dd512f84935c5f126c8bd8b9f2fc001e9f54af255e8c5f16b0e0", size = 17786643, upload-time = "2024-02-05T23:57:56.585Z" }, + { url = "https://files.pythonhosted.org/packages/28/4a/46d9e65106879492374999e76eb85f87b15328e06bd1550668f79f7b18c6/numpy-1.26.4-cp312-cp312-win32.whl", hash = "sha256:50193e430acfc1346175fcbdaa28ffec49947a06918b7b92130744e81e640110", size = 5677803, upload-time = "2024-02-05T23:58:08.963Z" }, + { url = "https://files.pythonhosted.org/packages/16/2e/86f24451c2d530c88daf997cb8d6ac622c1d40d19f5a031ed68a4b73a374/numpy-1.26.4-cp312-cp312-win_amd64.whl", hash = "sha256:08beddf13648eb95f8d867350f6a018a4be2e5ad54c8d8caed89ebca558b2818", size = 15517754, upload-time = "2024-02-05T23:58:36.364Z" }, +] + +[[package]] +name = "openmm" +version = "8.4.0.post1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/35/9a51d07fc60fc267a241a2199deb31502baa2e438801d4223a256b898c8a/openmm-8.4.0.post1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:eb5d3da43b7ac4b3e41e3a29005982b1f87de1879d2d7a97b982d20022321383", size = 13228166, upload-time = "2025-11-07T18:03:58.349Z" }, + { url = "https://files.pythonhosted.org/packages/95/50/772848b1b81c7609cbe119c5456996488ce2efd4863e782c257e42829be4/openmm-8.4.0.post1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ef172c5ab504f99a7346c3e4b35e29703be803a610ab72e6b2d16e39f754ebea", size = 12741362, upload-time = "2025-11-07T18:04:04.236Z" }, + { url = "https://files.pythonhosted.org/packages/7b/b2/8acf418560b6fe29635f4f992f3a1a71d9f017cca19ebe4ccff9f86fae90/openmm-8.4.0.post1-cp311-cp311-manylinux_2_34_x86_64.whl", hash = "sha256:2b5dcbc789d9d16fbe4fcc7326532499f45143a2bec05c869fa98af0c23c3d80", size = 14248283, upload-time = "2025-11-07T18:04:10.516Z" }, + { url = "https://files.pythonhosted.org/packages/38/1f/976e1fae88a6ff4ba74310dc663faf80e58e9f740ebfc12c890309706032/openmm-8.4.0.post1-cp311-cp311-win_amd64.whl", hash = "sha256:a49c498564bdaeb3bad936440ed402dca4b0718a6578147142f2c3ffe4580559", size = 13096103, upload-time = "2025-11-07T18:04:16.787Z" }, + { url = "https://files.pythonhosted.org/packages/6d/c1/63de7b93c6b57323cb3524f1038fbb75749d44f087a9c443de4a5d35be33/openmm-8.4.0.post1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:117165ce53a465389f05249cae9feb90276874dc7ac5bf0cc8b9151031b06fa6", size = 13224758, upload-time = "2025-11-07T18:04:22.948Z" }, + { url = "https://files.pythonhosted.org/packages/dc/17/ddae4b246950b68a45a5521f925a90db332e0761c28194a3b7533e2e4495/openmm-8.4.0.post1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:226d462ea3e1b36daa5276b931979d36db72c9daf0e3502f7cfd5528ade8861f", size = 12739396, upload-time = "2025-11-07T18:04:29.885Z" }, + { url = "https://files.pythonhosted.org/packages/3b/ca/2063be344cefbeaec480d9d44662551f41f27097c0df825cc2bcbea221cc/openmm-8.4.0.post1-cp312-cp312-manylinux_2_34_x86_64.whl", hash = "sha256:a503c2b860e21364f1c49bf23c5bb8b6fa04e1a4227bc577d543d4137e99eaf5", size = 14253643, upload-time = "2025-11-07T18:04:37.888Z" }, + { url = "https://files.pythonhosted.org/packages/0b/2d/6cafa30d7085bb76a61b3de0c492d1518939068d52924eb8c93a9571e2de/openmm-8.4.0.post1-cp312-cp312-win_amd64.whl", hash = "sha256:04599a8371f340a86bfbd494797b2bcb63327450ffabe44ad4ea375999e821c6", size = 13098400, upload-time = "2025-11-07T18:04:43.861Z" }, + { url = "https://files.pythonhosted.org/packages/d3/d0/c79e94e3708a0756af59cb3f0583dea8795df53493503dd9ec1627c4b94c/openmm-8.4.0.post1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:3dc56968b2f4eca5e981d006a52daf4cc96a92b65db750784f0f2cbd66e318eb", size = 13224726, upload-time = "2025-11-07T18:04:48.439Z" }, + { url = "https://files.pythonhosted.org/packages/65/e6/bbc688fe968c656eeb31a8e9d3e7e05ced8adc18f8eb78895a308485fe8f/openmm-8.4.0.post1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5053880aa0c9938f10dff889b110c6a743cfb4772daae10da090ab21c54d2175", size = 12738281, upload-time = "2025-11-07T18:04:53.239Z" }, + { url = "https://files.pythonhosted.org/packages/a0/7d/575d0b0e37f84282135841800d23d8f6c24a632f38a989b634835b9b4348/openmm-8.4.0.post1-cp313-cp313-manylinux_2_34_x86_64.whl", hash = "sha256:ae114defa3c5cdca8f5cf73a5d1a2786de96c7881e50b81baf31ed77e1bdaf8d", size = 14251632, upload-time = "2025-11-07T18:05:00.878Z" }, + { url = "https://files.pythonhosted.org/packages/81/15/ddf0d68a87ce6c4779d32e8e83e6a6ab13d1f8708ac10532e63a605181c5/openmm-8.4.0.post1-cp313-cp313-win_amd64.whl", hash = "sha256:519518a7fd55f10fa2d24aaeacc261cbbbf59e5cfb4a3c7ea013da31c1477f56", size = 13096639, upload-time = "2025-11-07T18:05:07.365Z" }, + { url = "https://files.pythonhosted.org/packages/76/9a/5d83ce626594add0762199dd487a161d854bdb81a5ad34b019a70c0c2b4f/openmm-8.4.0.post1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:ea8c0745cf88d43538774a5d814927b490ce5898fc390cbfcb32061b4224e8ae", size = 13228335, upload-time = "2025-11-07T18:05:14.17Z" }, + { url = "https://files.pythonhosted.org/packages/4e/19/452be1362c8ce806b63171e20fa7d5722bfeab62a854ec8144a30ae7ff55/openmm-8.4.0.post1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1c657ac46316f19e4356e3ee1c64acab42a7eec1d36ce87373781a550cdc1eab", size = 12741135, upload-time = "2025-11-07T18:05:21.301Z" }, + { url = "https://files.pythonhosted.org/packages/b8/80/e132b6548566671633afd3a429315208d3f3af1a12d8bc1f6705d65a3501/openmm-8.4.0.post1-cp314-cp314-manylinux_2_34_x86_64.whl", hash = "sha256:62285d6349d4214fdde8b921b9b191b11f60611f110cca1dd71f4a20a5b3a592", size = 14253053, upload-time = "2025-11-07T18:05:28.353Z" }, + { url = "https://files.pythonhosted.org/packages/71/89/723644512a2e36fdc5b850bbec33cadc53e41d733c829f73b2a6b9f92cff/openmm-8.4.0.post1-cp314-cp314-win_amd64.whl", hash = "sha256:5e62008a142e6c9a862791e7ab2ee509ebb76bb6c15a9a50157065b504dd4467", size = 13097585, upload-time = "2025-11-07T18:05:33.08Z" }, +] + +[[package]] +name = "openmm-systems" +version = "0.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdtraj" }, + { name = "numpy" }, + { name = "scipy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/88/53/b321144a077a07168717291ee61c801f49a0be5d82ffefa8781f4fdc3a64/openmm_systems-0.0.0.tar.gz", hash = "sha256:b65717bcb59230313bf3670190603a227087ea2c1e0bdf8eaa62191dbce2ae52", size = 11082338, upload-time = "2020-04-30T19:51:34.773Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/e1/02debe3a6f49490d2fc40c3fc5a96ab902ad96a66e5b8173254d3ef900b5/openmm_systems-0.0.0-py2.py3-none-any.whl", hash = "sha256:9e9ce3157a1034baf9fae1f652dc5187f6b36e6d66b2871521a4ef98f3c4b64b", size = 11327692, upload-time = "2020-04-30T19:51:18.139Z" }, +] + +[[package]] +name = "overrides" +version = "7.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/36/86/b585f53236dec60aba864e050778b25045f857e17f6e5ea0ae95fe80edd2/overrides-7.7.0.tar.gz", hash = "sha256:55158fa3d93b98cc75299b1e67078ad9003ca27945c76162c1c0766d6f91820a", size = 22812, upload-time = "2024-01-27T21:01:33.423Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/ab/fc8290c6a4c722e5514d80f62b2dc4c4df1a68a41d1364e625c35990fcf3/overrides-7.7.0-py3-none-any.whl", hash = "sha256:c7ed9d062f78b8e4c1a7b70bd8796b35ead4d9f510227ef9c5dc7626c60d7e49", size = 17832, upload-time = "2024-01-27T21:01:31.393Z" }, +] + +[[package]] +name = "packaging" +version = "25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, +] + +[[package]] +name = "pandas" +version = "2.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "python-dateutil" }, + { name = "pytz" }, + { name = "tzdata" }, +] +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/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" }, + { url = "https://files.pythonhosted.org/packages/cd/4b/18b035ee18f97c1040d94debd8f2e737000ad70ccc8f5513f4eefad75f4b/pandas-2.3.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:56851a737e3470de7fa88e6131f41281ed440d29a9268dcbf0002da5ac366713", size = 11544671, upload-time = "2025-09-29T23:21:05.024Z" }, + { url = "https://files.pythonhosted.org/packages/31/94/72fac03573102779920099bcac1c3b05975c2cb5f01eac609faf34bed1ca/pandas-2.3.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bdcd9d1167f4885211e401b3036c0c8d9e274eee67ea8d0758a256d60704cfe8", size = 10680807, upload-time = "2025-09-29T23:21:15.979Z" }, + { url = "https://files.pythonhosted.org/packages/16/87/9472cf4a487d848476865321de18cc8c920b8cab98453ab79dbbc98db63a/pandas-2.3.3-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e32e7cc9af0f1cc15548288a51a3b681cc2a219faa838e995f7dc53dbab1062d", size = 11709872, upload-time = "2025-09-29T23:21:27.165Z" }, + { url = "https://files.pythonhosted.org/packages/15/07/284f757f63f8a8d69ed4472bfd85122bd086e637bf4ed09de572d575a693/pandas-2.3.3-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:318d77e0e42a628c04dc56bcef4b40de67918f7041c2b061af1da41dcff670ac", size = 12306371, upload-time = "2025-09-29T23:21:40.532Z" }, + { url = "https://files.pythonhosted.org/packages/33/81/a3afc88fca4aa925804a27d2676d22dcd2031c2ebe08aabd0ae55b9ff282/pandas-2.3.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4e0a175408804d566144e170d0476b15d78458795bb18f1304fb94160cabf40c", size = 12765333, upload-time = "2025-09-29T23:21:55.77Z" }, + { url = "https://files.pythonhosted.org/packages/8d/0f/b4d4ae743a83742f1153464cf1a8ecfafc3ac59722a0b5c8602310cb7158/pandas-2.3.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:93c2d9ab0fc11822b5eece72ec9587e172f63cff87c00b062f6e37448ced4493", size = 13418120, upload-time = "2025-09-29T23:22:10.109Z" }, + { url = "https://files.pythonhosted.org/packages/4f/c7/e54682c96a895d0c808453269e0b5928a07a127a15704fedb643e9b0a4c8/pandas-2.3.3-cp313-cp313-win_amd64.whl", hash = "sha256:f8bfc0e12dc78f777f323f55c58649591b2cd0c43534e8355c51d3fede5f4dee", size = 10993991, upload-time = "2025-09-29T23:25:04.889Z" }, + { url = "https://files.pythonhosted.org/packages/f9/ca/3f8d4f49740799189e1395812f3bf23b5e8fc7c190827d55a610da72ce55/pandas-2.3.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:75ea25f9529fdec2d2e93a42c523962261e567d250b0013b16210e1d40d7c2e5", size = 12048227, upload-time = "2025-09-29T23:22:24.343Z" }, + { url = "https://files.pythonhosted.org/packages/0e/5a/f43efec3e8c0cc92c4663ccad372dbdff72b60bdb56b2749f04aa1d07d7e/pandas-2.3.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:74ecdf1d301e812db96a465a525952f4dde225fdb6d8e5a521d47e1f42041e21", size = 11411056, upload-time = "2025-09-29T23:22:37.762Z" }, + { url = "https://files.pythonhosted.org/packages/46/b1/85331edfc591208c9d1a63a06baa67b21d332e63b7a591a5ba42a10bb507/pandas-2.3.3-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6435cb949cb34ec11cc9860246ccb2fdc9ecd742c12d3304989017d53f039a78", size = 11645189, upload-time = "2025-09-29T23:22:51.688Z" }, + { url = "https://files.pythonhosted.org/packages/44/23/78d645adc35d94d1ac4f2a3c4112ab6f5b8999f4898b8cdf01252f8df4a9/pandas-2.3.3-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:900f47d8f20860de523a1ac881c4c36d65efcb2eb850e6948140fa781736e110", size = 12121912, upload-time = "2025-09-29T23:23:05.042Z" }, + { url = "https://files.pythonhosted.org/packages/53/da/d10013df5e6aaef6b425aa0c32e1fc1f3e431e4bcabd420517dceadce354/pandas-2.3.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:a45c765238e2ed7d7c608fc5bc4a6f88b642f2f01e70c0c23d2224dd21829d86", size = 12712160, upload-time = "2025-09-29T23:23:28.57Z" }, + { url = "https://files.pythonhosted.org/packages/bd/17/e756653095a083d8a37cbd816cb87148debcfcd920129b25f99dd8d04271/pandas-2.3.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c4fc4c21971a1a9f4bdb4c73978c7f7256caa3e62b323f70d6cb80db583350bc", size = 13199233, upload-time = "2025-09-29T23:24:24.876Z" }, + { url = "https://files.pythonhosted.org/packages/04/fd/74903979833db8390b73b3a8a7d30d146d710bd32703724dd9083950386f/pandas-2.3.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:ee15f284898e7b246df8087fc82b87b01686f98ee67d85a17b7ab44143a3a9a0", size = 11540635, upload-time = "2025-09-29T23:25:52.486Z" }, + { url = "https://files.pythonhosted.org/packages/21/00/266d6b357ad5e6d3ad55093a7e8efc7dd245f5a842b584db9f30b0f0a287/pandas-2.3.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1611aedd912e1ff81ff41c745822980c49ce4a7907537be8692c8dbc31924593", size = 10759079, upload-time = "2025-09-29T23:26:33.204Z" }, + { url = "https://files.pythonhosted.org/packages/ca/05/d01ef80a7a3a12b2f8bbf16daba1e17c98a2f039cbc8e2f77a2c5a63d382/pandas-2.3.3-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6d2cefc361461662ac48810cb14365a365ce864afe85ef1f447ff5a1e99ea81c", size = 11814049, upload-time = "2025-09-29T23:27:15.384Z" }, + { url = "https://files.pythonhosted.org/packages/15/b2/0e62f78c0c5ba7e3d2c5945a82456f4fac76c480940f805e0b97fcbc2f65/pandas-2.3.3-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ee67acbbf05014ea6c763beb097e03cd629961c8a632075eeb34247120abcb4b", size = 12332638, upload-time = "2025-09-29T23:27:51.625Z" }, + { url = "https://files.pythonhosted.org/packages/c5/33/dd70400631b62b9b29c3c93d2feee1d0964dc2bae2e5ad7a6c73a7f25325/pandas-2.3.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c46467899aaa4da076d5abc11084634e2d197e9460643dd455ac3db5856b24d6", size = 12886834, upload-time = "2025-09-29T23:28:21.289Z" }, + { url = "https://files.pythonhosted.org/packages/d3/18/b5d48f55821228d0d2692b34fd5034bb185e854bdb592e9c640f6290e012/pandas-2.3.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6253c72c6a1d990a410bc7de641d34053364ef8bcd3126f7e7450125887dffe3", size = 13409925, upload-time = "2025-09-29T23:28:58.261Z" }, + { url = "https://files.pythonhosted.org/packages/a6/3d/124ac75fcd0ecc09b8fdccb0246ef65e35b012030defb0e0eba2cbbbe948/pandas-2.3.3-cp314-cp314-win_amd64.whl", hash = "sha256:1b07204a219b3b7350abaae088f451860223a52cfb8a6c53358e7948735158e5", size = 11109071, upload-time = "2025-09-29T23:32:27.484Z" }, + { url = "https://files.pythonhosted.org/packages/89/9c/0e21c895c38a157e0faa1fb64587a9226d6dd46452cac4532d80c3c4a244/pandas-2.3.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2462b1a365b6109d275250baaae7b760fd25c726aaca0054649286bcfbb3e8ec", size = 12048504, upload-time = "2025-09-29T23:29:31.47Z" }, + { url = "https://files.pythonhosted.org/packages/d7/82/b69a1c95df796858777b68fbe6a81d37443a33319761d7c652ce77797475/pandas-2.3.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0242fe9a49aa8b4d78a4fa03acb397a58833ef6199e9aa40a95f027bb3a1b6e7", size = 11410702, upload-time = "2025-09-29T23:29:54.591Z" }, + { url = "https://files.pythonhosted.org/packages/f9/88/702bde3ba0a94b8c73a0181e05144b10f13f29ebfc2150c3a79062a8195d/pandas-2.3.3-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a21d830e78df0a515db2b3d2f5570610f5e6bd2e27749770e8bb7b524b89b450", size = 11634535, upload-time = "2025-09-29T23:30:21.003Z" }, + { url = "https://files.pythonhosted.org/packages/a4/1e/1bac1a839d12e6a82ec6cb40cda2edde64a2013a66963293696bbf31fbbb/pandas-2.3.3-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2e3ebdb170b5ef78f19bfb71b0dc5dc58775032361fa188e814959b74d726dd5", size = 12121582, upload-time = "2025-09-29T23:30:43.391Z" }, + { url = "https://files.pythonhosted.org/packages/44/91/483de934193e12a3b1d6ae7c8645d083ff88dec75f46e827562f1e4b4da6/pandas-2.3.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d051c0e065b94b7a3cea50eb1ec32e912cd96dba41647eb24104b6c6c14c5788", size = 12699963, upload-time = "2025-09-29T23:31:10.009Z" }, + { url = "https://files.pythonhosted.org/packages/70/44/5191d2e4026f86a2a109053e194d3ba7a31a2d10a9c2348368c63ed4e85a/pandas-2.3.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3869faf4bd07b3b66a9f462417d0ca3a9df29a9f6abd5d0d0dbab15dac7abe87", size = 13202175, upload-time = "2025-09-29T23:31:59.173Z" }, +] + +[[package]] +name = "pandocfilters" +version = "1.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/70/6f/3dd4940bbe001c06a65f88e36bad298bc7a0de5036115639926b0c5c0458/pandocfilters-1.5.1.tar.gz", hash = "sha256:002b4a555ee4ebc03f8b66307e287fa492e4a77b4ea14d3f934328297bb4939e", size = 8454, upload-time = "2024-01-18T20:08:13.726Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/af/4fbc8cab944db5d21b7e2a5b8e9211a03a79852b1157e2c102fcc61ac440/pandocfilters-1.5.1-py2.py3-none-any.whl", hash = "sha256:93be382804a9cdb0a7267585f157e5d1731bbe5545a85b268d6f5fe6232de2bc", size = 8663, upload-time = "2024-01-18T20:08:11.28Z" }, +] + +[[package]] +name = "parso" +version = "0.8.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d4/de/53e0bcf53d13e005bd8c92e7855142494f41171b34c2536b86187474184d/parso-0.8.5.tar.gz", hash = "sha256:034d7354a9a018bdce352f48b2a8a450f05e9d6ee85db84764e9b6bd96dafe5a", size = 401205, upload-time = "2025-08-23T15:15:28.028Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/16/32/f8e3c85d1d5250232a5d3477a2a28cc291968ff175caeadaf3cc19ce0e4a/parso-0.8.5-py2.py3-none-any.whl", hash = "sha256:646204b5ee239c396d040b90f9e272e9a8017c630092bf59980beb62fd033887", size = 106668, upload-time = "2025-08-23T15:15:25.663Z" }, +] + +[[package]] +name = "pathspec" +version = "0.12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ca/bc/f35b8446f4531a7cb215605d100cd88b7ac6f44ab3fc94870c120ab3adbf/pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712", size = 51043, upload-time = "2023-12-10T22:30:45Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cc/20/ff623b09d963f88bfde16306a54e12ee5ea43e9b597108672ff3a408aad6/pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", size = 31191, upload-time = "2023-12-10T22:30:43.14Z" }, +] + +[[package]] +name = "pdbpp" +version = "0.11.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "fancycompleter" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c6/4c/118ef9534ac0632859b48c305d8c5dc9d6f963564fdfa66bc785c560247c/pdbpp-0.11.7.tar.gz", hash = "sha256:cb6604ac31a35ed0f2a29650a8c022b26284620be3e01cfd41b683b91da1ff14", size = 76026, upload-time = "2025-07-18T09:36:02.781Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/e9/704bbc08aace64fee536e4c2c20f63f64f6fdbad72938c5ed46c9723a9f1/pdbpp-0.11.7-py3-none-any.whl", hash = "sha256:51916b63693898cf4881b36b4501c83947758d73f582f1f84893662b163bdb75", size = 30545, upload-time = "2025-07-18T09:36:01.478Z" }, +] + +[[package]] +name = "pexpect" +version = "4.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ptyprocess" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/92/cc564bf6381ff43ce1f4d06852fc19a2f11d180f23dc32d9588bee2f149d/pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f", size = 166450, upload-time = "2023-11-25T09:07:26.339Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/c3/059298687310d527a58bb01f3b1965787ee3b40dce76752eda8b44e9a2c5/pexpect-4.9.0-py2.py3-none-any.whl", hash = "sha256:7236d1e080e4936be2dc3e326cec0af72acf9212a7e1d060210e70a47e253523", size = 63772, upload-time = "2023-11-25T06:56:14.81Z" }, +] + +[[package]] +name = "pillow" +version = "12.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/b0/cace85a1b0c9775a9f8f5d5423c8261c858760e2466c79b2dd184638b056/pillow-12.0.0.tar.gz", hash = "sha256:87d4f8125c9988bfbed67af47dd7a953e2fc7b0cc1e7800ec6d2080d490bb353", size = 47008828, upload-time = "2025-10-15T18:24:14.008Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/5a/a2f6773b64edb921a756eb0729068acad9fc5208a53f4a349396e9436721/pillow-12.0.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:0fd00cac9c03256c8b2ff58f162ebcd2587ad3e1f2e397eab718c47e24d231cc", size = 5289798, upload-time = "2025-10-15T18:21:47.763Z" }, + { url = "https://files.pythonhosted.org/packages/2e/05/069b1f8a2e4b5a37493da6c5868531c3f77b85e716ad7a590ef87d58730d/pillow-12.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a3475b96f5908b3b16c47533daaa87380c491357d197564e0ba34ae75c0f3257", size = 4650589, upload-time = "2025-10-15T18:21:49.515Z" }, + { url = "https://files.pythonhosted.org/packages/61/e3/2c820d6e9a36432503ead175ae294f96861b07600a7156154a086ba7111a/pillow-12.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:110486b79f2d112cf6add83b28b627e369219388f64ef2f960fef9ebaf54c642", size = 6230472, upload-time = "2025-10-15T18:21:51.052Z" }, + { url = "https://files.pythonhosted.org/packages/4f/89/63427f51c64209c5e23d4d52071c8d0f21024d3a8a487737caaf614a5795/pillow-12.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5269cc1caeedb67e6f7269a42014f381f45e2e7cd42d834ede3c703a1d915fe3", size = 8033887, upload-time = "2025-10-15T18:21:52.604Z" }, + { url = "https://files.pythonhosted.org/packages/f6/1b/c9711318d4901093c15840f268ad649459cd81984c9ec9887756cca049a5/pillow-12.0.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa5129de4e174daccbc59d0a3b6d20eaf24417d59851c07ebb37aeb02947987c", size = 6343964, upload-time = "2025-10-15T18:21:54.619Z" }, + { url = "https://files.pythonhosted.org/packages/41/1e/db9470f2d030b4995083044cd8738cdd1bf773106819f6d8ba12597d5352/pillow-12.0.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bee2a6db3a7242ea309aa7ee8e2780726fed67ff4e5b40169f2c940e7eb09227", size = 7034756, upload-time = "2025-10-15T18:21:56.151Z" }, + { url = "https://files.pythonhosted.org/packages/cc/b0/6177a8bdd5ee4ed87cba2de5a3cc1db55ffbbec6176784ce5bb75aa96798/pillow-12.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:90387104ee8400a7b4598253b4c406f8958f59fcf983a6cea2b50d59f7d63d0b", size = 6458075, upload-time = "2025-10-15T18:21:57.759Z" }, + { url = "https://files.pythonhosted.org/packages/bc/5e/61537aa6fa977922c6a03253a0e727e6e4a72381a80d63ad8eec350684f2/pillow-12.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bc91a56697869546d1b8f0a3ff35224557ae7f881050e99f615e0119bf934b4e", size = 7125955, upload-time = "2025-10-15T18:21:59.372Z" }, + { url = "https://files.pythonhosted.org/packages/1f/3d/d5033539344ee3cbd9a4d69e12e63ca3a44a739eb2d4c8da350a3d38edd7/pillow-12.0.0-cp311-cp311-win32.whl", hash = "sha256:27f95b12453d165099c84f8a8bfdfd46b9e4bda9e0e4b65f0635430027f55739", size = 6298440, upload-time = "2025-10-15T18:22:00.982Z" }, + { url = "https://files.pythonhosted.org/packages/4d/42/aaca386de5cc8bd8a0254516957c1f265e3521c91515b16e286c662854c4/pillow-12.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:b583dc9070312190192631373c6c8ed277254aa6e6084b74bdd0a6d3b221608e", size = 6999256, upload-time = "2025-10-15T18:22:02.617Z" }, + { url = "https://files.pythonhosted.org/packages/ba/f1/9197c9c2d5708b785f631a6dfbfa8eb3fb9672837cb92ae9af812c13b4ed/pillow-12.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:759de84a33be3b178a64c8ba28ad5c135900359e85fb662bc6e403ad4407791d", size = 2436025, upload-time = "2025-10-15T18:22:04.598Z" }, + { url = "https://files.pythonhosted.org/packages/2c/90/4fcce2c22caf044e660a198d740e7fbc14395619e3cb1abad12192c0826c/pillow-12.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:53561a4ddc36facb432fae7a9d8afbfaf94795414f5cdc5fc52f28c1dca90371", size = 5249377, upload-time = "2025-10-15T18:22:05.993Z" }, + { url = "https://files.pythonhosted.org/packages/fd/e0/ed960067543d080691d47d6938ebccbf3976a931c9567ab2fbfab983a5dd/pillow-12.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:71db6b4c1653045dacc1585c1b0d184004f0d7e694c7b34ac165ca70c0838082", size = 4650343, upload-time = "2025-10-15T18:22:07.718Z" }, + { url = "https://files.pythonhosted.org/packages/e7/a1/f81fdeddcb99c044bf7d6faa47e12850f13cee0849537a7d27eeab5534d4/pillow-12.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2fa5f0b6716fc88f11380b88b31fe591a06c6315e955c096c35715788b339e3f", size = 6232981, upload-time = "2025-10-15T18:22:09.287Z" }, + { url = "https://files.pythonhosted.org/packages/88/e1/9098d3ce341a8750b55b0e00c03f1630d6178f38ac191c81c97a3b047b44/pillow-12.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:82240051c6ca513c616f7f9da06e871f61bfd7805f566275841af15015b8f98d", size = 8041399, upload-time = "2025-10-15T18:22:10.872Z" }, + { url = "https://files.pythonhosted.org/packages/a7/62/a22e8d3b602ae8cc01446d0c57a54e982737f44b6f2e1e019a925143771d/pillow-12.0.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55f818bd74fe2f11d4d7cbc65880a843c4075e0ac7226bc1a23261dbea531953", size = 6347740, upload-time = "2025-10-15T18:22:12.769Z" }, + { url = "https://files.pythonhosted.org/packages/4f/87/424511bdcd02c8d7acf9f65caa09f291a519b16bd83c3fb3374b3d4ae951/pillow-12.0.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b87843e225e74576437fd5b6a4c2205d422754f84a06942cfaf1dc32243e45a8", size = 7040201, upload-time = "2025-10-15T18:22:14.813Z" }, + { url = "https://files.pythonhosted.org/packages/dc/4d/435c8ac688c54d11755aedfdd9f29c9eeddf68d150fe42d1d3dbd2365149/pillow-12.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c607c90ba67533e1b2355b821fef6764d1dd2cbe26b8c1005ae84f7aea25ff79", size = 6462334, upload-time = "2025-10-15T18:22:16.375Z" }, + { url = "https://files.pythonhosted.org/packages/2b/f2/ad34167a8059a59b8ad10bc5c72d4d9b35acc6b7c0877af8ac885b5f2044/pillow-12.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:21f241bdd5080a15bc86d3466a9f6074a9c2c2b314100dd896ac81ee6db2f1ba", size = 7134162, upload-time = "2025-10-15T18:22:17.996Z" }, + { url = "https://files.pythonhosted.org/packages/0c/b1/a7391df6adacf0a5c2cf6ac1cf1fcc1369e7d439d28f637a847f8803beb3/pillow-12.0.0-cp312-cp312-win32.whl", hash = "sha256:dd333073e0cacdc3089525c7df7d39b211bcdf31fc2824e49d01c6b6187b07d0", size = 6298769, upload-time = "2025-10-15T18:22:19.923Z" }, + { url = "https://files.pythonhosted.org/packages/a2/0b/d87733741526541c909bbf159e338dcace4f982daac6e5a8d6be225ca32d/pillow-12.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:9fe611163f6303d1619bbcb653540a4d60f9e55e622d60a3108be0d5b441017a", size = 7001107, upload-time = "2025-10-15T18:22:21.644Z" }, + { url = "https://files.pythonhosted.org/packages/bc/96/aaa61ce33cc98421fb6088af2a03be4157b1e7e0e87087c888e2370a7f45/pillow-12.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:7dfb439562f234f7d57b1ac6bc8fe7f838a4bd49c79230e0f6a1da93e82f1fad", size = 2436012, upload-time = "2025-10-15T18:22:23.621Z" }, + { url = "https://files.pythonhosted.org/packages/62/f2/de993bb2d21b33a98d031ecf6a978e4b61da207bef02f7b43093774c480d/pillow-12.0.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:0869154a2d0546545cde61d1789a6524319fc1897d9ee31218eae7a60ccc5643", size = 4045493, upload-time = "2025-10-15T18:22:25.758Z" }, + { url = "https://files.pythonhosted.org/packages/0e/b6/bc8d0c4c9f6f111a783d045310945deb769b806d7574764234ffd50bc5ea/pillow-12.0.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:a7921c5a6d31b3d756ec980f2f47c0cfdbce0fc48c22a39347a895f41f4a6ea4", size = 4120461, upload-time = "2025-10-15T18:22:27.286Z" }, + { url = "https://files.pythonhosted.org/packages/5d/57/d60d343709366a353dc56adb4ee1e7d8a2cc34e3fbc22905f4167cfec119/pillow-12.0.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:1ee80a59f6ce048ae13cda1abf7fbd2a34ab9ee7d401c46be3ca685d1999a399", size = 3576912, upload-time = "2025-10-15T18:22:28.751Z" }, + { url = "https://files.pythonhosted.org/packages/a4/a4/a0a31467e3f83b94d37568294b01d22b43ae3c5d85f2811769b9c66389dd/pillow-12.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c50f36a62a22d350c96e49ad02d0da41dbd17ddc2e29750dbdba4323f85eb4a5", size = 5249132, upload-time = "2025-10-15T18:22:30.641Z" }, + { url = "https://files.pythonhosted.org/packages/83/06/48eab21dd561de2914242711434c0c0eb992ed08ff3f6107a5f44527f5e9/pillow-12.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5193fde9a5f23c331ea26d0cf171fbf67e3f247585f50c08b3e205c7aeb4589b", size = 4650099, upload-time = "2025-10-15T18:22:32.73Z" }, + { url = "https://files.pythonhosted.org/packages/fc/bd/69ed99fd46a8dba7c1887156d3572fe4484e3f031405fcc5a92e31c04035/pillow-12.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bde737cff1a975b70652b62d626f7785e0480918dece11e8fef3c0cf057351c3", size = 6230808, upload-time = "2025-10-15T18:22:34.337Z" }, + { url = "https://files.pythonhosted.org/packages/ea/94/8fad659bcdbf86ed70099cb60ae40be6acca434bbc8c4c0d4ef356d7e0de/pillow-12.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a6597ff2b61d121172f5844b53f21467f7082f5fb385a9a29c01414463f93b07", size = 8037804, upload-time = "2025-10-15T18:22:36.402Z" }, + { url = "https://files.pythonhosted.org/packages/20/39/c685d05c06deecfd4e2d1950e9a908aa2ca8bc4e6c3b12d93b9cafbd7837/pillow-12.0.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0b817e7035ea7f6b942c13aa03bb554fc44fea70838ea21f8eb31c638326584e", size = 6345553, upload-time = "2025-10-15T18:22:38.066Z" }, + { url = "https://files.pythonhosted.org/packages/38/57/755dbd06530a27a5ed74f8cb0a7a44a21722ebf318edbe67ddbd7fb28f88/pillow-12.0.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f4f1231b7dec408e8670264ce63e9c71409d9583dd21d32c163e25213ee2a344", size = 7037729, upload-time = "2025-10-15T18:22:39.769Z" }, + { url = "https://files.pythonhosted.org/packages/ca/b6/7e94f4c41d238615674d06ed677c14883103dce1c52e4af16f000338cfd7/pillow-12.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6e51b71417049ad6ab14c49608b4a24d8fb3fe605e5dfabfe523b58064dc3d27", size = 6459789, upload-time = "2025-10-15T18:22:41.437Z" }, + { url = "https://files.pythonhosted.org/packages/9c/14/4448bb0b5e0f22dd865290536d20ec8a23b64e2d04280b89139f09a36bb6/pillow-12.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d120c38a42c234dc9a8c5de7ceaaf899cf33561956acb4941653f8bdc657aa79", size = 7130917, upload-time = "2025-10-15T18:22:43.152Z" }, + { url = "https://files.pythonhosted.org/packages/dd/ca/16c6926cc1c015845745d5c16c9358e24282f1e588237a4c36d2b30f182f/pillow-12.0.0-cp313-cp313-win32.whl", hash = "sha256:4cc6b3b2efff105c6a1656cfe59da4fdde2cda9af1c5e0b58529b24525d0a098", size = 6302391, upload-time = "2025-10-15T18:22:44.753Z" }, + { url = "https://files.pythonhosted.org/packages/6d/2a/dd43dcfd6dae9b6a49ee28a8eedb98c7d5ff2de94a5d834565164667b97b/pillow-12.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:4cf7fed4b4580601c4345ceb5d4cbf5a980d030fd5ad07c4d2ec589f95f09905", size = 7007477, upload-time = "2025-10-15T18:22:46.838Z" }, + { url = "https://files.pythonhosted.org/packages/77/f0/72ea067f4b5ae5ead653053212af05ce3705807906ba3f3e8f58ddf617e6/pillow-12.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:9f0b04c6b8584c2c193babcccc908b38ed29524b29dd464bc8801bf10d746a3a", size = 2435918, upload-time = "2025-10-15T18:22:48.399Z" }, + { url = "https://files.pythonhosted.org/packages/f5/5e/9046b423735c21f0487ea6cb5b10f89ea8f8dfbe32576fe052b5ba9d4e5b/pillow-12.0.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:7fa22993bac7b77b78cae22bad1e2a987ddf0d9015c63358032f84a53f23cdc3", size = 5251406, upload-time = "2025-10-15T18:22:49.905Z" }, + { url = "https://files.pythonhosted.org/packages/12/66/982ceebcdb13c97270ef7a56c3969635b4ee7cd45227fa707c94719229c5/pillow-12.0.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:f135c702ac42262573fe9714dfe99c944b4ba307af5eb507abef1667e2cbbced", size = 4653218, upload-time = "2025-10-15T18:22:51.587Z" }, + { url = "https://files.pythonhosted.org/packages/16/b3/81e625524688c31859450119bf12674619429cab3119eec0e30a7a1029cb/pillow-12.0.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c85de1136429c524e55cfa4e033b4a7940ac5c8ee4d9401cc2d1bf48154bbc7b", size = 6266564, upload-time = "2025-10-15T18:22:53.215Z" }, + { url = "https://files.pythonhosted.org/packages/98/59/dfb38f2a41240d2408096e1a76c671d0a105a4a8471b1871c6902719450c/pillow-12.0.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:38df9b4bfd3db902c9c2bd369bcacaf9d935b2fff73709429d95cc41554f7b3d", size = 8069260, upload-time = "2025-10-15T18:22:54.933Z" }, + { url = "https://files.pythonhosted.org/packages/dc/3d/378dbea5cd1874b94c312425ca77b0f47776c78e0df2df751b820c8c1d6c/pillow-12.0.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7d87ef5795da03d742bf49439f9ca4d027cde49c82c5371ba52464aee266699a", size = 6379248, upload-time = "2025-10-15T18:22:56.605Z" }, + { url = "https://files.pythonhosted.org/packages/84/b0/d525ef47d71590f1621510327acec75ae58c721dc071b17d8d652ca494d8/pillow-12.0.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:aff9e4d82d082ff9513bdd6acd4f5bd359f5b2c870907d2b0a9c5e10d40c88fe", size = 7066043, upload-time = "2025-10-15T18:22:58.53Z" }, + { url = "https://files.pythonhosted.org/packages/61/2c/aced60e9cf9d0cde341d54bf7932c9ffc33ddb4a1595798b3a5150c7ec4e/pillow-12.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:8d8ca2b210ada074d57fcee40c30446c9562e542fc46aedc19baf758a93532ee", size = 6490915, upload-time = "2025-10-15T18:23:00.582Z" }, + { url = "https://files.pythonhosted.org/packages/ef/26/69dcb9b91f4e59f8f34b2332a4a0a951b44f547c4ed39d3e4dcfcff48f89/pillow-12.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:99a7f72fb6249302aa62245680754862a44179b545ded638cf1fef59befb57ef", size = 7157998, upload-time = "2025-10-15T18:23:02.627Z" }, + { url = "https://files.pythonhosted.org/packages/61/2b/726235842220ca95fa441ddf55dd2382b52ab5b8d9c0596fe6b3f23dafe8/pillow-12.0.0-cp313-cp313t-win32.whl", hash = "sha256:4078242472387600b2ce8d93ade8899c12bf33fa89e55ec89fe126e9d6d5d9e9", size = 6306201, upload-time = "2025-10-15T18:23:04.709Z" }, + { url = "https://files.pythonhosted.org/packages/c0/3d/2afaf4e840b2df71344ababf2f8edd75a705ce500e5dc1e7227808312ae1/pillow-12.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:2c54c1a783d6d60595d3514f0efe9b37c8808746a66920315bfd34a938d7994b", size = 7013165, upload-time = "2025-10-15T18:23:06.46Z" }, + { url = "https://files.pythonhosted.org/packages/6f/75/3fa09aa5cf6ed04bee3fa575798ddf1ce0bace8edb47249c798077a81f7f/pillow-12.0.0-cp313-cp313t-win_arm64.whl", hash = "sha256:26d9f7d2b604cd23aba3e9faf795787456ac25634d82cd060556998e39c6fa47", size = 2437834, upload-time = "2025-10-15T18:23:08.194Z" }, + { url = "https://files.pythonhosted.org/packages/54/2a/9a8c6ba2c2c07b71bec92cf63e03370ca5e5f5c5b119b742bcc0cde3f9c5/pillow-12.0.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:beeae3f27f62308f1ddbcfb0690bf44b10732f2ef43758f169d5e9303165d3f9", size = 4045531, upload-time = "2025-10-15T18:23:10.121Z" }, + { url = "https://files.pythonhosted.org/packages/84/54/836fdbf1bfb3d66a59f0189ff0b9f5f666cee09c6188309300df04ad71fa/pillow-12.0.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:d4827615da15cd59784ce39d3388275ec093ae3ee8d7f0c089b76fa87af756c2", size = 4120554, upload-time = "2025-10-15T18:23:12.14Z" }, + { url = "https://files.pythonhosted.org/packages/0d/cd/16aec9f0da4793e98e6b54778a5fbce4f375c6646fe662e80600b8797379/pillow-12.0.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:3e42edad50b6909089750e65c91aa09aaf1e0a71310d383f11321b27c224ed8a", size = 3576812, upload-time = "2025-10-15T18:23:13.962Z" }, + { url = "https://files.pythonhosted.org/packages/f6/b7/13957fda356dc46339298b351cae0d327704986337c3c69bb54628c88155/pillow-12.0.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:e5d8efac84c9afcb40914ab49ba063d94f5dbdf5066db4482c66a992f47a3a3b", size = 5252689, upload-time = "2025-10-15T18:23:15.562Z" }, + { url = "https://files.pythonhosted.org/packages/fc/f5/eae31a306341d8f331f43edb2e9122c7661b975433de5e447939ae61c5da/pillow-12.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:266cd5f2b63ff316d5a1bba46268e603c9caf5606d44f38c2873c380950576ad", size = 4650186, upload-time = "2025-10-15T18:23:17.379Z" }, + { url = "https://files.pythonhosted.org/packages/86/62/2a88339aa40c4c77e79108facbd307d6091e2c0eb5b8d3cf4977cfca2fe6/pillow-12.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58eea5ebe51504057dd95c5b77d21700b77615ab0243d8152793dc00eb4faf01", size = 6230308, upload-time = "2025-10-15T18:23:18.971Z" }, + { url = "https://files.pythonhosted.org/packages/c7/33/5425a8992bcb32d1cb9fa3dd39a89e613d09a22f2c8083b7bf43c455f760/pillow-12.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f13711b1a5ba512d647a0e4ba79280d3a9a045aaf7e0cc6fbe96b91d4cdf6b0c", size = 8039222, upload-time = "2025-10-15T18:23:20.909Z" }, + { url = "https://files.pythonhosted.org/packages/d8/61/3f5d3b35c5728f37953d3eec5b5f3e77111949523bd2dd7f31a851e50690/pillow-12.0.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6846bd2d116ff42cba6b646edf5bf61d37e5cbd256425fa089fee4ff5c07a99e", size = 6346657, upload-time = "2025-10-15T18:23:23.077Z" }, + { url = "https://files.pythonhosted.org/packages/3a/be/ee90a3d79271227e0f0a33c453531efd6ed14b2e708596ba5dd9be948da3/pillow-12.0.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c98fa880d695de164b4135a52fd2e9cd7b7c90a9d8ac5e9e443a24a95ef9248e", size = 7038482, upload-time = "2025-10-15T18:23:25.005Z" }, + { url = "https://files.pythonhosted.org/packages/44/34/a16b6a4d1ad727de390e9bd9f19f5f669e079e5826ec0f329010ddea492f/pillow-12.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa3ed2a29a9e9d2d488b4da81dcb54720ac3104a20bf0bd273f1e4648aff5af9", size = 6461416, upload-time = "2025-10-15T18:23:27.009Z" }, + { url = "https://files.pythonhosted.org/packages/b6/39/1aa5850d2ade7d7ba9f54e4e4c17077244ff7a2d9e25998c38a29749eb3f/pillow-12.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d034140032870024e6b9892c692fe2968493790dd57208b2c37e3fb35f6df3ab", size = 7131584, upload-time = "2025-10-15T18:23:29.752Z" }, + { url = "https://files.pythonhosted.org/packages/bf/db/4fae862f8fad0167073a7733973bfa955f47e2cac3dc3e3e6257d10fab4a/pillow-12.0.0-cp314-cp314-win32.whl", hash = "sha256:1b1b133e6e16105f524a8dec491e0586d072948ce15c9b914e41cdadd209052b", size = 6400621, upload-time = "2025-10-15T18:23:32.06Z" }, + { url = "https://files.pythonhosted.org/packages/2b/24/b350c31543fb0107ab2599464d7e28e6f856027aadda995022e695313d94/pillow-12.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:8dc232e39d409036af549c86f24aed8273a40ffa459981146829a324e0848b4b", size = 7142916, upload-time = "2025-10-15T18:23:34.71Z" }, + { url = "https://files.pythonhosted.org/packages/0f/9b/0ba5a6fd9351793996ef7487c4fdbde8d3f5f75dbedc093bb598648fddf0/pillow-12.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:d52610d51e265a51518692045e372a4c363056130d922a7351429ac9f27e70b0", size = 2523836, upload-time = "2025-10-15T18:23:36.967Z" }, + { url = "https://files.pythonhosted.org/packages/f5/7a/ceee0840aebc579af529b523d530840338ecf63992395842e54edc805987/pillow-12.0.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1979f4566bb96c1e50a62d9831e2ea2d1211761e5662afc545fa766f996632f6", size = 5255092, upload-time = "2025-10-15T18:23:38.573Z" }, + { url = "https://files.pythonhosted.org/packages/44/76/20776057b4bfd1aef4eeca992ebde0f53a4dce874f3ae693d0ec90a4f79b/pillow-12.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b2e4b27a6e15b04832fe9bf292b94b5ca156016bbc1ea9c2c20098a0320d6cf6", size = 4653158, upload-time = "2025-10-15T18:23:40.238Z" }, + { url = "https://files.pythonhosted.org/packages/82/3f/d9ff92ace07be8836b4e7e87e6a4c7a8318d47c2f1463ffcf121fc57d9cb/pillow-12.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fb3096c30df99fd01c7bf8e544f392103d0795b9f98ba71a8054bcbf56b255f1", size = 6267882, upload-time = "2025-10-15T18:23:42.434Z" }, + { url = "https://files.pythonhosted.org/packages/9f/7a/4f7ff87f00d3ad33ba21af78bfcd2f032107710baf8280e3722ceec28cda/pillow-12.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7438839e9e053ef79f7112c881cef684013855016f928b168b81ed5835f3e75e", size = 8071001, upload-time = "2025-10-15T18:23:44.29Z" }, + { url = "https://files.pythonhosted.org/packages/75/87/fcea108944a52dad8cca0715ae6247e271eb80459364a98518f1e4f480c1/pillow-12.0.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d5c411a8eaa2299322b647cd932586b1427367fd3184ffbb8f7a219ea2041ca", size = 6380146, upload-time = "2025-10-15T18:23:46.065Z" }, + { url = "https://files.pythonhosted.org/packages/91/52/0d31b5e571ef5fd111d2978b84603fce26aba1b6092f28e941cb46570745/pillow-12.0.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d7e091d464ac59d2c7ad8e7e08105eaf9dafbc3883fd7265ffccc2baad6ac925", size = 7067344, upload-time = "2025-10-15T18:23:47.898Z" }, + { url = "https://files.pythonhosted.org/packages/7b/f4/2dd3d721f875f928d48e83bb30a434dee75a2531bca839bb996bb0aa5a91/pillow-12.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:792a2c0be4dcc18af9d4a2dfd8a11a17d5e25274a1062b0ec1c2d79c76f3e7f8", size = 6491864, upload-time = "2025-10-15T18:23:49.607Z" }, + { url = "https://files.pythonhosted.org/packages/30/4b/667dfcf3d61fc309ba5a15b141845cece5915e39b99c1ceab0f34bf1d124/pillow-12.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:afbefa430092f71a9593a99ab6a4e7538bc9eabbf7bf94f91510d3503943edc4", size = 7158911, upload-time = "2025-10-15T18:23:51.351Z" }, + { url = "https://files.pythonhosted.org/packages/a2/2f/16cabcc6426c32218ace36bf0d55955e813f2958afddbf1d391849fee9d1/pillow-12.0.0-cp314-cp314t-win32.whl", hash = "sha256:3830c769decf88f1289680a59d4f4c46c72573446352e2befec9a8512104fa52", size = 6408045, upload-time = "2025-10-15T18:23:53.177Z" }, + { url = "https://files.pythonhosted.org/packages/35/73/e29aa0c9c666cf787628d3f0dcf379f4791fba79f4936d02f8b37165bdf8/pillow-12.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:905b0365b210c73afb0ebe9101a32572152dfd1c144c7e28968a331b9217b94a", size = 7148282, upload-time = "2025-10-15T18:23:55.316Z" }, + { url = "https://files.pythonhosted.org/packages/c1/70/6b41bdcddf541b437bbb9f47f94d2db5d9ddef6c37ccab8c9107743748a4/pillow-12.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:99353a06902c2e43b43e8ff74ee65a7d90307d82370604746738a1e0661ccca7", size = 2525630, upload-time = "2025-10-15T18:23:57.149Z" }, + { url = "https://files.pythonhosted.org/packages/1d/b3/582327e6c9f86d037b63beebe981425d6811104cb443e8193824ef1a2f27/pillow-12.0.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:b22bd8c974942477156be55a768f7aa37c46904c175be4e158b6a86e3a6b7ca8", size = 5215068, upload-time = "2025-10-15T18:23:59.594Z" }, + { url = "https://files.pythonhosted.org/packages/fd/d6/67748211d119f3b6540baf90f92fae73ae51d5217b171b0e8b5f7e5d558f/pillow-12.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:805ebf596939e48dbb2e4922a1d3852cfc25c38160751ce02da93058b48d252a", size = 4614994, upload-time = "2025-10-15T18:24:01.669Z" }, + { url = "https://files.pythonhosted.org/packages/2d/e1/f8281e5d844c41872b273b9f2c34a4bf64ca08905668c8ae730eedc7c9fa/pillow-12.0.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cae81479f77420d217def5f54b5b9d279804d17e982e0f2fa19b1d1e14ab5197", size = 5246639, upload-time = "2025-10-15T18:24:03.403Z" }, + { url = "https://files.pythonhosted.org/packages/94/5a/0d8ab8ffe8a102ff5df60d0de5af309015163bf710c7bb3e8311dd3b3ad0/pillow-12.0.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeaefa96c768fc66818730b952a862235d68825c178f1b3ffd4efd7ad2edcb7c", size = 6986839, upload-time = "2025-10-15T18:24:05.344Z" }, + { url = "https://files.pythonhosted.org/packages/20/2e/3434380e8110b76cd9eb00a363c484b050f949b4bbe84ba770bb8508a02c/pillow-12.0.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:09f2d0abef9e4e2f349305a4f8cc784a8a6c2f58a8c4892eea13b10a943bd26e", size = 5313505, upload-time = "2025-10-15T18:24:07.137Z" }, + { url = "https://files.pythonhosted.org/packages/57/ca/5a9d38900d9d74785141d6580950fe705de68af735ff6e727cb911b64740/pillow-12.0.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bdee52571a343d721fb2eb3b090a82d959ff37fc631e3f70422e0c2e029f3e76", size = 5963654, upload-time = "2025-10-15T18:24:09.579Z" }, + { url = "https://files.pythonhosted.org/packages/95/7e/f896623c3c635a90537ac093c6a618ebe1a90d87206e42309cb5d98a1b9e/pillow-12.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:b290fd8aa38422444d4b50d579de197557f182ef1068b75f5aa8558638b8d0a5", size = 6997850, upload-time = "2025-10-15T18:24:11.495Z" }, +] + +[[package]] +name = "pint" +version = "0.25.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "flexcache" }, + { name = "flexparser" }, + { name = "platformdirs" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5f/74/bc3f671997158aef171194c3c4041e549946f4784b8690baa0626a0a164b/pint-0.25.2.tar.gz", hash = "sha256:85a45d1da8fe9c9f7477fed8aef59ad2b939af3d6611507e1a9cbdacdcd3450a", size = 254467, upload-time = "2025-11-06T22:08:09.184Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ab/88/550d41e81e6d43335603a960cd9c75c1d88f9cf01bc9d4ee8e86290aba7d/pint-0.25.2-py3-none-any.whl", hash = "sha256:ca35ab1d8eeeb6f7d9942b3cb5f34ca42b61cdd5fb3eae79531553dcca04dda7", size = 306762, upload-time = "2025-11-06T22:08:07.745Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/61/33/9611380c2bdb1225fdef633e2a9610622310fed35ab11dac9620972ee088/platformdirs-4.5.0.tar.gz", hash = "sha256:70ddccdd7c99fc5942e9fc25636a8b34d04c24b335100223152c2803e4063312", size = 21632, upload-time = "2025-10-08T17:44:48.791Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/73/cb/ac7874b3e5d58441674fb70742e6c374b28b0c7cb988d37d991cde47166c/platformdirs-4.5.0-py3-none-any.whl", hash = "sha256:e578a81bb873cbb89a41fcc904c7ef523cc18284b7e3b3ccf06aca1403b7ebd3", size = 18651, upload-time = "2025-10-08T17:44:47.223Z" }, +] + +[[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 = "pockets" +version = "0.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/8e/0601097cfcce2e8c2297db5080e9719f549c2bd4b94420ddc8d3f848bbca/pockets-0.9.1.tar.gz", hash = "sha256:9320f1a3c6f7a9133fe3b571f283bcf3353cd70249025ae8d618e40e9f7e92b3", size = 24993, upload-time = "2019-11-02T14:46:19.433Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/2f/a4583c70fbd8cd04910e2884bcc2bdd670e884061f7b4d70bc13e632a993/pockets-0.9.1-py2.py3-none-any.whl", hash = "sha256:68597934193c08a08eb2bf6a1d85593f627c22f9b065cc727a4f03f669d96d86", size = 26263, upload-time = "2019-11-02T14:46:17.814Z" }, +] + +[[package]] +name = "prometheus-client" +version = "0.23.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/23/53/3edb5d68ecf6b38fcbcc1ad28391117d2a322d9a1a3eff04bfdb184d8c3b/prometheus_client-0.23.1.tar.gz", hash = "sha256:6ae8f9081eaaaf153a2e959d2e6c4f4fb57b12ef76c8c7980202f1e57b48b2ce", size = 80481, upload-time = "2025-09-18T20:47:25.043Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b8/db/14bafcb4af2139e046d03fd00dea7873e48eafe18b7d2797e73d6681f210/prometheus_client-0.23.1-py3-none-any.whl", hash = "sha256:dd1913e6e76b59cfe44e7a4b83e01afc9873c1bdfd2ed8739f1e76aeca115f99", size = 61145, upload-time = "2025-09-18T20:47:23.875Z" }, +] + +[[package]] +name = "prompt-toolkit" +version = "3.0.52" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wcwidth" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a1/96/06e01a7b38dce6fe1db213e061a4602dd6032a8a97ef6c1a862537732421/prompt_toolkit-3.0.52.tar.gz", hash = "sha256:28cde192929c8e7321de85de1ddbe736f1375148b02f2e17edd840042b1be855", size = 434198, upload-time = "2025-08-27T15:24:02.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/03/0d3ce49e2505ae70cf43bc5bb3033955d2fc9f932163e84dc0779cc47f48/prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955", size = 391431, upload-time = "2025-08-27T15:23:59.498Z" }, +] + +[[package]] +name = "psutil" +version = "7.1.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e1/88/bdd0a41e5857d5d703287598cbf08dad90aed56774ea52ae071bae9071b6/psutil-7.1.3.tar.gz", hash = "sha256:6c86281738d77335af7aec228328e944b30930899ea760ecf33a4dba66be5e74", size = 489059, upload-time = "2025-11-02T12:25:54.619Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bd/93/0c49e776b8734fef56ec9c5c57f923922f2cf0497d62e0f419465f28f3d0/psutil-7.1.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0005da714eee687b4b8decd3d6cc7c6db36215c9e74e5ad2264b90c3df7d92dc", size = 239751, upload-time = "2025-11-02T12:25:58.161Z" }, + { url = "https://files.pythonhosted.org/packages/6f/8d/b31e39c769e70780f007969815195a55c81a63efebdd4dbe9e7a113adb2f/psutil-7.1.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:19644c85dcb987e35eeeaefdc3915d059dac7bd1167cdcdbf27e0ce2df0c08c0", size = 240368, upload-time = "2025-11-02T12:26:00.491Z" }, + { url = "https://files.pythonhosted.org/packages/62/61/23fd4acc3c9eebbf6b6c78bcd89e5d020cfde4acf0a9233e9d4e3fa698b4/psutil-7.1.3-cp313-cp313t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:95ef04cf2e5ba0ab9eaafc4a11eaae91b44f4ef5541acd2ee91d9108d00d59a7", size = 287134, upload-time = "2025-11-02T12:26:02.613Z" }, + { url = "https://files.pythonhosted.org/packages/30/1c/f921a009ea9ceb51aa355cb0cc118f68d354db36eae18174bab63affb3e6/psutil-7.1.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1068c303be3a72f8e18e412c5b2a8f6d31750fb152f9cb106b54090296c9d251", size = 289904, upload-time = "2025-11-02T12:26:05.207Z" }, + { url = "https://files.pythonhosted.org/packages/a6/82/62d68066e13e46a5116df187d319d1724b3f437ddd0f958756fc052677f4/psutil-7.1.3-cp313-cp313t-win_amd64.whl", hash = "sha256:18349c5c24b06ac5612c0428ec2a0331c26443d259e2a0144a9b24b4395b58fa", size = 249642, upload-time = "2025-11-02T12:26:07.447Z" }, + { url = "https://files.pythonhosted.org/packages/df/ad/c1cd5fe965c14a0392112f68362cfceb5230819dbb5b1888950d18a11d9f/psutil-7.1.3-cp313-cp313t-win_arm64.whl", hash = "sha256:c525ffa774fe4496282fb0b1187725793de3e7c6b29e41562733cae9ada151ee", size = 245518, upload-time = "2025-11-02T12:26:09.719Z" }, + { url = "https://files.pythonhosted.org/packages/2e/bb/6670bded3e3236eb4287c7bcdc167e9fae6e1e9286e437f7111caed2f909/psutil-7.1.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b403da1df4d6d43973dc004d19cee3b848e998ae3154cc8097d139b77156c353", size = 239843, upload-time = "2025-11-02T12:26:11.968Z" }, + { url = "https://files.pythonhosted.org/packages/b8/66/853d50e75a38c9a7370ddbeefabdd3d3116b9c31ef94dc92c6729bc36bec/psutil-7.1.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ad81425efc5e75da3f39b3e636293360ad8d0b49bed7df824c79764fb4ba9b8b", size = 240369, upload-time = "2025-11-02T12:26:14.358Z" }, + { url = "https://files.pythonhosted.org/packages/41/bd/313aba97cb5bfb26916dc29cf0646cbe4dd6a89ca69e8c6edce654876d39/psutil-7.1.3-cp314-cp314t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8f33a3702e167783a9213db10ad29650ebf383946e91bc77f28a5eb083496bc9", size = 288210, upload-time = "2025-11-02T12:26:16.699Z" }, + { url = "https://files.pythonhosted.org/packages/c2/fa/76e3c06e760927a0cfb5705eb38164254de34e9bd86db656d4dbaa228b04/psutil-7.1.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fac9cd332c67f4422504297889da5ab7e05fd11e3c4392140f7370f4208ded1f", size = 291182, upload-time = "2025-11-02T12:26:18.848Z" }, + { url = "https://files.pythonhosted.org/packages/0f/1d/5774a91607035ee5078b8fd747686ebec28a962f178712de100d00b78a32/psutil-7.1.3-cp314-cp314t-win_amd64.whl", hash = "sha256:3792983e23b69843aea49c8f5b8f115572c5ab64c153bada5270086a2123c7e7", size = 250466, upload-time = "2025-11-02T12:26:21.183Z" }, + { url = "https://files.pythonhosted.org/packages/00/ca/e426584bacb43a5cb1ac91fae1937f478cd8fbe5e4ff96574e698a2c77cd/psutil-7.1.3-cp314-cp314t-win_arm64.whl", hash = "sha256:31d77fcedb7529f27bb3a0472bea9334349f9a04160e8e6e5020f22c59893264", size = 245756, upload-time = "2025-11-02T12:26:23.148Z" }, + { url = "https://files.pythonhosted.org/packages/ef/94/46b9154a800253e7ecff5aaacdf8ebf43db99de4a2dfa18575b02548654e/psutil-7.1.3-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:2bdbcd0e58ca14996a42adf3621a6244f1bb2e2e528886959c72cf1e326677ab", size = 238359, upload-time = "2025-11-02T12:26:25.284Z" }, + { url = "https://files.pythonhosted.org/packages/68/3a/9f93cff5c025029a36d9a92fef47220ab4692ee7f2be0fba9f92813d0cb8/psutil-7.1.3-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:bc31fa00f1fbc3c3802141eede66f3a2d51d89716a194bf2cd6fc68310a19880", size = 239171, upload-time = "2025-11-02T12:26:27.23Z" }, + { url = "https://files.pythonhosted.org/packages/ce/b1/5f49af514f76431ba4eea935b8ad3725cdeb397e9245ab919dbc1d1dc20f/psutil-7.1.3-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3bb428f9f05c1225a558f53e30ccbad9930b11c3fc206836242de1091d3e7dd3", size = 263261, upload-time = "2025-11-02T12:26:29.48Z" }, + { url = "https://files.pythonhosted.org/packages/e0/95/992c8816a74016eb095e73585d747e0a8ea21a061ed3689474fabb29a395/psutil-7.1.3-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:56d974e02ca2c8eb4812c3f76c30e28836fffc311d55d979f1465c1feeb2b68b", size = 264635, upload-time = "2025-11-02T12:26:31.74Z" }, + { url = "https://files.pythonhosted.org/packages/55/4c/c3ed1a622b6ae2fd3c945a366e64eb35247a31e4db16cf5095e269e8eb3c/psutil-7.1.3-cp37-abi3-win_amd64.whl", hash = "sha256:f39c2c19fe824b47484b96f9692932248a54c43799a84282cfe58d05a6449efd", size = 247633, upload-time = "2025-11-02T12:26:33.887Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ad/33b2ccec09bf96c2b2ef3f9a6f66baac8253d7565d8839e024a6b905d45d/psutil-7.1.3-cp37-abi3-win_arm64.whl", hash = "sha256:bd0d69cee829226a761e92f28140bec9a5ee9d5b4fb4b0cc589068dbfff559b1", size = 244608, upload-time = "2025-11-02T12:26:36.136Z" }, +] + +[[package]] +name = "ptyprocess" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/20/e5/16ff212c1e452235a90aeb09066144d0c5a6a8c0834397e03f5224495c4e/ptyprocess-0.7.0.tar.gz", hash = "sha256:5c5d0a3b48ceee0b48485e0c26037c0acd7d29765ca3fbb5cb3831d347423220", size = 70762, upload-time = "2020-12-28T15:15:30.155Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/a6/858897256d0deac81a172289110f31629fc4cee19b6f01283303e18c8db3/ptyprocess-0.7.0-py2.py3-none-any.whl", hash = "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35", size = 13993, upload-time = "2020-12-28T15:15:28.35Z" }, +] + +[[package]] +name = "pure-eval" +version = "0.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/05/0a34433a064256a578f1783a10da6df098ceaa4a57bbeaa96a6c0352786b/pure_eval-0.2.3.tar.gz", hash = "sha256:5f4e983f40564c576c7c8635ae88db5956bb2229d7e9237d03b3c0b0190eaf42", size = 19752, upload-time = "2024-07-21T12:58:21.801Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8e/37/efad0257dc6e593a18957422533ff0f87ede7c9c6ea010a2177d738fb82f/pure_eval-0.2.3-py3-none-any.whl", hash = "sha256:1db8e35b67b3d218d818ae653e27f06c3aa420901fa7b081ca98cbedc874e0d0", size = 11842, upload-time = "2024-07-21T12:58:20.04Z" }, +] + +[[package]] +name = "py" +version = "1.11.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/98/ff/fec109ceb715d2a6b4c4a85a61af3b40c723a961e8828319fbcb15b868dc/py-1.11.0.tar.gz", hash = "sha256:51c75c4126074b472f746a24399ad32f6053d1b34b68d2fa41e558e6f4a98719", size = 207796, upload-time = "2021-11-04T17:17:01.377Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f6/f0/10642828a8dfb741e5f3fbaac830550a518a775c7fff6f04a007259b0548/py-1.11.0-py2.py3-none-any.whl", hash = "sha256:607c53218732647dff4acdfcd50cb62615cedf612e72d1724fb1a0cc6405b378", size = 98708, upload-time = "2021-11-04T17:17:00.152Z" }, +] + +[[package]] +name = "pybtex" +version = "0.25.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "latexcodec" }, + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5f/bc/c2be05ca72f8c103670e983df8be26d1e288bc6556f487fa8cccaa27779f/pybtex-0.25.1.tar.gz", hash = "sha256:9eaf90267c7e83e225af89fea65c370afbf65f458220d3946a9e3049e1eca491", size = 406157, upload-time = "2025-06-26T13:27:41.903Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/25/68/ceb5d6679baa326261f5d3e5113d9cfed6efef2810afd9f18bffb8ed312b/pybtex-0.25.1-py2.py3-none-any.whl", hash = "sha256:9053b0d619409a0a83f38abad5d9921de5f7b3ede00742beafcd9f10ad0d8c5c", size = 127437, upload-time = "2025-06-26T13:27:43.585Z" }, +] + +[[package]] +name = "pybtex-docutils" +version = "1.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "docutils" }, + { name = "pybtex" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7e/84/796ea94d26188a853660f81bded39f8de4cfe595130aef0dea1088705a11/pybtex-docutils-1.0.3.tar.gz", hash = "sha256:3a7ebdf92b593e00e8c1c538aa9a20bca5d92d84231124715acc964d51d93c6b", size = 18348, upload-time = "2023-08-22T18:47:54.833Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/11/b1/ce1f4596211efb5410e178a803f08e59b20bedb66837dcf41e21c54f9ec1/pybtex_docutils-1.0.3-py3-none-any.whl", hash = "sha256:8fd290d2ae48e32fcb54d86b0efb8d573198653c7e2447d5bec5847095f430b9", size = 6385, upload-time = "2023-08-22T06:43:20.513Z" }, +] + +[[package]] +name = "pycparser" +version = "2.23" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/cf/d2d3b9f5699fb1e4615c8e32ff220203e43b248e1dfcc6736ad9057731ca/pycparser-2.23.tar.gz", hash = "sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2", size = 173734, upload-time = "2025-09-09T13:23:47.91Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/e3/59cd50310fc9b59512193629e1984c1f95e5c8ae6e5d8c69532ccc65a7fe/pycparser-2.23-py3-none-any.whl", hash = "sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934", size = 118140, upload-time = "2025-09-09T13:23:46.651Z" }, +] + +[[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 = "pympler" +version = "1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pywin32", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/dd/37/c384631908029676d8e7213dd956bb686af303a80db7afbc9be36bc49495/pympler-1.1.tar.gz", hash = "sha256:1eaa867cb8992c218430f1708fdaccda53df064144d1c5656b1e6f1ee6000424", size = 179954, upload-time = "2024-06-28T19:56:06.563Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/4f/a6a2e2b202d7fd97eadfe90979845b8706676b41cbd3b42ba75adf329d1f/Pympler-1.1-py3-none-any.whl", hash = "sha256:5b223d6027d0619584116a0cbc28e8d2e378f7a79c1e5e024f9ff3b673c58506", size = 165766, upload-time = "2024-06-28T19:56:05.087Z" }, +] + +[[package]] +name = "pyparsing" +version = "3.2.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f2/a5/181488fc2b9d093e3972d2a472855aae8a03f000592dbfce716a512b3359/pyparsing-3.2.5.tar.gz", hash = "sha256:2df8d5b7b2802ef88e8d016a2eb9c7aeaa923529cd251ed0fe4608275d4105b6", size = 1099274, upload-time = "2025-09-21T04:11:06.277Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/5e/1aa9a93198c6b64513c9d7752de7422c06402de6600a8767da1524f9570b/pyparsing-3.2.5-py3-none-any.whl", hash = "sha256:e38a4f02064cf41fe6593d328d0512495ad1f3d8a91c4f73fc401b3079a59a5e", size = 113890, upload-time = "2025-09-21T04:11:04.117Z" }, +] + +[[package]] +name = "pyreadline3" +version = "3.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/49/4cea918a08f02817aabae639e3d0ac046fef9f9180518a3ad394e22da148/pyreadline3-3.5.4.tar.gz", hash = "sha256:8d57d53039a1c75adba8e50dd3d992b28143480816187ea5efbd5c78e6c885b7", size = 99839, upload-time = "2024-09-19T02:40:10.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/dc/491b7661614ab97483abf2056be1deee4dc2490ecbf7bff9ab5cdbac86e1/pyreadline3-3.5.4-py3-none-any.whl", hash = "sha256:eaf8e6cc3c49bcccf145fc6067ba8643d1df34d604a1ec0eccbf7a18e6d3fae6", size = 83178, upload-time = "2024-09-19T02:40:08.598Z" }, +] + +[[package]] +name = "pyrepl" +version = "0.11.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/08/4f/7088417e5465c53a30b918d30542aad89352ea0d635a5d077717c69a7d2b/pyrepl-0.11.4.tar.gz", hash = "sha256:efe988b4a6e5eed587e9769dc2269aeec2b6feec2f5d77995ee85b9ad7cf7063", size = 51089, upload-time = "2025-07-17T22:56:25.42Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bd/a5/ce97a778f096aaa27cfcb7ad09f1198cf73277dcab6c68a4b8f332d91e48/pyrepl-0.11.4-py3-none-any.whl", hash = "sha256:ac30d6340267a21c39e1b1934f92bca6b8735017d14b17e40f903b2d1563541d", size = 55596, upload-time = "2025-07-17T22:56:24.537Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/07/56/f013048ac4bc4c1d9be45afd4ab209ea62822fb1598f40687e6bf45dcea4/pytest-9.0.1.tar.gz", hash = "sha256:3e9c069ea73583e255c3b21cf46b8d3c56f6e3a1a8f6da94ccb0fcf57b9d73c8", size = 1564125, upload-time = "2025-11-12T13:05:09.333Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/8b/6300fb80f858cda1c51ffa17075df5d846757081d11ab4aa35cef9e6258b/pytest-9.0.1-py3-none-any.whl", hash = "sha256:67be0030d194df2dfa7b556f2e56fb3c3315bd5c8822c6951162b92b32ce7dad", size = 373668, upload-time = "2025-11-12T13:05:07.379Z" }, +] + +[[package]] +name = "pytest-check" +version = "2.6.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0b/b9/9b1636f27a75fbc613037020a148f6410c6f973170fa2189ee0059ae6e5b/pytest_check-2.6.2.tar.gz", hash = "sha256:5e2128405b14978ad74f21f3225fcc52bbb1cc7c41a55d5eaad9f1e3cd27e42e", size = 27988, upload-time = "2025-11-29T02:51:15.908Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ad/e2/26113dc55d59b2364e01afa25270eec29470dd52bf117ad857a3e64e688b/pytest_check-2.6.2-py3-none-any.whl", hash = "sha256:71762eb58f6b8a2d75afb9e6c8b800af7f634f67359d899a65ee48aaee087215", size = 16060, upload-time = "2025-11-29T02:51:14.521Z" }, +] + +[[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-datadir" +version = "1.3.1.dev9+g41e9a1e94" +source = { git = "https://github.com/salotz/pytest-datadir-extras.git?rev=41e9a1e94ba27efe28ce9d0019b1e0a73be34400#41e9a1e94ba27efe28ce9d0019b1e0a73be34400" } +dependencies = [ + { name = "pytest" }, +] + +[[package]] +name = "pytest-flakefinder" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ec/53/69c56a93ea057895b5761c5318455804873a6cd9d796d7c55d41c2358125/pytest-flakefinder-1.1.0.tar.gz", hash = "sha256:e2412a1920bdb8e7908783b20b3d57e9dad590cc39a93e8596ffdd493b403e0e", size = 6795, upload-time = "2022-10-26T18:27:54.243Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/8b/06787150d0fd0cbd3a8054262b56f91631c7778c1bc91bf4637e47f909ad/pytest_flakefinder-1.1.0-py2.py3-none-any.whl", hash = "sha256:741e0e8eea427052f5b8c89c2b3c3019a50c39a59ce4df6a305a2c2d9ba2bd13", size = 4644, upload-time = "2022-10-26T18:27:52.128Z" }, +] + +[[package]] +name = "pytest-print" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0c/0d/7167bc3a83d6862241b367bce92119c338929a46408b4ca9555c15cb5b40/pytest_print-1.2.0.tar.gz", hash = "sha256:62514752d788206789d04dde2e69218d04138068377e5355d389fcc186a8e51e", size = 11898, upload-time = "2025-10-09T19:19:55.919Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4c/80/02c87f95140e3621dde9a599c0b5684464f16acc0786ab8c8c5e929330c2/pytest_print-1.2.0-py3-none-any.whl", hash = "sha256:12969d579e72d549b71a68cef8a2d10ec47afdbaf92e32c8777d5d93d5a1cd42", size = 7151, upload-time = "2025-10-09T19:19:54.924Z" }, +] + +[[package]] +name = "pytest-rerunfailures" +version = "16.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/de/04/71e9520551fc8fe2cf5c1a1842e4e600265b0815f2016b7c27ec85688682/pytest_rerunfailures-16.1.tar.gz", hash = "sha256:c38b266db8a808953ebd71ac25c381cb1981a78ff9340a14bcb9f1b9bff1899e", size = 30889, upload-time = "2025-10-10T07:06:01.238Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/54/60eabb34445e3db3d3d874dc1dfa72751bfec3265bd611cb13c8b290adea/pytest_rerunfailures-16.1-py3-none-any.whl", hash = "sha256:5d11b12c0ca9a1665b5054052fcc1084f8deadd9328962745ef6b04e26382e86", size = 14093, upload-time = "2025-10-10T07:06:00.019Z" }, +] + +[[package]] +name = "pytest-shutil" +version = "1.8.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "execnet" }, + { name = "pytest" }, + { name = "six" }, + { name = "termcolor" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a0/8d/5a60eeced74608fd2304dafc9e26815988714f8a2cfa72da99c8c3c85515/pytest-shutil-1.8.1.tar.gz", hash = "sha256:7dcc02e8a372098d51a98737e7f662e6edfd75cec7070a11e904141de49d866b", size = 37554, upload-time = "2024-11-29T19:34:17.066Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b2/17/1b161657385982134b723dcd463483d498e54e286c5632d42602407e606f/pytest_shutil-1.8.1-py3-none-any.whl", hash = "sha256:0793e347e07b9296d814ce33377ed348ce376ffac76c0e57b28bf84235499f51", size = 15948, upload-time = "2024-11-29T19:33:26.349Z" }, +] + +[[package]] +name = "pytest-timeout" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/82/4c9ecabab13363e72d880f2fb504c5f750433b2b6f16e99f4ec21ada284c/pytest_timeout-2.4.0.tar.gz", hash = "sha256:7e68e90b01f9eff71332b25001f85c75495fc4e3a836701876183c4bcfd0540a", size = 17973, upload-time = "2025-05-05T19:44:34.99Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/b6/3127540ecdf1464a00e5a01ee60a1b09175f6913f0644ac748494d9c4b21/pytest_timeout-2.4.0-py3-none-any.whl", hash = "sha256:c42667e5cdadb151aeb5b26d114aff6bdf5a907f176a007a30b940d3d865b5c2", size = 14382, upload-time = "2025-05-05T19:44:33.502Z" }, +] + +[[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 = "python-json-logger" +version = "4.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/29/bf/eca6a3d43db1dae7070f70e160ab20b807627ba953663ba07928cdd3dc58/python_json_logger-4.0.0.tar.gz", hash = "sha256:f58e68eb46e1faed27e0f574a55a0455eecd7b8a5b88b85a784519ba3cff047f", size = 17683, upload-time = "2025-10-06T04:15:18.984Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/e5/fecf13f06e5e5f67e8837d777d1bc43fac0ed2b77a676804df5c34744727/python_json_logger-4.0.0-py3-none-any.whl", hash = "sha256:af09c9daf6a813aa4cc7180395f50f2a9e5fa056034c9953aec92e381c5ba1e2", size = 15548, upload-time = "2025-10-06T04:15:17.553Z" }, +] + +[[package]] +name = "pytokens" +version = "0.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/8d/a762be14dae1c3bf280202ba3172020b2b0b4c537f94427435f19c413b72/pytokens-0.3.0.tar.gz", hash = "sha256:2f932b14ed08de5fcf0b391ace2642f858f1394c0857202959000b68ed7a458a", size = 17644, upload-time = "2025-11-05T13:36:35.34Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/25/d9db8be44e205a124f6c98bc0324b2bb149b7431c53877fc6d1038dddaf5/pytokens-0.3.0-py3-none-any.whl", hash = "sha256:95b2b5eaf832e469d141a378872480ede3f251a5a5041b8ec6e581d3ac71bbf3", size = 12195, upload-time = "2025-11-05T13:36:33.183Z" }, +] + +[[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 = "pywin32" +version = "311" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7c/af/449a6a91e5d6db51420875c54f6aff7c97a86a3b13a0b4f1a5c13b988de3/pywin32-311-cp311-cp311-win32.whl", hash = "sha256:184eb5e436dea364dcd3d2316d577d625c0351bf237c4e9a5fabbcfa5a58b151", size = 8697031, upload-time = "2025-07-14T20:13:13.266Z" }, + { url = "https://files.pythonhosted.org/packages/51/8f/9bb81dd5bb77d22243d33c8397f09377056d5c687aa6d4042bea7fbf8364/pywin32-311-cp311-cp311-win_amd64.whl", hash = "sha256:3ce80b34b22b17ccbd937a6e78e7225d80c52f5ab9940fe0506a1a16f3dab503", size = 9508308, upload-time = "2025-07-14T20:13:15.147Z" }, + { url = "https://files.pythonhosted.org/packages/44/7b/9c2ab54f74a138c491aba1b1cd0795ba61f144c711daea84a88b63dc0f6c/pywin32-311-cp311-cp311-win_arm64.whl", hash = "sha256:a733f1388e1a842abb67ffa8e7aad0e70ac519e09b0f6a784e65a136ec7cefd2", size = 8703930, upload-time = "2025-07-14T20:13:16.945Z" }, + { url = "https://files.pythonhosted.org/packages/e7/ab/01ea1943d4eba0f850c3c61e78e8dd59757ff815ff3ccd0a84de5f541f42/pywin32-311-cp312-cp312-win32.whl", hash = "sha256:750ec6e621af2b948540032557b10a2d43b0cee2ae9758c54154d711cc852d31", size = 8706543, upload-time = "2025-07-14T20:13:20.765Z" }, + { url = "https://files.pythonhosted.org/packages/d1/a8/a0e8d07d4d051ec7502cd58b291ec98dcc0c3fff027caad0470b72cfcc2f/pywin32-311-cp312-cp312-win_amd64.whl", hash = "sha256:b8c095edad5c211ff31c05223658e71bf7116daa0ecf3ad85f3201ea3190d067", size = 9495040, upload-time = "2025-07-14T20:13:22.543Z" }, + { url = "https://files.pythonhosted.org/packages/ba/3a/2ae996277b4b50f17d61f0603efd8253cb2d79cc7ae159468007b586396d/pywin32-311-cp312-cp312-win_arm64.whl", hash = "sha256:e286f46a9a39c4a18b319c28f59b61de793654af2f395c102b4f819e584b5852", size = 8710102, upload-time = "2025-07-14T20:13:24.682Z" }, + { url = "https://files.pythonhosted.org/packages/a5/be/3fd5de0979fcb3994bfee0d65ed8ca9506a8a1260651b86174f6a86f52b3/pywin32-311-cp313-cp313-win32.whl", hash = "sha256:f95ba5a847cba10dd8c4d8fefa9f2a6cf283b8b88ed6178fa8a6c1ab16054d0d", size = 8705700, upload-time = "2025-07-14T20:13:26.471Z" }, + { url = "https://files.pythonhosted.org/packages/e3/28/e0a1909523c6890208295a29e05c2adb2126364e289826c0a8bc7297bd5c/pywin32-311-cp313-cp313-win_amd64.whl", hash = "sha256:718a38f7e5b058e76aee1c56ddd06908116d35147e133427e59a3983f703a20d", size = 9494700, upload-time = "2025-07-14T20:13:28.243Z" }, + { url = "https://files.pythonhosted.org/packages/04/bf/90339ac0f55726dce7d794e6d79a18a91265bdf3aa70b6b9ca52f35e022a/pywin32-311-cp313-cp313-win_arm64.whl", hash = "sha256:7b4075d959648406202d92a2310cb990fea19b535c7f4a78d3f5e10b926eeb8a", size = 8709318, upload-time = "2025-07-14T20:13:30.348Z" }, + { url = "https://files.pythonhosted.org/packages/c9/31/097f2e132c4f16d99a22bfb777e0fd88bd8e1c634304e102f313af69ace5/pywin32-311-cp314-cp314-win32.whl", hash = "sha256:b7a2c10b93f8986666d0c803ee19b5990885872a7de910fc460f9b0c2fbf92ee", size = 8840714, upload-time = "2025-07-14T20:13:32.449Z" }, + { url = "https://files.pythonhosted.org/packages/90/4b/07c77d8ba0e01349358082713400435347df8426208171ce297da32c313d/pywin32-311-cp314-cp314-win_amd64.whl", hash = "sha256:3aca44c046bd2ed8c90de9cb8427f581c479e594e99b5c0bb19b29c10fd6cb87", size = 9656800, upload-time = "2025-07-14T20:13:34.312Z" }, + { url = "https://files.pythonhosted.org/packages/c0/d2/21af5c535501a7233e734b8af901574572da66fcc254cb35d0609c9080dd/pywin32-311-cp314-cp314-win_arm64.whl", hash = "sha256:a508e2d9025764a8270f93111a970e1d0fbfc33f4153b388bb649b7eec4f9b42", size = 8932540, upload-time = "2025-07-14T20:13:36.379Z" }, +] + +[[package]] +name = "pywinpty" +version = "3.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/bb/a7cc2967c5c4eceb6cc49cfe39447d4bfc56e6c865e7c2249b6eb978935f/pywinpty-3.0.2.tar.gz", hash = "sha256:1505cc4cb248af42cb6285a65c9c2086ee9e7e574078ee60933d5d7fa86fb004", size = 30669, upload-time = "2025-10-03T21:16:29.205Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a6/a1/409c1651c9f874d598c10f51ff586c416625601df4bca315d08baec4c3e3/pywinpty-3.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:327790d70e4c841ebd9d0f295a780177149aeb405bca44c7115a3de5c2054b23", size = 2050304, upload-time = "2025-10-03T21:19:29.466Z" }, + { url = "https://files.pythonhosted.org/packages/02/4e/1098484e042c9485f56f16eb2b69b43b874bd526044ee401512234cf9e04/pywinpty-3.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:99fdd9b455f0ad6419aba6731a7a0d2f88ced83c3c94a80ff9533d95fa8d8a9e", size = 2050391, upload-time = "2025-10-03T21:19:01.642Z" }, + { url = "https://files.pythonhosted.org/packages/fc/19/b757fe28008236a4a713e813283721b8a40aa60cd7d3f83549f2e25a3155/pywinpty-3.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:18f78b81e4cfee6aabe7ea8688441d30247b73e52cd9657138015c5f4ee13a51", size = 2050057, upload-time = "2025-10-03T21:19:26.732Z" }, + { url = "https://files.pythonhosted.org/packages/cb/44/cbae12ecf6f4fa4129c36871fd09c6bef4f98d5f625ecefb5e2449765508/pywinpty-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:663383ecfab7fc382cc97ea5c4f7f0bb32c2f889259855df6ea34e5df42d305b", size = 2049874, upload-time = "2025-10-03T21:18:53.923Z" }, + { url = "https://files.pythonhosted.org/packages/ca/15/f12c6055e2d7a617d4d5820e8ac4ceaff849da4cb124640ef5116a230771/pywinpty-3.0.2-cp314-cp314-win_amd64.whl", hash = "sha256:28297cecc37bee9f24d8889e47231972d6e9e84f7b668909de54f36ca785029a", size = 2050386, upload-time = "2025-10-03T21:18:50.477Z" }, + { url = "https://files.pythonhosted.org/packages/de/24/c6907c5bb06043df98ad6a0a0ff5db2e0affcecbc3b15c42404393a3f72a/pywinpty-3.0.2-cp314-cp314t-win_amd64.whl", hash = "sha256:34b55ae9a1b671fe3eae071d86618110538e8eaad18fcb1531c0830b91a82767", size = 2049834, upload-time = "2025-10-03T21:19:25.688Z" }, +] + +[[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/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" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "pyzmq" +version = "27.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "implementation_name == 'pypy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/04/0b/3c9baedbdf613ecaa7aa07027780b8867f57b6293b6ee50de316c9f3222b/pyzmq-27.1.0.tar.gz", hash = "sha256:ac0765e3d44455adb6ddbf4417dcce460fc40a05978c08efdf2948072f6db540", size = 281750, upload-time = "2025-09-08T23:10:18.157Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/06/5d/305323ba86b284e6fcb0d842d6adaa2999035f70f8c38a9b6d21ad28c3d4/pyzmq-27.1.0-cp311-cp311-macosx_10_15_universal2.whl", hash = "sha256:226b091818d461a3bef763805e75685e478ac17e9008f49fce2d3e52b3d58b86", size = 1333328, upload-time = "2025-09-08T23:07:45.946Z" }, + { url = "https://files.pythonhosted.org/packages/bd/a0/fc7e78a23748ad5443ac3275943457e8452da67fda347e05260261108cbc/pyzmq-27.1.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:0790a0161c281ca9723f804871b4027f2e8b5a528d357c8952d08cd1a9c15581", size = 908803, upload-time = "2025-09-08T23:07:47.551Z" }, + { url = "https://files.pythonhosted.org/packages/7e/22/37d15eb05f3bdfa4abea6f6d96eb3bb58585fbd3e4e0ded4e743bc650c97/pyzmq-27.1.0-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c895a6f35476b0c3a54e3eb6ccf41bf3018de937016e6e18748317f25d4e925f", size = 668836, upload-time = "2025-09-08T23:07:49.436Z" }, + { url = "https://files.pythonhosted.org/packages/b1/c4/2a6fe5111a01005fc7af3878259ce17684fabb8852815eda6225620f3c59/pyzmq-27.1.0-cp311-cp311-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5bbf8d3630bf96550b3be8e1fc0fea5cbdc8d5466c1192887bd94869da17a63e", size = 857038, upload-time = "2025-09-08T23:07:51.234Z" }, + { url = "https://files.pythonhosted.org/packages/cb/eb/bfdcb41d0db9cd233d6fb22dc131583774135505ada800ebf14dfb0a7c40/pyzmq-27.1.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:15c8bd0fe0dabf808e2d7a681398c4e5ded70a551ab47482067a572c054c8e2e", size = 1657531, upload-time = "2025-09-08T23:07:52.795Z" }, + { url = "https://files.pythonhosted.org/packages/ab/21/e3180ca269ed4a0de5c34417dfe71a8ae80421198be83ee619a8a485b0c7/pyzmq-27.1.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:bafcb3dd171b4ae9f19ee6380dfc71ce0390fefaf26b504c0e5f628d7c8c54f2", size = 2034786, upload-time = "2025-09-08T23:07:55.047Z" }, + { url = "https://files.pythonhosted.org/packages/3b/b1/5e21d0b517434b7f33588ff76c177c5a167858cc38ef740608898cd329f2/pyzmq-27.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e829529fcaa09937189178115c49c504e69289abd39967cd8a4c215761373394", size = 1894220, upload-time = "2025-09-08T23:07:57.172Z" }, + { url = "https://files.pythonhosted.org/packages/03/f2/44913a6ff6941905efc24a1acf3d3cb6146b636c546c7406c38c49c403d4/pyzmq-27.1.0-cp311-cp311-win32.whl", hash = "sha256:6df079c47d5902af6db298ec92151db82ecb557af663098b92f2508c398bb54f", size = 567155, upload-time = "2025-09-08T23:07:59.05Z" }, + { url = "https://files.pythonhosted.org/packages/23/6d/d8d92a0eb270a925c9b4dd039c0b4dc10abc2fcbc48331788824ef113935/pyzmq-27.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:190cbf120fbc0fc4957b56866830def56628934a9d112aec0e2507aa6a032b97", size = 633428, upload-time = "2025-09-08T23:08:00.663Z" }, + { url = "https://files.pythonhosted.org/packages/ae/14/01afebc96c5abbbd713ecfc7469cfb1bc801c819a74ed5c9fad9a48801cb/pyzmq-27.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:eca6b47df11a132d1745eb3b5b5e557a7dae2c303277aa0e69c6ba91b8736e07", size = 559497, upload-time = "2025-09-08T23:08:02.15Z" }, + { url = "https://files.pythonhosted.org/packages/92/e7/038aab64a946d535901103da16b953c8c9cc9c961dadcbf3609ed6428d23/pyzmq-27.1.0-cp312-abi3-macosx_10_15_universal2.whl", hash = "sha256:452631b640340c928fa343801b0d07eb0c3789a5ffa843f6e1a9cee0ba4eb4fc", size = 1306279, upload-time = "2025-09-08T23:08:03.807Z" }, + { url = "https://files.pythonhosted.org/packages/e8/5e/c3c49fdd0f535ef45eefcc16934648e9e59dace4a37ee88fc53f6cd8e641/pyzmq-27.1.0-cp312-abi3-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:1c179799b118e554b66da67d88ed66cd37a169f1f23b5d9f0a231b4e8d44a113", size = 895645, upload-time = "2025-09-08T23:08:05.301Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e5/b0b2504cb4e903a74dcf1ebae157f9e20ebb6ea76095f6cfffea28c42ecd/pyzmq-27.1.0-cp312-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3837439b7f99e60312f0c926a6ad437b067356dc2bc2ec96eb395fd0fe804233", size = 652574, upload-time = "2025-09-08T23:08:06.828Z" }, + { url = "https://files.pythonhosted.org/packages/f8/9b/c108cdb55560eaf253f0cbdb61b29971e9fb34d9c3499b0e96e4e60ed8a5/pyzmq-27.1.0-cp312-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43ad9a73e3da1fab5b0e7e13402f0b2fb934ae1c876c51d0afff0e7c052eca31", size = 840995, upload-time = "2025-09-08T23:08:08.396Z" }, + { url = "https://files.pythonhosted.org/packages/c2/bb/b79798ca177b9eb0825b4c9998c6af8cd2a7f15a6a1a4272c1d1a21d382f/pyzmq-27.1.0-cp312-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:0de3028d69d4cdc475bfe47a6128eb38d8bc0e8f4d69646adfbcd840facbac28", size = 1642070, upload-time = "2025-09-08T23:08:09.989Z" }, + { url = "https://files.pythonhosted.org/packages/9c/80/2df2e7977c4ede24c79ae39dcef3899bfc5f34d1ca7a5b24f182c9b7a9ca/pyzmq-27.1.0-cp312-abi3-musllinux_1_2_i686.whl", hash = "sha256:cf44a7763aea9298c0aa7dbf859f87ed7012de8bda0f3977b6fb1d96745df856", size = 2021121, upload-time = "2025-09-08T23:08:11.907Z" }, + { url = "https://files.pythonhosted.org/packages/46/bd/2d45ad24f5f5ae7e8d01525eb76786fa7557136555cac7d929880519e33a/pyzmq-27.1.0-cp312-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:f30f395a9e6fbca195400ce833c731e7b64c3919aa481af4d88c3759e0cb7496", size = 1878550, upload-time = "2025-09-08T23:08:13.513Z" }, + { url = "https://files.pythonhosted.org/packages/e6/2f/104c0a3c778d7c2ab8190e9db4f62f0b6957b53c9d87db77c284b69f33ea/pyzmq-27.1.0-cp312-abi3-win32.whl", hash = "sha256:250e5436a4ba13885494412b3da5d518cd0d3a278a1ae640e113c073a5f88edd", size = 559184, upload-time = "2025-09-08T23:08:15.163Z" }, + { url = "https://files.pythonhosted.org/packages/fc/7f/a21b20d577e4100c6a41795842028235998a643b1ad406a6d4163ea8f53e/pyzmq-27.1.0-cp312-abi3-win_amd64.whl", hash = "sha256:9ce490cf1d2ca2ad84733aa1d69ce6855372cb5ce9223802450c9b2a7cba0ccf", size = 619480, upload-time = "2025-09-08T23:08:17.192Z" }, + { url = "https://files.pythonhosted.org/packages/78/c2/c012beae5f76b72f007a9e91ee9401cb88c51d0f83c6257a03e785c81cc2/pyzmq-27.1.0-cp312-abi3-win_arm64.whl", hash = "sha256:75a2f36223f0d535a0c919e23615fc85a1e23b71f40c7eb43d7b1dedb4d8f15f", size = 552993, upload-time = "2025-09-08T23:08:18.926Z" }, + { url = "https://files.pythonhosted.org/packages/60/cb/84a13459c51da6cec1b7b1dc1a47e6db6da50b77ad7fd9c145842750a011/pyzmq-27.1.0-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:93ad4b0855a664229559e45c8d23797ceac03183c7b6f5b4428152a6b06684a5", size = 1122436, upload-time = "2025-09-08T23:08:20.801Z" }, + { url = "https://files.pythonhosted.org/packages/dc/b6/94414759a69a26c3dd674570a81813c46a078767d931a6c70ad29fc585cb/pyzmq-27.1.0-cp313-cp313-android_24_x86_64.whl", hash = "sha256:fbb4f2400bfda24f12f009cba62ad5734148569ff4949b1b6ec3b519444342e6", size = 1156301, upload-time = "2025-09-08T23:08:22.47Z" }, + { url = "https://files.pythonhosted.org/packages/a5/ad/15906493fd40c316377fd8a8f6b1f93104f97a752667763c9b9c1b71d42d/pyzmq-27.1.0-cp313-cp313t-macosx_10_15_universal2.whl", hash = "sha256:e343d067f7b151cfe4eb3bb796a7752c9d369eed007b91231e817071d2c2fec7", size = 1341197, upload-time = "2025-09-08T23:08:24.286Z" }, + { url = "https://files.pythonhosted.org/packages/14/1d/d343f3ce13db53a54cb8946594e567410b2125394dafcc0268d8dda027e0/pyzmq-27.1.0-cp313-cp313t-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:08363b2011dec81c354d694bdecaef4770e0ae96b9afea70b3f47b973655cc05", size = 897275, upload-time = "2025-09-08T23:08:26.063Z" }, + { url = "https://files.pythonhosted.org/packages/69/2d/d83dd6d7ca929a2fc67d2c3005415cdf322af7751d773524809f9e585129/pyzmq-27.1.0-cp313-cp313t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d54530c8c8b5b8ddb3318f481297441af102517602b569146185fa10b63f4fa9", size = 660469, upload-time = "2025-09-08T23:08:27.623Z" }, + { url = "https://files.pythonhosted.org/packages/3e/cd/9822a7af117f4bc0f1952dbe9ef8358eb50a24928efd5edf54210b850259/pyzmq-27.1.0-cp313-cp313t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f3afa12c392f0a44a2414056d730eebc33ec0926aae92b5ad5cf26ebb6cc128", size = 847961, upload-time = "2025-09-08T23:08:29.672Z" }, + { url = "https://files.pythonhosted.org/packages/9a/12/f003e824a19ed73be15542f172fd0ec4ad0b60cf37436652c93b9df7c585/pyzmq-27.1.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c65047adafe573ff023b3187bb93faa583151627bc9c51fc4fb2c561ed689d39", size = 1650282, upload-time = "2025-09-08T23:08:31.349Z" }, + { url = "https://files.pythonhosted.org/packages/d5/4a/e82d788ed58e9a23995cee70dbc20c9aded3d13a92d30d57ec2291f1e8a3/pyzmq-27.1.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:90e6e9441c946a8b0a667356f7078d96411391a3b8f80980315455574177ec97", size = 2024468, upload-time = "2025-09-08T23:08:33.543Z" }, + { url = "https://files.pythonhosted.org/packages/d9/94/2da0a60841f757481e402b34bf4c8bf57fa54a5466b965de791b1e6f747d/pyzmq-27.1.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:add071b2d25f84e8189aaf0882d39a285b42fa3853016ebab234a5e78c7a43db", size = 1885394, upload-time = "2025-09-08T23:08:35.51Z" }, + { url = "https://files.pythonhosted.org/packages/4f/6f/55c10e2e49ad52d080dc24e37adb215e5b0d64990b57598abc2e3f01725b/pyzmq-27.1.0-cp313-cp313t-win32.whl", hash = "sha256:7ccc0700cfdf7bd487bea8d850ec38f204478681ea02a582a8da8171b7f90a1c", size = 574964, upload-time = "2025-09-08T23:08:37.178Z" }, + { url = "https://files.pythonhosted.org/packages/87/4d/2534970ba63dd7c522d8ca80fb92777f362c0f321900667c615e2067cb29/pyzmq-27.1.0-cp313-cp313t-win_amd64.whl", hash = "sha256:8085a9fba668216b9b4323be338ee5437a235fe275b9d1610e422ccc279733e2", size = 641029, upload-time = "2025-09-08T23:08:40.595Z" }, + { url = "https://files.pythonhosted.org/packages/f6/fa/f8aea7a28b0641f31d40dea42d7ef003fded31e184ef47db696bc74cd610/pyzmq-27.1.0-cp313-cp313t-win_arm64.whl", hash = "sha256:6bb54ca21bcfe361e445256c15eedf083f153811c37be87e0514934d6913061e", size = 561541, upload-time = "2025-09-08T23:08:42.668Z" }, + { url = "https://files.pythonhosted.org/packages/87/45/19efbb3000956e82d0331bafca5d9ac19ea2857722fa2caacefb6042f39d/pyzmq-27.1.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:ce980af330231615756acd5154f29813d553ea555485ae712c491cd483df6b7a", size = 1341197, upload-time = "2025-09-08T23:08:44.973Z" }, + { url = "https://files.pythonhosted.org/packages/48/43/d72ccdbf0d73d1343936296665826350cb1e825f92f2db9db3e61c2162a2/pyzmq-27.1.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:1779be8c549e54a1c38f805e56d2a2e5c009d26de10921d7d51cfd1c8d4632ea", size = 897175, upload-time = "2025-09-08T23:08:46.601Z" }, + { url = "https://files.pythonhosted.org/packages/2f/2e/a483f73a10b65a9ef0161e817321d39a770b2acf8bcf3004a28d90d14a94/pyzmq-27.1.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7200bb0f03345515df50d99d3db206a0a6bee1955fbb8c453c76f5bf0e08fb96", size = 660427, upload-time = "2025-09-08T23:08:48.187Z" }, + { url = "https://files.pythonhosted.org/packages/f5/d2/5f36552c2d3e5685abe60dfa56f91169f7a2d99bbaf67c5271022ab40863/pyzmq-27.1.0-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01c0e07d558b06a60773744ea6251f769cd79a41a97d11b8bf4ab8f034b0424d", size = 847929, upload-time = "2025-09-08T23:08:49.76Z" }, + { url = "https://files.pythonhosted.org/packages/c4/2a/404b331f2b7bf3198e9945f75c4c521f0c6a3a23b51f7a4a401b94a13833/pyzmq-27.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:80d834abee71f65253c91540445d37c4c561e293ba6e741b992f20a105d69146", size = 1650193, upload-time = "2025-09-08T23:08:51.7Z" }, + { url = "https://files.pythonhosted.org/packages/1c/0b/f4107e33f62a5acf60e3ded67ed33d79b4ce18de432625ce2fc5093d6388/pyzmq-27.1.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:544b4e3b7198dde4a62b8ff6685e9802a9a1ebf47e77478a5eb88eca2a82f2fd", size = 2024388, upload-time = "2025-09-08T23:08:53.393Z" }, + { url = "https://files.pythonhosted.org/packages/0d/01/add31fe76512642fd6e40e3a3bd21f4b47e242c8ba33efb6809e37076d9b/pyzmq-27.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cedc4c68178e59a4046f97eca31b148ddcf51e88677de1ef4e78cf06c5376c9a", size = 1885316, upload-time = "2025-09-08T23:08:55.702Z" }, + { url = "https://files.pythonhosted.org/packages/c4/59/a5f38970f9bf07cee96128de79590bb354917914a9be11272cfc7ff26af0/pyzmq-27.1.0-cp314-cp314t-win32.whl", hash = "sha256:1f0b2a577fd770aa6f053211a55d1c47901f4d537389a034c690291485e5fe92", size = 587472, upload-time = "2025-09-08T23:08:58.18Z" }, + { url = "https://files.pythonhosted.org/packages/70/d8/78b1bad170f93fcf5e3536e70e8fadac55030002275c9a29e8f5719185de/pyzmq-27.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:19c9468ae0437f8074af379e986c5d3d7d7bfe033506af442e8c879732bedbe0", size = 661401, upload-time = "2025-09-08T23:08:59.802Z" }, + { url = "https://files.pythonhosted.org/packages/81/d6/4bfbb40c9a0b42fc53c7cf442f6385db70b40f74a783130c5d0a5aa62228/pyzmq-27.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dc5dbf68a7857b59473f7df42650c621d7e8923fb03fa74a526890f4d33cc4d7", size = 575170, upload-time = "2025-09-08T23:09:01.418Z" }, + { url = "https://files.pythonhosted.org/packages/4c/c6/c4dcdecdbaa70969ee1fdced6d7b8f60cfabe64d25361f27ac4665a70620/pyzmq-27.1.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:18770c8d3563715387139060d37859c02ce40718d1faf299abddcdcc6a649066", size = 836265, upload-time = "2025-09-08T23:09:49.376Z" }, + { url = "https://files.pythonhosted.org/packages/3e/79/f38c92eeaeb03a2ccc2ba9866f0439593bb08c5e3b714ac1d553e5c96e25/pyzmq-27.1.0-pp311-pypy311_pp73-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:ac25465d42f92e990f8d8b0546b01c391ad431c3bf447683fdc40565941d0604", size = 800208, upload-time = "2025-09-08T23:09:51.073Z" }, + { url = "https://files.pythonhosted.org/packages/49/0e/3f0d0d335c6b3abb9b7b723776d0b21fa7f3a6c819a0db6097059aada160/pyzmq-27.1.0-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:53b40f8ae006f2734ee7608d59ed661419f087521edbfc2149c3932e9c14808c", size = 567747, upload-time = "2025-09-08T23:09:52.698Z" }, + { url = "https://files.pythonhosted.org/packages/a1/cf/f2b3784d536250ffd4be70e049f3b60981235d70c6e8ce7e3ef21e1adb25/pyzmq-27.1.0-pp311-pypy311_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f605d884e7c8be8fe1aa94e0a783bf3f591b84c24e4bc4f3e7564c82ac25e271", size = 747371, upload-time = "2025-09-08T23:09:54.563Z" }, + { url = "https://files.pythonhosted.org/packages/01/1b/5dbe84eefc86f48473947e2f41711aded97eecef1231f4558f1f02713c12/pyzmq-27.1.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:c9f7f6e13dff2e44a6afeaf2cf54cee5929ad64afaf4d40b50f93c58fc687355", size = 544862, upload-time = "2025-09-08T23:09:56.509Z" }, +] + +[[package]] +name = "referencing" +version = "0.37.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "rpds-py" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" }, +] + +[[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 = "rfc3339-validator" +version = "0.1.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/28/ea/a9387748e2d111c3c2b275ba970b735e04e15cdb1eb30693b6b5708c4dbd/rfc3339_validator-0.1.4.tar.gz", hash = "sha256:138a2abdf93304ad60530167e51d2dfb9549521a836871b88d7f4695d0022f6b", size = 5513, upload-time = "2021-05-12T16:37:54.178Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/44/4e421b96b67b2daff264473f7465db72fbdf36a07e05494f50300cc7b0c6/rfc3339_validator-0.1.4-py2.py3-none-any.whl", hash = "sha256:24f6ec1eda14ef823da9e36ec7113124b39c04d50a4d3d3a3c2859577e7791fa", size = 3490, upload-time = "2021-05-12T16:37:52.536Z" }, +] + +[[package]] +name = "rfc3986-validator" +version = "0.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/da/88/f270de456dd7d11dcc808abfa291ecdd3f45ff44e3b549ffa01b126464d0/rfc3986_validator-0.1.1.tar.gz", hash = "sha256:3d44bde7921b3b9ec3ae4e3adca370438eccebc676456449b145d533b240d055", size = 6760, upload-time = "2019-10-28T16:00:19.144Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/51/17023c0f8f1869d8806b979a2bffa3f861f26a3f1a66b094288323fba52f/rfc3986_validator-0.1.1-py2.py3-none-any.whl", hash = "sha256:2f235c432ef459970b4306369336b9d5dbdda31b510ca1e327636e01f528bfa9", size = 4242, upload-time = "2019-10-28T16:00:13.976Z" }, +] + +[[package]] +name = "rfc3987-syntax" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "lark" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2c/06/37c1a5557acf449e8e406a830a05bf885ac47d33270aec454ef78675008d/rfc3987_syntax-1.1.0.tar.gz", hash = "sha256:717a62cbf33cffdd16dfa3a497d81ce48a660ea691b1ddd7be710c22f00b4a0d", size = 14239, upload-time = "2025-07-18T01:05:05.015Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/71/44ce230e1b7fadd372515a97e32a83011f906ddded8d03e3c6aafbdedbb7/rfc3987_syntax-1.1.0-py3-none-any.whl", hash = "sha256:6c3d97604e4c5ce9f714898e05401a0445a641cfa276432b0a648c80856f6a3f", size = 8046, upload-time = "2025-07-18T01:05:03.843Z" }, +] + +[[package]] +name = "rich" +version = "14.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fb/d2/8920e102050a0de7bfabeb4c4614a49248cf8d5d7a8d01885fbb24dc767a/rich-14.2.0.tar.gz", hash = "sha256:73ff50c7c0c1c77c8243079283f4edb376f0f6442433aecb8ce7e6d0b92d1fe4", size = 219990, upload-time = "2025-10-09T14:16:53.064Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/25/7a/b0178788f8dc6cafce37a212c99565fa1fe7872c70c6c9c1e1a372d9d88f/rich-14.2.0-py3-none-any.whl", hash = "sha256:76bc51fe2e57d2b1be1f96c524b890b816e334ab4c1e45888799bfaab0021edd", size = 243393, upload-time = "2025-10-09T14:16:51.245Z" }, +] + +[[package]] +name = "rich-rst" +version = "1.3.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "docutils" }, + { name = "rich" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bc/6d/a506aaa4a9eaa945ed8ab2b7347859f53593864289853c5d6d62b77246e0/rich_rst-1.3.2.tar.gz", hash = "sha256:a1196fdddf1e364b02ec68a05e8ff8f6914fee10fbca2e6b6735f166bb0da8d4", size = 14936, upload-time = "2025-10-14T16:49:45.332Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/2f/b4530fbf948867702d0a3f27de4a6aab1d156f406d72852ab902c4d04de9/rich_rst-1.3.2-py3-none-any.whl", hash = "sha256:a99b4907cbe118cf9d18b0b44de272efa61f15117c61e39ebdc431baf5df722a", size = 12567, upload-time = "2025-10-14T16:49:42.953Z" }, +] + +[[package]] +name = "rpds-py" +version = "0.29.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/98/33/23b3b3419b6a3e0f559c7c0d2ca8fc1b9448382b25245033788785921332/rpds_py-0.29.0.tar.gz", hash = "sha256:fe55fe686908f50154d1dc599232016e50c243b438c3b7432f24e2895b0e5359", size = 69359, upload-time = "2025-11-16T14:50:39.532Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/36/ab/7fb95163a53ab122c74a7c42d2d2f012819af2cf3deb43fb0d5acf45cc1a/rpds_py-0.29.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:9b9c764a11fd637e0322a488560533112837f5334ffeb48b1be20f6d98a7b437", size = 372344, upload-time = "2025-11-16T14:47:57.279Z" }, + { url = "https://files.pythonhosted.org/packages/b3/45/f3c30084c03b0d0f918cb4c5ae2c20b0a148b51ba2b3f6456765b629bedd/rpds_py-0.29.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3fd2164d73812026ce970d44c3ebd51e019d2a26a4425a5dcbdfa93a34abc383", size = 363041, upload-time = "2025-11-16T14:47:58.908Z" }, + { url = "https://files.pythonhosted.org/packages/e3/e9/4d044a1662608c47a87cbb37b999d4d5af54c6d6ebdda93a4d8bbf8b2a10/rpds_py-0.29.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4a097b7f7f7274164566ae90a221fd725363c0e9d243e2e9ed43d195ccc5495c", size = 391775, upload-time = "2025-11-16T14:48:00.197Z" }, + { url = "https://files.pythonhosted.org/packages/50/c9/7616d3ace4e6731aeb6e3cd85123e03aec58e439044e214b9c5c60fd8eb1/rpds_py-0.29.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7cdc0490374e31cedefefaa1520d5fe38e82fde8748cbc926e7284574c714d6b", size = 405624, upload-time = "2025-11-16T14:48:01.496Z" }, + { url = "https://files.pythonhosted.org/packages/c2/e2/6d7d6941ca0843609fd2d72c966a438d6f22617baf22d46c3d2156c31350/rpds_py-0.29.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:89ca2e673ddd5bde9b386da9a0aac0cab0e76f40c8f0aaf0d6311b6bbf2aa311", size = 527894, upload-time = "2025-11-16T14:48:03.167Z" }, + { url = "https://files.pythonhosted.org/packages/8d/f7/aee14dc2db61bb2ae1e3068f134ca9da5f28c586120889a70ff504bb026f/rpds_py-0.29.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a5d9da3ff5af1ca1249b1adb8ef0573b94c76e6ae880ba1852f033bf429d4588", size = 412720, upload-time = "2025-11-16T14:48:04.413Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e2/2293f236e887c0360c2723d90c00d48dee296406994d6271faf1712e94ec/rpds_py-0.29.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8238d1d310283e87376c12f658b61e1ee23a14c0e54c7c0ce953efdbdc72deed", size = 392945, upload-time = "2025-11-16T14:48:06.252Z" }, + { url = "https://files.pythonhosted.org/packages/14/cd/ceea6147acd3bd1fd028d1975228f08ff19d62098078d5ec3eed49703797/rpds_py-0.29.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:2d6fb2ad1c36f91c4646989811e84b1ea5e0c3cf9690b826b6e32b7965853a63", size = 406385, upload-time = "2025-11-16T14:48:07.575Z" }, + { url = "https://files.pythonhosted.org/packages/52/36/fe4dead19e45eb77a0524acfdbf51e6cda597b26fc5b6dddbff55fbbb1a5/rpds_py-0.29.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:534dc9df211387547267ccdb42253aa30527482acb38dd9b21c5c115d66a96d2", size = 423943, upload-time = "2025-11-16T14:48:10.175Z" }, + { url = "https://files.pythonhosted.org/packages/a1/7b/4551510803b582fa4abbc8645441a2d15aa0c962c3b21ebb380b7e74f6a1/rpds_py-0.29.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d456e64724a075441e4ed648d7f154dc62e9aabff29bcdf723d0c00e9e1d352f", size = 574204, upload-time = "2025-11-16T14:48:11.499Z" }, + { url = "https://files.pythonhosted.org/packages/64/ba/071ccdd7b171e727a6ae079f02c26f75790b41555f12ca8f1151336d2124/rpds_py-0.29.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:a738f2da2f565989401bd6fd0b15990a4d1523c6d7fe83f300b7e7d17212feca", size = 600587, upload-time = "2025-11-16T14:48:12.822Z" }, + { url = "https://files.pythonhosted.org/packages/03/09/96983d48c8cf5a1e03c7d9cc1f4b48266adfb858ae48c7c2ce978dbba349/rpds_py-0.29.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a110e14508fd26fd2e472bb541f37c209409876ba601cf57e739e87d8a53cf95", size = 562287, upload-time = "2025-11-16T14:48:14.108Z" }, + { url = "https://files.pythonhosted.org/packages/40/f0/8c01aaedc0fa92156f0391f39ea93b5952bc0ec56b897763858f95da8168/rpds_py-0.29.0-cp311-cp311-win32.whl", hash = "sha256:923248a56dd8d158389a28934f6f69ebf89f218ef96a6b216a9be6861804d3f4", size = 221394, upload-time = "2025-11-16T14:48:15.374Z" }, + { url = "https://files.pythonhosted.org/packages/7e/a5/a8b21c54c7d234efdc83dc034a4d7cd9668e3613b6316876a29b49dece71/rpds_py-0.29.0-cp311-cp311-win_amd64.whl", hash = "sha256:539eb77eb043afcc45314d1be09ea6d6cafb3addc73e0547c171c6d636957f60", size = 235713, upload-time = "2025-11-16T14:48:16.636Z" }, + { url = "https://files.pythonhosted.org/packages/a7/1f/df3c56219523947b1be402fa12e6323fe6d61d883cf35d6cb5d5bb6db9d9/rpds_py-0.29.0-cp311-cp311-win_arm64.whl", hash = "sha256:bdb67151ea81fcf02d8f494703fb728d4d34d24556cbff5f417d74f6f5792e7c", size = 229157, upload-time = "2025-11-16T14:48:17.891Z" }, + { url = "https://files.pythonhosted.org/packages/3c/50/bc0e6e736d94e420df79be4deb5c9476b63165c87bb8f19ef75d100d21b3/rpds_py-0.29.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a0891cfd8db43e085c0ab93ab7e9b0c8fee84780d436d3b266b113e51e79f954", size = 376000, upload-time = "2025-11-16T14:48:19.141Z" }, + { url = "https://files.pythonhosted.org/packages/3e/3a/46676277160f014ae95f24de53bed0e3b7ea66c235e7de0b9df7bd5d68ba/rpds_py-0.29.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3897924d3f9a0361472d884051f9a2460358f9a45b1d85a39a158d2f8f1ad71c", size = 360575, upload-time = "2025-11-16T14:48:20.443Z" }, + { url = "https://files.pythonhosted.org/packages/75/ba/411d414ed99ea1afdd185bbabeeaac00624bd1e4b22840b5e9967ade6337/rpds_py-0.29.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2a21deb8e0d1571508c6491ce5ea5e25669b1dd4adf1c9d64b6314842f708b5d", size = 392159, upload-time = "2025-11-16T14:48:22.12Z" }, + { url = "https://files.pythonhosted.org/packages/8f/b1/e18aa3a331f705467a48d0296778dc1fea9d7f6cf675bd261f9a846c7e90/rpds_py-0.29.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9efe71687d6427737a0a2de9ca1c0a216510e6cd08925c44162be23ed7bed2d5", size = 410602, upload-time = "2025-11-16T14:48:23.563Z" }, + { url = "https://files.pythonhosted.org/packages/2f/6c/04f27f0c9f2299274c76612ac9d2c36c5048bb2c6c2e52c38c60bf3868d9/rpds_py-0.29.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:40f65470919dc189c833e86b2c4bd21bd355f98436a2cef9e0a9a92aebc8e57e", size = 515808, upload-time = "2025-11-16T14:48:24.949Z" }, + { url = "https://files.pythonhosted.org/packages/83/56/a8412aa464fb151f8bc0d91fb0bb888adc9039bd41c1c6ba8d94990d8cf8/rpds_py-0.29.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:def48ff59f181130f1a2cb7c517d16328efac3ec03951cca40c1dc2049747e83", size = 416015, upload-time = "2025-11-16T14:48:26.782Z" }, + { url = "https://files.pythonhosted.org/packages/04/4c/f9b8a05faca3d9e0a6397c90d13acb9307c9792b2bff621430c58b1d6e76/rpds_py-0.29.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ad7bd570be92695d89285a4b373006930715b78d96449f686af422debb4d3949", size = 395325, upload-time = "2025-11-16T14:48:28.055Z" }, + { url = "https://files.pythonhosted.org/packages/34/60/869f3bfbf8ed7b54f1ad9a5543e0fdffdd40b5a8f587fe300ee7b4f19340/rpds_py-0.29.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:5a572911cd053137bbff8e3a52d31c5d2dba51d3a67ad902629c70185f3f2181", size = 410160, upload-time = "2025-11-16T14:48:29.338Z" }, + { url = "https://files.pythonhosted.org/packages/91/aa/e5b496334e3aba4fe4c8a80187b89f3c1294c5c36f2a926da74338fa5a73/rpds_py-0.29.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d583d4403bcbf10cffc3ab5cee23d7643fcc960dff85973fd3c2d6c86e8dbb0c", size = 425309, upload-time = "2025-11-16T14:48:30.691Z" }, + { url = "https://files.pythonhosted.org/packages/85/68/4e24a34189751ceb6d66b28f18159922828dd84155876551f7ca5b25f14f/rpds_py-0.29.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:070befbb868f257d24c3bb350dbd6e2f645e83731f31264b19d7231dd5c396c7", size = 574644, upload-time = "2025-11-16T14:48:31.964Z" }, + { url = "https://files.pythonhosted.org/packages/8c/cf/474a005ea4ea9c3b4f17b6108b6b13cebfc98ebaff11d6e1b193204b3a93/rpds_py-0.29.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:fc935f6b20b0c9f919a8ff024739174522abd331978f750a74bb68abd117bd19", size = 601605, upload-time = "2025-11-16T14:48:33.252Z" }, + { url = "https://files.pythonhosted.org/packages/f4/b1/c56f6a9ab8c5f6bb5c65c4b5f8229167a3a525245b0773f2c0896686b64e/rpds_py-0.29.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8c5a8ecaa44ce2d8d9d20a68a2483a74c07f05d72e94a4dff88906c8807e77b0", size = 564593, upload-time = "2025-11-16T14:48:34.643Z" }, + { url = "https://files.pythonhosted.org/packages/b3/13/0494cecce4848f68501e0a229432620b4b57022388b071eeff95f3e1e75b/rpds_py-0.29.0-cp312-cp312-win32.whl", hash = "sha256:ba5e1aeaf8dd6d8f6caba1f5539cddda87d511331714b7b5fc908b6cfc3636b7", size = 223853, upload-time = "2025-11-16T14:48:36.419Z" }, + { url = "https://files.pythonhosted.org/packages/1f/6a/51e9aeb444a00cdc520b032a28b07e5f8dc7bc328b57760c53e7f96997b4/rpds_py-0.29.0-cp312-cp312-win_amd64.whl", hash = "sha256:b5f6134faf54b3cb83375db0f113506f8b7770785be1f95a631e7e2892101977", size = 239895, upload-time = "2025-11-16T14:48:37.956Z" }, + { url = "https://files.pythonhosted.org/packages/d1/d4/8bce56cdad1ab873e3f27cb31c6a51d8f384d66b022b820525b879f8bed1/rpds_py-0.29.0-cp312-cp312-win_arm64.whl", hash = "sha256:b016eddf00dca7944721bf0cd85b6af7f6c4efaf83ee0b37c4133bd39757a8c7", size = 230321, upload-time = "2025-11-16T14:48:39.71Z" }, + { url = "https://files.pythonhosted.org/packages/fd/d9/c5de60d9d371bbb186c3e9bf75f4fc5665e11117a25a06a6b2e0afb7380e/rpds_py-0.29.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1585648d0760b88292eecab5181f5651111a69d90eff35d6b78aa32998886a61", size = 375710, upload-time = "2025-11-16T14:48:41.063Z" }, + { url = "https://files.pythonhosted.org/packages/b3/b3/0860cdd012291dc21272895ce107f1e98e335509ba986dd83d72658b82b9/rpds_py-0.29.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:521807963971a23996ddaf764c682b3e46459b3c58ccd79fefbe16718db43154", size = 360582, upload-time = "2025-11-16T14:48:42.423Z" }, + { url = "https://files.pythonhosted.org/packages/92/8a/a18c2f4a61b3407e56175f6aab6deacdf9d360191a3d6f38566e1eaf7266/rpds_py-0.29.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a8896986efaa243ab713c69e6491a4138410f0fe36f2f4c71e18bd5501e8014", size = 391172, upload-time = "2025-11-16T14:48:43.75Z" }, + { url = "https://files.pythonhosted.org/packages/fd/49/e93354258508c50abc15cdcd5fcf7ac4117f67bb6233ad7859f75e7372a0/rpds_py-0.29.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1d24564a700ef41480a984c5ebed62b74e6ce5860429b98b1fede76049e953e6", size = 409586, upload-time = "2025-11-16T14:48:45.498Z" }, + { url = "https://files.pythonhosted.org/packages/5a/8d/a27860dae1c19a6bdc901f90c81f0d581df1943355802961a57cdb5b6cd1/rpds_py-0.29.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e6596b93c010d386ae46c9fba9bfc9fc5965fa8228edeac51576299182c2e31c", size = 516339, upload-time = "2025-11-16T14:48:47.308Z" }, + { url = "https://files.pythonhosted.org/packages/fc/ad/a75e603161e79b7110c647163d130872b271c6b28712c803c65d492100f7/rpds_py-0.29.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5cc58aac218826d054c7da7f95821eba94125d88be673ff44267bb89d12a5866", size = 416201, upload-time = "2025-11-16T14:48:48.615Z" }, + { url = "https://files.pythonhosted.org/packages/b9/42/555b4ee17508beafac135c8b450816ace5a96194ce97fefc49d58e5652ea/rpds_py-0.29.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:de73e40ebc04dd5d9556f50180395322193a78ec247e637e741c1b954810f295", size = 395095, upload-time = "2025-11-16T14:48:50.027Z" }, + { url = "https://files.pythonhosted.org/packages/cd/f0/c90b671b9031e800ec45112be42ea9f027f94f9ac25faaac8770596a16a1/rpds_py-0.29.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:295ce5ac7f0cf69a651ea75c8f76d02a31f98e5698e82a50a5f4d4982fbbae3b", size = 410077, upload-time = "2025-11-16T14:48:51.515Z" }, + { url = "https://files.pythonhosted.org/packages/3d/80/9af8b640b81fe21e6f718e9dec36c0b5f670332747243130a5490f292245/rpds_py-0.29.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1ea59b23ea931d494459c8338056fe7d93458c0bf3ecc061cd03916505369d55", size = 424548, upload-time = "2025-11-16T14:48:53.237Z" }, + { url = "https://files.pythonhosted.org/packages/e4/0b/b5647446e991736e6a495ef510e6710df91e880575a586e763baeb0aa770/rpds_py-0.29.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f49d41559cebd608042fdcf54ba597a4a7555b49ad5c1c0c03e0af82692661cd", size = 573661, upload-time = "2025-11-16T14:48:54.769Z" }, + { url = "https://files.pythonhosted.org/packages/f7/b3/1b1c9576839ff583d1428efbf59f9ee70498d8ce6c0b328ac02f1e470879/rpds_py-0.29.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:05a2bd42768ea988294ca328206efbcc66e220d2d9b7836ee5712c07ad6340ea", size = 600937, upload-time = "2025-11-16T14:48:56.247Z" }, + { url = "https://files.pythonhosted.org/packages/6c/7b/b6cfca2f9fee4c4494ce54f7fb1b9f578867495a9aa9fc0d44f5f735c8e0/rpds_py-0.29.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:33ca7bdfedd83339ca55da3a5e1527ee5870d4b8369456b5777b197756f3ca22", size = 564496, upload-time = "2025-11-16T14:48:57.691Z" }, + { url = "https://files.pythonhosted.org/packages/b9/fb/ba29ec7f0f06eb801bac5a23057a9ff7670623b5e8013bd59bec4aa09de8/rpds_py-0.29.0-cp313-cp313-win32.whl", hash = "sha256:20c51ae86a0bb9accc9ad4e6cdeec58d5ebb7f1b09dd4466331fc65e1766aae7", size = 223126, upload-time = "2025-11-16T14:48:59.058Z" }, + { url = "https://files.pythonhosted.org/packages/3c/6b/0229d3bed4ddaa409e6d90b0ae967ed4380e4bdd0dad6e59b92c17d42457/rpds_py-0.29.0-cp313-cp313-win_amd64.whl", hash = "sha256:6410e66f02803600edb0b1889541f4b5cc298a5ccda0ad789cc50ef23b54813e", size = 239771, upload-time = "2025-11-16T14:49:00.872Z" }, + { url = "https://files.pythonhosted.org/packages/e4/38/d2868f058b164f8efd89754d85d7b1c08b454f5c07ac2e6cc2e9bd4bd05b/rpds_py-0.29.0-cp313-cp313-win_arm64.whl", hash = "sha256:56838e1cd9174dc23c5691ee29f1d1be9eab357f27efef6bded1328b23e1ced2", size = 229994, upload-time = "2025-11-16T14:49:02.673Z" }, + { url = "https://files.pythonhosted.org/packages/52/91/5de91c5ec7d41759beec9b251630824dbb8e32d20c3756da1a9a9d309709/rpds_py-0.29.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:37d94eadf764d16b9a04307f2ab1d7af6dc28774bbe0535c9323101e14877b4c", size = 365886, upload-time = "2025-11-16T14:49:04.133Z" }, + { url = "https://files.pythonhosted.org/packages/85/7c/415d8c1b016d5f47ecec5145d9d6d21002d39dce8761b30f6c88810b455a/rpds_py-0.29.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:d472cf73efe5726a067dce63eebe8215b14beabea7c12606fd9994267b3cfe2b", size = 355262, upload-time = "2025-11-16T14:49:05.543Z" }, + { url = "https://files.pythonhosted.org/packages/3d/14/bf83e2daa4f980e4dc848aed9299792a8b84af95e12541d9e7562f84a6ef/rpds_py-0.29.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:72fdfd5ff8992e4636621826371e3ac5f3e3b8323e9d0e48378e9c13c3dac9d0", size = 384826, upload-time = "2025-11-16T14:49:07.301Z" }, + { url = "https://files.pythonhosted.org/packages/33/b8/53330c50a810ae22b4fbba5e6cf961b68b9d72d9bd6780a7c0a79b070857/rpds_py-0.29.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2549d833abdf8275c901313b9e8ff8fba57e50f6a495035a2a4e30621a2f7cc4", size = 394234, upload-time = "2025-11-16T14:49:08.782Z" }, + { url = "https://files.pythonhosted.org/packages/cc/32/01e2e9645cef0e584f518cfde4567563e57db2257244632b603f61b40e50/rpds_py-0.29.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4448dad428f28a6a767c3e3b80cde3446a22a0efbddaa2360f4bb4dc836d0688", size = 520008, upload-time = "2025-11-16T14:49:10.253Z" }, + { url = "https://files.pythonhosted.org/packages/98/c3/0d1b95a81affae2b10f950782e33a1fd2edd6ce2a479966cac98c9a66f57/rpds_py-0.29.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:115f48170fd4296a33938d8c11f697f5f26e0472e43d28f35624764173a60e4d", size = 409569, upload-time = "2025-11-16T14:49:12.478Z" }, + { url = "https://files.pythonhosted.org/packages/fa/60/aa3b8678f3f009f675b99174fa2754302a7fbfe749162e8043d111de2d88/rpds_py-0.29.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e5bb73ffc029820f4348e9b66b3027493ae00bca6629129cd433fd7a76308ee", size = 385188, upload-time = "2025-11-16T14:49:13.88Z" }, + { url = "https://files.pythonhosted.org/packages/92/02/5546c1c8aa89c18d40c1fcffdcc957ba730dee53fb7c3ca3a46f114761d2/rpds_py-0.29.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:b1581fcde18fcdf42ea2403a16a6b646f8eb1e58d7f90a0ce693da441f76942e", size = 398587, upload-time = "2025-11-16T14:49:15.339Z" }, + { url = "https://files.pythonhosted.org/packages/6c/e0/ad6eeaf47e236eba052fa34c4073078b9e092bd44da6bbb35aaae9580669/rpds_py-0.29.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:16e9da2bda9eb17ea318b4c335ec9ac1818e88922cbe03a5743ea0da9ecf74fb", size = 416641, upload-time = "2025-11-16T14:49:16.832Z" }, + { url = "https://files.pythonhosted.org/packages/1a/93/0acedfd50ad9cdd3879c615a6dc8c5f1ce78d2fdf8b87727468bb5bb4077/rpds_py-0.29.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:28fd300326dd21198f311534bdb6d7e989dd09b3418b3a91d54a0f384c700967", size = 566683, upload-time = "2025-11-16T14:49:18.342Z" }, + { url = "https://files.pythonhosted.org/packages/62/53/8c64e0f340a9e801459fc6456821abc15b3582cb5dc3932d48705a9d9ac7/rpds_py-0.29.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:2aba991e041d031c7939e1358f583ae405a7bf04804ca806b97a5c0e0af1ea5e", size = 592730, upload-time = "2025-11-16T14:49:19.767Z" }, + { url = "https://files.pythonhosted.org/packages/85/ef/3109b6584f8c4b0d2490747c916df833c127ecfa82be04d9a40a376f2090/rpds_py-0.29.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:7f437026dbbc3f08c99cc41a5b2570c6e1a1ddbe48ab19a9b814254128d4ea7a", size = 557361, upload-time = "2025-11-16T14:49:21.574Z" }, + { url = "https://files.pythonhosted.org/packages/ff/3b/61586475e82d57f01da2c16edb9115a618afe00ce86fe1b58936880b15af/rpds_py-0.29.0-cp313-cp313t-win32.whl", hash = "sha256:6e97846e9800a5d0fe7be4d008f0c93d0feeb2700da7b1f7528dabafb31dfadb", size = 211227, upload-time = "2025-11-16T14:49:23.03Z" }, + { url = "https://files.pythonhosted.org/packages/3b/3a/12dc43f13594a54ea0c9d7e9d43002116557330e3ad45bc56097ddf266e2/rpds_py-0.29.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f49196aec7c4b406495f60e6f947ad71f317a765f956d74bbd83996b9edc0352", size = 225248, upload-time = "2025-11-16T14:49:24.841Z" }, + { url = "https://files.pythonhosted.org/packages/89/b1/0b1474e7899371d9540d3bbb2a499a3427ae1fc39c998563fe9035a1073b/rpds_py-0.29.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:394d27e4453d3b4d82bb85665dc1fcf4b0badc30fc84282defed71643b50e1a1", size = 363731, upload-time = "2025-11-16T14:49:26.683Z" }, + { url = "https://files.pythonhosted.org/packages/28/12/3b7cf2068d0a334ed1d7b385a9c3c8509f4c2bcba3d4648ea71369de0881/rpds_py-0.29.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:55d827b2ae95425d3be9bc9a5838b6c29d664924f98146557f7715e331d06df8", size = 354343, upload-time = "2025-11-16T14:49:28.24Z" }, + { url = "https://files.pythonhosted.org/packages/eb/73/5afcf8924bc02a749416eda64e17ac9c9b28f825f4737385295a0e99b0c1/rpds_py-0.29.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fc31a07ed352e5462d3ee1b22e89285f4ce97d5266f6d1169da1142e78045626", size = 385406, upload-time = "2025-11-16T14:49:29.943Z" }, + { url = "https://files.pythonhosted.org/packages/c8/37/5db736730662508535221737a21563591b6f43c77f2e388951c42f143242/rpds_py-0.29.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c4695dd224212f6105db7ea62197144230b808d6b2bba52238906a2762f1d1e7", size = 396162, upload-time = "2025-11-16T14:49:31.833Z" }, + { url = "https://files.pythonhosted.org/packages/70/0d/491c1017d14f62ce7bac07c32768d209a50ec567d76d9f383b4cfad19b80/rpds_py-0.29.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fcae1770b401167f8b9e1e3f566562e6966ffa9ce63639916248a9e25fa8a244", size = 517719, upload-time = "2025-11-16T14:49:33.804Z" }, + { url = "https://files.pythonhosted.org/packages/d7/25/b11132afcb17cd5d82db173f0c8dab270ffdfaba43e5ce7a591837ae9649/rpds_py-0.29.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:90f30d15f45048448b8da21c41703b31c61119c06c216a1bf8c245812a0f0c17", size = 409498, upload-time = "2025-11-16T14:49:35.222Z" }, + { url = "https://files.pythonhosted.org/packages/0f/7d/e6543cedfb2e6403a1845710a5ab0e0ccf8fc288e0b5af9a70bfe2c12053/rpds_py-0.29.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:44a91e0ab77bdc0004b43261a4b8cd6d6b451e8d443754cfda830002b5745b32", size = 382743, upload-time = "2025-11-16T14:49:36.704Z" }, + { url = "https://files.pythonhosted.org/packages/75/11/a4ebc9f654293ae9fefb83b2b6be7f3253e85ea42a5db2f77d50ad19aaeb/rpds_py-0.29.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:4aa195e5804d32c682e453b34474f411ca108e4291c6a0f824ebdc30a91c973c", size = 400317, upload-time = "2025-11-16T14:49:39.132Z" }, + { url = "https://files.pythonhosted.org/packages/52/18/97677a60a81c7f0e5f64e51fb3f8271c5c8fcabf3a2df18e97af53d7c2bf/rpds_py-0.29.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7971bdb7bf4ee0f7e6f67fa4c7fbc6019d9850cc977d126904392d363f6f8318", size = 416979, upload-time = "2025-11-16T14:49:40.575Z" }, + { url = "https://files.pythonhosted.org/packages/f0/69/28ab391a9968f6c746b2a2db181eaa4d16afaa859fedc9c2f682d19f7e18/rpds_py-0.29.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8ae33ad9ce580c7a47452c3b3f7d8a9095ef6208e0a0c7e4e2384f9fc5bf8212", size = 567288, upload-time = "2025-11-16T14:49:42.24Z" }, + { url = "https://files.pythonhosted.org/packages/3b/d3/0c7afdcdb830eee94f5611b64e71354ffe6ac8df82d00c2faf2bfffd1d4e/rpds_py-0.29.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:c661132ab2fb4eeede2ef69670fd60da5235209874d001a98f1542f31f2a8a94", size = 593157, upload-time = "2025-11-16T14:49:43.782Z" }, + { url = "https://files.pythonhosted.org/packages/e2/ac/a0fcbc2feed4241cf26d32268c195eb88ddd4bd862adfc9d4b25edfba535/rpds_py-0.29.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:bb78b3a0d31ac1bde132c67015a809948db751cb4e92cdb3f0b242e430b6ed0d", size = 554741, upload-time = "2025-11-16T14:49:45.557Z" }, + { url = "https://files.pythonhosted.org/packages/0f/f1/fcc24137c470df8588674a677f33719d5800ec053aaacd1de8a5d5d84d9e/rpds_py-0.29.0-cp314-cp314-win32.whl", hash = "sha256:f475f103488312e9bd4000bc890a95955a07b2d0b6e8884aef4be56132adbbf1", size = 215508, upload-time = "2025-11-16T14:49:47.562Z" }, + { url = "https://files.pythonhosted.org/packages/7b/c7/1d169b2045512eac019918fc1021ea07c30e84a4343f9f344e3e0aa8c788/rpds_py-0.29.0-cp314-cp314-win_amd64.whl", hash = "sha256:b9cf2359a4fca87cfb6801fae83a76aedf66ee1254a7a151f1341632acf67f1b", size = 228125, upload-time = "2025-11-16T14:49:49.064Z" }, + { url = "https://files.pythonhosted.org/packages/be/36/0cec88aaba70ec4a6e381c444b0d916738497d27f0c30406e3d9fcbd3bc2/rpds_py-0.29.0-cp314-cp314-win_arm64.whl", hash = "sha256:9ba8028597e824854f0f1733d8b964e914ae3003b22a10c2c664cb6927e0feb9", size = 221992, upload-time = "2025-11-16T14:49:50.777Z" }, + { url = "https://files.pythonhosted.org/packages/b1/fa/a2e524631717c9c0eb5d90d30f648cfba6b731047821c994acacb618406c/rpds_py-0.29.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:e71136fd0612556b35c575dc2726ae04a1669e6a6c378f2240312cf5d1a2ab10", size = 366425, upload-time = "2025-11-16T14:49:52.691Z" }, + { url = "https://files.pythonhosted.org/packages/a2/a4/6d43ebe0746ff694a30233f63f454aed1677bd50ab7a59ff6b2bb5ac61f2/rpds_py-0.29.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:76fe96632d53f3bf0ea31ede2f53bbe3540cc2736d4aec3b3801b0458499ef3a", size = 355282, upload-time = "2025-11-16T14:49:54.292Z" }, + { url = "https://files.pythonhosted.org/packages/fa/a7/52fd8270e0320b09eaf295766ae81dd175f65394687906709b3e75c71d06/rpds_py-0.29.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9459a33f077130dbb2c7c3cea72ee9932271fb3126404ba2a2661e4fe9eb7b79", size = 384968, upload-time = "2025-11-16T14:49:55.857Z" }, + { url = "https://files.pythonhosted.org/packages/f4/7d/e6bc526b7a14e1ef80579a52c1d4ad39260a058a51d66c6039035d14db9d/rpds_py-0.29.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5c9546cfdd5d45e562cc0444b6dddc191e625c62e866bf567a2c69487c7ad28a", size = 394714, upload-time = "2025-11-16T14:49:57.343Z" }, + { url = "https://files.pythonhosted.org/packages/c0/3f/f0ade3954e7db95c791e7eaf978aa7e08a756d2046e8bdd04d08146ed188/rpds_py-0.29.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:12597d11d97b8f7e376c88929a6e17acb980e234547c92992f9f7c058f1a7310", size = 520136, upload-time = "2025-11-16T14:49:59.162Z" }, + { url = "https://files.pythonhosted.org/packages/87/b3/07122ead1b97009715ab9d4082be6d9bd9546099b2b03fae37c3116f72be/rpds_py-0.29.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28de03cf48b8a9e6ec10318f2197b83946ed91e2891f651a109611be4106ac4b", size = 409250, upload-time = "2025-11-16T14:50:00.698Z" }, + { url = "https://files.pythonhosted.org/packages/c9/c6/dcbee61fd1dc892aedcb1b489ba661313101aa82ec84b1a015d4c63ebfda/rpds_py-0.29.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd7951c964069039acc9d67a8ff1f0a7f34845ae180ca542b17dc1456b1f1808", size = 384940, upload-time = "2025-11-16T14:50:02.312Z" }, + { url = "https://files.pythonhosted.org/packages/47/11/914ecb6f3574cf9bf8b38aced4063e0f787d6e1eb30b181a7efbc6c1da9a/rpds_py-0.29.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:c07d107b7316088f1ac0177a7661ca0c6670d443f6fe72e836069025e6266761", size = 399392, upload-time = "2025-11-16T14:50:03.829Z" }, + { url = "https://files.pythonhosted.org/packages/f5/fd/2f4bd9433f58f816434bb934313584caa47dbc6f03ce5484df8ac8980561/rpds_py-0.29.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1de2345af363d25696969befc0c1688a6cb5e8b1d32b515ef84fc245c6cddba3", size = 416796, upload-time = "2025-11-16T14:50:05.558Z" }, + { url = "https://files.pythonhosted.org/packages/79/a5/449f0281af33efa29d5c71014399d74842342ae908d8cd38260320167692/rpds_py-0.29.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:00e56b12d2199ca96068057e1ae7f9998ab6e99cda82431afafd32f3ec98cca9", size = 566843, upload-time = "2025-11-16T14:50:07.243Z" }, + { url = "https://files.pythonhosted.org/packages/ab/32/0a6a1ccee2e37fcb1b7ba9afde762b77182dbb57937352a729c6cd3cf2bb/rpds_py-0.29.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3919a3bbecee589300ed25000b6944174e07cd20db70552159207b3f4bbb45b8", size = 593956, upload-time = "2025-11-16T14:50:09.029Z" }, + { url = "https://files.pythonhosted.org/packages/4a/3d/eb820f95dce4306f07a495ede02fb61bef36ea201d9137d4fcd5ab94ec1e/rpds_py-0.29.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e7fa2ccc312bbd91e43aa5e0869e46bc03278a3dddb8d58833150a18b0f0283a", size = 557288, upload-time = "2025-11-16T14:50:10.73Z" }, + { url = "https://files.pythonhosted.org/packages/e9/f8/b8ff786f40470462a252918e0836e0db903c28e88e3eec66bc4a7856ee5d/rpds_py-0.29.0-cp314-cp314t-win32.whl", hash = "sha256:97c817863ffc397f1e6a6e9d2d89fe5408c0a9922dac0329672fb0f35c867ea5", size = 211382, upload-time = "2025-11-16T14:50:12.827Z" }, + { url = "https://files.pythonhosted.org/packages/c9/7f/1a65ae870bc9d0576aebb0c501ea5dccf1ae2178fe2821042150ebd2e707/rpds_py-0.29.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2023473f444752f0f82a58dfcbee040d0a1b3d1b3c2ec40e884bd25db6d117d2", size = 225919, upload-time = "2025-11-16T14:50:14.734Z" }, + { url = "https://files.pythonhosted.org/packages/f2/ac/b97e80bf107159e5b9ba9c91df1ab95f69e5e41b435f27bdd737f0d583ac/rpds_py-0.29.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:acd82a9e39082dc5f4492d15a6b6c8599aa21db5c35aaf7d6889aea16502c07d", size = 373963, upload-time = "2025-11-16T14:50:16.205Z" }, + { url = "https://files.pythonhosted.org/packages/40/5a/55e72962d5d29bd912f40c594e68880d3c7a52774b0f75542775f9250712/rpds_py-0.29.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:715b67eac317bf1c7657508170a3e011a1ea6ccb1c9d5f296e20ba14196be6b3", size = 364644, upload-time = "2025-11-16T14:50:18.22Z" }, + { url = "https://files.pythonhosted.org/packages/99/2a/6b6524d0191b7fc1351c3c0840baac42250515afb48ae40c7ed15499a6a2/rpds_py-0.29.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f3b1b87a237cb2dba4db18bcfaaa44ba4cd5936b91121b62292ff21df577fc43", size = 393847, upload-time = "2025-11-16T14:50:20.012Z" }, + { url = "https://files.pythonhosted.org/packages/1c/b8/c5692a7df577b3c0c7faed7ac01ee3c608b81750fc5d89f84529229b6873/rpds_py-0.29.0-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1c3c3e8101bb06e337c88eb0c0ede3187131f19d97d43ea0e1c5407ea74c0cbf", size = 407281, upload-time = "2025-11-16T14:50:21.64Z" }, + { url = "https://files.pythonhosted.org/packages/f0/57/0546c6f84031b7ea08b76646a8e33e45607cc6bd879ff1917dc077bb881e/rpds_py-0.29.0-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2b8e54d6e61f3ecd3abe032065ce83ea63417a24f437e4a3d73d2f85ce7b7cfe", size = 529213, upload-time = "2025-11-16T14:50:23.219Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c1/01dd5f444233605555bc11fe5fed6a5c18f379f02013870c176c8e630a23/rpds_py-0.29.0-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3fbd4e9aebf110473a420dea85a238b254cf8a15acb04b22a5a6b5ce8925b760", size = 413808, upload-time = "2025-11-16T14:50:25.262Z" }, + { url = "https://files.pythonhosted.org/packages/aa/0a/60f98b06156ea2a7af849fb148e00fbcfdb540909a5174a5ed10c93745c7/rpds_py-0.29.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80fdf53d36e6c72819993e35d1ebeeb8e8fc688d0c6c2b391b55e335b3afba5a", size = 394600, upload-time = "2025-11-16T14:50:26.956Z" }, + { url = "https://files.pythonhosted.org/packages/37/f1/dc9312fc9bec040ece08396429f2bd9e0977924ba7a11c5ad7056428465e/rpds_py-0.29.0-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:ea7173df5d86f625f8dde6d5929629ad811ed8decda3b60ae603903839ac9ac0", size = 408634, upload-time = "2025-11-16T14:50:28.989Z" }, + { url = "https://files.pythonhosted.org/packages/ed/41/65024c9fd40c89bb7d604cf73beda4cbdbcebe92d8765345dd65855b6449/rpds_py-0.29.0-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:76054d540061eda273274f3d13a21a4abdde90e13eaefdc205db37c05230efce", size = 426064, upload-time = "2025-11-16T14:50:30.674Z" }, + { url = "https://files.pythonhosted.org/packages/a2/e0/cf95478881fc88ca2fdbf56381d7df36567cccc39a05394beac72182cd62/rpds_py-0.29.0-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:9f84c549746a5be3bc7415830747a3a0312573afc9f95785eb35228bb17742ec", size = 575871, upload-time = "2025-11-16T14:50:33.428Z" }, + { url = "https://files.pythonhosted.org/packages/ea/c0/df88097e64339a0218b57bd5f9ca49898e4c394db756c67fccc64add850a/rpds_py-0.29.0-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:0ea962671af5cb9a260489e311fa22b2e97103e3f9f0caaea6f81390af96a9ed", size = 601702, upload-time = "2025-11-16T14:50:36.051Z" }, + { url = "https://files.pythonhosted.org/packages/87/f4/09ffb3ebd0cbb9e2c7c9b84d252557ecf434cd71584ee1e32f66013824df/rpds_py-0.29.0-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:f7728653900035fb7b8d06e1e5900545d8088efc9d5d4545782da7df03ec803f", size = 564054, upload-time = "2025-11-16T14:50:37.733Z" }, +] + +[[package]] +name = "ruff" +version = "0.14.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/52/f0/62b5a1a723fe183650109407fa56abb433b00aa1c0b9ba555f9c4efec2c6/ruff-0.14.6.tar.gz", hash = "sha256:6f0c742ca6a7783a736b867a263b9a7a80a45ce9bee391eeda296895f1b4e1cc", size = 5669501, upload-time = "2025-11-21T14:26:17.903Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/d2/7dd544116d107fffb24a0064d41a5d2ed1c9d6372d142f9ba108c8e39207/ruff-0.14.6-py3-none-linux_armv6l.whl", hash = "sha256:d724ac2f1c240dbd01a2ae98db5d1d9a5e1d9e96eba999d1c48e30062df578a3", size = 13326119, upload-time = "2025-11-21T14:25:24.2Z" }, + { url = "https://files.pythonhosted.org/packages/36/6a/ad66d0a3315d6327ed6b01f759d83df3c4d5f86c30462121024361137b6a/ruff-0.14.6-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9f7539ea257aa4d07b7ce87aed580e485c40143f2473ff2f2b75aee003186004", size = 13526007, upload-time = "2025-11-21T14:25:26.906Z" }, + { url = "https://files.pythonhosted.org/packages/a3/9d/dae6db96df28e0a15dea8e986ee393af70fc97fd57669808728080529c37/ruff-0.14.6-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7f6007e55b90a2a7e93083ba48a9f23c3158c433591c33ee2e99a49b889c6332", size = 12676572, upload-time = "2025-11-21T14:25:29.826Z" }, + { url = "https://files.pythonhosted.org/packages/76/a4/f319e87759949062cfee1b26245048e92e2acce900ad3a909285f9db1859/ruff-0.14.6-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a8e7b9d73d8728b68f632aa8e824ef041d068d231d8dbc7808532d3629a6bef", size = 13140745, upload-time = "2025-11-21T14:25:32.788Z" }, + { url = "https://files.pythonhosted.org/packages/95/d3/248c1efc71a0a8ed4e8e10b4b2266845d7dfc7a0ab64354afe049eaa1310/ruff-0.14.6-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d50d45d4553a3ebcbd33e7c5e0fe6ca4aafd9a9122492de357205c2c48f00775", size = 13076486, upload-time = "2025-11-21T14:25:35.601Z" }, + { url = "https://files.pythonhosted.org/packages/a5/19/b68d4563fe50eba4b8c92aa842149bb56dd24d198389c0ed12e7faff4f7d/ruff-0.14.6-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:118548dd121f8a21bfa8ab2c5b80e5b4aed67ead4b7567790962554f38e598ce", size = 13727563, upload-time = "2025-11-21T14:25:38.514Z" }, + { url = "https://files.pythonhosted.org/packages/47/ac/943169436832d4b0e867235abbdb57ce3a82367b47e0280fa7b4eabb7593/ruff-0.14.6-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:57256efafbfefcb8748df9d1d766062f62b20150691021f8ab79e2d919f7c11f", size = 15199755, upload-time = "2025-11-21T14:25:41.516Z" }, + { url = "https://files.pythonhosted.org/packages/c9/b9/288bb2399860a36d4bb0541cb66cce3c0f4156aaff009dc8499be0c24bf2/ruff-0.14.6-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ff18134841e5c68f8e5df1999a64429a02d5549036b394fafbe410f886e1989d", size = 14850608, upload-time = "2025-11-21T14:25:44.428Z" }, + { url = "https://files.pythonhosted.org/packages/ee/b1/a0d549dd4364e240f37e7d2907e97ee80587480d98c7799d2d8dc7a2f605/ruff-0.14.6-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:29c4b7ec1e66a105d5c27bd57fa93203637d66a26d10ca9809dc7fc18ec58440", size = 14118754, upload-time = "2025-11-21T14:25:47.214Z" }, + { url = "https://files.pythonhosted.org/packages/13/ac/9b9fe63716af8bdfddfacd0882bc1586f29985d3b988b3c62ddce2e202c3/ruff-0.14.6-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:167843a6f78680746d7e226f255d920aeed5e4ad9c03258094a2d49d3028b105", size = 13949214, upload-time = "2025-11-21T14:25:50.002Z" }, + { url = "https://files.pythonhosted.org/packages/12/27/4dad6c6a77fede9560b7df6802b1b697e97e49ceabe1f12baf3ea20862e9/ruff-0.14.6-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:16a33af621c9c523b1ae006b1b99b159bf5ac7e4b1f20b85b2572455018e0821", size = 14106112, upload-time = "2025-11-21T14:25:52.841Z" }, + { url = "https://files.pythonhosted.org/packages/6a/db/23e322d7177873eaedea59a7932ca5084ec5b7e20cb30f341ab594130a71/ruff-0.14.6-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:1432ab6e1ae2dc565a7eea707d3b03a0c234ef401482a6f1621bc1f427c2ff55", size = 13035010, upload-time = "2025-11-21T14:25:55.536Z" }, + { url = "https://files.pythonhosted.org/packages/a8/9c/20e21d4d69dbb35e6a1df7691e02f363423658a20a2afacf2a2c011800dc/ruff-0.14.6-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:4c55cfbbe7abb61eb914bfd20683d14cdfb38a6d56c6c66efa55ec6570ee4e71", size = 13054082, upload-time = "2025-11-21T14:25:58.625Z" }, + { url = "https://files.pythonhosted.org/packages/66/25/906ee6a0464c3125c8d673c589771a974965c2be1a1e28b5c3b96cb6ef88/ruff-0.14.6-py3-none-musllinux_1_2_i686.whl", hash = "sha256:efea3c0f21901a685fff4befda6d61a1bf4cb43de16da87e8226a281d614350b", size = 13303354, upload-time = "2025-11-21T14:26:01.816Z" }, + { url = "https://files.pythonhosted.org/packages/4c/58/60577569e198d56922b7ead07b465f559002b7b11d53f40937e95067ca1c/ruff-0.14.6-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:344d97172576d75dc6afc0e9243376dbe1668559c72de1864439c4fc95f78185", size = 14054487, upload-time = "2025-11-21T14:26:05.058Z" }, + { url = "https://files.pythonhosted.org/packages/67/0b/8e4e0639e4cc12547f41cb771b0b44ec8225b6b6a93393176d75fe6f7d40/ruff-0.14.6-py3-none-win32.whl", hash = "sha256:00169c0c8b85396516fdd9ce3446c7ca20c2a8f90a77aa945ba6b8f2bfe99e85", size = 13013361, upload-time = "2025-11-21T14:26:08.152Z" }, + { url = "https://files.pythonhosted.org/packages/fb/02/82240553b77fd1341f80ebb3eaae43ba011c7a91b4224a9f317d8e6591af/ruff-0.14.6-py3-none-win_amd64.whl", hash = "sha256:390e6480c5e3659f8a4c8d6a0373027820419ac14fa0d2713bd8e6c3e125b8b9", size = 14432087, upload-time = "2025-11-21T14:26:10.891Z" }, + { url = "https://files.pythonhosted.org/packages/a5/1f/93f9b0fad9470e4c829a5bb678da4012f0c710d09331b860ee555216f4ea/ruff-0.14.6-py3-none-win_arm64.whl", hash = "sha256:d43c81fbeae52cfa8728d8766bbf46ee4298c888072105815b392da70ca836b2", size = 13520930, upload-time = "2025-11-21T14:26:13.951Z" }, +] + +[[package]] +name = "scipy" +version = "1.16.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0a/ca/d8ace4f98322d01abcd52d381134344bf7b431eba7ed8b42bdea5a3c2ac9/scipy-1.16.3.tar.gz", hash = "sha256:01e87659402762f43bd2fee13370553a17ada367d42e7487800bf2916535aecb", size = 30597883, upload-time = "2025-10-28T17:38:54.068Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/5f/6f37d7439de1455ce9c5a556b8d1db0979f03a796c030bafdf08d35b7bf9/scipy-1.16.3-cp311-cp311-macosx_10_14_x86_64.whl", hash = "sha256:40be6cf99e68b6c4321e9f8782e7d5ff8265af28ef2cd56e9c9b2638fa08ad97", size = 36630881, upload-time = "2025-10-28T17:31:47.104Z" }, + { url = "https://files.pythonhosted.org/packages/7c/89/d70e9f628749b7e4db2aa4cd89735502ff3f08f7b9b27d2e799485987cd9/scipy-1.16.3-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:8be1ca9170fcb6223cc7c27f4305d680ded114a1567c0bd2bfcbf947d1b17511", size = 28941012, upload-time = "2025-10-28T17:31:53.411Z" }, + { url = "https://files.pythonhosted.org/packages/a8/a8/0e7a9a6872a923505dbdf6bb93451edcac120363131c19013044a1e7cb0c/scipy-1.16.3-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:bea0a62734d20d67608660f69dcda23e7f90fb4ca20974ab80b6ed40df87a005", size = 20931935, upload-time = "2025-10-28T17:31:57.361Z" }, + { url = "https://files.pythonhosted.org/packages/bd/c7/020fb72bd79ad798e4dbe53938543ecb96b3a9ac3fe274b7189e23e27353/scipy-1.16.3-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:2a207a6ce9c24f1951241f4693ede2d393f59c07abc159b2cb2be980820e01fb", size = 23534466, upload-time = "2025-10-28T17:32:01.875Z" }, + { url = "https://files.pythonhosted.org/packages/be/a0/668c4609ce6dbf2f948e167836ccaf897f95fb63fa231c87da7558a374cd/scipy-1.16.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:532fb5ad6a87e9e9cd9c959b106b73145a03f04c7d57ea3e6f6bb60b86ab0876", size = 33593618, upload-time = "2025-10-28T17:32:06.902Z" }, + { url = "https://files.pythonhosted.org/packages/ca/6e/8942461cf2636cdae083e3eb72622a7fbbfa5cf559c7d13ab250a5dbdc01/scipy-1.16.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0151a0749efeaaab78711c78422d413c583b8cdd2011a3c1d6c794938ee9fdb2", size = 35899798, upload-time = "2025-10-28T17:32:12.665Z" }, + { url = "https://files.pythonhosted.org/packages/79/e8/d0f33590364cdbd67f28ce79368b373889faa4ee959588beddf6daef9abe/scipy-1.16.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b7180967113560cca57418a7bc719e30366b47959dd845a93206fbed693c867e", size = 36226154, upload-time = "2025-10-28T17:32:17.961Z" }, + { url = "https://files.pythonhosted.org/packages/39/c1/1903de608c0c924a1749c590064e65810f8046e437aba6be365abc4f7557/scipy-1.16.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:deb3841c925eeddb6afc1e4e4a45e418d19ec7b87c5df177695224078e8ec733", size = 38878540, upload-time = "2025-10-28T17:32:23.907Z" }, + { url = "https://files.pythonhosted.org/packages/f1/d0/22ec7036ba0b0a35bccb7f25ab407382ed34af0b111475eb301c16f8a2e5/scipy-1.16.3-cp311-cp311-win_amd64.whl", hash = "sha256:53c3844d527213631e886621df5695d35e4f6a75f620dca412bcd292f6b87d78", size = 38722107, upload-time = "2025-10-28T17:32:29.921Z" }, + { url = "https://files.pythonhosted.org/packages/7b/60/8a00e5a524bb3bf8898db1650d350f50e6cffb9d7a491c561dc9826c7515/scipy-1.16.3-cp311-cp311-win_arm64.whl", hash = "sha256:9452781bd879b14b6f055b26643703551320aa8d79ae064a71df55c00286a184", size = 25506272, upload-time = "2025-10-28T17:32:34.577Z" }, + { url = "https://files.pythonhosted.org/packages/40/41/5bf55c3f386b1643812f3a5674edf74b26184378ef0f3e7c7a09a7e2ca7f/scipy-1.16.3-cp312-cp312-macosx_10_14_x86_64.whl", hash = "sha256:81fc5827606858cf71446a5e98715ba0e11f0dbc83d71c7409d05486592a45d6", size = 36659043, upload-time = "2025-10-28T17:32:40.285Z" }, + { url = "https://files.pythonhosted.org/packages/1e/0f/65582071948cfc45d43e9870bf7ca5f0e0684e165d7c9ef4e50d783073eb/scipy-1.16.3-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:c97176013d404c7346bf57874eaac5187d969293bf40497140b0a2b2b7482e07", size = 28898986, upload-time = "2025-10-28T17:32:45.325Z" }, + { url = "https://files.pythonhosted.org/packages/96/5e/36bf3f0ac298187d1ceadde9051177d6a4fe4d507e8f59067dc9dd39e650/scipy-1.16.3-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:2b71d93c8a9936046866acebc915e2af2e292b883ed6e2cbe5c34beb094b82d9", size = 20889814, upload-time = "2025-10-28T17:32:49.277Z" }, + { url = "https://files.pythonhosted.org/packages/80/35/178d9d0c35394d5d5211bbff7ac4f2986c5488b59506fef9e1de13ea28d3/scipy-1.16.3-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:3d4a07a8e785d80289dfe66b7c27d8634a773020742ec7187b85ccc4b0e7b686", size = 23565795, upload-time = "2025-10-28T17:32:53.337Z" }, + { url = "https://files.pythonhosted.org/packages/fa/46/d1146ff536d034d02f83c8afc3c4bab2eddb634624d6529a8512f3afc9da/scipy-1.16.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0553371015692a898e1aa858fed67a3576c34edefa6b7ebdb4e9dde49ce5c203", size = 33349476, upload-time = "2025-10-28T17:32:58.353Z" }, + { url = "https://files.pythonhosted.org/packages/79/2e/415119c9ab3e62249e18c2b082c07aff907a273741b3f8160414b0e9193c/scipy-1.16.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:72d1717fd3b5e6ec747327ce9bda32d5463f472c9dce9f54499e81fbd50245a1", size = 35676692, upload-time = "2025-10-28T17:33:03.88Z" }, + { url = "https://files.pythonhosted.org/packages/27/82/df26e44da78bf8d2aeaf7566082260cfa15955a5a6e96e6a29935b64132f/scipy-1.16.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1fb2472e72e24d1530debe6ae078db70fb1605350c88a3d14bc401d6306dbffe", size = 36019345, upload-time = "2025-10-28T17:33:09.773Z" }, + { url = "https://files.pythonhosted.org/packages/82/31/006cbb4b648ba379a95c87262c2855cd0d09453e500937f78b30f02fa1cd/scipy-1.16.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c5192722cffe15f9329a3948c4b1db789fbb1f05c97899187dcf009b283aea70", size = 38678975, upload-time = "2025-10-28T17:33:15.809Z" }, + { url = "https://files.pythonhosted.org/packages/c2/7f/acbd28c97e990b421af7d6d6cd416358c9c293fc958b8529e0bd5d2a2a19/scipy-1.16.3-cp312-cp312-win_amd64.whl", hash = "sha256:56edc65510d1331dae01ef9b658d428e33ed48b4f77b1d51caf479a0253f96dc", size = 38555926, upload-time = "2025-10-28T17:33:21.388Z" }, + { url = "https://files.pythonhosted.org/packages/ce/69/c5c7807fd007dad4f48e0a5f2153038dc96e8725d3345b9ee31b2b7bed46/scipy-1.16.3-cp312-cp312-win_arm64.whl", hash = "sha256:a8a26c78ef223d3e30920ef759e25625a0ecdd0d60e5a8818b7513c3e5384cf2", size = 25463014, upload-time = "2025-10-28T17:33:25.975Z" }, + { url = "https://files.pythonhosted.org/packages/72/f1/57e8327ab1508272029e27eeef34f2302ffc156b69e7e233e906c2a5c379/scipy-1.16.3-cp313-cp313-macosx_10_14_x86_64.whl", hash = "sha256:d2ec56337675e61b312179a1ad124f5f570c00f920cc75e1000025451b88241c", size = 36617856, upload-time = "2025-10-28T17:33:31.375Z" }, + { url = "https://files.pythonhosted.org/packages/44/13/7e63cfba8a7452eb756306aa2fd9b37a29a323b672b964b4fdeded9a3f21/scipy-1.16.3-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:16b8bc35a4cc24db80a0ec836a9286d0e31b2503cb2fd7ff7fb0e0374a97081d", size = 28874306, upload-time = "2025-10-28T17:33:36.516Z" }, + { url = "https://files.pythonhosted.org/packages/15/65/3a9400efd0228a176e6ec3454b1fa998fbbb5a8defa1672c3f65706987db/scipy-1.16.3-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:5803c5fadd29de0cf27fa08ccbfe7a9e5d741bf63e4ab1085437266f12460ff9", size = 20865371, upload-time = "2025-10-28T17:33:42.094Z" }, + { url = "https://files.pythonhosted.org/packages/33/d7/eda09adf009a9fb81827194d4dd02d2e4bc752cef16737cc4ef065234031/scipy-1.16.3-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:b81c27fc41954319a943d43b20e07c40bdcd3ff7cf013f4fb86286faefe546c4", size = 23524877, upload-time = "2025-10-28T17:33:48.483Z" }, + { url = "https://files.pythonhosted.org/packages/7d/6b/3f911e1ebc364cb81320223a3422aab7d26c9c7973109a9cd0f27c64c6c0/scipy-1.16.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0c3b4dd3d9b08dbce0f3440032c52e9e2ab9f96ade2d3943313dfe51a7056959", size = 33342103, upload-time = "2025-10-28T17:33:56.495Z" }, + { url = "https://files.pythonhosted.org/packages/21/f6/4bfb5695d8941e5c570a04d9fcd0d36bce7511b7d78e6e75c8f9791f82d0/scipy-1.16.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7dc1360c06535ea6116a2220f760ae572db9f661aba2d88074fe30ec2aa1ff88", size = 35697297, upload-time = "2025-10-28T17:34:04.722Z" }, + { url = "https://files.pythonhosted.org/packages/04/e1/6496dadbc80d8d896ff72511ecfe2316b50313bfc3ebf07a3f580f08bd8c/scipy-1.16.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:663b8d66a8748051c3ee9c96465fb417509315b99c71550fda2591d7dd634234", size = 36021756, upload-time = "2025-10-28T17:34:13.482Z" }, + { url = "https://files.pythonhosted.org/packages/fe/bd/a8c7799e0136b987bda3e1b23d155bcb31aec68a4a472554df5f0937eef7/scipy-1.16.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eab43fae33a0c39006a88096cd7b4f4ef545ea0447d250d5ac18202d40b6611d", size = 38696566, upload-time = "2025-10-28T17:34:22.384Z" }, + { url = "https://files.pythonhosted.org/packages/cd/01/1204382461fcbfeb05b6161b594f4007e78b6eba9b375382f79153172b4d/scipy-1.16.3-cp313-cp313-win_amd64.whl", hash = "sha256:062246acacbe9f8210de8e751b16fc37458213f124bef161a5a02c7a39284304", size = 38529877, upload-time = "2025-10-28T17:35:51.076Z" }, + { url = "https://files.pythonhosted.org/packages/7f/14/9d9fbcaa1260a94f4bb5b64ba9213ceb5d03cd88841fe9fd1ffd47a45b73/scipy-1.16.3-cp313-cp313-win_arm64.whl", hash = "sha256:50a3dbf286dbc7d84f176f9a1574c705f277cb6565069f88f60db9eafdbe3ee2", size = 25455366, upload-time = "2025-10-28T17:35:59.014Z" }, + { url = "https://files.pythonhosted.org/packages/e2/a3/9ec205bd49f42d45d77f1730dbad9ccf146244c1647605cf834b3a8c4f36/scipy-1.16.3-cp313-cp313t-macosx_10_14_x86_64.whl", hash = "sha256:fb4b29f4cf8cc5a8d628bc8d8e26d12d7278cd1f219f22698a378c3d67db5e4b", size = 37027931, upload-time = "2025-10-28T17:34:31.451Z" }, + { url = "https://files.pythonhosted.org/packages/25/06/ca9fd1f3a4589cbd825b1447e5db3a8ebb969c1eaf22c8579bd286f51b6d/scipy-1.16.3-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:8d09d72dc92742988b0e7750bddb8060b0c7079606c0d24a8cc8e9c9c11f9079", size = 29400081, upload-time = "2025-10-28T17:34:39.087Z" }, + { url = "https://files.pythonhosted.org/packages/6a/56/933e68210d92657d93fb0e381683bc0e53a965048d7358ff5fbf9e6a1b17/scipy-1.16.3-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:03192a35e661470197556de24e7cb1330d84b35b94ead65c46ad6f16f6b28f2a", size = 21391244, upload-time = "2025-10-28T17:34:45.234Z" }, + { url = "https://files.pythonhosted.org/packages/a8/7e/779845db03dc1418e215726329674b40576879b91814568757ff0014ad65/scipy-1.16.3-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:57d01cb6f85e34f0946b33caa66e892aae072b64b034183f3d87c4025802a119", size = 23929753, upload-time = "2025-10-28T17:34:51.793Z" }, + { url = "https://files.pythonhosted.org/packages/4c/4b/f756cf8161d5365dcdef9e5f460ab226c068211030a175d2fc7f3f41ca64/scipy-1.16.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:96491a6a54e995f00a28a3c3badfff58fd093bf26cd5fb34a2188c8c756a3a2c", size = 33496912, upload-time = "2025-10-28T17:34:59.8Z" }, + { url = "https://files.pythonhosted.org/packages/09/b5/222b1e49a58668f23839ca1542a6322bb095ab8d6590d4f71723869a6c2c/scipy-1.16.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cd13e354df9938598af2be05822c323e97132d5e6306b83a3b4ee6724c6e522e", size = 35802371, upload-time = "2025-10-28T17:35:08.173Z" }, + { url = "https://files.pythonhosted.org/packages/c1/8d/5964ef68bb31829bde27611f8c9deeac13764589fe74a75390242b64ca44/scipy-1.16.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:63d3cdacb8a824a295191a723ee5e4ea7768ca5ca5f2838532d9f2e2b3ce2135", size = 36190477, upload-time = "2025-10-28T17:35:16.7Z" }, + { url = "https://files.pythonhosted.org/packages/ab/f2/b31d75cb9b5fa4dd39a0a931ee9b33e7f6f36f23be5ef560bf72e0f92f32/scipy-1.16.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:e7efa2681ea410b10dde31a52b18b0154d66f2485328830e45fdf183af5aefc6", size = 38796678, upload-time = "2025-10-28T17:35:26.354Z" }, + { url = "https://files.pythonhosted.org/packages/b4/1e/b3723d8ff64ab548c38d87055483714fefe6ee20e0189b62352b5e015bb1/scipy-1.16.3-cp313-cp313t-win_amd64.whl", hash = "sha256:2d1ae2cf0c350e7705168ff2429962a89ad90c2d49d1dd300686d8b2a5af22fc", size = 38640178, upload-time = "2025-10-28T17:35:35.304Z" }, + { url = "https://files.pythonhosted.org/packages/8e/f3/d854ff38789aca9b0cc23008d607ced9de4f7ab14fa1ca4329f86b3758ca/scipy-1.16.3-cp313-cp313t-win_arm64.whl", hash = "sha256:0c623a54f7b79dd88ef56da19bc2873afec9673a48f3b85b18e4d402bdd29a5a", size = 25803246, upload-time = "2025-10-28T17:35:42.155Z" }, + { url = "https://files.pythonhosted.org/packages/99/f6/99b10fd70f2d864c1e29a28bbcaa0c6340f9d8518396542d9ea3b4aaae15/scipy-1.16.3-cp314-cp314-macosx_10_14_x86_64.whl", hash = "sha256:875555ce62743e1d54f06cdf22c1e0bc47b91130ac40fe5d783b6dfa114beeb6", size = 36606469, upload-time = "2025-10-28T17:36:08.741Z" }, + { url = "https://files.pythonhosted.org/packages/4d/74/043b54f2319f48ea940dd025779fa28ee360e6b95acb7cd188fad4391c6b/scipy-1.16.3-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:bb61878c18a470021fb515a843dc7a76961a8daceaaaa8bad1332f1bf4b54657", size = 28872043, upload-time = "2025-10-28T17:36:16.599Z" }, + { url = "https://files.pythonhosted.org/packages/4d/e1/24b7e50cc1c4ee6ffbcb1f27fe9f4c8b40e7911675f6d2d20955f41c6348/scipy-1.16.3-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:f2622206f5559784fa5c4b53a950c3c7c1cf3e84ca1b9c4b6c03f062f289ca26", size = 20862952, upload-time = "2025-10-28T17:36:22.966Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3a/3e8c01a4d742b730df368e063787c6808597ccb38636ed821d10b39ca51b/scipy-1.16.3-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:7f68154688c515cdb541a31ef8eb66d8cd1050605be9dcd74199cbd22ac739bc", size = 23508512, upload-time = "2025-10-28T17:36:29.731Z" }, + { url = "https://files.pythonhosted.org/packages/1f/60/c45a12b98ad591536bfe5330cb3cfe1850d7570259303563b1721564d458/scipy-1.16.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8b3c820ddb80029fe9f43d61b81d8b488d3ef8ca010d15122b152db77dc94c22", size = 33413639, upload-time = "2025-10-28T17:36:37.982Z" }, + { url = "https://files.pythonhosted.org/packages/71/bc/35957d88645476307e4839712642896689df442f3e53b0fa016ecf8a3357/scipy-1.16.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d3837938ae715fc0fe3c39c0202de3a8853aff22ca66781ddc2ade7554b7e2cc", size = 35704729, upload-time = "2025-10-28T17:36:46.547Z" }, + { url = "https://files.pythonhosted.org/packages/3b/15/89105e659041b1ca11c386e9995aefacd513a78493656e57789f9d9eab61/scipy-1.16.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:aadd23f98f9cb069b3bd64ddc900c4d277778242e961751f77a8cb5c4b946fb0", size = 36086251, upload-time = "2025-10-28T17:36:55.161Z" }, + { url = "https://files.pythonhosted.org/packages/1a/87/c0ea673ac9c6cc50b3da2196d860273bc7389aa69b64efa8493bdd25b093/scipy-1.16.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b7c5f1bda1354d6a19bc6af73a649f8285ca63ac6b52e64e658a5a11d4d69800", size = 38716681, upload-time = "2025-10-28T17:37:04.1Z" }, + { url = "https://files.pythonhosted.org/packages/91/06/837893227b043fb9b0d13e4bd7586982d8136cb249ffb3492930dab905b8/scipy-1.16.3-cp314-cp314-win_amd64.whl", hash = "sha256:e5d42a9472e7579e473879a1990327830493a7047506d58d73fc429b84c1d49d", size = 39358423, upload-time = "2025-10-28T17:38:20.005Z" }, + { url = "https://files.pythonhosted.org/packages/95/03/28bce0355e4d34a7c034727505a02d19548549e190bedd13a721e35380b7/scipy-1.16.3-cp314-cp314-win_arm64.whl", hash = "sha256:6020470b9d00245926f2d5bb93b119ca0340f0d564eb6fbaad843eaebf9d690f", size = 26135027, upload-time = "2025-10-28T17:38:24.966Z" }, + { url = "https://files.pythonhosted.org/packages/b2/6f/69f1e2b682efe9de8fe9f91040f0cd32f13cfccba690512ba4c582b0bc29/scipy-1.16.3-cp314-cp314t-macosx_10_14_x86_64.whl", hash = "sha256:e1d27cbcb4602680a49d787d90664fa4974063ac9d4134813332a8c53dbe667c", size = 37028379, upload-time = "2025-10-28T17:37:14.061Z" }, + { url = "https://files.pythonhosted.org/packages/7c/2d/e826f31624a5ebbab1cd93d30fd74349914753076ed0593e1d56a98c4fb4/scipy-1.16.3-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:9b9c9c07b6d56a35777a1b4cc8966118fb16cfd8daf6743867d17d36cfad2d40", size = 29400052, upload-time = "2025-10-28T17:37:21.709Z" }, + { url = "https://files.pythonhosted.org/packages/69/27/d24feb80155f41fd1f156bf144e7e049b4e2b9dd06261a242905e3bc7a03/scipy-1.16.3-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:3a4c460301fb2cffb7f88528f30b3127742cff583603aa7dc964a52c463b385d", size = 21391183, upload-time = "2025-10-28T17:37:29.559Z" }, + { url = "https://files.pythonhosted.org/packages/f8/d3/1b229e433074c5738a24277eca520a2319aac7465eea7310ea6ae0e98ae2/scipy-1.16.3-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:f667a4542cc8917af1db06366d3f78a5c8e83badd56409f94d1eac8d8d9133fa", size = 23930174, upload-time = "2025-10-28T17:37:36.306Z" }, + { url = "https://files.pythonhosted.org/packages/16/9d/d9e148b0ec680c0f042581a2be79a28a7ab66c0c4946697f9e7553ead337/scipy-1.16.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f379b54b77a597aa7ee5e697df0d66903e41b9c85a6dd7946159e356319158e8", size = 33497852, upload-time = "2025-10-28T17:37:42.228Z" }, + { url = "https://files.pythonhosted.org/packages/2f/22/4e5f7561e4f98b7bea63cf3fd7934bff1e3182e9f1626b089a679914d5c8/scipy-1.16.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4aff59800a3b7f786b70bfd6ab551001cb553244988d7d6b8299cb1ea653b353", size = 35798595, upload-time = "2025-10-28T17:37:48.102Z" }, + { url = "https://files.pythonhosted.org/packages/83/42/6644d714c179429fc7196857866f219fef25238319b650bb32dde7bf7a48/scipy-1.16.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:da7763f55885045036fabcebd80144b757d3db06ab0861415d1c3b7c69042146", size = 36186269, upload-time = "2025-10-28T17:37:53.72Z" }, + { url = "https://files.pythonhosted.org/packages/ac/70/64b4d7ca92f9cf2e6fc6aaa2eecf80bb9b6b985043a9583f32f8177ea122/scipy-1.16.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ffa6eea95283b2b8079b821dc11f50a17d0571c92b43e2b5b12764dc5f9b285d", size = 38802779, upload-time = "2025-10-28T17:37:59.393Z" }, + { url = "https://files.pythonhosted.org/packages/61/82/8d0e39f62764cce5ffd5284131e109f07cf8955aef9ab8ed4e3aa5e30539/scipy-1.16.3-cp314-cp314t-win_amd64.whl", hash = "sha256:d9f48cafc7ce94cf9b15c6bffdc443a81a27bf7075cf2dcd5c8b40f85d10c4e7", size = 39471128, upload-time = "2025-10-28T17:38:05.259Z" }, + { url = "https://files.pythonhosted.org/packages/64/47/a494741db7280eae6dc033510c319e34d42dd41b7ac0c7ead39354d1a2b5/scipy-1.16.3-cp314-cp314t-win_arm64.whl", hash = "sha256:21d9d6b197227a12dcbf9633320a4e34c6b0e51c57268df255a0942983bac562", size = 26464127, upload-time = "2025-10-28T17:38:11.34Z" }, +] + +[[package]] +name = "send2trash" +version = "1.8.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fd/3a/aec9b02217bb79b87bbc1a21bc6abc51e3d5dcf65c30487ac96c0908c722/Send2Trash-1.8.3.tar.gz", hash = "sha256:b18e7a3966d99871aefeb00cfbcfdced55ce4871194810fc71f4aa484b953abf", size = 17394, upload-time = "2024-04-07T00:01:09.267Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/40/b0/4562db6223154aa4e22f939003cb92514c79f3d4dccca3444253fd17f902/Send2Trash-1.8.3-py3-none-any.whl", hash = "sha256:0c31227e0bd08961c7665474a3d1ef7193929fedda4233843689baa056be46c9", size = 18072, upload-time = "2024-04-07T00:01:07.438Z" }, +] + +[[package]] +name = "setuptools" +version = "80.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/18/5d/3bf57dcd21979b887f014ea83c24ae194cfcd12b9e0fda66b957c69d1fca/setuptools-80.9.0.tar.gz", hash = "sha256:f36b47402ecde768dbfafc46e8e4207b4360c654f1f3bb84475f0a28628fb19c", size = 1319958, upload-time = "2025-05-27T00:56:51.443Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/dc/17031897dae0efacfea57dfd3a82fdd2a2aeb58e0ff71b77b87e44edc772/setuptools-80.9.0-py3-none-any.whl", hash = "sha256:062d34222ad13e0cc312a4c02d73f059e86a4acbfbdea8f8f76b28c99f306922", size = 1201486, upload-time = "2025-05-27T00:56:49.664Z" }, +] + +[[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 = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, +] + +[[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 = "soupsieve" +version = "2.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6d/e6/21ccce3262dd4889aa3332e5a119a3491a95e8f60939870a3a035aabac0d/soupsieve-2.8.tar.gz", hash = "sha256:e2dd4a40a628cb5f28f6d4b0db8800b8f581b65bb380b97de22ba5ca8d72572f", size = 103472, upload-time = "2025-08-27T15:39:51.78Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/a0/bb38d3b76b8cae341dad93a2dd83ab7462e6dbcdd84d43f54ee60a8dc167/soupsieve-2.8-py3-none-any.whl", hash = "sha256:0cc76456a30e20f5d7f2e14a98a4ae2ee4e5abdc7c5ea0aafe795f344bc7984c", size = 36679, upload-time = "2025-08-27T15:39:50.179Z" }, +] + +[[package]] +name = "sphinx" +version = "8.1.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "alabaster" }, + { name = "babel" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "docutils" }, + { name = "imagesize" }, + { name = "jinja2" }, + { name = "packaging" }, + { name = "pygments" }, + { name = "requests" }, + { name = "snowballstemmer" }, + { name = "sphinxcontrib-applehelp" }, + { name = "sphinxcontrib-devhelp" }, + { name = "sphinxcontrib-htmlhelp" }, + { name = "sphinxcontrib-jsmath" }, + { name = "sphinxcontrib-qthelp" }, + { name = "sphinxcontrib-serializinghtml" }, +] +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 = "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-bibtex" +version = "2.6.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "docutils" }, + { name = "pybtex" }, + { name = "pybtex-docutils" }, + { name = "sphinx" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/de/83/1488c9879f2fa3c2cbd6f666c7a3a42a1fa9e08462bec73281fa6c092cba/sphinxcontrib_bibtex-2.6.5.tar.gz", hash = "sha256:9b3224dd6fece9268ebd8c905dc0a83ff2f6c54148a9235fe70e9d1e9ff149c0", size = 118462, upload-time = "2025-06-27T10:40:14.061Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/a0/3a612da94f828f26cabb247817393e79472c32b12c49222bf85fb6d7b6c8/sphinxcontrib_bibtex-2.6.5-py3-none-any.whl", hash = "sha256:455ea4509642ea0b28ede3721550273626f85af65af01f161bfd8e19dc1edd7d", size = 40410, upload-time = "2025-06-27T10:40:12.274Z" }, +] + +[[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-napoleon" +version = "0.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pockets" }, + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fa/eb/ad89500f4cee83187596e07f43ad561f293e8e6e96996005c3319653b89f/sphinxcontrib-napoleon-0.7.tar.gz", hash = "sha256:407382beed396e9f2d7f3043fad6afda95719204a1e1a231ac865f40abcbfcf8", size = 21232, upload-time = "2018-09-23T14:16:47.272Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/75/f2/6b7627dfe7b4e418e295e254bb15c3a6455f11f8c0ad0d43113f678049c3/sphinxcontrib_napoleon-0.7-py2.py3-none-any.whl", hash = "sha256:711e41a3974bdf110a484aec4c1a556799eb0b3f3b897521a018ad7e2db13fef", size = 17151, upload-time = "2018-09-23T14:16:45.548Z" }, +] + +[[package]] +name = "sphinxcontrib-newsfeed" +version = "0.1.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "sphinx" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2b/5e/8bc839b5c4ef030bf26eede24208a49f25d00033cbd4969b3895264f14db/sphinxcontrib-newsfeed-0.1.4.tar.gz", hash = "sha256:f78c865b1a32d249644a9c46f395b547df0dc21ed69b70898d64235b4755efb4", size = 10862, upload-time = "2015-02-18T23:53:38.696Z" } + +[[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 = "stack-data" +version = "0.6.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "asttokens" }, + { name = "executing" }, + { name = "pure-eval" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/28/e3/55dcc2cfbc3ca9c29519eb6884dd1415ecb53b0e934862d3559ddcb7e20b/stack_data-0.6.3.tar.gz", hash = "sha256:836a778de4fec4dcd1dcd89ed8abff8a221f58308462e1c4aa2a3cf30148f0b9", size = 44707, upload-time = "2023-09-30T13:58:05.479Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/7b/ce1eafaf1a76852e2ec9b22edecf1daa58175c090266e9f6c64afcd81d91/stack_data-0.6.3-py3-none-any.whl", hash = "sha256:d5558e0c25a4cb0853cddad3d77da9891a08cb85dd9f9f91b9f8cd66e511e695", size = 24521, upload-time = "2023-09-30T13:58:03.53Z" }, +] + +[[package]] +name = "tabulate" +version = "0.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ec/fe/802052aecb21e3797b8f7902564ab6ea0d60ff8ca23952079064155d1ae1/tabulate-0.9.0.tar.gz", hash = "sha256:0095b12bf5966de529c0feb1fa08671671b3368eec77d7ef7ab114be2c068b3c", size = 81090, upload-time = "2022-10-06T17:21:48.54Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/40/44/4a5f08c96eb108af5cb50b41f76142f0afa346dfa99d5296fe7202a11854/tabulate-0.9.0-py3-none-any.whl", hash = "sha256:024ca478df22e9340661486f85298cff5f6dcdba14f3813e8830015b9ed1948f", size = 35252, upload-time = "2022-10-06T17:21:44.262Z" }, +] + +[[package]] +name = "termcolor" +version = "3.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/87/56/ab275c2b56a5e2342568838f0d5e3e66a32354adcc159b495e374cda43f5/termcolor-3.2.0.tar.gz", hash = "sha256:610e6456feec42c4bcd28934a8c87a06c3fa28b01561d46aa09a9881b8622c58", size = 14423, upload-time = "2025-10-25T19:11:42.586Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f9/d5/141f53d7c1eb2a80e6d3e9a390228c3222c27705cbe7f048d3623053f3ca/termcolor-3.2.0-py3-none-any.whl", hash = "sha256:a10343879eba4da819353c55cb8049b0933890c2ebf9ad5d3ecd2bb32ea96ea6", size = 7698, upload-time = "2025-10-25T19:11:41.536Z" }, +] + +[[package]] +name = "terminado" +version = "0.18.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ptyprocess", marker = "os_name != 'nt'" }, + { name = "pywinpty", marker = "os_name == 'nt'" }, + { name = "tornado" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8a/11/965c6fd8e5cc254f1fe142d547387da17a8ebfd75a3455f637c663fb38a0/terminado-0.18.1.tar.gz", hash = "sha256:de09f2c4b85de4765f7714688fff57d3e75bad1f909b589fde880460c753fd2e", size = 32701, upload-time = "2024-03-12T14:34:39.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/9e/2064975477fdc887e47ad42157e214526dcad8f317a948dee17e1659a62f/terminado-0.18.1-py3-none-any.whl", hash = "sha256:a4468e1b37bb318f8a86514f65814e1afc977cf29b3992a4500d9dd305dcceb0", size = 14154, upload-time = "2024-03-12T14:34:36.569Z" }, +] + +[[package]] +name = "tinycss2" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "webencodings" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7a/fd/7a5ee21fd08ff70d3d33a5781c255cbe779659bd03278feb98b19ee550f4/tinycss2-1.4.0.tar.gz", hash = "sha256:10c0972f6fc0fbee87c3edb76549357415e94548c1ae10ebccdea16fb404a9b7", size = 87085, upload-time = "2024-10-24T14:58:29.895Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e6/34/ebdc18bae6aa14fbee1a08b63c015c72b64868ff7dae68808ab500c492e2/tinycss2-1.4.0-py3-none-any.whl", hash = "sha256:3a49cf47b7675da0b15d0c6e1df8df4ebd96e9394bb905a5775adb0d884c5289", size = 26610, upload-time = "2024-10-24T14:58:28.029Z" }, +] + +[[package]] +name = "tomli" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/52/ed/3f73f72945444548f33eba9a87fc7a6e969915e7b1acc8260b30e1f76a2f/tomli-2.3.0.tar.gz", hash = "sha256:64be704a875d2a59753d80ee8a533c3fe183e3f06807ff7dc2232938ccb01549", size = 17392, upload-time = "2025-10-08T22:01:47.119Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/2e/299f62b401438d5fe1624119c723f5d877acc86a4c2492da405626665f12/tomli-2.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:88bd15eb972f3664f5ed4b57c1634a97153b4bac4479dcb6a495f41921eb7f45", size = 153236, upload-time = "2025-10-08T22:01:00.137Z" }, + { url = "https://files.pythonhosted.org/packages/86/7f/d8fffe6a7aefdb61bced88fcb5e280cfd71e08939da5894161bd71bea022/tomli-2.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:883b1c0d6398a6a9d29b508c331fa56adbcdff647f6ace4dfca0f50e90dfd0ba", size = 148084, upload-time = "2025-10-08T22:01:01.63Z" }, + { url = "https://files.pythonhosted.org/packages/47/5c/24935fb6a2ee63e86d80e4d3b58b222dafaf438c416752c8b58537c8b89a/tomli-2.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d1381caf13ab9f300e30dd8feadb3de072aeb86f1d34a8569453ff32a7dea4bf", size = 234832, upload-time = "2025-10-08T22:01:02.543Z" }, + { url = "https://files.pythonhosted.org/packages/89/da/75dfd804fc11e6612846758a23f13271b76d577e299592b4371a4ca4cd09/tomli-2.3.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a0e285d2649b78c0d9027570d4da3425bdb49830a6156121360b3f8511ea3441", size = 242052, upload-time = "2025-10-08T22:01:03.836Z" }, + { url = "https://files.pythonhosted.org/packages/70/8c/f48ac899f7b3ca7eb13af73bacbc93aec37f9c954df3c08ad96991c8c373/tomli-2.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0a154a9ae14bfcf5d8917a59b51ffd5a3ac1fd149b71b47a3a104ca4edcfa845", size = 239555, upload-time = "2025-10-08T22:01:04.834Z" }, + { url = "https://files.pythonhosted.org/packages/ba/28/72f8afd73f1d0e7829bfc093f4cb98ce0a40ffc0cc997009ee1ed94ba705/tomli-2.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:74bf8464ff93e413514fefd2be591c3b0b23231a77f901db1eb30d6f712fc42c", size = 245128, upload-time = "2025-10-08T22:01:05.84Z" }, + { url = "https://files.pythonhosted.org/packages/b6/eb/a7679c8ac85208706d27436e8d421dfa39d4c914dcf5fa8083a9305f58d9/tomli-2.3.0-cp311-cp311-win32.whl", hash = "sha256:00b5f5d95bbfc7d12f91ad8c593a1659b6387b43f054104cda404be6bda62456", size = 96445, upload-time = "2025-10-08T22:01:06.896Z" }, + { url = "https://files.pythonhosted.org/packages/0a/fe/3d3420c4cb1ad9cb462fb52967080575f15898da97e21cb6f1361d505383/tomli-2.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:4dc4ce8483a5d429ab602f111a93a6ab1ed425eae3122032db7e9acf449451be", size = 107165, upload-time = "2025-10-08T22:01:08.107Z" }, + { url = "https://files.pythonhosted.org/packages/ff/b7/40f36368fcabc518bb11c8f06379a0fd631985046c038aca08c6d6a43c6e/tomli-2.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d7d86942e56ded512a594786a5ba0a5e521d02529b3826e7761a05138341a2ac", size = 154891, upload-time = "2025-10-08T22:01:09.082Z" }, + { url = "https://files.pythonhosted.org/packages/f9/3f/d9dd692199e3b3aab2e4e4dd948abd0f790d9ded8cd10cbaae276a898434/tomli-2.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:73ee0b47d4dad1c5e996e3cd33b8a76a50167ae5f96a2607cbe8cc773506ab22", size = 148796, upload-time = "2025-10-08T22:01:10.266Z" }, + { url = "https://files.pythonhosted.org/packages/60/83/59bff4996c2cf9f9387a0f5a3394629c7efa5ef16142076a23a90f1955fa/tomli-2.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:792262b94d5d0a466afb5bc63c7daa9d75520110971ee269152083270998316f", size = 242121, upload-time = "2025-10-08T22:01:11.332Z" }, + { url = "https://files.pythonhosted.org/packages/45/e5/7c5119ff39de8693d6baab6c0b6dcb556d192c165596e9fc231ea1052041/tomli-2.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4f195fe57ecceac95a66a75ac24d9d5fbc98ef0962e09b2eddec5d39375aae52", size = 250070, upload-time = "2025-10-08T22:01:12.498Z" }, + { url = "https://files.pythonhosted.org/packages/45/12/ad5126d3a278f27e6701abde51d342aa78d06e27ce2bb596a01f7709a5a2/tomli-2.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e31d432427dcbf4d86958c184b9bfd1e96b5b71f8eb17e6d02531f434fd335b8", size = 245859, upload-time = "2025-10-08T22:01:13.551Z" }, + { url = "https://files.pythonhosted.org/packages/fb/a1/4d6865da6a71c603cfe6ad0e6556c73c76548557a8d658f9e3b142df245f/tomli-2.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b0882799624980785240ab732537fcfc372601015c00f7fc367c55308c186f6", size = 250296, upload-time = "2025-10-08T22:01:14.614Z" }, + { url = "https://files.pythonhosted.org/packages/a0/b7/a7a7042715d55c9ba6e8b196d65d2cb662578b4d8cd17d882d45322b0d78/tomli-2.3.0-cp312-cp312-win32.whl", hash = "sha256:ff72b71b5d10d22ecb084d345fc26f42b5143c5533db5e2eaba7d2d335358876", size = 97124, upload-time = "2025-10-08T22:01:15.629Z" }, + { url = "https://files.pythonhosted.org/packages/06/1e/f22f100db15a68b520664eb3328fb0ae4e90530887928558112c8d1f4515/tomli-2.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:1cb4ed918939151a03f33d4242ccd0aa5f11b3547d0cf30f7c74a408a5b99878", size = 107698, upload-time = "2025-10-08T22:01:16.51Z" }, + { url = "https://files.pythonhosted.org/packages/89/48/06ee6eabe4fdd9ecd48bf488f4ac783844fd777f547b8d1b61c11939974e/tomli-2.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5192f562738228945d7b13d4930baffda67b69425a7f0da96d360b0a3888136b", size = 154819, upload-time = "2025-10-08T22:01:17.964Z" }, + { url = "https://files.pythonhosted.org/packages/f1/01/88793757d54d8937015c75dcdfb673c65471945f6be98e6a0410fba167ed/tomli-2.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:be71c93a63d738597996be9528f4abe628d1adf5e6eb11607bc8fe1a510b5dae", size = 148766, upload-time = "2025-10-08T22:01:18.959Z" }, + { url = "https://files.pythonhosted.org/packages/42/17/5e2c956f0144b812e7e107f94f1cc54af734eb17b5191c0bbfb72de5e93e/tomli-2.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c4665508bcbac83a31ff8ab08f424b665200c0e1e645d2bd9ab3d3e557b6185b", size = 240771, upload-time = "2025-10-08T22:01:20.106Z" }, + { url = "https://files.pythonhosted.org/packages/d5/f4/0fbd014909748706c01d16824eadb0307115f9562a15cbb012cd9b3512c5/tomli-2.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4021923f97266babc6ccab9f5068642a0095faa0a51a246a6a02fccbb3514eaf", size = 248586, upload-time = "2025-10-08T22:01:21.164Z" }, + { url = "https://files.pythonhosted.org/packages/30/77/fed85e114bde5e81ecf9bc5da0cc69f2914b38f4708c80ae67d0c10180c5/tomli-2.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4ea38c40145a357d513bffad0ed869f13c1773716cf71ccaa83b0fa0cc4e42f", size = 244792, upload-time = "2025-10-08T22:01:22.417Z" }, + { url = "https://files.pythonhosted.org/packages/55/92/afed3d497f7c186dc71e6ee6d4fcb0acfa5f7d0a1a2878f8beae379ae0cc/tomli-2.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ad805ea85eda330dbad64c7ea7a4556259665bdf9d2672f5dccc740eb9d3ca05", size = 248909, upload-time = "2025-10-08T22:01:23.859Z" }, + { url = "https://files.pythonhosted.org/packages/f8/84/ef50c51b5a9472e7265ce1ffc7f24cd4023d289e109f669bdb1553f6a7c2/tomli-2.3.0-cp313-cp313-win32.whl", hash = "sha256:97d5eec30149fd3294270e889b4234023f2c69747e555a27bd708828353ab606", size = 96946, upload-time = "2025-10-08T22:01:24.893Z" }, + { url = "https://files.pythonhosted.org/packages/b2/b7/718cd1da0884f281f95ccfa3a6cc572d30053cba64603f79d431d3c9b61b/tomli-2.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:0c95ca56fbe89e065c6ead5b593ee64b84a26fca063b5d71a1122bf26e533999", size = 107705, upload-time = "2025-10-08T22:01:26.153Z" }, + { url = "https://files.pythonhosted.org/packages/19/94/aeafa14a52e16163008060506fcb6aa1949d13548d13752171a755c65611/tomli-2.3.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:cebc6fe843e0733ee827a282aca4999b596241195f43b4cc371d64fc6639da9e", size = 154244, upload-time = "2025-10-08T22:01:27.06Z" }, + { url = "https://files.pythonhosted.org/packages/db/e4/1e58409aa78eefa47ccd19779fc6f36787edbe7d4cd330eeeedb33a4515b/tomli-2.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4c2ef0244c75aba9355561272009d934953817c49f47d768070c3c94355c2aa3", size = 148637, upload-time = "2025-10-08T22:01:28.059Z" }, + { url = "https://files.pythonhosted.org/packages/26/b6/d1eccb62f665e44359226811064596dd6a366ea1f985839c566cd61525ae/tomli-2.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c22a8bf253bacc0cf11f35ad9808b6cb75ada2631c2d97c971122583b129afbc", size = 241925, upload-time = "2025-10-08T22:01:29.066Z" }, + { url = "https://files.pythonhosted.org/packages/70/91/7cdab9a03e6d3d2bb11beae108da5bdc1c34bdeb06e21163482544ddcc90/tomli-2.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0eea8cc5c5e9f89c9b90c4896a8deefc74f518db5927d0e0e8d4a80953d774d0", size = 249045, upload-time = "2025-10-08T22:01:31.98Z" }, + { url = "https://files.pythonhosted.org/packages/15/1b/8c26874ed1f6e4f1fcfeb868db8a794cbe9f227299402db58cfcc858766c/tomli-2.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b74a0e59ec5d15127acdabd75ea17726ac4c5178ae51b85bfe39c4f8a278e879", size = 245835, upload-time = "2025-10-08T22:01:32.989Z" }, + { url = "https://files.pythonhosted.org/packages/fd/42/8e3c6a9a4b1a1360c1a2a39f0b972cef2cc9ebd56025168c4137192a9321/tomli-2.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b5870b50c9db823c595983571d1296a6ff3e1b88f734a4c8f6fc6188397de005", size = 253109, upload-time = "2025-10-08T22:01:34.052Z" }, + { url = "https://files.pythonhosted.org/packages/22/0c/b4da635000a71b5f80130937eeac12e686eefb376b8dee113b4a582bba42/tomli-2.3.0-cp314-cp314-win32.whl", hash = "sha256:feb0dacc61170ed7ab602d3d972a58f14ee3ee60494292d384649a3dc38ef463", size = 97930, upload-time = "2025-10-08T22:01:35.082Z" }, + { url = "https://files.pythonhosted.org/packages/b9/74/cb1abc870a418ae99cd5c9547d6bce30701a954e0e721821df483ef7223c/tomli-2.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:b273fcbd7fc64dc3600c098e39136522650c49bca95df2d11cf3b626422392c8", size = 107964, upload-time = "2025-10-08T22:01:36.057Z" }, + { url = "https://files.pythonhosted.org/packages/54/78/5c46fff6432a712af9f792944f4fcd7067d8823157949f4e40c56b8b3c83/tomli-2.3.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:940d56ee0410fa17ee1f12b817b37a4d4e4dc4d27340863cc67236c74f582e77", size = 163065, upload-time = "2025-10-08T22:01:37.27Z" }, + { url = "https://files.pythonhosted.org/packages/39/67/f85d9bd23182f45eca8939cd2bc7050e1f90c41f4a2ecbbd5963a1d1c486/tomli-2.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f85209946d1fe94416debbb88d00eb92ce9cd5266775424ff81bc959e001acaf", size = 159088, upload-time = "2025-10-08T22:01:38.235Z" }, + { url = "https://files.pythonhosted.org/packages/26/5a/4b546a0405b9cc0659b399f12b6adb750757baf04250b148d3c5059fc4eb/tomli-2.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a56212bdcce682e56b0aaf79e869ba5d15a6163f88d5451cbde388d48b13f530", size = 268193, upload-time = "2025-10-08T22:01:39.712Z" }, + { url = "https://files.pythonhosted.org/packages/42/4f/2c12a72ae22cf7b59a7fe75b3465b7aba40ea9145d026ba41cb382075b0e/tomli-2.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c5f3ffd1e098dfc032d4d3af5c0ac64f6d286d98bc148698356847b80fa4de1b", size = 275488, upload-time = "2025-10-08T22:01:40.773Z" }, + { url = "https://files.pythonhosted.org/packages/92/04/a038d65dbe160c3aa5a624e93ad98111090f6804027d474ba9c37c8ae186/tomli-2.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e01decd096b1530d97d5d85cb4dff4af2d8347bd35686654a004f8dea20fc67", size = 272669, upload-time = "2025-10-08T22:01:41.824Z" }, + { url = "https://files.pythonhosted.org/packages/be/2f/8b7c60a9d1612a7cbc39ffcca4f21a73bf368a80fc25bccf8253e2563267/tomli-2.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8a35dd0e643bb2610f156cca8db95d213a90015c11fee76c946aa62b7ae7e02f", size = 279709, upload-time = "2025-10-08T22:01:43.177Z" }, + { url = "https://files.pythonhosted.org/packages/7e/46/cc36c679f09f27ded940281c38607716c86cf8ba4a518d524e349c8b4874/tomli-2.3.0-cp314-cp314t-win32.whl", hash = "sha256:a1f7f282fe248311650081faafa5f4732bdbfef5d45fe3f2e702fbc6f2d496e0", size = 107563, upload-time = "2025-10-08T22:01:44.233Z" }, + { url = "https://files.pythonhosted.org/packages/84/ff/426ca8683cf7b753614480484f6437f568fd2fda2edbdf57a2d3d8b27a0b/tomli-2.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:70a251f8d4ba2d9ac2542eecf008b3c8a9fc5c3f9f02c56a9d7952612be2fdba", size = 119756, upload-time = "2025-10-08T22:01:45.234Z" }, + { url = "https://files.pythonhosted.org/packages/77/b8/0135fadc89e73be292b473cb820b4f5a08197779206b33191e801feeae40/tomli-2.3.0-py3-none-any.whl", hash = "sha256:e95b1af3c5b07d9e643909b5abbec77cd9f1217e6d0bca72b0234736b9fb1f1b", size = 14408, upload-time = "2025-10-08T22:01:46.04Z" }, +] + +[[package]] +name = "tornado" +version = "6.5.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/09/ce/1eb500eae19f4648281bb2186927bb062d2438c2e5093d1360391afd2f90/tornado-6.5.2.tar.gz", hash = "sha256:ab53c8f9a0fa351e2c0741284e06c7a45da86afb544133201c5cc8578eb076a0", size = 510821, upload-time = "2025-08-08T18:27:00.78Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f6/48/6a7529df2c9cc12efd2e8f5dd219516184d703b34c06786809670df5b3bd/tornado-6.5.2-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:2436822940d37cde62771cff8774f4f00b3c8024fe482e16ca8387b8a2724db6", size = 442563, upload-time = "2025-08-08T18:26:42.945Z" }, + { url = "https://files.pythonhosted.org/packages/f2/b5/9b575a0ed3e50b00c40b08cbce82eb618229091d09f6d14bce80fc01cb0b/tornado-6.5.2-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:583a52c7aa94ee046854ba81d9ebb6c81ec0fd30386d96f7640c96dad45a03ef", size = 440729, upload-time = "2025-08-08T18:26:44.473Z" }, + { url = "https://files.pythonhosted.org/packages/1b/4e/619174f52b120efcf23633c817fd3fed867c30bff785e2cd5a53a70e483c/tornado-6.5.2-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b0fe179f28d597deab2842b86ed4060deec7388f1fd9c1b4a41adf8af058907e", size = 444295, upload-time = "2025-08-08T18:26:46.021Z" }, + { url = "https://files.pythonhosted.org/packages/95/fa/87b41709552bbd393c85dd18e4e3499dcd8983f66e7972926db8d96aa065/tornado-6.5.2-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b186e85d1e3536d69583d2298423744740986018e393d0321df7340e71898882", size = 443644, upload-time = "2025-08-08T18:26:47.625Z" }, + { url = "https://files.pythonhosted.org/packages/f9/41/fb15f06e33d7430ca89420283a8762a4e6b8025b800ea51796ab5e6d9559/tornado-6.5.2-cp39-abi3-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e792706668c87709709c18b353da1f7662317b563ff69f00bab83595940c7108", size = 443878, upload-time = "2025-08-08T18:26:50.599Z" }, + { url = "https://files.pythonhosted.org/packages/11/92/fe6d57da897776ad2e01e279170ea8ae726755b045fe5ac73b75357a5a3f/tornado-6.5.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:06ceb1300fd70cb20e43b1ad8aaee0266e69e7ced38fa910ad2e03285009ce7c", size = 444549, upload-time = "2025-08-08T18:26:51.864Z" }, + { url = "https://files.pythonhosted.org/packages/9b/02/c8f4f6c9204526daf3d760f4aa555a7a33ad0e60843eac025ccfd6ff4a93/tornado-6.5.2-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:74db443e0f5251be86cbf37929f84d8c20c27a355dd452a5cfa2aada0d001ec4", size = 443973, upload-time = "2025-08-08T18:26:53.625Z" }, + { url = "https://files.pythonhosted.org/packages/ae/2d/f5f5707b655ce2317190183868cd0f6822a1121b4baeae509ceb9590d0bd/tornado-6.5.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b5e735ab2889d7ed33b32a459cac490eda71a1ba6857b0118de476ab6c366c04", size = 443954, upload-time = "2025-08-08T18:26:55.072Z" }, + { url = "https://files.pythonhosted.org/packages/e8/59/593bd0f40f7355806bf6573b47b8c22f8e1374c9b6fd03114bd6b7a3dcfd/tornado-6.5.2-cp39-abi3-win32.whl", hash = "sha256:c6f29e94d9b37a95013bb669616352ddb82e3bfe8326fccee50583caebc8a5f0", size = 445023, upload-time = "2025-08-08T18:26:56.677Z" }, + { url = "https://files.pythonhosted.org/packages/c7/2a/f609b420c2f564a748a2d80ebfb2ee02a73ca80223af712fca591386cafb/tornado-6.5.2-cp39-abi3-win_amd64.whl", hash = "sha256:e56a5af51cc30dd2cae649429af65ca2f6571da29504a07995175df14c18f35f", size = 445427, upload-time = "2025-08-08T18:26:57.91Z" }, + { url = "https://files.pythonhosted.org/packages/5e/4f/e1f65e8f8c76d73658b33d33b81eed4322fb5085350e4328d5c956f0c8f9/tornado-6.5.2-cp39-abi3-win_arm64.whl", hash = "sha256:d6c33dc3672e3a1f3618eb63b7ef4683a7688e7b9e6e8f0d9aa5726360a004af", size = 444456, upload-time = "2025-08-08T18:26:59.207Z" }, +] + +[[package]] +name = "traitlets" +version = "5.14.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/eb/79/72064e6a701c2183016abbbfedaba506d81e30e232a68c9f0d6f6fcd1574/traitlets-5.14.3.tar.gz", hash = "sha256:9ed0579d3502c94b4b3732ac120375cda96f923114522847de4b3bb98b96b6b7", size = 161621, upload-time = "2024-04-19T11:11:49.746Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/00/c0/8f5d070730d7836adc9c9b6408dec68c6ced86b304a9b26a14df072a6e8c/traitlets-5.14.3-py3-none-any.whl", hash = "sha256:b74e89e397b1ed28cc831db7aea759ba6640cb3de13090ca145426688ff1ac4f", size = 85359, upload-time = "2024-04-19T11:11:46.763Z" }, +] + +[[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 = "tzdata" +version = "2025.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/95/32/1a225d6164441be760d75c2c42e2780dc0873fe382da3e98a2e1e48361e5/tzdata-2025.2.tar.gz", hash = "sha256:b60a638fcc0daffadf82fe0f57e53d06bdec2f36c4df66280ae79bce6bd6f2b9", size = 196380, upload-time = "2025-03-23T13:54:43.652Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/23/c7abc0ca0a1526a0774eca151daeb8de62ec457e77262b66b359c3c7679e/tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8", size = 347839, upload-time = "2025-03-23T13:54:41.845Z" }, +] + +[[package]] +name = "uri-template" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/31/c7/0336f2bd0bcbada6ccef7aaa25e443c118a704f828a0620c6fa0207c1b64/uri-template-1.3.0.tar.gz", hash = "sha256:0e00f8eb65e18c7de20d595a14336e9f337ead580c70934141624b6d1ffdacc7", size = 21678, upload-time = "2023-06-21T01:49:05.374Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/00/3fca040d7cf8a32776d3d81a00c8ee7457e00f80c649f1e4a863c8321ae9/uri_template-1.3.0-py3-none-any.whl", hash = "sha256:a44a133ea12d44a0c0f06d7d42a52d71282e77e2f937d8abd5655b8d56fc1363", size = 11140, upload-time = "2023-06-21T01:49:03.467Z" }, +] + +[[package]] +name = "urllib3" +version = "2.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/15/22/9ee70a2574a4f4599c47dd506532914ce044817c7752a79b6a51286319bc/urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760", size = 393185, upload-time = "2025-06-18T14:07:41.644Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795, upload-time = "2025-06-18T14:07:40.39Z" }, +] + +[[package]] +name = "wcwidth" +version = "0.2.14" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/30/6b0809f4510673dc723187aeaf24c7f5459922d01e2f794277a3dfb90345/wcwidth-0.2.14.tar.gz", hash = "sha256:4d478375d31bc5395a3c55c40ccdf3354688364cd61c4f6adacaa9215d0b3605", size = 102293, upload-time = "2025-09-22T16:29:53.023Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/af/b5/123f13c975e9f27ab9c0770f514345bd406d0e8d3b7a0723af9d43f710af/wcwidth-0.2.14-py2.py3-none-any.whl", hash = "sha256:a7bb560c8aee30f9957e5f9895805edd20602f2d7f720186dfd906e82b4982e1", size = 37286, upload-time = "2025-09-22T16:29:51.641Z" }, +] + +[[package]] +name = "webcolors" +version = "25.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1d/7a/eb316761ec35664ea5174709a68bbd3389de60d4a1ebab8808bfc264ed67/webcolors-25.10.0.tar.gz", hash = "sha256:62abae86504f66d0f6364c2a8520de4a0c47b80c03fc3a5f1815fedbef7c19bf", size = 53491, upload-time = "2025-10-31T07:51:03.977Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e2/cc/e097523dd85c9cf5d354f78310927f1656c422bd7b2613b2db3e3f9a0f2c/webcolors-25.10.0-py3-none-any.whl", hash = "sha256:032c727334856fc0b968f63daa252a1ac93d33db2f5267756623c210e57a4f1d", size = 14905, upload-time = "2025-10-31T07:51:01.778Z" }, +] + +[[package]] +name = "webencodings" +version = "0.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0b/02/ae6ceac1baeda530866a85075641cec12989bd8d31af6d5ab4a3e8c92f47/webencodings-0.5.1.tar.gz", hash = "sha256:b36a1c245f2d304965eb4e0a82848379241dc04b865afcc4aab16748587e1923", size = 9721, upload-time = "2017-04-05T20:21:34.189Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/24/2a3e3df732393fed8b3ebf2ec078f05546de641fe1b667ee316ec1dcf3b7/webencodings-0.5.1-py2.py3-none-any.whl", hash = "sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78", size = 11774, upload-time = "2017-04-05T20:21:32.581Z" }, +] + +[[package]] +name = "websocket-client" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2c/41/aa4bf9664e4cda14c3b39865b12251e8e7d239f4cd0e3cc1b6c2ccde25c1/websocket_client-1.9.0.tar.gz", hash = "sha256:9e813624b6eb619999a97dc7958469217c3176312b3a16a4bd1bc7e08a46ec98", size = 70576, upload-time = "2025-10-07T21:16:36.495Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/34/db/b10e48aa8fff7407e67470363eac595018441cf32d5e1001567a7aeba5d2/websocket_client-1.9.0-py3-none-any.whl", hash = "sha256:af248a825037ef591efbf6ed20cc5faa03d3b47b9e5a2230a529eeee1c1fc3ef", size = 82616, upload-time = "2025-10-07T21:16:34.951Z" }, +] + +[[package]] +name = "wepy" +version = "2.0.0b0" +source = { editable = "." } +dependencies = [ + { name = "attrs" }, + { name = "click" }, + { name = "geomm" }, + { name = "h5py" }, + { name = "immutables" }, + { name = "jinja2" }, + { name = "more-itertools" }, + { name = "networkx" }, + { name = "numpy" }, + { name = "pandas" }, + { name = "pint" }, + { name = "scipy" }, + { name = "tabulate" }, +] + +[package.optional-dependencies] +graphics = [ + { name = "matplotlib" }, + { name = "pillow" }, +] +md = [ + { name = "lxml" }, + { name = "mdtraj" }, + { name = "openmm" }, + { name = "openmm-systems" }, +] +prometheus = [ + { name = "prometheus-client" }, + { name = "pympler" }, +] + +[package.dev-dependencies] +dev = [ + { name = "black" }, + { name = "coverage" }, + { name = "cyclopts" }, + { name = "interrogate" }, + { name = "ipython" }, + { name = "isort" }, + { name = "lxml" }, + { name = "mypy" }, + { name = "nbsphinx" }, + { name = "notebook" }, + { name = "pdbpp" }, + { name = "psutil" }, + { name = "pytest" }, + { name = "pytest-check" }, + { name = "pytest-cov" }, + { name = "pytest-datadir" }, + { name = "pytest-flakefinder" }, + { name = "pytest-print" }, + { name = "pytest-rerunfailures" }, + { name = "pytest-shutil" }, + { name = "pytest-timeout" }, + { name = "ruff" }, + { name = "sphinx" }, + { name = "sphinxcontrib-bibtex" }, + { name = "sphinxcontrib-napoleon" }, + { name = "sphinxcontrib-newsfeed" }, +] + +[package.metadata] +requires-dist = [ + { name = "attrs" }, + { name = "click" }, + { name = "geomm" }, + { name = "h5py", specifier = ">=3" }, + { name = "immutables" }, + { name = "jinja2" }, + { name = "lxml", marker = "extra == 'md'" }, + { name = "matplotlib", marker = "extra == 'graphics'" }, + { name = "mdtraj", marker = "extra == 'md'" }, + { name = "more-itertools" }, + { name = "networkx" }, + { name = "numpy" }, + { name = "openmm", marker = "extra == 'md'" }, + { name = "openmm-systems", marker = "extra == 'md'", specifier = "==0.0.0" }, + { name = "pandas" }, + { name = "pillow", marker = "extra == 'graphics'", specifier = ">=10.0.1" }, + { name = "pint" }, + { name = "prometheus-client", marker = "extra == 'prometheus'" }, + { name = "pympler", marker = "extra == 'prometheus'" }, + { name = "scipy" }, + { name = "tabulate" }, +] +provides-extras = ["md", "prometheus", "graphics"] + +[package.metadata.requires-dev] +dev = [ + { name = "black" }, + { name = "coverage" }, + { name = "cyclopts" }, + { name = "interrogate" }, + { name = "ipython" }, + { name = "isort" }, + { name = "lxml" }, + { name = "mypy" }, + { name = "nbsphinx" }, + { name = "notebook" }, + { name = "pdbpp" }, + { name = "psutil" }, + { name = "pytest" }, + { name = "pytest-check" }, + { name = "pytest-cov" }, + { name = "pytest-datadir", git = "https://github.com/salotz/pytest-datadir-extras.git?rev=41e9a1e94ba27efe28ce9d0019b1e0a73be34400" }, + { name = "pytest-flakefinder" }, + { name = "pytest-print" }, + { name = "pytest-rerunfailures" }, + { name = "pytest-shutil" }, + { name = "pytest-timeout" }, + { name = "ruff" }, + { name = "sphinx" }, + { name = "sphinxcontrib-bibtex" }, + { name = "sphinxcontrib-napoleon" }, + { name = "sphinxcontrib-newsfeed" }, +]