From 18d85fd2c8f4ebb3e734b8b241b058392c1a4463 Mon Sep 17 00:00:00 2001 From: technocore Date: Sat, 1 Aug 2026 01:16:57 +0000 Subject: [PATCH] fix(api): expose trend_analysis at top level (closes #81) trend_analysis was listed in __all__ but never bound, breaking 'from eo_processor import trend_analysis' and star-imports. Import it from _core, add a thin wrapper and TrendSegment alias, add the type stub, tests, and update README examples to use the public API. --- README.md | 3 +-- docs/source/README.md | 3 +-- python/eo_processor/__init__.py | 24 +++++++++++++++++++++ python/eo_processor/__init__.pyi | 11 ++++++++++ tests/test_trends.py | 36 +++++++++++++++++++++++++++++++- 5 files changed, 72 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 9fde365..99d9e41 100644 --- a/README.md +++ b/README.md @@ -318,8 +318,7 @@ Example: ```python import numpy as np -from eo_processor._core import trend_analysis -from eo_processor import linear_regression +from eo_processor import trend_analysis, linear_regression # Simple linear regression y_reg = np.array([1.0, 2.1, 2.9, 4.2]) diff --git a/docs/source/README.md b/docs/source/README.md index 9fde365..99d9e41 100644 --- a/docs/source/README.md +++ b/docs/source/README.md @@ -318,8 +318,7 @@ Example: ```python import numpy as np -from eo_processor._core import trend_analysis -from eo_processor import linear_regression +from eo_processor import trend_analysis, linear_regression # Simple linear regression y_reg = np.array([1.0, 2.1, 2.9, 4.2]) diff --git a/python/eo_processor/__init__.py b/python/eo_processor/__init__.py index e790681..d80b79d 100644 --- a/python/eo_processor/__init__.py +++ b/python/eo_processor/__init__.py @@ -51,6 +51,8 @@ replace_nans as _replace_nans, savi as _savi, linear_regression as _linear_regression, + trend_analysis as _trend_analysis, + TrendSegment as _TrendSegment, temporal_sum as _temporal_sum, temporal_composite as _temporal_composite, zonal_stats as _zonal_stats, @@ -355,7 +357,29 @@ def linear_regression(y): return _linear_regression(y) +def trend_analysis(y, threshold): + """ + Detect breaks in a time series by recursively fitting linear models. + + Parameters + ---------- + y : sequence of float + 1D time series of finite values. + threshold : float + Maximum absolute residual tolerated before a segment is split. + Must be non-negative. + + Returns + ------- + list of TrendSegment + Each segment exposes `start_index`, `end_index`, `slope`, and + `intercept` attributes. + """ + return _trend_analysis(y, threshold) + + ZoneStats = _ZoneStats +TrendSegment = _TrendSegment def zonal_stats(values: np.ndarray, zones: np.ndarray) -> dict[int, ZoneStats]: diff --git a/python/eo_processor/__init__.pyi b/python/eo_processor/__init__.pyi index ecdf3c6..3387cc7 100644 --- a/python/eo_processor/__init__.pyi +++ b/python/eo_processor/__init__.pyi @@ -84,6 +84,17 @@ def composite( def temporal_mean(arr: NumericArray, skip_na: bool = ...) -> NDArray[np.float64]: ... def temporal_std(arr: NumericArray, skip_na: bool = ...) -> NDArray[np.float64]: ... +# Trend analysis & regression +class TrendSegment: + start_index: int + end_index: int + slope: float + intercept: float + +def trend_analysis( + y: Sequence[float], threshold: float +) -> list[TrendSegment]: ... + # Advanced temporal processes def moving_average_temporal( arr: NumericArray, diff --git a/tests/test_trends.py b/tests/test_trends.py index d4eb21d..625c8ab 100644 --- a/tests/test_trends.py +++ b/tests/test_trends.py @@ -1,7 +1,7 @@ import numpy as np import pytest -from eo_processor import linear_regression +from eo_processor import linear_regression, trend_analysis def test_linear_regression_basic(): @@ -20,3 +20,37 @@ def test_linear_regression_rejects_too_short(): def test_linear_regression_rejects_non_finite(): with pytest.raises(ValueError, match="finite"): linear_regression(np.array([1.0, np.nan, 2.0], dtype=np.float64)) + + +def test_trend_analysis_no_break_single_segment(): + y = np.linspace(0.0, 10.0, 50) + segments = trend_analysis(y.tolist(), threshold=1e9) + assert len(segments) == 1 + assert segments[0].start_index == 0 + assert segments[0].end_index == 49 + assert segments[0].slope == pytest.approx(10.0 / 49.0, rel=1e-6) + + +def test_trend_analysis_detects_break(): + y = np.concatenate([np.linspace(0, 10, 50), np.linspace(10, 0, 50)]) + segments = trend_analysis(y.tolist(), threshold=1.0) + assert len(segments) >= 2 + for segment in segments: + assert segment.end_index >= segment.start_index + + +def test_trend_analysis_rejects_negative_threshold(): + y = np.linspace(0.0, 10.0, 20) + with pytest.raises(ValueError, match="non-negative"): + trend_analysis(y.tolist(), threshold=-1.0) + + +def test_trend_analysis_rejects_non_finite(): + with pytest.raises(ValueError, match="finite"): + trend_analysis([1.0, np.nan, 2.0], threshold=0.5) + + +def test_star_import_exposes_trend_analysis(): + namespace = {} + exec("from eo_processor import *", namespace) + assert "trend_analysis" in namespace