Skip to content

feat: PhysiCell substrate access + microenvironment analysis module - #5

Merged
emcramer merged 1 commit into
mainfrom
feat/physicell-microenvironment
Jul 23, 2026
Merged

feat: PhysiCell substrate access + microenvironment analysis module#5
emcramer merged 1 commit into
mainfrom
feat/physicell-microenvironment

Conversation

@emcramer

@emcramer emcramer commented Jul 23, 2026

Copy link
Copy Markdown
Owner

Two related pieces of the v0.4.0 PhysiCell/microenvironment work, plus the fixes from an in-house pair-review.

1. Substrate access on PhysiCellTimeStep (commit 1)

Closes the main functional gap from the original v0.2.0 defect report (issue #3): the microenvironment .mat was parseable but unreachable from the high-level API. Exposes two distinct notions of "how much substrate," because they answer different questions:

Question Source Method
Environmental oxygen around a location microenvironment voxel field substrate_at(name, x, y)
Internalized oxygen a cell took up cell data internalized_total_substrates internalized_substrates()

The internalized view reflects PhysiCell's actual uptake dynamics rather than approximating from the voxel a cell sits in. Also adds substrates, voxel_positions, substrate_names. substrate_at uses a cached cKDTree and matches brute-force argmin exactly on all 1,035 cells of the example frame; ties are tested explicitly. Backward compatible — discover_physicell_timesteps keeps its 3-tuple return; the microenvironment file is resolved lazily inside the timestep.

2. microenvironment analysis module (commit 2)

Implements the previously-empty stub with three composed, domain-framed analyses:

  • identify_niches(data, n_niches, radius=...) — spatial niches (recurring local cell-type compositions) by k-means over neighborhood composition vectors → NicheResult. Complementary to the LDA topic view.
  • detect_boundaries(data, radius, labels=None) — interface cells scored by neighborhood "foreignness" → BoundaryResult. Generalizes the pairwise spatial.interface_cells to any grouping; pass niche labels for niche interfaces.
  • spatial_gradient / substrate_gradient / density_gradient — scalar-field gradients by local linear regression → GradientField (magnitude, direction). Validated against a finite-difference reference on the regular voxel grid (correlation > 0.97 on interior points; a globally linear field recovers its exact gradient).

3. Pair-review fixes (commit 3)

I had a reviewer go over both pieces adversarially. It caught one real bug my own tests missed, plus two hardening items:

  • HIGH — internalized_substrates() was broken for 1- and 3-substrate models. It hardcoded _0.._n column suffixes, but the parser names a size-1 vector with the bare field name and a size-3 vector with _x/_y/_z. So oxygen-only sims (the most common PhysiCell setup) and 3-substrate sims raised a misleading "not recorded" error even though the data was present. The 5-substrate example fixture never exercised it. Now derives the column names from the field's own <labels> entry. Regression tests added for 1/3/5 substrates.
  • MEDIUM — spatial_gradient returned a silent wrong answer for under-determined fits (fewer than ndim+1 points, or collinear neighbors): lstsq gave a plausible minimum-norm gradient. Now clamps k ≥ ndim+1 and returns NaN for rank-deficient fits. Tests added.
  • LOW — substrate_at flattened multi-dimensional queries despite documenting shape preservation. Now broadcasts inputs and reshapes output. Tests added.

Verification

  • 534 passed, 2 skipped (+53 across the two new test files; the 2 skips are the pre-existing optional-NetworkX cases)
  • ruff check . clean

Notes

  • In the bundled example, internalized_total_substrates is all-zero — that field only accumulates when the run enables track_internalized_substrates_in_each_agent. Documented on the method; not a bug (columns match the raw matrix exactly).
  • Additive feature set → 0.4.0 when cut. Left as [Unreleased] in the changelog.
  • The microenvironment/ module here covers niches/boundaries/gradients. It does not implement the separately-envisioned niche segmentation / spatial-domain partitioning beyond these; that can follow if wanted.

Close the main functional gap from the v0.2.0 defect report: the
microenvironment .mat was parseable but unreachable from the high-level
reader classes, so substrate-coupled analysis — the common case for
tumour ABM work — could not be expressed through the API.

Add two complementary views of "how much substrate":

- Environmental concentration at a location. PhysiCellTimeStep gains
  substrates, voxel_positions, and substrate_at(name, x, y, z=None),
  which returns the concentration in the nearest voxel via a cached
  cKDTree. Verified exactly equal to a brute-force argmin nearest-voxel
  lookup on every cell of the example frame.

- Per-cell internalized amount. internalized_substrates() returns
  PhysiCell's internalized_total_substrates field as a DataFrame keyed by
  substrate name, reflecting the framework's actual uptake dynamics
  rather than approximating uptake from the voxel the cell sits in.

The microenvironment file is located from the XML's
microenvironment/domain/data/filename element (falling back to the
default naming), resolved lazily inside the timestep. discover_physicell_
timesteps keeps its 3-tuple return type, so no existing caller breaks.

Adds tests/test_physicell_microenvironment.py (22 tests).
@emcramer emcramer self-assigned this Jul 23, 2026
@emcramer
emcramer merged commit 1274b57 into main Jul 23, 2026
5 checks passed
@emcramer emcramer changed the title feat: PhysiCell substrate and microenvironment access feat: PhysiCell substrate access + microenvironment analysis module Jul 23, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant