spacr.qt.widgets.gate_spec

Gates — a shape drawn on a plot, which is a filter.

This is how the people who use spaCR already think about their data, because it is how flow cytometry has worked for forty years: draw a threshold on a histogram, draw a polygon round the cloud on a two-parameter scatter, name it, and everything downstream is about the cells inside it. What makes it a gate rather than a lasso is that it is a predicate, not a list of objects:

  • it can be re-applied to another dataset — the next plate, the re-run, the replicate — and still mean the same thing;

  • it can be saved and read back;

  • it can be sequenced — gate on gate on gate — and the hierarchy is what carries the reasoning (“of the single cells, the live ones, of those the infected ones”), together with the percentage at each step that says whether the reasoning survived contact with the data.

That is exactly the distinction spacr.selection draws between a filter and a selection, so a gate produces the first: GateSet.filter_for() returns a spacr.selection.DataFilter, and every linked view honours it the moment it is published. Nothing in the views needs to know what a gate is.

The clause

GateClause is duck-typed onto DataFilter the way RangeFilter and CategoryFilter are — a column attribute, a mask(frame) and a describe(). Its column is "gate:<name>", which makes DataFilter.add’s replace-by-column rule do the right thing: a re-drawn gate of the same name replaces its older self instead of stacking two versions of the same shape.

A whole chain becomes ONE clause rather than one per gate, and that is load-bearing rather than tidy: singlets might be area >= 100 and its child big might be area <= 500, and as two range clauses on area the second would replace the first and silently widen the population.

Threshold and rectangle gates can also hand back genuine RangeFilter clauses (Gate.range_filters()), for a caller that wants the gate to appear in the Local Data Filter as ordinary per-column controls the user can then nudge.

Geometry

Point-in-polygon is the even–odd ray-casting rule, vectorised. A row whose x or y is missing is outside every gate — not “unknown”, not silently kept. An object with no measurement is not an object inside the region, and letting it through would put objects with no value into a population the user defined by value; the same rule RangeFilter applies to NaN.

Percentages

GateSet.stats() reports, per gate, the count, the percentage of its parent and the percentage of the whole table. Both, always: 90% of a parent that is itself 2% of the table is 1.8% of the objects, and a hierarchy that prints only one of the two numbers is the standard way a gating strategy flatters itself.

No Qt in here — pure numpy and pandas, like spacr.selection and spacr.qt.widgets.graph_spec.

Exceptions

ClusterError

Clustering cannot run, or produced nothing worth gating.

GateError

A gate that cannot mean anything, with the reason in the message.

WandError

A wand click that cannot become a gate, with the reason.

Classes

BoxGate

A rectangular region in THREE measurements.

ClusterCandidate

One point in a clustering Walk: what this eps actually produced.

CompositeGate

Other gates, combined. The point 5.

CylinderGate

An oval drawn on one plane of the volume, extended along the third.

EllipseGate

An oval region — the shape a real population usually is.

Gate

Base of the three gate shapes. Frozen: a gate is a value.

GateClause

A whole gate chain as one DataFilter clause.

GateSet

An ordered, named collection of gates, with parents.

GateStats

One row of the gating hierarchy: how many survived, and out of what.

Handle

One draggable anchor on a gate.

PolygonGate

A closed region on a two-parameter scatter.

PrismGate

A polygon drawn on one plane of the volume, extended along the third.

RectGate

A rectangle on a two-parameter scatter — the quadrant gate.

ThresholdGate

A cut on one column — the line dragged across a histogram.

ViewGate

A shape drawn on the 3D view, extended through it along the line of sight.

Functions

best_cluster_candidate(→ Optional[ClusterCandidate])

Pick the candidate to recommend, or None when none is defensible.

cluster_gates(→ List[PolygonGate])

Find dense populations and return one gate per cluster.

cluster_walk_candidates(→ List[ClusterCandidate])

Try a range of eps around the chosen one and score each result.

gate_from_dict(→ Gate)

Rebuild one gate from Gate.to_dict().

points_in_polygon(→ numpy.ndarray)

Even–odd ray casting, vectorised over every point at once.

wand_gate(→ PolygonGate)

Grow a selection from a click and fit a polygon around it.

wand_select(→ numpy.ndarray)

Grow a selection outward from a clicked point.

Module Contents

exception spacr.qt.widgets.gate_spec.ClusterError[source]

Bases: GateError

Clustering cannot run, or produced nothing worth gating.

Initialize self. See help(type(self)) for accurate signature.

exception spacr.qt.widgets.gate_spec.GateError[source]

Bases: ValueError

A gate that cannot mean anything, with the reason in the message.

Raised where the gate is built rather than where it is applied: a gate with two vertices, a parent that does not exist or a name already taken would otherwise fail somewhere downstream, long after the drag that caused it.

Initialize self. See help(type(self)) for accurate signature.

exception spacr.qt.widgets.gate_spec.WandError[source]

Bases: GateError

A wand click that cannot become a gate, with the reason.

Initialize self. See help(type(self)) for accurate signature.

class spacr.qt.widgets.gate_spec.BoxGate[source]

Bases: Gate

A rectangular region in THREE measurements.

What a gate is in the volume. It is not a polygon with depth bolted on: a shape dragged on a rotated projection has no well-defined extent along the axis pointing at the viewer, so any attempt to read one off invents a number. Three ranges say exactly what is meant and read the same from every angle.

A box whose z range is unbounded is a RECTANGLE extended through the volume, which is what a 2D gate already is when seen in 3D – so the two agree rather than being different answers to the same question.

Parameters:

name – unique name within a GateSet, by which the hierarchy and the filter clause identify this gate; surrounding whitespace is stripped and an empty name raises GateError.

__post_init__() → None[source]

Normalise the three columns and swap any inverted bound pair.

Unlike the 2-D gates this does not require a bound to be set: a box with open faces is a legitimate slab through the cube.

Raises:

GateError – if any of the three columns is blank.

centre() → Tuple[float | None, float | None][source]

The region’s middle.

Returns:

(x, y).

describe() → str[source]

Each of the three axes’ bounds, with open sides said as such.

Returns:

a one-line description.

classmethod from_limits(name: str, columns: Sequence[str], limits: Sequence[Tuple[float, float]], *, parent: str | None = None) → BoxGate[source]

A box enclosing what is currently in view.

How a gate is made in the volume: frame a population by spinning and zooming, then keep what you framed. The view is already the gesture – asking the user to also drag a shape on a rotated projection would be asking them to aim at something that is not flat.

Parameters:
  • name – the new gate’s name.

  • columns – the x, y and z measurements, in that order; only the first three are used.

  • limits – one (low, high) range per column, in the same order; only the first three are used. Fewer than three columns or ranges raise GateError.

mask(frame: pandas.DataFrame) → numpy.ndarray[source]

Which rows fall inside the region.

Parameters:

frame – the measurements to test.

Returns:

a boolean array, one entry per row.

range_filters() → Tuple[spacr.selection.RangeFilter, ...][source]

The box as three independent range filters, one per axis.

A box is the three-dimensional shape that survives being pushed into a query; the curved solids beside it do not.

Returns:

one filter per bounded axis.

scaled(factor: float, *, about: Tuple[float, float] | None = None) → BoxGate[source]

A copy grown or shrunk about a point.

Parameters:
  • factor – multiplier; must be positive.

  • about – the anchor, defaulting to the gate’s own centre.

Returns:

the resized copy.

thresholds() → Dict[str, Tuple[float | None, float | None]][source]

All three sides, whether or not they are currently set.

to_dict() → Dict[str, Any][source]

This gate as plain data, including every coordinate needed to rebuild the shape.

Returns:

a JSON-safe dict.

