weac 3.0.0__py3-none-any.whl → 3.0.2__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.
@@ -0,0 +1,441 @@
1
+ """
2
+ This module defines the system model for the WEAC simulation. The system
3
+ model is the heart of the WEAC simulation. All data sources are bundled into
4
+ the system model. The system model initializes and calculates all the
5
+ parameterizations and passes relevant data to the different components.
6
+
7
+ We utilize the pydantic library to define the system model.
8
+ """
9
+
10
+ import logging
11
+ from typing import Literal
12
+
13
+ import numpy as np
14
+ from numpy.linalg import LinAlgError
15
+
16
+ from weac.components import SystemType
17
+ from weac.constants import G_MM_S2
18
+ from weac.core.eigensystem import Eigensystem
19
+ from weac.core.field_quantities import FieldQuantities
20
+ from weac.core.scenario import Scenario
21
+
22
+ # from weac.constants import G_MM_S2, LSKI_MM
23
+ from weac.utils.misc import decompose_to_normal_tangential, get_skier_point_load
24
+
25
+ logger = logging.getLogger(__name__)
26
+
27
+
28
+ class UnknownConstantsSolver:
29
+ """
30
+ This class solves the unknown constants for the WEAC simulation.
31
+ """
32
+
33
+ @classmethod
34
+ def solve_for_unknown_constants(
35
+ cls,
36
+ scenario: Scenario,
37
+ eigensystem: Eigensystem,
38
+ system_type: SystemType,
39
+ touchdown_distance: float | None = None,
40
+ touchdown_mode: Literal["A_free_hanging", "B_point_contact", "C_in_contact"]
41
+ | None = None,
42
+ collapsed_weak_layer_kR: float | None = None,
43
+ ) -> np.ndarray:
44
+ """
45
+ Compute free constants *C* for system. \\
46
+ Assemble LHS from supported and unsupported segments in the form::
47
+
48
+ [ ] [ zh1 0 0 ... 0 0 0 ][ ] [ ] [ ] (left)
49
+ [ ] [ zh1 zh2 0 ... 0 0 0 ][ ] [ ] [ ] (mid)
50
+ [ ] [ 0 zh2 zh3 ... 0 0 0 ][ ] [ ] [ ] (mid)
51
+ [z0] = [ ... ... ... ... ... ... ... ][ C ] + [ zp ] = [ rhs ] (mid)
52
+ [ ] [ 0 0 0 ... zhL zhM 0 ][ ] [ ] [ ] (mid)
53
+ [ ] [ 0 0 0 ... 0 zhM zhN ][ ] [ ] [ ] (mid)
54
+ [ ] [ 0 0 0 ... 0 0 zhN ][ ] [ ] [ ] (right)
55
+
56
+ and solve for constants C.
57
+
58
+ Returns
59
+ -------
60
+ C : ndarray
61
+ Matrix(6xN) of solution constants for a system of N
62
+ segements. Columns contain the 6 constants of each segement.
63
+ """
64
+ logger.debug("Starting solve unknown constants")
65
+ phi = scenario.phi
66
+ qs = scenario.surface_load
67
+ li = scenario.li
68
+ ki = scenario.ki
69
+ mi = scenario.mi
70
+
71
+ # Determine size of linear system of equations
72
+ nS = len(li) # Number of beam segments
73
+ nDOF = 6 # Number of free constants per segment
74
+ logger.debug("Number of segments: %s, DOF per segment: %s", nS, nDOF)
75
+
76
+ # Assemble position vector
77
+ pi = np.full(nS, "m")
78
+ pi[0], pi[-1] = "length", "r"
79
+
80
+ # Initialize matrices
81
+ Zh0 = np.zeros([nS * 6, nS * nDOF])
82
+ Zp0 = np.zeros([nS * 6, 1])
83
+ rhs = np.zeros([nS * 6, 1])
84
+ logger.debug(
85
+ "Initialized Zh0 shape: %s, Zp0 shape: %s, rhs shape: %s",
86
+ Zh0.shape,
87
+ Zp0.shape,
88
+ rhs.shape,
89
+ )
90
+
91
+ # LHS: Transmission & Boundary Conditions between segments
92
+ for i in range(nS):
93
+ # Length, foundation and position of segment i
94
+ length, has_foundation, pos = li[i], ki[i], pi[i]
95
+
96
+ logger.debug(
97
+ "Assembling segment %s: length=%s, has_foundation=%s, pos=%s",
98
+ i,
99
+ length,
100
+ has_foundation,
101
+ pos,
102
+ )
103
+ # Matrix of Size one of: (l: [9,6], m: [12,6], r: [9,6])
104
+ Zhi = cls._setup_conditions(
105
+ zl=eigensystem.zh(x=0, length=length, has_foundation=has_foundation),
106
+ zr=eigensystem.zh(
107
+ x=length, length=length, has_foundation=has_foundation
108
+ ),
109
+ eigensystem=eigensystem,
110
+ has_foundation=has_foundation,
111
+ pos=pos,
112
+ touchdown_mode=touchdown_mode,
113
+ system_type=system_type,
114
+ collapsed_weak_layer_kR=collapsed_weak_layer_kR,
115
+ )
116
+ # Vector of Size one of: (l: [9,1], m: [12,1], r: [9,1])
117
+ zpi = cls._setup_conditions(
118
+ zl=eigensystem.zp(x=0, phi=phi, has_foundation=has_foundation, qs=qs),
119
+ zr=eigensystem.zp(
120
+ x=length, phi=phi, has_foundation=has_foundation, qs=qs
121
+ ),
122
+ eigensystem=eigensystem,
123
+ has_foundation=has_foundation,
124
+ pos=pos,
125
+ touchdown_mode=touchdown_mode,
126
+ system_type=system_type,
127
+ collapsed_weak_layer_kR=collapsed_weak_layer_kR,
128
+ )
129
+
130
+ # Rows for left-hand side assembly
131
+ start = 0 if i == 0 else 3
132
+ stop = 6 if i == nS - 1 else 9
133
+ # Assemble left-hand side
134
+ Zh0[(6 * i - start) : (6 * i + stop), i * nDOF : (i + 1) * nDOF] = Zhi
135
+ Zp0[(6 * i - start) : (6 * i + stop)] += zpi
136
+ logger.debug(
137
+ "Segment %s: Zhi shape: %s, zpi shape: %s", i, Zhi.shape, zpi.shape
138
+ )
139
+
140
+ # Loop through loads to assemble right-hand side
141
+ for i, m in enumerate(mi, start=1):
142
+ # Get skier point-load
143
+ F = get_skier_point_load(m)
144
+ Fn, Ft = decompose_to_normal_tangential(f=F, phi=phi)
145
+ # Right-hand side for transmission from segment i-1 to segment i
146
+ rhs[6 * i : 6 * i + 3] = np.vstack([Ft, -Ft * scenario.slab.H / 2, Fn])
147
+ logger.debug("Load %s: m=%s, F=%s, Fn=%s, Ft=%s", i, m, F, Fn, Ft)
148
+ logger.debug("RHS %s", rhs[6 * i : 6 * i + 3])
149
+ # Set RHS so that Complementary Integral vanishes at boundaries
150
+ if system_type not in ["pst-", "-pst", "rested"]:
151
+ logger.debug("Pre RHS %s", rhs[:3])
152
+ rhs[:3] = cls._boundary_conditions(
153
+ eigensystem.zp(x=0, phi=phi, has_foundation=ki[0], qs=qs),
154
+ eigensystem,
155
+ False,
156
+ "mid",
157
+ system_type,
158
+ touchdown_mode,
159
+ collapsed_weak_layer_kR,
160
+ )
161
+ logger.debug("Post RHS %s", rhs[:3])
162
+ rhs[-3:] = cls._boundary_conditions(
163
+ eigensystem.zp(x=li[-1], phi=phi, has_foundation=ki[-1], qs=qs),
164
+ eigensystem,
165
+ False,
166
+ "mid",
167
+ system_type,
168
+ touchdown_mode,
169
+ collapsed_weak_layer_kR,
170
+ )
171
+ logger.debug("Post RHS %s", rhs[-3:])
172
+ logger.debug("Set complementary integral vanishing at boundaries.")
173
+
174
+ # Set rhs for vertical faces
175
+ if system_type in ["vpst-", "-vpst"]:
176
+ # Calculate center of gravity and mass of added or cut off slab segement
177
+ x_cog, z_cog, m = scenario.slab.calc_vertical_center_of_gravity(phi)
178
+ logger.debug(
179
+ "Vertical center of gravity: x_cog=%s, z_cog=%s, m=%s", x_cog, z_cog, m
180
+ )
181
+ # Convert slope angle to radians
182
+ phi = np.deg2rad(phi)
183
+ # Translate into section forces and moments
184
+ N = -G_MM_S2 * m * np.sin(phi)
185
+ M = -G_MM_S2 * m * (x_cog * np.cos(phi) + z_cog * np.sin(phi))
186
+ V = G_MM_S2 * m * np.cos(phi)
187
+ # Add to right-hand side
188
+ rhs[:3] = np.vstack([N, M, V]) # left end
189
+ rhs[-3:] = np.vstack([N, M, V]) # right end
190
+ logger.debug("Vertical faces: N=%s, M=%s, V=%s", N, M, V)
191
+
192
+ # Loop through segments to set touchdown conditions at rhs
193
+ for i in range(nS):
194
+ # Length, foundation and position of segment i
195
+ length, has_foundation, pos = li[i], ki[i], pi[i]
196
+ # Set displacement BC in stage B
197
+ if not has_foundation and bool(touchdown_mode in ["B_point_contact"]):
198
+ if i == 0:
199
+ rhs[:3] = np.vstack([0, 0, scenario.crack_h])
200
+ if i == (nS - 1):
201
+ rhs[-3:] = np.vstack([0, 0, scenario.crack_h])
202
+ # Set normal force and displacement BC for stage C
203
+ if not has_foundation and bool(touchdown_mode in ["C_in_contact"]):
204
+ N = scenario.qt * (scenario.cut_length - touchdown_distance)
205
+ if i == 0:
206
+ rhs[:3] = np.vstack([-N, 0, scenario.crack_h])
207
+ if i == (nS - 1):
208
+ rhs[-3:] = np.vstack([N, 0, scenario.crack_h])
209
+
210
+ # Rhs for substitute spring stiffness
211
+ if system_type in ["rot"]:
212
+ # apply arbitrary moment of 1 at left boundary
213
+ rhs = rhs * 0
214
+ rhs[1] = 1
215
+ if system_type in ["trans"]:
216
+ # apply arbitrary force of 1 at left boundary
217
+ rhs = rhs * 0
218
+ rhs[2] = 1
219
+
220
+ # Solve z0 = Zh0*C + Zp0 = rhs for constants, i.e. Zh0*C = rhs - Zp0
221
+ try:
222
+ C = np.linalg.solve(Zh0, rhs - Zp0)
223
+ except LinAlgError as e:
224
+ zh_shape = Zh0.shape
225
+ rhs_shape = rhs.shape
226
+ zp_shape = Zp0.shape
227
+ rank = int(np.linalg.matrix_rank(Zh0))
228
+ min_dim = min(zh_shape)
229
+ try:
230
+ cond_val = float(np.linalg.cond(Zh0))
231
+ cond_text = f"{cond_val:.3e}"
232
+ except np.linalg.LinAlgError: # Fallback if condition number fails
233
+ cond_val = float("inf")
234
+ cond_text = "inf"
235
+ rank_status = "singular" if rank < min_dim else "full-rank"
236
+ msg_format = (
237
+ "Failed to solve linear system (np.linalg.solve) with diagnostics: "
238
+ "Zh0.shape=%s, rhs.shape=%s, Zp0.shape=%s, "
239
+ "rank(Zh0)=%s/%s (%s), cond(Zh0)=%s. "
240
+ "Original error: %s"
241
+ )
242
+ msg_args = (
243
+ zh_shape,
244
+ rhs_shape,
245
+ zp_shape,
246
+ rank,
247
+ min_dim,
248
+ rank_status,
249
+ cond_text,
250
+ e,
251
+ )
252
+ logger.error(msg_format, *msg_args)
253
+ raise LinAlgError(msg_format % msg_args) from e
254
+ # Sort (nDOF = 6) constants for each segment into columns of a matrix
255
+ return C.reshape([-1, nDOF]).T
256
+
257
+ @classmethod
258
+ def _setup_conditions(
259
+ cls,
260
+ zl: np.ndarray,
261
+ zr: np.ndarray,
262
+ eigensystem: Eigensystem,
263
+ has_foundation: bool,
264
+ pos: Literal["l", "r", "m", "left", "right", "mid"],
265
+ system_type: SystemType,
266
+ touchdown_mode: Literal["A_free_hanging", "B_point_contact", "C_in_contact"]
267
+ | None = None,
268
+ collapsed_weak_layer_kR: float | None = None,
269
+ ) -> np.ndarray:
270
+ """
271
+ Provide boundary or transmission conditions for beam segments.
272
+
273
+ Arguments
274
+ ---------
275
+ zl : ndarray
276
+ Solution vector (6x1) or (6x6) at left end of beam segement.
277
+ zr : ndarray
278
+ Solution vector (6x1) or (6x6) at right end of beam segement.
279
+ has_foundation : boolean
280
+ Indicates whether segment has foundation(True) or not (False).
281
+ Default is False.
282
+ pos: {'left', 'mid', 'right', 'l', 'm', 'r'}, optional
283
+ Determines whether the segement under consideration
284
+ is a left boundary segement (left, l), one of the
285
+ center segement (mid, m), or a right boundary
286
+ segement (right, r). Default is 'mid'.
287
+
288
+ Returns
289
+ -------
290
+ conditions : ndarray
291
+ `zh`: Matrix of Size one of: (`l: [9,6], m: [12,6], r: [9,6]`)
292
+
293
+ `zp`: Vector of Size one of: (`l: [9,1], m: [12,1], r: [9,1]`)
294
+ """
295
+ fq = FieldQuantities(eigensystem=eigensystem)
296
+ if pos in ("l", "left"):
297
+ bcs = cls._boundary_conditions(
298
+ zl,
299
+ eigensystem,
300
+ has_foundation,
301
+ pos,
302
+ system_type,
303
+ touchdown_mode,
304
+ collapsed_weak_layer_kR,
305
+ ) # Left boundary condition
306
+ conditions = np.array(
307
+ [
308
+ bcs[0],
309
+ bcs[1],
310
+ bcs[2],
311
+ fq.u(zr, h0=0), # ui(xi = li)
312
+ fq.w(zr), # wi(xi = li)
313
+ fq.psi(zr), # psii(xi = li)
314
+ fq.N(zr), # Ni(xi = li)
315
+ fq.M(zr), # Mi(xi = li)
316
+ fq.V(zr), # Vi(xi = li)
317
+ ]
318
+ )
319
+ elif pos in ("m", "mid"):
320
+ conditions = np.array(
321
+ [
322
+ -fq.u(zl, h0=0), # -ui(xi = 0)
323
+ -fq.w(zl), # -wi(xi = 0)
324
+ -fq.psi(zl), # -psii(xi = 0)
325
+ -fq.N(zl), # -Ni(xi = 0)
326
+ -fq.M(zl), # -Mi(xi = 0)
327
+ -fq.V(zl), # -Vi(xi = 0)
328
+ fq.u(zr, h0=0), # ui(xi = li)
329
+ fq.w(zr), # wi(xi = li)
330
+ fq.psi(zr), # psii(xi = li)
331
+ fq.N(zr), # Ni(xi = li)
332
+ fq.M(zr), # Mi(xi = li)
333
+ fq.V(zr), # Vi(xi = li)
334
+ ]
335
+ )
336
+ elif pos in ("r", "right"):
337
+ bcs = cls._boundary_conditions(
338
+ zr,
339
+ eigensystem,
340
+ has_foundation,
341
+ pos,
342
+ system_type,
343
+ touchdown_mode,
344
+ collapsed_weak_layer_kR,
345
+ ) # Right boundary condition
346
+ conditions = np.array(
347
+ [
348
+ -fq.u(zl, h0=0), # -ui(xi = 0)
349
+ -fq.w(zl), # -wi(xi = 0)
350
+ -fq.psi(zl), # -psii(xi = 0)
351
+ -fq.N(zl), # -Ni(xi = 0)
352
+ -fq.M(zl), # -Mi(xi = 0)
353
+ -fq.V(zl), # -Vi(xi = 0)
354
+ bcs[0],
355
+ bcs[1],
356
+ bcs[2],
357
+ ]
358
+ )
359
+ logger.debug("Boundary Conditions at pos %s: %s", pos, conditions.shape) # pylint: disable=E0606
360
+ return conditions
361
+
362
+ @classmethod
363
+ def _boundary_conditions(
364
+ cls,
365
+ z,
366
+ eigensystem: Eigensystem,
367
+ has_foundation: bool,
368
+ pos: Literal["l", "r", "m", "left", "right", "mid"],
369
+ system_type: SystemType,
370
+ touchdown_mode: Literal["A_free_hanging", "B_point_contact", "C_in_contact"]
371
+ | None = None,
372
+ collapsed_weak_layer_kR: float | None = None,
373
+ ):
374
+ """
375
+ Provide equations for free (pst) or infinite (skiers) ends.
376
+
377
+ Arguments
378
+ ---------
379
+ z : ndarray
380
+ Solution vector (6x1) at a certain position x.
381
+ l : float, optional
382
+ Length of the segment in consideration. Default is zero.
383
+ has_foundation : boolean
384
+ Indicates whether segment has foundation(True) or not (False).
385
+ Default is False.
386
+ pos : {'left', 'mid', 'right', 'l', 'm', 'r'}, optional
387
+ Determines whether the segement under consideration
388
+ is a left boundary segement (left, l), one of the
389
+ center segement (mid, m), or a right boundary
390
+ segement (right, r). Default is 'mid'.
391
+
392
+ Returns
393
+ -------
394
+ bc : ndarray
395
+ Boundary condition vector (lenght 3) at position x.
396
+ """
397
+ fq = FieldQuantities(eigensystem=eigensystem)
398
+ # Set boundary conditions for PST-systems
399
+ if system_type in ["pst-", "-pst"]:
400
+ if not has_foundation:
401
+ if touchdown_mode in ["A_free_hanging"]:
402
+ # Free end
403
+ bc = np.array([fq.N(z), fq.M(z), fq.V(z)])
404
+ elif touchdown_mode in ["B_point_contact"] and pos in ["r", "right"]:
405
+ # Touchdown right
406
+ bc = np.array([fq.N(z), fq.M(z), fq.w(z)])
407
+ elif touchdown_mode in ["B_point_contact"] and pos in ["l", "left"]:
408
+ # Touchdown left
409
+ bc = np.array([fq.N(z), fq.M(z), fq.w(z)])
410
+ elif touchdown_mode in ["C_in_contact"] and pos in ["r", "right"]:
411
+ # Spring stiffness
412
+ kR = collapsed_weak_layer_kR
413
+ # Touchdown right
414
+ bc = np.array([fq.N(z), fq.M(z) + kR * fq.psi(z), fq.w(z)])
415
+ elif touchdown_mode in ["C_in_contact"] and pos in ["l", "left"]:
416
+ # Spring stiffness
417
+ kR = collapsed_weak_layer_kR
418
+ # Touchdown left
419
+ bc = np.array([fq.N(z), fq.M(z) - kR * fq.psi(z), fq.w(z)])
420
+ else:
421
+ # Touchdown not enabled
422
+ bc = np.array([fq.N(z), fq.M(z), fq.V(z)])
423
+ else:
424
+ # Free end
425
+ bc = np.array([fq.N(z), fq.M(z), fq.V(z)])
426
+ # Set boundary conditions for PST-systems with vertical faces
427
+ elif system_type in ["-vpst", "vpst-"]:
428
+ bc = np.array([fq.N(z), fq.M(z), fq.V(z)])
429
+ # Set boundary conditions for SKIER-systems
430
+ elif system_type in ["skier", "skiers"]:
431
+ # Infinite end (vanishing complementary solution)
432
+ bc = np.array([fq.u(z, h0=0), fq.w(z), fq.psi(z)])
433
+ # Set boundary conditions for substitute spring calculus
434
+ elif system_type in ["rot", "trans"]:
435
+ bc = np.array([fq.N(z), fq.M(z), fq.V(z)])
436
+ else:
437
+ raise ValueError(
438
+ f"Boundary conditions not defined for system of type {system_type}."
439
+ )
440
+
441
+ return bc
weac/logging_config.py CHANGED
@@ -4,10 +4,9 @@ Logging configuration for weak layer anticrack nucleation model.
4
4
 
