dfm-python 0.2.7__py3-none-any.whl → 0.2.9__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.
@@ -10,11 +10,62 @@ This module provides adapters for loading DFMConfig from various sources:
10
10
  All adapters implement the ConfigSource protocol and return DFMConfig objects.
11
11
  """
12
12
 
13
- from typing import Protocol, Optional, Dict, Any, Union
13
+ import warnings
14
+ from typing import Protocol, Optional, Dict, Any, Union, Tuple
14
15
  from pathlib import Path
15
16
  from dataclasses import is_dataclass, asdict
16
17
 
17
18
  from .config import DFMConfig, SeriesConfig, BlockConfig, DEFAULT_GLOBAL_BLOCK_NAME
19
+ import pandas as pd
20
+
21
+
22
+ def _load_config_from_defaults(cfg, root_config_dir, config_type: str) -> Optional[dict]:
23
+ """Load config from defaults or direct path (helper for series/blocks loading).
24
+
25
+ Parameters
26
+ ----------
27
+ cfg : OmegaConf.DictConfig
28
+ Main config object
29
+ root_config_dir : Path
30
+ Root config directory (contains series/ and blocks/ subdirectories)
31
+ config_type : str
32
+ Type of config to load: 'series' or 'blocks'
33
+
34
+ Returns
35
+ -------
36
+ Optional[dict]
37
+ Loaded config dict or None if not found
38
+ """
39
+ from omegaconf import OmegaConf
40
+
41
+ config_dict = None
42
+ config_loaded = False
43
+
44
+ # Try loading from defaults
45
+ if 'defaults' in cfg:
46
+ defaults_list = cfg.defaults
47
+ for default_item in defaults_list:
48
+ default_dict = OmegaConf.to_container(default_item, resolve=False) if hasattr(default_item, 'keys') else default_item
49
+
50
+ # Handle dict format: {'series': 'default'} or {'blocks': 'default'}
51
+ if isinstance(default_dict, dict) and config_type in default_dict:
52
+ config_name = default_dict[config_type]
53
+ config_path = root_config_dir / config_type / f'{config_name}.yaml'
54
+ if config_path.exists():
55
+ config_cfg = OmegaConf.load(config_path)
56
+ config_dict = OmegaConf.to_container(config_cfg, resolve=True)
57
+ config_loaded = True
58
+ break
59
+
60
+ # If not loaded from defaults, try direct path
61
+ if not config_loaded:
62
+ config_path = root_config_dir / config_type / 'default.yaml'
63
+ if config_path.exists():
64
+ config_cfg = OmegaConf.load(config_path)
65
+ config_dict = OmegaConf.to_container(config_cfg, resolve=True)
66
+ config_loaded = True
67
+
68
+ return config_dict if config_loaded else None
18
69
 
19
70
 
20
71
  class ConfigSource(Protocol):
@@ -55,6 +106,14 @@ class YamlSource:
55
106
  raise FileNotFoundError(f"Configuration file not found: {configfile}")
56
107
 
57
108
  config_dir = configfile.parent
109
+ # If config file is in a subdirectory (e.g., experiment/), find the root config directory
110
+ # Look for series/ or blocks/ directories to identify config root
111
+ root_config_dir = config_dir
112
+ while root_config_dir.parent != root_config_dir: # Not at filesystem root
113
+ if (root_config_dir / 'series').exists() or (root_config_dir / 'blocks').exists():
114
+ break
115
+ root_config_dir = root_config_dir.parent
116
+
58
117
  cfg = OmegaConf.load(configfile)
59
118
  cfg_dict = OmegaConf.to_container(cfg, resolve=True)
60
119
 
@@ -64,44 +123,28 @@ class YamlSource:
64
123
 
65
124
  # Load series from config/series/default.yaml
66
125
  series_list = []
67
- series_loaded = False
68
- series_dict = None
69
-
70
- if 'defaults' in cfg:
71
- defaults_list = cfg.defaults
72
- for default_item in defaults_list:
73
- default_dict = OmegaConf.to_container(default_item, resolve=False) if hasattr(default_item, 'keys') else default_item
74
-
75
- # Handle dict format: {'series': 'default'}
76
- if isinstance(default_dict, dict) and 'series' in default_dict:
77
- series_config_name = default_dict['series']
78
- series_config_path = config_dir / 'series' / f'{series_config_name}.yaml'
79
- if series_config_path.exists():
80
- series_cfg = OmegaConf.load(series_config_path)
81
- series_dict = OmegaConf.to_container(series_cfg, resolve=True)
82
- series_loaded = True
83
- break
84
-
85
- # If not loaded from defaults, try direct path
86
- if not series_loaded:
87
- series_config_path = config_dir / 'series' / 'default.yaml'
88
- if series_config_path.exists():
89
- series_cfg = OmegaConf.load(series_config_path)
90
- series_dict = OmegaConf.to_container(series_cfg, resolve=True)
91
- series_loaded = True
126
+ series_dict = _load_config_from_defaults(cfg, root_config_dir, 'series')
127
+ series_loaded = series_dict is not None
92
128
 
93
129
  # Convert series dict to SeriesConfig objects
94
130
  if series_loaded and series_dict is not None:
95
131
  for series_id, series_data in series_dict.items():
96
132
  if isinstance(series_data, dict):
133
+ # Parse release_date if available
134
+ release_date = series_data.get('release', series_data.get('release_date', None))
135
+ if release_date is not None:
136
+ try:
137
+ release_date = int(release_date)
138
+ except (ValueError, TypeError):
139
+ release_date = None
140
+
97
141
  series_list.append(SeriesConfig(
98
142
  series_id=series_id,
99
143
  series_name=series_data.get('series_name', series_id),
100
144
  frequency=series_data.get('frequency', 'm'),
101
145
  transformation=series_data.get('transformation', 'lin'),
102
- category=series_data.get('category', ''),
103
- units=series_data.get('units', ''),
104
- blocks=series_data.get('blocks', [])
146
+ blocks=series_data.get('blocks', []),
147
+ release_date=release_date
105
148
  ))
106
149
 
107
150
  # If no series loaded from separate files, try to get from main config
@@ -118,31 +161,8 @@ class YamlSource:
118
161
 
119
162
  # Load blocks from config/blocks/default.yaml
120
163
  blocks_dict = {}
121
- blocks_loaded = False
122
- blocks_dict_raw = None
123
-
124
- if 'defaults' in cfg:
125
- defaults_list = cfg.defaults
126
- for default_item in defaults_list:
127
- default_dict = OmegaConf.to_container(default_item, resolve=False) if hasattr(default_item, 'keys') else default_item
128
-
129
- # Handle dict format: {'blocks': 'default'}
130
- if isinstance(default_dict, dict) and 'blocks' in default_dict:
131
- blocks_config_name = default_dict['blocks']
132
- blocks_config_path = config_dir / 'blocks' / f'{blocks_config_name}.yaml'
133
- if blocks_config_path.exists():
134
- blocks_cfg = OmegaConf.load(blocks_config_path)
135
- blocks_dict_raw = OmegaConf.to_container(blocks_cfg, resolve=True)
136
- blocks_loaded = True
137
- break
138
-
139
- # If not loaded from defaults, try direct path
140
- if not blocks_loaded:
141
- blocks_config_path = config_dir / 'blocks' / 'default.yaml'
142
- if blocks_config_path.exists():
143
- blocks_cfg = OmegaConf.load(blocks_config_path)
144
- blocks_dict_raw = OmegaConf.to_container(blocks_cfg, resolve=True)
145
- blocks_loaded = True
164
+ blocks_dict_raw = _load_config_from_defaults(cfg, root_config_dir, 'blocks')
165
+ blocks_loaded = blocks_dict_raw is not None
146
166
 
147
167
  # Convert blocks dict to BlockConfig objects
148
168
  if blocks_loaded and blocks_dict_raw is not None:
@@ -241,9 +261,6 @@ class SpecCSVSource:
241
261
 
242
262
  def load(self) -> DFMConfig:
243
263
  """Load series definitions from spec CSV."""
244
- import pandas as pd
245
- from .data import _load_config_from_dataframe
246
-
247
264
  specfile = Path(self.spec_path)
248
265
  if not specfile.exists():
249
266
  raise FileNotFoundError(f"Spec file not found: {specfile}")
@@ -371,6 +388,190 @@ class MergedConfigSource:
371
388
  )
372
389
 
373
390
 
391
+ def from_spec(
392
+ csv_path: Union[str, Path],
393
+ output_dir: Optional[Union[str, Path]] = None,
394
+ series_filename: Optional[str] = None,
395
+ blocks_filename: Optional[str] = None
396
+ ) -> Tuple[Path, Path]:
397
+ """Convert spec CSV file to YAML configuration files.
398
+
399
+ This function reads a spec CSV file and generates two YAML files:
400
+ - config/series/{basename}.yaml - series definitions
401
+ - config/blocks/{basename}.yaml - block definitions
402
+
403
+ Parameters
404
+ ----------
405
+ csv_path : str or Path
406
+ Path to the spec CSV file
407
+ output_dir : str or Path, optional
408
+ Output directory for YAML files. Defaults to config/ directory relative to CSV.
409
+ series_filename : str, optional
410
+ Custom filename for series YAML (without .yaml extension).
411
+ Defaults to CSV basename.
412
+ blocks_filename : str, optional
413
+ Custom filename for blocks YAML (without .yaml extension).
414
+ Defaults to CSV basename.
415
+
416
+ Returns
417
+ -------
418
+ Tuple[Path, Path]
419
+ Paths to generated series YAML and blocks YAML files
420
+
421
+ Examples
422
+ --------
423
+ >>> series_path, blocks_path = from_spec('data/sample_spec.csv')
424
+ >>> # Creates config/series/sample_spec.yaml and config/blocks/sample_spec.yaml
425
+ """
426
+
427
+ csv_path = Path(csv_path)
428
+ if not csv_path.exists():
429
+ raise FileNotFoundError(f"CSV file not found: {csv_path}")
430
+
431
+ # Determine output directory
432
+ if output_dir is None:
433
+ # Default: config/ directory relative to CSV file's parent
434
+ output_dir = csv_path.parent.parent / 'config'
435
+ else:
436
+ output_dir = Path(output_dir)
437
+
438
+ output_dir.mkdir(parents=True, exist_ok=True)
439
+
440
+ # Determine filenames
441
+ csv_basename = csv_path.stem
442
+ series_filename = series_filename or csv_basename
443
+ blocks_filename = blocks_filename or csv_basename
444
+
445
+ # Read CSV and convert to DFMConfig
446
+ try:
447
+ df = pd.read_csv(csv_path)
448
+ except Exception as e:
449
+ raise ValueError(f"Failed to read CSV file {csv_path}: {e}")
450
+
451
+ config = _load_config_from_dataframe(df)
452
+
453
+ # Prepare series YAML content
454
+ series_dir = output_dir / 'series'
455
+ series_dir.mkdir(parents=True, exist_ok=True)
456
+ series_path = series_dir / f'{series_filename}.yaml'
457
+
458
+ series_dict = {}
459
+ # Get block names in order (from config.block_names)
460
+ block_names = config.block_names
461
+
462
+ for series in config.series:
463
+ # Convert blocks from indices to block names if needed
464
+ if isinstance(series.blocks, list) and len(series.blocks) > 0:
465
+ if isinstance(series.blocks[0], int):
466
+ # Blocks are indices - convert to block names
467
+ blocks_names = [block_names[i] for i, val in enumerate(series.blocks) if val != 0]
468
+ else:
469
+ # Blocks are already names
470
+ blocks_names = list(series.blocks)
471
+ else:
472
+ blocks_names = ['Block_Global'] # Default
473
+
474
+ series_entry = {
475
+ 'series_name': series.series_name,
476
+ 'frequency': series.frequency,
477
+ 'transformation': series.transformation,
478
+ 'blocks': blocks_names
479
+ }
480
+ if series.release_date is not None:
481
+ series_entry['release'] = series.release_date
482
+ series_dict[series.series_id] = series_entry
483
+
484
+ # Prepare blocks YAML content
485
+ blocks_dir = output_dir / 'blocks'
486
+ blocks_dir.mkdir(parents=True, exist_ok=True)
487
+ blocks_path = blocks_dir / f'{blocks_filename}.yaml'
488
+
489
+ blocks_dict = {}
490
+ for block_name, block_config in config.blocks.items():
491
+ block_entry = {
492
+ 'factors': block_config.factors,
493
+ 'ar_lag': block_config.ar_lag,
494
+ 'clock': block_config.clock
495
+ }
496
+ if block_config.notes:
497
+ block_entry['notes'] = block_config.notes
498
+ blocks_dict[block_name] = block_entry
499
+
500
+ # Write YAML files
501
+ try:
502
+ try:
503
+ import yaml
504
+ yaml_lib = yaml
505
+ yaml_kwargs = {'default_flow_style': False, 'sort_keys': False, 'allow_unicode': True}
506
+ except ImportError:
507
+ try:
508
+ from omegaconf import OmegaConf
509
+ yaml_lib = OmegaConf
510
+ yaml_kwargs = {}
511
+ except ImportError:
512
+ raise ImportError(
513
+ "Either PyYAML or omegaconf is required for YAML generation. "
514
+ "Install with: pip install pyyaml or pip install omegaconf"
515
+ )
516
+
517
+ # Write series YAML
518
+ if hasattr(yaml_lib, 'dump'):
519
+ # PyYAML - add blank lines between series for better readability
520
+ import io
521
+ stream = io.StringIO()
522
+ yaml_lib.dump(series_dict, stream, **yaml_kwargs)
523
+ yaml_content = stream.getvalue()
524
+
525
+ # Add blank line before each series key (except the first one)
526
+ lines = yaml_content.split('\n')
527
+ formatted_lines = []
528
+ for i, line in enumerate(lines):
529
+ # If line starts a new series (starts with a key and colon, not indented)
530
+ if line and not line.startswith(' ') and ':' in line and not line.strip().startswith('#'):
531
+ # Add blank line before this line (except for the first series)
532
+ if i > 0 and formatted_lines and formatted_lines[-1].strip():
533
+ formatted_lines.append('')
534
+ formatted_lines.append(line)
535
+
536
+ with open(series_path, 'w', encoding='utf-8') as f:
537
+ f.write('\n'.join(formatted_lines))
538
+ else:
539
+ # OmegaConf
540
+ series_cfg = OmegaConf.create(series_dict)
541
+ OmegaConf.save(series_cfg, series_path)
542
+
543
+ # Write blocks YAML
544
+ if hasattr(yaml_lib, 'dump'):
545
+ # PyYAML - add blank lines between blocks for better readability
546
+ import io
547
+ stream = io.StringIO()
548
+ yaml_lib.dump(blocks_dict, stream, **yaml_kwargs)
549
+ yaml_content = stream.getvalue()
550
+
551
+ # Add blank line before each block key (except the first one)
552
+ lines = yaml_content.split('\n')
553
+ formatted_lines = []
554
+ for i, line in enumerate(lines):
555
+ # If line starts a new block (starts with a key and colon, not indented)
556
+ if line and not line.startswith(' ') and ':' in line and not line.strip().startswith('#'):
557
+ # Add blank line before this line (except for the first block)
558
+ if i > 0 and formatted_lines and formatted_lines[-1].strip():
559
+ formatted_lines.append('')
560
+ formatted_lines.append(line)
561
+
562
+ with open(blocks_path, 'w', encoding='utf-8') as f:
563
+ f.write('\n'.join(formatted_lines))
564
+ else:
565
+ # OmegaConf
566
+ blocks_cfg = OmegaConf.create(blocks_dict)
567
+ OmegaConf.save(blocks_cfg, blocks_path)
568
+
569
+ except Exception as e:
570
+ raise RuntimeError(f"Failed to write YAML files: {e}")
571
+
572
+ return series_path, blocks_path
573
+
574
+
374
575
  def make_config_source(
375
576
  source: Optional[Union[str, Path, Dict[str, Any], ConfigSource]] = None,
376
577
  *,
@@ -503,6 +704,101 @@ def make_config_source(
503
704
  )
504
705
 
505
706
 
707
+ # ============================================================================
708
+ # Internal helper: Load config from DataFrame
709
+ # ============================================================================
710
+
711
+ def _load_config_from_dataframe(df: pd.DataFrame) -> DFMConfig:
712
+ """Load configuration from DataFrame (internal helper).
713
+
714
+ This function converts a DataFrame with series specifications into a DFMConfig.
715
+ It's used internally by SpecCSVSource and other config loaders.
716
+
717
+ Parameters
718
+ ----------
719
+ df : pd.DataFrame
720
+ DataFrame with columns: series_id, series_name, frequency, transformation, blocks
721
+ Optional columns: Release (release date)
722
+ Block_* columns: Binary indicators for block membership
723
+
724
+ Returns
725
+ -------
726
+ DFMConfig
727
+ Model configuration object
728
+ """
729
+ series_list = []
730
+ block_names_set = set()
731
+
732
+ for _, row in df.iterrows():
733
+ # Parse blocks from Block_* columns or 'blocks' column
734
+ blocks = []
735
+
736
+ # First, try to find Block_* columns (CSV format)
737
+ block_cols = [col for col in df.columns
738
+ if (col.startswith('Block_') or col.startswith('Block-'))
739
+ and col not in ['blocks']]
740
+
741
+ if block_cols:
742
+ # CSV format: Block_Global, Block_Consumption, etc. columns with 1/0 values
743
+ for block_col in block_cols:
744
+ block_value = row.get(block_col, 0)
745
+ # Handle various types: int, float, numpy int/float, bool, string "1"/"0"
746
+ # Check numpy types first (numpy.int64, numpy.float64, etc.)
747
+ if hasattr(block_value, 'item'):
748
+ # numpy scalar - convert to Python native type
749
+ block_value = block_value.item()
750
+
751
+ if isinstance(block_value, (int, float)) and block_value != 0:
752
+ blocks.append(block_col)
753
+ elif isinstance(block_value, bool) and block_value:
754
+ blocks.append(block_col)
755
+ elif isinstance(block_value, str) and block_value.strip() in ['1', 'True', 'true']:
756
+ blocks.append(block_col)
757
+ else:
758
+ # Fallback: try 'blocks' column (comma-separated string or list)
759
+ blocks_str = row.get('blocks', 'Block_Global')
760
+ if isinstance(blocks_str, str):
761
+ blocks = [b.strip() for b in blocks_str.split(',')]
762
+ elif isinstance(blocks_str, list):
763
+ blocks = blocks_str
764
+ else:
765
+ blocks = ['Block_Global']
766
+
767
+ # Ensure at least Block_Global
768
+ if not blocks:
769
+ blocks = ['Block_Global']
770
+
771
+ # Track block names
772
+ for block in blocks:
773
+ block_names_set.add(block)
774
+
775
+ # Parse release_date if available
776
+ release_date = row.get('Release', None)
777
+ if release_date is not None:
778
+ try:
779
+ release_date = int(release_date)
780
+ except (ValueError, TypeError):
781
+ release_date = None
782
+
783
+ series_list.append(SeriesConfig(
784
+ series_id=row.get('series_id', f"series_{len(series_list)}"),
785
+ series_name=row.get('series_name', row.get('series_id', f"Series {len(series_list)}")),
786
+ frequency=row.get('frequency', 'm'),
787
+ transformation=row.get('transformation', 'lin'),
788
+ blocks=blocks,
789
+ release_date=release_date
790
+ ))
791
+
792
+ # Create default blocks if none specified
793
+ block_names = sorted(block_names_set) if block_names_set else ['Block_Global']
794
+ blocks = {}
795
+ for block_name in block_names:
796
+ blocks[block_name] = BlockConfig(factors=1, ar_lag=1, clock='m')
797
+
798
+ # block_names is derived automatically in DFMConfig.__post_init__ from blocks dict
799
+ return DFMConfig(series=series_list, blocks=blocks)
800
+
801
+
506
802
  # ============================================================================
507
803
  # Hydra ConfigStore Registration (optional - only if Hydra is available)
508
804
  # ============================================================================
@@ -527,10 +823,9 @@ if HYDRA_AVAILABLE and ConfigStore is not None:
527
823
  series_id: str
528
824
  series_name: str
529
825
  frequency: str
530
- units: str
531
826
  transformation: str
532
- category: str
533
827
  blocks: ListType[int]
828
+ units: Optional[str] = None # Optional, for display only
534
829
 
535
830
  @schema_dataclass
536
831
  class DFMConfigSchema: