Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,7 @@ jobs:
- name: Tests
run: |
pytest

- name: Quick import smoke test
run: |
python -c "import epydem; print('epydem imported')"
84 changes: 0 additions & 84 deletions .github/workflows/test.yml

This file was deleted.

29 changes: 29 additions & 0 deletions PR_ACTIONS_FIX.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
GitHub Actions fix: CI was failing on Python 3.9 because epydem now requires Python >= 3.10 (pyproject.toml).

Changes
- Consolidate CI by removing redundant `.github/workflows/test.yml` (keep `ci.yml` as the single CI).
- Ensure CI only tests supported Python versions (>=3.10).
- Install via `pip install -e '.[dev]'` (single source of truth).
- Add a quick import smoke test to CI.

Why this implementation
- Running CI on unsupported Python versions creates noisy failures and slows iteration.
- Keeping workflows consistent reduces maintenance and confusion.

Multi-role debate (differences, not consensus)

Role A — pragmatic developer
- 👍 Likes: CI goes green and matches supported versions; simpler workflow.
- ⚠️ Concern: removes older-Python signal; but we explicitly don’t support <3.10.

Role B — architecture
- 👍 Likes: single tooling stack (ruff) and consistent install path.
- ⚠️ Concern: having both `ci.yml` and `test.yml` is redundant; consider consolidating later.

Role C — developer user (DX)
- 👍 Likes: less CI noise; clearer support policy.
- ⚠️ Concern: if users want older Python, they’ll need a documented support decision.

Points of divergence to revisit later
1) Consolidate workflows (keep one CI file).
2) Add `python-version: 3.13` when ready.
11 changes: 11 additions & 0 deletions PR_PHASE1.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
Implements CDC/MMWR epidemiological weeks as the default epiweek logic.

Key points
- Add `mmwr_week(date)` returning `(mmwr_year, mmwr_week)`.
- Add `mmwr_week1_start(year)` (Sunday on/before Jan 4).
- Add `parse_ymd()` to accept `YYYY-MM-DD` strings, `date`, or `datetime`.
- Keep `calculate()` as backward-compatible wrapper returning week number only.
- Replace prior week-0-based tests with focused MMWR boundary tests.

Notes
- This removes the old ISO-like "first Thursday" implementation and eliminates week 0 for MMWR logic.
33 changes: 33 additions & 0 deletions PR_PHASE1_DEBATE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
Implements CDC/MMWR epidemiological weeks as the default epiweek logic.

Key points
- Add `mmwr_week(date)` returning `(mmwr_year, mmwr_week)`.
- Add `mmwr_week1_start(year)` (Sunday on/before Jan 4).
- Add `parse_ymd()` to accept `YYYY-MM-DD` strings, `date`, or `datetime`.
- Keep `calculate()` as backward-compatible wrapper returning week number only.
- Replace prior week-0-based tests with focused MMWR boundary tests.

Why this implementation
- CDC/MMWR weeks start on Sunday and Week 1 is defined as the week containing Jan 4. That implies Week 1 starts on the Sunday on/before Jan 4.
- Computing `week1_start(year)` from Jan 4 is a simple, O(1) pure function that avoids tricky edge cases.
- Returning `(mmwr_year, mmwr_week)` is essential because early January dates can belong to the previous MMWR year.
- `parse_ymd()` centralizes date parsing/validation so later features (incidence curves, summaries) can reuse it.

Multi-role debate (differences, not consensus)

Role A — pragmatic developer
- 👍 Likes: pure function, O(1), easy to test; `parse_ymd()` makes downstream code simpler.
- ⚠️ Concern: backward-compat behavior changes for early-January dates (previously could be week 0; now becomes week 52/53). Correct for MMWR but can surprise users.

Role B — architecture
- 👍 Likes: explicit MMWR semantics; returning `(year, week)` avoids ambiguity.
- ⚠️ Concern: module/API organization. Might prefer a `time/` namespace (e.g., `epydem.time.mmwr_week`) if we later add ISO/WHO week systems.

Role C — developer user (DX)
- 👍 Likes: no week 0; tuple output makes grouping across years correct.
- ⚠️ Concern: naming. Users may want `epiweek()` as the default MMWR function and clear docs with boundary examples (e.g., 2022-01-01 -> (2021, 52)).

