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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 28 additions & 14 deletions Firmware/TreeHouse_Controllers/lib/Patterns/Patterns.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -61,12 +61,22 @@ bool usesDrive(PatternId pattern) { return pattern != PatternId::Flash; }
// renders exactly as it did before the ramp existed.
bool usesRamp(PatternId pattern) { return pattern == PatternId::Fire; }

// Flash: four 250 ms bursts, evenly spaced, then a four-second pause.
constexpr float kFlashOnS = 0.25f;
constexpr float kFlashPeriodS = 0.5f; // on, then an equal gap
constexpr int kFlashBursts = 4;
constexpr float kFlashPauseS = 4.0f;
constexpr float kFlashCycleS = kFlashBursts * kFlashPeriodS + kFlashPauseS;
// Flash: one 100 ms burst, a one-second fade out, then a long dark minute.
constexpr float kFlashOnS = 0.1f;
constexpr float kFlashFadeS = 1.0f;
constexpr float kFlashCycleS = 60.0f;
// Decay constant of the fade. Larger drops faster out of the burst and leaves
// a longer thin tail; 5 reads as an afterglow rather than a dimmer being pulled
// down. The curve is normalised to land on exactly 0 at the end of the fade,
// so the channel is properly dark for the rest of the minute.
constexpr float kFlashDecay = 5.0f;

// 1 -> 0 across u in [0, 1], steep at first. Perceptually this reads as a
// decay; a linear ramp hangs near the bottom and looks mechanical.
float flashFade(float u) {
const float floor_value = std::exp(-kFlashDecay);
return (std::exp(-kFlashDecay * u) - floor_value) / (1.0f - floor_value);
}

} // namespace

Expand Down Expand Up @@ -202,15 +212,19 @@ float ChannelAnimator::patternLevel(uint16_t index) const {
}

case PatternId::Flash: {
// A strobe burst on a fixed schedule: kFlashBursts on/off pairs, then a
// long dark pause. Binary — off, or the channel ceiling — so it reads
// as a strobe and not as a lamp being faded up and down. Timed off
// time_s_ rather than phase_, so the burst length does not change with
// drive; tying the burst rate to the Arc comes later.
// One burst a minute: a hard-edged 100 ms hit at the channel ceiling,
// then a one-second decay back to dark. The rise stays instant so it
// still punctuates rather than pulsing; only the tail is faded. Timed
// off time_s_ rather than phase_, so the burst length does not change
// with drive; tying the burst rate to the Arc comes later.
const float t = time_s_ - std::floor(time_s_ / kFlashCycleS) * kFlashCycleS;
const float train = kFlashBursts * kFlashPeriodS;
const bool lit = t < train && (t - std::floor(t / kFlashPeriodS) * kFlashPeriodS) < kFlashOnS;
value = lit ? spec_.max_level : 0.0f;
if (t < kFlashOnS) {
value = spec_.max_level;
} else if (t < kFlashOnS + kFlashFadeS) {
value = spec_.max_level * flashFade((t - kFlashOnS) / kFlashFadeS);
} else {
value = 0.0f;
}
break;
}
}
Expand Down
2 changes: 1 addition & 1 deletion Firmware/TreeHouse_Controllers/lib/Patterns/Patterns.h
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ enum class PatternId {
Fire, // per-pixel heat, hottest at the hearth, ramped base -> base_hot
Breathe, // slow uniform swell — also the idle fallback
Filament, // dimmer: slow-slewing level with a hint of flicker
Flash, // dimmer: binary strobe — a burst of blinks, then a dark pause;
Flash, // dimmer: one hard burst a minute, fading out to a dark pause;
// drive-free, so it runs unchanged when Garden State is stale
};

