forcingkit 0.4.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.4.0 → forcingkit-0.6.0}/.env.template +6 -2
  2. {forcingkit-0.4.0 → forcingkit-0.6.0}/PKG-INFO +1 -1
  3. {forcingkit-0.4.0 → forcingkit-0.6.0}/docs/source/conf.py +1 -1
  4. {forcingkit-0.4.0 → forcingkit-0.6.0}/docs/source/index.rst +10 -3
  5. forcingkit-0.6.0/docs/source/rivers.rst +177 -0
  6. {forcingkit-0.4.0 → forcingkit-0.6.0}/docs/source/roadmap.rst +43 -3
  7. {forcingkit-0.4.0 → forcingkit-0.6.0}/docs/source/sources.rst +21 -0
  8. {forcingkit-0.4.0 → forcingkit-0.6.0}/pyproject.toml +1 -1
  9. {forcingkit-0.4.0 → forcingkit-0.6.0}/service/forcingkit_serve/main.py +220 -0
  10. forcingkit-0.6.0/src/forcingkit/data/rivers/northeast_us.json +29 -0
  11. {forcingkit-0.4.0 → forcingkit-0.6.0}/src/forcingkit/dispatcher.py +153 -0
  12. forcingkit-0.6.0/src/forcingkit/fetchers/coastwatch.py +661 -0
  13. {forcingkit-0.4.0 → forcingkit-0.6.0}/src/forcingkit/fetchers/necofs.py +116 -0
  14. forcingkit-0.6.0/src/forcingkit/fetchers/usgs.py +347 -0
  15. forcingkit-0.6.0/src/forcingkit/fetchers/usgs_rivers.py +625 -0
  16. forcingkit-0.6.0/tests/unit/test_coastwatch.py +301 -0
  17. {forcingkit-0.4.0 → forcingkit-0.6.0}/tests/unit/test_necofs_parent.py +9 -0
  18. forcingkit-0.6.0/tests/unit/test_usgs.py +110 -0
  19. forcingkit-0.6.0/tests/unit/test_usgs_rivers.py +490 -0
  20. {forcingkit-0.4.0 → forcingkit-0.6.0}/uv.lock +1 -1
  21. {forcingkit-0.4.0 → forcingkit-0.6.0}/.agents/forcingkit.md +0 -0
  22. {forcingkit-0.4.0 → forcingkit-0.6.0}/.claude/CLAUDE.md +0 -0
  23. {forcingkit-0.4.0 → forcingkit-0.6.0}/.github/copilot-instructions.md +0 -0
  24. {forcingkit-0.4.0 → forcingkit-0.6.0}/.github/workflows/docs.yml +0 -0
  25. {forcingkit-0.4.0 → forcingkit-0.6.0}/.github/workflows/publish.yml +0 -0
  26. {forcingkit-0.4.0 → forcingkit-0.6.0}/.github/workflows/tests.yml +0 -0
  27. {forcingkit-0.4.0 → forcingkit-0.6.0}/.gitignore +0 -0
  28. {forcingkit-0.4.0 → forcingkit-0.6.0}/.markdownlint.yaml +0 -0
  29. {forcingkit-0.4.0 → forcingkit-0.6.0}/.pre-commit-config.yaml +0 -0
  30. {forcingkit-0.4.0 → forcingkit-0.6.0}/.python-version +0 -0
  31. {forcingkit-0.4.0 → forcingkit-0.6.0}/AGENTS.md +0 -0
  32. {forcingkit-0.4.0 → forcingkit-0.6.0}/CONTRIBUTING.md +0 -0
  33. {forcingkit-0.4.0 → forcingkit-0.6.0}/Dockerfile +0 -0
  34. {forcingkit-0.4.0 → forcingkit-0.6.0}/LICENSE +0 -0
  35. {forcingkit-0.4.0 → forcingkit-0.6.0}/README.md +0 -0
  36. {forcingkit-0.4.0 → forcingkit-0.6.0}/docker-compose.yml +0 -0
  37. {forcingkit-0.4.0 → forcingkit-0.6.0}/docs/Makefile +0 -0
  38. {forcingkit-0.4.0 → forcingkit-0.6.0}/docs/make.bat +0 -0
  39. {forcingkit-0.4.0 → forcingkit-0.6.0}/docs/requirements-docs.txt +0 -0
  40. {forcingkit-0.4.0 → forcingkit-0.6.0}/docs/source/_extra/CNAME +0 -0
  41. {forcingkit-0.4.0 → forcingkit-0.6.0}/docs/source/_static/custom.css +0 -0
  42. {forcingkit-0.4.0 → forcingkit-0.6.0}/docs/source/_static/forcingkit-noreaster-wind.gif +0 -0
  43. {forcingkit-0.4.0 → forcingkit-0.6.0}/docs/source/atmospheric_forcing.rst +0 -0
  44. {forcingkit-0.4.0 → forcingkit-0.6.0}/docs/source/fetchers.rst +0 -0
  45. {forcingkit-0.4.0 → forcingkit-0.6.0}/docs/source/hycom.rst +0 -0
  46. {forcingkit-0.4.0 → forcingkit-0.6.0}/docs/source/necofs.rst +0 -0
  47. {forcingkit-0.4.0 → forcingkit-0.6.0}/docs/source/nyofs.rst +0 -0
  48. {forcingkit-0.4.0 → forcingkit-0.6.0}/main.py +0 -0
  49. {forcingkit-0.4.0 → forcingkit-0.6.0}/service/__init__.py +0 -0
  50. {forcingkit-0.4.0 → forcingkit-0.6.0}/service/forcingkit_serve/__init__.py +0 -0
  51. {forcingkit-0.4.0 → forcingkit-0.6.0}/service/forcingkit_serve/routers/bathymetry.py +0 -0
  52. {forcingkit-0.4.0 → forcingkit-0.6.0}/service/forcingkit_serve/routers/plotly_api.py +0 -0
  53. {forcingkit-0.4.0 → forcingkit-0.6.0}/service/forcingkit_serve/routers/removed.py +0 -0
  54. {forcingkit-0.4.0 → forcingkit-0.6.0}/service/forcingkit_serve/routers/viewer.py +0 -0
  55. {forcingkit-0.4.0 → forcingkit-0.6.0}/service/run_server.py +0 -0
  56. {forcingkit-0.4.0 → forcingkit-0.6.0}/src/forcingkit/__init__.py +0 -0
  57. {forcingkit-0.4.0 → forcingkit-0.6.0}/src/forcingkit/fetchers/dbofs.py +0 -0
  58. {forcingkit-0.4.0 → forcingkit-0.6.0}/src/forcingkit/fetchers/erddap.py +0 -0
  59. {forcingkit-0.4.0 → forcingkit-0.6.0}/src/forcingkit/fetchers/hrrr.py +0 -0
  60. {forcingkit-0.4.0 → forcingkit-0.6.0}/src/forcingkit/fetchers/hrrr_atmosphere.py +0 -0
  61. {forcingkit-0.4.0 → forcingkit-0.6.0}/src/forcingkit/fetchers/hycom.py +0 -0
  62. {forcingkit-0.4.0 → forcingkit-0.6.0}/src/forcingkit/fetchers/hydrography.py +0 -0
  63. {forcingkit-0.4.0 → forcingkit-0.6.0}/src/forcingkit/fetchers/ndbc.py +0 -0
  64. {forcingkit-0.4.0 → forcingkit-0.6.0}/src/forcingkit/fetchers/noaa.py +0 -0
  65. {forcingkit-0.4.0 → forcingkit-0.6.0}/src/forcingkit/fetchers/nyofs.py +0 -0
  66. {forcingkit-0.4.0 → forcingkit-0.6.0}/src/forcingkit/settings.py +0 -0
  67. {forcingkit-0.4.0 → forcingkit-0.6.0}/src/forcingkit/zarr_stream.py +0 -0
  68. {forcingkit-0.4.0 → forcingkit-0.6.0}/static/app.js +0 -0
  69. {forcingkit-0.4.0 → forcingkit-0.6.0}/static/favicon.ico +0 -0
  70. {forcingkit-0.4.0 → forcingkit-0.6.0}/static/favicon.svg +0 -0
  71. {forcingkit-0.4.0 → forcingkit-0.6.0}/static/index.html +0 -0
  72. {forcingkit-0.4.0 → forcingkit-0.6.0}/static/logo.svg +0 -0
  73. {forcingkit-0.4.0 → forcingkit-0.6.0}/static/preview3d.js +0 -0
  74. {forcingkit-0.4.0 → forcingkit-0.6.0}/static/styles.css +0 -0
  75. {forcingkit-0.4.0 → forcingkit-0.6.0}/tests/__init__.py +0 -0
  76. {forcingkit-0.4.0 → forcingkit-0.6.0}/tests/integration/test_auth.py +0 -0
  77. {forcingkit-0.4.0 → forcingkit-0.6.0}/tests/integration/test_erddap_fetch.py +0 -0
  78. {forcingkit-0.4.0 → forcingkit-0.6.0}/tests/integration/test_nyofs_obc_fetch.py +0 -0
  79. {forcingkit-0.4.0 → forcingkit-0.6.0}/tests/integration/test_obc_offshore_nj.py +0 -0
  80. {forcingkit-0.4.0 → forcingkit-0.6.0}/tests/integration/test_ofs_archive_paths.py +0 -0
  81. {forcingkit-0.4.0 → forcingkit-0.6.0}/tests/integration/test_s3_roms_fetchers.py +0 -0
  82. {forcingkit-0.4.0 → forcingkit-0.6.0}/tests/scripts/inspect_dem.py +0 -0
  83. {forcingkit-0.4.0 → forcingkit-0.6.0}/tests/scripts/inspect_grib.py +0 -0
  84. {forcingkit-0.4.0 → forcingkit-0.6.0}/tests/scripts/inspect_zarr.py +0 -0
  85. {forcingkit-0.4.0 → forcingkit-0.6.0}/tests/unit/test_dbofs.py +0 -0
  86. {forcingkit-0.4.0 → forcingkit-0.6.0}/tests/unit/test_dispatcher.py +0 -0
  87. {forcingkit-0.4.0 → forcingkit-0.6.0}/tests/unit/test_hrrr_atm.py +0 -0
  88. {forcingkit-0.4.0 → forcingkit-0.6.0}/tests/unit/test_hrrr_idx.py +0 -0
  89. {forcingkit-0.4.0 → forcingkit-0.6.0}/tests/unit/test_hycom.py +0 -0
  90. {forcingkit-0.4.0 → forcingkit-0.6.0}/tests/unit/test_main.py +0 -0
  91. {forcingkit-0.4.0 → forcingkit-0.6.0}/tests/unit/test_ndbc.py +0 -0
  92. {forcingkit-0.4.0 → forcingkit-0.6.0}/tests/unit/test_noaa_currents.py +0 -0
  93. {forcingkit-0.4.0 → forcingkit-0.6.0}/tests/unit/test_noaa_datum.py +0 -0
  94. {forcingkit-0.4.0 → forcingkit-0.6.0}/tests/unit/test_nyofs.py +0 -0
  95. {forcingkit-0.4.0 → forcingkit-0.6.0}/tests/unit/test_obc_donor_report.py +0 -0
  96. {forcingkit-0.4.0 → forcingkit-0.6.0}/tests/unit/test_obc_pipeline.py +0 -0
  97. {forcingkit-0.4.0 → forcingkit-0.6.0}/tests/unit/test_removed_routes.py +0 -0
  98. {forcingkit-0.4.0 → forcingkit-0.6.0}/tests/unit/test_settings.py +0 -0
  99. {forcingkit-0.4.0 → forcingkit-0.6.0}/tests/unit/test_zarr_stream.py +0 -0
