Skip to content

Commit f18d310

Browse files
committed
perf(ogc): faster page parsing and chunk combination
- Replace pd.json_normalize with pd.DataFrame in _get_resp_data. OGC API properties are flat dicts; json_normalize's recursive flattening is unnecessary overhead (~2.4× faster per page: 3.2 ms → 1.35 ms on 2500-feature pages). - Skip drop_duplicates in _combine_chunk_frames when the plan has no filter axis. List-axis chunks produce non-overlapping partitions, so dedup is pure overhead in the common parallel_chunks case (~2.7× faster combine: 3.4 ms → 1.25 ms on 52k-row results). - Add ChunkPlan.has_filter_axis property to distinguish plans that need dedup (filter-axis overlap possible) from those that don't (list-axis only).
1 parent d575933 commit f18d310

3 files changed

Lines changed: 52 additions & 13 deletions

File tree

dataretrieval/ogc/chunking.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -433,7 +433,7 @@ def _combine_raw(self) -> tuple[pd.DataFrame, httpx.Response]:
433433
frames = [self._chunks[i][0] for i in sorted(self._chunks)]
434434
responses = [response for _, response in self._chunks.values()]
435435
return (
436-
_combine_chunk_frames(frames),
436+
_combine_chunk_frames(frames, dedup=self.plan.has_filter_axis),
437437
_combine_chunk_responses(responses, self.plan.canonical_url),
438438
)
439439

dataretrieval/ogc/planning.py

Lines changed: 40 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -596,6 +596,28 @@ def total(self) -> int:
596596
"""
597597
return math.prod((len(self.chunks[ax.arg_key]) for ax in self.axes), start=1)
598598

599+
@property
600+
def has_filter_axis(self) -> bool:
601+
"""Whether the plan splits along the cql-text ``filter`` axis.
602+
603+
Filter-axis chunks can overlap (a feature matching multiple
604+
OR-clauses appears in each clause's chunk), so deduplication is
605+
required when combining their frames. List-axis chunks (the
606+
common case — multi-value list parameters like
607+
``monitoring_location_id``) never overlap, so the combine step
608+
can skip the ``drop_duplicates`` call.
609+
610+
Returns
611+
-------
612+
bool
613+
``True`` when the plan has a filter axis with >1 chunk;
614+
``False`` otherwise.
615+
"""
616+
return any(
617+
ax.joiner != _LIST_SEP and len(self.chunks[ax.arg_key]) > 1
618+
for ax in self.axes
619+
)
620+
599621
def iter_sub_args(self) -> Iterator[dict[str, Any]]:
600622
"""
601623
Yield substituted args for each sub-request, in deterministic
@@ -621,20 +643,33 @@ def iter_sub_args(self) -> Iterator[dict[str, Any]]:
621643
yield sub_args
622644

623645

624-
def _combine_chunk_frames(frames: list[pd.DataFrame]) -> pd.DataFrame:
646+
def _combine_chunk_frames(
647+
frames: list[pd.DataFrame], *, dedup: bool = True
648+
) -> pd.DataFrame:
625649
"""
626-
Concatenate per-chunk frames, dropping empties and deduping by ``id``.
650+
Concatenate per-chunk frames, dropping empties and optionally deduping
651+
by ``id``.
627652
628653
Parameters
629654
----------
630655
frames : list[pandas.DataFrame]
631656
One frame per completed sub-request.
657+
dedup : bool, default True
658+
Whether to drop duplicate rows keyed on the ``id`` column.
659+
List-axis chunks (multi-value list parameters like
660+
``monitoring_location_id``) produce non-overlapping partitions,
661+
so dedup is unnecessary and skipping it saves ~2 ms on a 50 k-row
662+
result. Filter-axis chunks *can* overlap (a feature matching
663+
multiple OR-clauses appears in each clause's chunk), so dedup is
664+
required there. Callers that know their chunks don't overlap
665+
(e.g. :meth:`ChunkedCall._combine_raw` when the plan has no
666+
filter axis) pass ``dedup=False``.
632667
633668
Returns
634669
-------
635670
pandas.DataFrame
636-
The concatenated, deduplicated result. Empty when every input
637-
frame is empty.
671+
The concatenated (and optionally deduplicated) result. Empty when
672+
every input frame is empty.
638673
639674
Notes
640675
-----
@@ -667,7 +702,7 @@ def _combine_chunk_frames(frames: list[pd.DataFrame]) -> pd.DataFrame:
667702
# don't accidentally mutate ``_chunks[0][0]`` in place.
668703
return non_empty[0].copy()
669704
combined = pd.concat(non_empty, ignore_index=True)
670-
if "id" in combined.columns:
705+
if dedup and "id" in combined.columns:
671706
has_id = combined["id"].notna()
672707
if has_id.all():
673708
combined = combined.drop_duplicates(subset="id", ignore_index=True)

dataretrieval/ogc/shaping.py

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -128,13 +128,17 @@ def _get_resp_data(
128128
return _empty_feature_frame(geopd)
129129

130130
if not geopd:
131-
df = pd.json_normalize([f.get("properties") or {} for f in features], sep="_")
132-
# Always materialize the ``id`` column (may be all-None) so
133-
# ``_arrange_cols``'s ``df.rename(columns={"id": output_id})``
134-
# produces the documented service-specific output_id column
135-
# (daily_id, channel_measurements_id, …) even if the upstream
136-
# response carried no feature-level id.
137-
df["id"] = [f.get("id") for f in features]
131+
# Build the frame directly from the flat properties dicts rather than
132+
# routing through ``pd.json_normalize``. The OGC API returns flat
133+
# (non-nested) property objects, so ``json_normalize``'s recursive
134+
# flattening is unnecessary overhead (~2.5× slower than plain
135+
# ``pd.DataFrame`` for typical page sizes). The ``id`` key is merged
136+
# into each row dict up-front so the column is always present (may be
137+
# all-None) — ``_arrange_cols`` downstream relies on it for the rename
138+
# to the service-specific output_id (daily_id, channel_measurements_id,
139+
# …).
140+
rows = [{**(f.get("properties") or {}), "id": f.get("id")} for f in features]
141+
df = pd.DataFrame(rows)
138142
_attach_coordinates(df, features)
139143
return df
140144

0 commit comments

Comments
 (0)