HBV-Lab 1.3.0__tar.gz → 1.4.1__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 (26) hide show
  1. {hbv_lab-1.3.0 → hbv_lab-1.4.1}/HBV_Lab/__init__.py +1 -1
  2. {hbv_lab-1.3.0 → hbv_lab-1.4.1}/HBV_Lab/mcp_server.py +202 -5
  3. {hbv_lab-1.3.0 → hbv_lab-1.4.1/HBV_Lab.egg-info}/PKG-INFO +39 -15
  4. hbv_lab-1.3.0/README.md → hbv_lab-1.4.1/PKG-INFO +379 -314
  5. hbv_lab-1.3.0/HBV_Lab.egg-info/PKG-INFO → hbv_lab-1.4.1/README.md +338 -355
  6. {hbv_lab-1.3.0 → hbv_lab-1.4.1}/setup.py +3 -3
  7. {hbv_lab-1.3.0 → hbv_lab-1.4.1}/HBV_Lab/HBV_model.py +0 -0
  8. {hbv_lab-1.3.0 → hbv_lab-1.4.1}/HBV_Lab/calibration.py +0 -0
  9. {hbv_lab-1.3.0 → hbv_lab-1.4.1}/HBV_Lab/hbv_step.py +0 -0
  10. {hbv_lab-1.3.0 → hbv_lab-1.4.1}/HBV_Lab/response.py +0 -0
  11. {hbv_lab-1.3.0 → hbv_lab-1.4.1}/HBV_Lab/routing.py +0 -0
  12. {hbv_lab-1.3.0 → hbv_lab-1.4.1}/HBV_Lab/snow.py +0 -0
  13. {hbv_lab-1.3.0 → hbv_lab-1.4.1}/HBV_Lab/soil.py +0 -0
  14. {hbv_lab-1.3.0 → hbv_lab-1.4.1}/HBV_Lab/uncertainty.py +0 -0
  15. {hbv_lab-1.3.0 → hbv_lab-1.4.1}/HBV_Lab.egg-info/SOURCES.txt +0 -0
  16. {hbv_lab-1.3.0 → hbv_lab-1.4.1}/HBV_Lab.egg-info/dependency_links.txt +0 -0
  17. {hbv_lab-1.3.0 → hbv_lab-1.4.1}/HBV_Lab.egg-info/entry_points.txt +0 -0
  18. {hbv_lab-1.3.0 → hbv_lab-1.4.1}/HBV_Lab.egg-info/requires.txt +0 -0
  19. {hbv_lab-1.3.0 → hbv_lab-1.4.1}/HBV_Lab.egg-info/top_level.txt +0 -0
  20. {hbv_lab-1.3.0 → hbv_lab-1.4.1}/LICENSE +0 -0
  21. {hbv_lab-1.3.0 → hbv_lab-1.4.1}/setup.cfg +0 -0
  22. {hbv_lab-1.3.0 → hbv_lab-1.4.1}/tests/test_hbv_model.py +0 -0
  23. {hbv_lab-1.3.0 → hbv_lab-1.4.1}/tests/test_hbv_step.py +0 -0
  24. {hbv_lab-1.3.0 → hbv_lab-1.4.1}/tests/test_response.py +0 -0
  25. {hbv_lab-1.3.0 → hbv_lab-1.4.1}/tests/test_snow.py +0 -0
  26. {hbv_lab-1.3.0 → hbv_lab-1.4.1}/tests/test_soil.py +0 -0
@@ -24,7 +24,7 @@ from .soil import soil_routine
24
24
  from .response import response_routine_two_tanks
25
25
  from .routing import route_with_maxbas
26
26
 
27
- __version__ = "1.3.0"
27
+ __version__ = "1.4.1"
28
28
 
29
29
  __all__ = [
30
30
  "HBVModel",
@@ -82,6 +82,26 @@ def _downsample(seq, n: int = 20) -> list:
82
82
  return [seq[i] for i in idx]
83
83
 
84
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
+
85
105
  # --- tools --------------------------------------------------------------------
86
106
  @mcp.tool()
87
107
  def create_model(name: str = "") -> dict:
@@ -155,14 +175,61 @@ def load_data(
155
175
 
156
176
  @mcp.tool()
157
177
  def get_parameters(model_id: str) -> dict:
158
- """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."""
159
180
  model = _get(model_id)
160
181
  return {
161
- group: {name: info["default"] for name, info in params.items()}
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."""
194
+ model = _get(model_id)
195
+ return {
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
+ }
162
204
  for group, params in model.params.items()
163
205
  }
164
206
 
165
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
+
166
233
  @mcp.tool()
167
234
  def set_parameters(model_id: str, values: dict) -> dict:
168
235
  """Set parameter values from a flat mapping, e.g. {"FC": 250, "MAXBAS": 4}.
@@ -292,18 +359,54 @@ async def calibrate(
292
359
 
293
360
  result = out["optimization_result"]
294
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
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
+ "Used the full iteration budget and the objective is still improving - "
376
+ "call calibrate again to continue (it resumes from the current parameters)."
377
+ if still_improving
378
+ else "Used the full iteration budget and the objective has plateaued "
379
+ "(improvement < 1e-3 over recent iterations); further iterations are "
380
+ "unlikely to help much. Stop here, or widen ranges if parameters are at_bound."
381
+ )
382
+ else:
383
+ status, guidance = "failed", f"Optimizer stopped without converging: {message}"
384
+
385
+ at_bound = _at_bound(model)
386
+ if at_bound:
387
+ guidance += (
388
+ " Some parameters are at their range limits "
389
+ f"({', '.join(b['parameter'] for b in at_bound)}); consider widening them "
390
+ "with set_parameter_ranges before re-calibrating."
391
+ )
392
+
295
393
  return {
296
394
  "model_id": model_id,
297
395
  "objective": objective,
298
396
  "method": method,
299
397
  "n_iterations": int(getattr(result, "nit", len(traj))),
300
- "success": bool(getattr(result, "success", True)),
301
- "message": str(getattr(result, "message", "")),
398
+ "converged": success,
399
+ "status": status,
400
+ "still_improving": still_improving,
401
+ "guidance": guidance,
402
+ "success": success, # kept for backward compatibility
403
+ "message": message,
302
404
  "optimized_parameters": {
303
- group: {name: round(info["default"], 6) for name, info in params.items()}
405
+ group: {name: round(float(info["default"]), 6) for name, info in params.items()}
304
406
  for group, params in model.params.items()
305
407
  },
306
408
  "performance_metrics": _metrics(model),
409
+ "at_bound": at_bound,
307
410
  "objective_trajectory": _downsample(traj, 20),
308
411
  }
309
412
 
@@ -323,6 +426,8 @@ def evaluate_uncertainty(
323
426
  model and can be persisted with save_results.
324
427
  """
325
428
  model = _get(model_id)
429
+ import numpy as np
430
+
326
431
  out = model.evaluate_uncertainty(
327
432
  n_runs=n_runs,
328
433
  objective=objective,
@@ -331,12 +436,41 @@ def evaluate_uncertainty(
331
436
  plot_results=False,
332
437
  verbose=False,
333
438
  )
439
+
440
+ # 95% prediction-interval diagnostics from the best runs
441
+ df = out["best_runs"]
442
+ obs = np.asarray(df["observed"].values, dtype=float)
443
+ lo = np.asarray(df["q5"].values, dtype=float)
444
+ hi = np.asarray(df["q95"].values, dtype=float)
445
+ valid = ~np.isnan(obs)
446
+ if valid.any():
447
+ inside = (obs[valid] >= lo[valid]) & (obs[valid] <= hi[valid])
448
+ coverage_95 = round(float(inside.mean()), 3)
449
+ mean_band_width = round(float(np.nanmean(hi[valid] - lo[valid])), 4)
450
+ else:
451
+ coverage_95 = mean_band_width = None
452
+
453
+ # Per-parameter posterior ranges across the best parameter sets
454
+ posterior: dict = {}
455
+ for s in out["best_parameter_sets"]:
456
+ for params in s["parameters"].values():
457
+ for name, info in params.items():
458
+ posterior.setdefault(name, []).append(info["default"])
459
+ posterior_ranges = {
460
+ name: {"min": round(float(min(v)), 4), "max": round(float(max(v)), 4)}
461
+ for name, v in posterior.items()
462
+ }
463
+
334
464
  return {
335
465
  "model_id": model_id,
336
466
  "objective": objective,
337
467
  "n_runs": n_runs,
468
+ "save_best": save_best,
338
469
  "best_performance": round(float(out["best_performance"]), 4),
339
470
  "current_performance": round(float(out["original_performance"]), 4),
471
+ "coverage_95": coverage_95, # fraction of observations inside the 95% band
472
+ "mean_band_width": mean_band_width, # mean (q95 - q5), mm/day
473
+ "parameter_posterior_ranges": posterior_ranges,
340
474
  }
341
475
 
342
476
 
@@ -375,6 +509,69 @@ def load_model(model_path: str) -> dict:
375
509
  return {"model_id": model_id, "performance_metrics": _metrics(model)}
376
510
 
377
511
 
512
+ @mcp.tool()
513
+ def clone_model(model_id: str, name: str = "") -> dict:
514
+ """Deep-copy a model (parameters, data, states, results) into a new model_id.
515
+
516
+ The canonical split-sample pattern: calibrate ``model-A``, ``clone_model`` it, then
517
+ ``load_data`` the validation window on the clone and ``run_model`` — the calibrated
518
+ parameters carry over, no manual parameter transfer needed.
519
+ """
520
+ import copy
521
+
522
+ src = _get(model_id)
523
+ new_id = _new_id()
524
+ _MODELS[new_id] = copy.deepcopy(src)
525
+ return {"model_id": new_id, "cloned_from": model_id, "name": name}
526
+
527
+
528
+ @mcp.tool()
529
+ def copy_parameters(from_model_id: str, to_model_id: str) -> dict:
530
+ """Copy the calibrated parameters (and ranges) from one model to another.
531
+
532
+ Use this to transfer a calibration to a validation model without copying its data.
533
+ """
534
+ import copy
535
+
536
+ src = _get(from_model_id)
537
+ dst = _get(to_model_id)
538
+ dst.params = copy.deepcopy(src.params)
539
+ return {
540
+ "from_model_id": from_model_id,
541
+ "to_model_id": to_model_id,
542
+ "parameters": {
543
+ group: {name: float(info["default"]) for name, info in params.items()}
544
+ for group, params in dst.params.items()
545
+ },
546
+ }
547
+
548
+
549
+ @mcp.tool()
550
+ def get_metrics(model_id: str) -> dict:
551
+ """Return the model's current performance metrics without re-running it.
552
+
553
+ Empty if the model hasn't been run with observed discharge yet.
554
+ """
555
+ model = _get(model_id)
556
+ return {"model_id": model_id, "performance_metrics": _metrics(model)}
557
+
558
+
559
+ @mcp.tool()
560
+ def compare_models(model_ids: list) -> dict:
561
+ """Tabulate performance metrics across several models side by side.
562
+
563
+ Handy for comparing calibration vs validation, or several calibration variants.
564
+ """
565
+ rows = []
566
+ for mid in model_ids:
567
+ try:
568
+ model = _get(mid)
569
+ rows.append({"model_id": mid, "metrics": _metrics(model)})
570
+ except ValueError as e:
571
+ rows.append({"model_id": mid, "error": str(e)})
572
+ return {"comparison": rows}
573
+
574
+
378
575
  def main() -> None:
379
576
  """Console-script / module entry point.
380
577
 
@@ -1,12 +1,12 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: HBV_Lab
3
- Version: 1.3.0
4
- Summary: An intuitive, object-oriented and user-friendly Python implementation of a lumped conceptual HBV hydrological model for educational and research purposes.
3
+ Version: 1.4.1
4
+ Summary: An agentic, object-oriented Python implementation of a lumped conceptual HBV hydrological model with calibration, uncertainty analysis, and a built-in MCP server that lets AI agents drive the workflow.
5
5
  Home-page: https://github.com/abdallaox/HBV_python_implementation
6
6
  Author: Abdalla Mohammed
7
7
  Author-email: abdalla.mohammed.ox@gmail.com
8
8
  License: MIT
9
- Keywords: hydrology HBV-model rainfall-runoff hydrological-modelling
9
+ Keywords: hydrology HBV-model rainfall-runoff hydrological-modelling MCP model-context-protocol agent agentic LLM
10
10
  Classifier: Programming Language :: Python :: 3
11
11
  Classifier: License :: OSI Approved :: MIT License
12
12
  Classifier: Operating System :: OS Independent
@@ -39,9 +39,9 @@ Dynamic: requires-dist
39
39
  Dynamic: requires-python
40
40
  Dynamic: summary
41
41
 
42
- # HBV_Lab
42
+ # HBV_Lab — an agentic HBV hydrological model with an MCP server
43
43
 
44
- **An intuitive, object-oriented Python implementation of a lumped conceptual HBV rainfall–runoff model, with built-in calibration and uncertainty analysis.**
44
+ **An intuitive, object-oriented Python implementation of a lumped conceptual HBV rainfall–runoff model with built-in calibration and uncertainty analysis, and a Model Context Protocol (MCP) server that lets AI agents drive the whole workflow.**
45
45
 
46
46
  [![PyPI version](https://img.shields.io/pypi/v/HBV_Lab.svg)](https://pypi.org/project/HBV_Lab/)
47
47
  [![Python versions](https://img.shields.io/pypi/pyversions/HBV_Lab.svg)](https://pypi.org/project/HBV_Lab/)
@@ -59,6 +59,11 @@ full model is wrapped in a single `HBVModel` object that handles data loading, s
59
59
  calibration, uncertainty analysis, plotting, and persistence. It is well suited to research
60
60
  prototyping, method development, and hydrology education.
61
61
 
62
+ **It is also agent-ready:** a built-in [MCP](https://modelcontextprotocol.io/) server exposes the
63
+ model as tools, so an AI agent (Claude Code, Claude Desktop, or any MCP client) can build, calibrate,
64
+ validate and run uncertainty analysis on HBV models through natural language — see
65
+ [Use it as an MCP server](#use-it-as-an-mcp-server-for-ai-agents).
66
+
62
67
  > **Scope at a glance:** lumped (single-cell, spatially averaged) · conceptual · daily time step ·
63
68
  > requires precipitation, temperature and potential evapotranspiration as input · 14 calibratable
64
69
  > parameters. Please read [Scope, Assumptions & Limitations](#scope-assumptions--limitations) before
@@ -68,6 +73,8 @@ prototyping, method development, and hydrology education.
68
73
 
69
74
  ## Features
70
75
 
76
+ - **Agent-ready MCP server** — drive the full *create → load → calibrate → validate → uncertainty*
77
+ workflow from an AI agent over stdio or HTTP, with live calibration progress and structured results.
71
78
  - **Complete HBV structure** — snow, soil, groundwater response (two reservoirs, three runoff
72
79
  components) and `MAXBAS` triangular routing.
73
80
  - **Object-oriented API** — one `HBVModel` object holds data, parameters, states and results.
@@ -197,6 +204,12 @@ Install once:
197
204
  pip install "HBV_Lab[mcp]"
198
205
  ```
199
206
 
207
+ > **Upgrading:** stop/close the MCP client (and any running `hbv-mcp` process) **before**
208
+ > upgrading — a running server holds `hbv-mcp.exe` open on Windows, so an in-place
209
+ > `pip install -U` can fail with a file-lock error. After upgrading, **fully restart the
210
+ > client** (or remove and re-add the server) so it re-discovers the tool list; clients fetch
211
+ > tools only once per connection, so upgrading mid-session leaves them showing the old tools.
212
+
200
213
  #### Option A — local (stdio)
201
214
 
202
215
  No need to start anything yourself; the agent runs `hbv-mcp` for you. Just add it to your client's
@@ -250,19 +263,30 @@ mode deploys cleanly to platforms like Railway, Render or Fly.
250
263
 
251
264
  #### What the agent can do
252
265
 
253
- Tools: `create_model`, `load_data` (from a CSV/Excel file path), `get_parameters`, `set_parameters`,
254
- `set_initial_conditions`, `run_model`, `calibrate`, `evaluate_uncertainty`, `plot_results`,
255
- `save_results`, `save_model`, `load_model`, `list_models`. A typical agent flow is *create → load →
256
- run calibrate plot*. Model state is kept server-side (each tool takes a `model_id`) and large time
257
- series are passed by **file path**, not through the agent's context tools return compact metrics and
258
- output-file paths.
266
+ Tools: `create_model`, `clone_model`, `copy_parameters`, `load_data` (from a CSV/Excel file path),
267
+ `get_parameters`, `set_parameters`, `get_parameter_ranges`, `set_parameter_ranges`,
268
+ `set_initial_conditions`, `run_model`, `calibrate`, `evaluate_uncertainty`, `get_metrics`,
269
+ `compare_models`, `plot_results`, `save_results`, `save_model`, `load_model`, `list_models`. A typical
270
+ agent flow is *create load run calibrate plot*. Model state is kept server-side (each tool
271
+ takes a `model_id`) and large time series are passed by **file path**, not through the agent's
272
+ context — tools return compact metrics and output-file paths.
259
273
 
260
274
  **Calibration progress & agent steering.** `calibrate` emits MCP progress notifications every
261
275
  optimizer iteration (clients that surface them show a live progress/log view), and its result
262
- includes the optimizer status and the best-objective-per-iteration `objective_trajectory`. Because
263
- each call continues from the model's *current* parameters, an agent can also calibrate incrementally
264
- call `calibrate` with a small `iterations` budget, inspect the improving metric, and decide whether
265
- to keep going, widen ranges, or switch objective between rounds.
276
+ reports an honest convergence `status` (`converged` / `hit_iteration_budget` / `failed`),
277
+ `still_improving`, the best-objective-per-iteration `objective_trajectory`, and an `at_bound` list of
278
+ parameters pinned to their range limits. Because each call continues from the model's *current*
279
+ parameters, an agent can calibrate incrementally call `calibrate` with a small `iterations` budget,
280
+ inspect the improving metric, and decide whether to keep going, widen ranges (via
281
+ `set_parameter_ranges`), or switch objective between rounds.
282
+
283
+ **Split-sample made easy.** Calibrate one model, `clone_model` it (or `copy_parameters` to a second
284
+ model), `load_data` the validation window on the clone, and `run_model` — the calibrated parameters
285
+ carry over with no manual transfer. `compare_models` tabulates calibration vs. validation metrics.
286
+
287
+ **Richer uncertainty.** `evaluate_uncertainty` returns the 95% prediction-band **coverage** (fraction
288
+ of observations inside the band), **mean band width**, and per-parameter **posterior ranges**, not
289
+ just the best/current objective.
266
290
 
267
291
  ## Inputs & outputs
268
292