lsdviztools3 0.4.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.
Files changed (149) hide show
  1. lsdviztools3/__init__.py +12 -0
  2. lsdviztools3/_gmt.py +48 -0
  3. lsdviztools3/charts/__init__.py +214 -0
  4. lsdviztools3/charts/burned_profiles.py +270 -0
  5. lsdviztools3/charts/channel_bearings.py +988 -0
  6. lsdviztools3/charts/channel_slopes.py +245 -0
  7. lsdviztools3/charts/chi.py +501 -0
  8. lsdviztools3/charts/circular.py +231 -0
  9. lsdviztools3/charts/cosmo_comparison.py +208 -0
  10. lsdviztools3/charts/cosmo_transient.py +437 -0
  11. lsdviztools3/charts/cosmo_transient_spatial.py +549 -0
  12. lsdviztools3/charts/cross_sections.py +1449 -0
  13. lsdviztools3/charts/distributions.py +120 -0
  14. lsdviztools3/charts/divide_migration.py +398 -0
  15. lsdviztools3/charts/erodibility_calibration.py +220 -0
  16. lsdviztools3/charts/estar_rstar.py +250 -0
  17. lsdviztools3/charts/floodplain_qq.py +91 -0
  18. lsdviztools3/charts/gradient_curvature.py +253 -0
  19. lsdviztools3/charts/hand.py +939 -0
  20. lsdviztools3/charts/hillslope_profiles.py +187 -0
  21. lsdviztools3/charts/hillslope_residuals.py +102 -0
  22. lsdviztools3/charts/hillslope_transient_residuals.py +87 -0
  23. lsdviztools3/charts/hilltop_coupling.py +691 -0
  24. lsdviztools3/charts/hydraulic_breaks.py +555 -0
  25. lsdviztools3/charts/knickpoints.py +395 -0
  26. lsdviztools3/charts/ladder_band_map.py +108 -0
  27. lsdviztools3/charts/ladder_density.py +149 -0
  28. lsdviztools3/charts/ladder_windows.py +187 -0
  29. lsdviztools3/charts/landslides.py +197 -0
  30. lsdviztools3/charts/lithology_ksn.py +683 -0
  31. lsdviztools3/charts/network_geometry.py +471 -0
  32. lsdviztools3/charts/network_maps.py +255 -0
  33. lsdviztools3/charts/perimeter_sources.py +379 -0
  34. lsdviztools3/charts/ridgeline.py +736 -0
  35. lsdviztools3/charts/rose.py +148 -0
  36. lsdviztools3/charts/slope_area.py +306 -0
  37. lsdviztools3/charts/snapping.py +223 -0
  38. lsdviztools3/charts/spectral.py +419 -0
  39. lsdviztools3/charts/surface_methods.py +124 -0
  40. lsdviztools3/charts/swath.py +99 -0
  41. lsdviztools3/charts/training_polygons.py +108 -0
  42. lsdviztools3/charts/valley_section_map.py +151 -0
  43. lsdviztools3/charts/valley_sections.py +309 -0
  44. lsdviztools3/charts/valley_spacing.py +302 -0
  45. lsdviztools3/charts/valley_width_network.py +610 -0
  46. lsdviztools3/cli/__init__.py +26 -0
  47. lsdviztools3/cli/_chi_common.py +74 -0
  48. lsdviztools3/cli/_common.py +154 -0
  49. lsdviztools3/cli/_ridgeline.py +199 -0
  50. lsdviztools3/cli/grab_dem.py +78 -0
  51. lsdviztools3/cli/plot_animation.py +82 -0
  52. lsdviztools3/cli/plot_basemap.py +86 -0
  53. lsdviztools3/cli/plot_basins.py +136 -0
  54. lsdviztools3/cli/plot_burned_profiles.py +77 -0
  55. lsdviztools3/cli/plot_channel_bearings.py +299 -0
  56. lsdviztools3/cli/plot_channel_slopes.py +91 -0
  57. lsdviztools3/cli/plot_channels.py +69 -0
  58. lsdviztools3/cli/plot_chi.py +112 -0
  59. lsdviztools3/cli/plot_chi_cosmo.py +109 -0
  60. lsdviztools3/cli/plot_cosmo_basins.py +134 -0
  61. lsdviztools3/cli/plot_cosmo_compare.py +81 -0
  62. lsdviztools3/cli/plot_cosmo_published.py +64 -0
  63. lsdviztools3/cli/plot_cosmo_transient.py +88 -0
  64. lsdviztools3/cli/plot_divide_migration.py +137 -0
  65. lsdviztools3/cli/plot_drape.py +171 -0
  66. lsdviztools3/cli/plot_erodibility_calibration.py +150 -0
  67. lsdviztools3/cli/plot_estar_rstar.py +83 -0
  68. lsdviztools3/cli/plot_floodplain.py +140 -0
  69. lsdviztools3/cli/plot_hillshade.py +69 -0
  70. lsdviztools3/cli/plot_hillslope_comparison.py +42 -0
  71. lsdviztools3/cli/plot_hilltop_coupling.py +148 -0
  72. lsdviztools3/cli/plot_knickpoints.py +140 -0
  73. lsdviztools3/cli/plot_ksn_by_lithology.py +117 -0
  74. lsdviztools3/cli/plot_ladder_band_map.py +50 -0
  75. lsdviztools3/cli/plot_ladder_density.py +64 -0
  76. lsdviztools3/cli/plot_ladder_windows.py +67 -0
  77. lsdviztools3/cli/plot_lem_ci_animation.py +90 -0
  78. lsdviztools3/cli/plot_lem_pulse.py +150 -0
  79. lsdviztools3/cli/plot_lithology_map.py +235 -0
  80. lsdviztools3/cli/plot_mdd_ridgeline.py +69 -0
  81. lsdviztools3/cli/plot_perimeter_sources.py +74 -0
  82. lsdviztools3/cli/plot_ridgeline_curvature.py +74 -0
  83. lsdviztools3/cli/plot_ridgeline_spacing.py +147 -0
  84. lsdviztools3/cli/plot_ridgeline_summary.py +115 -0
  85. lsdviztools3/cli/plot_sti.py +237 -0
  86. lsdviztools3/cli/plot_surface_methods.py +83 -0
  87. lsdviztools3/cli/plot_valley_section_map.py +66 -0
  88. lsdviztools3/cli/plot_valley_sections.py +95 -0
  89. lsdviztools3/cli/plot_valley_widths.py +131 -0
  90. lsdviztools3/cli/run_lsdtt.py +88 -0
  91. lsdviztools3/colour_limits.py +64 -0
  92. lsdviztools3/download/__init__.py +1 -0
  93. lsdviztools3/download/apikey.py +69 -0
  94. lsdviztools3/download/ot_scraper.py +167 -0
  95. lsdviztools3/driver/__init__.py +19 -0
  96. lsdviztools3/driver/config.py +226 -0
  97. lsdviztools3/driver/lsdtt_driver.py +123 -0
  98. lsdviztools3/frames.py +122 -0
  99. lsdviztools3/io/__init__.py +6 -0
  100. lsdviztools3/io/channel_bearings.py +442 -0
  101. lsdviztools3/io/chi.py +336 -0
  102. lsdviztools3/io/coverage.py +118 -0
  103. lsdviztools3/io/divide_migration.py +368 -0
  104. lsdviztools3/io/erodibility_calibration.py +204 -0
  105. lsdviztools3/io/grids.py +40 -0
  106. lsdviztools3/io/hilltop_coupling.py +272 -0
  107. lsdviztools3/io/lithology.py +696 -0
  108. lsdviztools3/io/network_geometry.py +428 -0
  109. lsdviztools3/io/raster.py +196 -0
  110. lsdviztools3/io/ridgeline.py +528 -0
  111. lsdviztools3/io/snapping.py +434 -0
  112. lsdviztools3/io/spectral.py +347 -0
  113. lsdviztools3/io/swath.py +92 -0
  114. lsdviztools3/io/table.py +54 -0
  115. lsdviztools3/io/valley_spacing.py +171 -0
  116. lsdviztools3/io/vector.py +91 -0
  117. lsdviztools3/paths.py +53 -0
  118. lsdviztools3/regions.py +155 -0
  119. lsdviztools3/render/__init__.py +15 -0
  120. lsdviztools3/render/animation.py +349 -0
  121. lsdviztools3/render/base.py +199 -0
  122. lsdviztools3/render/basemap.py +165 -0
  123. lsdviztools3/render/basins.py +112 -0
  124. lsdviztools3/render/categorical.py +490 -0
  125. lsdviztools3/render/channels.py +360 -0
  126. lsdviztools3/render/colormaps.py +144 -0
  127. lsdviztools3/render/decorations.py +505 -0
  128. lsdviztools3/render/divide_migration.py +188 -0
  129. lsdviztools3/render/erodibility_calibration_map.py +193 -0
  130. lsdviztools3/render/floodplain.py +176 -0
  131. lsdviztools3/render/graticule.py +187 -0
  132. lsdviztools3/render/hillshade.py +115 -0
  133. lsdviztools3/render/hillslope_comparison_animation.py +352 -0
  134. lsdviztools3/render/lem_ci_animation.py +342 -0
  135. lsdviztools3/render/lem_pulse.py +522 -0
  136. lsdviztools3/render/lithology_colours.py +124 -0
  137. lsdviztools3/render/lithology_map.py +668 -0
  138. lsdviztools3/render/points.py +149 -0
  139. lsdviztools3/render/relief3d.py +152 -0
  140. lsdviztools3/render/ridgeline.py +225 -0
  141. lsdviztools3/render/sti.py +54 -0
  142. lsdviztools3/render/style.py +74 -0
  143. lsdviztools3/render/surface_methods.py +184 -0
  144. lsdviztools3/workflows.py +2713 -0
  145. lsdviztools3-0.4.1.dist-info/METADATA +367 -0
  146. lsdviztools3-0.4.1.dist-info/RECORD +149 -0
  147. lsdviztools3-0.4.1.dist-info/WHEEL +4 -0
  148. lsdviztools3-0.4.1.dist-info/entry_points.txt +42 -0
  149. lsdviztools3-0.4.1.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,12 @@
