Skip to content

Finish the dusk step, and stop the capture loop paying for a reading it discards - #16

Merged
ekstremedia merged 10 commits into
mainfrom
fix-dusk-step-and-capture-jitter
Aug 8, 2026
Merged

Finish the dusk step, and stop the capture loop paying for a reading it discards#16
ekstremedia merged 10 commits into
mainfrom
fix-dusk-step-and-capture-jitter

Conversation

@ekstremedia

@ekstremedia ekstremedia commented Aug 7, 2026

Copy link
Copy Markdown
Owner

The white-balance fix in 1.5.0 was correct, but it only covered the colour half
of the problem, and the frames that prompted this report were taken by the old
code. Three separate defects were behind what the camera's owner reported as
"colours still jumping" and "a bit choppy here and there".

The colour pop was already fixed — the daemon just had not restarted

Commit b652d7f reached the working tree at 2026-08-06 22:17. The capture
daemon had been running since the Aug-5 boot and did not restart until
2026-08-07 08:06, so the reported frames (00_23_14 / 00_23_44 — the overlay
prints %H:%M, so "00:23" is exactly two frames) ran the pre-fix code. Every
frame since that restart has been mode='day', so no day→transition crossing
has occurred and the fix has still never executed in production.

Measured in the delivered 4K video, frame 2327 at t=93.1 s:

before after
R/G in the frame 0.851 0.965
luma 109.42 109.57

Luma is flat because deflicker corrects luminance and only luminance. The
colour step passes through untouched. Nothing in this PR changes that path;
it is listed here because it is half the report.

What was still live

1. The exposure stepped 12% at the same boundary. seed_from_metadata
reseeded the loop from the last daylight frame's camera metadata. Metadata
reports what the sensor did, not what was commanded, and this sensor's analogue
gain floor is 1.1228 where ladder.allocate asks for 1.0. Nine consecutive
nights (2026-07-29 → 08-07) show 200043 → 224519 µs in one frame, mean
brightness +8 to +11 points.

The ladder is continuous and label() names a region after the settings are
chosen — "No exposure decision consults it" — so nothing needed carrying across
the boundary. _required already held the correct continuation. The seeding is
deleted, along with _seeded, transition_seeded, reset_seed_state,
seed_exposure, _seed_gain/_seed_wb (both already dead), and the daemon's
_seed_across_mode_change. seed_from_capture is a different function and
stays.

2. The same mistake at every restart. _seed_from_last_capture passed the
database's analogue_gain column into the loop's state. That column holds the
achieved gain; the state is in commanded units. Measured at the 08:06:33
restart:

08:06:14   604 us   brightness 119.9
08:06:33   657 us   brightness 126.2    <- 604 x 1.1228, on the sensor grid
08:06:44   604 us   brightness 120.2

The column is now trusted only where the ladder genuinely commands gain: at the
shutter ceiling.

3. The reference shot — this is the "choppy here and there". Taking it means
tearing the running camera down and opening a second one, and it happens between
the top of the loop and the capture, so that frame lands ~3 s late and the next
reverts. On 2026-08-06: 68 of the day's 2879 intervals off by ≥2 s from 30
hourly on the dot from REFERENCE_MAX_INTERVAL_FRAMES, plus a cluster either
side of midnight from REFERENCE_LADDER_STEP. In a 115-second video that is a
10% playback-speed wobble roughly once a second, worst at dawn and dusk, which
is where the choppiness was reported as worst.

On a camera with fixed_colour_gains the reading is then discarded:
_target_colour_gains prefers the configured value. _wants_reference_shot's
own docstring already said the path was "dead weight" and told the operator to
set test_shot.enabled: false by hand. The controller now answers that itself
via learns_day_wb, which lives next to the precedence it describes. Cameras
without a configured white point are unaffected.

4. Capture cadence. sleep = interval - elapsed clamps at zero, so an
iteration that overruns its slot moved every later frame by the overrun,
permanently. Times now land on multiples of the interval since the epoch.

