weac 3.2.0__tar.gz → 3.3.0__tar.gz

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.
Files changed (95) hide show
  1. {weac-3.2.0 → weac-3.3.0}/CITATION.cff +1 -1
  2. {weac-3.2.0/src/weac.egg-info → weac-3.3.0}/PKG-INFO +43 -3
  3. {weac-3.2.0 → weac-3.3.0}/README.md +40 -1
  4. {weac-3.2.0 → weac-3.3.0}/pyproject.toml +4 -3
  5. {weac-3.2.0 → weac-3.3.0}/src/weac/__init__.py +1 -1
  6. weac-3.3.0/src/weac/parser/__init__.py +13 -0
  7. weac-3.3.0/src/weac/parser/plotting/__init__.py +19 -0
  8. weac-3.3.0/src/weac/parser/plotting/force_penetration_layers.py +148 -0
  9. weac-3.3.0/src/weac/parser/plotting/force_penetration_plot.py +107 -0
  10. weac-3.3.0/src/weac/parser/plotting/smp_penetration_plot.py +34 -0
  11. weac-3.3.0/src/weac/parser/plotting/snowscope_penetration_plot.py +42 -0
  12. weac-3.3.0/src/weac/parser/smp_parser.py +204 -0
  13. {weac-3.2.0/src/weac/utils → weac-3.3.0/src/weac/parser}/snowpilot_parser.py +3 -25
  14. weac-3.3.0/src/weac/parser/snowscope_parser.py +307 -0
  15. weac-3.3.0/src/weac/parser/utils/__init__.py +11 -0
  16. weac-3.3.0/src/weac/parser/utils/layer_binning.py +120 -0
  17. weac-3.3.0/src/weac/parser/utils/layer_gradient.py +141 -0
  18. weac-3.3.0/src/weac/parser/utils/plump_to_slope_normal.py +21 -0
  19. weac-3.3.0/src/weac/utils/snowpilot_parser.py +16 -0
  20. {weac-3.2.0 → weac-3.3.0/src/weac.egg-info}/PKG-INFO +43 -3
  21. {weac-3.2.0 → weac-3.3.0}/src/weac.egg-info/SOURCES.txt +17 -1
  22. {weac-3.2.0 → weac-3.3.0}/src/weac.egg-info/requires.txt +2 -1
  23. weac-3.3.0/tests/parser/test_profile_layers.py +208 -0
  24. weac-3.3.0/tests/parser/test_smp_parser.py +114 -0
  25. {weac-3.2.0/tests/utils → weac-3.3.0/tests/parser}/test_snowpilot_parser.py +16 -7
  26. weac-3.3.0/tests/parser/test_snowscope_parser.py +103 -0
  27. {weac-3.2.0 → weac-3.3.0}/LICENSE +0 -0
  28. {weac-3.2.0 → weac-3.3.0}/MANIFEST.in +0 -0
  29. {weac-3.2.0 → weac-3.3.0}/img/bc.png +0 -0
  30. {weac-3.2.0 → weac-3.3.0}/img/layering.png +0 -0
  31. {weac-3.2.0 → weac-3.3.0}/img/logo.png +0 -0
  32. {weac-3.2.0 → weac-3.3.0}/img/model.png +0 -0
  33. {weac-3.2.0 → weac-3.3.0}/img/profiles.png +0 -0
  34. {weac-3.2.0 → weac-3.3.0}/img/systems.png +0 -0
  35. {weac-3.2.0 → weac-3.3.0}/setup.cfg +0 -0
  36. {weac-3.2.0 → weac-3.3.0}/src/weac/analysis/__init__.py +0 -0
  37. {weac-3.2.0 → weac-3.3.0}/src/weac/analysis/analyzer.py +0 -0
  38. {weac-3.2.0 → weac-3.3.0}/src/weac/analysis/coupled_criterion.py +0 -0
  39. {weac-3.2.0 → weac-3.3.0}/src/weac/analysis/criteria_evaluator.py +0 -0
  40. {weac-3.2.0 → weac-3.3.0}/src/weac/analysis/envelopes.py +0 -0
  41. {weac-3.2.0 → weac-3.3.0}/src/weac/analysis/plotter.py +0 -0
  42. {weac-3.2.0 → weac-3.3.0}/src/weac/analysis/steady_state.py +0 -0
  43. {weac-3.2.0 → weac-3.3.0}/src/weac/components/__init__.py +0 -0
  44. {weac-3.2.0 → weac-3.3.0}/src/weac/components/config.py +0 -0
  45. {weac-3.2.0 → weac-3.3.0}/src/weac/components/criteria_config.py +0 -0
  46. {weac-3.2.0 → weac-3.3.0}/src/weac/components/layer.py +0 -0
  47. {weac-3.2.0 → weac-3.3.0}/src/weac/components/model_input.py +0 -0
  48. {weac-3.2.0 → weac-3.3.0}/src/weac/components/presets.py +0 -0
  49. {weac-3.2.0 → weac-3.3.0}/src/weac/components/scenario_config.py +0 -0
  50. {weac-3.2.0 → weac-3.3.0}/src/weac/components/segment.py +0 -0
  51. {weac-3.2.0 → weac-3.3.0}/src/weac/components/weak_layer.py +0 -0
  52. {weac-3.2.0 → weac-3.3.0}/src/weac/constants.py +0 -0
  53. {weac-3.2.0 → weac-3.3.0}/src/weac/core/__init__.py +0 -0
  54. {weac-3.2.0 → weac-3.3.0}/src/weac/core/eigensystem.py +0 -0
  55. {weac-3.2.0 → weac-3.3.0}/src/weac/core/field_quantities.py +0 -0
  56. {weac-3.2.0 → weac-3.3.0}/src/weac/core/generalized_eigensystem.py +0 -0
  57. {weac-3.2.0 → weac-3.3.0}/src/weac/core/generalized_field_quantities.py +0 -0
  58. {weac-3.2.0 → weac-3.3.0}/src/weac/core/generalized_unknown_constants_solver.py +0 -0
  59. {weac-3.2.0 → weac-3.3.0}/src/weac/core/scenario.py +0 -0
  60. {weac-3.2.0 → weac-3.3.0}/src/weac/core/slab.py +0 -0
  61. {weac-3.2.0 → weac-3.3.0}/src/weac/core/slab_touchdown.py +0 -0
  62. {weac-3.2.0 → weac-3.3.0}/src/weac/core/system_model.py +0 -0
  63. {weac-3.2.0 → weac-3.3.0}/src/weac/core/unknown_constants_solver.py +0 -0
  64. {weac-3.2.0 → weac-3.3.0}/src/weac/logging_config.py +0 -0
  65. {weac-3.2.0 → weac-3.3.0}/src/weac/utils/__init__.py +0 -0
  66. {weac-3.2.0 → weac-3.3.0}/src/weac/utils/geldsetzer.py +0 -0
  67. {weac-3.2.0 → weac-3.3.0}/src/weac/utils/misc.py +0 -0
  68. {weac-3.2.0 → weac-3.3.0}/src/weac/utils/snow_types.py +0 -0
  69. {weac-3.2.0 → weac-3.3.0}/src/weac.egg-info/dependency_links.txt +0 -0
  70. {weac-3.2.0 → weac-3.3.0}/src/weac.egg-info/top_level.txt +0 -0
  71. {weac-3.2.0 → weac-3.3.0}/tests/__init__.py +0 -0
  72. {weac-3.2.0 → weac-3.3.0}/tests/analysis/__init__.py +0 -0
  73. {weac-3.2.0 → weac-3.3.0}/tests/analysis/test_analyzer.py +0 -0
  74. {weac-3.2.0 → weac-3.3.0}/tests/analysis/test_criteria_evaluator.py +0 -0
  75. {weac-3.2.0 → weac-3.3.0}/tests/analysis/test_pst_critical_cut_touchdown.py +0 -0
  76. {weac-3.2.0 → weac-3.3.0}/tests/analysis/test_slab_tensile_comparisons.py +0 -0
  77. {weac-3.2.0 → weac-3.3.0}/tests/analysis/test_steady_state_ease.py +0 -0
  78. {weac-3.2.0 → weac-3.3.0}/tests/components/__init__.py +0 -0
  79. {weac-3.2.0 → weac-3.3.0}/tests/components/test_configs.py +0 -0
  80. {weac-3.2.0 → weac-3.3.0}/tests/components/test_layer.py +0 -0
  81. {weac-3.2.0 → weac-3.3.0}/tests/conftest.py +0 -0
  82. {weac-3.2.0 → weac-3.3.0}/tests/core/__init__.py +0 -0
  83. {weac-3.2.0 → weac-3.3.0}/tests/core/test_eigensystem.py +0 -0
  84. {weac-3.2.0 → weac-3.3.0}/tests/core/test_field_quantities.py +0 -0
  85. {weac-3.2.0 → weac-3.3.0}/tests/core/test_scenario.py +0 -0
  86. {weac-3.2.0 → weac-3.3.0}/tests/core/test_slab.py +0 -0
  87. {weac-3.2.0 → weac-3.3.0}/tests/core/test_slab_touchdown.py +0 -0
  88. {weac-3.2.0 → weac-3.3.0}/tests/core/test_system_model.py +0 -0
  89. {weac-3.2.0 → weac-3.3.0}/tests/test_comparison_results.py +0 -0
  90. {weac-3.2.0 → weac-3.3.0}/tests/test_regression_simulation.py +0 -0
  91. {weac-3.2.0 → weac-3.3.0}/tests/utils/__init__.py +0 -0
  92. {weac-3.2.0 → weac-3.3.0}/tests/utils/json_helpers.py +0 -0
  93. {weac-3.2.0 → weac-3.3.0}/tests/utils/test_json_helpers.py +0 -0
  94. {weac-3.2.0 → weac-3.3.0}/tests/utils/test_misc.py +0 -0
  95. {weac-3.2.0 → weac-3.3.0}/tests/utils/weac_reference_runner.py +0 -0
