Skip to content

biwt.core

Pure-Python logic with no Qt dependency: loading files, inferring the domain, placing cells, and reconciling cell-type edits.

Internal

These are not part of the public API, and signatures may change between releases. If you are embedding BIWT, work through biwt.types and create_biwt_widget instead.

biwt.core.data_loader

Reads .h5ad, .rds/.rda/.rdata, and .csv into a single BiwtData shape, so nothing downstream needs to know the source format.

Unified single-cell data loader.

Supported formats

.h5ad AnnData (requires biwt[anndata]) .rds Seurat / SingleCellExperiment / SpatialExperiment via rpy2 + anndata2ri (requires biwt[seurat]) .rda / .rdata R workspace files (same rpy2 requirement, loaded with base::load(); the object of a supported class is used, and an ambiguous workspace is refused rather than guessed at) .csv Flat tabular; spatial coordinates inferred from column names

All paths return a BiwtData object with a common interface so downstream core logic never needs to know the source format.

BiwtData dataclass

BiwtData(
    obs: DataFrame,
    obsm: dict = dict(),
    spatial_location: Optional[str] = None,
    file_path: str = "",
    probability_columns: list = list(),
    host_units_per_data_unit: Optional[float] = None,
)

Unified in-memory representation of imported single-cell data.

Attributes:

Name Type Description
obs DataFrame

Per-cell metadata DataFrame (cluster labels, cell-type columns, etc.). Analogous to AnnData.obs.

obsm dict

Named coordinate arrays (e.g. {"spatial": ndarray, "X_umap": ndarray}). Analogous to AnnData.obsm.

spatial_location Optional[str]

Human-readable description of where spatial coordinates were found, e.g. "obsm['spatial']" or "obs columns 'x', 'y'" or None.

file_path str

Path the data was loaded from.

probability_columns list

Obs columns that look like per-cell-type deconvolution probabilities.

host_units_per_data_unit Optional[float]

Conversion factor (host units per one raw data-coordinate unit) that the file itself provided, or None if none. Currently only 10x Visium .h5ad (via scalefactors) supplies this, and what it supplies is µm/pixel — so the value only means "host units" for a host measuring in microns. It seeds the editable scale factor in the domain editor; it is never applied silently.

LoadError

LoadError(message: str, *, docs_url: Optional[str] = None)

Bases: Exception

Raised when a file cannot be loaded.

docs_url is set when the fix is an installation or environment change (a missing optional dependency, a broken R stack) and is None when the failure is about the file itself (unsupported extension, malformed CSV).

So a host displaying this error should render the link only when docs_url is not None, rather than always linking to the setup docs.

Source code in src/biwt/core/data_loader.py
112
113
114
def __init__(self, message: str, *, docs_url: Optional[str] = None):
    super().__init__(message)
    self.docs_url = docs_url

load

load(file_path: str) -> BiwtData

Load single-cell data from file_path and return a BiwtData.

Dispatches on file extension: .h5ad → AnnData .rds → R object saved with saveRDS() .rda / .rdata → R workspace saved with save() .csv → flat CSV

Raises:

Type Description
LoadError

On unsupported format or read failure, with an actionable message.

Source code in src/biwt/core/data_loader.py
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
def load(file_path: str) -> BiwtData:
    """Load single-cell data from *file_path* and return a ``BiwtData``.

    Dispatches on file extension:
        ``.h5ad``              → AnnData
        ``.rds``               → R object saved with ``saveRDS()``
        ``.rda`` / ``.rdata``  → R workspace saved with ``save()``
        ``.csv``               → flat CSV

    Raises
    ------
    LoadError
        On unsupported format or read failure, with an actionable message.
    """
    path = Path(file_path)
    suffix = path.suffix.lower()
    if suffix == ".h5ad":
        return _load_h5ad(file_path)
    if suffix in _R_EXTENSIONS:
        return _load_r_file(file_path, suffix)
    if suffix == ".csv":
        return _load_csv(file_path)
    raise LoadError(
        f"Unsupported file extension '{suffix}'. "
        "BIWT supports: .h5ad, .rds, .rda, .rdata, .csv"
    )

biwt.core.domain

Domain inference and the two-tier mismatch classification that decides whether the domain editor opens on its own.

