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.
@@ -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
+ )