From ee0ee9a1d3b8d2031ac390f43d76efa656c1cf01 Mon Sep 17 00:00:00 2001 From: Matysh Date: Mon, 3 Aug 2026 01:26:38 +0300 Subject: [PATCH] SUN: spec (docs/SUN.md) + pure logic src/sun.ts with unit tests planSunAngle/sunDirOnPlan (compass wrap), dayPhase palette, exterior-wall probing, window wedges (rayQuad + polyclip room clipping), cloudFactor map, north_deg/bg_mode/sun_rays inheritance. 26 new unit tests. --- docs/SUN.md | 140 +++++++++++++++++++++ src/sun.ts | 298 +++++++++++++++++++++++++++++++++++++++++++++ test/sun.test.mjs | 240 ++++++++++++++++++++++++++++++++++++ tsconfig.test.json | 1 + 4 files changed, 679 insertions(+) create mode 100644 docs/SUN.md create mode 100644 src/sun.ts create mode 100644 test/sun.test.mjs diff --git a/docs/SUN.md b/docs/SUN.md new file mode 100644 index 00000000..d8561543 --- /dev/null +++ b/docs/SUN.md @@ -0,0 +1,140 @@ +# Sun on the plan — the spec (source of truth) + +Status: approved by the owner 2026-08-03. Dev-only for now (no release). +Scope decisions final: the compass lives in the GENERAL settings with a +per-space override, the feature is silent until `north_deg` is set +anywhere, wedges ship for the FULL card only in v1, and mutual shading +of the building's wings is explicitly NOT computed. + +## Principle + +The plan learns where north is, and from that single number plus HA's +own `sun.sun` the card knows where the sun stands relative to every +wall. Two visuals follow: the stage background can breathe with the +day (day → golden hour → dusk → night), and windows on exterior walls +cast soft wedges of light into their rooms. Everything is display +only — no entities are created, no services are called. + +## Data + +- Source: the `sun.sun` entity (`attributes.azimuth` 0–360, 0 = north, + clockwise; `attributes.elevation` in degrees, negative below the + horizon). No `sun.sun` in the install → the whole feature stays + silent and the settings dialog says why. +- Sun attributes update rarely (~30–120 s). Sun geometry is recomputed + ONLY when (azimuth, elevation) or the config change — never on every + `hass` tick. The wedge layer memoises on + `(azimuth, elevation, config rev, space id, weather state)`. +- Angle on the plan: `plan_angle = azimuth − north_deg` (normalised to + 0–360). With `north_deg = 0` the top of the canvas is north; the + direction TOWARD the sun on the canvas is + `(sin(plan_angle), −cos(plan_angle))` (y grows downward). + +## Compass — `settings.north_deg` + +- Integer 0–359, degrees clockwise from "up on the canvas" to true + north. Lives in the GENERAL settings (⚙) as a circular dial: drag + the «N» arrow around the ring, 1° steps, 15° with Shift held; a + plain number input sits next to it for accessibility and precision. +- Per-space override in the space settings (empty = inherit), the same + pattern as `show_lqi` / `fill_mode`. +- While `north_deg` is null at BOTH levels the whole sun feature is + inert: static background, no wedges, nothing computed. The settings + dialogs show a hint. +- Backend validation: integer in 0–359 at both levels. + +## Plan background — `settings.bg_mode: 'static' | 'daynight'` + +- Global default in the general settings, per-space override (null = + inherit). Default `'static'`. +- `'static'` — the existing `bg_color` behaviour, color picker and + all. Nothing changes for existing installs. +- `'daynight'` — the stage background follows the sun's elevation: + neutral by day, a warm shift in the golden hour (elevation below + ~10°), cooling through dusk, deep darkening at night. The PLAN + itself dims only ~10% at night (`filter: brightness(.9)`), so the + daytime room fills stay readable. Transitions are a CSS + background/filter transition tens of seconds long; + `prefers-reduced-motion` gets the current colors statically. +- The UI is a two-option selector; the color picker shows only for + `'static'`. +- Backend validation: `In(['static', 'daynight'])` at both levels. +- `'daynight'` follows the general gate: without `north_deg` (or + without `sun.sun`) it behaves as `'static'`. + +## Window light wedges — `settings.sun_rays` + +Boolean, global + per-space (null = inherit), default OFF. + +For every opening of type «window» sitting on an EXTERIOR wall — a +wall stretch with no other room on its outer side, decided by probing +the existing room geometry just off both sides of the window; windows +on interior walls do not participate, and open (virtual) boundaries +never qualify because both sides are rooms — the card draws a wedge +when BOTH hold: + +- the sun is above the horizon (`elevation > 0`), and +- the dot product of the wall's outward normal with the direction + toward the sun is positive (the sun actually faces this window). + +The wedge is a quadrilateral cast from the window's span along the +direction AWAY from the sun (light falls inward), clipped by the +room's polygon (`polyclip` intersection, the same dependency +`src/resize.ts` already uses). Its length is `k(elevation)` in window +lengths: ~2.5 at sunrise/sunset tapering to ~0.8 at the zenith +(`0.8 + 1.7·(1 − elevation/90)^1.6`). A linear gradient runs bright at +the window and dissolves inward; the color is warm orange while +`elevation < 10°` and neutral by day; peak opacity is modest (~0.18 — +two overlapping wedges never exceed a readable ceiling). Near the +horizon the opacity ramps in over the first ~2° so wedges never pop. + +Layer order: ABOVE room fills (and the glow layer), BELOW devices and +labels (those live in the HTML `devlayer` anyway). Night +(`elevation ≤ 0`) → no wedges. Wedges work under BOTH `bg_mode`s. + +## Cloud cover — `settings.weather_entity` (optional) + +String entity id or null; GLOBAL settings only. When set and the +entity's state reads overcast, the wedges fade by an opacity +multiplier — ~0.25 fully overcast, 0 (gone) in rain/snow: + +| states | factor | +| --- | --- | +| clear, sunny, clear-night, windy, exceptional, unset entity | 1.0 | +| partlycloudy, windy-variant | 0.7 | +| cloudy | 0.4 | +| overcast, fog | 0.25 | +| rainy, pouring, snowy, snowy-rainy, hail, lightning, lightning-rainy | 0.0 | +| unknown, unavailable | 1.0 (a dead sensor must not kill the sun) | + +Backend validation: string or null. + +## Edge cases and limits + +- No `sun.sun` → silent feature + a hint in the settings dialog. +- `north_deg` unset everywhere → silent feature + a hint. +- `prefers-reduced-motion` → no transitions; colors and wedges render + statically for the current sun position. +- Kiosk mode → works (same view path). +- Static `houseplan-space-card` → the background honours the effective + `bg_mode`/color; wedges are v1 FULL-CARD ONLY (documented limit). +- Editors (plan/devices/decor) → no wedges and no day/night: the + editor canvases render exactly as before. +- Mutual shading of the building's own wings (an L-shaped house + shadowing its inner corner) is NOT computed — a lit window casts its + wedge even when another wing geometrically blocks the sun. Accepted + v1 limit. +- Wedges of windows on all four wall orientations are unit-tested, + including the 359→0 azimuth wrap. + +## Files + +- `src/sun.ts` — pure logic (angles, day phase, exterior walls, wedge + quads + clipping, cloud factor, settings inheritance); unit-tested + in `test/sun.test.mjs`. +- `src/houseplan-card.ts` — the memoised wedge layer, the day/night + stage background, both settings dialogs (compass dial included). +- `src/space-render.ts` — the static card's background only. +- `custom_components/houseplan/validation.py` — the four settings at + both levels; tests in `tests_backend/test_validation.py`. +- `demo/smoke_sun.mjs` — end-to-end behaviour against the demo rig. diff --git a/src/sun.ts b/src/sun.ts new file mode 100644 index 00000000..c93e60e1 --- /dev/null +++ b/src/sun.ts @@ -0,0 +1,298 @@ +/** + * Sun on the plan — pure logic only (docs/SUN.md). + * + * Angles, the day phase palette, exterior-wall detection, window light + * wedges and their clipping, the cloud factor and the settings + * inheritance. Coordinates are render units (NORM_W-scaled canvas, + * y grows DOWNWARD), same as the card's space model. Nothing here + * touches Lit, the DOM or `hass` beyond a plain state object. + */ +import { intersection } from 'polyclip-ts'; +import { pointInPolygon, lerpColor } from './logic'; + +// ---------------- angles ---------------- + +/** Normalise any angle in degrees to [0, 360). */ +export function norm360(deg: number): number { + const d = deg % 360; + return d < 0 ? d + 360 : d; +} + +/** + * The sun's bearing on the CANVAS: 0 = up, clockwise (docs/SUN.md). + * `north_deg` is how far true north is rotated clockwise from "canvas up". + */ +export function planSunAngle(azimuth: number, northDeg: number): number { + return norm360(azimuth - northDeg); +} + +/** Unit vector TOWARD the sun on the canvas (x right, y down). */ +export function sunDirOnPlan(azimuth: number, northDeg: number): [number, number] { + const a = (planSunAngle(azimuth, northDeg) * Math.PI) / 180; + return [Math.sin(a), -Math.cos(a)]; +} + +// ---------------- day phase (bg_mode: 'daynight') ---------------- + +export interface DayPhase { + /** Stage background color for the current elevation. */ + bg: string; + /** How much the PLAN itself dims (0..0.1 — readability first). */ + planDim: number; + /** 1 = golden hour / horizon, 0 = plain daylight. Drives wedge color. */ + warmth: number; +} + +/** elevation° → color stops; piecewise-linear between neighbours. */ +const BG_STOPS: [number, string][] = [ + [-90, '#070c14'], // deep night + [-12, '#070c14'], + [-4, '#131a28'], // dusk cools down + [0, '#4a3527'], // warm band right at the horizon + [10, '#46505f'], // golden hour fades into neutral + [30, '#5a6673'], // plain day + [90, '#5a6673'], +]; + +const clamp01 = (t: number) => Math.min(1, Math.max(0, t)); + +/** Background, plan dim and warmth for a sun elevation (docs/SUN.md). */ +export function dayPhase(elevation: number): DayPhase { + const e = Math.min(90, Math.max(-90, Number(elevation) || 0)); + let bg = BG_STOPS[BG_STOPS.length - 1][1]; + for (let i = 1; i < BG_STOPS.length; i++) { + const [e0, c0] = BG_STOPS[i - 1]; + const [e1, c1] = BG_STOPS[i]; + if (e <= e1) { + bg = lerpColor(c0, c1, (e - e0) / (e1 - e0)); + break; + } + } + return { + bg, + // full 10% below ~-6°, gone above +10° — a slow dusk, not a switch + planDim: clamp01((10 - e) / 16) * 0.1, + warmth: e < 0 ? 1 : clamp01(1 - e / 10), + }; +} + +// ---------------- exterior walls & windows ---------------- + +export interface SunRoom { id: string; poly: number[][] } +/** A window opening in render units: centre, wall angle°, full length. */ +export interface SunWindow { id: string; x: number; y: number; angle: number; length: number } + +/** + * Is the wall stretch at `mid` with outward normal `n` exterior — i.e. is + * there NO room just outside it? Probes one point `probe` units out. + */ +export function isExteriorWall(mid: number[], n: number[], rooms: SunRoom[], probe = 6): boolean { + const p = [mid[0] + n[0] * probe, mid[1] + n[1] * probe]; + return !rooms.some((r) => r.poly.length >= 3 && pointInPolygon(p, r.poly)); +} + +/** + * The wall a window sits on: probe both sides of the window centre. Exactly + * one side inside a room → exterior wall; the outward normal points to the + * empty side and the room on the other side hosts the wedge. Both sides in + * rooms (interior walls, open/virtual boundaries) or neither (a window not + * on any boundary) → null: this window never casts light (docs/SUN.md). + */ +export function windowWallInfo( + win: { x: number; y: number; angle: number }, + rooms: SunRoom[], + probe = 6, +): { normal: [number, number]; roomId: string } | null { + const rad = (win.angle * Math.PI) / 180; + // perpendicular to the wall (the wall runs along `angle`) + const n: [number, number] = [Math.sin(rad), -Math.cos(rad)]; + const roomAt = (side: 1 | -1): SunRoom | null => { + const p = [win.x + n[0] * probe * side, win.y + n[1] * probe * side]; + return rooms.find((r) => r.poly.length >= 3 && pointInPolygon(p, r.poly)) || null; + }; + const plus = roomAt(1); + const minus = roomAt(-1); + if (plus && minus) return null; // interior wall (incl. open boundaries) + if (!plus && !minus) return null; // not on any room's wall + return plus + ? { normal: [-n[0], -n[1]], roomId: plus.id! } + : { normal: n, roomId: minus!.id }; +} + +/** Does the sun actually shine INTO this window right now? (A grazing sun + * exactly along the wall does not count — hence the epsilon, which also + * swallows the sin/cos float dust of the right-angle directions.) */ +export function windowLit(normal: number[], sunDir: number[], elevation: number): boolean { + return elevation > 0 && normal[0] * sunDir[0] + normal[1] * sunDir[1] > 1e-9; +} + +// ---------------- wedge geometry ---------------- + +/** + * Wedge length in WINDOW LENGTHS: longest (~2.5) at sunrise/sunset, shortest + * (~0.8) at the zenith. `0.8 + 1.7·(1 − e/90)^1.6` — long low shafts, short + * noon pools, smooth in between (docs/SUN.md). + */ +export function rayLength(elevation: number): number { + const e = Math.min(90, Math.max(0, elevation)); + return 0.8 + 1.7 * Math.pow(1 - e / 90, 1.6); +} + +/** The unclipped wedge: window span a-b extruded by `len` along `dir`. */ +export function rayQuad(a: number[], b: number[], dir: number[], len: number): number[][] { + return [ + [a[0], a[1]], + [b[0], b[1]], + [b[0] + dir[0] * len, b[1] + dir[1] * len], + [a[0] + dir[0] * len, a[1] + dir[1] * len], + ]; +} + +/** Clip a wedge by the room outline. Returns outer rings (may be several). */ +export function clipToRoom(quad: number[][], room: number[][]): number[][][] { + try { + const res = intersection( + [[...quad.map((p) => [p[0], p[1]]), [quad[0][0], quad[0][1]]]] as any, + [[...room.map((p) => [p[0], p[1]]), [room[0][0], room[0][1]]]] as any, + ); + const out: number[][][] = []; + for (const poly of res as any) { + const ring = poly?.[0]; + if (!Array.isArray(ring) || ring.length < 4) continue; + out.push(ring.slice(0, ring.length - 1).map((p: number[]) => [p[0], p[1]])); + } + return out; + } catch { + return []; // a degenerate clip draws nothing rather than everything + } +} + +export interface SunRay { + openingId: string; + roomId: string; + /** Clipped wedge outline(s), render units. */ + polys: number[][][]; + /** Window span endpoints (the bright end of the gradient). */ + a: number[]; + b: number[]; + /** Direction the light travels (AWAY from the sun), unit vector. */ + dir: [number, number]; + /** Wedge reach in render units (the gradient's fade distance). */ + len: number; +} + +/** + * All wedges of a space for one sun position. Pure and deterministic — the + * card memoises the result on (azimuth, elevation, config rev) and reuses + * it across hass ticks (docs/SUN.md). Mutual shading of building wings is + * NOT considered (documented limit). + */ +export function computeSunRays( + rooms: SunRoom[], + windows: SunWindow[], + azimuth: number, + elevation: number, + northDeg: number, +): SunRay[] { + if (!(elevation > 0)) return []; + const toSun = sunDirOnPlan(azimuth, northDeg); + const away: [number, number] = [-toSun[0], -toSun[1]]; + const k = rayLength(elevation); + const out: SunRay[] = []; + for (const w of windows) { + if (!(w.length > 0)) continue; + const info = windowWallInfo(w, rooms); + if (!info || !windowLit(info.normal, toSun, elevation)) continue; + const room = rooms.find((r) => r.id === info.roomId); + if (!room) continue; + const rad = (w.angle * Math.PI) / 180; + const hx = (Math.cos(rad) * w.length) / 2; + const hy = (Math.sin(rad) * w.length) / 2; + const a = [w.x - hx, w.y - hy]; + const b = [w.x + hx, w.y + hy]; + const len = k * w.length; + const polys = clipToRoom(rayQuad(a, b, away, len), room.poly); + if (!polys.length) continue; + out.push({ openingId: w.id, roomId: info.roomId, polys, a, b, dir: away, len }); + } + return out; +} + +// ---------------- wedge dressing ---------------- + +/** Peak wedge opacity; two overlapping wedges stay readable (docs/SUN.md). */ +export const RAY_MAX_ALPHA = 0.18; + +/** Wedge opacity: ramps in over the first ~2° so sunrise never pops. */ +export function rayAlpha(elevation: number, cloud = 1): number { + if (!(elevation > 0)) return 0; + return RAY_MAX_ALPHA * Math.min(1, elevation / 2) * clamp01(cloud); +} + +/** Wedge color: warm orange at the horizon → neutral daylight. */ +export function rayColor(warmth: number): string { + return lerpColor('#ffe9c2', '#ff9a45', clamp01(warmth)); +} + +// ---------------- cloud cover ---------------- + +/** weather.* state → wedge opacity multiplier (docs/SUN.md table). */ +const CLOUD_FACTORS: Record = { + 'clear': 1, 'sunny': 1, 'clear-night': 1, 'windy': 1, 'exceptional': 1, + 'partlycloudy': 0.7, 'windy-variant': 0.7, + 'cloudy': 0.4, + 'overcast': 0.25, 'fog': 0.25, + 'rainy': 0, 'pouring': 0, 'snowy': 0, 'snowy-rainy': 0, + 'hail': 0, 'lightning': 0, 'lightning-rainy': 0, +}; + +/** + * Cloud multiplier for a weather entity state. Unset entity, unknown state + * or a dead sensor → 1: a broken weather sensor must not kill the sun. + */ +export function cloudFactor(state: string | null | undefined): number { + if (!state) return 1; + const f = CLOUD_FACTORS[String(state).toLowerCase()]; + return f === undefined ? 1 : f; +} + +// ---------------- settings inheritance (global → space) ---------------- + +const intDeg = (v: any): number | null => + typeof v === 'number' && Number.isInteger(v) && v >= 0 && v <= 359 ? v : null; + +/** Effective compass: the space override wins, null = feature inert. */ +export function northDegOf(settings: any, spaceSettings: any): number | null { + const sp = intDeg(spaceSettings?.north_deg); + if (sp !== null) return sp; + return intDeg(settings?.north_deg); +} + +export type BgMode = 'static' | 'daynight'; + +/** Effective background mode; anything unknown falls back to 'static'. */ +export function bgModeOf(settings: any, spaceSettings: any): BgMode { + const pick = (v: any): BgMode | null => (v === 'static' || v === 'daynight' ? v : null); + return pick(spaceSettings?.bg_mode) ?? pick(settings?.bg_mode) ?? 'static'; +} + +/** Effective «sun in the windows» flag; default OFF (docs/SUN.md). */ +export function sunRaysOn(settings: any, spaceSettings: any): boolean { + const sp = spaceSettings?.sun_rays; + if (typeof sp === 'boolean') return sp; + return settings?.sun_rays === true; +} + +/** The optional weather entity (GLOBAL settings only). */ +export function weatherEntityOf(settings: any): string | null { + const v = settings?.weather_entity; + return typeof v === 'string' && v.trim() ? v.trim() : null; +} + +/** Read sun.sun out of a hass-like object; null when absent/garbage. */ +export function sunStateOf(hass: any): { azimuth: number; elevation: number } | null { + const attrs = hass?.states?.['sun.sun']?.attributes; + const az = Number(attrs?.azimuth); + const el = Number(attrs?.elevation); + return Number.isFinite(az) && Number.isFinite(el) ? { azimuth: az, elevation: el } : null; +} diff --git a/test/sun.test.mjs b/test/sun.test.mjs new file mode 100644 index 00000000..40009625 --- /dev/null +++ b/test/sun.test.mjs @@ -0,0 +1,240 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + norm360, planSunAngle, sunDirOnPlan, dayPhase, + isExteriorWall, windowWallInfo, windowLit, + rayLength, rayQuad, clipToRoom, computeSunRays, + rayAlpha, rayColor, cloudFactor, RAY_MAX_ALPHA, + northDegOf, bgModeOf, sunRaysOn, weatherEntityOf, sunStateOf, +} from '../test-build/sun.js'; + +const near = (a, b, eps = 1e-9) => Math.abs(a - b) < eps; + +// ---- the test house: two rooms sharing the x=500 wall, windows on all four +// outer walls plus one on the shared (interior) wall -------------------- +const ROOMS = [ + { id: 'r1', poly: [[100, 100], [500, 100], [500, 500], [100, 500]] }, + { id: 'r2', poly: [[500, 100], [800, 100], [800, 500], [500, 500]] }, +]; +const WIN = { + north: { id: 'wN', x: 300, y: 100, angle: 0, length: 60 }, + south: { id: 'wS', x: 300, y: 500, angle: 0, length: 60 }, + west: { id: 'wW', x: 100, y: 300, angle: 90, length: 60 }, + east: { id: 'wE', x: 800, y: 300, angle: 90, length: 60 }, + inner: { id: 'wI', x: 500, y: 300, angle: 90, length: 60 }, +}; +const ALL = Object.values(WIN); + +test('planSunAngle: plain subtraction, wraps around the circle (359→0)', () => { + assert.equal(planSunAngle(180, 0), 180); + assert.equal(planSunAngle(0, 1), 359); + assert.equal(planSunAngle(359, 359), 0); + assert.equal(planSunAngle(10, 350), 20); + assert.equal(norm360(-90), 270); + assert.equal(norm360(720), 0); +}); + +test('sunDirOnPlan: compass points map to canvas vectors (y grows down)', () => { + const cases = [ + [0, [0, -1]], // north = canvas up + [90, [1, 0]], // east = right + [180, [0, 1]], // south = down + [270, [-1, 0]], // west = left + ]; + for (const [az, [x, y]] of cases) { + const d = sunDirOnPlan(az, 0); + assert.ok(near(d[0], x, 1e-12) && near(d[1], y, 1e-12), `az ${az}`); + } + // rotating the compass rotates the whole sky: east sun, north_deg=90 → up + const d = sunDirOnPlan(90, 90); + assert.ok(near(d[0], 0, 1e-12) && near(d[1], -1, 1e-12)); +}); + +test('dayPhase: night is dark and dim, noon is neutral, sunrise is warm', () => { + const night = dayPhase(-20); + const dawn = dayPhase(2); + const noon = dayPhase(60); + assert.equal(night.bg, '#070c14'); + assert.equal(noon.bg, '#5a6673'); + assert.notEqual(dawn.bg, night.bg); + assert.notEqual(dawn.bg, noon.bg); + assert.ok(near(night.planDim, 0.1)); + assert.equal(noon.planDim, 0); + assert.ok(dawn.planDim > 0 && dawn.planDim < 0.1); + assert.equal(night.warmth, 1); + assert.equal(noon.warmth, 0); + assert.ok(near(dawn.warmth, 0.8)); + // garbage elevation never throws and stays inside the palette + assert.ok(dayPhase(NaN).bg.startsWith('#')); +}); + +test('windowWallInfo: exterior windows on all four sides get outward normals', () => { + const n = windowWallInfo(WIN.north, ROOMS); + const s = windowWallInfo(WIN.south, ROOMS); + const w = windowWallInfo(WIN.west, ROOMS); + const e = windowWallInfo(WIN.east, ROOMS); + assert.deepEqual(n.roomId, 'r1'); + assert.ok(near(n.normal[0], 0, 1e-12) && near(n.normal[1], -1, 1e-12)); + assert.ok(near(s.normal[0], 0, 1e-12) && near(s.normal[1], 1, 1e-12)); + assert.ok(near(w.normal[0], -1, 1e-12) && near(w.normal[1], 0, 1e-12)); + assert.equal(w.roomId, 'r1'); + assert.ok(near(e.normal[0], 1, 1e-12) && near(e.normal[1], 0, 1e-12)); + assert.equal(e.roomId, 'r2'); +}); + +test('windowWallInfo: interior and orphan windows never participate', () => { + assert.equal(windowWallInfo(WIN.inner, ROOMS), null); // shared wall + assert.equal(windowWallInfo({ x: 300, y: 300, angle: 0 }, ROOMS), null); // mid-room + assert.equal(windowWallInfo({ x: 950, y: 950, angle: 0 }, ROOMS), null); // nowhere +}); + +test('isExteriorWall probes the outer side', () => { + assert.ok(isExteriorWall([300, 100], [0, -1], ROOMS)); + assert.ok(!isExteriorWall([500, 300], [1, 0], ROOMS)); // r2 is outside r1 here +}); + +test('windowLit: above the horizon AND facing the sun', () => { + const east = [1, 0]; + assert.ok(windowLit(east, sunDirOnPlan(90, 0), 10)); + assert.ok(!windowLit(east, sunDirOnPlan(270, 0), 10)); // sun behind the house + assert.ok(!windowLit(east, sunDirOnPlan(90, 0), 0)); // sunset moment + assert.ok(!windowLit(east, sunDirOnPlan(90, 0), -5)); // night +}); + +test('rayLength: longest at the horizon, shortest at noon, monotonic', () => { + assert.ok(near(rayLength(0), 2.5, 1e-9)); + assert.ok(near(rayLength(90), 0.8, 1e-9)); + assert.ok(rayLength(10) > rayLength(30)); + assert.ok(rayLength(30) > rayLength(60)); + assert.ok(near(rayLength(-5), 2.5, 1e-9)); // clamped +}); + +test('rayQuad + clipToRoom: the wedge is cut by the room outline', () => { + const quad = rayQuad([100, 270], [100, 330], [1, 0], 1000); // way past the wall + const clipped = clipToRoom(quad, ROOMS[0].poly); + assert.equal(clipped.length, 1); + for (const [x, y] of clipped[0]) { + assert.ok(x >= 100 - 1e-6 && x <= 500 + 1e-6, 'x inside the room'); + assert.ok(y >= 100 - 1e-6 && y <= 500 + 1e-6, 'y inside the room'); + } + assert.ok(clipped[0].some(([x]) => near(x, 500, 1e-6)), 'reaches the far wall, not past it'); + // a wedge fully outside the room clips to nothing + assert.equal(clipToRoom(rayQuad([900, 900], [960, 900], [0, 1], 50), ROOMS[0].poly).length, 0); +}); + +test('computeSunRays: morning east sun lights ONLY the east window', () => { + const rays = computeSunRays(ROOMS, ALL, 90, 5, 0); + assert.deepEqual(rays.map((r) => r.openingId), ['wE']); + assert.equal(rays[0].roomId, 'r2'); + // light travels AWAY from the sun: westward into the room + assert.ok(near(rays[0].dir[0], -1, 1e-12) && near(rays[0].dir[1], 0, 1e-12)); + for (const [x, y] of rays[0].polys[0]) { + assert.ok(x >= 500 - 1e-6 && x <= 800 + 1e-6 && y >= 100 - 1e-6 && y <= 500 + 1e-6); + } +}); + +test('computeSunRays: noon south sun → south window, short wedge', () => { + const rays = computeSunRays(ROOMS, ALL, 180, 60, 0); + assert.deepEqual(rays.map((r) => r.openingId), ['wS']); + assert.ok(near(rays[0].len, rayLength(60) * 60, 1e-9)); + assert.ok(rays[0].len < computeSunRays(ROOMS, ALL, 90, 5, 0)[0].len); +}); + +test('computeSunRays: evening west sun → west window', () => { + const rays = computeSunRays(ROOMS, ALL, 270, 4, 0); + assert.deepEqual(rays.map((r) => r.openingId), ['wW']); +}); + +test('computeSunRays: night → nothing at all', () => { + assert.deepEqual(computeSunRays(ROOMS, ALL, 90, 0, 0), []); + assert.deepEqual(computeSunRays(ROOMS, ALL, 90, -10, 0), []); +}); + +test('computeSunRays: rotating the compass swings the light to another window', () => { + // the same morning east sun, but the plan is rotated 90°: what the canvas + // shows as "up" is now east → the NORTH-drawn window faces the sun + const rays = computeSunRays(ROOMS, ALL, 90, 5, 90); + assert.deepEqual(rays.map((r) => r.openingId), ['wN']); + // and the interior window still never lights up whatever the compass says + for (const nd of [0, 45, 90, 180, 270]) { + for (const az of [0, 90, 180, 270]) { + assert.ok(!computeSunRays(ROOMS, ALL, az, 5, nd).some((r) => r.openingId === 'wI')); + } + } +}); + +test('rayAlpha: capped, ramps in near the horizon, scaled by clouds', () => { + assert.equal(rayAlpha(0), 0); + assert.equal(rayAlpha(-3), 0); + assert.ok(near(rayAlpha(1), RAY_MAX_ALPHA / 2)); + assert.ok(near(rayAlpha(30), RAY_MAX_ALPHA)); + assert.ok(near(rayAlpha(30, 0.25), RAY_MAX_ALPHA * 0.25)); + assert.equal(rayAlpha(30, 0), 0); +}); + +test('rayColor: warm at the horizon, neutral by day', () => { + assert.equal(rayColor(1), '#ff9a45'); + assert.equal(rayColor(0), '#ffe9c2'); + assert.notEqual(rayColor(0.5), rayColor(0)); +}); + +test('cloudFactor: the state map, garbage-safe', () => { + assert.equal(cloudFactor('sunny'), 1); + assert.equal(cloudFactor('clear-night'), 1); + assert.equal(cloudFactor('partlycloudy'), 0.7); + assert.equal(cloudFactor('cloudy'), 0.4); + assert.equal(cloudFactor('overcast'), 0.25); + assert.equal(cloudFactor('fog'), 0.25); + assert.equal(cloudFactor('rainy'), 0); + assert.equal(cloudFactor('pouring'), 0); + assert.equal(cloudFactor('snowy'), 0); + assert.equal(cloudFactor('lightning-rainy'), 0); + assert.equal(cloudFactor('unknown'), 1); + assert.equal(cloudFactor('unavailable'), 1); + assert.equal(cloudFactor(null), 1); + assert.equal(cloudFactor(undefined), 1); + assert.equal(cloudFactor('CLOUDY'), 0.4); +}); + +test('northDegOf: space override wins, strict int 0–359, null = inert', () => { + assert.equal(northDegOf({ north_deg: 90 }, {}), 90); + assert.equal(northDegOf({ north_deg: 90 }, { north_deg: 0 }), 0); // 0 is a value, not "unset" + assert.equal(northDegOf({}, { north_deg: 359 }), 359); + assert.equal(northDegOf({}, {}), null); + assert.equal(northDegOf(null, undefined), null); + for (const bad of [360, -1, 1.5, '90', true, NaN]) { + assert.equal(northDegOf({ north_deg: bad }, {}), null, String(bad)); + } + // a garbage override falls back to the valid global + assert.equal(northDegOf({ north_deg: 45 }, { north_deg: 999 }), 45); +}); + +test('bgModeOf: inherit chain with a static fallback', () => { + assert.equal(bgModeOf({}, {}), 'static'); + assert.equal(bgModeOf({ bg_mode: 'daynight' }, {}), 'daynight'); + assert.equal(bgModeOf({ bg_mode: 'daynight' }, { bg_mode: 'static' }), 'static'); + assert.equal(bgModeOf({}, { bg_mode: 'daynight' }), 'daynight'); + assert.equal(bgModeOf({ bg_mode: 'disco' }, {}), 'static'); +}); + +test('sunRaysOn: default OFF, per-space tri-state inherit', () => { + assert.equal(sunRaysOn({}, {}), false); + assert.equal(sunRaysOn({ sun_rays: true }, {}), true); + assert.equal(sunRaysOn({ sun_rays: true }, { sun_rays: false }), false); + assert.equal(sunRaysOn({}, { sun_rays: true }), true); + assert.equal(sunRaysOn({ sun_rays: true }, { sun_rays: null }), true); // null = inherit + assert.equal(sunRaysOn({ sun_rays: 'yes' }, {}), false); +}); + +test('weatherEntityOf / sunStateOf: strings and hass shapes, garbage-safe', () => { + assert.equal(weatherEntityOf({ weather_entity: 'weather.home' }), 'weather.home'); + assert.equal(weatherEntityOf({ weather_entity: ' ' }), null); + assert.equal(weatherEntityOf({}), null); + assert.deepEqual( + sunStateOf({ states: { 'sun.sun': { attributes: { azimuth: 120.5, elevation: -3 } } } }), + { azimuth: 120.5, elevation: -3 }, + ); + assert.equal(sunStateOf({ states: {} }), null); + assert.equal(sunStateOf({ states: { 'sun.sun': { attributes: { azimuth: 'x', elevation: 1 } } } }), null); + assert.equal(sunStateOf(null), null); +}); diff --git a/tsconfig.test.json b/tsconfig.test.json index 37feb37a..864271fc 100644 --- a/tsconfig.test.json +++ b/tsconfig.test.json @@ -9,6 +9,7 @@ }, "include": [ "src/logic.ts", "src/vacuum.ts", + "src/sun.ts", "src/resize.ts", "src/rules.ts", "src/devices.ts",