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.
Files changed (94) hide show
  1. {core_lens-0.1.dev200 → core_lens-0.1.dev203}/PKG-INFO +1 -1
  2. {core_lens-0.1.dev200 → core_lens-0.1.dev203}/src/core_lens/_version.py +2 -2
  3. {core_lens-0.1.dev200 → core_lens-0.1.dev203}/src/core_lens/base/view.py +60 -6
  4. {core_lens-0.1.dev200 → core_lens-0.1.dev203}/src/core_lens/utils/spatial.py +122 -12
  5. {core_lens-0.1.dev200 → core_lens-0.1.dev203}/tests/unit/test_spatial.py +4 -0
  6. {core_lens-0.1.dev200 → core_lens-0.1.dev203}/tests/unit/test_view.py +70 -0
  7. {core_lens-0.1.dev200 → core_lens-0.1.dev203}/.github/ISSUE_TEMPLATE/blank-proposal.yaml +0 -0
  8. {core_lens-0.1.dev200 → core_lens-0.1.dev203}/.github/ISSUE_TEMPLATE/bug-report.yaml +0 -0
  9. {core_lens-0.1.dev200 → core_lens-0.1.dev203}/.github/ISSUE_TEMPLATE/feature-request.yaml +0 -0
  10. {core_lens-0.1.dev200 → core_lens-0.1.dev203}/.github/pull_request_template.md +0 -0
  11. {core_lens-0.1.dev200 → core_lens-0.1.dev203}/.github/workflows/ci.yml +0 -0
  12. {core_lens-0.1.dev200 → core_lens-0.1.dev203}/.github/workflows/gh-pages.yml +0 -0
  13. {core_lens-0.1.dev200 → core_lens-0.1.dev203}/.github/workflows/pre-release.yml +0 -0
  14. {core_lens-0.1.dev200 → core_lens-0.1.dev203}/.github/workflows/release.yml +0 -0
  15. {core_lens-0.1.dev200 → core_lens-0.1.dev203}/.gitignore +0 -0
  16. {core_lens-0.1.dev200 → core_lens-0.1.dev203}/.gitmessage +0 -0
  17. {core_lens-0.1.dev200 → core_lens-0.1.dev203}/.pre-commit-config.yaml +0 -0
  18. {core_lens-0.1.dev200 → core_lens-0.1.dev203}/.python-version +0 -0
  19. {core_lens-0.1.dev200 → core_lens-0.1.dev203}/CONTRIBUTING.md +0 -0
  20. {core_lens-0.1.dev200 → core_lens-0.1.dev203}/LICENSE +0 -0
  21. {core_lens-0.1.dev200 → core_lens-0.1.dev203}/README.md +0 -0
  22. {core_lens-0.1.dev200 → core_lens-0.1.dev203}/SKILLS.md +0 -0
  23. {core_lens-0.1.dev200 → core_lens-0.1.dev203}/benchmarks/README.md +0 -0
  24. {core_lens-0.1.dev200 → core_lens-0.1.dev203}/benchmarks/bench_aoi.py +0 -0
  25. {core_lens-0.1.dev200 → core_lens-0.1.dev203}/benchmarks/bench_entity.py +0 -0
  26. {core_lens-0.1.dev200 → core_lens-0.1.dev203}/benchmarks/bench_export.py +0 -0
  27. {core_lens-0.1.dev200 → core_lens-0.1.dev203}/benchmarks/bench_polars_utils.py +0 -0
  28. {core_lens-0.1.dev200 → core_lens-0.1.dev203}/benchmarks/bench_result.py +0 -0
  29. {core_lens-0.1.dev200 → core_lens-0.1.dev203}/benchmarks/bench_schema.py +0 -0
  30. {core_lens-0.1.dev200 → core_lens-0.1.dev203}/benchmarks/bench_season.py +0 -0
  31. {core_lens-0.1.dev200 → core_lens-0.1.dev203}/benchmarks/bench_spatial.py +0 -0
  32. {core_lens-0.1.dev200 → core_lens-0.1.dev203}/benchmarks/bench_view.py +0 -0
  33. {core_lens-0.1.dev200 → core_lens-0.1.dev203}/benchmarks/run_all.sh +0 -0
  34. {core_lens-0.1.dev200 → core_lens-0.1.dev203}/docs/Makefile +0 -0
  35. {core_lens-0.1.dev200 → core_lens-0.1.dev203}/docs/make.bat +0 -0
  36. {core_lens-0.1.dev200 → core_lens-0.1.dev203}/docs/source/concepts.md +0 -0
  37. {core_lens-0.1.dev200 → core_lens-0.1.dev203}/docs/source/conf.py +0 -0
  38. {core_lens-0.1.dev200 → core_lens-0.1.dev203}/docs/source/export.md +0 -0
  39. {core_lens-0.1.dev200 → core_lens-0.1.dev203}/docs/source/index.rst +0 -0
  40. {core_lens-0.1.dev200 → core_lens-0.1.dev203}/docs/source/intro.md +0 -0
  41. {core_lens-0.1.dev200 → core_lens-0.1.dev203}/docs/source/logging.md +0 -0
  42. {core_lens-0.1.dev200 → core_lens-0.1.dev203}/docs/source/plots.md +0 -0
  43. {core_lens-0.1.dev200 → core_lens-0.1.dev203}/docs/source/plugins.md +0 -0
  44. {core_lens-0.1.dev200 → core_lens-0.1.dev203}/docs/source/queries.md +0 -0
  45. {core_lens-0.1.dev200 → core_lens-0.1.dev203}/docs/source/quickstart.md +0 -0
  46. {core_lens-0.1.dev200 → core_lens-0.1.dev203}/docs/source/stats.md +0 -0
  47. {core_lens-0.1.dev200 → core_lens-0.1.dev203}/examples/demo_mws.py +0 -0
  48. {core_lens-0.1.dev200 → core_lens-0.1.dev203}/examples/demo_tehsil.py +0 -0
  49. {core_lens-0.1.dev200 → core_lens-0.1.dev203}/hooks/mypy.sh +0 -0
  50. {core_lens-0.1.dev200 → core_lens-0.1.dev203}/hooks/no-parquet-outside-fixtures.sh +0 -0
  51. {core_lens-0.1.dev200 → core_lens-0.1.dev203}/hooks/pytest.sh +0 -0
  52. {core_lens-0.1.dev200 → core_lens-0.1.dev203}/pyproject.toml +0 -0
  53. {core_lens-0.1.dev200 → core_lens-0.1.dev203}/src/core_lens/__init__.py +0 -0
  54. {core_lens-0.1.dev200 → core_lens-0.1.dev203}/src/core_lens/__main__.py +0 -0
  55. {core_lens-0.1.dev200 → core_lens-0.1.dev203}/src/core_lens/aoi.py +0 -0
  56. {core_lens-0.1.dev200 → core_lens-0.1.dev203}/src/core_lens/base/__init__.py +0 -0
  57. {core_lens-0.1.dev200 → core_lens-0.1.dev203}/src/core_lens/base/entity.py +0 -0
  58. {core_lens-0.1.dev200 → core_lens-0.1.dev203}/src/core_lens/base/namespaces/__init__.py +0 -0
  59. {core_lens-0.1.dev200 → core_lens-0.1.dev203}/src/core_lens/base/namespaces/plot.py +0 -0
  60. {core_lens-0.1.dev200 → core_lens-0.1.dev203}/src/core_lens/base/namespaces/stats.py +0 -0
  61. {core_lens-0.1.dev200 → core_lens-0.1.dev203}/src/core_lens/base/result.py +0 -0
  62. {core_lens-0.1.dev200 → core_lens-0.1.dev203}/src/core_lens/entities/__init__.py +0 -0
  63. {core_lens-0.1.dev200 → core_lens-0.1.dev203}/src/core_lens/entities/farms.py +0 -0
  64. {core_lens-0.1.dev200 → core_lens-0.1.dev203}/src/core_lens/entities/mws.py +0 -0
  65. {core_lens-0.1.dev200 → core_lens-0.1.dev203}/src/core_lens/entities/tehsil.py +0 -0
  66. {core_lens-0.1.dev200 → core_lens-0.1.dev203}/src/core_lens/entities/waterbody.py +0 -0
  67. {core_lens-0.1.dev200 → core_lens-0.1.dev203}/src/core_lens/export/__init__.py +0 -0
  68. {core_lens-0.1.dev200 → core_lens-0.1.dev203}/src/core_lens/export/formats.py +0 -0
  69. {core_lens-0.1.dev200 → core_lens-0.1.dev203}/src/core_lens/py.typed +0 -0
  70. {core_lens-0.1.dev200 → core_lens-0.1.dev203}/src/core_lens/schema/__init__.py +0 -0
  71. {core_lens-0.1.dev200 → core_lens-0.1.dev203}/src/core_lens/schema/detection.py +0 -0
  72. {core_lens-0.1.dev200 → core_lens-0.1.dev203}/src/core_lens/schema/profile.py +0 -0
  73. {core_lens-0.1.dev200 → core_lens-0.1.dev203}/src/core_lens/utils/__init__.py +0 -0
  74. {core_lens-0.1.dev200 → core_lens-0.1.dev203}/src/core_lens/utils/paths.py +0 -0
  75. {core_lens-0.1.dev200 → core_lens-0.1.dev203}/src/core_lens/utils/polars_utils.py +0 -0
  76. {core_lens-0.1.dev200 → core_lens-0.1.dev203}/src/core_lens/utils/season.py +0 -0
  77. {core_lens-0.1.dev200 → core_lens-0.1.dev203}/tests/fixtures/generate_fixtures.py +0 -0
  78. {core_lens-0.1.dev200 → core_lens-0.1.dev203}/tests/unit/conftest.py +0 -0
  79. {core_lens-0.1.dev200 → core_lens-0.1.dev203}/tests/unit/test_aoi.py +0 -0
  80. {core_lens-0.1.dev200 → core_lens-0.1.dev203}/tests/unit/test_entities.py +0 -0
  81. {core_lens-0.1.dev200 → core_lens-0.1.dev203}/tests/unit/test_entity.py +0 -0
  82. {core_lens-0.1.dev200 → core_lens-0.1.dev203}/tests/unit/test_export.py +0 -0
  83. {core_lens-0.1.dev200 → core_lens-0.1.dev203}/tests/unit/test_main.py +0 -0
  84. {core_lens-0.1.dev200 → core_lens-0.1.dev203}/tests/unit/test_plot.py +0 -0
  85. {core_lens-0.1.dev200 → core_lens-0.1.dev203}/tests/unit/test_polars_utils.py +0 -0
  86. {core_lens-0.1.dev200 → core_lens-0.1.dev203}/tests/unit/test_profile.py +0 -0
  87. {core_lens-0.1.dev200 → core_lens-0.1.dev203}/tests/unit/test_result.py +0 -0
  88. {core_lens-0.1.dev200 → core_lens-0.1.dev203}/tests/unit/test_schema_detection.py +0 -0
  89. {core_lens-0.1.dev200 → core_lens-0.1.dev203}/tests/unit/test_schema_profile.py +0 -0
  90. {core_lens-0.1.dev200 → core_lens-0.1.dev203}/tests/unit/test_season.py +0 -0
  91. {core_lens-0.1.dev200 → core_lens-0.1.dev203}/tests/unit/test_season_config.py +0 -0
  92. {core_lens-0.1.dev200 → core_lens-0.1.dev203}/tests/unit/test_stats.py +0 -0
  93. {core_lens-0.1.dev200 → core_lens-0.1.dev203}/usage.md +0 -0
  94. {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.dev200
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.dev200'
22
- __version_tuple__ = version_tuple = (0, 1, 'dev200')
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(self, other: "BaseEntity", agg: dict[str, str]) -> "View":
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 :class:`BaseEntity` whose columns will be
245
- joined and aggregated onto ``self``.
246
- agg (dict[str, str]): Mapping of ``{column: aggregation}`` specifying which columns
247
- from ``other`` to bring in and how to aggregate them.
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": other, "agg": agg},
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 ``{column: aggregation}`` — which columns from
610
- ``other_entity`` to bring in and how to aggregate them. Valid
611
- aggregations: ``"count"``, ``"mean"``, ``"sum"``, ``"min"``,
612
- ``"max"``, ``"area"``.
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
- agg_col_names = [c for c in agg if c not in ("count", "area")]
671
- other_cols = list(dict.fromkeys(other_key_cols + [other_geom_col] + agg_col_names))
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 other_cols})
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(other_cols)
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=other_cols,
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