to_rect() → RectGate[source]

The box seen from the front: its x and y, ignoring depth.

What the 2D editor shows and edits. Its handles, its drag and its outline then all work unchanged, and the depth the 2D view cannot express is retained rather than silently reset.

translated(dx: float, dy: float) → BoxGate[source]

A copy moved by (dx, dy).

Parameters:
  • dx – shift along the x axis.

  • dy – shift along the y axis.

Returns:

the moved copy.

with_handle(role: str, x: float, y: float) → BoxGate[source]

Pulled by the flat view’s anchors, and still a box.

The 2D editor draws and grabs a box as to_rect(), so the roles it offers are a RECTANGLE’s – 'x_low,y_low' and the rest. The inherited Gate.with_handle() knows nothing of them and refused every one with GateError: BoxGate has no handle 'x_low', which the canvas swallowed: the corners were drawn, they could be grabbed, and pulling them did nothing.

Applying the pull to the front rectangle and taking its bounds keeps the depth this view cannot express, rather than replacing the box with the rectangle drawn for it and silently dropping the z range the user set in the volume.

Parameters:
  • role – a handle role from Gate.handles(), as offered for the front rectangle.

  • x – the new x, in data units.

  • y – the new y, in data units.

Returns:

a new box with the pulled x/y bounds and this box’s depth.

with_threshold(column: str, low: float | None, high: float | None) → BoxGate[source]

Any of the three sides, by name.

Parameters:
  • column – one of the box’s three columns; any other column raises GateError.

  • low – lower bound, or None to leave that side open. The two bounds are swapped if given in reverse.

  • high – upper bound, or None to leave that side open.

property columns: Tuple[str, ...][source]

its three axes.

Returns:

the column names, in axis order.

Type:

The three columns this gate reads

property kind: str[source]

The tag a saved box gate carries.

Returns:

the shape tag.

class spacr.qt.widgets.gate_spec.ClusterCandidate[source]

One point in a clustering Walk: what this eps actually produced.

Kept as data rather than rendered straight into a table so the search is testable without a GUI, and so the caller decides what “best” means – which it must, because the scores disagree on purpose. Silhouette is blind to how much was discarded, so a run that calls 90% of the plate noise and tightly clusters the rest scores beautifully and is useless.

class spacr.qt.widgets.gate_spec.CompositeGate[source]

Bases: Gate

Other gates, combined. The point 5.

“if the user draws another gate on the same 3d graph they should be

able to set the new gate as being its own gate, subtracting or add[ing] from/to the other gates in view”

WHY THIS IS THE FEATURE AND NOT A CONVENIENCE. “The bright, small, round ones” is three measurements at once, and answering it today means gating twice and intersecting the results by hand – an intersection that exists only in whatever the user did next. A gate that IS “this cylinder minus that box” is a statement someone else can re-run.

OPERANDS ARE NAMES, NOT COPIES, and that is the whole design decision. Holding copies would make mask self-contained, which is tidier – and would mean that adjusting one of the gates being combined left the composite showing the old shape, silently, for as long as nobody looked. One source of truth costs a resolver; two sources cost a wrong answer.

So mask() cannot work alone and says so rather than guessing. GateSet.mask() passes the set in.

NOT THE SAME THING AS parent. A parent is SEQUENTIAL gating – draw inside what you already kept – which is always an intersection and always a tree. This is set algebra between siblings, which is neither. Both exist because both are asked for.

Parameters:

name – unique name within a GateSet, by which the hierarchy and the filter clause identify this gate; surrounding whitespace is stripped and an empty name raises GateError.

__post_init__() → None[source]

Normalise the operation and operand names.

Raises:

GateError – if the operation is not one of COMPOSITE_OPS, if fewer than two operands survive stripping, if the gate lists itself, or if an operand is repeated – a gate unioned with itself is itself and subtracted from itself is empty, so neither is likely to be what was meant.

centre() → Tuple[float | None, float | None][source]

(None, None): there is no geometry to have a middle.

Returns:

a pair of Nones.

describe() → str[source]

The operands joined by the operation, in words.

Returns:

a one-line description.

mask(frame: pandas.DataFrame) → numpy.ndarray[source]

Always refuses: a composite names other gates and cannot stand alone.

IT SAYS WHO TO ASK. Evaluating this needs the gates it combines, and only the set holding them can look them up – so the refusal names the operands and points at the set rather than returning an empty mask that would read as “nothing matched”.

Parameters:

frame – unused; present to satisfy the gate contract.

Returns:

never; always raises.

mask_with(frame: pandas.DataFrame, lookup: Mapping[str, Gate]) → numpy.ndarray[source]

Evaluate against a name -> gate mapping.

Parameters:
  • frame – the measurements to test; one entry per row is returned.

  • lookup – operand name mapped to either a gate, masked on frame, or an already computed boolean array. Every operand must be present.

Raises:

GateError – naming any operand the mapping does not hold. A composite whose operand was deleted must not quietly become the union of what is left.

scaled(factor: float, *, about: Tuple[float, float] | None = None) → CompositeGate[source]

Itself, unchanged: a composite has no geometry of its own.

The factor is still validated, so a caller that passes a nonsense multiplier is told here rather than at the next shape that has one.

Parameters:
  • factor – multiplier; must be positive.

  • about – unused; present to satisfy the gate contract.

Returns:

this gate.

to_dict() → Dict[str, Any][source]

This composite as plain data: the operation and the names it joins.

Returns:

a JSON-safe dict.

translated(dx: float, dy: float) → CompositeGate[source]

Unchanged: moving a composite would have to move its operands, and those are other gates with their own users.

Parameters:
  • dx – horizontal shift; ignored.

  • dy – vertical shift; ignored.

property columns: Tuple[str, ...][source]

the columns are its operands’, and only the set knows them.

A composite that guessed would be guessing about gates it cannot see. GateSet.columns_for() answers this properly.

Type:

Empty

property kind: str[source]

The tag a saved composite carries.

Returns:

the shape tag.

class spacr.qt.widgets.gate_spec.CylinderGate[source]

Bases: Gate

An oval drawn on one plane of the volume, extended along the third.

The cylinder. The user draws in 2D on the plane they chose – which is the only place a drag has a well-defined meaning – and the shape is extended along the axis pointing out of it.

THE PLANE IS NAMED BY ITS COLUMNS, not by a string. u_column and v_column are the two the oval is drawn on and axis_column is the normal; that says which of XY / XZ / YZ was the anchor without a second field that could disagree with the columns. It also reads the same from every camera angle, which is the principle BoxGate states: a shape dragged on a rotated projection has no well-defined extent along the axis pointing at the viewer, so any attempt to read one off invents a number.

AN UNBOUNDED AXIS IS THE DEFAULT AND THAT IS A DECISION, not an accident. A cylinder with no bound along its normal means exactly what the 2D ellipse on that plane already meant, so drawing one in 3D and drawing one in 2D agree; narrowing it is then an explicit act rather than something the user has to undo.

Parameters:

name – unique name within a GateSet, by which the hierarchy and the filter clause identify this gate; surrounding whitespace is stripped and an empty name raises GateError.

__post_init__() → None[source]

Normalise the plane columns, the axis column, the radii and the extent.

A negative radius is taken as its absolute value, and the axis bounds are swapped into order.

Raises:

GateError – if any column is blank, or if the plane and its axis do not name three different measurements.

centre() → Tuple[float | None, float | None][source]

The region’s middle.

Returns:

(x, y).

describe() → str[source]

The circular base and the height axis’s bounds.

Returns:

a one-line description.

classmethod from_ellipse(ellipse: EllipseGate, axis_column: str, *, axis_low: float | None = None, axis_high: float | None = None) → CylinderGate[source]

