guohanghui commited on
Commit
1de04ae
·
verified ·
1 Parent(s): 24943cd

Update pyPDAF/mcp_output/mcp_plugin/mcp_service.py

Browse files
pyPDAF/mcp_output/mcp_plugin/mcp_service.py CHANGED
@@ -1,1741 +1,606 @@
1
  """
2
- MCP Service for pyPDAF - Parallel Data Assimilation Framework
3
 
4
- This module provides MCP (Model Context Protocol) tools for interacting with
5
- the pyPDAF library, which is a Python interface to the PDAF (Parallel Data
6
- Assimilation Framework) library for ensemble-based data assimilation.
7
  """
8
 
9
- import os
10
  import sys
11
- from typing import List, Optional
 
 
12
 
13
  import numpy as np
14
-
15
- source_path = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), "source")
16
- sys.path.insert(0, source_path)
17
-
18
  from fastmcp import FastMCP
19
- from src.pyPDAF import PDAF, PDAF3, PDAFlocal, PDAFlocalomi, PDAFomi
 
20
 
21
- mcp = FastMCP("pyPDAF_service")
 
 
 
 
 
 
 
 
 
 
22
 
23
 
24
  # ============================================================================
25
- # PDAF Core Module Tools
26
  # ============================================================================
27
 
28
- @mcp.tool(name="pdaf_gather_dim_obs_f", description="Gather global observation dimension across local domains")
29
- def pdaf_gather_dim_obs_f(dim_obs_p: int) -> dict:
30
- """
31
- Gather total observation dimension and displacements from local dimensions.
32
-
33
- This allocates the global observation size used by gather_obs_f routines.
34
-
35
- Args:
36
- dim_obs_p: Process-local observation dimension.
37
-
38
- Returns:
39
- dict: A dictionary containing the full observation dimension.
40
- """
41
- try:
42
- dim_obs_f = PDAF.gather_dim_obs_f(dim_obs_p)
43
- return {"success": True, "result": {"dim_obs_f": int(dim_obs_f)}, "error": None}
44
- except Exception as e:
45
- return {"success": False, "result": None, "error": str(e)}
46
-
47
-
48
- @mcp.tool(name="pdaf_gather_obs_f", description="Gather full observation vector from process-local pieces")
49
- def pdaf_gather_obs_f(obs_p: List[float], dimobs_f: int) -> dict:
50
- """
51
- Concatenate process-local observation vectors into a full vector (local-filter use).
52
-
53
- Args:
54
- obs_p: Process-local observation vector.
55
- dimobs_f: Full observation dimension.
56
-
57
- Returns:
58
- dict: A dictionary containing the gathered observation vector and status.
59
- """
60
- try:
61
- obs_p_np = np.array(obs_p, dtype=np.float64)
62
- obs_f, status = PDAF.gather_obs_f(obs_p_np, dimobs_f)
63
- return {
64
- "success": True,
65
- "result": {"obs_f": obs_f.tolist(), "status": int(status)},
66
- "error": None,
67
- }
68
- except Exception as e:
69
- return {"success": False, "result": None, "error": str(e)}
70
-
71
-
72
- @mcp.tool(name="pdaf_gather_obs_f2", description="Gather observation coordinates from local pieces")
73
- def pdaf_gather_obs_f2(coords_p: List[List[float]], nrows: int, dimobs_f: int) -> dict:
74
- """
75
- Concatenate process-local observation coordinate arrays into a full coordinate array.
76
-
77
- Args:
78
- coords_p: Local coordinate array shaped [nrows, dimobs_p].
79
- nrows: Number of coordinate rows (e.g., spatial dimensions).
80
- dimobs_f: Full observation dimension.
81
-
82
- Returns:
83
- dict: A dictionary containing the gathered coordinates and status.
84
- """
85
- try:
86
- coords_p_np = np.array(coords_p, dtype=np.float64)
87
- coords_f, status = PDAF.gather_obs_f2(coords_p_np, nrows, dimobs_f)
88
- return {
89
- "success": True,
90
- "result": {"coords_f": coords_f.tolist(), "status": int(status)},
91
- "error": None,
92
- }
93
- except Exception as e:
94
- return {"success": False, "result": None, "error": str(e)}
95
-
96
-
97
- @mcp.tool(name="pdaf_gather_obs_f_flex", description="Gather full observation vector without PDAF-internal metadata")
98
- def pdaf_gather_obs_f_flex(dim_obs_p: int, dim_obs_f: int, obs_p: List[float]) -> dict:
99
- """
100
- Flexibly gather observation vectors using explicit local/global sizes.
101
-
102
- Args:
103
- dim_obs_p: Process-local observation dimension.
104
- dim_obs_f: Full observation dimension.
105
- obs_p: Local observation vector.
106
-
107
- Returns:
108
- dict: A dictionary containing the gathered observation vector and status.
109
- """
110
- try:
111
- obs_p_np = np.array(obs_p, dtype=np.float64)
112
- obs_f, status = PDAF.gather_obs_f_flex(dim_obs_p, dim_obs_f, obs_p_np)
113
- return {
114
- "success": True,
115
- "result": {"obs_f": obs_f.tolist(), "status": int(status)},
116
- "error": None,
117
- }
118
- except Exception as e:
119
- return {"success": False, "result": None, "error": str(e)}
120
-
121
-
122
- @mcp.tool(name="pdaf_gather_obs_f2_flex", description="Gather observation coordinates without PDAF-internal metadata")
123
- def pdaf_gather_obs_f2_flex(dim_obs_p: int, dim_obs_f: int, coords_p: List[List[float]], nrows: int) -> dict:
124
- """
125
- Flexibly gather 2D observation coordinate arrays using explicit local/global sizes.
126
-
127
- Args:
128
- dim_obs_p: Process-local observation dimension.
129
- dim_obs_f: Full observation dimension.
130
- coords_p: Local coordinate array shaped [nrows, dim_obs_p].
131
- nrows: Number of coordinate rows (e.g., spatial dimensions).
132
-
133
- Returns:
134
- dict: A dictionary containing the gathered coordinates and status.
135
- """
136
- try:
137
- coords_p_np = np.array(coords_p, dtype=np.float64)
138
- coords_f, status = PDAF.gather_obs_f2_flex(dim_obs_p, dim_obs_f, coords_p_np, nrows)
139
- return {
140
- "success": True,
141
- "result": {"coords_f": coords_f.tolist(), "status": int(status)},
142
- "error": None,
143
- }
144
- except Exception as e:
145
- return {"success": False, "result": None, "error": str(e)}
146
-
147
- @mcp.tool(name="pdaf_correlation_function", description="Calculate the value of a correlation function at a given distance")
148
- def pdaf_correlation_function(ctype: int, length: float, distance: float) -> dict:
149
- """
150
- Calculate the value of the chosen correlation function according to the specified length scale.
151
-
152
- Args:
153
- ctype: Type of correlation function
154
- 1: Gaussian with f(0)=1.0
155
- 2: 5th-order polynomial (Gaspari/Cohn, 1999)
156
- length: Length scale of function
157
- ctype=1: standard deviation
158
- ctype=2: support length (f=0 for distance>length)
159
- distance: Distance at which the function is evaluated
160
-
161
- Returns:
162
- dict: A dictionary containing the success status and the correlation value.
163
- """
164
- try:
165
- value = PDAF.correlation_function(ctype, length, distance)
166
- return {"success": True, "result": {"value": float(value)}, "error": None}
167
- except Exception as e:
168
- return {"success": False, "result": None, "error": str(e)}
169
-
170
-
171
- @mcp.tool(name="pdaf_deallocate", description="Finalize the PDAF system and free allocated memory")
172
- def pdaf_deallocate() -> dict:
173
- """
174
- Finalise the PDAF system including freeing some of the memory used by PDAF.
175
-
176
- Note: This function cannot free all allocated PDAF memory.
177
- Therefore, one should not use PDAF.init afterwards.
178
-
179
- Returns:
180
- dict: A dictionary containing the success status.
181
- """
182
- try:
183
- PDAF.deallocate()
184
- return {"success": True, "result": "PDAF memory deallocated successfully", "error": None}
185
- except Exception as e:
186
- return {"success": False, "result": None, "error": str(e)}
187
-
188
-
189
- @mcp.tool(name="pdaf_eofcovar", description="Perform EOF analysis of an ensemble of state vectors by SVD")
190
- def pdaf_eofcovar(
191
- dim: int,
192
- nstates: int,
193
- nfields: int,
194
- dim_fields: List[int],
195
- offsets: List[int],
196
- remove_mstate: int,
197
- do_mv: int,
198
- states: List[List[float]],
199
- meanstate: List[float],
200
- verbose: int
201
- ) -> dict:
202
- """
203
- EOF analysis of an ensemble of state vectors by singular value decomposition.
204
-
205
- This function performs a singular value decomposition of the ensemble anomaly.
206
- The singular values and corresponding singular vectors can be used to
207
- construct a covariance matrix for the initial ensemble.
208
-
209
- Args:
210
- dim: Dimension of state vector
211
- nstates: Number of state vectors
212
- nfields: Number of fields in state vector
213
- dim_fields: Size of each field (list of length nfields)
214
- offsets: Start position of each field (list of length nfields)
215
- remove_mstate: 1 to subtract mean state from states
216
- do_mv: 1 for multivariate scaling; 0 for no scaling
217
- states: State perturbations (2D list of shape [dim, nstates])
218
- meanstate: Mean state (list of length dim)
219
- verbose: Verbosity flag
220
-
221
- Returns:
222
- dict: A dictionary containing:
223
- - states: Updated state perturbations
224
- - stddev: Standard deviation of field variability
225
- - svals: Singular values divided by sqrt(nstates-1)
226
- - svec: Singular vectors
227
- - meanstate: Updated mean state
228
- - status: Status flag
229
- """
230
- try:
231
- states_np = np.array(states, dtype=np.float64)
232
- meanstate_np = np.array(meanstate, dtype=np.float64)
233
- dim_fields_np = np.array(dim_fields, dtype=np.int32)
234
- offsets_np = np.array(offsets, dtype=np.int32)
235
 
236
- result = PDAF.eofcovar(
237
- dim, nstates, nfields, dim_fields_np, offsets_np,
238
- remove_mstate, do_mv, states_np, meanstate_np, verbose
239
- )
240
 
241
- states_out, stddev, svals, svec, meanstate_out, status = result
242
- return {
243
- "success": True,
244
- "result": {
245
- "states": states_out.tolist(),
246
- "stddev": stddev.tolist(),
247
- "svals": svals.tolist(),
248
- "svec": svec.tolist(),
249
- "meanstate": meanstate_out.tolist(),
250
- "status": int(status)
251
- },
252
- "error": None
253
- }
254
- except Exception as e:
255
- return {"success": False, "result": None, "error": str(e)}
256
-
257
-
258
- @mcp.tool(name="pdaf_force_analysis", description="Force PDAF to perform analysis at next assimilation call")
259
- def pdaf_force_analysis() -> dict:
260
- """
261
- Force PDAF to perform assimilation at the next function call.
262
-
263
- This function overwrites member index of the ensemble state
264
- and forces that the analysis step is executed at the next call
265
- to PDAF assimilation functions.
266
-
267
- Returns:
268
- dict: A dictionary containing the success status.
269
- """
270
- try:
271
- PDAF.force_analysis()
272
- return {"success": True, "result": "Analysis forced successfully", "error": None}
273
- except Exception as e:
274
- return {"success": False, "result": None, "error": str(e)}
275
-
276
-
277
- @mcp.tool(name="pdaf_get_fcst_info", description="Get forecast information including time steps and exit flag")
278
- def pdaf_get_fcst_info(steps: int = 0, time: float = 0.0, doexit: int = 0) -> dict:
279
- """
280
- Return the number of time steps, current model time, and exit flag.
281
-
282
- This is used when the flexible parallelization mode is used with
283
- PDAF3.assimilate. This is also relevant for legacy assimilation functions.
284
-
285
- Args:
286
- steps: Number of forecast time steps (input can be arbitrary)
287
- time: Current model time
288
- doexit: Whether to exit from forecasts
289
-
290
- Returns:
291
- dict: A dictionary containing:
292
- - steps: Number of forecast time steps for next assimilation
293
- - time: Current model time
294
- - doexit: Whether to exit from forecasts
295
- """
296
- try:
297
- steps_out, time_out, doexit_out = PDAF.get_fcst_info(steps, time, doexit)
298
- return {
299
- "success": True,
300
- "result": {
301
- "steps": int(steps_out),
302
- "time": float(time_out),
303
- "doexit": int(doexit_out)
304
- },
305
- "error": None
306
- }
307
- except Exception as e:
308
- return {"success": False, "result": None, "error": str(e)}
309
-
310
-
311
- @mcp.tool(name="pdaf_print_filter_types", description="Print available filter types in PDAF")
312
- def pdaf_print_filter_types(verbose: int = 1) -> dict:
313
- """
314
- Print all available filter types in PDAF to the console.
315
-
316
- Args:
317
- verbose: Verbosity flag. If 0, no output; if > 0, prints list to stdout.
318
-
319
- Returns:
320
- dict: A dictionary containing the success status.
321
- """
322
- try:
323
- PDAF.print_filter_types(verbose)
324
- return {"success": True, "result": "Filter types printed to console", "error": None}
325
- except Exception as e:
326
- return {"success": False, "result": None, "error": str(e)}
327
 
