csiphon 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. csiphon/__init__.py +80 -0
  2. csiphon/core/__init__.py +62 -0
  3. csiphon/core/arrays.py +31 -0
  4. csiphon/core/axes.py +162 -0
  5. csiphon/core/errors.py +35 -0
  6. csiphon/core/layout.py +211 -0
  7. csiphon/core/profile.py +155 -0
  8. csiphon/core/sampling.py +87 -0
  9. csiphon/core/semantics.py +74 -0
  10. csiphon/core/signal.py +158 -0
  11. csiphon/graphics/__init__.py +27 -0
  12. csiphon/graphics/style.py +200 -0
  13. csiphon/graphics/tree.py +717 -0
  14. csiphon/inspection.py +64 -0
  15. csiphon/pipeline/__init__.py +49 -0
  16. csiphon/pipeline/_align_ops.py +137 -0
  17. csiphon/pipeline/_lines.py +11 -0
  18. csiphon/pipeline/merges.py +647 -0
  19. csiphon/pipeline/pipeline.py +591 -0
  20. csiphon/pipeline/runners.py +267 -0
  21. csiphon/pipeline/sequence.py +407 -0
  22. csiphon/pipeline/step.py +222 -0
  23. csiphon/py.typed +0 -0
  24. csiphon/spec.py +833 -0
  25. csiphon/steps/__init__.py +131 -0
  26. csiphon/steps/_support/__init__.py +1 -0
  27. csiphon/steps/_support/broadcasting.py +68 -0
  28. csiphon/steps/_support/windowing.py +217 -0
  29. csiphon/steps/baseline/__init__.py +8 -0
  30. csiphon/steps/baseline/running_mean_subtract.py +110 -0
  31. csiphon/steps/baseline/temporal_mean_subtract.py +59 -0
  32. csiphon/steps/calibration/__init__.py +11 -0
  33. csiphon/steps/calibration/axis_reference.py +198 -0
  34. csiphon/steps/calibration/linear_phase_correction.py +222 -0
  35. csiphon/steps/cleaning/__init__.py +8 -0
  36. csiphon/steps/cleaning/nan_scrub.py +49 -0
  37. csiphon/steps/cleaning/noise_floor_clip.py +54 -0
  38. csiphon/steps/components/__init__.py +10 -0
  39. csiphon/steps/components/magnitude.py +49 -0
  40. csiphon/steps/components/phase.py +49 -0
  41. csiphon/steps/components/power.py +51 -0
  42. csiphon/steps/components/unit_phase.py +53 -0
  43. csiphon/steps/delay/__init__.py +8 -0
  44. csiphon/steps/delay/channel_impulse_response.py +113 -0
  45. csiphon/steps/delay/delay_autocorrelation.py +116 -0
  46. csiphon/steps/filtering/__init__.py +8 -0
  47. csiphon/steps/filtering/butterworth_filter.py +197 -0
  48. csiphon/steps/filtering/savitzky_golay.py +98 -0
  49. csiphon/steps/normalization/__init__.py +13 -0
  50. csiphon/steps/normalization/gain_normalize.py +76 -0
  51. csiphon/steps/normalization/global_max_normalize.py +61 -0
  52. csiphon/steps/normalization/per_frame_max_normalize.py +56 -0
  53. csiphon/steps/pooling/__init__.py +9 -0
  54. csiphon/steps/pooling/dyadic_frequency_bands.py +106 -0
  55. csiphon/steps/pooling/fixed_size_window_sum.py +111 -0
  56. csiphon/steps/pooling/mean_over_axes.py +92 -0
  57. csiphon/steps/reduction/__init__.py +22 -0
  58. csiphon/steps/reduction/delay_taps.py +89 -0
  59. csiphon/steps/reduction/principal_components.py +178 -0
  60. csiphon/steps/reduction/robust_pca.py +221 -0
  61. csiphon/steps/reduction/select_axis.py +92 -0
  62. csiphon/steps/resampling/__init__.py +35 -0
  63. csiphon/steps/resampling/drop_samples.py +161 -0
  64. csiphon/steps/resampling/resample.py +374 -0
  65. csiphon/steps/resampling/subsample.py +103 -0
  66. csiphon/steps/restructuring/__init__.py +8 -0
  67. csiphon/steps/restructuring/fold_axes.py +139 -0
  68. csiphon/steps/restructuring/stack_amp_phase.py +75 -0
  69. csiphon/steps/scaling/__init__.py +8 -0
  70. csiphon/steps/scaling/log_scale.py +58 -0
  71. csiphon/steps/scaling/to_decibels.py +60 -0
  72. csiphon/steps/statistics/__init__.py +8 -0
  73. csiphon/steps/statistics/covariance_spectrum.py +197 -0
  74. csiphon/steps/statistics/local_pca_bias.py +162 -0
  75. csiphon/steps/temporal_features/__init__.py +9 -0
  76. csiphon/steps/temporal_features/time_difference.py +160 -0
  77. csiphon/steps/temporal_features/windowed_slope.py +149 -0
  78. csiphon/steps/temporal_features/windowed_variance.py +149 -0
  79. csiphon/steps/time_frequency/__init__.py +15 -0
  80. csiphon/steps/time_frequency/complex_stft.py +193 -0
  81. csiphon/steps/time_frequency/multitaper.py +213 -0
  82. csiphon/steps/time_frequency/synchrosqueezed_power.py +230 -0
  83. csiphon/steps/time_frequency/windowed_fft_power.py +152 -0
  84. csiphon-0.1.0.dist-info/METADATA +109 -0
  85. csiphon-0.1.0.dist-info/RECORD +88 -0
  86. csiphon-0.1.0.dist-info/WHEEL +5 -0
  87. csiphon-0.1.0.dist-info/licenses/LICENSE +21 -0
  88. csiphon-0.1.0.dist-info/top_level.txt +1 -0