Extrude a drawn oval along axis_column.

This is what “translated to 3 dims when the gate is generated” means: the drawing stays 2D and reuses the existing geometry, and the third dimension is added at the end.

Parameters:
  • ellipse – the drawn oval; its name, parent, columns, centre and radii become the cylinder’s cross-section.

  • axis_column – the measurement the oval is extended along.

mask(frame: pandas.DataFrame) → numpy.ndarray[source]

Which rows fall inside the region.

Parameters:

frame – the measurements to test.

Returns:

a boolean array, one entry per row.

range_filters() → Tuple[spacr.selection.RangeFilter, ...][source]

Only the normal, and only when it is bounded.

The oval is not a conjunction of ranges – the same reason PolygonGate returns nothing – so offering a bounding box for it would quietly include the corners. The axis bound IS a range and is exact, so it is given.

scaled(factor: float, *, about: Tuple[float, float] | None = None) → CylinderGate[source]

A copy grown or shrunk about a point.

Parameters:
  • factor – multiplier; must be positive.

  • about – the anchor, defaulting to the gate’s own centre.

Returns:

the resized copy.

thresholds() → Dict[str, Tuple[float | None, float | None]][source]

The normal, ALWAYS – even when it is currently unbounded.

range_filters answers “what does this gate filter on”; an unbounded normal filters on nothing and is rightly absent there. This answers “what can the user set”, and an axis they cannot see in the panel is an axis they cannot bound – which is the whole of point 4.

to_dict() → Dict[str, Any][source]

This gate as plain data, including every coordinate needed to rebuild the shape.

Returns:

a JSON-safe dict.

to_ellipse() → EllipseGate[source]

The cylinder seen down its own axis: the oval that was drawn.

The same service BoxGate.to_rect() provides – the 2D editor’s handles, drag and outline all work on this unchanged, and the extent along the normal that a 2D view cannot express is left alone rather than silently reset.

translated(dx: float, dy: float) → CylinderGate[source]

A copy moved by (dx, dy).

Parameters:
  • dx – shift along the x axis.

  • dy – shift along the y axis.

Returns:

the moved copy.

with_threshold(column: str, low: float | None, high: float | None) → Gate[source]

Bound the NORMAL. The oval/polygon is not a range and is not one.

This is how the user bounds the cylinder’s height, which is what point 4 of the design asks for.

Parameters:
  • column – must be axis_column, the cylinder’s normal; any other column raises GateError.

  • low – lower bound, or None to leave that side open. The two bounds are swapped if given in reverse.

  • high – upper bound, or None to leave that side open.

property columns: Tuple[str, ...][source]

its two radial axes and its height axis.

Returns:

the column names, in axis order.

Type:

The three columns this gate reads

property kind: str[source]

The tag a saved cylinder gate carries.

Returns:

the shape tag.

class spacr.qt.widgets.gate_spec.EllipseGate[source]

Bases: Gate

An oval region — the shape a real population usually is.

A cloud of cells is round-ish and a rectangle around it always takes corner debris with it. An ellipse is the cheapest shape that does not, and unlike a polygon it is defined by four numbers, so it can be dragged out in one gesture and resized without touching vertices.

A circle is an ellipse with equal radii; there is no separate kind, because a “circle” that cannot be squashed is a shape the user has to delete and redraw the moment the axes are not comparable — and on a scatter of two different measurements they never are.

Parameters:

name – unique name within a GateSet, by which the hierarchy and the filter clause identify this gate; surrounding whitespace is stripped and an empty name raises GateError.

__post_init__() → None[source]

Normalise the columns, centre and radii.

Raises:

GateError – if either column is blank, if both name the same measurement, or if either radius is zero or negative – such an ellipse selects nothing.

centre() → Tuple[float | None, float | None][source]

The region’s middle.

Returns:

(x, y).

describe() → str[source]

The oval’s centre and radii on both axes.

Returns:

a one-line description.

classmethod from_drag(name: str, x_column: str, y_column: str, x0: float, y0: float, x1: float, y1: float, *, parent: str | None = None) → EllipseGate[source]

Build the ellipse INSCRIBED in the dragged box.

Inscribed rather than circumscribed, so the shape ends where the pointer did. A user who drags a box expects the shape to touch the corner they released at, not to extend past it.

Parameters:
  • name – the new gate’s name.

  • x_column – measurement on the horizontal axis.

  • y_column – measurement on the vertical axis.

  • x0 – horizontal coordinate where the drag started, in data units.

  • y0 – vertical coordinate where the drag started.

  • x1 – horizontal coordinate where the drag was released.

  • y1 – vertical coordinate where the drag was released. A drag with no extent on an axis gets a radius of 1e-12 there.

handles(view: View) → Tuple[Handle, ...][source]

Four on the axes of the oval, four on its bounding box.

The axis handles change one radius, the corners change both. Corners are placed on the bounding box rather than on the curve because that is where the user reaches for them – the curve at 45 degrees is inside the box and feels like a miss.

Parameters:

view – visible (x_low, x_high, y_low, y_high) axis limits; not used, because every handle sits on the shape itself.

mask(frame: pandas.DataFrame) → numpy.ndarray[source]

Which rows fall inside the region.

Parameters:

frame – the measurements to test.

Returns:

a boolean array, one entry per row.

scaled(factor: float, *, about: Tuple[float, float] | None = None) → EllipseGate[source]

A copy grown or shrunk about a point.

Parameters:
  • factor – multiplier; must be positive.

  • about – the anchor, defaulting to the gate’s own centre.

Returns:

the resized copy.

to_dict() → Dict[str, Any][source]

This gate as plain data, including every coordinate needed to rebuild the shape.

Returns:

a JSON-safe dict.

translated(dx: float, dy: float) → EllipseGate[source]

A copy moved by (dx, dy).

Parameters:
  • dx – shift along the x axis.

  • dy – shift along the y axis.

Returns:

the moved copy.

with_handle(role: str, x: float, y: float) → EllipseGate[source]

A copy with one or both radii dragged to (x, y).

Parameters:
  • role – x_radius, y_radius, or both comma-separated.

  • x – the handle’s new x.

  • y – the handle’s new y.

Returns:

the resized copy.

property columns: Tuple[str, ...][source]

its two axes.

Returns:

the column names, in axis order.

Type:

The two columns this gate reads

property kind: str[source]

The tag a saved ellipse gate carries.

Returns:

the shape tag.

class spacr.qt.widgets.gate_spec.Gate[source]

Base of the three gate shapes. Frozen: a gate is a value.

Parameters:
  • name – unique within a GateSet, and the thing the hierarchy and the filter clause are read by.

  • parent – the gate this one is drawn inside, by name, or None for a root gate. Sequential gating is this field and nothing else.

__post_init__() → None[source]

Normalise the name and parent, and reject a gate that parents itself.

Gates nest: a child gate is evaluated only on the rows its parent already kept. A gate that names itself as parent would need its own result before it could be computed, so it is rejected here rather than looping later.

Raises:

GateError – if parent equals name.

abstract centre() → Tuple[float | None, float | None][source]

The gate’s middle in data units, for the default resize anchor.

None on an axis the gate does not bound – an open-ended threshold has no centre along its own column, and pretending it does would move it somewhere arbitrary on the first drag.

abstract describe() → str[source]

This gate in one line, for a person reading the hierarchy.

SAYS THE NUMBERS, not just the shape. “Region on x x y” is true of every polygon ever drawn; what a reader needs is which region.

Returns:

a one-line description.

handles(view: View) → Tuple[Handle, ...][source]

The draggable anchor points, in data units.

Parameters:

view – the visible axis limits, used ONLY to place handles on sides the gate does not bound. An unbounded side is at infinity and cannot be drawn or grabbed there; putting its handle at the edge of the view lets the user pull a bound onto a gate that never had one.

Returns:

the anchors. Empty when the gate has nothing to pull.

abstract mask(frame: pandas.DataFrame) → numpy.ndarray[source]

Which rows of frame fall inside this gate.

Parameters:

frame – the measurements to test.

Returns:

a boolean array, one entry per row.

range_filters() → Tuple[spacr.selection.RangeFilter, ...][source]

This gate as ordinary per-column range clauses, where it is one.

Empty for a polygon, which is not a conjunction of ranges and must not pretend to be — a bounding box would quietly include the corners.

rename(name: str) → Gate[source]

A copy of this gate under a new name.

The caller is responsible for the name being unique in its set; a gate does not know what else the set holds.

Parameters:

name – the new name.

Returns:

the renamed copy.

abstract scaled(factor: float, *, about: Tuple[float, float] | None = None) → Gate[source]

Return this gate grown or shrunk about a fixed point.

Parameters:
  • factor – >1 grows, <1 shrinks.

  • about – the point held fixed; the gate’s own centre by default, which is what “pull to expand” means when the user has not grabbed a particular edge.

Raises:

GateError – a non-positive factor, which would invert or collapse the shape rather than resize it.

thresholds() → Dict[str, Tuple[float | None, float | None]][source]

Return editable per-column thresholds represented by this gate.

Thresholds are derived from range_filters(), so shapes that are not conjunctions of independent ranges expose only their genuine range constraints. A polygon returns no thresholds, while a cylinder exposes its normal-axis bounds rather than bounds for its elliptical section. This method is distinct from PolygonGate.bounds(), which returns a geometric bounding box.

Returns:

Mapping of column names to (low, high) bounds.

abstract to_dict() → Dict[str, Any][source]

This gate as plain data, for the saved set.

Returns:

a JSON-safe dict carrying kind, name, parent and whatever the shape needs to be rebuilt.

abstract translated(dx: float, dy: float) → Gate[source]

Return this gate moved by (dx, dy) in DATA units.

Data units, not pixels: a gate is a statement about measurements, and moving it by pixels would mean it drifted whenever the axes rescaled.

Parameters:
  • dx – shift along the gate’s x measurement.

  • dy – shift along its y measurement. Ignored by a one-column gate, which has no y.

with_handle(role: str, x: float, y: float) → Gate[source]

Return this gate with the role anchor moved to (x, y).

Parameters:
  • role – a role from handles().

  • x – the new position along the gate’s x measurement.

  • y – along its y measurement.

Returns:

a new gate, or self when the drag would collapse the shape. Refusing beats raising here: this runs on mouse-release, and a gate that snaps back is a clear “that is too small” while a traceback out of an event handler is not.

Raises:

GateError – a role this gate does not have, which is a bug in the caller rather than something the user did.

with_parent(parent: str | None) → Gate[source]

A copy of this gate drawn inside parent.

A COPY, because a gate is a value: mutating one that a set already holds would change a hierarchy without the set knowing.

Parameters:

parent – the enclosing gate’s name, or None for a root gate.

Returns:

the reparented copy.

with_threshold(column: str, low: float | None, high: float | None) → Gate[source]

Return this gate with column bounded to low..high.

Parameters:
  • column – the measurement column to bound. The base class bounds none, so it always raises GateError; shapes with bounds override it.

  • low – lower bound, or None for an open side.

  • high – upper bound, or None for an open side.

Raises:

GateError – when this gate has no bound on column. A gate that silently ignored the edit would leave the panel showing a number the gate does not honour.

property columns: Tuple[str, ...][source]
Abstractmethod:

Which measured columns this gate reads.

What lets a gate be validated against a table before it is applied, so a set saved on one experiment says which of its columns are missing here rather than raising part-way through a mask.

Returns:

the column names, in axis order.

property kind: str[source]
Abstractmethod:

The shape’s tag, as it appears in a saved gate set.

The string a from_dict dispatches on, so it is part of the FILE FORMAT and cannot be renamed to read better without migrating every saved set.

Returns:

the shape tag.

class spacr.qt.widgets.gate_spec.GateClause[source]

A whole gate chain as one DataFilter clause.

Duck-typed onto DataFilter exactly as RangeFilter is — column, mask, describe — so no view has to learn what a gate is.

One clause for the whole chain rather than one per gate: two gates on the same column (area ≥ 100 and its child area ≤ 500) would otherwise replace each other under DataFilter.add’s replace-by-column rule and silently widen the population.

Parameters:

gates – the chain from the outermost gate down to the one whose population is selected; at least one gate is required.

__post_init__() → None[source]

Freeze the gate names into a tuple.

Raises:

GateError – if the clause names no gates.

describe() → str[source]

The chain from outermost to innermost, with the final gate spelled out.

Returns:

a one-line description.

mask(frame: pandas.DataFrame) → numpy.ndarray[source]

Rows that survive EVERY gate in the chain.

Parameters:

frame – the measurements to test.

Returns:

a boolean array, one entry per row.

property column: str[source]

"gate:<leaf name>" — the key DataFilter.add replaces on.

Not a real column, and deliberately not one: a re-drawn gate of the same name replaces its older self, and two different gates never collide.

property name: str[source]

The chain’s name, which is the name of its LAST gate.

A chain is identified by where it ends, because that is the population it selects; the gates above it are how you got there.

Returns:

the deepest gate’s name.

class spacr.qt.widgets.gate_spec.GateSet[source]

An ordered, named collection of gates, with parents.

Mutable — it is a thing the user edits — but every gate in it is frozen and it round-trips through to_json(). Order is definition order, which is also a valid topological order because a parent has to exist before a child can name it.

__contains__(name: object) → bool[source]

Report whether a gate of this name is in the set.

Parameters:

name – gate name; coerced with str(), so a Gate does not match – membership is by name, as everywhere else in the set.

Returns:

True if the name is present.

__len__() → int[source]

Return the number of gates in the set.

__post_init__() → None[source]

Re-add every incoming gate through add().

A set built from a list – or read back from a file – gets the same parent and cycle checks as one built by clicking, rather than trusting that whatever produced the list already validated it.

add(gate: Gate) → GateSet[source]

Add gate, replacing any gate of the same name.

Replacing rather than appending is what makes re-drawing a gate an edit: the children keep pointing at the name, so adjusting a threshold moves everything below it rather than orphaning it.

Parameters:

gate – the gate to add; its parent, if any, must already be in the set.

Raises:

GateError – if the parent does not exist, or if the gate would close a cycle.

children(name: str | None) → Tuple[Gate, ...][source]

The gates drawn directly inside name (None for the roots).

Parameters:

name – the parent gate’s name, or None (or an empty name) for the root gates.

clause_for(name: str) → GateClause[source]

The whole chain as one filter clause.

Parameters:

name – the gate whose chain, from path(), becomes the clause.

clear() → GateSet[source]

Drop every gate. Returns self, so it chains.

Returns:

this set, now empty.

depth(name: str) → int[source]

How deeply name is nested; a root gate is 0.

Parameters:

name – the gate’s name.

Returns:

the number of enclosing gates.

describe() → str[source]

Every gate in hierarchy order, each with its own description.

Returns:

a one-line description of the whole set.

filter_for(name: str, base: spacr.selection.DataFilter | None = None) → spacr.selection.DataFilter[source]

A DataFilter carrying this gate.

Parameters:
  • name – gate whose complete ancestor chain becomes the added filter clause.

  • base – an existing filter to add the clause to. The gate is added rather than replacing what is there, so a gate and the Local Data Filter’s own clauses compose — which is what “the gate becomes a filter every linked view honours” has to mean in a screen that also has a filter panel.