328
 
329
- @mcp.tool(name="pdaf_print_da_types", description="Print available DA method types in PDAF")
330
- def pdaf_print_da_types(verbose: int = 1) -> dict:
331
- """
332
- Print all available data assimilation method types in PDAF to the console.
333
-
334
- Args:
335
- verbose: Verbosity flag. If 0, no output; if > 0, prints list to stdout.
336
-
337
- Returns:
338
- dict: A dictionary containing the success status.
339
- """
340
- try:
341
- PDAF.print_da_types(verbose)
342
- return {"success": True, "result": "DA types printed to console", "error": None}
343
- except Exception as e:
344
- return {"success": False, "result": None, "error": str(e)}
345
 
346
 
347
- @mcp.tool(name="pdaf_print_info", description="Print PDAF configuration and status information")
348
- def pdaf_print_info(printtype: int) -> dict:
349
- """
350
- Print PDAF configuration and status information.
351
 
352
- Args:
353
- printtype: Type of information to print
354
- 1: Print filter type and settings
355
- 2: Print timing information
356
- 3: Print memory usage
357
 
358
- Returns:
359
- dict: A dictionary containing the success status.
360
- """
361
- try:
362
- PDAF.print_info(printtype)
363
- return {"success": True, "result": f"Info type {printtype} printed to console", "error": None}
364
- except Exception as e:
365
- return {"success": False, "result": None, "error": str(e)}
366
 
367
 
368
- @mcp.tool(name="pdaf_reset_forget", description="Reset the forgetting factor in PDAF")
369
- def pdaf_reset_forget(forget_in: float) -> dict:
 
370
  """
371
- Reset the forgetting factor used in ensemble filters.
372
-
373
- The forgetting factor is used for covariance inflation.
374
- A value less than 1.0 inflates the ensemble spread.
375
-
376
- For local ensemble Kalman filters, the forgetting factor can be set
377
- either globally (outside the loop over local domains) or differently
378
- for each local analysis domain (within the loop).
379
-
380
- Args:
381
- forget_in: New forgetting factor value
382
-
383
- Returns:
384
- dict: A dictionary containing the success status.
385
  """
386
- try:
387
- PDAF.reset_forget(forget_in)
388
- return {"success": True, "result": f"Forgetting factor reset to {forget_in}", "error": None}
389
- except Exception as e:
390
- return {"success": False, "result": None, "error": str(e)}
 
 
 
391
 
392
 
393
- @mcp.tool(name="pdaf_set_debug_flag", description="Activate or deactivate PDAF debug output")
394
- def pdaf_set_debug_flag(debugval: int) -> dict:
395
- """
396
- Activate or deactivate debug output for PDAF.
397
-
398
- When activated, debug information is sent to screen output.
399
- The output ends when the debug flag is set to 0.
400
-
401
- Args:
402
- debugval: Value for debugging flag (0 to disable, non-zero to enable)
403
-
404
- Returns:
405
- dict: A dictionary containing the success status.
406
- """
407
- try:
408
- PDAF.set_debug_flag(debugval)
409
- return {"success": True, "result": f"Debug flag set to {debugval}", "error": None}
410
- except Exception as e:
411
- return {"success": False, "result": None, "error": str(e)}
412
 
413
 
414
- @mcp.tool(name="pdaf_set_offline_mode", description="Set PDAF to offline mode")
415
- def pdaf_set_offline_mode(screen: int) -> dict:
416
  """
417
- Set PDAF to offline mode for offline data assimilation.
418
 
419
  Args:
420
- screen: Screen output level (0 for no output)
421
-
422
- Returns:
423
- dict: A dictionary containing the success status.
424
- """
425
- try:
426
- PDAF.set_offline_mode(screen)
427
- return {"success": True, "result": f"Offline mode set with screen={screen}", "error": None}
428
- except Exception as e:
429
- return {"success": False, "result": None, "error": str(e)}
430
-
431
-
432
- @mcp.tool(name="pdaf_get_assim_flag", description="Get the flag indicating if DA was performed in last time step")
433
- def pdaf_get_assim_flag() -> dict:
434
- """
435
- Return the flag that indicates if the DA is performed in the last time step.
436
- This only works for online DA systems.
437
 
438
  Returns:
439
- dict: A dictionary containing:
440
- - did_assim: 1 for assimilation performed, 0 otherwise
441
  """
442
- try:
443
- did_assim = PDAF.get_assim_flag()
444
- return {"success": True, "result": {"did_assim": int(did_assim)}, "error": None}
445
- except Exception as e:
446
- return {"success": False, "result": None, "error": str(e)}
447
-
448
-
449
- @mcp.tool(name="pdaf_get_localfilter", description="Check whether a local filter is used")
450
- def pdaf_get_localfilter() -> dict:
451
- """
452
- Return whether a local filter is used.
453
-
454
- Returns:
455
- dict: A dictionary containing:
456
- - lfilter: 1 for local filters (domain-localized), 0 for global filters
457
- """
458
- try:
459
- lfilter = PDAF.get_localfilter()
460
- return {"success": True, "result": {"lfilter": int(lfilter)}, "error": None}
461
- except Exception as e:
462
- return {"success": False, "result": None, "error": str(e)}
463
-
464
-
465
- @mcp.tool(name="pdaf_get_memberid", description="Get the ensemble member ID on the current process")
466
- def pdaf_get_memberid(memberid: int = 0) -> dict:
467
- """
468
- Return the ensemble member ID on the current process.
469
-
470
- This can be called during ensemble integration if ensemble-specific
471
- forcing is read. It can also be used in user-supplied functions.
472
-
473
- Args:
474
- memberid: Input member ID (can be any value)
475
 
476
- Returns:
477
- dict: A dictionary containing:
478
- - memberid: Index in the local ensemble
479
- """
480
- try:
481
- result = PDAF.get_memberid(memberid)
482
- return {"success": True, "result": {"memberid": int(result)}, "error": None}
483
- except Exception as e:
484
- return {"success": False, "result": None, "error": str(e)}
 
 
 
 
 
 
485
 
486
 
487
  # ============================================================================
488
- # PDAF Diagnostic Tools
489
  # ============================================================================
490
 
491
- @mcp.tool(name="pdaf_diag_ensmean", description="Compute ensemble mean of state vectors")
492
- def pdaf_diag_ensmean(dim: int, dim_ens: int, ens: List[List[float]]) -> dict:
493
- """
494
- Compute the ensemble mean of the state ensemble.
495
-
496
- Args:
497
- dim: State dimension
498
- dim_ens: Ensemble size
499
- ens: State ensemble (2D list of shape [dim, dim_ens])
500
-
501
- Returns:
502
- dict: A dictionary containing:
503
- - state: Ensemble mean (list of length dim)
504
- - status: Status flag (0=success)
505
- """
506
- try:
507
- ens_np = np.array(ens, dtype=np.float64)
508
- state = np.zeros(dim, dtype=np.float64)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
509
 
510
- state_out, status = PDAF.diag_ensmean(dim, dim_ens, state, ens_np)
511
- return {
512
- "success": True,
513
- "result": {
514
- "state": state_out.tolist(),
515
- "status": int(status)
516
- },
517
- "error": None
518
- }
519
- except Exception as e:
520
- return {"success": False, "result": None, "error": str(e)}
521
-
522
-
523
- @mcp.tool(name="pdaf_diag_effsample", description="Compute effective sample size for particle filter")
524
- def pdaf_diag_effsample(dim_sample: int, weights: List[float]) -> dict:
525
- """
526
- Compute effective ensemble size from particle filter weights.
527
-
528
- This is a diagnostic for particle filters that measures how many
529
- particles are effectively contributing to the estimate.
530
-
531
- Based on Doucet et al. (2001), it is defined as:
532
- N_eff = 1 / sum(w_i^2)
533
- where w_i is the weight of particle i.
534
-
535
- If N_eff = N, all weights are identical and the filter has no influence.
536
- If N_eff = 0, the filter is collapsed.
537
-
538
- Args:
539
- dim_sample: Sample size (number of particles)
540
- weights: Particle weights (list of length dim_sample)
541
-
542
- Returns:
543
- dict: A dictionary containing:
544
- - n_eff: Effective sample size
545
- """
546
- try:
547
- weights_np = np.array(weights, dtype=np.float64)
548
- n_eff = PDAF.diag_effsample(dim_sample, weights_np)
549
- return {"success": True, "result": {"n_eff": float(n_eff)}, "error": None}
550
- except Exception as e:
551
- return {"success": False, "result": None, "error": str(e)}
 
 
 
 
 
 
 
 
 
 
 
552
 
553
 
554
  # ============================================================================
555
- # PDAFomi Module Tools
556
  # ============================================================================
557
 
558
- @mcp.tool(name="pdafomi_init", description="Initialize PDAFomi with number of observation types")
559
- def pdafomi_init(n_obs: int) -> dict:
560
- """
561
- Allocate an array of obs_f derived type instances.
562
-
563
- This function initializes the number of observation types,
564
- which should be called at the start of the DA system after PDAF.init.
565
-
566
- Args:
567
- n_obs: Number of observation types
568
-
569
- Returns:
570
- dict: A dictionary containing the success status.
571
- """
572
- try:
573
- PDAFomi.init(n_obs)
574
- return {"success": True, "result": f"PDAFomi initialized with {n_obs} observation types", "error": None}
575
- except Exception as e:
576
- return {"success": False, "result": None, "error": str(e)}
577
-
578
-
579
- @mcp.tool(name="pdafomi_init_local", description="Initialize local observation types for local analysis")
580
- def pdafomi_init_local() -> dict:
581
- """
582
- Allocate an array of obs_l derived type instances for local analysis.
583
-
584
- This function initializes the number of observation types for each
585
- local analysis domain, which should be called at the start of the
586
- local analysis loop.
587
-
588
- Returns:
589
- dict: A dictionary containing the success status.
590
- """
591
- try:
592
- PDAFomi.init_local()
593
- return {"success": True, "result": "PDAFomi local initialized", "error": None}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
594
  except Exception as e:
595
- return {"success": False, "result": None, "error": str(e)}
596
-
597
-
598
- @mcp.tool(name="pdafomi_check_error", description="Check PDAFomi internal error flag")
599
- def pdafomi_check_error(flag: int = 0) -> dict:
600
- """
601
- Check the value of the PDAF-OMI internal error flag.
602
 
603
- Since PDAF-OMI executes internal routines in which errors could occur
604
- due to inconsistent configuration of observations, this function
605
- allows checking for such errors.
606
 
607
- Args:
608
- flag: Error flag input (can be any value)
609
 
610
- Returns:
611
- dict: A dictionary containing:
612
- - flag: Error flag value (0 = no error)
613
- """
614
- try:
615
- result = PDAFomi.check_error(flag)
616
- return {"success": True, "result": {"flag": int(result)}, "error": None}
617
- except Exception as e:
618
- return {"success": False, "result": None, "error": str(e)}
619
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
620
 
621
- @mcp.tool(name="pdafomi_set_debug_flag", description="Activate or deactivate PDAFomi debug output")
622
- def pdafomi_set_debug_flag(debugval: int) -> dict:
623
- """
624
- Activate or deactivate debug output for PDAFomi.
625
-
626
- Args:
627
- debugval: Value for debugging flag (0 to disable, non-zero to enable)
628
-
629
- Returns:
630
- dict: A dictionary containing the success status.
631
- """
632
- try:
633
- PDAFomi.set_debug_flag(debugval)
634
- return {"success": True, "result": f"PDAFomi debug flag set to {debugval}", "error": None}
635
- except Exception as e:
636
- return {"success": False, "result": None, "error": str(e)}
637
 
 
 
 
638
 
639
- @mcp.tool(name="pdafomi_set_doassim", description="Set whether to assimilate a given observation type")
640
- def pdafomi_set_doassim(i_obs: int, doassim: int) -> dict:
641
- """
642
- Set the doassim attribute for a given observation type.
643
-
644
- Args:
645
- i_obs: Index of observation type
646
- doassim: 0) do not assimilate; 1) assimilate the observation type
647
-
648
- Returns:
649
- dict: A dictionary containing the success status.
650
- """
651
- try:
652
- PDAFomi.set_doassim(i_obs, doassim)
653
- return {"success": True, "result": f"Observation type {i_obs} doassim set to {doassim}", "error": None}
654
- except Exception as e:
655
- return {"success": False, "result": None, "error": str(e)}
656
-
657
-
658
- @mcp.tool(name="pdafomi_set_disttype", description="Set distance calculation method for observation localization")
659
- def pdafomi_set_disttype(i_obs: int, disttype: int) -> dict:
660
- """
661
- Set the observation localization distance calculation method.
662
-
663
- Args:
664
- i_obs: Index of observation type
665
- disttype: Type of distance calculation:
666
- 0) Cartesian (any units)
667
- 1) Cartesian periodic (any units)
668
- 2) Geographic distance in metres (lat/lon in radians)
669
- 3) Haversine formula for distance on sphere
670
- 10) 3D Cartesian with separate horizontal/vertical
671
- 11) 3D Cartesian periodic with separate horizontal/vertical
672
- 12) Geographic horizontal + user vertical
673
- 13) Haversine horizontal + user vertical
674
-
675
- Returns:
676
- dict: A dictionary containing the success status.
677
- """
678
- try:
679
- PDAFomi.set_disttype(i_obs, disttype)
680
- return {"success": True, "result": f"Observation type {i_obs} disttype set to {disttype}", "error": None}
681
- except Exception as e:
682
- return {"success": False, "result": None, "error": str(e)}
683
-
684
-
685
- @mcp.tool(name="pdafomi_set_ncoord", description="Set number of spatial dimensions for observations")
686
- def pdafomi_set_ncoord(i_obs: int, ncoord: int) -> dict:
687
- """
688
- Set the number of spatial dimensions of observations.
689
-
690
- Args:
691
- i_obs: Index of observation type
692
- ncoord: Dimension of the observation coordinate (e.g., 2 for 2D)
693
-
694
- Returns:
695
- dict: A dictionary containing the success status.
696
- """
697
- try:
698
- PDAFomi.set_ncoord(i_obs, ncoord)
699
- return {"success": True, "result": f"Observation type {i_obs} ncoord set to {ncoord}", "error": None}
700
- except Exception as e:
701
- return {"success": False, "result": None, "error": str(e)}
702
-
703
-
704
- @mcp.tool(name="pdafomi_diag_nobstypes", description="Get number of active observation types")
705
- def pdafomi_diag_nobstypes(nobs: int = 0) -> dict:
706
- """
707
- Get the number of observation types that are active in an assimilation run.
708
-
709
- Args:
710
- nobs: Number of observation types (input can be arbitrary)
711
-
712
- Returns:
713
- dict: A dictionary containing:
714
- - nobs: Number of active observation types
715
- """
716
- try:
717
- result = PDAFomi.diag_nobstypes(nobs)
718
- return {"success": True, "result": {"nobs": int(result)}, "error": None}
719
- except Exception as e:
720
- return {"success": False, "result": None, "error": str(e)}
721
-
722
-
723
- @mcp.tool(name="pdafomi_diag_dimobs", description="Get observation dimensions for each observation type")
724
- def pdafomi_diag_dimobs() -> dict:
725
- """
726
- Get observation dimension for each observation type.
727
-
728
- Returns:
729
- dict: A dictionary containing:
730
- - dim_obs: Observation dimension for each type (list)
731
- """
732
- try:
733
- result = PDAFomi.diag_dimobs()
734
- return {"success": True, "result": {"dim_obs": result.tolist()}, "error": None}
735
- except Exception as e:
736
- return {"success": False, "result": None, "error": str(e)}
737
-
738
-
739
- # ============================================================================
740
- # PDAFlocal Module Tools
741
- # ============================================================================
742
-
743
- @mcp.tool(name="pdaflocal_set_indices", description="Set index mapping from local to global state vector")
744
- def pdaflocal_set_indices(dim_l: int, map_indices: List[int]) -> dict:
745
- """
746
- Set index vector to map local state vector to global state vectors.
747
-
748
- This is called in the user-supplied function py__init_dim_l_pdaf.
749
- Each element of map is an index of the global state vector (1-based).
750
-
751
- E.g., map[0] = 2 means that the first element of local state vector
752
- is the 2nd element of the global state vector.
753
-
754
- Args:
755
- dim_l: Dimension of local state vector
756
- map_indices: Index array for mapping between local and global state vector
757
-
758
- Returns:
759
- dict: A dictionary containing the success status.
760
- """
761
- try:
762
- map_np = np.array(map_indices, dtype=np.int32)
763
- PDAFlocal.set_indices(dim_l, map_np)
764
- return {"success": True, "result": f"Local indices set for dim_l={dim_l}", "error": None}
765
- except Exception as e:
766
- return {"success": False, "result": None, "error": str(e)}
767
-
768
-
769
- @mcp.tool(name="pdaflocal_set_increment_weights", description="Set local increment weights for vertical localization")
770
- def pdaflocal_set_increment_weights(dim_l: int, weights: List[float]) -> dict:
771
- """
772
- Initialize a PDAF-internal local array of increment weights.
773
-
774
- The weights are applied where the local state vector is weighted.
775
- These can be used to apply vertical localization or implement
776
- weakly-coupled assimilation.
777
-
778
- Args:
779
- dim_l: Dimension of local state vector
780
- weights: Weights array (list of length dim_l)
781
-
782
- Returns:
783
- dict: A dictionary containing the success status.
784
- """
785
- try:
786
- weights_np = np.array(weights, dtype=np.float64)
787
- PDAFlocal.set_increment_weights(dim_l, weights_np)
788
- return {"success": True, "result": f"Increment weights set for dim_l={dim_l}", "error": None}
789
- except Exception as e:
790
- return {"success": False, "result": None, "error": str(e)}
791
-
792
-
793
- @mcp.tool(name="pdaflocal_clear_increment_weights", description="Deallocate local increment weight vector")
794
- def pdaflocal_clear_increment_weights() -> dict:
795
- """
796
- Deallocate the local increment weight vector set by set_increment_weights.
797
-
798
- Returns:
799
- dict: A dictionary containing the success status.
800
- """
801
- try:
802
- PDAFlocal.clear_increment_weights()
803
- return {"success": True, "result": "Increment weights cleared", "error": None}
804
- except Exception as e:
805
- return {"success": False, "result": None, "error": str(e)}
806
-
807
-
808
- # ============================================================================
809
- # Additional PDAF Diagnostic Tools
810
- # ============================================================================
811
-
812
- @mcp.tool(name="pdaf_diag_stddev_nompi", description="Compute ensemble standard deviation without MPI")
813
- def pdaf_diag_stddev_nompi(
814
- dim: int,
815
- dim_ens: int,
816
- ens: List[List[float]],
817
- do_mean: int
818
- ) -> dict:
819
- """
820
- Compute ensemble standard deviation and ensemble mean without MPI.
821
-
822
- Args:
823
- dim: State dimension
824
- dim_ens: Ensemble size
825
- ens: State ensemble (2D list of shape [dim, dim_ens])
826
- do_mean: Whether to compute ensemble mean (1=yes, 0=no)
827
-
828
- Returns:
829
- dict: A dictionary containing:
830
- - state: State vector (ensemble mean if do_mean=1)
831
- - stddev: Standard deviation of ensemble
832
- - status: Status flag (0=success)
833
- """
834
- try:
835
- ens_np = np.array(ens, dtype=np.float64)
836
- state = np.zeros(dim, dtype=np.float64)
837
-
838
- state_out, stddev, status = PDAF.diag_stddev_nompi(dim, dim_ens, state, ens_np, do_mean)
839
- return {
840
- "success": True,
841
- "result": {
842
- "state": state_out.tolist(),
843
- "stddev": float(stddev),
844
- "status": int(status)
845
- },
846
- "error": None
847
- }
848
- except Exception as e:
849
- return {"success": False, "result": None, "error": str(e)}
850
-
851
-
852
- @mcp.tool(name="pdaf_diag_variance_nompi", description="Compute ensemble variance without MPI")
853
- def pdaf_diag_variance_nompi(
854
- dim: int,
855
- dim_ens: int,
856
- ens: List[List[float]],
857
- do_mean: int,
858
- do_stddev: int
859
- ) -> dict:
860
- """
861
- Compute ensemble variance/standard deviation and mean without MPI.
862
-
863
- Args:
864
- dim: State dimension
865
- dim_ens: Ensemble size
866
- ens: State ensemble (2D list of shape [dim, dim_ens])
867
- do_mean: Whether to compute ensemble mean (1=yes, 0=no)
868
- do_stddev: Whether to compute the ensemble mean standard deviation (1=yes, 0=no)
869
-
870
- Returns:
871
- dict: A dictionary containing:
872
- - state: State vector (ensemble mean if do_mean=1)
873
- - variance: Variance state vector
874
- - stddev: Standard deviation of ensemble
875
- - status: Status flag (0=success)
876
- """
877
- try:
878
- ens_np = np.array(ens, dtype=np.float64)
879
- state = np.zeros(dim, dtype=np.float64)
880
-
881
- state_out, variance, stddev, status = PDAF.diag_variance_nompi(
882
- dim, dim_ens, state, ens_np, do_mean, do_stddev
883
- )
884
- return {
885
- "success": True,
886
- "result": {
887
- "state": state_out.tolist(),
888
- "variance": variance.tolist(),
889
- "stddev": float(stddev),
890
- "status": int(status)
891
- },
892
- "error": None
893
- }
894
- except Exception as e:
895
- return {"success": False, "result": None, "error": str(e)}
896
-
897
-
898
- @mcp.tool(name="pdaf_diag_rmsd_nompi", description="Compute RMSD between two vectors without MPI")
899
- def pdaf_diag_rmsd_nompi(
900
- dim_p: int,
901
- statea_p: List[float],
902
- stateb_p: List[float]
903
- ) -> dict:
904
- """
905
- Compute the root mean squared distance between two vectors without MPI.
906
-
907
- Args:
908
- dim_p: State dimension
909
- statea_p: State vector A (list of length dim_p)
910
- stateb_p: State vector B (list of length dim_p)
911
-
912
- Returns:
913
- dict: A dictionary containing:
914
- - rmsd_p: Root mean squared distance
915
- - status: Status flag (0=success)
916
- """
917
- try:
918
- statea_np = np.array(statea_p, dtype=np.float64)
919
- stateb_np = np.array(stateb_p, dtype=np.float64)
920
-
921
- rmsd_p, status = PDAF.diag_rmsd_nompi(dim_p, statea_np, stateb_np)
922
- return {
923
- "success": True,
924
- "result": {
925
- "rmsd_p": float(rmsd_p),
926
- "status": int(status)
927
- },
928
- "error": None
929
- }
930
- except Exception as e:
931
- return {"success": False, "result": None, "error": str(e)}
932
-
933
-
934
- @mcp.tool(name="pdaf_diag_ensstats", description="Compute ensemble skewness and kurtosis")
935
- def pdaf_diag_ensstats(
936
- dim: int,
937
- dim_ens: int,
938
- element: int,
939
- ens: List[List[float]]
940
- ) -> dict:
941
- """
942
- Compute the skewness and kurtosis of the ensemble for a given state vector element.
943
-
944
- The definition used for kurtosis follows Lawson & Hansen (2004).
945
-
946
- Args:
947
- dim: PE-local state dimension
948
- dim_ens: Ensemble size
949
- element: ID of element to be used
950
- ens: State ensemble (2D list of shape [dim, dim_ens])
951
-
952
- Returns:
953
- dict: A dictionary containing:
954
- - skewness: Skewness of ensemble
955
- - kurtosis: Kurtosis of ensemble
956
- - status: Status flag (0=success)
957
- """
958
- try:
959
- ens_np = np.array(ens, dtype=np.float64)
960
- state = np.zeros(dim, dtype=np.float64)
961
-
962
- skewness, kurtosis, status = PDAF.diag_ensstats(dim, dim_ens, element, state, ens_np)
963
- return {
964
- "success": True,
965
- "result": {
966
- "skewness": float(skewness),
967
- "kurtosis": float(kurtosis),
968
- "status": int(status)
969
- },
970
- "error": None
971
- }
972
- except Exception as e:
973
- return {"success": False, "result": None, "error": str(e)}
974
-
975
-
976
- @mcp.tool(name="pdaf_sample_ens", description="Generate ensemble from EOF modes and singular values")
977
- def pdaf_sample_ens(
978
- dim: int,
979
- dim_ens: int,
980
- modes: List[List[float]],
981
- svals: List[float],
982
- state: List[float],
983
- verbose: int,
984
- flag: int = 0
985
- ) -> dict:
986
- """
987
- Generate an ensemble from singular values and their vectors (EOF modes).
988
-
989
- The singular values and vectors are derived from ensemble anomalies.
990
- This ensemble anomaly can be obtained from a time anomaly of a model
991
- trajectory using PDAF.eofcovar.
992
-
993
- Args:
994
- dim: Size of the state vector
995
- dim_ens: Ensemble size
996
- modes: Array of EOF modes/matrix of singular vectors (shape [dim, dim_ens-1])
997
- svals: Singular values (list of length dim_ens-1)
998
- state: PE-local model mean state (list of length dim)
999
- verbose: Verbosity flag
1000
- flag: Status flag input
1001
-
1002
- Returns:
1003
- dict: A dictionary containing:
1004
- - modes: Updated EOF modes
1005
- - state: Updated mean state
1006
- - ens: Generated state ensemble (shape [dim, dim_ens])
1007
- - flag: Status flag
1008
- """
1009
- try:
1010
- modes_np = np.array(modes, dtype=np.float64)
1011
- svals_np = np.array(svals, dtype=np.float64)
1012
- state_np = np.array(state, dtype=np.float64)
1013
-
1014
- modes_out, state_out, ens, flag_out = PDAF.sample_ens(
1015
- dim, dim_ens, modes_np, svals_np, state_np, verbose, flag
1016
- )
1017
- return {
1018
- "success": True,
1019
- "result": {
1020
- "modes": modes_out.tolist(),
1021
- "state": state_out.tolist(),
1022
- "ens": ens.tolist(),
1023
- "flag": int(flag_out)
1024
- },
1025
- "error": None
1026
- }
1027
- except Exception as e:
1028
- return {"success": False, "result": None, "error": str(e)}
1029
-
1030
-
1031
- @mcp.tool(name="pdaf_local_weight", description="Compute localization weight for a given distance")
1032
- def pdaf_local_weight(
1033
- wtype: int,
1034
- rtype: int,
1035
- cradius: float,
1036
- sradius: float,
1037
- distance: float,
1038
- nrows: int,
1039
- ncols: int,
1040
- a: List[List[float]],
1041
- var_obs: float,
1042
- verbose: int
1043
- ) -> dict:
1044
- """
1045
- Get localization weight for given distance, cut-off radius, support radius,
1046
- weighting type, and weighting function.
1047
-
1048
- Args:
1049
- wtype: Type of weight function:
1050
- 0: unit weight (weight=1 up to distance=cradius)
1051
- 1: exponential decrease (weight=1/e at distance=sradius)
1052
- 2: 5th order polynomial (Gaspari and Cohn 1999)
1053
- rtype: Type of regulated weighting:
1054
- !=1: no regulation
1055
- 1: regulated by variance of matrix A and observation variance
1056
- cradius: Cut-off radius where weight=0 beyond it
1057
- sradius: Support radius of localization function
1058
- distance: Distance to observation
1059
- nrows: Number of rows in matrix A
1060
- ncols: Number of columns in matrix A
1061
- a: Ensemble perturbation/anomaly matrix (shape [nrows, ncols])
1062
- var_obs: Observation variance
1063
- verbose: Verbosity flag
1064
-
1065
- Returns:
1066
- dict: A dictionary containing:
1067
- - weight: Localization weight
1068
- """
1069
- try:
1070
- a_np = np.array(a, dtype=np.float64)
1071
-
1072
- weight = PDAF.local_weight(
1073
- wtype, rtype, cradius, sradius, distance,
1074
- nrows, ncols, a_np, var_obs, verbose
1075
- )
1076
- return {"success": True, "result": {"weight": float(weight)}, "error": None}
1077
- except Exception as e:
1078
- return {"success": False, "result": None, "error": str(e)}
1079
-
1080
-
1081
- @mcp.tool(name="pdaf_get_local_type", description="Get the localization type of the selected filter")
1082
- def pdaf_get_local_type() -> dict:
1083
- """
1084
- Return the information on the localization type of the selected filter.
1085
-
1086
- Returns:
1087
- dict: A dictionary containing:
1088
- - localtype: Localization type
1089
- 0: no localization; global filter
1090
- 1: domain localization (LESTKF, LETKF, LNETF, LSEIK)
1091
- 2: covariance localization (LEnKF)
1092
- 3: covariance loc. but observation handling like domain localization (ENSRF)
1093
- """
1094
- try:
1095
- localtype = PDAF.get_local_type()
1096
- return {"success": True, "result": {"localtype": int(localtype)}, "error": None}
1097
- except Exception as e:
1098
- return {"success": False, "result": None, "error": str(e)}
1099
-
1100
-
1101
- @mcp.tool(name="pdaf_set_ens_pointer", description="Get a numpy array view of the internal ensemble array")
1102
- def pdaf_set_ens_pointer() -> dict:
1103
- """
1104
- Return the ensemble in a numpy array with the same memory address as
1105
- PDAF's internal ensemble array, allowing for manual ensemble modification.
1106
-
1107
- Returns:
1108
- dict: A dictionary containing:
1109
- - ens_shape: Shape of the ensemble array [dim, dim_ens]
1110
- - status: Status flag
1111
- """
1112
- try:
1113
- ens_ptr, status = PDAF.set_ens_pointer()
1114
- return {
1115
- "success": True,
1116
- "result": {
1117
- "ens_shape": list(ens_ptr.shape),
1118
- "ens": ens_ptr.tolist(),
1119
- "status": int(status)
1120
- },
1121
- "error": None
1122
- }
1123
- except Exception as e:
1124
- return {"success": False, "result": None, "error": str(e)}
1125
-
1126
-
1127
- # ============================================================================
1128
- # Additional PDAFomi Tools
1129
- # ============================================================================
1130
-
1131
- @mcp.tool(name="pdafomi_set_obs_err_type", description="Set observation error distribution type")
1132
- def pdafomi_set_obs_err_type(i_obs: int, obs_err_type: int) -> dict:
1133
- """
1134
- Set the type of observation error distribution for a given observation type.
1135
-
1136
- Args:
1137
- i_obs: Index of observation type
1138
- obs_err_type: Type of observation error distribution:
1139
- 0: Gaussian (default)
1140
- 1: double exponential (Laplacian)
1141
-
1142
- Returns:
1143
- dict: A dictionary containing the success status.
1144
- """
1145
- try:
1146
- PDAFomi.set_obs_err_type(i_obs, obs_err_type)
1147
- return {"success": True, "result": f"Observation type {i_obs} error type set to {obs_err_type}", "error": None}
1148
- except Exception as e:
1149
- return {"success": False, "result": None, "error": str(e)}
1150
-
1151
-
1152
- @mcp.tool(name="pdafomi_set_use_global_obs", description="Set whether to use global or process-local observations")
1153
- def pdafomi_set_use_global_obs(i_obs: int, use_global_obs: int) -> dict:
1154
- """
1155
- Set switch for using process-local or global observations.
1156
-
1157
- By default (use_global_obs=1), PDAF-OMI gathers the entire observation
1158
- vector for all processes. Setting use_global_obs=0 uses only process-local
1159
- observations, which can save computational cost.
1160
-
1161
- Args:
1162
- i_obs: Index of observation type
1163
- use_global_obs: 0: Using process-local observations
1164
- 1: Using cross-process observations (default)
1165
-
1166
- Returns:
1167
- dict: A dictionary containing the success status.
1168
- """
1169
- try:
1170
- PDAFomi.set_use_global_obs(i_obs, use_global_obs)
1171
- return {"success": True, "result": f"Observation type {i_obs} use_global_obs set to {use_global_obs}", "error": None}
1172
- except Exception as e:
1173
- return {"success": False, "result": None, "error": str(e)}
1174
-
1175
-
1176
- @mcp.tool(name="pdafomi_diag_obs_rmsd", description="Compute RMSD between observations and observed model state")
1177
- def pdafomi_diag_obs_rmsd(nobs: int, verbose: int) -> dict:
1178
- """
1179
- Compute root mean squared distance between observations and observed
1180
- model state for each observation type.
1181
-
1182
- Args:
1183
- nobs: Number of observation types
1184
- verbose: Verbosity flag
1185
-
1186
- Returns:
1187
- dict: A dictionary containing:
1188
- - nobs: Number of observation types
1189
- - rmsd: Vector of RMSD values for each observation type
1190
- """
1191
- try:
1192
- nobs_out, rmsd = PDAFomi.diag_obs_rmsd(nobs, verbose)
1193
- return {
1194
- "success": True,
1195
- "result": {
1196
- "nobs": int(nobs_out),
1197
- "rmsd": rmsd.tolist()
1198
- },
1199
- "error": None
1200
- }
1201
- except Exception as e:
1202
- return {"success": False, "result": None, "error": str(e)}
1203
-
1204
-
1205
- @mcp.tool(name="pdafomi_diag_stats", description="Compute statistics comparing observations and observed ensemble mean")
1206
- def pdafomi_diag_stats(nobs: int, verbose: int) -> dict:
1207
- """
1208
- Compute a selection of 6 statistics comparing observations and
1209
- observed ensemble mean for each observation type.
1210
-
1211
- Statistics include:
1212
- - (1,:) correlations between observation and observed ensemble mean
1213
- - (2,:) centered RMS difference
1214
- - (3,:) mean bias (observation minus observed ensemble mean)
1215
- - (4,:) mean absolute difference
1216
- - (5,:) variance of observations
1217
- - (6,:) variance of observed ensemble mean
1218
-
1219
- Args:
1220
- nobs: Number of observation types
1221
- verbose: Verbosity flag
1222
-
1223
- Returns:
1224
- dict: A dictionary containing:
1225
- - nobs: Number of observation types
1226
- - obsstats: Array of observation statistics (shape [6, nobs])
1227
- """
1228
- try:
1229
- nobs_out, obsstats = PDAFomi.diag_stats(nobs, verbose)
1230
- return {
1231
- "success": True,
1232
- "result": {
1233
- "nobs": int(nobs_out),
1234
- "obsstats": obsstats.tolist()
1235
- },
1236
- "error": None
1237
- }
1238
- except Exception as e:
1239
- return {"success": False, "result": None, "error": str(e)}
1240
-
1241
-
1242
- # ============================================================================
1243
- # Additional PDAF Setter Functions (不需要回调和MPI)
1244
- # ============================================================================
1245
-
1246
- @mcp.tool(name="pdaf_set_comm_pdaf", description="Set the MPI communicator used by PDAF")
1247
- def pdaf_set_comm_pdaf(in_comm_pdaf: int) -> dict:
1248
- """
1249
- Set the MPI communicator used by PDAF.
1250
-
1251
- By default, PDAF assumes it can use all available processes (MPI_COMM_WORLD).
1252
- By using this function, we limit the number of processes that can be used
1253
- by PDAF to the given MPI communicator.
1254
-
1255
- Args:
1256
- in_comm_pdaf: MPI communicator for PDAF (integer handle)
1257
-
1258
- Returns:
1259
- dict: A dictionary containing the success status.
1260
- """
1261
- try:
1262
- PDAF.set_comm_pdaf(in_comm_pdaf)
1263
- return {"success": True, "result": f"PDAF communicator set to {in_comm_pdaf}", "error": None}
1264
- except Exception as e:
1265
- return {"success": False, "result": None, "error": str(e)}
1266
-
1267
-
1268
- @mcp.tool(name="pdaf_set_iparam", description="Set integer parameters for PDAF")
1269
- def pdaf_set_iparam(idval: int, value: int, flag: int = 0) -> dict:
1270
- """
1271
- Set integer parameters for PDAF.
1272
-
1273
- This function provides an alternative way to set integer parameters
1274
- instead of providing all parameters in the call to PDAF.init.
1275
-
1276
- Args:
1277
- idval: Index of parameter
1278
- value: Parameter value
1279
- flag: Status flag input (default 0)
1280
-
1281
- Returns:
1282
- dict: A dictionary containing:
1283
- - flag: Status flag (0 for no error)
1284
- """
1285
- try:
1286
- result = PDAF.set_iparam(idval, value, flag)
1287
- return {"success": True, "result": {"flag": int(result)}, "error": None}
1288
- except Exception as e:
1289
- return {"success": False, "result": None, "error": str(e)}
1290
-
1291
-
1292
- @mcp.tool(name="pdaf_set_rparam", description="Set floating-point parameters for PDAF")
1293
- def pdaf_set_rparam(idval: int, value: float, flag: int = 0) -> dict:
1294
- """
1295
- Set floating-point parameters for PDAF.
1296
-
1297
- This function provides an alternative way to set real parameters
1298
- instead of providing all parameters in the call to PDAF.init.
1299
-
1300
- Args:
1301
- idval: Index of parameter
1302
- value: Parameter value (float)
1303
- flag: Status flag input (default 0)
1304
-
1305
- Returns:
1306
- dict: A dictionary containing:
1307
- - flag: Status flag (0 for no error)
1308
- """
1309
- try:
1310
- result = PDAF.set_rparam(idval, value, flag)
1311
- return {"success": True, "result": {"flag": int(result)}, "error": None}
1312
- except Exception as e:
1313
- return {"success": False, "result": None, "error": str(e)}
1314
-
1315
-
1316
- @mcp.tool(name="pdaf_set_memberid", description="Set the ensemble member index to a given value")
1317
- def pdaf_set_memberid(memberid: int) -> dict:
1318
- """
1319
- Set the ensemble member index to a given value.
1320
-
1321
- Args:
1322
- memberid: Index in the local ensemble
1323
-
1324
- Returns:
1325
- dict: A dictionary containing:
1326
- - memberid: The set member index
1327
- """
1328
- try:
1329
- result = PDAF.set_memberid(memberid)
1330
- return {"success": True, "result": {"memberid": int(result)}, "error": None}
1331
- except Exception as e:
1332
- return {"success": False, "result": None, "error": str(e)}
1333
-
1334
-
1335
- @mcp.tool(name="pdaf_set_seedset", description="Choose a seedset for the random number generator")
1336
- def pdaf_set_seedset(seedset_in: int) -> dict:
1337
- """
1338
- Choose a seedset for the random number generator used in PDAF.
1339
-
1340
- Args:
1341
- seedset_in: Seedset index (1-20)
1342
-
1343
- Returns:
1344
- dict: A dictionary containing the success status.
1345
- """
1346
- try:
1347
- PDAF.set_seedset(seedset_in)
1348
- return {"success": True, "result": f"Seedset set to {seedset_in}", "error": None}
1349
- except Exception as e:
1350
- return {"success": False, "result": None, "error": str(e)}
1351
-
1352
-
1353
- @mcp.tool(name="pdaf_get_obsmemberid", description="Get ensemble member ID when observation operator is applied")
1354
- def pdaf_get_obsmemberid(memberid: int = 0) -> dict:
1355
- """
1356
- Return the ensemble member ID when observation operator is being applied.
1357
-
1358
- This function is used specifically for user-supplied function py__obs_op_pdaf.
1359
-
1360
- Args:
1361
- memberid: Input member ID (can be any value)
1362
-
1363
- Returns:
1364
- dict: A dictionary containing:
1365
- - memberid: Index in the local ensemble
1366
- """
1367
- try:
1368
- result = PDAF.get_obsmemberid(memberid)
1369
- return {"success": True, "result": {"memberid": int(result)}, "error": None}
1370
- except Exception as e:
1371
- return {"success": False, "result": None, "error": str(e)}
1372
-
1373
-
1374
- @mcp.tool(name="pdaf_local_weights", description="Get a vector of localization weights for given distances")
1375
- def pdaf_local_weights(
1376
- wtype: int,
1377
- cradius: float,
1378
- sradius: float,
1379
- dim: int,
1380
- distance: List[float],
1381
- verbose: int
1382
- ) -> dict:
1383
- """
1384
- Get a vector of localization weights for given distances.
1385
-
1386
- This is a vectorized version of pdaf_local_weight without regulation.
1387
-
1388
- Args:
1389
- wtype: Type of weight function:
1390
- 0: unit weight (weight=1 up to distance=cradius)
1391
- 1: exponential decrease (weight=1/e at distance=sradius)
1392
- 2: 5th order polynomial (Gaspari and Cohn 1999)
1393
- cradius: Cut-off radius where weight=0 beyond it
1394
- sradius: Support radius of localization function
1395
- dim: Size of distance and weight arrays
1396
- distance: Array of distances to observations (list of length dim)
1397
- verbose: Verbosity flag
1398
-
1399
- Returns:
1400
- dict: A dictionary containing:
1401
- - weights: Array of localization weights (list of length dim)
1402
- """
1403
- try:
1404
- distance_np = np.array(distance, dtype=np.float64)
1405
- weights = PDAF.local_weights(wtype, cradius, sradius, dim, distance_np, verbose)
1406
- return {"success": True, "result": {"weights": weights.tolist()}, "error": None}
1407
- except Exception as e:
1408
- return {"success": False, "result": None, "error": str(e)}
1409
-
1410
-
1411
- @mcp.tool(name="pdaf_diag_crps_nompi", description="Compute Continuous Ranked Probability Score without MPI")
1412
- def pdaf_diag_crps_nompi(
1413
- dim: int,
1414
- dim_ens: int,
1415
- element: int,
1416
- oens: List[List[float]],
1417
- obs: List[float]
1418
- ) -> dict:
1419
- """
1420
- Obtain a Continuous Ranked Probability Score (CRPS) for an ensemble without MPI.
1421
-
1422
- Based on Hersbach (2000) decomposition of CRPS.
1423
-
1424
- Args:
1425
- dim: Dimension of state vector
1426
- dim_ens: Ensemble size
1427
- element: ID of element to be used (0 for mean over all elements)
1428
- oens: State ensemble (2D list of shape [dim, dim_ens])
1429
- obs: Observation/true state (list of length dim)
1430
-
1431
- Returns:
1432
- dict: A dictionary containing:
1433
- - CRPS: Continuous Ranked Probability Score
1434
- - reli: Reliability
1435
- - resol: Resolution
1436
- - uncert: Uncertainty
1437
- - status: Status flag (0=success)
1438
- """
1439
- try:
1440
- oens_np = np.array(oens, dtype=np.float64)
1441
- obs_np = np.array(obs, dtype=np.float64)
1442
-
1443
- crps, reli, resol, uncert, status = PDAF.diag_crps_nompi(
1444
- dim, dim_ens, element, oens_np, obs_np
1445
- )
1446
- return {
1447
- "success": True,
1448
- "result": {
1449
- "CRPS": float(crps),
1450
- "reli": float(reli),
1451
- "resol": float(resol),
1452
- "uncert": float(uncert),
1453
- "status": int(status)
1454
- },
1455
- "error": None
1456
- }
1457
- except Exception as e:
1458
- return {"success": False, "result": None, "error": str(e)}
1459
-
1460
-
1461
- @mcp.tool(name="pdaf_diag_compute_moments", description="Compute statistical moments from an ensemble")
1462
- def pdaf_diag_compute_moments(
1463
- dim_p: int,
1464
- dim_ens: int,
1465
- ens: List[List[float]],
1466
- kmax: int,
1467
- bias: int
1468
- ) -> dict:
1469
- """
1470
- Compute the mean, variance, skewness, and excess kurtosis from an ensemble.
1471
-
1472
- Args:
1473
- dim_p: PE-local state dimension
1474
- dim_ens: Ensemble size
1475
- ens: State ensemble (2D list of shape [dim_p, dim_ens])
1476
- kmax: Maximum moment to compute (1=mean, 2=variance, 3=skewness, 4=kurtosis)
1477
- bias: 0 for unbiased estimates, 1 for biased estimates
1478
-
1479
- Returns:
1480
- dict: A dictionary containing:
1481
- - moments: Array of moments (shape [kmax, dim_p])
1482
- """
1483
- try:
1484
- ens_np = np.array(ens, dtype=np.float64)
1485
- moments = PDAF.diag_compute_moments(dim_p, dim_ens, ens_np, kmax, bias)
1486
- return {"success": True, "result": {"moments": moments.tolist()}, "error": None}
1487
- except Exception as e:
1488
- return {"success": False, "result": None, "error": str(e)}
1489
-
1490
-
1491
- # ============================================================================
1492
- # Additional PDAFomi Setter Functions
1493
- # ============================================================================
1494
-
1495
- @mcp.tool(name="pdafomi_set_inno_omit", description="Set innovation threshold for removing observation outliers")
1496
- def pdafomi_set_inno_omit(i_obs: int, inno_omit: float) -> dict:
1497
- """
1498
- Set innovation threshold for removing observation outliers.
1499
-
1500
- By default, no observations are omitted. Observation omission is only
1501
- activated when inno_omit > 0.0. PDAF will omit observations where
1502
- the squared innovation of the ensemble mean is larger than the product
1503
- of inno_omit and observation error variance.
1504
-
1505
- Args:
1506
- i_obs: Index of observation type
1507
- inno_omit: Threshold of innovation to be omitted
1508
-
1509
- Returns:
1510
- dict: A dictionary containing the success status.
1511
- """
1512
- try:
1513
- PDAFomi.set_inno_omit(i_obs, inno_omit)
1514
- return {"success": True, "result": f"Observation type {i_obs} inno_omit set to {inno_omit}", "error": None}
1515
- except Exception as e:
1516
- return {"success": False, "result": None, "error": str(e)}
1517
-
1518
-
1519
- @mcp.tool(name="pdafomi_set_inno_omit_ivar", description="Set inverse variance for omitted observations")
1520
- def pdafomi_set_inno_omit_ivar(i_obs: int, inno_omit_ivar: float) -> dict:
1521
- """
1522
- Set the inverse of observation error variance for omitted observations.
1523
-
1524
- This should be set to a very small value relative to assimilated observations.
1525
- By default, it is set to 1e-12.
1526
-
1527
- Args:
1528
- i_obs: Index of observation type
1529
- inno_omit_ivar: Inverse of observation variance for omitted observations
1530
-
1531
- Returns:
1532
- dict: A dictionary containing the success status.
1533
- """
1534
- try:
1535
- PDAFomi.set_inno_omit_ivar(i_obs, inno_omit_ivar)
1536
- return {"success": True, "result": f"Observation type {i_obs} inno_omit_ivar set to {inno_omit_ivar}", "error": None}
1537
- except Exception as e:
1538
- return {"success": False, "result": None, "error": str(e)}
1539
-
1540
-
1541
- @mcp.tool(name="pdafomi_set_domainsize", description="Set domain size for observation type")
1542
- def pdafomi_set_domainsize(i_obs: int, domainsize: List[float]) -> dict:
1543
- """
1544
- Set the domain size for periodic boundary conditions.
1545
-
1546
- This is used when disttype is set to periodic distance calculation.
1547
-
1548
- Args:
1549
- i_obs: Index of observation type
1550
- domainsize: Domain size array (list of floats)
1551
-
1552
- Returns:
1553
- dict: A dictionary containing the success status.
1554
- """
1555
- try:
1556
- domainsize_np = np.array(domainsize, dtype=np.float64)
1557
- PDAFomi.set_domainsize(i_obs, domainsize_np)
1558
- return {"success": True, "result": f"Observation type {i_obs} domainsize set", "error": None}
1559
- except Exception as e:
1560
- return {"success": False, "result": None, "error": str(e)}
1561
-
1562
-
1563
- @mcp.tool(name="pdafomi_set_name", description="Set the name of an observation type")
1564
- def pdafomi_set_name(i_obs: int, name: str) -> dict:
1565
- """
1566
- Set the name identifier for an observation type.
1567
-
1568
- This name is used in diagnostic output to identify the observation type.
1569
-
1570
- Args:
1571
- i_obs: Index of observation type
1572
- name: Name string for the observation type
1573
-
1574
- Returns:
1575
- dict: A dictionary containing the success status.
1576
- """
1577
- try:
1578
- PDAFomi.set_name(i_obs, name)
1579
- return {"success": True, "result": f"Observation type {i_obs} name set to '{name}'", "error": None}
1580
- except Exception as e:
1581
- return {"success": False, "result": None, "error": str(e)}
1582
-
1583
-
1584
- @mcp.tool(name="pdafomi_diag_get_obs", description="Get observation vector and coordinates for an observation type")
1585
- def pdafomi_diag_get_obs(id_obs: int) -> dict:
1586
- """
1587
- Get observation vector and corresponding coordinates for specified observation type.
1588
-
1589
- Args:
1590
- id_obs: Index of observation type to return
1591
-
1592
- Returns:
1593
- dict: A dictionary containing:
1594
- - dim_obs_diag: Observation dimension
1595
- - ncoord: Number of observation dimensions
1596
- - obs_p: Observation vector
1597
- - ocoord_p: Coordinate array
1598
- """
1599
- try:
1600
- dim_obs_diag, ncoord, obs_p, ocoord_p = PDAFomi.diag_get_obs(id_obs)
1601
- return {
1602
- "success": True,
1603
- "result": {
1604
- "dim_obs_diag": int(dim_obs_diag),
1605
- "ncoord": int(ncoord),
1606
- "obs_p": obs_p.tolist(),
1607
- "ocoord_p": ocoord_p.tolist()
1608
- },
1609
- "error": None
1610
- }
1611
- except Exception as e:
1612
- return {"success": False, "result": None, "error": str(e)}
1613
-
1614
-
1615
- @mcp.tool(name="pdafomi_diag_get_hxmean", description="Get observed ensemble mean for an observation type")
1616
- def pdafomi_diag_get_hxmean(id_obs: int) -> dict:
1617
- """
1618
- Get observed ensemble mean for a given observation type.
1619
-
1620
- Args:
1621
- id_obs: Index of observation type to return
1622
-
1623
- Returns:
1624
- dict: A dictionary containing:
1625
- - dim_obs_diag: Observation dimension
1626
- - hxmean_p: Observed ensemble mean
1627
- """
1628
- try:
1629
- dim_obs_diag, hxmean_p = PDAFomi.diag_get_hxmean(id_obs)
1630
- return {
1631
- "success": True,
1632
- "result": {
1633
- "dim_obs_diag": int(dim_obs_diag),
1634
- "hxmean_p": hxmean_p.tolist()
1635
- },
1636
- "error": None
1637
- }
1638
- except Exception as e:
1639
- return {"success": False, "result": None, "error": str(e)}
1640
-
1641
-
1642
- @mcp.tool(name="pdafomi_diag_get_ivar", description="Get inverse observation error variance for an observation type")
1643
- def pdafomi_diag_get_ivar(id_obs: int) -> dict:
1644
- """
1645
- Get inverse of observation error variance for a given observation type.
1646
-
1647
- Args:
1648
- id_obs: Index of observation type to return
1649
-
1650
- Returns:
1651
- dict: A dictionary containing:
1652
- - dim_obs_diag: Observation dimension
1653
- - ivar: Inverse observation error variances
1654
- """
1655
- try:
1656
- dim_obs_diag, ivar = PDAFomi.diag_get_ivar(id_obs)
1657
- return {
1658
- "success": True,
1659
- "result": {
1660
- "dim_obs_diag": int(dim_obs_diag),
1661
- "ivar": ivar.tolist()
1662
- },
1663
- "error": None
1664
- }
1665
- except Exception as e:
1666
- return {"success": False, "result": None, "error": str(e)}
1667
-
1668
-
1669
- # ============================================================================
1670
- # Utility Functions
1671
- # ============================================================================
1672
-
1673
- @mcp.tool(name="get_pdaf_module_info", description="Get information about available pyPDAF modules and their functions")
1674
- def get_pdaf_module_info() -> dict:
1675
- """
1676
- Get information about available pyPDAF modules and their main functions.
1677
-
1678
- Returns:
1679
- dict: A dictionary containing module information.
1680
- """
1681
- try:
1682
- info = {
1683
- "PDAF": {
1684
- "description": "Core PDAF module with filter functions and utilities",
1685
- "main_functions": [
1686
- "correlation_function", "deallocate", "eofcovar", "force_analysis",
1687
- "get_fcst_info", "print_filter_types", "print_da_types", "print_info",
1688
- "reset_forget", "set_debug_flag", "set_offline_mode", "sample_ens",
1689
- "local_weight", "local_weights", "set_ens_pointer",
1690
- "get_assim_flag", "get_localfilter", "get_local_type", "get_memberid",
1691
- "diag_ensmean", "diag_stddev_nompi", "diag_stddev",
1692
- "diag_variance_nompi", "diag_variance",
1693
- "diag_rmsd_nompi", "diag_rmsd",
1694
- "diag_effsample", "diag_ensstats", "diag_compute_moments"
1695
- ]
1696
- },
1697
- "PDAF3": {
1698
- "description": "PDAF3 module with initialization and assimilation functions",
1699
- "main_functions": [
1700
- "init", "init_forecast", "set_parallel",
1701
- "assimilate", "assim_offline",
1702
- "assimilate_3dvar_all", "assim_offline_3dvar_all",
1703
- "assimilate_local_nondiagr", "assimilate_global_nondiagr",
1704
- "generate_obs", "generate_obs_offline"
1705
- ]
1706
- },
1707
- "PDAFomi": {
1708
- "description": "PDAF Observation Module Interface for flexible observation handling",
1709
- "main_functions": [
1710
- "init", "init_local", "check_error", "gather_obs",
1711
- "set_debug_flag", "set_doassim", "set_disttype", "set_ncoord",
1712
- "set_obs_err_type", "set_use_global_obs",
1713
- "set_inno_omit", "set_inno_omit_ivar",
1714
- "diag_nobstypes", "diag_dimobs", "diag_obs_rmsd", "diag_stats",
1715
- "diag_get_hx", "diag_get_hxmean", "diag_get_obs"
1716
- ]
1717
- },
1718
- "PDAFlocal": {
1719
- "description": "PDAF local analysis module for domain localization",
1720
- "main_functions": [
1721
- "set_indices", "set_increment_weights", "clear_increment_weights"
1722
- ]
1723
- },
1724
- "PDAFlocalomi": {
1725
- "description": "PDAF local analysis with OMI observation handling",
1726
- "main_functions": []
1727
- }
1728
- }
1729
- return {"success": True, "result": info, "error": None}
1730
- except Exception as e:
1731
- return {"success": False, "result": None, "error": str(e)}
1732
-
1733
-
1734
- def create_app() -> FastMCP:
1735
- """
1736
- Creates and returns the FastMCP application instance.
1737
-
1738
- Returns:
1739
- FastMCP: The FastMCP application instance.
1740
- """
1741
- return mcp
 
1
  """
2
+ MCP Server for Ocean Data Assimilation using pyPDAF EnOI
3
 
4
+ This module provides two MCP tools:
5
+ 1. generate_demo_ocean_data: Generate synthetic ocean data (restart, ensemble, observations)
6
+ 2. run_enoi_pipeline: Run EnOI data assimilation with optional localization
7
  """
8
 
 
9
  import sys
10
+ import os
11
+ # Add source path to import pyPDAF
12
+ sys.path.insert(0, os.path.join(os.path.dirname(__file__), '../../source/src'))
13
 
14
  import numpy as np
15
+ import netCDF4 as nc
 
 
 
16
  from fastmcp import FastMCP
17
+ from typing import Dict, Any, Tuple
18
+ import glob
19
 
20
+ try:
21
+ import pyPDAF
22
+ import pyPDAF.PDAF as PDAF
23
+ import pyPDAF.PDAFomi as PDAFomi
24
+ PYPDAF_AVAILABLE = True
25
+ except ImportError:
26
+ PYPDAF_AVAILABLE = False
27
+ print("Warning: pyPDAF not available, run_enoi_pipeline will not work")
28
+
29
+ # Initialize FastMCP server
30
+ mcp = FastMCP("pyPDAF MCP Server")
31
 
32
 
