dfm-python 0.2.4__py3-none-any.whl → 0.2.5__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/config.py CHANGED
@@ -1,48 +1,35 @@
1
- """Configuration models and source adapters for DFM nowcasting.
1
+ """Configuration models and factory methods for DFM nowcasting.
2
2
 
3
- This module provides a unified configuration system for Dynamic Factor Models:
3
+ This module provides the core configuration system for Dynamic Factor Models:
4
4
  - Configuration dataclasses (DFMConfig, SeriesConfig, BlockConfig, Params)
5
- - Configuration source adapters (YAML, Dict, Spec CSV, Hydra)
6
- - Factory functions for flexible config loading
5
+ - Factory methods for creating DFMConfig from dictionaries and Hydra configs
7
6
 
8
- The configuration system supports:
9
- - YAML files (with Hydra/OmegaConf support)
10
- - Direct DFMConfig object creation
11
- - Spec CSV files (series definitions)
12
- - Dictionary configurations
13
- - Merging multiple configuration sources
7
+ The configuration dataclasses define:
8
+ - Model structure (series, blocks, factors)
9
+ - Estimation parameters (EM algorithm settings)
10
+ - Numerical stability controls (regularization, clipping, damping)
14
11
 
15
- All adapters return a DFMConfig object, ensuring a consistent interface
16
- regardless of the source format.
12
+ Factory methods support:
13
+ - Dictionary configurations (legacy format, new format, Hydra format)
14
+ - Hydra DictConfig objects (via from_hydra())
15
+
16
+ For loading configurations from files (YAML, Spec CSV) or other sources,
17
+ see the config_sources module which provides source adapters.
18
+
19
+ Note: Source adapter classes (YamlSource, DictSource, etc.) are re-exported
20
+ from config_sources for backward compatibility.
17
21
  """
18
22
 
19
23
  import numpy as np
20
24
  from typing import List, Optional, Dict, Any, Union
21
- from pathlib import Path
22
- import warnings
23
- import logging
24
- from dataclasses import dataclass, field, is_dataclass, asdict
25
+ from dataclasses import dataclass, field
25
26
 
26
27
  try:
27
28
  from typing import Protocol
28
29
  except ImportError:
29
30
  from typing_extensions import Protocol
30
31
 
31
- try:
32
- from hydra.core.config_store import ConfigStore
33
- HYDRA_AVAILABLE = True
34
- except ImportError:
35
- HYDRA_AVAILABLE = False
36
- ConfigStore = None
37
-
38
- # Valid frequency codes
39
- _VALID_FREQUENCIES = {'d', 'w', 'm', 'q', 'sa', 'a'}
40
-
41
- # Valid transformation codes
42
- _VALID_TRANSFORMATIONS = {
43
- 'lin', 'chg', 'ch1', 'pch', 'pc1', 'pca',
44
- 'cch', 'cca', 'log'
45
- }
32
+ # Validation functions are defined at the end of this file
46
33
 
47
34
  # Default global block name (can be overridden in config)
48
35
  DEFAULT_GLOBAL_BLOCK_NAME = 'Block_Global'
@@ -61,20 +48,6 @@ _TRANSFORM_UNITS_MAP = {
61
48
  }
62
49
 
63
50
 
64
- def validate_frequency(frequency: str) -> str:
65
- """Validate frequency code."""
66
- if frequency not in _VALID_FREQUENCIES:
67
- raise ValueError(f"Invalid frequency: {frequency}. Must be one of {_VALID_FREQUENCIES}")
68
- return frequency
69
-
70
-
71
- def validate_transformation(transformation: str) -> str:
72
- """Validate transformation code."""
73
- if transformation not in _VALID_TRANSFORMATIONS:
74
- warnings.warn(f"Unknown transformation code: {transformation}. Will use untransformed data.")
75
- return transformation
76
-
77
-
78
51
  @dataclass
79
52
  class BlockConfig:
80
53
  """Configuration for a single factor block.
@@ -262,6 +235,14 @@ class Params:
262
235
  Damping factor: 0.8 = 80% new, 20% old (default: 0.8)
263
236
  warn_on_damped_update : bool
264
237
  Warn when damped updates are used (default: True)
238
+ augment_idio : bool
239
+ Enable state augmentation with idiosyncratic components (default: True)
240
+ augment_idio_slow : bool
241
+ Enable tent-length chains for slower-frequency series (default: True)
242
+ idio_rho0 : float
243
+ Initial AR coefficient for idiosyncratic components (default: 0.1)
244
+ idio_min_var : float
245
+ Minimum variance for idiosyncratic innovation covariance (default: 1e-8)
265
246
 
266
247
  Examples
267
248
  --------
@@ -300,6 +281,12 @@ class Params:
300
281
  damping_factor: float = 0.8
301
282
  warn_on_damped_update: bool = True
302
283
 