5
5
  import os
6
6
  from logging.config import dictConfig
7
- from typing import Optional
8
7
 
9
8
 
10
- def setup_logging(level: Optional[str] = None) -> None:
9
+ def setup_logging(level: str | None = None) -> None:
11
10
  """
12
11
  Initialise the global logging configuration exactly once.
13
12
  The level is taken from the env var WEAC_LOG_LEVEL (default WARNING).
weac/utils/__init__.py ADDED
File without changes
@@ -0,0 +1,166 @@
1
+ """
2
+ Hand hardness + Grain Type Parameterization to Density
3
+ according to Geldsetzer & Jamieson (2000)
4
+ `https://arc.lib.montana.edu/snow-science/objects/issw-2000-121-127.pdf`
5
+
6
+ Inputs:
7
+ Hand Hardness + Grain Type
8
+ Output:
9
+ Density [kg/m^3]
10
+ """
11
+
12
+ SKIP_VALUE = "!skip"
13
+
14
+
15
+ DENSITY_PARAMETERS = {
16
+ SKIP_VALUE: (0, 0),
17
+ "SH": (125, 0), # 125 kg/m^3 so that bergfeld is E~1.0
18
+ "PP": (45, 36),
19
+ "PPgp": (83, 37),
20
+ "DF": (65, 36),
21
+ "FCmx": (56, 64),
22
+ "FC": (112, 46),
23
+ "DH": (185, 25),
24
+ "RGmx": (91, 42),
25
+ "RG": (154, 1.51),
26
+ "MFCr": (292.25, 0),
27
+ }
28
+
29
+ # Map SnowPilot grain type to those we know
30
+ GRAIN_TYPE = {
31
+ "": SKIP_VALUE,
32
+ "DF": "DF",
33
+ "DFbk": "DF",
34
+ "DFdc": "DF",
35
+ "DH": "DH",
36
+ "DHch": "DH",
37
+ "DHcp": "DH",
38
+ "DHla": "DH",
39
+ "DHpr": "DH",
40
+ "DHxr": "DH",
41
+ "FC": "FC",
42
+ "FCsf": "FCmx",
43
+ "FCso": "FCmx",
44
+ "FCxr": "FCmx",
45
+ "IF": "MFCr",
46
+ "IFbi": "MFCr",
47
+ "IFic": "MFCr",
48
+ "IFil": "MFCr",
49
+ "IFrc": "MFCr",
50
+ "IFsc": "MFCr",
51
+ "MF": "MFCr",
52
+ "MFcl": "MFCr",
53
+ "MFcr": "MFCr",
54
+ "MFpc": "MFCr",
55
+ "MFsl": "MFCr",
56
+ "PP": "PP",
57
+ "PPco": "PP",
58
+ "PPgp": "PPgp",
59
+ "gp": "PPgp",
60
+ "PPhl": "PP",
61
+ "PPip": "PP",
62
+ "PPir": "PP",
63
+ "PPnd": "PP",
64
+ "PPpl": "PP",
65
+ "PPrm": "PP",
66
+ "PPsd": "PP",
67
+ "RG": "RG",
68
+ "RGlr": "RGmx",
69
+ "RGsr": "RGmx",
70
+ "RGwp": "RGmx",
71
+ "RGxf": "RGmx",
72
+ "SH": "SH",
73
+ "SHcv": "SH",
74
+ "SHsu": "SH",
75
+ "SHxr": "SH",
76
+ "WG": "WG",
77
+ }
78
+
79
+ # Translate hand hardness to numerical values
80
+ HAND_HARDNESS = {
81
+ "": SKIP_VALUE,
82
+ "F-": 0.67,
83
+ "F": 1,
84
+ "F+": 1.33,
85
+ "4F-": 1.67,
86
+ "4F": 2,
87
+ "4F+": 2.33,
88
+ "1F-": 2.67,
89
+ "1F": 3,
90
+ "1F+": 3.33,
91
+ "P-": 3.67,
92
+ "P": 4,
93
+ "P+": 4.33,
94
+ "K-": 4.67,
95
+ "K": 5,
96
+ "K+": 5.33,
97
+ "I-": 5.67,
98
+ "I": 6,
99
+ "I+": 6.33,
100
+ }
101
+
102
+ GRAIN_TYPE_TO_DENSITY = {
103
+ "PP": 84.9,
104
+ "PPgp": 162.3,
105
+ "DF": 136.3,
106
+ "RG": 247.4,
107
+ "RGmx": 220.6,
108
+ "FC": 248.2,
109
+ "FCmx": 288.8,
110
+ "DH": 252.8,
111
+ "WG": 254.3,
112
+ "MFCr": 292.3,
113
+ "SH": 125,
114
+ }
115
+
116
+ HAND_HARDNESS_TO_DENSITY = {
117
+ "F-": 71.7,
118
+ "F": 103.7,
119
+ "F+": 118.4,
120
+ "4F-": 127.9,
121
+ "4F": 158.2,
122
+ "4F+": 163.7,
123
+ "1F-": 188.6,
124
+ "1F": 208,
125
+ "1F+": 224.4,
126
+ "P-": 252.8,
127
+ "P": 275.9,
128
+ "P+": 314.6,
129
+ "K-": 359.1,
130
+ "K": 347.4,
131
+ "K+": 407.8,
132
+ "I-": 407.8,
133
+ "I": 407.8,
134
+ "I+": 407.8,
135
+ }
136
+
137
+
138
+ def compute_density(grainform: str | None, hardness: str | None) -> float:
139
+ """
140
+ Geldsetzer & Jamieson (2000)
141
+ `https://arc.lib.montana.edu/snow-science/objects/issw-2000-121-127.pdf`
142
+ """
143
+ # Adaptation based on CAAML profiles (which sometimes provide top and bottom hardness)
144
+ if hardness is None and grainform is None:
145
+ raise ValueError("Provide at least one of grainform or hardness")
146
+ if hardness is None:
147
+ grain_type = GRAIN_TYPE[grainform]
148
+ return GRAIN_TYPE_TO_DENSITY[grain_type]
149
+ if grainform is None:
150
+ return HAND_HARDNESS_TO_DENSITY[hardness]
151
+
152
+ hardness_value = HAND_HARDNESS[hardness]
153
+ grain_type = GRAIN_TYPE[grainform]
154
+ a, b = DENSITY_PARAMETERS[grain_type]
155
+
156
+ if grain_type == SKIP_VALUE:
157
+ raise ValueError(f"Grain type is {SKIP_VALUE}")
158
+ if hardness_value == SKIP_VALUE:
159
+ raise ValueError(f"Hardness value is {SKIP_VALUE}")
160
+
161
+ if grain_type == "RG":
162
+ # Special computation for 'RG' grain form
163
+ rho = a + b * (hardness_value**3.15)
164
+ else:
165
+ rho = a + b * hardness_value
166
+ return rho