forcingkit 0.5.0__tar.gz → 0.6.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.
Files changed (99) hide show
  1. {forcingkit-0.5.0 → forcingkit-0.6.0}/PKG-INFO +1 -1
  2. {forcingkit-0.5.0 → forcingkit-0.6.0}/docs/source/conf.py +1 -1
  3. {forcingkit-0.5.0 → forcingkit-0.6.0}/pyproject.toml +1 -1
  4. {forcingkit-0.5.0 → forcingkit-0.6.0}/src/forcingkit/fetchers/coastwatch.py +176 -2
  5. {forcingkit-0.5.0 → forcingkit-0.6.0}/tests/unit/test_coastwatch.py +79 -0
  6. {forcingkit-0.5.0 → forcingkit-0.6.0}/uv.lock +1 -1
  7. {forcingkit-0.5.0 → forcingkit-0.6.0}/.agents/forcingkit.md +0 -0
  8. {forcingkit-0.5.0 → forcingkit-0.6.0}/.claude/CLAUDE.md +0 -0
  9. {forcingkit-0.5.0 → forcingkit-0.6.0}/.env.template +0 -0
  10. {forcingkit-0.5.0 → forcingkit-0.6.0}/.github/copilot-instructions.md +0 -0
  11. {forcingkit-0.5.0 → forcingkit-0.6.0}/.github/workflows/docs.yml +0 -0
  12. {forcingkit-0.5.0 → forcingkit-0.6.0}/.github/workflows/publish.yml +0 -0
  13. {forcingkit-0.5.0 → forcingkit-0.6.0}/.github/workflows/tests.yml +0 -0
  14. {forcingkit-0.5.0 → forcingkit-0.6.0}/.gitignore +0 -0
  15. {forcingkit-0.5.0 → forcingkit-0.6.0}/.markdownlint.yaml +0 -0
  16. {forcingkit-0.5.0 → forcingkit-0.6.0}/.pre-commit-config.yaml +0 -0
  17. {forcingkit-0.5.0 → forcingkit-0.6.0}/.python-version +0 -0
  18. {forcingkit-0.5.0 → forcingkit-0.6.0}/AGENTS.md +0 -0
  19. {forcingkit-0.5.0 → forcingkit-0.6.0}/CONTRIBUTING.md +0 -0
  20. {forcingkit-0.5.0 → forcingkit-0.6.0}/Dockerfile +0 -0
  21. {forcingkit-0.5.0 → forcingkit-0.6.0}/LICENSE +0 -0
  22. {forcingkit-0.5.0 → forcingkit-0.6.0}/README.md +0 -0
  23. {forcingkit-0.5.0 → forcingkit-0.6.0}/docker-compose.yml +0 -0
  24. {forcingkit-0.5.0 → forcingkit-0.6.0}/docs/Makefile +0 -0
  25. {forcingkit-0.5.0 → forcingkit-0.6.0}/docs/make.bat +0 -0
  26. {forcingkit-0.5.0 → forcingkit-0.6.0}/docs/requirements-docs.txt +0 -0
  27. {forcingkit-0.5.0 → forcingkit-0.6.0}/docs/source/_extra/CNAME +0 -0
  28. {forcingkit-0.5.0 → forcingkit-0.6.0}/docs/source/_static/custom.css +0 -0
  29. {forcingkit-0.5.0 → forcingkit-0.6.0}/docs/source/_static/forcingkit-noreaster-wind.gif +0 -0
  30. {forcingkit-0.5.0 → forcingkit-0.6.0}/docs/source/atmospheric_forcing.rst +0 -0
  31. {forcingkit-0.5.0 → forcingkit-0.6.0}/docs/source/fetchers.rst +0 -0
  32. {forcingkit-0.5.0 → forcingkit-0.6.0}/docs/source/hycom.rst +0 -0
  33. {forcingkit-0.5.0 → forcingkit-0.6.0}/docs/source/index.rst +0 -0
  34. {forcingkit-0.5.0 → forcingkit-0.6.0}/docs/source/necofs.rst +0 -0
  35. {forcingkit-0.5.0 → forcingkit-0.6.0}/docs/source/nyofs.rst +0 -0
  36. {forcingkit-0.5.0 → forcingkit-0.6.0}/docs/source/rivers.rst +0 -0
  37. {forcingkit-0.5.0 → forcingkit-0.6.0}/docs/source/roadmap.rst +0 -0
  38. {forcingkit-0.5.0 → forcingkit-0.6.0}/docs/source/sources.rst +0 -0
  39. {forcingkit-0.5.0 → forcingkit-0.6.0}/main.py +0 -0
  40. {forcingkit-0.5.0 → forcingkit-0.6.0}/service/__init__.py +0 -0
  41. {forcingkit-0.5.0 → forcingkit-0.6.0}/service/forcingkit_serve/__init__.py +0 -0
  42. {forcingkit-0.5.0 → forcingkit-0.6.0}/service/forcingkit_serve/main.py +0 -0
  43. {forcingkit-0.5.0 → forcingkit-0.6.0}/service/forcingkit_serve/routers/bathymetry.py +0 -0
  44. {forcingkit-0.5.0 → forcingkit-0.6.0}/service/forcingkit_serve/routers/plotly_api.py +0 -0
  45. {forcingkit-0.5.0 → forcingkit-0.6.0}/service/forcingkit_serve/routers/removed.py +0 -0
  46. {forcingkit-0.5.0 → forcingkit-0.6.0}/service/forcingkit_serve/routers/viewer.py +0 -0
  47. {forcingkit-0.5.0 → forcingkit-0.6.0}/service/run_server.py +0 -0
  48. {forcingkit-0.5.0 → forcingkit-0.6.0}/src/forcingkit/__init__.py +0 -0
  49. {forcingkit-0.5.0 → forcingkit-0.6.0}/src/forcingkit/data/rivers/northeast_us.json +0 -0
  50. {forcingkit-0.5.0 → forcingkit-0.6.0}/src/forcingkit/dispatcher.py +0 -0
  51. {forcingkit-0.5.0 → forcingkit-0.6.0}/src/forcingkit/fetchers/dbofs.py +0 -0
  52. {forcingkit-0.5.0 → forcingkit-0.6.0}/src/forcingkit/fetchers/erddap.py +0 -0
  53. {forcingkit-0.5.0 → forcingkit-0.6.0}/src/forcingkit/fetchers/hrrr.py +0 -0
  54. {forcingkit-0.5.0 → forcingkit-0.6.0}/src/forcingkit/fetchers/hrrr_atmosphere.py +0 -0
  55. {forcingkit-0.5.0 → forcingkit-0.6.0}/src/forcingkit/fetchers/hycom.py +0 -0
  56. {forcingkit-0.5.0 → forcingkit-0.6.0}/src/forcingkit/fetchers/hydrography.py +0 -0
  57. {forcingkit-0.5.0 → forcingkit-0.6.0}/src/forcingkit/fetchers/ndbc.py +0 -0
  58. {forcingkit-0.5.0 → forcingkit-0.6.0}/src/forcingkit/fetchers/necofs.py +0 -0
  59. {forcingkit-0.5.0 → forcingkit-0.6.0}/src/forcingkit/fetchers/noaa.py +0 -0
  60. {forcingkit-0.5.0 → forcingkit-0.6.0}/src/forcingkit/fetchers/nyofs.py +0 -0
  61. {forcingkit-0.5.0 → forcingkit-0.6.0}/src/forcingkit/fetchers/usgs.py +0 -0
  62. {forcingkit-0.5.0 → forcingkit-0.6.0}/src/forcingkit/fetchers/usgs_rivers.py +0 -0
  63. {forcingkit-0.5.0 → forcingkit-0.6.0}/src/forcingkit/settings.py +0 -0
  64. {forcingkit-0.5.0 → forcingkit-0.6.0}/src/forcingkit/zarr_stream.py +0 -0
  65. {forcingkit-0.5.0 → forcingkit-0.6.0}/static/app.js +0 -0
  66. {forcingkit-0.5.0 → forcingkit-0.6.0}/static/favicon.ico +0 -0
  67. {forcingkit-0.5.0 → forcingkit-0.6.0}/static/favicon.svg +0 -0
  68. {forcingkit-0.5.0 → forcingkit-0.6.0}/static/index.html +0 -0
  69. {forcingkit-0.5.0 → forcingkit-0.6.0}/static/logo.svg +0 -0
  70. {forcingkit-0.5.0 → forcingkit-0.6.0}/static/preview3d.js +0 -0
  71. {forcingkit-0.5.0 → forcingkit-0.6.0}/static/styles.css +0 -0
  72. {forcingkit-0.5.0 → forcingkit-0.6.0}/tests/__init__.py +0 -0
  73. {forcingkit-0.5.0 → forcingkit-0.6.0}/tests/integration/test_auth.py +0 -0
  74. {forcingkit-0.5.0 → forcingkit-0.6.0}/tests/integration/test_erddap_fetch.py +0 -0
  75. {forcingkit-0.5.0 → forcingkit-0.6.0}/tests/integration/test_nyofs_obc_fetch.py +0 -0
  76. {forcingkit-0.5.0 → forcingkit-0.6.0}/tests/integration/test_obc_offshore_nj.py +0 -0
  77. {forcingkit-0.5.0 → forcingkit-0.6.0}/tests/integration/test_ofs_archive_paths.py +0 -0
  78. {forcingkit-0.5.0 → forcingkit-0.6.0}/tests/integration/test_s3_roms_fetchers.py +0 -0
  79. {forcingkit-0.5.0 → forcingkit-0.6.0}/tests/scripts/inspect_dem.py +0 -0
  80. {forcingkit-0.5.0 → forcingkit-0.6.0}/tests/scripts/inspect_grib.py +0 -0
  81. {forcingkit-0.5.0 → forcingkit-0.6.0}/tests/scripts/inspect_zarr.py +0 -0
  82. {forcingkit-0.5.0 → forcingkit-0.6.0}/tests/unit/test_dbofs.py +0 -0
  83. {forcingkit-0.5.0 → forcingkit-0.6.0}/tests/unit/test_dispatcher.py +0 -0
  84. {forcingkit-0.5.0 → forcingkit-0.6.0}/tests/unit/test_hrrr_atm.py +0 -0
  85. {forcingkit-0.5.0 → forcingkit-0.6.0}/tests/unit/test_hrrr_idx.py +0 -0
  86. {forcingkit-0.5.0 → forcingkit-0.6.0}/tests/unit/test_hycom.py +0 -0
  87. {forcingkit-0.5.0 → forcingkit-0.6.0}/tests/unit/test_main.py +0 -0
  88. {forcingkit-0.5.0 → forcingkit-0.6.0}/tests/unit/test_ndbc.py +0 -0
  89. {forcingkit-0.5.0 → forcingkit-0.6.0}/tests/unit/test_necofs_parent.py +0 -0
  90. {forcingkit-0.5.0 → forcingkit-0.6.0}/tests/unit/test_noaa_currents.py +0 -0
  91. {forcingkit-0.5.0 → forcingkit-0.6.0}/tests/unit/test_noaa_datum.py +0 -0
  92. {forcingkit-0.5.0 → forcingkit-0.6.0}/tests/unit/test_nyofs.py +0 -0
  93. {forcingkit-0.5.0 → forcingkit-0.6.0}/tests/unit/test_obc_donor_report.py +0 -0
  94. {forcingkit-0.5.0 → forcingkit-0.6.0}/tests/unit/test_obc_pipeline.py +0 -0
  95. {forcingkit-0.5.0 → forcingkit-0.6.0}/tests/unit/test_removed_routes.py +0 -0
  96. {forcingkit-0.5.0 → forcingkit-0.6.0}/tests/unit/test_settings.py +0 -0
  97. {forcingkit-0.5.0 → forcingkit-0.6.0}/tests/unit/test_usgs.py +0 -0
  98. {forcingkit-0.5.0 → forcingkit-0.6.0}/tests/unit/test_usgs_rivers.py +0 -0
  99. {forcingkit-0.5.0 → forcingkit-0.6.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.5.0
3
+ Version: 0.6.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
@@ -9,7 +9,7 @@ sys.path.insert(0, os.path.abspath("../../src"))
9
9
  project = "forcingkit"
10
10
  copyright = "2026, Long Horizon Observatory"
11
11
  author = "Daniel Fry"
12
- release = "0.5.0"
12
+ release = "0.6.0"
13
13
 
14
14
  extensions = [
15
15
  "sphinx.ext.autodoc",
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "forcingkit"
7
- version = "0.5.0"
7
+ version = "0.6.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
- keep = binary_erosion(water, iterations=pixels, border_value=1)
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:
@@ -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)
@@ -873,7 +873,7 @@ wheels = [
873
873
 
874
874
  [[package]]
875
875
  name = "forcingkit"
876
- version = "0.5.0"
876
+ version = "0.6.0"
877
877
  source = { editable = "." }
878
878
  dependencies = [
879
879
  { name = "bottleneck" },
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