284
+ # Idiosyncratic component augmentation
285
+ augment_idio: bool = True # Enable state augmentation with idiosyncratic components (default: True)
286
+ augment_idio_slow: bool = True # Enable tent-length chains for slower-frequency series (default: True)
287
+ idio_rho0: float = 0.1 # Initial AR coefficient for idiosyncratic components (default: 0.1)
288
+ idio_min_var: float = 1e-8 # Minimum variance for idiosyncratic innovation covariance (default: 1e-8)
289
+
303
290
  def __post_init__(self):
304
291
  """Validate parameters."""
305
292
  self.clock = validate_frequency(self.clock)
@@ -373,6 +360,12 @@ class DFMConfig:
373
360
  damping_factor: float = 0.8 # Damping factor (0.8 = 80% new, 20% old)
374
361
  warn_on_damped_update: bool = True # Warn when damped updates are used
375
362
 
363
+ # Idiosyncratic Component Augmentation
364
+ augment_idio: bool = True # Enable state augmentation with idiosyncratic components (default: True)
365
+ augment_idio_slow: bool = True # Enable tent-length chains for slower-frequency series (default: True)
366
+ idio_rho0: float = 0.1 # Initial AR coefficient for idiosyncratic components (default: 0.1)
367
+ idio_min_var: float = 1e-8 # Minimum variance for idiosyncratic innovation covariance (default: 1e-8)
368
+
376
369
  # ========================================================================
377
370
  # Internal cache (not user-configurable)
378
371
  # ========================================================================
@@ -397,7 +390,7 @@ class DFMConfig:
397
390
  indicating what needs to be fixed.