csiphon/__init__.py ADDED
@@ -0,0 +1,80 @@
1
+ """csiphon: an online-first, schema-validated CSI preprocessing pipeline.
2
+
3
+ Build an immutable Pipeline from steps (branch and merge as needed), compile it
4
+ against an AcquisitionProfile into a Siphon, then run it over a whole recording
5
+ (`siphon.pour()`) or chunk by chunk (`siphon.stream()`). A run returns Outlets,
6
+ the named results.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from csiphon.core import (
12
+ AcquisitionProfile,
13
+ Axis,
14
+ AxisName,
15
+ Layout,
16
+ Representation,
17
+ Signal,
18
+ ValueKind,
19
+ create_signal,
20
+ )
21
+ from csiphon.inspection import describe
22
+ from csiphon.pipeline import (
23
+ Alignment,
24
+ Concatenate,
25
+ Exact,
26
+ Fuse,
27
+ Hold,
28
+ Junction,
29
+ Mean,
30
+ MergeStrategy,
31
+ Node,
32
+ Outlets,
33
+ Pipeline,
34
+ PointwiseStep,
35
+ Sequence,
36
+ Siphon,
37
+ Stack,
38
+ Step,
39
+ Stream,
40
+ StreamOperator,
41
+ Sum,
42
+ Time,
43
+ )
44
+ from csiphon.spec import Category, Description, StepSpec, Streaming
45
+
46
+ __all__ = [
47
+ "AcquisitionProfile",
48
+ "Alignment",
49
+ "Axis",
50
+ "AxisName",
51
+ "Category",
52
+ "Concatenate",
53
+ "Description",
54
+ "Exact",
55
+ "Fuse",
56
+ "Hold",
57
+ "Junction",
58
+ "Layout",
59
+ "Mean",
60
+ "MergeStrategy",
61
+ "Node",
62
+ "Outlets",
63
+ "Pipeline",
64
+ "PointwiseStep",
65
+ "Representation",
66
+ "Sequence",
67
+ "Signal",
68
+ "Siphon",
69
+ "Stack",
70
+ "Step",
71
+ "StepSpec",
72
+ "Stream",
73
+ "StreamOperator",
74
+ "Streaming",
75
+ "Sum",
76
+ "Time",
77
+ "ValueKind",
78
+ "create_signal",
79
+ "describe",
80
+ ]
@@ -0,0 +1,62 @@
1
+ """Core data model: axes, layout, signal, acquisition profile, sampling."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from csiphon.core.arrays import (
6
+ ComplexArray,
7
+ RealArray,
8
+ SignalArray,
9
+ as_complex_array,
10
+ as_real_array,
11
+ as_signal_array,
12
+ )
13
+ from csiphon.core.axes import Axis, AxisName, Coordinate
14
+ from csiphon.core.errors import (
15
+ ClogError,
16
+ CompileError,
17
+ DataError,
18
+ LayoutError,
19
+ MissingDependencyError,
20
+ StreamingError,
21
+ )
22
+ from csiphon.core.layout import Layout
23
+ from csiphon.core.profile import AcquisitionProfile
24
+ from csiphon.core.sampling import (
25
+ JitterWarning,
26
+ check_jitter,
27
+ effective_rate_hz,
28
+ estimate_rate_hz,
29
+ jitter_ratio,
30
+ )
31
+ from csiphon.core.semantics import Representation, ValueKind
32
+ from csiphon.core.signal import Signal, create_signal, empty_signal
33
+
34
+ __all__ = [
35
+ "AcquisitionProfile",
36
+ "Axis",
37
+ "AxisName",
38
+ "ClogError",
39
+ "CompileError",
40
+ "ComplexArray",
41
+ "Coordinate",
42
+ "DataError",
43
+ "JitterWarning",
44
+ "Layout",
45
+ "LayoutError",
46
+ "MissingDependencyError",
47
+ "RealArray",
48
+ "Representation",
49
+ "Signal",
50
+ "SignalArray",
51
+ "StreamingError",
52
+ "ValueKind",
53
+ "as_complex_array",
54
+ "as_real_array",
55
+ "as_signal_array",
56
+ "check_jitter",
57
+ "create_signal",
58
+ "effective_rate_hz",
59
+ "empty_signal",
60
+ "estimate_rate_hz",
61
+ "jitter_ratio",
62
+ ]
csiphon/core/arrays.py ADDED
@@ -0,0 +1,31 @@
1
+ """Concrete NumPy array types used at numerical boundaries."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import numpy as np
6
+ import numpy.typing as npt
7
+
8
+ type RealArray = npt.NDArray[np.float64]
9
+ type ComplexArray = npt.NDArray[np.complex128]
10
+ type SignalArray = RealArray | ComplexArray
11
+
12
+
13
+ def as_complex_array(values: npt.ArrayLike) -> ComplexArray:
14
+ """Return values as the canonical complex array type."""
15
+
16
+ return np.asarray(values, dtype=np.complex128)
17
+
18
+
19
+ def as_real_array(values: npt.ArrayLike) -> RealArray:
20
+ """Return values as the canonical real array type."""
21
+
22
+ return np.asarray(values, dtype=np.float64)
23
+
24
+
25
+ def as_signal_array(values: npt.ArrayLike) -> SignalArray:
26
+ """Preserve real or complex values while normalizing the dtype."""
27
+
28
+ if np.iscomplexobj(values):
29
+ return as_complex_array(values)
30
+
31
+ return as_real_array(values)
csiphon/core/axes.py ADDED
@@ -0,0 +1,162 @@
1
+ """Named tensor axes; mostly for semantics-based processing (vs. raw axis numbers)
2
+
3
+ An Axis is a named tensor dimension. Steps address dimensions by name
4
+ (`AxisName.SUBCARRIER`), never by integer position, so unrelated dimensions can
5
+ sit in any order.
6
+
7
+ Size and coordinates come in three cases:
8
+
9
+ - Statically sized.
10
+ `size` and (optionally) `coordinates` are known at compile time from the
11
+ profile and each step's config: subcarriers, delay taps, dyadic bands,
12
+ the frequency bins of a windowed FFT, and so on.
13
+ - Runtime sized.
14
+ `size is None` because the length only shows up once data arrives.
15
+ Not just time: the number of SST frequency bins depends on the recording
16
+ length, so that axis is runtime sized and carries its coordinates on the
17
+ Signal (`coords`) instead of in the layout. Any number of axes can be
18
+ runtime sized.
19
+ - The time axis, named AxisName.TIME.
20
+ Its coordinates ride with the data as `Signal.times`. It is runtime sized
21
+ too, since you don't know a recording's or a stream's length up front. A
22
+ layout has at most one time axis.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ from collections.abc import Hashable, Sequence
28
+ from dataclasses import dataclass
29
+ from enum import StrEnum
30
+
31
+ from csiphon.core.errors import LayoutError
32
+
33
+ # The axis name gives a dimension its semantics.
34
+ # Its coordinates give each position along it a physical meaning.
35
+ # For example:
36
+ # - the exact frequency values along a `FREQUENCY` axis
37
+ # - the subcarrier index each `SUBCARRIER` position corresponds to.
38
+ # Any hashable value works; axes with no meaningful label (eg. PCA components)
39
+ # just use their integer index.
40
+ type Coordinate = Hashable
41
+
42
+
43
+ class AxisName(StrEnum):
44
+ """Semantic dimensions used by CSI preprocessing steps.
45
+
46
+ A curated, closed set (which we might extend)
47
+ The fixed vocabulary keeps describe() and cross-step validation type-safe.
48
+ The spatial names describe a general MIMO / multi-device capture,
49
+ such as (receivers, transmit/receive antennas, spatial streams).
50
+
51
+ When a step produces a genuinely new kind of dimension (say, reducing subcarriers
52
+ to abstract components), pick the closest name here (`COMPONENT`, `FEATURE`, or
53
+ `LATENT` for learned reductions) rather than mislabelling the output. Add a
54
+ member when you need a new physical dimension.
55
+ """
56
+
57
+ # fmt: off
58
+ # time
59
+ TIME = "time"
60
+
61
+ # spatial (MIMO / multi-device)
62
+ RECEIVER = "receiver" # receiver device, for a multi-device capture
63
+ RX_ANTENNA = "rx_antenna" # receive antenna
64
+ TX_ANTENNA = "tx_antenna" # transmit antenna
65
+ SPATIAL_STREAM = "spatial_stream" # spatial stream (Nss)
66
+
67
+ # spectral / delay / doppler
68
+ SUBCARRIER = "subcarrier"
69
+ FREQUENCY = "frequency"
70
+ DELAY = "delay"
71
+ DOPPLER = "doppler"
72
+ BAND = "band"
73
+ WAVELET_BAND = "wavelet_band"
74
+ LAG = "lag"
75
+
76
+ # derived / learned
77
+ COMPONENT = "component" # e.g. a PCA component
78
+ LATENT = "latent"
79
+ FEATURE = "feature" # several axes folded into one
80
+ # fmt: on
81
+
82
+
83
+ @dataclass(frozen=True, slots=True)
84
+ class Axis:
85
+ """One named tensor dimension.
86
+
87
+ `size` is `None` when the length is only known at run time (the time axis, or
88
+ an axis like the SST frequency bins whose count depends on the data).
89
+
90
+ Statically sized axes provide a positive `size` and, optionally, matching
91
+ `coordinates`.
92
+ """
93
+
94
+ # fmt: off
95
+ name : AxisName
96
+ size : int | None
97
+ coordinates : tuple[Coordinate, ...] | None = None
98
+ unit : str | None = None
99
+ # fmt: on
100
+
101
+ def __post_init__(self) -> None:
102
+ """Validate the size / coordinates relationship."""
103
+
104
+ if self.size is None:
105
+ if self.coordinates is not None:
106
+ raise LayoutError(
107
+ f"Dynamic axis '{self.name}' must not carry coordinates."
108
+ )
109
+ return
110
+
111
+ if self.size < 1:
112
+ raise LayoutError(f"Axis '{self.name}' must have a positive size.")
113
+
114
+ if self.coordinates is not None and len(self.coordinates) != self.size:
115
+ raise LayoutError(
116
+ f"Axis '{self.name}' has {len(self.coordinates)} coordinates "
117
+ f"but size {self.size}."
118
+ )
119
+
120
+ # -------------------------------------------------------------------------
121
+ # Properties
122
+ # -------------------------------------------------------------------------
123
+ @property
124
+ def is_dynamic(self) -> bool:
125
+ """Return whether the size is only known at runtime (`size is None`)."""
126
+
127
+ return self.size is None
128
+
129
+ # -------------------------------------------------------------------------
130
+ # Constructors
131
+ # -------------------------------------------------------------------------
132
+ @classmethod
133
+ def dynamic(cls, name: AxisName = AxisName.TIME, unit: str | None = "s") -> Axis:
134
+ """Create the dynamic time / window axis."""
135
+
136
+ return cls(name=name, size=None, coordinates=None, unit=unit)
137
+
138
+ @classmethod
139
+ def static(
140
+ cls,
141
+ name: AxisName,
142
+ coordinates: Sequence[Coordinate],
143
+ unit: str | None = None,
144
+ ) -> Axis:
145
+ """Create a static axis from explicit coordinates."""
146
+
147
+ values = tuple(coordinates)
148
+ return cls(name=name, size=len(values), coordinates=values, unit=unit)
149
+
150
+ @classmethod
151
+ def sized(cls, name: AxisName, size: int, unit: str | None = None) -> Axis:
152
+ """Create a static axis of a known size without explicit coordinates."""
153
+
154
+ return cls(name=name, size=size, coordinates=None, unit=unit)
155
+
156
+ # -------------------------------------------------------------------------
157
+ # Transformations
158
+ # -------------------------------------------------------------------------
159
+ def relabel(self, coordinates: Sequence[Coordinate]) -> Axis:
160
+ """Return this axis with new coordinates (and matching size)."""
161
+
162
+ return Axis.static(self.name, coordinates, unit=self.unit)
csiphon/core/errors.py ADDED
@@ -0,0 +1,35 @@
1
+ """Exceptions raised by layouts, steps, and pipeline compilation."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ class LayoutError(ValueError):
7
+ """A signal layout does not satisfy a step's structural requirements."""
8
+
9
+
10
+ class DataError(ValueError):
11
+ """Numerical values violate an assumption checked during execution."""
12
+
13
+
14
+ class CompileError(ValueError):
15
+ """A pipeline step is incompatible with the layout before it."""
16
+
17
+
18
+ class StreamingError(ValueError):
19
+ """A pipeline (or one of its steps) cannot run in streaming mode."""
20
+
21
+
22
+ class ClogError(StreamingError):
23
+ """A merge branch held too many samples without aligning (a stalled branch)."""
24
+
25
+
26
+ class MissingDependencyError(ImportError):
27
+ """A step needs an optional dependency that is not installed."""
28
+
29
+ def __init__(self, package: str, extra: str) -> None:
30
+ """Explain which extra to install for the missing dependency."""
31
+
32
+ super().__init__(
33
+ f"This step requires the optional dependency '{package}'. "
34
+ f"Install it with: pip install 'csiphon[{extra}]'"
35
+ )
csiphon/core/layout.py ADDED
@@ -0,0 +1,211 @@
1
+ """The structural contract carried through a pipeline.
2
+
3
+ A Layout is the structure of a signal: its ordered named axes and their semantics.
4
+ It does not hold the recording's timestamps or data.
5
+
6
+ A pipeline is "compiled" against an `AcquisitionProfile` (antennas, subcarriers,
7
+ bandwidth, ...), which builds this layout. Because the layout carries no
8
+ per-recording data, one compiled pipeline then works for every recording from
9
+ that profile, and for an unbounded live stream where we don't know the
10
+ timestamps up front.
11
+
12
+ The concrete data and timestamps live on the runtime Signal.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from collections.abc import Iterable, Sequence
18
+ from dataclasses import dataclass, replace
19
+
20
+ from csiphon.core.axes import Axis, AxisName
21
+ from csiphon.core.errors import LayoutError
22
+ from csiphon.core.semantics import Representation, ValueKind
23
+
24
+
25
+ @dataclass(frozen=True, slots=True)
26
+ class Layout:
27
+ """Axes and semantic information known before numerical execution."""
28
+
29
+ # fmt: off
30
+ axes : tuple[Axis, ...]
31
+ representation : Representation
32
+ values : ValueKind
33
+ # fmt: on
34
+
35
+ def __post_init__(self) -> None:
36
+ """Reject duplicate axis names.
37
+
38
+ An axis may have an unknown size (`size is None`) when it only resolves
39
+ at run time, like the SST frequency axis whose bin count depends on the
40
+ recording length.
41
+
42
+ The time axis (AxisName.TIME) is special: its coordinates are the
43
+ per-sample timestamps, and they live on the Signal (Signal.times), not
44
+ in the layout.
45
+ """
46
+
47
+ names = tuple(axis.name for axis in self.axes)
48
+ if len(names) != len(set(names)):
49
+ raise LayoutError("Every axis name must be unique within a signal.")
50
+
51
+ # -------------------------------------------------------------------------
52
+ # Introspection
53
+ # -------------------------------------------------------------------------
54
+
55
+ @property
56
+ def axis_names(self) -> tuple[AxisName, ...]:
57
+ """Return current semantic axes in tensor order."""
58
+
59
+ return tuple(axis.name for axis in self.axes)
60
+
61
+ @property
62
+ def shape(self) -> tuple[int | None, ...]:
63
+ """Return the tensor shape, with `None` for the dynamic axis."""
64
+
65
+ return tuple(axis.size for axis in self.axes)
66
+
67
+ @property
68
+ def dynamic_index(self) -> int | None:
69
+ """Return the position of the time axis, or `None` if there is none.
70
+
71
+ The time axis carries the timestamps on the Signal; every other
72
+ axis is fixed by the layout (even if its size is only known at run time).
73
+ """
74
+
75
+ for position, axis in enumerate(self.axes):
76
+ if axis.name == AxisName.TIME:
77
+ return position
78
+ return None
79
+
80
+ def axis(self, name: AxisName) -> Axis:
81
+ """Return one axis or fail with the available names."""
82
+
83
+ for axis in self.axes:
84
+ if axis.name == name:
85
+ return axis
86
+
87
+ available = ", ".join(self.axis_names) or "<none>"
88
+ raise LayoutError(
89
+ f"Required axis '{name}' is missing. Available axes: {available}."
90
+ )
91
+
92
+ def axis_position(self, name: AxisName) -> int:
93
+ """Return the current tensor position of a named axis."""
94
+
95
+ for position, axis in enumerate(self.axes):
96
+ if axis.name == name:
97
+ return position
98
+
99
+ # The axis is missing: this call raises with a helpful message. The
100
+ # AssertionError after it never runs; it is only here so the type checker
101
+ # sees that this branch cannot fall through without a return.
102
+ self.axis(name)
103
+ raise AssertionError("")
104
+
105
+ def has_axis(self, name: AxisName) -> bool:
106
+ """Return whether an axis with this name exists."""
107
+
108
+ return name in self.axis_names
109
+
110
+ # -------------------------------------------------------------------------
111
+ # Requirements (used by Step.output_layout)
112
+ # -------------------------------------------------------------------------
113
+
114
+ def require_values(self, allowed: Iterable[ValueKind]) -> None:
115
+ """Fail when current value semantics are unsupported."""
116
+
117
+ allowed_values = tuple(allowed)
118
+ if self.values in allowed_values:
119
+ return
120
+
121
+ expected = ", ".join(value.value for value in allowed_values)
122
+ raise LayoutError(f"Expected {expected}; received {self.values.value}.")
123
+
124
+ def require_representation(self, allowed: Iterable[Representation]) -> None:
125
+ """Fail when the current scientific representation is unsupported."""
126
+
127
+ allowed_reprs = tuple(allowed)
128
+ if self.representation in allowed_reprs:
129
+ return
130
+
131
+ expected = ", ".join(value.value for value in allowed_reprs)
132
+ raise LayoutError(
133
+ f"Expected representation {expected}; received {self.representation.value}."
134
+ )
135
+
136
+ def require_axis(self, name: AxisName) -> Axis:
137
+ """Return a required axis (raising a helpful error if absent)."""
138
+
139
+ return self.axis(name)
140
+
141
+ def require_axis_absent(self, name: AxisName) -> None:
142
+ """Fail when a step would create a duplicate axis."""
143
+
144
+ if name in self.axis_names:
145
+ raise LayoutError(f"Axis '{name}' already exists.")
146
+
147
+ def require_static_axis(self, name: AxisName) -> Axis:
148
+ """Return a required axis, failing if it is the dynamic axis."""
149
+
150
+ axis = self.axis(name)
151
+ if axis.is_dynamic:
152
+ raise LayoutError(f"Axis '{name}' must be static for this step.")
153
+ return axis
154
+
155
+ # -------------------------------------------------------------------------
156
+ # Transformations (return new layouts)
157
+ # -------------------------------------------------------------------------
158
+
159
+ def with_values(self, values: ValueKind) -> Layout:
160
+ """Return this layout with different value semantics."""
161
+
162
+ return replace(self, values=values)
163
+
164
+ def with_representation(self, representation: Representation) -> Layout:
165
+ """Return this layout with a different representation."""
166
+
167
+ return replace(self, representation=representation)
168
+
169
+ def replace_axis(self, old_name: AxisName, new_axis: Axis) -> Layout:
170
+ """Replace an axis without changing its tensor position."""
171
+
172
+ if new_axis.name != old_name:
173
+ self.require_axis_absent(new_axis.name)
174
+
175
+ position = self.axis_position(old_name)
176
+ axes = list(self.axes)
177
+ axes[position] = new_axis
178
+ return replace(self, axes=tuple(axes))
179
+
180
+ def insert_axis_after(self, existing: AxisName, new_axis: Axis) -> Layout:
181
+ """Insert a new axis directly after an existing one."""
182
+
183
+ self.require_axis_absent(new_axis.name)
184
+ position = self.axis_position(existing) + 1
185
+ axes = list(self.axes)
186
+ axes.insert(position, new_axis)
187
+ return replace(self, axes=tuple(axes))
188
+
189
+ def remove_axes(self, names: Sequence[AxisName]) -> Layout:
190
+ """Remove named axes while preserving remaining order."""
191
+
192
+ target = set(names)
193
+ for name in target:
194
+ # Raise now if any named axis is missing, before we drop anything.
195
+ self.axis(name)
196
+ remaining = tuple(axis for axis in self.axes if axis.name not in target)
197
+ return replace(self, axes=remaining)
198
+
199
+ def set_axes(self, axes: Sequence[Axis]) -> Layout:
200
+ """Return this layout with an explicit new axis tuple."""
201
+
202
+ return replace(self, axes=tuple(axes))
203
+
204
+ def describe_axes(self) -> str:
205
+ """Return a compact human-readable axis summary."""
206
+
207
+ parts: list[str] = []
208
+ for axis in self.axes:
209
+ size = "?" if axis.size is None else str(axis.size)
210
+ parts.append(f"{axis.name}[{size}]")
211
+ return ", ".join(parts) or "<scalar>"