HBV-Lab 1.2.0__tar.gz → 1.3.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.3.0}/HBV_Lab/__init__.py +1 -1
  2. {hbv_lab-1.2.0 → hbv_lab-1.3.0}/HBV_Lab/calibration.py +37 -10
  3. {hbv_lab-1.2.0 → hbv_lab-1.3.0}/HBV_Lab/mcp_server.py +75 -9
  4. {hbv_lab-1.2.0 → hbv_lab-1.3.0}/HBV_Lab.egg-info/PKG-INFO +8 -1
  5. {hbv_lab-1.2.0 → hbv_lab-1.3.0}/PKG-INFO +8 -1
  6. {hbv_lab-1.2.0 → hbv_lab-1.3.0}/README.md +7 -0
  7. {hbv_lab-1.2.0 → hbv_lab-1.3.0}/setup.py +1 -1
  8. {hbv_lab-1.2.0 → hbv_lab-1.3.0}/HBV_Lab/HBV_model.py +0 -0
  9. {hbv_lab-1.2.0 → hbv_lab-1.3.0}/HBV_Lab/hbv_step.py +0 -0
  10. {hbv_lab-1.2.0 → hbv_lab-1.3.0}/HBV_Lab/response.py +0 -0
  11. {hbv_lab-1.2.0 → hbv_lab-1.3.0}/HBV_Lab/routing.py +0 -0
  12. {hbv_lab-1.2.0 → hbv_lab-1.3.0}/HBV_Lab/snow.py +0 -0
  13. {hbv_lab-1.2.0 → hbv_lab-1.3.0}/HBV_Lab/soil.py +0 -0
  14. {hbv_lab-1.2.0 → hbv_lab-1.3.0}/HBV_Lab/uncertainty.py +0 -0
  15. {hbv_lab-1.2.0 → hbv_lab-1.3.0}/HBV_Lab.egg-info/SOURCES.txt +0 -0
  16. {hbv_lab-1.2.0 → hbv_lab-1.3.0}/HBV_Lab.egg-info/dependency_links.txt +0 -0
  17. {hbv_lab-1.2.0 → hbv_lab-1.3.0}/HBV_Lab.egg-info/entry_points.txt +0 -0
  18. {hbv_lab-1.2.0 → hbv_lab-1.3.0}/HBV_Lab.egg-info/requires.txt +0 -0
  19. {hbv_lab-1.2.0 → hbv_lab-1.3.0}/HBV_Lab.egg-info/top_level.txt +0 -0
  20. {hbv_lab-1.2.0 → hbv_lab-1.3.0}/LICENSE +0 -0
  21. {hbv_lab-1.2.0 → hbv_lab-1.3.0}/setup.cfg +0 -0
  22. {hbv_lab-1.2.0 → hbv_lab-1.3.0}/tests/test_hbv_model.py +0 -0
  23. {hbv_lab-1.2.0 → hbv_lab-1.3.0}/tests/test_hbv_step.py +0 -0
  24. {hbv_lab-1.2.0 → hbv_lab-1.3.0}/tests/test_response.py +0 -0
  25. {hbv_lab-1.2.0 → hbv_lab-1.3.0}/tests/test_snow.py +0 -0
  26. {hbv_lab-1.2.0 → hbv_lab-1.3.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.3.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,20 @@ 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
+
71
85
  # --- tools --------------------------------------------------------------------
72
86
  @mcp.tool()
73
87
  def create_model(name: str = "") -> dict:
@@ -210,35 +224,87 @@ def run_model(model_id: str) -> dict:
210
224
 
211
225
 
212
226
  @mcp.tool()
213
- def calibrate(
227
+ async def calibrate(
214
228
  model_id: str,
215
229
  objective: str = "NSE",
216
230
  method: str = "Nelder-Mead",
217
231
  iterations: int = 1000,
232
+ ctx: Context = None,
218
233
  ) -> dict:
219
234
  """Calibrate the model to observed discharge (requires obs data loaded).
220
235
 
221
236
  ``objective`` is one of NSE / KGE / RMSE / MAE. ``method`` defaults to the
222
237
  gradient-free 'Nelder-Mead' (recommended for HBV's piecewise objective).
223
- Returns the optimized parameters and final performance metrics.
238
+
239
+ Progress: while running, the server emits MCP progress notifications each
240
+ optimizer iteration (visible in clients that surface them).
241
+
242
+ Incremental use: each call continues from the model's *current* parameters, so an
243
+ agent can call calibrate repeatedly with a small ``iterations`` budget (e.g. 50),
244
+ inspect the improving metric and ``objective_trajectory`` between calls, and decide
245
+ whether to keep going, widen parameter ranges, or switch objective.
246
+
247
+ Returns the optimized parameters, final metrics, optimizer status, and the
248
+ best-objective-per-iteration trajectory (downsampled).
224
249
  """
250
+ import asyncio
251
+
225
252
  model = _get(model_id)
226
- model.calibrate(
227
- method=method,
228
- objective=objective,
229
- iterations=iterations,
230
- verbose=False,
231
- plot_results=False,
253
+ loop = asyncio.get_running_loop()
254
+ updates: asyncio.Queue = asyncio.Queue()
255
+
256
+ def progress_cb(i, total, current, best):
257
+ # Runs in the worker thread — hand the update back to the event loop safely.
258
+ loop.call_soon_threadsafe(updates.put_nowait, (i, total, current, best))
259
+
260
+ async def report(i, total, current, best):
261
+ if ctx is None:
262
+ return
263
+ try:
264
+ await ctx.report_progress(i, total)
265
+ await ctx.info(
266
+ f"calibrate iter {i}/{total}: {objective}={current:.4f} (best {best:.4f})"
267
+ )
268
+ except Exception:
269
+ pass
270
+
271
+ # Run the (blocking) calibration in a worker thread so we can stream progress.
272
+ task = asyncio.create_task(
273
+ asyncio.to_thread(
274
+ model.calibrate,
275
+ method=method,
276
+ objective=objective,
277
+ iterations=iterations,
278
+ verbose=False,
279
+ plot_results=False,
280
+ progress_callback=progress_cb,
281
+ )
232
282
  )
283
+ while not task.done():
284
+ try:
285
+ i, total, current, best = await asyncio.wait_for(updates.get(), timeout=0.25)
286
+ await report(i, total, current, best)
287
+ except asyncio.TimeoutError:
288
+ pass
289
+ while not updates.empty(): # flush any stragglers
290
+ await report(*updates.get_nowait())
291
+ out = await task # re-raises any error from the worker thread
292
+
293
+ result = out["optimization_result"]
294
+ traj = out.get("trajectory", [])
233
295
  return {
234
296
  "model_id": model_id,
235
297
  "objective": objective,
236
298
  "method": method,
299
+ "n_iterations": int(getattr(result, "nit", len(traj))),
300
+ "success": bool(getattr(result, "success", True)),
301
+ "message": str(getattr(result, "message", "")),
237
302
  "optimized_parameters": {
238
303
  group: {name: round(info["default"], 6) for name, info in params.items()}
239
304
  for group, params in model.params.items()
240
305
  },
241
306
  "performance_metrics": _metrics(model),
307
+ "objective_trajectory": _downsample(traj, 20),
242
308
  }
243
309
 
244
310
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: HBV_Lab
3
- Version: 1.2.0
3
+ Version: 1.3.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
@@ -257,6 +257,13 @@ run → calibrate → plot*. Model state is kept server-side (each tool takes a
257
257
  series are passed by **file path**, not through the agent's context — tools return compact metrics and
258
258
  output-file paths.
259
259
 
260
+ **Calibration progress & agent steering.** `calibrate` emits MCP progress notifications every
261
+ 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.
266
+
260
267
  ## Inputs & outputs
261
268
 
262
269
  **Inputs** (daily, consistent units): precipitation (mm), air temperature (°C) and potential
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: HBV_Lab
3
- Version: 1.2.0
3
+ Version: 1.3.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
@@ -257,6 +257,13 @@ run → calibrate → plot*. Model state is kept server-side (each tool takes a
257
257
  series are passed by **file path**, not through the agent's context — tools return compact metrics and
258
258
  output-file paths.
259
259
 
260
+ **Calibration progress & agent steering.** `calibrate` emits MCP progress notifications every
261
+ 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.
266
+
260
267
  ## Inputs & outputs
261
268
 
262
269
  **Inputs** (daily, consistent units): precipitation (mm), air temperature (°C) and potential
@@ -216,6 +216,13 @@ run → calibrate → plot*. Model state is kept server-side (each tool takes a
216
216
  series are passed by **file path**, not through the agent's context — tools return compact metrics and
217
217
  output-file paths.
218
218
 
219
+ **Calibration progress & agent steering.** `calibrate` emits MCP progress notifications every
220
+ optimizer iteration (clients that surface them show a live progress/log view), and its result
221
+ includes the optimizer status and the best-objective-per-iteration `objective_trajectory`. Because
222
+ each call continues from the model's *current* parameters, an agent can also calibrate incrementally
223
+ — call `calibrate` with a small `iterations` budget, inspect the improving metric, and decide whether
224
+ to keep going, widen ranges, or switch objective between rounds.
225
+
219
226
  ## Inputs & outputs
220
227
 
221
228
  **Inputs** (daily, consistent units): precipitation (mm), air temperature (°C) and potential
@@ -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.3.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