398
391
  """
399
392
  # Import frequency hierarchy for validation
400
- from .utils.aggregation import FREQUENCY_HIERARCHY
393
+ from .utils import FREQUENCY_HIERARCHY
401
394
 
402
395
  if not self.series:
403
396
  raise ValueError(
@@ -446,7 +439,9 @@ class DFMConfig:
446
439
  if block_clock_hierarchy < global_clock_hierarchy:
447
440
  raise ValueError(
448
441
  f"Block '{block_name}' has clock '{block_cfg.clock}' which is faster than "
449
- f"global clock '{self.clock}'. Block clocks must be >= global clock."
442
+ f"global clock '{self.clock}'. Block clocks must be >= global clock. "
443
+ f"Suggested fix: change block '{block_name}' clock to '{self.clock}' or slower, "
444
+ f"or set global clock to '{block_cfg.clock}' or faster."
450
445
  )
451
446
 
452
447
  # Auto-generate series_id if not provided and convert blocks to indices
@@ -496,11 +491,17 @@ class DFMConfig:
496
491
 
497
492
  # Series frequency must be <= block clock (slower or equal)
498
493
  if series_freq_hierarchy < block_clock_hierarchy:
494
+ # Suggest valid frequencies for the series
495
+ valid_freqs = [freq for freq, hier in FREQUENCY_HIERARCHY.items()
496
+ if hier >= block_clock_hierarchy]
497
+ valid_freqs_str = ', '.join(sorted(valid_freqs))
499
498
  raise ValueError(
500
499
  f"Series '{s.series_id}' has frequency '{s.frequency}' which is faster than "
501
500
  f"block '{block_name}' clock '{block_cfg.clock}'. "
502
501
  f"Series in a block must have frequency <= block clock. "
503
- f"Either change the series frequency or move it to a faster block."
502
+ f"Suggested fix: change series frequency to one of [{valid_freqs_str}] "
503
+ f"(slower or equal to block clock '{block_cfg.clock}'), "
504
+ f"or set block clock to '{s.frequency}' or faster."
504
505
  )
505
506
 
506
507
  # Validate factors_per_block
@@ -538,6 +539,106 @@ class DFMConfig:
538
539
  self._cached_blocks = np.array(blocks_list, dtype=int)
539
540
  return self._cached_blocks
540
541
 
542
+ def validate_and_report(self) -> Dict[str, Any]:
543
+ """Validate configuration and return structured report with issues and suggestions.
544
+
545
+ This method performs validation checks without raising exceptions, returning
546
+ a structured report that can be used for debugging and user guidance.
547
+
548
+ Returns
549
+ -------
550
+ Dict[str, Any]
551
+ Report dictionary with keys:
552
+ - 'valid': bool - Whether configuration is valid
553
+ - 'errors': List[str] - List of error messages
554
+ - 'warnings': List[str] - List of warning messages
555
+ - 'suggestions': List[str] - List of actionable suggestions
556
+
557
+ Examples
558
+ --------
559
+ >>> config = DFMConfig(...)
560
+ >>> report = config.validate_and_report()
561
+ >>> if not report['valid']:
562
+ ... print("Errors:", report['errors'])
563
+ ... print("Suggestions:", report['suggestions'])
564
+ """
565
+ from .utils import FREQUENCY_HIERARCHY
566
+
567
+ report = {
568
+ 'valid': True,
569
+ 'errors': [],
570
+ 'warnings': [],
571
+ 'suggestions': []
572
+ }
573
+
574
+ # Check for empty series
575
+ if not self.series:
576
+ report['valid'] = False
577
+ report['errors'].append("DFM configuration must contain at least one series.")
578
+ report['suggestions'].append("Add series definitions to your configuration.")
579
+ return report
580
+
581
+ # Check for empty blocks
582
+ if not self.blocks:
583
+ report['valid'] = False
584
+ report['errors'].append("DFM configuration must contain at least one block.")
585
+ report['suggestions'].append("Add block definitions to your configuration.")
586
+ return report
587
+
588
+ # Check frequency constraints
589
+ global_clock_hierarchy = FREQUENCY_HIERARCHY.get(self.clock, 3)
590
+ for i, s in enumerate(self.series):
591
+ series_freq_hierarchy = FREQUENCY_HIERARCHY.get(s.frequency, 3)
592
+
593
+ for block_idx, loads_on_block in enumerate(s.blocks):
594
+ if loads_on_block == 1:
595
+ if block_idx < len(self.block_names):
596
+ block_name = self.block_names[block_idx]
597
+ block_cfg = self.blocks[block_name]
598
+ block_clock_hierarchy = FREQUENCY_HIERARCHY.get(block_cfg.clock, 3)
599
+
600
+ if series_freq_hierarchy < block_clock_hierarchy:
601
+ valid_freqs = [freq for freq, hier in FREQUENCY_HIERARCHY.items()
602
+ if hier >= block_clock_hierarchy]
603
+ valid_freqs_str = ', '.join(sorted(valid_freqs))
604
+ report['valid'] = False
605
+ report['errors'].append(
606
+ f"Series '{s.series_id}' has frequency '{s.frequency}' which is faster than "
607
+ f"block '{block_name}' clock '{block_cfg.clock}'."
608
+ )
609
+ report['suggestions'].append(
610
+ f"For series '{s.series_id}': change frequency to one of [{valid_freqs_str}], "
611
+ f"or set block '{block_name}' clock to '{s.frequency}' or faster."
612
+ )
613
+
614
+ # Check block clock constraints
615
+ for block_name, block_cfg in self.blocks.items():
616
+ block_clock_hierarchy = FREQUENCY_HIERARCHY.get(block_cfg.clock, 3)
617
+ if block_clock_hierarchy < global_clock_hierarchy:
618
+ report['valid'] = False
619
+ report['errors'].append(
620
+ f"Block '{block_name}' has clock '{block_cfg.clock}' which is faster than "
621
+ f"global clock '{self.clock}'."
622
+ )
623
+ report['suggestions'].append(
624
+ f"Change block '{block_name}' clock to '{self.clock}' or slower, "
625
+ f"or set global clock to '{block_cfg.clock}' or faster."
626
+ )
627
+
628
+ # Check factors_per_block
629
+ if any(f < 1 for f in self.factors_per_block):
630
+ invalid_blocks = [i for i, f in enumerate(self.factors_per_block) if f < 1]
631
+ report['valid'] = False
632
+ report['errors'].append(
633
+ f"factors_per_block must contain positive integers (>= 1). "
634
+ f"Invalid values found at block indices {invalid_blocks}."
635
+ )
636
+ report['suggestions'].append(
637
+ f"Set factors_per_block[{invalid_blocks[0]}] to at least 1 for block '{self.block_names[invalid_blocks[0]]}'."
638
+ )
639
+
640
+ return report
641
+
541
642
  # Note: Legacy PascalCase properties were removed to keep the API clean and generic.
542
643
  # Use the snake_case helper methods above.
543
644
 
@@ -570,7 +671,12 @@ class DFMConfig:
570
671
  'warn_on_regularization': data.get('warn_on_regularization', True),
571
672
  'use_damped_updates': data.get('use_damped_updates', True),
572
673
  'damping_factor': data.get('damping_factor', 0.8),
573
- 'warn_on_damped_update': data.get('warn_on_damped_update', True)
674
+ 'warn_on_damped_update': data.get('warn_on_damped_update', True),
675
+ # Idiosyncratic component augmentation
676
+ 'augment_idio': data.get('augment_idio', True),
677
+ 'augment_idio_slow': data.get('augment_idio_slow', True),
678
+ 'idio_rho0': data.get('idio_rho0', 0.1),
679
+ 'idio_min_var': data.get('idio_min_var', 1e-8)
574
680
  }
575
681
 
576
682
  @classmethod
@@ -828,536 +934,104 @@ class DFMConfig:
828
934
  return cls.from_dict(cfg)
829
935
 
830
936
 
831
- # Register with Hydra ConfigStore (optional - only if Hydra is available)
832
- if HYDRA_AVAILABLE and ConfigStore is not None:
833
- try:
834
- cs = ConfigStore.instance()
835
- if cs is not None:
836
- from dataclasses import dataclass as schema_dataclass
837
-
838
- @schema_dataclass
839
- class SeriesConfigSchema:
840
- """Schema for SeriesConfig validation in Hydra."""
841
- series_id: str
842
- series_name: str
843
- frequency: str
844
- units: str
845
- transformation: str
846
- category: str
847
- blocks: List[int]
848
-
849
- @schema_dataclass
850
- class DFMConfigSchema:
851
- """Schema for unified DFMConfig validation in Hydra."""
852
- series: List[SeriesConfigSchema]
853
- block_names: List[str]
854
- factors_per_block: Optional[List[int]] = None
855
- ar_lag: int = 1
856
- threshold: float = 1e-5
857
- max_iter: int = 5000
858
- nan_method: int = 2
859
- nan_k: int = 3
860
- clock: str = 'm'
861
-
862
- # Register schemas
863
- cs.store(group="dfm", name="base_dfm_config", node=DFMConfigSchema)
864
- cs.store(group="model", name="base_model_config", node=DFMConfigSchema)
865
- cs.store(name="dfm_config_schema", node=DFMConfigSchema)
866
- cs.store(name="model_config_schema", node=DFMConfigSchema)
867
-
868
- except Exception as e:
869
- warnings.warn(f"Could not register Hydra structured config schemas: {e}. "
870
- f"Configs will still work via from_dict() but without schema validation.")
871
-
872
-
873
937
  # ============================================================================
874
938
  # Configuration Source Adapters
875
939
  # ============================================================================
876
940
 
877
- logger = logging.getLogger(__name__)
941
+ # Import ConfigSource classes from separate module
942
+ from .config_sources import (
943
+ ConfigSource,
944
+ YamlSource,
945
+ DictSource,
946
+ SpecCSVSource,
947
+ HydraSource,
948
+ MergedConfigSource,
949
+ make_config_source,
950
+ )
878
951
 
952
+ # Re-export for backward compatibility
953
+ __all__ = [
954
+ 'DFMConfig', 'SeriesConfig', 'BlockConfig', 'Params',
955
+ 'DEFAULT_GLOBAL_BLOCK_NAME',
956
+ 'ConfigSource', 'YamlSource', 'DictSource', 'SpecCSVSource',
957
+ 'HydraSource', 'MergedConfigSource', 'make_config_source',
958
+ ]
879
959
 
880
- class ConfigSource(Protocol):
881
- """Protocol for configuration sources.
882
-
883
- Any object that implements a `load()` method returning a DFMConfig
884
- can be used as a configuration source.
885
- """
886
- def load(self) -> DFMConfig:
887
- """Load and return a DFMConfig object."""
888
- ...
960
+ # Legacy class definitions removed - now in config_sources.py
961
+ # For backward compatibility, classes are imported and re-exported above
889
962
 
890
963
 
891
- class YamlSource:
892
- """Load configuration from a YAML file.
893
-
894
- Supports Hydra-style configs with defaults for series and blocks.
895
- """
896
- def __init__(self, yaml_path: Union[str, Path]):
897
- """Initialize YAML source.
898
-
899
- Parameters
900
- ----------
901
- yaml_path : str or Path
902
- Path to YAML configuration file
903
- """
904
- self.yaml_path = Path(yaml_path)
905
-
906
- def load(self) -> DFMConfig:
907
- """Load configuration from YAML file."""
908
- try:
909
- from omegaconf import OmegaConf
910
- except ImportError:
911
- raise ImportError("omegaconf is required for YAML config loading. Install with: pip install omegaconf")
912
-
913
- configfile = Path(self.yaml_path)
914
- if not configfile.exists():
915
- raise FileNotFoundError(f"Configuration file not found: {configfile}")
916
-
917
- config_dir = configfile.parent
918
- cfg = OmegaConf.load(configfile)
919
- cfg_dict = OmegaConf.to_container(cfg, resolve=True)
920
-
921
- # Extract main settings (estimation parameters)
922
- excluded_keys = {'defaults', '_target_', '_recursive_', '_convert_'}
923
- main_settings = {k: v for k, v in cfg_dict.items() if k not in excluded_keys}
924
-
925
- # Load series from config/series/default.yaml
926
- series_list = []
927
- series_loaded = False
928
- series_dict = None
929
-
930
- if 'defaults' in cfg:
931
- defaults_list = cfg.defaults
932
- for default_item in defaults_list:
933
- default_dict = OmegaConf.to_container(default_item, resolve=False) if hasattr(default_item, 'keys') else default_item
934
-
935
- # Handle dict format: {'series': 'default'}
936
- if isinstance(default_dict, dict) and 'series' in default_dict:
937
- series_config_name = default_dict['series']
938
- series_config_path = config_dir / 'series' / f'{series_config_name}.yaml'
939
- if series_config_path.exists():
940
- series_cfg = OmegaConf.load(series_config_path)
941
- series_dict = OmegaConf.to_container(series_cfg, resolve=True)
942
- series_loaded = True
943
- break
944
-
945
- # If not loaded from defaults, try direct path
946
- if not series_loaded:
947
- series_config_path = config_dir / 'series' / 'default.yaml'
948
- if series_config_path.exists():
949
- series_cfg = OmegaConf.load(series_config_path)
950
- series_dict = OmegaConf.to_container(series_cfg, resolve=True)
951
- series_loaded = True
952
-
953
- # Convert series dict to SeriesConfig objects
954
- if series_loaded and series_dict is not None:
955
- for series_id, series_data in series_dict.items():
956
- if isinstance(series_data, dict):
957
- series_list.append(SeriesConfig(
958
- series_id=series_id,
959
- series_name=series_data.get('series_name', series_id),
960
- frequency=series_data.get('frequency', 'm'),
961
- transformation=series_data.get('transformation', 'lin'),
962
- category=series_data.get('category', ''),
963
- units=series_data.get('units', ''),
964
- blocks=series_data.get('blocks', [])
965
- ))
966
-
967
- # If no series loaded from separate files, try to get from main config
968
- if not series_loaded and 'series' in cfg_dict:
969
- series_data = cfg_dict['series']
970
- if isinstance(series_data, list):
971
- for series_item in series_data:
972
- series_list.append(SeriesConfig(**series_item))
973
- elif isinstance(series_data, dict):
974
- for series_id, series_item in series_data.items():
975
- if isinstance(series_item, dict):
976
- series_item['series_id'] = series_id
977
- series_list.append(SeriesConfig(**series_item))
978
-
979
- # Load blocks from config/blocks/default.yaml
980
- blocks_dict = {}
981
- blocks_loaded = False
982
- blocks_dict_raw = None
983
-
984
- if 'defaults' in cfg:
985
- defaults_list = cfg.defaults
986
- for default_item in defaults_list:
987
- default_dict = OmegaConf.to_container(default_item, resolve=False) if hasattr(default_item, 'keys') else default_item
988
-
989
- # Handle dict format: {'blocks': 'default'}
990
- if isinstance(default_dict, dict) and 'blocks' in default_dict:
991
- blocks_config_name = default_dict['blocks']
992
- blocks_config_path = config_dir / 'blocks' / f'{blocks_config_name}.yaml'
993
- if blocks_config_path.exists():
994
- blocks_cfg = OmegaConf.load(blocks_config_path)
995
- blocks_dict_raw = OmegaConf.to_container(blocks_cfg, resolve=True)
996
- blocks_loaded = True
997
- break
998
-
999
- # If not loaded from defaults, try direct path
1000
- if not blocks_loaded:
1001
- blocks_config_path = config_dir / 'blocks' / 'default.yaml'
1002
- if blocks_config_path.exists():
1003
- blocks_cfg = OmegaConf.load(blocks_config_path)
1004
- blocks_dict_raw = OmegaConf.to_container(blocks_cfg, resolve=True)
1005
- blocks_loaded = True
1006
-
1007
- # Convert blocks dict to BlockConfig objects
1008
- if blocks_loaded and blocks_dict_raw is not None:
1009
- for block_name, block_data in blocks_dict_raw.items():
1010
- if isinstance(block_data, dict):
1011
- blocks_dict[block_name] = BlockConfig(
1012
- factors=block_data.get('factors', 1),
1013
- ar_lag=block_data.get('ar_lag', 1),
1014
- clock=block_data.get('clock', 'm'),
1015
- notes=block_data.get('notes', None)
1016
- )
1017
-
1018
- # If no blocks loaded from separate files, try to get from main config
1019
- if not blocks_loaded and 'blocks' in cfg_dict:
1020
- blocks_data = cfg_dict['blocks']
1021
- if isinstance(blocks_data, dict):
1022
- for block_name, block_item in blocks_data.items():
1023
- if isinstance(block_item, dict):
1024
- blocks_dict[block_name] = BlockConfig(**block_item)
1025
-
1026
- # Ensure at least one block exists
1027
- if not blocks_dict:
1028
- blocks_dict[DEFAULT_GLOBAL_BLOCK_NAME] = BlockConfig(factors=1, clock=main_settings.get('clock', 'm'))
1029
-
1030
- return DFMConfig(
1031
- series=series_list,
1032
- blocks=blocks_dict,
1033
- **main_settings
1034
- )
964
+ # ============================================================================
965
+ # Configuration validation functions
966
+ # ============================================================================
1035
967
 
968
+ # Valid frequency codes
969
+ _VALID_FREQUENCIES = {'d', 'w', 'm', 'q', 'sa', 'a'}
1036
970
 
1037
- class DictSource:
1038
- """Load configuration from a dictionary.
1039
-
1040
- Supports multiple dict formats:
1041
- - Legacy format: {'SeriesID': [...], 'Frequency': [...], ...}
1042
- - New format (list): {'series': [{'series_id': ..., ...}], ...}
1043
- - Hydra format: {'series': {'series_id': {...}}, 'blocks': {...}}
1044
- """
1045
- def __init__(self, mapping: Dict[str, Any]):
1046
- """Initialize dictionary source.
1047
-
1048
- Parameters
1049
- ----------
1050
- mapping : dict
1051
- Dictionary containing configuration data
1052
- """
1053
- self.mapping = mapping
1054
-
1055
- def load(self) -> DFMConfig:
1056
- """Load configuration from dictionary.
1057
-
1058
- If the dictionary is partial (e.g., only max_iter, threshold),
1059
- it will be merged with a minimal default config.
1060
- """
1061
- # Check if this is a partial config (missing series or blocks)
1062
- has_series = 'series' in self.mapping and self.mapping['series']
1063
- has_blocks = 'blocks' in self.mapping and self.mapping['blocks']
1064
-
1065
- if not has_series or not has_blocks:
1066
- # This is a partial config - create a minimal default and merge
1067
- minimal_default = {
1068
- 'series': [],
1069
- 'blocks': {},
1070
- 'clock': 'm',
1071
- 'max_iter': 5000,
1072
- 'threshold': 1e-5
1073
- }
1074
- # Merge: mapping takes precedence
1075
- merged = {**minimal_default, **self.mapping}
1076
- return DFMConfig.from_dict(merged)
1077
-
1078
- return DFMConfig.from_dict(self.mapping)
971
+ # Valid transformation codes
972
+ _VALID_TRANSFORMATIONS = {
973
+ 'lin', 'chg', 'ch1', 'pch', 'pc1', 'pca',
974
+ 'cch', 'cca', 'log'
975
+ }
1079
976
 
1080
977
 
1081
- class SpecCSVSource:
1082
- """Load series definitions from a spec CSV file.
1083
-
1084
- This source reads series definitions and block memberships from a CSV.
1085
- It constructs a valid DFMConfig with sensible defaults for other
1086
- parameters (e.g., clock='m', threshold=1e-5, max_iter=5000, factors=1).
1087
-
1088
- For custom main settings (threshold, max_iter, clock, per-block factors),
1089
- you can still merge with a base config via MergedConfigSource or
1090
- dfm.load_config(base=..., override=spec_path).
1091
- """
1092
- def __init__(self, spec_path: Union[str, Path]):
1093
- """Initialize spec CSV source.
1094
-
1095
- Parameters
1096
- ----------
1097
- spec_path : str or Path
1098
- Path to spec CSV file
1099
- """
1100
- self.spec_path = Path(spec_path)
978
+ def validate_frequency(frequency: str) -> str:
979
+ """Validate frequency code.
1101
980
 
