Skip to content

Commit 9e61ecd

Browse files
authored
Merge pull request #111 from fish-pace/copilot/revise-pc-id-approach
Revise pc_id approach: honor user-supplied pc_id, retain extra columns, sort output
2 parents b3b60b1 + 9432257 commit 9e61ecd

3 files changed

Lines changed: 349 additions & 13 deletions

File tree

src/point_collocation/core/engine.py

Lines changed: 39 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -189,9 +189,11 @@ def matchup(
189189
always includes:
190190
191191
``pc_id``
192-
The original row index from the input dataframe, allowing
193-
matchup rows to be traced back to their source point even
194-
when a single point matches multiple granules.
192+
Point identifier. If the input dataframe contains a ``pc_id``
193+
column those values are preserved as-is; otherwise the row
194+
index from the input dataframe is used. Duplicate ``pc_id``
195+
values in the input are not allowed and raise a
196+
:class:`ValueError` during planning.
195197
``granule_id``
196198
Identifier of the granule that provided this row's values.
197199
``granule_lat``
@@ -206,7 +208,10 @@ def matchup(
206208
search result metadata rather than in the dataset itself.
207209
For zero-match rows, this column is ``pandas.NaT``.
208210
209-
Points with zero matching granules contribute a single NaN row.
211+
Any extra columns present in the input dataframe are retained in
212+
the output. Points with zero matching granules contribute a
213+
single NaN row. The output is sorted to match the ``pc_id``
214+
order from the input dataframe.
210215
211216
Raises
212217
------
@@ -483,6 +488,17 @@ def _execute_plan(
483488
save_path = pathlib.Path(save_dir)
484489
save_path.mkdir(parents=True, exist_ok=True)
485490

491+
# Determine whether the user supplied their own pc_id column. If so, use
492+
# those values as-is; otherwise assign the DataFrame row index as pc_id.
493+
has_user_pc_id: bool = "pc_id" in plan.points.columns
494+
495+
# Build a mapping from pc_id value → its position in the input DataFrame so
496+
# the output can be sorted to match the user's original point order.
497+
if has_user_pc_id:
498+
pc_id_order: dict = {val: pos for pos, val in enumerate(plan.points["pc_id"])}
499+
else:
500+
pc_id_order = {idx: pos for pos, idx in enumerate(plan.points.index)}
501+
486502
# Build granule_index → [point_indices] for all matched granules
487503
granule_to_points: dict[int, list[object]] = {}
488504
zero_match_pt_indices: list[object] = []
@@ -499,7 +515,8 @@ def _execute_plan(
499515
# Zero-match points → single NaN row each
500516
for pt_idx in zero_match_pt_indices:
501517
row: dict = plan.points.loc[pt_idx].to_dict()
502-
row["pc_id"] = pt_idx
518+
if not has_user_pc_id:
519+
row["pc_id"] = pt_idx
503520
row["granule_id"] = float("nan")
504521
row["granule_lat"] = float("nan")
505522
row["granule_lon"] = float("nan")
@@ -665,7 +682,8 @@ def _execute_plan(
665682
rows_for_granule = []
666683
for pt_idx in pt_indices:
667684
row = plan.points.loc[pt_idx].to_dict()
668-
row["pc_id"] = pt_idx
685+
if not has_user_pc_id:
686+
row["pc_id"] = pt_idx
669687
row["granule_id"] = gm.granule_id
670688
row["granule_time"] = granule_time
671689
rows_for_granule.append(row)
@@ -681,7 +699,8 @@ def _execute_plan(
681699
# ndpoint for the whole granule (and all future ones).
682700
def _make_row(pt_idx: object) -> dict:
683701
r = plan.points.loc[pt_idx].to_dict()
684-
r["pc_id"] = pt_idx
702+
if not has_user_pc_id:
703+
r["pc_id"] = pt_idx
685704
r["granule_id"] = gm.granule_id
686705
r["granule_time"] = granule_time
687706
return r
@@ -717,7 +736,8 @@ def _make_row(pt_idx: object) -> dict:
717736
else:
718737
for pt_idx in pt_indices:
719738
row = plan.points.loc[pt_idx].to_dict()
720-
row["pc_id"] = pt_idx
739+
if not has_user_pc_id:
740+
row["pc_id"] = pt_idx
721741
row["granule_id"] = gm.granule_id
722742
row["granule_time"] = granule_time
723743
_extract_nearest(ds, row, variables, lon_name, lat_name, time_dim)
@@ -734,7 +754,8 @@ def _make_row(pt_idx: object) -> dict:
734754
failed_granule_time = gm.begin + (gm.end - gm.begin) / 2
735755
for pt_idx in pt_indices:
736756
row = plan.points.loc[pt_idx].to_dict()
737-
row["pc_id"] = pt_idx
757+
if not has_user_pc_id:
758+
row["pc_id"] = pt_idx
738759
row["granule_id"] = gm.granule_id
739760
row["granule_lat"] = float("nan")
740761
row["granule_lon"] = float("nan")
@@ -795,7 +816,8 @@ def _make_row(pt_idx: object) -> dict:
795816

796817
if not output_rows:
797818
empty = plan.points.iloc[:0].copy()
798-
empty["pc_id"] = pd.Series(dtype=object)
819+
if not has_user_pc_id:
820+
empty["pc_id"] = pd.Series(dtype=object)
799821
empty["granule_id"] = pd.Series(dtype=object)
800822
empty["granule_lat"] = pd.Series(dtype=float)
801823
empty["granule_lon"] = pd.Series(dtype=float)
@@ -813,6 +835,13 @@ def _make_row(pt_idx: object) -> dict:
813835
if expanded and var in df.columns:
814836
df = df.drop(columns=[var])
815837

838+
# Sort by the pc_id order from the input DataFrame so that output rows
839+
# follow the same point ordering the user provided. A stable sort
840+
# preserves the relative order of rows with the same pc_id (e.g. multiple
841+
# granules for one point).
842+
df["_pc_sort"] = df["pc_id"].map(pc_id_order)
843+
df = df.sort_values("_pc_sort", kind="stable").drop(columns=["_pc_sort"]).reset_index(drop=True)
844+
816845
return df
817846

818847

src/point_collocation/core/plan.py

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -629,6 +629,13 @@ def plan(
629629
``date`` as an alias). If the column is named ``date`` and
630630
contains date-only values, the time-of-day is set to noon
631631
(12:00 UTC) for matching purposes.
632+
633+
An optional ``pc_id`` column may be included to supply custom
634+
point identifiers. If present, these values must be unique;
635+
duplicate ``pc_id`` values raise a :class:`ValueError`. Any
636+
additional columns beyond ``lat``, ``lon``, ``time``, and
637+
``pc_id`` are preserved and included in the output returned by
638+
:func:`~point_collocation.matchup`.
632639
data_source:
633640
Data source to search. Currently only ``"earthaccess"`` is
634641
supported.
@@ -654,8 +661,9 @@ def plan(
654661
------
655662
ValueError
656663
If *points* is missing required columns, *data_source* is not
657-
recognised, or ``source_kwargs`` does not contain at least one of
658-
``"short_name"``, ``"concept_id"``, or ``"doi"``.
664+
recognised, ``source_kwargs`` does not contain at least one of
665+
``"short_name"``, ``"concept_id"``, or ``"doi"``, or the
666+
``pc_id`` column contains duplicate values.
659667
ImportError
660668
If the ``earthaccess`` package is not installed.
661669
"""
@@ -713,13 +721,24 @@ def _plan_normalise_time(points: PointsFrame) -> PointsFrame:
713721

714722

715723
def _plan_validate_points(points: PointsFrame) -> None:
716-
"""Raise ``ValueError`` if *points* is missing required columns."""
724+
"""Raise ``ValueError`` if *points* is missing required columns or has invalid ``pc_id``."""
717725
missing = _REQUIRED_COLUMNS - set(points.columns)
718726
if missing:
719727
raise ValueError(
720728
f"points DataFrame is missing required columns: {sorted(missing)}"
721729
)
722730

731+
if "pc_id" in points.columns:
732+
duplicated_mask = points["pc_id"].duplicated()
733+
if duplicated_mask.any():
734+
dup_vals = sorted(points.loc[duplicated_mask, "pc_id"].unique().tolist())
735+
raise ValueError(
736+
f"The 'pc_id' column contains duplicate values: {dup_vals}. "
737+
"Each pc_id must be unique. "
738+
"Please fix the duplicate values or remove the 'pc_id' column "
739+
"to let point-collocation assign identifiers automatically."
740+
)
741+
723742

724743
def _parse_time_buffer(
725744
time_buffer: str | pd.Timedelta | datetime.timedelta | int,

0 commit comments

Comments
 (0)