HBV-Lab 1.1.0__tar.gz → 1.2.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.
- {hbv_lab-1.1.0 → hbv_lab-1.2.0}/HBV_Lab/__init__.py +1 -1
- hbv_lab-1.2.0/HBV_Lab/mcp_server.py +353 -0
- hbv_lab-1.2.0/HBV_Lab.egg-info/PKG-INFO +348 -0
- {hbv_lab-1.1.0 → hbv_lab-1.2.0}/HBV_Lab.egg-info/SOURCES.txt +2 -0
- hbv_lab-1.2.0/HBV_Lab.egg-info/entry_points.txt +2 -0
- {hbv_lab-1.1.0 → hbv_lab-1.2.0}/HBV_Lab.egg-info/requires.txt +3 -0
- hbv_lab-1.2.0/PKG-INFO +348 -0
- hbv_lab-1.2.0/README.md +307 -0
- {hbv_lab-1.1.0 → hbv_lab-1.2.0}/setup.py +8 -1
- hbv_lab-1.1.0/HBV_Lab.egg-info/PKG-INFO +0 -134
- hbv_lab-1.1.0/PKG-INFO +0 -134
- hbv_lab-1.1.0/README.md +0 -95
- {hbv_lab-1.1.0 → hbv_lab-1.2.0}/HBV_Lab/HBV_model.py +0 -0
- {hbv_lab-1.1.0 → hbv_lab-1.2.0}/HBV_Lab/calibration.py +0 -0
- {hbv_lab-1.1.0 → hbv_lab-1.2.0}/HBV_Lab/hbv_step.py +0 -0
- {hbv_lab-1.1.0 → hbv_lab-1.2.0}/HBV_Lab/response.py +0 -0
- {hbv_lab-1.1.0 → hbv_lab-1.2.0}/HBV_Lab/routing.py +0 -0
- {hbv_lab-1.1.0 → hbv_lab-1.2.0}/HBV_Lab/snow.py +0 -0
- {hbv_lab-1.1.0 → hbv_lab-1.2.0}/HBV_Lab/soil.py +0 -0
- {hbv_lab-1.1.0 → hbv_lab-1.2.0}/HBV_Lab/uncertainty.py +0 -0
- {hbv_lab-1.1.0 → hbv_lab-1.2.0}/HBV_Lab.egg-info/dependency_links.txt +0 -0
- {hbv_lab-1.1.0 → hbv_lab-1.2.0}/HBV_Lab.egg-info/top_level.txt +0 -0
- {hbv_lab-1.1.0 → hbv_lab-1.2.0}/LICENSE +0 -0
- {hbv_lab-1.1.0 → hbv_lab-1.2.0}/setup.cfg +0 -0
- {hbv_lab-1.1.0 → hbv_lab-1.2.0}/tests/test_hbv_model.py +0 -0
- {hbv_lab-1.1.0 → hbv_lab-1.2.0}/tests/test_hbv_step.py +0 -0
- {hbv_lab-1.1.0 → hbv_lab-1.2.0}/tests/test_response.py +0 -0
- {hbv_lab-1.1.0 → hbv_lab-1.2.0}/tests/test_snow.py +0 -0
- {hbv_lab-1.1.0 → hbv_lab-1.2.0}/tests/test_soil.py +0 -0
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
"""
|
|
2
|
+
HBV_Lab MCP server — exposes the HBV hydrological model as tools an agent can call.
|
|
3
|
+
|
|
4
|
+
Run it (stdio transport, for Claude Desktop / Claude Code / any MCP client):
|
|
5
|
+
|
|
6
|
+
pip install "HBV_Lab[mcp]"
|
|
7
|
+
python -m HBV_Lab.mcp_server # or the console script: hbv-mcp
|
|
8
|
+
|
|
9
|
+
Design notes
|
|
10
|
+
------------
|
|
11
|
+
* **Stateful models, stateless calls.** An ``HBVModel`` holds data, parameters and
|
|
12
|
+
results, but MCP tools are individual calls. The server keeps a registry of live
|
|
13
|
+
models; every tool references one by ``model_id``. Create one with ``create_model``,
|
|
14
|
+
then load data / run / calibrate against that id.
|
|
15
|
+
* **No big arrays through the context window.** ``load_data`` reads a CSV or Excel
|
|
16
|
+
file from disk by *path* — the multi-thousand-row time series never passes through
|
|
17
|
+
the model's context. Tools return compact JSON (metrics, parameter values, output
|
|
18
|
+
file paths); full result arrays are written to files via ``save_results`` /
|
|
19
|
+
``plot_results`` and referenced by path.
|
|
20
|
+
* Matplotlib runs headless (Agg backend) so plotting works on a server with no display.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import os
|
|
26
|
+
import matplotlib
|
|
27
|
+
|
|
28
|
+
matplotlib.use("Agg") # headless: no GUI needed on a server
|
|
29
|
+
|
|
30
|
+
from mcp.server.fastmcp import FastMCP
|
|
31
|
+
|
|
32
|
+
from . import HBVModel, __version__
|
|
33
|
+
|
|
34
|
+
mcp = FastMCP("HBV_Lab")
|
|
35
|
+
|
|
36
|
+
# --- in-memory model registry -------------------------------------------------
|
|
37
|
+
_MODELS: dict[str, HBVModel] = {}
|
|
38
|
+
_COUNTER = {"n": 0}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _new_id() -> str:
|
|
42
|
+
_COUNTER["n"] += 1
|
|
43
|
+
return f"model-{_COUNTER['n']}"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _get(model_id: str) -> HBVModel:
|
|
47
|
+
if model_id not in _MODELS:
|
|
48
|
+
raise ValueError(
|
|
49
|
+
f"Unknown model_id '{model_id}'. Call create_model first, "
|
|
50
|
+
f"or list_models to see live ids."
|
|
51
|
+
)
|
|
52
|
+
return _MODELS[model_id]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _metrics(model: HBVModel) -> dict:
|
|
56
|
+
"""Round performance metrics for compact, readable output."""
|
|
57
|
+
pm = getattr(model, "performance_metrics", None)
|
|
58
|
+
if not pm:
|
|
59
|
+
return {}
|
|
60
|
+
# cast numpy scalars to plain float so the result serializes cleanly to JSON
|
|
61
|
+
return {k: (round(float(v), 4) if v is not None else None) for k, v in pm.items()}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _find_group(model: HBVModel, name: str) -> str | None:
|
|
65
|
+
for group, params in model.params.items():
|
|
66
|
+
if name in params:
|
|
67
|
+
return group
|
|
68
|
+
return None
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
# --- tools --------------------------------------------------------------------
|
|
72
|
+
@mcp.tool()
|
|
73
|
+
def create_model(name: str = "") -> dict:
|
|
74
|
+
"""Create a new HBV model instance and return its model_id.
|
|
75
|
+
|
|
76
|
+
All other tools operate on a model referenced by this id.
|
|
77
|
+
"""
|
|
78
|
+
model_id = _new_id()
|
|
79
|
+
_MODELS[model_id] = HBVModel()
|
|
80
|
+
return {"model_id": model_id, "name": name, "hbv_lab_version": __version__}
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@mcp.tool()
|
|
84
|
+
def list_models() -> dict:
|
|
85
|
+
"""List the model_ids currently held in the server registry."""
|
|
86
|
+
return {"model_ids": list(_MODELS.keys())}
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@mcp.tool()
|
|
90
|
+
def load_data(
|
|
91
|
+
model_id: str,
|
|
92
|
+
file_path: str,
|
|
93
|
+
precip_column: str = "Precipitation",
|
|
94
|
+
temp_column: str = "Temperature",
|
|
95
|
+
pet_column: str = "PotentialET",
|
|
96
|
+
date_column: str = "Date",
|
|
97
|
+
obs_q_column: str = "",
|
|
98
|
+
date_format: str = "%Y%m%d",
|
|
99
|
+
warmup_end: str = "",
|
|
100
|
+
start_date: str = "",
|
|
101
|
+
end_date: str = "",
|
|
102
|
+
) -> dict:
|
|
103
|
+
"""Load forcing data into a model from a CSV or Excel (.xlsx) file on disk.
|
|
104
|
+
|
|
105
|
+
Pass the column names used in the file. ``obs_q_column`` (observed discharge) is
|
|
106
|
+
optional but required for calibration, uncertainty analysis and performance
|
|
107
|
+
metrics. Dates use ``date_format`` (e.g. '%Y%m%d' for 19810101). ``warmup_end`` /
|
|
108
|
+
``start_date`` / ``end_date`` are optional date filters (same format).
|
|
109
|
+
"""
|
|
110
|
+
import pandas as pd
|
|
111
|
+
|
|
112
|
+
model = _get(model_id)
|
|
113
|
+
if not os.path.exists(file_path):
|
|
114
|
+
raise ValueError(f"File not found: {file_path}")
|
|
115
|
+
if file_path.lower().endswith((".xlsx", ".xls")):
|
|
116
|
+
df = pd.read_excel(file_path)
|
|
117
|
+
else:
|
|
118
|
+
df = pd.read_csv(file_path)
|
|
119
|
+
|
|
120
|
+
model.load_data(
|
|
121
|
+
data=df,
|
|
122
|
+
date_column=date_column,
|
|
123
|
+
precip_column=precip_column,
|
|
124
|
+
temp_column=temp_column,
|
|
125
|
+
pet_column=pet_column,
|
|
126
|
+
obs_q_column=(obs_q_column or None),
|
|
127
|
+
date_format=date_format,
|
|
128
|
+
warmup_end=(warmup_end or None),
|
|
129
|
+
start_date=(start_date or None),
|
|
130
|
+
end_date=(end_date or None),
|
|
131
|
+
)
|
|
132
|
+
return {
|
|
133
|
+
"model_id": model_id,
|
|
134
|
+
"n_timesteps": int(len(model.data)),
|
|
135
|
+
"start_date": str(model.start_date),
|
|
136
|
+
"end_date": str(model.end_date),
|
|
137
|
+
"time_step": model.time_step,
|
|
138
|
+
"has_observed_discharge": bool(obs_q_column),
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
@mcp.tool()
|
|
143
|
+
def get_parameters(model_id: str) -> dict:
|
|
144
|
+
"""Return the model's current parameter values (the 'default' of each), grouped."""
|
|
145
|
+
model = _get(model_id)
|
|
146
|
+
return {
|
|
147
|
+
group: {name: info["default"] for name, info in params.items()}
|
|
148
|
+
for group, params in model.params.items()
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
@mcp.tool()
|
|
153
|
+
def set_parameters(model_id: str, values: dict) -> dict:
|
|
154
|
+
"""Set parameter values from a flat mapping, e.g. {"FC": 250, "MAXBAS": 4}.
|
|
155
|
+
|
|
156
|
+
Each named parameter's 'default' is updated; its group (snow/soil/response) is
|
|
157
|
+
resolved automatically. Unknown names are reported and ignored.
|
|
158
|
+
"""
|
|
159
|
+
model = _get(model_id)
|
|
160
|
+
update: dict = {}
|
|
161
|
+
unknown: list[str] = []
|
|
162
|
+
for name, val in values.items():
|
|
163
|
+
group = _find_group(model, name)
|
|
164
|
+
if group is None:
|
|
165
|
+
unknown.append(name)
|
|
166
|
+
continue
|
|
167
|
+
update.setdefault(group, {})[name] = {"default": val}
|
|
168
|
+
if update:
|
|
169
|
+
model.set_parameters(update)
|
|
170
|
+
return {"updated": update, "unknown_parameters": unknown}
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
@mcp.tool()
|
|
174
|
+
def set_initial_conditions(
|
|
175
|
+
model_id: str,
|
|
176
|
+
snowpack: float | None = None,
|
|
177
|
+
liquid_water: float | None = None,
|
|
178
|
+
soil_moisture: float | None = None,
|
|
179
|
+
upper_storage: float | None = None,
|
|
180
|
+
lower_storage: float | None = None,
|
|
181
|
+
) -> dict:
|
|
182
|
+
"""Set initial state values (mm). Omitted states keep their current value."""
|
|
183
|
+
model = _get(model_id)
|
|
184
|
+
model.set_initial_conditions(
|
|
185
|
+
snowpack=snowpack,
|
|
186
|
+
liquid_water=liquid_water,
|
|
187
|
+
soil_moisture=soil_moisture,
|
|
188
|
+
upper_storage=upper_storage,
|
|
189
|
+
lower_storage=lower_storage,
|
|
190
|
+
)
|
|
191
|
+
return {"model_id": model_id, "states": model.states}
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
@mcp.tool()
|
|
195
|
+
def run_model(model_id: str) -> dict:
|
|
196
|
+
"""Run the simulation over the loaded period. Returns a compact summary +
|
|
197
|
+
performance metrics (if observed discharge was provided)."""
|
|
198
|
+
import numpy as np
|
|
199
|
+
|
|
200
|
+
model = _get(model_id)
|
|
201
|
+
model.run(verbose=False)
|
|
202
|
+
q = model.results["discharge"]
|
|
203
|
+
return {
|
|
204
|
+
"model_id": model_id,
|
|
205
|
+
"n_timesteps": int(len(q)),
|
|
206
|
+
"mean_discharge": round(float(np.mean(q)), 4),
|
|
207
|
+
"max_discharge": round(float(np.max(q)), 4),
|
|
208
|
+
"performance_metrics": _metrics(model),
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
@mcp.tool()
|
|
213
|
+
def calibrate(
|
|
214
|
+
model_id: str,
|
|
215
|
+
objective: str = "NSE",
|
|
216
|
+
method: str = "Nelder-Mead",
|
|
217
|
+
iterations: int = 1000,
|
|
218
|
+
) -> dict:
|
|
219
|
+
"""Calibrate the model to observed discharge (requires obs data loaded).
|
|
220
|
+
|
|
221
|
+
``objective`` is one of NSE / KGE / RMSE / MAE. ``method`` defaults to the
|
|
222
|
+
gradient-free 'Nelder-Mead' (recommended for HBV's piecewise objective).
|
|
223
|
+
Returns the optimized parameters and final performance metrics.
|
|
224
|
+
"""
|
|
225
|
+
model = _get(model_id)
|
|
226
|
+
model.calibrate(
|
|
227
|
+
method=method,
|
|
228
|
+
objective=objective,
|
|
229
|
+
iterations=iterations,
|
|
230
|
+
verbose=False,
|
|
231
|
+
plot_results=False,
|
|
232
|
+
)
|
|
233
|
+
return {
|
|
234
|
+
"model_id": model_id,
|
|
235
|
+
"objective": objective,
|
|
236
|
+
"method": method,
|
|
237
|
+
"optimized_parameters": {
|
|
238
|
+
group: {name: round(info["default"], 6) for name, info in params.items()}
|
|
239
|
+
for group, params in model.params.items()
|
|
240
|
+
},
|
|
241
|
+
"performance_metrics": _metrics(model),
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
@mcp.tool()
|
|
246
|
+
def evaluate_uncertainty(
|
|
247
|
+
model_id: str,
|
|
248
|
+
n_runs: int = 1000,
|
|
249
|
+
objective: str = "NSE",
|
|
250
|
+
save_best: int = 10,
|
|
251
|
+
seed: int = 42,
|
|
252
|
+
) -> dict:
|
|
253
|
+
"""Monte-Carlo uncertainty analysis (requires obs data loaded).
|
|
254
|
+
|
|
255
|
+
Samples the parameter ranges ``n_runs`` times and keeps the ``save_best`` runs.
|
|
256
|
+
Returns the best vs. current performance; full prediction intervals stay in the
|
|
257
|
+
model and can be persisted with save_results.
|
|
258
|
+
"""
|
|
259
|
+
model = _get(model_id)
|
|
260
|
+
out = model.evaluate_uncertainty(
|
|
261
|
+
n_runs=n_runs,
|
|
262
|
+
objective=objective,
|
|
263
|
+
save_best=save_best,
|
|
264
|
+
seed=seed,
|
|
265
|
+
plot_results=False,
|
|
266
|
+
verbose=False,
|
|
267
|
+
)
|
|
268
|
+
return {
|
|
269
|
+
"model_id": model_id,
|
|
270
|
+
"objective": objective,
|
|
271
|
+
"n_runs": n_runs,
|
|
272
|
+
"best_performance": round(float(out["best_performance"]), 4),
|
|
273
|
+
"current_performance": round(float(out["original_performance"]), 4),
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
@mcp.tool()
|
|
278
|
+
def plot_results(model_id: str, output_file: str) -> dict:
|
|
279
|
+
"""Render the full diagnostic figure to an image file (PNG). Returns its path."""
|
|
280
|
+
model = _get(model_id)
|
|
281
|
+
model.plot_results(output_file=output_file, show_plots=False)
|
|
282
|
+
return {"model_id": model_id, "figure_path": os.path.abspath(output_file)}
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
@mcp.tool()
|
|
286
|
+
def save_results(model_id: str, output_file: str) -> dict:
|
|
287
|
+
"""Write the full result time series (all fluxes and states) to a CSV file."""
|
|
288
|
+
model = _get(model_id)
|
|
289
|
+
model.save_results(output_file)
|
|
290
|
+
return {"model_id": model_id, "results_path": os.path.abspath(output_file)}
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
@mcp.tool()
|
|
294
|
+
def save_model(model_id: str, output_path: str) -> dict:
|
|
295
|
+
"""Persist the entire model (data, params, results) to a file via pickle."""
|
|
296
|
+
model = _get(model_id)
|
|
297
|
+
model.save_model(output_path)
|
|
298
|
+
return {"model_id": model_id, "model_path": os.path.abspath(output_path)}
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
@mcp.tool()
|
|
302
|
+
def load_model(model_path: str) -> dict:
|
|
303
|
+
"""Load a previously saved model from disk into the registry. Returns a new id."""
|
|
304
|
+
if not os.path.exists(model_path):
|
|
305
|
+
raise ValueError(f"File not found: {model_path}")
|
|
306
|
+
model = HBVModel.load_model(model_path)
|
|
307
|
+
model_id = _new_id()
|
|
308
|
+
_MODELS[model_id] = model
|
|
309
|
+
return {"model_id": model_id, "performance_metrics": _metrics(model)}
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def main() -> None:
|
|
313
|
+
"""Console-script / module entry point.
|
|
314
|
+
|
|
315
|
+
Default transport is **stdio** (for local clients like Claude Desktop / Claude Code).
|
|
316
|
+
Pass ``--http`` to serve over **streamable HTTP** instead, so any remote agent can
|
|
317
|
+
connect at ``http://<host>:<port>/mcp``.
|
|
318
|
+
|
|
319
|
+
hbv-mcp # stdio (local)
|
|
320
|
+
hbv-mcp --http # HTTP on 127.0.0.1:8000 -> http://127.0.0.1:8000/mcp
|
|
321
|
+
hbv-mcp --http --host 0.0.0.0 --port 9000 # expose on the network
|
|
322
|
+
|
|
323
|
+
Host/port also fall back to env vars (``HBV_MCP_HOST``, ``HBV_MCP_PORT`` or ``PORT``),
|
|
324
|
+
which makes it deploy-friendly on platforms like Railway/Render/Fly.
|
|
325
|
+
"""
|
|
326
|
+
import argparse
|
|
327
|
+
|
|
328
|
+
parser = argparse.ArgumentParser(prog="hbv-mcp", description="HBV_Lab MCP server")
|
|
329
|
+
parser.add_argument(
|
|
330
|
+
"--http", action="store_true",
|
|
331
|
+
help="Serve over streamable HTTP (remote agents) instead of stdio.",
|
|
332
|
+
)
|
|
333
|
+
parser.add_argument(
|
|
334
|
+
"--host", default=os.environ.get("HBV_MCP_HOST", "127.0.0.1"),
|
|
335
|
+
help="Host to bind with --http (default 127.0.0.1; use 0.0.0.0 to expose).",
|
|
336
|
+
)
|
|
337
|
+
parser.add_argument(
|
|
338
|
+
"--port", type=int,
|
|
339
|
+
default=int(os.environ.get("PORT", os.environ.get("HBV_MCP_PORT", "8000"))),
|
|
340
|
+
help="Port to bind with --http (default 8000, or $PORT).",
|
|
341
|
+
)
|
|
342
|
+
args = parser.parse_args()
|
|
343
|
+
|
|
344
|
+
if args.http:
|
|
345
|
+
mcp.settings.host = args.host
|
|
346
|
+
mcp.settings.port = args.port
|
|
347
|
+
mcp.run(transport="streamable-http")
|
|
348
|
+
else:
|
|
349
|
+
mcp.run() # stdio
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
if __name__ == "__main__":
|
|
353
|
+
main()
|