1102
- def load(self) -> DFMConfig:
1103
- """Load series definitions from spec CSV."""
1104
- import pandas as pd
1105
- from .data_loader import _load_config_from_dataframe
1106
-
1107
- specfile = Path(self.spec_path)
1108
- if not specfile.exists():
1109
- raise FileNotFoundError(f"Spec file not found: {specfile}")
981
+ Parameters
982
+ ----------
983
+ frequency : str
984
+ Frequency code to validate
1110
985
 
1111
- try:
1112
- df_full = pd.read_csv(specfile)
1113
- except Exception as e:
1114
- raise ValueError(f"Failed to read spec file {specfile}: {e}")
986
+ Returns
987
+ -------
988
+ str
989
+ Validated frequency code (same as input if valid)
1115
990
 
1116
- return _load_config_from_dataframe(df_full)
1117
-
1118
-
1119
- class HydraSource:
1120
- """Load configuration from a Hydra DictConfig or dict.
1121
-
1122
- This adapter handles Hydra's composed configuration objects,
1123
- converting them to DFMConfig format.
1124
- """
1125
- def __init__(self, cfg: Union[Dict[str, Any], 'DictConfig']):
1126
- """Initialize Hydra source.
991
+ Raises
992
+ ------
993
+ ValueError
994
+ If frequency is not in the set of valid frequencies
1127
995
 
1128
- Parameters
1129
- ----------
1130
- cfg : DictConfig or dict
1131
- Hydra configuration object or dictionary in Hydra format
1132
- """
1133
- self.cfg = cfg
1134
-
1135
- def load(self) -> DFMConfig:
1136
- """Load configuration from Hydra DictConfig/dict."""
1137
- return DFMConfig.from_hydra(self.cfg)
1138
-
1139
-
1140
- class MergedConfigSource:
1141
- """Merge multiple configuration sources.
1142
-
1143
- This allows combining configurations from different sources,
1144
- e.g., base YAML config + series from Spec CSV.
1145
-
1146
- The merge strategy:
1147
- - Base config provides main settings (threshold, max_iter, clock, blocks)
1148
- - Override config provides series definitions (replaces base series)
1149
- - Block definitions are merged (override takes precedence)
996
+ Examples
997
+ --------
998
+ >>> validate_frequency('m')
999
+ 'm'
1000
+ >>> validate_frequency('invalid')
1001
+ ValueError: Invalid frequency: invalid. Must be one of {'d', 'w', 'm', 'q', 'sa', 'a'}
1150
1002
  """
