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/__init__.py +28 -0
- dfm_python/config.py +435 -0
- dfm_python/data_loader.py +500 -0
- dfm_python/dfm.py +2356 -0
- dfm_python/kalman.py +403 -0
- dfm_python/news.py +557 -0
- dfm_python-0.1.0.dist-info/METADATA +271 -0
- dfm_python-0.1.0.dist-info/RECORD +9 -0
- dfm_python-0.1.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,500 @@
|
|
|
1
|
+
"""Data loading, specification parsing, and transformation functions."""
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
import pandas as pd
|
|
5
|
+
import re
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import List, Optional, Tuple, Union
|
|
8
|
+
from datetime import date
|
|
9
|
+
import warnings
|
|
10
|
+
import logging
|
|
11
|
+
|
|
12
|
+
from .config import DFMConfig
|
|
13
|
+
# Backward compatibility
|
|
14
|
+
ModelConfig = DFMConfig
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
warnings.filterwarnings('ignore', category=UserWarning)
|
|
19
|
+
|
|
20
|
+
try:
|
|
21
|
+
from scipy.io import loadmat, savemat
|
|
22
|
+
SCIPY_AVAILABLE = True
|
|
23
|
+
except ImportError:
|
|
24
|
+
SCIPY_AVAILABLE = False
|
|
25
|
+
|
|
26
|
+
try:
|
|
27
|
+
from omegaconf import OmegaConf
|
|
28
|
+
OMEGACONF_AVAILABLE = True
|
|
29
|
+
except ImportError:
|
|
30
|
+
OMEGACONF_AVAILABLE = False
|
|
31
|
+
|
|
32
|
+
# MATLAB datenum offset: datenum('1970-01-01') = 719529
|
|
33
|
+
_MATLAB_DATENUM_OFFSET = 719529
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def load_config_from_yaml(configfile: Union[str, Path]) -> DFMConfig:
|
|
37
|
+
"""Load model configuration from YAML file.
|
|
38
|
+
|
|
39
|
+
Parameters
|
|
40
|
+
----------
|
|
41
|
+
configfile : str or Path
|
|
42
|
+
Path to YAML configuration file
|
|
43
|
+
|
|
44
|
+
Returns
|
|
45
|
+
-------
|
|
46
|
+
ModelConfig
|
|
47
|
+
Model configuration (dataclass with validation)
|
|
48
|
+
|
|
49
|
+
Raises
|
|
50
|
+
------
|
|
51
|
+
FileNotFoundError
|
|
52
|
+
If configfile does not exist
|
|
53
|
+
ImportError
|
|
54
|
+
If omegaconf is not available
|
|
55
|
+
ValueError
|
|
56
|
+
If configuration is invalid
|
|
57
|
+
"""
|
|
58
|
+
if not OMEGACONF_AVAILABLE:
|
|
59
|
+
raise ImportError("omegaconf is required for YAML config loading. Install with: pip install omegaconf")
|
|
60
|
+
|
|
61
|
+
configfile = Path(configfile)
|
|
62
|
+
if not configfile.exists():
|
|
63
|
+
raise FileNotFoundError(f"Configuration file not found: {configfile}")
|
|
64
|
+
|
|
65
|
+
cfg = OmegaConf.load(configfile)
|
|
66
|
+
cfg_dict = OmegaConf.to_container(cfg, resolve=True)
|
|
67
|
+
|
|
68
|
+
# Handle nested structure (if config has @package model: directive)
|
|
69
|
+
if 'series' in cfg_dict and 'block_names' in cfg_dict:
|
|
70
|
+
# Direct model config structure
|
|
71
|
+
return DFMConfig.from_dict(cfg_dict)
|
|
72
|
+
elif 'model' in cfg_dict:
|
|
73
|
+
# Nested under 'model' key (from @package model:)
|
|
74
|
+
return DFMConfig.from_dict(cfg_dict['model'])
|
|
75
|
+
else:
|
|
76
|
+
# Try to construct from top-level keys
|
|
77
|
+
return DFMConfig.from_dict(cfg_dict)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def load_config_from_csv(configfile: Union[str, Path]) -> DFMConfig:
|
|
81
|
+
"""Load model configuration from CSV file.
|
|
82
|
+
|
|
83
|
+
CSV format should have columns:
|
|
84
|
+
- series_id (or 'id' as alias), series_name, frequency, transformation, category, units
|
|
85
|
+
- Block columns (named after block names, e.g., Global, Consumption, Investment, External, or Block_Global, Block_Consumption, etc.)
|
|
86
|
+
- Block columns contain 0 or 1 (1 = series loads on that block)
|
|
87
|
+
|
|
88
|
+
Note: API/database-specific columns (data_code, item_id, api_source, country, is_kpi, etc.)
|
|
89
|
+
are ignored by the generic DFM module. They should be handled by application-specific adapters.
|
|
90
|
+
|
|
91
|
+
Example:
|
|
92
|
+
id,series_name,frequency,transformation,category,units,Block_Global,Block_Consumption,Block_Invest,Block_Extern
|
|
93
|
+
0,Real GDP (Quarterly),q,pca,GDP,Billion Won,1,1,0,0
|
|
94
|
+
1,Nominal GDP (Quarterly),q,pca,GDP,Billion Won,1,1,0,0
|
|
95
|
+
|
|
96
|
+
Parameters
|
|
97
|
+
----------
|
|
98
|
+
configfile : str or Path
|
|
99
|
+
Path to CSV specification file
|
|
100
|
+
|
|
101
|
+
Returns
|
|
102
|
+
-------
|
|
103
|
+
ModelConfig
|
|
104
|
+
Model configuration object
|
|
105
|
+
|
|
106
|
+
Raises
|
|
107
|
+
------
|
|
108
|
+
FileNotFoundError
|
|
109
|
+
If configfile does not exist
|
|
110
|
+
ValueError
|
|
111
|
+
If required columns are missing or data is invalid
|
|
112
|
+
"""
|
|
113
|
+
configfile = Path(configfile)
|
|
114
|
+
if not configfile.exists():
|
|
115
|
+
raise FileNotFoundError(f"Configuration file not found: {configfile}")
|
|
116
|
+
|
|
117
|
+
try:
|
|
118
|
+
df = pd.read_csv(configfile)
|
|
119
|
+
except Exception as e:
|
|
120
|
+
raise ValueError(f"Failed to read CSV file {configfile}: {e}")
|
|
121
|
+
|
|
122
|
+
# Handle 'id' as alias for 'series_id' (for backward compatibility)
|
|
123
|
+
# If CSV has data_code, item_id, api_source, generate series_id from them
|
|
124
|
+
# Otherwise, use 'id' as fallback
|
|
125
|
+
if 'id' in df.columns and 'series_id' not in df.columns:
|
|
126
|
+
# Try to generate series_id from data_code, item_id, api_source if available
|
|
127
|
+
if all(col in df.columns for col in ['data_code', 'item_id', 'api_source']):
|
|
128
|
+
# Generate series_id: {api_source}_{data_code}_{item_id}
|
|
129
|
+
df['series_id'] = df.apply(
|
|
130
|
+
lambda row: f"{row.get('api_source', '')}_{row.get('data_code', '')}_{row.get('item_id', '')}",
|
|
131
|
+
axis=1
|
|
132
|
+
)
|
|
133
|
+
else:
|
|
134
|
+
# Fallback: use 'id' as series_id if series_id column doesn't exist
|
|
135
|
+
df['series_id'] = df['id'].astype(str)
|
|
136
|
+
elif 'id' in df.columns and 'series_id' in df.columns:
|
|
137
|
+
# Both exist - prefer series_id, but if it's empty, use id
|
|
138
|
+
df['series_id'] = df['series_id'].fillna(df['id'].astype(str))
|
|
139
|
+
|
|
140
|
+
# Required fields (DFM-relevant only - no API/database fields)
|
|
141
|
+
required_fields = ['series_id', 'series_name', 'frequency', 'transformation', 'category', 'units']
|
|
142
|
+
optional_fields = [] # No optional fields in generic DFM module
|
|
143
|
+
|
|
144
|
+
missing = [f for f in required_fields if f not in df.columns]
|
|
145
|
+
if missing:
|
|
146
|
+
raise ValueError(f"Missing required columns: {', '.join(missing)}")
|
|
147
|
+
|
|
148
|
+
# Detect block columns (all columns that are not in required_fields or optional_fields)
|
|
149
|
+
# Also exclude 'id' (if it was used as alias, we already created series_id from it)
|
|
150
|
+
# and other non-DFM metadata columns (API/database fields are ignored by generic DFM module)
|
|
151
|
+
# Preserve original column order from DataFrame - first block should be Global
|
|
152
|
+
excluded_fields = set(required_fields) | set(optional_fields) | {
|
|
153
|
+
'id', 'country', 'data_code', 'item_id', 'api_source', 'api_code',
|
|
154
|
+
'api_group_id', 'is_kpi', 'description', 'priority', 'is_active', 'metadata'
|
|
155
|
+
}
|
|
156
|
+
# Use DataFrame column order (not sorted) to preserve CSV structure
|
|
157
|
+
block_columns = [col for col in df.columns if col not in excluded_fields]
|
|
158
|
+
|
|
159
|
+
if not block_columns:
|
|
160
|
+
raise ValueError("No block columns found. Expected columns like 'Global', 'Consumption', etc.")
|
|
161
|
+
|
|
162
|
+
# Validate block columns contain only 0 or 1
|
|
163
|
+
for block_col in block_columns:
|
|
164
|
+
if not df[block_col].isin([0, 1]).all():
|
|
165
|
+
raise ValueError(f"Block column '{block_col}' must contain only 0 or 1")
|
|
166
|
+
|
|
167
|
+
# Ensure all series load on at least one block (first block should always be 1)
|
|
168
|
+
if block_columns[0] not in df.columns:
|
|
169
|
+
raise ValueError(f"First block column '{block_columns[0]}' is required")
|
|
170
|
+
|
|
171
|
+
if not (df[block_columns[0]] == 1).all():
|
|
172
|
+
raise ValueError(f"All series must load on the first block '{block_columns[0]}' (Global)")
|
|
173
|
+
|
|
174
|
+
# Build blocks array (N x n_blocks)
|
|
175
|
+
blocks_data = df[block_columns].values.astype(int)
|
|
176
|
+
|
|
177
|
+
# Convert to ModelConfig format
|
|
178
|
+
from .config import SeriesConfig
|
|
179
|
+
|
|
180
|
+
series_list = []
|
|
181
|
+
for idx, row in df.iterrows():
|
|
182
|
+
# Build block array from block columns
|
|
183
|
+
blocks = [int(row[col]) for col in block_columns]
|
|
184
|
+
|
|
185
|
+
# Create SeriesConfig with only DFM-relevant fields
|
|
186
|
+
# API/database fields (data_code, item_id, api_source, etc.) are ignored by generic DFM module
|
|
187
|
+
series_list.append(SeriesConfig(
|
|
188
|
+
series_id=str(row['series_id']), # Ensure string type
|
|
189
|
+
series_name=row['series_name'],
|
|
190
|
+
frequency=row['frequency'],
|
|
191
|
+
transformation=row['transformation'],
|
|
192
|
+
category=row['category'],
|
|
193
|
+
units=row['units'],
|
|
194
|
+
blocks=blocks
|
|
195
|
+
))
|
|
196
|
+
|
|
197
|
+
return DFMConfig(series=series_list, block_names=block_columns)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
# Note: Excel support removed - use CSV or YAML configs instead
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def load_config(configfile: Union[str, Path]) -> DFMConfig:
|
|
204
|
+
"""Load model configuration from file (auto-detects YAML or CSV).
|
|
205
|
+
|
|
206
|
+
Parameters
|
|
207
|
+
----------
|
|
208
|
+
configfile : str or Path
|
|
209
|
+
Path to configuration file (.yaml, .yml, or .csv)
|
|
210
|
+
|
|
211
|
+
Returns
|
|
212
|
+
-------
|
|
213
|
+
ModelConfig
|
|
214
|
+
Model configuration (dataclass with validation)
|
|
215
|
+
|
|
216
|
+
Raises
|
|
217
|
+
------
|
|
218
|
+
FileNotFoundError
|
|
219
|
+
If configfile does not exist
|
|
220
|
+
ValueError
|
|
221
|
+
If file format is not supported or configuration is invalid
|
|
222
|
+
"""
|
|
223
|
+
configfile = Path(configfile)
|
|
224
|
+
if not configfile.exists():
|
|
225
|
+
raise FileNotFoundError(f"Configuration file not found: {configfile}")
|
|
226
|
+
|
|
227
|
+
suffix = configfile.suffix.lower()
|
|
228
|
+
if suffix in ['.yaml', '.yml']:
|
|
229
|
+
return load_config_from_yaml(configfile)
|
|
230
|
+
elif suffix == '.csv':
|
|
231
|
+
return load_config_from_csv(configfile)
|
|
232
|
+
else:
|
|
233
|
+
raise ValueError(f"Unsupported file format: {suffix}. Use .yaml, .yml, or .csv")
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _transform_series(Z: np.ndarray, formula: str, freq: str, step: int) -> np.ndarray:
|
|
237
|
+
"""Apply transformation to a single series."""
|
|
238
|
+
T = Z.shape[0]
|
|
239
|
+
X = np.full(T, np.nan)
|
|
240
|
+
t1 = step
|
|
241
|
+
n = step / 12
|
|
242
|
+
|
|
243
|
+
if formula == 'lin':
|
|
244
|
+
X[:] = Z
|
|
245
|
+
elif formula == 'chg':
|
|
246
|
+
idx = np.arange(t1, T, step)
|
|
247
|
+
if len(idx) > 1:
|
|
248
|
+
X[idx[0]] = np.nan
|
|
249
|
+
X[idx[1:]] = Z[idx[1:]] - Z[idx[:-1]]
|
|
250
|
+
elif formula == 'ch1':
|
|
251
|
+
idx = np.arange(12 + t1, T, step)
|
|
252
|
+
if len(idx) > 0:
|
|
253
|
+
X[idx] = Z[idx] - Z[idx - 12]
|
|
254
|
+
elif formula == 'pch':
|
|
255
|
+
idx = np.arange(t1, T, step)
|
|
256
|
+
if len(idx) > 1:
|
|
257
|
+
X[idx[0]] = np.nan
|
|
258
|
+
X[idx[1:]] = 100 * (Z[idx[1:]] / Z[idx[:-1]] - 1)
|
|
259
|
+
elif formula == 'pc1':
|
|
260
|
+
idx = np.arange(12 + t1, T, step)
|
|
261
|
+
if len(idx) > 0:
|
|
262
|
+
with np.errstate(divide='ignore', invalid='ignore'):
|
|
263
|
+
X[idx] = 100 * (Z[idx] / Z[idx - 12] - 1)
|
|
264
|
+
X[np.isinf(X)] = np.nan
|
|
265
|
+
elif formula == 'pca':
|
|
266
|
+
idx = np.arange(t1, T, step)
|
|
267
|
+
if len(idx) > 1:
|
|
268
|
+
X[idx[0]] = np.nan
|
|
269
|
+
with np.errstate(divide='ignore', invalid='ignore'):
|
|
270
|
+
X[idx[1:]] = 100 * ((Z[idx[1:]] / Z[idx[:-1]]) ** (1/n) - 1)
|
|
271
|
+
X[np.isinf(X)] = np.nan
|
|
272
|
+
elif formula == 'log':
|
|
273
|
+
with np.errstate(invalid='ignore'):
|
|
274
|
+
X[:] = np.log(Z)
|
|
275
|
+
else:
|
|
276
|
+
X[:] = Z
|
|
277
|
+
|
|
278
|
+
return X
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def transform_data(Z: np.ndarray, Time: pd.DatetimeIndex, config: DFMConfig) -> Tuple[np.ndarray, pd.DatetimeIndex, np.ndarray]:
|
|
282
|
+
"""Transform each data series based on specification."""
|
|
283
|
+
T, N = Z.shape
|
|
284
|
+
X = np.full((T, N), np.nan)
|
|
285
|
+
|
|
286
|
+
for i in range(N):
|
|
287
|
+
step = 3 if config.Frequency[i] == 'q' else 1
|
|
288
|
+
X[:, i] = _transform_series(Z[:, i], config.Transformation[i], config.Frequency[i], step)
|
|
289
|
+
|
|
290
|
+
# Drop first quarter of observations (4 months) since transformations cause missing values
|
|
291
|
+
# Note: MATLAB drops 4 rows (Time(4:end)), not 12
|
|
292
|
+
drop = 4
|
|
293
|
+
if T > drop:
|
|
294
|
+
return X[drop:], Time[drop:], Z[drop:]
|
|
295
|
+
return X, Time, Z
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def read_data(datafile: Union[str, Path]) -> Tuple[np.ndarray, pd.DatetimeIndex, List[str]]:
|
|
299
|
+
"""Read data from CSV file.
|
|
300
|
+
|
|
301
|
+
CSV file should have:
|
|
302
|
+
- First column: Date (YYYY-MM-DD format or pandas parseable)
|
|
303
|
+
- Subsequent columns: Series data (one column per series)
|
|
304
|
+
- Header row: Series IDs (matching config.SeriesID)
|
|
305
|
+
"""
|
|
306
|
+
datafile = Path(datafile)
|
|
307
|
+
if not datafile.exists():
|
|
308
|
+
raise FileNotFoundError(f"Data file not found: {datafile}")
|
|
309
|
+
|
|
310
|
+
# Read CSV file
|
|
311
|
+
try:
|
|
312
|
+
df = pd.read_csv(datafile, index_col=0, parse_dates=True)
|
|
313
|
+
except Exception as e:
|
|
314
|
+
raise ValueError(f"Failed to read CSV file {datafile}: {e}")
|
|
315
|
+
|
|
316
|
+
mnemonics = df.columns.tolist()
|
|
317
|
+
Time = df.index
|
|
318
|
+
Z = df.values.astype(float)
|
|
319
|
+
|
|
320
|
+
return Z, Time, mnemonics
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def sort_data(Z: np.ndarray, Mnem: List[str], config: DFMConfig) -> Tuple[np.ndarray, List[str]]:
|
|
324
|
+
"""Sort series to match configuration order."""
|
|
325
|
+
in_config = [m in config.SeriesID for m in Mnem]
|
|
326
|
+
Mnem_filt = [m for m, in_c in zip(Mnem, in_config) if in_c]
|
|
327
|
+
Z_filt = Z[:, in_config]
|
|
328
|
+
|
|
329
|
+
perm = [Mnem_filt.index(sid) for sid in config.SeriesID]
|
|
330
|
+
return Z_filt[:, perm], [Mnem_filt[i] for i in perm]
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def _datenum_to_pandas(Time_mat: np.ndarray) -> pd.DatetimeIndex:
|
|
334
|
+
"""Convert MATLAB datenum to pandas DatetimeIndex.
|
|
335
|
+
|
|
336
|
+
MATLAB datenum: days since 0000-01-01
|
|
337
|
+
Conversion: Time_pandas = 1970-01-01 + (Time_mat - 719529) days
|
|
338
|
+
"""
|
|
339
|
+
if Time_mat.ndim > 1:
|
|
340
|
+
Time_mat = Time_mat.flatten()
|
|
341
|
+
return pd.Timestamp('1970-01-01') + pd.to_timedelta(Time_mat - _MATLAB_DATENUM_OFFSET, unit='D')
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def _pandas_to_datenum(Time: pd.DatetimeIndex) -> np.ndarray:
|
|
345
|
+
"""Convert pandas DatetimeIndex to MATLAB datenum format.
|
|
346
|
+
|
|
347
|
+
Conversion: Time_mat = (Time_pandas - 1970-01-01).days + 719529
|
|
348
|
+
"""
|
|
349
|
+
if not isinstance(Time, pd.DatetimeIndex):
|
|
350
|
+
Time = pd.to_datetime(Time)
|
|
351
|
+
return (Time - pd.Timestamp('1970-01-01')).days.values.astype(float) + _MATLAB_DATENUM_OFFSET
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def _extract_matlab_variables(mat_data: dict) -> Tuple[Optional[np.ndarray], Optional[np.ndarray], Optional[List[str]]]:
|
|
355
|
+
"""Extract Z, Time, Mnem from MATLAB .mat file data.
|
|
356
|
+
|
|
357
|
+
Returns (Z, Time_mat, Mnem) or (None, None, None) if not found.
|
|
358
|
+
"""
|
|
359
|
+
Z = mat_data.get('Z')
|
|
360
|
+
Time_mat = mat_data.get('Time')
|
|
361
|
+
Mnem = mat_data.get('Mnem')
|
|
362
|
+
|
|
363
|
+
# Try alternative keys if not found (scipy sometimes adds prefixes)
|
|
364
|
+
if Z is None or Time_mat is None or Mnem is None:
|
|
365
|
+
for key in mat_data.keys():
|
|
366
|
+
if key.startswith('__'):
|
|
367
|
+
continue # Skip metadata
|
|
368
|
+
key_upper = key.upper()
|
|
369
|
+
if Z is None and 'Z' in key_upper and 'TIME' not in key_upper and 'MNEM' not in key_upper:
|
|
370
|
+
Z = mat_data[key]
|
|
371
|
+
elif Time_mat is None and 'TIME' in key_upper:
|
|
372
|
+
Time_mat = mat_data[key]
|
|
373
|
+
elif Mnem is None and 'MNEM' in key_upper:
|
|
374
|
+
Mnem = mat_data[key]
|
|
375
|
+
|
|
376
|
+
# Convert Mnem from MATLAB format to Python list
|
|
377
|
+
if Mnem is not None:
|
|
378
|
+
if isinstance(Mnem, np.ndarray):
|
|
379
|
+
Mnem = [str(item[0]) if isinstance(item, np.ndarray) and item.size > 0
|
|
380
|
+
else str(item) for item in Mnem.flatten()]
|
|
381
|
+
elif not isinstance(Mnem, list):
|
|
382
|
+
Mnem = [str(Mnem)]
|
|
383
|
+
|
|
384
|
+
return Z, Time_mat, Mnem
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def _load_from_mat_cache(datafile_mat: Path) -> Optional[Tuple[np.ndarray, pd.DatetimeIndex, List[str]]]:
|
|
388
|
+
"""Load data from cached .mat file.
|
|
389
|
+
|
|
390
|
+
Returns (Z, Time, Mnem) if successful, None otherwise.
|
|
391
|
+
"""
|
|
392
|
+
if not SCIPY_AVAILABLE or not datafile_mat.exists():
|
|
393
|
+
return None
|
|
394
|
+
|
|
395
|
+
try:
|
|
396
|
+
mat_data = loadmat(str(datafile_mat))
|
|
397
|
+
Z, Time_mat, Mnem = _extract_matlab_variables(mat_data)
|
|
398
|
+
|
|
399
|
+
if Z is None or Time_mat is None or Mnem is None:
|
|
400
|
+
return None
|
|
401
|
+
|
|
402
|
+
# Convert MATLAB datenum to pandas DatetimeIndex
|
|
403
|
+
Time = _datenum_to_pandas(Time_mat)
|
|
404
|
+
|
|
405
|
+
# Ensure Z is 2D
|
|
406
|
+
if Z.ndim == 1:
|
|
407
|
+
Z = Z.reshape(-1, 1)
|
|
408
|
+
|
|
409
|
+
print(f' Loaded from cached .mat file: {datafile_mat}')
|
|
410
|
+
return Z, Time, Mnem
|
|
411
|
+
except Exception as e:
|
|
412
|
+
warnings.warn(f"Failed to load from .mat cache ({e}). Reading from CSV instead.")
|
|
413
|
+
return None
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def _save_to_mat_cache(Z: np.ndarray, Time: pd.DatetimeIndex, Mnem: List[str],
|
|
417
|
+
mat_dir: Path, datafile_mat: Path) -> None:
|
|
418
|
+
"""Save data to .mat cache file."""
|
|
419
|
+
if not SCIPY_AVAILABLE:
|
|
420
|
+
return
|
|
421
|
+
|
|
422
|
+
try:
|
|
423
|
+
mat_dir.mkdir(exist_ok=True)
|
|
424
|
+
Time_mat = _pandas_to_datenum(Time)
|
|
425
|
+
Mnem_array = np.array([m.encode('utf-8') if isinstance(m, str) else m
|
|
426
|
+
for m in Mnem], dtype=object)
|
|
427
|
+
savemat(str(datafile_mat), {'Z': Z, 'Time': Time_mat, 'Mnem': Mnem_array})
|
|
428
|
+
print(f' Cached to .mat file: {datafile_mat}')
|
|
429
|
+
except Exception as e:
|
|
430
|
+
warnings.warn(f"Failed to save .mat cache ({e}). Continuing without cache.")
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def load_data(datafile: Union[str, Path], config: DFMConfig,
|
|
434
|
+
sample_start: Optional[Union[pd.Timestamp, str]] = None) -> Tuple[np.ndarray, pd.DatetimeIndex, np.ndarray]:
|
|
435
|
+
"""Load and transform data from CSV file.
|
|
436
|
+
|
|
437
|
+
This function reads data from a CSV file, sorts it to match the model
|
|
438
|
+
configuration (from CSV or YAML), and applies the specified transformations.
|
|
439
|
+
|
|
440
|
+
Note: For database-backed applications, create adapters that implement
|
|
441
|
+
the same interface (return X, Time, Z arrays).
|
|
442
|
+
|
|
443
|
+
Parameters
|
|
444
|
+
----------
|
|
445
|
+
datafile : str or Path
|
|
446
|
+
Path to CSV data file (.csv)
|
|
447
|
+
config : ModelConfig
|
|
448
|
+
Model configuration object (from CSV or YAML)
|
|
449
|
+
sample_start : pd.Timestamp or str, optional
|
|
450
|
+
Start date for sample (YYYY-MM-DD). If None, uses all available data.
|
|
451
|
+
Data before this date will be dropped.
|
|
452
|
+
|
|
453
|
+
Returns
|
|
454
|
+
-------
|
|
455
|
+
X : np.ndarray
|
|
456
|
+
Transformed data matrix (T x N), ready for DFM estimation
|
|
457
|
+
Time : pd.DatetimeIndex
|
|
458
|
+
Time index for the data
|
|
459
|
+
Z : np.ndarray
|
|
460
|
+
Original untransformed data (T x N), for reference
|
|
461
|
+
"""
|
|
462
|
+
print('Loading data...')
|
|
463
|
+
|
|
464
|
+
datafile = Path(datafile)
|
|
465
|
+
if datafile.suffix.lower() != '.csv':
|
|
466
|
+
raise ValueError('Only CSV files supported. Use database for production data loading.')
|
|
467
|
+
|
|
468
|
+
if not datafile.exists():
|
|
469
|
+
raise FileNotFoundError(f"Data file not found: {datafile}")
|
|
470
|
+
|
|
471
|
+
# Create path for cached .mat file (matching MATLAB behavior)
|
|
472
|
+
mat_dir = datafile.parent / 'mat'
|
|
473
|
+
datafile_mat = mat_dir / f'{datafile.stem}.mat'
|
|
474
|
+
|
|
475
|
+
# Try to load from cache first
|
|
476
|
+
cache_data = _load_from_mat_cache(datafile_mat)
|
|
477
|
+
|
|
478
|
+
# Read from CSV if cache load failed
|
|
479
|
+
if cache_data is None:
|
|
480
|
+
Z, Time, Mnem = read_data(datafile)
|
|
481
|
+
_save_to_mat_cache(Z, Time, Mnem, mat_dir, datafile_mat)
|
|
482
|
+
else:
|
|
483
|
+
Z, Time, Mnem = cache_data
|
|
484
|
+
|
|
485
|
+
# Process data: sort to match config order, then transform
|
|
486
|
+
Z, _ = sort_data(Z, Mnem, config)
|
|
487
|
+
X, Time, Z = transform_data(Z, Time, config)
|
|
488
|
+
|
|
489
|
+
# Apply sample_start filtering
|
|
490
|
+
if sample_start is not None:
|
|
491
|
+
if isinstance(sample_start, str):
|
|
492
|
+
sample_start = pd.to_datetime(sample_start)
|
|
493
|
+
mask = Time >= sample_start
|
|
494
|
+
Time, X, Z = Time[mask], X[mask], Z[mask]
|
|
495
|
+
|
|
496
|
+
return X, Time, Z
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
# Database adapters are application-specific and should be implemented separately.
|
|
500
|
+
# This keeps the DFM module generic and database-agnostic.
|