Domain inference logic.

Priority order for resolving the final DomainSpec: 1. preferred — host-supplied DomainSpec (always wins if provided) 2. the data — min/max of the raw coordinate arrays (obsm or obs columns), used exactly as found. The units are reported generically as "data unit" — BIWT infers no unit name from the data (a pixels→host-units scale factor is applied later, visibly, in the domain editor). Singular, like every other unit name: DomainSpec.units holds "micron", not "microns". 3. default — ±500 µm × ±10 µm fallback

Public entry point: infer_domain(preferred, obs, obsm)

infer_domain

infer_domain(
    preferred: Optional[DomainSpec] = None,
    obs=None,
    obsm: Optional[dict] = None,
    spatial_key: Optional[str] = None,
) -> DomainSpec

Return the best available DomainSpec given what the data provides.

Coordinates are used exactly as found; the reported units are the generic "data unit" (BIWT infers no unit name — imagerow/imagecol are still recognized and y-flipped, but not labeled "pixel").

Parameters:

Name Type Description Default
preferred Optional[DomainSpec]

DomainSpec from the host. Returned immediately if not None.

None
obs

AnnData / DataFrame of per-cell metadata. Checked for x/y(/z) columns when obsm yields nothing useful.

None
obsm Optional[dict]

Dict of named coordinate arrays (e.g. {"spatial": ndarray, ...}).

None
spatial_key Optional[str]

Explicit key to use in obsm. If None, heuristic search is used.

None
Source code in src/biwt/core/domain.py
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
def infer_domain(
    preferred: Optional[DomainSpec] = None,
    obs=None,                               # pd.DataFrame | None
    obsm: Optional[dict] = None,
    spatial_key: Optional[str] = None,
) -> DomainSpec:
    """Return the best available DomainSpec given what the data provides.

    Coordinates are used exactly as found; the reported units are the generic
    ``"data unit"`` (BIWT infers no unit name — imagerow/imagecol are still
    recognized and y-flipped, but not labeled "pixel").

    Parameters
    ----------
    preferred:
        DomainSpec from the host.  Returned immediately if not ``None``.
    obs:
        AnnData / DataFrame of per-cell metadata.  Checked for x/y(/z) columns
        when obsm yields nothing useful.
    obsm:
        Dict of named coordinate arrays (e.g. ``{"spatial": ndarray, ...}``).
    spatial_key:
        Explicit key to use in ``obsm``.  If ``None``, heuristic search is used.
    """
    if preferred is not None:
        return preferred

    # Resolve obs columns once (needed for the obs-column branch and the y-flip
    # of image columns).  The domain units are the generic "data unit" — no
    # unit name is inferred from the data.
    try:
        obs_cols = list(obs.columns) if obs is not None else []
    except AttributeError:
        obs_cols = []
    x_col, y_col, z_col, is_image_coords = resolve_obs_coord_cols(obs_cols)

    # --- try obsm ---------------------------------------------------------
    if obsm is not None:
        key = spatial_key or _find_spatial_key(obsm)
        if key and key in obsm:
            coords = np.asarray(obsm[key], dtype=float)
            if coords.ndim == 2 and coords.shape[1] >= 2:
                return _domain_from_coords(coords, source=DomainSource.DATA)

    # --- try obs columns --------------------------------------------------
    if x_col and y_col:
        xy = build_obs_coords(obs, x_col, y_col, z_col, is_image_coords)
        return _domain_from_coords(xy, source=DomainSource.DATA)

    return DomainSpec.default()

classify_domain_mismatch

classify_domain_mismatch(
    data: DomainSpec, preferred: DomainSpec
) -> Optional[str]

Classify the relationship between data coordinates and the preferred domain.

Returns:

Type Description
'outside'

One or more data boundaries exceed the preferred domain — those cells would be excluded from the simulation.

'small'

Data fits inside the preferred domain but covers < 50% of at least one axis, or < 50% of the 2-D area — cells would be very sparse.

None

No significant mismatch.

