Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
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
109 changes: 109 additions & 0 deletions usermods/usermod_v2_skystrip/FAQ.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
# SkyStrip Interpretation Guide

This FAQ explains how to read the various HSV-based views of the
`usermod_v2_skystrip` module. Each view maps weather data onto hue,
saturation, and value (brightness) along the LED strip.


## Cloud View (CV)

Markers for sunrise or sunset show as orange pixels. During
precipitation, hue denotes type—deep blue for rain, lavender for snow,
and indigo for mixed—while value scales with probability. In the
absence of precipitation, hue differentiates day from night: daylight
clouds appear pale yellow, nighttime clouds desaturate toward
white. For clouds, saturation is low and value grows with coverage,
keeping even thin clouds visible. Thus, a bright blue pixel highlights
likely rain, whereas a soft yellow glow marks daytime cloud cover.


## Wind View (WV)

The hue encodes wind direction around the compass: blue (240°) points
north, orange (~30°) east, yellow (~60°) south, and green (~120°)
west, with intermediate shades for diagonal winds. Saturation rises
with gustiness—calm breezes stay washed out while strong gusts drive
the color toward full intensity. Value scales with wind strength,
boosting brightness as the highest of sustained speed or gust
approaches 50 mph (or equivalent). For example, a saturated blue pixel
indicates gusty north winds, while a dim pastel green suggests a
gentle westerly breeze.

The mapping between wind direction and hue can be approximated as:

| Direction | Hue (°) | Color |
|-----------|---------|--------|
| N | 240 | Blue |
| NE | 300 | Purple |
| E | 30 | Orange |
| SE | 45 | Gold |
| S | 60 | Yellow |
| SW | 90 | Lime |
| W | 120 | Green |
| NW | 180 | Cyan |
| N | 240 | Blue |

Note: Hues wrap at 360°, so “N” repeats at the boundary.


## Temperature View (TV)

Hue follows a calibrated cold→hot gradient tuned for pleasing segment
appearance: deep blues near 14 °F transition through cyan and green to
warm yellows at 77 °F and reds at ~104 °F and above. Saturation
reflects humidity via dew‑point spread; muggy air produces softer,
desaturated colors, whereas dry air yields vivid tones. Value is fixed
at mid‑brightness, but local time markers (e.g., noon, midnight)
temporarily darken pixels to mark time. A bright orange‑red pixel thus
signifies hot, dry conditions around 95 °F, whereas a pale cyan pixel
indicates a cool, humid day near 50 °F.

The actual temperature→hue stops used by the renderer are:

| Temp (°F) | Hue (°) | Color |
|-----------|---------|-------------|
| ≤14 | 234.9 | Deep blue |
| 32 | 207.0 | Blue/cyan |
| 50 | 180.0 | Cyan |
| 68 | 138.8 | Greenish |
| 77 | 60.0 | Yellow |
| 86 | 38.8 | Orange |
| 95 | 18.8 | Orange‑red |
| ≥104 | 0.0 | Red |


## 24-Hour Delta View (DV)

Hue represents the temperature change relative to the previous day:
blues for cooling, greens for steady conditions, and yellows through
reds for warming. Saturation encodes humidity trend—the color
intensifies as the air grows drier and fades toward pastels when
becoming more humid. Value increases with the magnitude of change,
combining temperature and humidity shifts, so bright pixels flag
larger swings. A dim blue pixel therefore means a slight cool‑down
with more moisture, while a bright saturated red indicates rapid
warming coupled with drying.

Approximate mapping of day-to-day deltas to color attributes:

| Temperature | Hue (Color) |
|-------------|-------------|
| Cooling | Blue tones |
| Steady | Green |
| Warming | Yellow→Red |

| Humidity | Saturation |
|------------|------------|
| More humid | Low/Pastel |
| Stable | Medium |
| Drier | High/Vivid |


## Test Pattern View (TP)

This diagnostic view simply interpolates hue, saturation, and value
between configured start and end points along the segment. Hue shifts
steadily from the starting hue to the ending hue, with saturation and
brightness following the same linear ramp. It carries no weather
meaning; a common example is a gradient from black to white to verify
LED orientation.
229 changes: 229 additions & 0 deletions usermods/usermod_v2_skystrip/cloud_view.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,229 @@
#include "cloud_view.h"
#include "skymodel.h"
#include "util.h"
#include "wled.h"
#include <algorithm>
#include <cmath>
#include <limits>

static constexpr int16_t DEFAULT_SEG_ID = -1; // -1 means disabled
const char CFG_SEG_ID[] PROGMEM = "SegmentId";

