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