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 ADDED
File without changes
@@ -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