Source code in src/biwt/core/domain.py
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
def classify_domain_mismatch(
    data: DomainSpec,
    preferred: DomainSpec,
) -> Optional[str]:
    """Classify the relationship between data coordinates and the preferred domain.

    Returns
    -------
    "outside"
        One or more data boundaries exceed the preferred domain — those cells
        would be excluded from the simulation.
    "small"
        Data fits inside the preferred domain but covers < 50% of at least one
        axis, or < 50% of the 2-D area — cells would be very sparse.
    None
        No significant mismatch.
    """
    # Use a small tolerance (1e-6 µm ≈ sub-nanometer) to absorb floating-point
    # noise from min/max computations on coordinate arrays.
    tol = 1e-6
    fits_inside = (
        data.xmin >= preferred.xmin - tol and data.xmax <= preferred.xmax + tol
        and data.ymin >= preferred.ymin - tol and data.ymax <= preferred.ymax + tol
    )
    if not fits_inside:
        return "outside"
    if preferred.width == 0 or preferred.height == 0:
        return None
    if (
        data.width  < 0.5 * preferred.width
        or data.height < 0.5 * preferred.height
        or data.width * data.height < 0.5 * preferred.width * preferred.height
    ):
        return "small"
    return None

resolve_obs_coord_cols

resolve_obs_coord_cols(
    columns: list[str],
) -> tuple[
    Optional[str], Optional[str], Optional[str], bool
]

Resolve which obs columns hold spatial coordinates.

Returns (x_col, y_col, z_col, is_image_coords). Standard x/y/z columns take priority; only when they are absent do we fall back to pixel-space imagecol/imagerow columns (is_image_coords=True, no z axis). Returns (None, None, None, False) when nothing usable is found.

Source code in src/biwt/core/domain.py
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
def resolve_obs_coord_cols(
    columns: list[str],
) -> tuple[Optional[str], Optional[str], Optional[str], bool]:
    """Resolve which obs columns hold spatial coordinates.

    Returns ``(x_col, y_col, z_col, is_image_coords)``.  Standard ``x``/``y``/``z``
    columns take priority; only when they are absent do we fall back to
    pixel-space ``imagecol``/``imagerow`` columns (``is_image_coords=True``, no z axis).
    Returns ``(None, None, None, False)`` when nothing usable is found.
    """
    x_col = _find_coord_col(columns, "x")
    y_col = _find_coord_col(columns, "y")
    if x_col and y_col:
        return x_col, y_col, _find_coord_col(columns, "z"), False
    px = _find_pixel_coord_col(columns, "x")   # imagecol → x
    py = _find_pixel_coord_col(columns, "y")   # imagerow → y
    if px and py:
        return px, py, None, True
    return None, None, None, False

build_obs_coords

build_obs_coords(
    obs,
    x_col: str,
    y_col: str,
    z_col: Optional[str],
    is_image_coords: bool,
) -> np.ndarray

Build an (N, 2) or (N, 3) float coordinate array from obs columns.

When is_image_coords is True the y column is an image row index (increasing downward), so it is reflected about its maximum to give a y-up coordinate (top of image → largest y). The reflection preserves the coordinate range.

Source code in src/biwt/core/domain.py
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
def build_obs_coords(obs, x_col: str, y_col: str,
                     z_col: Optional[str], is_image_coords: bool) -> np.ndarray:
    """Build an ``(N, 2)`` or ``(N, 3)`` float coordinate array from obs columns.

    When *is_image_coords* is True the y column is an image row index (increasing
    downward), so it is reflected about its maximum to give a y-up coordinate
    (top of image → largest y).  The reflection preserves the coordinate range.
    """
    x = np.asarray(obs[x_col].values, dtype=float)
    y = np.asarray(obs[y_col].values, dtype=float)
    if is_image_coords:
        y = y.max() - y
    stacked = [x, y]
    if z_col:
        stacked.append(np.asarray(obs[z_col].values, dtype=float))
    return np.column_stack(stacked)

biwt.core.positioning

Coordinate scaling and assembly of the final cells DataFrame.

Spatial coordinate transformation and IC DataFrame construction.

compute_spatial_placement sizes and centers the data's extent inside the host's domain; the positions window applies the result to the coordinates it plots. build_ic_dataframe then collapses the per-cell-type coordinate dicts into the flat BiwtResult.coordinates DataFrame the host receives.

compute_spatial_placement

compute_spatial_placement(
    data_extent, domain_center, scale, is_2d
)

Placement rectangle for the spatial plotter.

The data is scaled uniformly by scale (host-units per data unit) and centered at domain_center — a pure scale + translate, so aspect ratio is always preserved. scale of 1.0 places the data at its own extent, centered (no conversion).

Parameters:

Name Type Description Default
data_extent

Sequence (dx, dy) (2-D) or (dx, dy, dz) (3-D) — the data's bounding-box widths.

required
domain_center

Sequence (cx, cy) or (cx, cy, cz) — the domain center to place the scaled data around.

required
scale

Uniform multiplier applied to every axis extent.

required
is_2d

When True use only x/y.

required

Returns:

Type Description
``[x0, y0, w, h]`` (2-D) or ``[x0, y0, z0, w, h, d]`` (3-D): the placement
rectangle's origin (min corner) followed by its widths.
Source code in src/biwt/core/positioning.py
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
def compute_spatial_placement(data_extent, domain_center, scale, is_2d):
    """Placement rectangle for the spatial plotter.

    The data is scaled uniformly by *scale* (host-units per data unit) and
    centered at *domain_center* — a pure scale + translate, so aspect ratio is
    always preserved.  *scale* of ``1.0`` places the data at its own extent,
    centered (no conversion).

    Parameters
    ----------
    data_extent:
        Sequence ``(dx, dy)`` (2-D) or ``(dx, dy, dz)`` (3-D) — the data's
        bounding-box widths.
    domain_center:
        Sequence ``(cx, cy)`` or ``(cx, cy, cz)`` — the domain center to place
        the scaled data around.
    scale:
        Uniform multiplier applied to every axis extent.
    is_2d:
        When True use only x/y.

    Returns
    -------
    ``[x0, y0, w, h]`` (2-D) or ``[x0, y0, z0, w, h, d]`` (3-D): the placement
    rectangle's origin (min corner) followed by its widths.
    """
    n = 2 if is_2d else 3
    wh = [float(data_extent[i]) * scale for i in range(n)]
    origin = [float(domain_center[i]) - wh[i] / 2.0 for i in range(n)]
    return origin + wh

build_ic_dataframe

build_ic_dataframe(
    coords_by_type: dict[str, ndarray],
) -> pd.DataFrame

Convert per-cell-type coordinate arrays into the standard IC DataFrame.

Parameters:

Name Type Description Default
coords_by_type dict[str, ndarray]

{cell_type_name: (N, 3) ndarray} — one entry per kept cell type.

required

Returns:

Type Description
DataFrame with columns ``["x", "y", "z", "type"]``.
A cell type whose array is empty contributes no rows — that is how a
zero-count type reaches the output: defined, but placing nothing.
Empty DataFrame (same columns, same dtypes) if no rows result at all.
Source code in src/biwt/core/positioning.py
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
def build_ic_dataframe(
    coords_by_type: dict[str, np.ndarray],
) -> pd.DataFrame:
    """Convert per-cell-type coordinate arrays into the standard IC DataFrame.

    Parameters
    ----------
    coords_by_type:
        ``{cell_type_name: (N, 3) ndarray}`` — one entry per kept cell type.

    Returns
    -------
    DataFrame with columns ``["x", "y", "z", "type"]``.
    A cell type whose array is empty contributes no rows — that is how a
    zero-count type reaches the output: defined, but placing nothing.
    Empty DataFrame (same columns, same dtypes) if no rows result at all.
    """
    rows = []
    for cell_type, coords in coords_by_type.items():
        coords = np.asarray(coords)
        for row in coords:
            rows.append({
                "x": float(row[0]),
                "y": float(row[1]),
                "z": float(row[2]) if len(row) > 2 else 0.0,
                "type": cell_type,
            })

    if not rows:
        # pd.DataFrame([]) types every column as object, which would then
        # propagate into a host's concat/append of real coordinates.
        return _empty_ic_dataframe()

    return pd.DataFrame(rows, columns=["x", "y", "z", "type"])

biwt.core.cell_types

Keep / merge / delete bookkeeping, and the name matching behind the rename suggestions and the template pre-selection. A host owns the "same cell type?" decision via create_biwt_widget(name_matches=…); default_name_matches is what BIWT falls back to.

Deciding whether two strings name the same cell type — purely data, no Qt.