33
  # ============================================================================
34
+ # Helper Functions
35
  # ============================================================================
36
 
37
+ def _write_model_nc(filepath: str, lon: np.ndarray, lat: np.ndarray, depth: np.ndarray,
38
+ temp: np.ndarray, salt: np.ndarray, u: np.ndarray, v: np.ndarray, eta_t: np.ndarray):
39
+ """Write ocean model data to NetCDF file."""
40
+ with nc.Dataset(filepath, 'w') as ds:
41
+ # Create dimensions
42
+ ds.createDimension('lon', len(lon))
43
+ ds.createDimension('lat', len(lat))
44
+ ds.createDimension('depth', len(depth))
45
+ ds.createDimension('time', None)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
46
 
47
+ # Create coordinate variables
48
+ lon_var = ds.createVariable('lon', 'f4', ('lon',))
49
+ lon_var[:] = lon
50
+ lon_var.units = 'degrees_east'
51
 
52
+ lat_var = ds.createVariable('lat', 'f4', ('lat',))
53
+ lat_var[:] = lat
54
+ lat_var.units = 'degrees_north'
55
+
56
+ depth_var = ds.createVariable('depth', 'f4', ('depth',))
57
+ depth_var[:] = depth
58
+ depth_var.units = 'meters'
59
+
60
+ time_var = ds.createVariable('time', 'f4', ('time',))
61
+ time_var.units = 'days since 2000-01-01'
62
+ time_var[:] = [0]
63
+
64
+ # Create data variables
65
+ temp_var = ds.createVariable('temp', 'f4', ('time', 'depth', 'lat', 'lon'))
66
+ temp_var[:] = temp[np.newaxis, :, :, :]
67
+ temp_var.long_name = 'Temperature'
68
+ temp_var.units = 'degC'
69
+
70
+ salt_var = ds.createVariable('salt', 'f4', ('time', 'depth', 'lat', 'lon'))
71
+ salt_var[:] = salt[np.newaxis, :, :, :]
72
+ salt_var.long_name = 'Salinity'
73
+ salt_var.units = 'psu'
74
+
75
+ u_var = ds.createVariable('u', 'f4', ('time', 'depth', 'lat', 'lon'))
76
+ u_var[:] = u[np.newaxis, :, :, :]
77
+ u_var.long_name = 'Zonal velocity'
78
+ u_var.units = 'm/s'
79
+
80
+ v_var = ds.createVariable('v', 'f4', ('time', 'depth', 'lat', 'lon'))
81
+ v_var[:] = v[np.newaxis, :, :, :]
82
+ v_var.long_name = 'Meridional velocity'
83
+ v_var.units = 'm/s'
84
+
85
+ eta_var = ds.createVariable('eta_t', 'f4', ('time', 'lat', 'lon'))
86
+ eta_var[:] = eta_t[np.newaxis, :, :]
87
+ eta_var.long_name = 'Sea surface height'
88
+ eta_var.units = 'meters'
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
89
 
