Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 86 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ results.plotter.plot_concentrations()

# Documentation

coming soon.
Full documentation is available at **https://virtualcell.github.io/pyvcell/**

# Examples:

Expand Down
6 changes: 2 additions & 4 deletions docs/getting-started/installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
33 changes: 32 additions & 1 deletion docs/getting-started/quickstart.md
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
4 changes: 0 additions & 4 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,13 +30,9 @@ result.plotter.plot_slice_3d(time_index=3, channel_id="s1")

## Getting started

<div class="grid cards" markdown>

- **[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

</div>

## Guides

| Guide | Description |
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -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 <schaff@uchc.edu>"]
repository = "https://github.com/virtualcell/pyvcell"
Expand Down
4 changes: 4 additions & 0 deletions pyvcell/vcml/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -98,6 +100,8 @@
"wait_for_simulation",
"export_n5",
"Field",
"list_biomodels",
"load_biomodel",
"load_vcml_url",
"load_sbml_url",
"suppress_stdout",
Expand Down
137 changes: 133 additions & 4 deletions pyvcell/vcml/utils.py
Original file line number Diff line number Diff line change
@@ -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]
Expand Down Expand Up @@ -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:
Expand Down
20 changes: 20 additions & 0 deletions tests/vcml/test_load_biomodel.py
Original file line number Diff line number Diff line change
@@ -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
Loading