classmethod from_dict(payload: Mapping[str, Any]) → GateSet[source]

Rebuild a set from plain data.

Parameters:

payload – what to_dict() produced.

Returns:

the rebuilt set.

classmethod from_json(text: str) → GateSet[source]

Rebuild a set from JSON text.

A parse failure is reported as “this is not a gate file” rather than as a column and line number, because the usual cause is dropping the wrong file rather than a corrupted one.

Parameters:

text – the JSON text.

Returns:

the rebuilt set.

Raises:

GateError – when the text is not JSON.

get(name: str) → Gate[source]

The gate called name.

The refusal LISTS the names that do exist, because the mistake this catches is a typo or a rename, and both are answered by seeing the real list rather than being told the one you asked for is absent.

Parameters:

name – the gate’s name.

Returns:

the gate.

Raises:

GateError – when no gate has that name.

classmethod load(path: str) → GateSet[source]

Read a gate file written by save().

Parameters:

path – a UTF-8 JSON gate file.

mask(frame: pandas.DataFrame, name: str) → numpy.ndarray[source]

The rows of frame inside name and every gate above it.

Parameters:
  • frame – the measurements to test; it must carry every column the chain reads.

  • name – the gate whose chain is applied.

Raises:

GateError – naming the missing column if frame does not carry what the chain needs — a gate re-applied to a table without the measurement is a mistake worth an exception, not a silently empty population.

order() → Tuple[Gate, ...][source]

Every gate, parents before children, siblings in definition order.

The order the hierarchy is drawn and the percentages are read in.

path(name: str) → Tuple[Gate, ...][source]

The chain from the outermost gate down to name, inclusive.

Parameters:

name – the name of the innermost gate of the chain.

Raises:

GateError – on a cycle, naming the gates in it — a hierarchy that loops would otherwise hang whatever walked it.

population(frame: pandas.DataFrame, name: str) → pandas.DataFrame[source]

frame narrowed to the gate and its ancestors.

Parameters:
  • frame – the measurements to narrow.

  • name – the gate whose chain selects the rows.

remove(name: str, *, cascade: bool = True) → GateSet[source]

Drop name.

Parameters:
  • name – exact gate name to remove. An unknown name leaves the set unchanged.

  • cascade – also drop everything gated inside it. On by default: a child whose parent is gone is a gate on a population that no longer exists, and silently re-rooting it would change what it means without saying so.

report(frame: pandas.DataFrame) → str[source]

The hierarchy as text, one gate per line, indented by depth.

Parameters:

frame – the measurements every gate is counted on; its row count heads the report.

save(path: str) → str[source]

Write the gates to path as JSON. Returns the path.

Parameters:

path – destination file, overwritten with the UTF-8 JSON of to_json().

stats(frame: pandas.DataFrame) → Tuple[GateStats, ...][source]

Count and percentages for every gate, parents first.

Parameters:

frame – the measurements every gate is counted on.

to_dict() → Dict[str, Any][source]

The whole set as plain data.

Returns:

a JSON-safe dict holding every gate.

to_json(*, indent: int | None = 2) → str[source]

The set as JSON text, with keys sorted so the file is diffable.

SORTED, deliberately: a gate file that reordered itself between saves would show as changed in version control every time it was written.

Parameters:

indent – passed to json.dumps(); None for the compact form.

Returns:

the JSON text.

property is_empty: bool[source]

Whether this set holds no gates at all.

Returns:

True when empty.

property names: Tuple[str, ...][source]

Every gate’s name, in insertion order.

Returns:

the names.

class spacr.qt.widgets.gate_spec.GateStats[source]

One row of the gating hierarchy: how many survived, and out of what.

Parameters:
  • name – the gate’s name.

  • depth – how deeply the gate is nested; a root gate is 0.

  • n_total – number of rows in the whole table.

  • n_parent – the population this gate was drawn inside. For a root gate, the whole table.

  • n_in – number of rows inside the gate and every gate above it.

  • of_parent – the fraction of that population, in [0, 1].

  • of_total – the fraction of the whole table. Both are reported because 90% of a parent that is 2% of the table is 1.8% of the objects.

describe() → str[source]

One indented row of the hierarchy: the count and both fractions.

Returns:

a one-line description, indented to its depth.

property of_parent: float[source]

This gate’s survivors as a fraction of the population it was drawn in.

NaN rather than zero for an empty parent: no cells entered, so the fraction is undefined rather than nothing having survived.

Returns:

the fraction, or NaN.

property of_total: float[source]

This gate’s survivors as a fraction of the whole table.

NaN rather than zero for an empty table, for the same reason.

Returns:

the fraction, or NaN.

class spacr.qt.widgets.gate_spec.Handle[source]

One draggable anchor on a gate.

role is what the anchor MEANS, not where it is: “x_low”, “vertex:3”, “x_low,y_high”. Position is derived from the gate and the view and is therefore never stored – a handle that remembered a coordinate would go stale the moment the gate moved.

class spacr.qt.widgets.gate_spec.PolygonGate[source]

Bases: Gate

A closed region on a two-parameter scatter.

The shape a real population needs: cell clouds are not rectangles, and approximating one with a bounding box is how the corner debris gets counted as cells.

Parameters:

name – unique name within a GateSet, by which the hierarchy and the filter clause identify this gate; surrounding whitespace is stripped and an empty name raises GateError.

__post_init__() → None[source]

Normalise the columns and vertices, and reject a polygon with no area.

A repeated closing vertex is accepted and dropped – the polygon closes itself, and keeping the duplicate would leave an edge of length zero. The remaining vertices are checked with the shoelace formula: a zero area means they are collinear, which selects nothing and is almost always a slipped click.

Raises:

GateError – if either column is blank, if both name the same measurement, if fewer than three vertices remain, or if the vertices enclose no area.

bounds() → Tuple[float, float, float, float][source]

(x_low, x_high, y_low, y_high) — for drawing, never for masking.

centre() → Tuple[float | None, float | None][source]

The vertex centroid.

Not the area centroid: for dragging, the vertex mean is stable, cheap, and is what the user sees as the middle of the shape. The area centroid of a strongly concave polygon can sit outside it, which makes a resize look like it moved.

describe() → str[source]

The outline’s vertex count and the axes it was drawn on.

Returns:

a one-line description.

handles(view: View) → Tuple[Handle, ...][source]

One anchor per vertex. A polygon has no sides to pull that are not already two vertices, so there are no side handles.

Parameters:

view – visible (x_low, x_high, y_low, y_high) axis limits; not used, because every handle sits on the shape itself.

mask(frame: pandas.DataFrame) → numpy.ndarray[source]

Which rows fall inside the drawn outline.

Parameters:

frame – the measurements to test.

Returns:

a boolean array, one entry per row.

scaled(factor: float, *, about: Tuple[float, float] | None = None) → PolygonGate[source]

A copy grown or shrunk about a point.

Parameters:
  • factor – multiplier; must be positive.

  • about – the anchor, defaulting to the gate’s own centre.

Returns:

the resized copy.

to_dict() → Dict[str, Any][source]

This gate as plain data, including every coordinate needed to rebuild the shape.

Returns:

a JSON-safe dict.

translated(dx: float, dy: float) → PolygonGate[source]

A copy with every vertex moved by (dx, dy).

Parameters:
  • dx – shift along the x axis.

  • dy – shift along the y axis.

Returns:

the moved copy.

with_handle(role: str, x: float, y: float) → PolygonGate[source]

A copy with one vertex moved to (x, y).