90
 
91
+ def _flatten_state(temp: np.ndarray, salt: np.ndarray, u: np.ndarray, v: np.ndarray, eta_t: np.ndarray) -> np.ndarray:
92
+ """Flatten 3D/2D ocean fields into 1D state vector."""
93
+ return np.concatenate([
94
+ temp.ravel(),
95
+ salt.ravel(),
96
+ u.ravel(),
97
+ v.ravel(),
98
+ eta_t.ravel()
99
+ ])
 
 
 
 
 
 
 
100
 
101
 
102
+ def _unflatten_state(state: np.ndarray, nz: int, ny: int, nx: int) -> Tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
103
+ """Unflatten 1D state vector back to 3D/2D ocean fields."""
104
+ n_3d = nz * ny * nx
105
+ n_2d = ny * nx
106
 
107
+ temp = state[0:n_3d].reshape((nz, ny, nx))
108
+ salt = state[n_3d:2*n_3d].reshape((nz, ny, nx))
109
+ u = state[2*n_3d:3*n_3d].reshape((nz, ny, nx))
110
+ v = state[3*n_3d:4*n_3d].reshape((nz, ny, nx))
111
+ eta_t = state[4*n_3d:4*n_3d+n_2d].reshape((ny, nx))
112
 
113
+ return temp, salt, u, v, eta_t
 
 
 
 
 
 
 