1151
- def __init__(self, base: ConfigSource, override: ConfigSource):
1152
- """Initialize merged config source.
1153
-
1154
- Parameters
1155
- ----------
1156
- base : ConfigSource
1157
- Base configuration (provides main settings)
1158
- override : ConfigSource
1159
- Override configuration (provides series/block overrides)
1160
- """
1161
- self.base = base
1162
- self.override = override
1163
-
1164
- def load(self) -> DFMConfig:
1165
- """Load and merge configurations."""
1166
- from dataclasses import fields
1167
-
1168
- base_cfg = self.base.load()
1169
-
1170
- # Check if override is a partial config (DictSource with partial dict)
1171
- override_is_partial = False
1172
- if isinstance(self.override, DictSource):
1173
- has_series = 'series' in self.override.mapping and self.override.mapping['series']
1174
- has_blocks = 'blocks' in self.override.mapping and self.override.mapping['blocks']
1175
- override_is_partial = not (has_series and has_blocks)
1176
-
1177
- if override_is_partial:
1178
- # Handle partial override: merge fields directly without loading full DFMConfig
1179
- override_dict = self.override.mapping
1180
- override_cfg = base_cfg # Use base as template
1181
- else:
1182
- override_cfg = self.override.load()
1183
-
1184
- # Merge blocks: override takes precedence
1185
- if override_is_partial and 'blocks' in override_dict:
1186
- # Merge block dicts
1187
- merged_blocks = {**base_cfg.blocks, **override_dict['blocks']}
1188
- else:
1189
- merged_blocks = {**base_cfg.blocks, **override_cfg.blocks}
1190
-
1191
- # Use override's series if provided and non-empty, otherwise use base's series
1192
- if override_is_partial:
1193
- if 'series' in override_dict and override_dict['series']:
1194
- merged_series = override_dict['series']
1195
- else:
1196
- merged_series = base_cfg.series
1197
- else:
1198
- merged_series = override_cfg.series if (override_cfg.series and len(override_cfg.series) > 0) else base_cfg.series
1199
-
1200
- # Get all config fields (excluding derived/computed fields)
1201
- excluded_fields = {'series', 'blocks', 'block_names', 'factors_per_block', '_cached_blocks'}
1202
- base_settings = {
1203
- field.name: getattr(base_cfg, field.name)
1204
- for field in fields(DFMConfig)
1205
- if field.name not in excluded_fields
1206
- }
1207
-
1208
- # Override settings from override_cfg or override_dict
1209
- if override_is_partial:
1210
- override_settings = {
1211
- field.name: override_dict.get(field.name, getattr(base_cfg, field.name))
1212
- for field in fields(DFMConfig)
1213
- if field.name not in excluded_fields
1214
- }
1215
- else:
1216
- override_settings = {
1217
- field.name: getattr(override_cfg, field.name)
1218
- for field in fields(DFMConfig)
1219
- if field.name not in excluded_fields
1220
- and hasattr(override_cfg, field.name)
1221
- }
1222
-
1223
- # Merge settings: base + override (override takes precedence)
1224
- merged_settings = {**base_settings, **override_settings}
1225
-
1226
- # Create merged config: merged settings + merged series + merged blocks
1227
- return DFMConfig(
1228
- series=merged_series,
1229
- blocks=merged_blocks,
1230
- **merged_settings
1231
- )
1003
+ if frequency not in _VALID_FREQUENCIES:
1004
+ raise ValueError(f"Invalid frequency: {frequency}. Must be one of {_VALID_FREQUENCIES}")
1005
+ return frequency
1232
1006
 
