ddDT-GFM 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.
dddt_gfm/__init__.py ADDED
@@ -0,0 +1,9 @@
1
+ """ddDT-GFM: data-driven Digital Twin Graph Foundation Models for fleet-level
2
+ system performance prediction using spatiotemporal graph neural networks.
3
+ """
4
+ from importlib.metadata import PackageNotFoundError, version
5
+
6
+ try:
7
+ __version__ = version("ddDT-GFM")
8
+ except PackageNotFoundError: # package is not installed
9
+ __version__ = "0.0.0"
File without changes
@@ -0,0 +1,358 @@
1
+ """
2
+ Physics-Informed Constraints for Data-Driven Digital Twins
3
+
4
+ This module implements physics-based validation and constraints that can be
5
+ applied during data preprocessing and model training. Addresses Reviewer Concern #1.
6
+
7
+ Key Features:
8
+ - Domain-specific physics validation
9
+ - Measurement filtering based on physical limits
10
+ - Integration with selective loss calculation
11
+ """
12
+
13
+ import numpy as np
14
+ import torch
15
+ from typing import Dict, Callable, Optional, Tuple
16
+ from abc import ABC, abstractmethod
17
+
18
+
19
+ class PhysicsConstraint(ABC):
20
+ """Base class for physics-informed constraints"""
21
+
22
+ @abstractmethod
23
+ def validate(self, data: Dict[str, np.ndarray]) -> np.ndarray:
24
+ """
25
+ Validate data against physics constraints
26
+
27
+ Args:
28
+ data: Dictionary of measurement arrays
29
+
30
+ Returns:
31
+ Boolean mask where True indicates valid data
32
+ """
33
+ pass
34
+
35
+ @abstractmethod
36
+ def get_theoretical_limit(self, data: Dict[str, np.ndarray]) -> np.ndarray:
37
+ """
38
+ Calculate theoretical limit based on physics
39
+
40
+ Args:
41
+ data: Dictionary of input measurements
42
+
43
+ Returns:
44
+ Array of theoretical maximum/minimum values
45
+ """
46
+ pass
47
+
48
+
49
+ class PVPowerConstraint(PhysicsConstraint):
50
+ """
51
+ Photovoltaic power constraint: Power cannot exceed theoretical maximum
52
+
53
+ Formula: max_power = irradiance * area * efficiency_limit
54
+ Default efficiency_limit = 0.25 (25% maximum efficiency)
55
+
56
+ References:
57
+ - Shockley-Queisser limit for single-junction solar cells
58
+ - Realistic field efficiency accounting for temperature, soiling, etc.
59
+ """
60
+
61
+ def __init__(self, area: float = 1.0, efficiency_limit: float = 0.25):
62
+ """
63
+ Args:
64
+ area: Panel area in m^2
65
+ efficiency_limit: Maximum theoretical efficiency (default 0.25 = 25%)
66
+ """
67
+ self.area = area
68
+ self.efficiency_limit = efficiency_limit
69
+
70
+ def validate(self, data: Dict[str, np.ndarray]) -> np.ndarray:
71
+ """
72
+ Validate PV power measurements
73
+
74
+ Args:
75
+ data: Must contain 'irradiance' (W/m^2) and 'dc_power' (W)
76
+
77
+ Returns:
78
+ Boolean mask: True where power ≤ theoretical maximum
79
+ """
80
+ irradiance = data['irradiance']
81
+ dc_power = data['dc_power']
82
+
83
+ max_power = self.get_theoretical_limit(data)
84
+
85
+ # Allow small tolerance for measurement uncertainty
86
+ tolerance = 1.05 # 5% tolerance
87
+ valid_mask = dc_power <= (max_power * tolerance)
88
+
89
+ # Also check for negative power (non-physical)
90
+ valid_mask &= (dc_power >= 0)
91
+
92
+ return valid_mask
93
+
94
+ def get_theoretical_limit(self, data: Dict[str, np.ndarray]) -> np.ndarray:
95
+ """
96
+ Calculate theoretical maximum power
97
+
98
+ Formula: P_max = G * A * η_max
99
+ where G = irradiance, A = area, η_max = efficiency limit
100
+ """
101
+ irradiance = data['irradiance']
102
+ return irradiance * self.area * self.efficiency_limit
103
+
104
+ def flag_violations(self, data: Dict[str, np.ndarray]) -> Tuple[np.ndarray, float]:
105
+ """
106
+ Identify and quantify constraint violations
107
+
108
+ Returns:
109
+ violation_mask: Boolean array of violations
110
+ violation_rate: Fraction of measurements violating constraint
111
+ """
112
+ valid_mask = self.validate(data)
113
+ violation_mask = ~valid_mask
114
+ violation_rate = np.mean(violation_mask)
115
+
116
+ return violation_mask, violation_rate
117
+
118
+
119
+ class LPBFEnergyDensityConstraint(PhysicsConstraint):
120
+ """
121
+ Laser Powder Bed Fusion energy density relationship
122
+
123
+ Formula: energy_density = laser_power / scan_speed
124
+
125
+ This physics-based relationship ensures that the reported energy density
126
+ is consistent with the process parameters (laser power and scan speed).
127
+ """
128
+
129
+ def __init__(self, tolerance: float = 0.02):
130
+ """
131
+ Args:
132
+ tolerance: Relative tolerance for energy density calculation (default 2%)
133
+ """
134
+ self.tolerance = tolerance
135
+
136
+ def validate(self, data: Dict[str, np.ndarray]) -> np.ndarray:
137
+ """
138
+ Validate energy density calculations
139
+
140
+ Args:
141
+ data: Must contain 'laser_power' (W), 'scan_speed' (mm/s),
142
+ and 'energy_density' (J/mm)
143
+ """
144
+ calculated_ed = self.get_theoretical_limit(data)
145
+ reported_ed = data['energy_density']
146
+
147
+ # Check relative error
148
+ relative_error = np.abs(calculated_ed - reported_ed) / (calculated_ed + 1e-10)
149
+ valid_mask = relative_error <= self.tolerance
150
+
151
+ # Also check for non-physical values
152
+ valid_mask &= (data['laser_power'] > 0)
153
+ valid_mask &= (data['scan_speed'] > 0)
154
+ valid_mask &= (data['energy_density'] > 0)
155
+
156
+ return valid_mask
157
+
158
+ def get_theoretical_limit(self, data: Dict[str, np.ndarray]) -> np.ndarray:
159
+ """
160
+ Calculate energy density from laser power and scan speed
161
+
162
+ E = P / v
163
+ where E = energy density, P = laser power, v = scan speed
164
+ """
165
+ laser_power = data['laser_power'] # W
166
+ scan_speed = data['scan_speed'] # mm/s
167
+
168
+ return laser_power / scan_speed # J/mm
169
+
170
+
171
+ class DIWPositionConstraint(PhysicsConstraint):
172
+ """
173
+ Direct Ink Write position constraints based on machine limits
174
+
175
+ Validates that position, velocity, and acceleration measurements
176
+ are within physical machine limits.
177
+ """
178
+
179
+ def __init__(self,
180
+ position_limits: Dict[str, Tuple[float, float]],
181
+ velocity_limit: float,
182
+ acceleration_limit: float):
183
+ """
184
+ Args:
185
+ position_limits: Dict with 'x', 'y', 'z' keys, values are (min, max) tuples
186
+ velocity_limit: Maximum velocity magnitude (mm/s)
187
+ acceleration_limit: Maximum acceleration magnitude (mm/s^2)
188
+ """
189
+ self.position_limits = position_limits
190
+ self.velocity_limit = velocity_limit
191
+ self.acceleration_limit = acceleration_limit
192
+
193
+ def validate(self, data: Dict[str, np.ndarray]) -> np.ndarray:
194
+ """
195
+ Validate DIW mechatronics measurements
196
+
197
+ Args:
198
+ data: Must contain position, velocity, acceleration for x, y, z axes
199
+ """
200
+ valid_mask = np.ones(len(data['x_pos']), dtype=bool)
201
+
202
+ # Check position limits
203
+ for axis in ['x', 'y', 'z']:
204
+ pos = data[f'{axis}_pos']
205
+ min_pos, max_pos = self.position_limits[axis]
206
+ valid_mask &= (pos >= min_pos) & (pos <= max_pos)
207
+
208
+ # Check velocity magnitude
209
+ velocity_mag = np.sqrt(
210
+ data['x_vel']**2 + data['y_vel']**2 + data['z_vel']**2
211
+ )
212
+ valid_mask &= (velocity_mag <= self.velocity_limit)
213
+
214
+ # Check acceleration magnitude
215
+ accel_mag = np.sqrt(
216
+ data['x_accel']**2 + data['y_accel']**2 + data['z_accel']**2
217
+ )
218
+ valid_mask &= (accel_mag <= self.acceleration_limit)
219
+
220
+ return valid_mask
221
+
222
+ def get_theoretical_limit(self, data: Dict[str, np.ndarray]) -> Dict[str, float]:
223
+ """Return the configured limits as a dictionary"""
224
+ return {
225
+ 'position': self.position_limits,
226
+ 'velocity': self.velocity_limit,
227
+ 'acceleration': self.acceleration_limit
228
+ }
229
+
230
+
231
+ class SelectiveLossCalculator:
232
+ """
233
+ Calculate loss only on validated/real measurements
234
+
235
+ This addresses the concern that training on imputed values can bias
236
+ the model. By using a mask to compute loss only on real measurements,
237
+ we maintain fidelity to actual system behavior.
238
+
239
+ Addresses Reviewer Concern #1 on physics-informed training.
240
+ """
241
+
242
+ def __init__(self, constraint: Optional[PhysicsConstraint] = None):
243
+ """
244
+ Args:
245
+ constraint: Optional physics constraint to further filter training data
246
+ """
247
+ self.constraint = constraint
248
+
249
+ def compute_loss(self,
250
+ predictions: torch.Tensor,
251
+ targets: torch.Tensor,
252
+ real_data_mask: torch.Tensor,
253
+ data_dict: Optional[Dict[str, np.ndarray]] = None,
254
+ criterion: Callable = torch.nn.functional.mse_loss) -> torch.Tensor:
255
+ """
256
+ Compute loss only on real (non-imputed) measurements that pass physics validation
257
+
258
+ Args:
259
+ predictions: Model predictions [batch, nodes, features, time]
260
+ targets: Ground truth values [batch, nodes, features, time]
261
+ real_data_mask: Boolean mask indicating real (non-imputed) measurements
262
+ data_dict: Optional dictionary for physics validation
263
+ criterion: Loss function (default MSE)
264
+
265
+ Returns:
266
+ Scalar loss computed only on valid measurements
267
+ """
268
+ # Start with real data mask
269
+ valid_mask = real_data_mask.clone()
270
+
271
+ # Apply physics constraints if provided
272
+ if self.constraint is not None and data_dict is not None:
273
+ physics_valid = self.constraint.validate(data_dict)
274
+ physics_valid_tensor = torch.from_numpy(physics_valid).to(valid_mask.device)
275
+ valid_mask = valid_mask & physics_valid_tensor
276
+
277
+ # Select only valid measurements
278
+ valid_predictions = predictions[valid_mask]
279
+ valid_targets = targets[valid_mask]
280
+
281
+ # Compute loss
282
+ if len(valid_predictions) > 0:
283
+ loss = criterion(valid_predictions, valid_targets)
284
+ else:
285
+ # If no valid measurements, return zero loss
286
+ loss = torch.tensor(0.0, device=predictions.device)
287
+
288
+ return loss
289
+
290
+ def get_validation_statistics(self, real_data_mask: torch.Tensor) -> Dict[str, float]:
291
+ """
292
+ Get statistics on data validation
293
+
294
+ Returns:
295
+ Dictionary with validation statistics
296
+ """
297
+ total_measurements = real_data_mask.numel()
298
+ valid_measurements = real_data_mask.sum().item()
299
+ validation_rate = valid_measurements / total_measurements if total_measurements > 0 else 0.0
300
+
301
+ return {
302
+ 'total_measurements': total_measurements,
303
+ 'valid_measurements': valid_measurements,
304
+ 'validation_rate': validation_rate,
305
+ 'rejected_measurements': total_measurements - valid_measurements
306
+ }
307
+
308
+
309
+ # Example usage demonstrating physics-informed training
310
+ def example_pv_training():
311
+ """
312
+ Example: Training PV model with physics-informed selective loss
313
+ """
314
+ # Initialize physics constraint
315
+ pv_constraint = PVPowerConstraint(area=50.0, efficiency_limit=0.25)
316
+
317
+ # Initialize selective loss calculator
318
+ loss_calculator = SelectiveLossCalculator(constraint=pv_constraint)
319
+
320
+ # Simulated data
321
+ batch_size, num_nodes, num_features, time_steps = 8, 29, 4, 96
322
+ predictions = torch.randn(batch_size, num_nodes, num_features, time_steps)
323
+ targets = torch.randn(batch_size, num_nodes, num_features, time_steps)
324
+ real_data_mask = torch.rand(batch_size, num_nodes, num_features, time_steps) > 0.1 # 10% missing
325
+
326
+ # Example data dict for physics validation
327
+ data_dict = {
328
+ 'irradiance': np.random.rand(batch_size * num_nodes * time_steps) * 1000,
329
+ 'dc_power': np.random.rand(batch_size * num_nodes * time_steps) * 10000
330
+ }
331
+
332
+ # Compute selective loss
333
+ loss = loss_calculator.compute_loss(
334
+ predictions,
335
+ targets,
336
+ real_data_mask,
337
+ data_dict
338
+ )
339
+
340
+ # Get validation statistics
341
+ stats = loss_calculator.get_validation_statistics(real_data_mask)
342
+
343
+ print(f"Loss: {loss.item():.4f}")
344
+ print(f"Validation rate: {stats['validation_rate']:.2%}")
345
+ print(f"Valid measurements: {stats['valid_measurements']:,} / {stats['total_measurements']:,}")
346
+
347
+
348
+ if __name__ == "__main__":
349
+ print("Physics-Informed Constraints Module")
350
+ print("=" * 50)
351
+ print("\nThis module addresses Reviewer Concern #1:")
352
+ print("Integration of physics-informed mechanisms in ddDT training")
353
+ print("\nKey features:")
354
+ print("1. Domain-specific physics validation (PV, L-PBF, DIW)")
355
+ print("2. Selective loss calculation on validated measurements")
356
+ print("3. Theoretical limit calculation based on physics")
357
+ print("\nRunning example...")
358
+ example_pv_training()
File without changes
@@ -0,0 +1,119 @@
1
+ #%%
2
+ import random
3
+ import numpy as np
4
+ import pandas as pd
5
+ import matplotlib.pyplot as plt
6
+
7
+ import torch
8
+ from tsl.engines import Predictor
9
+ from typing import Callable, Mapping, Optional, Tuple, Union
10
+ from tsl.utils.casting import torch_to_numpy
11
+ from tsl.ops.connectivity import adj_to_edge_index
12
+ from tsl.metrics.torch import MaskedMSE, MaskedMAE, MaskedMAPE
13
+ from tsl.data.preprocessing import MinMaxScaler
14
+ from tsl.data.datamodule import SpatioTemporalDataModule, TemporalSplitter
15
+ import pytorch_lightning as pl
16
+ from tsl.typing import DataArray, SparseTensArray, TemporalIndex
17
+ from pytorch_lightning.callbacks import Callback, ModelCheckpoint
18
+
19
+ # out of the box data loader
20
+ from tsl.data import ImputationDataset
21
+
22
+ class DIWDataset(ImputationDataset):
23
+ def __init__(self,
24
+ target_path: str,
25
+ connectivity = None,
26
+ eval_mask = None,
27
+ index= None,
28
+ mask = None,
29
+ covariates= None,
30
+ input_map = None,
31
+ target_map = None,
32
+ auxiliary_map = None,
33
+ scalers = None,
34
+ trend = None,
35
+ transform = None,
36
+ window: int = 12,
37
+ stride: int = 1,
38
+ window_lag: int = 1,
39
+ precision: Union[int, str] = 32,
40
+ name: Optional[str] = None):
41
+ target, connectivity_default, scaler = self._load_from_parquet(target_path)
42
+ self.scaler = scaler
43
+ if eval_mask is None:
44
+ eval_mask = torch.zeros_like(torch.from_numpy(target))
45
+ if connectivity is None:
46
+ connectivity = connectivity_default
47
+ super(DIWDataset, self).__init__(target,
48
+ eval_mask,
49
+ index=index,
50
+ mask=None,
51
+ connectivity=connectivity,
52
+ covariates=covariates,
53
+ input_map=input_map,
54
+ target_map=target_map,
55
+ auxiliary_map=auxiliary_map,
56
+ trend=trend,
57
+ transform=transform,
58
+ scalers=scalers,
59
+ window=window,
60
+ stride=stride,
61
+ precision=precision,
62
+ name=name)
63
+
64
+
65
+
66
+ def _load_from_parquet(self, target_path):
67
+ df = pd.read_parquet(target_path)
68
+ df['node_id'] = df['node_id'].apply(lambda x: '_'.join(x.split('_')[0:2]))
69
+ df = df.sort_values(by=['node_id', 'Time']).reset_index(drop=True)
70
+
71
+ target_cols = ['X_Pos_Error', 'Y_Pos_Error', 'Z_Pos_Error']
72
+
73
+ all_cols = df.columns.tolist()
74
+ #remove_cols = ['Time', 'node_id', 'delta_X_Pos', 'delta_Y_Pos', 'delta_Z_Pos']
75
+ remove_cols = ['Time', 'node_id', 'delta_X_Pos', 'delta_Y_Pos',
76
+ 'delta_Z_Pos', 'folder.id', 'layer.num', 'shape',
77
+ 'on.off', 'velocity', 'z_height', 'acceleration']
78
+ predictor_cols = [col for col in all_cols if col not in target_cols + remove_cols]
79
+
80
+ ids = df['node_id'].unique()
81
+
82
+ id_prefixes = [id.split('_')[0] for id in ids]
83
+ id_layer = [int(id.split('_')[1]) for id in ids]
84
+
85
+ df['Timestep'] = df.groupby('node_id').cumcount() + 1
86
+
87
+ df_max_timesteps = df.groupby('node_id')['Timestep'].max().reset_index(drop=True)
88
+ min_max_timestep = df_max_timesteps.min()
89
+
90
+ df = df[df['Timestep'] <= min_max_timestep]
91
+ df = df.drop(columns=['Time'])
92
+
93
+ all_cols = target_cols + predictor_cols
94
+
95
+ df_pivot = df.pivot_table(index='Timestep', columns='node_id', values=all_cols)
96
+ df_pivot = df_pivot[all_cols]
97
+ df_pivot = df_pivot.reorder_levels([1, 0], axis=1)
98
+ values = df_pivot.values
99
+
100
+ num_timesteps = df['Timestep'].nunique()
101
+ num_ids = df['node_id'].nunique()
102
+ num_features = len(all_cols)
103
+
104
+ node_ftr = values.reshape(num_timesteps, num_features, num_ids)
105
+ node_ftr = np.moveaxis(node_ftr, 1, 2)
106
+
107
+ adj_matrix = np.zeros((len(ids), len(ids)))
108
+
109
+ for i, id1 in enumerate(ids):
110
+ for j, id2 in enumerate(ids):
111
+ # if id_prefixes[i] == id_prefixes[j] and i != j and id_layer[i] % 2 == id_layer[j] % 2:
112
+ if id_prefixes[i] == id_prefixes[j] and i != j:
113
+ adj_matrix[i, j] = 1
114
+
115
+ scaler = MinMaxScaler(axis=(0))
116
+ scaler.fit(node_ftr)
117
+ node_ftr = scaler.transform(node_ftr)
118
+
119
+ return node_ftr, adj_matrix, scaler