Skip to content
Open
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
21 changes: 19 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]

Substrate / microenvironment access for PhysiCell simulations — the main
functional gap called out in the v0.2.0 defect report. Additive; nothing
existing changes behavior.
functional gap called out in the v0.2.0 defect report — plus a new
`microenvironment` analysis module. Additive; nothing existing changes
behavior.

### Added

- **`microenvironment` analysis module.** The previously-empty stub is now
implemented 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, returning a `NicheResult` with per-cell labels and per-niche
composition profiles. Complementary to the topic-model view in
`spatialtissuepy.lda`.
- `detect_boundaries(data, radius, labels=None)` — cells at the interface
between groups, scored by neighborhood "foreignness". Generalizes the
pairwise `spatial.interface_cells` to any grouping; pass niche labels to
find niche interfaces. Returns a `BoundaryResult`.
- `spatial_gradient` / `substrate_gradient` / `density_gradient` — spatial
gradients of a scalar field (substrate concentration or local cell density)
by local linear regression, returning a `GradientField` with `magnitude`
and `direction`. Validated against a finite-difference reference on the
regular voxel grid (correlation > 0.97 on interior points).
- **Environmental substrate sampling on `PhysiCellTimeStep`.** The
microenvironment `.mat` (oxygen and other diffusible fields) is now reachable
through the high-level API, having previously been parseable only by calling
Expand Down
42 changes: 32 additions & 10 deletions spatialtissuepy/microenvironment/__init__.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,37 @@
"""
Tumor microenvironment analysis module.
Tumor microenvironment analysis.

.. note::
Higher-level, domain-framed analyses of tissue microenvironment structure,
composed from the spatial, statistics, and PhysiCell-substrate machinery:

This module is a planned feature for the v0.3.0 release and is not yet
implemented. It will provide niche identification, boundary detection, and
spatial gradient analysis. In the meantime, much of this functionality can
be assembled from the :mod:`spatialtissuepy.spatial` and
:mod:`spatialtissuepy.statistics` modules (e.g. neighborhood composition,
Getis-Ord Gi* hotspots, and boundary cell detection).
- **Niche identification** (:func:`identify_niches`): recurring local cell-type
compositions, found by clustering neighborhood composition vectors.
- **Boundary detection** (:func:`detect_boundaries`): cells at the interface
between cell types or niches.
- **Gradient analysis** (:func:`spatial_gradient`, :func:`substrate_gradient`,
:func:`density_gradient`): spatial gradients of substrate concentration or
cell density.
"""

# Planned for v0.3.0 — see module docstring.
__all__ = []
from .boundaries import BoundaryResult, detect_boundaries
from .gradients import (
GradientField,
density_gradient,
spatial_gradient,
substrate_gradient,
)
from .niches import NicheResult, identify_niches

__all__ = [
# Niches
'identify_niches',
'NicheResult',
# Boundaries
'detect_boundaries',
'BoundaryResult',
# Gradients
'spatial_gradient',
'substrate_gradient',
'density_gradient',
'GradientField',
]
132 changes: 132 additions & 0 deletions spatialtissuepy/microenvironment/boundaries.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
"""
Boundary and interface detection.

A boundary cell sits at the interface between regions of different identity --
different cell types, or different niches. Each cell's *foreignness* is the
fraction of its spatial neighbors whose label differs from its own; cells above
a threshold are boundary cells. Passing niche labels (from
:func:`~spatialtissuepy.microenvironment.niches.identify_niches`) detects niche
interfaces; the default uses cell type, generalizing the pairwise
:func:`~spatialtissuepy.spatial.interface_cells` to any grouping.
"""

from __future__ import annotations

from dataclasses import dataclass
from typing import Optional

import numpy as np

from ..core import SpatialTissueData
from ..spatial import compute_neighborhoods


@dataclass
class BoundaryResult:
"""
Result of :func:`detect_boundaries`.

Attributes
----------
is_boundary : np.ndarray
``(n_cells,)`` boolean mask; ``True`` for boundary cells.
foreignness : np.ndarray
``(n_cells,)`` fraction of each cell's neighbors whose label differs
from its own. ``0`` for cells with too few neighbors.
labels : np.ndarray
``(n_cells,)`` grouping labels used for the comparison.
radius : float
Neighborhood radius used.
"""

is_boundary: np.ndarray
foreignness: np.ndarray
labels: np.ndarray
radius: float

@property
def boundary_indices(self) -> np.ndarray:
"""Indices of the boundary cells."""
return np.where(self.is_boundary)[0]

def boundary_fraction(self) -> float:
"""Fraction of all cells that are boundary cells."""
if self.is_boundary.size == 0:
return 0.0
return float(np.mean(self.is_boundary))


def detect_boundaries(
data: SpatialTissueData,
radius: float,
labels: Optional[np.ndarray] = None,
threshold: float = 0.0,
min_neighbors: int = 1,
) -> BoundaryResult:
"""
Detect cells at the interface between groups.

For each cell, ``foreignness`` is the fraction of neighbors within
``radius`` whose group label differs from the cell's own. A cell is a
boundary cell when its foreignness exceeds ``threshold`` and it has at least
``min_neighbors`` neighbors.

Parameters
----------
data : SpatialTissueData
Spatial tissue data.
radius : float
Neighborhood radius.
labels : np.ndarray, optional
``(n_cells,)`` group label per cell. Defaults to the cell types. Pass
niche labels to detect niche interfaces.
threshold : float, default 0.0
Minimum foreignness (exclusive) to call a cell a boundary. The default
of ``0.0`` flags any cell with at least one differing neighbor; raise it
to require a more mixed neighborhood.
min_neighbors : int, default 1
Minimum neighbor count; cells with fewer are never boundary cells and
get foreignness ``0``.

Returns
-------
BoundaryResult

Raises
------
ValueError
If ``labels`` is given but its length does not match ``n_cells``.
"""
if labels is None:
labels = np.asarray(data.cell_types)
else:
labels = np.asarray(labels)
if labels.shape[0] != data.n_cells:
raise ValueError(
f"labels length {labels.shape[0]} != n_cells {data.n_cells}"
)

neighborhoods = compute_neighborhoods(
data, method='radius', radius=radius, include_self=False
)

n = data.n_cells
foreignness = np.zeros(n)
is_boundary = np.zeros(n, dtype=bool)

for i, neighbors in enumerate(neighborhoods):
neighbors = np.asarray(neighbors)
if neighbors.size < min_neighbors:
continue
differing = np.count_nonzero(labels[neighbors] != labels[i])
frac = differing / neighbors.size
foreignness[i] = frac
if frac > threshold:
is_boundary[i] = True

return BoundaryResult(
is_boundary=is_boundary,
foreignness=foreignness,
labels=labels,
radius=radius,
)
Loading
Loading