Skip to content

fix: PhysiCell reader orientation and label handling - #2

Merged
emcramer merged 1 commit into
mainfrom
fix/physicell-reader-defects
Jul 21, 2026
Merged

fix: PhysiCell reader orientation and label handling#2
emcramer merged 1 commit into
mainfrom
fix/physicell-reader-defects

Conversation

@emcramer

Copy link
Copy Markdown
Owner

Fixes issues #1, #2, #4, #5, and #6 from the v0.2.1 defect report filed by the llm-abm-consistency harness (OHSU ChangLab, 2026-07-21). All six findings were reproduced before fixing; four of them reproduce against this repo's own bundled example simulation, not just the reporters' dataset.

Issue #3 (wiring parse_microenvironment_mat into the reader classes) is deliberately not in this PR — see below.

Why these shipped undetected

The bundled example simulation writes 154 variables per frame, and its smallest frame across all 696 timesteps holds 906 cells. n_cells > n_variables holds everywhere, so the orientation heuristic never fires and all 40 existing PhysiCell tests pass while the defect sits there. This is a fixture-coverage gap as much as a code defect, which is why the new tests build matrices where that assumption fails.

Changes

Issue #2 — orientation (High). Orientation now resolves by matching the shape against the declared variable count. A matrix matching neither orientation raises ValueError rather than returning data. With no labels available, the legacy heuristic still applies but emits a UserWarning naming the ambiguity. raw_data is now returned exactly as loadmat gave it, with a new 'orientation' key describing its layout — previously it was the reoriented matrix, so callers couldn't recover the truth when the heuristic misfired.

Reproduced before the fix, truncating a real frame to 40 cells:

truth:            40 cells,  x ∈ [-334.039,  348.976]
parse_cells_mat: 154 "cells", x ∈ [-662.443, 2494.000]

2494.0 is the PhysiCell default cell volume — the transposed read was serving the volume column as an x-coordinate.

Issue #1 — labels (High). parse_cells_mat gains a labels= parameter and derives column positions from the frame's own <labels> block. Precedence is index_mapping > labels > row-count autodetect. A new 'columns' key exposes every labelled column. The reader passes labels through automatically, so each frame resolves against its own XML.

Issue #4 — dead cells (Medium). to_dataframe() now honors include_dead_cells. On a real frame it returned 985 rows while n_cells, positions, and to_spatial_data() all reported 922.

Issue #6 — microenvironment guard (Low). Validates shape[0] == 4 + len(substrate_names), transposing or raising otherwise.

Issue #5 — version (Low). __version__ now reads from distribution metadata. It reported 0.1.0 at the v0.2.0 tag. Note this was three sources of truth, not two — conf.py and pyproject.toml were both updated during v0.2.0 prep and __init__.py was missed, which is exactly the drift the reporters predicted.

Two corrections to the spec

  1. max(labels) + 1 undercounts. The spec proposes this for the expected variable count, but it ignores the width of the final label — a trailing size == 3 vector undercounts by two. declared_variable_count() uses max(index + size) instead.

  2. Orientation is not derived from index_mapping. The spec proposes max(index_mapping.values()) + 1 as an alternative source. Index mappings are sparse: CELL_DATA_INDICES_V2 tops out at 39, but real frames here have 154 rows, so this would reject every valid matrix. Orientation comes from labels only; explicit index_mapping still controls column positions.

Deviation on scope

to_dataframe() emitting every labelled column is behind extra_columns=True rather than on by default. A full frame here carries 154 variables, and to_trajectory_dataframe() concatenates across all timesteps — the docstring already warns about memory, and a 14× column increase by default seemed wrong for a patch release. The columns are always available via parse_cells_mat(...)['columns'].

I also took the straight fix on #4 rather than the opt-in alternative the spec offers. to_dataframe() disagreeing with positions on both row count and row order is a bug, not an interface. include_dead_cells= is still accepted per-call to recover the old output.

Backward compatibility

Additive except for two deliberate changes, both in the changelog: raw_data is no longer transposed, and to_dataframe() filters dead cells by default. With labels=None, parser output is unchanged from v0.2.0 — verified field-by-field against a real frame.

Testing

25 new tests in tests/test_physicell_labels.py covering every acceptance criterion in the report, including all four orientation cases, cross-model label isolation (87 vs 91 variables in one session), and the version assertion. Full suite: 481 passed, 2 skipped (the 2 skips are pre-existing, optional network deps).

Fixtures are generated in-test with savemat rather than committed as binaries.

Not included

Issue #3 — microenvironment wiring (substrates, voxel_positions, substrate_at, and the discover_physicell_timesteps signature change) — is an API surface addition, not a bug fix, and the dataclass return type is a breaking change worth doing deliberately. Tracked for v0.3.0 and noted under "Known gaps" in the changelog. The reporters offered to upstream their KDTree implementation; worth taking them up on it, and re-verifying the argmin equivalence claim here rather than on their sample of 300 query points — ties on a regular voxel grid are exactly where that sample can miss.

@emcramer
emcramer force-pushed the fix/physicell-reader-defects branch from 955acbf to 353d27f Compare July 21, 2026 23:30
Resolve cell matrix orientation from the frame's declared variable count
instead of comparing matrix dimensions. The old heuristic assumed every
frame holds more cells than the model has variables, silently returning
transposed data for sparse initial conditions, early time steps, and
simulations approaching extinction.

Derive column positions from the MultiCellDS <labels> block, which was
already parsed into metadata.extra['custom_labels'] but never read. This
makes custom and non-indexed columns reachable via a new 'columns' key,
and lets models with differing variable counts parse in one session.

Also fix to_dataframe() ignoring include_dead_cells, add an orientation
guard to parse_microenvironment_mat, return raw_data as loaded from disk,
and single-source __version__ from distribution metadata.

Reported by the llm-abm-consistency harness (OHSU ChangLab).
@emcramer
emcramer force-pushed the fix/physicell-reader-defects branch from 353d27f to 7cd038f Compare July 21, 2026 23:41
@emcramer

Copy link
Copy Markdown
Owner Author

Rebased onto main now that #3 has landed. Force-pushed 7cd038f.

Conflict resolution. Eight regions across parser.py and reader.py — the lint pass rewrote whitespace on the same lines this fix touches. Resolved by taking the functional version, re-applying the one semantic change #3 made to these files (the TYPE_CHECKING import for the SpatialTissueData forward reference), then letting ruff renormalise formatting. No functional change was dropped: the 25 regression tests here encode every acceptance criterion from the defect report and all still pass.

Also removed docs/tutorials/14_morans_autocorrelation.marimo.py from this branch. It was an unrelated untracked file swept in by a git add -A on my part and never belonged to this PR. The diff is now 5 files / 661 insertions — parser, reader, __init__, tests, changelog.

Verified on the rebased branch:

  • ruff check . clean
  • 481 passed, 2 skipped (456 baseline + 25 new; the 2 skips are the pre-existing optional-NetworkX cases)
  • Python floor is now 3.10 from chore: make the ruff lint gate pass #3, so .[all] installs and the matrix runs 3.10–3.13

Unchanged from the original review: issue #3 from the report (wiring parse_microenvironment_mat into the reader classes) is still deliberately out of scope, tracked under Known gaps in the changelog for v0.3.0.

@emcramer
emcramer merged commit 357d654 into main Jul 21, 2026
5 checks passed
@emcramer
emcramer deleted the fix/physicell-reader-defects branch July 21, 2026 23:47
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