trajmod 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.
trajmod/__init__.py ADDED
@@ -0,0 +1,21 @@
1
+ """trajmod: Simple GNSS trajectory modeling.
2
+
3
+ A Python library for modeling GNSS displacement time series with support for
4
+ earthquakes, slow slip events, and advanced multi-tier postseismic filtering.
5
+ """
6
+
7
+ __version__ = "0.1.0"
8
+ __author__ = "Giuseppe Costantino"
9
+ __license__ = "MIT"
10
+
11
+ # Import main classes for convenient access
12
+ from trajmod.model.model import TrajectoryModel
13
+ from trajmod.config.modelconfig import ModelConfig
14
+ from trajmod.model.results import ModelResults
15
+
16
+ __all__ = [
17
+ "TrajectoryModel",
18
+ "ModelConfig",
19
+ "ModelResults",
20
+ "__version__",
21
+ ]
@@ -0,0 +1,5 @@
1
+ """Configuration module for trajmod."""
2
+
3
+ from trajmod.config.modelconfig import ModelConfig
4
+
5
+ __all__ = ["ModelConfig"]
@@ -0,0 +1,79 @@
1
+ """Configuration management for GNSS trajectory modeling."""
2
+ from dataclasses import dataclass, field
3
+ from typing import List, Optional, Any
4
+
5
+ import numpy as np
6
+
7
+
8
+ @dataclass
9
+ class ModelConfig:
10
+ """Configuration for TrajectoryModel parameters.
11
+
12
+ Attributes:
13
+ d_param: Empirical scaling parameter in radius law
14
+ include_seasonal: Whether to include annual & semi-annual terms
15
+ use_envelope_basis: Whether to use envelope basis functions
16
+ postseismic_mag_threshold: Minimum magnitude for post-seismic template
17
+ tau_grid: Candidate tau values (days) for post-seismic decay
18
+ merge_close_sse: Whether to merge close SSE templates
19
+ merge_earthquakes_same_day: Whether to merge earthquakes on same day
20
+ acceleration_term: Whether to include acceleration term
21
+ envelope_periods: Periods (days) for envelope basis functions
22
+ gap_merge_threshold: Threshold (days) for merging close events
23
+ bspline_knots: Number of knots for B-spline basis
24
+ bspline_degree: Degree of B-spline basis
25
+ """
26
+ d_param: Optional[float] = 1.0
27
+ include_seasonal: bool = True
28
+ use_envelope_basis: bool = False
29
+ postseismic_mag_threshold: float = 6.5
30
+ tau_grid: List[int] = field(default_factory=lambda: [7, 14, 30, 60, 90, 180, 1800])
31
+
32
+
33
+ fit_postseismic_decay: bool = True
34
+ postseismic_selection_criterion: str = 'aic' # 'aic', 'bic', 'ftest', 'always'
35
+ postseismic_selection_threshold: float = -2 # ΔAIC/ΔBIC < -2 or p < 0.05
36
+ fit_best_postseismic_tau: bool = True
37
+ enforce_postseismic_sign_consistency: bool = True
38
+ postseismic_min_step_amplitude: float = 5.0 # mm
39
+
40
+
41
+ merge_close_sse: bool = False
42
+ merge_earthquakes_same_day: bool = False
43
+ acceleration_term: bool = True
44
+
45
+ # envelope periods (60-365 days, not 1-6 years!)
46
+ envelope_periods: np.ndarray = field(
47
+ default_factory=lambda: np.array([
48
+ 365.25 / 6, # ~60.875 days (2 months)
49
+ 365.25 / 5, # ~73.05 days
50
+ 365.25 / 4, # ~91.3125 days (3 months)
51
+ 365.25 / 3, # ~121.75 days (4 months)
52
+ 365.25 / 2, # ~182.625 days (6 months)
53
+ 365.25 # 365.25 days (1 year)
54
+ ])
55
+ )
56
+
57
+ gap_merge_threshold: int = 5
58
+ bspline_knots: int = 20
59
+ bspline_degree: int = 3
60
+
61
+
62
+ def __post_init__(self):
63
+ """Validate configuration parameters."""
64
+ if self.d_param is not None and self.d_param <= 0:
65
+ raise ValueError(f"d_param must be positive, got {self.d_param}")
66
+
67
+ if self.postseismic_mag_threshold < 0:
68
+ raise ValueError("postseismic_mag_threshold must be non-negative")
69
+
70
+ # NEW: Validate min step amplitude
71
+ if self.postseismic_min_step_amplitude < 0:
72
+ raise ValueError("postseismic_min_step_amplitude must be non-negative")
73
+
74
+ if any(tau <= 0 for tau in self.tau_grid):
75
+ raise ValueError("All tau_grid values must be positive")
76
+
77
+ valid_criteria = ['aic', 'bic', 'ftest', 'always']
78
+ if self.postseismic_selection_criterion not in valid_criteria:
79
+ raise ValueError(f"postseismic_selection_criterion must be one of {valid_criteria}")
@@ -0,0 +1,2 @@
1
+ from trajmod.design.matrix import DesignMatrixBuilder
2
+ __all__ = ["DesignMatrixBuilder"]
@@ -0,0 +1,99 @@
1
+ """Design matrix construction for trajectory models."""
2
+ import numpy as np
3
+ from typing import List, Dict, Tuple
4
+ import logging
5
+
6
+ from trajmod.config.modelconfig import ModelConfig
7
+ from trajmod.templates.templates import TemplateFunctions
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+
12
+ class DesignMatrixBuilder:
13
+ """Builder for design matrix G."""
14
+
15
+ def __init__(self, templates: TemplateFunctions, config: ModelConfig):
16
+ self.templates = templates
17
+ self.config = config
18
+ self.template_names: List[str] = []
19
+
20
+ def build(self, selected_sse: List[Dict], selected_eq: List[Dict]) -> Tuple[np.ndarray, List[str]]:
21
+ """Build complete design matrix."""
22
+ self.template_names = []
23
+ columns = []
24
+
25
+ columns.extend(self._build_base_model())
26
+
27
+ if self.config.include_seasonal:
28
+ columns.extend(self._build_seasonal())
29
+
30
+ columns.extend(self._build_sse_templates(selected_sse))
31
+ columns.extend(self._build_earthquake_templates(selected_eq))
32
+
33
+ G = np.column_stack(columns) if columns else np.empty((len(self.templates.t_days), 0))
34
+
35
+ logger.info(f"Design matrix: {G.shape[0]} x {G.shape[1]}")
36
+ return G, self.template_names
37
+
38
+ def _build_base_model(self) -> List[np.ndarray]:
39
+ columns = []
40
+ columns.append(self.templates.offset())
41
+ self.template_names.append('offset')
42
+ columns.append(self.templates.trend())
43
+ self.template_names.append('trend')
44
+
45
+ if self.config.acceleration_term:
46
+ columns.append(self.templates.acceleration())
47
+ self.template_names.append('acceleration')
48
+
49
+ return columns
50
+
51
+ def _build_seasonal(self) -> List[np.ndarray]:
52
+ columns = []
53
+ columns.append(self.templates.seasonal_sin(365.25))
54
+ self.template_names.append('annual_sin')
55
+ columns.append(self.templates.seasonal_cos(365.25))
56
+ self.template_names.append('annual_cos')
57
+ columns.append(self.templates.seasonal_sin(365.25 / 2))
58
+ self.template_names.append('semiannual_sin')
59
+ columns.append(self.templates.seasonal_cos(365.25 / 2))
60
+ self.template_names.append('semiannual_cos')
61
+
62
+ '''if self.config.use_envelope_basis:
63
+ for T in self.config.envelope_periods:
64
+ columns.append(self.templates.seasonal_sin(T))
65
+ self.template_names.append(f'envelope_sin_{int(T / 365.25)}yr')
66
+ columns.append(self.templates.seasonal_cos(T))
67
+ self.template_names.append(f'envelope_cos_{int(T / 365.25)}yr')'''
68
+
69
+ if self.config.use_envelope_basis:
70
+ for T in self.config.envelope_periods:
71
+ columns.append(self.templates.seasonal_sin(T))
72
+ self.template_names.append(f'envelope_sin_{int(T)}d')
73
+ columns.append(self.templates.seasonal_cos(T))
74
+ self.template_names.append(f'envelope_cos_{int(T)}d')
75
+
76
+ return columns
77
+
78
+ def _build_sse_templates(self, selected_sse: List[Dict]) -> List[np.ndarray]:
79
+ columns = []
80
+ for i, event in enumerate(selected_sse):
81
+ rc = self.templates.raised_cosine(event['start_day'], event['end_day'])
82
+ columns.append(rc)
83
+ start_str = event['start_day'].strftime('%Y-%m-%d')
84
+ end_str = event['end_day'].strftime('%Y-%m-%d')
85
+ self.template_names.append(f'SSE_{i}_{start_str}_to_{end_str}')
86
+ return columns
87
+
88
+ def _build_earthquake_templates(self, selected_eq: List[Dict]) -> List[np.ndarray]:
89
+ columns = []
90
+ for i, eq in enumerate(selected_eq):
91
+ eq_date = eq['eq_day']
92
+
93
+ step = self.templates.step(eq_date)
94
+ columns.append(step)
95
+ eq_str = eq_date.strftime('%Y-%m-%d')
96
+ self.template_names.append(f'EQ_{i}_{eq_str}_step')
97
+
98
+ return columns
99
+
@@ -0,0 +1,15 @@
1
+ """Event catalog and selection module."""
2
+
3
+ from trajmod.events.events import EventCatalog
4
+ from trajmod.events.event_selection import (
5
+ EventSelector,
6
+ KneeEventSelector,
7
+ LassoEventSelector,
8
+ )
9
+
10
+ __all__ = [
11
+ "EventCatalog",
12
+ "EventSelector",
13
+ "KneeEventSelector",
14
+ "LassoEventSelector",
15
+ ]
@@ -0,0 +1,149 @@
1
+ """Amplitude-based event selection."""
2
+ import logging
3
+ from typing import List, Dict, Optional
4
+
5
+ import numpy as np
6
+
7
+ from .event_selection import EventSelector
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+
12
+ class AmplitudeThresholdSelector(EventSelector):
13
+ """Select events based on fitted amplitude threshold.
14
+
15
+ Fits all events and keeps only those with amplitude above threshold.
16
+ """
17
+
18
+ def __init__(self, threshold: float = 2.0, mode: str = 'absolute'):
19
+ """Initialize amplitude threshold selector.
20
+
21
+ Args:
22
+ threshold: Threshold value
23
+ mode: Threshold mode:
24
+ - 'absolute': Keep events with |amplitude| > threshold (mm)
25
+ - 'relative': Keep events with |amplitude| > threshold × RMS(data)
26
+ - 'snr': Keep events with |amplitude|/σ > threshold
27
+ """
28
+ if mode not in ['absolute', 'relative', 'snr']:
29
+ raise ValueError(f"mode must be 'absolute', 'relative', or 'snr', got {mode}")
30
+
31
+ self.threshold = threshold
32
+ self.mode = mode
33
+ self.last_amplitudes = None
34
+ self.last_threshold_value = None
35
+
36
+ def select(self, y: np.ndarray, G_baseline: np.ndarray,
37
+ event_templates: List[Dict], valid_mask: np.ndarray) -> List[int]:
38
+ """Select events with amplitude above threshold."""
39
+
40
+ if len(event_templates) == 0:
41
+ return []
42
+
43
+ # Fit full model to get event amplitudes
44
+ event_matrix = np.column_stack([ev['template'] for ev in event_templates])
45
+ G_full = np.hstack([G_baseline, event_matrix])
46
+
47
+ Gsub = G_full[valid_mask, :]
48
+ ysub = y[valid_mask]
49
+
50
+ try:
51
+ coeffs_full, _, _, _ = np.linalg.lstsq(Gsub, ysub, rcond=None)
52
+ except np.linalg.LinAlgError:
53
+ logger.warning("Singular matrix in amplitude selection, returning empty")
54
+ return []
55
+
56
+ # Extract event coefficients
57
+ n_baseline = G_baseline.shape[1]
58
+ event_coeffs = coeffs_full[n_baseline:]
59
+
60
+ # Store for debugging
61
+ self.last_amplitudes = event_coeffs
62
+
63
+ # Compute threshold based on mode
64
+ if self.mode == 'absolute':
65
+ threshold_value = self.threshold
66
+ elif self.mode == 'relative':
67
+ # Threshold relative to data RMS
68
+ data_rms = np.sqrt(np.mean(y[valid_mask] ** 2))
69
+ threshold_value = self.threshold * data_rms
70
+ else: # snr
71
+ # Estimate noise level using MAD
72
+ from src.trajmod.model.model_selection import ModelSelector
73
+ # Compute residuals from full fit
74
+ fitted = G_full @ coeffs_full
75
+ residuals = y - fitted
76
+ sigma = ModelSelector.mad_sigma(residuals[valid_mask])
77
+ # SNR = |amplitude| / sigma > threshold
78
+ # So amplitude threshold = threshold * sigma
79
+ threshold_value = self.threshold * sigma
80
+
81
+ self.last_threshold_value = threshold_value
82
+
83
+ # Select events above threshold
84
+ selected_mask = np.abs(event_coeffs) >= threshold_value
85
+ selected_indices = np.where(selected_mask)[0].tolist()
86
+
87
+ logger.info(f"Amplitude selector ({self.mode}, threshold={self.threshold}) "
88
+ f"selected {len(selected_indices)}/{len(event_templates)} events "
89
+ f"(effective threshold={threshold_value:.2f} mm)")
90
+
91
+ return selected_indices
92
+
93
+ def plot_amplitudes(self, event_names: Optional[List[str]] = None,
94
+ figsize=(10, 6), save_path: Optional[str] = None):
95
+ """Plot event amplitudes with threshold line.
96
+
97
+ Args:
98
+ event_names: Optional list of event names for x-axis labels
99
+ figsize: Figure size
100
+ save_path: Path to save plot
101
+ """
102
+ if self.last_amplitudes is None:
103
+ raise ValueError("Must call select() before plotting")
104
+
105
+ import matplotlib.pyplot as plt
106
+
107
+ fig, ax = plt.subplots(figsize=figsize)
108
+
109
+ n_events = len(self.last_amplitudes)
110
+ x = np.arange(n_events)
111
+ amplitudes = self.last_amplitudes
112
+
113
+ # Sort by amplitude for better visualization
114
+ sorted_idx = np.argsort(-np.abs(amplitudes))
115
+ amplitudes_sorted = amplitudes[sorted_idx]
116
+
117
+ # Plot bars
118
+ colors = ['steelblue' if abs(a) >= self.last_threshold_value else 'lightgray'
119
+ for a in amplitudes_sorted]
120
+ bars = ax.bar(x, amplitudes_sorted, color=colors, alpha=0.7, edgecolor='black')
121
+
122
+ # Add threshold lines
123
+ ax.axhline(self.last_threshold_value, color='red', linestyle='--',
124
+ linewidth=2, label=f'Threshold (+{self.last_threshold_value:.2f} mm)')
125
+ ax.axhline(-self.last_threshold_value, color='red', linestyle='--',
126
+ linewidth=2, label=f'Threshold (−{self.last_threshold_value:.2f} mm)')
127
+ ax.axhline(0, color='black', linewidth=0.8)
128
+
129
+ ax.set_xlabel('Event Index (sorted by amplitude)', fontsize=12)
130
+ ax.set_ylabel('Amplitude (mm)', fontsize=12)
131
+ ax.set_title(f'Event Amplitudes - {self.mode.capitalize()} Threshold',
132
+ fontsize=14, fontweight='bold')
133
+ ax.legend(fontsize=11)
134
+ ax.grid(True, alpha=0.3, axis='y')
135
+
136
+ # Add text showing selection
137
+ n_selected = np.sum(np.abs(amplitudes) >= self.last_threshold_value)
138
+ ax.text(0.98, 0.98, f'{n_selected}/{n_events} events selected',
139
+ transform=ax.transAxes, ha='right', va='top',
140
+ bbox=dict(boxstyle='round', facecolor='wheat', alpha=0.8),
141
+ fontsize=11)
142
+
143
+ plt.tight_layout()
144
+
145
+ if save_path:
146
+ plt.savefig(save_path, dpi=300, bbox_inches='tight')
147
+ logger.info(f"Saved amplitude plot to {save_path}")
148
+
149
+ return fig, ax