forcingkit 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- forcingkit/__init__.py +0 -0
- forcingkit/dispatcher.py +448 -0
- forcingkit/fetchers/dbofs.py +442 -0
- forcingkit/fetchers/erddap.py +142 -0
- forcingkit/fetchers/hrrr.py +72 -0
- forcingkit/fetchers/hrrr_atmosphere.py +289 -0
- forcingkit/fetchers/hycom.py +159 -0
- forcingkit/fetchers/hydrography.py +117 -0
- forcingkit/fetchers/ndbc.py +231 -0
- forcingkit/fetchers/necofs.py +369 -0
- forcingkit/fetchers/noaa.py +87 -0
- forcingkit/fetchers/nyofs.py +458 -0
- forcingkit/settings.py +72 -0
- forcingkit/zarr_stream.py +146 -0
- forcingkit-0.1.0.dist-info/METADATA +329 -0
- forcingkit-0.1.0.dist-info/RECORD +24 -0
- forcingkit-0.1.0.dist-info/WHEEL +4 -0
- forcingkit-0.1.0.dist-info/licenses/LICENSE +201 -0
- forcingkit_serve/__init__.py +0 -0
- forcingkit_serve/main.py +544 -0
- forcingkit_serve/routers/bathymetry.py +241 -0
- forcingkit_serve/routers/plotly_api.py +150 -0
- forcingkit_serve/routers/removed.py +86 -0
- forcingkit_serve/routers/viewer.py +1199 -0
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
import httpx
|
|
3
|
+
import logging
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from fastapi import APIRouter, Query, HTTPException
|
|
6
|
+
from fastapi.responses import StreamingResponse, FileResponse
|
|
7
|
+
from starlette.background import BackgroundTask
|
|
8
|
+
from pydantic import BaseModel
|
|
9
|
+
from forcingkit import settings
|
|
10
|
+
|
|
11
|
+
router = APIRouter(prefix="/api/v1/bathymetry", tags=["bathymetry"])
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
async def stream_remote_file(url: str):
|
|
16
|
+
"""
|
|
17
|
+
Passthrough proxy stream for local topobathykit setups to prevent local disk double-caching.
|
|
18
|
+
Large timeout applied because fusing geospatial data can take 5+ minutes.
|
|
19
|
+
"""
|
|
20
|
+
client = httpx.AsyncClient()
|
|
21
|
+
request = client.build_request("GET", url, timeout=3600.0)
|
|
22
|
+
response = await client.send(request, stream=True)
|
|
23
|
+
|
|
24
|
+
if response.status_code != 200:
|
|
25
|
+
await response.aread()
|
|
26
|
+
await response.aclose()
|
|
27
|
+
await client.aclose()
|
|
28
|
+
raise HTTPException(
|
|
29
|
+
status_code=response.status_code,
|
|
30
|
+
detail=f"topobathykit Error: {response.text}",
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
async def cleanup():
|
|
34
|
+
await response.aclose()
|
|
35
|
+
await client.aclose()
|
|
36
|
+
|
|
37
|
+
return StreamingResponse(
|
|
38
|
+
response.aiter_raw(),
|
|
39
|
+
media_type="application/zip",
|
|
40
|
+
background=BackgroundTask(cleanup),
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@router.get("/fuse")
|
|
45
|
+
async def fuse_bathymetry(
|
|
46
|
+
west: float = Query(..., description="Bounding box west (lon)"),
|
|
47
|
+
south: float = Query(..., description="Bounding box south (lat)"),
|
|
48
|
+
east: float = Query(..., description="Bounding box east (lon)"),
|
|
49
|
+
north: float = Query(..., description="Bounding box north (lat)"),
|
|
50
|
+
resolution: float = Query(10.0, description="Output resolution in meters"),
|
|
51
|
+
format: str = Query("zarr", description="Output format: geotiff or zarr"),
|
|
52
|
+
):
|
|
53
|
+
"""
|
|
54
|
+
Smart proxy for topobathykit that retrieves fused topography.
|
|
55
|
+
If topobathykit is on a remote cluster, this caches the zipped output locally
|
|
56
|
+
in forcingkit so future identical runs bypass the network.
|
|
57
|
+
"""
|
|
58
|
+
topobathy_url = settings.env("TOPOBATHYKIT_URL", "http://localhost:9595").rstrip(
|
|
59
|
+
"/"
|
|
60
|
+
)
|
|
61
|
+
target_url = f"{topobathy_url}/fuse?west={west}&south={south}&east={east}&north={north}&resolution={resolution}&format={format}"
|
|
62
|
+
|
|
63
|
+
is_local = (
|
|
64
|
+
"localhost" in topobathy_url
|
|
65
|
+
or "127.0.0.1" in topobathy_url
|
|
66
|
+
or "0.0.0.0" in topobathy_url
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
if is_local:
|
|
70
|
+
logger.info(
|
|
71
|
+
f"topobathykit is local ({topobathy_url}). Streaming directly to avoid disk duplication."
|
|
72
|
+
)
|
|
73
|
+
try:
|
|
74
|
+
return await stream_remote_file(target_url)
|
|
75
|
+
except httpx.ConnectError:
|
|
76
|
+
raise HTTPException(
|
|
77
|
+
status_code=502,
|
|
78
|
+
detail=f"topobathykit is unreachable at {topobathy_url}. Please ensure the service is running on port 9595.",
|
|
79
|
+
)
|
|
80
|
+
except httpx.ReadTimeout:
|
|
81
|
+
raise HTTPException(
|
|
82
|
+
status_code=504,
|
|
83
|
+
detail=f"topobathykit at {topobathy_url} timed out during fusion.",
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
# --- IF REMOTE, Cache Strategy is Engaged ---
|
|
87
|
+
logger.info(
|
|
88
|
+
f"topobathykit is remote ({topobathy_url}). Entering proxy-cache routine."
|
|
89
|
+
)
|
|
90
|
+
cache_dir = Path(settings.cache_dir()).expanduser() / "bathymetry"
|
|
91
|
+
cache_dir.mkdir(parents=True, exist_ok=True)
|
|
92
|
+
|
|
93
|
+
# Predictable cache key
|
|
94
|
+
key_str = f"{west},{south},{east},{north}_{resolution}_{format}"
|
|
95
|
+
cache_key = hashlib.md5(key_str.encode()).hexdigest()
|
|
96
|
+
cache_path = cache_dir / f"fused_proxy_{cache_key}.zip"
|
|
97
|
+
|
|
98
|
+
if cache_path.exists():
|
|
99
|
+
logger.info(f"Serving topography from local cache: {cache_path}")
|
|
100
|
+
return FileResponse(
|
|
101
|
+
path=cache_path,
|
|
102
|
+
media_type="application/zip",
|
|
103
|
+
filename=f"fused_{cache_key}.zip",
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
# Download and cache it
|
|
107
|
+
logger.info(
|
|
108
|
+
f"Cache miss. Fetching from remote topobathykit and writing to {cache_path}..."
|
|
109
|
+
)
|
|
110
|
+
try:
|
|
111
|
+
async with httpx.AsyncClient(timeout=3600.0) as client:
|
|
112
|
+
async with client.stream("GET", target_url) as response:
|
|
113
|
+
if response.status_code != 200:
|
|
114
|
+
text = await response.aread()
|
|
115
|
+
raise HTTPException(
|
|
116
|
+
status_code=response.status_code,
|
|
117
|
+
detail=f"topobathykit Error: {text.decode('utf-8')}",
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
# Stream directly to disk
|
|
121
|
+
with open(cache_path, "wb") as f_out:
|
|
122
|
+
async for chunk in response.aiter_bytes(chunk_size=8192):
|
|
123
|
+
f_out.write(chunk)
|
|
124
|
+
|
|
125
|
+
except Exception as e:
|
|
126
|
+
if cache_path.exists():
|
|
127
|
+
cache_path.unlink()
|
|
128
|
+
raise HTTPException(status_code=500, detail=f"Proxy stream failed: {str(e)}")
|
|
129
|
+
|
|
130
|
+
logger.info(f"Successfully cached and serving: {cache_path}")
|
|
131
|
+
return FileResponse(
|
|
132
|
+
path=cache_path, media_type="application/zip", filename=f"fused_{cache_key}.zip"
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class FusionRequest(BaseModel):
|
|
137
|
+
bbox: list[float]
|
|
138
|
+
resolution: float = 30.0
|
|
139
|
+
format: str = "zarr"
|
|
140
|
+
policy_name: str | None = None
|
|
141
|
+
policy_yaml: str | None = None
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
@router.post("/fuse")
|
|
145
|
+
async def fuse_bathymetry_post(request: FusionRequest):
|
|
146
|
+
topobathy_url = settings.env("TOPOBATHYKIT_URL", "http://localhost:9595").rstrip(
|
|
147
|
+
"/"
|
|
148
|
+
)
|
|
149
|
+
target_url = f"{topobathy_url}/fuse"
|
|
150
|
+
is_local = (
|
|
151
|
+
"localhost" in topobathy_url
|
|
152
|
+
or "127.0.0.1" in topobathy_url
|
|
153
|
+
or "0.0.0.0" in topobathy_url
|
|
154
|
+
)
|
|
155
|
+
if is_local:
|
|
156
|
+
client = httpx.AsyncClient()
|
|
157
|
+
req = client.build_request(
|
|
158
|
+
"POST", target_url, timeout=3600.0, json=request.model_dump()
|
|
159
|
+
)
|
|
160
|
+
try:
|
|
161
|
+
response = await client.send(req, stream=True)
|
|
162
|
+
except httpx.ConnectError:
|
|
163
|
+
raise HTTPException(
|
|
164
|
+
status_code=502,
|
|
165
|
+
detail=f"topobathykit is unreachable at {topobathy_url}. Please ensure the service is running on port 9595.",
|
|
166
|
+
)
|
|
167
|
+
except httpx.ReadTimeout:
|
|
168
|
+
raise HTTPException(
|
|
169
|
+
status_code=504,
|
|
170
|
+
detail=f"topobathykit at {topobathy_url} timed out during fusion.",
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
if response.status_code != 200:
|
|
174
|
+
text = await response.aread()
|
|
175
|
+
await response.aclose()
|
|
176
|
+
await client.aclose()
|
|
177
|
+
raise HTTPException(
|
|
178
|
+
status_code=response.status_code,
|
|
179
|
+
detail=f"topobathykit Error: {text.decode('utf-8')}",
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
async def cleanup():
|
|
183
|
+
await response.aclose()
|
|
184
|
+
await client.aclose()
|
|
185
|
+
|
|
186
|
+
return StreamingResponse(
|
|
187
|
+
response.aiter_raw(),
|
|
188
|
+
media_type="application/zip",
|
|
189
|
+
background=BackgroundTask(cleanup),
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
logger.info(
|
|
193
|
+
f"topobathykit is remote ({topobathy_url}). Entering proxy-cache routine."
|
|
194
|
+
)
|
|
195
|
+
cache_dir = Path(settings.cache_dir()).expanduser() / "bathymetry"
|
|
196
|
+
cache_dir.mkdir(parents=True, exist_ok=True)
|
|
197
|
+
|
|
198
|
+
# Include policy in cache key
|
|
199
|
+
policy_hash = "default"
|
|
200
|
+
if request.policy_yaml:
|
|
201
|
+
policy_hash = hashlib.md5(request.policy_yaml.encode()).hexdigest()
|
|
202
|
+
elif request.policy_name:
|
|
203
|
+
policy_hash = request.policy_name
|
|
204
|
+
|
|
205
|
+
key_str = f"{request.bbox}_{request.resolution}_{request.format}_{policy_hash}"
|
|
206
|
+
cache_key = hashlib.md5(key_str.encode()).hexdigest()
|
|
207
|
+
cache_path = cache_dir / f"fused_proxy_{cache_key}.zip"
|
|
208
|
+
|
|
209
|
+
if cache_path.exists():
|
|
210
|
+
logger.info(f"Serving topography from local cache: {cache_path}")
|
|
211
|
+
return FileResponse(
|
|
212
|
+
path=cache_path,
|
|
213
|
+
media_type="application/zip",
|
|
214
|
+
filename=f"fused_{cache_key}.zip",
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
logger.info(
|
|
218
|
+
f"Cache miss. Fetching from remote topobathykit and writing to {cache_path}..."
|
|
219
|
+
)
|
|
220
|
+
try:
|
|
221
|
+
async with httpx.AsyncClient(timeout=3600.0) as client:
|
|
222
|
+
async with client.stream(
|
|
223
|
+
"POST", target_url, json=request.model_dump()
|
|
224
|
+
) as response:
|
|
225
|
+
if response.status_code != 200:
|
|
226
|
+
text = await response.aread()
|
|
227
|
+
raise HTTPException(
|
|
228
|
+
status_code=response.status_code,
|
|
229
|
+
detail=f"topobathykit Error: {text.decode('utf-8')}",
|
|
230
|
+
)
|
|
231
|
+
with open(cache_path, "wb") as f_out:
|
|
232
|
+
async for chunk in response.aiter_bytes(chunk_size=8192):
|
|
233
|
+
f_out.write(chunk)
|
|
234
|
+
except Exception as e:
|
|
235
|
+
if cache_path.exists():
|
|
236
|
+
cache_path.unlink()
|
|
237
|
+
raise HTTPException(status_code=500, detail=f"Proxy stream failed: {str(e)}")
|
|
238
|
+
|
|
239
|
+
return FileResponse(
|
|
240
|
+
path=cache_path, media_type="application/zip", filename=f"fused_{cache_key}.zip"
|
|
241
|
+
)
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
from fastapi import APIRouter, Query, HTTPException, Response
|
|
2
|
+
import os
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
import logging
|
|
5
|
+
import json
|
|
6
|
+
import numpy as np
|
|
7
|
+
import xarray as xr
|
|
8
|
+
from forcingkit import settings
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
router = APIRouter(tags=["Cache View Data"])
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@router.get("/api/v1/cache/data")
|
|
15
|
+
async def get_dataset_data(
|
|
16
|
+
dataset_id: str = Query(..., description="ID of the dataset"),
|
|
17
|
+
ext: str = Query(..., description="Extension"),
|
|
18
|
+
var_name: str = Query(..., description="Variable query"),
|
|
19
|
+
time_idx: str = Query("0", description="Time step index OR 'all'"),
|
|
20
|
+
lod: int = Query(0, description="Level of Detail stride (0=auto)"),
|
|
21
|
+
):
|
|
22
|
+
cache_dir = Path(settings.cache_dir()).expanduser()
|
|
23
|
+
full_path = os.path.join(
|
|
24
|
+
cache_dir, f"{dataset_id}.zarr" if ext == "zarr" else f"{dataset_id}.{ext}"
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
if not os.path.exists(full_path):
|
|
28
|
+
raise HTTPException(status_code=404, detail="Dataset not found")
|
|
29
|
+
|
|
30
|
+
try:
|
|
31
|
+
ds = (
|
|
32
|
+
xr.open_zarr(full_path)
|
|
33
|
+
if ext == "zarr"
|
|
34
|
+
else xr.open_dataset(
|
|
35
|
+
full_path, engine="cfgrib" if ext == "grib" else "netcdf4"
|
|
36
|
+
)
|
|
37
|
+
)
|
|
38
|
+
if var_name not in ds.variables:
|
|
39
|
+
raise HTTPException(status_code=404, detail="Variable not found")
|
|
40
|
+
|
|
41
|
+
data_var = ds[var_name]
|
|
42
|
+
|
|
43
|
+
# Dimensions
|
|
44
|
+
slice_opts = {}
|
|
45
|
+
fetch_all_time = time_idx == "all"
|
|
46
|
+
|
|
47
|
+
for time_cand in ["time", "valid_time", "step", "ocean_time"]:
|
|
48
|
+
if time_cand in data_var.dims:
|
|
49
|
+
if not fetch_all_time:
|
|
50
|
+
idx = int(time_idx)
|
|
51
|
+
slice_opts[time_cand] = min(idx, ds.sizes[time_cand] - 1)
|
|
52
|
+
|
|
53
|
+
for z_name in ["depth", "zC", "level", "s_rho", "surface", "siglay", "siglev"]:
|
|
54
|
+
if z_name in data_var.dims and z_name not in slice_opts:
|
|
55
|
+
slice_opts[z_name] = (
|
|
56
|
+
-1 if z_name in ("s_rho", "siglay", "siglev") else 0
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
valid_opts = {k: v for k, v in slice_opts.items() if k in data_var.dims}
|
|
60
|
+
da = data_var.isel(valid_opts)
|
|
61
|
+
|
|
62
|
+
# Smart sub-sampling
|
|
63
|
+
y_name = next(
|
|
64
|
+
(
|
|
65
|
+
str(d)
|
|
66
|
+
for d in da.dims
|
|
67
|
+
if "lat" in str(d) or "eta" in str(d) or str(d) == "y"
|
|
68
|
+
),
|
|
69
|
+
None,
|
|
70
|
+
)
|
|
71
|
+
x_name = next(
|
|
72
|
+
(
|
|
73
|
+
str(d)
|
|
74
|
+
for d in da.dims
|
|
75
|
+
if "lon" in str(d) or "xi" in str(d) or str(d) == "x"
|
|
76
|
+
),
|
|
77
|
+
None,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
actual_stride = 1
|
|
81
|
+
if lod > 0:
|
|
82
|
+
actual_stride = lod
|
|
83
|
+
else:
|
|
84
|
+
# Auto compute stride based on spatial coords
|
|
85
|
+
if y_name and x_name:
|
|
86
|
+
max_dim = max(da.sizes[y_name], da.sizes[x_name])
|
|
87
|
+
else:
|
|
88
|
+
max_dim = max(da.shape)
|
|
89
|
+
if max_dim > 200:
|
|
90
|
+
actual_stride = int(np.ceil(max_dim / 200.0))
|
|
91
|
+
|
|
92
|
+
if actual_stride > 1:
|
|
93
|
+
slice_dict = {}
|
|
94
|
+
if y_name:
|
|
95
|
+
slice_dict[y_name] = slice(None, None, actual_stride)
|
|
96
|
+
if x_name:
|
|
97
|
+
slice_dict[x_name] = slice(None, None, actual_stride)
|
|
98
|
+
if slice_dict:
|
|
99
|
+
da = da.isel(slice_dict)
|
|
100
|
+
else:
|
|
101
|
+
# Fallback if no specific x/y dimension detected
|
|
102
|
+
spatial_dims = [
|
|
103
|
+
d
|
|
104
|
+
for d in da.dims
|
|
105
|
+
if d
|
|
106
|
+
not in [
|
|
107
|
+
"time",
|
|
108
|
+
"valid_time",
|
|
109
|
+
"step",
|
|
110
|
+
"ocean_time",
|
|
111
|
+
"depth",
|
|
112
|
+
"zC",
|
|
113
|
+
"level",
|
|
114
|
+
"s_rho",
|
|
115
|
+
"surface",
|
|
116
|
+
"siglay",
|
|
117
|
+
"siglev",
|
|
118
|
+
]
|
|
119
|
+
]
|
|
120
|
+
da = da.isel(
|
|
121
|
+
{d: slice(None, None, actual_stride) for d in spatial_dims}
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
array = da.values
|
|
125
|
+
|
|
126
|
+
# Replace NaNs with None for JSON
|
|
127
|
+
arr = np.asarray(array)
|
|
128
|
+
masked = np.where(np.isnan(arr), None, arr).tolist() # type: ignore
|
|
129
|
+
|
|
130
|
+
y_vals = (
|
|
131
|
+
ds[y_name].isel({y_name: slice(None, None, actual_stride)}).values.tolist()
|
|
132
|
+
if y_name and y_name in ds.coords
|
|
133
|
+
else None
|
|
134
|
+
)
|
|
135
|
+
x_vals = (
|
|
136
|
+
ds[x_name].isel({x_name: slice(None, None, actual_stride)}).values.tolist()
|
|
137
|
+
if x_name and x_name in ds.coords
|
|
138
|
+
else None
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
return Response(
|
|
142
|
+
content=json.dumps(
|
|
143
|
+
{"z": masked, "x": x_vals, "y": y_vals, "cube": fetch_all_time}
|
|
144
|
+
),
|
|
145
|
+
media_type="application/json",
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
except Exception as e:
|
|
149
|
+
logger.error(f"Plotly data prep failed: {e}")
|
|
150
|
+
raise HTTPException(status_code=500, detail=str(e))
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Stubs for the routes removed on 2026-10-05.
|
|
2
|
+
|
|
3
|
+
Each answers 410 Gone with the endpoint that replaces it, so a client still calling one learns
|
|
4
|
+
where to go instead of meeting a bare 404. The response carries `Deprecation` (RFC 9745) and
|
|
5
|
+
`Sunset` (RFC 8594) headers, both at the removal date, and `Link` headers to the notes at
|
|
6
|
+
forcingkit.docs.lhzn.io (docs/source/removed_endpoints.rst). The next release drops this module and its `include_router`.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
from datetime import datetime, timezone
|
|
11
|
+
from email.utils import format_datetime
|
|
12
|
+
|
|
13
|
+
from fastapi import APIRouter
|
|
14
|
+
from fastapi.responses import JSONResponse
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger("forcingkit_serve")
|
|
17
|
+
|
|
18
|
+
REMOVED_ON = datetime(2026, 10, 5, tzinfo=timezone.utc)
|
|
19
|
+
DOCS_URL = "https://forcingkit.docs.lhzn.io/removed_endpoints.html"
|
|
20
|
+
|
|
21
|
+
_PARENT = (
|
|
22
|
+
"POST /api/v1/obc: the parent ocean on true z (schema z-v2); its first record is the "
|
|
23
|
+
"initial condition"
|
|
24
|
+
)
|
|
25
|
+
_ATMOSPHERE = (
|
|
26
|
+
"POST /api/v1/atmosphere: the HRRR surface atmosphere (schema hrrr-atm-v1), including "
|
|
27
|
+
"10 m wind"
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
# (method, path) -> replacement
|
|
31
|
+
REMOVED_ROUTES: dict[tuple[str, str], str] = {
|
|
32
|
+
("POST", "/api/v1/ic/generate"): _PARENT,
|
|
33
|
+
("POST", "/api/v1/ic/regrid"): _PARENT,
|
|
34
|
+
("POST", "/api/v1/ic/cache"): "POST /api/v1/obc/cache",
|
|
35
|
+
("POST", "/api/v1/ic/predict-donor"): "POST /api/v1/obc/predict-donor",
|
|
36
|
+
("GET", "/api/v1/ic/download/{zarr_id}"): "GET /api/v1/obc/download/{zarr_id}",
|
|
37
|
+
("POST", "/api/v1/bc/generate"): _ATMOSPHERE,
|
|
38
|
+
("POST", "/api/v1/bc/cache"): _ATMOSPHERE + " (its response carries the store id)",
|
|
39
|
+
("POST", "/api/v1/bc/predict-donor"): (
|
|
40
|
+
"none: the atmosphere is HRRR from 2014-07-30; earlier runs use ERA5 through "
|
|
41
|
+
"NumericalEarth in the model"
|
|
42
|
+
),
|
|
43
|
+
(
|
|
44
|
+
"GET",
|
|
45
|
+
"/api/v1/bc/download/{zarr_id}",
|
|
46
|
+
): "GET /api/v1/atmosphere/download/{zarr_id}",
|
|
47
|
+
("POST", "/api/v1/harmonics"): (
|
|
48
|
+
"POST /api/v1/tide for observed water level, or the `zeta` variable of the "
|
|
49
|
+
"/api/v1/obc parent store"
|
|
50
|
+
),
|
|
51
|
+
("GET", "/api/v2/coupled-boundaries/{region}"): "POST /api/v1/obc",
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
router = APIRouter(tags=["Removed"])
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _gone(method: str, path: str, replacement: str):
|
|
58
|
+
async def handler() -> JSONResponse:
|
|
59
|
+
logger.warning(f"Removed endpoint called: {method} {path}; use {replacement}")
|
|
60
|
+
return JSONResponse(
|
|
61
|
+
status_code=410,
|
|
62
|
+
content={
|
|
63
|
+
"status": "gone",
|
|
64
|
+
"detail": f"{method} {path} was removed on {REMOVED_ON.date().isoformat()}.",
|
|
65
|
+
"replacement": replacement,
|
|
66
|
+
"docs": DOCS_URL,
|
|
67
|
+
},
|
|
68
|
+
headers={
|
|
69
|
+
"Deprecation": f"@{int(REMOVED_ON.timestamp())}",
|
|
70
|
+
"Sunset": format_datetime(REMOVED_ON, usegmt=True),
|
|
71
|
+
"Link": f'<{DOCS_URL}>; rel="deprecation", <{DOCS_URL}>; rel="sunset"',
|
|
72
|
+
},
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
return handler
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
for (_method, _path), _replacement in REMOVED_ROUTES.items():
|
|
79
|
+
router.add_api_route(
|
|
80
|
+
_path,
|
|
81
|
+
_gone(_method, _path, _replacement),
|
|
82
|
+
methods=[_method],
|
|
83
|
+
status_code=410,
|
|
84
|
+
deprecated=True,
|
|
85
|
+
summary=f"Removed on {REMOVED_ON.date().isoformat()}; use {_replacement.split(':')[0]}",
|
|
86
|
+
)
|