1
+ """lsdviztools3 — PyGMT-based visualisation tools for LSDTopoTools v3 output.
2
+
3
+ Version 1 provides a backend-agnostic IO/data core (``lsdviztools3.io``) and a
4
+ thin PyGMT render layer (``lsdviztools3.render``). Statistics (x-y) plotting is
5
+ deferred (see ``lsdviztools3.charts``).
6
+ """
7
+
8
+ __version__ = "0.4.1"
9
+
10
+ # TODO: curate top-level re-exports once the render API stabilises, e.g.
11
+ # from .render.hillshade import render_hillshade
12
+ # from .render.style import MapStyle
lsdviztools3/_gmt.py ADDED
@@ -0,0 +1,48 @@
1
+ """Import PyGMT with a clear message when GMT itself is missing.
2
+
3
+ PyGMT is a Python wrapper around the GMT C library. pip installs PyGMT (it is a
4
+ dependency of lsdviztools3) but cannot install GMT, so a plain ``pip install``
5
+ gives a PyGMT that fails on import with ``GMTCLibNotFoundError``. Every map
6
+ renderer imports PyGMT through :func:`require_pygmt`, which turns that, and a
7
+ missing PyGMT, into :class:`GMTNotAvailableError` saying how to install GMT.
8
+
9
+ The import happens inside each renderer, not when lsdviztools3 is imported, so
10
+ the matplotlib charts keep working without GMT.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ INSTALL_HELP = (
16
+ "lsdviztools3 draws maps with PyGMT, which needs the GMT library. pip cannot "
17
+ "install GMT: install it from conda-forge (for example "
18
+ "`conda install -c conda-forge gmt pygmt`, or use the pixi environment in "
19
+ "the lsdviztools3 repository), Homebrew (`brew install gmt`) or your system "
20
+ "package manager (GMT 6.5 or later). The matplotlib charts work without it. "
21
+ "See https://lsdtopotools.github.io/lsdviztools3/ for installation."
22
+ )
23
+
24
+
25
+ class GMTNotAvailableError(ImportError):
26
+ """PyGMT, or the GMT library it wraps, cannot be loaded.
27
+
28
+ A subclass of ImportError, so ``pytest.importorskip`` and code that already
29
+ catches ImportError treat a missing GMT like a missing package.
30
+ """
31
+
32
+
33
+ def require_pygmt():
34
+ """Return the ``pygmt`` module, or raise :class:`GMTNotAvailableError`."""
35
+ try:
36
+ import pygmt
37
+ except ImportError as err: # PyGMT itself is not installed
38
+ raise GMTNotAvailableError(f"PyGMT is not installed. {INSTALL_HELP}") from err
39
+ except Exception as err: # noqa: BLE001 - PyGMT raises its own types on load
40
+ # GMTCLibNotFoundError / GMTCLibError: PyGMT is there but cannot load
41
+ # libgmt (missing, or too old). Match on the name: pygmt.exceptions
42
+ # cannot be imported when importing pygmt itself has just failed.
43
+ if type(err).__name__.startswith("GMTCLib"):
44
+ raise GMTNotAvailableError(
45
+ f"PyGMT is installed but cannot load the GMT library ({err}). {INSTALL_HELP}"
46
+ ) from err
47
+ raise
48
+ return pygmt
@@ -0,0 +1,214 @@
1
+ """Statistics (x-y) plotting.
2
+
3
+ All x-y charts are implemented on a matplotlib engine (DESIGN decision #1),
4
+ imported lazily so the core maps install stays lean. Only the channel
5
+ longitudinal profile (:func:`plot_profile`) is still deferred and raises
6
+ ``NotImplementedError``.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from .chi import plot_chi_profile, plot_chi_stacked, plot_ksn_vs_erate
12
+ from .circular import (
13
+ circular_wasserstein, kuiper_two_sample, plot_circular_comparison,
14
+ )
15
+ from .cross_sections import (
16
+ plot_valley_cross_section_map, plot_valley_cross_sections,
17
+ plot_valley_curved_cross_section_map,
18
+ plot_valley_cross_section_features,
19
+ plot_valley_cross_section_features_map,
20
+ plot_valley_cross_section_hydraulics,
21
+ cross_section_hydraulics, detect_hydraulic_flats,
22
+ combine_width_steps, compute_flat_surface_edges,
23
+ )
24
+ from .cosmo_comparison import (
25
+ load_cosmo_table, plot_cosmo_sweep_panels, plot_cosmo_sample_residuals,
26
+ )
27
+ from .cosmo_transient import (
28
+ cosmo_transient_title, load_cosmo_transient_table,
29
+ plot_cosmo_transient_concentration, plot_cosmo_transient_erate_ratio,
30
+ plot_nuclide_removal_response,
31
+ )
32
+ from .cosmo_transient_spatial import (
33
+ load_joined_samples, plot_ratio_overlay_1d, plot_ratio_vs_mean_erate,
34
+ plot_scenario_delta,
35
+ plot_ratio_vs_steptime,
36
+ )
37
+ from .distributions import plot_distributions
38
+ from .lithology_ksn import (
39
+ plot_ksn_box_by_lithology, plot_ksn_by_lithology_and_basin,
40
+ plot_ksn_histogram_by_lithology, plot_reach_ksn_by_lithology,
41
+ )
42
+ from .channel_bearings import (
43
+ plot_binned_rose, plot_binned_rose_grid, plot_comparison_table,
44
+ plot_junction_angle_distributions, plot_junction_angle_summary,
45
+ plot_rose_comparison,
46
+ )
47
+ from .estar_rstar import plot_estar_rstar_model_scan
48
+ from .divide_migration import (
49
+ aggressor_annotation, plot_head_metric_vs_distance, plot_pair_comparison,
50
+ )
51
+ from .hand import (
52
+ plot_hand_distance, plot_hand_window_populations, plot_valley_break_metric,
53
+ plot_valley_break_fraction, plot_valley_floor_map, plot_valley_reach_map,
54
+ )
55
+ from .hydraulic_breaks import (
56
+ plot_hydraulic_breaks_downstream, hydraulic_break_clusters,
57
+ plot_hydraulic_break_clusters, plot_cross_section_informed_valley_mask,
58
+ )
59
+ from .landslides import (
60
+ load_landslide_inventory,
61
+ plot_landslide_area_volume_larsen,
62
+ plot_sample_effect_apparent_vs_true,
63
+ plot_sample_effect_ratio,
64
+ )
65
+ from .network_geometry import (
66
+ plot_delta_L_cdf, plot_edge_basin_cdf, plot_junction_angle_cdf,
67
+ plot_metric_panels, plot_metric_vs_temperature,
68
+ )
69
+ from .rose import rose_diagram
70
+ from .swath import plot_swath
71
+ from .hillslope_profiles import (
72
+ load_profile_raster, profile_derivatives, profile_residuals,
73
+ plot_hillslope_profiles, plot_hillslope_profile, plot_profile_residual_panels,
74
+ )
75
+ from .hillslope_residuals import (
76
+ residual_statistics, summarise_residuals, plot_residual_density,
77
+ plot_hillslope_residual_density, plot_hillslope_residuals,
78
+ summarize_residuals, plot_residual_raster,
79
+ )
80
+ from .hillslope_transient_residuals import (
81
+ load_transient_residuals, plot_transient_residuals,
82
+ plot_hillslope_transient_residuals, plot_strip_transient_metrics,
83
+ )
84
+
85
+ __all__ = [
86
+ "plot_chi_profile", "plot_chi_stacked", "plot_ksn_vs_erate", "plot_swath",
87
+ "plot_hand_distance", "plot_hand_window_populations",
88
+ "plot_valley_break_metric", "plot_valley_break_fraction",
89
+ "plot_valley_cross_sections",
90
+ "plot_valley_cross_section_map",
91
+ "plot_valley_curved_cross_section_map",
92
+ "plot_valley_cross_section_features",
93
+ "plot_valley_cross_section_features_map",
94
+ "plot_valley_cross_section_hydraulics",
95
+ "cross_section_hydraulics", "detect_hydraulic_flats",
96
+ "combine_width_steps", "compute_flat_surface_edges",
97
+ "plot_valley_reach_map",
98
+ "plot_valley_floor_map",
99
+ "plot_hydraulic_breaks_downstream", "hydraulic_break_clusters",
100
+ "plot_hydraulic_break_clusters",
101
+ "plot_cross_section_informed_valley_mask",
102
+ "plot_distributions", "rose_diagram",
103
+ "plot_ksn_histogram_by_lithology", "plot_ksn_box_by_lithology",
104
+ "plot_reach_ksn_by_lithology", "plot_ksn_by_lithology_and_basin",
105
+ "plot_binned_rose", "plot_binned_rose_grid", "plot_junction_angle_summary",
106
+ "plot_junction_angle_distributions", "plot_rose_comparison", "plot_comparison_table",
107
+ "plot_estar_rstar_model_scan",
108
+ "load_landslide_inventory",
109
+ "plot_landslide_area_volume_larsen",
110
+ "plot_sample_effect_apparent_vs_true",
111
+ "plot_sample_effect_ratio",
112
+ "circular_wasserstein", "kuiper_two_sample", "plot_circular_comparison",
113
+ "load_cosmo_table", "plot_cosmo_sweep_panels", "plot_cosmo_sample_residuals",
114
+ "load_cosmo_transient_table", "cosmo_transient_title",
115
+ "plot_cosmo_transient_concentration", "plot_cosmo_transient_erate_ratio",
116
+ "plot_nuclide_removal_response",
117
+ "load_joined_samples", "plot_ratio_vs_steptime", "plot_ratio_vs_mean_erate",
118
+ "plot_ratio_overlay_1d", "plot_scenario_delta",
119
+ "plot_pair_comparison", "plot_head_metric_vs_distance",
120
+ "aggressor_annotation",
121
+ "plot_junction_angle_cdf", "plot_delta_L_cdf", "plot_edge_basin_cdf",
122
+ "plot_metric_panels", "plot_metric_vs_temperature",
123
+ "load_profile_raster", "profile_derivatives", "profile_residuals",
124
+ "plot_hillslope_profiles", "plot_hillslope_profile", "plot_profile_residual_panels",
125
+ "residual_statistics",
126
+ "summarise_residuals", "plot_residual_density", "plot_hillslope_residual_density",
127
+ "plot_hillslope_residuals", "summarize_residuals", "plot_residual_raster",
128
+ "load_transient_residuals", "plot_transient_residuals",
129
+ "plot_hillslope_transient_residuals", "plot_strip_transient_metrics",
130
+ "plot_profile", "plot_slope_area", "plot_gradient_curvature",
131
+ "plot_valley_spectrum", "plot_spacing_vs_ci", "plot_landscape_panel",
132
+ "plot_significant_frequencies_polar", "plot_spectral_tile_summary",
133
+ "plot_normalised_spectrum_2d", "plot_radial_spectrum",
134
+ ]
135
+
136
+ _DEFERRED_MSG = (
137
+ "This lsdviztools3.charts x-y plot is deferred to a later release and is "
138
+ "not implemented yet."
139
+ )
140
+
141
+
142
+ def plot_profile(*args, **kwargs):
143
+ """Longitudinal channel profile. Deferred to a later release."""
144
+ raise NotImplementedError(_DEFERRED_MSG)
145
+
146
+
147
+ def plot_slope_area(*args, **kwargs):
148
+ """Slope-area "boomerang" -- see :mod:`lsdviztools3.charts.slope_area`."""
149
+ from .slope_area import plot_slope_area as _impl
150
+ return _impl(*args, **kwargs)
151
+
152
+ def plot_valley_spectrum(*args, **kwargs):
153
+ """One landscape's spectrum with both spacing measures marked.
154
+
155
+ See :mod:`lsdviztools3.charts.valley_spacing`.
156
+ """
157
+ from .valley_spacing import plot_valley_spectrum as _impl
158
+ return _impl(*args, **kwargs)
159
+
160
+ def plot_spacing_vs_ci(*args, **kwargs):
161
+ """Valley spacing against the Bonetti channelization index, for a sweep.
162
+
163
+ See :mod:`lsdviztools3.charts.valley_spacing`.
164
+ """
165
+ from .valley_spacing import plot_spacing_vs_ci as _impl
166
+ return _impl(*args, **kwargs)
167
+
168
+ def plot_landscape_panel(*args, **kwargs):
169
+ """Hillshade a set of landscapes side by side, for a visual sniff test.
170
+
171
+ See :mod:`lsdviztools3.charts.valley_spacing`.
172
+ """
173
+ from .valley_spacing import plot_landscape_panel as _impl
174
+ return _impl(*args, **kwargs)
175
+
176
+ def plot_significant_frequencies_polar(*args, **kwargs):
177
+ """Significant 2D spectrum cells on a North-up polar axis (bearing x frequency).
178
+
179
+ See :mod:`lsdviztools3.charts.spectral`.
180
+ """
181
+ from .spectral import plot_significant_frequencies_polar as _impl
182
+ return _impl(*args, **kwargs)
183
+
184
+ def plot_spectral_tile_summary(*args, **kwargs):
185
+ """One lsdtt-spectral-analysis run on a 2 x 2 figure (input, 2D, radial, polar).
186
+
187
+ See :mod:`lsdviztools3.charts.spectral`.
188
+ """
189
+ from .spectral import plot_spectral_tile_summary as _impl
190
+ return _impl(*args, **kwargs)
191
+
192
+ def plot_normalised_spectrum_2d(*args, **kwargs):
193
+ """Normalised 2D spectrum, north up, significant cells outlined.
194
+
195
+ See :mod:`lsdviztools3.charts.spectral`.
196
+ """
197
+ from .spectral import plot_normalised_spectrum_2d as _impl
198
+ return _impl(*args, **kwargs)
199
+
200
+ def plot_radial_spectrum(*args, **kwargs):
201
+ """Binned radial spectrum with background, normalised PSD and rollover.
202
+
203
+ See :mod:`lsdviztools3.charts.spectral`.
204
+ """
205
+ from .spectral import plot_radial_spectrum as _impl
206
+ return _impl(*args, **kwargs)
207
+
208
+ def plot_gradient_curvature(*args, **kwargs):
209
+ """Gradient-curvature diagnostic after Roering (2008).
210
+
211
+ See :mod:`lsdviztools3.charts.gradient_curvature`.
212
+ """
213
+ from .gradient_curvature import plot_gradient_curvature as _impl
214
+ return _impl(*args, **kwargs)
@@ -0,0 +1,270 @@
1
+ """Long profiles of channels burned by lsdtt3 ``lsdtt-stream-burning``.
2
+
3
+ Reads ``<write_fname>_burned_profiles.csv`` from a run with
4
+ ``stream_burn_mode: burn_to_corridor``: one row
5
+ per channel cell, downstream order within each path (a chain of reaches from
6
+ its head to where it joins another path or leaves the network), with
7
+
8
+ path_id, path_rank, reach_id, name, point_index, distance_m, flow_distance_m,
9
+ x, y, z_original, z_cleaned, z_profile, z_final, pit_filled, pit_id,
10
+ is_record_low, is_rejected_low, drainage_area_m2, chi, joins_path_id,
11
+ join_distance_m, starts_on_path_id
12
+
13
+ Everything here is drawing: which cells form a course (a path and the paths it
14
+ joins, down to the outlet) is read from ``joins_path_id`` / ``join_distance_m``;
15
+ nothing is recomputed. The one derived quantity is the chi axis of a course,
16
+ where each joined path's chi (counted from that path's head in the CSV) is
17
+ shifted so the course's chi is continuous at the joins.
18
+
19
+ ``course_rows`` the rows of a course, ordered downstream, with
20
+ ``course_distance_m`` and ``course_chi``
21
+ ``select_course`` a course by path id, path rank or river name
22
+ ``draw_burned_profile`` one course on one axis
23
+ ``plot_burned_profiles`` one panel per CSV (e.g. X = 0 / 60 / 150 m, or a
24
+ linear against a chi fit), shared x axis
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ from pathlib import Path
30
+
31
+ import matplotlib
32
+ matplotlib.use("Agg")
33
+ import matplotlib.pyplot as plt
34
+ import numpy as np
35
+ import pandas as pd
36
+
37
+ # Fixed roles (validated together, light surface; green is below 3:1 contrast,
38
+ # so every band carries a legend label).
39
+ FINAL_COLOUR = "#2a78d6" # the burned channel in the output DEM
40
+ CUT_COLOUR = "#eb6834" # cut below the cleaned DEM
41
+ PIT_COLOUR = "#1baf7a" # small pits filled before the profile was sampled
42
+ RAISE_COLOUR = "#9a5bd6" # raised above the cleaned DEM (junction pins, rejected minima)
43
+ ORIGINAL_COLOUR = "#8a8a85" # the input DEM under the channel
44
+ INK = "#3d3d3a"
45
+
46
+ REQUIRED = ["path_id", "path_rank", "name", "point_index", "distance_m", "flow_distance_m",
47
+ "x", "y", "z_original", "z_cleaned", "z_profile", "z_final", "pit_filled",
48
+ "pit_id", "is_record_low", "is_rejected_low", "chi", "joins_path_id",
49
+ "join_distance_m"]
50
+
51
+
52
+ def read_burned_profiles(csv) -> pd.DataFrame:
53
+ """Read a ``_burned_profiles.csv`` written by burn_to_corridor."""
54
+ p = Path(csv)
55
+ if not p.exists():
56
+ raise FileNotFoundError(f"{p} (run lsdtt-stream-burning with stream_burn_mode: "
57
+ "burn_to_corridor and write_burned_profiles_csv: true).")
58
+ df = pd.read_csv(p, keep_default_na=False, na_values={"chi": [""], "drainage_area_m2": [""],
59
+ "join_distance_m": [""]})
60
+ missing = [c for c in REQUIRED if c not in df.columns]
61
+ if missing:
62
+ raise ValueError(f"{p}: not a burn_to_corridor profiles file (missing {missing}); "
63
+ "older runs (z_filled / z_burned) are not supported.")
64
+ df["name"] = df["name"].astype(str)
65
+ return df
66
+
67
+
68
+ def course_rows(df: pd.DataFrame, path_id: int, start_index: int = 0) -> pd.DataFrame:
69
+ """Rows of the course from ``path_id`` (from its cell ``start_index``) down
70
+ to the outlet, following ``joins_path_id``. Adds ``course_distance_m``
71
+ (downstream from the course's first cell) and ``course_chi`` (continuous
72
+ at the joins; NaN where the run has no chi)."""
73
+ parts = []
74
+ pid, lo = int(path_id), int(start_index)
75
+ offset = 0.0 # added to the chi of the current path
76
+ seen = set()
77
+ while pid >= 0 and pid not in seen:
78
+ seen.add(pid)
79
+ g = df[df.path_id == pid].sort_values("point_index")
80
+ if g.empty:
81
+ raise ValueError(f"no path {pid} in the profiles file")
82
+ g = g[g.point_index >= lo].copy()
83
+ g["course_chi"] = g.chi.astype(float) + offset
84
+ nxt = int(g.joins_path_id.iloc[0])
85
+ if nxt >= 0:
86
+ # the joined path continues from the join cell (this path's last
87
+ # cell); its chi is shifted to match this path's value there
88
+ h = df[df.path_id == nxt].sort_values("point_index")
89
+ jd = float(g.join_distance_m.iloc[0])
90
+ at = h[np.isclose(h.distance_m, jd, atol=1e-6)]
91
+ if at.empty:
92
+ raise ValueError(f"path {pid} joins path {nxt} at {jd} m, which has no such cell")
93
+ lo = int(at.point_index.min())
94
+ offset = float(g.course_chi.iloc[-1]) - float(at.chi.iloc[0])
95
+ g = g.iloc[:-1]
96
+ parts.append(g)
97
+ pid = nxt
98
+ c = pd.concat(parts, ignore_index=True)
99
+ c["course_distance_m"] = c.flow_distance_m.iloc[0] - c.flow_distance_m
100
+ return c
101
+
102
+
103
+ def select_course(df: pd.DataFrame, path_id=None, path_rank=None, name=None):
104
+ """A course by path id, path rank (0 = the first, longest path) or river
105
+ name (from the most upstream cell with that name). Returns (rows, label)."""
106
+ given = [v is not None for v in (path_id, path_rank, name)]
107
+ if sum(given) > 1:
108
+ raise ValueError("give one of path_id, path_rank, name")
109
+ if name is not None:
110
+ hit = df[df.name == name]
111
+ if hit.empty:
112
+ raise ValueError(f"no cells named {name!r} (was vector_name_field set?)")
113
+ top = hit.loc[hit.flow_distance_m.idxmax()]
114
+ return course_rows(df, int(top.path_id), int(top.point_index)), name
115
+ if path_rank is None and path_id is None:
116
+ path_rank = 0
117
+ if path_rank is not None:
118
+ hit = df[df.path_rank == int(path_rank)]
119
+ if hit.empty:
120
+ raise ValueError(f"no path of rank {path_rank}")
121
+ path_id = int(hit.path_id.iloc[0])
122
+ rows = course_rows(df, int(path_id))
123
+ names = list(dict.fromkeys(n for n in rows.name if n))
124
+ label = (" / ".join(names[:3]) + (" ..." if len(names) > 3 else "")) if names else f"path {path_id}"
125
+ return rows, label
126
+
127
+
128
+ def _joins_on_course(df, course, max_labels):
129
+ """(course x position, name, length) of paths joining the course."""
130
+ on = {(round(a, 3), round(b, 3)): i for i, (a, b) in enumerate(zip(course.x, course.y))}
131
+ course_paths = set(course.path_id)
132
+ out = []
133
+ for pid, g in df.groupby("path_id"):
134
+ if pid in course_paths:
135
+ continue
136
+ g = g.sort_values("point_index")
137
+ last = g.iloc[-1]
138
+ k = on.get((round(last.x, 3), round(last.y, 3)))
139
+ if k is None or int(last.joins_path_id) < 0:
140
+ continue
141
+ nm = next((n for n in g.name[::-1] if n), "")
142
+ out.append((k, nm, float(g.distance_m.max())))
143
+ out.sort(key=lambda t: -t[2])
144
+ return out, out[:max_labels]
145
+
146
+
147
+ def draw_burned_profile(ax, df: pd.DataFrame, course: pd.DataFrame, x: str = "distance",
148
+ mark=None, max_join_labels: int = 6, legend: bool = True):
149
+ """Draw one course on ``ax``. ``x``: "distance" (km downstream) or "chi"."""
150
+ if x == "chi":
151
+ if not np.isfinite(course.course_chi).all():
152
+ raise ValueError("this run has no chi (burn_profile_fit_algorithm_choice: chi)")
153
+ xs = course.course_chi.to_numpy()
154
+ ax.set_xlabel("chi (m)")
155
+ else:
156
+ xs = course.course_distance_m.to_numpy() / 1000.0
157
+ ax.set_xlabel("distance downstream (km)")
158
+ zo = course.z_original.to_numpy()
159
+ zc = course.z_cleaned.to_numpy()
160
+ zf = course.z_final.to_numpy()
161
+ zp = course.z_profile.to_numpy()
162
+
163
+ # Bands first, lines over them. Each cell's change against the input DEM is
164
+ # split without overlap: cut (final below the DEM); pit fill (from the DEM
165
+ # up to the lower of the cleaned and final values: the part of a small pit
166
+ # the channel still sits in); other raise (above both: junction pins,
167
+ # rejected minima).
168
+ pit = zc > zo
169
+ n_pits = int(course.loc[pit, "pit_id"].nunique())
170
+
171
+ def widen(m):
172
+ # each run widened by one cell either side (the band is zero there),
173
+ # so a one-cell run still draws
174
+ w = m.copy()
175
+ w[1:] |= m[:-1]
176
+ w[:-1] |= m[1:]
177
+ return w
178
+
179
+ cut = zf < zo
180
+ pit_top = np.minimum(zc, zf)
181
+ pit_band = pit & (zf > zo)
182
+ raise_lo = np.maximum(zo, zc)
183
+ raise_band = zf > raise_lo + 1e-6
184
+ ax.fill_between(xs, zf, zo, where=widen(cut), color=CUT_COLOUR, alpha=0.35, lw=0)
185
+ ax.fill_between(xs, zo, np.where(pit_band, pit_top, zo), where=widen(pit_band),
186
+ color=PIT_COLOUR, alpha=0.6, lw=0)
187
+ ax.fill_between(xs, raise_lo, np.where(raise_band, zf, raise_lo), where=widen(raise_band),
188
+ color=RAISE_COLOUR, alpha=0.5, lw=0)
189
+ ax.plot(xs, zo, color=ORIGINAL_COLOUR, lw=0.8, zorder=3)
190
+ if np.nanmax(np.abs(zp - zf)) > 0.01:
191
+ ax.plot(xs, zp, color=INK, lw=0.8, ls="--", zorder=4)
192
+ ax.plot(xs, zf, color=FINAL_COLOUR, lw=1.6, zorder=5)
193
+ lows = course.is_record_low.to_numpy() == 1
194
+ ax.plot(xs[lows], zf[lows], "o", ms=3.0, color=INK, mec="white", mew=0.5, zorder=6, ls="none")
195
+ rej = course.is_rejected_low.to_numpy() == 1
196
+ if rej.any():
197
+ ax.plot(xs[rej], zo[rej], "x", ms=4.5, color=INK, zorder=6, ls="none")
198
+
199
+ joins, labelled = _joins_on_course(df, course, max_join_labels)
200
+ for k, nm, _ in joins:
201
+ ax.axvline(xs[k], color=ORIGINAL_COLOUR, lw=0.5, alpha=0.6, zorder=1)
202
+ ytop = ax.get_ylim()[1]
203
+ span = float(np.nanmax(xs) - np.nanmin(xs)) or 1.0
204
+ placed = []
205
+ for k, nm, _ in labelled:
206
+ if nm and all(abs(xs[k] - q) > 0.03 * span for q in placed):
207
+ ax.text(xs[k], ytop, " " + nm, rotation=90, va="top", ha="right", fontsize=7, color=INK)
208
+ placed.append(xs[k])
209
+ if mark is not None:
210
+ mx, my, mlabel = mark
211
+ k = int(np.argmin(np.hypot(course.x - mx, course.y - my)))
212
+ ax.axvline(xs[k], color=INK, lw=0.8, ls=":", zorder=2)
213
+ ax.text(xs[k], ytop, " " + mlabel, va="top", ha="left", fontsize=8, color=INK)
214
+ ax.set_ylabel("elevation (m)")
215
+ ax.grid(alpha=0.25, lw=0.5)
216
+ for s in ("top", "right"):
217
+ ax.spines[s].set_visible(False)
218
+
219
+ rises = int((np.diff(zf) > 0).sum())
220
+ stats = (f"{n_pits} pits filled ({int(pit.sum())} cells), {int(lows.sum())} minima used, "
221
+ f"{int(rej.sum())} rejected; final rises at {rises} of {len(zf) - 1} steps")
222
+ from matplotlib.lines import Line2D
223
+ from matplotlib.patches import Patch
224
+ handles = [Line2D([], [], color=ORIGINAL_COLOUR, lw=0.8, label="input DEM under the channel"),
225
+ Line2D([], [], color=FINAL_COLOUR, lw=1.6, label="burned channel (output DEM)"),
226
+ Line2D([], [], color=INK, marker="o", ms=3, mec="white", ls="none",
227
+ label="minima the profile joins"),
228
+ Patch(color=CUT_COLOUR, alpha=0.35, label="cut below the input DEM"),
229
+ Patch(color=PIT_COLOUR, alpha=0.6, label="small pit filled before sampling (channel in it)"),
230
+ Patch(color=RAISE_COLOUR, alpha=0.5, label="raised (junction, rejected minimum)")]
231
+ if rej.any():
232
+ handles.append(Line2D([], [], color=INK, marker="x", ls="none", label="minima rejected by the cut cap"))
233
+ if np.nanmax(np.abs(zp - zf)) > 0.01:
234
+ handles.append(Line2D([], [], color=INK, lw=0.8, ls="--", label="fitted profile"))
235
+ if legend:
236
+ ax.legend(handles=handles, fontsize=7, loc="upper right", frameon=False)
237
+ draw_burned_profile.last_handles = handles
238
+ return stats
239
+
240
+
241
+ def plot_burned_profiles(csvs, out, labels=None, path_id=None, path_rank=None, name=None,
242
+ x: str = "distance", mark=None, title=None, dpi: int = 200,
243
+ panel_height: float = 3.4, max_join_labels: int = 6):
244
+ """One panel per CSV for the same course (chosen in the first CSV, by path
245
+ id, path rank or name; by name or rank in the others, whose path ids may
246
+ differ), shared x axis. Writes ``out`` (PNG)."""
247
+ csvs = [csvs] if isinstance(csvs, (str, Path)) else list(csvs)
248
+ labels = list(labels) if labels else [Path(c).name.replace("_burned_profiles.csv", "") for c in csvs]
249
+ if len(labels) != len(csvs):
250
+ raise ValueError("give one label per CSV")
251
+ fig, axes = plt.subplots(len(csvs), 1, figsize=(11, panel_height * len(csvs)), sharex=True, squeeze=False)
252
+ course_label = None
253
+ for i, (csv, lab) in enumerate(zip(csvs, labels)):
254
+ ax = axes[i, 0]
255
+ df = read_burned_profiles(csv)
256
+ if i > 0 and path_id is not None and name is None and path_rank is None:
257
+ raise ValueError("compare several runs by name or path rank, not path id")
258
+ course, course_label = select_course(df, path_id=path_id, path_rank=path_rank, name=name)
259
+ stats = draw_burned_profile(ax, df, course, x=x, mark=mark, max_join_labels=max_join_labels,
260
+ legend=False)
261
+ ax.set_title(f"{lab}: {stats}", fontsize=9, loc="left")
262
+ if i < len(csvs) - 1:
263
+ ax.set_xlabel("")
264
+ fig.suptitle(title or course_label, fontsize=11)
265
+ handles = draw_burned_profile.last_handles
266
+ fig.legend(handles=handles, loc="lower center", ncol=min(4, len(handles)), fontsize=8, frameon=False)
267
+ fig.tight_layout(rect=(0, 0.06 if len(csvs) > 1 else 0.14, 1, 1))
268
+ fig.savefig(out, dpi=dpi)
269
+ plt.close(fig)
270
+ return out