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.
@@ -0,0 +1,444 @@
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, Optional
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: Optional[float] = None,
40
+ touchdown_mode: Optional[
41
+ Literal["A_free_hanging", "B_point_contact", "C_in_contact"]
42
+ ] = None,
43
+ collapsed_weak_layer_kR: Optional[float] = None,
44
+ ) -> np.ndarray:
45
+ """
46
+ Compute free constants *C* for system. \\
47
+ Assemble LHS from supported and unsupported segments in the form::
48
+
49
+ [ ] [ zh1 0 0 ... 0 0 0 ][ ] [ ] [ ] (left)
50
+ [ ] [ zh1 zh2 0 ... 0 0 0 ][ ] [ ] [ ] (mid)
51
+ [ ] [ 0 zh2 zh3 ... 0 0 0 ][ ] [ ] [ ] (mid)
52
+ [z0] = [ ... ... ... ... ... ... ... ][ C ] + [ zp ] = [ rhs ] (mid)
53
+ [ ] [ 0 0 0 ... zhL zhM 0 ][ ] [ ] [ ] (mid)
54
+ [ ] [ 0 0 0 ... 0 zhM zhN ][ ] [ ] [ ] (mid)
55
+ [ ] [ 0 0 0 ... 0 0 zhN ][ ] [ ] [ ] (right)
56
+
57
+ and solve for constants C.
58
+
59
+ Returns
60
+ -------
61
+ C : ndarray
62
+ Matrix(6xN) of solution constants for a system of N
63
+ segements. Columns contain the 6 constants of each segement.
64
+ """
65
+ logger.debug("Starting solve unknown constants")
66
+ phi = scenario.phi
67
+ qs = scenario.surface_load
68
+ li = scenario.li
69
+ ki = scenario.ki
70
+ mi = scenario.mi
71
+
72
+ # Determine size of linear system of equations
73
+ nS = len(li) # Number of beam segments
74
+ nDOF = 6 # Number of free constants per segment
75
+ logger.debug("Number of segments: %s, DOF per segment: %s", nS, nDOF)
76
+
77
+ # Assemble position vector
78
+ pi = np.full(nS, "m")
79
+ pi[0], pi[-1] = "length", "r"
80
+
81
+ # Initialize matrices
82
+ Zh0 = np.zeros([nS * 6, nS * nDOF])
83
+ Zp0 = np.zeros([nS * 6, 1])
84
+ rhs = np.zeros([nS * 6, 1])
85
+ logger.debug(
86
+ "Initialized Zh0 shape: %s, Zp0 shape: %s, rhs shape: %s",
87
+ Zh0.shape,
88
+ Zp0.shape,
89
+ rhs.shape,
90
+ )
91
+
92
+ # LHS: Transmission & Boundary Conditions between segments
93
+ for i in range(nS):
94
+ # Length, foundation and position of segment i
95
+ length, has_foundation, pos = li[i], ki[i], pi[i]
96
+
97
+ logger.debug(
98
+ "Assembling segment %s: length=%s, has_foundation=%s, pos=%s",
99
+ i,
100
+ length,
101
+ has_foundation,
102
+ pos,
103
+ )
104
+ # Matrix of Size one of: (l: [9,6], m: [12,6], r: [9,6])
105
+ Zhi = cls._setup_conditions(
106
+ zl=eigensystem.zh(x=0, length=length, has_foundation=has_foundation),
107
+ zr=eigensystem.zh(
108
+ x=length, length=length, has_foundation=has_foundation
109
+ ),
110
+ eigensystem=eigensystem,
111
+ has_foundation=has_foundation,
112
+ pos=pos,
113
+ touchdown_mode=touchdown_mode,
114
+ system_type=system_type,
115
+ collapsed_weak_layer_kR=collapsed_weak_layer_kR,
116
+ )
117
+ # Vector of Size one of: (l: [9,1], m: [12,1], r: [9,1])
118
+ zpi = cls._setup_conditions(
119
+ zl=eigensystem.zp(x=0, phi=phi, has_foundation=has_foundation, qs=qs),
120
+ zr=eigensystem.zp(
121
+ x=length, phi=phi, has_foundation=has_foundation, qs=qs
122
+ ),
123
+ eigensystem=eigensystem,
124
+ has_foundation=has_foundation,
125
+ pos=pos,
126
+ touchdown_mode=touchdown_mode,
127
+ system_type=system_type,
128
+ collapsed_weak_layer_kR=collapsed_weak_layer_kR,
129
+ )
130
+
131
+ # Rows for left-hand side assembly
132
+ start = 0 if i == 0 else 3
133
+ stop = 6 if i == nS - 1 else 9
134
+ # Assemble left-hand side
135
+ Zh0[(6 * i - start) : (6 * i + stop), i * nDOF : (i + 1) * nDOF] = Zhi
136
+ Zp0[(6 * i - start) : (6 * i + stop)] += zpi
137
+ logger.debug(
138
+ "Segment %s: Zhi shape: %s, zpi shape: %s", i, Zhi.shape, zpi.shape
139
+ )
140
+
141
+ # Loop through loads to assemble right-hand side
142
+ for i, m in enumerate(mi, start=1):
143
+ # Get skier point-load
144
+ F = get_skier_point_load(m)
145
+ Fn, Ft = decompose_to_normal_tangential(f=F, phi=phi)
146
+ # Right-hand side for transmission from segment i-1 to segment i
147
+ rhs[6 * i : 6 * i + 3] = np.vstack([Ft, -Ft * scenario.slab.H / 2, Fn])
148
+ logger.debug("Load %s: m=%s, F=%s, Fn=%s, Ft=%s", i, m, F, Fn, Ft)
149
+ logger.debug("RHS %s", rhs[6 * i : 6 * i + 3])
150
+ # Set RHS so that Complementary Integral vanishes at boundaries
151
+ if system_type not in ["pst-", "-pst", "rested"]:
152
+ logger.debug("Pre RHS %s", rhs[:3])
153
+ rhs[:3] = cls._boundary_conditions(
154
+ eigensystem.zp(x=0, phi=phi, has_foundation=ki[0], qs=qs),
155
+ eigensystem,
156
+ False,
157
+ "mid",
158
+ system_type,
159
+ touchdown_mode,
160
+ collapsed_weak_layer_kR,
161
+ )
162
+ logger.debug("Post RHS %s", rhs[:3])
163
+ rhs[-3:] = cls._boundary_conditions(
164
+ eigensystem.zp(x=li[-1], phi=phi, has_foundation=ki[-1], qs=qs),
165
+ eigensystem,
166
+ False,
167
+ "mid",
168
+ system_type,
169
+ touchdown_mode,
170
+ collapsed_weak_layer_kR,
171
+ )
172
+ logger.debug("Post RHS %s", rhs[-3:])
173
+ logger.debug("Set complementary integral vanishing at boundaries.")
174
+
175
+ # Set rhs for vertical faces
176
+ if system_type in ["vpst-", "-vpst"]:
177
+ # Calculate center of gravity and mass of added or cut off slab segement
178
+ x_cog, z_cog, m = scenario.slab.calc_vertical_center_of_gravity(phi)
179
+ logger.debug(
180
+ "Vertical center of gravity: x_cog=%s, z_cog=%s, m=%s", x_cog, z_cog, m
181
+ )
182
+ # Convert slope angle to radians
183
+ phi = np.deg2rad(phi)
184
+ # Translate into section forces and moments
185
+ N = -G_MM_S2 * m * np.sin(phi)
186
+ M = -G_MM_S2 * m * (x_cog * np.cos(phi) + z_cog * np.sin(phi))
187
+ V = G_MM_S2 * m * np.cos(phi)
188
+ # Add to right-hand side
189
+ rhs[:3] = np.vstack([N, M, V]) # left end
190
+ rhs[-3:] = np.vstack([N, M, V]) # right end
191
+ logger.debug("Vertical faces: N=%s, M=%s, V=%s", N, M, V)
192
+
193
+ # Loop through segments to set touchdown conditions at rhs
194
+ for i in range(nS):
195
+ # Length, foundation and position of segment i
196
+ length, has_foundation, pos = li[i], ki[i], pi[i]
197
+ # Set displacement BC in stage B
198
+ if not has_foundation and bool(touchdown_mode in ["B_point_contact"]):
199
+ if i == 0:
200
+ rhs[:3] = np.vstack([0, 0, scenario.crack_h])
201
+ if i == (nS - 1):
202
+ rhs[-3:] = np.vstack([0, 0, scenario.crack_h])
203
+ # Set normal force and displacement BC for stage C
204
+ if not has_foundation and bool(touchdown_mode in ["C_in_contact"]):
205
+ N = scenario.qt * (scenario.cut_length - touchdown_distance)
206
+ if i == 0:
207
+ rhs[:3] = np.vstack([-N, 0, scenario.crack_h])
208
+ if i == (nS - 1):
209
+ rhs[-3:] = np.vstack([N, 0, scenario.crack_h])
210
+
211
+ # Rhs for substitute spring stiffness
212
+ if system_type in ["rot"]:
213
+ # apply arbitrary moment of 1 at left boundary
214
+ rhs = rhs * 0
215
+ rhs[1] = 1
216
+ if system_type in ["trans"]:
217
+ # apply arbitrary force of 1 at left boundary
218
+ rhs = rhs * 0
219
+ rhs[2] = 1
220
+
221
+ # Solve z0 = Zh0*C + Zp0 = rhs for constants, i.e. Zh0*C = rhs - Zp0
222
+ try:
223
+ C = np.linalg.solve(Zh0, rhs - Zp0)
224
+ except LinAlgError as e:
225
+ zh_shape = Zh0.shape
226
+ rhs_shape = rhs.shape
227
+ zp_shape = Zp0.shape
228
+ rank = int(np.linalg.matrix_rank(Zh0))
229
+ min_dim = min(zh_shape)
230
+ try:
231
+ cond_val = float(np.linalg.cond(Zh0))
232
+ cond_text = f"{cond_val:.3e}"
233
+ except np.linalg.LinAlgError: # Fallback if condition number fails
234
+ cond_val = float("inf")
235
+ cond_text = "inf"
236
+ rank_status = "singular" if rank < min_dim else "full-rank"
237
+ msg_format = (
238
+ "Failed to solve linear system (np.linalg.solve) with diagnostics: "
239
+ "Zh0.shape=%s, rhs.shape=%s, Zp0.shape=%s, "
240
+ "rank(Zh0)=%s/%s (%s), cond(Zh0)=%s. "
241
+ "Original error: %s"
242
+ )
243
+ msg_args = (
244
+ zh_shape,
245
+ rhs_shape,
246
+ zp_shape,
247
+ rank,
248
+ min_dim,
249
+ rank_status,
250
+ cond_text,
251
+ e,
252
+ )
253
+ logger.error(msg_format, *msg_args)
254
+ raise LinAlgError(msg_format % msg_args) from e
255
+ # Sort (nDOF = 6) constants for each segment into columns of a matrix
256
+ return C.reshape([-1, nDOF]).T
257
+
258
+ @classmethod
259
+ def _setup_conditions(
260
+ cls,
261
+ zl: np.ndarray,
262
+ zr: np.ndarray,
263
+ eigensystem: Eigensystem,
264
+ has_foundation: bool,
265
+ pos: Literal["l", "r", "m", "left", "right", "mid"],
266
+ system_type: SystemType,
267
+ touchdown_mode: Optional[
268
+ Literal["A_free_hanging", "B_point_contact", "C_in_contact"]
269
+ ] = None,
270
+ collapsed_weak_layer_kR: Optional[float] = None,
271
+ ) -> np.ndarray:
272
+ """
273
+ Provide boundary or transmission conditions for beam segments.
274
+
275
+ Arguments
276
+ ---------
277
+ zl : ndarray
278
+ Solution vector (6x1) or (6x6) at left end of beam segement.
279
+ zr : ndarray
280
+ Solution vector (6x1) or (6x6) at right end of beam segement.
281
+ has_foundation : boolean
282
+ Indicates whether segment has foundation(True) or not (False).
283
+ Default is False.
284
+ pos: {'left', 'mid', 'right', 'l', 'm', 'r'}, optional
285
+ Determines whether the segement under consideration
286
+ is a left boundary segement (left, l), one of the
287
+ center segement (mid, m), or a right boundary
288
+ segement (right, r). Default is 'mid'.
289
+
290
+ Returns
291
+ -------
292
+ conditions : ndarray
293
+ `zh`: Matrix of Size one of: (`l: [9,6], m: [12,6], r: [9,6]`)
294
+
295
+ `zp`: Vector of Size one of: (`l: [9,1], m: [12,1], r: [9,1]`)
296
+ """
297
+ fq = FieldQuantities(eigensystem=eigensystem)
298
+ if pos in ("l", "left"):
299
+ bcs = cls._boundary_conditions(
300
+ zl,
301
+ eigensystem,
302
+ has_foundation,
303
+ pos,
304
+ system_type,
305
+ touchdown_mode,
306
+ collapsed_weak_layer_kR,
307
+ ) # Left boundary condition
308
+ conditions = np.array(
309
+ [
310
+ bcs[0],
311
+ bcs[1],
312
+ bcs[2],
313
+ fq.u(zr, h0=0), # ui(xi = li)
314
+ fq.w(zr), # wi(xi = li)
315
+ fq.psi(zr), # psii(xi = li)
316
+ fq.N(zr), # Ni(xi = li)
317
+ fq.M(zr), # Mi(xi = li)
318
+ fq.V(zr), # Vi(xi = li)
319
+ ]
320
+ )
321
+ elif pos in ("m", "mid"):
322
+ conditions = np.array(
323
+ [
324
+ -fq.u(zl, h0=0), # -ui(xi = 0)
325
+ -fq.w(zl), # -wi(xi = 0)
326
+ -fq.psi(zl), # -psii(xi = 0)
327
+ -fq.N(zl), # -Ni(xi = 0)
328
+ -fq.M(zl), # -Mi(xi = 0)
329
+ -fq.V(zl), # -Vi(xi = 0)
330
+ fq.u(zr, h0=0), # ui(xi = li)
331
+ fq.w(zr), # wi(xi = li)
332
+ fq.psi(zr), # psii(xi = li)
333
+ fq.N(zr), # Ni(xi = li)
334
+ fq.M(zr), # Mi(xi = li)
335
+ fq.V(zr), # Vi(xi = li)
336
+ ]
337
+ )
338
+ elif pos in ("r", "right"):
339
+ bcs = cls._boundary_conditions(
340
+ zr,
341
+ eigensystem,
342
+ has_foundation,
343
+ pos,
344
+ system_type,
345
+ touchdown_mode,
346
+ collapsed_weak_layer_kR,
347
+ ) # Right boundary condition
348
+ conditions = np.array(
349
+ [
350
+ -fq.u(zl, h0=0), # -ui(xi = 0)
351
+ -fq.w(zl), # -wi(xi = 0)
352
+ -fq.psi(zl), # -psii(xi = 0)
353
+ -fq.N(zl), # -Ni(xi = 0)
354
+ -fq.M(zl), # -Mi(xi = 0)
355
+ -fq.V(zl), # -Vi(xi = 0)
356
+ bcs[0],
357
+ bcs[1],
358
+ bcs[2],
359
+ ]
360
+ )
361
+ logger.debug("Boundary Conditions at pos %s: %s", pos, conditions.shape) # pylint: disable=E0606
362
+ return conditions
363
+
364
+ @classmethod
365
+ def _boundary_conditions(
366
+ cls,
367
+ z,
368
+ eigensystem: Eigensystem,
369
+ has_foundation: bool,
370
+ pos: Literal["l", "r", "m", "left", "right", "mid"],
371
+ system_type: SystemType,
372
+ touchdown_mode: Optional[
373
+ Literal["A_free_hanging", "B_point_contact", "C_in_contact"]
374
+ ] = None,
375
+ collapsed_weak_layer_kR: Optional[float] = None,
376
+ ):
377
+ """
378
+ Provide equations for free (pst) or infinite (skiers) ends.
379
+
380
+ Arguments
381
+ ---------
382
+ z : ndarray
383
+ Solution vector (6x1) at a certain position x.
384
+ l : float, optional
385
+ Length of the segment in consideration. Default is zero.
386
+ has_foundation : boolean
387
+ Indicates whether segment has foundation(True) or not (False).
388
+ Default is False.
389
+ pos : {'left', 'mid', 'right', 'l', 'm', 'r'}, optional
390
+ Determines whether the segement under consideration
391
+ is a left boundary segement (left, l), one of the
392
+ center segement (mid, m), or a right boundary
393
+ segement (right, r). Default is 'mid'.
394
+
395
+ Returns
396
+ -------
397
+ bc : ndarray
398
+ Boundary condition vector (lenght 3) at position x.
399
+ """
400
+ fq = FieldQuantities(eigensystem=eigensystem)
401
+ # Set boundary conditions for PST-systems
402
+ if system_type in ["pst-", "-pst"]:
403
+ if not has_foundation:
404
+ if touchdown_mode in ["A_free_hanging"]:
405
+ # Free end
406
+ bc = np.array([fq.N(z), fq.M(z), fq.V(z)])
407
+ elif touchdown_mode in ["B_point_contact"] and pos in ["r", "right"]:
408
+ # Touchdown right
409
+ bc = np.array([fq.N(z), fq.M(z), fq.w(z)])
410
+ elif touchdown_mode in ["B_point_contact"] and pos in ["l", "left"]:
411
+ # Touchdown left
412
+ bc = np.array([fq.N(z), fq.M(z), fq.w(z)])
413
+ elif touchdown_mode in ["C_in_contact"] and pos in ["r", "right"]:
414
+ # Spring stiffness
415
+ kR = collapsed_weak_layer_kR
416
+ # Touchdown right
417
+ bc = np.array([fq.N(z), fq.M(z) + kR * fq.psi(z), fq.w(z)])
418
+ elif touchdown_mode in ["C_in_contact"] and pos in ["l", "left"]:
419
+ # Spring stiffness
420
+ kR = collapsed_weak_layer_kR
421
+ # Touchdown left
422
+ bc = np.array([fq.N(z), fq.M(z) - kR * fq.psi(z), fq.w(z)])
423
+ else:
424
+ # Touchdown not enabled
425
+ bc = np.array([fq.N(z), fq.M(z), fq.V(z)])
426
+ else:
427
+ # Free end
428
+ bc = np.array([fq.N(z), fq.M(z), fq.V(z)])
429
+ # Set boundary conditions for PST-systems with vertical faces
430
+ elif system_type in ["-vpst", "vpst-"]:
431
+ bc = np.array([fq.N(z), fq.M(z), fq.V(z)])
432
+ # Set boundary conditions for SKIER-systems
433
+ elif system_type in ["skier", "skiers"]:
434
+ # Infinite end (vanishing complementary solution)
435
+ bc = np.array([fq.u(z, h0=0), fq.w(z), fq.psi(z)])
436
+ # Set boundary conditions for substitute spring calculus
437
+ elif system_type in ["rot", "trans"]:
438
+ bc = np.array([fq.N(z), fq.M(z), fq.V(z)])
439
+ else:
440
+ raise ValueError(
441
+ f"Boundary conditions not defined for system of type {system_type}."
442
+ )
443
+
444
+ return bc
weac/logging_config.py ADDED
@@ -0,0 +1,39 @@
1
+ """
2
+ Logging configuration for weak layer anticrack nucleation model.
3
+ """
4
+
5
+ import os
6
+ from logging.config import dictConfig
7
+ from typing import Optional
8
+
9
+
10
+ def setup_logging(level: Optional[str] = None) -> None:
11
+ """
12
+ Initialise the global logging configuration exactly once.
13
+ The level is taken from the env var WEAC_LOG_LEVEL (default WARNING).
14
+ """
15
+ if level is None:
16
+ level = os.getenv("WEAC_LOG_LEVEL", "WARNING").upper()
17
+
18
+ dictConfig(
19
+ {
20
+ "version": 1,
21
+ "disable_existing_loggers": False, # keep third-party loggers alive
22
+ "formatters": {
23
+ "console": {
24
+ "format": "%(asctime)s | %(levelname)-8s | %(name)s: %(message)s",
25
+ },
26
+ },
27
+ "handlers": {
28
+ "console": {
29
+ "class": "logging.StreamHandler",
30
+ "formatter": "console",
31
+ "level": level,
32
+ },
33
+ },
34
+ "root": { # applies to *all* loggers
35
+ "handlers": ["console"],
36
+ "level": level,
37
+ },
38
+ }
39
+ )
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