core-lens 0.1.dev189__tar.gz → 0.1.dev190__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.dev189 → core_lens-0.1.dev190}/PKG-INFO +1 -1
  2. {core_lens-0.1.dev189 → core_lens-0.1.dev190}/src/core_lens/_version.py +2 -2
  3. {core_lens-0.1.dev189 → core_lens-0.1.dev190}/src/core_lens/base/namespaces/stats.py +68 -44
  4. {core_lens-0.1.dev189 → core_lens-0.1.dev190}/.github/ISSUE_TEMPLATE/blank-proposal.yaml +0 -0
  5. {core_lens-0.1.dev189 → core_lens-0.1.dev190}/.github/ISSUE_TEMPLATE/bug-report.yaml +0 -0
  6. {core_lens-0.1.dev189 → core_lens-0.1.dev190}/.github/ISSUE_TEMPLATE/feature-request.yaml +0 -0
  7. {core_lens-0.1.dev189 → core_lens-0.1.dev190}/.github/pull_request_template.md +0 -0
  8. {core_lens-0.1.dev189 → core_lens-0.1.dev190}/.github/workflows/ci.yml +0 -0
  9. {core_lens-0.1.dev189 → core_lens-0.1.dev190}/.github/workflows/gh-pages.yml +0 -0
  10. {core_lens-0.1.dev189 → core_lens-0.1.dev190}/.github/workflows/pre-release.yml +0 -0
  11. {core_lens-0.1.dev189 → core_lens-0.1.dev190}/.github/workflows/release.yml +0 -0
  12. {core_lens-0.1.dev189 → core_lens-0.1.dev190}/.gitignore +0 -0
  13. {core_lens-0.1.dev189 → core_lens-0.1.dev190}/.gitmessage +0 -0
  14. {core_lens-0.1.dev189 → core_lens-0.1.dev190}/.pre-commit-config.yaml +0 -0
  15. {core_lens-0.1.dev189 → core_lens-0.1.dev190}/.python-version +0 -0
  16. {core_lens-0.1.dev189 → core_lens-0.1.dev190}/CONTRIBUTING.md +0 -0
  17. {core_lens-0.1.dev189 → core_lens-0.1.dev190}/LICENSE +0 -0
  18. {core_lens-0.1.dev189 → core_lens-0.1.dev190}/README.md +0 -0
  19. {core_lens-0.1.dev189 → core_lens-0.1.dev190}/SKILLS.md +0 -0
  20. {core_lens-0.1.dev189 → core_lens-0.1.dev190}/benchmarks/README.md +0 -0
  21. {core_lens-0.1.dev189 → core_lens-0.1.dev190}/benchmarks/bench_aoi.py +0 -0
  22. {core_lens-0.1.dev189 → core_lens-0.1.dev190}/benchmarks/bench_entity.py +0 -0
  23. {core_lens-0.1.dev189 → core_lens-0.1.dev190}/benchmarks/bench_export.py +0 -0
  24. {core_lens-0.1.dev189 → core_lens-0.1.dev190}/benchmarks/bench_polars_utils.py +0 -0
  25. {core_lens-0.1.dev189 → core_lens-0.1.dev190}/benchmarks/bench_result.py +0 -0
  26. {core_lens-0.1.dev189 → core_lens-0.1.dev190}/benchmarks/bench_schema.py +0 -0
  27. {core_lens-0.1.dev189 → core_lens-0.1.dev190}/benchmarks/bench_season.py +0 -0
  28. {core_lens-0.1.dev189 → core_lens-0.1.dev190}/benchmarks/bench_spatial.py +0 -0
  29. {core_lens-0.1.dev189 → core_lens-0.1.dev190}/benchmarks/bench_view.py +0 -0
  30. {core_lens-0.1.dev189 → core_lens-0.1.dev190}/benchmarks/run_all.sh +0 -0
  31. {core_lens-0.1.dev189 → core_lens-0.1.dev190}/docs/Makefile +0 -0
  32. {core_lens-0.1.dev189 → core_lens-0.1.dev190}/docs/make.bat +0 -0
  33. {core_lens-0.1.dev189 → core_lens-0.1.dev190}/docs/source/concepts.md +0 -0
  34. {core_lens-0.1.dev189 → core_lens-0.1.dev190}/docs/source/conf.py +0 -0
  35. {core_lens-0.1.dev189 → core_lens-0.1.dev190}/docs/source/export.md +0 -0
  36. {core_lens-0.1.dev189 → core_lens-0.1.dev190}/docs/source/index.rst +0 -0
  37. {core_lens-0.1.dev189 → core_lens-0.1.dev190}/docs/source/intro.md +0 -0
  38. {core_lens-0.1.dev189 → core_lens-0.1.dev190}/docs/source/logging.md +0 -0
  39. {core_lens-0.1.dev189 → core_lens-0.1.dev190}/docs/source/plots.md +0 -0
  40. {core_lens-0.1.dev189 → core_lens-0.1.dev190}/docs/source/plugins.md +0 -0
  41. {core_lens-0.1.dev189 → core_lens-0.1.dev190}/docs/source/queries.md +0 -0
  42. {core_lens-0.1.dev189 → core_lens-0.1.dev190}/docs/source/quickstart.md +0 -0
  43. {core_lens-0.1.dev189 → core_lens-0.1.dev190}/docs/source/stats.md +0 -0
  44. {core_lens-0.1.dev189 → core_lens-0.1.dev190}/examples/demo_mws.py +0 -0
  45. {core_lens-0.1.dev189 → core_lens-0.1.dev190}/examples/demo_tehsil.py +0 -0
  46. {core_lens-0.1.dev189 → core_lens-0.1.dev190}/hooks/mypy.sh +0 -0
  47. {core_lens-0.1.dev189 → core_lens-0.1.dev190}/hooks/no-parquet-outside-fixtures.sh +0 -0
  48. {core_lens-0.1.dev189 → core_lens-0.1.dev190}/hooks/pytest.sh +0 -0
  49. {core_lens-0.1.dev189 → core_lens-0.1.dev190}/pyproject.toml +0 -0
  50. {core_lens-0.1.dev189 → core_lens-0.1.dev190}/src/core_lens/__init__.py +0 -0
  51. {core_lens-0.1.dev189 → core_lens-0.1.dev190}/src/core_lens/__main__.py +0 -0
  52. {core_lens-0.1.dev189 → core_lens-0.1.dev190}/src/core_lens/aoi.py +0 -0
  53. {core_lens-0.1.dev189 → core_lens-0.1.dev190}/src/core_lens/base/__init__.py +0 -0
  54. {core_lens-0.1.dev189 → core_lens-0.1.dev190}/src/core_lens/base/entity.py +0 -0
  55. {core_lens-0.1.dev189 → core_lens-0.1.dev190}/src/core_lens/base/namespaces/__init__.py +0 -0
  56. {core_lens-0.1.dev189 → core_lens-0.1.dev190}/src/core_lens/base/namespaces/plot.py +0 -0
  57. {core_lens-0.1.dev189 → core_lens-0.1.dev190}/src/core_lens/base/result.py +0 -0
  58. {core_lens-0.1.dev189 → core_lens-0.1.dev190}/src/core_lens/base/view.py +0 -0
  59. {core_lens-0.1.dev189 → core_lens-0.1.dev190}/src/core_lens/entities/__init__.py +0 -0
  60. {core_lens-0.1.dev189 → core_lens-0.1.dev190}/src/core_lens/entities/farms.py +0 -0
  61. {core_lens-0.1.dev189 → core_lens-0.1.dev190}/src/core_lens/entities/mws.py +0 -0
  62. {core_lens-0.1.dev189 → core_lens-0.1.dev190}/src/core_lens/entities/tehsil.py +0 -0
  63. {core_lens-0.1.dev189 → core_lens-0.1.dev190}/src/core_lens/entities/waterbody.py +0 -0
  64. {core_lens-0.1.dev189 → core_lens-0.1.dev190}/src/core_lens/export/__init__.py +0 -0
  65. {core_lens-0.1.dev189 → core_lens-0.1.dev190}/src/core_lens/export/formats.py +0 -0
  66. {core_lens-0.1.dev189 → core_lens-0.1.dev190}/src/core_lens/py.typed +0 -0
  67. {core_lens-0.1.dev189 → core_lens-0.1.dev190}/src/core_lens/schema/__init__.py +0 -0
  68. {core_lens-0.1.dev189 → core_lens-0.1.dev190}/src/core_lens/schema/detection.py +0 -0
  69. {core_lens-0.1.dev189 → core_lens-0.1.dev190}/src/core_lens/schema/profile.py +0 -0
  70. {core_lens-0.1.dev189 → core_lens-0.1.dev190}/src/core_lens/utils/__init__.py +0 -0
  71. {core_lens-0.1.dev189 → core_lens-0.1.dev190}/src/core_lens/utils/paths.py +0 -0
  72. {core_lens-0.1.dev189 → core_lens-0.1.dev190}/src/core_lens/utils/polars_utils.py +0 -0
  73. {core_lens-0.1.dev189 → core_lens-0.1.dev190}/src/core_lens/utils/season.py +0 -0
  74. {core_lens-0.1.dev189 → core_lens-0.1.dev190}/src/core_lens/utils/spatial.py +0 -0
  75. {core_lens-0.1.dev189 → core_lens-0.1.dev190}/tests/fixtures/generate_fixtures.py +0 -0
  76. {core_lens-0.1.dev189 → core_lens-0.1.dev190}/tests/unit/conftest.py +0 -0
  77. {core_lens-0.1.dev189 → core_lens-0.1.dev190}/tests/unit/test_aoi.py +0 -0
  78. {core_lens-0.1.dev189 → core_lens-0.1.dev190}/tests/unit/test_entities.py +0 -0
  79. {core_lens-0.1.dev189 → core_lens-0.1.dev190}/tests/unit/test_entity.py +0 -0
  80. {core_lens-0.1.dev189 → core_lens-0.1.dev190}/tests/unit/test_export.py +0 -0
  81. {core_lens-0.1.dev189 → core_lens-0.1.dev190}/tests/unit/test_main.py +0 -0
  82. {core_lens-0.1.dev189 → core_lens-0.1.dev190}/tests/unit/test_plot.py +0 -0
  83. {core_lens-0.1.dev189 → core_lens-0.1.dev190}/tests/unit/test_polars_utils.py +0 -0
  84. {core_lens-0.1.dev189 → core_lens-0.1.dev190}/tests/unit/test_profile.py +0 -0
  85. {core_lens-0.1.dev189 → core_lens-0.1.dev190}/tests/unit/test_result.py +0 -0
  86. {core_lens-0.1.dev189 → core_lens-0.1.dev190}/tests/unit/test_schema_detection.py +0 -0
  87. {core_lens-0.1.dev189 → core_lens-0.1.dev190}/tests/unit/test_schema_profile.py +0 -0
  88. {core_lens-0.1.dev189 → core_lens-0.1.dev190}/tests/unit/test_season.py +0 -0
  89. {core_lens-0.1.dev189 → core_lens-0.1.dev190}/tests/unit/test_season_config.py +0 -0
  90. {core_lens-0.1.dev189 → core_lens-0.1.dev190}/tests/unit/test_spatial.py +0 -0
  91. {core_lens-0.1.dev189 → core_lens-0.1.dev190}/tests/unit/test_stats.py +0 -0
  92. {core_lens-0.1.dev189 → core_lens-0.1.dev190}/tests/unit/test_view.py +0 -0
  93. {core_lens-0.1.dev189 → core_lens-0.1.dev190}/usage.md +0 -0
  94. {core_lens-0.1.dev189 → core_lens-0.1.dev190}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: core-lens