@@ -8,7 +8,7 @@ authors:
8
8
  - family-names: "Weissgraeber"
9
9
  given-names: "Philipp"
10
10
  orcid: "https://orcid.org/0000-0001-8320-8672"
11
- version: 3.2.0
11
+ version: 3.3.0
12
12
  date-released: 2026-08-06
13
13
  identifiers:
14
14
  - description: Collection of archived snapshots of all versions of WEAC
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: weac
3
- Version: 3.2.0
3
+ Version: 3.3.0
4
4
  Summary: Weak layer anticrack nucleation model
5
5
  Author-email: 2phi GbR <mail@2phi.de>
6
6
  License-Expression: MIT
@@ -21,6 +21,7 @@ Requires-Dist: numpy<2.4.0,>=2.3.5
21
21
  Requires-Dist: scipy>=1.14.0
22
22
  Requires-Dist: pydantic>=2.11.7
23
23
  Requires-Dist: snowpylot>=1.1.5
24
+ Requires-Dist: snowmicropyn>=1.4.0
24
25
  Provides-Extra: interactive
25
26
  Requires-Dist: jupyter; extra == "interactive"
26
27
  Requires-Dist: ipython>=8.37.0; extra == "interactive"
@@ -53,7 +54,7 @@ Requires-Dist: pyzmq>=27.0.1; extra == "dev"
53
54
  Requires-Dist: tornado>=6.5.2; extra == "dev"
