Skip to content

Commit c3f32a9

Browse files
authored
Enhance dataset templates with coordinate specifications (#822)
* Introduce `declare_coordinate_specs` method in various seismic dataset templates to define non-dimension coordinates. * Implement `CoordinateSpec` class for better structure and validation of coordinate specifications. * Update existing templates to utilize the new coordinate declaration method, ensuring consistency across 2D and 3D seismic templates. * Add temporary drift guard to validate synchronization between declared and built coordinates, to be removed in future releases. * Introduce unit tests for schema resolution and coordinate specification validation to ensure robustness.
1 parent 72ad649 commit c3f32a9

16 files changed

Lines changed: 652 additions & 1 deletion

src/mdio/builder/templates/base.py

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
from mdio.builder.schemas.v1.units import AllUnitModel
1919
from mdio.builder.schemas.v1.variable import CoordinateMetadata
2020
from mdio.builder.schemas.v1.variable import VariableMetadata
21+
from mdio.builder.templates.types import CoordinateSpec
2122

2223
if TYPE_CHECKING:
2324
from mdio.builder.schemas.v1.dataset import Dataset
@@ -43,6 +44,13 @@ def __init__(self, data_domain: SeismicDataDomain) -> None:
4344
self._physical_coord_names: tuple[str, ...] = ()
4445
self._logical_coord_names: tuple[str, ...] = ()
4546
self._var_chunk_shape: tuple[int, ...] = ()
47+
self.synthesize_missing_dims: tuple[str, ...] = ()
48+
49+
# TEMPORARY (removed with declare_coordinate_specs): set when grid overrides mutate this
50+
# template in-place (dims collapsed into 'trace', extra coordinates added). Once mutated,
51+
# the runtime layout intentionally diverges from the static declare_coordinate_specs()
52+
# contract, so the drift guard in build_dataset() must not run.
53+
self._grid_overrides_applied: bool = False
4654

4755
self._builder: MDIODatasetBuilder | None = None
4856
self._dim_sizes: tuple[int, ...] = ()
@@ -67,6 +75,45 @@ def _repr_html_(self) -> str:
6775
"""Return an HTML representation of the template for Jupyter notebooks."""
6876
return template_repr_html(self)
6977

78+
def declare_coordinate_specs(self) -> tuple[CoordinateSpec, ...]:
79+
"""Declare the non-dimension coordinate specs (name, dims, dtype) for this template.
80+
81+
The ingestion ``SchemaResolver`` uses these specs to determine which trace-header
82+
fields to read and how to rewrite coordinate dimensions under grid overrides.
83+
84+
.. note::
85+
TEMPORARY (to be removed before the next minor release): these specs currently
86+
duplicate the non-dimension coordinates created in :meth:`_add_coordinates`.
87+
:meth:`build_dataset` validates that the two stay in sync (see
88+
:meth:`_validate_declared_coordinate_specs`). Once the ingestion pipeline builds
89+
datasets directly from the resolved schema, ``_add_coordinates`` will be derived
90+
from these specs and the duplication will disappear.
91+
92+
The default implementation assumes every non-dimension coordinate spans **all**
93+
spatial dimensions. Subclasses whose coordinates span only a subset (or use a
94+
non-default dtype) must override this method, otherwise ``build_dataset`` raises.
95+
96+
Returns:
97+
The declared non-dimension coordinate specs.
98+
"""
99+
specs = [
100+
CoordinateSpec(
101+
name=coord_name,
102+
dimensions=self.spatial_dimension_names,
103+
dtype=ScalarType.FLOAT64,
104+
)
105+
for coord_name in self.physical_coordinate_names
106+
]
107+
specs.extend(
108+
CoordinateSpec(
109+
name=coord_name,
110+
dimensions=self.spatial_dimension_names,
111+
dtype=ScalarType.UINT8 if coord_name == "gun" else ScalarType.INT32,
112+
)
113+
for coord_name in self.logical_coordinate_names
114+
)
115+
return tuple(specs)
116+
70117
def build_dataset(
71118
self,
72119
name: str,
@@ -107,6 +154,10 @@ def build_dataset(
107154
except ValueError as exc: # coordinate may already exist
108155
if "same name twice" not in str(exc):
109156
raise
157+
# Skip the static drift guard when grid overrides have transformed the template: the
158+
# runtime layout no longer matches the declared (override-free) specs by design.
159+
if not self._grid_overrides_applied:
160+
self._validate_declared_coordinate_specs()
110161
self._add_variables()
111162
self._add_trace_mask()
112163

@@ -123,6 +174,80 @@ def add_units(self, units: dict[str, AllUnitModel]) -> None:
123174
raise ValueError(msg)
124175
self._units |= units
125176

177+
def apply_resolved_dimensions(
178+
self,
179+
dim_names: tuple[str, ...],
180+
chunk_shape: tuple[int, ...],
181+
) -> None:
182+
"""Update the template's dimension layout from a resolved schema.
183+
184+
Supported entry point for the ingestion pipeline to push back dimension names
185+
and chunk shape after the SchemaResolver has applied grid overrides
186+
(e.g. NonBinned, HasDuplicates), instead of mutating private attributes.
187+
188+
Args:
189+
dim_names: Final ordered dimension names.
190+
chunk_shape: Chunk shape matching ``dim_names`` length.
191+
192+
Raises:
193+
ValueError: If ``len(chunk_shape) != len(dim_names)``.
194+
"""
195+
if len(chunk_shape) != len(dim_names):
196+
msg = f"chunk_shape length {len(chunk_shape)} does not match dim_names length {len(dim_names)}"
197+
raise ValueError(msg)
198+
self._dim_names = tuple(dim_names)
199+
self._var_chunk_shape = tuple(chunk_shape)
200+
201+
def _validate_declared_coordinate_specs(self) -> None:
202+
"""Fail the build if :meth:`declare_coordinate_specs` drifted from the built coordinates.
203+
204+
TEMPORARY (to be removed before the next minor release): while
205+
:meth:`declare_coordinate_specs` duplicates the non-dimension coordinates created in
206+
:meth:`_add_coordinates`, this guard ensures the two never diverge in name, dimensions,
207+
or dtype. The ingestion ``SchemaResolver`` trusts the declared specs, so silent drift
208+
would corrupt resolved schemas. The check runs for every template (built-in and
209+
user-defined) on every ``build_dataset`` call that does not apply grid overrides. Grid
210+
overrides mutate the template in-place (collapsing dims into ``trace`` and adding
211+
coordinates), so the runtime layout intentionally diverges from the declared specs and
212+
the guard is skipped for those builds. It is removed once ``_add_coordinates`` is derived
213+
from the resolved schema and the duplication no longer exists.
214+
215+
Raises:
216+
ValueError: If the declared specs do not match the built non-dimension coordinates.
217+
"""
218+
dim_names = set(self._dim_names)
219+
built = {coord.name: coord for coord in self._builder._coordinates if coord.name not in dim_names}
220+
declared = {spec.name: spec for spec in self.declare_coordinate_specs()}
221+
222+
if set(declared) != set(built):
223+
built_only = sorted(set(built) - set(declared))
224+
declared_only = sorted(set(declared) - set(built))
225+
msg = (
226+
f"declare_coordinate_specs() for template {self.name!r} is out of sync with the "
227+
f"coordinates built by _add_coordinates(). Built but not declared: {built_only}. "
228+
f"Declared but not built: {declared_only}. Override declare_coordinate_specs() so "
229+
f"it matches the non-dimension coordinates this template creates."
230+
)
231+
raise ValueError(msg)
232+
233+
for coord_name, spec in declared.items():
234+
coord = built[coord_name]
235+
built_dims = tuple(dim.name for dim in coord.dimensions)
236+
if built_dims != spec.dimensions:
237+
msg = (
238+
f"declare_coordinate_specs() for template {self.name!r} declares coordinate "
239+
f"{coord_name!r} over dimensions {spec.dimensions}, but _add_coordinates() built "
240+
f"it over {built_dims}."
241+
)
242+
raise ValueError(msg)
243+
if coord.data_type != spec.dtype:
244+
msg = (
245+
f"declare_coordinate_specs() for template {self.name!r} declares coordinate "
246+
f"{coord_name!r} as {spec.dtype}, but _add_coordinates() built it as "
247+
f"{coord.data_type}."
248+
)
249+
raise ValueError(msg)
250+
126251
@property
127252
def name(self) -> str:
128253
"""Returns the name of the template."""

src/mdio/builder/templates/seismic_2d_cdp.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from mdio.builder.schemas.v1.variable import CoordinateMetadata
99
from mdio.builder.templates.base import AbstractDatasetTemplate
1010
from mdio.builder.templates.types import CdpGatherDomain
11+
from mdio.builder.templates.types import CoordinateSpec
1112
from mdio.builder.templates.types import SeismicDataDomain
1213

1314

@@ -35,6 +36,13 @@ def _name(self) -> str:
3536
def _load_dataset_attributes(self) -> dict[str, Any]:
3637
return {"surveyType": "2D", "gatherType": "cdp"}
3738

39+
def declare_coordinate_specs(self) -> tuple[CoordinateSpec, ...]:
40+
"""Declare CDP-indexed X/Y coordinates for the 2D CDP gathers template."""
41+
return (
42+
CoordinateSpec(name="cdp_x", dimensions=("cdp",), dtype=ScalarType.FLOAT64),
43+
CoordinateSpec(name="cdp_y", dimensions=("cdp",), dtype=ScalarType.FLOAT64),
44+
)
45+
3846
def _add_coordinates(self) -> None:
3947
# Add dimension coordinates
4048
self._builder.add_coordinate(

src/mdio/builder/templates/seismic_2d_streamer_shot.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from mdio.builder.schemas.dtype import ScalarType
77
from mdio.builder.schemas.v1.variable import CoordinateMetadata
88
from mdio.builder.templates.base import AbstractDatasetTemplate
9+
from mdio.builder.templates.types import CoordinateSpec
910
from mdio.builder.templates.types import SeismicDataDomain
1011

1112

@@ -26,6 +27,17 @@ def _name(self) -> str:
2627
def _load_dataset_attributes(self) -> dict[str, Any]:
2728
return {"surveyType": "2D", "gatherType": "common_source"}
2829

30+
def declare_coordinate_specs(self) -> tuple[CoordinateSpec, ...]:
31+
"""Declare shot- and receiver-indexed coordinates for the 2D streamer shot gathers template."""
32+
shot_dim = ("shot_point",)
33+
receiver_dims = ("shot_point", "channel")
34+
return (
35+
CoordinateSpec(name="source_coord_x", dimensions=shot_dim, dtype=ScalarType.FLOAT64),
36+
CoordinateSpec(name="source_coord_y", dimensions=shot_dim, dtype=ScalarType.FLOAT64),
37+
CoordinateSpec(name="group_coord_x", dimensions=receiver_dims, dtype=ScalarType.FLOAT64),
38+
CoordinateSpec(name="group_coord_y", dimensions=receiver_dims, dtype=ScalarType.FLOAT64),
39+
)
40+
2941
def _add_coordinates(self) -> None:
3042
# Add dimension coordinates
3143
for name in self._dim_names:

src/mdio/builder/templates/seismic_3d_cdp.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from mdio.builder.schemas.v1.variable import CoordinateMetadata
99
from mdio.builder.templates.base import AbstractDatasetTemplate
1010
from mdio.builder.templates.types import CdpGatherDomain
11+
from mdio.builder.templates.types import CoordinateSpec
1112
from mdio.builder.templates.types import SeismicDataDomain
1213

1314

@@ -35,6 +36,13 @@ def _name(self) -> str:
3536
def _load_dataset_attributes(self) -> dict[str, Any]:
3637
return {"surveyType": "3D", "gatherType": "cdp"}
3738

39+
def declare_coordinate_specs(self) -> tuple[CoordinateSpec, ...]:
40+
"""Declare inline/crossline-indexed X/Y coordinates for the 3D CDP gathers template."""
41+
return (
42+
CoordinateSpec(name="cdp_x", dimensions=("inline", "crossline"), dtype=ScalarType.FLOAT64),
43+
CoordinateSpec(name="cdp_y", dimensions=("inline", "crossline"), dtype=ScalarType.FLOAT64),
44+
)
45+
3846
def _add_coordinates(self) -> None:
3947
# Add dimension coordinates
4048
self._builder.add_coordinate(

src/mdio/builder/templates/seismic_3d_coca.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from mdio.builder.schemas.dtype import ScalarType
77
from mdio.builder.schemas.v1.variable import CoordinateMetadata
88
from mdio.builder.templates.base import AbstractDatasetTemplate
9+
from mdio.builder.templates.types import CoordinateSpec
910
from mdio.builder.templates.types import SeismicDataDomain
1011

1112

@@ -26,6 +27,13 @@ def _name(self) -> str:
2627
def _load_dataset_attributes(self) -> dict[str, Any]:
2728
return {"surveyType": "3D", "gatherType": "common_offset_common_azimuth"}
2829

30+
def declare_coordinate_specs(self) -> tuple[CoordinateSpec, ...]:
31+
"""Declare inline/crossline-indexed X/Y coordinates for the 3D CoCA gathers template."""
32+
return (
33+
CoordinateSpec(name="cdp_x", dimensions=("inline", "crossline"), dtype=ScalarType.FLOAT64),
34+
CoordinateSpec(name="cdp_y", dimensions=("inline", "crossline"), dtype=ScalarType.FLOAT64),
35+
)
36+
2937
def _add_coordinates(self) -> None:
3038
# Add dimension coordinates
3139
self._builder.add_coordinate(

src/mdio/builder/templates/seismic_3d_obn.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from mdio.builder.schemas.dtype import ScalarType
66
from mdio.builder.schemas.v1.variable import CoordinateMetadata
77
from mdio.builder.templates.base import AbstractDatasetTemplate
8+
from mdio.builder.templates.types import CoordinateSpec
89
from mdio.builder.templates.types import SeismicDataDomain
910

1011

@@ -49,6 +50,19 @@ def _name(self) -> str:
4950
def _load_dataset_attributes(self) -> dict[str, Any]:
5051
return {"surveyType": "3D", "gatherType": "common_receiver"}
5152

53+
def declare_coordinate_specs(self) -> tuple[CoordinateSpec, ...]:
54+
"""Declare receiver- and shot-indexed coordinates for the 3D OBN receiver gathers template."""
55+
receiver_dim = ("receiver",)
56+
shot_dims = ("shot_line", "gun", "shot_index")
57+
return (
58+
CoordinateSpec(name="group_coord_x", dimensions=receiver_dim, dtype=ScalarType.FLOAT64),
59+
CoordinateSpec(name="group_coord_y", dimensions=receiver_dim, dtype=ScalarType.FLOAT64),
60+
CoordinateSpec(name="shot_point", dimensions=shot_dims, dtype=ScalarType.UINT32),
61+
CoordinateSpec(name="orig_field_record_num", dimensions=shot_dims, dtype=ScalarType.UINT32),
62+
CoordinateSpec(name="source_coord_x", dimensions=shot_dims, dtype=ScalarType.FLOAT64),
63+
CoordinateSpec(name="source_coord_y", dimensions=shot_dims, dtype=ScalarType.FLOAT64),
64+
)
65+
5266
def _add_coordinates(self) -> None:
5367
# Add dimension coordinates
5468
# EXCLUDE: `shot_index` since it's 0-N (calculated dimension)

src/mdio/builder/templates/seismic_3d_offset_tiles.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from mdio.builder.schemas.dtype import ScalarType
77
from mdio.builder.schemas.v1.variable import CoordinateMetadata
88
from mdio.builder.templates.base import AbstractDatasetTemplate
9+
from mdio.builder.templates.types import CoordinateSpec
910
from mdio.builder.templates.types import SeismicDataDomain
1011

1112

@@ -33,6 +34,13 @@ def _name(self) -> str:
3334
def _load_dataset_attributes(self) -> dict[str, Any]:
3435
return {"surveyType": "3D", "gatherType": "offset_tiles"}
3536

37+
def declare_coordinate_specs(self) -> tuple[CoordinateSpec, ...]:
38+
"""Declare inline/crossline-indexed X/Y coordinates for the 3D offset tiles template."""
39+
return (
40+
CoordinateSpec(name="cdp_x", dimensions=("inline", "crossline"), dtype=ScalarType.FLOAT64),
41+
CoordinateSpec(name="cdp_y", dimensions=("inline", "crossline"), dtype=ScalarType.FLOAT64),
42+
)
43+
3644
def _add_coordinates(self) -> None:
3745
# Add dimension coordinates
3846
self._builder.add_coordinate(

src/mdio/builder/templates/seismic_3d_receiver_gathers.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from mdio.builder.schemas.dtype import ScalarType
77
from mdio.builder.schemas.v1.variable import CoordinateMetadata
88
from mdio.builder.templates.base import AbstractDatasetTemplate
9+
from mdio.builder.templates.types import CoordinateSpec
910

1011

1112
class Seismic3DReceiverGathersTemplate(AbstractDatasetTemplate):
@@ -32,6 +33,18 @@ def _name(self) -> str:
3233
def _load_dataset_attributes(self) -> dict[str, Any]:
3334
return {"surveyType": "3D", "gatherType": "receiver_gathers"}
3435

36+
def declare_coordinate_specs(self) -> tuple[CoordinateSpec, ...]:
37+
"""Declare receiver- and shot-indexed coordinates for the 3D receiver gathers template."""
38+
receiver_dim = ("receiver",)
39+
shot_dims = ("shot_line", "shot_index")
40+
return (
41+
CoordinateSpec(name="receiver_x", dimensions=receiver_dim, dtype=ScalarType.FLOAT64),
42+
CoordinateSpec(name="receiver_y", dimensions=receiver_dim, dtype=ScalarType.FLOAT64),
43+
CoordinateSpec(name="shot_point", dimensions=shot_dims, dtype=ScalarType.UINT32),
44+
CoordinateSpec(name="source_coord_x", dimensions=shot_dims, dtype=ScalarType.FLOAT64),
45+
CoordinateSpec(name="source_coord_y", dimensions=shot_dims, dtype=ScalarType.FLOAT64),
46+
)
47+
3548
def _add_coordinates(self) -> None:
3649
# Add dimension coordinates
3750
# Note: shot_index is calculated (0-N), so we don't add a coordinate for it

src/mdio/builder/templates/seismic_3d_shot_receiver_line.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from mdio.builder.schemas.dtype import ScalarType
66
from mdio.builder.schemas.v1.variable import CoordinateMetadata
77
from mdio.builder.templates.base import AbstractDatasetTemplate
8+
from mdio.builder.templates.types import CoordinateSpec
89
from mdio.builder.templates.types import SeismicDataDomain
910

1011

@@ -32,6 +33,18 @@ def _name(self) -> str:
3233
def _load_dataset_attributes(self) -> dict[str, Any]:
3334
return {"surveyType": "3D", "gatherType": "common_source"}
3435

36+
def declare_coordinate_specs(self) -> tuple[CoordinateSpec, ...]:
37+
"""Declare shot-line- and receiver-line-indexed coordinates for the 3D shot/receiver-line template."""
38+
source_dims = ("shot_line", "shot_point")
39+
group_dims = ("receiver_line", "receiver")
40+
return (
41+
CoordinateSpec(name="source_coord_x", dimensions=source_dims, dtype=ScalarType.FLOAT64),
42+
CoordinateSpec(name="source_coord_y", dimensions=source_dims, dtype=ScalarType.FLOAT64),
43+
CoordinateSpec(name="group_coord_x", dimensions=group_dims, dtype=ScalarType.FLOAT64),
44+
CoordinateSpec(name="group_coord_y", dimensions=group_dims, dtype=ScalarType.FLOAT64),
45+
CoordinateSpec(name="orig_field_record_num", dimensions=source_dims, dtype=ScalarType.UINT32),
46+
)
47+
3548
def _add_coordinates(self) -> None:
3649
# Add dimension coordinates
3750
self._builder.add_coordinate(

src/mdio/builder/templates/seismic_3d_streamer_field.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from mdio.builder.schemas.dtype import ScalarType
66
from mdio.builder.schemas.v1.variable import CoordinateMetadata
77
from mdio.builder.templates.base import AbstractDatasetTemplate
8+
from mdio.builder.templates.types import CoordinateSpec
89
from mdio.builder.templates.types import SeismicDataDomain
910

1011

@@ -38,6 +39,19 @@ def _name(self) -> str:
3839
def _load_dataset_attributes(self) -> dict[str, Any]:
3940
return {"surveyDimensionality": "3D", "gatherType": "common_source"}
4041

42+
def declare_coordinate_specs(self) -> tuple[CoordinateSpec, ...]:
43+
"""Declare shot- and receiver-indexed coordinates for the 3D streamer field records template."""
44+
shot_dims = ("sail_line", "gun", "shot_index")
45+
receiver_dims = ("sail_line", "gun", "shot_index", "cable", "channel")
46+
return (
47+
CoordinateSpec(name="orig_field_record_num", dimensions=shot_dims, dtype=ScalarType.UINT32),
48+
CoordinateSpec(name="shot_point", dimensions=shot_dims, dtype=ScalarType.UINT32),
49+
CoordinateSpec(name="source_coord_x", dimensions=shot_dims, dtype=ScalarType.FLOAT64),
50+
CoordinateSpec(name="source_coord_y", dimensions=shot_dims, dtype=ScalarType.FLOAT64),
51+
CoordinateSpec(name="group_coord_x", dimensions=receiver_dims, dtype=ScalarType.FLOAT64),
52+
CoordinateSpec(name="group_coord_y", dimensions=receiver_dims, dtype=ScalarType.FLOAT64),
53+
)
54+
4155
def _add_coordinates(self) -> None:
4256
# Add dimension coordinates
4357
# EXCLUDE: `shot_index` since its 0-N

0 commit comments

Comments
 (0)