static bool isDay(const SkyModel &m, time_t t) {
const time_t MAXTT = std::numeric_limits<time_t>::max();
if (m.sunrise_ == 0 && m.sunset_ == MAXTT)
return true; // 24h day
if (m.sunset_ == 0 && m.sunrise_ == MAXTT)
return false; // 24h night
constexpr time_t DAY = 24 * 60 * 60;
time_t sr = m.sunrise_;
time_t ss = m.sunset_;
while (t >= ss) {
sr += DAY;
ss += DAY;
}
while (t < sr) {
sr -= DAY;
ss -= DAY;
}
return t >= sr && t < ss;
}

CloudView::CloudView() : segId_(DEFAULT_SEG_ID) {
DEBUG_PRINTLN("SkyStrip: CV::CTOR");
snprintf(debugPixelString, sizeof(debugPixelString), "%s:\\n",
name().c_str());
debugPixelString[sizeof(debugPixelString) - 1] = '\0';
}

void CloudView::view(time_t now, SkyModel const &model, int16_t dbgPixelIndex) {
if (dbgPixelIndex < 0) {
snprintf(debugPixelString, sizeof(debugPixelString), "%s:\\n",
name().c_str());
debugPixelString[sizeof(debugPixelString) - 1] = '\0';
}
if (segId_ == DEFAULT_SEG_ID) {
freezeHandle_.release();
return;
}
if (model.cloud_cover_forecast.empty())
return;
if (segId_ < 0 || segId_ >= strip.getMaxSegments()) {
freezeHandle_.release();
return;
}

Segment *segPtr = freezeHandle_.acquire(segId_);
if (!segPtr)
return;
Segment &seg = *segPtr;
int len = seg.virtualLength();
if (len <= 0) {
freezeHandle_.release();
return;
}
// Initialize segment drawing parameters so virtualLength()/mapping are valid
seg.beginDraw();

constexpr double kHorizonSec = 48.0 * 3600.0;
const double step = (len > 1) ? (kHorizonSec / double(len - 1)) : 0.0;

const time_t markerTol = time_t(std::llround(step * 0.5));
const time_t sunrise = model.sunrise_;
const time_t sunset = model.sunset_;
constexpr time_t DAY = 24 * 60 * 60;
const time_t MAXTT = std::numeric_limits<time_t>::max();

long offset = skystrip::util::current_offset();

bool useSunrise = (sunrise != 0 && sunrise != MAXTT);
bool useSunset = (sunset != 0 && sunset != MAXTT);
time_t sunriseTOD = 0;
time_t sunsetTOD = 0;
if (useSunrise)
sunriseTOD = (((sunrise + offset) % DAY) + DAY) % DAY; // normalize to [0, DAY)
if (useSunset)
sunsetTOD = (((sunset + offset) % DAY) + DAY) % DAY; // normalize to [0, DAY)

auto nearTOD = [&](time_t a, time_t b) {
time_t diff = (a >= b) ? (a - b) : (b - a);
if (diff <= markerTol)
return true;
return (DAY - diff) <= markerTol;
};

auto isMarker = [&](time_t t) {
if (!useSunrise && !useSunset)
return false;
time_t tod = (((t + offset) % DAY) + DAY) % DAY; // normalize to [0, DAY)
if (useSunrise && nearTOD(tod, sunriseTOD))
return true;
if (useSunset && nearTOD(tod, sunsetTOD))
return true;
return false;
};

constexpr float kCloudMaskThreshold = 0.05f;
constexpr float kDayHue = 60.f;
constexpr float kNightHue = 300.f;
constexpr float kDaySat = 0.30f;
constexpr float kNightSat = 0.00f;
constexpr float kDayVMax = 0.40f;
constexpr float kNightVMax= 0.40f;

// Brightness floor as a fraction of Vmax so mid/low clouds stay visible.
constexpr float kDayVMinFrac = 0.50f; // try 0.40–0.60 to taste
constexpr float kNightVMinFrac = 0.50f; // night can be a bit lower if preferred

constexpr float kMarkerHue= 25.f;
constexpr float kMarkerSat= 0.60f;
constexpr float kMarkerVal= 0.50f;

for (int i = 0; i < len; ++i) {
const time_t t = now + time_t(std::llround(step * i));
double clouds, precipTypeVal, precipProb;
if (!skystrip::util::estimateCloudAt(model, t, step, clouds))
continue;
if (!skystrip::util::estimatePrecipTypeAt(model, t, step, precipTypeVal))
precipTypeVal = 0.0;
if (!skystrip::util::estimatePrecipProbAt(model, t, step, precipProb))
precipProb = 0.0;

float clouds01 = skystrip::util::clamp01(float(clouds / 100.0));
int p = int(std::round(precipTypeVal));
bool daytime = isDay(model, t);


float hue = 0.f, sat = 0.f, val = 0.f;
if (isMarker(t)) {
// always put the sunrise sunset markers in
hue = kMarkerHue;
sat = kMarkerSat;
val = kMarkerVal;
} else if (p != 0 && precipProb > 0.0) {
// precipitation has next priority: rain=blue, snow=lavender,
// mixed=indigo-ish blend
constexpr float kHueRain = 210.f; // deep blue
constexpr float kSatRain = 1.00f;

constexpr float kHueSnow = 285.f; // lavender for snow
constexpr float kSatSnow = 0.35f; // pastel-ish (tune to taste)

float ph, ps;
if (p == 1) {
// rain
ph = kHueRain;
ps = kSatRain;
} else if (p == 2) {
// snow → lavender
ph = kHueSnow;
ps = kSatSnow;
} else {
// mixed → halfway between blue and lavender
ph = 0.5f * (kHueRain + kHueSnow); // ~247.5° (indigo-ish)
ps = 0.5f * (kSatRain + kSatSnow); // ~0.675
}

float pv = skystrip::util::clamp01(float(precipProb));
pv = 0.3f + 0.7f * pv; // brightness ramp
hue = ph;
sat = ps;
val = pv;
} else {
// finally show daytime or nightime clouds
if (clouds01 < kCloudMaskThreshold) {
hue = 0.f;
sat = 0.f;
val = 0.f;
} else {
float vmax = daytime ? kDayVMax : kNightVMax;
float vmin = (daytime ? kDayVMinFrac : kNightVMinFrac) * vmax;
// Use sqrt curve to boost brightness at lower cloud coverage
val = vmin + (vmax - vmin) * sqrtf(clouds01);
hue = daytime ? kDayHue : kNightHue;
sat = daytime ? kDaySat : kNightSat;
}
}

uint32_t col = skystrip::util::hsv2rgb(hue, sat, val);
seg.setPixelColor(i, skystrip::util::blinkDebug(i, dbgPixelIndex, col));

if (dbgPixelIndex >= 0) {
static time_t lastDebug = 0;
if (now - lastDebug > 1 && i == dbgPixelIndex) {
char nowbuf[20];
skystrip::util::fmt_local(nowbuf, sizeof(nowbuf), now);
char dbgbuf[20];
skystrip::util::fmt_local(dbgbuf, sizeof(dbgbuf), t);
snprintf(debugPixelString, sizeof(debugPixelString),
"%s: nowtm=%s dbgndx=%d dbgtm=%s day=%d clouds01=%.2f precip=%d pop=%.2f H=%.0f S=%.0f V=%.0f\\n",
name().c_str(), nowbuf, i, dbgbuf, daytime, clouds01, p,
precipProb, hue, sat * 100, val * 100);
lastDebug = now;
}
}
}
}