Points of divergence to revisit later
1) Naming: `mmwr_week()` vs providing a default `epiweek()` alias.
2) Compatibility: keep `calculate()` returning week-only vs switching `calculate()` to tuple (breaking change).
3) Structure: keep in `epiweek.py` vs split into `epydem/time.py` or `epydem/time/` module.
29 changes: 29 additions & 0 deletions PR_PHASE2_1.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
Phase 2.1: incidence DX improvements (docs + rolling/cumulative)

Key points
- Add optional `cumulative=True` for wide output.
- Add optional `rolling=<int>` for wide output with `rolling_kind` (sum/mean).
- Expand README examples for incidence.

Why this implementation
- These options are common in epi workflows (cumulative incidence, smoothed curves) and improve usability without changing the core incidence semantics.
- Implemented at the wide-output layer so behavior is consistent across strata columns.

Multi-role debate (differences, not consensus)

Role A — pragmatic developer
- 👍 Likes: small API surface change, easy to test, minimal code.
- ⚠️ Concern: rolling/cumulative after pivot means we assume the index ordering fully represents time (true for date and (epi_year, epi_week) sorted).

Role B — architecture
- 👍 Likes: keeps incidence core as counts; transformations are optional flags.
- ⚠️ Concern: feature creep: might prefer a separate `transform_incidence()` pipeline later.

Role C — developer user (DX)
- 👍 Likes: fewer steps in notebooks; one-liners for rolling/cumulative.
- ⚠️ Concern: wants more control (e.g., min_periods, centered rolling, cumulative per-calendar-year vs epi-year).

Points of divergence to revisit later
1) Whether these transforms belong inside `incidence()` long-term.
2) Add `min_periods`, `center`, and `cumulative_by` options.
3) Apply transforms to `output="long"` as well.
33 changes: 33 additions & 0 deletions PR_PHASE2_DEBATE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
Adds basic incidence computation from a line list (daily and weekly CDC/MMWR).

Key points
- Add `epydem.incidence(df, date_col=..., freq=...)`.
- Supports:
- `freq="D"` -> daily counts by calendar date
- `freq="W-MMWR"` -> weekly counts by CDC/MMWR epiweek (adds epi_year + epi_week)
- Optional stratification via `by=[...]`.

Why this implementation
- Incidence is the most common first step for line-list analysis; we want a minimal, composable primitive.
- Using `epi_year` + `epi_week` avoids ambiguity at year boundaries.
- Returning a tidy DataFrame (group columns + `cases`) makes plotting and further transforms straightforward in pandas.
- We intentionally do not implement missing-week filling, rolling averages, or cumulative sums yet to keep the first API small.

Multi-role debate (differences, not consensus)

Role A — pragmatic developer
- 👍 Likes: minimal API, returns tidy DF, works with `by` strata.
- ⚠️ Concern: performance if we iterate row-by-row for epiweek on large datasets; may need vectorization/caching later.

Role B — architecture
- 👍 Likes: keeps time semantics centralized in `epydem.time` and reuses `epiweek()`.
- ⚠️ Concern: frequency naming: `W-MMWR` is clear but may expand to ISO/WHO; may want a more general `freq="W"` + `system=`.

Role C — developer user (DX)
- 👍 Likes: one-liner to get epicurve-like table.
- ⚠️ Concern: expects convenience features soon (fill missing weeks, ordering, start/end bounds, cumulative) and clear examples in docs.

Points of divergence to revisit later
1) Performance: pure-Python loop vs vectorized epiweek mapping.
2) Output schema: keep tidy long DF vs allow pivot/wide convenience.
3) Feature growth: add `fill_missing`, `cumulative`, `rolling`, and `freq` generalization.
57 changes: 51 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,58 @@ pip install -e '.[dev]'
```python
import epydem

# Current API (will change):
week_number = epydem.calculate("2024-01-01")
print(week_number)
year, week = epydem.epiweek("2024-01-01")
print(year, week)

week_only = epydem.epiweek_number("2024-01-01")
print(week_only)

# Incidence (line list -> counts)
# df = pandas.DataFrame({"onset_date": [...], "sex": [...]})
# weekly = epydem.incidence(df, date_col="onset_date", freq="W-MMWR", by=["sex"], fill_missing=True)
# weekly_rolling2 = epydem.transform_incidence(weekly, rolling=2)
# weekly_cum = epydem.transform_incidence(weekly, cumulative=True)
```

