Skip to content

Commit 79ff6fb

Browse files
committed
Add SCHISM netCDF reader and integration tests
- Implement SchismNcReader and SchismNcDataReference for handling combined SCHISM netCDF output files. - Introduce lazy loading of time-series data with support for 2D and 3D variables. - Add metadata scanning functionality to discover available variables and nodes in the netCDF files. - Create integration tests for the SCHISM netCDF reader using real data from the HelloSCHISM tutorial project. - Ensure proper handling of dry nodes in elevation data and validate time range slicing in data retrieval. - Register the SCHISM netCDF reader with dvue's ReaderRegistry for seamless integration.
1 parent edd0eb0 commit 79ff6fb

7 files changed

Lines changed: 2400 additions & 11 deletions

File tree

schismviz/_nc_utils.py

Lines changed: 255 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,255 @@
1+
"""Shared utilities for reading SCHISM netCDF output files.
2+
3+
These helpers are used by both :mod:`schismviz.out2dui` and
4+
:mod:`schismviz.schism_nc` to avoid duplication.
5+
"""
6+
7+
from __future__ import annotations
8+
9+
from typing import Union
10+
11+
import pandas as pd
12+
13+
# ---------------------------------------------------------------------------
14+
# Known SCHISM variable → unit mapping
15+
# ---------------------------------------------------------------------------
16+
17+
#: Maps SCHISM netCDF variable name to its physical unit string.
18+
SCHISM_VAR_UNITS: dict[str, str] = {
19+
# 2-D / out2d variables
20+
"elevation": "m",
21+
"depthAverageVelX": "m/s",
22+
"depthAverageVelY": "m/s",
23+
"dahv": "m/s", # depth-averaged horizontal velocity (vector)
24+
"dahv_x": "m/s",
25+
"dahv_y": "m/s",
26+
"dryFlagNode": "",
27+
"dryFlagElement": "",
28+
"dryFlagSide": "",
29+
"bottom_index_node": "",
30+
# 3-D tracer variables
31+
"salinity": "PSU",
32+
"Salinity": "PSU",
33+
"salt": "PSU",
34+
"temperature": "deg_C",
35+
"Temperature": "deg_C",
36+
"temp": "deg_C",
37+
# 3-D velocity
38+
"hvel": "m/s",
39+
"hvel_x": "m/s",
40+
"hvel_y": "m/s",
41+
"u": "m/s",
42+
"v": "m/s",
43+
"w": "m/s",
44+
"vertical_velocity": "m/s",
45+
# z-coordinates
46+
"zcor": "m",
47+
"zCoordinates": "m",
48+
"z_coord": "m",
49+
}
50+
51+
# ---------------------------------------------------------------------------
52+
# SCHISM grid / topology variable names to exclude from data catalogs
53+
# ---------------------------------------------------------------------------
54+
55+
#: Variable names that represent mesh topology or static coordinate fields
56+
#: rather than time-varying physical data. These are skipped when building
57+
#: a data catalog from an xarray Dataset.
58+
SCHISM_GRID_VARS: frozenset[str] = frozenset(
59+
[
60+
"SCHISM_hgrid",
61+
"SCHISM_hgrid_node_x",
62+
"SCHISM_hgrid_node_y",
63+
"SCHISM_hgrid_face_x",
64+
"SCHISM_hgrid_face_y",
65+
"SCHISM_hgrid_edge_x",
66+
"SCHISM_hgrid_edge_y",
67+
"SCHISM_hgrid_face_nodes",
68+
"SCHISM_hgrid_edge_nodes",
69+
"bottom_index_node",
70+
"depth",
71+
"dryFlagNode",
72+
"dryFlagElement",
73+
"dryFlagSide",
74+
"node_bottom_index",
75+
]
76+
)
77+
78+
# Dimension name used by SCHISM for 3-D vertical layers
79+
SCHISM_VGRID_DIM: str = "nSCHISM_vgrid_layers"
80+
81+
# Dimension name used by SCHISM for horizontal nodes
82+
SCHISM_HGRID_NODE_DIM: str = "nSCHISM_hgrid_node"
83+
84+
85+
# ---------------------------------------------------------------------------
86+
# Time decoding
87+
# ---------------------------------------------------------------------------
88+
89+
90+
def _parse_base_date(base_date_str: str) -> pd.Timestamp:
91+
"""Parse the SCHISM *base_date* attribute string to a :class:`~pandas.Timestamp`.
92+
93+
The attribute is formatted as ``' 2009 2 10 0.00 8.00'``.
94+
Only the first three integer fields (year, month, day) are used.
95+
96+
Parameters
97+
----------
98+
base_date_str:
99+
Raw string value of the ``base_date`` attribute on the ``time``
100+
variable in a SCHISM netCDF output file.
101+
102+
Returns
103+
-------
104+
pandas.Timestamp
105+
Midnight on the parsed calendar date.
106+
"""
107+
parts = base_date_str.split()
108+
return pd.Timestamp(int(parts[0]), int(parts[1]), int(parts[2]))
109+
110+
111+
#: Any time value larger than this is treated as a NetCDF fill-value sentinel
112+
#: and replaced with NaN. The canonical NetCDF4 default fill value for float64
113+
#: is 9.969209968386869e+36; using 1e30 as the threshold is safe because no
114+
#: real SCHISM simulation runs for >3×10²² years.
115+
_NC_TIME_FILL_THRESHOLD: float = 1e30
116+
117+
118+
def _decode_times(base_date: pd.Timestamp, time_seconds) -> pd.DatetimeIndex:
119+
"""Convert a seconds-since-*base_date* array to a :class:`~pandas.DatetimeIndex`.
120+
121+
NetCDF output files that were pre-allocated but not fully written (e.g. an
122+
incomplete run) contain the default ``_FillValue`` sentinel
123+
(``9.969209968386869e+36``) in unwritten time slots. xarray does not always
124+
mask these values because the attribute may not be explicitly set in the
125+
file metadata. Passing the sentinel to :func:`pandas.to_timedelta` causes
126+
an ``OverflowError`` (too large for int64 nanoseconds) which in older
127+
pandas versions silently wraps to a large negative value, producing
128+
timestamps far in the past.
129+
130+
This function replaces any value larger than :data:`_NC_TIME_FILL_THRESHOLD`
131+
with ``NaN`` before conversion, which produces ``NaT`` entries for those
132+
slots. Callers should drop ``NaT`` rows from the resulting index.
133+
134+
Parameters
135+
----------
136+
base_date:
137+
Reference date returned by :func:`_parse_base_date`.
138+
time_seconds:
139+
Array-like of float seconds since *base_date* (the raw ``time``
140+
variable values from SCHISM netCDF output).
141+
142+
Returns
143+
-------
144+
pandas.DatetimeIndex
145+
"""
146+
import numpy as np
147+
148+
arr = np.asarray(time_seconds, dtype=np.float64)
149+
# Mask fill-value sentinels so they become NaT rather than overflowing.
150+
arr = np.where(arr > _NC_TIME_FILL_THRESHOLD, np.nan, arr)
151+
# Build per-element timedeltas, skipping NaN to avoid overflow.
152+
nat = np.timedelta64("NaT")
153+
td64 = np.where(
154+
np.isnan(arr),
155+
nat,
156+
(arr * 1e9).astype("timedelta64[ns]"),
157+
)
158+
return pd.DatetimeIndex(base_date.to_datetime64() + td64)
159+
160+
161+
# ---------------------------------------------------------------------------
162+
# Variable classification
163+
# ---------------------------------------------------------------------------
164+
165+
166+
def _classify_vars(ds) -> dict[str, tuple[str, bool]]:
167+
"""Classify data variables in *ds* into (unit, is_3d) pairs.
168+
169+
Grid topology variables listed in :data:`SCHISM_GRID_VARS` are excluded.
170+
A variable is considered 3-D when its dimensions include
171+
:data:`SCHISM_VGRID_DIM`.
172+
173+
Parameters
174+
----------
175+
ds:
176+
:class:`xarray.Dataset` opened from one or more SCHISM combined NC
177+
output files.
178+
179+
Returns
180+
-------
181+
dict
182+
Mapping ``varname → (unit_str, is_3d)`` for every data variable that
183+
should appear in a user-facing catalog.
184+
"""
185+
result: dict[str, tuple[str, bool]] = {}
186+
for varname in ds.data_vars:
187+
if varname in SCHISM_GRID_VARS:
188+
continue
189+
var = ds[varname]
190+
# Only include time-varying, node-based variables. Variables with
191+
# neither 'time' nor 'nSCHISM_hgrid_node' in their dimensions are
192+
# static fields, scalar metadata, or face/edge fields — skip them.
193+
if "time" not in var.dims or SCHISM_HGRID_NODE_DIM not in var.dims:
194+
continue
195+
is_3d = SCHISM_VGRID_DIM in var.dims
196+
unit = SCHISM_VAR_UNITS.get(varname, "")
197+
result[varname] = (unit, is_3d)
198+
return result
199+
200+
201+
# ---------------------------------------------------------------------------
202+
# Layer index resolution
203+
# ---------------------------------------------------------------------------
204+
205+
206+
def _resolve_layers(
207+
n_layers: int,
208+
layers_arg: Union[None, str, list[int]],
209+
) -> list[int]:
210+
"""Resolve the *layers* argument to a concrete list of layer indices.
211+
212+
Parameters
213+
----------
214+
n_layers:
215+
Total number of vertical layers in the dataset
216+
(``ds.dims[SCHISM_VGRID_DIM]``).
217+
layers_arg:
218+
Controls which layer indices are included:
219+
220+
* ``None`` — surface and bottom layers only:
221+
``[0, n_layers - 1]`` (when ``n_layers >= 2``; ``[0]`` otherwise).
222+
* ``"all"`` — every layer: ``list(range(n_layers))``.
223+
* A list/sequence of ``int`` — the specified indices (validated to be
224+
in ``[0, n_layers)``) are returned as-is.
225+
226+
Returns
227+
-------
228+
list of int
229+
Sorted, deduplicated list of valid layer indices.
230+
231+
Raises
232+
------
233+
ValueError
234+
If any requested index is out of range or *layers_arg* is not a
235+
recognised value.
236+
"""
237+
if layers_arg is None:
238+
if n_layers <= 1:
239+
return list(range(n_layers))
240+
return [0, n_layers - 1]
241+
if isinstance(layers_arg, str):
242+
if layers_arg.lower() == "all":
243+
return list(range(n_layers))
244+
raise ValueError(
245+
f"layers_arg string must be 'all', got {layers_arg!r}"
246+
)
247+
# Treat as an iterable of ints
248+
indices = sorted(set(int(k) for k in layers_arg))
249+
bad = [k for k in indices if k < 0 or k >= n_layers]
250+
if bad:
251+
raise ValueError(
252+
f"Layer indices {bad} are out of range for a dataset with "
253+
f"{n_layers} layers (valid: 0..{n_layers - 1})."
254+
)
255+
return indices

