import dascore as dc
from dascore.utils.patch import check_kind, get_patch_kind
patch = dc.get_example_patch()
assert tuple(get_patch_kind(patch)) == dc.get_config().patch_kind_attrs
processed = patch.pass_filter(time=(None, 10)).set_units("m/s")
assert check_kind(patch, processed)
assert not check_kind(patch, patch.update_attrs(tag="other"), check_behavior="ignore")
# A missing value matches; the result carries the known one.
keyed = patch.update_attrs(acquisition_key="DAS.R2D1..RAW")
assert check_kind(patch, keyed)
assert (patch + keyed).attrs.acquisition_key == "DAS.R2D1..RAW"Patch Compatibility
Several operations combine patches: the arithmetic operators and ufuncs (patch1 + patch2), Spool.concatenate, Spool.stack, and Spool.chunk. They all answer the same question in the same order: are the patches the same kind, and if so, how do their coordinates fit together? The code cells below execute against the real machinery, so this note fails the doc build if it drifts from the implementation.
Kind
The kind of a patch is the set of values it holds for the attributes named by the config option patch_kind_attrs. By default these are acquisition_key, data_category, and tag (plus the legacy network and station, which only archives predating acquisition_key still carry). Two patches are the same kind unless they hold conflicting values for one of these: a value that is missing — the attribute is absent, null, or left at its empty default "" — conflicts with nothing. So a hand-built patch with no acquisition_key combines with one that has it, a reader that stamps network="" agrees with a patch carrying no network at all (which is also how spool metadata records such attributes), and the result of combining carries the union of the known values.
Kind attributes are categorical labels — strings naming an acquisition, a category, a tag — not quantities; the spool index partitions on them as stored text, so a quantity named as a kind attribute is not supported. Kind is decided from attributes alone. Coordinates, data units, history, the patch and processing ids, and every attribute outside the configured names never enter: a filtered patch is the same kind as its raw source, and so is a copy with different units or a different time range. Attributes are scalars, and the missing-value rule is the same everywhere: an attribute nobody recorded (absent, null, or "") is satisfied by whatever another patch knows. data_type is deliberately not a kind attribute: processing rewrites it (standardize clears it, envelope sets it), so it would separate a patch from its own derivatives — vel / vel.envelope("time") must work — while the acquisition key, category, and tag already keep unrelated data apart.
check_kind implements this test for patches, and every combining operation applies it before looking at coordinates; the spool operations apply the same rule to their metadata, so a plan decided without loading data agrees with the patches it later assembles.
Because a missing value matches anything, “same kind” is not transitive — "a" matches "" matches "b", yet "a" conflicts with "b" — and operations that combine more than two patches resolve this in two ways. concatenate_patches and stack run through their patches in order with an accumulated kind: a value any kept patch knows binds the patches after it, so a run never ends up holding two values for one attribute. Spool.chunk and Spool.concatenate must partition a whole spool at once, so they gather patches into the same kind of runs in a canonical order: fully specified patches seed the runs, and each remaining patch joins the one run it conflicts with nothing in (the run taking on the values it knows), starts its own when none fits, or — when several runs would accept it, an unlabelled file between two acquisitions — starts its own rather than guessing. That keeps plans deterministic and independent of row order.
Rule 1: different kinds never combine
Patches of different kinds are never combined, whatever their coordinates. What happens instead depends on what the operation promises:
| Operation | Different kind |
|---|---|
| operators, ufuncs | raises IncompatiblePatchError — the operation must yield one patch and cannot |
concatenate_patches, stack |
skipped, per check_behavior ("warn" by default, "raise", or "ignore") — these operate relative to the first patch |
Spool.chunk, Spool.concatenate |
separate outputs, never an error — a spool operation partitions |
import numpy as np
import pytest
from dascore.exceptions import IncompatiblePatchError
other = patch.update_attrs(tag="other")
with pytest.raises(IncompatiblePatchError, match="not the same kind"):
patch + other
spool = dc.spool([patch, other])
with pytest.warns(UserWarning, match="not the same kind"):
stacked = spool.stack()
assert np.allclose(stacked.data, patch.data) # the other patch was left out
assert len(spool.chunk(time=None)) == 2Patch.where follows the operator rule: its condition and fill patches must be the same kind as the patch it is called on. Since data_type is not kind, a mask built from a processed copy of the same patch is fine.
vel = patch.update_attrs(data_type="velocity")
out = vel.where(vel.standardize("time") > 0, other=vel.full(0))
assert out.attrs.data_type == "velocity"Rule 2: coordinates decide how same-kind patches fit
Only once the kind matches do coordinates matter, and each operation has its own fit:
- Operators, ufuncs, and
wherebroadcast. A dimension only one patch has is appended to the other as length one; a dimension both have is aligned on the intersection of its coordinate values, so the result covers only where both patches have data. Sharing a dimension but none of its values is a conflict and raises aPatchCoordinateError— an empty result is never what was meant, and it would propagate silently. concatenaterequires the same dimensions and equal coordinates except along the concatenated dimension;stackthe same, except alongdim_vary, which must still have the same shape.Spool.concatenatepartitions a spool’s metadata aschunkdoes — kind, dimensions, the identity of every other dimension, and the concatenated dimension’s units (a patch with no values along it joins either) — so patches which differ on those land in separate outputs, then joins each partition’s patches in the order of the dimension (when its step is known; irregular values, labels, and a new dimension keep spool order), contiguous or not; likechunk, it polices the remaining attributes withconflict. Coordinates are not policed by it: one riding the concatenated dimension is joined along it, and any other must agree between the members or the output raises when it loads — a patch never quietly loses a coordinate its catalog row describes. A selection still to be applied when the patches load leaves their coordinate identities undecided, so such patches stay in separate outputs; load them (dc.spool(list(spool))) to concatenate what the selection reconciles. Where the index knows a coordinate only by a summary — no fingerprint of its values — two patches whose summaries agree plan together and are settled when the output loads: equal values concatenate, different ones raise rather than being mixed.chunkpartitions further by structure (the dimensions tuple, the coordinate identity of every non-chunked dimension, the chunked dimension’s units), sampling interval, and continuity; see Spool Chunking.
from dascore.exceptions import PatchCoordinateError
sub = patch.select(time=(..., 100), samples=True)
assert (patch + sub).shape == sub.shape
assert (patch + patch.mean("distance")).shape == patch.shape
time = patch.get_coord("time")
later = patch.update_coords(time_min=time.max() + time.step)
with pytest.raises(PatchCoordinateError, match="share no values"):
patch + laterRule 3: the remaining attributes never gate
Attributes outside the kind — data_units, history, the ids, and any custom attribute — never decide whether patches combine. They are folded instead:
- Operators and ufuncs: data units take part in the arithmetic, so metres times seconds gives metre-seconds and metres plus seconds raises a
UnitError; convertible units are converted to the first patch’s. An operand without units — a patch with nodata_units, an array, or a scalar — conflicts with nothing, just like a missing kind value: it is dimensionless where that works (metres times it is metres, it divided by metres is 1/metres) and takes the other operand’s units where the operation needs equal units (metres plus it is metres; comparisons likewise). Offset units such asdegCcannot be scaled, so a patch in them accepts only sums, differences, extrema, and comparisons: beside a unitless operand, which counts as a difference of so many degrees, it may be added to or taken from the temperature but the temperature not from it (temp - 1is Celsius,1 - tempraises); beside another temperature, a difference is a delta (delta_degC), extrema and comparisons work, and a sum is refused; beside a difference (delta_degC, or anything convertible to it), a sum or a difference is a temperature. Anything else asks you to convert to kelvin first. Every other attribute keeps the first patch’s value, and attributes only the second patch has are added. History and the ids follow the patch identity rules. concatenateandstack: the output carries the first patch’s attributes, with kind values and data units it lacks filled from the others, and its history with the operation’s own entry appended. Splicing or summing data demands one unit, and units are not converted for you: a patch whose known data units differ from the first’s is skipped (or raises) byconcatenate_patchesandstack, and is a conflict forSpool.concatenateandchunk, policed byconflict— convert it first.chunk: attributes must hold no conflicting known values within a partition, policed by itsconflictargument ("raise"by default,"drop", or"keep_first"); a missing value conflicts with nothing and the output carries the known one, exactly as for kind.- History is never compared by any operation — processing differing between members is what combining is for — and the output carries the first patch’s (plus the combining operation’s own entry, where it records one). Operations which splice members side by side along a dimension (
chunk’s merge andconcatenate) warn when the histories differ, since a raw patch merged beside a filtered one is usually a mistake; elementwise operations do not, since a residual is exactly filtered minus raw.
from dascore.exceptions import UnitError
metres, seconds = patch.set_units("m"), patch.set_units("s")
assert dc.get_quantity((metres * seconds).attrs.data_units) == dc.get_quantity("m * s")
with pytest.raises(UnitError):
metres + seconds
unitless = patch.set_units(None)
assert dc.get_quantity((metres * unitless).attrs.data_units) == dc.get_quantity("m")
assert dc.get_quantity((unitless / metres).attrs.data_units) == dc.get_quantity("1/m")
assert dc.get_quantity((unitless + metres).attrs.data_units) == dc.get_quantity("m")
first = patch.update_attrs(foo="a")
assert (first + patch.update_attrs(foo="b")).attrs.foo == "a"
warm, cool = patch.set_units("degC"), patch.set_units("degC") - 5
assert dc.get_quantity((warm - 1).attrs.data_units) == dc.get_quantity("degC")
assert dc.get_quantity((warm - cool).attrs.data_units) == dc.get_quantity("delta_degC")
with pytest.raises(UnitError):
1 - warm
with pytest.raises(UnitError):
warm + coolConfiguring kind
Because kind is a config option, a workflow can widen or narrow it. Adding a custom attribute makes patches conflicting on it separate kinds everywhere at once; dropping tag lets differently tagged patches of one acquisition combine.
from dascore.config import config_context
first, second = patch.update_attrs(foo="a"), patch.update_attrs(foo="b")
assert check_kind(first, second)
with config_context(patch_kind_attrs=("acquisition_key", "foo")):
with pytest.raises(IncompatiblePatchError, match="'foo'"):
first + second
assert len(dc.spool([first, second]).chunk(time=None)) == 2Spool.chunk additionally accepts a per-call group argument naming the attributes to partition on for that call alone; unlike the config names, explicitly passed names must exist on at least one patch in the spool.