54
55
  Requires-Dist: traitlets>=5.14.3; extra == "dev"
55
56
  Requires-Dist: ruff>=0.4.0; extra == "dev"
56
- Requires-Dist: pylint>=3.2.0; extra == "dev"
57
+ Requires-Dist: pylint>=3.3.0; extra == "dev"
57
58
  Requires-Dist: pycodestyle>=2.11.1; extra == "dev"
58
59
  Requires-Dist: black>=24.4.0; extra == "dev"
59
60
  Requires-Dist: isort>=5.13.0; extra == "dev"
@@ -193,7 +194,8 @@ Needs (runtime dependencies are declared in [pyproject.toml](https://github.com/
193
194
  - [Scipy](https://www.scipy.org/) ≥ 1.14.0
194
195
  - [Matplotlib](https://matplotlib.org/) ≥ 3.9.1
195
196
  - [Pydantic](https://docs.pydantic.dev/latest/) ≥ 2.11.7
196
- - [Snowpylot](https://github.com/connellymk/snowpylot) ≥ 1.1.3
197
+ - [Snowpylot](https://github.com/connellymk/snowpylot) ≥ 1.1.5
198
+ - [snowmicropyn](https://github.com/slf-dot-ch/snowmicropyn) ≥ 1.4.0
197
199
 
198
200
  <!-- DEVELOPMENT SETUP -->
199
201
  ## Development Setup
@@ -428,6 +430,44 @@ u_bot = skier_system.fq.u(Z=z_skier, h0=bot, unit='um')
428
430
  psi = skier_system.fq.psi(Z=z_skier, unit='deg')
429
431
  ```
430
432
 
433
+ ### Parsing field profiles into layers
434
+
435
+ Instead of hand-building `Layer` lists, WEAC ships parsers in `weac.parser` that convert common field-profile formats into slope-normal WEAC layers (ordered surface → ground). All parsers expose `extract_layers(...)` returning `(layers, density_methods)`, so the output plugs directly into `ModelInput`. See [`demo/parser_demo.ipynb`](https://github.com/2phi/weac/blob/main/demo/parser_demo.ipynb) for side-by-side plots of the three parsers on a co-located Weissfluhjoch pit.
436
+
437
+ ```python
438
+ from weac.parser import SMPParser, SnowScopeParser, SnowPilotParser
439
+ ```
440
+
441
+ **SnowMicroPen** (`.PNT`) — `SMPParser` derives density and SSA from the force signal via [snowmicropyn](https://github.com/slf-dot-ch/snowmicropyn)'s Löwe 2012 shot-noise model plus a parameterization (`P2015`, `CR2020`, `K2020a`, `K2020b`). Surface and ground are auto-detected at construction.
442
+
443
+ ```python
444
+ smp = SMPParser("profile.PNT", density_method="CR2020")
445
+ # "bin" groups at a fixed thickness; "gradient" cuts on density-gradient jumps
446
+ layers, density_methods = smp.extract_layers(method="bin", layer_thickness_mm=20.0)
447
+ ```
448
+
449
+ **SnowScope** (`.csv`) — `SnowScopeParser` reads penetration hardness (kPa) and derives density from a semilog model `D = a·ln(F) + b` (default `HAGENMULLER2018`). It exposes the same `extract_layers` interface as the SMP parser.
450
+
451
+ ```python
452
+ scope = SnowScopeParser("profile.csv", density_method="HAGENMULLER2018")
453
+ layers, density_methods = scope.extract_layers(method="gradient", gradient_threshold=12.0)
454
+
455
+ scope_custom = SnowScopeParser(
456
+ "profile.csv",
457
+ semilog_slope=50.0,
458
+ semilog_intercept=100.0,
459
+ )
460
+ ```
461
+
462
+ **SnowPilot / CAAML** (`.xml`) — `SnowPilotParser` uses [snowpylot](https://github.com/connellymk/snowpylot) to read CAAML snowpits, emitting hand-hardness / density-observation layers. Densities come from measured observations where available, falling back to the Geldsetzer estimate from grain type and hand hardness.
463
+
464
+ ```python
465
+ pilot = SnowPilotParser("profile.xml")
466
+ # pass a known slope angle to scale plumb depths to slope-normal, e.g.:
467
+ # pilot.extract_layers(slope_angle_deg=pilot.pit_slope_angle_deg() or 0.0)
468
+ layers, density_methods = pilot.extract_layers()
469
+ ```
470
+
431
471
  <!-- ROADMAP -->
432
472
  ## Roadmap
433
473
 
@@ -130,7 +130,8 @@ Needs (runtime dependencies are declared in [pyproject.toml](https://github.com/
130
130
  - [Scipy](https://www.scipy.org/) ≥ 1.14.0
131
131
  - [Matplotlib](https://matplotlib.org/) ≥ 3.9.1
132
132
  - [Pydantic](https://docs.pydantic.dev/latest/) ≥ 2.11.7
133
- - [Snowpylot](https://github.com/connellymk/snowpylot) ≥ 1.1.3
133
+ - [Snowpylot](https://github.com/connellymk/snowpylot) ≥ 1.1.5
134
+ - [snowmicropyn](https://github.com/slf-dot-ch/snowmicropyn) ≥ 1.4.0
134
135
 
135
136
  <!-- DEVELOPMENT SETUP -->
136
137
  ## Development Setup
@@ -365,6 +366,44 @@ u_bot = skier_system.fq.u(Z=z_skier, h0=bot, unit='um')
365
366
  psi = skier_system.fq.psi(Z=z_skier, unit='deg')
366
367
  ```
367
368
 
369
+ ### Parsing field profiles into layers
370
+
371
+ Instead of hand-building `Layer` lists, WEAC ships parsers in `weac.parser` that convert common field-profile formats into slope-normal WEAC layers (ordered surface → ground). All parsers expose `extract_layers(...)` returning `(layers, density_methods)`, so the output plugs directly into `ModelInput`. See [`demo/parser_demo.ipynb`](https://github.com/2phi/weac/blob/main/demo/parser_demo.ipynb) for side-by-side plots of the three parsers on a co-located Weissfluhjoch pit.
372
+
373
+ ```python
374
+ from weac.parser import SMPParser, SnowScopeParser, SnowPilotParser
375
+ ```
376
+
377
+ **SnowMicroPen** (`.PNT`) — `SMPParser` derives density and SSA from the force signal via [snowmicropyn](https://github.com/slf-dot-ch/snowmicropyn)'s Löwe 2012 shot-noise model plus a parameterization (`P2015`, `CR2020`, `K2020a`, `K2020b`). Surface and ground are auto-detected at construction.
378
+
379
+ ```python
380
+ smp = SMPParser("profile.PNT", density_method="CR2020")
381
+ # "bin" groups at a fixed thickness; "gradient" cuts on density-gradient jumps
382
+ layers, density_methods = smp.extract_layers(method="bin", layer_thickness_mm=20.0)
383
+ ```
384
+
385
+ **SnowScope** (`.csv`) — `SnowScopeParser` reads penetration hardness (kPa) and derives density from a semilog model `D = a·ln(F) + b` (default `HAGENMULLER2018`). It exposes the same `extract_layers` interface as the SMP parser.
386
+
387
+ ```python
388
+ scope = SnowScopeParser("profile.csv", density_method="HAGENMULLER2018")
389
+ layers, density_methods = scope.extract_layers(method="gradient", gradient_threshold=12.0)
390
+
391
+ scope_custom = SnowScopeParser(
392
+ "profile.csv",
393
+ semilog_slope=50.0,
394
+ semilog_intercept=100.0,
395
+ )
396
+ ```
397
+
398
+ **SnowPilot / CAAML** (`.xml`) — `SnowPilotParser` uses [snowpylot](https://github.com/connellymk/snowpylot) to read CAAML snowpits, emitting hand-hardness / density-observation layers. Densities come from measured observations where available, falling back to the Geldsetzer estimate from grain type and hand hardness.
399
+
400
+ ```python
401
+ pilot = SnowPilotParser("profile.xml")
402
+ # pass a known slope angle to scale plumb depths to slope-normal, e.g.:
403
+ # pilot.extract_layers(slope_angle_deg=pilot.pit_slope_angle_deg() or 0.0)
404
+ layers, density_methods = pilot.extract_layers()
405
+ ```
406
+
368
407
  <!-- ROADMAP -->
369
408
  ## Roadmap
370
409
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "weac"
7
- version = "3.2.0"
7
+ version = "3.3.0"
8
8
  authors = [{ name = "2phi GbR", email = "mail@2phi.de" }]
9
9
  description = "Weak layer anticrack nucleation model"
10
10
  readme = "README.md"
@@ -24,6 +24,7 @@ dependencies = [
24
24
  "scipy>=1.14.0",
25
25
  "pydantic>=2.11.7",
26
26
  "snowpylot>=1.1.5",
27
+ "snowmicropyn>=1.4.0",
27
28
  ]
28
29
 
29
30
  [project.urls]
@@ -67,7 +68,7 @@ dev = [
67
68
 
68
69
  # Linters/formatters aligned with configured tools
69
70
  "ruff>=0.4.0",
70
- "pylint>=3.2.0",
71
+ "pylint>=3.3.0",
71
72
  "pycodestyle>=2.11.1",
72
73
  "black>=24.4.0",
73
74
  "isort>=5.13.0",
@@ -132,7 +133,7 @@ ignore = [
132
133
  ]
133
134
 
134
135
  [tool.bumpversion]
135
- current_version = "3.2.0"
136
+ current_version = "3.3.0"
136
137
 
137
138
  [[tool.bumpversion.files]]
138
139
  filename = "pyproject.toml"
@@ -2,4 +2,4 @@
2
2
  WEAC - Weak Layer Anticrack Nucleation Model
3
3
  """
4
4
 
5
- __version__ = "3.2.0"
5
+ __version__ = "3.3.0"
@@ -0,0 +1,13 @@
1
+ """Parsers that convert field-profile formats into WEAC layers."""
2
+
3
+ from weac.parser.smp_parser import SMPParser, SMPProfile
4
+ from weac.parser.snowpilot_parser import SnowPilotParser
5
+ from weac.parser.snowscope_parser import SnowScopeParser, SnowScopeProfile
6
+
7
+ __all__ = [
8
+ "SMPParser",
9
+ "SMPProfile",
10
+ "SnowPilotParser",
11
+ "SnowScopeParser",
12
+ "SnowScopeProfile",
13
+ ]
@@ -0,0 +1,19 @@
1
+ """Debug plotting for penetration-resistance-first parsers (SMP, SnowScope)."""
2
+
3
+ from weac.parser.plotting.force_penetration_layers import ForcePenetrationProfile
4
+ from weac.parser.plotting.force_penetration_plot import (
5
+ ForcePenetrationParser,
6
+ plot_force_penetration_layers,
7
+ plot_force_penetration_parser,
8
+ plot_smp_penetration_parser,
9
+ plot_snowscope_penetration_parser,
10
+ )
11
+
12
+ __all__ = [
13
+ "ForcePenetrationParser",
14
+ "ForcePenetrationProfile",
15
+ "plot_force_penetration_layers",
16
+ "plot_force_penetration_parser",
17
+ "plot_smp_penetration_parser",
18
+ "plot_snowscope_penetration_parser",
19
+ ]
@@ -0,0 +1,148 @@
1
+ """Shared four-panel penetration-resistance / density / layer plots."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Protocol
7
+
8
+ import matplotlib.pyplot as plt
9
+ import numpy as np
10
+ from matplotlib.figure import Figure
11
+
12
+ from weac.components import Layer
13
+ from weac.parser.utils import plumb_to_slope_normal
14
+
15
+
16
+ class ForcePenetrationProfile(Protocol):
17
+ """Profile from SMP or SnowScope (resistance–depth signal plus derived density)."""
18
+
19
+ depth_mm: np.ndarray
20
+ penetration_resistance_kPa: np.ndarray
21
+ density_kg_m3: np.ndarray
22
+ density_method: str
23
+
24
+
25
+ def _layer_staircase(
26
+ layers: list[Layer], surface_mm: float
27
+ ) -> tuple[np.ndarray, np.ndarray]:
28
+ """Return (density, depth) staircase points for a top-down layer list.
29
+
30
+ Boundaries accumulate ``Layer.h`` from ``surface_mm`` (the profile's first
31
+ sample depth). Consecutive vertical segments share a boundary depth, so a
32
+ single ``plot`` call renders them as a density-vs-depth staircase. Because
33
+ ``Layer.h`` is slope-normal, the staircase belongs on the slope-normal axis.
34
+ """
35
+ if not layers:
36
+ return np.array([]), np.array([])
37
+ thicknesses = np.array([layer.h for layer in layers], dtype=np.float64)
38
+ densities = np.array([layer.rho for layer in layers], dtype=np.float64)
39
+ tops = surface_mm + np.concatenate(([0.0], np.cumsum(thicknesses)[:-1]))
40
+ bottoms = surface_mm + np.cumsum(thicknesses)
41
+ rho = np.repeat(densities, 2)
42
+ depth = np.empty(rho.size, dtype=np.float64)
43
+ depth[0::2] = tops
44
+ depth[1::2] = bottoms
45
+ return rho, depth
46
+
47
+
48
+ def plot_force_penetration_layers(
49
+ profile: ForcePenetrationProfile,
50
+ layers: list[Layer],
51
+ *,
52
+ slope_angle_deg: float = 0.0,
53
+ title: str | None = None,
54
+ layer_label: str = "layers",
55
+ save_path: str | Path | None = None,
56
+ show: bool = True,
57
+ figsize: tuple[float, float] = (13.0, 6.5),
58
+ ) -> Figure:
59
+ """Plot penetration resistance, density, slope-normal density, and layers on a depth axis.
60
+
61
+ Args:
62
+ profile: :class:`~weac.parser.smp_parser.SMPProfile` or
63
+ :class:`~weac.parser.snowscope_parser.SnowScopeProfile` (must expose
64
+ ``depth_mm``, ``penetration_resistance_kPa``, ``density_kg_m3``,
65
+ ``density_method``).
66
+ layers: WEAC layers ordered surface -> ground (from ``extract_layers``).
67
+ ``Layer.h`` is slope-normal, so the reconstructed staircase is drawn
68
+ on the slope-normal axis (panels 3-4). Pass the same
69
+ ``slope_angle_deg`` used to extract the layers so the slope-normal
70
+ density panel and the staircase share one axis. The staircase bottom
71
+ overshoots the last sample by one cell because the binning helpers
72
+ repeat the final sample spacing (an intentional part of their cell
73
+ model, see ``layer_binning``).
74
+ slope_angle_deg: Slope angle [deg] used when the layers were extracted;
75
+ sets the ``cos(phi)`` compression of the slope-normal depth axis.
76
+ title: Figure suptitle; defaults to the density method name.
77
+ layer_label: Legend/title label for the segmented panel.
78
+ save_path: If given, save the figure (PNG) to this path.
79
+ show: Call ``plt.show()`` before returning (default ``True``).
80
+ figsize: Figure size in inches.
81
+
82
+ Returns:
83
+ The created :class:`~matplotlib.figure.Figure`.
84
+ """
85
+ depth = np.asarray(profile.depth_mm, dtype=np.float64)
86
+ resistance = np.asarray(profile.penetration_resistance_kPa, dtype=np.float64)
87
+ density = np.asarray(profile.density_kg_m3, dtype=np.float64)
88
+ surface_mm = float(depth[0]) if depth.size else 0.0
89
+
90
+ phi = float(slope_angle_deg)
91
+ scale = plumb_to_slope_normal(phi) if phi != 0.0 else 1.0
92
+ # Anchor at the surface; compress only the below-surface span (matches how
93
+ # the layer staircase accumulates slope-normal thicknesses from surface_mm).
94
+ depth_sn = surface_mm + (depth - surface_mm) * scale
95
+ rho_step, depth_step = _layer_staircase(layers, surface_mm)
96
+
97
+ fig, (ax_resistance, ax_density, ax_density_sn, ax_layers) = plt.subplots(
98
+ 1, 4, figsize=figsize, sharey=True
99
+ )
100
+
101
+ ax_resistance.plot(resistance, depth, color="#004E8A", lw=0.9)
102
+ ax_resistance.set_xlabel("Penetration resistance [kPa]")
103
+ ax_resistance.set_ylabel("Depth [mm]")
104
+ ax_resistance.set_title("Penetration resistance\n(plumb)")
105
+
106
+ ax_density.plot(density, depth, color="#00689D", lw=0.9)
107
+ ax_density.set_xlabel("Density [kg m$^{-3}$]")
108
+ ax_density.set_title("Sample density\n(plumb)")
109
+
110
+ ax_density_sn.plot(density, depth_sn, color="#009D81", lw=0.9)
111
+ ax_density_sn.set_xlabel("Density [kg m$^{-3}$]")
112
+ ax_density_sn.set_title(f"Sample density\n(slope-normal, φ={phi:g}°)")
113
+
114
+ # Segmented layers (slope-normal) with the slope-normal density overlaid.
115
+ ax_layers.plot(
116
+ density,
117
+ depth_sn,
118
+ color="#B5B5B5",
119
+ lw=0.8,
120
+ alpha=0.7,
121
+ label="sample density",
122
+ )
123
+ if rho_step.size:
124
+ ax_layers.plot(rho_step, depth_step, color="#EC6500", lw=1.6, label=layer_label)
125
+ # Boundary ticks (every other point is a shared top/bottom depth).
126
+ for boundary in depth_step[1::2]:
127
+ ax_layers.axhline(boundary, color="#EC6500", lw=0.3, alpha=0.3)
128
+ ax_layers.set_xlabel("Density [kg m$^{-3}$]")
129
+ ax_layers.set_title(f"Segmented {layer_label}\n(slope-normal, n={len(layers)})")
130
+ ax_layers.legend(loc="lower right", fontsize=8, frameon=False)
131
+
132
+ # Depth increases downward: surface at the top. Plumb depth is the deepest.
133
+ if depth.size:
134
+ max_depth = float(np.nanmax(depth))
135
+ if depth_step.size:
136
+ max_depth = max(max_depth, float(np.nanmax(depth_step)))
137
+ ax_resistance.set_ylim(max_depth, surface_mm)
138
+ for ax in (ax_resistance, ax_density, ax_density_sn, ax_layers):
139
+ ax.grid(True, alpha=0.25, lw=0.4)
140
+
141
+ fig.suptitle(title or f"Penetration resistance profile ({profile.density_method})")
142
+ fig.tight_layout()
143
+
144
+ if save_path is not None:
145
+ fig.savefig(save_path, dpi=200, bbox_inches="tight", facecolor="white")
146
+ if show:
147
+ plt.show()
148
+ return fig
@@ -0,0 +1,107 @@
1
+ """SMP and SnowScope penetration-resistance debug plotting (unified entry points).
2
+
3
+ Penetration parsers record **penetration resistance first**, then derive sample
4
+ density (SnowMicroPen via snowmicropyn; SnowScope via a semilog hardness model).
5
+ SnowPilot pits are layer lists only — they do not expose this resistance–depth
6
+ signal, so this package does not apply to
7
+ :class:`~weac.parser.snowpilot_parser.SnowPilotParser`.
8
+
9
+ Four depth-aligned panels (see :func:`plot_force_penetration_layers`):
10
+
11
+ 1. Penetration resistance vs plumb depth.
12
+ 2. Sample density vs plumb depth.
13
+ 3. Sample density vs slope-normal depth (plumb depth compressed by ``cos(phi)``).
14
+ 4. Segmented WEAC layers (``bin`` or ``gradient``) vs slope-normal depth, with
15
+ the slope-normal density faintly overlaid.
16
+
17
+ Parser-specific extract-and-plot helpers live in
18
+ :mod:`weac.parser.plotting.smp_penetration_plot` and
19
+ :mod:`weac.parser.plotting.snowscope_penetration_plot`.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ from typing import Literal, Protocol
25
+
26
+ from matplotlib.figure import Figure
27
+
28
+ from weac.components import Layer
29
+ from weac.parser.plotting.force_penetration_layers import (
30
+ ForcePenetrationProfile,
31
+ plot_force_penetration_layers,
32
+ )
33
+ from weac.parser.plotting.smp_penetration_plot import plot_smp_penetration_parser
34
+ from weac.parser.plotting.snowscope_penetration_plot import (
35
+ plot_snowscope_penetration_parser,
36
+ )
37
+ from weac.parser.smp_parser import SMPParser
38
+ from weac.parser.snowscope_parser import SnowScopeParser
39
+
40
+ __all__ = [
41
+ "ForcePenetrationParser",
42
+ "ForcePenetrationProfile",
43
+ "plot_force_penetration_layers",
44
+ "plot_force_penetration_parser",
45
+ "plot_smp_penetration_parser",
46
+ "plot_snowscope_penetration_parser",
47
+ ]
48
+
49
+
50
+ class ForcePenetrationParser(Protocol):
51
+ """SMP or SnowScope parser with ``extract_profile`` / ``extract_layers``."""
52
+
53
+ def extract_profile(self, **kwargs) -> ForcePenetrationProfile: ...
54
+
55
+ def extract_layers(self, *args, **kwargs) -> tuple[list[Layer], list[str]]: ...
56
+
57
+
58
+ def plot_force_penetration_parser(
59
+ parser: ForcePenetrationParser,
60
+ *,
61
+ method: Literal["bin", "gradient"] = "bin",
62
+ slope_angle_deg: float = 0.0,
63
+ density_method: str | None = None,
64
+ semilog_slope: float | None = None,
65
+ semilog_intercept: float | None = None,
66
+ layer_thickness_mm: float | None = None,
67
+ gradient_threshold: float = 12.0,
68
+ **plot_kwargs,
69
+ ) -> Figure:
70
+ """Extract from an SMP or SnowScope parser and plot the debug view.
71
+
72
+ Dispatches to :func:`plot_smp_penetration_parser` or
73
+ :func:`plot_snowscope_penetration_parser`. ``semilog_slope`` /
74
+ ``semilog_intercept`` apply only to SnowScope. ``**plot_kwargs`` (e.g.
75
+ ``title``, ``save_path``, ``show``) pass through to
76
+ :func:`plot_force_penetration_layers`.
77
+ """
78
+ if isinstance(parser, SnowScopeParser):
79
+ return plot_snowscope_penetration_parser(
80
+ parser,
81
+ method=method,
82
+ slope_angle_deg=slope_angle_deg,
83
+ density_method=density_method, # type: ignore[arg-type]
84
+ semilog_slope=semilog_slope,
85
+ semilog_intercept=semilog_intercept,
86
+ layer_thickness_mm=layer_thickness_mm,
87
+ gradient_threshold=gradient_threshold,
88
+ **plot_kwargs,
89
+ )
90
+ if isinstance(parser, SMPParser):
91
+ if semilog_slope is not None or semilog_intercept is not None:
92
+ raise TypeError(
93
+ "semilog_slope and semilog_intercept apply only to SnowScopeParser"
94
+ )
95
+ return plot_smp_penetration_parser(
96
+ parser,
97
+ method=method,
98
+ slope_angle_deg=slope_angle_deg,
99
+ density_method=density_method, # type: ignore[arg-type]
100
+ layer_thickness_mm=layer_thickness_mm,
101
+ gradient_threshold=gradient_threshold,
102
+ **plot_kwargs,
103
+ )
104
+ raise TypeError(
105
+ "plot_force_penetration_parser expects SMPParser or SnowScopeParser, "
106
+ f"got {type(parser).__name__}"
107
+ )
@@ -0,0 +1,34 @@
1
+ """Extract-and-plot helper for :class:`~weac.parser.smp_parser.SMPParser`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Literal
6
+
7
+ from matplotlib.figure import Figure
8
+
9
+ from weac.parser.plotting.force_penetration_layers import plot_force_penetration_layers
10
+ from weac.parser.smp_parser import DensityMethod, SMPParser
11
+
12
+
13
+ def plot_smp_penetration_parser(
14
+ parser: SMPParser,
15
+ *,
16
+ method: Literal["bin", "gradient"] = "bin",
17
+ slope_angle_deg: float = 0.0,
18
+ density_method: DensityMethod | None = None,
19
+ layer_thickness_mm: float | None = None,
20
+ gradient_threshold: float = 12.0,
21
+ **plot_kwargs,
22
+ ) -> Figure:
23
+ """Extract from an SMP parser and plot the penetration-resistance debug view."""
24
+ profile = parser.extract_profile(density_method=density_method)
25
+ layers, _ = parser.extract_layers(
26
+ slope_angle_deg,
27
+ method=method,
28
+ density_method=density_method,
29
+ layer_thickness_mm=layer_thickness_mm,
30
+ gradient_threshold=gradient_threshold,
31
+ )
32
+ plot_kwargs.setdefault("layer_label", method)
33
+ plot_kwargs.setdefault("slope_angle_deg", slope_angle_deg)
34
+ return plot_force_penetration_layers(profile, layers, **plot_kwargs)
@@ -0,0 +1,42 @@
1
+ """Extract-and-plot helper for :class:`~weac.parser.snowscope_parser.SnowScopeParser`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Literal
6
+
7
+ from matplotlib.figure import Figure
8
+
9
+ from weac.parser.plotting.force_penetration_layers import plot_force_penetration_layers
10
+ from weac.parser.snowscope_parser import DensityMethod, SnowScopeParser
11
+
12
+
13
+ def plot_snowscope_penetration_parser(
14
+ parser: SnowScopeParser,
15
+ *,
16
+ method: Literal["bin", "gradient"] = "bin",
17
+ slope_angle_deg: float = 0.0,
18
+ density_method: DensityMethod | None = None,
19
+ semilog_slope: float | None = None,
20
+ semilog_intercept: float | None = None,
21
+ layer_thickness_mm: float | None = None,
22
+ gradient_threshold: float = 12.0,
23
+ **plot_kwargs,
24
+ ) -> Figure:
25
+ """Extract from a SnowScope parser and plot the penetration-resistance debug view."""
26
+ profile = parser.extract_profile(
27
+ density_method=density_method,
28
+ semilog_slope=semilog_slope,
29
+ semilog_intercept=semilog_intercept,
30
+ )
31
+ layers, _ = parser.extract_layers(
32
+ slope_angle_deg,
33
+ method=method,
34
+ density_method=density_method,
35
+ semilog_slope=semilog_slope,
36
+ semilog_intercept=semilog_intercept,
37
+ layer_thickness_mm=layer_thickness_mm,
38
+ gradient_threshold=gradient_threshold,
39
+ )
40
+ plot_kwargs.setdefault("layer_label", method)
41
+ plot_kwargs.setdefault("slope_angle_deg", slope_angle_deg)
42
+ return plot_force_penetration_layers(profile, layers, **plot_kwargs)