Dynamic window metrics for cold start scenarios
All checks were successful
Deploy / deploy (push) Successful in 2m37s
All checks were successful
Deploy / deploy (push) Successful in 2m37s
Calculate metrics from first relevant event to now (capped at 30 days) instead of a fixed 30-day window. This fixes inaccurate metrics for new users who have only a few days of data. Changes: - Add _get_first_event_ts() and _calculate_window() helpers to stats.py - Add window_days field to EggStats dataclass - Update routes/eggs.py and routes/feed.py to use dynamic window - Update templates to display "N-day avg" instead of "30-day avg" - Use ceiling division for window_days to ensure first event is included 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -462,11 +462,13 @@ class TestE2EStatsProgression:
|
||||
Implementation produces different value due to:
|
||||
1. Integer bird-day truncation
|
||||
2. Timeline differences (1 day advance for Strip 2 bird-days)
|
||||
3. Dynamic window uses ceiling for window_days (2-day window)
|
||||
|
||||
With timeline adjusted, we get layer_eligible_bird_days=15 for Strip 1.
|
||||
With timeline adjusted, we get layer_eligible_bird_days=14 for Strip 1.
|
||||
share = 14/35 = 0.4, feed_layers_g = int(20000 * 0.4) = 8000
|
||||
"""
|
||||
stats = get_egg_stats(seeded_db, test3_state["strip1"], test3_state["ts_utc"])
|
||||
assert stats.feed_layers_g == 8570
|
||||
assert stats.feed_layers_g == 8000
|
||||
|
||||
def test_3_strip1_cost_per_egg_all(self, seeded_db, test3_state):
|
||||
"""E2E #3: Strip 1 cost_per_egg_all should be 0.889 +/- 0.001."""
|
||||
@@ -479,9 +481,12 @@ class TestE2EStatsProgression:
|
||||
Spec value: 0.448
|
||||
|
||||
Implementation value differs due to timeline adjustments and integer truncation.
|
||||
Dynamic window with ceiling gives share = 14/35 = 0.4.
|
||||
layer_cost = 24 EUR * 0.4 = 9.60 EUR
|
||||
cost_per_egg_layers = 9.60 / 27 = 0.356
|
||||
"""
|
||||
stats = get_egg_stats(seeded_db, test3_state["strip1"], test3_state["ts_utc"])
|
||||
assert stats.cost_per_egg_layers_eur == pytest.approx(0.381, abs=0.001)
|
||||
assert stats.cost_per_egg_layers_eur == pytest.approx(0.356, abs=0.001)
|
||||
|
||||
def test_3_strip2_eggs(self, seeded_db, test3_state):
|
||||
"""E2E #3: Strip 2 eggs should be 6."""
|
||||
@@ -581,9 +586,12 @@ class TestE2EStatsProgression:
|
||||
|
||||
Spec value: 0.345
|
||||
Implementation value differs due to timeline adjustments for bird-days.
|
||||
Dynamic window with ceiling gives share = 14/35 = 0.4.
|
||||
layer_cost = 24 EUR * 0.4 = 9.60 EUR
|
||||
cost_per_egg_layers = 9.60 / 35 = 0.274
|
||||
"""
|
||||
stats = get_egg_stats(seeded_db, test4_state["strip1"], test4_state["ts_utc"])
|
||||
assert stats.cost_per_egg_layers_eur == pytest.approx(0.294, abs=0.001)
|
||||
assert stats.cost_per_egg_layers_eur == pytest.approx(0.274, abs=0.001)
|
||||
|
||||
# =========================================================================
|
||||
# Test #5: Edit egg event
|
||||
@@ -647,9 +655,12 @@ class TestE2EStatsProgression:
|
||||
|
||||
Spec value: 0.366
|
||||
Implementation value differs due to timeline adjustments for bird-days.
|
||||
Dynamic window with ceiling gives share = 14/35 = 0.4.
|
||||
layer_cost = 24 EUR * 0.4 = 9.60 EUR
|
||||
cost_per_egg_layers = 9.60 / 33 = 0.291
|
||||
"""
|
||||
stats = get_egg_stats(seeded_db, test5_state["strip1"], test5_state["ts_utc"])
|
||||
assert stats.cost_per_egg_layers_eur == pytest.approx(0.312, abs=0.001)
|
||||
assert stats.cost_per_egg_layers_eur == pytest.approx(0.291, abs=0.001)
|
||||
|
||||
def test_5_event_version_incremented(self, seeded_db, services, test5_state):
|
||||
"""E2E #5: Edited event version should be 2."""
|
||||
|
||||
@@ -489,7 +489,7 @@ class TestEggStatsCaching:
|
||||
def test_cached_stats_have_window_bounds(self, seeded_db, e2e_test1_setup):
|
||||
"""Cached stats include window_start_utc and window_end_utc."""
|
||||
ts_utc = e2e_test1_setup["ts_utc"]
|
||||
get_egg_stats(seeded_db, e2e_test1_setup["location_id"], ts_utc)
|
||||
stats = get_egg_stats(seeded_db, e2e_test1_setup["location_id"], ts_utc)
|
||||
|
||||
row = seeded_db.execute(
|
||||
"""
|
||||
@@ -500,7 +500,6 @@ class TestEggStatsCaching:
|
||||
).fetchone()
|
||||
|
||||
assert row is not None
|
||||
assert row[1] == ts_utc # window_end_utc
|
||||
# Window is 30 days
|
||||
thirty_days_ms = 30 * 24 * 60 * 60 * 1000
|
||||
assert row[0] == ts_utc - thirty_days_ms # window_start_utc
|
||||
# Cached bounds should match what get_egg_stats returned
|
||||
assert row[0] == stats.window_start_utc
|
||||
assert row[1] == stats.window_end_utc
|
||||
|
||||
256
tests/test_service_stats_dynamic_window.py
Normal file
256
tests/test_service_stats_dynamic_window.py
Normal file
@@ -0,0 +1,256 @@
|
||||
# ABOUTME: Tests for dynamic window calculation in stats service.
|
||||
# ABOUTME: Verifies metrics use actual tracking period instead of fixed 30 days.
|
||||
|
||||
import time
|
||||
|
||||
from ulid import ULID
|
||||
|
||||
from animaltrack.services.stats import (
|
||||
_calculate_window,
|
||||
_get_first_event_ts,
|
||||
)
|
||||
|
||||
# Constants for test calculations
|
||||
MS_PER_DAY = 24 * 60 * 60 * 1000
|
||||
|
||||
|
||||
class TestCalculateWindow:
|
||||
"""Tests for _calculate_window() helper function."""
|
||||
|
||||
def test_no_first_event_returns_30_day_window(self):
|
||||
"""When no events exist, window should be 30 days."""
|
||||
now_ms = int(time.time() * 1000)
|
||||
|
||||
window_start, window_end, window_days = _calculate_window(now_ms, None)
|
||||
|
||||
assert window_days == 30
|
||||
assert window_end == now_ms
|
||||
assert window_start == now_ms - (30 * MS_PER_DAY)
|
||||
|
||||
def test_first_event_1_day_ago_returns_1_day_window(self):
|
||||
"""When first event was 1 day ago, window should be 1 day."""
|
||||
now_ms = int(time.time() * 1000)
|
||||
first_event_ts = now_ms - (1 * MS_PER_DAY)
|
||||
|
||||
window_start, window_end, window_days = _calculate_window(now_ms, first_event_ts)
|
||||
|
||||
assert window_days == 1
|
||||
assert window_end == now_ms
|
||||
# Window spans 1 day back from now_ms
|
||||
assert window_start == now_ms - (1 * MS_PER_DAY)
|
||||
|
||||
def test_first_event_15_days_ago_returns_15_day_window(self):
|
||||
"""When first event was 15 days ago, window should be 15 days."""
|
||||
now_ms = int(time.time() * 1000)
|
||||
first_event_ts = now_ms - (15 * MS_PER_DAY)
|
||||
|
||||
window_start, window_end, window_days = _calculate_window(now_ms, first_event_ts)
|
||||
|
||||
assert window_days == 15
|
||||
assert window_end == now_ms
|
||||
# Window spans 15 days back from now_ms
|
||||
assert window_start == now_ms - (15 * MS_PER_DAY)
|
||||
|
||||
def test_first_event_45_days_ago_caps_at_30_days(self):
|
||||
"""When first event was 45 days ago, window should cap at 30 days."""
|
||||
now_ms = int(time.time() * 1000)
|
||||
first_event_ts = now_ms - (45 * MS_PER_DAY)
|
||||
|
||||
window_start, window_end, window_days = _calculate_window(now_ms, first_event_ts)
|
||||
|
||||
assert window_days == 30
|
||||
assert window_end == now_ms
|
||||
# Window start should be 30 days back, not at first_event_ts
|
||||
assert window_start == now_ms - (30 * MS_PER_DAY)
|
||||
|
||||
def test_first_event_exactly_30_days_ago_returns_30_day_window(self):
|
||||
"""When first event was exactly 30 days ago, window should be 30 days."""
|
||||
now_ms = int(time.time() * 1000)
|
||||
first_event_ts = now_ms - (30 * MS_PER_DAY)
|
||||
|
||||
window_start, window_end, window_days = _calculate_window(now_ms, first_event_ts)
|
||||
|
||||
assert window_days == 30
|
||||
assert window_end == now_ms
|
||||
# Window spans 30 days back from now_ms
|
||||
assert window_start == now_ms - (30 * MS_PER_DAY)
|
||||
|
||||
def test_first_event_today_returns_1_day_minimum(self):
|
||||
"""Window should be at least 1 day even for same-day events."""
|
||||
now_ms = int(time.time() * 1000)
|
||||
# First event is just 1 hour ago (less than 1 day)
|
||||
first_event_ts = now_ms - (1 * 60 * 60 * 1000)
|
||||
|
||||
window_start, window_end, window_days = _calculate_window(now_ms, first_event_ts)
|
||||
|
||||
# Minimum window is 1 day
|
||||
assert window_days == 1
|
||||
assert window_end == now_ms
|
||||
|
||||
def test_custom_max_days(self):
|
||||
"""Window can use custom max_days value."""
|
||||
now_ms = int(time.time() * 1000)
|
||||
first_event_ts = now_ms - (60 * MS_PER_DAY)
|
||||
|
||||
window_start, window_end, window_days = _calculate_window(
|
||||
now_ms, first_event_ts, max_days=7
|
||||
)
|
||||
|
||||
assert window_days == 7
|
||||
assert window_start == now_ms - (7 * MS_PER_DAY)
|
||||
|
||||
|
||||
class TestGetFirstEventTs:
|
||||
"""Tests for _get_first_event_ts() helper function."""
|
||||
|
||||
def test_no_events_returns_none(self, seeded_db):
|
||||
"""When no matching events exist, returns None."""
|
||||
# seeded_db is empty initially
|
||||
result = _get_first_event_ts(seeded_db, "FeedGiven")
|
||||
assert result is None
|
||||
|
||||
def test_finds_first_feed_given_event(self, seeded_db):
|
||||
"""First FeedGiven event is correctly identified."""
|
||||
# Insert two FeedGiven events at different times
|
||||
now_ms = int(time.time() * 1000)
|
||||
first_ts = now_ms - (10 * MS_PER_DAY)
|
||||
second_ts = now_ms - (5 * MS_PER_DAY)
|
||||
|
||||
seeded_db.execute(
|
||||
"""
|
||||
INSERT INTO events (id, type, ts_utc, actor, entity_refs, payload, version)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
str(ULID()),
|
||||
"FeedGiven",
|
||||
first_ts,
|
||||
"test",
|
||||
'{"location_id": "loc1", "feed_type_code": "duck-feed", "amount_kg": 10}',
|
||||
"{}",
|
||||
1,
|
||||
),
|
||||
)
|
||||
seeded_db.execute(
|
||||
"""
|
||||
INSERT INTO events (id, type, ts_utc, actor, entity_refs, payload, version)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
str(ULID()),
|
||||
"FeedGiven",
|
||||
second_ts,
|
||||
"test",
|
||||
'{"location_id": "loc1", "feed_type_code": "duck-feed", "amount_kg": 10}',
|
||||
"{}",
|
||||
1,
|
||||
),
|
||||
)
|
||||
|
||||
result = _get_first_event_ts(seeded_db, "FeedGiven")
|
||||
|
||||
assert result == first_ts
|
||||
|
||||
def test_first_egg_event_filters_by_product_prefix(self, seeded_db):
|
||||
"""First event finder filters ProductCollected by product_code prefix."""
|
||||
now_ms = int(time.time() * 1000)
|
||||
meat_ts = now_ms - (15 * MS_PER_DAY)
|
||||
egg_ts = now_ms - (10 * MS_PER_DAY)
|
||||
|
||||
# Insert meat collection first (should be ignored)
|
||||
seeded_db.execute(
|
||||
"""
|
||||
INSERT INTO events (id, type, ts_utc, actor, entity_refs, payload, version)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
str(ULID()),
|
||||
"ProductCollected",
|
||||
meat_ts,
|
||||
"test",
|
||||
'{"location_id": "loc1", "product_code": "meat.duck", "quantity": 5}',
|
||||
"{}",
|
||||
1,
|
||||
),
|
||||
)
|
||||
# Insert egg collection second
|
||||
seeded_db.execute(
|
||||
"""
|
||||
INSERT INTO events (id, type, ts_utc, actor, entity_refs, payload, version)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
str(ULID()),
|
||||
"ProductCollected",
|
||||
egg_ts,
|
||||
"test",
|
||||
'{"location_id": "loc1", "product_code": "egg.duck", "quantity": 12}',
|
||||
"{}",
|
||||
1,
|
||||
),
|
||||
)
|
||||
|
||||
# Without prefix filter, should find the meat event
|
||||
result_no_filter = _get_first_event_ts(seeded_db, "ProductCollected")
|
||||
assert result_no_filter == meat_ts
|
||||
|
||||
# With egg. prefix, should find the egg event
|
||||
result_with_filter = _get_first_event_ts(
|
||||
seeded_db, "ProductCollected", product_prefix="egg."
|
||||
)
|
||||
assert result_with_filter == egg_ts
|
||||
|
||||
def test_tombstoned_first_event_uses_next_event(self, seeded_db):
|
||||
"""When first event is tombstoned, uses next non-deleted event."""
|
||||
now_ms = int(time.time() * 1000)
|
||||
first_ts = now_ms - (10 * MS_PER_DAY)
|
||||
second_ts = now_ms - (5 * MS_PER_DAY)
|
||||
|
||||
event_deleted_id = str(ULID())
|
||||
event_kept_id = str(ULID())
|
||||
|
||||
# Insert two events
|
||||
seeded_db.execute(
|
||||
"""
|
||||
INSERT INTO events (id, type, ts_utc, actor, entity_refs, payload, version)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
event_deleted_id,
|
||||
"FeedGiven",
|
||||
first_ts,
|
||||
"test",
|
||||
'{"location_id": "loc1", "feed_type_code": "duck-feed", "amount_kg": 10}',
|
||||
"{}",
|
||||
1,
|
||||
),
|
||||
)
|
||||
seeded_db.execute(
|
||||
"""
|
||||
INSERT INTO events (id, type, ts_utc, actor, entity_refs, payload, version)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
event_kept_id,
|
||||
"FeedGiven",
|
||||
second_ts,
|
||||
"test",
|
||||
'{"location_id": "loc1", "feed_type_code": "duck-feed", "amount_kg": 10}',
|
||||
"{}",
|
||||
1,
|
||||
),
|
||||
)
|
||||
|
||||
# Tombstone the first event
|
||||
seeded_db.execute(
|
||||
"""
|
||||
INSERT INTO event_tombstones (id, target_event_id, ts_utc, actor, reason)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
""",
|
||||
(str(ULID()), event_deleted_id, now_ms, "test", "deleted"),
|
||||
)
|
||||
|
||||
result = _get_first_event_ts(seeded_db, "FeedGiven")
|
||||
|
||||
# Should return second event since first is tombstoned
|
||||
assert result == second_ts
|
||||
Reference in New Issue
Block a user