weac 2.6.4__py3-none-any.whl → 3.0.1__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.
weac/utils/misc.py ADDED
@@ -0,0 +1,127 @@
1
+ """
2
+ This module contains miscellaneous utility functions.
3
+ """
4
+
5
+ from typing import Literal
6
+
7
+ import numpy as np
8
+
9
+ from weac.components import Layer
10
+ from weac.constants import G_MM_S2, LSKI_MM
11
+
12
+
13
+ def decompose_to_normal_tangential(f: float, phi: float) -> tuple[float, float]:
14
+ """
15
+ Resolve a gravity-type force/line-load into its tangential (downslope) and
16
+ normal (into-slope) components with respect to an inclined surface.
17
+
18
+ Parameters
19
+ ----------
20
+ f : float
21
+ is interpreted as a vertical load magnitude
22
+ acting straight downward (global y negative).
23
+ phi : float
24
+ Surface dip angle `in degrees`, measured from horizontal.
25
+ Positive `phi` means the surface slopes upward in +x.
26
+
27
+ Returns
28
+ -------
29
+ f_norm, f_tan : float
30
+ Magnitudes of the tangential ( + downslope ) and normal
31
+ ( + into-slope ) components, respectively.
32
+ """
33
+ # Convert units
34
+ phi = np.deg2rad(phi) # Convert inclination to rad
35
+ # Split into components
36
+ f_norm = f * np.cos(phi) # Normal direction
37
+ f_tan = -f * np.sin(phi) # Tangential direction
38
+ return f_norm, f_tan
39
+
40
+
41
+ def get_skier_point_load(m: float) -> float:
42
+ """
43
+ Calculate skier point load.
44
+
45
+ Arguments
46
+ ---------
47
+ m : float
48
+ Skier weight [kg].
49
+
50
+ Returns
51
+ -------
52
+ f : float
53
+ Skier load [N/mm].
54
+ """
55
+ F = 1e-3 * m * G_MM_S2 / LSKI_MM # Total skier
56
+ return F
57
+
58
+
59
+ def load_dummy_profile(
60
+ profile_id: Literal[
61
+ "a", "b", "c", "d", "e", "f", "h", "soft", "medium", "hard", "comp"
62
+ ],
63
+ ) -> list[Layer]:
64
+ """Define standard layering types for comparison."""
65
+ soft_layer = Layer(rho=180, h=120, E=5)
66
+ medium_layer = Layer(rho=270, h=120, E=30)
67
+ hard_layer = Layer(rho=350, h=120, E=93.8)
68
+
69
+ tested_layers = [
70
+ Layer(rho=350, h=120),
71
+ Layer(rho=270, h=120),
72
+ Layer(rho=180, h=120),
73
+ ]
74
+
75
+ # Database (top to bottom)
76
+ database = {
77
+ # Layered
78
+ "a": [hard_layer, medium_layer, soft_layer],
79
+ "b": [soft_layer, medium_layer, hard_layer],
80
+ "c": [hard_layer, soft_layer, hard_layer],
81
+ "d": [soft_layer, hard_layer, soft_layer],
82
+ "e": [hard_layer, soft_layer, soft_layer],
83
+ "f": [soft_layer, soft_layer, hard_layer],
84
+ "tested": tested_layers,
85
+ # Homogeneous
86
+ "h": [medium_layer, medium_layer, medium_layer],
87
+ "soft": [soft_layer, soft_layer, soft_layer],
88
+ "medium": [medium_layer, medium_layer, medium_layer],
89
+ "hard": [hard_layer, hard_layer, hard_layer],
90
+ # Comparison
91
+ "comp": [
92
+ Layer(rho=240, h=200, E=5.23),
93
+ ],
94
+ }
95
+
96
+ # Load profile
97
+ try:
98
+ profile = database[profile_id.lower()]
99
+ except KeyError:
100
+ raise ValueError(f"Profile {profile_id} is not defined.") from None
101
+ return profile
102
+
103
+
104
+ def isnotebook() -> bool:
105
+ """
106
+ Check if code is running in a Jupyter notebook environment.
107
+
108
+ Returns
109
+ -------
110
+ bool
111
+ True if running in Jupyter notebook, False otherwise.
112
+ """
113
+ try:
114
+ # Check if we're in IPython
115
+ from IPython import get_ipython # pylint: disable=import-outside-toplevel
116
+
117
+ if get_ipython() is None:
118
+ return False
119
+
120
+ # Check if we're specifically in a notebook (not just IPython terminal)
121
+ if get_ipython().__class__.__name__ == "ZMQInteractiveShell":
122
+ return True # Jupyter notebook
123
+ if get_ipython().__class__.__name__ == "TerminalInteractiveShell":
124
+ return False # IPython terminal
125
+ return False # Other IPython environments
126
+ except ImportError:
127
+ return False # IPython not available
@@ -0,0 +1,82 @@
1
+ """
2
+ Snow grain types and hand hardness values.
3
+
4
+ These values are used in Pydantic models for validation and correspond to the
5
+ parameterizations available in `geldsetzer.py`.
6
+ """
7
+
8
+ from enum import Enum
9
+
10
+
11
+ class GrainType(str, Enum):
12
+ """SnowPilot grain type codes (see `geldsetzer.GRAIN_TYPE`)."""
13
+
14
+ DF = "DF"
15
+ DFbk = "DFbk"
16
+ DFdc = "DFdc"
17
+ DH = "DH"
18
+ DHch = "DHch"
19
+ DHcp = "DHcp"
20
+ DHla = "DHla"
21
+ DHpr = "DHpr"
22
+ DHxr = "DHxr"
23
+ FC = "FC"
24
+ FCsf = "FCsf"
25
+ FCso = "FCso"
26
+ FCxr = "FCxr"
27
+ IF = "IF"
28
+ IFbi = "IFbi"
29
+ IFic = "IFic"
30
+ IFil = "IFil"
31
+ IFrc = "IFrc"
32
+ IFsc = "IFsc"
33
+ MF = "MF"
34
+ MFcl = "MFcl"
35
+ MFcr = "MFcr"
36
+ MFpc = "MFpc"
37
+ MFsl = "MFsl"
38
+ PP = "PP"
39
+ PPco = "PPco"
40
+ PPgp = "PPgp"
41
+ PPhl = "PPhl"
42
+ PPip = "PPip"
43
+ PPir = "PPir"
44
+ PPnd = "PPnd"
45
+ PPpl = "PPpl"
46
+ PPrm = "PPrm"
47
+ PPsd = "PPsd"
48
+ RG = "RG"
49
+ RGlr = "RGlr"
50
+ RGsr = "RGsr"
51
+ RGwp = "RGwp"
52
+ RGxf = "RGxf"
53
+ SH = "SH"
54
+ SHcv = "SHcv"
55
+ SHsu = "SHsu"
56
+ SHxr = "SHxr"
57
+
58
+
59
+ class HandHardness(str, Enum):
60
+ """Field hand hardness codes (see `geldsetzer.HAND_HARDNESS`).
61
+
62
+ Enum member names avoid starting with digits and special characters.
63
+ """
64
+
65
+ Fm = "F-"
66
+ F = "F"
67
+ Fp = "F+"
68
+ _4Fm = "4F-"
69
+ _4F = "4F"
70
+ _4Fp = "4F+"
71
+ _1Fm = "1F-"
72
+ _1F = "1F"
73
+ _1Fp = "1F+"
74
+ Pm = "P-"
75
+ P = "P"
76
+ Pp = "P+"
77
+ Km = "K-"
78
+ K = "K"
79
+ Kp = "K+"
80
+ Im = "I-"
81
+ I = "I"
82
+ Ip = "I+"
@@ -0,0 +1,332 @@
1
+ """
2
+ Utilizes the snowpylot library to convert a CAAML file to a WEAC ModelInput.
3
+
4
+ The snowpylot library is used to parse the CAAML file and extract the snowpit.
5
+ The snowpit is then converted to a List of WEAC ModelInput.
6
+
7
+ Based on the different stability tests performed, several scenarios are created.
8
+ Each scenario is a WEAC ModelInput.
9
+
10
+ The scenarios are created based on the following logic:
11
+ - For each PropSawTest, a scenario is created with `the cut length` and `a standard segment.`
12
+ - For each ExtColumnTest, a scenario is created with `a standard segment.`
13
+ - For each ComprTest, a scenario is created with `a standard segment.`
14
+ - For each RBlockTest, a scenario is created with `a standard segment.`
15
+
16
+ The `a standard segment` is a segment with a length of 1000 mm and a foundation of True.
17
+
18
+ The `the cut length` is the cut length of the PropSawTest.
19
+ The `the column length` is the column length of the PropSawTest.
20
+ """
21
+
22
+ import logging
23
+ from typing import List, Tuple
24
+
25
+ import numpy as np
26
+ from snowpylot import caaml_parser
27
+ from snowpylot.layer import Layer as SnowpylotLayer
28
+ from snowpylot.snow_pit import SnowPit
29
+ from snowpylot.snow_profile import DensityObs
30
+
31
+ # Import WEAC components
32
+ from weac.components import (
33
+ Layer,
34
+ WeakLayer,
35
+ )
36
+ from weac.utils.geldsetzer import compute_density
37
+
38
+ logger = logging.getLogger(__name__)
39
+
40
+ convert_to_mm = {"cm": 10, "mm": 1, "m": 1000, "dm": 100}
41
+ convert_to_deg = {"deg": 1, "rad": 180 / np.pi}
42
+
43
+
44
+ class SnowPilotParser:
45
+ """Parser for SnowPilot files using the snowpylot library."""
46
+
47
+ def __init__(self, file_path: str):
48
+ self.snowpit: SnowPit = caaml_parser(file_path)
49
+
50
+ def extract_layers(self) -> Tuple[List[Layer], List[str]]:
51
+ """Extract layers from snowpit."""
52
+ snowpit = self.snowpit
53
+ # Extract layers from snowpit: List[SnowpylotLayer]
54
+ sp_layers: List[SnowpylotLayer] = [
55
+ layer
56
+ for layer in snowpit.snow_profile.layers
57
+ if layer.depth_top is not None
58
+ ]
59
+ sp_layers = sorted(sp_layers, key=lambda x: x.depth_top[0]) # type: ignore
60
+
61
+ # Extract density layers from snowpit: List[DensityObs]
62
+ sp_density_layers: List[DensityObs] = [
63
+ layer
64
+ for layer in snowpit.snow_profile.density_profile
65
+ if layer.depth_top is not None
66
+ ]
67
+ sp_density_layers = sorted(sp_density_layers, key=lambda x: x.depth_top[0]) # type: ignore
68
+
69
+ # Populate WEAC layers: List[Layer]
70
+ layers: List[Layer] = []
71
+ density_methods: List[str] = []
72
+ for _i, layer in enumerate(sp_layers):
73
+ # Parameters
74
+ grain_type = None
75
+ grain_size = None
76
+ hand_hardness = None
77
+ density = None
78
+ thickness = None
79
+
80
+ # extract THICKNESS
81
+ if layer.thickness is not None:
82
+ thickness, unit = layer.thickness
83
+ thickness = thickness * convert_to_mm[unit] # Convert to mm
84
+ else:
85
+ raise ValueError("Thickness not found")
86
+
87
+ # extract GRAIN TYPE and SIZE
88
+ if layer.grain_form_primary:
89
+ if layer.grain_form_primary.grain_form:
90
+ grain_type = layer.grain_form_primary.grain_form
91
+ if layer.grain_form_primary.grain_size_avg:
92
+ grain_size = (
93
+ layer.grain_form_primary.grain_size_avg[0]
94
+ * convert_to_mm[layer.grain_form_primary.grain_size_avg[1]]
95
+ )
96
+ elif layer.grain_form_primary.grain_size_max:
97
+ grain_size = (
98
+ layer.grain_form_primary.grain_size_max[0]
99
+ * convert_to_mm[layer.grain_form_primary.grain_size_max[1]]
100
+ )
101
+
102
+ # extract DENSITY
103
+ # Get layer depth range in mm for density matching
104
+ layer_depth_top_mm = layer.depth_top[0] * convert_to_mm[layer.depth_top[1]]
105
+ layer_depth_bottom_mm = layer_depth_top_mm + thickness
106
+ # Try to find density measurement that overlaps with this layer
107
+ measured_density = self.get_density_for_layer_range(
108
+ layer_depth_top_mm, layer_depth_bottom_mm, sp_density_layers
109
+ )
110
+
111
+ # Handle hardness and create layers accordingly
112
+ if layer.hardness_top is not None and layer.hardness_bottom is not None:
113
+ hand_hardness_top = layer.hardness_top
114
+ hand_hardness_bottom = layer.hardness_bottom
115
+
116
+ # Two hardness values - split into two layers
117
+ half_thickness = thickness / 2
118
+ layer_mid_depth_mm = layer_depth_top_mm + half_thickness
119
+
120
+ # Create top layer (first half)
121
+ if measured_density is not None:
122
+ density_top = self.get_density_for_layer_range(
123
+ layer_depth_top_mm, layer_mid_depth_mm, sp_density_layers
124
+ )
125
+ if density_top is None:
126
+ density_methods.append("geldsetzer")
127
+ density_top = compute_density(grain_type, hand_hardness_top)
128
+ else:
129
+ density_methods.append("density_obs")
130
+ else:
131
+ density_methods.append("geldsetzer")
132
+ density_top = compute_density(grain_type, hand_hardness_top)
133
+
134
+ layers.append(
135
+ Layer(
136
+ rho=density_top,
137
+ h=half_thickness,
138
+ grain_type=grain_type,
139
+ grain_size=grain_size,
140
+ hand_hardness=hand_hardness_top,
141
+ )
142
+ )
143
+
144
+ # Create bottom layer (second half)
145
+ if measured_density is not None:
146
+ density_bottom = self.get_density_for_layer_range(
147
+ layer_mid_depth_mm, layer_depth_bottom_mm, sp_density_layers
148
+ )
149
+ if density_bottom is None:
150
+ density_methods.append("geldsetzer")
151
+ density_bottom = compute_density(
152
+ grain_type, hand_hardness_bottom
153
+ )
154
+ else:
155
+ density_methods.append("density_obs")
156
+ else:
157
+ try:
158
+ density_methods.append("geldsetzer")
159
+ density_bottom = compute_density(
160
+ grain_type, hand_hardness_bottom
161
+ )
162
+ except Exception as exc:
163
+ raise AttributeError(
164
+ "Layer is missing density information; density profile, "
165
+ "hand hardness and grain type are all missing. "
166
+ "Excluding SnowPit from calculations."
167
+ ) from exc
168
+
169
+ layers.append(
170
+ Layer(
171
+ rho=density_bottom,
172
+ h=half_thickness,
173
+ grain_type=grain_type,
174
+ grain_size=grain_size,
175
+ hand_hardness=hand_hardness_bottom,
176
+ )
177
+ )
178
+ else:
179
+ # Single hardness value - create one layer
180
+ hand_hardness = layer.hardness
181
+
182
+ if measured_density is not None:
183
+ density = measured_density
184
+ density_methods.append("density_obs")
185
+ else:
186
+ try:
187
+ density_methods.append("geldsetzer")
188
+ density = compute_density(grain_type, hand_hardness)
189
+ except Exception as exc:
190
+ raise AttributeError(
191
+ "Layer is missing density information; density profile, "
192
+ "hand hardness and grain type are all missing. "
193
+ "Excluding SnowPit from calculations."
194
+ ) from exc
195
+
196
+ layers.append(
197
+ Layer(
198
+ rho=density,
199
+ h=thickness,
200
+ grain_type=grain_type,
201
+ grain_size=grain_size,
202
+ hand_hardness=hand_hardness,
203
+ )
204
+ )
205
+
206
+ if len(layers) == 0:
207
+ raise AttributeError(
208
+ "No layers found for snowpit. Excluding SnowPit from calculations."
209
+ )
210
+ return layers, density_methods
211
+
212
+ def get_density_for_layer_range(
213
+ self,
214
+ layer_top_mm: float,
215
+ layer_bottom_mm: float,
216
+ sp_density_layers: List[DensityObs],
217
+ ) -> float | None:
218
+ """Find density measurements that overlap with the given layer depth range.
219
+
220
+ Args:
221
+ layer_top_mm: Top depth of layer in mm
222
+ layer_bottom_mm: Bottom depth of layer in mm
223
+ sp_density_layers: List of density observations
224
+
225
+ Returns:
226
+ Average density from overlapping measurements, or None if no overlap
227
+ """
228
+ if not sp_density_layers:
229
+ return None
230
+
231
+ overlapping_densities = []
232
+ overlapping_weights = []
233
+
234
+ for density_obs in sp_density_layers:
235
+ if density_obs.depth_top is None or density_obs.thickness is None:
236
+ continue
237
+
238
+ # Convert density observation depth range to mm
239
+ density_top_mm = (
240
+ density_obs.depth_top[0] * convert_to_mm[density_obs.depth_top[1]]
241
+ )
242
+ density_thickness_mm = (
243
+ density_obs.thickness[0] * convert_to_mm[density_obs.thickness[1]]
244
+ )
245
+ density_bottom_mm = density_top_mm + density_thickness_mm
246
+
247
+ # Check for overlap between layer and density measurement
248
+ overlap_top = max(layer_top_mm, density_top_mm)
249
+ overlap_bottom = min(layer_bottom_mm, density_bottom_mm)
250
+
251
+ if overlap_top < overlap_bottom: # There is overlap
252
+ overlap_thickness = overlap_bottom - overlap_top
253
+
254
+ # Extract density value
255
+ if density_obs.density is not None:
256
+ density_value = density_obs.density[0] # (value, unit)
257
+
258
+ overlapping_densities.append(density_value)
259
+ overlapping_weights.append(overlap_thickness)
260
+
261
+ if overlapping_densities:
262
+ # Calculate weighted average based on overlap thickness
263
+ total_weight = sum(overlapping_weights)
264
+ if total_weight > 0:
265
+ weighted_density = (
266
+ sum(
267
+ d * w
268
+ for d, w in zip(overlapping_densities, overlapping_weights)
269
+ )
270
+ / total_weight
271
+ )
272
+ return float(weighted_density)
273
+ return None
274
+
275
+ def extract_weak_layer_and_layers_above(
276
+ self, weak_layer_depth: float, layers: List[Layer]
277
+ ) -> Tuple[WeakLayer, List[Layer]]:
278
+ """Extract weak layer and layers above the weak layer for the given
279
+ depth_top extracted from the stability test."""
280
+ depth = 0
281
+ layers_above = []
282
+ weak_layer_rho = None
283
+ weak_layer_hand_hardness = None
284
+ weak_layer_grain_type = None
285
+ weak_layer_grain_size = None
286
+ if weak_layer_depth <= 0:
287
+ raise ValueError(
288
+ "The depth of the weak layer is not positive. "
289
+ "Excluding SnowPit from calculations."
290
+ )
291
+ if weak_layer_depth > sum(layer.h for layer in layers):
292
+ raise ValueError(
293
+ "The depth of the weak layer is below the recorded layers. "
294
+ "Excluding SnowPit from calculations."
295
+ )
296
+ layers = [layer.model_copy(deep=True) for layer in layers]
297
+ for i, layer in enumerate(layers):
298
+ if depth + layer.h < weak_layer_depth:
299
+ layers_above.append(layer)
300
+ depth += layer.h
301
+ elif depth < weak_layer_depth < depth + layer.h:
302
+ layer.h = weak_layer_depth - depth
303
+ layers_above.append(layer)
304
+ weak_layer_rho = layers[i].rho
305
+ weak_layer_hand_hardness = layers[i].hand_hardness
306
+ weak_layer_grain_type = layers[i].grain_type
307
+ weak_layer_grain_size = layers[i].grain_size
308
+ break
309
+ elif depth + layer.h == weak_layer_depth:
310
+ if i + 1 < len(layers):
311
+ layers_above.append(layer)
312
+ weak_layer_rho = layers[i + 1].rho
313
+ weak_layer_hand_hardness = layers[i + 1].hand_hardness
314
+ weak_layer_grain_type = layers[i + 1].grain_type
315
+ weak_layer_grain_size = layers[i + 1].grain_size
316
+ else:
317
+ weak_layer_rho = layers[i].rho
318
+ weak_layer_hand_hardness = layers[i].hand_hardness
319
+ weak_layer_grain_type = layers[i].grain_type
320
+ weak_layer_grain_size = layers[i].grain_size
321
+ break
322
+
323
+ weak_layer = WeakLayer(
324
+ rho=weak_layer_rho,
325
+ h=20.0,
326
+ hand_hardness=weak_layer_hand_hardness,
327
+ grain_type=weak_layer_grain_type,
328
+ grain_size=weak_layer_grain_size,
329
+ )
330
+ if len(layers_above) == 0:
331
+ raise ValueError("No layers above weak layer found")
332
+ return weak_layer, layers_above