Spatial units¶
A spatial unit is the polygon everything is measured on and a class is assigned to. Every stage
after this one joins on its identifier, unit_id.
Three strategies, all satisfying SpatialUnitStrategy.
unit_strategy is config; the default is grid and there is deliberately no auto-selection.
Units must form a partition of the bounding box — they must tile it exactly, with no gaps and no overlaps. Assert it explicitly — a validity test is not a partition test, and enclosure faces formed outside the extent were measured at 222% of the bbox on Berlin and 379% on Rotterdam, silently corrupting the denominator of every area-weighted statistic downstream.
The three differ in scale, which is the thing to know before choosing one:
| median unit | |
|---|---|
EnclosureUnits, Hong Kong fixture |
0.04 ha — a block |
GridUnits, 100 m |
1.00 ha |
PatchUnits |
11.69 ha |
| a WUDAPT polygon, across the study cities | 2.2–52 ha |
| a So2Sat patch | 10.24 ha |
An enclosure is a city block, bounded by streets; an LCZ patch — the area a class is meant
to describe — is a neighbourhood hundreds of metres across. A thinner barrier set does not close
that gap — it stops subdividing big faces rather than enlarging small ones — so PatchUnits sets
the scale with a merge step instead.
lczkit.units
¶
Spatial-unit generation: EnclosureUnits, GridUnits, and aggregate() between them.
Also home to check_units(), the entry contract every stage keyed on unit_id enforces. It lives
with the definition of the unit of exchange rather than with any one consumer.
check_units
¶
Raise unless units is projected, indexed by unit_id, and uniquely indexed.
Same checks aggregate() and height_metrics() make inline, for the same reason: a geographic
CRS makes every area meaningless, and a missing or duplicated unit_id index makes the join
silently wrong rather than loud.
Source code in src/lczkit/units/__init__.py
Grid¶
lczkit.units.grid
¶
GridUnits: a 100 m regular grid in the local UTM CRS.
The default, because it is the format every existing LCZ map, validation dataset and WRF workflow
uses. lczkit.validation joins against the Demuzere global LCZ map on this same 100 m grid.
GridUnits
¶
A regular cell_size_m grid, aligned to the local UTM CRS's own coordinate origin.
Aligned to that origin — not to bbox. Two overlapping bboxes therefore assign the same
unit_id to the same real-world cell, which is what makes grid unit_ids meaningfully
"stable" across runs and across cities sharing a UTM zone, unlike EnclosureUnits'
barrier-dependent ids.
Cells are kept whole (never clipped to bbox) and are included if they intersect bbox at
all, so the returned grid can extend slightly beyond bbox's edges. barriers is accepted
for SpatialUnitStrategy conformance but ignored, per the protocol's own docstring.
Set the cell side in metres, defaulting to the 100 m every LCZ workflow uses.
100 m is what every existing LCZ map, validation dataset and WRF workflow is on, so a run at another size is comparable to nothing. Rejects a non-positive size rather than producing an empty or inverted grid downstream.
Source code in src/lczkit/units/grid.py
generate
¶
generate(bbox: BBox, barriers: GeoDataFrame | None = None) -> GeoDataFrame
Return the grid cells intersecting bbox, indexed by unit_id.
bbox is lon/lat and the returned frame is in the local UTM CRS, carrying geometry
alone — every later stage joins onto it by unit_id. Cells are kept whole and are
included if they intersect bbox at all, so the grid can extend slightly past its edges.
barriers is accepted for SpatialUnitStrategy conformance and ignored: a regular grid
is defined by the CRS origin and the cell size, and nothing about the city moves it.
Source code in src/lczkit/units/grid.py
Enclosures¶
lczkit.units.enclosures
¶
EnclosureUnits: momepy.enclosures()-based spatial units, the GeoClimate RSU analogue.
The barrier set is streets, rail, waterbodies and large vegetation patches, and that list is exhaustive rather than illustrative.
Large vegetation patches have no vector source, so assemble_barriers accepts vegetation as an
optional layer and omits it until a raster-derived vegetation-patch layer exists to pass in.
The one plausible-looking wrong answer: VectorSource.land_use() returns polygons that include
parks and green space, and it is tempting to reach for them as the missing vegetation barrier. Do
not. Land use carries functional semantics only — it feeds industrial_fraction — and it is
neither a barrier here nor a land-cover source. Rasters own land cover, and the vegetation barrier
must come from them.
EnclosureUnits
¶
momepy.enclosures()-based spatial units.
All barrier linework is unioned into one series before polygonization (see
momepy.elements.enclosures's source), so momepy's own primary_barriers /
additional_barriers split — meaningful elsewhere for RSU semantics — makes no difference
to the resulting enclosure geometries here; barriers is passed through as
primary_barriers with no additional_barriers.
unit_ids are f"enclosure_{eid}", eid being the sequential integer momepy.enclosures
assigns in polygonization order. This is deterministic for a fixed barriers input (same
rows, same order, same library versions) but not stable against changes to the set of
barriers — unlike GridUnits, whose ids are tied to absolute coordinates, an enclosure's id
can shift if upstream barrier data changes. Documented, not fixed: no id scheme survives
which regions of the barrier network happen to change.
Enclosures are restricted to bbox. Barrier linework runs past the study area — a rail
line entering the extent continues well beyond it — and polygonizing it produces faces lying
wholly outside. Left in, those are returned as units: on the Berlin fixture the result covered
222% of the requested extent and on Rotterdam 379%, so the units were not a partition and any
area-weighted statistic over them was measured against the wrong denominator. clip=True
keeps only the faces whose representative point falls inside limit, which is exact rather
than approximate: limit's boundary is part of the noded linework, so every face is already
split at the extent's edge and none straddles it. No geometry is modified.
generate
¶
generate(bbox: BBox, barriers: GeoDataFrame | None = None) -> GeoDataFrame
Polygonize barriers into enclosures clipped to bbox, indexed by unit_id.
bbox is lon/lat; barriers must already be in the projected CRS the units come back
in, and the returned frame carries geometry alone — every later stage joins onto it by
unit_id.
Unlike GridUnits, barriers is required rather than optional: this strategy has no
barrier source of its own, and silently returning one face for the whole bbox would be a
partition with no information in it. Build the argument with assemble_barriers.
Source code in src/lczkit/units/enclosures.py
assemble_barriers
¶
assemble_barriers(streets: GeoDataFrame, waterbodies: GeoDataFrame, *, rail: GeoDataFrame | None = None, vegetation: GeoDataFrame | None = None) -> GeoDataFrame
Combine barrier layers into the single barriers frame EnclosureUnits.generate expects.
That is: one geometry column, no attributes, all layers in the same projected CRS.
rail and vegetation are optional — pass None (the default) when a layer isn't
available; enclosures form from whatever barriers are given. All non-empty inputs must
already share one CRS (typically the one clean_vectors() reprojected into); this function
does not itself reproject anything.
These four layers are the whole eligible barrier set. In particular, do not pass
CleanedVectors.land_use as vegetation — see this module's docstring.
Source code in src/lczkit/units/enclosures.py
Patches¶
lczkit.units.patches
¶
PatchUnits: street-bounded blocks merged into units at the scale an LCZ patch is drawn at.
The measurement this exists to answer. Enclosures are a block, not a patch. On the Hong Kong
fixture momepy.enclosures over the cleaned barrier set returns a median unit of 0.04 ha with
72.9% under 0.1 ha, against WUDAPT polygons that run 2.2-52 ha across the sixteen study cities and
a So2Sat patch of 10.24 ha. Berlin shows the same from the other direction: 78% of its enclosures
are sub-1000 m2 slivers.
Re-cutting the barrier set does not fix that, which was worth measuring before assuming it:
| barriers (HK fixture) | seeds | median | p90 | max | % < 0.1 ha |
|---|---|---|---|---|---|
| all streets | 4095 | 0.04 ha | 0.36 ha | 673 ha | 72.9% |
| drop footway/steps/path | 769 | 0.11 ha | 2.52 ha | 691 ha | 48.6% |
| major + tertiary | 509 | 0.07 ha | 3.85 ha | 692 ha | 56.2% |
| major only | 397 | 0.07 ha | 2.96 ha | 692 ha | 57.9% |
Every barrier set is bimodal — slivers plus a handful of very large faces — because a thinner barrier network does not enlarge the small faces, it only stops subdividing the big ones. The scale is set by a merge step, not by which streets are barriers.
So: seeds, then merge. Stage 1 is EnclosureUnits unchanged, over a barrier set with the
pedestrian classes removed — a footpath is not a boundary between two LCZ patches, and it is 50-73%
of the network in Berlin, Hong Kong and Milan. Stage 2 merges each seed into the contiguous
neighbour it most resembles until the units reach min_area_m2.
This does not replace GridUnits and is not the default. The strategy is config, the default
is grid, and there is no auto-selection. Grid cells are what every published LCZ map, validation
dataset and WRF workflow uses, and every published figure here is measured on them.
A caveat that must not be lost. The merge reads building surface fraction and mean height, and
those are two of the seven dimensions the classifier then scores. Units defined partly by the
quantity being measured is the standard shape of a regionalisation (SKATER, AZP and the rest work
this way), and it is still a form of circularity: a patch is more homogeneous in BSF than a cell
partly because it was built to be. It cannot inflate agreement with an external reference, which
is what the validation measures, but it does mean bsf_by_reference_class on patch units is a
weaker test than the same table on a grid, so read that table on the grid.
DEFAULT_MIN_AREA_M2
module-attribute
¶
5 ha - the median WUDAPT polygon across the sixteen study cities, which is the grain the reference is actually drawn at. A 100 m cell is 1 ha and a So2Sat patch 10.24 ha, so this sits between the two objects that are genuinely patch-scale.
A floor, not a centre, which is why it is named for what it does. Merging stops when a unit reaches the minimum, and the merge that gets it there overshoots, so the resulting median lands around twice this value: on the Hong Kong fixture 5 ha gives p10 5.75, median 10.5, p90 24.5 ha. Set it to roughly half the grain wanted.
DEFAULT_MAX_AREA_M2
module-attribute
¶
50 ha. A ceiling, not a target: it stops a merge chain swallowing a whole industrial estate or a park into one unit, which is the failure mode of merging on size alone.
PEDESTRIAN_CLASSES
module-attribute
¶
PEDESTRIAN_CLASSES: frozenset[str] = frozenset({'footway', 'steps', 'path', 'cycleway', 'bridleway'})
Overture road classes excluded from the barrier set by default.
Not a quality judgement about the data - these are real features, correctly mapped. They are excluded because an LCZ patch is a neighbourhood of homogeneous cover and a footpath through a housing estate does not divide one patch from another. They are also the majority of the network where they are mapped at all: 72.7% of Berlin's segments, 72.8% of Hong Kong's, 50.6% of Milan's, against 3.5-7.5% in cities where pedestrian mapping is sparser. Left in, the seed partition is mostly an artefact of how thoroughly a city's footpaths have been surveyed.
pedestrian itself is not here: Overture uses it for plazas and pedestrianised streets, which
are genuine breaks in the urban fabric at the width a street has.
MERGE_COLUMNS
module-attribute
¶
The seed-level features the merge compares neighbours on.
Deliberately the two cheap ones. Both come from buildings_area alone with a single overlay - no
land cover, no street profile, no second compute_parameters - and between them they carry the two
axes LCZ separates built types on: compactness and height band. Adding the land-cover fractions
would double the cost of unit generation to refine a decision the classifier makes afterwards
anyway.
PatchReport
dataclass
¶
PatchReport(n_seeds: int, n_patches: int, n_merges: int, n_isolates: int, n_below_minimum: int, n_blocked_by_max_area: int, n_seeds_split: int, n_above_maximum: int, area_above_maximum: float, min_area_m2: float, max_area_m2: float, seed_area_quantiles: dict[str, float] = dict(), patch_area_quantiles: dict[str, float] = dict())
What the merge did, for the run manifest.
A unit strategy whose output depends on a threshold has to say what that threshold produced, or two runs at different settings are indistinguishable in the archive.
n_isolates
instance-attribute
¶
Seeds adjacent to nothing - an island, or a face fully enclosed by the study boundary. They stay as their own patch at whatever size they are, since there is nothing to merge them into.
n_below_minimum
instance-attribute
¶
Patches still under min_area_m2 when the merge stopped. Non-zero exactly where isolates
or the max_area_m2 ceiling blocked further merging, so it is the number that says whether the
target was reachable rather than merely requested.
n_blocked_by_max_area
instance-attribute
¶
Merges that took the smallest neighbour rather than the most similar one, because every
similar neighbour would have breached max_area_m2. High values mean the two thresholds are
fighting each other.
n_seeds_split
instance-attribute
¶
Seeds that exceeded max_area_m2 and were subdivided before merging.
Zero on a barrier set that produced no oversized face, and zero whenever max_area_m2 is
None. High values say the barrier network left large unbounded faces — usually water, or a
periphery Overture does not cover — which is worth seeing rather than inferring from a
suspiciously round patch count.
n_above_maximum
instance-attribute
¶
Patches larger than max_area_m2 when the merge stopped.
Non-zero only where a merge could not be avoided: split_oversized cuts every oversized seed
before the merge starts, so what remains here is a unit the merge itself pushed over the line
because the alternative was leaving a sliver stranded. That trade is deliberate — see
n_blocked_by_max_area — and this is the count that says how often it was taken.
area_above_maximum
instance-attribute
¶
Total area in those patches, m². The count alone understates it badly: a handful of units can hold most of the extent.
patch_area_quantiles
class-attribute
instance-attribute
¶
p10/p50/p90 in m², before and after. The pair is the point: it says what the merge moved.
PatchUnits
¶
PatchUnits(*, min_area_m2: float = DEFAULT_MIN_AREA_M2, max_area_m2: float | None = DEFAULT_MAX_AREA_M2, buildings: GeoDataFrame | None = None)
Enclosure seeds merged to LCZ-patch scale, satisfying SpatialUnitStrategy.
buildings is taken at construction rather than passed to generate because the protocol's
signature is (bbox, barriers) and widening it for one strategy would put a building layer in
the interface every strategy has to accept. Without it the merge runs on size alone, which is
supported and worse — MERGE_COLUMNS is what makes a patch homogeneous rather than merely big.
The last PatchReport is kept on the instance so a caller can put it in the manifest without
the strategy having to return two things and break the protocol.
Set the area floor the merge works towards, and the building layer it judges by.
min_area_m2 is a floor, not a target: merging stops when a unit reaches it and the
merge that got it there overshoots, so 5 ha yields a ~10.5 ha median. max_area_m2
blocks a merge that would overshoot too far. buildings is optional and the merge runs
on size alone without it — supported, and worse.
Source code in src/lczkit/units/patches.py
generate
¶
generate(bbox: BBox, barriers: GeoDataFrame | None = None) -> GeoDataFrame
Seed enclosures over barriers and merge them to patch scale, indexed by unit_id.
Same contract as the other two strategies — bbox lon/lat, geometry-only frame back in
the projected CRS — and the same requirement as EnclosureUnits that barriers be
supplied, since the seeds are enclosures.
Sets self.report as a side effect. The protocol returns one frame, and a caller that
wants the merge outcome in the manifest reads it off the instance afterwards rather than
the interface growing a second return value for one strategy.
Source code in src/lczkit/units/patches.py
filter_street_barriers
¶
filter_street_barriers(streets: GeoDataFrame, *, drop_classes: frozenset[str] = PEDESTRIAN_CLASSES, class_column: str = 'class') -> GeoDataFrame
streets without the classes that are not boundaries between LCZ patches.
A no-op returning streets unchanged when the frame has no class column, which is the honest
behaviour for a VectorSource that does not supply one: silently treating every segment as
keepable is what the caller would get anyway, and raising would make the class column a hard
requirement of a protocol that does not promise it.
Overture's transportation/segment carries class on 87-99% of rows globally, and
OvertureSource already selects it, so on the shipped source this is free.
Source code in src/lczkit/units/patches.py
seed_features
¶
MERGE_COLUMNS per seed, from buildings alone.
Building surface fraction is footprint area overlaid onto the seed, over seed area — the same
definition lczkit.ucp.buildings uses, recomputed here rather than imported because this runs
before units exist and compute_parameters takes units as an input.
Height is the unweighted geometric mean, matching Hr, so a seed's value here and its value in
the parameter table are the same statistic. Null where the seed holds no building with a height,
and the merge handles that as a missing dimension rather than as a zero — a block of unmeasured
buildings is not a block of 1 m buildings.
buildings must already carry heights; pass what the cascade returned.
Source code in src/lczkit/units/patches.py
split_oversized
¶
Subdivide any seed larger than max_area_m2, returning the seeds and how many were split.
max_area_m2 was a merge guard and not a ceiling, and this is what makes the name true.
It refused to combine two seeds into something oversized and had no way to divide a seed that
already exceeded it — and enclosure seeds routinely do, because a face bounded by nothing but
the study edge is as large as the unmapped ground it covers. On a 4 555 km² Istanbul extent 807
patches exceeded the shipped 50 ha setting and held 72.7% of the total area, the largest being
1 073 km²; one 98 km² unit contained 1 310 buildings and was given a single LCZ label at a
uniqueness of 0.12. Benign where the giant faces are sea. Not benign where a city's periphery is
simply unsurveyed, which is the case in the cities this package exists to reach.
The cut is a regular grid of side sqrt(max_area_m2) anchored on each seed's own bounds, and it
is deliberately the dullest thing that works. It needs no building layer, so it behaves the same
on the unmapped hinterland that produces most oversized seeds; it is deterministic, so two runs
over the same extent agree; and intersecting a polygon with a grid that covers it preserves the
partition exactly — the pieces union back to the seed, and no ground is gained or lost. Pieces
that a concave seed leaves disconnected are exploded, so every unit stays a single polygon.
Slivers along the cut lines are expected and are not a problem: merge_to_patches runs next and
absorbs anything under min_area_m2 into its most similar neighbour, which is the same
treatment enclosure slivers already get.
Piece count is bounded by the seed area over max_area_m2 — that is, by the number of units the
caller asked for — so this adds no unbounded operation to the stage.
Source code in src/lczkit/units/patches.py
merge_to_patches
¶
merge_to_patches(seeds: GeoDataFrame, features: DataFrame | None = None, *, min_area_m2: float = DEFAULT_MIN_AREA_M2, max_area_m2: float | None = DEFAULT_MAX_AREA_M2) -> tuple[GeoDataFrame, PatchReport]
Merge contiguous seeds until each reaches min_area_m2, most similar neighbour first.
Repeatedly takes the smallest surviving unit and merges it into the contiguous neighbour
closest to it in features, subject to the combined area not exceeding max_area_m2. Where no
neighbour satisfies that ceiling it takes the smallest neighbour instead, so the loop always
makes progress and cannot stall on a unit hemmed in by large ones.
Deterministic. Units are processed smallest-area-first with ties broken on unit_id,
candidate neighbours are scanned in sorted order, and equal distances break on unit_id too.
The same seeds in a different row order give the same patches.
A partition in, a partition out. Only contiguous units are ever merged and the result is
their union, so the covered ground and the absence of overlap are both preserved exactly from
the seed partition. EnclosureUnits supplies that with clip=True.
features may be None, in which case every neighbour is equally similar and the merge runs on
size alone. That is a worse unit and it is offered because it needs no building layer.
Source code in src/lczkit/units/patches.py
332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 | |
Aggregation¶
lczkit.units.aggregate
¶
aggregate(): move attribute columns between two unit systems via an area overlay.
aggregate
¶
aggregate(from_units: GeoDataFrame, to_units: GeoDataFrame, method: AggregateMethod) -> GeoDataFrame
Move every non-geometry column of from_units onto to_units' polygons.
Both must be indexed by unit_id, in the same projected CRS — typically both are outputs
of a SpatialUnitStrategy.generate() call, with from_units carrying attribute columns
already joined onto it elsewhere in the pipeline. This function is a geometric overlay plus
a column-wise reduction; it does not compute any parameter itself.
"majority": every column takes the value from whichever from_units polygon overlaps a
given to_units polygon by the largest area. Works for any column dtype.
"area_weighted": every numeric column becomes the area-weighted mean across overlapping
from_units polygons; non-numeric columns are dropped (there is no defined mean of a
string) rather than silently guessing a reducer for them.
to_units rows with no overlapping from_units polygon get null attribute values.
Every result carries an aggregate_coverage column: the share of the target polygon that
overlapping source polygons actually cover. "area_weighted" normalises by the summed overlap
area rather than by the target's own area, so a target one tenth covered reports the mean over
that tenth and is otherwise indistinguishable from a fully measured one. Reporting coverage
beside the value is what makes the two distinguishable; the normalisation is left alone because
changing it would move every arm-B projection.
Source code in src/lczkit/units/aggregate.py
Overlay¶
The one definition of "intersect a layer with the units and measure the pieces" — the operation every per-unit area statistic in the package is built from, and which five near-identical private helpers each carried a copy of.
lczkit.units.overlay
¶
Overlaying a layer against the units, once — the operation five helpers each had a copy of.
Every per-unit area statistic in this package is the same three steps: intersect a layer with the
units, measure each piece, and sum by unit_id. ucp.industrial had three copies of it and
ucp.semantics two, and between them they ran seventeen overlays over a parameter stage that
needs two — semantic_metrics alone overlaid the land-use layer six times, once for its coverage
column and once per configured semantic group.
Three things follow from having one definition rather than five.
The pieces are reusable. unit_pieces carries the attributes through, so selecting industrial
buildings, or a semantic group's parcels, is a filter on a frame that already exists rather than
another intersection. ucp.parameters overlays each layer once and hands the result down, which is
the same move it already made for building_area_m2.
There is one answer to "does this need dissolving". covered_fraction(dissolve=True) clips
first and dissolves per unit, which is what semantics did; industrial reached the same quantity
through a whole-layer union_all, which is superlinear and — measured on real Overture land use —
raises GEOSException: side location conflict even after make_valid. That call site was safe
only because it ran on a few dozen industrial parcels, and nothing about its name said so. The
union of the clipped pieces inside a unit is the clip of the global union, so the safe form is not
an approximation of the unsafe one.
Splitting at unit boundaries stays the rule. A footprint straddling a boundary contributes its
share to each side rather than landing wholly in one, matching the rule the height cascade uses, so
every fraction built here shares a denominator with building_surface_fraction exactly.
PIECE_AREA
module-attribute
¶
Column unit_pieces measures each intersection into. Named rather than recomputed downstream so
two consumers of the same pieces cannot disagree about what area means.
unit_pieces
¶
unit_pieces(units: GeoDataFrame, layer: GeoDataFrame | GeoSeries, *, columns: Sequence[str] = (), keep_geom_type: bool = True) -> GeoDataFrame
layer intersected with units, one row per (unit, feature) pair.
Carries unit_id, the piece geometry, piece_area, and whichever of columns the layer has.
Absent columns are skipped rather than raising, so a caller can ask for height and subtype
without first checking which of them a hand-assembled layer happens to carry.
The layer is reset positionally before the overlay. The building layers carry no uniqueness
guarantee and .loc[an_index] over a duplicated one silently returns extra rows — a wrong
number rather than an error — so nothing here selects by index.
Returns an empty frame with the right columns when either side is empty, so callers branch on
.empty and never on None.
Source code in src/lczkit/units/overlay.py
area_in_units
¶
Total piece area per unit, zero where nothing reached the unit.
Zero rather than null: "nothing of this layer is here" is a measurement, unlike a land-cover fraction over ground the raster never covered. Callers that need the undefined case — a share of a unit holding no buildings — mask it themselves, where the reason is visible.
Source code in src/lczkit/units/overlay.py
covered_fraction
¶
Share of each unit's area covered by pieces.
dissolve unions the pieces within each unit first, so ground under two overlapping features
counts once. Required wherever the source layer has no overlap resolution — cleaning.land_use
applies make_valid and nothing else, and Milan's parcels sum to 106.6% of its bbox — and
wrong to pay for where it does: trim_overlaps has already made buildings_area disjoint.
Explicit rather than defaulted, because both mistakes are silent. Without it a fraction can exceed 1.0; with it unnecessarily, a run pays for a union nobody needed.
Source code in src/lczkit/units/overlay.py
share_of
¶
numerator / denominator, null where the denominator is zero.
The shape every "of what is built here, how much is X" column takes. Null rather than zero: a share of nothing is undefined, and reporting 0.0 tells a downstream rule that a cell definitely is not X rather than that there was nothing to judge.