Math, Statistics, and Data › Data Visualization › Day 131
Hands-on lab — Day 131: Time Series Visualization
- ← Back to the Day 131 lesson
- Open the hands-on files on GitHub — clone or download them from the public labs repository
- Local path in your clone:
labs/sections/math-statistics-and-data/day-131-time-series-visualization/
Commands
Setup
cd labs/sections/math-statistics-and-data/day-131-time-series-visualization
python3 -m venv .venv
.venv/bin/pip install -r requirements/requirements.txt
.venv/bin/python3 -c "import pandas; print(pandas.__version__)" Run
.venv/bin/pytest examples
.venv/bin/pytest starter Test
bash tests/run_tests.sh File tree
examples/conftest.py examples/data.py examples/test_timeseries.py expected-output/examples-run.txt expected-output/FIELDS.md expected-output/starter-run.txt expected-output/test-run.txt metadata.yml README.md requirements/README.md requirements/requirements.txt security.md starter/00_brief.md starter/conftest.py starter/data.py starter/test_timeseries.py tests/run_tests.sh troubleshooting.md
Lab README
Day 131 lab — Time Told Honestly
Lesson
- Lesson title: Time Series Visualization
- Day number: 131 of 365
- Lesson article: https://ai-roadmap-365.github.io/day-131-time-series-visualization
- Lab files: everything you need is in this directory — follow “How to run” below.
- Browse the course locally: from the repository root, this lab also appears in the course website at
/labs/day-131-time-series-visualizationwhen the site is running.
Purpose
Nine numbered exercises, seventeen tests, each proving one real pandas
3.0.5 / matplotlib 3.11.1 behaviour about how time series get plotted
wrong — headless, via the Agg backend, by reading real x-positions,
computed values, and artist state. The through-line is that time is
not just another axis — it has structure, and the commonest charting
mistakes are the ones that throw that structure away. Exercise 1 makes
that concrete immediately: the exact same data, plotted with the exact
same code except for one axis, either hides a real fourteen-day sensor
outage completely (against a row index) or reveals it as a single wide
jump between otherwise identical steps (against a parsed datetime).
Every later exercise adds one more way that structure gets discarded:
resampling's choice of aggregation, aliasing (downsampling below a
signal's own frequency, which does not just lose detail but manufactures
a pattern that was never there), a trailing rolling window's lag against
a centred one, a missing row silently connected across instead of shown
as a gap, log-scale straightness as the test for constant growth, and
correct year-over-year alignment across a leap year — finishing with the
fact that a single calendar day can genuinely contain 23 or 25 hours.
Learning objectives
By the end of this lab you will be able to:
- Demonstrate that plotting a time series against its row index instead
of its parsed
DatetimeIndexsilently erases a real gap in observations, and read the difference directly off each line's x-positions. - Resample the same series to monthly
mean,sumandlast, and state which question each of the three different, equally true answers actually answers. - Construct a signal with a known short period, downsample it below that frequency, and demonstrate that the result is not merely less detailed but contains a specific, predictable, spurious period that exists nowhere in the source signal.
- Measure a trailing rolling window's lag against the true peak it is meant to summarize, and show that a centred window of the same size does not lag at all.
- Demonstrate that matplotlib connects straight across a physically
absent row but genuinely breaks its line at an explicit
NaN, and that reindexing to the full period converts the first case into the second. - Show that constant-percentage growth is collinear on a log-scaled axis to a tight numerical tolerance, while constant linear growth is measurably not, and explain why that distinguishes compounding from linear growth by eye.
- Align two years of daily data by calendar (month, day) rather than ordinal day-of-year, and explain why the ordinal version silently misaligns every date after a leap year's Feb 29.
- Facet a many-series frame into small multiples and confirm the exact number of Axes and lines that produced.
- Resample hourly timezone-aware data across a real Daylight Saving Time boundary and confirm that one calendar day genuinely contains 23 hours and another genuinely contains 25.
Prerequisites
- Day 121 — loading and inspecting data, specifically
parse_dates, which is exactly what turns a plain string column into theDatetimeIndexevery exercise in this lab depends on. - Day 123 — groupby and aggregation, the mechanics this lab's
resamplecalls (exercise 2) build directly on. - Day 127 — why to visualize and how to choose a chart type; this lab assumes that decision is already made and focuses on what a time axis specifically can get wrong.
- Day 128 — matplotlib's object model (
fig, ax = plt.subplots(), theFigure/Axes/Artisthierarchy, and testing by asserting on artists), which every x-position andLine2Dassertion in this lab depends on directly. - A working
python3on yourPATHto create the lab's virtual environment.
Supported operating systems
| System | Status |
|---|---|
| macOS (Apple Silicon or Intel) | Captured here — macOS 26.5.2, arm64 |
| Linux (any current distribution) | Expected identical, given the pinned versions below, the headless Agg backend, and a system IANA timezone database |
| Windows | Use WSL and follow the Linux path. mktemp -d is used inside tests/run_tests.sh; native Windows was not tested and no output is claimed for it. Exercise 9 may additionally need pip install tzdata — see requirements/README.md |
Hardware requirements
Anything. Every table and signal in this lab is a small, deterministic construction — at most a few hundred rows. No GPU, no display, no meaningful disk use, and no network beyond the one-time install.
Required software
| Tool | Minimum | Used here | Why |
|---|---|---|---|
python3 |
3.11 | 3.14.0 | Runs everything; standard library venv builds the lab's environment |
pandas |
3.0.5 exactly | 3.0.5 | Every DatetimeIndex, resample, rolling and tz_convert call in this lab |
matplotlib |
3.11.1 exactly | 3.11.1 | Every plot; every assertion reads its Axes/Figure/Line2D objects directly |
numpy |
2.5.2 | 2.5.2 | The aliasing signal (exercise 3) and log-space arithmetic (exercise 6) |
pytest |
9.1.1 | 9.1.1 | The test harness every exercise is written against |
bash |
3.2 | 3.2.57 | The outer test harness |
Check your Python in one line: python3 --version.
Free and open-source options
Everything here is free.
- pandas (BSD 3-Clause), matplotlib (PSF-derived, BSD-style), NumPy (BSD 3-Clause) and pytest (MIT) are fully open source with no paid tier.
- Plotly (MIT core library, paid Dash Enterprise tier for deployment) and Bokeh (BSD 3-Clause, fully free) — both described from documentation only, not run here — offer free interactive zooming on long series in a browser or notebook.
No account, no key, no paid tier, and no part of this lab is degraded without one.
Installation
cd labs/sections/math-statistics-and-data/day-131-time-series-visualization
python3 -m venv .venv
.venv/bin/pip install -r requirements/requirements.txt
.venv/bin/python3 -c "import pandas; print(pandas.__version__)"
If your tools live somewhere unusual, tests/run_tests.sh takes an
override rather than guessing:
PYTEST=/path/to/pytest bash tests/run_tests.sh
File structure
day-131-time-series-visualization/
├── README.md this file
├── metadata.yml lab metadata and the recorded run
├── security.md what this lab does to your machine
├── troubleshooting.md grouped by the message you actually see
├── requirements/
│ ├── README.md versions, and what each package is for
│ └── requirements.txt pandas==3.0.5, matplotlib==3.11.1, numpy==2.5.2, pytest==9.1.1
├── starter/ YOUR work happens here
│ ├── 00_brief.md exercise-by-exercise instructions
│ ├── data.py every builder function the exercises use
│ ├── conftest.py fixtures wrapping data.py, headless Agg setup
│ └── test_timeseries.py nine exercises, seventeen tests, each a pytest.skip to replace
├── examples/ the reference. Read AFTER you have tried
│ ├── data.py
│ ├── conftest.py
│ └── test_timeseries.py the fully worked, 17-test answer key
├── tests/
│ └── run_tests.sh 17 checks of real behaviour
└── expected-output/ captured from a real run on 2026-08-20
├── FIELDS.md what must match, what is version-specific, and what is exact
├── examples-run.txt pytest examples -v, captured
├── starter-run.txt pytest starter -v, captured (all skip)
└── test-run.txt the full harness run
How to run
## 1. The reference suite. Read this AFTER you have tried the exercises,
## never before -- it is the answer key.
.venv/bin/pytest examples
.venv/bin/pytest examples -v
## 2. Where you stand on the exercises. An untouched checkout reports
## 17 skipped, 0 failed.
.venv/bin/pytest starter -v
## 3. Your work: open starter/test_timeseries.py and starter/00_brief.md,
## and replace each pytest.skip(...) with real assertions.
.venv/bin/pytest starter -v -k test_1
.venv/bin/pytest starter -v -k test_2
## ... and so on through test_9, or just:
.venv/bin/pytest starter -v
## 4. Check everything, including the harness's own proof that it can fail.
bash tests/run_tests.sh
Never run pytest examples starter in one command. Both directories
define a module named test_timeseries.py; pytest imports test modules
by their dotted name, and running both together was tested directly in
this lab and aborts collection outright with an import file mismatch
error before running a single test. Run them as two separate commands,
always, as shown above.
What the commands do
.venv/bin/pytest examples runs the fully worked reference suite: 17
tests across the nine exercises, each asserting a real value read off a
real pandas/matplotlib object built from one of the builder functions in
data.py.
.venv/bin/pytest starter runs your own suite. On an untouched
checkout, every one of the 17 tests calls pytest.skip(...) and is
reported as s, so the run exits 0 with nothing yet proven. Replace a
skip with real assertions and delete the skip line; when all 17 are
written and passing, the exercise is done.
bash tests/run_tests.sh confirms the installed packages match
requirements.txt exactly, runs pytest examples and requires 17
passed, runs pytest starter and requires 17 skipped on the checked-in
state, then solves every exercise in a scratch copy made with
mktemp -d (never touching the real starter/test_timeseries.py),
confirms that copy passes in full, deliberately breaks one assertion
inside it, confirms the run now exits non-zero with a failure reported,
restores the line, and confirms it passes again. It then draws one real
line plot, saves it headless to a temporary PNG file, confirms the file
exists, and removes it — proving both that headless rendering genuinely
works and that this lab leaves no image file behind. It finishes by
checking no file in examples/ or starter/ contains a URL, and that
nothing is left on disk.
Expected output
The harness ends with a real captured line:
17 checks, 0 failure(s)
and exits 0. pytest examples ends with:
17 passed in 0.05s
pytest starter, on the checked-in state, ends with:
17 skipped in 0.01s
Exercise 3's aliasing trap, exactly as captured — a signal whose true period is 4 days, sampled every 5th day, produces an observed period of 20 days:
>>> ALIASING_TRUE_PERIOD_DAYS, ALIASING_SAMPLE_INTERVAL_DAYS
4, 5
>>> observed spurious period (days)
20
The full capture of both suites is in expected-output/, and
expected-output/FIELDS.md says which values are exact everywhere,
which are specific to this pin set or timezone database, and which
(exercise 4's trailing-lag offset) are asserted with a tolerance rather
than the single captured integer.
Validation steps
bash tests/run_tests.shends with17 checks, 0 failure(s)and exits 0.- A gapped series plotted against
range(len(df))has every x-step equal to1.0; plotted against the parsed dates, every step is1.0except one, which is15.0. daily_1to90.resample("MS")gives January.mean() == 16.0,.sum() == 496.0,.last() == 31.0— three different numbers from the same 31 rows.- A 4-day cosine sampled every 5th day repeats with an observed period of 20 days, not 4.
- A trailing 30-day rolling mean's peak lands 10-20 days after the true peak; a centred 30-day window's peak lands exactly on it.
- A series with a physically absent row plots with no
NaNand one fewer point than the full range; the same gap made an explicitNaNplots with the full point count and a realNaNat that position; reindexing the first into the second's own index produces an identical series. numpy.diff(numpy.log(...))has a standard deviation under1e-9for constant-percentage growth and over1e-3for constant linear growth.- Merging two years' data on a
(month, day)key keeps Dec 31 aligned in both a leap and a non-leap year; Dec 31's raw.dayofyearis366in 2024 and365in 2025. - Faceting a 6-column frame produces exactly 6
Axes, oneLine2Deach. - Resampling hourly
America/New_Yorkdata to daily counts gives23for 2024-03-10,25for 2024-11-03, and24for an ordinary day.
Tests
bash tests/run_tests.sh
echo "exit code: $?"
17 checks, exit 0 when they all pass and non-zero otherwise. They are
value checks, not file-existence checks: the reference suite's 17 tests
are exercised through pytest, the exercise suite is confirmed all-skip
on the checked-in state, a scratch copy proves the suite can genuinely
fail and then recover, and one real headless savefig proves the Agg
backend and the lab's own cleanup both work.
Override, if your tools are somewhere unusual:
PYTEST=/path/to/pytest bash tests/run_tests.sh
Cleanup
find . -path ./.venv -prune -o -type d -name '__pycache__' -print -exec rm -rf -- {} +
rm -rf .pytest_cache
tests/run_tests.sh clears __pycache__ and .pytest_cache both before
and after it runs, and its scratch copy of the solved suite and its
savefig demonstration both live in mktemp -d directories removed
immediately after use — so if you only ran the harness, there is nothing
left to clean up.
To remove the lab's virtual environment entirely: rm -rf .venv.
To reset your own work and start the exercises again:
git checkout -- starter/
Troubleshooting
troubleshooting.md has the full list, grouped by the message you
actually see. The ones you are most likely to meet:
pytest examples starteraborts withimport file mismatch— do not run both directories in one invocation; they share a module name.- A plot window tries to open — something imported
pyplotbeforematplotlib.use("Agg")ran; bothconftest.pyfiles set the backend first, and the harness also exportsMPLBACKEND=Agg. - Exercise 9 raises a timezone-database error — install the
pure-Python fallback with
.venv/bin/pip install tzdata; seerequirements/README.md. - Exercise 3's spurious period is not exactly 20 — recompute it from
ALIASING_TRUE_PERIOD_DAYSandALIASING_SAMPLE_INTERVAL_DAYSindata.pyrather than hardcoding the number.
Security notes
security.md has the full account. In short: this lab opens the network
exactly once, to install its four pinned packages, renders entirely
headless via matplotlib's Agg backend, writes only inside its own
.venv and temporary directories it cleans up itself, reads (never
writes) your system's timezone database for exercise 9, and touches no
real data — every table and signal is a small deterministic construction
built by hand in data.py.
Extension exercises
- Aliasing at a different sampling interval. Change
ALIASING_SAMPLE_INTERVAL_DAYSin a scratch copy ofdata.pyto a value that shares a common factor with the true period (for example, 8, which shares a factor of 4 with the true 4-day period) and observe that the "spurious period" search either fails to find a short repeat or finds a much shorter, less deceptive one — explain in one sentence why sampling in lockstep with part of the true cycle is a different (and less dangerous) failure than sampling in genuine aliasing territory. - A wider or narrower rolling window. Repeat exercise 4 with window sizes of 10 and 60 instead of 30, and confirm the trailing lag scales with roughly half of whichever window you chose.
- A second, larger missing stretch. Extend exercise 5's series with
a five-day gap in addition to the single missing day, reindex to the
full range, and confirm matplotlib now breaks the line at five
consecutive
NaNpoints rather than one. - Year-over-year with three years. Extend exercise 7's alignment to
2023, 2024 and 2025 at once (a
(month, day)key working across all three), and confirm Feb 29 appears with a value only in 2024. col_wrap-style reshaping of the small multiples. Instead of one column of Axes in exercise 8, arrange the same six series in a 2×3 grid withplt.subplots(2, 3, ...)and confirm the Axes count is unchanged while the shape is not — the same distinction Day 129'scol_wrap=made for aFacetGrid.
Navigation
- Previous day: Day 130 — Distributions and Relationships
(
labs/sections/math-statistics-and-data/day-130-distributions-and-relationships/). - Next day: Day 132 — Visual Storytelling and Chart Honesty
(
labs/sections/math-statistics-and-data/), continuing Week 19. - Week 19 project: the week's project directory
(
labs/sections/math-statistics-and-data/projects/week-19/), building directly on this week's charting fundamentals.
Expected output
FIELDS.md
# What in these captures is exact, and what may differ
Captured from a real run on 2026-08-20, in this lab's own `.venv`, on
pandas 3.0.5, matplotlib 3.11.1, NumPy 2.5.2, pytest 9.1.1, Python
3.14.0, macOS (arm64).
## Exact everywhere, on any correctly installed copy of these pins
- `examples-run.txt` ends with `17 passed`, `starter-run.txt` ends with
`17 skipped` — both counts are structural (17 test functions in each
file) and do not depend on the machine.
- Exercise 1's x-step values (`1.0` for every ordinary day, one step of
`15.0` across the fourteen-day gap) are arithmetic on a fixed,
hand-written date range and are exact everywhere.
- Exercise 2's three January aggregates (`16.0`, `496.0`, `31.0`) are
arithmetic on a fixed literal (`value = day number`, 1..90) and are
exact everywhere.
- Exercise 3's true period (4 days), sampling interval (5 days) and the
resulting spurious period (20 days) are deterministic properties of a
`numpy.cos` construction with no randomness anywhere.
- Exercise 4's centred-window offset (exactly `0` days) is structural.
The trailing-window offset (`14` days, captured directly) is also
deterministic given the fixed triangular-bump construction and the
30-day window, but is asserted with a `10`-`20` day tolerance rather
than the single captured integer, because a slightly different bump
shape or window size would still legitimately land inside "roughly
half the window" without landing on exactly 14.
- Exercise 5's row counts (19 for the missing-row series, 20 for the
explicit-NaN series) and the exact position of the NaN
(`MISSING_DAY_POSITION = 10`) are structural.
- Exercise 6's constant-percentage-growth log-difference standard
deviation was measured at `3.04e-16` (floating-point noise around
zero, well under the asserted `1e-9` threshold) and the linear-growth
series' at `9.53e-3` (over the asserted `1e-3` threshold). The 1e-16
figure is a floating-point-arithmetic artifact of this specific
computation order and is not asserted on directly; only the "under
1e-9" / "over 1e-3" thresholds are.
- Exercise 7's structural facts — 2024 has 366 days including Feb 29,
2025 has 365, Dec 31's ordinal day-of-year is 366 in 2024 and 365 in
2025 — are calendar facts, exact on any machine.
- Exercise 8's Axes-and-line counts are structural (one Axes and one
line per column of a fixed 6-column frame).
- Exercise 9's hour counts (23 for 2024-03-10, 25 for 2024-11-03, 24 for
an ordinary day) are determined by the `America/New_York` timezone's
published DST transition rules for 2024 and are exact wherever the
installed tz database matches the IANA release these rules come from
(see "Version-specific" below).
- `17 checks, 0 failure(s)` and exit 0 from `tests/run_tests.sh`.
## Version-specific, checked directly rather than assumed
- Exercise 9's specific transition dates (2024-03-10 for spring forward,
2024-11-03 for fall back) are properties of the `America/New_York`
zone's 2024 DST rules as published in the IANA tz database installed
on this machine, via Python's `zoneinfo` (no separate `tzdata` package
was needed here — macOS ships a system tz database and pandas 3.0.5
found it automatically). A machine with an older tz database release
would still show a spring-forward and a fall-back transition in 2024
on these same two dates, because the US DST rule itself has been
stable since 2007; the requirements/README documents `pip install
tzdata` as a fallback for platforms with no system database at all
(chiefly a minimal Windows install).
- No `MatplotlibDeprecationWarning` or similar was observed anywhere in
this lab's captured output on matplotlib 3.11.1 — every plotting call
used here (`ax.plot`, `plt.subplots`) is long-stable API with no
deprecated keyword arguments.
## Machine-dependent
- Wall-clock timings inside pytest's own summary lines (`in 0.05s`, `in
0.01s`) will differ on any other machine and are not asserted on
anywhere in this lab.
- The `.venv` path embedded in pytest's own `rootdir:` and platform
banner lines has been sanitized to `<repo>` in these captures; on a
fresh checkout it will show that checkout's own absolute path instead.
## Nothing in this lab is sampled or non-reproducible
Unlike Day 129's unseeded-bootstrap exercise, every exercise in this lab
is built from a deterministic literal or a deterministic closed-form
signal (a fixed date range, a fixed cosine, a fixed triangular bump, a
fixed compounding-growth formula). Re-running `examples/` on the same
pin set reproduces every asserted number exactly, with no exceptions.
examples-run.txt
============================= test session starts ==============================
platform darwin -- Python 3.14.0, pytest-9.1.1, pluggy-1.6.0 -- <repo>/labs/sections/math-statistics-and-data/day-131-time-series-visualization/.venv/bin/python3.14
cachedir: .pytest_cache
rootdir: <repo>/labs/sections/math-statistics-and-data/day-131-time-series-visualization
collecting ... collected 17 items
examples/test_timeseries.py::test_1_index_axis_x_positions_are_perfectly_uniform PASSED [ 5%]
examples/test_timeseries.py::test_1_datetime_axis_x_positions_reveal_the_gap PASSED [ 11%]
examples/test_timeseries.py::test_2_monthly_mean_sum_and_last_are_three_different_answers PASSED [ 17%]
examples/test_timeseries.py::test_3_downsampling_below_the_true_frequency_manufactures_a_false_period PASSED [ 23%]
examples/test_timeseries.py::test_4_trailing_rolling_mean_peak_lags_the_true_peak PASSED [ 29%]
examples/test_timeseries.py::test_4_centred_rolling_mean_peak_does_not_lag PASSED [ 35%]
examples/test_timeseries.py::test_5_matplotlib_connects_across_a_missing_row_with_no_nan_present PASSED [ 41%]
examples/test_timeseries.py::test_5_matplotlib_breaks_the_line_at_an_explicit_nan PASSED [ 47%]
examples/test_timeseries.py::test_5_reindexing_the_missing_row_series_produces_the_explicit_nan_series PASSED [ 52%]
examples/test_timeseries.py::test_6_constant_percentage_growth_is_collinear_in_log_space PASSED [ 58%]
examples/test_timeseries.py::test_6_constant_linear_growth_is_not_collinear_in_log_space PASSED [ 64%]
examples/test_timeseries.py::test_7_month_day_alignment_keeps_dec_31_lined_up_across_a_leap_year PASSED [ 70%]
examples/test_timeseries.py::test_7_raw_ordinal_day_of_year_misaligns_after_the_leap_day PASSED [ 76%]
examples/test_timeseries.py::test_8_faceting_produces_one_axes_per_series_with_exactly_one_line_each PASSED [ 82%]
examples/test_timeseries.py::test_9_spring_forward_day_has_23_hours PASSED [ 88%]
examples/test_timeseries.py::test_9_fall_back_day_has_25_hours PASSED [ 94%]
examples/test_timeseries.py::test_9_an_ordinary_day_outside_the_dst_boundary_has_24_hours PASSED [100%]
============================== 17 passed in 0.05s ==============================
starter-run.txt
============================= test session starts ==============================
platform darwin -- Python 3.14.0, pytest-9.1.1, pluggy-1.6.0 -- <repo>/labs/sections/math-statistics-and-data/day-131-time-series-visualization/.venv/bin/python3.14
cachedir: .pytest_cache
rootdir: <repo>/labs/sections/math-statistics-and-data/day-131-time-series-visualization
collecting ... collected 17 items
starter/test_timeseries.py::test_1_index_axis_x_positions_are_perfectly_uniform SKIPPED [ 5%]
starter/test_timeseries.py::test_1_datetime_axis_x_positions_reveal_the_gap SKIPPED [ 11%]
starter/test_timeseries.py::test_2_monthly_mean_sum_and_last_are_three_different_answers SKIPPED [ 17%]
starter/test_timeseries.py::test_3_downsampling_below_the_true_frequency_manufactures_a_false_period SKIPPED [ 23%]
starter/test_timeseries.py::test_4_trailing_rolling_mean_peak_lags_the_true_peak SKIPPED [ 29%]
starter/test_timeseries.py::test_4_centred_rolling_mean_peak_does_not_lag SKIPPED [ 35%]
starter/test_timeseries.py::test_5_matplotlib_connects_across_a_missing_row_with_no_nan_present SKIPPED [ 41%]
starter/test_timeseries.py::test_5_matplotlib_breaks_the_line_at_an_explicit_nan SKIPPED [ 47%]
starter/test_timeseries.py::test_5_reindexing_the_missing_row_series_produces_the_explicit_nan_series SKIPPED [ 52%]
starter/test_timeseries.py::test_6_constant_percentage_growth_is_collinear_in_log_space SKIPPED [ 58%]
starter/test_timeseries.py::test_6_constant_linear_growth_is_not_collinear_in_log_space SKIPPED [ 64%]
starter/test_timeseries.py::test_7_month_day_alignment_keeps_dec_31_lined_up_across_a_leap_year SKIPPED [ 70%]
starter/test_timeseries.py::test_7_raw_ordinal_day_of_year_misaligns_after_the_leap_day SKIPPED [ 76%]
starter/test_timeseries.py::test_8_faceting_produces_one_axes_per_series_with_exactly_one_line_each SKIPPED [ 82%]
starter/test_timeseries.py::test_9_spring_forward_day_has_23_hours SKIPPED [ 88%]
starter/test_timeseries.py::test_9_fall_back_day_has_25_hours SKIPPED [ 94%]
starter/test_timeseries.py::test_9_an_ordinary_day_outside_the_dst_boundary_has_24_hours SKIPPED [100%]
============================= 17 skipped in 0.01s ==============================
test-run.txt
Day 131 — Time Told Honestly
1. The tools and the versions this lab was written against
python 3.14.0
pandas 3.0.5
matplotlib 3.11.1
numpy 2.5.2
pytest 9.1.1
ok: installed packages match requirements.txt exactly
2. Reference suite -- examples/ must pass in full
................. [100%]
17 passed in 0.05s
ok: examples/ exits 0
ok: examples/ reports 17 passed, 0 failed
3. Exercise suite -- starter/ is all-skip on an untouched checkout
sssssssssssssssss [100%]
17 skipped in 0.01s
ok: starter/ (untouched) exits 0
ok: starter/ (untouched) reports 17 skipped, 0 failed
4. Never run 'pytest examples starter' in one invocation -- same
module name (test_timeseries.py) in both directories means pytest
collects them by dotted module name and the second can collide
with the first. Documented, and run only as two commands above.
5. Prove the suite can genuinely FAIL: solve every exercise in a
scratch copy, confirm green, break one assertion on purpose,
confirm a non-zero exit and a printed FAIL, then restore.
ok: scratch copy of the solved suite exits 0
ok: scratch copy reports 17 passed
ok: broken scratch copy exits non-zero
ok: broken scratch copy prints a FAIL/failed line
ok: restored scratch copy exits 0 again
ok: restored scratch copy reports 17 passed again
6. A real headless savefig, into a temporary directory, cleaned up
ok: headless savefig exits 0
ok: the saved PNG file actually exists and is non-empty
ok: the temporary savefig directory is fully removed
7. Nothing in examples/ or starter/ opens a network connection, and
no image file is left anywhere inside this lab
ok: no URLs inside examples/ or starter/
ok: no image files left anywhere inside the lab
8. Cleanliness -- nothing left behind by THIS run
ok: no __pycache__ or .pytest_cache left behind
-------------------------------------------------------------
17 checks, 0 failure(s)
Source files
examples/conftest.py (2048 bytes)
"""Shared fixtures and headless matplotlib setup.
pytest finds this file by itself -- nothing imports it. `matplotlib.use`
must run before `pyplot` is imported anywhere, which is why it happens
here, first, before the `data` import below pulls in pandas. Every
fixture returns a FRESH copy of its table so one test's accidental
mutation can never leak into the next test, and every test that opens a
Figure is responsible for `plt.close()`-ing it -- `_close_all_figures`
below is a backstop, not a substitute.
"""
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import pytest
from data import (
build_aliasing_signal,
build_daily_1to90,
build_full_series,
build_gapped_series,
build_hourly_utc,
build_linear_growth_series,
build_many_series,
build_pct_growth_series,
build_series_with_a_missing_row,
build_series_with_an_explicit_nan,
build_single_peak_series,
build_two_year_daily,
)
@pytest.fixture
def gapped_series():
return build_gapped_series()
@pytest.fixture
def daily_1to90():
return build_daily_1to90()
@pytest.fixture
def aliasing_signal():
return build_aliasing_signal()
@pytest.fixture
def single_peak_series():
return build_single_peak_series()
@pytest.fixture
def full_series():
return build_full_series()
@pytest.fixture
def series_with_a_missing_row():
return build_series_with_a_missing_row()
@pytest.fixture
def series_with_an_explicit_nan():
return build_series_with_an_explicit_nan()
@pytest.fixture
def pct_growth_series():
return build_pct_growth_series()
@pytest.fixture
def linear_growth_series():
return build_linear_growth_series()
@pytest.fixture
def two_year_daily():
return build_two_year_daily()
@pytest.fixture
def many_series():
return build_many_series()
@pytest.fixture
def hourly_utc():
def _build(start, end):
return build_hourly_utc(start, end)
return _build
@pytest.fixture(autouse=True)
def _close_all_figures():
yield
plt.close("all")
examples/data.py (9450 bytes)
"""The tables and signals every exercise in this lab is built from.
Nothing here is randomised or loaded from a file -- every table is a small,
deterministic construction so a reader can check every asserted number by
eye against the source. This lab's through-line is that time has structure
a plain axis throws away, so most of these builders return a real
`DatetimeIndex`, not a `RangeIndex` -- and where an exercise needs to
compare "index axis" against "datetime axis" behaviour, it builds both on
purpose.
`build_gapped_series()` -- exercise 1. Two contiguous stretches of daily
observations with a real fourteen-day gap between them (a sensor that was
offline), the exact shape a naive plot silently erases.
`build_daily_1to90()` -- exercise 2 and part of exercise 5. Ninety days of
`value = day number` (1..90), so every resample aggregate is hand-checkable
arithmetic: January's mean is 16.0, its sum is 496.0, its last value is
31.0.
`build_aliasing_signal()` -- exercise 3. A daily cosine with a *true*
period of 4 days, long enough (400 days) to sample repeatedly at any
interval.
`build_single_peak_series()` -- exercise 4. A 200-day triangular bump
centred exactly on day 100, base width 120 days, used to measure how far a
rolling mean's own peak drifts from the true one.
`build_series_with_a_missing_day()` -- exercise 5. Twenty daily values
with the day-10 row physically absent from the DataFrame (not present at
all, as opposed to present-with-NaN).
`build_pct_growth_series()` / `build_linear_growth_series()` -- exercise
6. Sixty periods of constant 5%-per-period compounding growth versus
sixty periods of constant 5-units-per-period linear growth.
`build_two_year_daily()` -- exercise 7. Full calendar years 2024 (a leap
year, 366 days) and 2025 (365 days), value = day-of-year-in-that-year, so
Feb 29's own row is trivially identifiable.
`build_many_series(n)` -- exercise 8. `n` short random-walk-free series
sharing one date range, each with a distinct deterministic offset, for
faceting into small multiples.
`build_hourly_utc(start, end)` -- exercise 9. Hourly, UTC-anchored
timestamps across a US Eastern DST boundary, built in UTC first and
converted afterward so no ambiguous or nonexistent local time is ever
constructed directly.
"""
from __future__ import annotations
import numpy as np
import pandas as pd
# --------------------------------------------------------------------------
# Exercise 1 -- the opening failure. Two runs of daily dates with a real
# fourteen-day gap between them: Jan 1 - Jan 31 (31 days), then a break,
# then Feb 15 - Feb 28 (14 days). 45 rows total. Plotted against a plain
# RangeIndex the gap is invisible; plotted against the parsed datetime it
# is a visible jump.
# --------------------------------------------------------------------------
def build_gapped_series() -> pd.DataFrame:
first_run = pd.date_range("2024-01-01", periods=31, freq="D")
second_run = pd.date_range("2024-02-15", periods=14, freq="D")
dates = first_run.append(second_run)
values = np.arange(len(dates), dtype=float)
return pd.DataFrame({"date": dates, "value": values})
# --------------------------------------------------------------------------
# Exercise 2 -- resample aggregation changes the answer. Ninety days
# (Jan 1 - Mar 30, 2024), value equal to the day number (1..90), so every
# monthly aggregate is arithmetic anyone can re-check: January's 31 values
# are 1..31, mean 16.0, sum 496.0, last 31.0 -- three different, all true,
# numbers from the same 31 rows.
# --------------------------------------------------------------------------
def build_daily_1to90() -> pd.Series:
dates = pd.date_range("2024-01-01", periods=90, freq="D")
values = np.arange(1, len(dates) + 1, dtype=float)
return pd.Series(values, index=dates, name="value")
# --------------------------------------------------------------------------
# Exercise 3 -- aliasing. A daily cosine with a TRUE period of 4 days,
# sampled every 5th day. 5 and 4 share no common factor smaller than
# themselves in a way that keeps the alias clean: the sampled sequence
# repeats every 4 SAMPLES (20 real days), a spurious period 5x longer than
# the signal that actually produced it, and nowhere close to either input
# number.
# --------------------------------------------------------------------------
ALIASING_TRUE_PERIOD_DAYS = 4
ALIASING_SAMPLE_INTERVAL_DAYS = 5
def build_aliasing_signal(n_days: int = 400) -> np.ndarray:
t = np.arange(n_days, dtype=float)
return np.cos(2 * np.pi * t / ALIASING_TRUE_PERIOD_DAYS)
# --------------------------------------------------------------------------
# Exercise 4 -- trailing lag. A single triangular bump over 200 days,
# peaking exactly at day-position 100 (base half-width 60 days either
# side), so a rolling window's own peak position can be measured against a
# known, exact answer.
# --------------------------------------------------------------------------
PEAK_POSITION_DAYS = 100
PEAK_HALF_WIDTH_DAYS = 60
def build_single_peak_series() -> pd.Series:
idx = pd.date_range("2024-01-01", periods=200, freq="D")
t = np.arange(len(idx))
values = np.maximum(0, PEAK_HALF_WIDTH_DAYS - np.abs(t - PEAK_POSITION_DAYS)) / PEAK_HALF_WIDTH_DAYS
return pd.Series(values, index=idx, name="value")
# --------------------------------------------------------------------------
# Exercise 5 -- missing row versus NaN. Twenty daily values, value = day
# position (0..19). `full` carries all twenty rows. `missing_row` is the
# same series with the day-10 row DROPPED entirely -- nineteen rows, no
# NaN anywhere -- as opposed to a series that keeps all twenty rows but
# sets day 10's value to NaN explicitly.
# --------------------------------------------------------------------------
MISSING_DAY_POSITION = 10
def build_full_series() -> pd.Series:
idx = pd.date_range("2024-01-01", periods=20, freq="D")
values = np.arange(len(idx), dtype=float)
return pd.Series(values, index=idx, name="value")
def build_series_with_a_missing_row() -> pd.Series:
full = build_full_series()
return full.drop(full.index[MISSING_DAY_POSITION])
def build_series_with_an_explicit_nan() -> pd.Series:
full = build_full_series().copy()
full.iloc[MISSING_DAY_POSITION] = np.nan
return full
# --------------------------------------------------------------------------
# Exercise 6 -- log straightness. Sixty periods of 5%-per-period
# compounding growth versus sixty periods of a fixed 5-units-per-period
# linear increase, both starting at 100.
# --------------------------------------------------------------------------
def build_pct_growth_series(periods: int = 60, rate: float = 0.05) -> pd.Series:
idx = pd.date_range("2024-01-01", periods=periods, freq="D")
t = np.arange(periods, dtype=float)
values = 100.0 * (1.0 + rate) ** t
return pd.Series(values, index=idx, name="value")
def build_linear_growth_series(periods: int = 60, step: float = 5.0) -> pd.Series:
idx = pd.date_range("2024-01-01", periods=periods, freq="D")
t = np.arange(periods, dtype=float)
values = 100.0 + step * t
return pd.Series(values, index=idx, name="value")
# --------------------------------------------------------------------------
# Exercise 7 -- year-over-year alignment. Full calendar years 2024 (a leap
# year -- 366 days, including Feb 29) and 2025 (365 days). Value equals
# the position within that year (0-based), so every value is trivially
# checkable, and the leap day's own row is easy to isolate.
# --------------------------------------------------------------------------
def build_two_year_daily() -> tuple[pd.Series, pd.Series]:
dates_2024 = pd.date_range("2024-01-01", "2024-12-31", freq="D")
dates_2025 = pd.date_range("2025-01-01", "2025-12-31", freq="D")
s2024 = pd.Series(np.arange(len(dates_2024), dtype=float), index=dates_2024, name="value")
s2025 = pd.Series(np.arange(len(dates_2025), dtype=float), index=dates_2025, name="value")
return s2024, s2025
# --------------------------------------------------------------------------
# Exercise 8 -- small multiples. `n` series over the same 60-day range,
# each a distinct deterministic sine with its own offset, so they are
# visibly different but reproducible.
# --------------------------------------------------------------------------
def build_many_series(n: int = 6, periods: int = 60) -> pd.DataFrame:
idx = pd.date_range("2024-01-01", periods=periods, freq="D")
t = np.arange(periods, dtype=float)
data = {f"series_{i}": 10 * (i + 1) + 3 * np.sin(2 * np.pi * t / (10 + i)) for i in range(n)}
return pd.DataFrame(data, index=idx)
# --------------------------------------------------------------------------
# Exercise 9 -- DST honesty. Hourly timestamps built in UTC first (never
# constructing a local wall-clock time directly, so no nonexistent or
# ambiguous local hour is ever asked for), then converted to US/Eastern,
# which observes DST. Spring-forward (2024-03-10) loses an hour; fall-back
# (2024-11-03) repeats one.
# --------------------------------------------------------------------------
def build_hourly_utc(start: str, end: str) -> pd.Series:
rng_utc = pd.date_range(start, end, freq="h", tz="UTC", inclusive="left")
local = rng_utc.tz_convert("America/New_York")
return pd.Series(1, index=local, name="reading")
examples/test_timeseries.py (11886 bytes)
"""The worked reference suite for Day 131 -- "Time Told Honestly".
Nine exercises, each proving one real pandas 3.0.5 / matplotlib 3.11.1
behaviour by building a real series, plotting or resampling it, and
reading real x-positions, artist state, or computed values -- never by
reading source. Run it:
pytest examples
Every table and signal these tests use comes from `data.py`, imported
through the fixtures in `conftest.py`. Read `starter/00_brief.md` for the
exercise-by-exercise explanation; this file is the answer key.
"""
import matplotlib
import matplotlib.dates as mdates
import numpy as np
import pandas as pd
import pytest
from data import ALIASING_SAMPLE_INTERVAL_DAYS, ALIASING_TRUE_PERIOD_DAYS, MISSING_DAY_POSITION
# --------------------------------------------------------------------------
# Exercise 1 -- index versus datetime axis. Same data, same code except
# for the x argument. Against the RangeIndex the fourteen-day gap is
# invisible; against the parsed datetime it is a real, measurable jump.
# --------------------------------------------------------------------------
def test_1_index_axis_x_positions_are_perfectly_uniform(gapped_series):
import matplotlib.pyplot as plt
fig, ax = plt.subplots()
ax.plot(range(len(gapped_series)), gapped_series["value"])
xdata = np.asarray(ax.lines[0].get_xdata(), dtype=float)
diffs = np.diff(xdata)
# Every step is exactly 1 -- the outage is completely erased.
assert set(np.unique(diffs)) == {1.0}
def test_1_datetime_axis_x_positions_reveal_the_gap(gapped_series):
import matplotlib.pyplot as plt
fig, ax = plt.subplots()
ax.plot(gapped_series["date"], gapped_series["value"])
xdata = mdates.date2num(ax.lines[0].get_xdata())
diffs = np.diff(xdata)
normal_steps = diffs[diffs < 5]
gap_steps = diffs[diffs >= 5]
assert set(np.round(normal_steps, 6)) == {1.0} # every ordinary day is one apart
assert len(gap_steps) == 1 # exactly one wide step
assert gap_steps[0] == 15.0 # 14 missing days plus the 1-day step either side of them
# --------------------------------------------------------------------------
# Exercise 2 -- resampling is a claim. Ninety days of value = day number;
# January's mean, sum and last are three different, all-true numbers.
# --------------------------------------------------------------------------
def test_2_monthly_mean_sum_and_last_are_three_different_answers(daily_1to90):
monthly_mean = daily_1to90.resample("MS").mean()
monthly_sum = daily_1to90.resample("MS").sum()
monthly_last = daily_1to90.resample("MS").last()
january_mean = float(monthly_mean.iloc[0])
january_sum = float(monthly_sum.iloc[0])
january_last = float(monthly_last.iloc[0])
assert (january_mean, january_sum, january_last) == (16.0, 496.0, 31.0)
assert len({january_mean, january_sum, january_last}) == 3 # all distinct
# Each is arithmetic anyone can re-check against January's 31 raw values (1..31).
january_raw = daily_1to90.loc["2024-01"]
assert january_raw.mean() == january_mean
assert january_raw.sum() == january_sum
assert january_raw.iloc[-1] == january_last
# --------------------------------------------------------------------------
# Exercise 3 -- aliasing. True period 4 days, sampled every 5th day.
# The sampled sequence repeats every 4 SAMPLES -- a spurious period of
# 20 real days, five times the true period, manufactured entirely by the
# sampling interval.
# --------------------------------------------------------------------------
def _first_repeat_period(sequence: np.ndarray, atol: float = 1e-9) -> int:
for k in range(1, len(sequence) // 2):
if np.allclose(sequence[:-k], sequence[k:], atol=atol):
return k
raise AssertionError("no repeating period found")
def test_3_downsampling_below_the_true_frequency_manufactures_a_false_period(aliasing_signal):
sampled = aliasing_signal[:: ALIASING_SAMPLE_INTERVAL_DAYS]
observed_period_in_samples = _first_repeat_period(sampled)
observed_period_days = observed_period_in_samples * ALIASING_SAMPLE_INTERVAL_DAYS
assert ALIASING_TRUE_PERIOD_DAYS == 4
assert observed_period_days == 20 # the spurious period this sampling interval manufactures
assert observed_period_days != ALIASING_TRUE_PERIOD_DAYS
assert observed_period_days == 5 * ALIASING_TRUE_PERIOD_DAYS # five times longer than the real cycle
# The full-resolution signal itself genuinely repeats every 4 days --
# the alias is entirely a product of the sampling interval, not the signal.
full_res_period = _first_repeat_period(aliasing_signal)
assert full_res_period == ALIASING_TRUE_PERIOD_DAYS
# --------------------------------------------------------------------------
# Exercise 4 -- trailing lag. A trailing rolling mean's own peak sits
# roughly half the window AFTER the true peak; a centred window's peak
# does not move at all.
# --------------------------------------------------------------------------
def test_4_trailing_rolling_mean_peak_lags_the_true_peak(single_peak_series):
window = 30
trailing = single_peak_series.rolling(window).mean()
true_peak_date = single_peak_series.idxmax()
trailing_peak_date = trailing.idxmax()
offset_days = (trailing_peak_date - true_peak_date).days
assert offset_days > 0 # the trailing peak is measurably LATE
assert 10 <= offset_days <= 20 # roughly half the 30-day window (14, measured here)
def test_4_centred_rolling_mean_peak_does_not_lag(single_peak_series):
window = 30
centred = single_peak_series.rolling(window, center=True).mean()
true_peak_date = single_peak_series.idxmax()
centred_peak_date = centred.idxmax()
offset_days = (centred_peak_date - true_peak_date).days
assert offset_days == 0 # no lag at all -- unlike the trailing version above
# --------------------------------------------------------------------------
# Exercise 5 -- missing row versus NaN. matplotlib connects straight
# across an absent row (no NaN anywhere in the drawn data); it genuinely
# breaks at an explicit NaN. Reindexing converts the first case into the
# second.
# --------------------------------------------------------------------------
def test_5_matplotlib_connects_across_a_missing_row_with_no_nan_present(series_with_a_missing_row, full_series):
import matplotlib.pyplot as plt
fig, ax = plt.subplots()
ax.plot(series_with_a_missing_row.index, series_with_a_missing_row.values)
ydata = np.asarray(ax.lines[0].get_ydata(), dtype=float)
assert len(ydata) == len(full_series) - 1 # the absent row is simply not there
assert not np.isnan(ydata).any() # nothing marks where the gap was
def test_5_matplotlib_breaks_the_line_at_an_explicit_nan(series_with_an_explicit_nan, full_series):
import matplotlib.pyplot as plt
fig, ax = plt.subplots()
ax.plot(series_with_an_explicit_nan.index, series_with_an_explicit_nan.values)
ydata = np.asarray(ax.lines[0].get_ydata(), dtype=float)
assert len(ydata) == len(full_series) # all twenty rows are still present
assert np.isnan(ydata[MISSING_DAY_POSITION]) # the gap is visible AS a gap
def test_5_reindexing_the_missing_row_series_produces_the_explicit_nan_series(
series_with_a_missing_row, series_with_an_explicit_nan, full_series
):
reindexed = series_with_a_missing_row.reindex(full_series.index)
pd.testing.assert_series_equal(reindexed, series_with_an_explicit_nan)
assert pd.isna(reindexed.iloc[MISSING_DAY_POSITION])
# --------------------------------------------------------------------------
# Exercise 6 -- log straightness. Constant percentage growth is collinear
# in log space (its log-differences are constant); constant linear growth
# is not.
# --------------------------------------------------------------------------
def test_6_constant_percentage_growth_is_collinear_in_log_space(pct_growth_series):
log_values = np.log(pct_growth_series.values)
log_diffs = np.diff(log_values)
assert log_diffs.std() < 1e-9 # essentially perfectly constant -- a straight line
def test_6_constant_linear_growth_is_not_collinear_in_log_space(linear_growth_series):
log_values = np.log(linear_growth_series.values)
log_diffs = np.diff(log_values)
assert log_diffs.std() > 1e-3 # measurably curved, not a straight line
# --------------------------------------------------------------------------
# Exercise 7 -- year-over-year alignment. Aligning by calendar (month, day)
# keeps Dec 31 lined up with Dec 31 in both a leap and a non-leap year;
# aligning by raw ordinal day-of-year does not, because 2024 has 366 days
# and 2025 has 365.
# --------------------------------------------------------------------------
def test_7_month_day_alignment_keeps_dec_31_lined_up_across_a_leap_year(two_year_daily):
s2024, s2025 = two_year_daily
df2024 = s2024.to_frame("value")
df2024["month_day"] = list(zip(df2024.index.month, df2024.index.day))
df2025 = s2025.to_frame("value")
df2025["month_day"] = list(zip(df2025.index.month, df2025.index.day))
merged = pd.merge(df2024, df2025, on="month_day", suffixes=("_2024", "_2025"), how="outer")
dec_31_row = merged.loc[merged["month_day"] == (12, 31)]
assert len(dec_31_row) == 1 # Dec 31 exists in both years and merges to exactly one row
assert not dec_31_row[["value_2024", "value_2025"]].isna().any().any() # both sides present
feb_29_row = merged.loc[merged["month_day"] == (2, 29)]
assert len(feb_29_row) == 1
assert not pd.isna(feb_29_row["value_2024"].iloc[0]) # 2024 has a Feb 29
assert pd.isna(feb_29_row["value_2025"].iloc[0]) # 2025 does not
def test_7_raw_ordinal_day_of_year_misaligns_after_the_leap_day(two_year_daily):
s2024, s2025 = two_year_daily
dec_31_2024_ordinal = s2024.index[-1].dayofyear
dec_31_2025_ordinal = s2025.index[-1].dayofyear
assert dec_31_2024_ordinal == 366 # 2024 is a leap year
assert dec_31_2025_ordinal == 365 # 2025 is not
assert dec_31_2024_ordinal != dec_31_2025_ordinal # same calendar date, different ordinal number
# --------------------------------------------------------------------------
# Exercise 8 -- small multiples. Faceting a many-series frame produces one
# Axes per series, each carrying exactly one line.
# --------------------------------------------------------------------------
def test_8_faceting_produces_one_axes_per_series_with_exactly_one_line_each(many_series):
import matplotlib.pyplot as plt
n_series = many_series.shape[1]
fig, axes = plt.subplots(n_series, 1, sharex=True, figsize=(6, 2 * n_series))
for ax, column in zip(axes, many_series.columns):
ax.plot(many_series.index, many_series[column])
assert len(fig.axes) == n_series
for ax in fig.axes:
assert len(ax.lines) == 1
# --------------------------------------------------------------------------
# Exercise 9 -- DST honesty. Hourly data resampled to daily calendar days
# across a US Eastern DST boundary produces one 23-hour day (spring
# forward) and one 25-hour day (fall back).
# --------------------------------------------------------------------------
def test_9_spring_forward_day_has_23_hours(hourly_utc):
s = hourly_utc("2024-03-06", "2024-03-14")
daily_counts = s.resample("D").size()
assert int(daily_counts.loc["2024-03-10"]) == 23
def test_9_fall_back_day_has_25_hours(hourly_utc):
s = hourly_utc("2024-10-30", "2024-11-06")
daily_counts = s.resample("D").size()
assert int(daily_counts.loc["2024-11-03"]) == 25
def test_9_an_ordinary_day_outside_the_dst_boundary_has_24_hours(hourly_utc):
s = hourly_utc("2024-03-06", "2024-03-14")
daily_counts = s.resample("D").size()
assert int(daily_counts.loc["2024-03-08"]) == 24
metadata.yml (2455 bytes)
lesson_id: D131
day: 131
kind: guided-build
languages: [python, bash]
setup_commands:
- cd labs/sections/math-statistics-and-data/day-131-time-series-visualization
- python3 -m venv .venv
- .venv/bin/pip install -r requirements/requirements.txt
- .venv/bin/python3 -c "import pandas; print(pandas.__version__)"
run_commands:
- .venv/bin/pytest examples
- .venv/bin/pytest starter
test_commands:
- bash tests/run_tests.sh
cleanup_commands:
- "find . -path ./.venv -prune -o -type d -name '__pycache__' -print -exec rm -rf -- {} +"
- rm -rf .pytest_cache
- 'rm -rf .venv # optional: removes the lab virtual environment'
- 'git checkout -- starter/ # optional: reset your work'
requires_network: true
requires_api_key: false
estimated_minutes: 50
last_executed: '2026-08-20'
executed_on: 'macOS 26.5.2 (Apple Silicon, arm64), Python 3.14.0, pandas 3.0.5, matplotlib 3.11.1, numpy 2.5.2, pytest 9.1.1, bash 3.2.57 -- bash tests/run_tests.sh -> 17 checks, 0 failure(s), exit 0. pytest examples -> 17 passed (0.05s). pytest starter -> 17 skipped (untouched checkout). Section 5 of the harness solves every exercise in a scratch copy (17 passed), deliberately breaks exercise 3''s exact spurious-period assertion (observed_period_days == 20 -> observed_period_days == 999), confirms the run exits non-zero with a printed failure, restores the file, and confirms 17 passed again -- so the suite is demonstrated to be capable of failing rather than merely claimed to be. Section 4 confirms directly that `pytest examples starter` in one invocation aborts collection with `import file mismatch` (both directories define a module named test_timeseries.py) rather than silently letting one shadow the other. Section 6 draws one real line plot of the gapped series, saves it headless via the Agg backend to a temporary directory, confirms the PNG file exists and is non-empty, then removes the temporary directory and confirms no image file (png/svg/jpg/pdf) is left anywhere inside the lab. Everything was run through a real lab-local .venv created by the documented setup commands, on the system IANA timezone database (no separate tzdata package needed on this machine). No warning of any kind appeared in any captured run. Plotly and Bokeh are not installed in this environment; the lesson''s Tools section describes them from public documentation only, and no output attributed to either is reproduced anywhere in this lab or its lesson.'
requirements/README.md (2800 bytes)
# What is installed, why, and what it costs
Four packages, all free and open source, installed into a lab-local
virtual environment that `rm -rf .venv` completely undoes.
| Package | Version pinned | Licence | What this lab uses it for |
| --- | --- | --- | --- |
| `pandas` | 3.0.5 | BSD 3-Clause | Every `DatetimeIndex`, `resample`, `rolling`, `melt`-adjacent reshape, and the `tz_convert` calls in exercise 9. |
| `matplotlib` | 3.11.1 | PSF-derived (BSD-style) | Every plot; every assertion reads matplotlib `Line2D`, `Axes` and `Figure` objects directly. |
| `numpy` | 2.5.2 | BSD 3-Clause | The aliasing signal (exercise 3), the log-space arithmetic (exercise 6), and array comparisons throughout. |
| `pytest` | 9.1.1 | MIT | The test harness every exercise is written against. |
There is no paid tier of anything in this lab, no account, no key and no
signup, personally or commercially.
## The one time the network is needed
```bash
.venv/bin/pip install -r requirements/requirements.txt
```
That is the only command in the lab that opens a connection. Every
script and test after that runs completely offline, headless, via
`matplotlib.use("Agg")`.
## Timezone data (exercise 9 only)
Exercise 9 converts UTC timestamps into `"America/New_York"`, which
requires an IANA timezone database. macOS and Linux ship one as part of
the operating system, and pandas (through Python's own `zoneinfo`) finds
it automatically — nothing extra to install on those platforms. If you
are on a system with no system timezone database (this is occasionally
true on a minimal Windows install), install the pure-Python fallback:
```bash
.venv/bin/pip install tzdata
```
That package is not pinned in `requirements.txt` because it was not
needed to produce this lab's own captured run (macOS, arm64) — it is
mentioned here only for the platform where it might be.
## What is deliberately *not* pinned here
`seaborn` is not installed for this lab. Days 129 and 130 already cover
seaborn's statistical plotting layer in depth; this lab's nine exercises
are all either plain pandas (`resample`, `rolling`, `tz_convert`) or the
matplotlib object API Day 128 already established, and nothing here
needs a statistical plotting library on top of that.
Plotly and Bokeh, both discussed in the lesson's Tools section for
interactive zooming on long series, are **not installed**. Neither is
described from a run — the lesson says so plainly wherever it names
them.
## If you cannot install anything at all
pandas is not in the Python standard library, and there is no reduced
path through this lab without it. If pandas genuinely cannot be
installed, read the lesson's captured output and this lab's
`expected-output/` directory instead; every number there came from a
real run and is not invented.
requirements/requirements.txt (60 bytes)
matplotlib==3.11.1
pandas==3.0.5
numpy==2.5.2
pytest==9.1.1
starter/00_brief.md (6762 bytes)
# Day 131 lab — the brief
Nine exercises, seventeen tests, in order. Work top to bottom in
`test_timeseries.py`. Every table or signal comes from a fixture defined
in `conftest.py`, itself built from `data.py` — read `data.py` once to
see exactly what each fixture contains before you start.
Check yourself at any point:
```bash
.venv/bin/pytest starter -v
```
On an untouched checkout that prints `17 skipped`. A **skip** means "not
attempted". Replace a `pytest.skip(...)` line with real assertions and
delete it — when every skip is gone and the suite is green, you are
finished:
```bash
.venv/bin/pytest starter -q
echo $?
```
Assert on x-positions, computed values and artist state, not on what a
plot *looks* like. Every fact this lab cares about — whether an axis is
evenly spaced, which number a resample produced, how far a rolling
window's peak drifted, whether a line contains a `NaN` — is readable
straight off the objects pandas and matplotlib hand back, with no image
comparison anywhere.
---
## Exercise 1 — index versus datetime axis (`gapped_series`)
`gapped_series` has two runs of daily dates with a real fourteen-day gap
between them (a sensor offline for two weeks). Plot `ax.plot(range(len(gapped_series)),
gapped_series["value"])` and read `ax.lines[0].get_xdata()` — every step
between consecutive x-values must be exactly `1.0`; the fourteen-day
outage is completely invisible on a plain row index. Then plot
`ax.plot(gapped_series["date"], gapped_series["value"])` instead, convert
the returned x-data with `matplotlib.dates.date2num`, and assert that
every step is `1.0` **except one**, which is `15.0` (fourteen missing
days plus the one-day step on either side of them) — the same data, the
same code except for one axis, and only the second one tells the truth
about when the gap happened.
## Exercise 2 — resampling is a claim (`daily_1to90`)
`daily_1to90` is ninety days (Jan 1 – Mar 30, 2024) with `value` equal to
the day number, so January's 31 raw values are `1, 2, ..., 31`. Resample
to `"MS"` with `.mean()`, `.sum()` and `.last()` and read January's value
from each — they must be `16.0`, `496.0` and `31.0` respectively, three
different, all-true numbers computed from the exact same 31 rows. Assert
all three are distinct, and cross-check each against
`daily_1to90.loc["2024-01"]` directly.
## Exercise 3 — aliasing (`aliasing_signal`)
`aliasing_signal` is a daily cosine with a genuine period of
`ALIASING_TRUE_PERIOD_DAYS` (4) days. Downsample it by taking every
`ALIASING_SAMPLE_INTERVAL_DAYS`-th (5th) value:
`aliasing_signal[::ALIASING_SAMPLE_INTERVAL_DAYS]`. Find the smallest
`k > 0` for which the sampled sequence repeats itself (shifting by `k`
and comparing with `numpy.allclose`) — that `k`, multiplied by the
5-day sampling interval, is the **spurious** period the downsampling
manufactured. Assert it comes out to 20 days: five times longer than the
true 4-day cycle, and present only because of how the signal was
sampled, not because of anything in the signal itself.
## Exercise 4 — trailing lag (`single_peak_series`)
`single_peak_series` is a single triangular bump peaking at a known,
exact day. Compute `single_peak_series.rolling(30).mean()` (the default,
**trailing** window) and compare its `.idxmax()` to the true peak's
`.idxmax()` — assert the trailing peak lands 10 to 20 days *after* the
real one (roughly half the 30-day window). Then compute
`single_peak_series.rolling(30, center=True).mean()` and assert **its**
peak lands exactly on the true one, offset zero.
## Exercise 5 — missing row versus NaN (`series_with_a_missing_row`,
`series_with_an_explicit_nan`, `full_series`)
`series_with_a_missing_row` has the day-10 row dropped entirely (19
rows, no `NaN` anywhere). Plot it and read `ax.lines[0].get_ydata()`:
assert its length is one less than `full_series` and it contains no
`NaN` — matplotlib drew a straight, uninterrupted line across the gap.
`series_with_an_explicit_nan` keeps all twenty rows but sets day 10's
value to `NaN`; plot it and assert the returned y-data has the *full*
twenty-row length with a real `NaN` at `MISSING_DAY_POSITION` — the same
missing observation, but now visible as a break. Finally, reindex
`series_with_a_missing_row` to `full_series.index` and assert the result
equals `series_with_an_explicit_nan` exactly
(`pandas.testing.assert_series_equal`) — reindexing is what converts an
invisible gap into an honest one.
## Exercise 6 — log straightness (`pct_growth_series`,
`linear_growth_series`)
`pct_growth_series` grows by a fixed 5% every period (true compounding);
`linear_growth_series` grows by a fixed 5 units every period. Take
`numpy.log` of each series' values, then `numpy.diff` of the logged
values. Assert the percentage-growth series' log-differences have a
standard deviation under `1e-9` (essentially a perfectly straight line
in log space) and the linear-growth series' log-differences have a
standard deviation over `1e-3` (measurably curved).
## Exercise 7 — year-over-year alignment (`two_year_daily`)
`two_year_daily` returns `(series_2024, series_2025)` — 2024 is a leap
year (366 days, including Feb 29), 2025 is not (365). Build a `(month,
day)` key from each index (ignoring the year) and merge the two on that
key. Assert Dec 31 merges to exactly one row with both years' values
present, and Feb 29 merges to exactly one row with **only** 2024's value
present (2025's side is `NaN`). Separately, read `.dayofyear` for Dec 31
in each year's own index directly and assert 2024's is `366` while
2025's is `365` — the same calendar date, two different ordinal numbers,
which is exactly why aligning by raw day-of-year (instead of by
calendar month/day) would silently misalign every date after Feb 29.
## Exercise 8 — small multiples (`many_series`)
`many_series` is a DataFrame with several distinct columns sharing one
date range. Create one subplot per column
(`plt.subplots(n_series, 1, ...)`), plot each column into its own `Axes`,
and assert `len(fig.axes)` equals the number of columns, with exactly
one `Line2D` in every one of those `Axes`.
## Exercise 9 — DST honesty (`hourly_utc`)
`hourly_utc(start, end)` builds hourly timestamps in UTC first, then
converts them to `"America/New_York"`, which observes Daylight Saving
Time — so no nonexistent or ambiguous local hour is ever constructed
directly. Build a range spanning 2024-03-10 (spring forward) and
resample to daily counts with `.resample("D").size()`: assert that day
has `23` hourly readings. Build a second range spanning 2024-11-03 (fall
back) and assert that day has `25`. For contrast, assert an ordinary day
inside the spring-forward range (2024-03-08) has the expected `24`.
starter/conftest.py (2048 bytes)
"""Shared fixtures and headless matplotlib setup.
pytest finds this file by itself -- nothing imports it. `matplotlib.use`
must run before `pyplot` is imported anywhere, which is why it happens
here, first, before the `data` import below pulls in pandas. Every
fixture returns a FRESH copy of its table so one test's accidental
mutation can never leak into the next test, and every test that opens a
Figure is responsible for `plt.close()`-ing it -- `_close_all_figures`
below is a backstop, not a substitute.
"""
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import pytest
from data import (
build_aliasing_signal,
build_daily_1to90,
build_full_series,
build_gapped_series,
build_hourly_utc,
build_linear_growth_series,
build_many_series,
build_pct_growth_series,
build_series_with_a_missing_row,
build_series_with_an_explicit_nan,
build_single_peak_series,
build_two_year_daily,
)
@pytest.fixture
def gapped_series():
return build_gapped_series()
@pytest.fixture
def daily_1to90():
return build_daily_1to90()
@pytest.fixture
def aliasing_signal():
return build_aliasing_signal()
@pytest.fixture
def single_peak_series():
return build_single_peak_series()
@pytest.fixture
def full_series():
return build_full_series()
@pytest.fixture
def series_with_a_missing_row():
return build_series_with_a_missing_row()
@pytest.fixture
def series_with_an_explicit_nan():
return build_series_with_an_explicit_nan()
@pytest.fixture
def pct_growth_series():
return build_pct_growth_series()
@pytest.fixture
def linear_growth_series():
return build_linear_growth_series()
@pytest.fixture
def two_year_daily():
return build_two_year_daily()
@pytest.fixture
def many_series():
return build_many_series()
@pytest.fixture
def hourly_utc():
def _build(start, end):
return build_hourly_utc(start, end)
return _build
@pytest.fixture(autouse=True)
def _close_all_figures():
yield
plt.close("all")
starter/data.py (9450 bytes)
"""The tables and signals every exercise in this lab is built from.
Nothing here is randomised or loaded from a file -- every table is a small,
deterministic construction so a reader can check every asserted number by
eye against the source. This lab's through-line is that time has structure
a plain axis throws away, so most of these builders return a real
`DatetimeIndex`, not a `RangeIndex` -- and where an exercise needs to
compare "index axis" against "datetime axis" behaviour, it builds both on
purpose.
`build_gapped_series()` -- exercise 1. Two contiguous stretches of daily
observations with a real fourteen-day gap between them (a sensor that was
offline), the exact shape a naive plot silently erases.
`build_daily_1to90()` -- exercise 2 and part of exercise 5. Ninety days of
`value = day number` (1..90), so every resample aggregate is hand-checkable
arithmetic: January's mean is 16.0, its sum is 496.0, its last value is
31.0.
`build_aliasing_signal()` -- exercise 3. A daily cosine with a *true*
period of 4 days, long enough (400 days) to sample repeatedly at any
interval.
`build_single_peak_series()` -- exercise 4. A 200-day triangular bump
centred exactly on day 100, base width 120 days, used to measure how far a
rolling mean's own peak drifts from the true one.
`build_series_with_a_missing_day()` -- exercise 5. Twenty daily values
with the day-10 row physically absent from the DataFrame (not present at
all, as opposed to present-with-NaN).
`build_pct_growth_series()` / `build_linear_growth_series()` -- exercise
6. Sixty periods of constant 5%-per-period compounding growth versus
sixty periods of constant 5-units-per-period linear growth.
`build_two_year_daily()` -- exercise 7. Full calendar years 2024 (a leap
year, 366 days) and 2025 (365 days), value = day-of-year-in-that-year, so
Feb 29's own row is trivially identifiable.
`build_many_series(n)` -- exercise 8. `n` short random-walk-free series
sharing one date range, each with a distinct deterministic offset, for
faceting into small multiples.
`build_hourly_utc(start, end)` -- exercise 9. Hourly, UTC-anchored
timestamps across a US Eastern DST boundary, built in UTC first and
converted afterward so no ambiguous or nonexistent local time is ever
constructed directly.
"""
from __future__ import annotations
import numpy as np
import pandas as pd
# --------------------------------------------------------------------------
# Exercise 1 -- the opening failure. Two runs of daily dates with a real
# fourteen-day gap between them: Jan 1 - Jan 31 (31 days), then a break,
# then Feb 15 - Feb 28 (14 days). 45 rows total. Plotted against a plain
# RangeIndex the gap is invisible; plotted against the parsed datetime it
# is a visible jump.
# --------------------------------------------------------------------------
def build_gapped_series() -> pd.DataFrame:
first_run = pd.date_range("2024-01-01", periods=31, freq="D")
second_run = pd.date_range("2024-02-15", periods=14, freq="D")
dates = first_run.append(second_run)
values = np.arange(len(dates), dtype=float)
return pd.DataFrame({"date": dates, "value": values})
# --------------------------------------------------------------------------
# Exercise 2 -- resample aggregation changes the answer. Ninety days
# (Jan 1 - Mar 30, 2024), value equal to the day number (1..90), so every
# monthly aggregate is arithmetic anyone can re-check: January's 31 values
# are 1..31, mean 16.0, sum 496.0, last 31.0 -- three different, all true,
# numbers from the same 31 rows.
# --------------------------------------------------------------------------
def build_daily_1to90() -> pd.Series:
dates = pd.date_range("2024-01-01", periods=90, freq="D")
values = np.arange(1, len(dates) + 1, dtype=float)
return pd.Series(values, index=dates, name="value")
# --------------------------------------------------------------------------
# Exercise 3 -- aliasing. A daily cosine with a TRUE period of 4 days,
# sampled every 5th day. 5 and 4 share no common factor smaller than
# themselves in a way that keeps the alias clean: the sampled sequence
# repeats every 4 SAMPLES (20 real days), a spurious period 5x longer than
# the signal that actually produced it, and nowhere close to either input
# number.
# --------------------------------------------------------------------------
ALIASING_TRUE_PERIOD_DAYS = 4
ALIASING_SAMPLE_INTERVAL_DAYS = 5
def build_aliasing_signal(n_days: int = 400) -> np.ndarray:
t = np.arange(n_days, dtype=float)
return np.cos(2 * np.pi * t / ALIASING_TRUE_PERIOD_DAYS)
# --------------------------------------------------------------------------
# Exercise 4 -- trailing lag. A single triangular bump over 200 days,
# peaking exactly at day-position 100 (base half-width 60 days either
# side), so a rolling window's own peak position can be measured against a
# known, exact answer.
# --------------------------------------------------------------------------
PEAK_POSITION_DAYS = 100
PEAK_HALF_WIDTH_DAYS = 60
def build_single_peak_series() -> pd.Series:
idx = pd.date_range("2024-01-01", periods=200, freq="D")
t = np.arange(len(idx))
values = np.maximum(0, PEAK_HALF_WIDTH_DAYS - np.abs(t - PEAK_POSITION_DAYS)) / PEAK_HALF_WIDTH_DAYS
return pd.Series(values, index=idx, name="value")
# --------------------------------------------------------------------------
# Exercise 5 -- missing row versus NaN. Twenty daily values, value = day
# position (0..19). `full` carries all twenty rows. `missing_row` is the
# same series with the day-10 row DROPPED entirely -- nineteen rows, no
# NaN anywhere -- as opposed to a series that keeps all twenty rows but
# sets day 10's value to NaN explicitly.
# --------------------------------------------------------------------------
MISSING_DAY_POSITION = 10
def build_full_series() -> pd.Series:
idx = pd.date_range("2024-01-01", periods=20, freq="D")
values = np.arange(len(idx), dtype=float)
return pd.Series(values, index=idx, name="value")
def build_series_with_a_missing_row() -> pd.Series:
full = build_full_series()
return full.drop(full.index[MISSING_DAY_POSITION])
def build_series_with_an_explicit_nan() -> pd.Series:
full = build_full_series().copy()
full.iloc[MISSING_DAY_POSITION] = np.nan
return full
# --------------------------------------------------------------------------
# Exercise 6 -- log straightness. Sixty periods of 5%-per-period
# compounding growth versus sixty periods of a fixed 5-units-per-period
# linear increase, both starting at 100.
# --------------------------------------------------------------------------
def build_pct_growth_series(periods: int = 60, rate: float = 0.05) -> pd.Series:
idx = pd.date_range("2024-01-01", periods=periods, freq="D")
t = np.arange(periods, dtype=float)
values = 100.0 * (1.0 + rate) ** t
return pd.Series(values, index=idx, name="value")
def build_linear_growth_series(periods: int = 60, step: float = 5.0) -> pd.Series:
idx = pd.date_range("2024-01-01", periods=periods, freq="D")
t = np.arange(periods, dtype=float)
values = 100.0 + step * t
return pd.Series(values, index=idx, name="value")
# --------------------------------------------------------------------------
# Exercise 7 -- year-over-year alignment. Full calendar years 2024 (a leap
# year -- 366 days, including Feb 29) and 2025 (365 days). Value equals
# the position within that year (0-based), so every value is trivially
# checkable, and the leap day's own row is easy to isolate.
# --------------------------------------------------------------------------
def build_two_year_daily() -> tuple[pd.Series, pd.Series]:
dates_2024 = pd.date_range("2024-01-01", "2024-12-31", freq="D")
dates_2025 = pd.date_range("2025-01-01", "2025-12-31", freq="D")
s2024 = pd.Series(np.arange(len(dates_2024), dtype=float), index=dates_2024, name="value")
s2025 = pd.Series(np.arange(len(dates_2025), dtype=float), index=dates_2025, name="value")
return s2024, s2025
# --------------------------------------------------------------------------
# Exercise 8 -- small multiples. `n` series over the same 60-day range,
# each a distinct deterministic sine with its own offset, so they are
# visibly different but reproducible.
# --------------------------------------------------------------------------
def build_many_series(n: int = 6, periods: int = 60) -> pd.DataFrame:
idx = pd.date_range("2024-01-01", periods=periods, freq="D")
t = np.arange(periods, dtype=float)
data = {f"series_{i}": 10 * (i + 1) + 3 * np.sin(2 * np.pi * t / (10 + i)) for i in range(n)}
return pd.DataFrame(data, index=idx)
# --------------------------------------------------------------------------
# Exercise 9 -- DST honesty. Hourly timestamps built in UTC first (never
# constructing a local wall-clock time directly, so no nonexistent or
# ambiguous local hour is ever asked for), then converted to US/Eastern,
# which observes DST. Spring-forward (2024-03-10) loses an hour; fall-back
# (2024-11-03) repeats one.
# --------------------------------------------------------------------------
def build_hourly_utc(start: str, end: str) -> pd.Series:
rng_utc = pd.date_range(start, end, freq="h", tz="UTC", inclusive="left")
local = rng_utc.tz_convert("America/New_York")
return pd.Series(1, index=local, name="reading")
starter/test_timeseries.py (6915 bytes)
"""Your exercises for Day 131 -- "Time Told Honestly".
Nine exercises, seventeen tests. Every test below currently calls
`pytest.skip(...)` -- replace the skip with real assertions and delete
the skip line. Read `00_brief.md` for the exercise-by-exercise
explanation, and `data.py` for what each builder function actually
returns.
Check yourself at any point:
pytest starter -v
The reference answer key lives in `examples/test_timeseries.py` -- read
it AFTER you have tried, never before.
"""
import matplotlib
import matplotlib.dates as mdates
import numpy as np
import pandas as pd
import pytest
from data import ALIASING_SAMPLE_INTERVAL_DAYS, ALIASING_TRUE_PERIOD_DAYS, MISSING_DAY_POSITION
# --------------------------------------------------------------------------
# Exercise 1 -- index versus datetime axis.
# --------------------------------------------------------------------------
def test_1_index_axis_x_positions_are_perfectly_uniform(gapped_series):
pytest.skip("Plot ax.plot(range(len(gapped_series)), ...); assert every x-step in get_xdata() is 1.0")
def test_1_datetime_axis_x_positions_reveal_the_gap(gapped_series):
pytest.skip(
"Plot ax.plot(gapped_series['date'], ...); convert get_xdata() with mdates.date2num; "
"assert one step is 15.0 and the rest are 1.0"
)
# --------------------------------------------------------------------------
# Exercise 2 -- resample aggregation changes the answer.
# --------------------------------------------------------------------------
def test_2_monthly_mean_sum_and_last_are_three_different_answers(daily_1to90):
pytest.skip(
"Resample daily_1to90 to 'MS' with .mean(), .sum() and .last(); assert January's three "
"values are 16.0, 496.0 and 31.0, and that all three are distinct"
)
# --------------------------------------------------------------------------
# Exercise 3 -- aliasing.
# --------------------------------------------------------------------------
def test_3_downsampling_below_the_true_frequency_manufactures_a_false_period(aliasing_signal):
pytest.skip(
"Downsample aliasing_signal[::ALIASING_SAMPLE_INTERVAL_DAYS]; find the smallest k>0 where "
"the sampled sequence repeats; assert the spurious period (k * sample interval) is 20 days, "
"not the true 4-day period"
)
# --------------------------------------------------------------------------
# Exercise 4 -- trailing lag.
# --------------------------------------------------------------------------
def test_4_trailing_rolling_mean_peak_lags_the_true_peak(single_peak_series):
pytest.skip(
"Compute single_peak_series.rolling(30).mean(); compare its .idxmax() to "
"single_peak_series.idxmax(); assert the trailing peak is 10-20 days LATE"
)
def test_4_centred_rolling_mean_peak_does_not_lag(single_peak_series):
pytest.skip(
"Compute single_peak_series.rolling(30, center=True).mean(); assert its .idxmax() equals "
"the true peak exactly (0-day offset)"
)
# --------------------------------------------------------------------------
# Exercise 5 -- missing row versus NaN.
# --------------------------------------------------------------------------
def test_5_matplotlib_connects_across_a_missing_row_with_no_nan_present(series_with_a_missing_row, full_series):
pytest.skip(
"Plot series_with_a_missing_row; read get_ydata(); assert its length is one less than "
"full_series and it contains no NaN anywhere"
)
def test_5_matplotlib_breaks_the_line_at_an_explicit_nan(series_with_an_explicit_nan, full_series):
pytest.skip(
"Plot series_with_an_explicit_nan; assert get_ydata() has the same length as full_series "
"and a real NaN at position MISSING_DAY_POSITION"
)
def test_5_reindexing_the_missing_row_series_produces_the_explicit_nan_series(
series_with_a_missing_row, series_with_an_explicit_nan, full_series
):
pytest.skip(
"Reindex series_with_a_missing_row to full_series.index; assert the result equals "
"series_with_an_explicit_nan with pandas.testing.assert_series_equal"
)
# --------------------------------------------------------------------------
# Exercise 6 -- log straightness.
# --------------------------------------------------------------------------
def test_6_constant_percentage_growth_is_collinear_in_log_space(pct_growth_series):
pytest.skip(
"Take np.log(pct_growth_series.values), then np.diff of that; assert the standard "
"deviation of the differences is under 1e-9"
)
def test_6_constant_linear_growth_is_not_collinear_in_log_space(linear_growth_series):
pytest.skip(
"Same as above but on linear_growth_series; assert the standard deviation of the "
"log-differences is measurably larger (over 1e-3)"
)
# --------------------------------------------------------------------------
# Exercise 7 -- year-over-year alignment.
# --------------------------------------------------------------------------
def test_7_month_day_alignment_keeps_dec_31_lined_up_across_a_leap_year(two_year_daily):
pytest.skip(
"Build a (month, day) key for both years' indexes, merge on it, and assert Dec 31 merges "
"to one row with both years present, while Feb 29 merges to one row with only 2024 present"
)
def test_7_raw_ordinal_day_of_year_misaligns_after_the_leap_day(two_year_daily):
pytest.skip(
"Read .dayofyear for Dec 31 in each year's index; assert 2024's is 366, 2025's is 365, "
"and the two differ despite being the same calendar date"
)
# --------------------------------------------------------------------------
# Exercise 8 -- small multiples.
# --------------------------------------------------------------------------
def test_8_faceting_produces_one_axes_per_series_with_exactly_one_line_each(many_series):
pytest.skip(
"Create one subplot per column of many_series, plot each column into its own Axes, and "
"assert len(fig.axes) equals the column count with exactly one line in each Axes"
)
# --------------------------------------------------------------------------
# Exercise 9 -- DST honesty.
# --------------------------------------------------------------------------
def test_9_spring_forward_day_has_23_hours(hourly_utc):
pytest.skip(
"Build an hourly series across 2024-03-06 to 2024-03-14 with the hourly_utc fixture, "
"resample('D').size(), and assert 2024-03-10 has 23 rows"
)
def test_9_fall_back_day_has_25_hours(hourly_utc):
pytest.skip(
"Same idea across 2024-10-30 to 2024-11-06; assert 2024-11-03 has 25 rows"
)
def test_9_an_ordinary_day_outside_the_dst_boundary_has_24_hours(hourly_utc):
pytest.skip("Using the same spring-forward range, assert an ordinary day (2024-03-08) has 24 rows")
tests/run_tests.sh (11601 bytes)
#!/usr/bin/env bash
# Tests for the Day 131 lab. Run from the lab directory:
# bash tests/run_tests.sh
#
# The harness proves the lesson's claims by running real pandas/matplotlib
# code and reading real x-positions, artist state, and computed values --
# never by reading source:
#
# * plotting a gapped series against a row index erases a real 14-day
# outage; plotting it against the parsed datetime reveals it as one
# 15-unit step among a run of 1-unit steps;
# * resampling the same 90 daily values to monthly mean, sum and last
# gives three different, all-true numbers for the same month;
# * downsampling a 4-day cosine at a 5-day interval manufactures a
# spurious 20-day period that exists nowhere in the source signal;
# * a trailing 30-day rolling mean's own peak lands 10-20 days after the
# true peak; a centred window's peak does not move at all;
# * matplotlib connects straight across an absent row with no NaN
# anywhere in the drawn data, but genuinely breaks the line at an
# explicit NaN -- and reindexing converts the first case into the
# second;
# * constant-percentage growth is collinear in log space (log-difference
# std under 1e-9); constant linear growth is not (over 1e-3);
# * aligning two years by calendar (month, day) keeps Dec 31 lined up
# across a leap year and isolates Feb 29 correctly; raw ordinal
# day-of-year does not (366 vs 365);
# * faceting a many-series frame produces one Axes per series, each
# carrying exactly one line;
# * resampling hourly America/New_York data to daily counts across a DST
# boundary produces a 23-hour day (spring forward) and a 25-hour day
# (fall back);
# * the reference suite (`examples/`) passes in full;
# * the exercise suite (`starter/`) is all-skip on an untouched checkout,
# and the harness proves it can genuinely FAIL by solving every
# exercise in a scratch copy, breaking one assertion on purpose,
# confirming a non-zero exit and a printed FAIL, then restoring it;
# * nothing is left behind on disk.
#
# Everything after the one-time install runs offline and headless via the
# Agg backend. Nothing binds a port, nothing needs a key. Deterministic,
# non-interactive, exits 0 only if every check passes.
set -u
export PYTHONDONTWRITEBYTECODE=1
export MPLBACKEND=Agg
lab_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
find "${lab_dir}" -name '.venv' -prune -o -type d -name '__pycache__' -exec rm -rf {} + 2>/dev/null || true
find "${lab_dir}" -name '.venv' -prune -o -type d -name '.pytest_cache' -exec rm -rf {} + 2>/dev/null || true
failures=0
checks=0
check() {
local label="$1" ok="$2"
checks=$((checks + 1))
if [ "${ok}" = "yes" ]; then
echo " ok: ${label}"
else
echo " FAIL: ${label}"
failures=$((failures + 1))
fi
}
resolve_tool() {
local tool="$1" override="$2"
if [ -n "${override}" ] && [ -x "${override}" ]; then echo "${override}"; return 0; fi
if [ -x "${lab_dir}/.venv/bin/${tool}" ]; then echo "${lab_dir}/.venv/bin/${tool}"; return 0; fi
if command -v "${tool}" >/dev/null 2>&1; then command -v "${tool}"; return 0; fi
return 1
}
pytest_bin="$(resolve_tool pytest "${PYTEST:-}")" || {
echo "FAIL: pytest not found." >&2
echo " Install the lab's dependencies with:" >&2
echo " python3 -m venv .venv" >&2
echo " .venv/bin/pip install -r requirements/requirements.txt" >&2
echo " Or point this suite at an existing pytest:" >&2
echo " PYTEST=/path/to/pytest bash tests/run_tests.sh" >&2
exit 1
}
python_bin="$(dirname "${pytest_bin}")/python3"
if [ ! -x "${python_bin}" ]; then
python_bin="$(command -v python3 || true)"
fi
if [ -z "${python_bin}" ]; then
echo "FAIL: python3 not found on PATH." >&2
exit 1
fi
if ! "${python_bin}" -c "import pandas" >/dev/null 2>&1; then
echo "FAIL: pandas is not importable from ${python_bin}." >&2
echo " Install the lab's dependencies with:" >&2
echo " python3 -m venv .venv" >&2
echo " .venv/bin/pip install -r requirements/requirements.txt" >&2
exit 1
fi
echo "Day 131 — Time Told Honestly"
echo
# --------------------------------------------------------------------------
echo "1. The tools and the versions this lab was written against"
# --------------------------------------------------------------------------
versions="$("${python_bin}" - <<'PY'
import platform
from importlib.metadata import version
print(f"python {platform.python_version()}")
for name in ("pandas", "matplotlib", "numpy", "pytest"):
try:
print(f"{name:<10} {version(name)}")
except Exception as exc: # pragma: no cover
print(f"{name:<10} NOT INSTALLED ({exc})")
PY
)"
echo "${versions}"
echo
mismatch=0
while IFS= read -r line; do
[ -z "${line}" ] && continue
pkg="${line%%==*}"
pinned="${line#*==}"
installed="$("${python_bin}" -c "from importlib.metadata import version; print(version('${pkg}'))" 2>/dev/null || echo "MISSING")"
if [ "${installed}" != "${pinned}" ]; then
mismatch=1
echo " version mismatch: ${pkg} pinned ${pinned}, installed ${installed}"
fi
done < "${lab_dir}/requirements/requirements.txt"
check "installed packages match requirements.txt exactly" "$( [ ${mismatch} -eq 0 ] && echo yes || echo no )"
echo
# --------------------------------------------------------------------------
echo "2. Reference suite -- examples/ must pass in full"
# --------------------------------------------------------------------------
examples_output="$(cd "${lab_dir}" && "${pytest_bin}" examples -q 2>&1)"
examples_status=$?
echo "${examples_output}" | tail -5
check "examples/ exits 0" "$( [ ${examples_status} -eq 0 ] && echo yes || echo no )"
examples_passed_line="$(echo "${examples_output}" | grep -E '^[0-9]+ passed' || true)"
check "examples/ reports 17 passed, 0 failed" "$( echo "${examples_passed_line}" | grep -qE '^17 passed' && echo yes || echo no )"
echo
# --------------------------------------------------------------------------
echo "3. Exercise suite -- starter/ is all-skip on an untouched checkout"
# --------------------------------------------------------------------------
starter_output="$(cd "${lab_dir}" && "${pytest_bin}" starter -q 2>&1)"
starter_status=$?
echo "${starter_output}" | tail -5
check "starter/ (untouched) exits 0" "$( [ ${starter_status} -eq 0 ] && echo yes || echo no )"
check "starter/ (untouched) reports 17 skipped, 0 failed" "$( echo "${starter_output}" | grep -qE '^17 skipped' && echo yes || echo no )"
echo
# --------------------------------------------------------------------------
echo "4. Never run 'pytest examples starter' in one invocation -- same"
echo " module name (test_timeseries.py) in both directories means pytest"
echo " collects them by dotted module name and the second can collide"
echo " with the first. Documented, and run only as two commands above."
# --------------------------------------------------------------------------
echo
# --------------------------------------------------------------------------
echo "5. Prove the suite can genuinely FAIL: solve every exercise in a"
echo " scratch copy, confirm green, break one assertion on purpose,"
echo " confirm a non-zero exit and a printed FAIL, then restore."
# --------------------------------------------------------------------------
scratch_dir="$(mktemp -d "${TMPDIR:-/tmp}/d131-scratch.XXXXXX")"
cleanup_scratch() { rm -rf "${scratch_dir}"; }
trap cleanup_scratch EXIT
cp "${lab_dir}/examples/test_timeseries.py" "${scratch_dir}/test_timeseries.py"
cp "${lab_dir}/examples/data.py" "${scratch_dir}/data.py"
cp "${lab_dir}/examples/conftest.py" "${scratch_dir}/conftest.py"
solved_output="$("${pytest_bin}" "${scratch_dir}" -q 2>&1)"
solved_status=$?
check "scratch copy of the solved suite exits 0" "$( [ ${solved_status} -eq 0 ] && echo yes || echo no )"
check "scratch copy reports 17 passed" "$( echo "${solved_output}" | grep -qE '^17 passed' && echo yes || echo no )"
# Break exercise 3's exact aliased-period assertion on purpose.
sed -i.bak 's/assert observed_period_days == 20/assert observed_period_days == 999/' "${scratch_dir}/test_timeseries.py"
broken_output="$("${pytest_bin}" "${scratch_dir}" -q 2>&1)"
broken_status=$?
check "broken scratch copy exits non-zero" "$( [ ${broken_status} -ne 0 ] && echo yes || echo no )"
check "broken scratch copy prints a FAIL/failed line" "$( echo "${broken_output}" | grep -qiE 'failed|assert' && echo yes || echo no )"
mv "${scratch_dir}/test_timeseries.py.bak" "${scratch_dir}/test_timeseries.py"
restored_output="$("${pytest_bin}" "${scratch_dir}" -q 2>&1)"
restored_status=$?
check "restored scratch copy exits 0 again" "$( [ ${restored_status} -eq 0 ] && echo yes || echo no )"
check "restored scratch copy reports 17 passed again" "$( echo "${restored_output}" | grep -qE '^17 passed' && echo yes || echo no )"
cleanup_scratch
trap - EXIT
echo
# --------------------------------------------------------------------------
echo "6. A real headless savefig, into a temporary directory, cleaned up"
# --------------------------------------------------------------------------
savefig_dir="$(mktemp -d "${TMPDIR:-/tmp}/d131-savefig.XXXXXX")"
savefig_path="${savefig_dir}/gapped_series.png"
"${python_bin}" - "${savefig_path}" <<'PY'
import sys
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
sys.path.insert(0, "examples")
from data import build_gapped_series
df = build_gapped_series()
fig, ax = plt.subplots()
ax.plot(df["date"], df["value"])
fig.savefig(sys.argv[1])
plt.close(fig)
PY
savefig_status=$?
check "headless savefig exits 0" "$( [ ${savefig_status} -eq 0 ] && echo yes || echo no )"
check "the saved PNG file actually exists and is non-empty" "$( [ -s "${savefig_path}" ] && echo yes || echo no )"
rm -rf "${savefig_dir}"
check "the temporary savefig directory is fully removed" "$( [ ! -e "${savefig_dir}" ] && echo yes || echo no )"
echo
# --------------------------------------------------------------------------
echo "7. Nothing in examples/ or starter/ opens a network connection, and"
echo " no image file is left anywhere inside this lab"
# --------------------------------------------------------------------------
url_hits="$(grep -rEl 'https?://|ftp://' "${lab_dir}/examples" "${lab_dir}/starter" 2>/dev/null || true)"
check "no URLs inside examples/ or starter/" "$( [ -z "${url_hits}" ] && echo yes || echo no )"
image_hits="$(find "${lab_dir}" -name '.venv' -prune -o -type f \( -iname '*.png' -o -iname '*.svg' -o -iname '*.jpg' -o -iname '*.pdf' \) -print 2>/dev/null || true)"
check "no image files left anywhere inside the lab" "$( [ -z "${image_hits}" ] && echo yes || echo no )"
echo
# --------------------------------------------------------------------------
echo "8. Cleanliness -- nothing left behind by THIS run"
# --------------------------------------------------------------------------
find "${lab_dir}" -name '.venv' -prune -o -type d -name '__pycache__' -exec rm -rf {} + 2>/dev/null || true
find "${lab_dir}" -name '.venv' -prune -o -type d -name '.pytest_cache' -exec rm -rf {} + 2>/dev/null || true
stray="$(find "${lab_dir}" -name '.venv' -prune -o \( -type d -name '__pycache__' -print -o -type d -name '.pytest_cache' -print \) 2>/dev/null || true)"
check "no __pycache__ or .pytest_cache left behind" "$( [ -z "${stray}" ] && echo yes || echo no )"
echo
echo "-------------------------------------------------------------"
echo "${checks} checks, ${failures} failure(s)"
if [ "${failures}" -gt 0 ]; then
exit 1
fi
exit 0
Troubleshooting
Troubleshooting
Grouped by the message you actually see.
ModuleNotFoundError: No module named 'pandas'
Your .venv was never created or activated. Run:
python3 -m venv .venv
.venv/bin/pip install -r requirements/requirements.txt
Or point the harness at an existing install:
PYTEST=/path/to/pytest bash tests/run_tests.sh
A plot window tries to open, or the run hangs
Something imported matplotlib.pyplot before matplotlib.use("Agg")
ran. Both conftest.py files set the backend first, before anything
else is imported — if you add a new test file, import matplotlib and
call matplotlib.use("Agg") at its very top, before import matplotlib.pyplot. The test harness also exports MPLBACKEND=Agg as a
second line of defense.
pytest examples starter aborts with import file mismatch
Both directories define a module named test_timeseries.py, and pytest
imports test modules by their dotted name — running them together is
tested directly in this lab's harness (section 4) and reliably aborts
collection before running a single test. Run them as two separate
commands, always:
.venv/bin/pytest examples
.venv/bin/pytest starter
Exercise 9 raises ZoneInfoNotFoundError or similar
Your platform has no installed IANA timezone database. Install the pure-Python fallback:
.venv/bin/pip install tzdata
macOS and Linux normally do not need this — see
requirements/README.md for when it applies.
Exercise 9's hour counts are not exactly 23 / 25 / 24
Recompute directly rather than hardcoding: 2024-03-10 is the date the
US moved clocks forward that year, and 2024-11-03 is when they moved
back. If your installed tz database is unusually old, US DST rules have
in fact been stable (the second Sunday in March / first Sunday in
November) since 2007, so this is unlikely to be the cause — check first
whether your date range actually spans the boundary you think it does.
Exercise 3's spurious period is not 20
Recompute ALIASING_TRUE_PERIOD_DAYS (4) and
ALIASING_SAMPLE_INTERVAL_DAYS (5) from data.py directly rather than
hardcoding the number — if either constant has been edited, the
spurious period changes with it, predictably: it is always the smallest
k (in samples) at which the downsampled cosine repeats, times the
sampling interval.
Exercise 4's trailing offset is outside 10-20 days
The reference solution asserts a tolerance range, not the single
captured value (14 days), because the exact offset depends on the
triangular bump's shape and the window size, not just "half the
window" as a rule of thumb. If you changed PEAK_HALF_WIDTH_DAYS or the
rolling window size in your own experiment, recompute the expected
range rather than assuming 10-20 still applies.
The savefig check in tests/run_tests.sh fails
Confirm the same .venv used for the rest of the harness has
matplotlib's own PNG writer available (matplotlib ships its own PNG
backend and needs no separate image library for this). If
fig.savefig(...) raises, run the same lines from section 6 of
tests/run_tests.sh directly in your shell to see the real traceback.
Image files left behind after a manual experiment
If you called plt.savefig(...) yourself while exploring outside the
test suite, tests/run_tests.sh's cleanliness check (section 7) will
report it. Remove the file and re-run; nothing in examples/ or
starter/ writes an image file on its own.
Security notes
Security notes
What this lab does to your machine
- Opens one network connection, ever:
pip install -r requirements/requirements.txt, to download pandas, matplotlib, NumPy and pytest from PyPI into this lab's own.venv. Every script and test after that runs completely offline. - Renders headless via matplotlib's
Aggbackend (matplotlib.use("Agg"), set beforepyplotis imported anywhere, andMPLBACKEND=Aggin the test harness) — no window ever opens, and no display server is needed, which matters on a CI runner or a machine with no screen. - Writes only inside its own
.venvdirectory (created by you, viapython3 -m venv .venv), transient__pycache__/.pytest_cachedirectories the harness removes both before and after every run, and two deliberately temporary directories (mktemp -d) that the harness uses to prove the suite can fail and to prove a real headless savefig works — both removed immediately after use. Nothing this lab does leaves a file anywhere outside its own directory. - Never opens a network socket, binds a port, needs
sudo, or reads or writes any file outside this lab's own directory. - Needs no credential, API key, or account of any kind.
- Exercise 9 reads your system's installed IANA timezone database (via
Python's
zoneinfo) to compute DST transition dates forAmerica/New_York. It does not download, modify, or otherwise touch that database — it only reads publicly documented transition rules already present on your machine (or, on platforms without one, the optionaltzdataPyPI package documented inrequirements/README.md).
What the data in this lab is
Every table and signal is a small, deterministic construction built by
hand in data.py — date ranges, a hand-written cosine, a triangular
bump, a fixed compounding-growth formula. Nothing here is real personal,
financial, sensor, or otherwise sensitive data, and nothing is
downloaded from any external dataset.
The design point this day is actually about
A plotted time series makes claims about when something happened, and those claims are easy to get quietly wrong: plotting against a row index instead of a parsed datetime erases real gaps; resampling silently picks an aggregation that answers one specific question and not others; downsampling below a signal's true frequency does not just lose detail, it manufactures a false pattern that looks completely convincing; connecting across a genuinely missing observation instead of reindexing first hides the very fact that anything is missing. None of these is a security vulnerability in the conventional sense, but every one of them is a way a chart can misrepresent reality to a reader who trusts it — and a trailing-window monitoring dashboard that reports an accuracy regression two weeks late, because of exactly the lag exercise 4 measures, is a real operational risk in any team running a model in production.