The decision is the host's: it can supply a predicate via create_biwt_widget(name_matches=…). default_name_matches is the fallback BIWT ships, and best_match is the one selection routine used by both rename hints (suggest_name_mappings) and the cell-templates step.

default_name_matches

default_name_matches(
    a: str,
    b: str,
    cutoff: float = DEFAULT_NAME_MATCH_CUTOFF,
) -> bool

Whether a and b plausibly name the same cell type.

Digit runs must be equal, then SequenceMatcher ratio on the casefolded strings must reach cutoff. Digits distinguish types rather than spell them (M1/M2 Macrophage scores 0.92), so they gate similarity instead of feeding it. A non-numeric qualifier is not caught (PD-1hi …/PD-1lo …); a host curating such names supplies its own predicate.

Hosts replace this wholesale — and the cutoff with it — via create_biwt_widget(name_matches=…). Rationale and the rejection table: docs/integration/templates-and-matching.md.

Source code in src/biwt/core/cell_types.py
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
def default_name_matches(a: str, b: str, cutoff: float = DEFAULT_NAME_MATCH_CUTOFF) -> bool:
    """Whether *a* and *b* plausibly name the same cell type.

    Digit runs must be equal, then ``SequenceMatcher`` ratio on the casefolded
    strings must reach *cutoff*.  Digits distinguish types rather than spell them
    (``M1``/``M2 Macrophage`` scores 0.92), so they gate similarity instead of
    feeding it.  A *non-numeric* qualifier is not caught (``PD-1hi …``/``PD-1lo
    …``); a host curating such names supplies its own predicate.

    Hosts replace this wholesale — and the cutoff with it — via
    ``create_biwt_widget(name_matches=…)``.  Rationale and the rejection table:
    docs/integration/templates-and-matching.md.
    """
    a_folded, b_folded = a.casefold(), b.casefold()
    if _DIGIT_RUN.findall(a_folded) != _DIGIT_RUN.findall(b_folded):
        return False
    return SequenceMatcher(None, a_folded, b_folded).ratio() >= cutoff

best_match

best_match(
    name: str,
    candidates,
    matches: Optional[Callable[[str, str], bool]] = None,
    sort_key=alpha_key,
) -> Optional[str]

Return the candidate that names the same cell type as name, or None.

A case-insensitive exact match always wins — every candidate is tried at that tier before any is accepted on similarity alone. Within a tier the first in sort_key order wins: a predicate offers no way to rank, and sorting keeps the result independent of how the candidates were collected.

Pass sort_key to express a preference among candidates, e.g. one source before another. matches defaults to :func:default_name_matches.

Source code in src/biwt/core/cell_types.py
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
def best_match(
    name: str,
    candidates,
    matches: Optional[Callable[[str, str], bool]] = None,
    sort_key=alpha_key,
) -> Optional[str]:
    """Return the candidate that names the same cell type as *name*, or None.

    A case-insensitive exact match always wins — every candidate is tried at that
    tier before any is accepted on similarity alone.  Within a tier the first in
    *sort_key* order wins: a predicate offers no way to rank, and sorting keeps
    the result independent of how the candidates were collected.

    Pass *sort_key* to express a preference among candidates, e.g. one source
    before another.  *matches* defaults to :func:`default_name_matches`.
    """
    matches = matches or default_name_matches
    ordered = sorted(candidates, key=sort_key)

    folded = name.casefold()
    for candidate in ordered:
        if candidate.casefold() == folded:
            return candidate
    for candidate in ordered:
        if matches(name, candidate):
            return candidate
    return None

suggest_name_mappings

suggest_name_mappings(
    data_labels: list[str],
    host_names: list[str],
    matches: Optional[Callable[[str, str], bool]] = None,
) -> dict[str, Optional[str]]

Suggest a host cell-type name for each data label.

Delegates to :func:best_match, so the notion of "same cell type" is the one the host chose — see create_biwt_widget(name_matches=…).

Returns a dict {data_label: host_name | None}. None means no suggestion was found.

These are only hints for pre-populating the GUI; the user can overwrite any of them. A future version will query a cell-type ontology / registry.