3
- Version: 0.1.dev189
3
+ Version: 0.1.dev190
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.dev189'
22
- __version_tuple__ = version_tuple = (0, 1, 'dev189')
21
+ __version__ = version = '0.1.dev190'
22
+ __version_tuple__ = version_tuple = (0, 1, 'dev190')
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -741,6 +741,8 @@ class StatsNamespace:
741
741
  }
742
742
  return self._r._replace(data=data, has_geometry=False, metadata=metadata)
743
743
 
744
+ import warnings
745
+
744
746
  def anomaly(
745
747
  self,
746
748
  column: str,
@@ -760,16 +762,17 @@ class StatsNamespace:
760
762
  used as the reference/"normal" period. Required for timeseries; optional
761
763
  for cross-sectional.
762
764
  target_years (tuple[int, int] | None, optional): ``(from_year, to_year)`` inclusive,
763
- the years actually scored for anomalies. Timeseries: defaults to "everything
764
- after ``baseline[1]``" if omitted (old behaviour preserved). Cross-sectional:
765
- defaults to all rows if omitted. Must not overlap ``baseline``.
765
+ years actually scored. Timeseries: defaults to "everything after
766
+ ``baseline[1]``" if omitted. Cross-sectional: defaults to all rows.
767
+ Must not overlap ``baseline``.
766
768
  threshold (float, optional): Sigma / score threshold for anomaly flag (default 2.0).
767
769
 
768
770
  Returns:
769
771
  Result: data has ``key_col | anomaly_score | is_anomaly`` (cross-sectional) or
770
- ``key_col | year | anomaly_score | is_anomaly`` (timeseries, baseline excluded,
771
- restricted to ``target_years`` if given). ``metadata`` includes
772
- ``n_entities_dropped`` (entities skipped for insufficient baseline obs).
772
+ ``key_col | <time_col> | anomaly_score | is_anomaly`` (timeseries, baseline
773
+ excluded, restricted to ``target_years`` if given). ``<time_col>`` is the
774
+ finer-grained date/period column when the data is sub-annual, falling back
775
+ to plain ``year`` for annual data. ``metadata`` includes ``n_entities_dropped``.
773
776
 
774
777
  Raises:
775
778
  ValueError: If ``mode``/``method`` invalid, observation count too low, or
@@ -784,8 +787,6 @@ class StatsNamespace:
784
787
  f"target_years={target_years} overlap. They must be disjoint."
785
788
  )