114
 
115
 
116
+ def _state_indices_for_temp(obs_lon: np.ndarray, obs_lat: np.ndarray,
117
+ lon: np.ndarray, lat: np.ndarray,
118
+ nz: int, ny: int, nx: int) -> np.ndarray:
119
  """
120
+ Map observation lon/lat to state vector indices for temperature at k=0 layer.
121
+ Uses nearest-neighbor approach.
 
 
 
 
 
 
 
 
 
 
 
 
122
  """
123
+ indices = []
124
+ for olon, olat in zip(obs_lon, obs_lat):
125
+ i = np.argmin(np.abs(lon - olon))
126
+ j = np.argmin(np.abs(lat - olat))
127
+ k = 0
128
+ idx = k * ny * nx + j * nx + i
129
+ indices.append(idx)
130
+ return np.array(indices)
131
 
132
 
133
+ def _read_state_nc(filepath: str) -> Tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
134
+ """Read ocean state from NetCDF file."""
135
+ with nc.Dataset(filepath, 'r') as ds:
136
+ lon = ds.variables['lon'][:]
137
+ lat = ds.variables['lat'][:]
138
+ depth = ds.variables['depth'][:]
139
+ temp = ds.variables['temp'][0, :, :, :]
140
+ salt = ds.variables['salt'][0, :, :, :]
141
+ u = ds.variables['u'][0, :, :, :]
142
+ v = ds.variables['v'][0, :, :, :]
143
+ eta_t = ds.variables['eta_t'][0, :, :]
144
+ return lon, lat, depth, temp, salt, u, v, eta_t
 
 
 
 
 
 
 
145
 
146
 
147
+ def _gaspari_cohn(r: float, c: float) -> float:
 
148
  """
149
+ Gaspari-Cohn correlation function for localization.
150
 
151
  Args:
152
+ r: Distance between two points
153
+ c: Cutoff radius (localization radius)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
154
 
155
  Returns:
156
+ Correlation value between 0 and 1
 
157
  """
158
+ z = r / c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
159
 
160
+ if z >= 2:
161
+ return 0.0
162
+ elif z >= 1:
163
+ term1 = z**5 / 12.0
164
+ term2 = z**4 / 2.0
165
+ term3 = 5.0 * z**3 / 8.0
166
+ term4 = 5.0 * z**2 / 3.0
167
+ result = -term1 + term2 + term3 - term4 + 5.0 * z - 4.0 + 2.0 / (3.0 * z)
168
+ return result
169
+ else: # z < 1
170
+ term1 = z**5 / 4.0
171
+ term2 = z**4 / 2.0
172
+ term3 = 5.0 * z**3 / 8.0
173
+ result = -term1 + term2 + term3 + 1.0
174
+ return result
175
 
176
 
177
  # ============================================================================
178
+ # MCP Tool 1: Generate Demo Ocean Data
179
  # ============================================================================
180
 
181
+ @mcp.tool(
182
+ name="generate_demo_ocean_data",
183
+ description="Generate synthetic ocean data for EnOI testing. Creates restart.nc (background state), ensemble members, and temperature observations at surface."
184
+ )
185
+ def generate_demo_ocean_data(
186
+ nx: int = 20,
187
+ ny: int = 20,
188
+ nz: int = 5,
189
+ n_ensemble: int = 10,
190
+ n_obs: int = 50,
191
+ lon_min: float = 0.0,
192
+ lon_max: float = 20.0,
193
+ lat_min: float = 0.0,
194
+ lat_max: float = 20.0,
195
+ depth_max: float = 100.0,
196
+ restart_path: str = "restart.nc",
197
+ ensemble_dir: str = "ensemble_data",
198
+ obs_path: str = "obs.nc",
199
+ ensemble_noise_std: float = 0.5,
200
+ obs_noise_std: float = 0.3,
201
+ seed: int = 42
202
+ ) -> Dict[str, Any]:
203
+ """
204
+ Generate synthetic ocean data for EnOI testing.
205
+
206
+ Creates three types of files:
207
+ 1. Background state (restart.nc): Initial ocean state
208
+ 2. Static ensemble (ensemble_data/member_*.nc): Ensemble members for error covariance
209
+ 3. Observations (obs.nc): Temperature observations at surface (k=0)
210
+
211
+ Args:
212
+ nx, ny, nz: Grid dimensions (longitude, latitude, depth)
213
+ n_ensemble: Number of ensemble members
214
+ n_obs: Number of observations
215
+ lon_min, lon_max, lat_min, lat_max, depth_max: Coordinate ranges
216
+ restart_path: Path to background state file
217
+ ensemble_dir: Directory for ensemble members
218
+ obs_path: Path to observation file
219
+ ensemble_noise_std: Noise level for ensemble perturbations
220
+ obs_noise_std: Observation error standard deviation
221
+ seed: Random seed for reproducibility
222
+
223
+ Returns:
224
+ Status dictionary with file paths and metadata
225
+ """
226
+ np.random.seed(seed)
227
+
228
+ # Create coordinate grids
229
+ lon = np.linspace(lon_min, lon_max, nx)
230
+ lat = np.linspace(lat_min, lat_max, ny)
231
+ depth = np.linspace(0, depth_max, nz)
232
+
233
+ LON, LAT, DEPTH = np.meshgrid(lon, lat, depth, indexing='ij')
234
+ LON = LON.T
235
+ LAT = LAT.T
236
+ DEPTH = DEPTH.T
237
+
238
+ # Generate background state (restart.nc)
239
+ temp = 25.0 - 0.5 * (LAT - 20.0) - 2.0 * DEPTH
240
+ salt = 35.0 + np.random.randn(nz, ny, nx) * 0.1
241
+ u = np.sin(LON / 5.0) * 0.5
242
+ v = np.cos(LAT / 5.0) * 0.5
243
+ eta_t = np.random.randn(ny, nx) * 0.1
244
+
245
+ _write_model_nc(restart_path, lon, lat, depth, temp, salt, u, v, eta_t)
246
+
247
+ # Generate ensemble members
248
+ os.makedirs(ensemble_dir, exist_ok=True)
249
+ ensemble_paths = []
250
+
251
+ for i in range(n_ensemble):
252
+ member_path = os.path.join(ensemble_dir, f"member_{i+1:03d}.nc")
253
 
254
+ temp_m = temp + np.random.randn(nz, ny, nx) * ensemble_noise_std
255
+ salt_m = salt + np.random.randn(nz, ny, nx) * ensemble_noise_std * 0.5
256
+ u_m = u + np.random.randn(nz, ny, nx) * ensemble_noise_std * 0.2
257
+ v_m = v + np.random.randn(nz, ny, nx) * ensemble_noise_std * 0.2
258
+ eta_m = eta_t + np.random.randn(ny, nx) * ensemble_noise_std * 0.1
259
+
260
+ _write_model_nc(member_path, lon, lat, depth, temp_m, salt_m, u_m, v_m, eta_m)
261
+ ensemble_paths.append(member_path)
262
+
263
+ # Generate observations (temperature at surface k=0)
264
+ obs_lon = np.random.uniform(lon_min, lon_max, n_obs)
265
+ obs_lat = np.random.uniform(lat_min, lat_max, n_obs)
266
+
267
+ # Create "true" observations from background + noise
268
+ obs_temp = []
269
+ for olon, olat in zip(obs_lon, obs_lat):
270
+ i = np.argmin(np.abs(lon - olon))
271
+ j = np.argmin(np.abs(lat - olat))
272
+ true_val = temp[0, j, i] + np.random.randn() * obs_noise_std
273
+ obs_temp.append(true_val)
274
+ obs_temp = np.array(obs_temp)
275
+
276
+ # Write observations to NetCDF
277
+ with nc.Dataset(obs_path, 'w') as ds:
278
+ ds.createDimension('obs', n_obs)
279
+
280
+ lon_var = ds.createVariable('lon', 'f4', ('obs',))
281
+ lon_var[:] = obs_lon
282
+ lon_var.units = 'degrees_east'
283
+
284
+ lat_var = ds.createVariable('lat', 'f4', ('obs',))
285
+ lat_var[:] = obs_lat
286
+ lat_var.units = 'degrees_north'
287
+
288
+ temp_var = ds.createVariable('temp', 'f4', ('obs',))
289
+ temp_var[:] = obs_temp
290
+ temp_var.long_name = 'Temperature'
291
+ temp_var.units = 'degC'
292
+
293
+ error_var = ds.createVariable('error', 'f4', ('obs',))
294
+ error_var[:] = np.full(n_obs, obs_noise_std)
295
+ error_var.long_name = 'Observation error std'
296
+
297
+ return {
298
+ "status": "success",
299
+ "restart_path": os.path.abspath(restart_path),
300
+ "ensemble_dir": os.path.abspath(ensemble_dir),
301
+ "n_ensemble": n_ensemble,
302
+ "obs_path": os.path.abspath(obs_path),
303
+ "n_obs": n_obs,
304
+ "grid_shape": {"nx": nx, "ny": ny, "nz": nz},
305
+ "state_vector_size": nx * ny * nz * 4 + nx * ny
306
+ }
307
 
308
 
309
  # ============================================================================
310
+ # MCP Tool 2: Run EnOI Pipeline with Localization
311
  # ============================================================================
312
 