Source code in src/biwt/core/cell_types.py
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
def suggest_name_mappings(
    data_labels: list[str],
    host_names: list[str],
    matches: Optional[Callable[[str, str], bool]] = None,
) -> dict[str, Optional[str]]:
    """Suggest a host cell-type name for each data label.

    Delegates to :func:`best_match`, so the notion of "same cell type" is the
    one the host chose — see ``create_biwt_widget(name_matches=…)``.

    Returns a dict ``{data_label: host_name | None}``.
    ``None`` means no suggestion was found.

    These are only hints for pre-populating the GUI; the user can overwrite any
    of them.  A future version will query a cell-type ontology / registry.
    """
    return {
        label: best_match(label, host_names, matches=matches)
        for label in data_labels
    }

biwt.core.templates

Reading cell template files, and choosing a starting template per cell type. BIWT ships no templates of its own and never parses their content.

Cell template files.

A template file is a TOML mapping of template_name = "content". The content is opaque to BIWT: it is read as text, never parsed, validated or wrapped, and reaches the host verbatim in BiwtResult.cell_templates. For a PhysiCell host the content happens to be an XML <phenotype> block, but nothing here knows or cares about that.

BIWT ships no templates of its own. Files come from the landing screen's library — seeded by the host at widget construction, edited by the user — or from the user at the cell-templates step.

load_templates_from_file

load_templates_from_file(path: str) -> dict[str, str]

Load a TOML template file and return {name: content}.

Raises ValueError if any value is not a string — a stray table or number would otherwise travel all the way to the host and fail there, long after the file that caused it is out of sight.

Source code in src/biwt/core/templates.py
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
def load_templates_from_file(path: str) -> dict[str, str]:
    """Load a TOML template file and return ``{name: content}``.

    Raises ``ValueError`` if any value is not a string — a stray table or number
    would otherwise travel all the way to the host and fail there, long after
    the file that caused it is out of sight.
    """
    try:
        import tomllib
    except ImportError:
        try:
            import tomli as tomllib  # type: ignore[no-redef]  # 3.9/3.10 fallback
        except ImportError as exc:
            raise ImportError(
                "Python < 3.11 requires 'tomli' to load template files. "
                "Install it with: pip install tomli"
            ) from exc
    with open(path, "rb") as f:
        data = tomllib.load(f)

    bad = {k: type(v).__name__ for k, v in data.items() if not isinstance(v, str)}
    if bad:
        listed = ", ".join(f"{k} ({t})" for k, t in sorted(bad.items()))
        raise ValueError(
            "Every value in a template file must be a string; these are not: "
            f"{listed}. A '[section]' header makes the keys that follow it "
            "nested tables, which is the usual cause."
        )
    return data

matched_candidates

matched_candidates(
    cell_types: list[str],
    template_names: list[str],
    matches: Optional[Callable[[str, str], bool]] = None,
    host_names: Optional[list[str]] = None,
) -> dict[str, Optional[str]]

Per cell type, the candidate that names it, or None.

host_names are the cell types the host already defines (BiwtInput.host_cell_type_names). They rank ahead of the templates, so the host wins where both name a type equally well — but only within a match tier, so an exact template match still beats a merely similar host name.

None means nothing named this cell type. What to do then — fall back to a default, leave the type unassigned — is the caller's: it depends on which source supplies the baseline, and only the caller knows sources.

Source code in src/biwt/core/templates.py
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
def matched_candidates(
    cell_types: list[str],
    template_names: list[str],
    matches: Optional[Callable[[str, str], bool]] = None,
    host_names: Optional[list[str]] = None,
) -> dict[str, Optional[str]]:
    """Per cell type, the candidate that names it, or ``None``.

    *host_names* are the cell types the host already defines
    (``BiwtInput.host_cell_type_names``).  They rank ahead of the templates, so
    the host wins where both name a type equally well — but only within a match
    tier, so an exact template match still beats a merely similar host name.

    ``None`` means nothing named this cell type.  What to do then — fall back to a
    ``default``, leave the type unassigned — is the caller's: it depends on which
    source supplies the baseline, and only the caller knows sources.
    """
    hosts = set(host_names or [])
    pool = list(hosts) + list(template_names)

    def rank(name: str):
        return (name not in hosts, alpha_key(name))

    return {ct: best_match(ct, pool, matches=matches, sort_key=rank)
            for ct in cell_types}