786
789
 
787
- # Single explicit collect boundary — everything below is eager by necessity
788
- # (numpy/scipy/statsmodels have no lazy path).
789
790
  df = self._r.df()
790
791
  if isinstance(df, pl.LazyFrame):
791
792
  df = df.collect()
@@ -819,7 +820,6 @@ class StatsNamespace:
819
820
  f"but only got {len(ref_vals)}."
820
821
  )
821
822
 
822
- # target_years scopes which rows get scored/returned; baseline stays reference-only.
823
823
  eval_df = df
824
824
  if target_years is not None and has_year:
825
825
  eval_df = df.filter(
@@ -889,7 +889,6 @@ class StatsNamespace:
889
889
  "threshold": threshold,
890
890
  }
891
891
 
892
- # Build straight from numpy — no .tolist() round-trip.
893
892
  data = eval_df.select(pl.col(key)).with_columns(
894
893
  pl.Series("anomaly_score", scores, dtype=pl.Float64),
895
894
  pl.Series("is_anomaly", flags, dtype=pl.Boolean),
@@ -913,6 +912,13 @@ class StatsNamespace:
913
912
  "Ensure data is at annual or sub_annual resolution."
914
913
  )
915
914
 
915
+ # Finer-grained time column for sub-annual data, if present.
916
+ # `year_col` still drives baseline/target-year windowing regardless.
917
+ date_col = next(
918
+ (c for c in ("date", "period", "period_start") if c in df.columns), None
919
+ )
920
+ output_time_col = date_col or year_col
921
+
916
922
  min_obs = _MIN_OBS[method.value]
917
923
  n_dropped = 0
918
924
 
@@ -963,11 +969,11 @@ class StatsNamespace:
963
969
  .with_columns(
964
970
  (pl.col("anomaly_score").abs() > threshold).alias("is_anomaly")
965
971
  )
966
- .select(key, year_col, "anomaly_score", "is_anomaly")
972
+ .select(key, output_time_col, "anomaly_score", "is_anomaly")
967
973
  .drop_nulls("anomaly_score")
968
974
  )
969
975
 
970
- # ---- CUSUM / STL: inherently sequential/per-entity, keep loop but partition_by ----
976
+ # ---- CUSUM / STL: inherently sequential/per-entity, partition_by instead of filter-loop ----
971
977
  else:
972
978
  rows: list[dict[str, Any]] = []
973
979
  for sub in df.sort(year_col).partition_by(key, maintain_order=True):
@@ -992,10 +998,15 @@ class StatsNamespace:
992
998
  )
993
999
  else:
994
1000
  eval_sub = sub.filter(pl.col(year_col) > baseline[1])
995
- eval_vals = eval_sub[column].drop_nulls().to_numpy().astype(float)
996
- eval_years = eval_sub.filter(pl.col(column).is_not_null())[
1001
+ eval_sub = eval_sub.filter(pl.col(column).is_not_null())
1002
+
1003
+ eval_vals = eval_sub[column].to_numpy().astype(float)
1004
+ eval_years_lookup = eval_sub[
997
1005
  year_col
998
- ].to_list()
1006
+ ].to_list() # for baseline/dict matching
1007
+ eval_times = eval_sub[
1008
+ output_time_col
1009
+ ].to_list() # for display in output
999
1010
 
1000
1011
  ts_scores: list[float] = []
1001
1012
  ts_flags: list[bool] = []
@@ -1013,30 +1024,42 @@ class StatsNamespace:
1013
1024
  ts_flags.append(s > h)
1014
1025
 
1015
1026
  else: # STL
