diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..829bcc7 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,86 @@ +# CLAUDE.md + +## Project overview + +pyvcell is the Python interface for [Virtual Cell](https://vcell.org) — spatial modeling, simulation, and analysis of cell biological systems. It wraps the VCell finite volume solver and provides a high-level API for building models, running simulations, and analyzing results. + +- **Repository**: https://github.com/virtualcell/pyvcell +- **Documentation**: https://virtualcell.github.io/pyvcell/ +- **Python**: >= 3.11 + +## Key commands + +```bash +make check # Run all quality checks (lint, type check, deptry) +make test # Run pytest with coverage +make docs # Build and serve documentation locally +make docs-test # Test documentation build +``` + +### Verification (run after code changes) + +Run `make check`, which does: + +1. `poetry check --lock` — verify lock file consistency +2. `poetry run pre-commit run -a` — linting (ruff, ruff-format, prettier) +3. `poetry run mypy` — static type checking +4. `poetry run deptry --exclude=.venv --exclude=.venv_jupyter --exclude=examples --exclude=tests .` — obsolete dependency check + +Then run tests: `poetry run pytest tests -v` + +## Project structure + +``` +pyvcell/ + vcml/ # Main public API — models, reader, writer, simulation, remote + sbml/ # SBML spatial model support + sim_results/ # Result objects, plotting, VTK data + _internal/ + api/ # Generated REST client (OpenAPI) — do not edit by hand + geometry/ # Geometry utilities (SegmentedImageGeometry) + simdata/ # Simulation data: zarr, mesh, field data, VTK + solvers/ # FV solver wrapper +tests/ + vcml/ # VCML reader/writer/simulation tests + sbml/ # SBML tests + sim_results/ # Result and plotting tests + guides/ # Notebook execution tests + _internal/ # Internal module tests + fixtures/ # Test data and fixtures +docs/ # mkdocs-material site with guides and API reference +scripts/ + generate.sh # Regenerate OpenAPI client + python-fix.sh # Post-generation fixes for known codegen bugs + openapi.yaml # OpenAPI spec for VCell server +``` + +## Public API + +The main entry point is `import pyvcell.vcml as vc`. Key functions: + +- **Load models**: `vc.load_vcml_file()`, `vc.load_vcml_url()`, `vc.load_biomodel(id)`, `vc.load_sbml_file()`, `vc.load_antimony_str()` +- **Simulate**: `vc.simulate(biomodel, sim_name)` (local), `vc.run_remote(...)` (server) +- **Results**: `result.plotter.plot_concentrations()`, `result.plotter.plot_slice_3d()` + +## Code conventions + +- **Line length**: 120 characters (ruff) +- **Type checking**: mypy strict mode; the `pyvcell._internal.api.*` module has relaxed overrides since it's auto-generated +- **Linting**: ruff with flake8-bandit (S), bugbear (B), isort (I), pyupgrade (UP) and others enabled +- **Pre-commit hooks exclude** `pyvcell/_internal/api/.*\.py` from ruff (generated code) +- **Test fixtures**: defined in `tests/fixtures/` and imported via `tests/conftest.py` + +## Generated API client + +The `pyvcell/_internal/api/vcell_client/` directory is auto-generated from `scripts/openapi.yaml` using OpenAPI Generator. After regeneration: + +1. Run `scripts/generate.sh` (which calls `scripts/python-fix.sh` to patch known codegen bugs) +2. Run `make check` to reformat and verify +3. The generated code uses pydantic models with camelCase aliases — use alias names (e.g., `simulationName=`) when constructing models to satisfy mypy + +## Testing notes + +- Tests that call `plt.show()` or use VTK rendering need `matplotlib.use("Agg")` to avoid hanging on interactive display +- CI uses `xvfb-run` for tests that need a display server (VTK/PyVista) +- The `remote-simulations` notebook test is skipped (requires interactive auth) +- Test expected values for `result.concentrations` use domain-masked means from the zarr writer diff --git a/README.md b/README.md index 083e26b..50be058 100644 --- a/README.md +++ b/README.md @@ -63,7 +63,7 @@ results.plotter.plot_concentrations() # Documentation -coming soon. +Full documentation is available at **https://virtualcell.github.io/pyvcell/** # Examples: diff --git a/docs/getting-started/installation.md b/docs/getting-started/installation.md index 458c420..147557b 100644 --- a/docs/getting-started/installation.md +++ b/docs/getting-started/installation.md @@ -41,16 +41,14 @@ vc.set_workspace_dir("/path/to/my/workspace") The workspace directory is created automatically if it doesn't exist. -## Optional dependencies +## Included visualization libraries -pyvcell includes visualization tools that depend on: +pyvcell bundles several visualization libraries, all installed automatically: - **Matplotlib** — 2D plots and concentration time series - **VTK / PyVista** — 3D volume rendering and mesh visualization - **Trame** — Interactive browser-based 3D widgets (for Jupyter notebooks) -All of these are installed automatically with `pip install pyvcell`. - ## Next steps - [Quick Start](quickstart.md) — Load a model, simulate, and plot results diff --git a/docs/getting-started/quickstart.md b/docs/getting-started/quickstart.md index 6fb842d..f9c6695 100644 --- a/docs/getting-started/quickstart.md +++ b/docs/getting-started/quickstart.md @@ -11,7 +11,38 @@ biomodel = vc.load_vcml_file("path/to/model.vcml") print(biomodel) ``` -You can also load models from a URL: +You can also load public models directly from the VCell database by ID: + +```python +biomodel = vc.load_biomodel("279851639") +``` + +To browse or search models by name, authenticate first: + +```python +from pyvcell._internal.api.vcell_client.auth.auth_utils import login_interactive + +api_client = login_interactive() # opens a browser for login + +# List available models (public, shared, and your private models) +for m in vc.list_biomodels(api_client=api_client)[:2]: + print(m) + +# Load by name and owner +biomodel = vc.load_biomodel(name="Tutorial_MultiApp", owner="tutorial", api_client=api_client) + +# Load by database ID +biomodel = vc.load_biomodel("279851639", api_client=api_client) +``` + +Output: + +``` +{'id': '117367327', 'name': ' Design dose in mammal MTB37rv', 'owner': 'mcgama88'} +{'id': '102571573', 'name': ' Zika- denge differential test to fetus x 1', 'owner': 'mcgama88'} +``` + +Or from a URL: ```python biomodel = vc.load_vcml_url( diff --git a/docs/index.md b/docs/index.md index 954ce29..591e102 100644 --- a/docs/index.md +++ b/docs/index.md @@ -30,13 +30,9 @@ result.plotter.plot_slice_3d(time_index=3, channel_id="s1") ## Getting started -
- - **[Installation](getting-started/installation.md)** — Install pyvcell and set up your environment - **[Quick Start](getting-started/quickstart.md)** — Load a model, run a simulation, and plot results -
- ## Guides | Guide | Description | diff --git a/pyproject.toml b/pyproject.toml index 23f22d3..7ea2a9b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "pyvcell" -version = "0.1.21" +version = "0.1.22" description = "This is the python wrapper for vcell modeling and simulation" authors = ["Jim Schaff "] repository = "https://github.com/virtualcell/pyvcell" diff --git a/pyvcell/vcml/__init__.py b/pyvcell/vcml/__init__.py index 78a1c5f..793dd78 100644 --- a/pyvcell/vcml/__init__.py +++ b/pyvcell/vcml/__init__.py @@ -26,8 +26,10 @@ ) from pyvcell.vcml.utils import ( field_data_refs, + list_biomodels, load_antimony_file, load_antimony_str, + load_biomodel, load_sbml_file, load_sbml_str, load_sbml_url, @@ -98,6 +100,8 @@ "wait_for_simulation", "export_n5", "Field", + "list_biomodels", + "load_biomodel", "load_vcml_url", "load_sbml_url", "suppress_stdout", diff --git a/pyvcell/vcml/utils.py b/pyvcell/vcml/utils.py index 197f7a7..958bca2 100644 --- a/pyvcell/vcml/utils.py +++ b/pyvcell/vcml/utils.py @@ -1,8 +1,14 @@ +from __future__ import annotations + import logging import os import sys import tempfile from os import PathLike +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from pyvcell._internal.api.vcell_client.api_client import ApiClient from pathlib import Path import sympy # type: ignore[import-untyped] @@ -196,12 +202,135 @@ def _download_url(url: str) -> str: raise ValueError(f"Failed to download file from {url}: {response.status_code}") -def load_vcml_biomodel_id(biomodel_id: str) -> Biomodel: +def list_biomodels(api_client: ApiClient | None = None) -> list[dict[str, str | None]]: + """Return a list of accessible BioModels from the VCell server. + + Each entry is a dictionary with ``"id"``, ``"name"``, and ``"owner"`` keys. + Requires an authenticated *api_client*. + + Args: + api_client: A pre-configured, authenticated :class:`ApiClient`. + + Returns: + A list of dictionaries, e.g. + ``[{"id": "279851639", "name": "My Model", "owner": "jsmith"}, ...]`` """ - Load a VCML model from a VCell Biomodel ID. + from pyvcell._internal.api.vcell_client.api_client import ApiClient + from pyvcell._internal.api.vcell_client.configuration import Configuration + + if api_client is None: + api_client = ApiClient(configuration=Configuration()) + + host = api_client.configuration.host + headers: dict[str, str] = {"Accept": "application/json"} + token = api_client.configuration.access_token + if token: + headers["Authorization"] = f"Bearer {token}" + + import requests as _requests + + resp = _requests.get(f"{host}/api/v1/bioModel/summaries", headers=headers, timeout=30) + if resp.status_code != 200: + raise ValueError(f"Failed to list BioModels: {resp.status_code} {resp.text[:200]}") + + results: list[dict[str, str | None]] = [] + for item in resp.json(): + v = item.get("version") + if v is None: + continue + owner_info = v.get("owner") + results.append({ + "id": v.get("versionKey"), + "name": v.get("name"), + "owner": owner_info.get("userName") if owner_info else None, + }) + return results + + +def load_biomodel( + biomodel_id: str | None = None, + *, + name: str | None = None, + owner: str | None = None, + api_client: ApiClient | None = None, +) -> Biomodel: + """Load a VCell BioModel by database key or by name/owner lookup. + + Provide either *biomodel_id* **or** *name* (optionally with *owner*) + to identify the model. When searching by name, the VCell server is + queried for all accessible BioModel summaries and filtered + client-side. + + For public models loaded by *biomodel_id*, no *api_client* is needed — + an anonymous client is created automatically. Searching by *name* + requires an authenticated :class:`ApiClient`. + + Args: + biomodel_id: The BioModel database key (e.g. ``"279851639"``). + name: BioModel name to search for (case-insensitive substring match). + Requires an authenticated *api_client*. + owner: Owner username to narrow the search (exact, case-insensitive). + api_client: Optional pre-configured :class:`ApiClient` for + accessing private models or searching by name. + + Returns: + A parsed :class:`Biomodel` instance. + + Raises: + ValueError: If no matching model is found or if the arguments are + ambiguous. """ - uri = f"https://vcell.cam.uchc.edu/api/v0/biomodel/{biomodel_id}/biomodel.vcml" - return load_vcml_url(uri) + from pyvcell._internal.api.vcell_client.api.bio_model_resource_api import BioModelResourceApi + from pyvcell._internal.api.vcell_client.api_client import ApiClient + from pyvcell._internal.api.vcell_client.configuration import Configuration + + if biomodel_id is None and name is None: + raise ValueError("Provide either biomodel_id or name to identify the model") + if biomodel_id is not None and name is not None: + raise ValueError("Provide either biomodel_id or name, not both") + + if api_client is None: + api_client = ApiClient(configuration=Configuration()) + + bm_api = BioModelResourceApi(api_client) + + if biomodel_id is not None: + vcml_str: str = bm_api.get_bio_model_vcml(biomodel_id, _headers={"Accept": "text/xml"}) + return load_vcml_str(vcml_str) + + # Search by name (and optionally owner) — name is guaranteed non-None here + # because we checked (biomodel_id is None and name is None) above. + name_lower = name.lower() # type: ignore[union-attr] + owner_lower = owner.lower() if owner else None + + all_models = list_biomodels(api_client=api_client) + matches = [] + for m in all_models: + m_name = m.get("name") + if m_name is None: + continue + if name_lower not in m_name.lower(): + continue + if owner_lower is not None: + m_owner = m.get("owner") + if m_owner is None or m_owner.lower() != owner_lower: + continue + matches.append(m) + + if len(matches) == 0: + msg = f"No BioModel found with name containing '{name}'" + if owner: + msg += f" owned by '{owner}'" + raise ValueError(msg) + if len(matches) > 1: + match_desc = ", ".join(f"'{m['name']}' (id={m['id']}, owner={m['owner']})" for m in matches) + raise ValueError(f"Multiple BioModels match name '{name}': {match_desc}. Use biomodel_id or owner to narrow.") + + found_id = matches[0]["id"] + if found_id is None: + raise ValueError("Matched BioModel has no version key") + vcml_str = bm_api.get_bio_model_vcml(found_id, _headers={"Accept": "text/xml"}) + return load_vcml_str(vcml_str) def load_vcml_url(vcml_url: str) -> Biomodel: diff --git a/tests/vcml/test_load_biomodel.py b/tests/vcml/test_load_biomodel.py new file mode 100644 index 0000000..9f5bcb1 --- /dev/null +++ b/tests/vcml/test_load_biomodel.py @@ -0,0 +1,20 @@ +import pyvcell.vcml as vc +from pyvcell._internal.api.vcell_client import ApiClient, Configuration + + +def test_load_biomodel_public() -> None: + """Load a public BioModel by ID using the default anonymous client.""" + # Dolgitzer 2025 — a published, public model + bm = vc.load_biomodel("279851639") + assert bm.name == "Dolgitzer 2025 A Continuum Model of Mechanosensation Based on Contractility Kit Assembly" + assert len(bm.applications) > 0 + assert bm.version is not None + assert bm.version.key == "279851639" + + +def test_load_biomodel_with_explicit_client() -> None: + """Load a public BioModel using an explicitly provided ApiClient.""" + client = ApiClient(configuration=Configuration(host="https://vcell.cam.uchc.edu")) + bm = vc.load_biomodel("279851639", api_client=client) + assert bm.name == "Dolgitzer 2025 A Continuum Model of Mechanosensation Based on Contractility Kit Assembly" + assert len(bm.applications) > 0