dfm-python 0.1.0__py3-none-any.whl

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.
dfm_python/news.py ADDED
@@ -0,0 +1,557 @@
1
+ """News decomposition for nowcast updates."""
2
+
3
+ import numpy as np
4
+ from scipy.linalg import pinv, inv
5
+ from typing import Tuple, Optional, Dict, Union, List, Any, Callable
6
+ from datetime import datetime
7
+ import pandas as pd
8
+
9
+ from .kalman import skf, fis, miss_data
10
+
11
+
12
+ def para_const(X: np.ndarray, P, lag: int) -> Dict:
13
+ """Implement Kalman filter for news calculation with fixed parameters.
14
+
15
+ Parameters:
16
+ -----------
17
+ X : np.ndarray
18
+ Data matrix (T x N)
19
+ P : DFMResult
20
+ DFM parameters
21
+ lag : int
22
+ Number of lags
23
+
24
+ Returns:
25
+ --------
26
+ Dict with keys: Plag, P, X_sm, F
27
+ """
28
+ # Set model parameters
29
+ Z_0 = P.Z_0
30
+ V_0 = P.V_0
31
+ A = P.A
32
+ C = P.C
33
+ Q = P.Q
34
+ R = P.R
35
+ Mx = P.Mx
36
+ Wx = P.Wx
37
+
38
+ # Prepare data
39
+ T, _ = X.shape
40
+
41
+ # Standardize
42
+ Y = ((X - Mx) / Wx).T # n x T
43
+
44
+ # Apply Kalman filter and smoother
45
+ Sf = skf(Y, A, C, Q, R, Z_0, V_0)
46
+ Ss = fis(A, Sf)
47
+
48
+ # Calculate parameter output
49
+ Vs = Ss.VmT[:, :, 1:] # Smoothed factor covariance
50
+ Vf = Sf.VmU[:, :, 1:] # Filtered factor posterior covariance
51
+ Zsmooth = Ss.ZmT
52
+ Vsmooth = Ss.VmT
53
+
54
+ Plag = [Vs]
55
+
56
+ # Calculate lagged covariance matrices
57
+ # MATLAB loops backwards: for jt = size(Plag{1},3):-1:lag+1
58
+ for jk in range(1, lag + 1):
59
+ Plag_jk = np.zeros_like(Plag[0])
60
+ # Loop backwards from last time index down to lag+1 (MATLAB: size(Plag{1},3):-1:lag+1)
61
+ # Note: Python uses 0-based indexing, so range(N-1, lag, -1) gives N-1 down to lag+1
62
+ for jt in range(Plag[0].shape[2] - 1, lag, -1): # Backward iteration to match MATLAB
63
+ As = Vf[:, :, jt - jk] @ A.T @ pinv(A @ Vf[:, :, jt - jk] @ A.T + Q)
64
+ Plag_jk[:, :, jt] = As @ Plag[jk - 1][:, :, jt]
65
+ Plag.append(Plag_jk)
66
+
67
+ # Prepare data for output
68
+ Zsmooth = Zsmooth.T # T x m
69
+ x_sm = Zsmooth[1:, :] @ C.T # T x N
70
+ X_sm = Wx * x_sm + Mx # Unstandardized
71
+
72
+ return {
73
+ 'Plag': Plag,
74
+ 'P': Vsmooth,
75
+ 'X_sm': X_sm,
76
+ 'F': Zsmooth[1:, :]
77
+ }
78
+
79
+
80
+ def news_dfm(X_old: np.ndarray, X_new: np.ndarray, Res, t_fcst: int,
81
+ v_news: Union[int, np.ndarray, List[int]]) -> Tuple[Union[float, np.ndarray], Union[float, np.ndarray], np.ndarray, np.ndarray,
82
+ np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
83
+ """Calculate changes in news from data releases.
84
+
85
+ Parameters:
86
+ -----------
87
+ X_old : np.ndarray
88
+ Old data matrix (T x N)
89
+ X_new : np.ndarray
90
+ New data matrix (T x N)
91
+ Res : DFMResult
92
+ DFM estimation results
93
+ t_fcst : int
94
+ Target time index (0-based)
95
+ v_news : int or array-like
96
+ Target variable index(s) (0-based). Can be:
97
+ - int: Single target variable (backward compatible)
98
+ - np.ndarray or List[int]: Multiple target variables (MATLAB-compatible)
99
+
100
+ Returns:
101
+ --------
102
+ y_old, y_new, singlenews, actual, forecast, weight, t_miss, v_miss, innov
103
+
104
+ If v_news is scalar:
105
+ - y_old, y_new: float
106
+ - singlenews: (N,) array
107
+ If v_news is array:
108
+ - y_old, y_new: (n_targets,) array
109
+ - singlenews: (N, n_targets) array where each column is news for one target
110
+
111
+ Note:
112
+ -----
113
+ Now supports both single and multiple target variables, matching MATLAB functionality.
114
+ """
115
+ # Normalize v_news to array for consistent handling
116
+ v_news_arr = np.atleast_1d(v_news)
117
+ is_scalar = isinstance(v_news, (int, np.integer)) or (isinstance(v_news, np.ndarray) and v_news.ndim == 0)
118
+ n_targets = len(v_news_arr)
119
+
120
+ r = Res.C.shape[1]
121
+ _, N = X_new.shape
122
+
123
+ # Check if targets are already observed
124
+ targets_observed = np.array([not np.isnan(X_new[t_fcst, v]) for v in v_news_arr])
125
+
126
+ if np.all(targets_observed):
127
+ # NO FORECAST CASE: Already values for all target variables at time t_fcst
128
+ Res_old = para_const(X_old, Res, 0)
129
+
130
+ # Initialize output arrays
131
+ if is_scalar:
132
+ singlenews = np.zeros(N)
133
+ singlenews[v_news_arr[0]] = X_new[t_fcst, v_news_arr[0]] - Res_old['X_sm'][t_fcst, v_news_arr[0]]
134
+ y_old = Res_old['X_sm'][t_fcst, v_news_arr[0]]
135
+ y_new = X_new[t_fcst, v_news_arr[0]]
136
+ else:
137
+ singlenews = np.zeros((N, n_targets))
138
+ for i, v in enumerate(v_news_arr):
139
+ singlenews[v, i] = X_new[t_fcst, v] - Res_old['X_sm'][t_fcst, v]
140
+ y_old = np.array([Res_old['X_sm'][t_fcst, v] for v in v_news_arr])
141
+ y_new = np.array([X_new[t_fcst, v] for v in v_news_arr])
142
+
143
+ actual = np.array([])
144
+ forecast = np.array([])
145
+ weight = np.array([])
146
+ t_miss = np.array([])
147
+ v_miss = np.array([])
148
+ innov = np.array([])
149
+ else:
150
+ # FORECAST CASE
151
+ Mx = Res.Mx
152
+ Wx = Res.Wx
153
+
154
+ # Calculate indicators for missing values
155
+ miss_old = np.isnan(X_old)
156
+ miss_new = np.isnan(X_new)
157
+
158
+ # Indicator for missing: 1 = new data available, -1 = old data available but not new
159
+ i_miss = miss_old.astype(int) - miss_new.astype(int)
160
+
161
+ # Time/variable indices where new data is available
162
+ t_miss, v_miss = np.where(i_miss == 1)
163
+ t_miss = t_miss.flatten()
164
+ v_miss = v_miss.flatten()
165
+
166
+ if len(v_miss) == 0:
167
+ # NO NEW INFORMATION
168
+ Res_old = para_const(X_old, Res, 0)
169
+ Res_new = para_const(X_new, Res, 0)
170
+
171
+ if is_scalar:
172
+ y_old = Res_old['X_sm'][t_fcst, v_news_arr[0]]
173
+ y_new = y_old
174
+ singlenews = np.array([])
175
+ else:
176
+ y_old = np.array([Res_old['X_sm'][t_fcst, v] for v in v_news_arr])
177
+ y_new = y_old
178
+ singlenews = np.array([]).reshape(0, n_targets)
179
+
180
+ actual = np.array([])
181
+ forecast = np.array([])
182
+ weight = np.array([])
183
+ t_miss = np.array([])
184
+ v_miss = np.array([])
185
+ innov = np.array([])
186
+ else:
187
+ # NEW INFORMATION
188
+ # Difference between forecast time and new data time
189
+ lag = t_fcst - t_miss
190
+
191
+ # Biggest time interval
192
+ k = max([np.max(np.abs(lag)), np.max(lag) - np.min(lag)])
193
+ k = int(k)
194
+
195
+ C = Res.C
196
+ R_cov = Res.R.T # Note: R in Res might need transposition
197
+
198
+ n_news = len(lag)
199
+
200
+ # Smooth old dataset
201
+ Res_old = para_const(X_old, Res, k)
202
+ Plag = Res_old['Plag']
203
+
204
+ # Smooth new dataset
205
+ Res_new = para_const(X_new, Res, 0)
206
+
207
+ # Get nowcasts for all target variables
208
+ if is_scalar:
209
+ y_old = Res_old['X_sm'][t_fcst, v_news_arr[0]]
210
+ y_new = Res_new['X_sm'][t_fcst, v_news_arr[0]]
211
+ else:
212
+ y_old = np.array([Res_old['X_sm'][t_fcst, v] for v in v_news_arr])
213
+ y_new = np.array([Res_new['X_sm'][t_fcst, v] for v in v_news_arr])
214
+
215
+ # Calculate projection matrices (simplified)
216
+ P1 = []
217
+ for i in range(n_news):
218
+ h = abs(t_fcst - t_miss[i])
219
+ m = max(t_miss[i], t_fcst)
220
+
221
+ if t_miss[i] > t_fcst:
222
+ Pp = Plag[h + 1][:, :, m] if h + 1 < len(Plag) else Plag[-1][:, :, m]
223
+ else:
224
+ Pp = Plag[h + 1][:, :, m].T if h + 1 < len(Plag) else Plag[-1][:, :, m].T
225
+
226
+ P1.append(Pp @ C[v_miss[i], :r].T)
227
+
228
+ P1 = np.hstack(P1) if len(P1) > 0 else np.zeros((r, 1))
229
+
230
+ # Calculate innovations
231
+ innov = np.zeros(n_news)
232
+ for i in range(n_news):
233
+ X_new_norm = (X_new[t_miss[i], v_miss[i]] - Mx[v_miss[i]]) / Wx[v_miss[i]]
234
+ X_sm_norm = (Res_old['X_sm'][t_miss[i], v_miss[i]] - Mx[v_miss[i]]) / Wx[v_miss[i]]
235
+ innov[i] = X_new_norm - X_sm_norm
236
+
237
+ # Calculate P2 (covariance of innovations) - simplified
238
+ P2 = np.zeros((n_news, n_news))
239
+ for i in range(n_news):
240
+ for j in range(n_news):
241
+ h = abs(lag[i] - lag[j])
242
+ m = max(t_miss[i], t_miss[j])
243
+
244
+ if t_miss[j] > t_miss[i]:
245
+ Pp = Plag[h + 1][:, :, m] if h + 1 < len(Plag) else Plag[-1][:, :, m]
246
+ else:
247
+ Pp = Plag[h + 1][:, :, m].T if h + 1 < len(Plag) else Plag[-1][:, :, m].T
248
+
249
+ if v_miss[i] == v_miss[j] and t_miss[i] != t_miss[j]:
250
+ WW = 0
251
+ else:
252
+ WW = R_cov[v_miss[i], v_miss[j]]
253
+
254
+ P2[i, j] = C[v_miss[i], :r] @ Pp @ C[v_miss[j], :r].T + WW
255
+
256
+ # Calculate weights and news for each target variable
257
+ if n_news > 0 and P2.size > 0:
258
+ try:
259
+ P2_inv = inv(P2)
260
+ # Calculate gain for each target variable
261
+ if is_scalar:
262
+ v_idx = v_news_arr[0]
263
+ gain = (Wx[v_idx] * C[v_idx, :r] @ P1 @ P2_inv).reshape(-1)
264
+ totnews = Wx[v_idx] * C[v_idx, :r] @ P1 @ P2_inv @ innov
265
+ else:
266
+ gain = np.zeros((n_targets, n_news))
267
+ totnews = np.zeros(n_targets)
268
+ for idx, v in enumerate(v_news_arr):
269
+ gain[idx, :] = Wx[v] * C[v, :r] @ P1 @ P2_inv
270
+ totnews[idx] = Wx[v] * C[v, :r] @ P1 @ P2_inv @ innov
271
+ except:
272
+ # If inversion fails, use simpler approach
273
+ if is_scalar:
274
+ gain = np.ones(n_news) * 0.1
275
+ totnews = np.sum(innov) * 0.1
276
+ else:
277
+ gain = np.ones((n_targets, n_news)) * 0.1
278
+ totnews = np.ones(n_targets) * np.sum(innov) * 0.1
279
+ else:
280
+ if is_scalar:
281
+ gain = np.zeros(n_news)
282
+ totnews = 0
283
+ else:
284
+ gain = np.zeros((n_targets, n_news))
285
+ totnews = np.zeros(n_targets)
286
+
287
+ # Organize output
288
+ if is_scalar:
289
+ singlenews = np.full(N, np.nan)
290
+ actual = np.full(N, np.nan)
291
+ forecast = np.full(N, np.nan)
292
+ weight = np.full(N, np.nan)
293
+
294
+ for i in range(n_news):
295
+ actual[v_miss[i]] = X_new[t_miss[i], v_miss[i]]
296
+ forecast[v_miss[i]] = Res_old['X_sm'][t_miss[i], v_miss[i]]
297
+ if i < len(gain):
298
+ singlenews[v_miss[i]] = gain[i] * innov[i] / Wx[v_miss[i]] if Wx[v_miss[i]] != 0 else 0
299
+ weight[v_miss[i]] = gain[i] / Wx[v_miss[i]] if Wx[v_miss[i]] != 0 else 0
300
+ else:
301
+ # Multiple targets: singlenews is (N, n_targets)
302
+ singlenews = np.full((N, n_targets), np.nan)
303
+ actual = np.full(N, np.nan)
304
+ forecast = np.full(N, np.nan)
305
+ weight = np.full((N, n_targets), np.nan)
306
+
307
+ for i in range(n_news):
308
+ actual[v_miss[i]] = X_new[t_miss[i], v_miss[i]]
309
+ forecast[v_miss[i]] = Res_old['X_sm'][t_miss[i], v_miss[i]]
310
+ for idx in range(n_targets):
311
+ if i < len(gain[idx]):
312
+ singlenews[v_miss[i], idx] = gain[idx, i] * innov[i] / Wx[v_miss[i]] if Wx[v_miss[i]] != 0 else 0
313
+ weight[v_miss[i], idx] = gain[idx, i] / Wx[v_miss[i]] if Wx[v_miss[i]] != 0 else 0
314
+
315
+ # Remove duplicates from v_miss
316
+ v_miss = np.unique(v_miss)
317
+
318
+ return y_old, y_new, singlenews, actual, forecast, weight, t_miss, v_miss, innov
319
+
320
+
321
+ # Database saving is application-specific and should be implemented in adapters.
322
+ # This keeps the DFM module generic and database-agnostic.
323
+ def update_nowcast(X_old: np.ndarray, X_new: np.ndarray, Time: pd.DatetimeIndex,
324
+ config, Res, series: str, period: str,
325
+ vintage_old: str, vintage_new: str,
326
+ model_id: Optional[int] = None,
327
+ save_callback: Optional[Callable] = None) -> None:
328
+ """Update nowcast and decompose changes into news.
329
+
330
+ Parameters:
331
+ -----------
332
+ X_old : np.ndarray
333
+ Old vintage data (T_old x N)
334
+ X_new : np.ndarray
335
+ New vintage data (T_new x N)
336
+ Time : pd.DatetimeIndex
337
+ Time index
338
+ config : DFMConfig
339
+ Model configuration
340
+ Res : DFMResult or dict
341
+ DFM estimation results. If dict, must contain 'Res' and optionally 'Spec'.
342
+ If DFMResult and has .Spec attribute, it will be checked for consistency.
343
+ series : str
344
+ Target series ID
345
+ period : str
346
+ Target period (e.g., '2016q4')
347
+ vintage_old : str
348
+ Old vintage date
349
+ vintage_new : str
350
+ New vintage date
351
+ model_id : int, optional
352
+ Model ID for saving (passed to save_callback if provided)
353
+ save_callback : Callable, optional
354
+ Optional callback function to save nowcast results.
355
+ If provided, will be called with nowcast parameters.
356
+ Example: Implement save_callback in your application adapter
357
+ save_callback=lambda **kwargs: save_nowcast_to_db(**kwargs)
358
+
359
+ Notes:
360
+ ------
361
+ If Res contains a saved Config and it differs from the current config, a warning
362
+ is issued. This matches MATLAB behavior where Res.Config is checked for consistency.
363
+
364
+ Database saving is robust with error handling - failures will log warnings
365
+ but not interrupt the nowcast computation.
366
+ """
367
+ # Handle dict format (from saved results) - extract Res and check config consistency
368
+ if isinstance(Res, dict):
369
+ if 'Spec' in Res and 'Res' in Res:
370
+ # Legacy: Spec key for backward compatibility
371
+ saved_config = Res['Spec']
372
+ Res = Res['Res']
373
+ # Simple consistency check: compare SeriesID
374
+ try:
375
+ if hasattr(saved_config, 'SeriesID') and hasattr(config, 'SeriesID'):
376
+ if saved_config.SeriesID != config.SeriesID:
377
+ import warnings
378
+ warnings.warn(
379
+ "Config used in estimation differs from current config. "
380
+ "Results may be inconsistent. Consider re-estimating.",
381
+ UserWarning
382
+ )
383
+ except Exception:
384
+ pass # If comparison fails, continue anyway
385
+ elif 'Config' in Res and 'Res' in Res:
386
+ # New format with Config key
387
+ saved_config = Res['Config']
388
+ Res = Res['Res']
389
+ try:
390
+ if hasattr(saved_config, 'SeriesID') and hasattr(config, 'SeriesID'):
391
+ if saved_config.SeriesID != config.SeriesID:
392
+ import warnings
393
+ warnings.warn(
394
+ "Config used in estimation differs from current config. "
395
+ "Results may be inconsistent. Consider re-estimating.",
396
+ UserWarning
397
+ )
398
+ except Exception:
399
+ pass
400
+ elif 'Res' in Res:
401
+ Res = Res['Res']
402
+
403
+ # Also check if DFMResult has a Config attribute (for future compatibility)
404
+ if hasattr(Res, 'Config') and hasattr(config, 'SeriesID'):
405
+ try:
406
+ if hasattr(Res.Config, 'SeriesID') and Res.Config.SeriesID != config.SeriesID:
407
+ import warnings
408
+ warnings.warn(
409
+ "Config in Res differs from current config. "
410
+ "Results may be inconsistent.",
411
+ UserWarning
412
+ )
413
+ except Exception:
414
+ pass
415
+
416
+ # Make sure datasets are same size
417
+ N = X_new.shape[1]
418
+ T_old = X_old.shape[0]
419
+ T_new = X_new.shape[0]
420
+
421
+ if T_new > T_old:
422
+ X_old = np.vstack([X_old, np.full((T_new - T_old, N), np.nan)])
423
+
424
+ # Append 1 year (12 months) for forecasting horizon
425
+ X_old = np.vstack([X_old, np.full((12, N), np.nan)])
426
+ X_new = np.vstack([X_new, np.full((12, N), np.nan)])
427
+
428
+ # Extend time index
429
+ last_date = Time.iloc[-1] if hasattr(Time, 'iloc') else Time[-1]
430
+ if hasattr(last_date, 'month'):
431
+ new_dates = pd.date_range(
432
+ start=last_date + pd.DateOffset(months=1),
433
+ periods=12,
434
+ freq='MS'
435
+ )
436
+ else:
437
+ # Fallback if not pandas datetime
438
+ new_dates = pd.date_range(start='2017-01-01', periods=12, freq='MS')
439
+
440
+ # Handle both Index and Series types
441
+ if isinstance(Time, pd.Series):
442
+ Time = pd.concat([Time, pd.Series(new_dates)])
443
+ elif hasattr(Time, 'append'):
444
+ try:
445
+ Time = Time.append(pd.Index(new_dates))
446
+ except AttributeError:
447
+ Time = pd.concat([Time, pd.Index(new_dates)])
448
+ else:
449
+ Time = pd.concat([Time, pd.Index(new_dates)])
450
+
451
+ # Find series index
452
+ try:
453
+ i_series = config.SeriesID.index(series)
454
+ except ValueError:
455
+ raise ValueError(f"Series {series} not found in configuration")
456
+
457
+ series_name = config.SeriesName[i_series]
458
+ freq = config.Frequency[i_series]
459
+
460
+ # Parse period
461
+ if freq == 'm':
462
+ year_str, month_str = period.split('m')
463
+ year = int(year_str)
464
+ month = int(month_str)
465
+ target_date = pd.Timestamp(year, month, 1)
466
+ elif freq == 'q':
467
+ year_str, q_str = period.split('q')
468
+ year = int(year_str)
469
+ q = int(q_str)
470
+ month = 3 * q
471
+ target_date = pd.Timestamp(year, month, 1)
472
+ else:
473
+ raise ValueError(f"Unsupported frequency: {freq}")
474
+
475
+ # Find time index
476
+ t_nowcast = None
477
+ for i, t in enumerate(Time):
478
+ if hasattr(t, 'year') and hasattr(t, 'month'):
479
+ if t.year == target_date.year and t.month == target_date.month:
480
+ t_nowcast = i
481
+ break
482
+
483
+ if t_nowcast is None:
484
+ raise ValueError('Period is out of nowcasting horizon (up to one year ahead).')
485
+
486
+ # Separate revisions from new releases
487
+ X_rev = X_new.copy()
488
+ X_rev[np.isnan(X_old)] = np.nan
489
+
490
+ # Compute news
491
+ # Impact from data revisions
492
+ y_old = news_dfm(X_old, X_rev, Res, t_nowcast, i_series)[0]
493
+
494
+ # Impact from data releases
495
+ y_rev, y_new, _, actual, forecast, weight = news_dfm(X_rev, X_new, Res, t_nowcast, i_series)[:6]
496
+
497
+ # Display output
498
+ print('\n\n\n')
499
+ print(f'Nowcast Update: {pd.to_datetime(vintage_new).strftime("%B %d, %Y")}')
500
+
501
+ if freq == 'q':
502
+ period_str = pd.Timestamp(Time[t_nowcast]).strftime('%Y:Q%q')
503
+ else:
504
+ period_str = pd.Timestamp(Time[t_nowcast]).strftime('%Y-%m')
505
+
506
+ print(f'Nowcast for {series_name} ({config.UnitsTransformed[i_series]}), {period_str}')
507
+
508
+ if len(forecast) == 0 or np.all(np.isnan(forecast)):
509
+ print('\n No forecast was made.\n')
510
+ else:
511
+ impact_revisions = y_rev - y_old
512
+ news = actual - forecast
513
+ impact_releases = weight * news
514
+
515
+ # Create summary table
516
+ data_released = np.any(np.isnan(X_old) & ~np.isnan(X_new), axis=0)
517
+
518
+ print('\n Nowcast Impact Decomposition')
519
+ print(' Note: The displayed output is subject to rounding error\n')
520
+ print(f' {pd.to_datetime(vintage_old).strftime("%b %d")} nowcast: {y_old:5.2f}')
521
+ print(f' Impact from data revisions: {impact_revisions:5.2f}')
522
+ print(f' Impact from data releases: {np.nansum(impact_releases):5.2f}')
523
+ print(' +_________')
524
+ print(f' Total impact: {impact_revisions + np.nansum(impact_releases):5.2f}')
525
+ print(f' {pd.to_datetime(vintage_new).strftime("%b %d")} nowcast: {y_new:5.2f}\n')
526
+
527
+ # Display table for series with updates
528
+ print('\n Nowcast Detail Table \n')
529
+ for i in range(N):
530
+ if data_released[i] and not np.isnan(forecast[i]):
531
+ series_id = config.SeriesID[i]
532
+ print(f'{series_id:20s} Forecast: {forecast[i]:8.2f} Actual: {actual[i]:8.2f} '
533
+ f'Weight: {weight[i]:8.4f} Impact: {impact_releases[i]:8.2f}')
534
+
535
+ # Save nowcast using callback if provided (generic - not database-specific)
536
+ if save_callback is not None:
537
+ try:
538
+ save_callback(
539
+ model_id=model_id,
540
+ series=series,
541
+ forecast_date=target_date,
542
+ forecast_value=y_new,
543
+ old_forecast_value=y_old,
544
+ impact_revisions=impact_revisions,
545
+ impact_releases=np.nansum(impact_releases),
546
+ total_impact=impact_revisions + np.nansum(impact_releases),
547
+ vintage_old=vintage_old,
548
+ vintage_new=vintage_new,
549
+ Res=Res
550
+ )
551
+ except Exception as e:
552
+ import warnings
553
+ warnings.warn(
554
+ f"save_callback failed: {e}. Continuing without saving.",
555
+ UserWarning
556
+ )
557
+