1027
+ full = sub.filter(pl.col(column).is_not_null())
1028
+ full_vals = full[column].to_numpy().astype(float)
1029
+ full_years = full[year_col].to_list()
1030
+
1031
+ period = (
1032
+ 26 if len(full_vals) >= 52 else max(2, len(full_vals) // 2)
1033
+ )
1034
+ min_required = 2 * period
1035
+ min_reliable = (
1036
+ 3 * period
1037
+ ) # want ~3 cycles minimum for a trustworthy seasonal fit, not just 2
1038
+
1039
+ if len(full_vals) < min_required:
1040
+ warnings.warn(
1041
+ f"StatsNamespace.anomaly: entity {eid!r} has {len(full_vals)} points, "
1042
+ f"period={period} needs >= {min_reliable} for a reliable STL fit. Skipping."
1043
+ )
1044
+ n_dropped += 1
1045
+ continue
1046
+
1047
+ if len(full_vals) < min_required:
1048
+ warnings.warn(
1049
+ f"StatsNamespace.anomaly: entity {eid!r} has {len(full_vals)} points, "
1050
+ f"needs >= {min_required} for period={period}. Skipping."
1051
+ )
1052
+ n_dropped += 1
1053
+ continue
1054
+
1016
1055
  try:
1017
1056
  from statsmodels.tsa.seasonal import STL # type: ignore[import-untyped]
1018
1057
 
1019
- full = sub.filter(pl.col(column).is_not_null())
1020
- full_vals = full[column].to_numpy().astype(float)
1021
- full_years = full[year_col].to_list()
1022
- if len(full_vals) < min_obs:
1023
- n_dropped += 1
1024
- continue
1025
-
1026
- # Seasonal period: sub-annual cadence assumed 24 steps/year
1027
- # (e.g. fortnightly). Falls back to half the series length
1028
- # for shorter records. Adjust if your data's cadence differs.
1029
- period = (
1030
- 24
1031
- if len(full_vals) >= 24
1032
- else max(2, len(full_vals) // 2)
1033
- )
1034
1058
  res = STL(full_vals, period=period).fit()
1035
1059
  resid = res.resid
1036
1060
 
1037
- # Match residuals to years EXPLICITLY (not positional slicing)
1038
- # positional slicing silently misaligns if there are gaps between
1039
- # baseline and eval rows.
1061
+ # Match residuals to years EXPLICITLY, not positionally
1062
+ # avoids misalignment if there are gaps in the series.
1040
1063
  resid_by_year = dict(zip(full_years, resid))
1041
1064
  base_resid = [
1042
1065
  resid_by_year[y]
@@ -1049,7 +1072,7 @@ class StatsNamespace:
1049
1072
  float(resid_by_year[y] / std)
1050
1073
  if y in resid_by_year
1051
1074
  else float("nan")
1052
- for y in eval_years
1075
+ for y in eval_years_lookup
1053
1076
  ]
1054
1077
  ts_flags = [
1055
1078
  abs(s) > threshold if s == s else False
@@ -1060,31 +1083,32 @@ class StatsNamespace:
1060
1083
  warnings.warn(
1061
1084
  f"StatsNamespace.anomaly: STL fit failed for entity {eid!r}: {e}"
1062
1085
  )
1063
- ts_scores = [float("nan")] * len(eval_years)
1064
- ts_flags = [False] * len(eval_years)
1086
+ ts_scores = [float("nan")] * len(eval_years_lookup)
1087
+ ts_flags = [False] * len(eval_years_lookup)
1065
1088
 
1066
- for yr, sc, fl in zip(eval_years, ts_scores, ts_flags):
1089
+ for t, sc, fl in zip(eval_times, ts_scores, ts_flags):
1067
1090
  rows.append(
1068
1091
  {
1069
1092
  key: eid,
1070
- year_col: yr,
1093
+ output_time_col: t,
1071
1094
  "anomaly_score": float(sc),
1072
1095
  "is_anomaly": bool(fl),
1073
1096
  }
1074
1097
  )
1075
1098
 
1076
- data = (
1077
- pl.DataFrame(rows)
1078
- if rows
1079
- else pl.DataFrame(
1099
+ if rows:
1100
+ data = pl.DataFrame(rows)
1101
+ else:
1102
+ data = pl.DataFrame(
1080
1103
  {
1081
1104
  key: pl.Series([], dtype=pl.String),
1082
- year_col: pl.Series([], dtype=pl.Int32),
1105
+ output_time_col: pl.Series(
1106
+ [], dtype=pl.Date if date_col else pl.Int32
1107
+ ),
1083
1108
  "anomaly_score": pl.Series([], dtype=pl.Float64),
1084
1109
  "is_anomaly": pl.Series([], dtype=pl.Boolean),
1085
1110
  }
1086
1111
  )
1087
- )
1088
1112
 
1089
1113
  global_base_vals = (
1090
1114
  df.filter(pl.col(year_col).is_between(baseline[0], baseline[1]))[column]
File without changes
File without changes
File without changes
File without changes
File without changes