5. -r was after -i in the ffmpeg command, making it an output-side
constant-rate conversion against the concat demuxer's implicit 25 fps. Measured
on ffmpeg 5.1.9 with 50 real images at -r 30: 60 encoded frames after -i,
50 before it
. A no-op at the configured 25, and would have bitten the first
time anyone changed video.fps. Not -framerate — the concat demuxer has no
such option and ffmpeg exits.

The golden re-record

Removing the dusk seeding changes an exposure decision, so
tests/test_replay_golden.py goes red by design. tests/replay/README.md sets
the bar for record_golden.py: the commit's whole purpose is the behaviour
change, and something other than the tests says it is better. Checked rather
than assumed:

across all 17 sequences golden new
flicker (stops/frame) 0.06753 0.06689 — better or equal on every one
brightness error 30.15 31.34

The brightness figure is not a regression in anything production does. Only the
seven recorded sequences changed, and they are exactly the ones with no seed
key — they cold-start at 20 ms and climb. In the golden, the seeding teleported
them to the recorded ground truth at the first crossing; a controller has no
such oracle. Excluding those first 20 frames the two agree: crashing_light
79.97 vs 79.97, dusk_transition 5.70 vs 5.70. The daemon seeds from the
database at startup, so it never cold-starts this way.

mutation_check.py lost overexposure critical to the change — the violent
overshoot the seeding produced was the only thing reaching that branch. Verified
against the pre-change tree that all 33 were caught before, added
synthetic_overexposure_critical_edge to replace it deliberately, and all 33
are caught again.

Deliberately not done

  • The exposure quantisation limit cycle (315 ↔ 368 µs for 30 frames) is
    real, but deflicker already removes it: source 99th-percentile frame delta
    4.56% → 0.95% in the video, frames changing >5%: 21 → 0. The obvious fix —
    damping the bright end — is recorded in Meter.speed's docstring as already
    tried and "measurably worse", and simulation confirms the cycle's amplitude is
    set by the sensor grid rather than the loop gain. A better approach exists
    (closing the loop on the achieved exposure) and belongs in its own PR.
  • Cloud strobing. Each daytime frame is a ~260 µs sample of a 30-second
    interval, so there is no motion blur and moving things teleport. Only more
    frames or synthesised blur can change that, and the interval is staying at 30 s
    for now.

Verification

  • 1141 tests passing, ruff clean.
  • Every fix checked by reverting it and confirming the new test fails. The
    daemon-level one fails with the production numbers: "the controller holds
    0.22460968588399888 but handed the camera 0.2"
    .
  • Deployed to sortlandcam-3 at 15:49. Capture clean, no warnings, frames on
    the 30-second grid.
  • Outstanding: tonight's dusk (crossings have landed between 23:32 and
    01:21) is the first day→transition crossing since the daemon picked up 1.5.0,
    and so the first live test of both the colour fix and this one.

🤖 Generated with Claude Code

https://claude.ai/code/session_01DmLb2SeiKE4hdQXptxhaja

Summary by CodeRabbit

  • Bug Fixes
    • Improved exposure transitions between daylight, dusk, and night to avoid abrupt changes and overexposure.
    • Prevented unnecessary white-balance reference captures when fixed gains are configured.
    • Improved restart exposure handling and frame-timing accuracy.
    • Fixed video creation so FFmpeg preserves the intended frame rate without duplicated or dropped frames.
  • New Features
    • Added absolute-grid timelapse scheduling that skips missed intervals cleanly.
    • Added stronger protection for critical overexposure conditions.
  • Tests
    • Expanded replay coverage for lighting transitions, dark scenes, and overexposure edge cases.

Also carried on this branch

docs/upgrade-runbook was merged in (a6fa0dc). It has no PR of its own, so it
would otherwise sit orphaned on a branch. It is documentation only — no source
files, and the diff against the code commits is two files and 814 lines of
Markdown.

  • UpgradeOldRaspilapse.md — taking a pre-1.5.0 camera to 1.5.0: what
    breaks, the config keys that went inert, the database step, which units to
    install, and a rollback. Ends with a summary of the traps.
  • newcam.md — standing up a new camera Pi end to end, including the
    python-reverb half that raspilapse alone does not provide (no live image and
    no vitals without it) and the upload-retry unit that has no install script.

