Skip to content

Restructure into a package, and replace the three exposure modes with one ladder - #13

Merged
ekstremedia merged 16 commits into
mainfrom
refactor
Jul 27, 2026
Merged

Restructure into a package, and replace the three exposure modes with one ladder#13
ekstremedia merged 16 commits into
mainfrom
refactor

Conversation

@ekstremedia

@ekstremedia ekstremedia commented Jul 27, 2026

Copy link
Copy Markdown
Owner

Eight commits, each verified against the live camera at Kringelen, which
captured continuously throughout.

Three goals: make the code readable, make the optional parts genuinely
optional, and make the exposure work anywhere rather than only at 68°N.

Making it work anywhere

Mode selection is gone. It compared an uncalibrated lux figure against
absolute thresholds — night: 3, day: 80 — that had to be retuned for
every camera and every site, and were overridden at high latitude by a
sun-elevation check because the thresholds could not survive being moved.

In its place, one ladder. A longer shutter costs time; more gain costs
noise, so the order is forced:

required = current * (target / measured) ** damping
shutter  = min(required, ceiling)
gain     = required / shutter

That turned out to be the simplification rather than a trade against
it. _settings_day and _settings_transition were the same function
with different log strings, and _settings_night differed only in where
it started. exposure.py went from 1418 lines to 440.

mode survives as a label derived from the settings, so it can no longer
disagree with what the camera is doing. The old one could: 368k frames
were labelled "day", and among them were frames at a 20-second exposure
and gain 5.5, because the polar override set the label while the shutter
was wide open.

astral is demoted, not removed. It was used, but as a patch rather
than a feature — its docstring claimed it preserved AWB through twilight,
and day mode had set AwbEnable = 0 for a long time. Sun elevation is
still recorded with every frame and still feeds graph_solar_patterns.py.
Nothing decides from it, and without astral installed the column is NULL
and the camera behaves identically.

Is the new exposure better, or just different?

tests/replay/compare.py runs both controllers against the same recorded
light with the loop closed, over 3400 frames of this camera's history:

old new
brightness error 35.0 25.6
flicker, stops per frame 0.077 0.031
settled 38% 61%

Closing that loop mattered, and getting it wrong first was instructive.
Replaying recorded brightness is open-loop: the measurement is a
property of the exposure the old controller happened to be using, so the
new one lowers exposure, nothing responds, and it lowers again into a
runaway that cannot happen on a camera. It reported the ladder as 13
stops darker. Deriving scene luminance and showing each controller the
brightness its own choice would have produced fixed it — and immediately
found two real bugs: a near-black frame held the exposure where it was,
and a uniform rate limit made daylight sluggish.

Making the optional parts optional

overlay.enabled: false was silently disabling all database logging.
ImageOverlay.__init__ returned before assigning self.weather, and the
capture loop read capture.overlay.weather inside an except Exception
that logged at DEBUG. Weather is a data source the daemon owns now, and
capture.py no longer imports the renderer at all — so Pillow is out of
the capture path entirely.

The install is three apt packages:

sudo apt install -y python3-picamera2 python3-yaml ffmpeg

It used to be seven plus a pip line. Two of those were redundant —
python3-picamera2 depends on numpy and Pillow — and the rest each buy
one feature and now degrade cleanly when absent.

Readability

  • 17 dual-import blocks → 0. src/ is a raspilapse package grouped
    by what each part talks to. That also removed mypy's project-wide
    no-redef suppression and ruff's E402 exemption.
  • config.example.yml: 681 lines → 69. Everything the code indexes
    without a fallback has a default in raspilapse/config.py; the full
    annotated schema moved to docs/CONFIG-REFERENCE.yml. An eight-line
    config works.
  • run(): 287 lines → 97, decomposed into named steps.
  • Camera cycles: 40 → 22 per 20 frames, by taking the metering shot
    off every frame.

Bugs fixed

Each confirmed by reverting it and watching a test fail.

  1. overlay.enabled: false silently disabled all database logging.
  2. The package move broke the ship-icon path — silently, because an
    exists() check swallowed it.
  3. The move also broke the 05:00 daily video job: daily.py shelled
    out to a path assembled from separate arguments, so grepping for
    src/ missed it. Its test asserted only that "make_timelapse.py"
    appeared in the command, which stayed true after the file stopped
    existing.
  4. A near-black frame held the exposure instead of opening up.
  5. A uniform rate limit made the bright end sluggish.
  6. lux was computed from commanded settings against a brightness measured
    at the delivered ones.

Two things built, measured, and thrown away

The plan said keep the camera open between frames. It cannot be done: a
commanded exposure took eight frames to reach the metadata, with four
consecutive captures returning a value from two commands earlier. At a
20-second night exposure that is 160 seconds of wrong frames.

Then a "skip the teardown when settings barely move" path — the sensor
quantises 217 µs to 210 µs, so command and delivery always differ by more
than any useful tolerance and the branch never fired. Both are gone
rather than left in looking useful.

Reported, not fixed

  • highlight_protection.min_scale below 0.70 is dead configuration: the
    curve reaches 0.70 at p95 255, the highest an 8-bit frame has.
  • This camera runs out of exposure range on bright summer days. It
    commands 100 µs at gain 1.0 — the bottom of the ladder — and the sensor
    delivers 210 µs at gain 1.12, leaving frames about a stop overexposed.
    This predates the branch: 13 July had 893 frames pinned at minimum
    exposure averaging brightness 153. It is optics, not software.

Testing

831 → 985 tests. The golden-master harness in tests/replay/ records
what the controller decides for twelve sequences — six pulled from the
capture database, six synthetic — and mutation_check.py breaks one
constant at a time to prove those tests can fail. All 33 mutations are
caught, and three further constants are documented as absent with
reasons rather than quietly dropped.

Golden files were re-recorded once, for the ladder, which is what those
tests exist to make deliberate.

Not done

overlay/render.py is still 1186 lines with a 469-line apply_overlay.
It is the last structural item; exposure.py no longer needs one.

🤖 Generated with Claude Code

https://claude.ai/code/session_0123LLDR874Fyp1gbjAVbR2a

Summary by CodeRabbit

  • New Features
    • Added installable command-line tools for capture, status, snapshots, timelapse generation, overlays, database maintenance, and upload retries.
    • Added default configuration support, allowing minimal config files with documented fallback values.
    • Introduced brightness-feedback exposure control using a continuous exposure ladder with highlight protection.
  • Documentation
    • Added a comprehensive configuration reference and refreshed installation, usage, exposure, overlay, and troubleshooting guidance (including updated module-based commands).
  • Bug Fixes
    • Optional features now degrade gracefully when extras are unavailable, with clearer warnings/errors.
    • Overlay-disabled operation no longer interferes with weather and system data collection.
  • Chores
    • Released version 1.4.0.

ekstremedia and others added 8 commits July 27, 2026 11:56
The refactor ahead moves every module in the project. This is what will
show whether moving them changed any exposure decision.

Recorded light goes in -- lux, sun elevation, brightness metrics, camera
metadata -- and every settings dict the controller produces comes out,
compared against output recorded from the code as it stands now. The
harness drives the controller through the same call order as the capture
loop, so a divergence means the reorganisation changed behaviour.

Twelve sequences. Six are real, pulled from the capture database: the
dawn and dusk mode boundaries, a converged day, a bright polar night, the
darkest frames on record, and a midsummer noon against the top stop. Six
are synthetic, and exist because mutating the controller's constants one
at a time showed the recorded ones were not enough -- the feedback ratio
clamps, the underexposure thresholds and the clipped-pixel thresholds all
survived being changed.

mutation_check.py is that experiment, kept: it breaks one constant at a
time and asserts the golden tests notice. All 32 mutations are caught.
Every sequence in the synthetic set exists because a mutation survived
until it was added, and three needed the masking diagnosed first --
saturated exposure, an entering-night branch that overrides the ramp
speeds, and an EMA that only lands on a threshold exactly once.

Two findings fell out of that work, both left alone for now:

  - min_scale below 0.70 is dead configuration. highlight_factor's last
    segment reaches 0.70 at p95 255, the highest an 8-bit frame can have,
    before the floor is ever consulted.

  - lux is identical across consecutive daylight frames in the database,
    because the test shot is pinned at 0.2s and saturates in daylight.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0123LLDR874Fyp1gbjAVbR2a
Every module carried a block like

    try:
        from src.logging_config import get_logger
    except ImportError:
        from logging_config import get_logger

because the systemd units ran the modules as scripts, so sys.path[0] was
src/ and `src.x` was not importable. Seventeen of these across twelve
files, and the cost was not only the noise: mypy had to disable no-redef
project-wide because it read every fallback arm as a redefinition, ruff
had to exempt src/ from E402 because the imports followed sys.path edits,
and a CI job existed purely to prove the flat half still worked.

src/ becomes raspilapse/, grouped by what each part talks to -- camera/,
overlay/, video/, storage/, cli/ -- so it is visible at a glance which
parts you can do without. There is one import path per module now, and
all seventeen blocks are gone, along with four stray sys.path bootstraps.

Verification that this changed no behaviour: the golden replay tests pass
unchanged. Same exposure decisions, frame for frame, across all twelve
recorded and synthetic sequences.

Dropped with the idiom:

  - mypy's no-redef suppression. Confirmed to leave no no-redef errors
    behind; ~60 unrelated pre-existing errors remain, mostly Any leaking
    out of config.get(), so mypy stays advisory until those are dealt
    with separately.
  - ruff's E402 exemption for application code.
  - the compatibility-check CI job, replaced by one that installs the
    package and checks every module imports and every console script
    resolves.

Also fixed: raspilapse/__init__.py declared __version__ = "0.1.0" while
__version__.py, pyproject and the CHANGELOG all said 1.4.0. It now
re-exports the real one.

The units run `python3 -m raspilapse.cli.x` from WorkingDirectory rather
than a path into src/. Console scripts are declared and work after
`pip install -e .`, but nothing requires them: a virtualenv is usually
the wrong move on Raspberry Pi OS, because picamera2 comes from apt and
a venv's own numpy shadows the one it was built against. Verified on the
live camera -- units reinstalled, service restarted, capture continuous.

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

The install line asked for seven apt packages and a pip invocation before
you could take a photo. Two of them were redundant: python3-picamera2
depends on python3-numpy and python3-pil, so naming them separately only
made the command look bigger than it is.

    sudo apt install -y python3-picamera2 python3-yaml ffmpeg

is now the whole thing. astral, requests, requests-toolbelt and matplotlib
each buy exactly one feature and are installed separately if wanted.

This is not what the plan said to do. The plan was to drop numpy by
rewriting the lores metering onto a stdlib histogram, on the premise that
numpy was a barrier to getting started. It is not -- apt pulls it in with
picamera2 either way -- so that rewrite would have touched the
exposure-critical percentile code for no gain to anyone installing this.
The barrier was the install line itself.

What actually needed doing:

  - requests was a hard module-level import in storage/upload.py, so a
    camera that never uploads still needed an HTTP client to render its
    daily video. Now optional, with a sentinel exception class so the
    handler still parses, and a clear error naming the apt package.
  - install.sh treated every module as required and failed the check if
    astral was missing. Required and optional are now separate, and the
    optional ones report what each would buy.

Also fixed, found while removing the last sys.path bootstraps: daily.py
shelled out to os.path.join(project_root, "src", "make_timelapse.py"),
assembled from separate arguments, which is why the previous commit's
grep for "src/" did not find it. The 05:00 video job would have failed
with a file-not-found. Its test asserted only that "make_timelapse.py"
appeared in the command, which stayed true after the path stopped
existing; it now resolves the target with find_spec, and was confirmed to
fail against the broken version.

New tests: test_optional_dependencies.py hides each optional module from
the import machinery -- patching the attribute to None cannot catch a
top-level import, because by then it has already succeeded -- plus a
guard asserting the hiding itself works, so the suite cannot pass
vacuously.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0123LLDR874Fyp1gbjAVbR2a
`overlay.enabled: false` was supposed to mean "do not draw text on my
frames". It also silently switched off all database logging.

