HBV-Lab 1.2.0__tar.gz → 1.4.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.2.0 → hbv_lab-1.4.0}/HBV_Lab/__init__.py +1 -1
- {hbv_lab-1.2.0 → hbv_lab-1.4.0}/HBV_Lab/calibration.py +37 -10
- {hbv_lab-1.2.0 → hbv_lab-1.4.0}/HBV_Lab/mcp_server.py +273 -12
- {hbv_lab-1.2.0 → hbv_lab-1.4.0}/HBV_Lab.egg-info/PKG-INFO +25 -7
- {hbv_lab-1.2.0 → hbv_lab-1.4.0}/PKG-INFO +25 -7
- {hbv_lab-1.2.0 → hbv_lab-1.4.0}/README.md +24 -6
- {hbv_lab-1.2.0 → hbv_lab-1.4.0}/setup.py +1 -1
- {hbv_lab-1.2.0 → hbv_lab-1.4.0}/HBV_Lab/HBV_model.py +0 -0
- {hbv_lab-1.2.0 → hbv_lab-1.4.0}/HBV_Lab/hbv_step.py +0 -0
- {hbv_lab-1.2.0 → hbv_lab-1.4.0}/HBV_Lab/response.py +0 -0
- {hbv_lab-1.2.0 → hbv_lab-1.4.0}/HBV_Lab/routing.py +0 -0
- {hbv_lab-1.2.0 → hbv_lab-1.4.0}/HBV_Lab/snow.py +0 -0
- {hbv_lab-1.2.0 → hbv_lab-1.4.0}/HBV_Lab/soil.py +0 -0
- {hbv_lab-1.2.0 → hbv_lab-1.4.0}/HBV_Lab/uncertainty.py +0 -0
- {hbv_lab-1.2.0 → hbv_lab-1.4.0}/HBV_Lab.egg-info/SOURCES.txt +0 -0
- {hbv_lab-1.2.0 → hbv_lab-1.4.0}/HBV_Lab.egg-info/dependency_links.txt +0 -0
- {hbv_lab-1.2.0 → hbv_lab-1.4.0}/HBV_Lab.egg-info/entry_points.txt +0 -0
- {hbv_lab-1.2.0 → hbv_lab-1.4.0}/HBV_Lab.egg-info/requires.txt +0 -0
- {hbv_lab-1.2.0 → hbv_lab-1.4.0}/HBV_Lab.egg-info/top_level.txt +0 -0
- {hbv_lab-1.2.0 → hbv_lab-1.4.0}/LICENSE +0 -0
- {hbv_lab-1.2.0 → hbv_lab-1.4.0}/setup.cfg +0 -0
- {hbv_lab-1.2.0 → hbv_lab-1.4.0}/tests/test_hbv_model.py +0 -0
- {hbv_lab-1.2.0 → hbv_lab-1.4.0}/tests/test_hbv_step.py +0 -0
- {hbv_lab-1.2.0 → hbv_lab-1.4.0}/tests/test_response.py +0 -0
- {hbv_lab-1.2.0 → hbv_lab-1.4.0}/tests/test_snow.py +0 -0
- {hbv_lab-1.2.0 → hbv_lab-1.4.0}/tests/test_soil.py +0 -0
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
class calibration:
|
|
2
2
|
|
|
3
3
|
def calibrate(self, method='Nelder-Mead', objective='NSE', iterations=100,
|
|
4
|
-
verbose=True, plot_results=True):
|
|
4
|
+
verbose=True, plot_results=True, progress_callback=None):
|
|
5
5
|
"""
|
|
6
6
|
Calibrate an HBV model's parameters to optimize the objective function.
|
|
7
7
|
|
|
@@ -29,11 +29,19 @@ class calibration:
|
|
|
29
29
|
Whether to print progress information
|
|
30
30
|
plot_results : bool, default True
|
|
31
31
|
Whether to plot the final results after calibration
|
|
32
|
-
|
|
32
|
+
progress_callback : callable, optional
|
|
33
|
+
Called once per optimizer iteration as
|
|
34
|
+
``progress_callback(iteration, total_iterations, current_value, best_value)``,
|
|
35
|
+
where the values are in human-facing objective terms (e.g. NSE). Useful for
|
|
36
|
+
driving an external progress UI (e.g. the MCP server). Exceptions raised by
|
|
37
|
+
the callback are swallowed so they cannot interrupt calibration.
|
|
38
|
+
|
|
33
39
|
Returns:
|
|
34
40
|
--------
|
|
35
41
|
dict
|
|
36
|
-
Dictionary
|
|
42
|
+
Dictionary with keys ``parameters`` (optimized parameter dict),
|
|
43
|
+
``performance`` (final metrics), ``optimization_result`` (the SciPy result),
|
|
44
|
+
and ``trajectory`` (best objective value per iteration).
|
|
37
45
|
"""
|
|
38
46
|
import scipy.optimize as opt
|
|
39
47
|
import numpy as np
|
|
@@ -154,15 +162,21 @@ class calibration:
|
|
|
154
162
|
else:
|
|
155
163
|
raise ValueError(f"Unknown objective function: {objective}")
|
|
156
164
|
|
|
157
|
-
# Callback function to track progress
|
|
165
|
+
# Callback function to track progress.
|
|
166
|
+
# objective_function always returns a value to be MINIMIZED (for NSE/KGE it
|
|
167
|
+
# returns the negative metric), so the best-so-far starts at +inf for every
|
|
168
|
+
# objective and improves downward.
|
|
158
169
|
num_iter = [0]
|
|
159
|
-
best_value = [float('inf')
|
|
170
|
+
best_value = [float('inf')]
|
|
160
171
|
start_time = time.time()
|
|
161
|
-
|
|
172
|
+
# Per-iteration best-so-far objective values, in human-facing terms
|
|
173
|
+
# (e.g. NSE/KGE as-is, RMSE/MAE as-is). Returned to the caller.
|
|
174
|
+
trajectory = []
|
|
175
|
+
|
|
162
176
|
def callback(params):
|
|
163
177
|
num_iter[0] += 1
|
|
164
178
|
current_value = objective_function(params)
|
|
165
|
-
|
|
179
|
+
|
|
166
180
|
# For NSE and KGE, we're minimizing the negative value
|
|
167
181
|
if objective in ['NSE', 'KGE']:
|
|
168
182
|
display_value = -current_value
|
|
@@ -170,10 +184,22 @@ class calibration:
|
|
|
170
184
|
else:
|
|
171
185
|
display_value = current_value
|
|
172
186
|
is_better = current_value < best_value[0]
|
|
173
|
-
|
|
187
|
+
|
|
174
188
|
if is_better:
|
|
175
189
|
best_value[0] = current_value
|
|
176
|
-
|
|
190
|
+
|
|
191
|
+
# Best objective so far, in human-facing terms
|
|
192
|
+
best_display = -best_value[0] if objective in ['NSE', 'KGE'] else best_value[0]
|
|
193
|
+
trajectory.append(best_display)
|
|
194
|
+
|
|
195
|
+
# Optional external progress hook (e.g. for an MCP/agent UI).
|
|
196
|
+
# Kept defensive: a misbehaving callback must not break calibration.
|
|
197
|
+
if progress_callback is not None:
|
|
198
|
+
try:
|
|
199
|
+
progress_callback(num_iter[0], iterations, display_value, best_display)
|
|
200
|
+
except Exception:
|
|
201
|
+
pass
|
|
202
|
+
|
|
177
203
|
if verbose and num_iter[0] % max(1, iterations // 10) == 0:
|
|
178
204
|
elapsed = time.time() - start_time
|
|
179
205
|
print(f"Iteration {num_iter[0]}/{iterations}, "
|
|
@@ -238,7 +264,8 @@ class calibration:
|
|
|
238
264
|
return {
|
|
239
265
|
'parameters': optimized_params,
|
|
240
266
|
'performance': self.performance_metrics,
|
|
241
|
-
'optimization_result': result
|
|
267
|
+
'optimization_result': result,
|
|
268
|
+
'trajectory': trajectory
|
|
242
269
|
}
|
|
243
270
|
|
|
244
271
|
except Exception as e:
|
|
@@ -27,7 +27,7 @@ import matplotlib
|
|
|
27
27
|
|
|
28
28
|
matplotlib.use("Agg") # headless: no GUI needed on a server
|
|
29
29
|
|
|
30
|
-
from mcp.server.fastmcp import FastMCP
|
|
30
|
+
from mcp.server.fastmcp import FastMCP, Context
|
|
31
31
|
|
|
32
32
|
from . import HBVModel, __version__
|
|
33
33
|
|
|
@@ -68,6 +68,40 @@ def _find_group(model: HBVModel, name: str) -> str | None:
|
|
|
68
68
|
return None
|
|
69
69
|
|
|
70
70
|
|
|
71
|
+
def _downsample(seq, n: int = 20) -> list:
|
|
72
|
+
"""At most ``n`` evenly-spaced rounded points from ``seq`` (keeps first and last).
|
|
73
|
+
|
|
74
|
+
Keeps the calibration trajectory compact in the tool result regardless of how many
|
|
75
|
+
iterations ran.
|
|
76
|
+
"""
|
|
77
|
+
seq = [round(float(v), 4) for v in seq]
|
|
78
|
+
if len(seq) <= n:
|
|
79
|
+
return seq
|
|
80
|
+
step = (len(seq) - 1) / (n - 1)
|
|
81
|
+
idx = sorted({int(round(k * step)) for k in range(n)})
|
|
82
|
+
return [seq[i] for i in idx]
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _at_bound(model: HBVModel, rel_tol: float = 0.005) -> list:
|
|
86
|
+
"""List parameters whose value sits within ``rel_tol`` of their min or max bound.
|
|
87
|
+
|
|
88
|
+
A parameter pinned to a bound usually means the search range is clipping the true
|
|
89
|
+
optimum — surfacing it lets the agent widen the range with set_parameter_ranges.
|
|
90
|
+
"""
|
|
91
|
+
flags = []
|
|
92
|
+
for group, params in model.params.items():
|
|
93
|
+
for name, info in params.items():
|
|
94
|
+
lo, hi, val = info.get("min"), info.get("max"), info.get("default")
|
|
95
|
+
if lo is None or hi is None or hi <= lo:
|
|
96
|
+
continue
|
|
97
|
+
margin = rel_tol * (hi - lo)
|
|
98
|
+
if val <= lo + margin:
|
|
99
|
+
flags.append({"parameter": name, "bound": "lower", "value": round(float(val), 4), "limit": float(lo)})
|
|
100
|
+
elif val >= hi - margin:
|
|
101
|
+
flags.append({"parameter": name, "bound": "upper", "value": round(float(val), 4), "limit": float(hi)})
|
|
102
|
+
return flags
|
|
103
|
+
|
|
104
|
+
|
|
71
105
|
# --- tools --------------------------------------------------------------------
|
|
72
106
|
@mcp.tool()
|
|
73
107
|
def create_model(name: str = "") -> dict:
|
|
@@ -141,14 +175,61 @@ def load_data(
|
|
|
141
175
|
|
|
142
176
|
@mcp.tool()
|
|
143
177
|
def get_parameters(model_id: str) -> dict:
|
|
144
|
-
"""Return the model's current parameter values (the 'default' of each), grouped
|
|
178
|
+
"""Return the model's current parameter values (the 'default' of each), grouped,
|
|
179
|
+
plus an ``at_bound`` list flagging any parameter pinned to its min/max range."""
|
|
180
|
+
model = _get(model_id)
|
|
181
|
+
return {
|
|
182
|
+
"parameters": {
|
|
183
|
+
group: {name: float(info["default"]) for name, info in params.items()}
|
|
184
|
+
for group, params in model.params.items()
|
|
185
|
+
},
|
|
186
|
+
"at_bound": _at_bound(model),
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
@mcp.tool()
|
|
191
|
+
def get_parameter_ranges(model_id: str) -> dict:
|
|
192
|
+
"""Return the min / max / default of every parameter (the ranges the optimizer and
|
|
193
|
+
Monte-Carlo uncertainty sampling use), grouped."""
|
|
145
194
|
model = _get(model_id)
|
|
146
195
|
return {
|
|
147
|
-
group: {
|
|
196
|
+
group: {
|
|
197
|
+
name: {
|
|
198
|
+
"min": float(info["min"]),
|
|
199
|
+
"max": float(info["max"]),
|
|
200
|
+
"default": float(info["default"]),
|
|
201
|
+
}
|
|
202
|
+
for name, info in params.items()
|
|
203
|
+
}
|
|
148
204
|
for group, params in model.params.items()
|
|
149
205
|
}
|
|
150
206
|
|
|
151
207
|
|
|
208
|
+
@mcp.tool()
|
|
209
|
+
def set_parameter_ranges(model_id: str, ranges: dict) -> dict:
|
|
210
|
+
"""Widen or narrow parameter search ranges from a flat mapping, e.g.
|
|
211
|
+
{"FC": {"min": 50, "max": 500}, "CFMAX": {"max": 10}}.
|
|
212
|
+
|
|
213
|
+
Each entry may set any of "min" / "max" / "default"; the group is resolved
|
|
214
|
+
automatically. Use this when calibrate reports a parameter ``at_bound`` — widen its
|
|
215
|
+
range, then calibrate again. Unknown names are reported and ignored.
|
|
216
|
+
"""
|
|
217
|
+
model = _get(model_id)
|
|
218
|
+
update: dict = {}
|
|
219
|
+
unknown: list[str] = []
|
|
220
|
+
for name, spec in ranges.items():
|
|
221
|
+
group = _find_group(model, name)
|
|
222
|
+
if group is None:
|
|
223
|
+
unknown.append(name)
|
|
224
|
+
continue
|
|
225
|
+
allowed = {k: spec[k] for k in ("min", "max", "default") if k in spec}
|
|
226
|
+
if allowed:
|
|
227
|
+
update.setdefault(group, {})[name] = allowed
|
|
228
|
+
if update:
|
|
229
|
+
model.set_parameters(update)
|
|
230
|
+
return {"updated": update, "unknown_parameters": unknown}
|
|
231
|
+
|
|
232
|
+
|
|
152
233
|
@mcp.tool()
|
|
153
234
|
def set_parameters(model_id: str, values: dict) -> dict:
|
|
154
235
|
"""Set parameter values from a flat mapping, e.g. {"FC": 250, "MAXBAS": 4}.
|
|
@@ -210,35 +291,121 @@ def run_model(model_id: str) -> dict:
|
|
|
210
291
|
|
|
211
292
|
|
|
212
293
|
@mcp.tool()
|
|
213
|
-
def calibrate(
|
|
294
|
+
async def calibrate(
|
|
214
295
|
model_id: str,
|
|
215
296
|
objective: str = "NSE",
|
|
216
297
|
method: str = "Nelder-Mead",
|
|
217
298
|
iterations: int = 1000,
|
|
299
|
+
ctx: Context = None,
|
|
218
300
|
) -> dict:
|
|
219
301
|
"""Calibrate the model to observed discharge (requires obs data loaded).
|
|
220
302
|
|
|
221
303
|
``objective`` is one of NSE / KGE / RMSE / MAE. ``method`` defaults to the
|
|
222
304
|
gradient-free 'Nelder-Mead' (recommended for HBV's piecewise objective).
|
|
223
|
-
|
|
305
|
+
|
|
306
|
+
Progress: while running, the server emits MCP progress notifications each
|
|
307
|
+
optimizer iteration (visible in clients that surface them).
|
|
308
|
+
|
|
309
|
+
Incremental use: each call continues from the model's *current* parameters, so an
|
|
310
|
+
agent can call calibrate repeatedly with a small ``iterations`` budget (e.g. 50),
|
|
311
|
+
inspect the improving metric and ``objective_trajectory`` between calls, and decide
|
|
312
|
+
whether to keep going, widen parameter ranges, or switch objective.
|
|
313
|
+
|
|
314
|
+
Returns the optimized parameters, final metrics, optimizer status, and the
|
|
315
|
+
best-objective-per-iteration trajectory (downsampled).
|
|
224
316
|
"""
|
|
317
|
+
import asyncio
|
|
318
|
+
|
|
225
319
|
model = _get(model_id)
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
320
|
+
loop = asyncio.get_running_loop()
|
|
321
|
+
updates: asyncio.Queue = asyncio.Queue()
|
|
322
|
+
|
|
323
|
+
def progress_cb(i, total, current, best):
|
|
324
|
+
# Runs in the worker thread — hand the update back to the event loop safely.
|
|
325
|
+
loop.call_soon_threadsafe(updates.put_nowait, (i, total, current, best))
|
|
326
|
+
|
|
327
|
+
async def report(i, total, current, best):
|
|
328
|
+
if ctx is None:
|
|
329
|
+
return
|
|
330
|
+
try:
|
|
331
|
+
await ctx.report_progress(i, total)
|
|
332
|
+
await ctx.info(
|
|
333
|
+
f"calibrate iter {i}/{total}: {objective}={current:.4f} (best {best:.4f})"
|
|
334
|
+
)
|
|
335
|
+
except Exception:
|
|
336
|
+
pass
|
|
337
|
+
|
|
338
|
+
# Run the (blocking) calibration in a worker thread so we can stream progress.
|
|
339
|
+
task = asyncio.create_task(
|
|
340
|
+
asyncio.to_thread(
|
|
341
|
+
model.calibrate,
|
|
342
|
+
method=method,
|
|
343
|
+
objective=objective,
|
|
344
|
+
iterations=iterations,
|
|
345
|
+
verbose=False,
|
|
346
|
+
plot_results=False,
|
|
347
|
+
progress_callback=progress_cb,
|
|
348
|
+
)
|
|
349
|
+
)
|
|
350
|
+
while not task.done():
|
|
351
|
+
try:
|
|
352
|
+
i, total, current, best = await asyncio.wait_for(updates.get(), timeout=0.25)
|
|
353
|
+
await report(i, total, current, best)
|
|
354
|
+
except asyncio.TimeoutError:
|
|
355
|
+
pass
|
|
356
|
+
while not updates.empty(): # flush any stragglers
|
|
357
|
+
await report(*updates.get_nowait())
|
|
358
|
+
out = await task # re-raises any error from the worker thread
|
|
359
|
+
|
|
360
|
+
result = out["optimization_result"]
|
|
361
|
+
traj = out.get("trajectory", [])
|
|
362
|
+
success = bool(getattr(result, "success", True))
|
|
363
|
+
message = str(getattr(result, "message", ""))
|
|
364
|
+
|
|
365
|
+
# Honest convergence reporting: a maxiter-capped run isn't a "failure" — it just
|
|
366
|
+
# ran out of budget and (usually) is still improving. Distinguish the cases.
|
|
367
|
+
still_improving = bool(
|
|
368
|
+
len(traj) >= 2 and abs(float(traj[-1]) - float(traj[-min(6, len(traj))])) > 1e-3
|
|
232
369
|
)
|
|
370
|
+
if success:
|
|
371
|
+
status, guidance = "converged", "Optimizer converged. No further iterations needed."
|
|
372
|
+
elif "iteration" in message.lower() or "maxiter" in message.lower():
|
|
373
|
+
status = "hit_iteration_budget"
|
|
374
|
+
guidance = (
|
|
375
|
+
"Hit the iteration budget; still improving - call calibrate again to "
|
|
376
|
+
"continue (it resumes from the current parameters)."
|
|
377
|
+
if still_improving
|
|
378
|
+
else "Hit the iteration budget but the objective has plateaued; likely converged."
|
|
379
|
+
)
|
|
380
|
+
else:
|
|
381
|
+
status, guidance = "failed", f"Optimizer stopped without converging: {message}"
|
|
382
|
+
|
|
383
|
+
at_bound = _at_bound(model)
|
|
384
|
+
if at_bound:
|
|
385
|
+
guidance += (
|
|
386
|
+
" Some parameters are at their range limits "
|
|
387
|
+
f"({', '.join(b['parameter'] for b in at_bound)}); consider widening them "
|
|
388
|
+
"with set_parameter_ranges before re-calibrating."
|
|
389
|
+
)
|
|
390
|
+
|
|
233
391
|
return {
|
|
234
392
|
"model_id": model_id,
|
|
235
393
|
"objective": objective,
|
|
236
394
|
"method": method,
|
|
395
|
+
"n_iterations": int(getattr(result, "nit", len(traj))),
|
|
396
|
+
"converged": success,
|
|
397
|
+
"status": status,
|
|
398
|
+
"still_improving": still_improving,
|
|
399
|
+
"guidance": guidance,
|
|
400
|
+
"success": success, # kept for backward compatibility
|
|
401
|
+
"message": message,
|
|
237
402
|
"optimized_parameters": {
|
|
238
|
-
group: {name: round(info["default"], 6) for name, info in params.items()}
|
|
403
|
+
group: {name: round(float(info["default"]), 6) for name, info in params.items()}
|
|
239
404
|
for group, params in model.params.items()
|
|
240
405
|
},
|
|
241
406
|
"performance_metrics": _metrics(model),
|
|
407
|
+
"at_bound": at_bound,
|
|
408
|
+
"objective_trajectory": _downsample(traj, 20),
|
|
242
409
|
}
|
|
243
410
|
|
|
244
411
|
|
|
@@ -257,6 +424,8 @@ def evaluate_uncertainty(
|
|
|
257
424
|
model and can be persisted with save_results.
|
|
258
425
|
"""
|
|
259
426
|
model = _get(model_id)
|
|
427
|
+
import numpy as np
|
|
428
|
+
|
|
260
429
|
out = model.evaluate_uncertainty(
|
|
261
430
|
n_runs=n_runs,
|
|
262
431
|
objective=objective,
|
|
@@ -265,12 +434,41 @@ def evaluate_uncertainty(
|
|
|
265
434
|
plot_results=False,
|
|
266
435
|
verbose=False,
|
|
267
436
|
)
|
|
437
|
+
|
|
438
|
+
# 95% prediction-interval diagnostics from the best runs
|
|
439
|
+
df = out["best_runs"]
|
|
440
|
+
obs = np.asarray(df["observed"].values, dtype=float)
|
|
441
|
+
lo = np.asarray(df["q5"].values, dtype=float)
|
|
442
|
+
hi = np.asarray(df["q95"].values, dtype=float)
|
|
443
|
+
valid = ~np.isnan(obs)
|
|
444
|
+
if valid.any():
|
|
445
|
+
inside = (obs[valid] >= lo[valid]) & (obs[valid] <= hi[valid])
|
|
446
|
+
coverage_95 = round(float(inside.mean()), 3)
|
|
447
|
+
mean_band_width = round(float(np.nanmean(hi[valid] - lo[valid])), 4)
|
|
448
|
+
else:
|
|
449
|
+
coverage_95 = mean_band_width = None
|
|
450
|
+
|
|
451
|
+
# Per-parameter posterior ranges across the best parameter sets
|
|
452
|
+
posterior: dict = {}
|
|
453
|
+
for s in out["best_parameter_sets"]:
|
|
454
|
+
for params in s["parameters"].values():
|
|
455
|
+
for name, info in params.items():
|
|
456
|
+
posterior.setdefault(name, []).append(info["default"])
|
|
457
|
+
posterior_ranges = {
|
|
458
|
+
name: {"min": round(float(min(v)), 4), "max": round(float(max(v)), 4)}
|
|
459
|
+
for name, v in posterior.items()
|
|
460
|
+
}
|
|
461
|
+
|
|
268
462
|
return {
|
|
269
463
|
"model_id": model_id,
|
|
270
464
|
"objective": objective,
|
|
271
465
|
"n_runs": n_runs,
|
|
466
|
+
"save_best": save_best,
|
|
272
467
|
"best_performance": round(float(out["best_performance"]), 4),
|
|
273
468
|
"current_performance": round(float(out["original_performance"]), 4),
|
|
469
|
+
"coverage_95": coverage_95, # fraction of observations inside the 95% band
|
|
470
|
+
"mean_band_width": mean_band_width, # mean (q95 - q5), mm/day
|
|
471
|
+
"parameter_posterior_ranges": posterior_ranges,
|
|
274
472
|
}
|
|
275
473
|
|
|
276
474
|
|
|
@@ -309,6 +507,69 @@ def load_model(model_path: str) -> dict:
|
|
|
309
507
|
return {"model_id": model_id, "performance_metrics": _metrics(model)}
|
|
310
508
|
|
|
311
509
|
|
|
510
|
+
@mcp.tool()
|
|
511
|
+
def clone_model(model_id: str, name: str = "") -> dict:
|
|
512
|
+
"""Deep-copy a model (parameters, data, states, results) into a new model_id.
|
|
513
|
+
|
|
514
|
+
The canonical split-sample pattern: calibrate ``model-A``, ``clone_model`` it, then
|
|
515
|
+
``load_data`` the validation window on the clone and ``run_model`` — the calibrated
|
|
516
|
+
parameters carry over, no manual parameter transfer needed.
|
|
517
|
+
"""
|
|
518
|
+
import copy
|
|
519
|
+
|
|
520
|
+
src = _get(model_id)
|
|
521
|
+
new_id = _new_id()
|
|
522
|
+
_MODELS[new_id] = copy.deepcopy(src)
|
|
523
|
+
return {"model_id": new_id, "cloned_from": model_id, "name": name}
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
@mcp.tool()
|
|
527
|
+
def copy_parameters(from_model_id: str, to_model_id: str) -> dict:
|
|
528
|
+
"""Copy the calibrated parameters (and ranges) from one model to another.
|
|
529
|
+
|
|
530
|
+
Use this to transfer a calibration to a validation model without copying its data.
|
|
531
|
+
"""
|
|
532
|
+
import copy
|
|
533
|
+
|
|
534
|
+
src = _get(from_model_id)
|
|
535
|
+
dst = _get(to_model_id)
|
|
536
|
+
dst.params = copy.deepcopy(src.params)
|
|
537
|
+
return {
|
|
538
|
+
"from_model_id": from_model_id,
|
|
539
|
+
"to_model_id": to_model_id,
|
|
540
|
+
"parameters": {
|
|
541
|
+
group: {name: float(info["default"]) for name, info in params.items()}
|
|
542
|
+
for group, params in dst.params.items()
|
|
543
|
+
},
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
@mcp.tool()
|
|
548
|
+
def get_metrics(model_id: str) -> dict:
|
|
549
|
+
"""Return the model's current performance metrics without re-running it.
|
|
550
|
+
|
|
551
|
+
Empty if the model hasn't been run with observed discharge yet.
|
|
552
|
+
"""
|
|
553
|
+
model = _get(model_id)
|
|
554
|
+
return {"model_id": model_id, "performance_metrics": _metrics(model)}
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
@mcp.tool()
|
|
558
|
+
def compare_models(model_ids: list) -> dict:
|
|
559
|
+
"""Tabulate performance metrics across several models side by side.
|
|
560
|
+
|
|
561
|
+
Handy for comparing calibration vs validation, or several calibration variants.
|
|
562
|
+
"""
|
|
563
|
+
rows = []
|
|
564
|
+
for mid in model_ids:
|
|
565
|
+
try:
|
|
566
|
+
model = _get(mid)
|
|
567
|
+
rows.append({"model_id": mid, "metrics": _metrics(model)})
|
|
568
|
+
except ValueError as e:
|
|
569
|
+
rows.append({"model_id": mid, "error": str(e)})
|
|
570
|
+
return {"comparison": rows}
|
|
571
|
+
|
|
572
|
+
|
|
312
573
|
def main() -> None:
|
|
313
574
|
"""Console-script / module entry point.
|
|
314
575
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: HBV_Lab
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.4.0
|
|
4
4
|
Summary: An intuitive, object-oriented and user-friendly Python implementation of a lumped conceptual HBV hydrological model for educational and research purposes.
|
|
5
5
|
Home-page: https://github.com/abdallaox/HBV_python_implementation
|
|
6
6
|
Author: Abdalla Mohammed
|
|
@@ -250,12 +250,30 @@ mode deploys cleanly to platforms like Railway, Render or Fly.
|
|
|
250
250
|
|
|
251
251
|
#### What the agent can do
|
|
252
252
|
|
|
253
|
-
Tools: `create_model`, `load_data` (from a CSV/Excel file path),
|
|
254
|
-
`
|
|
255
|
-
`
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
253
|
+
Tools: `create_model`, `clone_model`, `copy_parameters`, `load_data` (from a CSV/Excel file path),
|
|
254
|
+
`get_parameters`, `set_parameters`, `get_parameter_ranges`, `set_parameter_ranges`,
|
|
255
|
+
`set_initial_conditions`, `run_model`, `calibrate`, `evaluate_uncertainty`, `get_metrics`,
|
|
256
|
+
`compare_models`, `plot_results`, `save_results`, `save_model`, `load_model`, `list_models`. A typical
|
|
257
|
+
agent flow is *create → load → run → calibrate → plot*. Model state is kept server-side (each tool
|
|
258
|
+
takes a `model_id`) and large time series are passed by **file path**, not through the agent's
|
|
259
|
+
context — tools return compact metrics and output-file paths.
|
|
260
|
+
|
|
261
|
+
**Calibration progress & agent steering.** `calibrate` emits MCP progress notifications every
|
|
262
|
+
optimizer iteration (clients that surface them show a live progress/log view), and its result
|
|
263
|
+
reports an honest convergence `status` (`converged` / `hit_iteration_budget` / `failed`),
|
|
264
|
+
`still_improving`, the best-objective-per-iteration `objective_trajectory`, and an `at_bound` list of
|
|
265
|
+
parameters pinned to their range limits. Because each call continues from the model's *current*
|
|
266
|
+
parameters, an agent can calibrate incrementally — call `calibrate` with a small `iterations` budget,
|
|
267
|
+
inspect the improving metric, and decide whether to keep going, widen ranges (via
|
|
268
|
+
`set_parameter_ranges`), or switch objective between rounds.
|
|
269
|
+
|
|
270
|
+
**Split-sample made easy.** Calibrate one model, `clone_model` it (or `copy_parameters` to a second
|
|
271
|
+
model), `load_data` the validation window on the clone, and `run_model` — the calibrated parameters
|
|
272
|
+
carry over with no manual transfer. `compare_models` tabulates calibration vs. validation metrics.
|
|
273
|
+
|
|
274
|
+
**Richer uncertainty.** `evaluate_uncertainty` returns the 95% prediction-band **coverage** (fraction
|
|
275
|
+
of observations inside the band), **mean band width**, and per-parameter **posterior ranges**, not
|
|
276
|
+
just the best/current objective.
|
|
259
277
|
|
|
260
278
|
## Inputs & outputs
|
|
261
279
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: HBV_Lab
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.4.0
|
|
4
4
|
Summary: An intuitive, object-oriented and user-friendly Python implementation of a lumped conceptual HBV hydrological model for educational and research purposes.
|
|
5
5
|
Home-page: https://github.com/abdallaox/HBV_python_implementation
|
|
6
6
|
Author: Abdalla Mohammed
|
|
@@ -250,12 +250,30 @@ mode deploys cleanly to platforms like Railway, Render or Fly.
|
|
|
250
250
|
|
|
251
251
|
#### What the agent can do
|
|
252
252
|
|
|
253
|
-
Tools: `create_model`, `load_data` (from a CSV/Excel file path),
|
|
254
|
-
`
|
|
255
|
-
`
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
253
|
+
Tools: `create_model`, `clone_model`, `copy_parameters`, `load_data` (from a CSV/Excel file path),
|
|
254
|
+
`get_parameters`, `set_parameters`, `get_parameter_ranges`, `set_parameter_ranges`,
|
|
255
|
+
`set_initial_conditions`, `run_model`, `calibrate`, `evaluate_uncertainty`, `get_metrics`,
|
|
256
|
+
`compare_models`, `plot_results`, `save_results`, `save_model`, `load_model`, `list_models`. A typical
|
|
257
|
+
agent flow is *create → load → run → calibrate → plot*. Model state is kept server-side (each tool
|
|
258
|
+
takes a `model_id`) and large time series are passed by **file path**, not through the agent's
|
|
259
|
+
context — tools return compact metrics and output-file paths.
|
|
260
|
+
|
|
261
|
+
**Calibration progress & agent steering.** `calibrate` emits MCP progress notifications every
|
|
262
|
+
optimizer iteration (clients that surface them show a live progress/log view), and its result
|
|
263
|
+
reports an honest convergence `status` (`converged` / `hit_iteration_budget` / `failed`),
|
|
264
|
+
`still_improving`, the best-objective-per-iteration `objective_trajectory`, and an `at_bound` list of
|
|
265
|
+
parameters pinned to their range limits. Because each call continues from the model's *current*
|
|
266
|
+
parameters, an agent can calibrate incrementally — call `calibrate` with a small `iterations` budget,
|
|
267
|
+
inspect the improving metric, and decide whether to keep going, widen ranges (via
|
|
268
|
+
`set_parameter_ranges`), or switch objective between rounds.
|
|
269
|
+
|
|
270
|
+
**Split-sample made easy.** Calibrate one model, `clone_model` it (or `copy_parameters` to a second
|
|
271
|
+
model), `load_data` the validation window on the clone, and `run_model` — the calibrated parameters
|
|
272
|
+
carry over with no manual transfer. `compare_models` tabulates calibration vs. validation metrics.
|
|
273
|
+
|
|
274
|
+
**Richer uncertainty.** `evaluate_uncertainty` returns the 95% prediction-band **coverage** (fraction
|
|
275
|
+
of observations inside the band), **mean band width**, and per-parameter **posterior ranges**, not
|
|
276
|
+
just the best/current objective.
|
|
259
277
|
|
|
260
278
|
## Inputs & outputs
|
|
261
279
|
|
|
@@ -209,12 +209,30 @@ mode deploys cleanly to platforms like Railway, Render or Fly.
|
|
|
209
209
|
|
|
210
210
|
#### What the agent can do
|
|
211
211
|
|
|
212
|
-
Tools: `create_model`, `load_data` (from a CSV/Excel file path),
|
|
213
|
-
`
|
|
214
|
-
`
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
212
|
+
Tools: `create_model`, `clone_model`, `copy_parameters`, `load_data` (from a CSV/Excel file path),
|
|
213
|
+
`get_parameters`, `set_parameters`, `get_parameter_ranges`, `set_parameter_ranges`,
|
|
214
|
+
`set_initial_conditions`, `run_model`, `calibrate`, `evaluate_uncertainty`, `get_metrics`,
|
|
215
|
+
`compare_models`, `plot_results`, `save_results`, `save_model`, `load_model`, `list_models`. A typical
|
|
216
|
+
agent flow is *create → load → run → calibrate → plot*. Model state is kept server-side (each tool
|
|
217
|
+
takes a `model_id`) and large time series are passed by **file path**, not through the agent's
|
|
218
|
+
context — tools return compact metrics and output-file paths.
|
|
219
|
+
|
|
220
|
+
**Calibration progress & agent steering.** `calibrate` emits MCP progress notifications every
|
|
221
|
+
optimizer iteration (clients that surface them show a live progress/log view), and its result
|
|
222
|
+
reports an honest convergence `status` (`converged` / `hit_iteration_budget` / `failed`),
|
|
223
|
+
`still_improving`, the best-objective-per-iteration `objective_trajectory`, and an `at_bound` list of
|
|
224
|
+
parameters pinned to their range limits. Because each call continues from the model's *current*
|
|
225
|
+
parameters, an agent can calibrate incrementally — call `calibrate` with a small `iterations` budget,
|
|
226
|
+
inspect the improving metric, and decide whether to keep going, widen ranges (via
|
|
227
|
+
`set_parameter_ranges`), or switch objective between rounds.
|
|
228
|
+
|
|
229
|
+
**Split-sample made easy.** Calibrate one model, `clone_model` it (or `copy_parameters` to a second
|
|
230
|
+
model), `load_data` the validation window on the clone, and `run_model` — the calibrated parameters
|
|
231
|
+
carry over with no manual transfer. `compare_models` tabulates calibration vs. validation metrics.
|
|
232
|
+
|
|
233
|
+
**Richer uncertainty.** `evaluate_uncertainty` returns the 95% prediction-band **coverage** (fraction
|
|
234
|
+
of observations inside the band), **mean band width**, and per-parameter **posterior ranges**, not
|
|
235
|
+
just the best/current objective.
|
|
218
236
|
|
|
219
237
|
## Inputs & outputs
|
|
220
238
|
|
|
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
|
|
File without changes
|
|
File without changes
|