ekstremedia and others added 5 commits August 7, 2026 15:39
The behaviour change is the entire purpose of this commit, and it re-records
the golden files. That is the bar tests/replay/README.md sets for running
record_golden.py, and the evidence is below.

seed_from_metadata reseeded the loop from the last daylight frame's *camera*
metadata. Metadata reports what the sensor did, not what was commanded, and
this sensor's analogue gain floor is 1.1228 where the ladder asks for 1.0 --
so _required was multiplied by 1.12 at every dusk. Nine consecutive nights in
this camera's database show it, 2026-07-29 through 2026-08-07:

    00:23:14  200043 us  brightness 109.9
    00:23:44  224519 us  brightness 118.4    x1.1223, one frame

Nothing needed seeding. The ladder is continuous and label() names a region
after the settings are chosen -- "No exposure decision consults it" -- so
_required already held the correct continuation. The colour half of this same
function was deleted for the same class of reason in b652d7f; this is the rest
of it.

Golden vs new, on compare.py's own metrics across all 17 sequences:

    flicker (stops/frame)   0.06753 -> 0.06689   better or equal on every one
    brightness error          30.15 -> 31.34     entirely the cold-start ramp

The brightness figure is not a regression in anything production does. Only
the seven *recorded* sequences changed, and they are exactly the ones with no
`seed` key, so they cold-start at 20 ms and climb. In the golden the seeding
teleported them to the recorded ground truth at the first crossing; a
controller has no such oracle. Excluding those first 20 frames the two agree:
crashing_light 79.97 vs 79.97, dusk_transition 5.70 vs 5.70. The daemon seeds
from the database at startup, so it never cold-starts this way.

mutation_check lost "overexposure critical" to this change, because the
overshoot the seeding produced was what used to reach that branch.
synthetic_overexposure_critical_edge replaces it deliberately; all 33 are
caught again.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DmLb2SeiKE4hdQXptxhaja
`-r` sat after `-i`, which makes it an output option. Against a concat input
that is a constant-rate conversion rather than a declaration: the demuxer
inherits image2's implicit 25 fps and ffmpeg duplicates or drops frames to
reach the requested rate. Measured on ffmpeg 5.1.9 with 50 real source images
at `-r 30`:

    -r after  -i :  50 images -> 60 encoded frames
    -r before -i :  50 images -> 50 encoded frames

At the configured `video.fps: 25` both rates agree and it is a no-op, so
nothing has been wrong in the delivered videos. It would have gone wrong the
first time anyone changed fps to make the day play slower, which is a
plausible next request.

Not `-framerate`: the concat demuxer has no such option and ffmpeg exits with
"Option framerate not found".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DmLb2SeiKE4hdQXptxhaja
Three fixes to the loop's timing and its startup state.

**The reference shot.** Taking it means tearing the running camera down and
opening a second one -- libcamera will not hold two -- and it happens between
the top of the loop and the capture, so that frame lands about three seconds
late and the next one reverts. Measured on 2026-08-06: 68 of the day's 2879
intervals off by two seconds or more from 30, hourly on the dot from
REFERENCE_MAX_INTERVAL_FRAMES plus a cluster either side of midnight from
REFERENCE_LADDER_STEP. In a 115-second video that is a 10% wobble in playback
speed roughly once a second, worst at dawn and dusk -- which is where the
camera's owner reports the timelapse feels choppiest.

On a camera with `fixed_colour_gains` the reading is then discarded:
_target_colour_gains prefers the configured value and never consults the
learned reference. _wants_reference_shot's own docstring already said so and
told the operator to set `test_shot.enabled: false` by hand. Nobody did. The
controller now answers that question itself, via `learns_day_wb`, which lives
next to the precedence it describes rather than being a second copy of it.
Cameras without a configured white point are unaffected.