@@ -2,8 +2,12 @@
2
2
  # Copy this file to .env and fill in your credentials.
3
3
  # A docker-compose service can load it with env_file.
4
4
 
5
- # No credentials are required at present: HRRR, NOAA OFS, NECOFS, HYCOM, NDBC and
6
- # CO-OPS are all served without authentication.
5
+ # No credentials are required: HRRR, NOAA OFS, NECOFS, HYCOM, NDBC, CO-OPS and the
6
+ # USGS Water Data APIs are all served without authentication.
7
+
8
+ # Optional USGS Water Data API key (free from https://api.waterdata.usgs.gov/signup/).
9
+ # Without it, river discharge requests still work at a lower hourly rate limit.
10
+ # USGS_API_KEY=
7
11
 
8
12
  # Cache root for every store (default ~/.cache/forcingkit). The old name
9
13
  # ECODATA_CACHE_CACHE_DIR is still read, with a warning, until the next release.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: forcingkit
3
- Version: 0.4.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.4.0"
12
+ release = "0.6.0"
13
13
 
14
14
  extensions = [
15
15
  "sphinx.ext.autodoc",
@@ -17,13 +17,15 @@ and serves model-ready time series, with a record of where every value came from
17
17
  What it does
18
18
  ------------
19
19
 
20
- A regional ocean model needs two things from outside its own domain: the state of a larger
21
- "parent" ocean along its open boundaries, and the atmosphere above its surface. Both come from
20
+ A regional ocean model needs three things from outside its own domain: the state of a larger
21
+ "parent" ocean along its open boundaries, the atmosphere above its surface, and the rivers that
22
+ flow into it. The first two come from
22
23
  operational and archive systems (NOAA's `HRRR <https://rapidrefresh.noaa.gov/hrrr/>`__, the NOAA
23
24
  `Operational Forecast Systems <https://tidesandcurrents.noaa.gov/models.html>`__,
24
25
  `NECOFS <https://fvcom.smast.umassd.edu/?p=20>`__, `HYCOM <https://www.hycom.org/>`__) that
25
26
  differ in grid type, vertical coordinate, time convention, file layout and access protocol, and
26
- that change those details without notice.
27
+ that change those details without notice. River discharge comes from stream gauges, which
28
+ measure flow upstream of the head of tide rather than at the mouth.
27
29
 
28
30
  forcingkit is a FastAPI service that sits between those providers and model codes such as
29
31
  `Oceananigans.jl <https://github.com/CliMA/Oceananigans.jl>`__ (for example through
@@ -61,6 +63,10 @@ What it serves
61
63
  - HRRR surface fields (10 m wind, 2 m temperature and humidity, pressure, radiation,
62
64
  precipitation) on a regular 0.03 degree grid, hourly, as schema ``hrrr-atm-v1``. See
63
65
  :doc:`atmospheric_forcing`.
66
+ * - ``POST /api/v1/rivers``
67
+ - Freshwater discharge at each listed river mouth in the box, hourly, from the tide-free
68
+ `USGS <https://waterdata.usgs.gov/>`__ stream gauges upstream (chosen per request) scaled
69
+ to the drainage area at the mouth, as schema ``river-v1``. See :doc:`rivers`.
64
70
  * - ``POST /api/v1/tide``
65
71
  - Observed water level at a `NOAA CO-OPS <https://tidesandcurrents.noaa.gov/>`__ station, for
66
72
  validation.
@@ -154,6 +160,7 @@ PyPI as ``forcingkit``.
154
160
  nyofs
155
161
  hycom
156
162
  atmospheric_forcing
163
+ rivers
157
164
 
158
165
  .. toctree::
159
166
  :maxdepth: 1
@@ -0,0 +1,177 @@
1
+ River Discharge
2
+ ===============
3
+
4
+ ``POST /api/v1/rivers`` delivers hourly freshwater discharge at the mouth of every listed river
5
+ in a box (or at mouths passed with the request), for point or surface sources in an ocean model. A nested regional model otherwise
6
+ receives river water only through its open boundaries and initial state, so a river that enters
7
+ inside the domain is missing as a source and its freshwater decays over the run.
8
+
9
+ Method
10
+ ------
11
+
12
+ The method is common practice for gauge-driven river forcing. The NOAA Operational Forecast
13
+ Systems map US Geological Survey real-time gauges onto model cells with a per-river scale factor
14
+ and fall back to climatology. Regional estuary models scale gauged flow to ungauged area by the
15
+ ratio of drainage areas.
16
+
17
+ 1. **Gauges, derived for every request.** Only a river's mouth is listed. From its NHDPlus
18
+ outlet flowline, forcingkit asks the USGS `NLDI <https://waterdata.usgs.gov/blog/nldi-intro/>`__
19
+ for every USGS site upstream, then keeps a site if:
20
+
21
+ - USGS classes it as a stream site (``ST``). Tidal streams (``ST-TS``) and estuaries (``ES``)
22
+ are left out, because their flow reverses with the tide; on the Connecticut River that rules
23
+ out Hartford and Middle Haddam;
24
+ - it has a drainage area, and a discharge record (15-minute or daily) covering the window;
25
+ - it drains at least ``min_share`` of the mouth's area (default 0.01), so headwater brooks
26
+ drop out;
27
+ - no other kept site lies downstream of it, which leaves the lowest gauge on each branch.
28
+
29
+ The kept gauges then measure disjoint parts of the basin. The selection follows the window: a
30
+ gauge that opened in 2010 is used from 2010 on, and one that closed is dropped from then on.
31
+ 2. **Discharge at the mouth.** The sum of the river's gauges, scaled by the drainage area at the
32
+ mouth over the gauged area:
33
+
34
+ .. math::
35
+
36
+ Q_\text{mouth} = \frac{A_\text{mouth}}{\sum_k A_k} \sum_k Q_k
37
+
38
+ Gauge areas come from the USGS monitoring-location records; the mouth's area is that of the
39
+ outlet's NLDI basin.
40
+ 3. **Time base.** The 15-minute record from the USGS Water Data APIs (``continuous``), averaged to
41
+ hourly values centred on the hour. The store holds ``hours + 1`` records, from ``start_time``
42
+ to ``start_time + hours``.
43
+ 4. **Gaps.** A gap of up to ``max_gap_hours`` (default 6) with values on both sides is
44
+ interpolated. A longer gap takes that day's mean from the ``daily`` record. A day with neither
45
+ is an error, unless ``allow_climatology`` is set; then it takes the USGS day-of-year median of
46
+ daily flow. Every filled hour is flagged in ``fill`` (0 measured, 1 interpolated, 2 daily
47
+ mean, 3 climatology median), and the store's ``provenance`` attribute counts them per river.
48
+ 5. **Water properties.** Salinity is zero. Water temperature (``temperature``) comes from the first
49
+ gauge on the river that measures it (USGS parameter 00010), and is NaN where none does.
50
+ 6. **Provisional data.** USGS values are provisional until approved, typically months later, and
51
+ provisional values are revised. A store containing any provisional value is marked
52
+ ``provisional = True`` and is served from the cache for one day only; after that a request
53
+ rebuilds it.
54
+ 7. **Travel time.** None by default. A mouth entry may set ``lag_hours``; gauge values are then
55
+ taken that many hours earlier.
56
+
57
+ Mouths
58
+ ------
59
+
60
+ Mouths are listed in ``forcingkit/data/rivers/*.json`` and in any ``*.json`` file in the directory
61
+ named by ``FORCINGKIT_RIVER_MOUTHS_DIR``; a later entry with the same name replaces an earlier one.
62
+ A request may instead pass its own ``mouths``. Adding a river needs a mouth entry and no code or
63
+ release:
64
+
65
+ .. code-block:: json
66
+
67
+ {"mouths": [
68
+ {"name": "Connecticut", "lon": -72.3305, "lat": 41.2726, "comid": 7703046},
69
+ {"name": "Saugatuck", "lon": -73.36, "lat": 41.10, "trace_from": "USGS-01208990"}
70
+ ]}
71
+
72
+ - ``name``, ``lon``, ``lat`` (required): the point places the mouth for the bbox test.
73
+ - ``comid``: the NHDPlus outlet flowline. Otherwise ``trace_from`` names any NLDI feature on the
74
+ river (a USGS site such as ``USGS-01184000``) and the outlet is the end of its downstream trace.
75
+ Failing both, the point is snapped to the nearest flowline, with a warning: at an estuary the
76
+ nearest flowline can be a short coastal one rather than the river.
77
+ - ``include`` and ``exclude``: USGS site numbers to pin or rule out where judgement beats the rule
78
+ (for example a gauge below a dam whose releases should not drive the forcing). A pinned set that
79
+ drains more than the mouth's area is refused as double counting.
80
+ - ``lag_hours``: travel time from the gauges to the mouth, applied to the gauge window.
81
+
82
+ The id of a store includes the mouth entries and ``min_share``, but not the derived gauges, which
83
+ are recorded in the store instead. NLDI facts about a mouth (its outlet, basin area, upstream sites
84
+ and which sites lie downstream of which) are cached for 30 days under ``usgs/derivations`` in the
85
+ cache directory, so only the first request for a mouth takes long (about a minute for the
86
+ Connecticut, whose basin holds 1,375 USGS sites).
87
+
88
+ The packaged mouths are the rivers into Long Island Sound. For a request over the Sound in April
89
+ 2026 the derivation chose:
90
+
91
+ .. list-table::
92
+ :header-rows: 1
93
+ :widths: 16 46 13 13 12
94
+
95
+ * - River
96
+ - USGS gauges kept
97
+ - Gauged (mi²)
98
+ - Mouth (mi²)
99
+ - Scale
100
+ * - Housatonic
101
+ - 01205500 at Stevenson; 01208500 Naugatuck at Beacon Falls
102
+ - 1,804
103
+ - 1,944
104
+ - 1.08
105
+ * - Quinnipiac
106
+ - 01196500 at Wallingford; 01196620 Mill River near Hamden; 01196561 Muddy River near East
107
+ Wallingford
108
+ - 148
109
+ - 204
110
+ - 1.37
111
+ * - Connecticut
112
+ - 01184000 at Thompsonville; 01189995 Farmington at Tariffville
113
+ - 10,237
114
+ - 11,242
115
+ - 1.10
116
+ * - Thames
117
+ - 01127000 Quinebaug at Jewett City; 011230695 Shetucket at Taftville; 01127500 Yantic at
118
+ Yantic
119
+ - 1,314
120
+ - 1,470
121
+ - 1.12
122
+
123
+ The Quinnipiac's outlet basin is New Haven Harbor's, so its scaled flow also stands for the
124
+ harbour's other streams. The Housatonic at Stevenson is below a hydroelectric dam, so its hourly
125
+ flow follows the dam's releases. The Bronx River gauge (01302020) has no record before the 2000s,
126
+ so a request for an earlier window fails unless ``skip_ungauged`` is set, which leaves such a
127
+ river out and lists it in the store's ``skipped_rivers``.
128
+
129
+ Request and store
130
+ -----------------
131
+
132
+ .. code-block:: bash
133
+
134
+ curl -s -X POST localhost:9598/api/v1/rivers -H 'Content-Type: application/json' -d '{
135
+ "bbox": {"min_lon": -73.855, "min_lat": 40.75, "max_lon": -71.9, "max_lat": 41.45},
136
+ "start_time": "2026-04-02T00:00:00Z", "hours": 48}'
137
+
138
+ Optional fields: ``mouths``, ``min_share`` (default 0.01), ``max_gap_hours`` (default 6),
139
+ ``allow_climatology`` and ``skip_ungauged`` (both default false), and ``cache_bust``.
140
+
141
+ For that request (four rivers, 49 records), the mean discharges over the 48 hours were 1,553 m³/s
142
+ for the Connecticut, 147 for the Housatonic, 148 for the Thames and 13 for the Quinnipiac.
143
+
144
+ The store (schema ``river-v1``) has a ``river`` dimension:
145
+
146
+ - ``discharge`` (time, river): m³ s⁻¹, ``water_volume_transport_in_river_channel``.
147
+ - ``temperature`` (time, river): °C.
148
+ - ``fill`` (time, river): the flags above.
149
+ - ``mouth_lon``, ``mouth_lat``, ``scale_factor``, ``salinity`` (river).
150
+ - Attributes: ``river_names``; ``provenance`` (JSON: per river, the gauges kept, the areas, the
151
+ scale factor, the outlet, and the dropped candidates counted by reason); ``derivation_dropped``
152
+ (JSON: every dropped candidate with its reason); ``skipped_rivers``; ``provisional``; and
153
+ ``built_utc``.
154
+
155
+ A JSON sidecar next to the store repeats the request, the mouths and the derivation. ``GET /api/v1/rivers/download/{zarr_id}``
156
+ returns the store as a zip.
157
+
158
+ Credentials
159
+ -----------
160
+
161
+ None are required. A free USGS Water Data API key (``USGS_API_KEY``, from
162
+ `api.waterdata.usgs.gov/signup <https://api.waterdata.usgs.gov/signup/>`__) raises the hourly rate
163
+ limit; it is sent in the ``X-Api-Key`` header. forcingkit uses the modernized APIs, not the
164
+ legacy ``waterservices.usgs.gov``, which USGS plans to retire in 2027.
165
+
166
+ Limits
167
+ ------
168
+
169
+ - Mouths are not found automatically within a box: each river needs a mouth entry. Finding
170
+ coastal outlets from NHDPlus would remove that step.
171
+ - The selection rule cannot tell a regulated gauge from a natural one; use ``exclude`` where dam
172
+ operations should not reach the forcing.
173
+ - Gauges measure surface runoff. Groundwater discharge (most of Long Island's freshwater input) is
174
+ not represented.
175
+ - A model that applies discharge as a surface freshwater flux on fixed levels dilutes salinity
176
+ but adds no volume. Whether the volume is added depends on the model's vertical coordinate, not
177
+ on this store.
@@ -1,9 +1,10 @@
1
1
  Roadmap and Contributing
2
2
  ========================
3
3
 
4
- As of 2026-10-06. This page covers the two kinds of forcing forcingkit delivers: the **parent
5
- ocean** for a model's open boundaries (``/api/v1/obc``) and the **surface atmosphere**
6
- (``/api/v1/atmosphere``). New sources are ranked by two questions:
4
+ As of 2026-10-09. This page covers the kinds of forcing forcingkit delivers: the **parent
5
+ ocean** for a model's open boundaries (``/api/v1/obc``), the **surface atmosphere**
6
+ (``/api/v1/atmosphere``) and **river discharge** (``/api/v1/rivers``). New sources are ranked by
7
+ two questions:
7
8
 
8
9
  1. **Coverage first.** Does it serve a region, or a span of years, that no current source does?
9
10
  2. **Resolution second.** Where something already serves, does it resolve the coast better?
@@ -44,6 +45,10 @@ Current coverage
44
45
  - Contiguous US (atmosphere)
45
46
  - 2014-07-30 on
46
47
  - 3 km
48
+ * - :doc:`USGS gauges <rivers>`
49
+ - Rivers into Long Island Sound (river discharge)
50
+ - 15-minute values online from about 2007; daily means fill in further back
51
+ - Five river mouths
47
52
 
48
53
  So today the only parent ocean outside the US Northeast is HYCOM, at 1/12 degree and in the
49
54
  legacy output layout, and there is no atmosphere outside the contiguous US: there, and before
@@ -73,6 +78,41 @@ Planned work
73
78
  hindcasts (:doc:`atmospheric_forcing`).
74
79
  * - Cache policy
75
80
  - Invalidate stores built from forecasts when the upstream system publishes a newer cycle.
81
+ * - A second global parent
82
+ - HYCOM is the only parent ocean for current dates outside the US Northeast, and its server
83
+ (``tds.hycom.org``) often stalls past the read timeout (:doc:`hycom`). Copernicus GLO12
84
+ (see `Global parent ocean`_) would give an independent fallback for 2020-11-01 on.
85
+ * - Warn on gaps in a donor's record
86
+ - Every HYCOM experiment has a few gaps of up to 51 hours in its 3-hourly record, and the
87
+ hourly resampling bridges them by linear interpolation without notice. Log a warning,
88
+ and record the gap in the store's attributes, when a window contains a step longer than
89
+ about 6 hours.
90
+ * - HYCOM fetcher clean-up
91
+ - Remove the unused ``_normalize_lons``. Select the initial-condition time by position, as
92
+ the boundary path does: the nearest-label lookup fails on the non-monotonic time axis of
93
+ GLBv0.08 ``expt_93.0``.
94
+ * - River mouths for more regions, and found automatically
95
+ - :doc:`rivers` derives each river's gauges per request, so a new river needs only a mouth
96
+ entry. Mouths for the Hudson, Raritan and Delaware would cover the NY Bight and Delaware Bay
97
+ parents already served; finding coastal outlets in a box from NHDPlus would remove the
98
+ entries altogether.
99
+ * - Ungauged rivers
100
+ - Where no tide-free gauge exists, the `National Water Model
101
+ <https://water.noaa.gov/about/nwm>`__ retrospective and operational streamflow (on AWS)
102
+ could supply discharge at the outlet reach.
103
+ * - mypy hook without an active environment
104
+ - The ``mypy`` pre-commit hook runs the ``mypy`` on ``PATH``, so a commit made without the
105
+ project environment activated fails with "Executable ``mypy`` not found". Run it through
106
+ ``uv run`` so it works from any shell.
107
+ * - Fetcher module names
108
+ - Modules in ``forcingkit.fetchers`` are named for their source (``necofs``, ``hycom``,
109
+ ``ndbc``, ``coastwatch``, ``usgs``), and a model-ready delivery for the source and what it
110
+ delivers (``hrrr_atmosphere``, ``usgs_rivers``). Three predate the convention:
111
+ ``noaa.py`` serves CO-OPS water levels and current predictions (``coops.py``),
112
+ ``erddap.py`` serves the UConn profile stations (named for the protocol, not the source),
113
+ and ``hydrography.py`` finds heads of tide from OpenStreetMap (``osm.py`` or similar).
114
+ Renaming them breaks imports from earlier releases, so it belongs in a release that says
115
+ so, with the old names kept as deprecated aliases for one release.
76
116
 
77
117
  Candidate sources: coverage
78
118
  ---------------------------
@@ -49,6 +49,21 @@ Providers
49
49
  * - NDBC buoys
50
50
  - `NOAA National Data Buoy Center <https://www.ndbc.noaa.gov/>`__
51
51
  - NOAA attribution.
52
+ * - River discharge
53
+ - `US Geological Survey <https://www.usgs.gov/>`__ stream gauges, through the `USGS Water
54
+ Data APIs <https://api.waterdata.usgs.gov/>`__; drainage areas from the USGS `NLDI
55
+ <https://waterdata.usgs.gov/blog/nldi-intro/>`__ and NHDPlus
56
+ - Cite the gauges as "U.S. Geological Survey, USGS Water Data for the Nation", with the site
57
+ numbers in the store's ``provenance``. USGS data are in the public domain; provisional
58
+ values are subject to revision.
59
+ * - Satellite surface fields
60
+ - `NOAA NESDIS CoastWatch <https://coastwatch.noaa.gov/>`__ ERDDAP servers: VIIRS
61
+ chlorophyll-a and Kd490 (S-NPP, NOAA-20, NOAA-21), Sentinel-3 OLCI chlorophyll-a, and the
62
+ gap-filled DINEOF products; MUR sea surface temperature from `NASA JPL
63
+ <https://podaac.jpl.nasa.gov/>`__ (PO.DAAC), served through CoastWatch
64
+ - Acknowledge NOAA NESDIS CoastWatch and the datasets named in the store's ``products``
65
+ attribute. MUR: JPL MUR MEaSUREs Project (2015) and Chin et al. (2017). OLCI and DINEOF
66
+ products require "Contains modified Copernicus Sentinel data [year]".
52
67
  * - Water-column profiles
53
68
  - `University of Connecticut Department of Marine Sciences
54
69
  <https://marinesciences.uconn.edu/>`__ (`ERDDAP
@@ -91,6 +106,9 @@ References
91
106
  estuaries. *Journal of Atmospheric and Oceanic Technology*, 20(1), 159-186.
92
107
  `doi:10.1175/1520-0426(2003)020<0159:AUGFVT>2.0.CO;2
93
108
  <https://doi.org/10.1175/1520-0426(2003)020%3C0159:AUGFVT%3E2.0.CO;2>`__
109
+ - Chin, T. M., J. Vazquez-Cuervo and E. M. Armstrong (2017). A multi-scale high-resolution
110
+ analysis of global sea surface temperature. *Remote Sensing of Environment*, 200, 154-169.
111
+ `doi:10.1016/j.rse.2017.07.029 <https://doi.org/10.1016/j.rse.2017.07.029>`__
94
112
  - Dowell, D. C., C. R. Alexander, E. P. James, et al. (2022). The High-Resolution Rapid Refresh
95
113
  (HRRR): An hourly updating convection-allowing forecast model. Part I: Motivation and system
96
114
  description. *Weather and Forecasting*, 37(8), 1371-1395.
@@ -102,6 +120,9 @@ References
102
120
  (HRRR): An hourly updating convection-allowing forecast model. Part II: Forecast performance.
103
121
  *Weather and Forecasting*, 37(8), 1397-1417.
104
122
  `doi:10.1175/WAF-D-21-0130.1 <https://doi.org/10.1175/WAF-D-21-0130.1>`__
123
+ - JPL MUR MEaSUREs Project (2015). GHRSST Level 4 MUR Global Foundation Sea Surface
124
+ Temperature Analysis (v4.1). PO.DAAC, CA, USA.
125
+ `doi:10.5067/GHGMR-4FJ04 <https://doi.org/10.5067/GHGMR-4FJ04>`__
105
126
  - NOAA (2002). *Implementation Plan, Port of New York and New Jersey Operational Forecast System
106
127
  (NYOFS)*. NOAA Technical Report NOS CO-OPS 37, Silver Spring, MD.
107
128
  `PDF <https://tidesandcurrents.noaa.gov/publications/techrpt37.pdf>`__
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "forcingkit"
7
- version = "0.4.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"
@@ -589,6 +589,226 @@ def download_atmosphere(zarr_id: str):
589
589
  )
590
590
 
591
591
 
592
+ class RiverRequest(BaseModel):
593
+ bbox: BoundingBox
594
+ start_time: str # ISO 8601, UTC
595
+ hours: int
596
+ max_gap_hours: int = 6
597
+ allow_climatology: bool = False
598
+ # Mouths to serve instead of the listed ones in the bbox: each {name, lon, lat} with
599
+ # optional comid or trace_from (the outlet), include / exclude (USGS site numbers) and
600
+ # lag_hours.
601
+ mouths: List[Dict[str, Any]] | None = None
602
+ # Gauges draining less than this share of the mouth's area are left out.
603
+ min_share: float = 0.01
604
+ # Leave out, rather than fail on, a river with no gauge record over the window.
605
+ skip_ungauged: bool = False
606
+ cache_bust: bool = False
607
+
608
+
609
+ def _river_cache_dir() -> str:
610
+ return settings.cache_dir("rivers")
611
+
612
+
613
+ @app.post("/api/v1/rivers")
614
+ def generate_rivers(request: RiverRequest) -> Dict[str, Any]:
615
+ """Hourly discharge at each river mouth in the bbox (or each of `mouths`), for point sources
616
+ in an ocean model: the lowest tide-free USGS gauge on each branch, derived per request from
617
+ USGS NLDI and site metadata, summed and scaled by drainage area, from `start_time` to
618
+ `start_time + hours`, streamed to a Zarr store."""
619
+ import json
620
+
621
+ import zarr
622
+
623
+ from forcingkit.dispatcher import dispatch_river_request, river_key, river_mouths
624
+
625
+ if request.hours < 1:
626
+ raise HTTPException(status_code=400, detail="hours must be at least 1")
627
+ if request.max_gap_hours < 0:
628
+ raise HTTPException(status_code=400, detail="max_gap_hours must be at least 0")
629
+ if not 0 <= request.min_share < 1:
630
+ raise HTTPException(status_code=400, detail="min_share must be in [0, 1)")
631
+ bbox_list = [
632
+ request.bbox.min_lon,
633
+ request.bbox.min_lat,
634
+ request.bbox.max_lon,
635
+ request.bbox.max_lat,
636
+ ]
637
+ try:
638
+ mouths = river_mouths(bbox_list, request.mouths)
639
+ except ValueError as e:
640
+ raise HTTPException(status_code=400, detail=str(e))
641
+ if not mouths:
642
+ raise HTTPException(
643
+ status_code=400, detail=f"no listed river mouth lies in bbox {bbox_list}"
644
+ )
645
+ zarr_id = river_key(
646
+ bbox_list,
647
+ request.start_time,
648
+ request.hours,
649
+ request.max_gap_hours,
650
+ request.allow_climatology,
651
+ mouths,
652
+ request.min_share,
653
+ request.skip_ungauged,
654
+ )
655
+ os.makedirs(_river_cache_dir(), exist_ok=True)
656
+ zarr_path = os.path.join(_river_cache_dir(), f"{zarr_id}.zarr")
657
+ try:
658
+ final_path = dispatch_river_request(
659
+ bbox_list,
660
+ request.start_time,
661
+ request.hours,
662
+ zarr_path,
663
+ max_gap_hours=request.max_gap_hours,
664
+ allow_climatology=request.allow_climatology,
665
+ cache_bust=request.cache_bust,
666
+ mouths=mouths,
667
+ min_share=request.min_share,
668
+ skip_ungauged=request.skip_ungauged,
669
+ )
670
+ except ValueError as e:
671
+ raise HTTPException(status_code=400, detail=str(e))
672
+ except Exception as e:
673
+ logger.error(f"River delivery failed: {e}")
674
+ raise HTTPException(status_code=500, detail=str(e))
675
+ attrs = dict(zarr.open_group(final_path, mode="r", zarr_format=2).attrs)
676
+ return {
677
+ "status": "success",
678
+ "zarr_id": zarr_id,
679
+ "zarr_path": final_path,
680
+ "download_url": f"/api/v1/rivers/download/{zarr_id}",
681
+ "rivers": json.loads(str(attrs.get("provenance", "[]"))),
682
+ "provisional": bool(attrs.get("provisional", False)),
683
+ "skipped_rivers": json.loads(str(attrs.get("skipped_rivers", "[]"))),
684
+ }
685
+
686
+
687
+ @app.get("/api/v1/rivers/download/{zarr_id}")
688
+ def download_rivers(zarr_id: str):
689
+ search_id = zarr_id if zarr_id.startswith("riv_") else f"riv_{zarr_id}"
690
+ if not search_id[4:].isalnum():
691
+ raise HTTPException(status_code=400, detail="invalid store id")
692
+ cache_dir = _river_cache_dir()
693
+ zarr_path = os.path.join(cache_dir, f"{search_id}.zarr")
694
+ if not os.path.isdir(zarr_path):
695
+ raise HTTPException(status_code=404, detail="River Zarr store not found.")
696
+ zip_path = os.path.join(cache_dir, f"{search_id}.zip")
697
+ if _zip_is_stale(zip_path, zarr_path):
698
+ shutil.make_archive(zip_path.replace(".zip", ""), "zip", zarr_path)
699
+ return FileResponse(
700
+ zip_path, media_type="application/zip", filename=f"{search_id}.zip"
701
+ )
702
+
703
+
704
+ class SatelliteRequest(BaseModel):
705
+ bbox: BoundingBox
706
+ quantity: (
707
+ str # "sst", "chlor_a", "kd_490" or "spm" (see fetchers.coastwatch.QUANTITIES)
708
+ )
709
+ end_time: str | None = None # ISO 8601, UTC; default now
710
+ days: int = 7
711
+ coast_pixels: int = 1
712
+ cache_bust: bool = False
713
+
714
+
715
+ def _satellite_cache_dir() -> str:
716
+ return settings.cache_dir("satellite")
717
+
718
+
719
+ def _satellite_retention_days() -> float:
720
+ return float(settings.env("FORCINGKIT_SATELLITE_RETENTION_DAYS", "30") or 30)
721
+
722
+
723
+ @app.post("/api/v1/satellite")
724
+ def get_satellite(request: SatelliteRequest) -> Dict[str, Any]:
725
+ """A satellite surface field over the bbox: each pixel's most recent valid value across the quantity's sensors
726
+ within `days` of `end_time`, with its age, a band of `coast_pixels` masked along the shore, as a Zarr store.
727
+
728
+ Stores older than FORCINGKIT_SATELLITE_RETENTION_DAYS (default 30) are pruned on every request, so the cache
729
+ stays bounded.
730
+ """
731
+ import json
732
+ from datetime import datetime, timezone
733
+
734
+ import pandas as pd
735
+
736
+ from forcingkit.fetchers import coastwatch
737
+
738
+ products = coastwatch.QUANTITIES.get(request.quantity)
739
+ if products is None:
740
+ raise HTTPException(
741
+ status_code=400,
742
+ detail=f"quantity must be one of {sorted(coastwatch.QUANTITIES)}",
743
+ )
744
+ if not 1 <= request.days <= 31:
745
+ raise HTTPException(status_code=400, detail="days must be 1 to 31")
746
+ end = (
747
+ pd.Timestamp(request.end_time).to_pydatetime()
748
+ if request.end_time
749
+ else datetime.now(timezone.utc)
750
+ )
751
+ bbox_list = [
752
+ request.bbox.min_lon,
753
+ request.bbox.min_lat,
754
+ request.bbox.max_lon,
755
+ request.bbox.max_lat,
756
+ ]
757
+ cache_dir = _satellite_cache_dir()
758
+ os.makedirs(cache_dir, exist_ok=True)
759
+ coastwatch.prune_cache(cache_dir, _satellite_retention_days())
760
+ zarr_id = coastwatch.cache_key(
761
+ products, bbox_list, end, request.days, request.coast_pixels
762
+ )
763
+ zarr_path = os.path.join(cache_dir, f"{zarr_id}.zarr")
764
+ if request.cache_bust or not os.path.isdir(zarr_path):
765
+ try:
766
+ ds = coastwatch.mask_coast(
767
+ coastwatch.composite(products, bbox_list, end, request.days),
768
+ request.coast_pixels,
769
+ )
770
+ except ValueError as e:
771
+ raise HTTPException(status_code=404, detail=str(e))
772
+ except Exception as e:
773
+ logger.error(f"Satellite delivery failed: {e}")
774
+ raise HTTPException(status_code=500, detail=str(e))
775
+ shutil.rmtree(zarr_path, ignore_errors=True)
776
+ ds.to_zarr(zarr_path, mode="w", zarr_format=2)
777
+ import xarray as xr
778
+
779
+ ds = xr.open_zarr(zarr_path)
780
+ valid = ds["value"].notnull()
781
+ return {
782
+ "status": "success",
783
+ "zarr_id": zarr_id,
784
+ "zarr_path": zarr_path,
785
+ "download_url": f"/api/v1/satellite/download/{zarr_id}",
786
+ "products": str(ds.attrs.get("products", "")).split(","),
787
+ "attribution": ds.attrs.get("attribution", ""),
788
+ "valid_share": float(valid.mean()),
789
+ "age_days_median": float(ds["age_days"].where(valid).median())
790
+ if bool(valid.any())
791
+ else None,
792
+ "attrs": json.loads(json.dumps(dict(ds.attrs), default=str)),
793
+ }
794
+
795
+
796
+ @app.get("/api/v1/satellite/download/{zarr_id}")
797
+ def download_satellite(zarr_id: str):
798
+ if not (zarr_id.startswith("sat_") and zarr_id[4:].isalnum()):
799
+ raise HTTPException(status_code=400, detail="invalid store id")
800
+ cache_dir = _satellite_cache_dir()
801
+ zarr_path = os.path.join(cache_dir, f"{zarr_id}.zarr")
802
+ if not os.path.isdir(zarr_path):
803
+ raise HTTPException(status_code=404, detail="Satellite Zarr store not found.")
804
+ zip_path = os.path.join(cache_dir, f"{zarr_id}.zip")
805
+ if _zip_is_stale(zip_path, zarr_path):
806
+ shutil.make_archive(zip_path.replace(".zip", ""), "zip", zarr_path)
807
+ return FileResponse(
808
+ zip_path, media_type="application/zip", filename=f"{zarr_id}.zip"
809
+ )
810
+
811
+
592
812
  if __name__ == "__main__":
593
813
  import uvicorn
594
814
  import argparse
@@ -0,0 +1,29 @@
1
+ {
2
+ "region": "Long Island Sound and its approaches, US Northeast",
3
+ "note": "River mouths only. The gauges and the scale factor of each river are derived for every request from USGS NLDI and site metadata (see forcingkit.fetchers.usgs_rivers). comid is the NHDPlus outlet flowline; lon and lat place the mouth for the bbox test. include and exclude pin or rule out gauges by USGS site number.",
4
+ "mouths": [
5
+ {
6
+ "name": "Bronx",
7
+ "lon": -73.8584,
8
+ "lat": 40.8032,
9
+ "comid": 26431899,
10
+ "note": "Enters the East River at Clason Point. The outlet is set by hand: NHDPlus routes the East River on through New York Harbor."
11
+ },
12
+ {"name": "Housatonic", "lon": -73.1113, "lat": 41.1714, "comid": 7718580},
13
+ {
14
+ "name": "Quinnipiac",
15
+ "lon": -72.9053,
16
+ "lat": 41.2981,
17
+ "comid": 6178614,
18
+ "note": "The outlet basin is New Haven Harbor's, so the scaled flow also stands for the Mill and West Rivers."
19
+ },
20
+ {
21
+ "name": "Connecticut",
22
+ "lon": -72.3305,
23
+ "lat": 41.2726,
24
+ "comid": 7703046,
25
+ "note": "Travel time from Thompsonville to the mouth is roughly a day at high flow; set lag_hours to apply it."
26
+ },
27
+ {"name": "Thames", "lon": -72.0827, "lat": 41.3178, "comid": 6170754}
28
+ ]
29
+ }