asp-plot 1.15.2__tar.gz → 1.16.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 (33) hide show
  1. {asp_plot-1.15.2 → asp_plot-1.16.0}/.gitignore +5 -0
  2. {asp_plot-1.15.2 → asp_plot-1.16.0}/CHANGELOG.md +10 -0
  3. {asp_plot-1.15.2 → asp_plot-1.16.0}/PKG-INFO +1 -1
  4. {asp_plot-1.15.2 → asp_plot-1.16.0}/asp_plot/altimetry.py +308 -52
  5. {asp_plot-1.15.2 → asp_plot-1.16.0}/asp_plot/cli/asp_plot.py +115 -6
  6. asp_plot-1.16.0/asp_plot/selections.py +231 -0
  7. {asp_plot-1.15.2 → asp_plot-1.16.0}/asp_plot/stereo.py +165 -60
  8. {asp_plot-1.15.2 → asp_plot-1.16.0}/pyproject.toml +1 -1
  9. {asp_plot-1.15.2 → asp_plot-1.16.0}/.flake8 +0 -0
  10. {asp_plot-1.15.2 → asp_plot-1.16.0}/.github/workflows/release.yml +0 -0
  11. {asp_plot-1.15.2 → asp_plot-1.16.0}/.github/workflows/run-tests.yml +0 -0
  12. {asp_plot-1.15.2 → asp_plot-1.16.0}/.pre-commit-config.yaml +0 -0
  13. {asp_plot-1.15.2 → asp_plot-1.16.0}/.readthedocs.yaml +0 -0
  14. {asp_plot-1.15.2 → asp_plot-1.16.0}/LICENSE +0 -0
  15. {asp_plot-1.15.2 → asp_plot-1.16.0}/README.md +0 -0
  16. {asp_plot-1.15.2 → asp_plot-1.16.0}/asp_plot/__init__.py +0 -0
  17. {asp_plot-1.15.2 → asp_plot-1.16.0}/asp_plot/alignment.py +0 -0
  18. {asp_plot-1.15.2 → asp_plot-1.16.0}/asp_plot/bundle_adjust.py +0 -0
  19. {asp_plot-1.15.2 → asp_plot-1.16.0}/asp_plot/cli/__init__.py +0 -0
  20. {asp_plot-1.15.2 → asp_plot-1.16.0}/asp_plot/cli/csm_camera_plot.py +0 -0
  21. {asp_plot-1.15.2 → asp_plot-1.16.0}/asp_plot/cli/gallery.py +0 -0
  22. {asp_plot-1.15.2 → asp_plot-1.16.0}/asp_plot/cli/request_planetary_altimetry.py +0 -0
  23. {asp_plot-1.15.2 → asp_plot-1.16.0}/asp_plot/cli/stereo_geom.py +0 -0
  24. {asp_plot-1.15.2 → asp_plot-1.16.0}/asp_plot/csm_camera.py +0 -0
  25. {asp_plot-1.15.2 → asp_plot-1.16.0}/asp_plot/gallery.py +0 -0
  26. {asp_plot-1.15.2 → asp_plot-1.16.0}/asp_plot/processing_parameters.py +0 -0
  27. {asp_plot-1.15.2 → asp_plot-1.16.0}/asp_plot/report.py +0 -0
  28. {asp_plot-1.15.2 → asp_plot-1.16.0}/asp_plot/scenes.py +0 -0
  29. {asp_plot-1.15.2 → asp_plot-1.16.0}/asp_plot/stereo_geometry.py +0 -0
  30. {asp_plot-1.15.2 → asp_plot-1.16.0}/asp_plot/stereopair_metadata_parser.py +0 -0
  31. {asp_plot-1.15.2 → asp_plot-1.16.0}/asp_plot/utils.py +0 -0
  32. {asp_plot-1.15.2 → asp_plot-1.16.0}/conda-forge-recipe/meta.yaml +0 -0
  33. {asp_plot-1.15.2 → asp_plot-1.16.0}/environment.yml +0 -0