**The capture grid.** `sleep = interval - (now - loop_start)` clamps at zero,
so an iteration that overruns its slot moves every later frame by the overrun,
permanently. Capture times now land on multiples of the interval since the
epoch: an overrun costs its own slots and no more, a restart resumes the
previous process's phase, and the tens-of-milliseconds-per-frame creep from
excluding the wake-up from `elapsed` is gone. This does not fix a frame that
runs late inside its own slot -- the old code recovered from those too. The
reference shot was the source of those, and it is gone.

**The restart seed.** `_seed_from_last_capture` passed the database's
analogue_gain column straight into the loop's state. That column holds what
the sensor reported; the state is in commanded units, and ladder.allocate
keeps gain at 1.0 until the shutter is at its ceiling while this sensor
answers 1.1228 regardless. In flight the constant is absorbed -- it is a
feedback loop and nothing reads it back -- but here it is read back as a
command. Measured at the restart on 2026-08-07 08:06:33:

    08:06:14   604 us   brightness 119.9
    08:06:33   657 us   brightness 126.2     <- 604 x 1.1228, on the sensor grid
    08:06:44   604 us   brightness 120.2

Same defect as the dusk seeding removed in the previous commit, once per
restart instead of once per dusk. The column is trusted only where the ladder
genuinely commands gain: at the shutter ceiling.

No golden files change. None of this touches an exposure decision the replay
corpus exercises.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DmLb2SeiKE4hdQXptxhaja
…changelog

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DmLb2SeiKE4hdQXptxhaja
Written while taking spjutvikacam from 2b6e0af to 1.5.0. The changelog does
not mention the src/ -> raspilapse/ package rename, yet that is what makes
every installed ExecStart a dead path -- the camera cannot start between the
pull and re-running the installer. That belonged in writing before the next
camera is upgraded rather than after.

UpgradeOldRaspilapse.md covers the config keys that are now inert (reference_lux
and civil_twilight_threshold are the two that will surprise you), the automatic
v3 -> v6 database migration, the retention dry-run that has to happen before
video.retention_days is ever set, and the NetworkManager precondition for
--with-netwatch, which is a no-op-plus-reboot-loop on a dhcpcd Pi.

It also records what the encoder change actually costs. Measured on the same
2877 frames: veryfast/crf20/3 threads runs at 1.6 fps against 0.9 for
fast/crf25/2, but the daily video goes 316 MB -> 821 MB, well past the ~500 MB
the changelog estimates. A coastal scene with moving water compresses worse
than whatever that figure came from, so the receiving server's bandwidth is
worth checking before the override is deleted.

Four traps cost real time and are called out explicitly: install.sh --check
exits 1 even when it prints "Ready to install", because its EXIT trap ends on a
false test; an EPERM on /dev/media* is usually a confined shell rather than a
wedged camera, and inherited seccomp means sudo does not lift it; sibling repos
must be grepped for "from src" as well as src/ paths; and a re-encoded day will
not re-upload without clearing its upload_queue row first.

newcam.md was already written and had been sitting untracked since July. It is
the other half of the same story -- build a camera, upgrade a camera -- so it is
tracked here too.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D9G6CYxVkhKu4ahBGncWD3
@coderabbitai

coderabbitai Bot commented Aug 7, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The change removes exposure handover seeding, adds epoch-aligned capture scheduling, makes white-balance reference shots conditional, corrects FFmpeg input-rate handling, and updates replay coverage for exposure transitions and critical overexposure.

Changes

Exposure and timelapse behavior

