forcingkit 0.5.0__tar.gz → 0.7.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.5.0 → forcingkit-0.7.0}/PKG-INFO +1 -1
- {forcingkit-0.5.0 → forcingkit-0.7.0}/docs/source/atmospheric_forcing.rst +21 -0
- {forcingkit-0.5.0 → forcingkit-0.7.0}/docs/source/conf.py +1 -1
- {forcingkit-0.5.0 → forcingkit-0.7.0}/pyproject.toml +1 -1
- {forcingkit-0.5.0 → forcingkit-0.7.0}/src/forcingkit/fetchers/coastwatch.py +176 -2
- forcingkit-0.7.0/src/forcingkit/fetchers/hrrr_points.py +224 -0
- {forcingkit-0.5.0 → forcingkit-0.7.0}/tests/unit/test_coastwatch.py +79 -0
- forcingkit-0.7.0/tests/unit/test_hrrr_points.py +134 -0
- {forcingkit-0.5.0 → forcingkit-0.7.0}/uv.lock +1 -1
- {forcingkit-0.5.0 → forcingkit-0.7.0}/.agents/forcingkit.md +0 -0
- {forcingkit-0.5.0 → forcingkit-0.7.0}/.claude/CLAUDE.md +0 -0
- {forcingkit-0.5.0 → forcingkit-0.7.0}/.env.template +0 -0
- {forcingkit-0.5.0 → forcingkit-0.7.0}/.github/copilot-instructions.md +0 -0
- {forcingkit-0.5.0 → forcingkit-0.7.0}/.github/workflows/docs.yml +0 -0
- {forcingkit-0.5.0 → forcingkit-0.7.0}/.github/workflows/publish.yml +0 -0
- {forcingkit-0.5.0 → forcingkit-0.7.0}/.github/workflows/tests.yml +0 -0
- {forcingkit-0.5.0 → forcingkit-0.7.0}/.gitignore +0 -0
- {forcingkit-0.5.0 → forcingkit-0.7.0}/.markdownlint.yaml +0 -0
- {forcingkit-0.5.0 → forcingkit-0.7.0}/.pre-commit-config.yaml +0 -0
- {forcingkit-0.5.0 → forcingkit-0.7.0}/.python-version +0 -0
- {forcingkit-0.5.0 → forcingkit-0.7.0}/AGENTS.md +0 -0
- {forcingkit-0.5.0 → forcingkit-0.7.0}/CONTRIBUTING.md +0 -0
- {forcingkit-0.5.0 → forcingkit-0.7.0}/Dockerfile +0 -0
- {forcingkit-0.5.0 → forcingkit-0.7.0}/LICENSE +0 -0
- {forcingkit-0.5.0 → forcingkit-0.7.0}/README.md +0 -0
- {forcingkit-0.5.0 → forcingkit-0.7.0}/docker-compose.yml +0 -0
- {forcingkit-0.5.0 → forcingkit-0.7.0}/docs/Makefile +0 -0
- {forcingkit-0.5.0 → forcingkit-0.7.0}/docs/make.bat +0 -0
- {forcingkit-0.5.0 → forcingkit-0.7.0}/docs/requirements-docs.txt +0 -0
- {forcingkit-0.5.0 → forcingkit-0.7.0}/docs/source/_extra/CNAME +0 -0
- {forcingkit-0.5.0 → forcingkit-0.7.0}/docs/source/_static/custom.css +0 -0
- {forcingkit-0.5.0 → forcingkit-0.7.0}/docs/source/_static/forcingkit-noreaster-wind.gif +0 -0
- {forcingkit-0.5.0 → forcingkit-0.7.0}/docs/source/fetchers.rst +0 -0
- {forcingkit-0.5.0 → forcingkit-0.7.0}/docs/source/hycom.rst +0 -0
- {forcingkit-0.5.0 → forcingkit-0.7.0}/docs/source/index.rst +0 -0
- {forcingkit-0.5.0 → forcingkit-0.7.0}/docs/source/necofs.rst +0 -0
- {forcingkit-0.5.0 → forcingkit-0.7.0}/docs/source/nyofs.rst +0 -0
- {forcingkit-0.5.0 → forcingkit-0.7.0}/docs/source/rivers.rst +0 -0
- {forcingkit-0.5.0 → forcingkit-0.7.0}/docs/source/roadmap.rst +0 -0
- {forcingkit-0.5.0 → forcingkit-0.7.0}/docs/source/sources.rst +0 -0
- {forcingkit-0.5.0 → forcingkit-0.7.0}/main.py +0 -0
- {forcingkit-0.5.0 → forcingkit-0.7.0}/service/__init__.py +0 -0
- {forcingkit-0.5.0 → forcingkit-0.7.0}/service/forcingkit_serve/__init__.py +0 -0
- {forcingkit-0.5.0 → forcingkit-0.7.0}/service/forcingkit_serve/main.py +0 -0
- {forcingkit-0.5.0 → forcingkit-0.7.0}/service/forcingkit_serve/routers/bathymetry.py +0 -0
- {forcingkit-0.5.0 → forcingkit-0.7.0}/service/forcingkit_serve/routers/plotly_api.py +0 -0
- {forcingkit-0.5.0 → forcingkit-0.7.0}/service/forcingkit_serve/routers/removed.py +0 -0
- {forcingkit-0.5.0 → forcingkit-0.7.0}/service/forcingkit_serve/routers/viewer.py +0 -0
- {forcingkit-0.5.0 → forcingkit-0.7.0}/service/run_server.py +0 -0
- {forcingkit-0.5.0 → forcingkit-0.7.0}/src/forcingkit/__init__.py +0 -0
- {forcingkit-0.5.0 → forcingkit-0.7.0}/src/forcingkit/data/rivers/northeast_us.json +0 -0
- {forcingkit-0.5.0 → forcingkit-0.7.0}/src/forcingkit/dispatcher.py +0 -0
- {forcingkit-0.5.0 → forcingkit-0.7.0}/src/forcingkit/fetchers/dbofs.py +0 -0
- {forcingkit-0.5.0 → forcingkit-0.7.0}/src/forcingkit/fetchers/erddap.py +0 -0
- {forcingkit-0.5.0 → forcingkit-0.7.0}/src/forcingkit/fetchers/hrrr.py +0 -0
- {forcingkit-0.5.0 → forcingkit-0.7.0}/src/forcingkit/fetchers/hrrr_atmosphere.py +0 -0
- {forcingkit-0.5.0 → forcingkit-0.7.0}/src/forcingkit/fetchers/hycom.py +0 -0
- {forcingkit-0.5.0 → forcingkit-0.7.0}/src/forcingkit/fetchers/hydrography.py +0 -0
- {forcingkit-0.5.0 → forcingkit-0.7.0}/src/forcingkit/fetchers/ndbc.py +0 -0
- {forcingkit-0.5.0 → forcingkit-0.7.0}/src/forcingkit/fetchers/necofs.py +0 -0
- {forcingkit-0.5.0 → forcingkit-0.7.0}/src/forcingkit/fetchers/noaa.py +0 -0
- {forcingkit-0.5.0 → forcingkit-0.7.0}/src/forcingkit/fetchers/nyofs.py +0 -0
- {forcingkit-0.5.0 → forcingkit-0.7.0}/src/forcingkit/fetchers/usgs.py +0 -0
- {forcingkit-0.5.0 → forcingkit-0.7.0}/src/forcingkit/fetchers/usgs_rivers.py +0 -0
- {forcingkit-0.5.0 → forcingkit-0.7.0}/src/forcingkit/settings.py +0 -0
- {forcingkit-0.5.0 → forcingkit-0.7.0}/src/forcingkit/zarr_stream.py +0 -0
- {forcingkit-0.5.0 → forcingkit-0.7.0}/static/app.js +0 -0
- {forcingkit-0.5.0 → forcingkit-0.7.0}/static/favicon.ico +0 -0
- {forcingkit-0.5.0 → forcingkit-0.7.0}/static/favicon.svg +0 -0
- {forcingkit-0.5.0 → forcingkit-0.7.0}/static/index.html +0 -0
- {forcingkit-0.5.0 → forcingkit-0.7.0}/static/logo.svg +0 -0
- {forcingkit-0.5.0 → forcingkit-0.7.0}/static/preview3d.js +0 -0
- {forcingkit-0.5.0 → forcingkit-0.7.0}/static/styles.css +0 -0
- {forcingkit-0.5.0 → forcingkit-0.7.0}/tests/__init__.py +0 -0
- {forcingkit-0.5.0 → forcingkit-0.7.0}/tests/integration/test_auth.py +0 -0
- {forcingkit-0.5.0 → forcingkit-0.7.0}/tests/integration/test_erddap_fetch.py +0 -0
- {forcingkit-0.5.0 → forcingkit-0.7.0}/tests/integration/test_nyofs_obc_fetch.py +0 -0
- {forcingkit-0.5.0 → forcingkit-0.7.0}/tests/integration/test_obc_offshore_nj.py +0 -0
- {forcingkit-0.5.0 → forcingkit-0.7.0}/tests/integration/test_ofs_archive_paths.py +0 -0
- {forcingkit-0.5.0 → forcingkit-0.7.0}/tests/integration/test_s3_roms_fetchers.py +0 -0
- {forcingkit-0.5.0 → forcingkit-0.7.0}/tests/scripts/inspect_dem.py +0 -0
- {forcingkit-0.5.0 → forcingkit-0.7.0}/tests/scripts/inspect_grib.py +0 -0
- {forcingkit-0.5.0 → forcingkit-0.7.0}/tests/scripts/inspect_zarr.py +0 -0
- {forcingkit-0.5.0 → forcingkit-0.7.0}/tests/unit/test_dbofs.py +0 -0
- {forcingkit-0.5.0 → forcingkit-0.7.0}/tests/unit/test_dispatcher.py +0 -0
- {forcingkit-0.5.0 → forcingkit-0.7.0}/tests/unit/test_hrrr_atm.py +0 -0
- {forcingkit-0.5.0 → forcingkit-0.7.0}/tests/unit/test_hrrr_idx.py +0 -0
- {forcingkit-0.5.0 → forcingkit-0.7.0}/tests/unit/test_hycom.py +0 -0
- {forcingkit-0.5.0 → forcingkit-0.7.0}/tests/unit/test_main.py +0 -0
- {forcingkit-0.5.0 → forcingkit-0.7.0}/tests/unit/test_ndbc.py +0 -0
- {forcingkit-0.5.0 → forcingkit-0.7.0}/tests/unit/test_necofs_parent.py +0 -0
- {forcingkit-0.5.0 → forcingkit-0.7.0}/tests/unit/test_noaa_currents.py +0 -0
- {forcingkit-0.5.0 → forcingkit-0.7.0}/tests/unit/test_noaa_datum.py +0 -0
- {forcingkit-0.5.0 → forcingkit-0.7.0}/tests/unit/test_nyofs.py +0 -0
- {forcingkit-0.5.0 → forcingkit-0.7.0}/tests/unit/test_obc_donor_report.py +0 -0
- {forcingkit-0.5.0 → forcingkit-0.7.0}/tests/unit/test_obc_pipeline.py +0 -0
- {forcingkit-0.5.0 → forcingkit-0.7.0}/tests/unit/test_removed_routes.py +0 -0
- {forcingkit-0.5.0 → forcingkit-0.7.0}/tests/unit/test_settings.py +0 -0
- {forcingkit-0.5.0 → forcingkit-0.7.0}/tests/unit/test_usgs.py +0 -0
- {forcingkit-0.5.0 → forcingkit-0.7.0}/tests/unit/test_usgs_rivers.py +0 -0
- {forcingkit-0.5.0 → forcingkit-0.7.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.7.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
|
|
@@ -147,6 +147,27 @@ coastal domains from about 15 to 85 km across. Hourly radiation and precipitatio
|
|
|
147
147
|
*Limits.* CONUS only. A forecast product, not a reanalysis: it carries forecast-model biases and is
|
|
148
148
|
not homogeneous across HRRR versions (v4 since December 2020). Radiation is instantaneous.
|
|
149
149
|
|
|
150
|
+
HRRR forecasts at points
|
|
151
|
+
~~~~~~~~~~~~~~~~~~~~~~~~
|
|
152
|
+
|
|
153
|
+
``forcingkit.fetchers.hrrr_points.fetch_points(cycles, leads, points, fields)`` returns HRRR forecasts
|
|
154
|
+
at caller-supplied points for any cycles and lead hours, one row per (issue time, valid time, lead,
|
|
155
|
+
point, field, value). Keeping both times makes the record bitemporal: a forecast can be compared
|
|
156
|
+
with what was later observed, by how far ahead it was made. Fields are 10 m wind (rotated to east
|
|
157
|
+
and north), gust, 2 m temperature and dew point, sea-level pressure and visibility, in the units
|
|
158
|
+
HRRR publishes. Each point takes an inverse-distance-squared mean of up to four HRRR water cells
|
|
159
|
+
within 6 km, so a coastal point never takes a land cell's temperature or wind.
|
|
160
|
+
|
|
161
|
+
Leads a cycle does not carry are skipped (HRRR v4 runs to 48 h at 00, 06, 12 and 18 UTC and to
|
|
162
|
+
18 h otherwise); a missing field in a published file raises. Three leads of seven fields at four
|
|
163
|
+
points took 28 s on a warm connection, so one cycle's leads 1 to 18 take about three minutes.
|
|
164
|
+
|
|
165
|
+
.. code-block:: python
|
|
166
|
+
|
|
167
|
+
from forcingkit.fetchers.hrrr_points import fetch_points
|
|
168
|
+
|
|
169
|
+
df = fetch_points(["2026-10-14T08:00"], [1, 6, 18], {"buoy": (-73.58, 40.96)})
|
|
170
|
+
|
|
150
171
|
ERA5 (fallback)
|
|
151
172
|
~~~~~~~~~~~~~~~
|
|
152
173
|
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "forcingkit"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.7.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"
|
|
@@ -292,6 +292,16 @@ def latest_time(product_id: str) -> pd.Timestamp | None:
|
|
|
292
292
|
return _utc(lines[-1]) if len(lines) >= 3 else None # header, units row, value
|
|
293
293
|
|
|
294
294
|
|
|
295
|
+
def first_time(product_id: str) -> pd.Timestamp | None:
|
|
296
|
+
"""The product's earliest time step (the near-real-time sectors keep a rolling window of a few months)."""
|
|
297
|
+
p = PRODUCTS[product_id]
|
|
298
|
+
resp = _get(f"{p.server}/griddap/{p.dataset}.csv?time[0]")
|
|
299
|
+
if resp is None:
|
|
300
|
+
return None
|
|
301
|
+
lines = [line for line in resp.text.strip().splitlines() if line]
|
|
302
|
+
return _utc(lines[-1]) if len(lines) >= 3 else None
|
|
303
|
+
|
|
304
|
+
|
|
295
305
|
def fetch(
|
|
296
306
|
product_id: str, bbox: list[float], start: datetime, end: datetime
|
|
297
307
|
) -> xr.Dataset:
|
|
@@ -300,8 +310,18 @@ def fetch(
|
|
|
300
310
|
The window is trimmed to the product's latest time step. An empty window returns a Dataset with a zero-length
|
|
301
311
|
time axis rather than raising.
|
|
302
312
|
"""
|
|
313
|
+
return _fetch_window(product_id, bbox, start, end, latest_time(product_id))
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def _fetch_window(
|
|
317
|
+
product_id: str,
|
|
318
|
+
bbox: list[float],
|
|
319
|
+
start: datetime,
|
|
320
|
+
end: datetime,
|
|
321
|
+
last: pd.Timestamp | None,
|
|
322
|
+
) -> xr.Dataset:
|
|
323
|
+
"""`fetch` with the product's latest time already known (None: the product has no data)."""
|
|
303
324
|
p = PRODUCTS[product_id]
|
|
304
|
-
last = latest_time(product_id)
|
|
305
325
|
if last is not None and _utc(end) > last:
|
|
306
326
|
end = last.to_pydatetime()
|
|
307
327
|
resp = (
|
|
@@ -428,6 +448,155 @@ def composite(
|
|
|
428
448
|
return out
|
|
429
449
|
|
|
430
450
|
|
|
451
|
+
def fetch_range(
|
|
452
|
+
product_id: str,
|
|
453
|
+
bbox: list[float],
|
|
454
|
+
start: datetime,
|
|
455
|
+
end: datetime,
|
|
456
|
+
chunk_days: int = 10,
|
|
457
|
+
) -> xr.Dataset:
|
|
458
|
+
"""`fetch` over a long window in chunks of `chunk_days`, so each ERDDAP response stays small (the servers'
|
|
459
|
+
proxies refuse long requests); the chunks are joined on time.
|
|
460
|
+
|
|
461
|
+
The product's time range is looked up once, and the window is clamped to it (the near-real-time sectors keep
|
|
462
|
+
only a few months). A chunk that still fails after its retries is logged and skipped, so one bad request costs
|
|
463
|
+
those days rather than the whole series. Raises only when the time range itself cannot be read.
|
|
464
|
+
"""
|
|
465
|
+
last = latest_time(product_id)
|
|
466
|
+
first = first_time(product_id) if last is not None else None
|
|
467
|
+
t0 = max(_utc(start), first) if first is not None else _utc(start)
|
|
468
|
+
t1 = min(_utc(end), last) if last is not None else _utc(end)
|
|
469
|
+
parts = []
|
|
470
|
+
while last is not None and t0 <= t1:
|
|
471
|
+
t_end = min(t1, t0 + pd.Timedelta(days=chunk_days) - pd.Timedelta(seconds=1))
|
|
472
|
+
try:
|
|
473
|
+
ds = _fetch_window(
|
|
474
|
+
product_id, bbox, t0.to_pydatetime(), t_end.to_pydatetime(), last
|
|
475
|
+
)
|
|
476
|
+
except Exception as exc:
|
|
477
|
+
logger.warning(
|
|
478
|
+
"satellite chunk skipped for %s, %s to %s: %s",
|
|
479
|
+
product_id,
|
|
480
|
+
_iso(t0),
|
|
481
|
+
_iso(t_end),
|
|
482
|
+
exc,
|
|
483
|
+
)
|
|
484
|
+
else:
|
|
485
|
+
if ds.sizes.get("time", 0):
|
|
486
|
+
parts.append(ds)
|
|
487
|
+
t0 = t_end + pd.Timedelta(seconds=1)
|
|
488
|
+
if not parts:
|
|
489
|
+
return _fetch_window(
|
|
490
|
+
product_id,
|
|
491
|
+
bbox,
|
|
492
|
+
_utc(end).to_pydatetime(),
|
|
493
|
+
_utc(start).to_pydatetime(),
|
|
494
|
+
None,
|
|
495
|
+
) # empty
|
|
496
|
+
out = xr.concat(parts, dim="time") if len(parts) > 1 else parts[0]
|
|
497
|
+
_, keep = np.unique(out["time"].values, return_index=True)
|
|
498
|
+
return out.isel(time=np.sort(keep))
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
def composite_series(
|
|
502
|
+
product_ids: list[str],
|
|
503
|
+
bbox: list[float],
|
|
504
|
+
frame_ends: list[datetime],
|
|
505
|
+
days: int = 7,
|
|
506
|
+
) -> xr.Dataset:
|
|
507
|
+
"""`composite` at each of `frame_ends` (ascending), from one fetch per product: for every frame, each pixel's
|
|
508
|
+
most recent valid value across the products within `days` before the frame's end, with its age in days.
|
|
509
|
+
|
|
510
|
+
Returns a Dataset with `value` and `age_days` (time, lat, lon; NaN where no pixel was valid within the window),
|
|
511
|
+
`time` being the frame ends, on the first available product's grid. Sensors that fail are skipped, as in
|
|
512
|
+
`composite`; a frame with no valid pixel is all NaN rather than an error.
|
|
513
|
+
"""
|
|
514
|
+
if not frame_ends:
|
|
515
|
+
raise ValueError("no frame ends given")
|
|
516
|
+
ends = [_utc(t) for t in frame_ends]
|
|
517
|
+
if any(b <= a for a, b in zip(ends, ends[1:])):
|
|
518
|
+
raise ValueError("frame_ends must be strictly ascending")
|
|
519
|
+
start = ends[0] - pd.Timedelta(days=days)
|
|
520
|
+
slices: list[tuple[np.datetime64, int, np.ndarray]] = []
|
|
521
|
+
used: list[str] = []
|
|
522
|
+
lat = lon = None
|
|
523
|
+
name = PRODUCTS[product_ids[0]].name
|
|
524
|
+
units = ""
|
|
525
|
+
for pid in product_ids:
|
|
526
|
+
try:
|
|
527
|
+
ds = fetch_range(pid, bbox, start.to_pydatetime(), ends[-1].to_pydatetime())
|
|
528
|
+
except Exception as exc: # one sensor's outage should not drop the others
|
|
529
|
+
logger.warning("satellite fetch failed for %s: %s", pid, exc)
|
|
530
|
+
continue
|
|
531
|
+
if ds.sizes.get("time", 0) == 0:
|
|
532
|
+
continue
|
|
533
|
+
da = ds[PRODUCTS[pid].name]
|
|
534
|
+
if lat is None or lon is None:
|
|
535
|
+
lat, lon = da["lat"].values, da["lon"].values
|
|
536
|
+
units = da.attrs.get("units", "")
|
|
537
|
+
elif not (
|
|
538
|
+
np.array_equal(da["lat"].values, lat)
|
|
539
|
+
and np.array_equal(da["lon"].values, lon)
|
|
540
|
+
):
|
|
541
|
+
dlat = float(np.abs(np.diff(lat)).mean()) if lat.size > 1 else 0.01
|
|
542
|
+
da = da.reindex(lat=lat, lon=lon, method="nearest", tolerance=dlat / 2)
|
|
543
|
+
k = len(used)
|
|
544
|
+
used.append(pid)
|
|
545
|
+
for ti in range(da.sizes["time"]):
|
|
546
|
+
slices.append((_naive64(da["time"].values[ti]), k, da.isel(time=ti).values))
|
|
547
|
+
if lat is None or lon is None:
|
|
548
|
+
raise ValueError(
|
|
549
|
+
f"no valid data for {product_ids} between {_iso(start)} and {_iso(ends[-1])}"
|
|
550
|
+
)
|
|
551
|
+
slices.sort(key=lambda item: (item[0], item[1]))
|
|
552
|
+
shape = (lat.size, lon.size)
|
|
553
|
+
best = np.full(shape, np.nan, dtype=np.float32)
|
|
554
|
+
best_t = np.full(shape, np.datetime64("NaT"), dtype="datetime64[ns]")
|
|
555
|
+
values = np.full((len(ends),) + shape, np.nan, dtype=np.float32)
|
|
556
|
+
ages = np.full((len(ends),) + shape, np.nan, dtype=np.float32)
|
|
557
|
+
window = np.timedelta64(days * 86400, "s")
|
|
558
|
+
i = 0
|
|
559
|
+
for f, end in enumerate(ends):
|
|
560
|
+
t_end = _naive64(end)
|
|
561
|
+
while i < len(slices) and slices[i][0] <= t_end:
|
|
562
|
+
t, _, v = slices[i]
|
|
563
|
+
newer = np.isfinite(v) & (np.isnat(best_t) | (best_t <= t))
|
|
564
|
+
best[newer] = v[newer]
|
|
565
|
+
best_t[newer] = t
|
|
566
|
+
i += 1
|
|
567
|
+
age = t_end - best_t
|
|
568
|
+
ok = ~np.isnat(best_t) & (age <= window)
|
|
569
|
+
values[f][ok] = best[ok]
|
|
570
|
+
ages[f][ok] = (
|
|
571
|
+
age[ok].astype("timedelta64[s]").astype(np.float64) / 86400.0
|
|
572
|
+
).astype(np.float32)
|
|
573
|
+
times = pd.DatetimeIndex([e.tz_localize(None) for e in ends])
|
|
574
|
+
return xr.Dataset(
|
|
575
|
+
{
|
|
576
|
+
"value": (
|
|
577
|
+
("time", "lat", "lon"),
|
|
578
|
+
values,
|
|
579
|
+
{"units": units, "long_name": name},
|
|
580
|
+
),
|
|
581
|
+
"age_days": (
|
|
582
|
+
("time", "lat", "lon"),
|
|
583
|
+
ages,
|
|
584
|
+
{
|
|
585
|
+
"units": "days",
|
|
586
|
+
"long_name": "age of the pixel's value at the frame's end",
|
|
587
|
+
},
|
|
588
|
+
),
|
|
589
|
+
},
|
|
590
|
+
coords={"time": times, "lat": lat, "lon": lon},
|
|
591
|
+
attrs={
|
|
592
|
+
"quantity": name,
|
|
593
|
+
"products": ",".join(used),
|
|
594
|
+
"attribution": "; ".join(sorted({PRODUCTS[p].attribution for p in used})),
|
|
595
|
+
"window_days": days,
|
|
596
|
+
},
|
|
597
|
+
)
|
|
598
|
+
|
|
599
|
+
|
|
431
600
|
def mask_coast(ds: xr.Dataset, pixels: int = 1, var: str = "value") -> xr.Dataset:
|
|
432
601
|
"""Drop `pixels` of water along the shore, where land adjacency biases ocean colour.
|
|
433
602
|
|
|
@@ -439,7 +608,12 @@ def mask_coast(ds: xr.Dataset, pixels: int = 1, var: str = "value") -> xr.Datase
|
|
|
439
608
|
if pixels <= 0:
|
|
440
609
|
return ds
|
|
441
610
|
water = np.isfinite(ds[var].values)
|
|
442
|
-
|
|
611
|
+
if water.ndim == 3: # a series: each frame on its own
|
|
612
|
+
keep = np.stack(
|
|
613
|
+
[binary_erosion(w, iterations=pixels, border_value=1) for w in water]
|
|
614
|
+
)
|
|
615
|
+
else:
|
|
616
|
+
keep = binary_erosion(water, iterations=pixels, border_value=1)
|
|
443
617
|
out = ds.copy()
|
|
444
618
|
for name in out.data_vars:
|
|
445
619
|
if out[name].dims == ds[var].dims:
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
"""HRRR forecasts at points, kept with both times: when each forecast was issued and the time it is for.
|
|
2
|
+
|
|
3
|
+
For each requested cycle (the issue time) and lead hour, the surface fields are read by byte range from that
|
|
4
|
+
cycle's `wrfsfcfNN` file on the anonymous AWS bucket (`noaa-hrrr-bdp-pds`, which holds every run since 2014-07-30),
|
|
5
|
+
and the value at each point is taken from the nearest HRRR water cells: an inverse-distance-squared mean of up to
|
|
6
|
+
four water cells within `max_km`, so a coastal point never takes a land cell's temperature or wind. Winds are
|
|
7
|
+
rotated from the Lambert grid axes to east and north. Values stay in the units HRRR publishes (m/s, K, Pa, m).
|
|
8
|
+
|
|
9
|
+
The result is one row per (issue_time, valid_time, lead_h, point, field, value): a bitemporal record, so a
|
|
10
|
+
forecast's error can later be measured by how far ahead it was made. A lead a cycle does not carry (HRRR runs to
|
|
11
|
+
48 h only at 00, 06, 12 and 18 UTC) is skipped; a missing field in a file that exists is an error.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import concurrent.futures
|
|
17
|
+
import logging
|
|
18
|
+
import os
|
|
19
|
+
import tempfile
|
|
20
|
+
from typing import Iterable
|
|
21
|
+
|
|
22
|
+
import numpy as np
|
|
23
|
+
import pandas as pd
|
|
24
|
+
|
|
25
|
+
from .hrrr import _fetch_s3_byte_ranges, _parse_idx, fs
|
|
26
|
+
from .hrrr_atmosphere import _read_message, earth_relative_winds
|
|
27
|
+
|
|
28
|
+
logger = logging.getLogger(__name__)
|
|
29
|
+
|
|
30
|
+
# GRIB variable and level of each field in wrfsfcfNN.
|
|
31
|
+
FIELDS = {
|
|
32
|
+
"u10": ("UGRD", "10 m above ground"),
|
|
33
|
+
"v10": ("VGRD", "10 m above ground"),
|
|
34
|
+
"gust": ("GUST", "surface"),
|
|
35
|
+
"t2m": ("TMP", "2 m above ground"),
|
|
36
|
+
"d2m": ("DPT", "2 m above ground"),
|
|
37
|
+
"mslp": ("MSLMA", "mean sea level"),
|
|
38
|
+
"vis": ("VIS", "surface"),
|
|
39
|
+
}
|
|
40
|
+
DEFAULT_FIELDS = tuple(FIELDS)
|
|
41
|
+
UNITS = {
|
|
42
|
+
"u10": "m s-1",
|
|
43
|
+
"v10": "m s-1",
|
|
44
|
+
"gust": "m s-1",
|
|
45
|
+
"t2m": "K",
|
|
46
|
+
"d2m": "K",
|
|
47
|
+
"mslp": "Pa",
|
|
48
|
+
"vis": "m",
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def s3_key(cycle: pd.Timestamp, lead: int) -> str:
|
|
53
|
+
return f"noaa-hrrr-bdp-pds/hrrr.{cycle.strftime('%Y%m%d')}/conus/hrrr.t{cycle.strftime('%H')}z.wrfsfcf{lead:02d}.grib2"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def idx_pattern(field: str, lead: int) -> str:
|
|
57
|
+
"""The .idx text of one field at one lead: "anl" at 0, "N hour fcst" after (instantaneous fields only)."""
|
|
58
|
+
var, level = FIELDS[field]
|
|
59
|
+
return f":{var}:{level}:{'anl' if lead == 0 else f'{lead} hour fcst'}:"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def max_lead(cycle: pd.Timestamp) -> int:
|
|
63
|
+
"""The longest lead a cycle carries: 48 h at 00, 06, 12 and 18 UTC (36 h before HRRR v4, 2020-12-02), else 18 h."""
|
|
64
|
+
if cycle.hour % 6:
|
|
65
|
+
return 18
|
|
66
|
+
return 48 if cycle >= pd.Timestamp("2020-12-02") else 36
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class PointWeights:
|
|
70
|
+
"""Inverse-distance-squared weights from the nearest water cells to each point, built once per grid."""
|
|
71
|
+
|
|
72
|
+
def __init__(
|
|
73
|
+
self,
|
|
74
|
+
lon,
|
|
75
|
+
lat,
|
|
76
|
+
land,
|
|
77
|
+
points: dict[str, tuple[float, float]],
|
|
78
|
+
k: int = 4,
|
|
79
|
+
max_km: float = 6.0,
|
|
80
|
+
):
|
|
81
|
+
lon = ((np.asarray(lon, dtype=float) + 180.0) % 360.0) - 180.0
|
|
82
|
+
lat = np.asarray(lat, dtype=float)
|
|
83
|
+
water = np.asarray(land) < 0.5
|
|
84
|
+
self.names = list(points)
|
|
85
|
+
self.cells: list[np.ndarray] = []
|
|
86
|
+
self.weights: list[np.ndarray] = []
|
|
87
|
+
flat_lon, flat_lat, flat_water = lon.ravel(), lat.ravel(), water.ravel()
|
|
88
|
+
for name in self.names:
|
|
89
|
+
plon, plat = points[name]
|
|
90
|
+
dx = (flat_lon - plon) * 111.32 * np.cos(np.deg2rad(plat))
|
|
91
|
+
dy = (flat_lat - plat) * 110.57
|
|
92
|
+
d = np.hypot(dx, dy)
|
|
93
|
+
d[~flat_water] = np.inf
|
|
94
|
+
near = np.argsort(d)[:k]
|
|
95
|
+
near = near[d[near] <= max_km]
|
|
96
|
+
if not len(near):
|
|
97
|
+
logger.warning(
|
|
98
|
+
"HRRR points: no water cell within %.0f km of %s", max_km, name
|
|
99
|
+
)
|
|
100
|
+
self.cells.append(np.array([], dtype=int))
|
|
101
|
+
self.weights.append(np.array([]))
|
|
102
|
+
continue
|
|
103
|
+
dist = d[near]
|
|
104
|
+
if dist[0] < 1e-6: # on a cell centre
|
|
105
|
+
w = np.zeros(len(near))
|
|
106
|
+
w[0] = 1.0
|
|
107
|
+
else:
|
|
108
|
+
w = 1.0 / dist**2
|
|
109
|
+
self.cells.append(near)
|
|
110
|
+
self.weights.append(w / w.sum())
|
|
111
|
+
|
|
112
|
+
def __call__(self, field) -> dict[str, float]:
|
|
113
|
+
flat = np.asarray(field, dtype=float).ravel()
|
|
114
|
+
return {
|
|
115
|
+
name: float(np.dot(flat[c], w)) if len(c) else float("nan")
|
|
116
|
+
for name, c, w in zip(self.names, self.cells, self.weights)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _fetch_lead(cycle: pd.Timestamp, lead: int, fields: Iterable[str], workdir: str):
|
|
121
|
+
"""The requested fields (and the land mask) of one cycle and lead, on HRRR's grid; None if the file is absent."""
|
|
122
|
+
key = s3_key(cycle, lead)
|
|
123
|
+
try:
|
|
124
|
+
with fs.open(key + ".idx", "r") as f:
|
|
125
|
+
idx = f.read()
|
|
126
|
+
except FileNotFoundError:
|
|
127
|
+
return None
|
|
128
|
+
patterns = {name: idx_pattern(name, lead) for name in fields} | {
|
|
129
|
+
"land": ":LAND:surface:"
|
|
130
|
+
}
|
|
131
|
+
out = {}
|
|
132
|
+
for name, pattern in patterns.items():
|
|
133
|
+
ranges = _parse_idx(idx, (pattern,))
|
|
134
|
+
if name == "land":
|
|
135
|
+
ranges = ranges[:1]
|
|
136
|
+
if len(ranges) != 1:
|
|
137
|
+
raise FileNotFoundError(
|
|
138
|
+
f"HRRR s3://{key}: expected one message for {pattern!r}, found {len(ranges)}"
|
|
139
|
+
)
|
|
140
|
+
path = os.path.join(
|
|
141
|
+
workdir, f"{name}_{cycle.strftime('%Y%m%d%H')}_f{lead:02d}.grib2"
|
|
142
|
+
)
|
|
143
|
+
_fetch_s3_byte_ranges(key, ranges, path)
|
|
144
|
+
try:
|
|
145
|
+
out[name] = _read_message(path)
|
|
146
|
+
finally:
|
|
147
|
+
os.remove(path)
|
|
148
|
+
return out
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
_weights_cache: dict = {}
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _weights(native, points, k, max_km) -> PointWeights:
|
|
155
|
+
u = native[next(iter(n for n in native if n != "land"))]
|
|
156
|
+
key = (u.shape, tuple(sorted(points.items())), k, max_km)
|
|
157
|
+
if key not in _weights_cache:
|
|
158
|
+
_weights_cache[key] = PointWeights(
|
|
159
|
+
u["longitude"].values,
|
|
160
|
+
u["latitude"].values,
|
|
161
|
+
native["land"].values,
|
|
162
|
+
points,
|
|
163
|
+
k,
|
|
164
|
+
max_km,
|
|
165
|
+
)
|
|
166
|
+
return _weights_cache[key]
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def fetch_points(
|
|
170
|
+
cycles: Iterable,
|
|
171
|
+
leads: Iterable[int],
|
|
172
|
+
points: dict[str, tuple[float, float]],
|
|
173
|
+
fields: Iterable[str] = DEFAULT_FIELDS,
|
|
174
|
+
k: int = 4,
|
|
175
|
+
max_km: float = 6.0,
|
|
176
|
+
max_workers: int = 6,
|
|
177
|
+
) -> pd.DataFrame:
|
|
178
|
+
"""HRRR forecasts at `points` ({name: (lon, lat)}) for every cycle and lead, as rows of
|
|
179
|
+
(issue_time, valid_time, lead_h, point, field, value). Leads past a cycle's `max_lead` are skipped."""
|
|
180
|
+
fields = tuple(fields)
|
|
181
|
+
unknown = set(fields) - set(FIELDS)
|
|
182
|
+
if unknown:
|
|
183
|
+
raise ValueError(f"unknown HRRR point fields: {sorted(unknown)}")
|
|
184
|
+
wind = "u10" in fields and "v10" in fields
|
|
185
|
+
tasks = []
|
|
186
|
+
for c in cycles:
|
|
187
|
+
cycle = pd.Timestamp(c)
|
|
188
|
+
if cycle.tzinfo is not None:
|
|
189
|
+
cycle = cycle.tz_convert("UTC").tz_localize(None)
|
|
190
|
+
tasks += [(cycle, int(lead)) for lead in leads if int(lead) <= max_lead(cycle)]
|
|
191
|
+
rows = []
|
|
192
|
+
with (
|
|
193
|
+
tempfile.TemporaryDirectory() as workdir,
|
|
194
|
+
concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as ex,
|
|
195
|
+
):
|
|
196
|
+
for (cycle, lead), native in zip(
|
|
197
|
+
tasks, ex.map(lambda t: _fetch_lead(t[0], t[1], fields, workdir), tasks)
|
|
198
|
+
):
|
|
199
|
+
if native is None:
|
|
200
|
+
logger.info("HRRR %s f%02d not published; skipped", cycle, lead)
|
|
201
|
+
continue
|
|
202
|
+
w = _weights(native, points, k, max_km)
|
|
203
|
+
values = {name: w(native[name].values) for name in fields}
|
|
204
|
+
if wind:
|
|
205
|
+
a = native["u10"].attrs
|
|
206
|
+
if int(a.get("GRIB_uvRelativeToGrid", 1)) == 1:
|
|
207
|
+
cone = float(np.sin(np.deg2rad(a["GRIB_Latin1InDegrees"])))
|
|
208
|
+
lov = float(a["GRIB_LoVInDegrees"])
|
|
209
|
+
for p in points:
|
|
210
|
+
u, v = earth_relative_winds(
|
|
211
|
+
np.array([values["u10"][p]]),
|
|
212
|
+
np.array([values["v10"][p]]),
|
|
213
|
+
np.array([points[p][0]]),
|
|
214
|
+
cone,
|
|
215
|
+
lov,
|
|
216
|
+
)
|
|
217
|
+
values["u10"][p], values["v10"][p] = float(u[0]), float(v[0])
|
|
218
|
+
valid = cycle + pd.Timedelta(hours=lead)
|
|
219
|
+
for name in fields:
|
|
220
|
+
for p, v in values[name].items():
|
|
221
|
+
rows.append((cycle, valid, lead, p, name, v))
|
|
222
|
+
return pd.DataFrame(
|
|
223
|
+
rows, columns=["issue_time", "valid_time", "lead_h", "point", "field", "value"]
|
|
224
|
+
)
|
|
@@ -75,6 +75,8 @@ class FakeERDDAP:
|
|
|
75
75
|
times, values = self.data[ds_id]
|
|
76
76
|
if "time[(last)]" in url:
|
|
77
77
|
return FakeResponse(200, text=f"time\nUTC\n{times[-1]}\n")
|
|
78
|
+
if "time[0]" in url:
|
|
79
|
+
return FakeResponse(200, text=f"time\nUTC\n{times[0]}\n")
|
|
78
80
|
var = url.split("?")[1].split("[")[0]
|
|
79
81
|
return FakeResponse(200, content=griddap_nc(var, times, values))
|
|
80
82
|
|
|
@@ -220,3 +222,80 @@ def test_cache_key_is_stable_within_the_hour():
|
|
|
220
222
|
b = coastwatch.cache_key(["sst_mur"], BBOX, datetime(2026, 10, 8, 12, 55), 7, 1)
|
|
221
223
|
c = coastwatch.cache_key(["sst_mur"], BBOX, datetime(2026, 10, 8, 13, 5), 7, 1)
|
|
222
224
|
assert a == b != c and a.startswith("sat_")
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _three_days():
|
|
228
|
+
"""Three daily passes: day 1 clear (1.0), day 2 cloudy in the first row (2.0 elsewhere), day 3 all cloud."""
|
|
229
|
+
v = np.stack(
|
|
230
|
+
[np.full((5, 5), 1.0), np.full((5, 5), 2.0), np.full((5, 5), np.nan)]
|
|
231
|
+
).astype(np.float32)
|
|
232
|
+
v[1, 0, :] = np.nan
|
|
233
|
+
times = ["2026-10-01T12:00:00Z", "2026-10-02T12:00:00Z", "2026-10-03T12:00:00Z"]
|
|
234
|
+
return times, v
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def test_composite_series_rolls_the_window(erddap):
|
|
238
|
+
erddap({"noaacwNPPVIIRSchlaSectorVYDaily": _three_days()})
|
|
239
|
+
ends = [datetime(2026, 10, d, 23, 59, tzinfo=timezone.utc) for d in (1, 2, 3, 5)]
|
|
240
|
+
s = coastwatch.composite_series(["chl_viirs_snpp"], BBOX, ends, days=2)
|
|
241
|
+
assert s["value"].dims == ("time", "lat", "lon") and s.sizes["time"] == 4
|
|
242
|
+
value, age = s["value"].values, s["age_days"].values
|
|
243
|
+
# Latitude ascends, so the descending input's first row is the last row here.
|
|
244
|
+
assert value[0, 0, 0] == pytest.approx(1.0) # Oct 1: the first pass
|
|
245
|
+
assert value[1, 0, 0] == pytest.approx(2.0) and value[1, -1, 0] == pytest.approx(
|
|
246
|
+
1.0
|
|
247
|
+
) # Oct 2: cloud keeps day 1
|
|
248
|
+
assert age[1, -1, 0] == pytest.approx(1.0 + 11.98 / 24, abs=1e-3)
|
|
249
|
+
assert value[2, 0, 0] == pytest.approx(
|
|
250
|
+
2.0
|
|
251
|
+
) # Oct 3 is all cloud: Oct 2 carries over
|
|
252
|
+
assert np.isnan(value[3]).all() # Oct 5: nothing within 2 days
|
|
253
|
+
assert np.isnan(
|
|
254
|
+
value[2, -1, 0]
|
|
255
|
+
) # Oct 3: the cloudy row's Oct 1 value is 2.5 days old, outside the window
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def test_composite_series_rejects_unsorted_ends(erddap):
|
|
259
|
+
erddap({"noaacwNPPVIIRSchlaSectorVYDaily": _three_days()})
|
|
260
|
+
ends = [
|
|
261
|
+
datetime(2026, 10, 3, tzinfo=timezone.utc),
|
|
262
|
+
datetime(2026, 10, 2, tzinfo=timezone.utc),
|
|
263
|
+
]
|
|
264
|
+
with pytest.raises(ValueError):
|
|
265
|
+
coastwatch.composite_series(["chl_viirs_snpp"], BBOX, ends, days=2)
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def test_mask_coast_on_a_series():
|
|
269
|
+
v = np.ones((2, 5, 5), dtype=np.float32)
|
|
270
|
+
v[:, :, 0] = np.nan # land on the west edge
|
|
271
|
+
ds = xr.Dataset({"value": (("time", "lat", "lon"), v)})
|
|
272
|
+
m = coastwatch.mask_coast(ds, pixels=1)
|
|
273
|
+
assert np.isnan(m["value"].values[:, :, 1]).all() # the band next to land
|
|
274
|
+
assert np.isfinite(m["value"].values[:, 2:4, 2:4]).all()
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def test_fetch_range_skips_a_failing_chunk(erddap, monkeypatch):
|
|
278
|
+
erddap({"noaacwNPPVIIRSchlaSectorVYDaily": _three_days()})
|
|
279
|
+
real = coastwatch._fetch_window
|
|
280
|
+
calls = {"n": 0}
|
|
281
|
+
|
|
282
|
+
def flaky(*args, **kwargs):
|
|
283
|
+
calls["n"] += 1
|
|
284
|
+
if calls["n"] == 1:
|
|
285
|
+
raise RuntimeError("ERDDAP: giving up after 3 attempts")
|
|
286
|
+
return real(*args, **kwargs)
|
|
287
|
+
|
|
288
|
+
monkeypatch.setattr(coastwatch, "_fetch_window", flaky)
|
|
289
|
+
ds = coastwatch.fetch_range(
|
|
290
|
+
"chl_viirs_snpp",
|
|
291
|
+
BBOX,
|
|
292
|
+
datetime(2026, 9, 1, tzinfo=timezone.utc),
|
|
293
|
+
datetime(2026, 10, 3, 23, tzinfo=timezone.utc),
|
|
294
|
+
chunk_days=1,
|
|
295
|
+
)
|
|
296
|
+
assert (
|
|
297
|
+
calls["n"] == 3
|
|
298
|
+
) # the window is clamped to the archive (Oct 1 to 3): three 1-day chunks, all tried
|
|
299
|
+
assert (
|
|
300
|
+
ds.sizes["time"] == 3
|
|
301
|
+
) # the later chunks still delivered (the fake serves every pass to each)
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""HRRR at points: .idx patterns, leads per cycle, water-cell weights, wind rotation and the bitemporal rows. No
|
|
2
|
+
network."""
|
|
3
|
+
|
|
4
|
+
import numpy as np
|
|
5
|
+
import pandas as pd
|
|
6
|
+
import pytest
|
|
7
|
+
import xarray as xr
|
|
8
|
+
|
|
9
|
+
from forcingkit.fetchers import hrrr_points as hp
|
|
10
|
+
|
|
11
|
+
LOV = 262.5
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def test_idx_patterns_name_the_lead():
|
|
15
|
+
assert hp.idx_pattern("u10", 0) == ":UGRD:10 m above ground:anl:"
|
|
16
|
+
assert hp.idx_pattern("u10", 6) == ":UGRD:10 m above ground:6 hour fcst:"
|
|
17
|
+
assert hp.idx_pattern("vis", 18) == ":VIS:surface:18 hour fcst:"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_leads_per_cycle():
|
|
21
|
+
assert hp.max_lead(pd.Timestamp("2026-10-14T06:00")) == 48
|
|
22
|
+
assert hp.max_lead(pd.Timestamp("2026-10-14T07:00")) == 18
|
|
23
|
+
assert hp.max_lead(pd.Timestamp("2019-06-01T12:00")) == 36
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_key_for_a_cycle_and_lead():
|
|
27
|
+
assert hp.s3_key(pd.Timestamp("2026-10-14T07:00"), 3).endswith(
|
|
28
|
+
"hrrr.20261014/conus/hrrr.t07z.wrfsfcf03.grib2"
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _grid():
|
|
33
|
+
j, i = np.meshgrid(np.arange(30), np.arange(30), indexing="ij")
|
|
34
|
+
lon = 286.0 + 0.03 * i # 0..360 longitudes, as HRRR's
|
|
35
|
+
lat = 40.5 + 0.03 * j
|
|
36
|
+
land = (i < 10).astype(float) # the western third is land
|
|
37
|
+
return lon, lat, land
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_weights_take_water_cells_only():
|
|
41
|
+
lon, lat, land = _grid()
|
|
42
|
+
# A point on the coast: its nearest cells are land, so the weights must reach east to water.
|
|
43
|
+
p = {"coast": (-74.0 + 0.03 * 9, 40.5 + 0.03 * 15)}
|
|
44
|
+
w = hp.PointWeights(lon, lat, land, p)
|
|
45
|
+
field = np.where(land > 0.5, -999.0, 7.0)
|
|
46
|
+
assert w(field)["coast"] == pytest.approx(7.0)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_weights_are_exact_on_a_cell_and_linear_fields_stay_close():
|
|
50
|
+
lon, lat, land = _grid()
|
|
51
|
+
on = {"on": (-74.0 + 0.03 * 20, 40.5 + 0.03 * 12)}
|
|
52
|
+
w = hp.PointWeights(lon, lat, land, on)
|
|
53
|
+
field = 3.0 * (lon - 360.0) + 2.0 * lat
|
|
54
|
+
assert w(field)["on"] == pytest.approx(3.0 * on["on"][0] + 2.0 * on["on"][1])
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def test_no_water_within_reach_gives_nan():
|
|
58
|
+
lon, lat, _ = _grid()
|
|
59
|
+
w = hp.PointWeights(lon, lat, np.ones_like(lon), {"inland": (-73.7, 40.9)})
|
|
60
|
+
assert np.isnan(w(np.zeros_like(lon))["inland"])
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _fake_native(cycle, lead, fields, workdir, grid_relative=0):
|
|
64
|
+
lon, lat, land = _grid()
|
|
65
|
+
out = {}
|
|
66
|
+
for n, name in enumerate(fields):
|
|
67
|
+
out[name] = xr.DataArray(
|
|
68
|
+
np.full(lon.shape, float(n + lead)),
|
|
69
|
+
dims=("y", "x"),
|
|
70
|
+
coords={"longitude": (("y", "x"), lon), "latitude": (("y", "x"), lat)},
|
|
71
|
+
attrs={
|
|
72
|
+
"GRIB_Latin1InDegrees": 38.5,
|
|
73
|
+
"GRIB_LoVInDegrees": LOV,
|
|
74
|
+
"GRIB_uvRelativeToGrid": grid_relative,
|
|
75
|
+
},
|
|
76
|
+
)
|
|
77
|
+
out["land"] = xr.DataArray(land, dims=("y", "x"))
|
|
78
|
+
return out
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def test_rows_are_bitemporal_and_skip_leads_a_cycle_lacks(monkeypatch):
|
|
82
|
+
monkeypatch.setattr(hp, "_fetch_lead", _fake_native)
|
|
83
|
+
hp._weights_cache.clear()
|
|
84
|
+
df = hp.fetch_points(
|
|
85
|
+
["2026-10-14T07:00"],
|
|
86
|
+
[1, 18, 24],
|
|
87
|
+
{"buoy": (-73.4, 40.8)},
|
|
88
|
+
fields=("t2m", "vis"),
|
|
89
|
+
)
|
|
90
|
+
# f24 is past a 07Z cycle's 18 h: skipped.
|
|
91
|
+
assert sorted(df["lead_h"].unique()) == [1, 18]
|
|
92
|
+
row = df[(df.lead_h == 18) & (df.field == "vis")].iloc[0]
|
|
93
|
+
assert row.issue_time == pd.Timestamp("2026-10-14T07:00")
|
|
94
|
+
assert row.valid_time == pd.Timestamp("2026-10-15T01:00")
|
|
95
|
+
assert row.value == pytest.approx(1 + 18) # second field, offset by the lead
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def test_an_absent_file_is_skipped(monkeypatch):
|
|
99
|
+
monkeypatch.setattr(
|
|
100
|
+
hp,
|
|
101
|
+
"_fetch_lead",
|
|
102
|
+
lambda c, lead, f, w: None if lead == 2 else _fake_native(c, lead, f, w),
|
|
103
|
+
)
|
|
104
|
+
hp._weights_cache.clear()
|
|
105
|
+
df = hp.fetch_points(
|
|
106
|
+
["2026-10-14T07:00"], [1, 2], {"buoy": (-73.4, 40.8)}, fields=("t2m",)
|
|
107
|
+
)
|
|
108
|
+
assert list(df["lead_h"].unique()) == [1]
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def test_grid_relative_winds_are_rotated(monkeypatch):
|
|
112
|
+
# A grid-relative wind along +x at 73.4 W is rotated by cone * (lon - lov) to east and north.
|
|
113
|
+
monkeypatch.setattr(
|
|
114
|
+
hp,
|
|
115
|
+
"_fetch_lead",
|
|
116
|
+
lambda c, lead, f, w: _fake_native(c, lead, f, w, grid_relative=1),
|
|
117
|
+
)
|
|
118
|
+
hp._weights_cache.clear()
|
|
119
|
+
df = hp.fetch_points(
|
|
120
|
+
["2026-10-14T07:00"], [1], {"buoy": (-73.4, 40.8)}, fields=("u10", "v10")
|
|
121
|
+
)
|
|
122
|
+
u = df[df.field == "u10"].value.iloc[0]
|
|
123
|
+
v = df[df.field == "v10"].value.iloc[0]
|
|
124
|
+
# The fake gives grid u = 0 + 1 and grid v = 1 + 1 (field order plus lead).
|
|
125
|
+
alpha = np.deg2rad(np.sin(np.deg2rad(38.5)) * (-73.4 - (LOV - 360.0)))
|
|
126
|
+
assert u == pytest.approx(np.cos(alpha) * 1.0 + np.sin(alpha) * 2.0)
|
|
127
|
+
assert v == pytest.approx(-np.sin(alpha) * 1.0 + np.cos(alpha) * 2.0)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def test_unknown_fields_are_refused():
|
|
131
|
+
with pytest.raises(ValueError):
|
|
132
|
+
hp.fetch_points(
|
|
133
|
+
["2026-10-14T07:00"], [1], {"b": (-73.4, 40.8)}, fields=("cape",)
|
|
134
|
+
)
|
|
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
|
|
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
|