void CloudView::deactivate() {
freezeHandle_.release();
}

void CloudView::addToConfig(JsonObject &subtree) {
subtree[FPSTR(CFG_SEG_ID)] = segId_;
}

void CloudView::appendConfigData(Print &s) {
// Keep the hint INLINE (BEFORE the input = 4th arg):
s.print(F("addInfo('SkyStrip:CloudView:SegmentId',1,'',"
"'&nbsp;<small style=\\'opacity:.8\\'>(-1 disables)</small>'"
");"));
}

bool CloudView::readFromConfig(JsonObject &subtree, bool startup_complete,
bool &invalidate_history) {
bool configComplete = !subtree.isNull();
configComplete &=
getJsonValue(subtree[FPSTR(CFG_SEG_ID)], segId_, DEFAULT_SEG_ID);
return configComplete;
}
30 changes: 30 additions & 0 deletions usermods/usermod_v2_skystrip/cloud_view.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
#pragma once

#include "interfaces.h"
#include "skymodel.h"
#include "util.h"

class SkyModel;

class CloudView : public IDataViewT<SkyModel> {
public:
CloudView();
~CloudView() override = default;

void view(time_t now, SkyModel const & model, int16_t dbgPixelIndex) override;
std::string name() const override { return "CV"; }
void appendDebugPixel(Print& s) const override { s.print(debugPixelString); }
void deactivate() override;

void addToConfig(JsonObject& subtree) override;
void appendConfigData(Print& s) override;
bool readFromConfig(JsonObject& subtree,
bool startup_complete,
bool& invalidate_history) override;
const char* configKey() const override { return "CloudView"; }

private:
int16_t segId_;
char debugPixelString[128];
skystrip::util::SegmentFreezeHandle freezeHandle_;
};
Loading