Parameters:
  • role – which handle, spelled vertex:<index>.

  • x – the handle’s new x.

  • y – the handle’s new y.

Returns:

the edited copy.

with_vertex(index: int, x: float, y: float) → PolygonGate[source]

Move ONE vertex – the per-vertex drag handle.

Parameters:
  • index – position of the vertex to move; negative values count from the end. An index outside the polygon raises GateError.

  • x – the vertex’s new horizontal coordinate, in data units.

  • y – the vertex’s new vertical coordinate, in data units.

Raises:

GateError – an index outside the polygon, which would otherwise silently move a different corner than the one grabbed.

property columns: Tuple[str, ...][source]

its two axes.

Returns:

the column names, in axis order.

Type:

The two columns this gate reads

property kind: str[source]

The tag a saved polygon gate carries.

Returns:

the shape tag.

class spacr.qt.widgets.gate_spec.PrismGate[source]

Bases: Gate

A polygon drawn on one plane of the volume, extended along the third.

The prism, and the sibling of CylinderGate in every respect – the plane is named by its columns, the normal is unbounded by default so it agrees with the 2D polygon, and the drawing stays 2D.

Parameters:

name – unique name within a GateSet, by which the hierarchy and the filter clause identify this gate; surrounding whitespace is stripped and an empty name raises GateError.

__post_init__() → None[source]

Normalise the plane columns, the axis column, the footprint and extent.

Raises:

GateError – if any column is blank, if the plane and its axis do not name three different measurements, or if the footprint has fewer than three vertices.

centre() → Tuple[float | None, float | None][source]

The region’s middle.

Returns:

(x, y).

describe() → str[source]

The base outline and the height axis’s bounds.

Returns:

a one-line description.

classmethod from_polygon(polygon: PolygonGate, axis_column: str, *, axis_low: float | None = None, axis_high: float | None = None) → PrismGate[source]

Extrude a drawn polygon along axis_column.

Parameters:
  • polygon – the drawn polygon; its name, parent, columns and vertices become the prism’s cross-section.

  • axis_column – the measurement the polygon is extended along.

mask(frame: pandas.DataFrame) → numpy.ndarray[source]

Which rows fall inside the region.

Parameters:

frame – the measurements to test.

Returns:

a boolean array, one entry per row.

range_filters() → Tuple[spacr.selection.RangeFilter, ...][source]

The normal only – see CylinderGate.range_filters().

scaled(factor: float, *, about: Tuple[float, float] | None = None) → PrismGate[source]

A copy grown or shrunk about a point.

Parameters:
  • factor – multiplier; must be positive.

  • about – the anchor, defaulting to the gate’s own centre.

Returns:

the resized copy.

thresholds() → Dict[str, Tuple[float | None, float | None]][source]

The normal, always – see CylinderGate.bounds().

to_dict() → Dict[str, Any][source]

This gate as plain data, including every coordinate needed to rebuild the shape.

Returns:

a JSON-safe dict.

to_polygon() → PolygonGate[source]

The prism seen down its own axis – see CylinderGate.to_ellipse().

translated(dx: float, dy: float) → PrismGate[source]

A copy moved by (dx, dy).

Parameters:
  • dx – shift along the x axis.

  • dy – shift along the y axis.

Returns:

the moved copy.

with_threshold(column: str, low: float | None, high: float | None) → Gate[source]

Bound the NORMAL. The oval/polygon is not a range and is not one.

This is how the user bounds the prism’s height, which is what point 4 of the design asks for.

Parameters:
  • column – must be axis_column, the prism’s normal; any other column raises GateError.

  • low – lower bound, or None to leave that side open. The two bounds are swapped if given in reverse.

  • high – upper bound, or None to leave that side open.

property columns: Tuple[str, ...][source]

its base axes and its height axis.

Returns:

the column names, in axis order.

Type:

The three columns this gate reads

property kind: str[source]

The tag a saved prism gate carries.

Returns:

the shape tag.

class spacr.qt.widgets.gate_spec.RectGate[source]

Bases: Gate

A rectangle on a two-parameter scatter — the quadrant gate.

Kept as its own shape rather than as a four-vertex polygon so it can hand back real RangeFilter clauses, which a rectangle genuinely is and a polygon genuinely is not.

Parameters:
  • name – unique name by which the hierarchy and filter identify this gate.

  • parent – name of the gate containing this one, or None for a root gate.

  • x_column – measurement shown on the horizontal axis.

  • y_column – distinct measurement shown on the vertical axis.

  • x_low – inclusive horizontal lower bound, or None when open.

  • x_high – inclusive horizontal upper bound, or None when open.

  • y_low – inclusive vertical lower bound, or None when open.

  • y_high – inclusive vertical upper bound, or None when open.

__post_init__() → None[source]

Normalise the two columns and the four bounds.

Each low/high pair is swapped into order, so dragging a corner past its opposite still yields a rectangle.

Raises:

GateError – if either column is blank, if both name the same measurement (every point would lie on the diagonal), or if all four bounds are None.

bounds_in(view: View) → Tuple[float, float, float, float][source]

The corners as drawn: unbounded sides fall back to the view edge.

A rectangle open on one side really does extend forever, so it is drawn to the edge of the axes. That edge is where its handle goes, and pulling it there gives the gate a bound it did not have – which is the only way to close an open side without redrawing the gate.

Parameters:

view – visible (x_low, x_high, y_low, y_high) axis limits.

Returns:

finite rectangle corners in the same four-value order.

centre() → Tuple[float | None, float | None][source]

The rectangle’s middle.

Returns:

(x, y).

describe() → str[source]

The rectangle’s extent on each axis.

Returns:

a one-line description.

handles(view: View) → Tuple[Handle, ...][source]

Return four corner handles and four side midpoints.

Parameters:

view – visible axis limits used for every unbounded side.

Returns:

eight handles whose roles identify the sides they move.

mask(frame: pandas.DataFrame) → numpy.ndarray[source]

Select finite rows inside every bounded side of the rectangle.

Parameters:

frame – measurement table containing both axis columns.

Returns:

boolean mask aligned row-for-row with frame.

range_filters() → Tuple[spacr.selection.RangeFilter, ...][source]

The rectangle as two independent range filters, one per axis.

A rectangle is exactly the shape that survives being pushed into a query, which is why it has this and the curved shapes do not.

Returns:

one filter per axis.

scaled(factor: float, *, about: Tuple[float, float] | None = None) → RectGate[source]

Return this rectangle resized about a fixed data-space point.

Parameters:
  • factor – positive scale factor; values above one grow the gate.

  • about – point held fixed, or None to use the rectangle’s finite centre independently on each axis. Without an explicit point, an axis lacking two finite bounds stays unchanged.

Raises:

GateError – when factor is not positive.

thresholds() → Dict[str, Tuple[float | None, float | None]][source]

Both sides, whether or not they are currently set.

to_dict() → Dict[str, Any][source]

This gate as plain data, including every coordinate needed to rebuild the shape.

Returns:

a JSON-safe dict.

translated(dx: float, dy: float) → RectGate[source]

Return a copy shifted along both measurement axes.

Parameters:
  • dx – displacement in horizontal-axis data units.

  • dy – displacement in vertical-axis data units.

with_handle(role: str, x: float, y: float) → RectGate[source]

Return the rectangle after dragging one side or corner handle.

Parameters:
  • role – one of the four side names emitted by handles(), or one horizontal and one vertical side joined by a comma for a corner.

  • x – new horizontal coordinate used by any horizontal role.

  • y – new vertical coordinate used by any vertical role.

Raises:

GateError – when role names no rectangle handle.

with_threshold(column: str, low: float | None, high: float | None) → RectGate[source]