Layer / File(s) Summary
Continuous exposure control and white-balance policy
raspilapse/camera/exposure.py, raspilapse/daemon.py, tests/test_auto_timelapse.py, tests/test_exposure.py
Removed metadata-based handover state. Restart seeding now handles gain according to shutter position. Fixed colour gains suppress reference shots. Tests cover controller invariants and transition continuity.
Absolute capture-slot scheduling
raspilapse/daemon.py, tests/test_auto_timelapse.py
Capture scheduling uses absolute epoch slots and skips slots consumed by overlong captures.
FFmpeg input-rate handling
raspilapse/video/timelapse.py, tests/test_make_timelapse.py
The frame-rate option now appears before the concat input. Tests verify one input-rate option and no -framerate option.
Replay trajectories and critical overexposure coverage
tests/replay/golden/*, tests/replay/harness.py, tests/replay/sequences/*, tests/replay/synthetic_sequences.py, CHANGELOG.md
Updated day-to-night replay trajectories and added a synthetic critical-overexposure sequence with corresponding golden data and release notes.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant CaptureLoop
  participant AdaptiveTimelapse
  participant ExposureController
  participant Camera
  CaptureLoop->>AdaptiveTimelapse: request decision at absolute slot
  AdaptiveTimelapse->>ExposureController: decide()
  ExposureController-->>AdaptiveTimelapse: exposure decision and diagnostics
  AdaptiveTimelapse->>Camera: capture using commanded settings
  Camera-->>CaptureLoop: capture completes
  CaptureLoop->>CaptureLoop: advance or skip elapsed slots
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 79.17% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately identifies the dusk exposure change and the removal of unnecessary readings, which are central parts of the pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix-dusk-step-and-capture-jitter

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

@codecov

codecov Bot commented Aug 7, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 97.36842% with 1 line in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
raspilapse/daemon.py 96.55% 0 Missing and 1 partial ⚠️

📢 Thoughts on this report? Let us know!

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🧹 Nitpick comments (2)
tests/replay/synthetic_sequences.py (1)

379-379: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Document or derive the 254.0 brightness cap.

The cap is load-bearing. scene_luminance in tests/replay/harness.py rejects a frame when brightness >= SATURATED (line 126), which makes measured_brightness None and stops the controller from observing. This sequence saturates from frame 15 onward, so the whole tail depends on 254.0 staying strictly below SATURATED. If SATURATED is ever lowered to 254.0, the tail becomes unmeasured frames and the golden silently loses the converged behavior it records.

Tie the cap to the constant, or state the dependency in the comment block.

♻️ Proposed change to derive the cap

Import the constant at the top of the file:

from tests.replay.harness import SATURATED

Then apply this diff:

-        "frames": [frame(50, min(254.0, 80.0 * 1.09**i), exposure_us=1_000_000) for i in range(60)],
+        # One below SATURATED: at or above it scene_luminance() returns None
+        # and the controller stops observing, which would blank the tail.
+        "frames": [
+            frame(50, min(SATURATED - 1.0, 80.0 * 1.09**i), exposure_us=1_000_000)
+            for i in range(60)
+        ],

Run the following script to confirm the value of SATURATED and that no other sequence already imports it:

#!/bin/bash
# Description: Locate the SATURATED constant and check existing import patterns.

fd -t f 'harness.py' tests | xargs rg -n '^SATURATED' -C 2
rg -n 'SATURATED' --type=py tests
rg -n '^from|^import' tests/replay/synthetic_sequences.py
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/replay/synthetic_sequences.py` at line 379, Update the brightness cap
in the sequence definition around the frames list to derive from the SATURATED
constant imported from tests.replay.harness, while keeping the cap strictly
below SATURATED so scene_luminance continues to produce measurements for the
tail frames.
tests/test_make_timelapse.py (1)

284-315: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Add an FFmpeg integration test.

Create valid images, call create_video without mocking subprocess.run, and use ffprobe to assert that fps=30 preserves the input frame count. Provision ffmpeg and ffprobe in CI because the workflow does not install them explicitly.

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

In `@tests/test_make_timelapse.py` around lines 284 - 315, Replace the mocked
subprocess test test_the_frame_rate_is_set_on_the_input_not_the_output with an
FFmpeg integration test that generates valid input images, invokes create_video
without patching subprocess.run, and uses ffprobe to verify the output reports
fps=30 while preserving the input frame count. Update the CI workflow to
explicitly install or provision both ffmpeg and ffprobe so the test runs in CI.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@raspilapse/camera/exposure.py`:
- Around line 367-390: Update the learns_day_wb policy and its callers so
reference learning is permitted only when the current mode is LightMode.DAY and
fixed_colour_gains are not configured. Ensure _wants_reference_shot() or the
equivalent decision path rejects transition and night modes before triggering
update_day_wb_reference(), and add coverage for both non-day states.

In `@raspilapse/daemon.py`:
- Around line 257-277: Update the ceiling classification in the restart seeding
logic around at_the_ceiling so a delivered shutter slightly below
self.exposure.max_shutter is still treated as ceiling exposure, using the
camera’s exposure quantization tolerance. Preserve seed_gain for gain-controlled
frames at that quantized ceiling and add a restart test covering a delivered
shutter just below the configured maximum.
- Around line 1064-1079: Update the initialization-failure recovery path near
the retry continue to advance next_slot using the same interval-grid logic as
the normal scheduling block before retrying, including skipping all elapsed
slots when recovery exceeds an interval; factor or reuse that logic to avoid
divergence, and add a regression test covering a failure lasting longer than one
interval.

---

Nitpick comments:
In `@tests/replay/synthetic_sequences.py`:
- Line 379: Update the brightness cap in the sequence definition around the
frames list to derive from the SATURATED constant imported from
tests.replay.harness, while keeping the cap strictly below SATURATED so
scene_luminance continues to produce measurements for the tail frames.

In `@tests/test_make_timelapse.py`:
- Around line 284-315: Replace the mocked subprocess test
test_the_frame_rate_is_set_on_the_input_not_the_output with an FFmpeg
integration test that generates valid input images, invokes create_video without
patching subprocess.run, and uses ffprobe to verify the output reports fps=30
while preserving the input frame count. Update the CI workflow to explicitly
install or provision both ffmpeg and ffprobe so the test runs in CI.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

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

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: f09a1719-2110-45f8-851b-d4a2a6905a1d

📥 Commits

Reviewing files that changed from the base of the PR and between 62c6422 and 5afc118.

📒 Files selected for processing (18)
  • CHANGELOG.md
  • raspilapse/camera/exposure.py
  • raspilapse/daemon.py
  • raspilapse/video/timelapse.py
  • tests/replay/golden/bright_night.json
  • tests/replay/golden/crashing_light.json
  • tests/replay/golden/dawn_transition.json
  • tests/replay/golden/deep_dark.json
  • tests/replay/golden/dusk_transition.json
  • tests/replay/golden/night_underexposure_edge.json
  • tests/replay/golden/synthetic_overexposure_critical_edge.json
  • tests/replay/golden/very_bright_night.json
  • tests/replay/harness.py
  • tests/replay/sequences/synthetic_overexposure_critical_edge.json
  • tests/replay/synthetic_sequences.py
  • tests/test_auto_timelapse.py
  • tests/test_exposure.py
  • tests/test_make_timelapse.py

Comment thread raspilapse/camera/exposure.py Outdated
Comment thread raspilapse/daemon.py
Comment thread raspilapse/daemon.py Outdated
ekstremedia and others added 5 commits August 7, 2026 16:12
…ntroduced

**The ceiling test rejected every real night frame.** `at_the_ceiling` compared
the database's exposure column against `max_shutter` with `>=`, but that column
records what the camera *delivered* and the camera under-delivers: a commanded
20 s comes back as 19999994 us. This database holds 62556 gain-controlled rows
at that value and not one at exactly 20000000, so the test was false for all of
them and a night restart would have been seeded at gain 1.0 -- up to six times
too dark. That is worse than the daylight bug the check exists for, and it was
introduced by the previous commit. Now compared with a 0.1% tolerance: 235x the
worst under-delivery on record, and still far tighter than any ladder step.

**`learns_day_wb` ignored the mode.** `update_day_wb_reference` drops every
reading taken outside DAY -- AWB has nothing to meter in the dark -- but it did
so on the far side of the camera teardown, so a camera without configured gains
paid the full price and threw the answer away. Dusk is the worst case: the
ladder crosses most of its range and REFERENCE_LADDER_STEP fires a couple of
dozen times. Pre-existing, and squarely what this branch is about.

**The camera-init failure path bypassed the grid.** It slept and `continue`d
straight past the scheduling, so the retry fired the moment it succeeded rather
than on a slot, and a failure lasting longer than an interval left the schedule
behind without skipping the slots it had missed. Both paths now go through
`_sleep_until_next_slot`.

Also derived the synthetic sequence's brightness cap from `SATURATED` rather
than writing 254.0 by hand -- scene_luminance() returns None at or above it, so
a cap that ever met it would blank the sequence's converged tail silently. The
generated fixture is byte-identical, so no golden re-record.

Not taken: the suggestion to replace the mocked ffmpeg test with an integration
test. The argument *position* is what regressed and the mocked test pins it
exactly; the real-ffmpeg measurement is recorded in its docstring (50 images ->
60 frames with `-r` after `-i`, 50 before), and putting ffmpeg into CI to
re-derive a constant is a bigger change than the finding warrants.

1147 tests, all 33 mutations still caught, each fix verified by reverting it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DmLb2SeiKE4hdQXptxhaja
codecov flagged the three call sites in run() that reach the scheduling
helpers. The helpers themselves were already unit-tested; what was not was that
the loop calls them, keeps what they return, and does so on every path -- which
is precisely the gap that let the camera-init branch `continue` past the
schedule in the first place.

Two bounded runs with the camera mocked, one clean and one where
initialize_camera raises before succeeding. Verified by reverting the init path
to its bare `time.sleep(min(interval, 5))`: the second test fails.

Two things the harness has to get right, both of which cost a hang to find:
frame_count is incremented inside capture_frame, so mocking that away leaves
test_mode's exit condition unreachable and the loop spinning; and the stub now
clears `running` after a bounded number of iterations, so a future change to
that exit condition fails the assertion rather than hanging the suite.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DmLb2SeiKE4hdQXptxhaja
CodeRabbit's docstring check reads the functions a PR adds, not the package --
raspilapse/ itself sits at 94.5%. Six of the ones added here had none: two test
methods, and four helpers whose reasoning was in comments above them rather
than inside them. The two nested stubs in particular are worth stating plainly,
since both encode a trap that costs a hung test run to rediscover.

Also picked up the two pre-existing reference-shot tests in the class this
branch already edits, which the diff attributes here because the insertions
moved their line numbers.

Functions added by this branch: 34/34 documented.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DmLb2SeiKE4hdQXptxhaja
The old cameras carry a blind `sudo reboot` cron from before any watchdog
existed, and nobody remembers what it was guarding. Now that the capture
watchdog handles a stalled camera in about fifteen minutes, that cron is doing
less than it used to -- but "less" is not an interval, so this adds the query
that turns it into one.

Bucketing missed-frame gaps by hour of day is enough: if they pile up in the
hour the cron fires, the reboot is the main thing interrupting the camera. On
spjutvikacam that was 14 of 20 events in 209 days, against roughly three
unexplained glitches in seven months, with capture having run 93, 72 and 42
days straight and RSS flat at 89 MB. It moved to weekly on that evidence.

Two things kept it from being dropped outright, and both are recorded here
because they are easy to talk yourself out of: a blind reboot cannot clear a
hard wedge, since cron does not run either -- this camera lost 56 hours in
February while on a two-day cycle -- and with netwatch unusable on a dhcpcd Pi
the cron is the last thing covering a wedged network.

Also notes that day-of-month `*/7` is not weekly. It fires on the 1st, 8th,
15th, 22nd and 29th and then gaps three days, which is the sort of thing that
looks right in a crontab and is not.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D9G6CYxVkhKu4ahBGncWD3
@ekstremedia
ekstremedia merged commit cc46174 into main Aug 8, 2026
8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant