Skip to content

fix: detect long gradual ramps truncated by false flat detection - #224

Merged
RussellSB merged 22 commits into
developfrom
fix/gradual-up-truncated
Jul 23, 2026
Merged

fix: detect long gradual ramps truncated by false flat detection#224
RussellSB merged 22 commits into
developfrom
fix/gradual-up-truncated

Conversation

@RussellSB

@RussellSB RussellSB commented Jul 12, 2026

Copy link
Copy Markdown
Owner

Summary

Fixes issue #195: long gradual ramps (e.g. 90-day ramp from 1000→1900) were detected as only ~27 days, with the rest absorbed into Flat.

Also fixes a pre-existing double-padding bug in abrupt_shaving.py where the second shave pass would re-pad already-padded segments.

Fix 1: Flat detection during gradual ramps (process_signals.py)

Root cause: min_nonzero_std (global minimum non-zero rolling std) was too aggressive as the flat threshold. During gradual ramps, the Savitzky-Golay smoothed signal has low local variation, so smoothed_std drops below this threshold and triggers false flat_flag, fragmenting the contiguous Up into short segments.

Fix: Add a derivative-near-zero check plus an extremely-smooth floor to the flat detection condition:

  1. smoothed_std <= min_nonzero_std (original condition), AND
  2. Either:
    • (a) derivative is near zero (|deriv| <= derivative_limit), OR
    • (b) signal is extremely smooth (smoothed_std < 83.5% of min_nonzero_std) — so smooth that even a positive derivative is likely noise

The 83.5% threshold was tuned to preserve the Feb 6-9 Flat in the core gradual test while still preventing false flat detection on long gradual ramps.

Target test: test_gradual_ramp_90day_detected (new regression test)

Fix 2: Double-padding in second shave pass (abrupt_shaving.py)

Root cause: The pad loop in shave_abrupt_trends runs on every call, including the second pass after reclassification. This re-pads segments that were already padded in the first pass, extending them by another abrupt_padding days.

Fix: Add a guard inside the pad loop: if second_pass and segment.get('padded', False): continue. This allows newly reclassified abrupt segments (gradual→abrupt) to be padded while preventing double-padding of already-padded segments.

Target test: test_abrupt_four_spikes (Down total_change preserved at -246.8)

Before / After

Before fix — 27-day Up, ramp truncated (from issue #195):

before

After fix — 83-day Up spanning the ramp (with abrupt_padding=28):

image

Changes

pytrendy/process_signals.py — flat detection fix:

  • Moved derivative_limit and smoothed_deriv computation before flat_flag detection
  • Added extremely_smooth check (smoothed_std < 83.5% of min_nonzero_std)
  • Modified flat detection: (derivative_near_zero | extremely_smooth) instead of just derivative_near_zero

pytrendy/post_processing/segments_refine/abrupt_shaving.py — double-padding fix:

  • Added if second_pass and segment.get('padded', False): continue guard inside pad loop

tests/tests_crashes_edgecases/data/gradual_ramp_edgecases.csv — new test fixture for issue #195

tests/tests_crashes_edgecases/test_uncommon_values.py — new regression test test_gradual_ramp_90day_detected

Threshold tuning analysis

The extremely_smooth threshold (min_nonzero_std * multiplier) was swept to find the sweet spot where both the core gradual test and the issue #195 test perform well:

Multiplier Core segments Feb 6-9 Flat Up start Up days Trade-off
0.50 7 Mar 27 94d Full ramp, loses both Flats
0.70 8 Mar 31 90d Good ramp, loses Mar Flat
0.835 8 Apr 7 83d Best balance
0.90 9 Apr 29 61d Keeps both Flats, shorter ramp

The 0.835 multiplier preserves the Feb 6-9 Flat while extending the ramp to 83 days (Apr 7 – Jun 29). The Mar 15-17 Flat is lost (Up extends to Mar 17), but this is a reasonable trade-off: the ramp coverage improves by 22 days (61→83d) at the cost of one short Flat segment.

Impact on Gradual Core Test Case

The core gradual test (test_gradual_trends) now produces 8 segments instead of 9:

Before

image

After

image

The Mar 15-17 Flat (3 days) is absorbed into the Up segment, extending it by 3 days. This is a positive trade-off: the algorithm is now more robust to minor dips during gradual trends, producing longer and more meaningful segments.

It's not ideal that this core test case is edited as a consequence, however several workarounds were explored and this was deemed to be the best solution with most balanced trade-offs between tests current in test suite. Could be further tuned/expanded on as new scenarios enter the test suite, with the aim of a positive plateau in pure generalisability.

Note: This change affects GIFs, plot baselines, and notebook tutorials that display the gradual column. See #233 for tracking.

Test expectation changes

Test File Change Reason
test_gradual_trends test_core_cases.py 9→8 segments, Up extended Mar 15-17 Flat removed
test_set_summary_structure test_io_results.py Flat count 3→2 Same segment change
test_filter_segments_* test_io_results.py Segment count 9→8 Same segment change
test_integration_full_workflow test_io_results.py Segment count 9→8 Same segment change
test_gradual_ramp_90day_detected test_uncommon_values.py Up start Apr 29→Apr 7 Better ramp coverage
test_noisy_edgecase_2 test_noise_edgecases.py Noise end Jun 29→Jun 30 1-day boundary shift
test_noisy_edgecase_4 test_noise_edgecases.py Flat/Down boundaries 1-day boundary shift
test_noisy_edgecase_5 test_noise_edgecases.py Down replaced with Up+Noise Segment classification changed
test_noisy_edgecase_11 test_noise_edgecases.py Noise end Jun 29→Jun 17 Boundary shift
test_gradual_spike_single_later_series test_noise_spikes_gradual.py Up end Mar 14→Mar 17 Extended Up boundary

Verification

  • Full test suite: 101 passed, 0 failed
  • Core suite: 74 passed, 0 failed
  • New regression test passes (abrupt_padding=28/)

@coderabbitai

coderabbitai Bot commented Jul 12, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Summary by CodeRabbit

  • Bug Fixes
    • Improved flat-period classification to use derivative-aware smoothness, reducing false splits in gradual trends.
    • Prevented an abrupt padding pass from re-padding segments already padded in an earlier pass.
    • Refined trend boundary detection across gradual, noisy, and edge-case scenarios.
  • Tests
    • Added a regression test to confirm long gradual ramps are detected as a single uptrend.
    • Updated multiple expected segment counts, classifications, and date ranges to match the corrected trend boundaries.

Walkthrough

Derivative-aware flat detection and guarded second-pass padding revise trend boundaries. Core, edge-case, gradual-ramp, and result-format tests now assert updated segment counts and dates.

Changes

Trend boundary refinement

Layer / File(s) Summary
Derivative-aware flat detection
pytrendy/process_signals.py
Flat regions now use smoothed derivative and rolling deviation thresholds alongside noise status, with explicit handling for empty non-zero deviation values.
Second-pass padding guard
pytrendy/post_processing/segments_refine/abrupt_shaving.py
Already padded segments are skipped during second-pass shaving.
Boundary regression coverage
tests/test_core_cases.py, tests/test_io_results.py, tests/tests_crashes_edgecases/*, tests/tests_noise/test_noise_spikes_gradual.py
Expected segment boundaries, counts, summaries, and a 90-day gradual-ramp regression case are updated.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Possibly related issues

  • RussellSB/pytrendy#233 — Covers the same flat-detection threshold and gradual/noise boundary changes.
  • RussellSB/pytrendy#195 — Covers the gradual-ramp truncation scenario addressed by the flat-detection refinement and regression test.

Possibly related PRs

  • RussellSB/pytrendy#170 — Changes noise handling in process_signals.py, which gates the flat classification updated here.

Suggested reviewers: copilot

Poem

Derivatives trace the rising line,
Flat flags yield to slopes more fine.
Padded segments rest in place,
Tests redraw each boundary’s face.
Up and Down now flow anew.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title uses Conventional Commits and accurately describes the main fix for false flat detection on gradual ramps.
Description check ✅ Passed The description is directly related to the changeset and explains the gradual-ramp and double-padding fixes.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
✨ Simplify code
  • Create PR with simplified code
  • Commit simplified code in branch fix/gradual-up-truncated

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@RussellSB
RussellSB force-pushed the fix/gradual-up-truncated branch from 8fe86ea to 78bea84 Compare July 12, 2026 08:12
@RussellSB
RussellSB changed the base branch from main to develop July 12, 2026 08:13
@github-actions

github-actions Bot commented Jul 12, 2026

Copy link
Copy Markdown
Contributor

📚 Docs preview removed

The docs preview for this PR has been cleaned up.

github-actions Bot added a commit that referenced this pull request Jul 12, 2026
The flat detection in process_signals.py used min_nonzero_std (the global
minimum non-zero rolling std) as the threshold. During gradual ramps the
smoothed signal has low local variation, so smoothed_std drops below this
threshold and triggers false flat_flag, fragmenting the contiguous Up.

Fix: add a derivative-near-zero check plus an extremely-smooth floor to
the flat detection condition. A region is only flagged flat when both
smoothed_std is low AND either the derivative is near zero or the signal
is so smooth (< 50% of min) that even a positive derivative is noise.

This allows genuine gradual ramps (positive derivative, moderate std) to
pass through without being misclassified as flat.

Reference: issue #195
@RussellSB
RussellSB force-pushed the fix/gradual-up-truncated branch from 78bea84 to c34d079 Compare July 12, 2026 08:20
github-actions Bot added a commit that referenced this pull request Jul 12, 2026
Guard the pad loop in shave_abrupt_trends with not second_pass so that
segments already padded in the first pass are not re-padded during the
reclassification second pass. Also clean up redundant padded check in
the shave loop.
github-actions Bot added a commit that referenced this pull request Jul 12, 2026
github-actions Bot added a commit that referenced this pull request Jul 12, 2026
Replace the not second_pass guard on the pad loop condition with a
padded flag check inside the loop. This allows newly reclassified
abrupt segments (gradual->abrupt) to be padded in the second pass
while still preventing double-padding of segments already padded
in the first pass.
github-actions Bot added a commit that referenced this pull request Jul 12, 2026
…_std

The 0.5x threshold was too aggressive - it caused the core gradual
test to lose 2 short Flat segments. Increasing to 0.9x preserves
those Flat segments while still preventing false flat detection on
long gradual ramps (issue #195).
Update segment expectations in 4 tests to match the 0.9x threshold:
- test_gradual_ramp_90day_detected: Up start shifted to Apr 29
- test_temp_scenario: Down boundary adjusted
- test_noisy_edgecase_1: Noise boundary adjusted
- test_noisy_edgecase_4: Up/Flat/Down boundaries adjusted
github-actions Bot added a commit that referenced this pull request Jul 13, 2026
@codecov

codecov Bot commented Jul 13, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@RussellSB
RussellSB marked this pull request as ready for review July 13, 2026 06:53
…t at the cost of flat sensitivity on core gradual test case
Adjust segment expectations across 6 test files to match the new
0.835x min_nonzero_std threshold. Core gradual case shifts from 9 to
8 segments (Mar 15-17 Flat absorbed into Up), and related tests
update boundary shifts accordingly.
github-actions Bot added a commit that referenced this pull request Jul 14, 2026
@RussellSB

This comment was marked as off-topic.

@coderabbitai

This comment was marked as off-topic.

@RussellSB

Copy link
Copy Markdown
Owner Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 14, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@pytrendy/process_signals.py`:
- Around line 185-196: Update the flat-detection logic around rolling_std and
min_nonzero_std so an empty positive-standard-deviation selection defaults
min_nonzero_std to 0.0 instead of NaN. Preserve the existing flat_flag
conditions and ensure both comparisons remain valid for perfectly flat or
shorter-than-WINDOW_FLAT inputs.

In `@tests/tests_crashes_edgecases/test_uncommon_values.py`:
- Around line 148-150: Align the regression test’s implementation and docstring:
in the sustained 90-day uptrend test, either remove the claim about default
parameters or add a separate invocation using default parameters with matching
assertions, while retaining coverage for abrupt_padding=28 with
avoid_noise=False as stated.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 5790af62-5098-435d-936b-4fa7ef4707d5

📥 Commits

Reviewing files that changed from the base of the PR and between 88cbebb and d67ec80.

⛔ Files ignored due to path filters (2)
  • tests/tests_crashes_edgecases/data/gradual_ramp_edgecases.csv is excluded by !**/*.csv
  • tests/tests_plotting/core/test_plot_gradual_trends.png is excluded by !**/*.png
📒 Files selected for processing (9)
  • pytrendy/post_processing/segments_refine/abrupt_shaving.py
  • pytrendy/process_signals.py
  • tests/test_core_cases.py
  • tests/test_io_results.py
  • tests/tests_crashes_edgecases/data/TESTDATA.md
  • tests/tests_crashes_edgecases/test_noise_crashes.py
  • tests/tests_crashes_edgecases/test_noise_edgecases.py
  • tests/tests_crashes_edgecases/test_uncommon_values.py
  • tests/tests_noise/test_noise_spikes_gradual.py
📜 Review details
🧰 Additional context used
📓 Path-based instructions (2)
tests/**/*

📄 CodeRabbit inference engine (AGENTS.md)

tests/**/*: Before pushing, run the core test suite with pytest tests/ -m core and then run the remaining tests with pytest tests/ -m "not core" --cov-append; CI also uses a 15-second per-test timeout.
Plot regression tests use pytest-mpl, and baselines must be regenerated only against matplotlib==3.10.8 to avoid false failures.

Files:

  • tests/tests_crashes_edgecases/data/TESTDATA.md
  • tests/tests_crashes_edgecases/test_noise_crashes.py
  • tests/tests_noise/test_noise_spikes_gradual.py
  • tests/test_core_cases.py
  • tests/tests_crashes_edgecases/test_uncommon_values.py
  • tests/tests_crashes_edgecases/test_noise_edgecases.py
  • tests/test_io_results.py
pytrendy/**/*

📄 CodeRabbit inference engine (AGENTS.md)

The package implements a single public entrypoint, pytrendy.detect_trends(df, date_col, value_col, ...), returning PyTrendyResults; changes under pytrendy/ should preserve that public API surface.

Files:

  • pytrendy/post_processing/segments_refine/abrupt_shaving.py
  • pytrendy/process_signals.py
🪛 Ruff (0.15.21)
tests/tests_crashes_edgecases/test_uncommon_values.py

[warning] 163-163: Unnecessary dict() call (rewrite as a literal)

Rewrite as a literal

(C408)

🔇 Additional comments (8)
pytrendy/process_signals.py (1)

177-184: LGTM!

Also applies to: 197-205

pytrendy/post_processing/segments_refine/abrupt_shaving.py (1)

137-140: LGTM!

tests/test_core_cases.py (1)

33-33: LGTM!

tests/tests_noise/test_noise_spikes_gradual.py (1)

108-111: LGTM!

tests/tests_crashes_edgecases/test_noise_edgecases.py (1)

34-34: LGTM!

Also applies to: 51-51, 84-86, 102-103, 206-206

tests/test_io_results.py (1)

246-249: LGTM!

Also applies to: 359-359, 390-390, 394-395, 444-448, 480-480, 497-497, 529-529, 546-546, 563-563, 650-651, 661-661, 673-677

tests/tests_crashes_edgecases/data/TESTDATA.md (1)

10-10: LGTM!

tests/tests_crashes_edgecases/test_noise_crashes.py (1)

47-47: LGTM!

Comment thread pytrendy/process_signals.py
Comment thread tests/tests_crashes_edgecases/test_uncommon_values.py Outdated
@RussellSB

Copy link
Copy Markdown
Owner Author

/oc address coderabitai's recent unresolved comments and resolve

coderabbitai[bot]
coderabbitai Bot previously approved these changes Jul 15, 2026
@opencode-agent

This comment was marked as off-topic.

@RussellSB

This comment was marked as off-topic.

@RussellSB

This comment was marked as off-topic.

@opencode-agent

This comment was marked as off-topic.

@RussellSB

This comment was marked as off-topic.

- Update print_summary() output in README.md and docs/index.md to reflect
  8 segments (removed Flat Mar 15-17, extended Up to Mar 17)
- Update filter_segments table with new Up segment dates and metrics
- Re-execute gradual.ipynb and noise.ipynb with new threshold
- Fix noise.ipynb markdown: 9 → 10 noise segments
- Run normalize_notebooks.sh for JupyterLite compatibility
Adds missing fallback logic from plot_pytrendy (lines 80-87, 104-106)
that was absent from adjust_segment_boundaries in utils.py.
github-actions Bot added a commit that referenced this pull request Jul 20, 2026
@RussellSB
RussellSB merged commit 6c06660 into develop Jul 23, 2026
9 checks passed
@RussellSB
RussellSB deleted the fix/gradual-up-truncated branch July 23, 2026 05:13
github-actions Bot added a commit that referenced this pull request Jul 23, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant