core-lens 0.1.dev200__tar.gz → 0.1.dev203__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.
- {core_lens-0.1.dev200 → core_lens-0.1.dev203}/PKG-INFO +1 -1
- {core_lens-0.1.dev200 → core_lens-0.1.dev203}/src/core_lens/_version.py +2 -2
- {core_lens-0.1.dev200 → core_lens-0.1.dev203}/src/core_lens/base/view.py +60 -6
- {core_lens-0.1.dev200 → core_lens-0.1.dev203}/src/core_lens/utils/spatial.py +122 -12
- {core_lens-0.1.dev200 → core_lens-0.1.dev203}/tests/unit/test_spatial.py +4 -0
- {core_lens-0.1.dev200 → core_lens-0.1.dev203}/tests/unit/test_view.py +70 -0
- {core_lens-0.1.dev200 → core_lens-0.1.dev203}/.github/ISSUE_TEMPLATE/blank-proposal.yaml +0 -0
- {core_lens-0.1.dev200 → core_lens-0.1.dev203}/.github/ISSUE_TEMPLATE/bug-report.yaml +0 -0
- {core_lens-0.1.dev200 → core_lens-0.1.dev203}/.github/ISSUE_TEMPLATE/feature-request.yaml +0 -0
- {core_lens-0.1.dev200 → core_lens-0.1.dev203}/.github/pull_request_template.md +0 -0
- {core_lens-0.1.dev200 → core_lens-0.1.dev203}/.github/workflows/ci.yml +0 -0
- {core_lens-0.1.dev200 → core_lens-0.1.dev203}/.github/workflows/gh-pages.yml +0 -0
- {core_lens-0.1.dev200 → core_lens-0.1.dev203}/.github/workflows/pre-release.yml +0 -0
- {core_lens-0.1.dev200 → core_lens-0.1.dev203}/.github/workflows/release.yml +0 -0
- {core_lens-0.1.dev200 → core_lens-0.1.dev203}/.gitignore +0 -0
- {core_lens-0.1.dev200 → core_lens-0.1.dev203}/.gitmessage +0 -0
- {core_lens-0.1.dev200 → core_lens-0.1.dev203}/.pre-commit-config.yaml +0 -0
- {core_lens-0.1.dev200 → core_lens-0.1.dev203}/.python-version +0 -0
- {core_lens-0.1.dev200 → core_lens-0.1.dev203}/CONTRIBUTING.md +0 -0
- {core_lens-0.1.dev200 → core_lens-0.1.dev203}/LICENSE +0 -0
- {core_lens-0.1.dev200 → core_lens-0.1.dev203}/README.md +0 -0
- {core_lens-0.1.dev200 → core_lens-0.1.dev203}/SKILLS.md +0 -0
- {core_lens-0.1.dev200 → core_lens-0.1.dev203}/benchmarks/README.md +0 -0
- {core_lens-0.1.dev200 → core_lens-0.1.dev203}/benchmarks/bench_aoi.py +0 -0
- {core_lens-0.1.dev200 → core_lens-0.1.dev203}/benchmarks/bench_entity.py +0 -0
- {core_lens-0.1.dev200 → core_lens-0.1.dev203}/benchmarks/bench_export.py +0 -0
- {core_lens-0.1.dev200 → core_lens-0.1.dev203}/benchmarks/bench_polars_utils.py +0 -0
- {core_lens-0.1.dev200 → core_lens-0.1.dev203}/benchmarks/bench_result.py +0 -0
- {core_lens-0.1.dev200 → core_lens-0.1.dev203}/benchmarks/bench_schema.py +0 -0
- {core_lens-0.1.dev200 → core_lens-0.1.dev203}/benchmarks/bench_season.py +0 -0
- {core_lens-0.1.dev200 → core_lens-0.1.dev203}/benchmarks/bench_spatial.py +0 -0
- {core_lens-0.1.dev200 → core_lens-0.1.dev203}/benchmarks/bench_view.py +0 -0
- {core_lens-0.1.dev200 → core_lens-0.1.dev203}/benchmarks/run_all.sh +0 -0
- {core_lens-0.1.dev200 → core_lens-0.1.dev203}/docs/Makefile +0 -0
- {core_lens-0.1.dev200 → core_lens-0.1.dev203}/docs/make.bat +0 -0
- {core_lens-0.1.dev200 → core_lens-0.1.dev203}/docs/source/concepts.md +0 -0
- {core_lens-0.1.dev200 → core_lens-0.1.dev203}/docs/source/conf.py +0 -0
- {core_lens-0.1.dev200 → core_lens-0.1.dev203}/docs/source/export.md +0 -0
- {core_lens-0.1.dev200 → core_lens-0.1.dev203}/docs/source/index.rst +0 -0
- {core_lens-0.1.dev200 → core_lens-0.1.dev203}/docs/source/intro.md +0 -0
- {core_lens-0.1.dev200 → core_lens-0.1.dev203}/docs/source/logging.md +0 -0
- {core_lens-0.1.dev200 → core_lens-0.1.dev203}/docs/source/plots.md +0 -0
- {core_lens-0.1.dev200 → core_lens-0.1.dev203}/docs/source/plugins.md +0 -0
- {core_lens-0.1.dev200 → core_lens-0.1.dev203}/docs/source/queries.md +0 -0
- {core_lens-0.1.dev200 → core_lens-0.1.dev203}/docs/source/quickstart.md +0 -0
- {core_lens-0.1.dev200 → core_lens-0.1.dev203}/docs/source/stats.md +0 -0
- {core_lens-0.1.dev200 → core_lens-0.1.dev203}/examples/demo_mws.py +0 -0
- {core_lens-0.1.dev200 → core_lens-0.1.dev203}/examples/demo_tehsil.py +0 -0
- {core_lens-0.1.dev200 → core_lens-0.1.dev203}/hooks/mypy.sh +0 -0
- {core_lens-0.1.dev200 → core_lens-0.1.dev203}/hooks/no-parquet-outside-fixtures.sh +0 -0
- {core_lens-0.1.dev200 → core_lens-0.1.dev203}/hooks/pytest.sh +0 -0
- {core_lens-0.1.dev200 → core_lens-0.1.dev203}/pyproject.toml +0 -0
- {core_lens-0.1.dev200 → core_lens-0.1.dev203}/src/core_lens/__init__.py +0 -0
- {core_lens-0.1.dev200 → core_lens-0.1.dev203}/src/core_lens/__main__.py +0 -0
- {core_lens-0.1.dev200 → core_lens-0.1.dev203}/src/core_lens/aoi.py +0 -0
- {core_lens-0.1.dev200 → core_lens-0.1.dev203}/src/core_lens/base/__init__.py +0 -0
- {core_lens-0.1.dev200 → core_lens-0.1.dev203}/src/core_lens/base/entity.py +0 -0
- {core_lens-0.1.dev200 → core_lens-0.1.dev203}/src/core_lens/base/namespaces/__init__.py +0 -0
- {core_lens-0.1.dev200 → core_lens-0.1.dev203}/src/core_lens/base/namespaces/plot.py +0 -0
- {core_lens-0.1.dev200 → core_lens-0.1.dev203}/src/core_lens/base/namespaces/stats.py +0 -0
- {core_lens-0.1.dev200 → core_lens-0.1.dev203}/src/core_lens/base/result.py +0 -0
- {core_lens-0.1.dev200 → core_lens-0.1.dev203}/src/core_lens/entities/__init__.py +0 -0
- {core_lens-0.1.dev200 → core_lens-0.1.dev203}/src/core_lens/entities/farms.py +0 -0
- {core_lens-0.1.dev200 → core_lens-0.1.dev203}/src/core_lens/entities/mws.py +0 -0
- {core_lens-0.1.dev200 → core_lens-0.1.dev203}/src/core_lens/entities/tehsil.py +0 -0
- {core_lens-0.1.dev200 → core_lens-0.1.dev203}/src/core_lens/entities/waterbody.py +0 -0
- {core_lens-0.1.dev200 → core_lens-0.1.dev203}/src/core_lens/export/__init__.py +0 -0
- {core_lens-0.1.dev200 → core_lens-0.1.dev203}/src/core_lens/export/formats.py +0 -0
- {core_lens-0.1.dev200 → core_lens-0.1.dev203}/src/core_lens/py.typed +0 -0
- {core_lens-0.1.dev200 → core_lens-0.1.dev203}/src/core_lens/schema/__init__.py +0 -0
- {core_lens-0.1.dev200 → core_lens-0.1.dev203}/src/core_lens/schema/detection.py +0 -0
- {core_lens-0.1.dev200 → core_lens-0.1.dev203}/src/core_lens/schema/profile.py +0 -0
- {core_lens-0.1.dev200 → core_lens-0.1.dev203}/src/core_lens/utils/__init__.py +0 -0
- {core_lens-0.1.dev200 → core_lens-0.1.dev203}/src/core_lens/utils/paths.py +0 -0
- {core_lens-0.1.dev200 → core_lens-0.1.dev203}/src/core_lens/utils/polars_utils.py +0 -0
- {core_lens-0.1.dev200 → core_lens-0.1.dev203}/src/core_lens/utils/season.py +0 -0
- {core_lens-0.1.dev200 → core_lens-0.1.dev203}/tests/fixtures/generate_fixtures.py +0 -0
- {core_lens-0.1.dev200 → core_lens-0.1.dev203}/tests/unit/conftest.py +0 -0
- {core_lens-0.1.dev200 → core_lens-0.1.dev203}/tests/unit/test_aoi.py +0 -0
- {core_lens-0.1.dev200 → core_lens-0.1.dev203}/tests/unit/test_entities.py +0 -0
- {core_lens-0.1.dev200 → core_lens-0.1.dev203}/tests/unit/test_entity.py +0 -0
- {core_lens-0.1.dev200 → core_lens-0.1.dev203}/tests/unit/test_export.py +0 -0
- {core_lens-0.1.dev200 → core_lens-0.1.dev203}/tests/unit/test_main.py +0 -0
- {core_lens-0.1.dev200 → core_lens-0.1.dev203}/tests/unit/test_plot.py +0 -0
- {core_lens-0.1.dev200 → core_lens-0.1.dev203}/tests/unit/test_polars_utils.py +0 -0
- {core_lens-0.1.dev200 → core_lens-0.1.dev203}/tests/unit/test_profile.py +0 -0
- {core_lens-0.1.dev200 → core_lens-0.1.dev203}/tests/unit/test_result.py +0 -0
- {core_lens-0.1.dev200 → core_lens-0.1.dev203}/tests/unit/test_schema_detection.py +0 -0
- {core_lens-0.1.dev200 → core_lens-0.1.dev203}/tests/unit/test_schema_profile.py +0 -0
- {core_lens-0.1.dev200 → core_lens-0.1.dev203}/tests/unit/test_season.py +0 -0
- {core_lens-0.1.dev200 → core_lens-0.1.dev203}/tests/unit/test_season_config.py +0 -0
- {core_lens-0.1.dev200 → core_lens-0.1.dev203}/tests/unit/test_stats.py +0 -0
- {core_lens-0.1.dev200 → core_lens-0.1.dev203}/usage.md +0 -0
- {core_lens-0.1.dev200 → core_lens-0.1.dev203}/uv.lock +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: core-lens
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.dev203
|
|
4
4
|
Summary: Query, analyse, and visualise CoreStack's microwatershed and Earth science data through a clean, composable Python API.
|
|
5
5
|
Project-URL: Homepage, https://github.com/ApoorvaKashyap/core-lens
|
|
6
6
|
Project-URL: Issues, https://github.com/ApoorvaKashyap/core-lens/issues
|
|
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
|
|
|
18
18
|
commit_id: str | None
|
|
19
19
|
__commit_id__: str | None
|
|
20
20
|
|
|
21
|
-
__version__ = version = '0.1.
|
|
22
|
-
__version_tuple__ = version_tuple = (0, 1, '
|
|
21
|
+
__version__ = version = '0.1.dev203'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 1, 'dev203')
|
|
23
23
|
|
|
24
24
|
__commit_id__ = commit_id = None
|
|
@@ -234,17 +234,59 @@ class View:
|
|
|
234
234
|
season_config=self._season_config,
|
|
235
235
|
)
|
|
236
236
|
|
|
237
|
-
def spatial_join(
|
|
237
|
+
def spatial_join(
|
|
238
|
+
self, other: "BaseEntity | View", agg: dict[str, "str | tuple[str, str]"]
|
|
239
|
+
) -> "View":
|
|
238
240
|
"""Return a new View with a cross-entity join pending.
|
|
239
241
|
|
|
240
242
|
The join is recorded in the View's ``join_spec`` and computed only at
|
|
241
243
|
materialisation time (``.static``, ``.annual``, or ``.sub_annual``).
|
|
242
244
|
|
|
245
|
+
``other`` may be either a :class:`~core_lens.base.entity.BaseEntity`
|
|
246
|
+
**or** a :class:`View` (e.g. ``aoi.waterbody``). When a ``View`` is
|
|
247
|
+
supplied its underlying ``.entity`` is extracted automatically, so
|
|
248
|
+
both of the following are equivalent::
|
|
249
|
+
|
|
250
|
+
aoi.mws.spatial_join(aoi.waterbody, agg={"kharif": "mean"})
|
|
251
|
+
aoi.mws.spatial_join(aoi.waterbody.entity, agg={"kharif": "mean"})
|
|
252
|
+
|
|
253
|
+
Each ``agg`` value is either a plain aggregation string (column read
|
|
254
|
+
from the **static** file of ``other``) or a
|
|
255
|
+
``(aggregation, resolution)`` tuple when the column lives in a
|
|
256
|
+
temporal file::
|
|
257
|
+
|
|
258
|
+
# column from the static file — shorthand
|
|
259
|
+
aoi.mws.spatial_join(aoi.waterbody, agg={"area_in_ha": "sum"})
|
|
260
|
+
|
|
261
|
+
# column from the annual file
|
|
262
|
+
aoi.mws.spatial_join(
|
|
263
|
+
aoi.waterbody,
|
|
264
|
+
agg={"kharif": ("mean", "annual")},
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
# mix static and temporal in one call
|
|
268
|
+
aoi.mws.spatial_join(
|
|
269
|
+
aoi.waterbody,
|
|
270
|
+
agg={
|
|
271
|
+
"count": "count",
|
|
272
|
+
"area_in_ha": "sum",
|
|
273
|
+
"kharif": ("mean", "annual"),
|
|
274
|
+
"ndvi": ("mean", "sub_annual"),
|
|
275
|
+
},
|
|
276
|
+
)
|
|
277
|
+
|
|
243
278
|
Args:
|
|
244
|
-
other (BaseEntity): The secondary
|
|
245
|
-
joined and aggregated onto ``self``.
|
|
246
|
-
|
|
247
|
-
|
|
279
|
+
other (BaseEntity | View): The secondary entity whose columns will
|
|
280
|
+
be joined and aggregated onto ``self``. Accepts either a
|
|
281
|
+
:class:`~core_lens.base.entity.BaseEntity` instance or a
|
|
282
|
+
:class:`View` (the latter is automatically unwrapped).
|
|
283
|
+
agg (dict[str, str | tuple[str, str]]): Mapping of column to
|
|
284
|
+
aggregation. Each value is either a plain aggregation string
|
|
285
|
+
(``"count"``, ``"mean"``, ``"sum"``, ``"min"``, ``"max"``,
|
|
286
|
+
``"area"``, ``"first"``) — column assumed to be in the static
|
|
287
|
+
file — or a ``(aggregation, resolution)`` tuple where
|
|
288
|
+
resolution is one of ``"static"``, ``"annual"``,
|
|
289
|
+
``"sub_annual"``.
|
|
248
290
|
|
|
249
291
|
Returns:
|
|
250
292
|
View: A new lazy :class:`View` with the join spec recorded.
|
|
@@ -256,12 +298,17 @@ class View:
|
|
|
256
298
|
)
|
|
257
299
|
raise ValueError("View already has a pending spatial_join.")
|
|
258
300
|
|
|
301
|
+
# Accept a View as `other` (e.g. aoi.waterbody) by unwrapping it.
|
|
302
|
+
# AoI attributes return scoped Views, not bare BaseEntity instances,
|
|
303
|
+
# so this is the natural way to reference a secondary entity.
|
|
304
|
+
other_entity = other.entity if isinstance(other, View) else other
|
|
305
|
+
|
|
259
306
|
return View(
|
|
260
307
|
keys=self.keys,
|
|
261
308
|
entity=self.entity,
|
|
262
309
|
entity_name=self.entity_name,
|
|
263
310
|
time_filter=self.time_filter,
|
|
264
|
-
join_spec={"other":
|
|
311
|
+
join_spec={"other": other_entity, "agg": agg},
|
|
265
312
|
season_config=self._season_config,
|
|
266
313
|
)
|
|
267
314
|
|
|
@@ -513,6 +560,13 @@ class View:
|
|
|
513
560
|
other = self.join_spec["other"]
|
|
514
561
|
agg = self.join_spec["agg"]
|
|
515
562
|
|
|
563
|
+
# Defensively unwrap a View to its entity — join_spec["other"] may be a
|
|
564
|
+
# View when callers pass e.g. aoi.waterbody directly (AoI.__getattr__
|
|
565
|
+
# returns Views, not bare entities). spatial_join() already does this
|
|
566
|
+
# unwrap, but _materialise guards here too for robustness.
|
|
567
|
+
if isinstance(other, View):
|
|
568
|
+
other = other.entity
|
|
569
|
+
|
|
516
570
|
from core_lens.base.entity import _entity_name as _ename
|
|
517
571
|
|
|
518
572
|
other_entity_name = _ename(type(other))
|
|
@@ -589,7 +589,7 @@ def execute_spatial_join(
|
|
|
589
589
|
primary_geom_col: str,
|
|
590
590
|
primary_geom_type: str,
|
|
591
591
|
other_entity: Any,
|
|
592
|
-
agg: dict[str, str],
|
|
592
|
+
agg: dict[str, str | tuple[str, str]],
|
|
593
593
|
other_entity_name: str,
|
|
594
594
|
) -> pl.DataFrame:
|
|
595
595
|
"""Materialise a cross-entity spatial join and return enriched DataFrame.
|
|
@@ -598,6 +598,23 @@ def execute_spatial_join(
|
|
|
598
598
|
``other_entity`` and aggregates the requested columns. Result columns are
|
|
599
599
|
named ``{other_entity_name}_{column_name}`` to avoid clashes.
|
|
600
600
|
|
|
601
|
+
Each entry in ``agg`` can be either:
|
|
602
|
+
|
|
603
|
+
* A plain string aggregation (column assumed to be in the static file)::
|
|
604
|
+
|
|
605
|
+
agg={"district": "first", "count": "count"}
|
|
606
|
+
|
|
607
|
+
* A ``(aggregation, resolution)`` tuple to pull the column from the
|
|
608
|
+
annual or sub_annual file of the other entity::
|
|
609
|
+
|
|
610
|
+
agg={"kharif": ("mean", "annual"), "ndvi": ("mean", "sub_annual")}
|
|
611
|
+
|
|
612
|
+
Valid resolution strings: ``"static"``, ``"annual"``, ``"sub_annual"``.
|
|
613
|
+
|
|
614
|
+
The geometry match is always performed against the static file (geometry
|
|
615
|
+
lives there). For temporal resolutions the matched keys are used to
|
|
616
|
+
filter the appropriate file before aggregation — no geometry I/O overhead.
|
|
617
|
+
|
|
601
618
|
Args:
|
|
602
619
|
primary_df (pl.DataFrame): The primary entity DataFrame (must contain
|
|
603
620
|
``primary_geom_col`` unless geometry is in a separate column).
|
|
@@ -606,10 +623,11 @@ def execute_spatial_join(
|
|
|
606
623
|
primary_geom_type (str): Geometry encoding — ``"wkb"`` or ``"wkt"``.
|
|
607
624
|
other_entity (BaseEntity): A :class:`~core_lens.base.entity.BaseEntity` instance
|
|
608
625
|
to join against.
|
|
609
|
-
agg (dict[str, str]): Mapping
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
``
|
|
626
|
+
agg (dict[str, str | tuple[str, str]]): Mapping of column to aggregation.
|
|
627
|
+
Each value is either a plain aggregation string (``"count"``,
|
|
628
|
+
``"mean"``, ``"sum"``, ``"min"``, ``"max"``, ``"area"``, ``"first"``)
|
|
629
|
+
or a ``(aggregation, resolution)`` tuple when the column lives in a
|
|
630
|
+
temporal file.
|
|
613
631
|
other_entity_name (str): Used to prefix result column names.
|
|
614
632
|
|
|
615
633
|
Returns:
|
|
@@ -666,9 +684,32 @@ def execute_spatial_join(
|
|
|
666
684
|
other_geom_col = other_profile.geometry_col
|
|
667
685
|
other_geom_type = other_profile.geometry_type
|
|
668
686
|
other_key_cols = other_entity.key_cols
|
|
687
|
+
other_so = getattr(other_entity, "_storage_options", None) or {}
|
|
688
|
+
|
|
689
|
+
# Normalise agg values: bare strings are treated as (fn, "static").
|
|
690
|
+
# Split by resolution so each file is read at most once.
|
|
691
|
+
_normalised: dict[str, tuple[str, str]] = {
|
|
692
|
+
col: (val if isinstance(val, tuple) else (val, "static"))
|
|
693
|
+
for col, val in agg.items()
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
# Columns that need to come from static (geometry + static agg cols).
|
|
697
|
+
static_agg_col_names = [
|
|
698
|
+
c
|
|
699
|
+
for c, (fn, res) in _normalised.items()
|
|
700
|
+
if res == "static" and c not in ("count", "area")
|
|
701
|
+
]
|
|
702
|
+
# Columns split by temporal resolution.
|
|
703
|
+
annual_agg_col_names = [
|
|
704
|
+
c for c, (fn, res) in _normalised.items() if res == "annual"
|
|
705
|
+
]
|
|
706
|
+
sub_annual_agg_col_names = [
|
|
707
|
+
c for c, (fn, res) in _normalised.items() if res == "sub_annual"
|
|
708
|
+
]
|
|
669
709
|
|
|
670
|
-
|
|
671
|
-
|
|
710
|
+
static_cols = list(
|
|
711
|
+
dict.fromkeys(other_key_cols + [other_geom_col] + static_agg_col_names)
|
|
712
|
+
)
|
|
672
713
|
|
|
673
714
|
if (
|
|
674
715
|
primary_total_box is not None
|
|
@@ -676,16 +717,15 @@ def execute_spatial_join(
|
|
|
676
717
|
):
|
|
677
718
|
# Pre-filter other_entity using its in-memory bounding box index
|
|
678
719
|
candidates = bbox_intersects_geometry(other_entity._index, primary_total_box)
|
|
679
|
-
other_so = getattr(other_entity, "_storage_options", None) or {}
|
|
680
720
|
if candidates.is_empty():
|
|
681
|
-
other_df = pl.DataFrame(schema={c: pl.String for c in
|
|
721
|
+
other_df = pl.DataFrame(schema={c: pl.String for c in static_cols})
|
|
682
722
|
else:
|
|
683
723
|
other_df = (
|
|
684
724
|
pl.scan_parquet(
|
|
685
725
|
other_static,
|
|
686
726
|
storage_options=other_so or None,
|
|
687
727
|
)
|
|
688
|
-
.select(
|
|
728
|
+
.select(static_cols)
|
|
689
729
|
.join(
|
|
690
730
|
candidates.select(other_key_cols).lazy(),
|
|
691
731
|
on=other_key_cols,
|
|
@@ -695,13 +735,83 @@ def execute_spatial_join(
|
|
|
695
735
|
.collect(engine="in-memory")
|
|
696
736
|
)
|
|
697
737
|
else:
|
|
698
|
-
other_so = getattr(other_entity, "_storage_options", None) or {}
|
|
699
738
|
other_df = pl.read_parquet(
|
|
700
739
|
other_static,
|
|
701
|
-
columns=
|
|
740
|
+
columns=static_cols,
|
|
702
741
|
storage_options=other_so or None,
|
|
703
742
|
)
|
|
704
743
|
|
|
744
|
+
# Load temporal agg columns (annual / sub_annual) keyed on other_key_cols,
|
|
745
|
+
# then left-join them onto other_df so the aggregation loop sees all values.
|
|
746
|
+
def _load_temporal_cols(
|
|
747
|
+
path: str | None, col_names: list[str]
|
|
748
|
+
) -> pl.DataFrame | None:
|
|
749
|
+
"""Read *col_names* from a temporal parquet, return None if unavailable."""
|
|
750
|
+
if not col_names or path is None:
|
|
751
|
+
return None
|
|
752
|
+
resolved = other_entity._resolve(path)
|
|
753
|
+
needed = list(dict.fromkeys(other_key_cols + col_names))
|
|
754
|
+
try:
|
|
755
|
+
return pl.read_parquet(
|
|
756
|
+
resolved,
|
|
757
|
+
columns=needed,
|
|
758
|
+
storage_options=other_so or None,
|
|
759
|
+
)
|
|
760
|
+
except Exception as exc:
|
|
761
|
+
logger.warning(
|
|
762
|
+
"execute_spatial_join: could not load temporal cols {} from {}: {}",
|
|
763
|
+
col_names,
|
|
764
|
+
resolved,
|
|
765
|
+
exc,
|
|
766
|
+
)
|
|
767
|
+
return None
|
|
768
|
+
|
|
769
|
+
annual_df = _load_temporal_cols(other_entity.annual_path, annual_agg_col_names)
|
|
770
|
+
sub_annual_df = _load_temporal_cols(
|
|
771
|
+
other_entity.sub_annual_path, sub_annual_agg_col_names
|
|
772
|
+
)
|
|
773
|
+
|
|
774
|
+
if annual_df is not None and not annual_df.is_empty():
|
|
775
|
+
# Aggregate temporal rows to one row per key before joining onto geometry.
|
|
776
|
+
_annual_agg_exprs = [
|
|
777
|
+
pl.col(c).mean().alias(c)
|
|
778
|
+
if _normalised[c][0] == "mean"
|
|
779
|
+
else pl.col(c).sum().alias(c)
|
|
780
|
+
if _normalised[c][0] == "sum"
|
|
781
|
+
else pl.col(c).min().alias(c)
|
|
782
|
+
if _normalised[c][0] == "min"
|
|
783
|
+
else pl.col(c).max().alias(c)
|
|
784
|
+
if _normalised[c][0] == "max"
|
|
785
|
+
else pl.col(c).first().alias(c)
|
|
786
|
+
for c in annual_agg_col_names
|
|
787
|
+
if c in annual_df.columns
|
|
788
|
+
]
|
|
789
|
+
if _annual_agg_exprs:
|
|
790
|
+
annual_df = annual_df.group_by(other_key_cols).agg(_annual_agg_exprs)
|
|
791
|
+
other_df = other_df.join(
|
|
792
|
+
annual_df, on=other_key_cols, how="left", maintain_order="left"
|
|
793
|
+
)
|
|
794
|
+
|
|
795
|
+
if sub_annual_df is not None and not sub_annual_df.is_empty():
|
|
796
|
+
_sub_agg_exprs = [
|
|
797
|
+
pl.col(c).mean().alias(c)
|
|
798
|
+
if _normalised[c][0] == "mean"
|
|
799
|
+
else pl.col(c).sum().alias(c)
|
|
800
|
+
if _normalised[c][0] == "sum"
|
|
801
|
+
else pl.col(c).min().alias(c)
|
|
802
|
+
if _normalised[c][0] == "min"
|
|
803
|
+
else pl.col(c).max().alias(c)
|
|
804
|
+
if _normalised[c][0] == "max"
|
|
805
|
+
else pl.col(c).first().alias(c)
|
|
806
|
+
for c in sub_annual_agg_col_names
|
|
807
|
+
if c in sub_annual_df.columns
|
|
808
|
+
]
|
|
809
|
+
if _sub_agg_exprs:
|
|
810
|
+
sub_annual_df = sub_annual_df.group_by(other_key_cols).agg(_sub_agg_exprs)
|
|
811
|
+
other_df = other_df.join(
|
|
812
|
+
sub_annual_df, on=other_key_cols, how="left", maintain_order="left"
|
|
813
|
+
)
|
|
814
|
+
|
|
705
815
|
if other_df.is_empty():
|
|
706
816
|
other_geoms = np.array([])
|
|
707
817
|
other_tree = shapely.STRtree([])
|
|
@@ -94,6 +94,8 @@ def test_execute_spatial_join(tmp_path: Any) -> None:
|
|
|
94
94
|
|
|
95
95
|
class DummyOther:
|
|
96
96
|
static_path = str(tmp_path / "other.parquet")
|
|
97
|
+
annual_path: str | None = None
|
|
98
|
+
sub_annual_path: str | None = None
|
|
97
99
|
key_cols = ["oid"]
|
|
98
100
|
schema_profile = SchemaProfile(
|
|
99
101
|
key_cols=["oid"],
|
|
@@ -202,6 +204,8 @@ def test_execute_spatial_join_no_match_and_agg(tmp_path: Any) -> None:
|
|
|
202
204
|
|
|
203
205
|
class DummyOther:
|
|
204
206
|
static_path = str(tmp_path / "other.parquet")
|
|
207
|
+
annual_path: str | None = None
|
|
208
|
+
sub_annual_path: str | None = None
|
|
205
209
|
key_cols = ["oid"]
|
|
206
210
|
schema_profile = SchemaProfile(
|
|
207
211
|
key_cols=["oid"],
|
|
@@ -297,6 +297,76 @@ class TestViewMaterialisation:
|
|
|
297
297
|
with pytest.raises(ValueError, match="already has a pending spatial_join"):
|
|
298
298
|
view.spatial_join(entity_cls(), {"district": "count"})
|
|
299
299
|
|
|
300
|
+
def test_spatial_join_accepts_view_as_other(self, entity_cls: Any) -> None:
|
|
301
|
+
"""Passing a View as `other` (e.g. aoi.waterbody) should unwrap to the
|
|
302
|
+
underlying entity so that materialisation can call .schema_profile on it.
|
|
303
|
+
|
|
304
|
+
Regression test for: AttributeError: 'View' object has no attribute
|
|
305
|
+
'schema_profile' raised inside execute_spatial_join when AoI attributes
|
|
306
|
+
(which return Views) were passed directly to spatial_join.
|
|
307
|
+
"""
|
|
308
|
+
entity = entity_cls()
|
|
309
|
+
other_entity = entity_cls()
|
|
310
|
+
# Wrap the secondary entity in a View, mimicking what AoI.__getattr__ returns.
|
|
311
|
+
other_view = View(
|
|
312
|
+
keys=pl.DataFrame({"mws_id": []}, schema={"mws_id": pl.String}),
|
|
313
|
+
entity=other_entity,
|
|
314
|
+
entity_name="otherw",
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
view = View(
|
|
318
|
+
keys=pl.DataFrame({"mws_id": []}, schema={"mws_id": pl.String}),
|
|
319
|
+
entity=entity,
|
|
320
|
+
entity_name="minimalmws",
|
|
321
|
+
)
|
|
322
|
+
joined = view.spatial_join(other_view, {"district": "count"})
|
|
323
|
+
|
|
324
|
+
# The join_spec must store the unwrapped BaseEntity, not the View.
|
|
325
|
+
assert joined.join_spec is not None
|
|
326
|
+
assert joined.join_spec["other"] is other_entity
|
|
327
|
+
assert not isinstance(joined.join_spec["other"], View)
|
|
328
|
+
|
|
329
|
+
def test_spatial_join_view_other_materialises(self, entity_cls: Any) -> None:
|
|
330
|
+
"""Materialising a View whose join `other` was supplied as a View should
|
|
331
|
+
produce the same prefixed columns as passing a bare entity directly.
|
|
332
|
+
"""
|
|
333
|
+
import pathlib
|
|
334
|
+
import tempfile
|
|
335
|
+
import sys
|
|
336
|
+
import os
|
|
337
|
+
|
|
338
|
+
conftest_dir = os.path.dirname(__file__)
|
|
339
|
+
if conftest_dir not in sys.path:
|
|
340
|
+
sys.path.insert(0, conftest_dir)
|
|
341
|
+
from conftest import _make_static_parquet, _make_entity_cls
|
|
342
|
+
|
|
343
|
+
with tempfile.TemporaryDirectory() as td:
|
|
344
|
+
sp = pathlib.Path(td) / "static.parquet"
|
|
345
|
+
_make_static_parquet(sp)
|
|
346
|
+
full_cls = _make_entity_cls(sp)
|
|
347
|
+
full_entity = full_cls()
|
|
348
|
+
keys = full_entity._index.select(full_entity.key_cols)
|
|
349
|
+
|
|
350
|
+
# Build the secondary entity wrapped in a View — the pattern from aoi.X
|
|
351
|
+
other_view = View(
|
|
352
|
+
keys=keys,
|
|
353
|
+
entity=full_entity,
|
|
354
|
+
entity_name="minimalmws",
|
|
355
|
+
)
|
|
356
|
+
|
|
357
|
+
view = View(
|
|
358
|
+
keys=keys,
|
|
359
|
+
entity=full_entity,
|
|
360
|
+
entity_name="minimalmws",
|
|
361
|
+
join_spec={"other": other_view, "agg": {"district": "count"}},
|
|
362
|
+
)
|
|
363
|
+
result = view.static
|
|
364
|
+
|
|
365
|
+
joined_cols = [c for c in result.df().columns if c.startswith("minimalmws_")]
|
|
366
|
+
assert joined_cols, (
|
|
367
|
+
f"Expected prefixed join columns but got: {result.df().columns}"
|
|
368
|
+
)
|
|
369
|
+
|
|
300
370
|
def test_spatial_join_fetches_geometry_if_missing(
|
|
301
371
|
self, entity_cls_full: Any
|
|
302
372
|
) -> None:
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|