Blend colours across an event's phases, day by day (#74) #128
No reviewers
Labels
No labels
area/ai
area/backend
area/frontend
area/infra
area/scheduler
area/wled
good-first-issue
priority/high
priority/low
priority/medium
type/bug
type/chore
type/ci-cd
type/docs
type/feature
type/qa
v1.0.0
v1.1.0
v1.2.0
v2.0.0
No milestone
No project
No assignees
2 participants
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
rbrooks/Iris-WLED!128
Loading…
Reference in a new issue
No description provided.
Delete branch "feat/74-hsv-interpolation"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Closes #74. Implements spec §4.2.
Opt in per event with
phase_config.interpolateand a 26-day Christmas fades from its opening colours through the mid ones to the closing ones, instead of stepping between three fixed looks.The design question is not the colour maths
It's what a human approves. Interpolation wants a scheme per day; the review queue wants a scheme per phase. Twenty-six independently approvable rows for one holiday would make the queue unusable.
So the four phase schemes stay the anchors — what the UI lists, what a person edits and approves — and the per-day rows are derived: generated from the anchors, rebuilt whenever an anchor changes, carrying
schemes.derived_from, and filtered out of the review list, WLED preset sync and config export. Anchors sit at the centre of their phase rather than its edge, so a phase's colours are actually seen during that phase rather than being already half-faded on day two.A derived day is approved only when every anchor it blends is approved. Falling back to the plain anchor colours on a half-approved event would light the tree in something the user never signed off on because a different scheme was approved. Approval means a human vetted what shows, so a half-approved event stays dark on its blended days exactly as an unapproved one does.
Colour maths
Hue takes the shorter arc. Red → magenta is 60° backwards; a linear blend takes the 300° route forwards and spends a fortnight passing through green. Exactly 180° apart is genuinely ambiguous, so forward is chosen and pinned by a test — otherwise the output depends on floating-point noise.
Blending is in HSV, not channel-wise RGB: the RGB midpoint of red and blue is a muddy
(128, 0, 128)rather than full-saturation magenta. On a strip that difference is the whole point of §4.2.An achromatic endpoint contributes no hue.
colorsysreports white as hue 0, which reads as "red" — so a naive blend from blue to white swings towards red and lands on a lilac nobody asked for. The other endpoint's hue holds instead.effect_idandpalettedo not interpolate. Effect 26 is not halfway between 12 and 40, it is an unrelated animation; lerping them walks the strip through effects nobody chose. They step to the nearer anchor at the midpoint. Days landing exactly on an anchor get no row at all — the blend there is the anchor — so a three-day event produces zero derived rows, which is correct: there is nothing between the anchors to interpolate.Two exclusions that are load-bearing, not tidiness
_find_approved_for_keymatchesevent_key + phase + approved, and a derived row satisfies all three: it carries the phase of the day it falls in and inherits approval. Without the filter, next year'smidanchor would be seeded from whichever blended day the query happened to return first — a colour halfway between two anchors that nobody chose.PRESET_COUNTon a single holiday. Presets mirror the schemes for manual recall from the WLED app; the scheduler pushes state directly and never reads them back, so the blends lose nothing by staying out of the slot budget.The UI now tells the truth about a blended day
The side panel previewed the anchor, which on an interpolated day is a colour that never reaches the controller — the same class of mistake as #93, where the calendar displayed a scheme the scheduler wasn't pushing. New
GET /schemes/for-date/{date}returns what will actually light, resolving through the same helper the scheduler uses, so the preview and the push cannot disagree. Approve/edit still target the anchor.Writing the tests found a real bug
sync_derived_schemesoriginally read anchor colours offscheme.segments. Butgenerate_schemes_for_eventrebuilds those rows withdb.add(SchemeSegment(...))rather than through the relationship, so the loaded collection can still hold the previous generation's values. Blending from those would produce a gradient between colours no longer anchored anywhere — and it would look entirely plausible. It now queries the rows directly.A second, smaller correction: three of my own tests were passing because of that staleness. They painted an anchor and then called the full generator, which regenerates unapproved anchors from the template and throws the edit away — the stale collection was preserving it. They now go through
resync_derived_for_event_id, which is whatPUT /schemes/{id}actually calls.Tests
59 new backend tests, written against properties a plausible-but-wrong implementation fails. The one that matters most is
test_the_gradient_actually_moves: it walks all fifteen days of an event and asserts the hue is strictly increasing, because a "blend" that returned either anchor unchanged would satisfy every per-day spot check.Verification
Backend
ruffclean,mypyclean, 654 tests passing.alembic upgrade head+alembic checkclean on a fresh DB, and the downgrade tested (it deletes derived rows before dropping the column, rather than leaving a downgraded install worse than one that never interpolated). Frontend innode:22:tsc -bclean,eslintclean, 81 vitest passing,npm run buildsucceeds.🤖 Generated with Claude Code
Opt in per event with phase_config.interpolate and a 26-day Christmas fades from its opening colours through the mid ones to the closing ones instead of stepping between three fixed looks. The design question this feature actually poses is not the colour maths, it is what a human approves. Interpolation wants a scheme per day; the review queue wants a scheme per phase. Twenty-six approvable rows for one holiday would make the queue unusable, so the four phase schemes stay the anchors -- what the UI lists, what a person edits and approves -- and the per-day rows are derived from them: rebuilt whenever an anchor changes, carrying derived_from, and filtered out of the review list, WLED preset sync and config export. Anchors sit at the centre of their phase rather than its edge, so a phase's colours are actually seen during it. A derived day is approved only when every anchor it blends is approved. Falling back to the plain anchor colours on a half-approved event would light the tree in something the user never signed off on because a different scheme was approved, and approval means a human vetted what shows. Hue takes the shorter arc of the wheel. Red to magenta is 60 degrees backwards; a linear blend takes the 300-degree route forwards and spends a fortnight passing through green. Blending is in HSV rather than channel-wise RGB because the RGB midpoint of red and blue is a muddy (128, 0, 128) instead of full-saturation magenta. An achromatic endpoint contributes no hue -- colorsys reports white as hue 0, which reads as red -- so blending towards white desaturates along the other colour's hue. effect_id and palette do not interpolate. Effect 26 is not halfway between 12 and 40, it is an unrelated animation, so they step to the nearer anchor at the midpoint. Days landing exactly on an anchor get no row at all: the blend there is the anchor, and a three-day event therefore produces zero derived rows, which is correct. Two exclusions are load-bearing rather than tidiness. _find_approved_for_key matches event_key + phase + approved, and a derived row satisfies all three -- without the filter, next year's mid anchor would be seeded from whichever blended day the query returned first. And preset sync would claim a WLED slot per day, overflowing PRESET_COUNT on a single holiday. The side panel now previews the day's actual colours rather than the anchor's, through a new GET /schemes/for-date/{date} that resolves via the same helper the scheduler uses -- so the preview and the push cannot disagree, which is the drift #93 fixed once already. Writing the tests found a real bug: sync_derived_schemes originally read anchor colours off scheme.segments, but generate_schemes_for_event rebuilds those rows with db.add(SchemeSegment(...)) rather than through the relationship, so the loaded collection can still hold the previous generation's values. Blending from those would have produced a gradient between colours no longer anchored anywhere, and it would have looked entirely plausible. It now queries the rows directly. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>eb024d8d9dbba8707f15