forcingkit 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.
- forcingkit/__init__.py +0 -0
- forcingkit/dispatcher.py +448 -0
- forcingkit/fetchers/dbofs.py +442 -0
- forcingkit/fetchers/erddap.py +142 -0
- forcingkit/fetchers/hrrr.py +72 -0
- forcingkit/fetchers/hrrr_atmosphere.py +289 -0
- forcingkit/fetchers/hycom.py +159 -0
- forcingkit/fetchers/hydrography.py +117 -0
- forcingkit/fetchers/ndbc.py +231 -0
- forcingkit/fetchers/necofs.py +369 -0
- forcingkit/fetchers/noaa.py +87 -0
- forcingkit/fetchers/nyofs.py +458 -0
- forcingkit/settings.py +72 -0
- forcingkit/zarr_stream.py +146 -0
- forcingkit-0.1.0.dist-info/METADATA +329 -0
- forcingkit-0.1.0.dist-info/RECORD +24 -0
- forcingkit-0.1.0.dist-info/WHEEL +4 -0
- forcingkit-0.1.0.dist-info/licenses/LICENSE +201 -0
- forcingkit_serve/__init__.py +0 -0
- forcingkit_serve/main.py +544 -0
- forcingkit_serve/routers/bathymetry.py +241 -0
- forcingkit_serve/routers/plotly_api.py +150 -0
- forcingkit_serve/routers/removed.py +86 -0
- forcingkit_serve/routers/viewer.py +1199 -0
forcingkit/__init__.py
ADDED
|
File without changes
|
forcingkit/dispatcher.py
ADDED
|
@@ -0,0 +1,448 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import logging
|
|
3
|
+
from typing import Optional
|
|
4
|
+
import numpy as np
|
|
5
|
+
from forcingkit import settings
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
logger = logging.getLogger(__name__)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _domain_area(domain_bbox: list[float]) -> float:
|
|
12
|
+
"""Compute approximate domain area in degree^2 from [min_lon, min_lat, max_lon, max_lat]."""
|
|
13
|
+
return (domain_bbox[2] - domain_bbox[0]) * (domain_bbox[3] - domain_bbox[1])
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def dispatch_station_profiles_request(
|
|
17
|
+
station_id: str,
|
|
18
|
+
start_time: str,
|
|
19
|
+
end_time: str,
|
|
20
|
+
cache_dir: str = os.path.join(
|
|
21
|
+
settings.cache_dir(),
|
|
22
|
+
"erddap",
|
|
23
|
+
),
|
|
24
|
+
cache_bust: bool = False,
|
|
25
|
+
) -> dict:
|
|
26
|
+
"""
|
|
27
|
+
Tiered modality dispatcher for internal nudging profiles.
|
|
28
|
+
Currently routes WLIS directly to the ERDDAP fetcher.
|
|
29
|
+
"""
|
|
30
|
+
logger.info(
|
|
31
|
+
f"Dispatching station profile request for {station_id} ({start_time} to {end_time})"
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
# We could implement a real fallback strategy here, but for now we dispatch straight to our ERDDAP module
|
|
35
|
+
from forcingkit.fetchers.erddap import fetch_erddap_station_profiles
|
|
36
|
+
|
|
37
|
+
try:
|
|
38
|
+
profiles = fetch_erddap_station_profiles(
|
|
39
|
+
station_id=station_id,
|
|
40
|
+
start_time=start_time,
|
|
41
|
+
end_time=end_time,
|
|
42
|
+
cache_dir=cache_dir,
|
|
43
|
+
cache_bust=cache_bust,
|
|
44
|
+
)
|
|
45
|
+
return profiles
|
|
46
|
+
except Exception as e:
|
|
47
|
+
logger.error(f"Failed to fetch profiles for {station_id}: {e}")
|
|
48
|
+
return {}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def dispatch_bounding_box_profiles_request(
|
|
52
|
+
bbox: list[float],
|
|
53
|
+
start_time: str,
|
|
54
|
+
end_time: str,
|
|
55
|
+
cache_dir: str = os.path.join(
|
|
56
|
+
settings.cache_dir(),
|
|
57
|
+
"erddap",
|
|
58
|
+
),
|
|
59
|
+
cache_bust: bool = False,
|
|
60
|
+
) -> dict:
|
|
61
|
+
"""
|
|
62
|
+
Tiered modality dispatcher for internal nudging profiles across a domain.
|
|
63
|
+
"""
|
|
64
|
+
logger.info(
|
|
65
|
+
f"Dispatching bounded profile request for {bbox} ({start_time} to {end_time})"
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
from forcingkit.fetchers.erddap import fetch_erddap_stations_in_bbox
|
|
69
|
+
|
|
70
|
+
try:
|
|
71
|
+
profiles = fetch_erddap_stations_in_bbox(
|
|
72
|
+
bbox=bbox,
|
|
73
|
+
start_time=start_time,
|
|
74
|
+
end_time=end_time,
|
|
75
|
+
cache_dir=cache_dir,
|
|
76
|
+
cache_bust=cache_bust,
|
|
77
|
+
)
|
|
78
|
+
return profiles
|
|
79
|
+
except Exception as e:
|
|
80
|
+
logger.error(f"Failed to fetch bounded profiles: {e}")
|
|
81
|
+
return {}
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _rank_obc_candidates(bbox: list[float]) -> list[tuple]:
|
|
85
|
+
candidates = []
|
|
86
|
+
for module, fetch_func in get_obc_fetchers():
|
|
87
|
+
if not module.supports_bbox(bbox):
|
|
88
|
+
continue
|
|
89
|
+
meta = module.get_metadata()
|
|
90
|
+
domain_bbox = meta.get("domain_bbox")
|
|
91
|
+
area = _domain_area(domain_bbox) if domain_bbox else float("inf")
|
|
92
|
+
resolution = meta.get("resolution_approx_m", float("inf"))
|
|
93
|
+
candidates.append((area, resolution, module, fetch_func, meta))
|
|
94
|
+
|
|
95
|
+
candidates.sort(key=lambda c: (c[0], c[1]))
|
|
96
|
+
ranked = [(c[2], c[3], c[4]) for c in candidates]
|
|
97
|
+
if ranked:
|
|
98
|
+
logger.info(
|
|
99
|
+
f"OBC donor ranking for bbox {bbox}: "
|
|
100
|
+
+ " > ".join(
|
|
101
|
+
f"{c[4]['name']} (area={c[0]:.1f}°², ~{c[1]:.0f}m)" for c in candidates
|
|
102
|
+
)
|
|
103
|
+
)
|
|
104
|
+
return ranked
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def predict_obc_donor(bbox: list[float]) -> dict:
|
|
108
|
+
ranked = _rank_obc_candidates(bbox)
|
|
109
|
+
if ranked:
|
|
110
|
+
return ranked[0][2]
|
|
111
|
+
return {}
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def dispatch_obc_request(
|
|
115
|
+
start_date: str,
|
|
116
|
+
duration_hours: int,
|
|
117
|
+
bbox: list[float],
|
|
118
|
+
cache_dir: str = settings.cache_dir(),
|
|
119
|
+
cache_bust: bool = False,
|
|
120
|
+
zarr_path: Optional[str] = None,
|
|
121
|
+
allow_donor_fallback: bool = True,
|
|
122
|
+
include_tides: bool = True,
|
|
123
|
+
tidal_model: str = "GOT4.10c",
|
|
124
|
+
sponge_cells: int = 0,
|
|
125
|
+
pad_cells: int = 3,
|
|
126
|
+
vertical_spacing_m: float = 2.0,
|
|
127
|
+
) -> str:
|
|
128
|
+
from forcingkit.fetchers.necofs import OBC_SCHEMA
|
|
129
|
+
|
|
130
|
+
os.makedirs(cache_dir, exist_ok=True)
|
|
131
|
+
obc_cache_dir = os.path.join(cache_dir, "obc")
|
|
132
|
+
os.makedirs(obc_cache_dir, exist_ok=True)
|
|
133
|
+
|
|
134
|
+
import hashlib
|
|
135
|
+
import json
|
|
136
|
+
|
|
137
|
+
if not zarr_path:
|
|
138
|
+
key_str = f"obc_{start_date}_{duration_hours}_{bbox[0]}_{bbox[1]}_{bbox[2]}_{bbox[3]}_s{sponge_cells}_{include_tides}_{tidal_model}_{OBC_SCHEMA}_p{pad_cells}_dz{vertical_spacing_m}"
|
|
139
|
+
key_hash = hashlib.sha256(key_str.encode()).hexdigest()[:12]
|
|
140
|
+
zarr_name = f"obc_{key_hash}.zarr"
|
|
141
|
+
zarr_path = os.path.join(obc_cache_dir, zarr_name)
|
|
142
|
+
|
|
143
|
+
# Write sidecar provenance
|
|
144
|
+
sidecar_path = os.path.join(obc_cache_dir, f"obc_{key_hash}.json")
|
|
145
|
+
if not os.path.exists(sidecar_path) or cache_bust:
|
|
146
|
+
try:
|
|
147
|
+
with open(sidecar_path, "w") as f:
|
|
148
|
+
json.dump(
|
|
149
|
+
{
|
|
150
|
+
"id": key_hash,
|
|
151
|
+
"type": "open_boundary_conditions",
|
|
152
|
+
"start_date": start_date,
|
|
153
|
+
"duration_hours": duration_hours,
|
|
154
|
+
"bbox": bbox,
|
|
155
|
+
"sponge_cells": sponge_cells,
|
|
156
|
+
"include_tides": include_tides,
|
|
157
|
+
"tidal_model": tidal_model,
|
|
158
|
+
"schema": OBC_SCHEMA,
|
|
159
|
+
"pad_cells": pad_cells,
|
|
160
|
+
"vertical_spacing_m": vertical_spacing_m,
|
|
161
|
+
},
|
|
162
|
+
f,
|
|
163
|
+
indent=2,
|
|
164
|
+
)
|
|
165
|
+
except Exception:
|
|
166
|
+
pass
|
|
167
|
+
|
|
168
|
+
from forcingkit.zarr_stream import store_is_complete
|
|
169
|
+
|
|
170
|
+
if not cache_bust and store_is_complete(zarr_path, (OBC_SCHEMA,)):
|
|
171
|
+
logger.info(f"Cache hit for OBC: {zarr_path}")
|
|
172
|
+
return zarr_path
|
|
173
|
+
|
|
174
|
+
ranked = _rank_obc_candidates(bbox)
|
|
175
|
+
if not ranked:
|
|
176
|
+
raise ValueError(f"No suitable OBC fetcher found for bbox {bbox}")
|
|
177
|
+
|
|
178
|
+
ds = None
|
|
179
|
+
target_module = None
|
|
180
|
+
meta = None
|
|
181
|
+
candidates_to_try = ranked if allow_donor_fallback else ranked[:1]
|
|
182
|
+
for candidate_module, candidate_fetch_func, candidate_meta in candidates_to_try:
|
|
183
|
+
logger.info(f"Trying OBC donor: {candidate_meta['name']}")
|
|
184
|
+
# A donor that can deliver hour by hour is streamed straight to disk and published only
|
|
185
|
+
# when complete; a failure there is an error, never a shorter store.
|
|
186
|
+
if hasattr(candidate_module, "iter_parent"):
|
|
187
|
+
try:
|
|
188
|
+
return _stream_parent(
|
|
189
|
+
candidate_module,
|
|
190
|
+
zarr_path,
|
|
191
|
+
start_date,
|
|
192
|
+
duration_hours,
|
|
193
|
+
bbox,
|
|
194
|
+
pad_cells=pad_cells,
|
|
195
|
+
vertical_spacing_m=vertical_spacing_m,
|
|
196
|
+
sponge_cells=sponge_cells,
|
|
197
|
+
)
|
|
198
|
+
except Exception as e:
|
|
199
|
+
if not allow_donor_fallback:
|
|
200
|
+
raise RuntimeError(
|
|
201
|
+
f"{candidate_meta['name']} parent delivery failed: {e}"
|
|
202
|
+
) from e
|
|
203
|
+
logger.warning(f"{candidate_meta['name']} parent delivery failed: {e}")
|
|
204
|
+
continue
|
|
205
|
+
try:
|
|
206
|
+
ds = candidate_fetch_func(
|
|
207
|
+
start_date,
|
|
208
|
+
duration_hours,
|
|
209
|
+
bbox,
|
|
210
|
+
**_supported_kwargs(
|
|
211
|
+
candidate_fetch_func,
|
|
212
|
+
pad_cells=pad_cells,
|
|
213
|
+
vertical_spacing_m=vertical_spacing_m,
|
|
214
|
+
),
|
|
215
|
+
)
|
|
216
|
+
except Exception as e:
|
|
217
|
+
logger.warning(f"{candidate_meta['name']} OBC fetch raised: {e}")
|
|
218
|
+
ds = None
|
|
219
|
+
if ds is not None:
|
|
220
|
+
target_module = candidate_module
|
|
221
|
+
meta = candidate_meta
|
|
222
|
+
logger.info(f"OBC donor succeeded: {meta['name']}")
|
|
223
|
+
break
|
|
224
|
+
if allow_donor_fallback:
|
|
225
|
+
logger.warning(
|
|
226
|
+
f"{candidate_meta['name']} OBC fetch returned no data, trying next donor."
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
if ds is None or target_module is None:
|
|
230
|
+
tried = [c[2]["name"] for c in candidates_to_try]
|
|
231
|
+
raise RuntimeError(
|
|
232
|
+
f"Primary OBC donor {tried[0]} failed to return data."
|
|
233
|
+
if not allow_donor_fallback
|
|
234
|
+
else f"All OBC donors failed for bbox {bbox}. Tried: {tried}"
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
# REQ-3.1: Temporal Harmonization - Resample to hourly
|
|
238
|
+
logger.info("Aligning OBC data to shared hourly temporal index...")
|
|
239
|
+
if "time" in ds.dims:
|
|
240
|
+
import pandas as pd
|
|
241
|
+
|
|
242
|
+
if not isinstance(ds.indexes["time"], pd.DatetimeIndex):
|
|
243
|
+
# Attempt to convert cftime or object arrays to DatetimeIndex
|
|
244
|
+
try:
|
|
245
|
+
ds["time"] = ds.indexes["time"].to_datetimeindex()
|
|
246
|
+
except AttributeError:
|
|
247
|
+
ds["time"] = pd.to_datetime(ds.indexes["time"].values)
|
|
248
|
+
# Interpolate to strictly hourly
|
|
249
|
+
ds = ds.resample(time="1h").interpolate("linear")
|
|
250
|
+
|
|
251
|
+
# REQ-1.3: Tidal boundary condition integration via GOT4.10c or EOT20 (pyTMD).
|
|
252
|
+
|
|
253
|
+
# Cast strictly to Float32 for Oceananigans
|
|
254
|
+
for var in ds.data_vars:
|
|
255
|
+
if ds[var].dtype != np.float32:
|
|
256
|
+
ds[var] = ds[var].astype(np.float32)
|
|
257
|
+
|
|
258
|
+
# Convert Endianness for Julia
|
|
259
|
+
for var in list(ds.variables):
|
|
260
|
+
ds[var].encoding.clear()
|
|
261
|
+
if ds[var].dtype.kind in "iu":
|
|
262
|
+
ds[var].encoding["_FillValue"] = -9999
|
|
263
|
+
else:
|
|
264
|
+
ds[var].encoding["_FillValue"] = -9999.0
|
|
265
|
+
|
|
266
|
+
# Coordinates keep full precision: Float32 longitudes near -74 resolve only about
|
|
267
|
+
# 8e-6 degrees, enough to misplace a 0.002 degree grid relative to its child.
|
|
268
|
+
if (
|
|
269
|
+
(ds[var].dtype == "float64" or ds[var].dtype == "float32")
|
|
270
|
+
and "time" not in str(var)
|
|
271
|
+
and var not in _FULL_PRECISION_COORDS
|
|
272
|
+
):
|
|
273
|
+
ds[var] = ds[var].astype("<f4")
|
|
274
|
+
|
|
275
|
+
# Schema-versioned parent stores state their time axis in seconds from the first record.
|
|
276
|
+
if ds.attrs.get("schema") == OBC_SCHEMA and "time" in ds.coords:
|
|
277
|
+
import pandas as pd
|
|
278
|
+
|
|
279
|
+
first = pd.Timestamp(ds["time"].values[0]).strftime("%Y-%m-%dT%H:%M:%S")
|
|
280
|
+
ds["time"].encoding = {"units": f"seconds since {first}", "dtype": "float64"}
|
|
281
|
+
|
|
282
|
+
ds.attrs["source"] = target_module.__name__
|
|
283
|
+
ds.attrs["type"] = "3D Time-Varying Hindcast (Coupled Tides + Reanalysis)"
|
|
284
|
+
ds.attrs["duration_hours"] = duration_hours
|
|
285
|
+
ds.attrs["sponge_cells"] = sponge_cells
|
|
286
|
+
ds.attrs["schema"] = ds.attrs.get("schema", "legacy")
|
|
287
|
+
|
|
288
|
+
logger.info(f"Writing OBC data to Zarr: {zarr_path}")
|
|
289
|
+
ds.to_zarr(zarr_path, mode="w", consolidated=True, zarr_format=2)
|
|
290
|
+
return zarr_path
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
_FULL_PRECISION_COORDS = ("lat", "lon", "z", "z_face")
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def atmosphere_key(
|
|
297
|
+
bbox: list[float],
|
|
298
|
+
start_time: str,
|
|
299
|
+
hours: int,
|
|
300
|
+
resolution_deg: float,
|
|
301
|
+
margin_deg: float,
|
|
302
|
+
source: str = "hrrr",
|
|
303
|
+
) -> str:
|
|
304
|
+
"""Cache id of an atmosphere delivery; the schema is part of it."""
|
|
305
|
+
import hashlib
|
|
306
|
+
|
|
307
|
+
from forcingkit.fetchers.hrrr_atmosphere import HRRR_ATM_SCHEMA
|
|
308
|
+
|
|
309
|
+
key = (
|
|
310
|
+
f"atm_{source}_{HRRR_ATM_SCHEMA}_{bbox[0]}_{bbox[1]}_{bbox[2]}_{bbox[3]}_"
|
|
311
|
+
f"{start_time}_{hours}_{resolution_deg}_{margin_deg}"
|
|
312
|
+
)
|
|
313
|
+
return "atm_" + hashlib.sha256(key.encode()).hexdigest()[:12]
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def dispatch_atmosphere_request(
|
|
317
|
+
bbox: list[float],
|
|
318
|
+
start_time: str,
|
|
319
|
+
hours: int,
|
|
320
|
+
zarr_path: str,
|
|
321
|
+
resolution_deg: float = 0.03,
|
|
322
|
+
margin_deg: float = 0.25,
|
|
323
|
+
cache_bust: bool = False,
|
|
324
|
+
) -> str:
|
|
325
|
+
"""HRRR prescribed atmosphere for `hours` from `start_time`, streamed to `zarr_path`."""
|
|
326
|
+
from forcingkit.fetchers import hrrr_atmosphere
|
|
327
|
+
from forcingkit.zarr_stream import StreamingZarrWriter, store_is_complete
|
|
328
|
+
|
|
329
|
+
if not cache_bust and store_is_complete(
|
|
330
|
+
zarr_path, (hrrr_atmosphere.HRRR_ATM_SCHEMA,)
|
|
331
|
+
):
|
|
332
|
+
logger.info(f"Cache hit for atmosphere: {zarr_path}")
|
|
333
|
+
return zarr_path
|
|
334
|
+
|
|
335
|
+
writer = None
|
|
336
|
+
try:
|
|
337
|
+
for item in hrrr_atmosphere.iter_atmosphere(
|
|
338
|
+
start_time,
|
|
339
|
+
hours,
|
|
340
|
+
bbox,
|
|
341
|
+
resolution_deg=resolution_deg,
|
|
342
|
+
margin_deg=margin_deg,
|
|
343
|
+
):
|
|
344
|
+
if item[0] == "static":
|
|
345
|
+
writer = StreamingZarrWriter(
|
|
346
|
+
zarr_path,
|
|
347
|
+
item[1],
|
|
348
|
+
hrrr_atmosphere.RECORD_DIMS,
|
|
349
|
+
expected_records=hours + 3,
|
|
350
|
+
attrs=dict(item[1].attrs),
|
|
351
|
+
record_attrs={
|
|
352
|
+
k: {"units": u, "standard_name": sn}
|
|
353
|
+
for k, (u, sn) in hrrr_atmosphere.UNITS.items()
|
|
354
|
+
},
|
|
355
|
+
)
|
|
356
|
+
continue
|
|
357
|
+
if writer is None:
|
|
358
|
+
raise RuntimeError(
|
|
359
|
+
"atmosphere records arrived before the static fields"
|
|
360
|
+
)
|
|
361
|
+
writer.append(item[1], item[2])
|
|
362
|
+
if writer is None:
|
|
363
|
+
raise RuntimeError("HRRR yielded no data")
|
|
364
|
+
return writer.close()
|
|
365
|
+
except Exception:
|
|
366
|
+
if writer is not None:
|
|
367
|
+
writer.abort()
|
|
368
|
+
raise
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def _stream_parent(
|
|
372
|
+
module,
|
|
373
|
+
zarr_path: str,
|
|
374
|
+
start_date: str,
|
|
375
|
+
duration_hours: int,
|
|
376
|
+
bbox: list[float],
|
|
377
|
+
pad_cells: int,
|
|
378
|
+
vertical_spacing_m: float,
|
|
379
|
+
sponge_cells: int,
|
|
380
|
+
) -> str:
|
|
381
|
+
"""Write a donor's `iter_parent` records to `zarr_path` one hour at a time."""
|
|
382
|
+
from forcingkit.zarr_stream import StreamingZarrWriter
|
|
383
|
+
|
|
384
|
+
writer = None
|
|
385
|
+
try:
|
|
386
|
+
for item in module.iter_parent(
|
|
387
|
+
start_date, duration_hours, bbox, pad_cells, vertical_spacing_m
|
|
388
|
+
):
|
|
389
|
+
if item[0] == "static":
|
|
390
|
+
static = item[1]
|
|
391
|
+
attrs = dict(static.attrs)
|
|
392
|
+
attrs.update(
|
|
393
|
+
source=module.__name__,
|
|
394
|
+
duration_hours=duration_hours,
|
|
395
|
+
sponge_cells=sponge_cells,
|
|
396
|
+
)
|
|
397
|
+
writer = StreamingZarrWriter(
|
|
398
|
+
zarr_path,
|
|
399
|
+
static,
|
|
400
|
+
module.PARENT_RECORD_DIMS,
|
|
401
|
+
expected_records=duration_hours,
|
|
402
|
+
attrs=attrs,
|
|
403
|
+
record_attrs={
|
|
404
|
+
k: {"units": u, "standard_name": sn}
|
|
405
|
+
for k, (u, sn) in module.PARENT_UNITS.items()
|
|
406
|
+
},
|
|
407
|
+
)
|
|
408
|
+
continue
|
|
409
|
+
_, t, record = item
|
|
410
|
+
if writer is None:
|
|
411
|
+
raise RuntimeError("parent records arrived before the static fields")
|
|
412
|
+
writer.append(t, record)
|
|
413
|
+
if writer is None:
|
|
414
|
+
raise RuntimeError("the donor yielded no data")
|
|
415
|
+
return writer.close()
|
|
416
|
+
except Exception:
|
|
417
|
+
if writer is not None:
|
|
418
|
+
writer.abort()
|
|
419
|
+
raise
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def _supported_kwargs(func, **kwargs) -> dict:
|
|
423
|
+
"""The subset of `kwargs` that `func` accepts, so options for one donor's fetcher can be
|
|
424
|
+
offered to every candidate without breaking the shared (start, duration, bbox) signature."""
|
|
425
|
+
import inspect
|
|
426
|
+
|
|
427
|
+
params = inspect.signature(func).parameters
|
|
428
|
+
if any(p.kind is inspect.Parameter.VAR_KEYWORD for p in params.values()):
|
|
429
|
+
return kwargs
|
|
430
|
+
return {k: v for k, v in kwargs.items() if k in params}
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def get_obc_fetchers():
|
|
434
|
+
from forcingkit.fetchers import (
|
|
435
|
+
dbofs,
|
|
436
|
+
hycom,
|
|
437
|
+
necofs,
|
|
438
|
+
nyofs,
|
|
439
|
+
)
|
|
440
|
+
|
|
441
|
+
# Dynamically discover fetch_xxx_boundary_conditions functions.
|
|
442
|
+
# Not all modules have implemented OBC yet, so we check with hasattr.
|
|
443
|
+
fetchers = []
|
|
444
|
+
for module in [nyofs, dbofs, necofs, hycom]:
|
|
445
|
+
func_name = "fetch_" + module.__name__.split(".")[-1] + "_boundary_conditions"
|
|
446
|
+
if hasattr(module, func_name):
|
|
447
|
+
fetchers.append((module, getattr(module, func_name)))
|
|
448
|
+
return fetchers
|