Skip to content

Commit daebb0a

Browse files
phase 1: S1 RTC Pydantic models aligned with S2 pattern
- Add src/eopf_geozarr/data_api/s1_rtc.py — Zarr V3 Pydantic models for S1 GRD γ0T RTC GeoZarr stores, using pyz.v3 GroupSpec/ArraySpec with TypedDict members (same pattern as s2.py uses pyz.v2) - Models: S1RtcRoot, S1RtcOrbitGroup, S1RtcNativeResolutionDataset, S1RtcOverviewResolutionDataset, S1RtcConditionsGroup - Validation: convention UUIDs, spatial:dimensions, multiscales layout, required data arrays (vv/vh/border_mask), gamma_area presence - Add tests/_test_data/s1_rtc_examples/s1-grd-rtc-31TCH.json — realistic fixture with 3 timesteps, 6 overview levels, 3 gamma_area conditions - Add tests/test_data_api/test_s1_rtc.py — 11 tests: round-trip, structure validation, negative cases (missing orbit, r10m, UUIDs, etc.) - Add conftest fixture s1_rtc_json_example parametrized over all fixtures
1 parent d4409b1 commit daebb0a

4 files changed

Lines changed: 2390 additions & 0 deletions

File tree

Lines changed: 316 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,316 @@
1+
"""
2+
Pydantic-zarr integrated models for Sentinel-1 GRD γ0T RTC GeoZarr stores.
3+
4+
Uses the pyz.v3 GroupSpec/ArraySpec with TypedDict members to enforce strict
5+
structure validation — same pattern as s2.py (which uses pyz.v2 for Zarr V2).
6+
7+
These models validate time-series Zarr V3 stores built from S1Tiling GeoTIFFs
8+
on the Sentinel-2 MGRS grid. This is a *different data product* from the EOPF
9+
L1 GRD models in s1.py — those describe radar-geometry Zarr V2 products.
10+
11+
Store hierarchy::
12+
13+
s1-grd-rtc-{tile}.zarr/
14+
├── zarr.json
15+
├── ascending/
16+
│ ├── zarr.json # zarr_conventions, multiscales, proj:, spatial:
17+
│ ├── r10m/ # native resolution dataset
18+
│ │ ├── vv/ # (time, Y, X) float32
19+
│ │ ├── vh/ # (time, Y, X) float32
20+
│ │ ├── border_mask/ # (time, Y, X) uint8
21+
│ │ ├── time/ # (time,) int64 datetime
22+
│ │ ├── absolute_orbit/
23+
│ │ ├── relative_orbit/
24+
│ │ └── platform/
25+
│ ├── r20m/ … r720m/ # overview levels (vv, vh, border_mask only)
26+
│ └── conditions/
27+
│ └── gamma_area_{orbit}/ # (Y, X) float32
28+
└── descending/
29+
└── (same structure)
30+
"""
31+
32+
from __future__ import annotations
33+
34+
from typing import Any, Literal, Self
35+
36+
from pydantic import BaseModel, Field, model_validator
37+
from typing_extensions import TypedDict
38+
from zarr_cm import geo_proj, multiscales as multiscales_cm, spatial as spatial_cm
39+
40+
from eopf_geozarr.data_api.geozarr.common import DatasetAttrs
41+
from eopf_geozarr.pyz.v3 import ArraySpec, GroupSpec
42+
43+
# ============================================================================
44+
# Constants
45+
# ============================================================================
46+
47+
MULTISCALES_UUID = multiscales_cm.UUID
48+
GEO_PROJ_UUID = geo_proj.UUID
49+
SPATIAL_UUID = spatial_cm.UUID
50+
51+
REQUIRED_CONVENTION_UUIDS = frozenset({MULTISCALES_UUID, GEO_PROJ_UUID, SPATIAL_UUID})
52+
53+
ResolutionLevel = Literal["r10m", "r20m", "r60m", "r120m", "r360m", "r720m"]
54+
OrbitDirection = Literal["ascending", "descending"]
55+
Polarisation = Literal["vv", "vh"]
56+
57+
# ============================================================================
58+
# Attributes models
59+
# ============================================================================
60+
61+
62+
class S1RtcOrbitGroupAttrs(BaseModel, extra="allow"):
63+
"""Attributes for an orbit-direction group (ascending or descending).
64+
65+
Carries the three GeoZarr conventions plus proj:/spatial:/multiscales metadata.
66+
"""
67+
68+
zarr_conventions: list[dict[str, Any]]
69+
multiscales: dict[str, Any] # validated structurally below
70+
proj_code: str = Field(alias="proj:code")
71+
spatial_dimensions: list[str] = Field(alias="spatial:dimensions")
72+
spatial_bbox: list[float] = Field(alias="spatial:bbox")
73+
74+
model_config = {"populate_by_name": True, "serialize_by_alias": True}
75+
76+
@model_validator(mode="after")
77+
def validate_zarr_conventions(self) -> Self:
78+
"""Ensure all three required convention UUIDs are present."""
79+
present = {c["uuid"] for c in self.zarr_conventions if "uuid" in c}
80+
missing = REQUIRED_CONVENTION_UUIDS - present
81+
if missing:
82+
raise ValueError(f"Missing required zarr_conventions UUIDs: {missing}")
83+
return self
84+
85+
@model_validator(mode="after")
86+
def validate_multiscales_layout(self) -> Self:
87+
"""Ensure multiscales has a layout array with at least one entry."""
88+
layout = self.multiscales.get("layout")
89+
if not layout or not isinstance(layout, (list, tuple)):
90+
raise ValueError("multiscales must contain a non-empty 'layout' array")
91+
for entry in layout:
92+
if "asset" not in entry:
93+
raise ValueError("Each multiscales layout entry must have an 'asset' key")
94+
return self
95+
96+
@model_validator(mode="after")
97+
def validate_spatial_dimensions(self) -> Self:
98+
if self.spatial_dimensions != ["Y", "X"]:
99+
raise ValueError(
100+
f"spatial:dimensions must be ['Y', 'X'], got {self.spatial_dimensions}"
101+
)
102+
return self
103+
104+
@model_validator(mode="after")
105+
def validate_spatial_bbox(self) -> Self:
106+
if len(self.spatial_bbox) != 4:
107+
raise ValueError(f"spatial:bbox must have 4 elements, got {len(self.spatial_bbox)}")
108+
return self
109+
110+
111+
class S1RtcResolutionAttrs(BaseModel, extra="allow"):
112+
"""Attributes for a resolution-level group (r10m, r20m, …)."""
113+
114+
spatial_shape: list[int] = Field(alias="spatial:shape")
115+
spatial_transform: list[float] = Field(alias="spatial:transform")
116+
117+
model_config = {"populate_by_name": True, "serialize_by_alias": True}
118+
119+
@model_validator(mode="after")
120+
def validate_shape(self) -> Self:
121+
if len(self.spatial_shape) != 2:
122+
raise ValueError(f"spatial:shape must have 2 elements, got {len(self.spatial_shape)}")
123+
return self
124+
125+
@model_validator(mode="after")
126+
def validate_transform(self) -> Self:
127+
if len(self.spatial_transform) != 6:
128+
raise ValueError(
129+
f"spatial:transform must have 6 elements, got {len(self.spatial_transform)}"
130+
)
131+
return self
132+
133+
134+
class S1RtcConditionsAttrs(BaseModel, extra="allow"):
135+
"""Attributes for the conditions group."""
136+
137+
proj_code: str = Field(alias="proj:code")
138+
spatial_dimensions: list[str] = Field(alias="spatial:dimensions")
139+
spatial_transform: list[float] = Field(alias="spatial:transform")
140+
141+
model_config = {"populate_by_name": True, "serialize_by_alias": True}
142+
143+
144+
# ============================================================================
145+
# TypedDict members (same pattern as S2 Sentinel2ResolutionMembers)
146+
# ============================================================================
147+
148+
149+
class S1RtcNativeResolutionMembers(TypedDict, closed=True, total=False): # type: ignore[call-arg]
150+
"""Members for the native resolution dataset (r10m).
151+
152+
Data variables (time, Y, X) plus 1-D coordinate variables (time,).
153+
All fields optional since not all arrays are present during incremental construction.
154+
"""
155+
156+
vv: ArraySpec[Any]
157+
vh: ArraySpec[Any]
158+
border_mask: ArraySpec[Any]
159+
time: ArraySpec[Any]
160+
absolute_orbit: ArraySpec[Any]
161+
relative_orbit: ArraySpec[Any]
162+
platform: ArraySpec[Any]
163+
164+
165+
class S1RtcOverviewResolutionMembers(TypedDict, closed=True, total=False): # type: ignore[call-arg]
166+
"""Members for overview resolution datasets (r20m … r720m).
167+
168+
Only data variables, no coordinate arrays.
169+
"""
170+
171+
vv: ArraySpec[Any]
172+
vh: ArraySpec[Any]
173+
border_mask: ArraySpec[Any]
174+
175+
176+
# ============================================================================
177+
# Group models (same pattern as S2 Sentinel2ResolutionDataset etc.)
178+
# ============================================================================
179+
180+
181+
class S1RtcNativeResolutionDataset(
182+
GroupSpec[S1RtcResolutionAttrs, S1RtcNativeResolutionMembers] # type: ignore[type-var]
183+
):
184+
"""The r10m dataset: data variables + coordinate arrays."""
185+
186+
@model_validator(mode="after")
187+
def validate_data_variables(self) -> Self:
188+
"""Ensure vv, vh, and border_mask are present."""
189+
for name in ("vv", "vh", "border_mask"):
190+
if name not in self.members:
191+
raise ValueError(f"Native resolution dataset must contain '{name}' array")
192+
return self
193+
194+
@property
195+
def vv(self) -> ArraySpec[Any]:
196+
return self.members["vv"]
197+
198+
@property
199+
def vh(self) -> ArraySpec[Any]:
200+
return self.members["vh"]
201+
202+
@property
203+
def border_mask(self) -> ArraySpec[Any]:
204+
return self.members["border_mask"]
205+
206+
207+
class S1RtcOverviewResolutionDataset(
208+
GroupSpec[S1RtcResolutionAttrs, S1RtcOverviewResolutionMembers] # type: ignore[type-var]
209+
):
210+
"""An overview resolution dataset (r20m–r720m): data variables only."""
211+
212+
213+
class S1RtcConditionsGroup(
214+
GroupSpec[S1RtcConditionsAttrs, dict[str, ArraySpec[Any]]] # type: ignore[type-var]
215+
):
216+
"""Time-invariant condition arrays, keyed by name (e.g. gamma_area_008)."""
217+
218+
@model_validator(mode="after")
219+
def validate_has_gamma_area(self) -> Self:
220+
"""At least one gamma_area_* array should be present."""
221+
if not any(k.startswith("gamma_area_") for k in self.members):
222+
raise ValueError(
223+
"Conditions group must contain at least one 'gamma_area_*' array"
224+
)
225+
return self
226+
227+
228+
class S1RtcOrbitGroupMembers(TypedDict, closed=True, total=False): # type: ignore[call-arg]
229+
"""Members for an orbit-direction group.
230+
231+
Contains resolution-level datasets and conditions.
232+
All optional to support incremental store construction.
233+
"""
234+
235+
r10m: S1RtcNativeResolutionDataset
236+
r20m: S1RtcOverviewResolutionDataset
237+
r60m: S1RtcOverviewResolutionDataset
238+
r120m: S1RtcOverviewResolutionDataset
239+
r360m: S1RtcOverviewResolutionDataset
240+
r720m: S1RtcOverviewResolutionDataset
241+
conditions: S1RtcConditionsGroup
242+
243+
244+
class S1RtcOrbitGroup(
245+
GroupSpec[S1RtcOrbitGroupAttrs, S1RtcOrbitGroupMembers] # type: ignore[type-var]
246+
):
247+
"""One orbit direction (ascending or descending) with multiscale layout."""
248+
249+
@model_validator(mode="after")
250+
def validate_r10m_present(self) -> Self:
251+
if "r10m" not in self.members:
252+
raise ValueError("Orbit group must contain 'r10m' native resolution dataset")
253+
return self
254+
255+
@property
256+
def r10m(self) -> S1RtcNativeResolutionDataset:
257+
return self.members["r10m"]
258+
259+
@property
260+
def conditions(self) -> S1RtcConditionsGroup | None:
261+
return self.members.get("conditions")
262+
263+
def get_resolution(self, level: ResolutionLevel) -> GroupSpec[Any, Any] | None:
264+
"""Retrieve a resolution dataset by level name."""
265+
return self.members.get(level)
266+
267+
268+
# ============================================================================
269+
# Root model (same pattern as S2 Sentinel2Root)
270+
# ============================================================================
271+
272+
273+
class S1RtcRootMembers(TypedDict, closed=True, total=False): # type: ignore[call-arg]
274+
"""Members for the root group. At least one orbit direction must be present."""
275+
276+
ascending: S1RtcOrbitGroup
277+
descending: S1RtcOrbitGroup
278+
279+
280+
class S1RtcRoot(GroupSpec[DatasetAttrs, S1RtcRootMembers]): # type: ignore[type-var]
281+
"""Complete S1 GRD RTC GeoZarr V3 hierarchy.
282+
283+
The hierarchy follows the implementation plan::
284+
285+
s1-grd-rtc-{tile}.zarr/
286+
├── zarr.json
287+
├── ascending/
288+
│ ├── zarr.json # zarr_conventions, multiscales, proj:, spatial:
289+
│ ├── r10m/
290+
│ │ ├── vv/ # (time, Y, X) float32
291+
│ │ ├── vh/ # (time, Y, X) float32
292+
│ │ ├── border_mask/ # (time, Y, X) uint8
293+
│ │ ├── time/ # (time,) int64
294+
│ │ ├── absolute_orbit/
295+
│ │ ├── relative_orbit/
296+
│ │ └── platform/
297+
│ ├── r20m/ … r720m/
298+
│ └── conditions/
299+
│ └── gamma_area_{orbit}/
300+
└── descending/
301+
└── (same)
302+
"""
303+
304+
@model_validator(mode="after")
305+
def validate_at_least_one_orbit(self) -> Self:
306+
if "ascending" not in self.members and "descending" not in self.members:
307+
raise ValueError("Store must contain at least one orbit group (ascending/descending)")
308+
return self
309+
310+
@property
311+
def ascending(self) -> S1RtcOrbitGroup | None:
312+
return self.members.get("ascending")
313+
314+
@property
315+
def descending(self) -> S1RtcOrbitGroup | None:
316+
return self.members.get("descending")

0 commit comments

Comments
 (0)