asp-plot 1.15.1__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.1 → asp_plot-1.16.0}/.gitignore +5 -0
- {asp_plot-1.15.1 → asp_plot-1.16.0}/CHANGELOG.md +15 -0
- {asp_plot-1.15.1 → asp_plot-1.16.0}/PKG-INFO +1 -1
- {asp_plot-1.15.1 → asp_plot-1.16.0}/asp_plot/altimetry.py +318 -52
- {asp_plot-1.15.1 → 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.1 → asp_plot-1.16.0}/asp_plot/stereo.py +165 -60
- {asp_plot-1.15.1 → asp_plot-1.16.0}/pyproject.toml +1 -1
- {asp_plot-1.15.1 → asp_plot-1.16.0}/.flake8 +0 -0
- {asp_plot-1.15.1 → asp_plot-1.16.0}/.github/workflows/release.yml +0 -0
- {asp_plot-1.15.1 → asp_plot-1.16.0}/.github/workflows/run-tests.yml +0 -0
- {asp_plot-1.15.1 → asp_plot-1.16.0}/.pre-commit-config.yaml +0 -0
- {asp_plot-1.15.1 → asp_plot-1.16.0}/.readthedocs.yaml +0 -0
- {asp_plot-1.15.1 → asp_plot-1.16.0}/LICENSE +0 -0
- {asp_plot-1.15.1 → asp_plot-1.16.0}/README.md +0 -0
- {asp_plot-1.15.1 → asp_plot-1.16.0}/asp_plot/__init__.py +0 -0
- {asp_plot-1.15.1 → asp_plot-1.16.0}/asp_plot/alignment.py +0 -0
- {asp_plot-1.15.1 → asp_plot-1.16.0}/asp_plot/bundle_adjust.py +0 -0
- {asp_plot-1.15.1 → asp_plot-1.16.0}/asp_plot/cli/__init__.py +0 -0
- {asp_plot-1.15.1 → asp_plot-1.16.0}/asp_plot/cli/csm_camera_plot.py +0 -0
- {asp_plot-1.15.1 → asp_plot-1.16.0}/asp_plot/cli/gallery.py +0 -0
- {asp_plot-1.15.1 → asp_plot-1.16.0}/asp_plot/cli/request_planetary_altimetry.py +0 -0
- {asp_plot-1.15.1 → asp_plot-1.16.0}/asp_plot/cli/stereo_geom.py +0 -0
- {asp_plot-1.15.1 → asp_plot-1.16.0}/asp_plot/csm_camera.py +0 -0
- {asp_plot-1.15.1 → asp_plot-1.16.0}/asp_plot/gallery.py +0 -0
- {asp_plot-1.15.1 → asp_plot-1.16.0}/asp_plot/processing_parameters.py +0 -0
- {asp_plot-1.15.1 → asp_plot-1.16.0}/asp_plot/report.py +0 -0
- {asp_plot-1.15.1 → asp_plot-1.16.0}/asp_plot/scenes.py +0 -0
- {asp_plot-1.15.1 → asp_plot-1.16.0}/asp_plot/stereo_geometry.py +0 -0
- {asp_plot-1.15.1 → asp_plot-1.16.0}/asp_plot/stereopair_metadata_parser.py +0 -0
- {asp_plot-1.15.1 → asp_plot-1.16.0}/asp_plot/utils.py +0 -0
- {asp_plot-1.15.1 → asp_plot-1.16.0}/conda-forge-recipe/meta.yaml +0 -0
- {asp_plot-1.15.1 → 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,21 @@ 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
|
+
|
|
18
|
+
## [1.15.2] - 2026-06-11
|
|
19
|
+
|
|
20
|
+
### Fixed
|
|
21
|
+
- **ESA WorldCover sampling crashed on machines configured with AWS SSO/login.** `_sample_worldcover_into_gdf()` opened the public ESA WorldCover S3 COGs with rasterio's default AWS session, which eagerly resolves credentials. On a machine whose `~/.aws/config` uses an SSO/login provider, botocore raised `MissingDependencyException: Using the login credential provider requires an additional dependency ... botocore[crt]`, aborting the entire `asp_plot` report even though the bucket is public and needs no credentials. The reads now use an explicit unsigned session (`rasterio.session.AWSSession(aws_unsigned=True)`), so anonymous access is used regardless of the user's AWS configuration.
|
|
22
|
+
|
|
8
23
|
## [1.15.1] - 2026-06-10
|
|
9
24
|
|
|
10
25
|
### 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
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
614
|
+
self._ingest_atl06sr(key, atl06sr, h_sigma_quantile)
|
|
615
|
+
|
|
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``.
|
|
637
682
|
|
|
638
|
-
|
|
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
|
"""
|
|
@@ -749,11 +847,20 @@ class Altimetry:
|
|
|
749
847
|
"""
|
|
750
848
|
import rasterio
|
|
751
849
|
from rasterio.errors import RasterioIOError
|
|
850
|
+
from rasterio.session import AWSSession
|
|
752
851
|
|
|
753
852
|
wc_col = "esa_worldcover.value"
|
|
754
853
|
if wc_col in atl06sr.columns:
|
|
755
854
|
return atl06sr
|
|
756
855
|
|
|
856
|
+
# The ESA WorldCover bucket is public, so read it anonymously
|
|
857
|
+
# (aws_unsigned=True). Without this, rasterio creates a default
|
|
858
|
+
# AWSSession that eagerly resolves credentials; on machines configured
|
|
859
|
+
# with AWS SSO/login this raises botocore MissingDependencyException
|
|
860
|
+
# ("requires botocore[crt]") and the whole report crashes — even though
|
|
861
|
+
# no credentials are needed for a public bucket.
|
|
862
|
+
unsigned_session = AWSSession(aws_unsigned=True)
|
|
863
|
+
|
|
757
864
|
gdf_4326 = atl06sr.to_crs("EPSG:4326")
|
|
758
865
|
lons = gdf_4326.geometry.x.values
|
|
759
866
|
lats = gdf_4326.geometry.y.values
|
|
@@ -771,6 +878,7 @@ class Altimetry:
|
|
|
771
878
|
for url in tile_keys:
|
|
772
879
|
try:
|
|
773
880
|
with rasterio.Env(
|
|
881
|
+
unsigned_session,
|
|
774
882
|
GDAL_DISABLE_READDIR_ON_OPEN="YES",
|
|
775
883
|
CPL_VSIL_CURL_USE_HEAD="NO",
|
|
776
884
|
):
|
|
@@ -2692,6 +2800,83 @@ class Altimetry:
|
|
|
2692
2800
|
"date": date_str,
|
|
2693
2801
|
}
|
|
2694
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
|
+
|
|
2695
2880
|
def histogram_by_landcover(
|
|
2696
2881
|
self,
|
|
2697
2882
|
key="all",
|
|
@@ -2936,7 +3121,9 @@ class Altimetry:
|
|
|
2936
3121
|
|
|
2937
3122
|
return (track, rgt, cycle, spot, track_count, track_date, dist, dh_vals)
|
|
2938
3123
|
|
|
2939
|
-
def _find_best_worst_segments(
|
|
3124
|
+
def _find_best_worst_segments(
|
|
3125
|
+
self, track, dh_col="icesat_minus_dem", segment_override=None
|
|
3126
|
+
):
|
|
2940
3127
|
"""
|
|
2941
3128
|
Identify 1 km segments with better and worse agreement along a track.
|
|
2942
3129
|
|
|
@@ -2945,12 +3132,28 @@ class Altimetry:
|
|
|
2945
3132
|
with a large bias cannot be selected as "better agreement" just
|
|
2946
3133
|
because its NMAD is small.
|
|
2947
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
|
+
|
|
2948
3150
|
Returns
|
|
2949
3151
|
-------
|
|
2950
3152
|
dict or None
|
|
2951
3153
|
Dictionary with keys: seg_best_mask, seg_worst_mask,
|
|
2952
|
-
|
|
2953
|
-
|
|
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.
|
|
2954
3157
|
"""
|
|
2955
3158
|
x_atc = track["x_atc"].values
|
|
2956
3159
|
track_length_m = x_atc[-1] - x_atc[0] if len(x_atc) > 1 else 0
|
|
@@ -2959,6 +3162,34 @@ class Altimetry:
|
|
|
2959
3162
|
if not (median_spacing > 0 and track_length_m >= 1000):
|
|
2960
3163
|
return None
|
|
2961
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
|
+
|
|
2962
3193
|
half_win = 500 # meters
|
|
2963
3194
|
median_weight = 3.0 # weight |median(dh)| more heavily than NMAD
|
|
2964
3195
|
scores = []
|
|
@@ -2994,15 +3225,40 @@ class Altimetry:
|
|
|
2994
3225
|
seg_worst_start = max(x_atc[idx_worst] - half_win, x_atc_lo)
|
|
2995
3226
|
seg_worst_end = min(x_atc[idx_worst] + half_win, x_atc_hi)
|
|
2996
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]
|
|
2997
3251
|
return {
|
|
2998
|
-
"seg_best_mask": (track["x_atc"] >=
|
|
2999
|
-
& (track["x_atc"] <=
|
|
3000
|
-
"
|
|
3001
|
-
|
|
3002
|
-
"
|
|
3003
|
-
"
|
|
3004
|
-
"
|
|
3005
|
-
"
|
|
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),
|
|
3006
3262
|
}
|
|
3007
3263
|
|
|
3008
3264
|
def _plot_hillshade_map(self, ax, track, seg_info=None):
|
|
@@ -3098,6 +3354,7 @@ class Altimetry:
|
|
|
3098
3354
|
rgt=None,
|
|
3099
3355
|
cycle=None,
|
|
3100
3356
|
spot=None,
|
|
3357
|
+
segments=None,
|
|
3101
3358
|
plot_aligned=False,
|
|
3102
3359
|
save_dir=None,
|
|
3103
3360
|
fig_fn=None,
|
|
@@ -3123,6 +3380,10 @@ class Altimetry:
|
|
|
3123
3380
|
Cycle number (auto-selected if None)
|
|
3124
3381
|
spot : int or None, optional
|
|
3125
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).
|
|
3126
3387
|
plot_aligned : bool, optional
|
|
3127
3388
|
Whether to also plot the aligned DEM profile, default is False
|
|
3128
3389
|
save_dir : str or None, optional
|
|
@@ -3135,8 +3396,8 @@ class Altimetry:
|
|
|
3135
3396
|
return
|
|
3136
3397
|
track, rgt, cycle, spot, track_count, track_date, dist, dh_vals = resolved
|
|
3137
3398
|
|
|
3138
|
-
# Segment selection
|
|
3139
|
-
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)
|
|
3140
3401
|
|
|
3141
3402
|
# --- Figure layout: left column = stacked elevation/dh (no gap),
|
|
3142
3403
|
# right column = map spanning both rows ---
|
|
@@ -3300,6 +3561,7 @@ class Altimetry:
|
|
|
3300
3561
|
rgt=None,
|
|
3301
3562
|
cycle=None,
|
|
3302
3563
|
spot=None,
|
|
3564
|
+
segments=None,
|
|
3303
3565
|
plot_aligned=False,
|
|
3304
3566
|
save_dir=None,
|
|
3305
3567
|
fig_fn=None,
|
|
@@ -3326,6 +3588,10 @@ class Altimetry:
|
|
|
3326
3588
|
Cycle number (auto-selected if None)
|
|
3327
3589
|
spot : int or None, optional
|
|
3328
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).
|
|
3329
3595
|
plot_aligned : bool, optional
|
|
3330
3596
|
Whether to overlay the aligned DEM heights and include aligned
|
|
3331
3597
|
Median/NMAD in each segment title. Requires
|
|
@@ -3341,7 +3607,7 @@ class Altimetry:
|
|
|
3341
3607
|
return
|
|
3342
3608
|
track, rgt, cycle, spot, track_count, track_date, _, _ = resolved
|
|
3343
3609
|
|
|
3344
|
-
seg_info = self._find_best_worst_segments(track)
|
|
3610
|
+
seg_info = self._find_best_worst_segments(track, segment_override=segments)
|
|
3345
3611
|
if seg_info is None:
|
|
3346
3612
|
logger.warning(
|
|
3347
3613
|
"\nTrack too short or insufficient data for segment selection.\n"
|