1233
1007
 
1234
- def make_config_source(
1235
- source: Optional[Union[str, Path, Dict[str, Any], ConfigSource]] = None,
1236
- *,
1237
- yaml: Optional[Union[str, Path]] = None,
1238
- mapping: Optional[Union[Dict[str, Any], Any]] = None,
1239
- spec: Optional[Union[str, Path]] = None,
1240
- hydra: Optional[Union[Dict[str, Any], 'DictConfig']] = None,
1241
- ) -> ConfigSource:
1242
- """Create a ConfigSource adapter from various input formats.
1243
-
1244
- This factory function automatically selects the appropriate adapter
1245
- based on the input type or explicit keyword arguments.
1008
+ def validate_transformation(transformation: str) -> str:
1009
+ """Validate transformation code.
1246
1010
 
1247
1011
  Parameters
1248
1012
  ----------
1249
- source : str, Path, dict, or ConfigSource, optional
1250
- Configuration source. If a ConfigSource, returned as-is.
1251
- If str/Path, treated as YAML file path.
1252
- If dict, treated as dictionary config.
1253
- yaml : str or Path, optional
1254
- Explicit YAML file path
1255
- mapping : dict, optional
1256
- Explicit dictionary config
1257
- spec : str or Path, optional
1258
- Explicit spec CSV file path
1259
- hydra : DictConfig or dict, optional
1260
- Explicit Hydra config
1013
+ transformation : str
1014
+ Transformation code to validate
1261
1015
 