Return this gate with replacement bounds on one axis.

Parameters:
  • column – horizontal or vertical measurement column to change.

  • low – inclusive lower bound, or None for an open lower side.

  • high – inclusive upper bound, or None for an open upper side.

Raises:

GateError – when column is not one of this rectangle’s axes.

property columns: Tuple[str, ...][source]

its two axes.

Returns:

the column names, in axis order.

Type:

The two columns this gate reads

property kind: str[source]

The tag a saved rect gate carries.

Returns:

the shape tag.

class spacr.qt.widgets.gate_spec.ThresholdGate[source]

Bases: Gate

A cut on one column — the line dragged across a histogram.

None on a bound means unbounded on that side, which is what a threshold dragged to the edge should mean rather than “exclude everything”. At least one bound is required: a gate with neither is the whole population, and naming that is a way to lose track of it.

Parameters:
  • name – unique name by which the hierarchy and filter identify this gate.

  • parent – name of the gate containing this one, or None for a root gate.

  • column – measurement column on which to apply the threshold.

  • low – inclusive lower bound, or None when the gate is unbounded below.

  • high – inclusive upper bound, or None when the gate is unbounded above.

__post_init__() → None[source]

Normalise the column and bounds, and reject a cut that cuts nothing.

low and high are swapped into order if they arrive the wrong way round, which is what happens when a line is dragged past its partner.

Raises:

GateError – if column is blank, or if both bounds are None – an unbounded threshold selects every row, so it is a mis-drag rather than a gate.

centre() → Tuple[float | None, float | None][source]

The midpoint of the cut, or None when it is open-ended.

REPORTED, NOT INVENTED. An open-ended cut has no middle, and a made-up one would send the first resize somewhere arbitrary.

Returns:

(x, y), either of which may be None.

describe() → str[source]

The cut with its bound(s), spelled for whichever side is open.

An unbounded side is written as a one-sided inequality rather than as a made-up limit, because that is what dragging a threshold to the edge means.

Returns:

a one-line description.

handles(view: View) → Tuple[Handle, ...][source]

One anchor per bound, at the middle of the view’s height.

An unbounded side gets no handle. A threshold with no upper bound is open to infinity, and an anchor at the edge of the view would look like a bound the gate does not have – the user would drag it and discover they had just invented one.

Parameters:

view – visible (x_low, x_high, y_low, y_high) limits used to place each bound handle vertically.

Returns:

one handle for every finite bound.

mask(frame: pandas.DataFrame) → numpy.ndarray[source]

Select finite values that lie within the inclusive bounds.

Parameters:

frame – measurement table containing this gate’s column.

Returns:

boolean mask aligned row-for-row with frame.

range_filters() → Tuple[spacr.selection.RangeFilter, ...][source]

The cut, as the one range filter a query can push down.

Returns:

a single filter on this gate’s column.

scaled(factor: float, *, about: Tuple[float, float] | None = None) → ThresholdGate[source]

Return this threshold resized about a horizontal anchor.

Parameters:
  • factor – positive scale factor; values above one move every finite bound away from the anchor.

  • about – point whose first coordinate stays fixed, or None to use the threshold’s finite centre. A half-open threshold has no finite centre and is returned unchanged when this is None.

Raises:

GateError – when factor is not positive.

to_dict() → Dict[str, Any][source]

This gate as plain data, including every coordinate needed to rebuild the shape.

Returns:

a JSON-safe dict.

translated(dx: float, dy: float) → ThresholdGate[source]

dy is ignored: a threshold is a cut on ONE column, so it has no second axis to move along.

Parameters:
  • dx – displacement to add to each finite threshold bound.

  • dy – vertical displacement, ignored by this one-column gate.

with_handle(role: str, x: float, y: float) → ThresholdGate[source]

Return the threshold after dragging one of its bound handles.

Parameters:
  • role – "low" or "high" for the bound being moved.

  • x – new bound value in the threshold column’s data units.

  • y – vertical handle coordinate, ignored by this one-column gate.

Raises:

GateError – when role names no threshold handle.

with_threshold(column: str, low: float | None, high: float | None) → ThresholdGate[source]

Return this gate with replacement bounds on its column.

Parameters:
  • column – measurement column whose bounds are being changed; a different column is rejected because this gate cannot represent it.

  • low – inclusive lower bound, or None for an open lower end.

  • high – inclusive upper bound, or None for an open upper end.

property columns: Tuple[str, ...][source]

the column it cuts.

Returns:

the column names, in axis order.

Type:

The one column this gate reads

property kind: str[source]

The tag a saved threshold gate carries.

Returns:

the shape tag.

class spacr.qt.widgets.gate_spec.ViewGate[source]

Bases: Gate

A shape drawn on the 3D view, extended through it along the line of sight.

The projected lasso. The user turns the volume to whatever angle separates a population, then draws around it on the screen; the gate is every object whose PROJECTION on that view falls inside the outline – the outline swept straight back through the volume, whatever the angle. Unlike PrismGate nothing about it is tied to one of the three axis planes.

THE VIEW IS PART OF THE GATE. An outline on the screen means nothing without the camera it was drawn through, so the gate carries the camera as projection: a 4 x 4 matrix taking a measurement (x, y, z, 1) to homogeneous view coordinates (sx, sy, depth, sw), scaled so that sw is positive in front of the camera. The outline lives in (sx / sw, sy / sw). Rows one, two and four are the whole of what mask() needs, so re-applying a saved gate needs no plotting library and gives the same answer on any machine; the depth row only lets the outline be drawn back into the volume from another angle.

view and limits record the camera angles (elev, azim, roll) and the axis limits the outline was drawn at, so the view can be described and shown again. They are not read by mask().

Parameters:

name – the gate’s unique name within its saved gate set.

__post_init__() → None[source]

Normalise the columns, the camera and the outline.

Raises:

GateError – blank or repeated columns, a projection that is not four finite rows of four, or an outline of fewer than three vertices.

centre() → Tuple[float | None, float | None][source]

The outline’s middle, in view coordinates.

Returns:

(sx, sy).

describe() → str[source]

The outline, its three measurements and the angle it was drawn at.

Returns:

a one-line description.

mask(frame: pandas.DataFrame) → numpy.ndarray[source]

Which rows project inside the outline.

Parameters:

frame – the measurements to test.

Returns:

a boolean array, one entry per row.

project(x, y, z) → Tuple[numpy.ndarray, numpy.ndarray][source]

Where measurements land on the view the gate was drawn on.

Parameters:
  • x – each object’s measurement along the first data axis.

  • y – each object’s measurement along the second data axis.

  • z – each object’s measurement along the third data axis.

Returns:

(sx, sy); NaN wherever a measurement is missing or the point is at or behind the camera.

scaled(factor: float, *, about: Tuple[float, float] | None = None) → ViewGate[source]

A copy with the outline grown or shrunk on its own view.

Parameters:
  • factor – multiplier; must be positive.

  • about – the anchor, defaulting to the outline’s centre.

Returns:

the resized copy.

to_dict() → Dict[str, Any][source]

This gate as plain data, camera included.

Returns:

a JSON-safe dict.

translated(dx: float, dy: float) → ViewGate[source]

A copy with the outline moved on its own view.

Parameters:
  • dx – shift along the view’s horizontal, in view coordinates.

  • dy – shift along its vertical.

Returns:

the moved copy.

property columns: Tuple[str, ...][source]

The three measurements the gate reads.

Returns:

(x, y, z).

property kind: str[source]

The tag a saved view gate carries.

Returns:

the shape tag.

spacr.qt.widgets.gate_spec.best_cluster_candidate(candidates: Sequence[ClusterCandidate], *, max_noise: float = 0.5) → ClusterCandidate | None[source]

Pick the candidate to recommend, or None when none is defensible.

Candidates must contain at least two clusters and discard no more than max_noise of the objects. Among those candidates, the highest silhouette wins. The noise ceiling prevents a small set of tight points from scoring well while most observations are labelled as debris.

Ties break toward the LARGER radius, which merges rather than splits – the conservative direction when two settings score alike.

spacr.qt.widgets.gate_spec.cluster_gates(frame: pandas.DataFrame, x_column: str, y_column: str, *, eps: float = 0.5, min_samples: int = 10, scale: bool = True, max_clusters: int = 20, name_prefix: str = 'cluster', method: str = 'dbscan', parent: str | None = None) → List[PolygonGate][source]

Find dense populations and return one gate per cluster.

Parameters:
  • frame – the measurement table.

  • x_column – the scatter’s x measurement.

  • y_column – the scatter’s y measurement.

  • eps – DBSCAN neighbourhood radius. In SCALED units when scale is true, which is what makes one default work across measurements whose ranges differ by orders of magnitude.

  • min_samples – points needed to seed a cluster.

  • scale – standardise both axes before clustering. On by default because cell_area runs to thousands and eccentricity to one, and unscaled DBSCAN on that pair clusters on area alone.

  • max_clusters – refuse beyond this many. Two hundred gates is not a result, it is a wrongly-tuned eps, and drawing them all makes the editor unusable while the user works out why.

  • name_prefix – gate names are <prefix> 1, <prefix> 2, …

  • method – "dbscan" or "hdbscan". Gate Settings offered this choice while this function always ran DBSCAN, so picking another algorithm silently returned DBSCAN’s answer.

  • parent – parent gate name, so clusters can be found inside a gate.

Returns:

one PolygonGate per cluster, largest first. Empty when only noise is found.

Raises:

ClusterError – a missing column, no usable rows, bad parameters, an unknown method, or more clusters than max_clusters.

spacr.qt.widgets.gate_spec.cluster_walk_candidates(frame: pandas.DataFrame, x_column: str, y_column: str, *, eps: float = 0.5, min_samples: int = 10, scale: bool = True, steps: int = 12, span: float = 3.0, method: str = 'dbscan') → List[ClusterCandidate][source]

Try a range of eps around the chosen one and score each result.

This is what “Walk” means elsewhere in spaCR – try the space and show the candidates – applied to the one parameter that decides how many populations DBSCAN finds. min_samples is deliberately held fixed: sweeping both turns one readable list into a grid, and eps is the parameter users actually get wrong.

The sweep is GEOMETRIC, from eps / span to eps * span, because eps is a distance and its useful range spans orders of magnitude – an arithmetic sweep from 0.1 to 3.0 spends most of its steps in a region where every one gives the same single blob.

Parameters:
  • frame – the measurement table to cluster; rows lacking either requested numeric measurement are omitted before fitting.

  • x_column – the first measurement column. It must differ from y_column and vary among the usable rows.

  • y_column – the second, independently varying measurement column.

  • eps – the centre of the sweep, normally the user’s current value.

  • steps – how many radii to try, at least 2.

  • span – multiplicative half-width, so 3.0 tries a ninefold range.

Returns:

one ClusterCandidate per radius, ordered by eps ascending. Radii that produce no cluster are INCLUDED, with clusters=0, because “nothing below here works” is the most useful part of the answer.

Raises:

ClusterError – anything _cluster_matrix() refuses, plus a steps or span that cannot describe a sweep.

spacr.qt.widgets.gate_spec.gate_from_dict(payload: Mapping[str, Any]) → Gate[source]

Rebuild one gate from Gate.to_dict().

Parameters:

payload – mapping as written by Gate.to_dict(); its kind selects the gate class and every other key must be a field of that class.

Raises:

GateError – on an unknown or missing kind, naming what was found — a gate file written by a newer build must fail with a sentence rather than a KeyError.

spacr.qt.widgets.gate_spec.points_in_polygon(x: numpy.ndarray, y: numpy.ndarray, vertices: Sequence[Tuple[float, float]]) → numpy.ndarray[source]

Even–odd ray casting, vectorised over every point at once.

Parameters:
  • x – x coordinates of the points to test.

  • y – y coordinates aligned with x; a point with either coordinate non-finite is outside.

  • vertices – the polygon, closed implicitly — the last vertex joins the first, so a caller does not have to repeat it (and repeating it is harmless).

Returns:

a boolean array. A point with a non-finite coordinate is outside, always.

spacr.qt.widgets.gate_spec.wand_gate(frame: pandas.DataFrame, x_column: str, y_column: str, x: float, y: float, *, name: str = '(unnamed)', tolerance: float = 0.05, max_radius: float = 0.35, scale: bool = True, parent: str | None = None) → PolygonGate[source]

Grow a selection from a click and fit a polygon around it.

A POLYGON, not the selection itself, because a gate has to be a shape: re-applied to another table it must select that table’s objects, and a list of row numbers cannot. Fitting the hull is what turns “these objects” into “this region”, which is the difference between a lasso and a gate.

Returns:

the fitted gate, unnamed unless name is given.

Raises:

WandError – too few objects to make a polygon out of.

spacr.qt.widgets.gate_spec.wand_select(frame: pandas.DataFrame, x_column: str, y_column: str, x: float, y: float, *, tolerance: float = 0.05, max_radius: float = 0.35, scale: bool = True) → numpy.ndarray[source]

Grow a selection outward from a clicked point.

The watershed gesture: click inside a population and the gate finds its edge. Starting from the object nearest the click, the selection repeatedly takes every unselected object within tolerance of something already selected, and stops when nothing new is close enough. Two limits keep it from swallowing the plot:

tolerance

how far apart two objects can be and still count as neighbours. This is what makes it a WATERSHED rather than a circle – the selection flows along a dense ridge and stops at a gap, so an elongated or bent population comes out whole and the sparse space around it does not.

max_radius

how far from the CLICK the selection may reach at all. Without it a single chain of objects bridging two populations merges them, which on a real scatter happens more often than not.

Both are in SCALED units by default – each axis mapped onto 0..1 across the data – so one pair of defaults works on measurements whose ranges differ by orders of magnitude, and so “distance” means the same in x as in y. Without that, a tolerance is a distance in whichever measurement has the larger numbers and the other axis is effectively ignored.

Parameters:
  • frame – the measurement table.

  • x_column – column supplying the horizontal measurement; non-numeric entries are excluded from the candidate objects.

  • y_column – column supplying the vertical measurement, paired row by row with x_column.

  • x – the clicked x, in DATA units.

  • y – the clicked y, in data units.

Returns:

a boolean mask over frame.

Raises:

WandError – a column that is missing, or a click with no finite object anywhere near it.

Nested helpers

BoxGate.describe.side(column, low, high)

One axis’s bound as words, handling either side being open.

spacr/qt/widgets/gate_spec.py:1524

GateSet.order.walk(parent: str | None) → None

Append this gate and everything under it, depth first.

spacr/qt/widgets/gate_spec.py:3403

RectGate.centre.middle(low, high)

The midpoint of a bound, or None when either side is open.

An open side has no midpoint, and inventing one would put a gate’s centre somewhere the gate does not reach.

spacr/qt/widgets/gate_spec.py:861

RectGate.describe.side(column, low, high)

One axis’s bound as words, handling either side being open.

spacr/qt/widgets/gate_spec.py:801

_convex_hull._half(sequence)

One monotone half of the hull, by cross product.

Points that turn the wrong way are popped, which is what leaves only the boundary – run twice, upper and lower, it gives the whole hull.

spacr/qt/widgets/gate_spec.py:1392