ImageOverlay.__init__ returned early when disabled, before assigning
self.weather. The capture loop read capture.overlay.weather to fill the
database's weather columns, so with the overlay off that raised
AttributeError into an `except Exception` that logged at DEBUG -- a level
nobody runs in production. Every capture went unrecorded and nothing said
so. Five hasattr(self, "ships") guards elsewhere in the class were the
same bug worked around rather than fixed; they are gone with it.

The underlying mistake was treating weather as an overlay feature. It is
a data source: the database has columns for it whether or not anything is
drawn. The daemon now owns a WeatherData directly. The weather cache is
process-wide and keyed by endpoint, so the daemon's instance and the
overlay's share it -- two instances, one HTTP request.

Pillow was also a hard requirement of taking a photo, because
camera/capture.py imported the renderer at module level. ImageCapture now
takes a post_process callable and has no idea what an overlay is;
overlay.build_overlay returns one only when the overlay is switched on,
importing Pillow only in that case. Verified by hiding PIL from the import
machinery: the capture path imports clean.

Also fixed here, and caused by the package move: the ship icon. The path
was Path(__file__).parent.parent / "icons", which reached the repo root
from src/ but lands in raspilapse/ from raspilapse/overlay/. Guarded by an
exists() check, so the icon just quietly stopped loading. It uses
PROJECT_ROOT now, and logs a warning when the file is missing rather than
saying nothing.

Verified on the live camera: service restarted, capture continuous, the
overlay renders with weather, exposure, tide curve and aurora intact, and
weather is landing in the database again.

Each fix was confirmed by reverting it and watching the tests fail.

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

config.example.yml was the starting point and the schema at once, so it
had to spell out every setting the code might read. Six hundred and
eighty-one lines is a lot to face before taking a first photograph, and
copying it wholesale is how a config ends up pinning values its owner
never chose.

The code indexed 35 config paths without a fallback -- config["camera"]
["resolution"], config["adaptive_timelapse"]["interval"] and so on -- and
those are what forced the file to be complete. They now have defaults in
raspilapse/config.py, merged underneath whatever the user wrote, so a
config file only has to say what it wants to change. An eight-line file
works.

The values are exactly what the old example shipped, so nothing changes
for an existing config. Keys already read with .get(key, fallback) are
deliberately not duplicated into the table -- that fallback is the
default, and a second copy is a second thing to keep in step.

  config/config.example.yml   69 lines: what to set, and what is optional
  docs/CONFIG-REFERENCE.yml   the former example, unchanged, as reference

The schema-drift tests move to the reference and keep their guarantees:
a documented key nothing reads still fails, and so does a key read into
an attribute nothing loads. Two new ones matter more:

  test_every_hard_indexed_key_has_a_default walks the AST for config
  subscripts with no fallback and requires each to be in DEFAULTS. This
  is the invariant that lets the example stay short -- adding
  config["new_section"] to the code fails here rather than on someone's
  camera. Confirmed by adding such a subscript and watching it fail.

  test_the_hard_index_scan_finds_something, because a scan that silently
  matched nothing would make the test above pass for the wrong reason.

Verified on the live camera: installer check passes, service restarted,
capture continuous at 30s with weather still recording.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0123LLDR874Fyp1gbjAVbR2a
run() was the first thing anyone reads to understand what this project
does, and it was 287 lines at five levels of indentation. Six locals were
threaded through it, initialised to None at the top so that a later
diagnostics call would not NameError when a branch skipped them.

It is 97 lines now, and the body reads as what it does:

    decision = self._meter()          measure light, pick mode, get settings
    capture_frame(...)                take the frame
    self._observe(...)                feed brightness back to the controller
    self._record(...)                 diagnostics, WB reference, database row

The six locals became a Decision dataclass, so a frame's decision travels
as one value rather than as a convention about which variables happen to
be set. Nothing in daemon.py is over 100 lines any more.

Two things dropped out of the split rather than being changed on purpose:

  - The inline transition-position arithmetic was a copy of
    ExposureController.transition_position, including the clamp. It now
    calls the method, which additionally guards the None and
    divide-by-zero cases the copy did not.

  - store_capture failures logged at DEBUG. That is the level that let a
    disabled overlay silently lose every database row for as long as it
    did. Now a warning.

Behaviour is unchanged: the golden replay tests pass, and the AST check
that pins the sun-elevation evaluation order still fires -- it walks the
whole module, so it followed the code from run() into _meter(). Confirmed
by reintroducing the inline form and watching it fail.

Verified on the live camera: service restarted, capture continuous, and
the per-frame diagnostics block still written in full.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0123LLDR874Fyp1gbjAVbR2a
Mode selection compared an uncalibrated lux figure against absolute
thresholds -- night: 3, day: 80 -- that had to be retuned for every
camera and every site, and were overridden at high latitude by sun
elevation because they could not survive being moved. The camera did not
work out of the box anywhere but here.

A camera has two ways to gather more light and they are not
interchangeable: a longer shutter costs time, more gain costs noise. So
the order is forced, and that single rule is the whole of what the three
modes were. _settings_day pinned gain at its floor; _settings_transition
did the same until the shutter reached 80% of its ceiling;
_settings_night opened the shutter fully and then raised gain. Three
regions of one curve, with different log strings.

    required = last * (target / measured) ** damping
    shutter, gain = allocate(required, ceiling, max_gain)

Deleted: determine_mode, apply_hysteresis (nothing discrete is left to
flip between), _is_polar_day and the civil-twilight override, the hybrid
brightness override, the three settings builders, the separate gain and
exposure interpolators, the entering-night coordinated ramp, and the
night gain-reduction path. Config keys light_thresholds, reference_lux,
hysteresis_frames, gain_transition_speed, civil_twilight_threshold and
smooth_wb_in_day_mode went with them -- the schema-drift test found each
one and refused to let the reference keep documenting them.

Mode survives as a label derived from the settings, so it cannot
disagree with what the camera is doing. The old one could: 368k frames
were labelled "day", and among them were frames at a 20-second exposure
and gain 5.5, because the polar override set the label while the shutter
was wide open.

Astral is demoted, not removed. Sun elevation is still recorded with
every frame and still feeds graph_solar_patterns.py; nothing decides
from it. Without astral installed the column is NULL and the camera
behaves identically, which is what "location does not matter" has to
mean.

Evidence this is better, not merely different -- tests/replay/compare.py,
which runs both controllers against the same recorded light with the loop
closed, across 3400 frames of this camera's history:

    brightness error   35.0 -> 25.6
    flicker, stops     0.077 -> 0.031
    settled            38% -> 61%

Closing that loop mattered. The first version replayed recorded
brightness, which is a property of the exposure the old controller
happened to be using -- so the new one lowered exposure, the measurement
did not respond, and it lowered again into a runaway that cannot happen
on a camera. It reported the ladder as 13 stops darker. The replay
harness derives scene luminance instead and shows each controller the
brightness its own choice would have produced.

Two bugs that only that measurement found:

  - a near-black frame held the exposure where it was, because the guard
    treated any brightness under 1.0 as a missing measurement. Under the
    old night mode it never showed; on the ladder it left a simulated
    polar night stuck at the cold-start 20 ms for 300 frames.
  - a uniform rate limit made daylight sluggish. The old code applied no
    interpolation in day mode at all and 8% per frame in night, so the
    rate now scales with ladder position between those two ends.

Golden files re-recorded, which the tests exist to make deliberate. The
mutation set was rebuilt for the new code: 33 mutations, all caught.
Three constants are documented as absent from it rather than silently
dropped -- two proven unreachable, one needing four simultaneous
conditions and pinned in test_metering.py instead. Fixing that last one
exposed a unit test of my own that covered nothing, asserting a value on
the wrong side of both thresholds.

Verified on the live camera: restarted, converged in three frames, stable
at 210us against a highlight-protected target, overlay and diagnostics
intact.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0123LLDR874Fyp1gbjAVbR2a
Every capture cost two camera cycles: close the running camera, open a
second one for a fixed-settings metering shot, close that, open again
with the real settings. Thirty times a minute, for the life of the
installation.

It cost that because the shot did two jobs. Neither needs its own frame
any more, or needs one as often:

  Lux is now measured from the frame the camera was taking anyway. Same
  arithmetic, from that frame's own brightness and the settings the
  sensor actually used. Since the ladder landed, nothing decides from
  lux -- it is a number for the overlay and the graphs -- so paying two
  camera restarts a frame for it had stopped making sense.

  White balance still needs the ISP, because the metering shot is the
  only frame taken with AWB enabled and there is no other way to learn
  what the scene's white is. But colour changes when light changes, so
  that is the trigger now: movement along the exposure ladder since the
  last reading, with a one-hour floor for drift the ladder does not see.

Measured over 20 frames on the camera: 40 camera initialisations before,
22 after.

Two things this turned up, both measured rather than assumed:

  Settings cannot be pushed to a running camera. set_controls looks like
  it should work; on this hardware a commanded exposure took eight frames
  to appear in the returned metadata, and four consecutive captures after
  a change all came back carrying the value from two commands earlier. At
  a 20-second night exposure that is 160 seconds of wrong frames against
  a teardown costing two. Keeping the camera open between frames is
  therefore not available, and the loop still tears it down -- once per
  frame instead of twice.

  Skipping even that teardown when the settings barely move was built,
  measured, and removed. The sensor quantises exposure to whole lines --
  it delivers 210us for a commanded 217us -- so command and delivery
  differ by more than any useful tolerance on every frame and the branch
  never fired. It is gone rather than left in place looking useful.

The lux column changes scale here, deliberately. It used to be measured
from a shot pinned at 0.2s, which saturates in daylight: 368 thousand
rows of this database carry the identical value 887.190349001447.
Continuity with a constant is not worth keeping. Calibrated so full
daylight reads around 20,000 and a 20-second night frame reads 0.03,
which is roughly what a light meter would say.

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

coderabbitai Bot commented Jul 27, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

You’ve reached a temporary PR review limit under our Fair Usage Limits Policy.

Your recent review volume is higher than typical usage, so adaptive limits are currently applied.

Next review available in: 24 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: f434e328-49a7-4f79-b8c4-4b0f8e505d92

📥 Commits

Reviewing files that changed from the base of the PR and between ec7767b and 0e5b1c5.

📒 Files selected for processing (8)
  • pyproject.toml
  • raspilapse/overlay/formats.py
  • raspilapse/overlay/render.py
  • raspilapse/overlay/widgets.py
  • requirements.txt
  • tests/overlay_render_check.py
  • tests/replay/README.md
  • tests/test_overlay_fields.py
📝 Walkthrough

Walkthrough

The project is migrated from src/ scripts to the raspilapse package, with packaged CLI entry points, merged configuration defaults, optional dependencies, updated services and documentation, and a ladder-based exposure pipeline with metering, feedback, reference shots, and replay validation.

Changes

Package migration and exposure-control overhaul

