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.
- {asp_plot-1.15.2 → asp_plot-1.16.0}/.gitignore +5 -0
- {asp_plot-1.15.2 → asp_plot-1.16.0}/CHANGELOG.md +10 -0
- {asp_plot-1.15.2 → asp_plot-1.16.0}/PKG-INFO +1 -1
- {asp_plot-1.15.2 → asp_plot-1.16.0}/asp_plot/altimetry.py +308 -52
- {asp_plot-1.15.2 → asp_plot-1.16.0}/asp_plot/cli/asp_plot.py +115 -6
- asp_plot-1.16.0/asp_plot/selections.py +231 -0
- {asp_plot-1.15.2 → asp_plot-1.16.0}/asp_plot/stereo.py +165 -60
- {asp_plot-1.15.2 → asp_plot-1.16.0}/pyproject.toml +1 -1
- {asp_plot-1.15.2 → asp_plot-1.16.0}/.flake8 +0 -0
- {asp_plot-1.15.2 → asp_plot-1.16.0}/.github/workflows/release.yml +0 -0
- {asp_plot-1.15.2 → asp_plot-1.16.0}/.github/workflows/run-tests.yml +0 -0
- {asp_plot-1.15.2 → asp_plot-1.16.0}/.pre-commit-config.yaml +0 -0
- {asp_plot-1.15.2 → asp_plot-1.16.0}/.readthedocs.yaml +0 -0
- {asp_plot-1.15.2 → asp_plot-1.16.0}/LICENSE +0 -0
- {asp_plot-1.15.2 → asp_plot-1.16.0}/README.md +0 -0
- {asp_plot-1.15.2 → asp_plot-1.16.0}/asp_plot/__init__.py +0 -0
- {asp_plot-1.15.2 → asp_plot-1.16.0}/asp_plot/alignment.py +0 -0
- {asp_plot-1.15.2 → asp_plot-1.16.0}/asp_plot/bundle_adjust.py +0 -0
- {asp_plot-1.15.2 → asp_plot-1.16.0}/asp_plot/cli/__init__.py +0 -0
- {asp_plot-1.15.2 → asp_plot-1.16.0}/asp_plot/cli/csm_camera_plot.py +0 -0
- {asp_plot-1.15.2 → asp_plot-1.16.0}/asp_plot/cli/gallery.py +0 -0
- {asp_plot-1.15.2 → asp_plot-1.16.0}/asp_plot/cli/request_planetary_altimetry.py +0 -0
- {asp_plot-1.15.2 → asp_plot-1.16.0}/asp_plot/cli/stereo_geom.py +0 -0
- {asp_plot-1.15.2 → asp_plot-1.16.0}/asp_plot/csm_camera.py +0 -0
- {asp_plot-1.15.2 → asp_plot-1.16.0}/asp_plot/gallery.py +0 -0
- {asp_plot-1.15.2 → asp_plot-1.16.0}/asp_plot/processing_parameters.py +0 -0
- {asp_plot-1.15.2 → asp_plot-1.16.0}/asp_plot/report.py +0 -0
- {asp_plot-1.15.2 → asp_plot-1.16.0}/asp_plot/scenes.py +0 -0
- {asp_plot-1.15.2 → asp_plot-1.16.0}/asp_plot/stereo_geometry.py +0 -0
- {asp_plot-1.15.2 → asp_plot-1.16.0}/asp_plot/stereopair_metadata_parser.py +0 -0
- {asp_plot-1.15.2 → asp_plot-1.16.0}/asp_plot/utils.py +0 -0
- {asp_plot-1.15.2 → asp_plot-1.16.0}/conda-forge-recipe/meta.yaml +0 -0
- {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 "<t0> to <t1>" 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.
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
2963
|
-
|
|
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"] >=
|
|
3009
|
-
& (track["x_atc"] <=
|
|
3010
|
-
"
|
|
3011
|
-
|
|
3012
|
-
"
|
|
3013
|
-
"
|
|
3014
|
-
"
|
|
3015
|
-
"
|
|
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"
|