@@ -143,4 +143,9 @@ CLAUDE*
143
143
  scripts/
144
144
  /*.parquet
145
145
  reports/regenerate_reports.sh
146
+ # Figure-selections sidecars written next to each report by `asp_plot`. These are
147
+ # regeneration byproducts that hardcode absolute local paths (dem_filename,
148
+ # parquet_cache), so they are not committed; a sanitized example is documented in
149
+ # docs/cli/asp_plot.md instead.
150
+ reports/*_figure_selections.yml
146
151
  *.csv
@@ -5,6 +5,16 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [1.16.0] - 2026-06-11
9
+
10
+ ### Added
11
+ - **Reusable "figure selections" for run-to-run comparison** ([#121](https://github.com/uw-cryo/asp_plot/issues/121)). When re-processing the same scene with different ASP parameters, the diagnostic figures previously changed *what they showed* between runs — a fresh ICESat-2 request returned a slightly different point set, the "best" profile track flipped, the best/worst agreement segments moved, and the detailed-hillshade clip boxes were re-selected from the re-processed intersection-error raster — making before/after comparison impossible. The `asp_plot` CLI now writes a `<report_stem>_figure_selections.yml` sidecar next to the report recording every non-deterministic selection, and a new `--reuse_selections PATH` flag replays a prior run's choices so figures are directly comparable.
12
+ - New `asp_plot/selections.py` module (`FigureSelections` dataclass + YAML read/write + clip-box ↔ pixel-window + CRS-reprojection helpers), deliberately free of `report.py` / `fpdf` imports so it is safe to use from notebooks.
13
+ - `StereoPlotter.plot_detailed_hillshade()` gains a `clip_windows` (+ `clip_windows_crs`) kwarg and records the boxes it drew on `self.detailed_hillshade_clips`. Clip boxes are stored in map coordinates and reprojected to the current DEM's CRS on reuse, so the same ground area is clipped even across stereo variants in different projections (e.g. mapprojected vs. non-mapprojected, which can land in different CRSs); boxes that fall outside the current DEM warn and fall back to automatic selection.
14
+ - `Altimetry` reuses the *exact* prior ICESat-2 points via `load_atl06sr_from_parquet()`, pins the profile track (`rgt`/`cycle`/`spot`) and best/worst segments (`segments=`) through `plot_atl06sr_dem_profile()` / `plot_best_worst_segments()`, and reports its choices via `get_altimetry_selections()`. A single run now also resolves the best track once and shares it across the profile and segment figures for self-consistency.
15
+ - Best/worst segments are pinned by **absolute along-track distance (`x_atc`)** rather than km-from-track-start, so a reused segment lands on the same ground even when outlier (3σ) filtering against a different DEM drops a different first point and shifts the track start. (Manifests keep the km extents for readability and still accept the legacy km-only form.)
16
+ - The reuse path restores the request's date range (`t0`/`t1`) from the parquet's stored SlideRule parameters, so plot titles keep their "&lt;t0&gt; to &lt;t1&gt;" line when points are loaded from cache instead of re-requested.
17
+
8
18
  ## [1.15.2] - 2026-06-11
9
19
 
10
20
  ### Fixed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: asp_plot
3
- Version: 1.15.2
3
+ Version: 1.16.0
4
4
  Summary: Package for plotting outputs Ames Stereo Pipeline processing
5
5
  Project-URL: Homepage, https://github.com/uw-cryo/asp_plot
6
6
  Project-URL: Documentation, https://asp-plot.readthedocs.io
@@ -549,6 +549,19 @@ class Altimetry:
549
549
  if key in processing_levels
550
550
  }
551
551
 
552
+ # Record the request settings + cache locations so a report run can
553
+ # write them to a figure-selections file for reproducibility (#121).
554
+ self.atl06sr_request_parms = {
555
+ "processing_levels": list(processing_levels),
556
+ "res": res,
557
+ "len": len,
558
+ "ats": ats,
559
+ "time_range": time_range,
560
+ "t0": t0_str,
561
+ "t1": t1_str,
562
+ }
563
+ self.atl06sr_parquet_paths = {}
564
+
552
565
  for key, custom_parm in custom_parms.items():
553
566
  parms = {**shared_parms, **custom_parm}
554
567
 
@@ -556,6 +569,7 @@ class Altimetry:
556
569
 
557
570
  print(f"\nICESat-2 ATL06 request processing for: {key}")
558
571
  fn = os.path.join(self.directory, f"{fn_base}.parquet")
572
+ self.atl06sr_parquet_paths[key] = fn
559
573
 
560
574
  print(parms)
561
575
 
@@ -597,45 +611,129 @@ class Altimetry:
597
611
  if save_to_parquet:
598
612
  self._save_to_parquet(fn, atl06sr, parms)
599
613
 
600
- # Normalize index: x-series returns time_ns (Unix nanoseconds),
601
- # legacy returns time (GPS seconds). Ensure a DatetimeIndex named "time".
602
- if atl06sr.index.name == "time_ns" or not isinstance(
603
- atl06sr.index, pd.DatetimeIndex
604
- ):
605
- if "time_ns" in atl06sr.columns:
606
- atl06sr.index = pd.to_datetime(atl06sr["time_ns"], unit="ns")
607
- elif atl06sr.index.name == "time_ns":
608
- atl06sr.index = pd.to_datetime(atl06sr.index, unit="ns")
609
- atl06sr.index.name = "time"
610
-
611
- # Normalize sample columns: x-series may return array values
612
- # instead of scalars for raster samples (e.g., esa_worldcover.value).
613
- # Extract the first element from any array-valued cells.
614
- # After parquet round-trip, arrays may deserialize as lists.
615
- for col in atl06sr.columns:
616
- if atl06sr[col].dtype == object and atl06sr.shape[0] > 0:
617
- first_val = atl06sr[col].iloc[0]
618
- if isinstance(first_val, (np.ndarray, list)):
619
- atl06sr[col] = atl06sr[col].apply(self._extract_scalar)
620
-
621
- self.atl06sr_processing_levels[key] = atl06sr
622
-
623
- print(f"Filtering ATL06-SR {key}")
624
-
625
- # From Aimee Gibbons:
626
- # I'd recommend anything cycle 03 and later, due to pointing issues before cycle 03.
627
- atl06sr_filtered = atl06sr[atl06sr["cycle"] >= 3]
628
-
629
- # Remove bad fits using high percentile of `h_sigma`, the error estimate for the least squares fit model.
630
- # TODO: not sure about h_sigma quantile...might throw out too much. Maybe just remove 0 values?
631
- atl06sr_filtered = atl06sr_filtered[
632
- atl06sr_filtered["h_sigma"]
633
- < atl06sr_filtered["h_sigma"].quantile(h_sigma_quantile)
634
- ]
635
- # Also need to filter out 0 values, not sure what these are caused by, but also very bad points.
636
- atl06sr_filtered = atl06sr_filtered[atl06sr_filtered["h_sigma"] != 0]
614
+ self._ingest_atl06sr(key, atl06sr, h_sigma_quantile)
637
615
 
638
- self.atl06sr_processing_levels_filtered[key] = atl06sr_filtered
616
+ def _ingest_atl06sr(self, key, atl06sr, h_sigma_quantile=1.0):
617
+ """
618
+ Normalize and quality-filter a raw ATL06-SR dataframe and store it.
619
+
620
+ Shared by ``request_atl06sr_multi_processing`` (fresh request or cache
621
+ hit) and ``load_atl06sr_from_parquet`` (replaying a prior run's points)
622
+ so both paths produce identical ``atl06sr_processing_levels`` and
623
+ ``atl06sr_processing_levels_filtered`` entries.
624
+
625
+ Parameters
626
+ ----------
627
+ key : str
628
+ Processing-level key (e.g. "all").
629
+ atl06sr : geopandas.GeoDataFrame
630
+ Raw ATL06-SR points (from SlideRule or a parquet cache).
631
+ h_sigma_quantile : float, optional
632
+ Quantile of ``h_sigma`` above which fits are discarded, default 1.0.
633
+ """
634
+ # Normalize index: x-series returns time_ns (Unix nanoseconds),
635
+ # legacy returns time (GPS seconds). Ensure a DatetimeIndex named "time".
636
+ if atl06sr.index.name == "time_ns" or not isinstance(
637
+ atl06sr.index, pd.DatetimeIndex
638
+ ):
639
+ if "time_ns" in atl06sr.columns:
640
+ atl06sr.index = pd.to_datetime(atl06sr["time_ns"], unit="ns")
641
+ elif atl06sr.index.name == "time_ns":
642
+ atl06sr.index = pd.to_datetime(atl06sr.index, unit="ns")
643
+ atl06sr.index.name = "time"
644
+
645
+ # Normalize sample columns: x-series may return array values
646
+ # instead of scalars for raster samples (e.g., esa_worldcover.value).
647
+ # Extract the first element from any array-valued cells.
648
+ # After parquet round-trip, arrays may deserialize as lists.
649
+ for col in atl06sr.columns:
650
+ if atl06sr[col].dtype == object and atl06sr.shape[0] > 0:
651
+ first_val = atl06sr[col].iloc[0]
652
+ if isinstance(first_val, (np.ndarray, list)):
653
+ atl06sr[col] = atl06sr[col].apply(self._extract_scalar)
654
+
655
+ self.atl06sr_processing_levels[key] = atl06sr
656
+
657
+ print(f"Filtering ATL06-SR {key}")
658
+
659
+ # From Aimee Gibbons:
660
+ # I'd recommend anything cycle 03 and later, due to pointing issues before cycle 03.
661
+ atl06sr_filtered = atl06sr[atl06sr["cycle"] >= 3]
662
+
663
+ # Remove bad fits using high percentile of `h_sigma`, the error estimate for the least squares fit model.
664
+ # TODO: not sure about h_sigma quantile...might throw out too much. Maybe just remove 0 values?
665
+ atl06sr_filtered = atl06sr_filtered[
666
+ atl06sr_filtered["h_sigma"]
667
+ < atl06sr_filtered["h_sigma"].quantile(h_sigma_quantile)
668
+ ]
669
+ # Also need to filter out 0 values, not sure what these are caused by, but also very bad points.
670
+ atl06sr_filtered = atl06sr_filtered[atl06sr_filtered["h_sigma"] != 0]
671
+
672
+ self.atl06sr_processing_levels_filtered[key] = atl06sr_filtered
673
+
674
+ def load_atl06sr_from_parquet(self, parquet_paths, h_sigma_quantile=1.0):
675
+ """
676
+ Load ATL06-SR points directly from saved parquet caches.
677
+
678
+ Replays the *exact* points a prior run used (issue #121), bypassing the
679
+ SlideRule request entirely so a re-processed scene compares against an
680
+ identical ICESat-2 sample. Runs the same normalization + quality filter
681
+ as a fresh request via ``_ingest_atl06sr``.
682
+
683
+ Parameters
684
+ ----------
685
+ parquet_paths : dict
686
+ Mapping of processing-level key -> parquet path
687
+ (e.g. ``{"all": ".../atl06sr_all.parquet"}``).
688
+ h_sigma_quantile : float, optional
689
+ Quantile of ``h_sigma`` above which fits are discarded, default 1.0.
690
+
691
+ Returns
692
+ -------
693
+ bool
694
+ True if at least one parquet was loaded, False otherwise.
695
+ """
696
+ self.atl06sr_parquet_paths = {}
697
+ loaded_any = False
698
+ for key, path in parquet_paths.items():
699
+ if not path or not os.path.exists(path):
700
+ logger.warning(
701
+ f"\nParquet for '{key}' not found at {path}; "
702
+ "cannot reuse those ICESat-2 points.\n"
703
+ )
704
+ continue
705
+ print(f"Reusing ICESat-2 ATL06-SR points for '{key}' from: {path}")
706
+ atl06sr = gpd.read_parquet(path)
707
+ self.atl06sr_parquet_paths[key] = path
708
+ self._restore_request_metadata_from_parquet(atl06sr)
709
+ self._ingest_atl06sr(key, atl06sr, h_sigma_quantile)
710
+ loaded_any = True
711
+ return loaded_any
712
+
713
+ def _restore_request_metadata_from_parquet(self, atl06sr):
714
+ """
715
+ Recover the SlideRule request time range from a cached parquet.
716
+
717
+ The reuse path bypasses ``request_atl06sr_multi_processing`` (which sets
718
+ ``self._t0`` / ``self._t1`` via ``_resolve_time_range``), so plot titles
719
+ would otherwise lose their "<t0> to <t1>" date-range line. The request
720
+ parameters are persisted in the parquet's ``sliderule_parameters``
721
+ column, so read ``t0`` / ``t1`` back from there.
722
+ """
723
+ if hasattr(self, "_t0") and hasattr(self, "_t1"):
724
+ return
725
+ if "sliderule_parameters" not in atl06sr.columns or atl06sr.empty:
726
+ return
727
+ try:
728
+ params = json.loads(atl06sr["sliderule_parameters"].iloc[0])
729
+ t0, t1 = params.get("t0"), params.get("t1")
730
+ if t0 and t1:
731
+ self._t0 = pd.Timestamp(t0).to_pydatetime()
732
+ self._t1 = pd.Timestamp(t1).to_pydatetime()
733
+ except Exception as e:
734
+ logger.warning(
735
+ f"\nCould not restore time range from parquet metadata: {e}\n"
736
+ )
639
737
 
640
738
  def _save_to_parquet(self, fn, df, parms):
641
739
  """
@@ -2702,6 +2800,83 @@ class Altimetry:
2702
2800
  "date": date_str,
2703
2801
  }
2704
2802
 
2803
+ def get_altimetry_selections(self, key="all"):
2804
+ """
2805
+ Report the ICESat-2 selections a run made, for reproducibility (#121).
2806
+
2807
+ Bundles the request settings, the parquet cache locations (the exact
2808
+ points used), the auto-selected profile track, and the best/worst
2809
+ segment extents so they can be written to a figure-selections file and
2810
+ replayed on a later run.
2811
+
2812
+ Parameters
2813
+ ----------
2814
+ key : str, optional
2815
+ Processing level key, default is "all".
2816
+
2817
+ Returns
2818
+ -------
2819
+ dict
2820
+ ``{"request": {..}, "parquet_cache": {key: path},
2821
+ "profile_track": {"rgt", "cycle", "spot"},
2822
+ "segments": {"best": {..}, "worst": {..}}}``. Keys are omitted when
2823
+ the corresponding selection is unavailable.
2824
+ """
2825
+ selections = {}
2826
+
2827
+ if getattr(self, "atl06sr_request_parms", None):
2828
+ selections["request"] = dict(self.atl06sr_request_parms)
2829
+ if getattr(self, "atl06sr_parquet_paths", None):
2830
+ selections["parquet_cache"] = dict(self.atl06sr_parquet_paths)
2831
+
2832
+ resolved = self._resolve_best_track(key)
2833
+ if resolved is None:
2834
+ return selections
2835
+ track, rgt, cycle, spot = resolved[0], resolved[1], resolved[2], resolved[3]
2836
+ selections["profile_track"] = {
2837
+ "rgt": int(rgt),
2838
+ "cycle": int(cycle),
2839
+ "spot": int(spot),
2840
+ }
2841
+
2842
+ seg_info = self._find_best_worst_segments(track)
2843
+ if seg_info is not None:
2844
+ selections["segments"] = {
2845
+ "best": self._segment_record(track, seg_info, "best"),
2846
+ "worst": self._segment_record(track, seg_info, "worst"),
2847
+ }
2848
+ return selections
2849
+
2850
+ @staticmethod
2851
+ def _segment_record(track, seg_info, which):
2852
+ """
2853
+ Build a serializable record for a best/worst segment, used by
2854
+ ``get_altimetry_selections``.
2855
+
2856
+ ``start_xatc`` / ``end_xatc`` are the **absolute** along-track extents
2857
+ (meters) and are what reuse actually replays — absolute ``x_atc`` is
2858
+ stable even when outlier filtering drops a different first point, whereas
2859
+ a track-start-relative offset would shift. ``start_km`` / ``end_km``
2860
+ (km from the track start) are kept for human readability. ``endpoints_xy``
2861
+ stores the segment's first/last point coordinates **in the track
2862
+ geometry's CRS** (the DEM/working CRS after dh computation, typically UTM
2863
+ — not lon/lat), for human inspection only.
2864
+ """
2865
+ record = {
2866
+ "start_xatc": float(seg_info[f"seg_{which}_start_xatc"]),
2867
+ "end_xatc": float(seg_info[f"seg_{which}_end_xatc"]),
2868
+ "start_km": float(seg_info[f"seg_{which}_start_km"]),
2869
+ "end_km": float(seg_info[f"seg_{which}_end_km"]),
2870
+ }
2871
+ seg_pts = track[seg_info[f"seg_{which}_mask"]]
2872
+ if not seg_pts.empty and seg_pts.geometry.notna().any():
2873
+ geom = seg_pts.geometry
2874
+ record["endpoints_xy"] = [
2875
+ [float(geom.iloc[0].x), float(geom.iloc[0].y)],
2876
+ [float(geom.iloc[-1].x), float(geom.iloc[-1].y)],
2877
+ ]
2878
+ return record
2879
+
2705
2880
  def histogram_by_landcover(
2706
2881
  self,
2707
2882
  key="all",
@@ -2946,7 +3121,9 @@ class Altimetry:
2946
3121
 
2947
3122
  return (track, rgt, cycle, spot, track_count, track_date, dist, dh_vals)
2948
3123
 
2949
- def _find_best_worst_segments(self, track, dh_col="icesat_minus_dem"):
3124
+ def _find_best_worst_segments(
3125
+ self, track, dh_col="icesat_minus_dem", segment_override=None
3126
+ ):
2950
3127
  """
2951
3128
  Identify 1 km segments with better and worse agreement along a track.
2952
3129
 
@@ -2955,12 +3132,28 @@ class Altimetry:
2955
3132
  with a large bias cannot be selected as "better agreement" just
2956
3133
  because its NMAD is small.
2957
3134
 
3135
+ Parameters
3136
+ ----------
3137
+ track : geopandas.GeoDataFrame
3138
+ The resolved track (sorted by ``x_atc``).
3139
+ dh_col : str, optional
3140
+ Column of height differences to score, default "icesat_minus_dem".
3141
+ segment_override : dict or None, optional
3142
+ When provided, pins the segment extents instead of scoring them
3143
+ (issue #121). Expects ``{"best": {...}, "worst": {...}}`` where each
3144
+ entry carries **absolute** along-track extents ``start_xatc`` /
3145
+ ``end_xatc`` (meters). ``start_km`` / ``end_km`` (km from the track
3146
+ start) are accepted as a legacy fallback, but absolute ``x_atc`` is
3147
+ preferred because it is stable even when outlier filtering drops a
3148
+ different first point and shifts the track start.
3149
+
2958
3150
  Returns
2959
3151
  -------
2960
3152
  dict or None
2961
3153
  Dictionary with keys: seg_best_mask, seg_worst_mask,
2962
- seg_best_start_km, seg_best_end_km, seg_worst_start_km,
2963
- seg_worst_end_km. None if segments cannot be identified.
3154
+ seg_{best,worst}_{start,end}_km (relative to this track's start, for
3155
+ plotting) and seg_{best,worst}_{start,end}_xatc (absolute, for
3156
+ stable reuse). None if segments cannot be identified.
2964
3157
  """
2965
3158
  x_atc = track["x_atc"].values
2966
3159
  track_length_m = x_atc[-1] - x_atc[0] if len(x_atc) > 1 else 0
@@ -2969,6 +3162,34 @@ class Altimetry:
2969
3162
  if not (median_spacing > 0 and track_length_m >= 1000):
2970
3163
  return None
2971
3164
 
3165
+ # Pinned segments: rebuild the masks/extents from absolute along-track
3166
+ # (x_atc) positions so a re-run highlights the same ground segments even
3167
+ # if outlier filtering removed a different first point (which would shift
3168
+ # any track-start-relative km offset).
3169
+ if segment_override is not None:
3170
+ try:
3171
+ x_atc_lo = x_atc[0]
3172
+
3173
+ def _resolve_extent(seg):
3174
+ if seg.get("start_xatc") is not None and (
3175
+ seg.get("end_xatc") is not None
3176
+ ):
3177
+ return float(seg["start_xatc"]), float(seg["end_xatc"])
3178
+ # Legacy fallback: km relative to track start.
3179
+ return (
3180
+ x_atc_lo + float(seg["start_km"]) * 1000.0,
3181
+ x_atc_lo + float(seg["end_km"]) * 1000.0,
3182
+ )
3183
+
3184
+ bs, be = _resolve_extent(segment_override["best"])
3185
+ ws, we = _resolve_extent(segment_override["worst"])
3186
+ return self._segment_dict(track, bs, be, ws, we)
3187
+ except (KeyError, TypeError, ValueError) as e:
3188
+ logger.warning(
3189
+ f"\nCould not apply pinned segments ({e}); "
3190
+ "falling back to automatic segment selection.\n"
3191
+ )
3192
+
2972
3193
  half_win = 500 # meters
2973
3194
  median_weight = 3.0 # weight |median(dh)| more heavily than NMAD
2974
3195
  scores = []
@@ -3004,15 +3225,40 @@ class Altimetry:
3004
3225
  seg_worst_start = max(x_atc[idx_worst] - half_win, x_atc_lo)
3005
3226
  seg_worst_end = min(x_atc[idx_worst] + half_win, x_atc_hi)
3006
3227
 
3228
+ return self._segment_dict(
3229
+ track, seg_best_start, seg_best_end, seg_worst_start, seg_worst_end
3230
+ )
3231
+
3232
+ @staticmethod
3233
+ def _segment_dict(track, bs, be, ws, we):
3234
+ """
3235
+ Build the best/worst segment dict from absolute along-track extents.
3236
+
3237
+ Parameters
3238
+ ----------
3239
+ track : geopandas.GeoDataFrame
3240
+ Resolved track (sorted by ``x_atc``).
3241
+ bs, be, ws, we : float
3242
+ Absolute ``x_atc`` (meters) start/end of the best and worst segments.
3243
+
3244
+ Returns
3245
+ -------
3246
+ dict
3247
+ Masks, km extents (relative to this track's start, for plotting),
3248
+ and absolute ``x_atc`` extents (for stable reuse).
3249
+ """
3250
+ x_atc_lo = track["x_atc"].values[0]
3007
3251
  return {
3008
- "seg_best_mask": (track["x_atc"] >= seg_best_start)
3009
- & (track["x_atc"] <= seg_best_end),
3010
- "seg_worst_mask": (track["x_atc"] >= seg_worst_start)
3011
- & (track["x_atc"] <= seg_worst_end),
3012
- "seg_best_start_km": (seg_best_start - x_atc[0]) / 1000.0,
3013
- "seg_best_end_km": (seg_best_end - x_atc[0]) / 1000.0,
3014
- "seg_worst_start_km": (seg_worst_start - x_atc[0]) / 1000.0,
3015
- "seg_worst_end_km": (seg_worst_end - x_atc[0]) / 1000.0,
3252
+ "seg_best_mask": (track["x_atc"] >= bs) & (track["x_atc"] <= be),
3253
+ "seg_worst_mask": (track["x_atc"] >= ws) & (track["x_atc"] <= we),
3254
+ "seg_best_start_km": (bs - x_atc_lo) / 1000.0,
3255
+ "seg_best_end_km": (be - x_atc_lo) / 1000.0,
3256
+ "seg_worst_start_km": (ws - x_atc_lo) / 1000.0,
3257
+ "seg_worst_end_km": (we - x_atc_lo) / 1000.0,
3258
+ "seg_best_start_xatc": float(bs),
3259
+ "seg_best_end_xatc": float(be),
3260
+ "seg_worst_start_xatc": float(ws),
3261
+ "seg_worst_end_xatc": float(we),
3016
3262
  }
3017
3263
 
3018
3264
  def _plot_hillshade_map(self, ax, track, seg_info=None):
@@ -3108,6 +3354,7 @@ class Altimetry:
3108
3354
  rgt=None,
3109
3355
  cycle=None,
3110
3356
  spot=None,
3357
+ segments=None,
3111
3358
  plot_aligned=False,
3112
3359
  save_dir=None,
3113
3360
  fig_fn=None,
@@ -3133,6 +3380,10 @@ class Altimetry:
3133
3380
  Cycle number (auto-selected if None)
3134
3381
  spot : int or None, optional
3135
3382
  Spot number (auto-selected if None)
3383
+ segments : dict or None, optional
3384
+ Pinned best/worst segment extents to replay a prior run's
3385
+ selection (issue #121). See ``_find_best_worst_segments``.
3386
+ Default None (automatic selection).
3136
3387
  plot_aligned : bool, optional
3137
3388
  Whether to also plot the aligned DEM profile, default is False
3138
3389
  save_dir : str or None, optional
@@ -3145,8 +3396,8 @@ class Altimetry:
3145
3396
  return
3146
3397
  track, rgt, cycle, spot, track_count, track_date, dist, dh_vals = resolved
3147
3398
 
3148
- # Segment selection
3149
- seg_info = self._find_best_worst_segments(track)
3399
+ # Segment selection (pinned via `segments` for run-to-run comparison)
3400
+ seg_info = self._find_best_worst_segments(track, segment_override=segments)
3150
3401
 
3151
3402
  # --- Figure layout: left column = stacked elevation/dh (no gap),
3152
3403
  # right column = map spanning both rows ---
@@ -3310,6 +3561,7 @@ class Altimetry:
3310
3561
  rgt=None,
3311
3562
  cycle=None,
3312
3563
  spot=None,
3564
+ segments=None,
3313
3565
  plot_aligned=False,
3314
3566
  save_dir=None,
3315
3567
  fig_fn=None,
@@ -3336,6 +3588,10 @@ class Altimetry:
3336
3588
  Cycle number (auto-selected if None)
3337
3589
  spot : int or None, optional
3338
3590
  Spot number (auto-selected if None)
3591
+ segments : dict or None, optional
3592
+ Pinned best/worst segment extents to replay a prior run's
3593
+ selection (issue #121). See ``_find_best_worst_segments``.
3594
+ Default None (automatic selection).
3339
3595
  plot_aligned : bool, optional
3340
3596
  Whether to overlay the aligned DEM heights and include aligned
3341
3597
  Median/NMAD in each segment title. Requires
@@ -3351,7 +3607,7 @@ class Altimetry:
3351
3607
  return
3352
3608
  track, rgt, cycle, spot, track_count, track_date, _, _ = resolved
3353
3609
 
3354
- seg_info = self._find_best_worst_segments(track)
3610
+ seg_info = self._find_best_worst_segments(track, segment_override=segments)
3355
3611
  if seg_info is None:
3356
3612
  logger.warning(
3357
3613
  "\nTrack too short or insufficient data for segment selection.\n"