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 +21 -0
- trajmod/config/__init__.py +5 -0
- trajmod/config/modelconfig.py +79 -0
- trajmod/design/__init__.py +2 -0
- trajmod/design/matrix.py +99 -0
- trajmod/events/__init__.py +15 -0
- trajmod/events/amplitude_threshold_selector.py +149 -0
- trajmod/events/event_selection.py +574 -0
- trajmod/events/events.py +170 -0
- trajmod/example.py +198 -0
- trajmod/model/__init__.py +6 -0
- trajmod/model/model.py +1014 -0
- trajmod/model/model_selection.py +274 -0
- trajmod/model/results.py +63 -0
- trajmod/model/uncertainty_quantification.py +266 -0
- trajmod/preprocessing/__init__.py +4 -0
- trajmod/preprocessing/geodetic_utils.py +63 -0
- trajmod/preprocessing/preprocessing.py +65 -0
- trajmod/preprocessing/time_utils.py +95 -0
- trajmod/strategies/__init__.py +17 -0
- trajmod/strategies/fitting.py +99 -0
- trajmod/templates/__init__.py +5 -0
- trajmod/templates/templates.py +124 -0
- trajmod/visualization.py +309 -0
- trajmod-0.1.0.dist-info/METADATA +378 -0
- trajmod-0.1.0.dist-info/RECORD +29 -0
- trajmod-0.1.0.dist-info/WHEEL +5 -0
- trajmod-0.1.0.dist-info/licenses/LICENSE +21 -0
- trajmod-0.1.0.dist-info/top_level.txt +1 -0
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,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}")
|
trajmod/design/matrix.py
ADDED
|
@@ -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
|