Expand Down
6 changes: 3 additions & 3 deletions Firmware/TreeHouse_Controllers/src/targets/jess.h
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,13 @@
// LED.
//
// The bright LED is a punctuation mark, not a light source: it is dark most of
// the time, strobes in short bursts, and goes full on for the Blow-Up
// the time, fires one short burst a minute, and goes full on for the Blow-Up
// Reaction. Its ceiling is held below 1.0 because at full power, this close
// to the windows, it is genuinely painful to look at.
//
// For now the burst schedule is fixed (see kFlash* in Patterns.cpp) and does
// not follow the FundingCAPTCHA Arc; speed and smoothing are inert on it.
// Because it reads no drive, it also strobes on unchanged when the Pi is
// Because it reads no drive, it also keeps firing unchanged when the Pi is
// unreachable, rather than dropping to the idle breathe the strips fall back
// to. A location with a dead Pi therefore still has a visible heartbeat.
#pragma once
Expand Down Expand Up @@ -60,7 +60,7 @@ constexpr ChannelSpec kChannels[] = {
.pattern = PatternId::Flash,
.weights = {.flowerbeds = 0.1f, .captcha = 0.8f, .pipes = 0.1f, .bias = 0.0f},
.min_level = 0.0f, // unused by the fixed burst; kept for when it drives again
.max_level = 0.80f,
.max_level = 0.8f,
.speed = 2.5f,
.smoothing_s = 0.6f,
.idle_level = 0.0f, // unused: the burst is drive-free, so it survives a stale Pi
Expand Down
73 changes: 52 additions & 21 deletions Firmware/TreeHouse_Controllers/test/test_patterns/test_patterns.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -182,9 +182,9 @@ void test_blowup_is_ignored_while_stale() {
}

// The idle fallback exists to replace a drive that can no longer be trusted.
// Flash does not read drive, so a stale controller keeps strobing rather than
// Flash does not read drive, so a stale controller keeps firing rather than
// going dark — which is what a location looks like when its Pi is missing.
void test_flash_keeps_strobing_while_stale() {
void test_flash_keeps_firing_while_stale() {
cg::ChannelSpec spec = dimmerSpec(cg::PatternId::Flash);
spec.max_level = 0.8f;
spec.idle_level = 0.0f; // as the Jess flash channel is configured
Expand All @@ -195,41 +195,71 @@ void test_flash_keeps_strobing_while_stale() {
blowup.captcha_blowup = true;

int lit_ms = 0;
float peak = 0.0f;
for (int ms = 0; ms < 6000; ++ms) {
animator.update(0.001f, blowup, /*stale=*/true);
if (animator.level() > 0.0f) {
++lit_ms;
// Still binary: a stale blowup must not spike it to full either.
TEST_ASSERT_EQUAL_FLOAT(0.8f, animator.level());
}
const float level = animator.level();
if (level > 0.0f) ++lit_ms;
if (level > peak) peak = level;
// A stale blowup must not spike it past the ceiling either.
TEST_ASSERT_TRUE(level <= 0.8f);
}
TEST_ASSERT_INT_WITHIN(8, 1000, lit_ms);
TEST_ASSERT_EQUAL_FLOAT(0.8f, peak); // the burst still reaches the ceiling
TEST_ASSERT_INT_WITHIN(8, 1100, lit_ms); // 100 ms burst + 1 s fade
}

void test_flash_channel_strobes_in_bursts() {
void test_flash_channel_bursts_once_a_minute() {
cg::ChannelSpec spec = dimmerSpec(cg::PatternId::Flash);
spec.max_level = 0.8f;
spec.weights.captcha = 1.0f; // nothing drives it; the burst runs regardless
cg::ChannelAnimator animator;
animator.configure(spec);

// Sample one 6 s cycle at 1 ms and count the on/off runs: four bursts of
// 250 ms, then a 4 s pause.
// Sample one 60 s cycle at 1 ms: a single 100 ms hit at the ceiling, a 1 s
// fade, then a dark minute — one rising edge and one falling edge in all.
int edges = 0;
int lit_ms = 0;
int at_ceiling_ms = 0;
bool lit = false;
for (int ms = 0; ms < 6000; ++ms) {
for (int ms = 0; ms < 60000; ++ms) {
animator.update(0.001f, active(), false);
const bool now_lit = animator.level() > 0.0f;
if (now_lit) {
++lit_ms;
TEST_ASSERT_EQUAL_FLOAT(0.8f, animator.level()); // binary, at the ceiling
}
const float level = animator.level();
const bool now_lit = level > 0.0f;
if (now_lit) ++lit_ms;
if (level >= 0.8f) ++at_ceiling_ms;
TEST_ASSERT_TRUE(level <= 0.8f);
if (now_lit != lit) ++edges;
lit = now_lit;
}
TEST_ASSERT_EQUAL_INT(8, edges); // four rising, four falling
TEST_ASSERT_INT_WITHIN(8, 1000, lit_ms); // 4 x 250 ms lit per cycle
TEST_ASSERT_EQUAL_INT(2, edges); // one rising, one falling
TEST_ASSERT_INT_WITHIN(8, 100, at_ceiling_ms); // the hard 100 ms burst
TEST_ASSERT_INT_WITHIN(8, 1100, lit_ms); // burst + fade, then dark
}

// The tail is a decay, not a second strobe: once the burst ends the level only
// ever falls, and it is genuinely dark well before the next minute comes round.
void test_flash_fade_decays_monotonically_to_dark() {
cg::ChannelSpec spec = dimmerSpec(cg::PatternId::Flash);
spec.max_level = 0.8f;
cg::ChannelAnimator animator;
animator.configure(spec);

float previous = 1.0f;
for (int ms = 0; ms < 1200; ++ms) {
animator.update(0.001f, active(), false);
if (ms < 100) continue; // still inside the flat burst
const float level = animator.level();
TEST_ASSERT_TRUE(level <= previous);
previous = level;
}
TEST_ASSERT_EQUAL_FLOAT(0.0f, animator.level());

// Halfway through the fade an exponential is already well under half.
cg::ChannelAnimator midpoint;
midpoint.configure(spec);
for (int ms = 0; ms < 600; ++ms) midpoint.update(0.001f, active(), false);
TEST_ASSERT_TRUE(midpoint.level() > 0.0f);
TEST_ASSERT_TRUE(midpoint.level() < 0.4f * 0.8f);
}

void test_max_level_caps_even_the_blowup_spike() {
Expand Down Expand Up @@ -374,8 +404,9 @@ int main(int, char**) {
RUN_TEST(test_stale_state_breathes_instead_of_going_dark);
RUN_TEST(test_blowup_spikes_then_decays);
RUN_TEST(test_blowup_is_ignored_while_stale);
RUN_TEST(test_flash_keeps_strobing_while_stale);
RUN_TEST(test_flash_channel_strobes_in_bursts);
RUN_TEST(test_flash_keeps_firing_while_stale);
RUN_TEST(test_flash_channel_bursts_once_a_minute);
RUN_TEST(test_flash_fade_decays_monotonically_to_dark);
RUN_TEST(test_max_level_caps_even_the_blowup_spike);
RUN_TEST(test_chase_lights_one_end_of_the_strip_at_a_time);
RUN_TEST(test_fire_keeps_every_pixel_lit);
Expand Down