forcingkit 0.1.0.post1__tar.gz → 0.2.0__tar.gz
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.
- {forcingkit-0.1.0.post1 → forcingkit-0.2.0}/PKG-INFO +4 -2
- {forcingkit-0.1.0.post1 → forcingkit-0.2.0}/README.md +3 -1
- forcingkit-0.2.0/docs/source/_static/forcingkit-noreaster-wind.gif +0 -0
- {forcingkit-0.1.0.post1 → forcingkit-0.2.0}/docs/source/conf.py +1 -1
- {forcingkit-0.1.0.post1 → forcingkit-0.2.0}/docs/source/removed_endpoints.rst +1 -1
- {forcingkit-0.1.0.post1 → forcingkit-0.2.0}/pyproject.toml +1 -1
- {forcingkit-0.1.0.post1 → forcingkit-0.2.0}/service/forcingkit_serve/main.py +1 -1
- {forcingkit-0.1.0.post1 → forcingkit-0.2.0}/service/forcingkit_serve/routers/removed.py +1 -1
- {forcingkit-0.1.0.post1 → forcingkit-0.2.0}/service/forcingkit_serve/routers/viewer.py +7 -0
- {forcingkit-0.1.0.post1 → forcingkit-0.2.0}/src/forcingkit/fetchers/necofs.py +52 -8
- {forcingkit-0.1.0.post1 → forcingkit-0.2.0}/src/forcingkit/fetchers/noaa.py +22 -5
- {forcingkit-0.1.0.post1 → forcingkit-0.2.0}/static/app.js +40 -0
- {forcingkit-0.1.0.post1 → forcingkit-0.2.0}/static/index.html +5 -2
- {forcingkit-0.1.0.post1 → forcingkit-0.2.0}/static/preview3d.js +74 -6
- {forcingkit-0.1.0.post1 → forcingkit-0.2.0}/tests/unit/test_necofs_parent.py +44 -2
- forcingkit-0.2.0/tests/unit/test_noaa_datum.py +66 -0
- {forcingkit-0.1.0.post1 → forcingkit-0.2.0}/uv.lock +1 -1
- forcingkit-0.1.0.post1/docs/source/_static/forcingkit-inventory-screenshot.png +0 -0
- {forcingkit-0.1.0.post1 → forcingkit-0.2.0}/.agents/forcingkit.md +0 -0
- {forcingkit-0.1.0.post1 → forcingkit-0.2.0}/.claude/CLAUDE.md +0 -0
- {forcingkit-0.1.0.post1 → forcingkit-0.2.0}/.env.template +0 -0
- {forcingkit-0.1.0.post1 → forcingkit-0.2.0}/.github/copilot-instructions.md +0 -0
- {forcingkit-0.1.0.post1 → forcingkit-0.2.0}/.github/workflows/docs.yml +0 -0
- {forcingkit-0.1.0.post1 → forcingkit-0.2.0}/.github/workflows/publish.yml +0 -0
- {forcingkit-0.1.0.post1 → forcingkit-0.2.0}/.github/workflows/tests.yml +0 -0
- {forcingkit-0.1.0.post1 → forcingkit-0.2.0}/.gitignore +0 -0
- {forcingkit-0.1.0.post1 → forcingkit-0.2.0}/.markdownlint.yaml +0 -0
- {forcingkit-0.1.0.post1 → forcingkit-0.2.0}/.pre-commit-config.yaml +0 -0
- {forcingkit-0.1.0.post1 → forcingkit-0.2.0}/.python-version +0 -0
- {forcingkit-0.1.0.post1 → forcingkit-0.2.0}/AGENTS.md +0 -0
- {forcingkit-0.1.0.post1 → forcingkit-0.2.0}/CONTRIBUTING.md +0 -0
- {forcingkit-0.1.0.post1 → forcingkit-0.2.0}/Dockerfile +0 -0
- {forcingkit-0.1.0.post1 → forcingkit-0.2.0}/LICENSE +0 -0
- {forcingkit-0.1.0.post1 → forcingkit-0.2.0}/docker-compose.yml +0 -0
- {forcingkit-0.1.0.post1 → forcingkit-0.2.0}/docs/Makefile +0 -0
- {forcingkit-0.1.0.post1 → forcingkit-0.2.0}/docs/make.bat +0 -0
- {forcingkit-0.1.0.post1 → forcingkit-0.2.0}/docs/requirements-docs.txt +0 -0
- {forcingkit-0.1.0.post1 → forcingkit-0.2.0}/docs/source/_extra/CNAME +0 -0
- {forcingkit-0.1.0.post1 → forcingkit-0.2.0}/docs/source/atmospheric_forcing.rst +0 -0
- {forcingkit-0.1.0.post1 → forcingkit-0.2.0}/docs/source/fetchers.rst +0 -0
- {forcingkit-0.1.0.post1 → forcingkit-0.2.0}/docs/source/index.rst +0 -0
- {forcingkit-0.1.0.post1 → forcingkit-0.2.0}/docs/source/nyofs.rst +0 -0
- {forcingkit-0.1.0.post1 → forcingkit-0.2.0}/main.py +0 -0
- {forcingkit-0.1.0.post1 → forcingkit-0.2.0}/service/__init__.py +0 -0
- {forcingkit-0.1.0.post1 → forcingkit-0.2.0}/service/forcingkit_serve/__init__.py +0 -0
- {forcingkit-0.1.0.post1 → forcingkit-0.2.0}/service/forcingkit_serve/routers/bathymetry.py +0 -0
- {forcingkit-0.1.0.post1 → forcingkit-0.2.0}/service/forcingkit_serve/routers/plotly_api.py +0 -0
- {forcingkit-0.1.0.post1 → forcingkit-0.2.0}/service/run_server.py +0 -0
- {forcingkit-0.1.0.post1 → forcingkit-0.2.0}/src/forcingkit/__init__.py +0 -0
- {forcingkit-0.1.0.post1 → forcingkit-0.2.0}/src/forcingkit/dispatcher.py +0 -0
- {forcingkit-0.1.0.post1 → forcingkit-0.2.0}/src/forcingkit/fetchers/dbofs.py +0 -0
- {forcingkit-0.1.0.post1 → forcingkit-0.2.0}/src/forcingkit/fetchers/erddap.py +0 -0
- {forcingkit-0.1.0.post1 → forcingkit-0.2.0}/src/forcingkit/fetchers/hrrr.py +0 -0
- {forcingkit-0.1.0.post1 → forcingkit-0.2.0}/src/forcingkit/fetchers/hrrr_atmosphere.py +0 -0
- {forcingkit-0.1.0.post1 → forcingkit-0.2.0}/src/forcingkit/fetchers/hycom.py +0 -0
- {forcingkit-0.1.0.post1 → forcingkit-0.2.0}/src/forcingkit/fetchers/hydrography.py +0 -0
- {forcingkit-0.1.0.post1 → forcingkit-0.2.0}/src/forcingkit/fetchers/ndbc.py +0 -0
- {forcingkit-0.1.0.post1 → forcingkit-0.2.0}/src/forcingkit/fetchers/nyofs.py +0 -0
- {forcingkit-0.1.0.post1 → forcingkit-0.2.0}/src/forcingkit/settings.py +0 -0
- {forcingkit-0.1.0.post1 → forcingkit-0.2.0}/src/forcingkit/zarr_stream.py +0 -0
- {forcingkit-0.1.0.post1 → forcingkit-0.2.0}/static/favicon.ico +0 -0
- {forcingkit-0.1.0.post1 → forcingkit-0.2.0}/static/favicon.svg +0 -0
- {forcingkit-0.1.0.post1 → forcingkit-0.2.0}/static/logo.svg +0 -0
- {forcingkit-0.1.0.post1 → forcingkit-0.2.0}/static/styles.css +0 -0
- {forcingkit-0.1.0.post1 → forcingkit-0.2.0}/tests/__init__.py +0 -0
- {forcingkit-0.1.0.post1 → forcingkit-0.2.0}/tests/integration/test_auth.py +0 -0
- {forcingkit-0.1.0.post1 → forcingkit-0.2.0}/tests/integration/test_erddap_fetch.py +0 -0
- {forcingkit-0.1.0.post1 → forcingkit-0.2.0}/tests/integration/test_nyofs_obc_fetch.py +0 -0
- {forcingkit-0.1.0.post1 → forcingkit-0.2.0}/tests/integration/test_obc_mab.py +0 -0
- {forcingkit-0.1.0.post1 → forcingkit-0.2.0}/tests/integration/test_s3_roms_fetchers.py +0 -0
- {forcingkit-0.1.0.post1 → forcingkit-0.2.0}/tests/scripts/inspect_dem.py +0 -0
- {forcingkit-0.1.0.post1 → forcingkit-0.2.0}/tests/scripts/inspect_grib.py +0 -0
- {forcingkit-0.1.0.post1 → forcingkit-0.2.0}/tests/scripts/inspect_zarr.py +0 -0
- {forcingkit-0.1.0.post1 → forcingkit-0.2.0}/tests/unit/test_dbofs.py +0 -0
- {forcingkit-0.1.0.post1 → forcingkit-0.2.0}/tests/unit/test_dispatcher.py +0 -0
- {forcingkit-0.1.0.post1 → forcingkit-0.2.0}/tests/unit/test_hrrr_atm.py +0 -0
- {forcingkit-0.1.0.post1 → forcingkit-0.2.0}/tests/unit/test_hrrr_idx.py +0 -0
- {forcingkit-0.1.0.post1 → forcingkit-0.2.0}/tests/unit/test_hycom.py +0 -0
- {forcingkit-0.1.0.post1 → forcingkit-0.2.0}/tests/unit/test_main.py +0 -0
- {forcingkit-0.1.0.post1 → forcingkit-0.2.0}/tests/unit/test_ndbc.py +0 -0
- {forcingkit-0.1.0.post1 → forcingkit-0.2.0}/tests/unit/test_nyofs.py +0 -0
- {forcingkit-0.1.0.post1 → forcingkit-0.2.0}/tests/unit/test_obc_pipeline.py +0 -0
- {forcingkit-0.1.0.post1 → forcingkit-0.2.0}/tests/unit/test_removed_routes.py +0 -0
- {forcingkit-0.1.0.post1 → forcingkit-0.2.0}/tests/unit/test_settings.py +0 -0
- {forcingkit-0.1.0.post1 → forcingkit-0.2.0}/tests/unit/test_zarr_stream.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: forcingkit
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Spatiotemporal forcing for computational Earth-system models: selects, regrids and serves model-ready time series with provenance.
|
|
5
5
|
Project-URL: Documentation, https://forcingkit.docs.lhzn.io
|
|
6
6
|
Project-URL: Issues, https://github.com/lhzn-io/forcingkit/issues
|
|
@@ -246,7 +246,9 @@ Description-Content-Type: text/markdown
|
|
|
246
246
|
[](https://opensource.org/licenses/Apache-2.0)
|
|
247
247
|
|
|
248
248
|
<div align="center">
|
|
249
|
-
<img src="https://raw.githubusercontent.com/lhzn-io/forcingkit/main/docs/source/_static/forcingkit-
|
|
249
|
+
<img src="https://raw.githubusercontent.com/lhzn-io/forcingkit/main/docs/source/_static/forcingkit-noreaster-wind.gif" alt="Animation of the HRRR 10 m wind from the NY Bight to Cape Cod, every 3 hours from 2026-09-22 00:00 to 2026-09-29 12:00 UTC" width="720" />
|
|
250
|
+
<br />
|
|
251
|
+
<sub>HRRR 10 m wind served by forcingkit through the 26-27 September 2026 nor'easter, NY Bight to Cape Cod, from the calm of 22 September to the calm of 29 September, every 3 hours (peak 25.9 m/s). The forcingkit viewer's time slider, played.</sub>
|
|
250
252
|
</div>
|
|
251
253
|
|
|
252
254
|
Spatiotemporal forcing for computational Earth-system models: selects, regrids and serves model-ready time series with provenance.
|
|
@@ -5,7 +5,9 @@
|
|
|
5
5
|
[](https://opensource.org/licenses/Apache-2.0)
|
|
6
6
|
|
|
7
7
|
<div align="center">
|
|
8
|
-
<img src="https://raw.githubusercontent.com/lhzn-io/forcingkit/main/docs/source/_static/forcingkit-
|
|
8
|
+
<img src="https://raw.githubusercontent.com/lhzn-io/forcingkit/main/docs/source/_static/forcingkit-noreaster-wind.gif" alt="Animation of the HRRR 10 m wind from the NY Bight to Cape Cod, every 3 hours from 2026-09-22 00:00 to 2026-09-29 12:00 UTC" width="720" />
|
|
9
|
+
<br />
|
|
10
|
+
<sub>HRRR 10 m wind served by forcingkit through the 26-27 September 2026 nor'easter, NY Bight to Cape Cod, from the calm of 22 September to the calm of 29 September, every 3 hours (peak 25.9 m/s). The forcingkit viewer's time slider, played.</sub>
|
|
9
11
|
</div>
|
|
10
12
|
|
|
11
13
|
Spatiotemporal forcing for computational Earth-system models: selects, regrids and serves model-ready time series with provenance.
|
|
Binary file
|
|
@@ -18,7 +18,7 @@ and the routes then answer 404.
|
|
|
18
18
|
* - Removed
|
|
19
19
|
- Use instead
|
|
20
20
|
* - ``POST /api/v1/ic/generate``, ``POST /api/v1/ic/regrid``
|
|
21
|
-
- ``POST /api/v1/obc``: the parent ocean on true z (schema ``z-
|
|
21
|
+
- ``POST /api/v1/obc``: the parent ocean on true z (schema ``z-v3``). Its first record is
|
|
22
22
|
the initial condition, already on regular lon/lat and fixed z levels, so no separate
|
|
23
23
|
regrid step remains.
|
|
24
24
|
* - ``POST /api/v1/ic/cache``, ``POST /api/v1/ic/predict-donor``,
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "forcingkit"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.2.0"
|
|
8
8
|
description = "Spatiotemporal forcing for computational Earth-system models: selects, regrids and serves model-ready time series with provenance."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.11"
|
|
@@ -131,7 +131,7 @@ class OBCRequest(BaseModel):
|
|
|
131
131
|
sponge_cells: int = 0
|
|
132
132
|
include_tides: bool = True
|
|
133
133
|
tidal_model: str = "GOT4.10c"
|
|
134
|
-
# Parent-ocean options (schema z-
|
|
134
|
+
# Parent-ocean options (schema z-v3): donor cells of padding beyond the bbox, so the parent
|
|
135
135
|
# brackets the child, and the spacing of the fixed z levels.
|
|
136
136
|
pad_cells: int = 3
|
|
137
137
|
vertical_spacing_m: float = 2.0
|
|
@@ -19,7 +19,7 @@ REMOVED_ON = datetime(2026, 10, 5, tzinfo=timezone.utc)
|
|
|
19
19
|
DOCS_URL = "https://forcingkit.docs.lhzn.io/removed_endpoints.html"
|
|
20
20
|
|
|
21
21
|
_PARENT = (
|
|
22
|
-
"POST /api/v1/obc: the parent ocean on true z (schema z-
|
|
22
|
+
"POST /api/v1/obc: the parent ocean on true z (schema z-v3); its first record is the "
|
|
23
23
|
"initial condition"
|
|
24
24
|
)
|
|
25
25
|
_ATMOSPHERE = (
|
|
@@ -912,10 +912,16 @@ async def get_dataset_preview_3d(
|
|
|
912
912
|
|
|
913
913
|
# Select time slice
|
|
914
914
|
time_name = next((n for n in ["time", "ocean_time", "t"] if n in ds.dims), None)
|
|
915
|
+
frame_time = None
|
|
915
916
|
if time_name and time_name in u_da.dims:
|
|
916
917
|
t_idx = min(time_idx, u_da.sizes[time_name] - 1)
|
|
917
918
|
u_da = u_da.isel({time_name: t_idx})
|
|
918
919
|
v_da = v_da.isel({time_name: t_idx})
|
|
920
|
+
# The record's time, for the viewer's label (UTC, to the minute).
|
|
921
|
+
if time_name in ds.coords and ds[time_name].dtype.kind == "M":
|
|
922
|
+
frame_time = (
|
|
923
|
+
str(ds[time_name].values[t_idx].astype("datetime64[m]")) + "Z"
|
|
924
|
+
)
|
|
919
925
|
|
|
920
926
|
# Find the best lon/lat coordinate for each variable.
|
|
921
927
|
# Staggered ROMS grids have per-variable coords (lon_u/lat_u, lon_v/lat_v).
|
|
@@ -1103,6 +1109,7 @@ async def get_dataset_preview_3d(
|
|
|
1103
1109
|
"bounds": [min(all_lons), min(all_lats), max(all_lons), max(all_lats)],
|
|
1104
1110
|
"depth_levels": sorted(set(v["depth"] for v in vectors)),
|
|
1105
1111
|
"count": len(vectors),
|
|
1112
|
+
"time": frame_time,
|
|
1106
1113
|
}
|
|
1107
1114
|
|
|
1108
1115
|
return Response(
|
|
@@ -60,7 +60,7 @@ def get_necofs_url(target_dt: pd.Timestamp) -> str:
|
|
|
60
60
|
|
|
61
61
|
# Parent-ocean delivery schema. Bump when the layout or meaning of the boundary store changes, so
|
|
62
62
|
# a cached store built under an older schema can never be served for a newer request.
|
|
63
|
-
OBC_SCHEMA = "z-
|
|
63
|
+
OBC_SCHEMA = "z-v3"
|
|
64
64
|
|
|
65
65
|
|
|
66
66
|
def necofs_archive_file_date(t: pd.Timestamp) -> pd.Timestamp:
|
|
@@ -145,6 +145,11 @@ class Barycentric:
|
|
|
145
145
|
self.vertices = tri.simplices[s]
|
|
146
146
|
self.shape = shape
|
|
147
147
|
|
|
148
|
+
def restrict(self, inside: np.ndarray) -> "Barycentric":
|
|
149
|
+
"""Also treat targets outside `inside` (flat, one per target) as outside."""
|
|
150
|
+
self.inside = self.inside & inside
|
|
151
|
+
return self
|
|
152
|
+
|
|
148
153
|
def __call__(self, values: np.ndarray) -> np.ndarray:
|
|
149
154
|
values = np.asarray(values, dtype=np.float64)
|
|
150
155
|
out = np.einsum("...pk,pk->...p", values[..., self.vertices], self.weights)
|
|
@@ -152,6 +157,44 @@ class Barycentric:
|
|
|
152
157
|
return out.reshape(values.shape[:-1] + self.shape)
|
|
153
158
|
|
|
154
159
|
|
|
160
|
+
class MeshBarycentric(Barycentric):
|
|
161
|
+
"""Linear interpolation on the triangles of an unstructured mesh, as FVCOM itself interpolates
|
|
162
|
+
node values within an element.
|
|
163
|
+
|
|
164
|
+
A Delaunay triangulation of the mesh nodes also covers land between them (a peninsula, an
|
|
165
|
+
island, the coast between two estuaries) and fills it with values blended across it. Here a
|
|
166
|
+
target takes values only from the mesh element that contains it, and a target inside no
|
|
167
|
+
element (land, or outside the mesh) is NaN. `triangles` are zero-based node indices, one row
|
|
168
|
+
per element.
|
|
169
|
+
"""
|
|
170
|
+
|
|
171
|
+
def __init__(
|
|
172
|
+
self,
|
|
173
|
+
lon: np.ndarray,
|
|
174
|
+
lat: np.ndarray,
|
|
175
|
+
triangles: np.ndarray,
|
|
176
|
+
targets: np.ndarray,
|
|
177
|
+
shape: tuple[int, ...],
|
|
178
|
+
):
|
|
179
|
+
from matplotlib.tri import Triangulation
|
|
180
|
+
|
|
181
|
+
mesh = Triangulation(lon, lat, triangles)
|
|
182
|
+
element = np.asarray(mesh.get_trifinder()(targets[:, 0], targets[:, 1]))
|
|
183
|
+
self.inside = element >= 0
|
|
184
|
+
self.vertices = triangles[np.where(self.inside, element, 0)]
|
|
185
|
+
x, y = lon[self.vertices], lat[self.vertices]
|
|
186
|
+
# Barycentric coordinates of each target in its element.
|
|
187
|
+
det = (y[:, 1] - y[:, 2]) * (x[:, 0] - x[:, 2]) + (x[:, 2] - x[:, 1]) * (
|
|
188
|
+
y[:, 0] - y[:, 2]
|
|
189
|
+
)
|
|
190
|
+
det = np.where(self.inside, det, 1.0)
|
|
191
|
+
dx, dy = targets[:, 0] - x[:, 2], targets[:, 1] - y[:, 2]
|
|
192
|
+
b0 = ((y[:, 1] - y[:, 2]) * dx + (x[:, 2] - x[:, 1]) * dy) / det
|
|
193
|
+
b1 = ((y[:, 2] - y[:, 0]) * dx + (x[:, 0] - x[:, 2]) * dy) / det
|
|
194
|
+
self.weights = np.column_stack([b0, b1, 1.0 - b0 - b1])
|
|
195
|
+
self.shape = shape
|
|
196
|
+
|
|
197
|
+
|
|
155
198
|
PARENT_RECORD_DIMS = {
|
|
156
199
|
"u": ("z", "lat", "lon"),
|
|
157
200
|
"v": ("z", "lat", "lon"),
|
|
@@ -212,7 +255,9 @@ def iter_parent(
|
|
|
212
255
|
parent brackets the child. The vertical axis is true depth: each FVCOM sigma layer is placed at
|
|
213
256
|
z = siglay * (h + zeta) + zeta for its column and interpolated onto fixed levels every
|
|
214
257
|
`vertical_spacing_m` metres, ordered bottom to top. Land, points outside the mesh and levels
|
|
215
|
-
below the sea floor are NaN.
|
|
258
|
+
below the sea floor are NaN. Land is what lies inside no element of the NECOFS mesh: node
|
|
259
|
+
fields are interpolated within the containing element, and element fields (u, v) are NaN
|
|
260
|
+
wherever the node mesh is.
|
|
216
261
|
|
|
217
262
|
Raises rather than returning a short record: an unreachable archive file, a missing hour or a
|
|
218
263
|
failed interpolation ends the delivery with an error.
|
|
@@ -259,16 +304,15 @@ def iter_parent(
|
|
|
259
304
|
logger.info(
|
|
260
305
|
"Building NECOFS parent interpolation weights and vertical grid..."
|
|
261
306
|
)
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
)
|
|
307
|
+
node_lon = np.asarray(ds["lon"].values, dtype=np.float64)
|
|
308
|
+
node_lat = np.asarray(ds["lat"].values, dtype=np.float64)
|
|
309
|
+
triangles = np.asarray(ds["nv"].values).T.astype(np.int64) - 1
|
|
310
|
+
at_node = MeshBarycentric(node_lon, node_lat, triangles, target_pts, shape)
|
|
267
311
|
at_elem = Barycentric(
|
|
268
312
|
Delaunay(np.column_stack((ds["lonc"].values, ds["latc"].values))),
|
|
269
313
|
target_pts,
|
|
270
314
|
shape,
|
|
271
|
-
)
|
|
315
|
+
).restrict(at_node.inside)
|
|
272
316
|
h_grid = at_node(ds["h"].values)
|
|
273
317
|
siglay_grid = at_node(np.asarray(ds["siglay"].values)) # surface first
|
|
274
318
|
if not np.any(np.isfinite(h_grid)):
|
|
@@ -8,6 +8,10 @@ from forcingkit import settings
|
|
|
8
8
|
logger = logging.getLogger(__name__)
|
|
9
9
|
|
|
10
10
|
|
|
11
|
+
# Water-level datums to request, in order of preference.
|
|
12
|
+
TIDE_DATUMS = ("NAVD", "MSL")
|
|
13
|
+
|
|
14
|
+
|
|
11
15
|
def fetch_noaa_tide_data(
|
|
12
16
|
station_id: str,
|
|
13
17
|
start_time: str,
|
|
@@ -57,7 +61,6 @@ def fetch_noaa_tide_data(
|
|
|
57
61
|
"end_date": end_str,
|
|
58
62
|
"station": station_id,
|
|
59
63
|
"product": "water_level",
|
|
60
|
-
"datum": "NAVD",
|
|
61
64
|
"units": "metric",
|
|
62
65
|
"time_zone": "gmt",
|
|
63
66
|
"format": "json",
|
|
@@ -65,16 +68,30 @@ def fetch_noaa_tide_data(
|
|
|
65
68
|
}
|
|
66
69
|
|
|
67
70
|
try:
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
+
# NAVD88 where the station has it, so stations share a datum; otherwise MSL (New Haven,
|
|
72
|
+
# 8465705, publishes only tidal datums). The datum used is recorded in the metadata.
|
|
73
|
+
for datum in TIDE_DATUMS:
|
|
74
|
+
response = requests.get(url, params={**params, "datum": datum}, timeout=30)
|
|
75
|
+
data = response.json() if response.content else {}
|
|
76
|
+
message = (
|
|
77
|
+
data.get("error", {}).get("message", "")
|
|
78
|
+
if isinstance(data, dict)
|
|
79
|
+
else ""
|
|
80
|
+
)
|
|
81
|
+
if "datum" in message.lower() and datum != TIDE_DATUMS[-1]:
|
|
82
|
+
logger.info(
|
|
83
|
+
f"NOAA station {station_id} has no {datum} datum; trying the next"
|
|
84
|
+
)
|
|
85
|
+
continue
|
|
86
|
+
response.raise_for_status()
|
|
87
|
+
break
|
|
71
88
|
|
|
72
89
|
if "error" in data:
|
|
73
90
|
logger.warning(
|
|
74
91
|
f"NOAA API returned error: {data['error'].get('message', 'Unknown error')}"
|
|
75
92
|
)
|
|
76
|
-
# Return a dummy fallback or raise? For now, we'll raise to let dispatcher handle it
|
|
77
93
|
raise RuntimeError(f"NOAA API Error: {data['error'].get('message')}")
|
|
94
|
+
data.setdefault("metadata", {})["datum"] = datum
|
|
78
95
|
|
|
79
96
|
# Cache the successful response
|
|
80
97
|
with open(cache_path, "w") as f:
|
|
@@ -316,8 +316,10 @@ document.addEventListener("DOMContentLoaded", () => {
|
|
|
316
316
|
document.querySelectorAll(".dataset-card").forEach(c => c.classList.remove("selected"));
|
|
317
317
|
cardElement.classList.add("selected");
|
|
318
318
|
|
|
319
|
+
setPlaying(false);
|
|
319
320
|
selectedDataset = item;
|
|
320
321
|
previewControls.classList.remove("hidden");
|
|
322
|
+
frameTime.textContent = "";
|
|
321
323
|
timeRange.value = 0;
|
|
322
324
|
timeIdxDisplay.textContent = "0";
|
|
323
325
|
|
|
@@ -434,6 +436,43 @@ document.addEventListener("DOMContentLoaded", () => {
|
|
|
434
436
|
previewDebounce = setTimeout(updatePreview, 300);
|
|
435
437
|
});
|
|
436
438
|
|
|
439
|
+
// Auto-play: step the time slider, waiting for each frame to render, and loop.
|
|
440
|
+
const playBtn = document.getElementById("playBtn");
|
|
441
|
+
const frameTime = document.getElementById("frameTime");
|
|
442
|
+
const FRAME_MS = 400; // the fastest step; slower frames take as long as they take to load
|
|
443
|
+
let playing = false;
|
|
444
|
+
|
|
445
|
+
function setPlaying(on) {
|
|
446
|
+
playing = on;
|
|
447
|
+
if (playBtn) playBtn.textContent = on ? "Pause" : "Play";
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
async function playLoop() {
|
|
451
|
+
while (playing && selectedDataset) {
|
|
452
|
+
const last = parseInt(timeRange.max) || 0;
|
|
453
|
+
if (last < 1) break;
|
|
454
|
+
const next = (parseInt(timeRange.value) + 1) % (last + 1);
|
|
455
|
+
const started = performance.now();
|
|
456
|
+
timeRange.value = next;
|
|
457
|
+
if (window.currentDataCube && window.currentDataCube[next]) {
|
|
458
|
+
timeRange.dispatchEvent(new Event("input"));
|
|
459
|
+
} else {
|
|
460
|
+
timeIdxDisplay.textContent = `${next} (${selectedDataset.time_steps || '?'})`;
|
|
461
|
+
await updatePreview();
|
|
462
|
+
}
|
|
463
|
+
await new Promise(r => setTimeout(r, Math.max(0, FRAME_MS - (performance.now() - started))));
|
|
464
|
+
}
|
|
465
|
+
setPlaying(false);
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
if (playBtn) {
|
|
469
|
+
playBtn.addEventListener("click", () => {
|
|
470
|
+
if (playing) { setPlaying(false); return; }
|
|
471
|
+
setPlaying(true);
|
|
472
|
+
playLoop();
|
|
473
|
+
});
|
|
474
|
+
}
|
|
475
|
+
|
|
437
476
|
const lodSelect = document.getElementById("lodSelect");
|
|
438
477
|
if (lodSelect) {
|
|
439
478
|
lodSelect.addEventListener("change", () => {
|
|
@@ -467,6 +506,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
|
|
467
506
|
|
|
468
507
|
try {
|
|
469
508
|
const info = await window.preview3d.load(id, parseInt(t));
|
|
509
|
+
frameTime.textContent = info.time ? info.time.replace("T", " ").replace("Z", " UTC") : "";
|
|
470
510
|
console.log(`3D preview: ${info.count} vectors (${info.u_var}/${info.v_var}), ${info.depths} depth levels`);
|
|
471
511
|
} catch (err) {
|
|
472
512
|
console.error("3D preview failed:", err);
|
|
@@ -101,8 +101,11 @@
|
|
|
101
101
|
</div>
|
|
102
102
|
</div>
|
|
103
103
|
<div id="timeSliderGroup" class="control-group">
|
|
104
|
-
<label for="timeRange">Time Index (<span id="timeIdxDisplay">0</span>)
|
|
105
|
-
<
|
|
104
|
+
<label for="timeRange">Time Index (<span id="timeIdxDisplay">0</span>) <span id="frameTime" style="color:#8b949e;"></span></label>
|
|
105
|
+
<div style="display:flex;align-items:center;gap:8px;">
|
|
106
|
+
<button id="playBtn" type="button" class="btn" title="Step through the time steps and loop">Play</button>
|
|
107
|
+
<input type="range" id="timeRange" min="0" max="48" value="0" class="form-range" style="flex:1;">
|
|
108
|
+
</div>
|
|
106
109
|
</div>
|
|
107
110
|
<div id="snapshotLabel" class="control-group" style="display:none;">
|
|
108
111
|
<span style="color:#8b949e; font-size:0.85rem; font-style:italic;" id="snapshotLabelText"></span>
|
|
@@ -8,6 +8,11 @@ let camera = null;
|
|
|
8
8
|
let controls = null;
|
|
9
9
|
let arrowGroup = null;
|
|
10
10
|
let animId = null;
|
|
11
|
+
let legendHost = null;
|
|
12
|
+
// The dataset on screen and the largest speed seen in it, so stepping through time keeps the
|
|
13
|
+
// camera where the user put it and colours that compare across frames.
|
|
14
|
+
let currentDatasetId = null;
|
|
15
|
+
let scaleMax = 0;
|
|
11
16
|
|
|
12
17
|
// - Depth exaggeration (matches hydro viewer: 10x base * slider) -
|
|
13
18
|
const DEPTH_EXAG = 8.0; // equivalent to hydro viewer slider default
|
|
@@ -46,6 +51,55 @@ function depthColor(mag, maxMag, depthFrac) {
|
|
|
46
51
|
}
|
|
47
52
|
|
|
48
53
|
// - Merge simple geometries -
|
|
54
|
+
// - Color map for single-level fields (atmosphere, or a surface-only ocean field): speed -
|
|
55
|
+
// Plasma stops (matplotlib), perceptually uniform and legible on the dark background.
|
|
56
|
+
const PLASMA = [
|
|
57
|
+
[0.00, [0.050, 0.031, 0.529]],
|
|
58
|
+
[0.25, [0.494, 0.012, 0.659]],
|
|
59
|
+
[0.50, [0.800, 0.278, 0.471]],
|
|
60
|
+
[0.75, [0.973, 0.584, 0.251]],
|
|
61
|
+
[1.00, [0.941, 0.976, 0.129]],
|
|
62
|
+
];
|
|
63
|
+
|
|
64
|
+
function speedColor(mag, maxMag) {
|
|
65
|
+
const t = Math.min(1, mag / Math.max(maxMag, 0.001));
|
|
66
|
+
for (let i = 1; i < PLASMA.length; i++) {
|
|
67
|
+
const [t1, c1] = PLASMA[i];
|
|
68
|
+
if (t <= t1) {
|
|
69
|
+
const [t0, c0] = PLASMA[i - 1];
|
|
70
|
+
const s = (t - t0) / (t1 - t0);
|
|
71
|
+
return new THREE.Color(
|
|
72
|
+
c0[0] + (c1[0] - c0[0]) * s,
|
|
73
|
+
c0[1] + (c1[1] - c0[1]) * s,
|
|
74
|
+
c0[2] + (c1[2] - c0[2]) * s,
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return new THREE.Color(...PLASMA[PLASMA.length - 1][1]);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// A speed legend over the 3D canvas: the plasma ramp from 0 to the frame's maximum speed.
|
|
82
|
+
function showSpeedLegend(maxMag) {
|
|
83
|
+
hideSpeedLegend();
|
|
84
|
+
if (!legendHost) return;
|
|
85
|
+
if (getComputedStyle(legendHost).position === 'static') legendHost.style.position = 'relative';
|
|
86
|
+
const stops = PLASMA.map(([s, c]) =>
|
|
87
|
+
`rgb(${c.map(x => Math.round(x * 255)).join(',')}) ${s * 100}%`).join(', ');
|
|
88
|
+
const el = document.createElement('div');
|
|
89
|
+
el.id = 'preview3dLegend';
|
|
90
|
+
el.style.cssText = 'position:absolute;right:16px;bottom:16px;padding:8px 10px;border-radius:6px;' +
|
|
91
|
+
'background:rgba(13,17,23,0.75);color:#c9d1d9;font:12px system-ui,sans-serif;pointer-events:none;';
|
|
92
|
+
el.innerHTML = `<div style="margin-bottom:4px">Speed (m/s)</div>` +
|
|
93
|
+
`<div style="width:160px;height:10px;border-radius:2px;background:linear-gradient(to right, ${stops})"></div>` +
|
|
94
|
+
`<div style="display:flex;justify-content:space-between;margin-top:2px"><span>0</span><span>${maxMag.toFixed(1)}</span></div>`;
|
|
95
|
+
legendHost.appendChild(el);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function hideSpeedLegend() {
|
|
99
|
+
const el = document.getElementById('preview3dLegend');
|
|
100
|
+
if (el) el.remove();
|
|
101
|
+
}
|
|
102
|
+
|
|
49
103
|
function mergeGeos(geos) {
|
|
50
104
|
let totalV = 0, allIdx = [];
|
|
51
105
|
for (const g of geos) { totalV += g.attributes.position.count; }
|
|
@@ -69,6 +123,7 @@ function mergeGeos(geos) {
|
|
|
69
123
|
}
|
|
70
124
|
|
|
71
125
|
function initThree(container) {
|
|
126
|
+
legendHost = container;
|
|
72
127
|
const canvas = document.createElement('canvas');
|
|
73
128
|
canvas.id = 'preview3dCanvas';
|
|
74
129
|
container.appendChild(canvas);
|
|
@@ -120,6 +175,7 @@ function initThree(container) {
|
|
|
120
175
|
}
|
|
121
176
|
|
|
122
177
|
function clearArrows() {
|
|
178
|
+
hideSpeedLegend();
|
|
123
179
|
if (arrowGroup) {
|
|
124
180
|
scene.remove(arrowGroup);
|
|
125
181
|
arrowGroup.traverse(c => { if (c.geometry) c.geometry.dispose(); if (c.material) c.material.dispose(); });
|
|
@@ -127,7 +183,7 @@ function clearArrows() {
|
|
|
127
183
|
}
|
|
128
184
|
}
|
|
129
185
|
|
|
130
|
-
function buildVectorField(data) {
|
|
186
|
+
function buildVectorField(data, fitCamera) {
|
|
131
187
|
clearArrows();
|
|
132
188
|
if (!data.vectors || data.vectors.length === 0) return;
|
|
133
189
|
|
|
@@ -151,6 +207,8 @@ function buildVectorField(data) {
|
|
|
151
207
|
const mag = Math.sqrt(v.u * v.u + v.v * v.v);
|
|
152
208
|
if (mag > maxMag) maxMag = mag;
|
|
153
209
|
}
|
|
210
|
+
scaleMax = Math.max(scaleMax, maxMag);
|
|
211
|
+
maxMag = scaleMax;
|
|
154
212
|
|
|
155
213
|
// Build arrow geometry (shaft + cone)
|
|
156
214
|
const shaft = new THREE.CylinderGeometry(0.12, 0.12, 1, 6);
|
|
@@ -162,8 +220,13 @@ function buildVectorField(data) {
|
|
|
162
220
|
const arrowGeo = mergeGeos([shaft, cone]);
|
|
163
221
|
|
|
164
222
|
const count = data.vectors.length;
|
|
223
|
+
// One level (an atmosphere, or a surface-only ocean field): colour by speed, not depth.
|
|
224
|
+
const singleLevel = !data.depth_levels || data.depth_levels.length <= 1;
|
|
165
225
|
// depthTest: false so subsurface arrows render through the grid plane
|
|
166
|
-
|
|
226
|
+
// Unlit for a single level, so the speed colour map shows true; shaded for depth layers.
|
|
227
|
+
const mat = singleLevel
|
|
228
|
+
? new THREE.MeshBasicMaterial({ depthTest: false })
|
|
229
|
+
: new THREE.MeshPhongMaterial({ flatShading: true, depthTest: false });
|
|
167
230
|
const mesh = new THREE.InstancedMesh(arrowGeo, mat, count);
|
|
168
231
|
mesh.renderOrder = 10;
|
|
169
232
|
|
|
@@ -187,7 +250,7 @@ function buildVectorField(data) {
|
|
|
187
250
|
dummy.scale.set(arrowLen, arrowLen, arrowLen);
|
|
188
251
|
dummy.updateMatrix();
|
|
189
252
|
mesh.setMatrixAt(i, dummy.matrix);
|
|
190
|
-
mesh.setColorAt(i, depthColor(mag, maxMag, v.depth_frac || 0));
|
|
253
|
+
mesh.setColorAt(i, singleLevel ? speedColor(mag, maxMag) : depthColor(mag, maxMag, v.depth_frac || 0));
|
|
191
254
|
}
|
|
192
255
|
|
|
193
256
|
mesh.instanceMatrix.needsUpdate = true;
|
|
@@ -211,8 +274,10 @@ function buildVectorField(data) {
|
|
|
211
274
|
}
|
|
212
275
|
|
|
213
276
|
scene.add(arrowGroup);
|
|
277
|
+
if (singleLevel) showSpeedLegend(maxMag);
|
|
214
278
|
|
|
215
|
-
// Fit camera to vector field bounds
|
|
279
|
+
// Fit camera to vector field bounds, only for a newly selected dataset
|
|
280
|
+
if (!fitCamera) return;
|
|
216
281
|
const box = new THREE.Box3().setFromObject(arrowGroup);
|
|
217
282
|
const center = box.getCenter(new THREE.Vector3());
|
|
218
283
|
const size = box.getSize(new THREE.Vector3());
|
|
@@ -242,6 +307,7 @@ window.preview3d = {
|
|
|
242
307
|
const canvas = document.getElementById('preview3dCanvas');
|
|
243
308
|
if (canvas) canvas.style.display = 'none';
|
|
244
309
|
this.active = false;
|
|
310
|
+
currentDatasetId = null;
|
|
245
311
|
clearArrows();
|
|
246
312
|
},
|
|
247
313
|
|
|
@@ -254,8 +320,10 @@ window.preview3d = {
|
|
|
254
320
|
throw new Error(err.detail || `HTTP ${res.status}`);
|
|
255
321
|
}
|
|
256
322
|
const data = await res.json();
|
|
257
|
-
|
|
258
|
-
|
|
323
|
+
const newDataset = datasetId !== currentDatasetId;
|
|
324
|
+
if (newDataset) { currentDatasetId = datasetId; scaleMax = 0; }
|
|
325
|
+
buildVectorField(data, newDataset);
|
|
326
|
+
return { count: data.count, u_var: data.u_var, v_var: data.v_var, depths: data.depth_levels.length, time: data.time };
|
|
259
327
|
} catch (e) {
|
|
260
328
|
console.error('3D preview failed:', e);
|
|
261
329
|
throw e;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""NECOFS parent-ocean delivery (schema z-
|
|
1
|
+
"""NECOFS parent-ocean delivery (schema z-v3): the vertical conversion, archive-file selection and
|
|
2
2
|
cache-key behaviour, on constructed data. No network.
|
|
3
3
|
|
|
4
4
|
The conversion matters because the previous store labelled FVCOM sigma layers with fake depths in
|
|
@@ -113,7 +113,7 @@ def test_options_reach_only_fetchers_that_accept_them():
|
|
|
113
113
|
|
|
114
114
|
|
|
115
115
|
def test_schema_tag_is_set():
|
|
116
|
-
assert OBC_SCHEMA == "z-
|
|
116
|
+
assert OBC_SCHEMA == "z-v3"
|
|
117
117
|
|
|
118
118
|
|
|
119
119
|
def test_barycentric_weights_match_linear_nd_interpolation():
|
|
@@ -138,3 +138,45 @@ def test_barycentric_weights_match_linear_nd_interpolation():
|
|
|
138
138
|
want = LinearNDInterpolator(tri, values[k])(targets).reshape(gx.shape)
|
|
139
139
|
np.testing.assert_allclose(got[k], want, rtol=1e-12, atol=1e-12, equal_nan=True)
|
|
140
140
|
assert np.isnan(got[0, 0, 0]) # (-0.1, -0.1) is outside the hull
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def test_mesh_barycentric_leaves_land_between_elements_nan():
|
|
144
|
+
"""Two triangles either side of a notch: a Delaunay triangulation of the four nodes would
|
|
145
|
+
bridge the notch, the mesh does not."""
|
|
146
|
+
from forcingkit.fetchers.necofs import MeshBarycentric
|
|
147
|
+
|
|
148
|
+
lon = np.array([0.0, 1.0, 0.0, 1.0, 0.5])
|
|
149
|
+
lat = np.array([0.0, 0.0, 1.0, 1.0, 0.2])
|
|
150
|
+
# Elements (0, 1, 4) along the bottom and (2, 4, 3) above leave the left and right gaps as land.
|
|
151
|
+
triangles = np.array([[0, 1, 4], [2, 4, 3]])
|
|
152
|
+
targets = np.array(
|
|
153
|
+
[
|
|
154
|
+
[0.5, 0.1], # inside the bottom element
|
|
155
|
+
[0.5, 0.6], # inside the top element
|
|
156
|
+
[0.1, 0.6], # land: inside the nodes' convex hull, in no element
|
|
157
|
+
[2.0, 2.0], # outside the mesh
|
|
158
|
+
]
|
|
159
|
+
)
|
|
160
|
+
interp = MeshBarycentric(lon, lat, triangles, targets, (4,))
|
|
161
|
+
assert interp.inside.tolist() == [True, True, False, False]
|
|
162
|
+
# A linear field is reproduced exactly inside elements.
|
|
163
|
+
field = 2.0 * lon + 3.0 * lat + 1.0
|
|
164
|
+
out = interp(field)
|
|
165
|
+
expected = 2.0 * targets[:, 0] + 3.0 * targets[:, 1] + 1.0
|
|
166
|
+
np.testing.assert_allclose(out[:2], expected[:2], rtol=1e-12)
|
|
167
|
+
assert np.isnan(out[2:]).all()
|
|
168
|
+
# Leading dimensions (levels) pass through.
|
|
169
|
+
stacked = interp(np.vstack([field, 2 * field]))
|
|
170
|
+
np.testing.assert_allclose(stacked[1, :2], 2 * expected[:2], rtol=1e-12)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def test_restrict_masks_element_fields_on_land():
|
|
174
|
+
from scipy.spatial import Delaunay
|
|
175
|
+
|
|
176
|
+
from forcingkit.fetchers.necofs import Barycentric
|
|
177
|
+
|
|
178
|
+
pts = np.array([[0.0, 0.0], [1.0, 0.0], [0.0, 1.0], [1.0, 1.0]])
|
|
179
|
+
targets = np.array([[0.25, 0.25], [0.75, 0.75]])
|
|
180
|
+
interp = Barycentric(Delaunay(pts), targets, (2,)).restrict(np.array([True, False]))
|
|
181
|
+
out = interp(np.array([1.0, 1.0, 1.0, 1.0]))
|
|
182
|
+
assert out[0] == 1.0 and np.isnan(out[1])
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""CO-OPS water level: NAVD88 where a station has it, MSL where it does not."""
|
|
2
|
+
|
|
3
|
+
from unittest import mock
|
|
4
|
+
|
|
5
|
+
from forcingkit.fetchers import noaa
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class FakeResponse:
|
|
9
|
+
def __init__(self, payload, status=200):
|
|
10
|
+
self._payload = payload
|
|
11
|
+
self.status_code = status
|
|
12
|
+
self.content = b"x"
|
|
13
|
+
|
|
14
|
+
def json(self):
|
|
15
|
+
return self._payload
|
|
16
|
+
|
|
17
|
+
def raise_for_status(self):
|
|
18
|
+
if self.status_code >= 400:
|
|
19
|
+
raise RuntimeError(f"HTTP {self.status_code}")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _fetch(tmp_path, responses):
|
|
23
|
+
calls = []
|
|
24
|
+
|
|
25
|
+
def fake_get(url, params, timeout):
|
|
26
|
+
calls.append(params["datum"])
|
|
27
|
+
return responses[params["datum"]]
|
|
28
|
+
|
|
29
|
+
with mock.patch.object(noaa.requests, "get", side_effect=fake_get):
|
|
30
|
+
data = noaa.fetch_noaa_tide_data(
|
|
31
|
+
"8465705",
|
|
32
|
+
"2026-04-02T00:00:00",
|
|
33
|
+
"2026-04-02T04:00:00",
|
|
34
|
+
cache_dir=str(tmp_path),
|
|
35
|
+
)
|
|
36
|
+
return data, calls
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_falls_back_to_msl_when_navd_is_unsupported(tmp_path):
|
|
40
|
+
rows = {
|
|
41
|
+
"metadata": {"id": "8465705"},
|
|
42
|
+
"data": [{"t": "2026-04-02 00:00", "v": "0.1"}],
|
|
43
|
+
}
|
|
44
|
+
data, calls = _fetch(
|
|
45
|
+
tmp_path,
|
|
46
|
+
{
|
|
47
|
+
"NAVD": FakeResponse(
|
|
48
|
+
{
|
|
49
|
+
"error": {
|
|
50
|
+
"message": " The supported Datum values are: MHHW, MHW, MTL, MSL"
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
status=400,
|
|
54
|
+
),
|
|
55
|
+
"MSL": FakeResponse(rows),
|
|
56
|
+
},
|
|
57
|
+
)
|
|
58
|
+
assert calls == ["NAVD", "MSL"]
|
|
59
|
+
assert data["metadata"]["datum"] == "MSL"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def test_uses_navd_when_available(tmp_path):
|
|
63
|
+
rows = {"metadata": {"id": "8516945"}, "data": []}
|
|
64
|
+
data, calls = _fetch(tmp_path, {"NAVD": FakeResponse(rows)})
|
|
65
|
+
assert calls == ["NAVD"]
|
|
66
|
+
assert data["metadata"]["datum"] == "NAVD"
|
|
Binary file
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|