1262
1016
  Returns
1263
1017
  -------
1264
- ConfigSource
1265
- Appropriate adapter for the input
1266
-
1018
+ str
1019
+ Validated transformation code (same as input, even if unknown)
1020
+
1021
+ Notes
1022
+ -----
1023
+ Unknown transformation codes trigger a warning but are not rejected,
1024
+ allowing for extensibility. The code will be used as-is, and the
1025
+ transformation logic should handle unknown codes appropriately.
1026
+
1267
1027
  Examples
1268
1028
  --------
1269
- >>> # From YAML file
1270
- >>> source = make_config_source('config/default.yaml')
1271
- >>>
1272
- >>> # From dictionary
1273
- >>> source = make_config_source({'series': [...], 'clock': 'm'})
1274
- >>>
1275
- >>> # Explicit keyword arguments
1276
- >>> source = make_config_source(yaml='config/default.yaml')
1277
- >>> source = make_config_source(spec='data/spec.csv')
1278
- >>>
1279
- >>> # Merge YAML base + Spec CSV series
1280
- >>> base = make_config_source(yaml='config/default.yaml')
1281
- >>> override = make_config_source(spec='data/spec.csv')
1282
- >>> merged = MergedConfigSource(base, override)
1029
+ >>> validate_transformation('lin')
1030
+ 'lin'
1031
+ >>> validate_transformation('unknown') # Issues warning but returns value
1032
+ 'unknown'
1283
1033
  """
1284
- # Check for explicit keyword arguments (only one allowed)
1285
- explicit_kwargs = [k for k, v in [('yaml', yaml), ('mapping', mapping), ('spec', spec), ('hydra', hydra)] if v is not None]
1286
- if len(explicit_kwargs) > 1:
1287
- raise ValueError(
1288
- f"Only one of yaml, mapping, spec, or hydra can be specified. "
1289
- f"Got: {', '.join(explicit_kwargs)}. "
1290
- f"For merging configs, use MergedConfigSource."
1291
- )
1292
-
1293
- # Helper: coerce arbitrary object with attributes into dict
1294
- def _coerce_to_mapping(obj: Any) -> Dict[str, Any]:
1295
- if isinstance(obj, dict):
1296
- return obj
1297
- if is_dataclass(obj):
1298
- return asdict(obj)
1299
- if hasattr(obj, "__dict__"):
1300
- try:
1301
- return dict(vars(obj))
1302
- except Exception:
1303
- pass
1304
- raise TypeError(
1305
- f"Unsupported mapping type: {type(obj)}. "
1306
- f"Provide a dict, dataclass instance, or an object with attributes."
1307
- )
1308
-
1309
- # Handle explicit keyword arguments
1310
- if yaml is not None:
1311
- return YamlSource(yaml)
1312
- if mapping is not None:
1313
- return DictSource(_coerce_to_mapping(mapping))
1314
- if spec is not None:
1315
- return SpecCSVSource(spec)
1316
- if hydra is not None:
1317
- return HydraSource(hydra)
1318
-
1319
- # Infer from source argument
1320
- if source is None:
1321
- raise ValueError(
1322
- "No configuration source provided. "
1323
- "Specify source, yaml, mapping, spec, or hydra."
1324
- )
1325
-
1326
- # If already a ConfigSource, return as-is
1327
- if hasattr(source, 'load') and callable(getattr(source, 'load')):
1328
- return source # type: ignore
1329
-
1330
- # Infer type from source
1331
- if isinstance(source, DFMConfig):
1332
- # Wrap DFMConfig in a simple adapter
1333
- class DFMConfigAdapter:
1334
- def __init__(self, cfg: DFMConfig):
1335
- self._cfg = cfg
1336
- def load(self) -> DFMConfig:
1337
- return self._cfg
1338
- return DFMConfigAdapter(source)
1339
-
1340
- if isinstance(source, (str, Path)):
1341
- path = Path(source)
1342
- suffix = path.suffix.lower()
1343
- if suffix in ['.yaml', '.yml']:
1344
- return YamlSource(path)
1345
- elif suffix == '.csv':
1346
- return SpecCSVSource(path)
1347
- else:
1348
- # Default to YAML if extension unclear
1349
- return YamlSource(path)
1350
-
1351
- if isinstance(source, dict):
1352
- return DictSource(source)
1353
- # Accept objects that can be coerced into dict (dataclass or attribute bag)
1354
- try:
1355
- coerced = _coerce_to_mapping(source)
1356
- return DictSource(coerced)
1357
- except Exception:
1358
- pass
1359
-
1360
- raise TypeError(
1361
- f"Unsupported source type: {type(source)}. "
1362
- f"Expected str, Path, dict, ConfigSource, or DFMConfig."
1363
- )
1034
+ import warnings
1035
+ if transformation not in _VALID_TRANSFORMATIONS:
1036
+ warnings.warn(f"Unknown transformation code: {transformation}. Will use untransformed data.")
1037
+ return transformation