Layer / File(s) Summary
Configuration, packaging, and tooling
.github/*, pyproject.toml, raspilapse/config.py, config/*, docs/CONFIG-REFERENCE.yml, Makefile, scripts/install.sh
Package discovery, console scripts, versioning, coverage, defaults merging, optional dependency handling, CI checks, and configuration documentation are updated for raspilapse.
Ladder metering and exposure controller
raspilapse/camera/ladder.py, raspilapse/camera/metering.py, raspilapse/camera/exposure.py, tests/test_ladder.py, tests/test_metering.py, tests/test_exposure.py
Exposure allocation, ladder positioning, mode labels, brightness metering, highlight protection, recovery speeds, feedback control, white-balance transitions, lux smoothing, diagnostics, and HDR handling are implemented and tested.
Capture loop and runtime integration
raspilapse/daemon.py, raspilapse/camera/capture.py, raspilapse/overlay/*, raspilapse/logging_setup.py, raspilapse/storage/*, raspilapse/cli/*
The daemon uses reference shots, controller decisions, captured-frame lux estimation, merged defaults, enriched persistence, dedicated weather handling, and optional post-capture overlays.
Replay harness and behavioral fixtures
tests/replay/*
Recorded and synthetic sequences, deterministic golden outputs, closed-loop replay, legacy-controller comparison, and mutation validation are added.
Unit, integration, and service validation
tests/*, systemd/*
Tests and service commands are retargeted to package modules, while capture, overlays, uploads, optional imports, video subprocesses, and console entry points receive updated coverage.

Estimated code review effort: 5 (Critical) | ~120 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 73.85% 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
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately captures the two main changes: package restructuring and replacing the exposure modes with a ladder.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch refactor

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

@codecov

codecov Bot commented Jul 27, 2026

Copy link
Copy Markdown

@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: 10

Note

Due to the large number of review comments, Critical, Major severity comments were prioritized as inline comments.

🟡 Minor comments (12)
tests/replay/compare.py-307-319 (1)

307-319: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

statistics.fmean raises on an empty list when every sequence scores NaN.

brightness_error and settled_fraction return NaN whenever a sequence has no frames past WARMUP_FRAMES (20). synthetic_threshold_edge_day and synthetic_threshold_edge_night are exactly 20 frames, so compare.py synthetic_threshold_edge_day leaves old_values/new_values empty and line 316 dies with StatisticsError instead of printing a summary.

🐛 Proposed guard
         old_values = [r[old_key] for r in results if not math.isnan(r[old_key])]
         new_values = [r[new_key] for r in results if not math.isnan(r[new_key])]
+        if not old_values or not new_values:
+            print(f"  {label:18} no comparable frames")
+            continue
🤖 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/compare.py` around lines 307 - 319, Guard the summary
calculation in the comparison logic around old_values, new_values, and the
statistics.fmean calls so all-NaN result sets do not raise StatisticsError.
Preserve the existing averages and win-count output for non-empty values, while
printing an appropriate empty-result summary when no valid scores remain.
tests/replay/compare.py-92-94 (1)

92-94: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Truly black frames are discarded as "missing metadata".

not brightness rejects mean_brightness == 0.0, which is a measurement, not an absence — the same distinction _required_exposure in raspilapse/camera/exposure.py (lines 277-284) was deliberately changed to make. These frames are then excluded from the usable count at line 223 and from both controllers' closed loops, so the darkest frames of deep_dark and crashing_light are dropped from exactly the comparison used to justify the ladder.

🐛 Proposed fix
-        if not brightness or not exposure_us:
+        if brightness is None or not exposure_us:
             out.append(None)
             continue
🤖 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/compare.py` around lines 92 - 94, Update the metadata presence
check near the replay comparison flow so a brightness value of 0.0 is accepted
as a valid measurement; only treat brightness as missing when it is None (while
preserving the equivalent exposure validation). Ensure truly black frames
continue through usable counting and both controller comparison loops.
tests/replay/compare.py-128-186 (1)

128-186: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Mirror the DAY→manual seeding in simulate_legacy. The harness seeds seed_from_metadata(...) on the first transition out of DAY, but this path only calls reset_seed_state() on DAY entry. That leaves the legacy replay missing the same handover setup and can skew the comparison.

🤖 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/compare.py` around lines 128 - 186, Update simulate_legacy to
mirror the DAY-to-manual handover behavior by invoking seed_from_metadata(...)
on the first transition out of DAY, in addition to the existing
reset_seed_state() call on DAY entry. Track the prior mode and ensure this
seeding occurs only when transitioning from modes.DAY to a non-DAY mode, using
the available frame/config metadata required by the controller.
raspilapse/cli/apply_overlay.py-27-39 (1)

27-39: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Point examples at the actual module.

raspilapse.cli.overlay is not the overlay CLI module; these commands fail before parsing arguments. Use raspilapse.cli.apply_overlay consistently.

Proposed fix
-  python3 -m raspilapse.cli.overlay test_photos/kringelen_2025_11_05_10_30_45.jpg
+  python3 -m raspilapse.cli.apply_overlay test_photos/kringelen_2025_11_05_10_30_45.jpg
...
-  python3 -m raspilapse.cli.overlay test_photos/*.jpg
+  python3 -m raspilapse.cli.apply_overlay test_photos/*.jpg
🤖 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 `@raspilapse/cli/apply_overlay.py` around lines 27 - 39, Update all overlay CLI
examples in the documentation block to invoke the actual
raspilapse.cli.apply_overlay module instead of raspilapse.cli.overlay,
preserving the existing arguments and command examples.
raspilapse/daemon.py-785-794 (1)

785-794: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

not brightness conflates a genuinely black frame with a missing measurement.

mean_brightness == 0.0 is a real reading in deep dark, but it returns None, so the row stores no lux at all instead of ~0. Test explicitly for None on the inputs and keep the arithmetic guard for the divisors.

🐛 Proposed fix
-        if not brightness or not exposure_us or not gain:
+        if brightness is None or exposure_us is None or gain is None:
             return None
🤖 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 `@raspilapse/daemon.py` around lines 785 - 794, Update the lux calculation
around the settings lookup to treat brightness, exposure_us, and gain as missing
only when they are None, allowing a valid mean_brightness of 0.0 to produce a
near-zero lux value. Preserve the existing positive-value guards for exposure_us
and gain before performing the arithmetic.
docs/OVERLAY.md-143-148 (1)

143-148: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Use the packaged overlay module name.

raspilapse.cli.overlay is not the documented entry-point module; the package exposes raspilapse.cli.apply_overlay. Both examples fail as written. Use python3 -m raspilapse.cli.apply_overlay ... or raspilapse-overlay ....

🤖 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 `@docs/OVERLAY.md` around lines 143 - 148, Update both command examples in the
overlay documentation to invoke the packaged entry point
raspilapse.cli.apply_overlay, or consistently use the raspilapse-overlay
executable, so the documented commands run successfully.
docs/CONFIG-REFERENCE.yml-170-170 (1)

170-170: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Stale src/ paths and pre-migration script names.

Line 611's python3 src/database.py --vacuum is now unrunnable after the package move, and the section headers still name auto_timelapse.py / daily_timelapse.py.

📝 Proposed fix
-# Adaptive Timelapse Settings (for auto_timelapse.py)
+# Adaptive Timelapse Settings (for raspilapse.cli.capture)
-# Video Upload Settings (for daily_timelapse.py)
+# Video Upload Settings (for raspilapse.cli.daily)
   # Pruning runs from raspilapse-cleanup.timer. Space is only handed back to
-  # the filesystem by an explicit `python3 src/database.py --vacuum`, which is
-  # slow and needs free disk equal to the database size.
+  # the filesystem by an explicit `python3 -m raspilapse.cli.db --vacuum`,
+  # which is slow and needs free disk equal to the database size.

Also applies to: 555-555, 610-612

🤖 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 `@docs/CONFIG-REFERENCE.yml` at line 170, Update the configuration reference’s
adaptive timelapse section and related entries to use the current post-migration
script names and paths, replacing auto_timelapse.py, daily_timelapse.py, and the
stale src/database.py vacuum command. Ensure the documented vacuum command is
runnable from the current package layout while preserving the existing
configuration guidance.
docs/CONFIG-REFERENCE.yml-1-8 (1)

1-8: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Header still frames this as a copy-me template.

Every doc that points here calls it reference-only (config/README.md: "Reference, not a template"), and config/config.example.yml is the file to copy. Telling readers to cp config/config.example.yml config/config.yml from inside the reference — while the surrounding text says "This is the default configuration template" — undoes the split this PR is making.

📝 Proposed fix
-# Raspilapse Configuration File (Example/Template)
-#
-# IMPORTANT: This is the default configuration template.
-#            Copy this file to config.yml and customize it:
-#            cp config/config.example.yml config/config.yml
-#
-# Your config.yml will NOT be tracked by git, so you can safely
-# customize it with your personal settings (API keys, paths, etc.)
+# Raspilapse configuration reference.
+#
+# Every setting there is, with its default. This is documentation, not a
+# template -- do not copy it wholesale. Start from
+# config/config.example.yml and copy in only the blocks you want to change.
🤖 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 `@docs/CONFIG-REFERENCE.yml` around lines 1 - 8, Update the header in
CONFIG-REFERENCE.yml to describe the file as reference-only rather than a
copyable template, and remove or revise the copy command so it points readers to
config/config.example.yml as the file to copy. Preserve the guidance about
customizing the untracked config.yml.
docs/INSTALL.md-53-53 (1)

53-53: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

"polar-day override" contradicts the new exposure model.

README.md lines 161-163 state that without astral the camera behaves identically and nothing decides from sun elevation. Astral now buys only the recorded elevation column and the graph script.

Note the same stale claim sits in the unchanged prose at lines 102-104 ("sun elevation decides when day begins and ends"), which is worth correcting in the same pass.

📝 Proposed fix
-| `pip3 install --break-system-packages 'astral>=3.2'` | sun elevation recorded with each frame, and the polar-day override |
+| `pip3 install --break-system-packages 'astral>=3.2'` | sun elevation recorded with each frame, for the database and the solar graphs |
🤖 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 `@docs/INSTALL.md` at line 53, Update the Astral dependency description in the
installation table to mention only recorded sun elevation and graph-script
support, removing the polar-day override claim. Also revise the related prose
around the sun-elevation day-boundary behavior so it matches the documented
exposure model: Astral must not be described as deciding when day begins or
ends.
tests/test_overlay_optional.py-28-35 (1)

28-35: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Disable weather explicitly in these fixtures.

disabled_config omits a weather section entirely, so get_weather_data() depends on WeatherData's internal default to avoid a real HTTP fetch. The daemon test below already pins {"weather": {"enabled": False}} — do the same here so the test can never reach the network.

🛡️ Proposed fix
 `@pytest.fixture`
 def disabled_config():
-    return {"overlay": {"enabled": False}}
+    return {"overlay": {"enabled": False}, "weather": {"enabled": False}}
 
 
 `@pytest.fixture`
 def enabled_config():
-    return {"overlay": {"enabled": True, "font": {"family": "default"}}}
+    return {
+        "overlay": {"enabled": True, "font": {"family": "default"}},
+        "weather": {"enabled": False},
+    }

Also applies to: 50-55

🤖 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_overlay_optional.py` around lines 28 - 35, Update the
disabled_config and enabled_config fixtures to explicitly include weather
enabled=False, ensuring overlay tests cannot trigger real weather requests while
preserving their existing overlay settings.
tests/replay/harness.py-124-139 (1)

124-139: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

not brightness swallows a legitimate zero.

A frame that genuinely measured mean_brightness == 0.0 (deep-dark sequences) is treated as "no metadata" and never fed to observe_frame, so the controller silently skips the closed loop for exactly the frames where correction matters most. Test for None instead.

🐛 Proposed fix
-    if not brightness or not exposure_us:
+    if brightness is None or not exposure_us:
         return None
🤖 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/harness.py` around lines 124 - 139, Update scene_luminance to
distinguish a missing mean_brightness from a valid zero value: check brightness
explicitly against None rather than using a falsy check. Preserve the existing
handling for absent exposure_us and continue returning zero luminance when
brightness is 0.0 and the exposure product is positive.
tests/test_config_example.py-41-45 (1)

41-45: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Drop ALLOWED_UNDOCUMENTED or wire it into the coverage test. It isn’t referenced anywhere in tests/test_config_example.py, and raspilapse.storage.database.retention_days doesn’t match the config-path checks used here (database.retention_days).

🤖 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_config_example.py` around lines 41 - 45, Remove the unused
ALLOWED_UNDOCUMENTED constant from tests/test_config_example.py, or integrate it
into the coverage test using the normalized database.retention_days path
expected by the existing checks.
🧹 Nitpick comments (10)
raspilapse/camera/exposure.py (1)

200-214: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

smooth_lux can return None, but is annotated -> float.

When raw_lux is None and nothing has been smoothed yet, line 208 returns None. Callers (replay harness, capture loop) record this value; the annotation should reflect it.

♻️ Proposed signature fix
-    def smooth_lux(self, raw_lux: float) -> float:
+    def smooth_lux(self, raw_lux: Optional[float]) -> Optional[float]:
🤖 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 `@raspilapse/camera/exposure.py` around lines 200 - 214, Update the smooth_lux
method’s return annotation to allow None, matching its existing behavior when
raw_lux and _smoothed_lux are both unset. Leave the smoothing logic and returned
values unchanged.
raspilapse/camera/metering.py (2)

324-417: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Thresholds are hardcoded while the rates they select are configurable.

Lines 338-339 and 388 hold the trigger/release points as literals, yet _fast_down, _critical_up, etc. all come from transition_mode. Anyone tuning a camera can change how fast it recovers but not when it decides to. Worth lifting into the same config section, or at least into module constants next to BrightnessZones.

🤖 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 `@raspilapse/camera/metering.py` around lines 324 - 417, Move the exposure
trigger and release thresholds used by _check_overexposure and
_check_underexposure out of their local literals into the existing
transition-mode configuration, or shared module-level constants beside
BrightnessZones. Update both methods to consume those centralized values while
preserving the current two-tier severity and hysteresis behavior.

202-222: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

_dynamic_target is annotated -> int but can return a float.

Line 218 returns self._base_target + self._overcast_boost unrounded; both come from config and may be floats. Only the interpolated branch rounds. This flows into diagnostics()["target_brightness"], so the golden fixtures would change shape for a float-configured boost.

♻️ Proposed fix
         if std <= self._contrast_low:
-            return min(self._base_target + self._overcast_boost, self._max_target)
+            return min(int(round(self._base_target + self._overcast_boost)), self._max_target)
🤖 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 `@raspilapse/camera/metering.py` around lines 202 - 222, Update _dynamic_target
so the low-contrast branch returning self._base_target plus self._overcast_boost
rounds the boosted value to an integer before applying the max-target clamp,
matching the interpolated branch and preserving the declared int return type.
tests/replay/synthetic_sequences.py (2)

155-172: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

These fixture descriptions document a controller that no longer exists.

determine_mode, the lux thresholds (night: 3, day: 80) and the hybrid override are all removed by this PR — raspilapse/camera/exposure.py derives the mode from the settings instead. The sequences remain useful as regression inputs, but synthetic_threshold_edge_* and synthetic_hybrid_override now claim to exercise branches that are gone, and the embedded description strings ship into the fixture JSON.

Worth a sentence in each saying they are retained as legacy-boundary regression inputs rather than tests of a live threshold.

Also applies to: 354-369

🤖 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` around lines 155 - 172, Update the
descriptions for synthetic_threshold_edge_night, synthetic_threshold_edge_day,
and synthetic_hybrid_override to state that these sequences are retained as
legacy-boundary regression inputs, not coverage of the removed threshold or
hybrid controller branches. Keep the fixture data unchanged and ensure the
revised descriptions do not claim to test determine_mode, lux thresholds, or
hybrid override behavior.

20-29: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Two sys.path inserts and two import styles for sibling modules.

Line 24 exists only so line 27 can import extract_sequences bare, while line 29 imports its sibling through the package. Dropping the first insert and using from tests.replay.extract_sequences import OUT_DIR, REPLAY_CONFIG makes both imports resolve the same way.

🤖 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` around lines 20 - 29, Remove the
sys.path insertion targeting the tests/replay directory and update the
extract_sequences import to use the tests.replay.extract_sequences package path,
keeping the repository-root path insertion and dump_frames import unchanged.
raspilapse/camera/capture.py (1)

488-497: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Drop the unreachable elif branch. request.get_metadata() already yields a dict on this path, so the fallback warning never fires.

🤖 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 `@raspilapse/camera/capture.py` around lines 488 - 497, Remove the unreachable
elif branch following the post_process invocation in the capture flow. Keep the
existing post-processing condition, success debug log, and failure warning
unchanged, since metadata_dict is always a dictionary on this path.
tests/replay/mutation_check.py (1)

261-284: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Unused temp-directory backup.

shutil.copy2 copies each target into tmp (Lines 262-263), but nothing ever reads from tmp afterward — restoration relies entirely on the in-memory originals dict (Lines 280, 282-283). The temporary directory and its copies are dead weight and could mislead a future reader into thinking they provide a recovery path (e.g., in case of kill -9 mid-run) when they don't.

Consider dropping the tempfile.TemporaryDirectory() block entirely, since originals already provides restoration.

🤖 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/mutation_check.py` around lines 261 - 284, Remove the unused
tempfile.TemporaryDirectory block and its shutil.copy2 backup loop from the
mutation workflow. Keep the existing originals-based restoration in the
try/finally around the MUTATIONS loop, including restoring each target from
originals after each mutation and during final cleanup.
README.md (1)

133-137: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Add a language to the fenced blocks (markdownlint MD040).

Both blocks are unlabeled; text is enough to satisfy the linter.

📝 Proposed fix
-```
+```text
 required = current * (target / measured) ** damping
-```
+```text
 raspilapse/

Also applies to: 199-222

🤖 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 `@README.md` around lines 133 - 137, Add the text language identifier to every
unlabeled fenced code block in README.md, including the blocks containing the
exposure formula and raspilapse/ directory example, while preserving their
contents.

Source: Linters/SAST tools

tests/replay/harness.py (1)

37-70: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Drop the pre-migration import fallbacks.

src.exposure / bare exposure no longer exist after the package move, so these branches can only mask a genuine ImportError from raspilapse.camera.exposure (e.g. a broken transitive import) by re-raising a confusing one from a path nobody ships. A plain import keeps failures legible.

♻️ Proposed simplification
-def load_controller() -> Callable[..., Any]:
-    """Return the ExposureController class from wherever it currently lives."""
-    try:
-        from raspilapse.camera.exposure import ExposureController  # noqa: F401
-
-        return ExposureController
-    except ImportError:
-        pass
-    try:
-        from src.exposure import ExposureController
-
-        return ExposureController
-    except ImportError:
-        from exposure import ExposureController
-
-        return ExposureController
-
-
-def load_modes() -> Any:
-    """Return the LightMode constants from wherever they currently live."""
-    try:
-        from raspilapse.camera.exposure import LightMode  # noqa: F401
-
-        return LightMode
-    except ImportError:
-        pass
-    try:
-        from src.exposure import LightMode
-
-        return LightMode
-    except ImportError:
-        from exposure import LightMode
-
-        return LightMode
+def load_controller() -> Callable[..., Any]:
+    """Return the ExposureController class."""
+    from raspilapse.camera.exposure import ExposureController
+
+    return ExposureController
+
+
+def load_modes() -> Any:
+    """Return the LightMode constants."""
+    from raspilapse.camera.exposure import LightMode
+
+    return LightMode
🤖 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/harness.py` around lines 37 - 70, Remove the legacy src.exposure
and bare exposure fallback imports from load_controller and load_modes. Import
ExposureController and LightMode directly from raspilapse.camera.exposure so
genuine import failures propagate without being masked.
tests/test_daily_timelapse.py (1)

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

Assert the claimed behavior instead of just commenting it.

The comment says subprocess should not be called with --only-upload, but nothing asserts it — mock_run could be invoked and the test would still pass.

✅ Suggested addition
         with patch("raspilapse.video.daily.subprocess.run") as mock_run:
             mock_run.return_value = MagicMock(returncode=0)
             main()

-        # Should not have called subprocess since --only-upload was used
+        # Should not have called subprocess since --only-upload was used
+        mock_run.assert_not_called()
🤖 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_daily_timelapse.py` around lines 315 - 318, Update the test around
main() and the patched mock_run to assert that subprocess.run was not called
with the --only-upload argument, while preserving the existing successful
return-code setup and test flow.
🤖 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 `@docs/CONFIG-REFERENCE.yml`:
- Around line 10-12: The documentation must stop describing sun elevation as
controlling exposure decisions. In docs/CONFIG-REFERENCE.yml lines 10-12, remove
the Polar Day/Night and Civil Twilight Override descriptions and state that
location is recorded per frame and read only by the solar graph script; in
docs/INSTALL.md line 53, remove “and the polar-day override,” leaving only the
recorded sun elevation description.

In `@raspilapse/cli/status.py`:
- Around line 202-215: Update StatusDisplay._load_config() to use
raspilapse.config.load_config() or apply merge_defaults() before the status
rendering reads adaptive["night_mode"]. Preserve the existing user-facing error
handling, and ensure configurations omitting adaptive_timelapse.night_mode
receive defaults so the status command continues printing instead of raising
KeyError.

In `@raspilapse/config.py`:
- Around line 89-97: Update merge_defaults to deep-copy all default values when
initializing merged, including nested dictionaries and lists, so returned
configurations cannot alias DEFAULTS. Preserve the existing recursive merge
behavior for user-provided config values while ensuring untouched nested
defaults remain independently mutable per load.

In `@raspilapse/daemon.py`:
- Around line 949-957: Wrap the camera reinitialization block in the inner
tolerant error-handling path used for frame failures, including both
ImageCapture construction and initialize_camera. On initialization errors, log
the failure, perform the existing retry sleep, and continue the capture loop so
transient libcamera errors do not reach the outer handler and terminate the
daemon.
- Around line 706-722: Update _take_reference_shot so the exception path records
the failed reference attempt by setting _reference_frame and _reference_position
to the current frame count and exposure ladder position before returning. Keep
the existing warning and successful metadata updates unchanged, ensuring
_wants_reference_shot() waits until REFERENCE_MAX_INTERVAL_FRAMES before
retrying.

In `@requirements.txt`:
- Line 18: Update the Pillow dependency declaration from Pillow>=10.0.0 to a
minimum version of Pillow>=10.0.1, ensuring the overlay and keogram
image-decoding paths use a patched release.

In `@tests/replay/golden/night_underexposure_edge.json`:
- Line 9: Update the replay fixture or replay update ordering so
diagnostics.applied_exposure_ms, diagnostics.applied_exposure_s, and
diagnostics.applied_gain match the frame settings ExposureTime and AnalogueGain
on the same record. Regenerate the night_underexposure_edge fixture if the
implementation already applies settings correctly; otherwise adjust the replay
state update to record applied values after the settings are applied.

In `@tests/replay/golden/very_bright_night.json`:
- Line 9: Update the replay flow in tests/replay/harness.py so DAY→TRANSITION
diagnostics are captured immediately after decide() returns, before
seed_from_metadata(...) can overwrite _shutter, _gain, and _position. Ensure the
recorded diagnostics reflect the frame’s actual settings, then re-record the
affected golden files.

In `@tests/replay/sequences/stable_day.json`:
- Around line 3-5: Regenerate the stable_day fixture from a genuinely stable
scene so its brightness remains near the stated baseline throughout the replay,
or rename the fixture and update its expected convergence/flicker assertions to
reflect the observed variation. Preserve the stable baseline semantics before
using stable_day for controller-drift validation.

In `@tests/replay/sequences/synthetic_clipping_sweep.json`:
- Around line 4-5: Regenerate the synthetic clipping sweep sequence so frames
8–179 contain non-zero clipped-pixel values spanning below, at, and above the 3%
and 5% thresholds, with corresponding percentile_95 values that exercise the
described controller-rate changes. Update the sequence’s golden output to match
the regenerated replay results while preserving the existing sweep progression
and configuration.

---

Minor comments:
In `@docs/CONFIG-REFERENCE.yml`:
- Line 170: Update the configuration reference’s adaptive timelapse section and
related entries to use the current post-migration script names and paths,
replacing auto_timelapse.py, daily_timelapse.py, and the stale src/database.py
vacuum command. Ensure the documented vacuum command is runnable from the
current package layout while preserving the existing configuration guidance.
- Around line 1-8: Update the header in CONFIG-REFERENCE.yml to describe the
file as reference-only rather than a copyable template, and remove or revise the
copy command so it points readers to config/config.example.yml as the file to
copy. Preserve the guidance about customizing the untracked config.yml.

In `@docs/INSTALL.md`:
- Line 53: Update the Astral dependency description in the installation table to
mention only recorded sun elevation and graph-script support, removing the
polar-day override claim. Also revise the related prose around the sun-elevation
day-boundary behavior so it matches the documented exposure model: Astral must
not be described as deciding when day begins or ends.

In `@docs/OVERLAY.md`:
- Around line 143-148: Update both command examples in the overlay documentation
to invoke the packaged entry point raspilapse.cli.apply_overlay, or consistently
use the raspilapse-overlay executable, so the documented commands run
successfully.

In `@raspilapse/cli/apply_overlay.py`:
- Around line 27-39: Update all overlay CLI examples in the documentation block
to invoke the actual raspilapse.cli.apply_overlay module instead of
raspilapse.cli.overlay, preserving the existing arguments and command examples.

In `@raspilapse/daemon.py`:
- Around line 785-794: Update the lux calculation around the settings lookup to
treat brightness, exposure_us, and gain as missing only when they are None,
allowing a valid mean_brightness of 0.0 to produce a near-zero lux value.
Preserve the existing positive-value guards for exposure_us and gain before
performing the arithmetic.

In `@tests/replay/compare.py`:
- Around line 307-319: Guard the summary calculation in the comparison logic
around old_values, new_values, and the statistics.fmean calls so all-NaN result
sets do not raise StatisticsError. Preserve the existing averages and win-count
output for non-empty values, while printing an appropriate empty-result summary
when no valid scores remain.
- Around line 92-94: Update the metadata presence check near the replay
comparison flow so a brightness value of 0.0 is accepted as a valid measurement;
only treat brightness as missing when it is None (while preserving the
equivalent exposure validation). Ensure truly black frames continue through
usable counting and both controller comparison loops.
- Around line 128-186: Update simulate_legacy to mirror the DAY-to-manual
handover behavior by invoking seed_from_metadata(...) on the first transition
out of DAY, in addition to the existing reset_seed_state() call on DAY entry.
Track the prior mode and ensure this seeding occurs only when transitioning from
modes.DAY to a non-DAY mode, using the available frame/config metadata required
by the controller.

In `@tests/replay/harness.py`:
- Around line 124-139: Update scene_luminance to distinguish a missing
mean_brightness from a valid zero value: check brightness explicitly against
None rather than using a falsy check. Preserve the existing handling for absent
exposure_us and continue returning zero luminance when brightness is 0.0 and the
exposure product is positive.

In `@tests/test_config_example.py`:
- Around line 41-45: Remove the unused ALLOWED_UNDOCUMENTED constant from
tests/test_config_example.py, or integrate it into the coverage test using the
normalized database.retention_days path expected by the existing checks.

In `@tests/test_overlay_optional.py`:
- Around line 28-35: Update the disabled_config and enabled_config fixtures to
explicitly include weather enabled=False, ensuring overlay tests cannot trigger
real weather requests while preserving their existing overlay settings.

---

Nitpick comments:
In `@raspilapse/camera/capture.py`:
- Around line 488-497: Remove the unreachable elif branch following the
post_process invocation in the capture flow. Keep the existing post-processing
condition, success debug log, and failure warning unchanged, since metadata_dict
is always a dictionary on this path.

In `@raspilapse/camera/exposure.py`:
- Around line 200-214: Update the smooth_lux method’s return annotation to allow
None, matching its existing behavior when raw_lux and _smoothed_lux are both
unset. Leave the smoothing logic and returned values unchanged.

In `@raspilapse/camera/metering.py`:
- Around line 324-417: Move the exposure trigger and release thresholds used by
_check_overexposure and _check_underexposure out of their local literals into
the existing transition-mode configuration, or shared module-level constants
beside BrightnessZones. Update both methods to consume those centralized values
while preserving the current two-tier severity and hysteresis behavior.
- Around line 202-222: Update _dynamic_target so the low-contrast branch
returning self._base_target plus self._overcast_boost rounds the boosted value
to an integer before applying the max-target clamp, matching the interpolated
branch and preserving the declared int return type.

In `@README.md`:
- Around line 133-137: Add the text language identifier to every unlabeled
fenced code block in README.md, including the blocks containing the exposure
formula and raspilapse/ directory example, while preserving their contents.

In `@tests/replay/harness.py`:
- Around line 37-70: Remove the legacy src.exposure and bare exposure fallback
imports from load_controller and load_modes. Import ExposureController and
LightMode directly from raspilapse.camera.exposure so genuine import failures
propagate without being masked.

In `@tests/replay/mutation_check.py`:
- Around line 261-284: Remove the unused tempfile.TemporaryDirectory block and
its shutil.copy2 backup loop from the mutation workflow. Keep the existing
originals-based restoration in the try/finally around the MUTATIONS loop,
including restoring each target from originals after each mutation and during
final cleanup.

In `@tests/replay/synthetic_sequences.py`:
- Around line 155-172: Update the descriptions for
synthetic_threshold_edge_night, synthetic_threshold_edge_day, and
synthetic_hybrid_override to state that these sequences are retained as
legacy-boundary regression inputs, not coverage of the removed threshold or
hybrid controller branches. Keep the fixture data unchanged and ensure the
revised descriptions do not claim to test determine_mode, lux thresholds, or
hybrid override behavior.
- Around line 20-29: Remove the sys.path insertion targeting the tests/replay
directory and update the extract_sequences import to use the
tests.replay.extract_sequences package path, keeping the repository-root path
insertion and dump_frames import unchanged.

In `@tests/test_daily_timelapse.py`:
- Around line 315-318: Update the test around main() and the patched mock_run to
assert that subprocess.run was not called with the --only-upload argument, while
preserving the existing successful return-code setup and test flow.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

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

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 57b761d0-69f6-405e-bb17-59d5a4c2d214

📥 Commits

Reviewing files that changed from the base of the PR and between 9c67309 and 28499f0.

📒 Files selected for processing (135)
  • .github/PULL_REQUEST_TEMPLATE.md
  • .github/workflows/tests.yml
  • CONTRIBUTING.md
  • Makefile
  • README.md
  • config/README.md
  • config/config.example.yml
  • docs/CONFIG-REFERENCE.yml
  • docs/EXPOSURE.md
  • docs/INSTALL.md
  • docs/OVERLAY.md
  • docs/TIMELAPSE_VIDEO.md
  • docs/TROUBLESHOOTING.md
  • pyproject.toml
  • raspilapse/__init__.py
  • raspilapse/__version__.py
  • raspilapse/camera/__init__.py
  • raspilapse/camera/capture.py
  • raspilapse/camera/exposure.py
  • raspilapse/camera/ladder.py
  • raspilapse/camera/metering.py
  • raspilapse/cli/__init__.py
  • raspilapse/cli/apply_overlay.py
  • raspilapse/cli/capture.py
  • raspilapse/cli/daily.py
  • raspilapse/cli/db.py
  • raspilapse/cli/retry_uploads.py
  • raspilapse/cli/snapshot.py
  • raspilapse/cli/status.py
  • raspilapse/cli/timelapse.py
  • raspilapse/config.py
  • raspilapse/console.py
  • raspilapse/daemon.py
  • raspilapse/logging_setup.py
  • raspilapse/overlay/__init__.py
  • raspilapse/overlay/layout.py
  • raspilapse/overlay/render.py
  • raspilapse/overlay/sources/__init__.py
  • raspilapse/overlay/sources/json_sources.py
  • raspilapse/overlay/sources/weather.py
  • raspilapse/storage/__init__.py
  • raspilapse/storage/database.py
  • raspilapse/storage/upload.py
  • raspilapse/system.py
  • raspilapse/video/__init__.py
  • raspilapse/video/daily.py
  • raspilapse/video/keogram.py
  • raspilapse/video/timelapse.py
  • requirements.txt
  • scripts/db_graphs.py
  • scripts/db_stats.py
  • scripts/graph_solar_patterns.py
  • scripts/install.sh
  • src/__init__.py
  • src/exposure.py
  • systemd/raspilapse-cleanup.service.in
  • systemd/raspilapse-daily-video.service.in
  • systemd/raspilapse-upload-retry.service.in
  • systemd/raspilapse.service.in
  • tests/conftest.py
  • tests/replay/__init__.py
  • tests/replay/compare.py
  • tests/replay/extract_sequences.py
  • tests/replay/golden/blown_highlights.json
  • 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/stable_day.json
  • tests/replay/golden/synthetic_clamped_highlights.json
  • tests/replay/golden/synthetic_clipped_pixels.json
  • tests/replay/golden/synthetic_clipping_sweep.json
  • tests/replay/golden/synthetic_extreme_gain.json
  • tests/replay/golden/synthetic_hybrid_override.json
  • tests/replay/golden/synthetic_low_night_gain.json
  • tests/replay/golden/synthetic_night_brightness_sweep.json
  • tests/replay/golden/synthetic_starved_light.json
  • tests/replay/golden/synthetic_threshold_edge_day.json
  • tests/replay/golden/synthetic_threshold_edge_night.json
  • tests/replay/golden/synthetic_underexposure_release.json
  • tests/replay/golden/very_bright_night.json
  • tests/replay/harness.py
  • tests/replay/mutation_check.py
  • tests/replay/record_golden.py
  • tests/replay/sequences/blown_highlights.json
  • tests/replay/sequences/bright_night.json
  • tests/replay/sequences/crashing_light.json
  • tests/replay/sequences/dawn_transition.json
  • tests/replay/sequences/deep_dark.json
  • tests/replay/sequences/dusk_transition.json
  • tests/replay/sequences/night_underexposure_edge.json
  • tests/replay/sequences/stable_day.json
  • tests/replay/sequences/synthetic_clamped_highlights.json
  • tests/replay/sequences/synthetic_clipped_pixels.json
  • tests/replay/sequences/synthetic_clipping_sweep.json
  • tests/replay/sequences/synthetic_extreme_gain.json
  • tests/replay/sequences/synthetic_hybrid_override.json
  • tests/replay/sequences/synthetic_low_night_gain.json
  • tests/replay/sequences/synthetic_night_brightness_sweep.json
  • tests/replay/sequences/synthetic_starved_light.json
  • tests/replay/sequences/synthetic_threshold_edge_day.json
  • tests/replay/sequences/synthetic_threshold_edge_night.json
  • tests/replay/sequences/synthetic_underexposure_release.json
  • tests/replay/sequences/very_bright_night.json
  • tests/replay/synthetic_sequences.py
  • tests/test_apply_overlay.py
  • tests/test_auto_timelapse.py
  • tests/test_capture_image.py
  • tests/test_colors.py
  • tests/test_config_example.py
  • tests/test_config_utils.py
  • tests/test_create_keogram.py
  • tests/test_daily_timelapse.py
  • tests/test_database.py
  • tests/test_exposure.py
  • tests/test_ladder.py
  • tests/test_logging_config.py
  • tests/test_make_timelapse.py
  • tests/test_make_timelapse_daily.py
  • tests/test_metering.py
  • tests/test_optional_dependencies.py
  • tests/test_overlay.py
  • tests/test_overlay_draw.py
  • tests/test_overlay_optional.py
  • tests/test_overlay_simplified.py
  • tests/test_overlay_sources.py
  • tests/test_replay_golden.py
  • tests/test_retry_uploads.py
  • tests/test_status.py
  • tests/test_system_monitor.py
  • tests/test_upload_service.py
  • tests/test_version.py
  • tests/test_weather.py
💤 Files with no reviewable changes (2)
  • src/init.py
  • src/exposure.py

Comment thread docs/CONFIG-REFERENCE.yml Outdated
Comment thread raspilapse/cli/status.py
Comment thread raspilapse/config.py Outdated
Comment thread raspilapse/daemon.py
Comment thread raspilapse/daemon.py Outdated
Comment thread requirements.txt Outdated
{"diagnostics":{"applied_exposure_ms":52.78,"applied_exposure_s":0.05278,"applied_gain":1.0,"base_target_brightness":120,"ladder_position":0.4478,"last_brightness":0.04,"last_p95":0.08,"mode":"day","overcast_boost_active":true,"required_exposure":0.05278,"target_brightness":135,"target_exposure_ms":52.78,"target_exposure_s":0.05278,"target_gain":1.0},"ladder_position":0.447835,"measured_brightness":0.043,"mode":"day","settings":{"AeEnable":0,"AnalogueGain":1.0,"AwbEnable":0,"ColourGains":[2.314075,1.71655],"ExposureTime":52780},"smoothed_lux":1.328223},
{"diagnostics":{"applied_exposure_ms":85.74,"applied_exposure_s":0.085742,"applied_gain":1.0,"base_target_brightness":120,"ladder_position":0.4825,"last_brightness":0.07,"last_p95":0.13,"mode":"day","overcast_boost_active":true,"required_exposure":0.085742,"target_brightness":135,"target_exposure_ms":85.74,"target_exposure_s":0.085742,"target_gain":1.0},"ladder_position":0.482498,"measured_brightness":0.07,"mode":"day","settings":{"AeEnable":0,"AnalogueGain":1.0,"AwbEnable":0,"ColourGains":[2.241464,1.762067],"ExposureTime":85741},"smoothed_lux":1.331788},
{"diagnostics":{"applied_exposure_ms":139.29,"applied_exposure_s":0.139288,"applied_gain":1.0,"base_target_brightness":120,"ladder_position":0.5172,"last_brightness":0.12,"last_p95":0.22,"mode":"day","overcast_boost_active":true,"required_exposure":0.139288,"target_brightness":135,"target_exposure_ms":139.29,"target_exposure_s":0.139288,"target_gain":1.0},"ladder_position":0.517161,"measured_brightness":0.115,"mode":"day","settings":{"AeEnable":0,"AnalogueGain":1.0,"AwbEnable":0,"ColourGains":[2.179744,1.800757],"ExposureTime":139288},"smoothed_lux":1.320299},
{"diagnostics":{"applied_exposure_ms":19999.99,"applied_exposure_s":19.999994,"applied_gain":5.988,"base_target_brightness":120,"ladder_position":0.9999,"last_brightness":0.19,"last_p95":0.36,"mode":"transition","overcast_boost_active":true,"required_exposure":0.226274,"target_brightness":135,"target_exposure_ms":226.27,"target_exposure_s":0.226274,"target_gain":1.0},"ladder_position":0.999861,"measured_brightness":0.187,"mode":"transition","settings":{"AeEnable":0,"AnalogueGain":1.0,"AwbEnable":0,"ColourGains":[2.127283,1.833644],"ExposureTime":226274},"smoothed_lux":1.312226},

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Keep diagnostics.applied_* consistent with the frame settings.

Line 9 records ExposureTime: 226274 and gain 1.0, but diagnostics.applied_* reports 20,000 ms and gain 5.988. This stale/misaligned state weakens replay validation; regenerate the fixture or fix the replay update ordering.

🤖 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/golden/night_underexposure_edge.json` at line 9, Update the
replay fixture or replay update ordering so diagnostics.applied_exposure_ms,
diagnostics.applied_exposure_s, and diagnostics.applied_gain match the frame
settings ExposureTime and AnalogueGain on the same record. Regenerate the
night_underexposure_edge fixture if the implementation already applies settings
correctly; otherwise adjust the replay state update to record applied values
after the settings are applied.

Comment thread tests/replay/golden/very_bright_night.json Outdated
Comment thread tests/replay/sequences/stable_day.json Outdated
Comment on lines +3 to +5
"description": "converged daylight, as a baseline that should barely move",
"source": "captures 2026-04-26T11:00:00 .. 2026-04-26T12:00:00",
"config": {"adaptive_timelapse": {"brightness_damping": 0.5, "brightness_target": {"base": 120, "contrast_threshold_high": 40, "contrast_threshold_low": 25, "max_target": 140, "overcast_boost": 15}, "day_mode": {"analogue_gain": 1, "awb_enable": true, "exposure_time": 0.01, "fixed_colour_gains": [2.5, 1.6]}, "diagnostics": {"enabled": true}, "enabled": true, "hdr": {"day_mode": "SingleExposure", "enabled": false, "night_mode": "Off"}, "highlight_protection": {"apply_in_night": false, "critical_p95": 240, "enabled": true, "min_scale": 0.7, "safe_p95": 200, "slew": 0.25, "warning_p95": 220}, "interval": 30, "light_thresholds": {"day": 80, "night": 3}, "night_mode": {"analogue_gain": 6, "awb_enable": false, "colour_gains": [1.83, 2.02], "max_exposure_time": 20}, "num_frames": 0, "reference_lux": 3.8, "test_shot": {"analogue_gain": 1, "enabled": true, "exposure_time": 0.2, "frequency": 1}, "transition_mode": {"brightness_feedback_enabled": true, "critical_rampdown_speed": 0.7, "ev_safety_clamp_enabled": true, "exposure_transition_speed": 0.08, "fast_rampdown_speed": 0.2, "fast_rampup_speed": 0.2, "gain_transition_speed": 0.1, "hysteresis_frames": 3, "lux_change_threshold": 3, "lux_smoothing_factor": 0.3, "sequential_ramping": true, "smooth_exposure_in_day_mode": true, "smooth_transition": true, "smooth_wb_in_day_mode": true, "target_brightness": 120, "wb_transition_speed": 0.15}}, "location": {"civil_twilight_threshold": -6, "latitude": 68.7, "longitude": 15.4, "timezone": "Europe/Oslo"}},

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Make stable_day actually stable before using it as a baseline.

The fixture varies substantially despite claiming it should “barely move”: mean brightness rises from 121.2 at Line 12 to 173.97 at Line 35, then ends around 155 at Line 126. This can mask exposure-controller drift and invalidate convergence/flicker assertions. Regenerate it from a stable scene, or rename it and adjust the expected behavior.

🤖 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/sequences/stable_day.json` around lines 3 - 5, Regenerate the
stable_day fixture from a genuinely stable scene so its brightness remains near
the stated baseline throughout the replay, or rename the fixture and update its
expected convergence/flicker assertions to reflect the observed variation.
Preserve the stable baseline semantics before using stable_day for
controller-drift validation.

Comment on lines +4 to +5
"description": "a dark scene with a bright light source in it, alternating between two levels while its highlights spread wider and wider -- walking the clipped-pixel fraction through the 3% and 5% thresholds at a point on the ladder, and in a state of the loop, where the resulting rate change is visible",
"config": {"adaptive_timelapse": {"brightness_damping": 0.5, "brightness_target": {"base": 120, "contrast_threshold_high": 40, "contrast_threshold_low": 25, "max_target": 140, "overcast_boost": 15}, "day_mode": {"analogue_gain": 1, "awb_enable": true, "exposure_time": 0.01, "fixed_colour_gains": [2.5, 1.6]}, "diagnostics": {"enabled": true}, "enabled": true, "hdr": {"day_mode": "SingleExposure", "enabled": false, "night_mode": "Off"}, "highlight_protection": {"apply_in_night": false, "critical_p95": 240, "enabled": true, "min_scale": 0.7, "safe_p95": 200, "slew": 0.25, "warning_p95": 220}, "interval": 30, "light_thresholds": {"day": 80, "night": 3}, "night_mode": {"analogue_gain": 6, "awb_enable": false, "colour_gains": [1.83, 2.02], "max_exposure_time": 20}, "num_frames": 0, "reference_lux": 3.8, "test_shot": {"analogue_gain": 1, "enabled": true, "exposure_time": 0.2, "frequency": 1}, "transition_mode": {"brightness_feedback_enabled": true, "critical_rampdown_speed": 0.7, "ev_safety_clamp_enabled": true, "exposure_transition_speed": 0.08, "fast_rampdown_speed": 0.2, "fast_rampup_speed": 0.2, "gain_transition_speed": 0.1, "hysteresis_frames": 3, "lux_change_threshold": 3, "lux_smoothing_factor": 0.3, "sequential_ramping": true, "smooth_exposure_in_day_mode": true, "smooth_transition": true, "smooth_wb_in_day_mode": true, "target_brightness": 120, "wb_transition_speed": 0.15}}, "location": {"civil_twilight_threshold": -6, "latitude": 68.7, "longitude": 15.4, "timezone": "Europe/Oslo"}},

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Populate the clipping sweep with non-zero clipped pixels.

Despite the description, every frame from Line 8 through Line 179 has overexposed_percent: 0.0 and percentile_95: 200.0. The replay therefore cannot cross the 3%/5% thresholds or validate the resulting controller-rate change. Regenerate the sequence with values below, at, and above both thresholds, then update its golden output.

🤖 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/sequences/synthetic_clipping_sweep.json` around lines 4 - 5,
Regenerate the synthetic clipping sweep sequence so frames 8–179 contain
non-zero clipped-pixel values spanning below, at, and above the 3% and 5%
thresholds, with corresponding percentile_95 values that exercise the described
controller-rate changes. Update the sequence’s golden output to match the
regenerated replay results while preserving the existing sweep progression and
configuration.

ekstremedia and others added 2 commits July 27, 2026 16:10
…ller ones

CodeRabbit's best catch is a genuine defect, not a test-only one. On any
frame crossing out of the bright end, the diagnostics written into the
metadata JSON described the handover seed rather than the exposure the
frame was taken with: `_decide()` calls `decide()`, then
`_seed_across_mode_change()` runs `seed_from_metadata`, which overwrites
the controller's shutter, gain and ladder position -- and `_record` read
the diagnostics after that. A frame carrying settings of gain 1.0
reported an applied gain of 5.9.

The golden files had it baked in too, so a later fix to the ordering
would have read as a behavioural regression against the baseline. The
Decision now carries a snapshot taken the instant decide() returns, and
the replay harness does the same.

A new test pins it, and checks both the committed goldens and a fresh
replay -- checking only the files caught a bad re-recording but not the
harness bug that produced it, since reintroducing the fault leaves the
files on disk untouched. Confirmed by reintroducing it.

Also genuinely broken:

  raspilapse-status died with `Error: 'night_mode'` against the very
  config.example.yml the README tells people to copy. It read raw YAML
  where every other entry point merges the defaults. Mine, from the
  ladder commit, which added that lookup.

  merge_defaults copied one level deep, so an untouched nested default
  aliased the entry in DEFAULTS and a caller mutating its own config
  would change what every later load in the process saw. The docstring
  promised a new dict.

  A failing reference shot left its position unrecorded, so
  _wants_reference_shot stayed true and the loop tore the live camera
  down and failed to open a second one on every frame, forever.

  Camera initialisation sat outside the tolerant try, so a device still
  briefly busy after the previous teardown ended the daemon rather than
  costing a frame. That matters more now the camera is opened once per
  frame.

  `not brightness` conflated a frame measuring 0.0 with a missing
  measurement, in three places -- the same distinction _required_exposure
  was deliberately changed to make, applied inconsistently. The darkest
  frames were being dropped from the comparison used to judge the ladder.

  Docs pointed at `raspilapse.cli.overlay`, which does not exist; the
  module is `apply_overlay` and the console script is `raspilapse-overlay`.

  CONFIG-REFERENCE.yml still read as a copy-me template, still described
  the polar-day override and civil-twilight threshold, and still told
  people to run `python3 src/database.py --vacuum`. INSTALL.md still said
  sun elevation decides when day begins and ends.

  Pillow floor raised to 10.0.1 -- 10.0.0 is affected by advisories fixed
  there, and this project decodes images in the overlay and keogram paths.

Skipped, with reasons: the metering thresholds are hardcoded while the
rates they select are configurable, which is a design opinion rather than
a defect and would add config surface to settings the mutation check
already pins. The clipping-sweep fixture's zero overexposed_percent is
not a gap -- the harness computes the clipped fraction from each frame's
spread and never reads that field; the description now says so, and the
mutation check proves the sweep crosses both thresholds.

Verified on the live camera: restarted, capturing, and the status command
now works against the shipped example.

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

CodeRabbit's pre-merge check puts docstring coverage at 79.63% against an
80% threshold. Six of the twenty-eight it counted were genuinely
undocumented and say something a reader cannot get from the name:

  Meter.__init__ -- which config section it reads and what for
  Meter.set_dark_end -- why the caller has to tell it, and what it changes
  the four CLI main() entry points -- one line each

The rest are left alone. They are property getters called `brightness`,
`p95`, `overexposed`, `last_mode` -- a docstring on those restates the
name and makes the file longer without making it clearer, which is the
opposite of what this branch is for.

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

Copy link
Copy Markdown
Owner Author

Three inline comments are still showing as open. One is fixed and just not
re-marked; two I resolved deliberately rather than as suggested, so the
reasoning belongs here rather than only in a commit message.

tests/replay/golden/night_underexposure_edge.json — fixed in a859818.
Same root cause as the very_bright_night comment, and the same fix: the
harness read diagnostics() after seed_from_metadata had already
overwritten the shutter, gain and ladder position. That was a real defect
in production code too, not just the fixtures — _record read the
controller's live state the same way, so every handover frame's metadata
JSON described the seed rather than the exposure it was taken with.

Decision now carries a snapshot taken the instant decide() returns.
The file currently has zero frames where the diagnostics disagree with
the settings, and test_diagnostics_describe_the_frame_they_were_recorded_with
pins it — against both the committed goldens and a fresh replay, because
checking only the files caught a bad re-recording but not the harness bug
that produced it. Confirmed by reintroducing the fault.

tests/replay/sequences/stable_day.json — description corrected, fixture
kept.
You're right that it varies: 121 → 174 → 155. But it is one real
hour of afternoon light from this camera, and cloud moving across the sun
is what an outdoor baseline looks like. Regenerating it from something
flatter would make it a less useful regression input, not a more useful
one — and nothing asserts convergence or flicker against it; it is a
deterministic input to the golden comparison. The description now says
what it actually contains instead of claiming it "barely moves".

tests/replay/sequences/synthetic_clipping_sweep.json — not a gap. The
overexposed_percent: 0.0 in the fixture is never read. The harness runs
a closed loop and derives the clipped fraction from each frame's spread
via a normal-tail model (harness._tail_fraction), because the recorded
value belongs to whatever exposure the old controller happened to be
using. What sweeps the thresholds is std_brightness, 40 → 124 and back.

That it works is checked rather than asserted: mutation_check.py moves
the 5% clipped-pixel warning threshold and the golden tests fail. The 3%
release threshold needs four conditions at once to show up in a rendered
frame — a mean between the two release points, a clipped fraction between
3% and 4%, a ladder position past 0.87 so the recovery rate exceeds the
ordinary one, and a loop that has not yet converged — so it is pinned
directly in test_metering.py instead, and mutation_check.py documents
why it is absent from the golden set rather than dropping it silently.

The sequence description now explains where the clipped fraction comes
from, since the zero field is genuinely misleading to read.

3.8 MB of JSON against 1.1 MB of Python is a ratio that needs justifying,
and nothing in the repository justified it. tests/replay/README.md now
does: what a sequence is, what a golden is, why some are recorded from
the database and some invented, and why there are seventeen.

Measuring that turned up three that no longer earn their place.
synthetic_threshold_edge_day and synthetic_threshold_edge_night pinned
determine_mode's `<` against `<=`; synthetic_hybrid_override drove the
hybrid brightness override. The ladder has neither a threshold to sit on
the edge of nor an override to trigger, so all three had been catching
mutations by coincidence since it landed -- which is not a reason to keep
a fixture. Removing them leaves all 33 mutations caught, and promotes
synthetic_clipping_sweep to sole detector of the clipped-pixel warning,
which is the fixture built for it.

The README carries the coverage measurement rather than an assurance:
which sequence detects how many of the 33 mutations, and which four are
the sole detector of something. That is the table to consult before
deleting one of these files, and it is why the remaining overlap is
deliberate -- six sequences would cover the mutation check on their own,
but compare.py needs a realistic spread of light to say whether an
exposure change is an improvement, and a thin sample would make the next
one much harder to justify.

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

@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

🤖 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 `@requirements.txt`:
- Line 18: Update the Pillow dependency constraint in requirements.txt from
10.0.1 to a project-compatible patched minimum at or above 12.2.0, then validate
the resulting dependency set across all supported targets with python -m
pip_audit -r requirements.txt.

In `@tests/replay/compare.py`:
- Around line 92-95: Update the scene-luminance eligibility checks in
tests/replay/compare.py lines 92-95 and tests/replay/harness.py lines 118-121 to
reject brightness values at or above SATURATED, along with existing unusable
values, before calculating luminance. Apply the identical rule in both helpers
so comparisons and replay goldens use the same scene model; preserve valid zero
brightness measurements.

In `@tests/replay/README.md`:
- Line 110: Update the fenced code block in the replay README to specify the
text language by changing its opening fence to use text, while leaving the block
contents unchanged.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

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

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 77d2f765-2d77-4aca-8dc2-6e8b49926f39

📥 Commits

Reviewing files that changed from the base of the PR and between 28499f0 and 334c977.

📒 Files selected for processing (47)
  • CONTRIBUTING.md
  • README.md
  • docs/CONFIG-REFERENCE.yml
  • docs/INSTALL.md
  • docs/OVERLAY.md
  • pyproject.toml
  • raspilapse/camera/exposure.py
  • raspilapse/camera/metering.py
  • raspilapse/cli/apply_overlay.py
  • raspilapse/cli/retry_uploads.py
  • raspilapse/cli/status.py
  • raspilapse/config.py
  • raspilapse/daemon.py
  • raspilapse/video/daily.py
  • raspilapse/video/keogram.py
  • raspilapse/video/timelapse.py
  • requirements.txt
  • tests/replay/README.md
  • tests/replay/compare.py
  • tests/replay/extract_sequences.py
  • tests/replay/golden/blown_highlights.json
  • 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/stable_day.json
  • tests/replay/golden/synthetic_clamped_highlights.json
  • tests/replay/golden/synthetic_clipped_pixels.json
  • tests/replay/golden/synthetic_clipping_sweep.json
  • tests/replay/golden/synthetic_extreme_gain.json
  • tests/replay/golden/synthetic_low_night_gain.json
  • tests/replay/golden/synthetic_night_brightness_sweep.json
  • tests/replay/golden/synthetic_starved_light.json
  • tests/replay/golden/synthetic_underexposure_release.json
  • tests/replay/golden/very_bright_night.json
  • tests/replay/harness.py
  • tests/replay/mutation_check.py
  • tests/replay/sequences/stable_day.json
  • tests/replay/sequences/synthetic_clipping_sweep.json
  • tests/replay/synthetic_sequences.py
  • tests/test_auto_timelapse.py
  • tests/test_config_example.py
  • tests/test_daily_timelapse.py
  • tests/test_overlay_optional.py
  • tests/test_replay_golden.py
💤 Files with no reviewable changes (1)
  • tests/test_config_example.py
🚧 Files skipped from review as they are similar to previous changes (38)
  • tests/replay/golden/deep_dark.json
  • tests/replay/golden/synthetic_starved_light.json
  • tests/replay/golden/synthetic_clipped_pixels.json
  • tests/replay/golden/synthetic_extreme_gain.json
  • tests/replay/golden/bright_night.json
  • tests/test_overlay_optional.py
  • tests/replay/golden/synthetic_low_night_gain.json
  • tests/replay/golden/synthetic_clipping_sweep.json
  • tests/replay/golden/dusk_transition.json
  • raspilapse/cli/apply_overlay.py
  • tests/replay/sequences/stable_day.json
  • tests/replay/golden/blown_highlights.json
  • tests/replay/sequences/synthetic_clipping_sweep.json
  • raspilapse/video/timelapse.py
  • raspilapse/video/daily.py
  • tests/replay/mutation_check.py
  • README.md
  • CONTRIBUTING.md
  • tests/replay/golden/night_underexposure_edge.json
  • tests/replay/golden/synthetic_clamped_highlights.json
  • pyproject.toml
  • docs/OVERLAY.md
  • tests/replay/golden/very_bright_night.json
  • tests/replay/golden/synthetic_night_brightness_sweep.json
  • tests/test_daily_timelapse.py
  • tests/replay/golden/crashing_light.json
  • tests/replay/extract_sequences.py
  • tests/test_replay_golden.py
  • raspilapse/config.py
  • raspilapse/cli/retry_uploads.py
  • raspilapse/video/keogram.py
  • docs/INSTALL.md
  • raspilapse/camera/exposure.py
  • raspilapse/camera/metering.py
  • tests/test_auto_timelapse.py
  • docs/CONFIG-REFERENCE.yml
  • tests/replay/golden/dawn_transition.json
  • raspilapse/daemon.py

Comment thread requirements.txt Outdated
# Image analysis (capture pipeline) and graph generation (scripts/db_graphs.py)
# Overlay rendering and keograms; also the lores brightness metering the
# exposure loop closes on. Both are pulled in by python3-picamera2.
Pillow>=10.0.1

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟠 Major

Raise the Pillow security floor beyond 10.0.1.

Pillow 10.0.1 remains affected by advisories such as PYSEC-2026-165, fixed in 12.2.0; PyPI lists 12.3.0 as the current release. Because this project uses Pillow for overlay/keogram image processing, this constraint still permits vulnerable installations. (osv.dev)

Use a project-compatible patched floor and validate all supported targets:

python -m pip_audit -r requirements.txt
🧰 Tools
🪛 OSV Scanner (2.4.0)

[CRITICAL] 18-18: pillow 10.0.1: undefined

(PYSEC-2026-165)


[CRITICAL] 18-18: pillow 10.0.1: Pillow buffer overflow vulnerability

(PYSEC-2026-1793)


[CRITICAL] 18-18: pillow 10.0.1: undefined

(PYSEC-2026-2253)


[CRITICAL] 18-18: pillow 10.0.1: undefined

(PYSEC-2026-2254)


[CRITICAL] 18-18: pillow 10.0.1: undefined

(PYSEC-2026-2255)


[CRITICAL] 18-18: pillow 10.0.1: undefined

(PYSEC-2026-2256)


[CRITICAL] 18-18: pillow 10.0.1: undefined

(PYSEC-2026-2257)


[CRITICAL] 18-18: pillow 10.0.1: Pillow has a PDF Parsing Trailer Infinite Loop (DoS)

(PYSEC-2026-2874)


[CRITICAL] 18-18: pillow 10.0.1: undefined

(PYSEC-2026-3451)


[CRITICAL] 18-18: pillow 10.0.1: undefined

(PYSEC-2026-3453)


[CRITICAL] 18-18: pillow 10.0.1: undefined

(PYSEC-2026-3454)


[CRITICAL] 18-18: pillow 10.0.1: Pillow: Out-of-bounds read via attacker-controlled row stride on Pillow's mmap path (McIdas AREA files)

(PYSEC-2026-3493)


[CRITICAL] 18-18: pillow 10.0.1: Pillow TGA RLE encoder can serialize up to ~57 KB of adjacent heap data into generated images

(PYSEC-2026-3494)


[CRITICAL] 18-18: pillow 10.0.1: Pillow: Decompression Bomb DoS via PdfParser.PdfStream.decode()

(PYSEC-2026-3495)


[CRITICAL] 18-18: pillow 10.0.1: Pillow JPEG2000 tiled decode retains a growing scratch buffer and can be used for denial of service

(PYSEC-2026-3496)


[CRITICAL] 18-18: pillow 10.0.1: Arbitrary Code Execution in Pillow

(PYSEC-2026-457)


[CRITICAL] 18-18: pillow 10.0.1: Arbitrary Code Execution in Pillow

(GHSA-3f63-hfp8-52jq)


[CRITICAL] 18-18: pillow 10.0.1: Pillow buffer overflow vulnerability

(GHSA-44wm-f244-xhp3)


[CRITICAL] 18-18: pillow 10.0.1: Pillow BdfFontFile: Image.new() called without _decompression_bomb_check() — bomb protection bypass via font loading

(GHSA-45hq-cxwh-f6vc)


[CRITICAL] 18-18: pillow 10.0.1: Pillow: WindowsViewer.get_command() OS command injection via unescaped shell path

(GHSA-4x4j-2g7c-83w6)


[CRITICAL] 18-18: pillow 10.0.1: Pillow: FontFile.compile(): Image.new() called without _decompression_bomb_check()

(GHSA-5x94-69rx-g8h2)


[CRITICAL] 18-18: pillow 10.0.1: Pillow: Out-of-bounds read via attacker-controlled row stride on Pillow's mmap path (McIdas AREA files)

(GHSA-62p4-gmf7-7g93)


[CRITICAL] 18-18: pillow 10.0.1: Pillow: Heap out-of-bounds write Image.paste() / Image.crop() via signed coordinate overflow

(GHSA-6r8x-57c9-28j4)


[CRITICAL] 18-18: pillow 10.0.1: Pillow PcfFontFile._load_bitmaps(): Image.frombytes() called without _decompression_bomb_check() — bomb protection bypass via PCF font loading

(GHSA-8v84-f9pq-wr9x)


[CRITICAL] 18-18: pillow 10.0.1: Pillow: Controlled heap out-of-bounds write in Pillow ImageCmsTransform.apply() via output mode mismatch

(GHSA-9hw9-ch79-4vh6)


[CRITICAL] 18-18: pillow 10.0.1: Pillow TGA RLE encoder can serialize up to ~57 KB of adjacent heap data into generated images

(GHSA-fj7v-r99m-22gq)


[CRITICAL] 18-18: pillow 10.0.1: Pillow: Decompression Bomb DoS via PdfParser.PdfStream.decode()

(GHSA-jjj6-mw9f-p565)


[CRITICAL] 18-18: pillow 10.0.1: Pillow GdImageFile._open(): image dimensions accepted without _decompression_bomb_check()

(GHSA-phj9-mv4w-65pm)


[CRITICAL] 18-18: pillow 10.0.1: Pillow has a PDF Parsing Trailer Infinite Loop (DoS)

(GHSA-r73j-pqj5-w3x7)


[CRITICAL] 18-18: pillow 10.0.1: Pillow JPEG2000 tiled decode retains a growing scratch buffer and can be used for denial of service

(GHSA-vjc4-5qp5-m44j)


[CRITICAL] 18-18: pillow 10.0.1: Pillow has an integer overflow when processing fonts

(GHSA-wjx4-4jcj-g98j)


[CRITICAL] 18-18: pillow 10.0.1: Pillow: Heap out-of-bounds write in ImageFilter.RankFilter via integer overflow in ImagingExpand

(GHSA-xj96-63gp-2gmr)

🤖 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 `@requirements.txt` at line 18, Update the Pillow dependency constraint in
requirements.txt from 10.0.1 to a project-compatible patched minimum at or above
12.2.0, then validate the resulting dependency set across all supported targets
with python -m pip_audit -r requirements.txt.

Source: Linters/SAST tools

Comment thread tests/replay/compare.py Outdated
Comment thread tests/replay/README.md Outdated
scene_luminance's docstring claimed it returned None for a saturated
frame, "since a clipped measurement puts only a lower bound on the
light". The code never checked. At the sensor ceiling the reading bounds
the scene from below rather than measuring it, so dividing understates
the light -- in the very comparison that judged the exposure ladder. Both
copies now reject it, so the goldens and the comparison share one scene
model.

No frame in the current fixtures is affected: nothing recorded reaches
254. The guard is right anyway, and the docstring is no longer a lie.

Pillow raised to 11.0.0, not the 12.2.0 the scanner asked for. Pillow 12
requires Python 3.10; this package supports 3.9 because Raspberry Pi OS
Bullseye ships it and CI builds against it, so 12.x would fail the build
outright. 11.x is the ceiling until 3.9 is dropped, which is a bigger
decision than a dependency floor.

Worth noting either way that a Pi installs neither: apt's python3-pil is
9.4.0 and arrives with picamera2. This floor is for pip installs -- CI
and development checkouts.

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

@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: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
tests/replay/README.md (1)

141-144: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Describe the fixture format accurately.

These files are JSON documents with one frame object per line inside the frames array—not one standalone JSON object per line (JSONL). Clarify this so readers do not try to parse each line independently.

Proposed fix
-The files are one JSON object per line on purpose:
+The files place one frame object per line inside a JSON document on purpose:
🤖 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/README.md` around lines 141 - 144, Update the fixture-format
description in the README to state that each file is a single JSON document
whose frames array contains one frame object per line, rather than standalone
JSONL objects. Preserve the explanation about readable diffs and large-file-hook
limits.
🤖 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 `@requirements.txt`:
- Around line 18-26: Update the Pillow requirement from >=11.0.0 to >=11.3.0,
revise the adjacent compatibility comments to state that 11.3.0 is the Python
3.9-compatible security floor and Pillow 12 remains unavailable until Python 3.9
support is dropped, then run pip_audit against requirements.txt across supported
environments.

---

Outside diff comments:
In `@tests/replay/README.md`:
- Around line 141-144: Update the fixture-format description in the README to
state that each file is a single JSON document whose frames array contains one
frame object per line, rather than standalone JSONL objects. Preserve the
explanation about readable diffs and large-file-hook limits.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

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

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 72803dbb-b4ca-4d8b-b6ef-a8f2e48607ee

📥 Commits

Reviewing files that changed from the base of the PR and between 334c977 and ec7767b.

📒 Files selected for processing (5)
  • pyproject.toml
  • requirements.txt
  • tests/replay/README.md
  • tests/replay/compare.py
  • tests/replay/harness.py
🚧 Files skipped from review as they are similar to previous changes (3)
  • tests/replay/compare.py
  • tests/replay/harness.py
  • pyproject.toml

Comment thread requirements.txt Outdated
ekstremedia and others added 4 commits July 27, 2026 22:45
apply_overlay was 469 lines, of which 213 drew the two right-hand sections of
the top bar. They move to overlay/widgets.py behind a contract the bar can
compose: draw yourself against the right edge inset by what is already there,
return the width you used. The five _format_* helpers move to overlay/formats.py,
where the fixed-width reasoning is written down once instead of being repeated
per formatter. render.py 1186 -> 910, apply_overlay 469 -> 283.

Verified by rendering rather than by the tests passing. The overlay's product is
an image, and the 120 overlay tests check formatters and data preparation, not
pixels -- so a golden-image check pins time, weather, tide, aurora and system
metrics, renders four cases, and hashes them. Original and refactored agree on
every pixel of all four.

The check was then mutated to confirm it can fail: divider alpha off by one,
wave resolution, wave width, section gap, marker position, marker colour and
tide event ordering are all caught. Two survived at first, and both were the
check's fault rather than the code's -- the tide fixture had its high before its
low, so the chronological branch and an always-high-first mutant emitted the
same string, and a fourth case was added for it. The other survivor is real and
documented at AURORA_WIDEST: only the wider line of each template pair sets the
section width, so shortening the narrower one is genuinely inert.

Also from review, both correct:

  Pillow floor 11.0.0 -> 11.3.0. 11.3.0 is the last 11.x release and carries
  every fix available to Python 3.9; the earlier floor admitted 11.0.0 and its
  advisories for no reason, since 3.9 support only rules out 12.x.

  tests/replay/README.md called the fixtures one JSON object per line, which
  reads as JSONL. They are one JSON document with one frame object per line
  inside the frames array, and must be read with json.load.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0123LLDR874Fyp1gbjAVbR2a
apply_overlay was still 283 lines after the widgets moved out: font loading,
the whole top-bar layout, the corner-box layout and the save, in one function
under one try. Those become _load_sized_fonts, _draw_top_bar and
_draw_corner_box, leaving apply_overlay at 65 lines that read as what it does --
open, size the fonts, prepare the data, pick a layout, save.

The extraction introduced one bug and the tests caught it. _draw_corner_box
returned None where the caller expected "did I draw anything", so every
non-top-bar preset skipped the save and returned the untouched image. The pixel
check was green throughout, because at that point it only rendered top-bar
frames. Two corner-box cases were added for exactly that reason, and the
restored bug is now caught by both.

The check itself moves to tests/overlay_render_check.py, next to the replay
harness and for the same reason: the overlay's product is an image and none of
the 122 overlay tests looks at a pixel. It freezes the clock, weather, tide,
aurora and system metrics, generates its input image rather than reading one,
and prints a hash per case to diff across a change.

It is a tool rather than a pytest test, and the docstring says why: text lands
on different pixels under a different Pillow, FreeType or DejaVu build, so a
committed hash would fail where nothing is wrong and teach people to re-record
it. Against itself on one machine it is exact.

Verified across the whole refactor -- ec7767b's 1186-line render.py and this
one produce byte-identical output on all six cases. Mutation-checked: a missing
return, a one-pixel line offset, a one-pixel text offset and a width/height swap
are all caught, and a no-op control survives.

raspilapse/overlay/render.py 1186 -> 934, largest function 469 -> 207
(_prepare_overlay_data, next).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0123LLDR874Fyp1gbjAVbR2a
The last long function in overlay/render.py, at 207 lines, was six unrelated
things sharing one dict: camera settings, host metrics, weather, ships, tide.
Each becomes a helper returning its own fields, and _prepare_overlay_data is the
six lines that merge them.

The tide block wrote its nine placeholder values out twice, identically -- once
for "disabled" and once for "enabled but no reading". Those are the same case
and now share a branch. The source is still only queried when enabled, which is
not cosmetic: get_widget_data reads a cache file and may refresh it over the
network, and doing that 2,880 times a day for output that is discarded is worth
avoiding. There is now a test that it is not called.

The refactor turned up a coverage hole rather than a bug. Dropping the ship
fields entirely, dropping the system fields entirely, changing the disabled-tide
placeholders, and querying the tide source while disabled all passed the full
overlay suite -- 122 tests, none of which looked at these keys. The contract
they leave unguarded is that every placeholder exists on every frame: a missing
one raises KeyError inside apply_overlay, which catches it and returns None, so
the failure is a single un-overlaid frame in the middle of a timelapse rather
than anything that announces itself.

tests/test_overlay_fields.py covers it: 41 tests over the tide, ship and system
groups, plus a parametrised check that 33 placeholders survive with every source
switched off and every network call failing. Six of the seven mutations above
are now caught there, and the seventh (dropping weather) was already caught by
the existing suite.

overlay/render.py 1186 -> 962, largest function 469 -> 154 (_draw_top_bar).
Pixel-identical to ec7767b on all six render-check cases; 1037 tests pass.

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

Review read synthetic_clipping_sweep's stored `overexposed_percent: 0.0` on all
172 frames and concluded the clipping path was unreachable, so the fixture could
not do the job its description claims. Reasonable from the file; wrong about the
harness.

The replay is closed-loop. It recovers scene luminance from the recorded
brightness and exposure, then shows the controller the brightness its own
choices would produce -- harness.observe derives overexposed_percent and
underexposed_percent from the simulated mean and the carried-over spread, and
never passes the recorded ones to the code under test. Measured over a replay:
clipping ranges to 26.8%, 129 of 172 frames sit past the 5% warning threshold,
and breaking that threshold on purpose is still detected by this sequence and no
other.

So no fixture change, but the confusion is the README's fault and this is the
second reader it has caught. It now says which stored fields are inputs to the
scene model and which are ignored, and names this sequence as the example.

Verified by re-running the mutation: `clipped_warning 5 -> 6` is detected by
synthetic_clipping_sweep alone, as the coverage table claims.

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

Copy link
Copy Markdown
Owner Author

Re-checked the four threads still showing as unresolved. All four are isOutdated — three were fixed earlier and one is a misreading. Evidence for each, since none of them re-marked automatically:

1. night_underexposure_edge.json — diagnostics disagreeing with settings. Real bug, fixed. seed_from_metadata overwrote the shutter and gain after decide() returned, and the harness read diagnostics late, so handover frames recorded a gain of 1.0 alongside a reported 5.988. Fixed by snapshotting diagnostics the instant decide() returns, and goldens re-recorded. Now 0 of 240 frames show any skew, and test_diagnostics_describe_the_frame_they_were_recorded_with pins it against both the committed files and a fresh replay — so reintroducing the ordering fault fails even though the files on disk would be untouched.

2. stable_day not stable. Correct observation, and the fixture was right — the description was wrong. Cloud genuinely takes the mean from 121 to 174 and back within the hour. It now says so: "an ordinary hour of afternoon daylight — converged, but real: cloud takes the mean from 121 to 174 and back, which is what a baseline for a camera that lives outdoors should look like." A baseline with the weather removed would be the less useful fixture.

3. synthetic_clipping_sweep having no clipped pixels — not actionable, the fixture works. The stored overexposed_percent: 0.0 is real but is never read by the code under test. The replay is closed-loop: it recovers scene luminance from the recorded brightness and exposure, then shows the controller the brightness its own choices would produce. harness.observe derives overexposed_percent from the simulated mean and the carried-over spread. Measured over an actual replay:

frames replayed: 172
  clipped > 0  : 172
  max clipped  : 26.791%
  frames > 5%  : 129     (the clipped_warning threshold)
  frames 3-5%  : 11      (between safe and warning)

And re-running the mutation directly: clipped_warning 5 -> 6 is detected by synthetic_clipping_sweep and no other sequence, as the coverage table claims.

The file is genuinely misleading though, and this is the second reader it has caught, so tests/replay/README.md now states which stored fields feed the scene model and which are ignored — 0e5b1c5.

4. Pillow floor. Fixed in f1e1ad0, and the thread text is stale (it refers to 10.0.1; the floor was 11.0.0 by then). Now Pillow>=11.3.0 in both requirements.txt and pyproject.toml — the last 11.x release, carrying every fix available to Python 3.9. 12.x needs 3.10 and Bullseye ships 3.9, so that is the ceiling until 3.9 is dropped.

All 6 CI checks pass; 1037 tests.

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