### `summary()` — Descriptive statistics

```python
import pandas as pd
import epydem

df = pd.DataFrame({
"region": ["N", "N", "S", "S", "S"],
"onset": ["2024-01-01", "2024-02-15", "2024-03-10", "NOT_A_DATE", None],
"age": [25, 40, 33, 55, 19],
"dx": ["flu", "flu", "covid", "covid", "flu"],
})

# Default: returns only row count
epydem.summary(df)

# With column specs (long format, the default)
result = epydem.summary(
df,
by="region",
date_cols=["onset"],
numeric_cols=["age"],
categorical_cols=["dx"],
)
print(result)
# region column metric value
# 0 N _n n 2
# 1 N onset missing_n 0
# 2 N onset missing_pct 0.0
# 3 N onset min 2024-01-01 ...
# ...

# Wide format
wide = epydem.summary(df, numeric_cols=["age"], output="wide")
print(wide)
```

## Roadmap (high level)

- CDC/MMWR epiweek (week starts Sunday; week 1 contains Jan 4)
- Incidence / epicurves from line lists (pandas)
- Summary statistics (descriptive epi)
- CDC/MMWR epiweek (week starts Sunday; week 1 contains Jan 4) ✅ (in progress)
- Incidence / epicurves from line lists (pandas) ✅ (in progress)
- Summary statistics (descriptive epi)
18 changes: 18 additions & 0 deletions epydem/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1,19 @@
"""epydem public API."""

from .epiweek import calculate
from .incidence import incidence
from .summary import summary
from .time import epiweek, epiweek_number, mmwr_week, mmwr_week1_start, parse_ymd
from .transform import transform_incidence

__all__ = [
"calculate",
"epiweek",
"epiweek_number",
"mmwr_week",
"mmwr_week1_start",
"parse_ymd",
"incidence",
"transform_incidence",
"summary",
]
65 changes: 12 additions & 53 deletions epydem/epiweek.py
Original file line number Diff line number Diff line change
@@ -1,60 +1,19 @@
import re
from datetime import datetime, timedelta
"""Backward-compat module.

Historically this project exposed `epydem.calculate()` from `epydem.epiweek`.
We are moving time-related utilities to `epydem.time`.

def calculate(date_str):
"""
Calculate the epidemiological week number for a given date string.
Prefer using:
- `epydem.epiweek()` -> (year, week)
- `epydem.epiweek_number()` -> week
"""

This function determines the week number of the year for a given date
based on epidemiological weeks, where the first epidemiological week
starts on a Sunday and includes the first Thursday of the year.
from __future__ import annotations

Args:
date_str (str): The date in 'YYYY-MM-DD' format.
from .time import epiweek_number

Returns:
int: The epidemiological week number (1-53). Returns 0 if the date is
before the start of the epidemiological year.

Raises:
ValueError: If the provided date string does not match the 'YYYY-MM-DD' format.
"""
if _verify_date_str(date_str):
date = datetime.strptime(date_str, "%Y-%m-%d")
year = date.year
def calculate(date_str: str) -> int:
"""Compatibility alias for week number only."""

# Find the first Thursday of the year
jan_1 = datetime(year, 1, 1)
days_to_thursday = (3 - jan_1.weekday()) % 7
first_thursday = jan_1 + timedelta(days=days_to_thursday)

# The epidemiological week 1 starts on the Sunday before the first Thursday
epi_week_1_start = first_thursday - timedelta(days=3)

# If the date is before the start of the epidemiological year, it's week 0
if date < epi_week_1_start:
return 0
else:
return ((date - epi_week_1_start).days // 7) + 1


def _verify_date_str(date_str):
"""
Verify that the date string is in the 'YYYY-MM-DD' format.

Args:
date_str (str): The date string to verify.

Returns:
bool: True if the date string is in the correct format.

Raises:
ValueError: If the date string does not match the 'YYYY-MM-DD' format.
"""
date_pattern = r"^\d{4}-\d{2}-\d{2}$" # Pattern for YYYY-MM-DD

if not re.match(date_pattern, date_str):
raise ValueError("Invalid date format. Please use YYYY-MM-DD")

return True
return epiweek_number(date_str)
Loading