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.
Files changed (26) hide show
  1. {hbv_lab-1.2.0 → hbv_lab-1.4.0}/HBV_Lab/__init__.py +1 -1
  2. {hbv_lab-1.2.0 → hbv_lab-1.4.0}/HBV_Lab/calibration.py +37 -10
  3. {hbv_lab-1.2.0 → hbv_lab-1.4.0}/HBV_Lab/mcp_server.py +273 -12
  4. {hbv_lab-1.2.0 → hbv_lab-1.4.0}/HBV_Lab.egg-info/PKG-INFO +25 -7
  5. {hbv_lab-1.2.0 → hbv_lab-1.4.0}/PKG-INFO +25 -7
  6. {hbv_lab-1.2.0 → hbv_lab-1.4.0}/README.md +24 -6
  7. {hbv_lab-1.2.0 → hbv_lab-1.4.0}/setup.py +1 -1
  8. {hbv_lab-1.2.0 → hbv_lab-1.4.0}/HBV_Lab/HBV_model.py +0 -0
  9. {hbv_lab-1.2.0 → hbv_lab-1.4.0}/HBV_Lab/hbv_step.py +0 -0
  10. {hbv_lab-1.2.0 → hbv_lab-1.4.0}/HBV_Lab/response.py +0 -0
  11. {hbv_lab-1.2.0 → hbv_lab-1.4.0}/HBV_Lab/routing.py +0 -0
  12. {hbv_lab-1.2.0 → hbv_lab-1.4.0}/HBV_Lab/snow.py +0 -0
  13. {hbv_lab-1.2.0 → hbv_lab-1.4.0}/HBV_Lab/soil.py +0 -0
  14. {hbv_lab-1.2.0 → hbv_lab-1.4.0}/HBV_Lab/uncertainty.py +0 -0
  15. {hbv_lab-1.2.0 → hbv_lab-1.4.0}/HBV_Lab.egg-info/SOURCES.txt +0 -0
  16. {hbv_lab-1.2.0 → hbv_lab-1.4.0}/HBV_Lab.egg-info/dependency_links.txt +0 -0
  17. {hbv_lab-1.2.0 → hbv_lab-1.4.0}/HBV_Lab.egg-info/entry_points.txt +0 -0
  18. {hbv_lab-1.2.0 → hbv_lab-1.4.0}/HBV_Lab.egg-info/requires.txt +0 -0
  19. {hbv_lab-1.2.0 → hbv_lab-1.4.0}/HBV_Lab.egg-info/top_level.txt +0 -0
  20. {hbv_lab-1.2.0 → hbv_lab-1.4.0}/LICENSE +0 -0
  21. {hbv_lab-1.2.0 → hbv_lab-1.4.0}/setup.cfg +0 -0
  22. {hbv_lab-1.2.0 → hbv_lab-1.4.0}/tests/test_hbv_model.py +0 -0
  23. {hbv_lab-1.2.0 → hbv_lab-1.4.0}/tests/test_hbv_step.py +0 -0
  24. {hbv_lab-1.2.0 → hbv_lab-1.4.0}/tests/test_response.py +0 -0
  25. {hbv_lab-1.2.0 → hbv_lab-1.4.0}/tests/test_snow.py +0 -0
  26. {hbv_lab-1.2.0 → hbv_lab-1.4.0}/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.2.0"
27
+ __version__ = "1.4.0"
28
28
 
29
29
  __all__ = [
30
30
  "HBVModel",
@@ -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 containing optimized parameters and performance metrics
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') if objective in ['RMSE', 'MAE'] else 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: {name: info["default"] for name, info in params.items()}
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
- Returns the optimized parameters and final performance metrics.
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
- model.calibrate(
227
- method=method,
228
- objective=objective,
229
- iterations=iterations,
230
- verbose=False,
231
- plot_results=False,
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.2.0
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), `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.
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.2.0
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), `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.
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), `get_parameters`, `set_parameters`,
213
- `set_initial_conditions`, `run_model`, `calibrate`, `evaluate_uncertainty`, `plot_results`,
214
- `save_results`, `save_model`, `load_model`, `list_models`. A typical agent flow is *create → load →
215
- run calibrate plot*. Model state is kept server-side (each tool takes a `model_id`) and large time
216
- series are passed by **file path**, not through the agent's context tools return compact metrics and
217
- output-file paths.
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
 
@@ -5,7 +5,7 @@ with open("README.md", "r", encoding="utf-8") as f:
5
5
 
6
6
  setup(
7
7
  name='HBV_Lab',
8
- version='1.2.0',
8
+ version='1.4.0',
9
9
  packages=find_packages(include=['HBV_Lab', 'HBV_Lab.*']),
10
10
  install_requires=[
11
11
  'numpy',
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