core-lens 0.1.dev164__tar.gz → 0.1.dev168__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.dev164 → core_lens-0.1.dev168}/CONTRIBUTING.md +2 -2
- {core_lens-0.1.dev164 → core_lens-0.1.dev168}/PKG-INFO +4 -2
- {core_lens-0.1.dev164 → core_lens-0.1.dev168}/SKILLS.md +7 -7
- {core_lens-0.1.dev164 → core_lens-0.1.dev168}/benchmarks/README.md +1 -1
- {core_lens-0.1.dev164 → core_lens-0.1.dev168}/benchmarks/bench_result.py +2 -2
- {core_lens-0.1.dev164 → core_lens-0.1.dev168}/benchmarks/bench_schema.py +10 -10
- {core_lens-0.1.dev164 → core_lens-0.1.dev168}/benchmarks/bench_season.py +13 -13
- {core_lens-0.1.dev164 → core_lens-0.1.dev168}/benchmarks/bench_view.py +6 -6
- core_lens-0.1.dev168/docs/source/concepts.md +7 -0
- {core_lens-0.1.dev164 → core_lens-0.1.dev168}/docs/source/logging.md +1 -1
- {core_lens-0.1.dev164 → core_lens-0.1.dev168}/docs/source/queries.md +7 -7
- {core_lens-0.1.dev164 → core_lens-0.1.dev168}/docs/source/stats.md +1 -1
- {core_lens-0.1.dev164 → core_lens-0.1.dev168}/examples/demo_mws.py +12 -12
- {core_lens-0.1.dev164 → core_lens-0.1.dev168}/examples/demo_tehsil.py +2 -2
- {core_lens-0.1.dev164 → core_lens-0.1.dev168}/pyproject.toml +3 -1
- {core_lens-0.1.dev164 → core_lens-0.1.dev168}/src/core_lens/_version.py +2 -2
- {core_lens-0.1.dev164 → core_lens-0.1.dev168}/src/core_lens/aoi.py +3 -3
- {core_lens-0.1.dev164 → core_lens-0.1.dev168}/src/core_lens/base/entity.py +13 -13
- {core_lens-0.1.dev164 → core_lens-0.1.dev168}/src/core_lens/base/namespaces/plot.py +30 -1
- {core_lens-0.1.dev164 → core_lens-0.1.dev168}/src/core_lens/base/namespaces/stats.py +38 -15
- {core_lens-0.1.dev164 → core_lens-0.1.dev168}/src/core_lens/base/result.py +10 -10
- {core_lens-0.1.dev164 → core_lens-0.1.dev168}/src/core_lens/base/view.py +15 -15
- {core_lens-0.1.dev164 → core_lens-0.1.dev168}/src/core_lens/entities/mws.py +4 -4
- {core_lens-0.1.dev164 → core_lens-0.1.dev168}/src/core_lens/entities/tehsil.py +1 -1
- {core_lens-0.1.dev164 → core_lens-0.1.dev168}/src/core_lens/schema/detection.py +18 -18
- {core_lens-0.1.dev164 → core_lens-0.1.dev168}/src/core_lens/schema/profile.py +8 -8
- {core_lens-0.1.dev164 → core_lens-0.1.dev168}/src/core_lens/utils/season.py +2 -2
- {core_lens-0.1.dev164 → core_lens-0.1.dev168}/tests/fixtures/generate_fixtures.py +7 -7
- {core_lens-0.1.dev164 → core_lens-0.1.dev168}/tests/unit/conftest.py +16 -16
- {core_lens-0.1.dev164 → core_lens-0.1.dev168}/tests/unit/test_aoi.py +3 -3
- {core_lens-0.1.dev164 → core_lens-0.1.dev168}/tests/unit/test_entities.py +2 -2
- {core_lens-0.1.dev164 → core_lens-0.1.dev168}/tests/unit/test_entity.py +2 -2
- {core_lens-0.1.dev164 → core_lens-0.1.dev168}/tests/unit/test_plot.py +1 -1
- {core_lens-0.1.dev164 → core_lens-0.1.dev168}/tests/unit/test_profile.py +1 -1
- {core_lens-0.1.dev164 → core_lens-0.1.dev168}/tests/unit/test_result.py +8 -8
- {core_lens-0.1.dev164 → core_lens-0.1.dev168}/tests/unit/test_schema_detection.py +7 -7
- {core_lens-0.1.dev164 → core_lens-0.1.dev168}/tests/unit/test_schema_profile.py +7 -7
- {core_lens-0.1.dev164 → core_lens-0.1.dev168}/tests/unit/test_spatial.py +3 -3
- {core_lens-0.1.dev164 → core_lens-0.1.dev168}/tests/unit/test_stats.py +6 -6
- {core_lens-0.1.dev164 → core_lens-0.1.dev168}/tests/unit/test_view.py +31 -61
- {core_lens-0.1.dev164 → core_lens-0.1.dev168}/uv.lock +569 -249
- core_lens-0.1.dev164/docs/source/concepts.md +0 -7
- {core_lens-0.1.dev164 → core_lens-0.1.dev168}/.github/ISSUE_TEMPLATE/blank-proposal.yaml +0 -0
- {core_lens-0.1.dev164 → core_lens-0.1.dev168}/.github/ISSUE_TEMPLATE/bug-report.yaml +0 -0
- {core_lens-0.1.dev164 → core_lens-0.1.dev168}/.github/ISSUE_TEMPLATE/feature-request.yaml +0 -0
- {core_lens-0.1.dev164 → core_lens-0.1.dev168}/.github/pull_request_template.md +0 -0
- {core_lens-0.1.dev164 → core_lens-0.1.dev168}/.github/workflows/ci.yml +0 -0
- {core_lens-0.1.dev164 → core_lens-0.1.dev168}/.github/workflows/gh-pages.yml +0 -0
- {core_lens-0.1.dev164 → core_lens-0.1.dev168}/.github/workflows/pre-release.yml +0 -0
- {core_lens-0.1.dev164 → core_lens-0.1.dev168}/.github/workflows/release.yml +0 -0
- {core_lens-0.1.dev164 → core_lens-0.1.dev168}/.gitignore +0 -0
- {core_lens-0.1.dev164 → core_lens-0.1.dev168}/.gitmessage +0 -0
- {core_lens-0.1.dev164 → core_lens-0.1.dev168}/.pre-commit-config.yaml +0 -0
- {core_lens-0.1.dev164 → core_lens-0.1.dev168}/.python-version +0 -0
- {core_lens-0.1.dev164 → core_lens-0.1.dev168}/LICENSE +0 -0
- {core_lens-0.1.dev164 → core_lens-0.1.dev168}/README.md +0 -0
- {core_lens-0.1.dev164 → core_lens-0.1.dev168}/benchmarks/bench_aoi.py +0 -0
- {core_lens-0.1.dev164 → core_lens-0.1.dev168}/benchmarks/bench_export.py +0 -0
- {core_lens-0.1.dev164 → core_lens-0.1.dev168}/benchmarks/bench_polars_utils.py +0 -0
- {core_lens-0.1.dev164 → core_lens-0.1.dev168}/benchmarks/bench_spatial.py +0 -0
- {core_lens-0.1.dev164 → core_lens-0.1.dev168}/benchmarks/run_all.sh +0 -0
- {core_lens-0.1.dev164 → core_lens-0.1.dev168}/docs/Makefile +0 -0
- {core_lens-0.1.dev164 → core_lens-0.1.dev168}/docs/make.bat +0 -0
- {core_lens-0.1.dev164 → core_lens-0.1.dev168}/docs/source/conf.py +0 -0
- {core_lens-0.1.dev164 → core_lens-0.1.dev168}/docs/source/export.md +0 -0
- {core_lens-0.1.dev164 → core_lens-0.1.dev168}/docs/source/index.rst +0 -0
- {core_lens-0.1.dev164 → core_lens-0.1.dev168}/docs/source/intro.md +0 -0
- {core_lens-0.1.dev164 → core_lens-0.1.dev168}/docs/source/plots.md +0 -0
- {core_lens-0.1.dev164 → core_lens-0.1.dev168}/docs/source/plugins.md +0 -0
- {core_lens-0.1.dev164 → core_lens-0.1.dev168}/docs/source/quickstart.md +0 -0
- {core_lens-0.1.dev164 → core_lens-0.1.dev168}/hooks/mypy.sh +0 -0
- {core_lens-0.1.dev164 → core_lens-0.1.dev168}/hooks/no-parquet-outside-fixtures.sh +0 -0
- {core_lens-0.1.dev164 → core_lens-0.1.dev168}/hooks/pytest.sh +0 -0
- {core_lens-0.1.dev164 → core_lens-0.1.dev168}/src/core_lens/__init__.py +0 -0
- {core_lens-0.1.dev164 → core_lens-0.1.dev168}/src/core_lens/__main__.py +0 -0
- {core_lens-0.1.dev164 → core_lens-0.1.dev168}/src/core_lens/base/__init__.py +0 -0
- {core_lens-0.1.dev164 → core_lens-0.1.dev168}/src/core_lens/base/namespaces/__init__.py +0 -0
- {core_lens-0.1.dev164 → core_lens-0.1.dev168}/src/core_lens/entities/__init__.py +0 -0
- {core_lens-0.1.dev164 → core_lens-0.1.dev168}/src/core_lens/entities/waterbody.py +0 -0
- {core_lens-0.1.dev164 → core_lens-0.1.dev168}/src/core_lens/export/__init__.py +0 -0
- {core_lens-0.1.dev164 → core_lens-0.1.dev168}/src/core_lens/export/formats.py +0 -0
- {core_lens-0.1.dev164 → core_lens-0.1.dev168}/src/core_lens/py.typed +0 -0
- {core_lens-0.1.dev164 → core_lens-0.1.dev168}/src/core_lens/schema/__init__.py +0 -0
- {core_lens-0.1.dev164 → core_lens-0.1.dev168}/src/core_lens/utils/__init__.py +0 -0
- {core_lens-0.1.dev164 → core_lens-0.1.dev168}/src/core_lens/utils/paths.py +0 -0
- {core_lens-0.1.dev164 → core_lens-0.1.dev168}/src/core_lens/utils/polars_utils.py +0 -0
- {core_lens-0.1.dev164 → core_lens-0.1.dev168}/src/core_lens/utils/spatial.py +0 -0
- {core_lens-0.1.dev164 → core_lens-0.1.dev168}/tests/unit/test_export.py +0 -0
- {core_lens-0.1.dev164 → core_lens-0.1.dev168}/tests/unit/test_main.py +0 -0
- {core_lens-0.1.dev164 → core_lens-0.1.dev168}/tests/unit/test_polars_utils.py +0 -0
- {core_lens-0.1.dev164 → core_lens-0.1.dev168}/tests/unit/test_season.py +0 -0
- {core_lens-0.1.dev164 → core_lens-0.1.dev168}/tests/unit/test_season_config.py +0 -0
|
@@ -143,7 +143,7 @@ Examples:
|
|
|
143
143
|
```text
|
|
144
144
|
feature/stats-similarity
|
|
145
145
|
feature/plugin-validation
|
|
146
|
-
fix/version-clash-
|
|
146
|
+
fix/version-clash-sub-annual
|
|
147
147
|
```
|
|
148
148
|
|
|
149
149
|
Workflow:
|
|
@@ -204,7 +204,7 @@ Examples:
|
|
|
204
204
|
|
|
205
205
|
```text
|
|
206
206
|
feat: add stats.similarity()
|
|
207
|
-
fix: resolve VersionClashError on
|
|
207
|
+
fix: resolve VersionClashError on sub-annual
|
|
208
208
|
docs: add plugin guide
|
|
209
209
|
test: add aggregate validation fixtures
|
|
210
210
|
chore: update ruff
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: core-lens
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.dev168
|
|
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
|
|
@@ -10,10 +10,12 @@ License-File: LICENSE
|
|
|
10
10
|
Classifier: Development Status :: 2 - Pre-Alpha
|
|
11
11
|
Classifier: Operating System :: POSIX :: Linux
|
|
12
12
|
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
13
15
|
Classifier: Topic :: Scientific/Engineering
|
|
14
16
|
Classifier: Topic :: Scientific/Engineering :: GIS
|
|
15
17
|
Classifier: Topic :: Scientific/Engineering :: Hydrology
|
|
16
|
-
Requires-Python: >=3.
|
|
18
|
+
Requires-Python: >=3.12
|
|
17
19
|
Requires-Dist: core-lens[core]
|
|
18
20
|
Requires-Dist: core-lens[spatial]
|
|
19
21
|
Provides-Extra: core
|
|
@@ -47,7 +47,7 @@ view.between(season="kharif", year=2020)
|
|
|
47
47
|
# Materialise into a Result object
|
|
48
48
|
static_result = view.static # has geometry
|
|
49
49
|
annual_result = view.annual # no geometry
|
|
50
|
-
|
|
50
|
+
sub_annual_result = view.sub_annual # no geometry
|
|
51
51
|
```
|
|
52
52
|
|
|
53
53
|
## 3. Operations on Results (Compute & Aggregation)
|
|
@@ -129,7 +129,7 @@ result.stats.similarity(
|
|
|
129
129
|
target="13_551",
|
|
130
130
|
columns={
|
|
131
131
|
"rainfall": ("annual", {"year": 2018}),
|
|
132
|
-
"ndvi": ("
|
|
132
|
+
"ndvi": ("sub_annual", {"season": "kharif", "year": 2020})
|
|
133
133
|
},
|
|
134
134
|
method="euclidean",
|
|
135
135
|
top_n=10
|
|
@@ -189,8 +189,8 @@ AoI.register(ForestEntity)
|
|
|
189
189
|
|
|
190
190
|
## 9. Execution Environment Context
|
|
191
191
|
|
|
192
|
-
- **Loading Data**: There is no explicit `AoI.load_data()` method. The `AoI(data_root="...")` constructor acts as the main entry point and defines the directory scope. Data reading happens lazily using Polars `scan_parquet()` when you explicitly call materialization methods like `.static`, `.annual`, or `.
|
|
193
|
-
- **Spatial and Temporal Relationship**: To conserve memory, temporal views (`.annual` and `.
|
|
192
|
+
- **Loading Data**: There is no explicit `AoI.load_data()` method. The `AoI(data_root="...")` constructor acts as the main entry point and defines the directory scope. Data reading happens lazily using Polars `scan_parquet()` when you explicitly call materialization methods like `.static`, `.annual`, or `.sub_annual` on a `View`.
|
|
193
|
+
- **Spatial and Temporal Relationship**: To conserve memory, temporal views (`.annual` and `.sub_annual`) drop geometry columns by default. To establish the spatial relationship between the static boundaries (`mws.parquet`) and the temporal time-series (`annual.parquet`), you use the `Result.with_geometry()` method. This triggers an internal join on the entity's primary key (e.g., `mws_id`) bringing the geometry back into the DataFrame for spatial operations and plotting.
|
|
194
194
|
|
|
195
195
|
- **Expected Directory Structure**: When initializing `AoI(data_root="data/")`, the library expects a specific folder structure inside the `data_root` for each registered entity. For example, the `MWSEntity` expects:
|
|
196
196
|
```text
|
|
@@ -199,7 +199,7 @@ AoI.register(ForestEntity)
|
|
|
199
199
|
├── static/
|
|
200
200
|
│ └── mws.parquet ← GeoParquet with boundaries
|
|
201
201
|
├── annual/ ← Directory of Parquets (often partitioned by basin)
|
|
202
|
-
└──
|
|
202
|
+
└── sub-annual/ ← Directory of Parquets (partitioned by year/basin)
|
|
203
203
|
```
|
|
204
204
|
*(Note: The exact paths for these files/directories are defined within the `BaseEntity` subclasses, e.g., `MWSEntity.static_path`)*
|
|
205
205
|
|
|
@@ -229,9 +229,9 @@ view = aoi.mws.where(ba_name="Barmer")
|
|
|
229
229
|
temporal_view = view.between(season="kharif", year=2021)
|
|
230
230
|
```
|
|
231
231
|
|
|
232
|
-
**5. Materialization:** Trigger the actual Parquet I/O to pull the data into memory by accessing `.static`, `.annual`, or `.
|
|
232
|
+
**5. Materialization:** Trigger the actual Parquet I/O to pull the data into memory by accessing `.static`, `.annual`, or `.sub_annual`.
|
|
233
233
|
```python
|
|
234
|
-
result = temporal_view.
|
|
234
|
+
result = temporal_view.sub_annual
|
|
235
235
|
```
|
|
236
236
|
|
|
237
237
|
**6. Compute & Analysis:** Chain computations, derive metrics, or run statistical tests on the returned `Result`.
|
|
@@ -10,7 +10,7 @@ benchmarks/
|
|
|
10
10
|
├── run_all.sh # Run all benchmarks in sequence
|
|
11
11
|
├── bench_aoi.py # AoI init, register, named-boundary resolution
|
|
12
12
|
├── bench_spatial.py # build_bbox_index, bbox_intersects_geometry, exact_spatial_filter
|
|
13
|
-
├── bench_view.py # View.where, spatial_filter, between, materialise (static/annual/
|
|
13
|
+
├── bench_view.py # View.where, spatial_filter, between, materialise (static/annual/sub-annual)
|
|
14
14
|
├── bench_result.py # Result.derive, aggregate, with_geometry, df/gdf
|
|
15
15
|
├── bench_season.py # resolve_time_filter, add_temporal_columns, SeasonConfig.season_for
|
|
16
16
|
├── bench_schema.py # schema detection (detect, _infer_* helpers)
|
|
@@ -7,7 +7,7 @@ Targets:
|
|
|
7
7
|
- Result.with_geometry() static-join to attach geometry to non-static result
|
|
8
8
|
- Result.derive() with_columns expression append
|
|
9
9
|
- Result.aggregate() group_by + agg (no temporal grouping)
|
|
10
|
-
- Result.aggregate(by=...) temporal grouping (annual /
|
|
10
|
+
- Result.aggregate(by=...) temporal grouping (annual / sub_annual)
|
|
11
11
|
|
|
12
12
|
Scalene focuses on:
|
|
13
13
|
- shapely.wkb.loads() in gdf() — Python loop over WKB bytes
|
|
@@ -130,7 +130,7 @@ print(
|
|
|
130
130
|
)
|
|
131
131
|
|
|
132
132
|
|
|
133
|
-
# ── Note: meaningful with_geometry() test requires annual/
|
|
133
|
+
# ── Note: meaningful with_geometry() test requires annual/sub_annual result ──
|
|
134
134
|
# Uncomment with an MWS entity that has annual_path set:
|
|
135
135
|
|
|
136
136
|
result_annual = aoi.mws.annual # no geometry
|
|
@@ -36,7 +36,7 @@ DATA_ROOT = "data/"
|
|
|
36
36
|
entity = MWSEntity(data_root=DATA_ROOT)
|
|
37
37
|
static_path = entity._resolve(entity.static_path)
|
|
38
38
|
annual_path = entity._resolve(entity.annual_path) if entity.annual_path else None
|
|
39
|
-
fn_path = entity._resolve(entity.
|
|
39
|
+
fn_path = entity._resolve(entity.sub_annual_path) if entity.sub_annual_path else None
|
|
40
40
|
|
|
41
41
|
|
|
42
42
|
def _section(title: str) -> None:
|
|
@@ -104,7 +104,7 @@ print(
|
|
|
104
104
|
print(f"Detected time col: {_infer_time_col(schema, static_path)!r}")
|
|
105
105
|
|
|
106
106
|
|
|
107
|
-
# ── 5. detect() — cold (no annual/
|
|
107
|
+
# ── 5. detect() — cold (no annual/sub_annual) ────────────────────────────────
|
|
108
108
|
_section("5. detect(static only) [full detection — cold]")
|
|
109
109
|
REPS = 10
|
|
110
110
|
profile = detect( # ensure bound before loop
|
|
@@ -112,7 +112,7 @@ profile = detect( # ensure bound before loop
|
|
|
112
112
|
key_cols=entity.key_cols,
|
|
113
113
|
geometry_col=entity.geometry_col,
|
|
114
114
|
annual_path=None,
|
|
115
|
-
|
|
115
|
+
sub_annual_path=None,
|
|
116
116
|
)
|
|
117
117
|
t0 = time.perf_counter()
|
|
118
118
|
for _ in range(REPS):
|
|
@@ -121,7 +121,7 @@ for _ in range(REPS):
|
|
|
121
121
|
key_cols=entity.key_cols,
|
|
122
122
|
geometry_col=entity.geometry_col,
|
|
123
123
|
annual_path=None,
|
|
124
|
-
|
|
124
|
+
sub_annual_path=None,
|
|
125
125
|
)
|
|
126
126
|
t1 = time.perf_counter()
|
|
127
127
|
print(
|
|
@@ -133,15 +133,15 @@ print(f"Profile bbox_cols : {profile.bbox_cols!r}")
|
|
|
133
133
|
print(f"Profile extra_static : {profile.extra_static_cols}")
|
|
134
134
|
|
|
135
135
|
|
|
136
|
-
# ── 6. detect() — with annual +
|
|
136
|
+
# ── 6. detect() — with annual + sub_annual ───────────────────────────────────
|
|
137
137
|
if annual_path or fn_path:
|
|
138
|
-
_section("6. detect(static + annual +
|
|
138
|
+
_section("6. detect(static + annual + sub_annual) [3× schema reads]")
|
|
139
139
|
profile_full = detect( # ensure bound before loop
|
|
140
140
|
static_path=static_path,
|
|
141
141
|
key_cols=entity.key_cols,
|
|
142
142
|
geometry_col=entity.geometry_col,
|
|
143
143
|
annual_path=annual_path,
|
|
144
|
-
|
|
144
|
+
sub_annual_path=fn_path,
|
|
145
145
|
)
|
|
146
146
|
t0 = time.perf_counter()
|
|
147
147
|
for _ in range(REPS):
|
|
@@ -150,7 +150,7 @@ if annual_path or fn_path:
|
|
|
150
150
|
key_cols=entity.key_cols,
|
|
151
151
|
geometry_col=entity.geometry_col,
|
|
152
152
|
annual_path=annual_path,
|
|
153
|
-
|
|
153
|
+
sub_annual_path=fn_path,
|
|
154
154
|
)
|
|
155
155
|
t1 = time.perf_counter()
|
|
156
156
|
print(
|
|
@@ -158,10 +158,10 @@ if annual_path or fn_path:
|
|
|
158
158
|
f"({(t1 - t0) / REPS * 1000:.2f} ms/call)"
|
|
159
159
|
)
|
|
160
160
|
print(f"annual_time_col : {profile_full.annual_time_col!r}")
|
|
161
|
-
print(f"
|
|
161
|
+
print(f"sub_annual_time_col : {profile_full.sub_annual_time_col!r}")
|
|
162
162
|
else:
|
|
163
163
|
print(
|
|
164
|
-
"\n[skip] No annual/
|
|
164
|
+
"\n[skip] No annual/sub_annual paths on TehsilEntity — add an entity with those paths"
|
|
165
165
|
)
|
|
166
166
|
|
|
167
167
|
|
|
@@ -6,7 +6,7 @@ Targets:
|
|
|
6
6
|
- resolve_time_filter() dict → Polars Expr build (date-range + season modes)
|
|
7
7
|
- _date_range_expr() expression build for date ranges
|
|
8
8
|
- _season_expr() expression build for seasons (year-crossing vs not)
|
|
9
|
-
- add_temporal_columns()
|
|
9
|
+
- add_temporal_columns() sub_annual vectorised temporal derivation
|
|
10
10
|
|
|
11
11
|
Scalene focuses on:
|
|
12
12
|
- Python datetime.strptime calls in __post_init__
|
|
@@ -35,22 +35,22 @@ from core_lens.utils.season import add_temporal_columns, resolve_time_filter
|
|
|
35
35
|
START_DATE = date(2010, 1, 1)
|
|
36
36
|
END_DATE = date(2023, 12, 31)
|
|
37
37
|
|
|
38
|
-
# Number of
|
|
39
|
-
|
|
38
|
+
# Number of sub_annual periods between START_DATE and END_DATE (roughly).
|
|
39
|
+
N_SUB_ANNUAL = (END_DATE - START_DATE).days // 14
|
|
40
40
|
|
|
41
|
-
# Representative synthetic
|
|
41
|
+
# Representative synthetic sub_annual dataframe for add_temporal_columns.
|
|
42
42
|
N_MWS = 500 # number of entity instances (e.g. MWS)
|
|
43
|
-
DATES = [START_DATE + timedelta(days=14 * i) for i in range(
|
|
44
|
-
# Long format: N_MWS ×
|
|
43
|
+
DATES = [START_DATE + timedelta(days=14 * i) for i in range(N_SUB_ANNUAL)]
|
|
44
|
+
# Long format: N_MWS × N_SUB_ANNUAL rows
|
|
45
45
|
_all_dates = DATES * N_MWS
|
|
46
|
-
|
|
46
|
+
SUB_ANNUAL_DF = pl.DataFrame(
|
|
47
47
|
{
|
|
48
48
|
"mws_id": [str(mid) for mid in range(N_MWS) for _ in DATES],
|
|
49
49
|
"date": _all_dates,
|
|
50
|
-
"ndvi": [0.5] * (N_MWS *
|
|
50
|
+
"ndvi": [0.5] * (N_MWS * N_SUB_ANNUAL),
|
|
51
51
|
}
|
|
52
52
|
)
|
|
53
|
-
print(f"Synthetic
|
|
53
|
+
print(f"Synthetic sub_annual frame: {SUB_ANNUAL_DF.shape}")
|
|
54
54
|
|
|
55
55
|
|
|
56
56
|
def _section(title: str) -> None:
|
|
@@ -162,15 +162,15 @@ print(
|
|
|
162
162
|
)
|
|
163
163
|
|
|
164
164
|
|
|
165
|
-
# ── 8. add_temporal_columns — large
|
|
166
|
-
_section(f"8. add_temporal_columns() [{
|
|
165
|
+
# ── 8. add_temporal_columns — large sub_annual DataFrame ─────────────────────
|
|
166
|
+
_section(f"8. add_temporal_columns() [{SUB_ANNUAL_DF.shape[0]:,} rows]")
|
|
167
167
|
t0 = time.perf_counter()
|
|
168
|
-
result_df = add_temporal_columns(
|
|
168
|
+
result_df = add_temporal_columns(SUB_ANNUAL_DF, "date", cfg)
|
|
169
169
|
t1 = time.perf_counter()
|
|
170
170
|
print(f"add_temporal_columns: {(t1 - t0) * 1000:.2f} ms")
|
|
171
171
|
print(f"Output shape : {cast(pl.DataFrame, result_df).shape}")
|
|
172
172
|
print(
|
|
173
|
-
f"New columns added : {[c for c in cast(pl.DataFrame, result_df).columns if c not in
|
|
173
|
+
f"New columns added : {[c for c in cast(pl.DataFrame, result_df).columns if c not in SUB_ANNUAL_DF.columns]}"
|
|
174
174
|
)
|
|
175
175
|
|
|
176
176
|
# Second call — all columns already present, should be near-zero.
|
|
@@ -4,14 +4,14 @@ Targets:
|
|
|
4
4
|
- View.where() attribute filter (Polars semi-join on static file)
|
|
5
5
|
- View.spatial_filter() STRtree filter narrowing existing keys
|
|
6
6
|
- View.between() time-filter dict construction (no I/O)
|
|
7
|
-
- View._materialise() static / annual /
|
|
7
|
+
- View._materialise() static / annual / sub_annual Parquet reads
|
|
8
8
|
- View chaining where → spatial_filter → between → static
|
|
9
9
|
|
|
10
10
|
Scalene focuses on:
|
|
11
11
|
- scan_with_key_filter() predicate-pushdown LazyFrame
|
|
12
12
|
- collect_lf() streaming collect (CPU or GPU)
|
|
13
13
|
- resolve_time_filter() expression build for time predicates
|
|
14
|
-
- add_temporal_columns()
|
|
14
|
+
- add_temporal_columns() sub_annual temporal column derivation
|
|
15
15
|
|
|
16
16
|
Usage:
|
|
17
17
|
uv run scalene run --html --outfile benchmarks/profiles/bench_view.html benchmarks/bench_view.py
|
|
@@ -140,12 +140,12 @@ print(f"annual : {(t1 - t0) * 1000:.2f} ms")
|
|
|
140
140
|
print(f"Shape : {result_annual.df().shape}")
|
|
141
141
|
|
|
142
142
|
|
|
143
|
-
# ── 11.
|
|
144
|
-
_section("11. aoi_small.mws.
|
|
143
|
+
# ── 11. sub_annual materialisation ───────────────────────────────────────────
|
|
144
|
+
_section("11. aoi_small.mws.sub_annual [sub_annual materialisation]")
|
|
145
145
|
t0 = time.perf_counter()
|
|
146
|
-
result_fn = aoi_small.mws.between(season=Season.KHARIF, year=2022).
|
|
146
|
+
result_fn = aoi_small.mws.between(season=Season.KHARIF, year=2022).sub_annual
|
|
147
147
|
t1 = time.perf_counter()
|
|
148
|
-
print(f"
|
|
148
|
+
print(f"sub_annual : {(t1 - t0) * 1000:.2f} ms")
|
|
149
149
|
print(f"Shape : {result_fn.df().shape}")
|
|
150
150
|
|
|
151
151
|
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# Core Concepts
|
|
2
|
+
|
|
3
|
+
CoreLens is built on three main layers:
|
|
4
|
+
|
|
5
|
+
1. **AoI (Area of Interest)**: The primary entry point. It represents a spatial boundary and acts as a gateway to all registered entities that intersect that boundary.
|
|
6
|
+
2. **View**: A lazy, immutable query definition. Filtering by attributes (`where`), space (`spatial_filter`), or time (`between`) returns a new `View` without reading any Parquet data.
|
|
7
|
+
3. **Result**: The lazy computation graph. Accessing `.static`, `.annual`, or `.sub_annual` on a `View` resolves the query plan and returns a `Result` object wrapping a Polars `LazyFrame`. Data is not actually read until you materialise it by calling `.df()` or `.gdf()`. This delays execution and reduces memory footprint, allowing the Polars streaming engine (automatically routed to the GPU if RAPIDS `cudf-polars` is installed) to optimize the query globally. All statistical operations, aggregations, and plotting are done on `Result` objects.
|
|
@@ -27,6 +27,6 @@ aoi = AoI("data/", tehsil="Pangi", district="Chamba")
|
|
|
27
27
|
|
|
28
28
|
`core_lens` emits logs at different levels depending on the operational context:
|
|
29
29
|
|
|
30
|
-
- **INFO**: Materialisation events (reading static, annual, or
|
|
30
|
+
- **INFO**: Materialisation events (reading static, annual, or sub-annual Parquet files), entity registrations, AoI initialisation, and cross-entity spatial joins.
|
|
31
31
|
- **DEBUG**: Sub-operations such as spatial bounding box index generation, exact geometry relationships, lazy attribute filters, and query generation.
|
|
32
32
|
- **ERROR**: Incorrect initialisation arguments, failed path validations, missing geometries, unknown schema structures, or conflicting date configurations.
|
|
@@ -11,10 +11,10 @@ annual_data = aoi.mws.between("2010-01-01", "2023-12-31").annual
|
|
|
11
11
|
# Season-based filtering (defaults to Kharif, Rabi, Zaid)
|
|
12
12
|
from core_lens.base.view import Season
|
|
13
13
|
|
|
14
|
-
kharif_2020 = aoi.mws.between(season=Season.KHARIF, year=2020).
|
|
14
|
+
kharif_2020 = aoi.mws.between(season=Season.KHARIF, year=2020).sub_annual
|
|
15
15
|
|
|
16
16
|
# Current season based on today's date
|
|
17
|
-
current = aoi.mws.between(season=Season.CURRENT).
|
|
17
|
+
current = aoi.mws.between(season=Season.CURRENT).sub_annual
|
|
18
18
|
```
|
|
19
19
|
|
|
20
20
|
## Aggregations & Derived Columns
|
|
@@ -31,10 +31,10 @@ derived_res = res_annual.derive(
|
|
|
31
31
|
pl.when(pl.col("rainfall") < 500).then(1).otherwise(0)
|
|
32
32
|
)
|
|
33
33
|
|
|
34
|
-
# 2. Aggregate (temporal grouping like "month" requires
|
|
35
|
-
|
|
36
|
-
monthly_avg =
|
|
37
|
-
seasonal_avg =
|
|
34
|
+
# 2. Aggregate (temporal grouping like "month" requires sub-annual, but "year" works on annual too)
|
|
35
|
+
res_sub_annual = aoi.mws.sub_annual
|
|
36
|
+
monthly_avg = res_sub_annual.aggregate(pl.mean("ndvi"), by="month")
|
|
37
|
+
seasonal_avg = res_sub_annual.aggregate(pl.mean("ndvi"), by="season_year")
|
|
38
38
|
```
|
|
39
39
|
|
|
40
40
|
## Cross-Entity Spatial Joins
|
|
@@ -59,5 +59,5 @@ joined_view = aoi.mws.spatial_join(
|
|
|
59
59
|
```
|
|
60
60
|
|
|
61
61
|
```{note}
|
|
62
|
-
Cross-entity spatial join execution (materialisation) is currently under development and will be added in a subsequent release. Calling materialisation methods (like `.annual`, `.static`, or `.
|
|
62
|
+
Cross-entity spatial join execution (materialisation) is currently under development and will be added in a subsequent release. Calling materialisation methods (like `.annual`, `.static`, or `.sub_annual`) on a joined view will raise a `NotImplementedError` in the current version.
|
|
63
63
|
```
|
|
@@ -107,7 +107,7 @@ similar = res.stats.similarity(
|
|
|
107
107
|
target="13_551",
|
|
108
108
|
columns={
|
|
109
109
|
"rainfall": ("annual", {"year": 2018}),
|
|
110
|
-
"ndvi": ("
|
|
110
|
+
"ndvi": ("sub_annual", {"season": "kharif", "year": 2020})
|
|
111
111
|
},
|
|
112
112
|
method=SimilarityMethod.EUCLIDEAN,
|
|
113
113
|
top_n=10
|
|
@@ -16,13 +16,13 @@ Data layout expected
|
|
|
16
16
|
│ └── mws.parquet ← GeoParquet with WKB geometry in "geom"
|
|
17
17
|
├── annual/
|
|
18
18
|
│ └── ... ← Parquet partitioned by year
|
|
19
|
-
└──
|
|
19
|
+
└── sub_annual/
|
|
20
20
|
└── ... ← Parquet partitioned by basin and year
|
|
21
21
|
|
|
22
22
|
Columns available:
|
|
23
23
|
- Static: mws_id, tehsil, district, state, area_in_ha, ba_name, geometry, etc.
|
|
24
24
|
- Annual: mws_id, year, dw_precipitation, dw_et, ci_cropping_intensity, etc.
|
|
25
|
-
-
|
|
25
|
+
- SubAnnual: mws_id, date, df_precipitation, df_et, season, etc.
|
|
26
26
|
"""
|
|
27
27
|
|
|
28
28
|
from __future__ import annotations
|
|
@@ -122,9 +122,9 @@ print("\nDataFrame head (static):")
|
|
|
122
122
|
print(df_static.select(["mws_id", "tehsil", "district", "state", "area_in_ha"]).head(3))
|
|
123
123
|
|
|
124
124
|
|
|
125
|
-
# ── 9. Temporal querying & Materialisation — .annual & .
|
|
125
|
+
# ── 9. Temporal querying & Materialisation — .annual & .sub_annual ──────────
|
|
126
126
|
# Use .between() to specify a time filter (year range, or season)
|
|
127
|
-
# Then use .annual or .
|
|
127
|
+
# Then use .annual or .sub_annual to materialise
|
|
128
128
|
|
|
129
129
|
# Annual data for a range of years
|
|
130
130
|
view_annual = view_barmer.between("2018-01-01", "2023-12-31")
|
|
@@ -133,13 +133,13 @@ print("\nAnnual result columns (first 10):", result_annual.columns[:10])
|
|
|
133
133
|
print("Resolution :", result_annual.resolution)
|
|
134
134
|
print("Has geometry :", result_annual.has_geometry) # False by default
|
|
135
135
|
|
|
136
|
-
#
|
|
137
|
-
result_fn = view_barmer.between(season=Season.KHARIF, year=2021).
|
|
138
|
-
print("\
|
|
136
|
+
# SubAnnual data for a specific season and year
|
|
137
|
+
result_fn = view_barmer.between(season=Season.KHARIF, year=2021).sub_annual
|
|
138
|
+
print("\nSubAnnual columns (first 10):", result_fn.columns[:10])
|
|
139
139
|
|
|
140
140
|
|
|
141
141
|
# ── 10. with_geometry() ──────────────────────────────────────────────────────
|
|
142
|
-
# .annual and .
|
|
142
|
+
# .annual and .sub_annual lack geometry to save memory. Use with_geometry()
|
|
143
143
|
# to join geometry from the static table.
|
|
144
144
|
|
|
145
145
|
result_annual_geo = result_annual.with_geometry()
|
|
@@ -174,7 +174,7 @@ print(desc_annual_by_entity.df().head(5))
|
|
|
174
174
|
|
|
175
175
|
|
|
176
176
|
# ── 13. Stats — correlate ────────────────────────────────────────────────────
|
|
177
|
-
# Pairwise correlations on
|
|
177
|
+
# Pairwise correlations on sub_annual data
|
|
178
178
|
corr = result_fn.stats.correlate(
|
|
179
179
|
columns=["df_precipitation", "df_et"],
|
|
180
180
|
method=CorrelateMethod.PEARSON,
|
|
@@ -197,7 +197,7 @@ print(test_result.df())
|
|
|
197
197
|
|
|
198
198
|
|
|
199
199
|
# ── 15. Stats — change ───────────────────────────────────────────────────────
|
|
200
|
-
# Computes change over time. Perfect for annual/
|
|
200
|
+
# Computes change over time. Perfect for annual/sub_annual results!
|
|
201
201
|
|
|
202
202
|
change_result = result_annual.stats.change(
|
|
203
203
|
column="dw_et",
|
|
@@ -302,12 +302,12 @@ print("GeoParquet written: output_mws.geoparquet")
|
|
|
302
302
|
final = (
|
|
303
303
|
aoi.mws.where(ba_name="Barmer")
|
|
304
304
|
.between(season=Season.KHARIF, year=2021)
|
|
305
|
-
.
|
|
305
|
+
.sub_annual.derive(
|
|
306
306
|
"et_ratio", (pl.col("df_et") / pl.col("df_precipitation")).round(3)
|
|
307
307
|
)
|
|
308
308
|
)
|
|
309
309
|
|
|
310
|
-
print("\nFull pipeline (
|
|
310
|
+
print("\nFull pipeline (sub_annual) result shape:", final.data.shape)
|
|
311
311
|
print("Columns:", final.columns[:10])
|
|
312
312
|
|
|
313
313
|
top_et = final.df().sort("et_ratio", descending=True).head(5)
|
|
@@ -208,7 +208,7 @@ print(test_vs_ref.metadata)
|
|
|
208
208
|
|
|
209
209
|
|
|
210
210
|
# ── 14. Stats — change ────────────────────────────────────────────────────────
|
|
211
|
-
# Note: change() is most meaningful on annual/
|
|
211
|
+
# Note: change() is most meaningful on annual/sub_annual data.
|
|
212
212
|
# For static entities it still works if the data carries a numeric column
|
|
213
213
|
# that can be compared between two named groups or derived states.
|
|
214
214
|
# Here we illustrate absolute change between two derived values stored
|
|
@@ -254,7 +254,7 @@ print("Polygon-filtered count :", len(result_poly.df()))
|
|
|
254
254
|
|
|
255
255
|
|
|
256
256
|
# ── 17. with_geometry() ───────────────────────────────────────────────────────
|
|
257
|
-
# .static already has geometry. On annual/
|
|
257
|
+
# .static already has geometry. On annual/sub_annual results (other entities)
|
|
258
258
|
# you'd call this to join geometry back in. Demonstrated here for completeness.
|
|
259
259
|
|
|
260
260
|
result_geo = result.with_geometry() # no-op since already has_geometry=True
|
|
@@ -4,11 +4,13 @@ dynamic = ["version"]
|
|
|
4
4
|
description = "Query, analyse, and visualise CoreStack's microwatershed and Earth science data through a clean, composable Python API."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
authors = [{ name = "Apoorva Kashyap" }]
|
|
7
|
-
requires-python = ">=3.
|
|
7
|
+
requires-python = ">=3.12"
|
|
8
8
|
license = "GPL-3.0-or-later"
|
|
9
9
|
license-files = ["LICEN[CS]E*"]
|
|
10
10
|
classifiers = [
|
|
11
11
|
"Programming Language :: Python :: 3",
|
|
12
|
+
"Programming Language :: Python :: 3.12",
|
|
13
|
+
"Programming Language :: Python :: 3.13",
|
|
12
14
|
"Operating System :: POSIX :: Linux",
|
|
13
15
|
"Development Status :: 2 - Pre-Alpha",
|
|
14
16
|
"Topic :: Scientific/Engineering",
|
|
@@ -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.dev168'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 1, 'dev168')
|
|
23
23
|
|
|
24
24
|
__commit_id__ = commit_id = None
|
|
@@ -272,7 +272,7 @@ class AoI:
|
|
|
272
272
|
|
|
273
273
|
``AoI`` holds no data itself. Entity attributes are lazy
|
|
274
274
|
:class:`~core_lens.base.view.View` objects; no Parquet I/O occurs until a
|
|
275
|
-
materialisation property (``.static``, ``.annual``, ``.
|
|
275
|
+
materialisation property (``.static``, ``.annual``, ``.sub_annual``) is
|
|
276
276
|
accessed on a View.
|
|
277
277
|
|
|
278
278
|
**Registration** must happen before any ``AoI`` is constructed::
|
|
@@ -685,7 +685,7 @@ def _bbox_to_polygon(
|
|
|
685
685
|
def _validate_entity_paths(entity: BaseEntity, name: str) -> None:
|
|
686
686
|
"""Validate an entity's path existence only — no Parquet I/O.
|
|
687
687
|
|
|
688
|
-
Checks that ``static_path``, ``annual_path``, and ``
|
|
688
|
+
Checks that ``static_path``, ``annual_path``, and ``sub_annual_path``
|
|
689
689
|
resolve to existing local files. Schema validation (key columns,
|
|
690
690
|
geometry column, geometry type) is intentionally deferred to first data
|
|
691
691
|
access via :attr:`BaseEntity.schema_profile`, which routes through the
|
|
@@ -727,7 +727,7 @@ def _validate_entity_paths(entity: BaseEntity, name: str) -> None:
|
|
|
727
727
|
)
|
|
728
728
|
|
|
729
729
|
# --- Temporal path existence checks (no schema read) --------------------
|
|
730
|
-
for attr, label in [("annual_path", "annual"), ("
|
|
730
|
+
for attr, label in [("annual_path", "annual"), ("sub_annual_path", "sub_annual")]:
|
|
731
731
|
path = getattr(entity, attr)
|
|
732
732
|
if path is not None:
|
|
733
733
|
try:
|
|
@@ -77,7 +77,7 @@ def _cached_detect(
|
|
|
77
77
|
key_cols: tuple[str, ...],
|
|
78
78
|
geometry_col: str,
|
|
79
79
|
annual_path: str | None,
|
|
80
|
-
|
|
80
|
+
sub_annual_path: str | None,
|
|
81
81
|
storage_options_key: tuple[tuple[str, Any], ...],
|
|
82
82
|
) -> "SchemaProfile":
|
|
83
83
|
"""Cached wrapper around :func:`~core_lens.schema.detection.detect`.
|
|
@@ -96,7 +96,7 @@ def _cached_detect(
|
|
|
96
96
|
key_cols=list(key_cols),
|
|
97
97
|
geometry_col=geometry_col,
|
|
98
98
|
annual_path=annual_path,
|
|
99
|
-
|
|
99
|
+
sub_annual_path=sub_annual_path,
|
|
100
100
|
storage_options=dict(storage_options_key) or None,
|
|
101
101
|
)
|
|
102
102
|
|
|
@@ -150,7 +150,7 @@ class BaseEntity(ABC):
|
|
|
150
150
|
Subclasses **may** override:
|
|
151
151
|
|
|
152
152
|
* :attr:`annual_path` — path to the annual time-series Parquet file
|
|
153
|
-
* :attr:`
|
|
153
|
+
* :attr:`sub_annual_path` — path to the sub_annual time-series Parquet file
|
|
154
154
|
* :attr:`schema_profile` — override auto-detection by returning an
|
|
155
155
|
explicit :class:`~core_lens.schema.profile.SchemaProfile`
|
|
156
156
|
|
|
@@ -176,7 +176,7 @@ class BaseEntity(ABC):
|
|
|
176
176
|
1. ``static_path`` exists and is readable.
|
|
177
177
|
2. ``key_cols`` are present and unique in the static file.
|
|
178
178
|
3. ``geometry_col`` is present and contains valid geometries.
|
|
179
|
-
4. ``annual_path`` and ``
|
|
179
|
+
4. ``annual_path`` and ``sub_annual_path`` exist if declared.
|
|
180
180
|
|
|
181
181
|
Any failure raises :class:`~core_lens.base.entity.EntityValidationError`.
|
|
182
182
|
"""
|
|
@@ -192,7 +192,7 @@ class BaseEntity(ABC):
|
|
|
192
192
|
data_root (str | pathlib.Path | None, optional): Root data directory or cloud
|
|
193
193
|
URI prefix (e.g. ``"s3://bucket/data"``). Relative
|
|
194
194
|
:attr:`static_path`, :attr:`annual_path`, and
|
|
195
|
-
:attr:`
|
|
195
|
+
:attr:`sub_annual_path` values are resolved against this
|
|
196
196
|
root. Defaults to ``None`` (resolved against cwd).
|
|
197
197
|
storage_options (dict[str, Any] | None, optional): Cloud credential /
|
|
198
198
|
configuration options forwarded to ``pyarrow.fs`` and
|
|
@@ -322,15 +322,15 @@ class BaseEntity(ABC):
|
|
|
322
322
|
return None
|
|
323
323
|
|
|
324
324
|
@property
|
|
325
|
-
def
|
|
326
|
-
"""Path to the
|
|
325
|
+
def sub_annual_path(self) -> str | None:
|
|
326
|
+
"""Path to the sub_annual time-series Parquet file, or ``None``.
|
|
327
327
|
|
|
328
|
-
Override in subclasses that carry
|
|
328
|
+
Override in subclasses that carry sub_annual temporal data. If
|
|
329
329
|
declared, the file must exist at :meth:`AoI.register` time or
|
|
330
330
|
:class:`EntityValidationError` is raised.
|
|
331
331
|
|
|
332
332
|
Returns:
|
|
333
|
-
str | None: A path string, or ``None`` if the entity has no
|
|
333
|
+
str | None: A path string, or ``None`` if the entity has no sub_annual data.
|
|
334
334
|
|
|
335
335
|
"""
|
|
336
336
|
return None
|
|
@@ -369,9 +369,9 @@ class BaseEntity(ABC):
|
|
|
369
369
|
if self.annual_path is not None
|
|
370
370
|
else None
|
|
371
371
|
),
|
|
372
|
-
|
|
373
|
-
self._resolve(self.
|
|
374
|
-
if self.
|
|
372
|
+
sub_annual_path=(
|
|
373
|
+
self._resolve(self.sub_annual_path)
|
|
374
|
+
if self.sub_annual_path is not None
|
|
375
375
|
else None
|
|
376
376
|
),
|
|
377
377
|
storage_options_key=_so_key(_so),
|
|
@@ -659,7 +659,7 @@ class BaseEntity(ABC):
|
|
|
659
659
|
r"""Return a lazy :class:`~core_lens.base.view.View` with a cross-entity join pending.
|
|
660
660
|
|
|
661
661
|
The join is recorded in the View's ``join_spec`` and computed only at
|
|
662
|
-
materialisation time (``.static``, ``.annual``, or ``.
|
|
662
|
+
materialisation time (``.static``, ``.annual``, or ``.sub_annual``).
|
|
663
663
|
Joined columns are namespaced as ``{entity_name}_{column_name}``.
|
|
664
664
|
|
|
665
665
|
Args:
|