schismviz/cli.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from schismviz import schismui, schismcalibplotui
55
from schismviz.viz_cli import viz
66
from schismviz.out2dui import show_out2d_ui
7+
from schismviz.schism_nc import show_schism_nc_ui
78

89

910
CONTEXT_SETTINGS = dict(help_option_names=["-h", "--help"])
@@ -59,6 +60,7 @@ def build_manager():
5960
main.add_command(schismcalibplotui.schism_calib_plot_ui, name="calib")
6061
main.add_command(viz, name="viz")
6162
main.add_command(show_out2d_ui, name="out2d")
63+
main.add_command(show_schism_nc_ui, name="nc")
6264
main.add_command(combine, name="combine")
6365

6466

schismviz/out2dui.py

Lines changed: 2 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
pn.extension("tabulator", notifications=True, design="native")
3434

3535
from dvue.tsdataui import TimeSeriesDataUIManager, TimeSeriesPlotAction
36+
from schismviz._nc_utils import _parse_base_date, _decode_times as _decode_times_util
3637

3738
logger = logging.getLogger(__name__)
3839

@@ -49,16 +50,6 @@
4950
}
5051

5152

52-
def _parse_base_date(base_date_str: str) -> pd.Timestamp:
53-
"""Parse the SCHISM *base_date* attribute string to a Timestamp.
54-
55-
The attribute is formatted as ``' 2009 2 10 0.00 8.00'``.
56-
Only the first three integer fields (year, month, day) are used.
57-
"""
58-
parts = base_date_str.split()
59-
return pd.Timestamp(int(parts[0]), int(parts[1]), int(parts[2]))
60-
61-
6253
class SchismOut2DPlotAction(TimeSeriesPlotAction):
6354
"""TimeSeriesPlotAction with out2d-specific curve labels and titles."""
6455

@@ -187,7 +178,7 @@ def _open_dataset(self):
187178

188179
def _decode_times(self, time_seconds) -> pd.DatetimeIndex:
189180
"""Convert seconds-since-base_date array to DatetimeIndex."""
190-
return self._base_date + pd.to_timedelta(time_seconds, unit="s")
181+
return _decode_times_util(self._base_date, time_seconds)
191182

192183
# ------------------------------------------------------------------
193184
# Catalog construction

schismviz/readers.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -559,5 +559,10 @@ def register_readers() -> None:
559559
],
560560
)
561561

562+
# Combined SCHISM netCDF output files (out2d_*.nc, salinity_*.nc, etc.)
563+
from schismviz.schism_nc_reader import SchismNcReader
564+
565+
ReaderRegistry.register("schism_nc", SchismNcReader, extensions=[".nc"])
566+
562567
# Backward compatibility: preserve import-time registration behavior.
563568
register_readers()

0 commit comments

Comments
 (0)