313
+ @mcp.tool(
314
+ name="run_enoi_pipeline",
315
+ description="Run Ensemble Optimal Interpolation (EnOI) data assimilation pipeline with optional Gaspari-Cohn localization. Computes analysis state from background, ensemble, and observations."
316
+ )
317
+ def run_enoi_pipeline(
318
+ restart_path: str = "restart.nc",
319
+ ensemble_dir: str = "ensemble_data",
320
+ obs_path: str = "obs.nc",
321
+ analysis_path: str = "restart_analysis.nc",
322
+ inflation_factor: float = 1.0,
323
+ localization_radius: float = 500.0
324
+ ) -> Dict[str, Any]:
325
+ """
326
+ Run Ensemble Optimal Interpolation (EnOI) data assimilation pipeline using pyPDAF.
327
+
328
+ This implements the EnOI algorithm by:
329
+ 1. Initialize PDAF with EnKF filtertype=200 (EnOI with static ensemble)
330
+ 2. Load background state, static ensemble, and observations
331
+ 3. Use PDAFomi to handle observation operator
332
+ 4. Apply optional Gaspari-Cohn localization
333
+ 5. Run offline assimilation
334
+ 6. Write analysis state
335
+
336
+ Args:
337
+ restart_path: Path to background state file
338
+ ensemble_dir: Directory containing ensemble member files
339
+ obs_path: Path to observation file
340
+ analysis_path: Output path for analysis state
341
+ inflation_factor: Covariance inflation factor (default 1.0)
342
+ localization_radius: Localization radius in km (default 500, 0 = no localization)
343
+
344
+ Returns:
345
+ Status dictionary with analysis statistics
346
+ """
347
+ if not PYPDAF_AVAILABLE:
348
+ return {"status": "error", "message": "pyPDAF library is not available"}
349
+
350
+ # Step 1: Load background state
351
+ lon, lat, depth, temp_b, salt_b, u_b, v_b, eta_b = _read_state_nc(restart_path)
352
+ nz, ny, nx = temp_b.shape
353
+
354
+ state_b = _flatten_state(temp_b, salt_b, u_b, v_b, eta_b)
355
+ n_state = len(state_b)
356
+
357
+ # Step 1b: Load ensemble members
358
+ member_files = sorted(glob.glob(os.path.join(ensemble_dir, "member_*.nc")))
359
+ n_ensemble = len(member_files)
360
+
361
+ if n_ensemble == 0:
362
+ return {"status": "error", "message": f"No ensemble members found in {ensemble_dir}"}
363
+
364
+ ensemble_states = np.zeros((n_state, n_ensemble), order='F')
365
+ for i, mfile in enumerate(member_files):
366
+ _, _, _, temp_m, salt_m, u_m, v_m, eta_m = _read_state_nc(mfile)
367
+ ensemble_states[:, i] = _flatten_state(temp_m, salt_m, u_m, v_m, eta_m)
368
+
369
+ # Step 2: Load observations
370
+ with nc.Dataset(obs_path, 'r') as ds:
371
+ obs_lon = ds.variables['lon'][:]
372
+ obs_lat = ds.variables['lat'][:]
373
+ obs_temp = ds.variables['temp'][:]
374
+ obs_error = ds.variables['error'][:]
375
+
376
+ n_obs = len(obs_temp)
377
+
378
+ # Build observation indices (for temp at k=0)
379
+ obs_indices = _state_indices_for_temp(obs_lon, obs_lat, lon, lat, nz, ny, nx)
380
+
381
+ # ========================================================================
382
+ # Step 3: Initialize PDAF for EnOI (offline mode)
383
+ # ========================================================================
384
+
385
+ # FilterType: For EnOI, we use filtertype=200 (SEEK with fixed covariance)
386
+ # or filtertype=2 (EnKF) which works for static ensemble
387
+ filtertype = 2 # EnKF
388
+ subtype = 0 # Standard form
389
+
390
+ # PDAF parameters
391
+ filter_param_i = np.array([n_state, n_ensemble], dtype=np.intc)
392
+ filter_param_r = np.array([inflation_factor], dtype=np.float64)
393
+
394
+ # User-supplied function to initialize ensemble
395
+ def init_ens_pdaf(filtertype_in, dim_p, dim_ens, state_p, uinv, ens_p, status):
396
+ """Initialize ensemble in PDAF"""
397
+ ens_p[:, :] = ensemble_states
398
+ state_p[:] = state_b
399
+ return state_p, uinv, ens_p, status
400
+
401
+ # Initialize PDAF
402
+ try:
403
+ _, _, status = pyPDAF.init(
404
+ filtertype, subtype, 0, # step=0 for offline
405
+ filter_param_i, len(filter_param_i),
406
+ filter_param_r, len(filter_param_r),
407
+ py__init_ens_pdaf=init_ens_pdaf,
408
+ in_screen=1 # verbose level
409
+ )
410
+ if status != 0:
411
+ return {"status": "error", "message": f"PDAF init failed with status {status}"}
412
  except Exception as e:
413
+ return {"status": "error", "message": f"PDAF init exception: {str(e)}"}
 
 
 
 
 
 
414
 
415
+ # ========================================================================
416
+ # Step 4: Setup PDAFomi for observations
417
+ # ========================================================================
418
 
419
+ # Initialize PDAFomi with 1 observation type
420
+ PDAFomi.init(1)
421
 
422
+ # Setup localization if requested
423
+ if localization_radius > 0:
424
+ # Convert radius from km to degrees (approximate)
425
+ radius_deg = localization_radius / 111.0
426
+
427
+ # Initialize local analysis
428
+ PDAFomi.init_local()
429
+
430
+ # Set localization
431
+ PDAF.set_localfilter(1) # Enable local filter
432
+
433
+ # Observation class to handle PDAFomi callbacks
434
+ class ObsHandler:
435
+ def __init__(self):
436
+ self.i_obs = 1
437
+
438
+ def init_dim_obs_pdafomi(self, step, dim_obs):
439
+ """Initialize observation dimension"""
440
+ # Set OMI parameters
441
+ PDAFomi.set_doassim(self.i_obs, 1) # Assimilate this obs type
442
+ PDAFomi.set_disttype(self.i_obs, 0) # Cartesian distance
443
+ PDAFomi.set_ncoord(self.i_obs, 2) # 2D coordinates
444
+
445
+ # Create coordinate array for observations
446
+ ocoord_p = np.zeros((2, n_obs), order='F')
447
+ ocoord_p[0, :] = obs_lon
448
+ ocoord_p[1, :] = obs_lat
449
+
450
+ # Set observation error
451
+ ivar_obs = 1.0 / (obs_error ** 2)
452
+
453
+ # Set id_obs_p (state vector indices, 1-indexed for Fortran)
454
+ id_obs_p = np.zeros((1, n_obs), dtype=np.intc, order='F')
455
+ id_obs_p[0, :] = obs_indices + 1 # Fortran 1-indexing
456
+
457
+ # Gather observation information
458
+ PDAFomi.set_id_obs_p(self.i_obs, 1, n_obs, id_obs_p)
459
+ PDAFomi.set_ivar_obs_p(self.i_obs, n_obs, ivar_obs)
460
+ PDAFomi.set_ocoord_p(self.i_obs, 2, n_obs, ocoord_p)
461
+
462
+ # Set observation values
463
+ PDAFomi.set_obs_p(self.i_obs, n_obs, obs_temp)
464
+
465
+ return n_obs
466
+
467
+ def obs_op_pdafomi(self, step, dim_p, dim_obs_p, state_p, ostate):
468
+ """Observation operator"""
469
+ # Extract observed values from state vector
470
+ ostate[:] = state_p[obs_indices]
471
+ return ostate
472
+
473
+ obs_handler = ObsHandler()
474
+
475
+ # Collector class to handle state collection
476
+ class StateCollector:
477
+ def __init__(self):
478
+ self.analysis_state = np.copy(state_b)
479
+
480
+ def collect_state_pdaf(self, dim_p, state_p):
481
+ """Collect state from model (not used in offline mode)"""
482
+ state_p[:] = self.analysis_state
483
+ return state_p
484
+
485
+ def distribute_state_pdaf(self, dim_p, state_p):
486
+ """Distribute state to model"""
487
+ self.analysis_state[:] = state_p
488
+ return state_p
489
+
490
+ def prepoststep_pdaf(self, step, dim_p, dim_ens, dim_ens_p, dim_obs_p,
491
+ state_p, uinv, ens_p, flag):
492
+ """Pre/post processing"""
493
+ if flag > 0: # After analysis
494
+ self.analysis_state[:] = state_p
495
+ return state_p, uinv, ens_p
496
+
497
+ collector = StateCollector()
498
+
499
+ # Localization handler (if needed)
500
+ if localization_radius > 0:
501
+ # Create grid coordinates
502
+ lon_grid, lat_grid = np.meshgrid(lon, lat, indexing='ij')
503
+
504
+ class LocalizationHandler:
505
+ def init_n_domains_pdaf(self, step, n_domains_p):
506
+ """Number of local analysis domains"""
507
+ n_domains_p = n_state
508
+ return n_domains_p
509
+
510
+ def init_dim_l_pdaf(self, step, domain_p, dim_l):
511
+ """Dimension of local state vector"""
512
+ dim_l = n_state # Full state for each domain
513
+ return dim_l
514
+
515
+ def init_dim_obs_l_pdafomi(self, domain_p, step, dim_obs_f, dim_obs_l):
516
+ """Local observation dimension"""
517
+ # For each domain point, find observations within localization radius
518
+ i_domain = domain_p - 1 # Convert to 0-index
519
+
520
+ # Get domain coordinates
521
+ state_lon = lon_grid.ravel()[i_domain % (nx*ny)]
522
+ state_lat = lat_grid.ravel()[i_domain % (nx*ny)]
523
+
524
+ # Calculate distances to all observations
525
+ dx = obs_lon - state_lon
526
+ dy = obs_lat - state_lat
527
+ dist = np.sqrt(dx**2 + dy**2)
528
+
529
+ # Count observations within localization radius
530
+ dim_obs_l = np.sum(dist <= radius_deg)
531
+ return dim_obs_l
532
+
533
+ loc_handler = LocalizationHandler()
534
+
535
+ # Run offline assimilation with localization
536
+ try:
537
+ status = 0
538
+ pyPDAF.assim_offline_local_nondiagr(
539
+ collector.collect_state_pdaf,
540
+ collector.distribute_state_pdaf,
541
+ obs_handler.init_dim_obs_pdafomi,
542
+ obs_handler.obs_op_pdafomi,
543
+ collector.prepoststep_pdaf,
544
+ loc_handler.init_n_domains_pdaf,
545
+ loc_handler.init_dim_l_pdaf,
546
+ loc_handler.init_dim_obs_l_pdafomi,
547
+ status
548
+ )
549
+ except Exception as e:
550
+ return {"status": "error", "message": f"Assimilation failed: {str(e)}"}
551
+ else:
552
+ # Run offline assimilation without localization (global)
553
+ try:
554
+ status = 0
555
+ pyPDAF.assim_offline_global_nondiagr(
556
+ collector.collect_state_pdaf,
557
+ collector.distribute_state_pdaf,
558
+ obs_handler.init_dim_obs_pdafomi,
559
+ obs_handler.obs_op_pdafomi,
560
+ collector.prepoststep_pdaf,
561
+ status
562
+ )
563
+ except Exception as e:
564
+ return {"status": "error", "message": f"Assimilation failed: {str(e)}"}
565
+
566
+ # ========================================================================
567
+ # Step 6: Write analysis state
568
+ # ========================================================================
569
+
570
+ # Unflatten analysis state
571
+ state_a = collector.analysis_state
572
+ temp_a, salt_a, u_a, v_a, eta_a = _unflatten_state(state_a, nz, ny, nx)
573
+ _write_model_nc(analysis_path, lon, lat, depth, temp_a, salt_a, u_a, v_a, eta_a)
574
+
575
+ # Compute statistics
576
+ increment = state_a - state_b
577
+ innovation = obs_temp - state_b[obs_indices]
578
+
579
+ rms_increment = float(np.sqrt(np.mean(increment**2)))
580
+ max_increment = float(np.max(np.abs(increment)))
581
+ rms_innovation = float(np.sqrt(np.mean(innovation**2)))
582
+
583
+ # Finalize PDAF
584
+ PDAF.deallocate()
585
+
586
+ return {
587
+ "status": "success",
588
+ "analysis_path": os.path.abspath(analysis_path),
589
+ "n_ensemble": n_ensemble,
590
+ "n_obs": n_obs,
591
+ "n_state": n_state,
592
+ "rms_innovation": rms_innovation,
593
+ "rms_increment": rms_increment,
594
+ "max_increment": max_increment,
595
+ "inflation_factor": inflation_factor,
596
+ "localization_radius_km": localization_radius if localization_radius > 0 else "none"
597
+ }
598
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
599
 
600
+ # ============================================================================
601
+ # Create MCP Server App
602
+ # ============================================================================
603
 
604
+ def create_app():
605
+ """Create and return the FastMCP application."""
606
+ return mcp