Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/now-ring.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"ftw-webapp": patch
---

Now draws your home as the phone app does: what the home uses sits in the middle of a ring, and the sun, the battery, the grid and the car sit in the corners as plain text. Each one lights the part of the ring that faces it, longer with more power, and a soft light runs along it toward the home or away from it. Only readings the box confirms live glow or move; old readings show as still grey arcs. Tap a corner or the middle to open it, as before. The first load is about 5 kB smaller.
28 changes: 19 additions & 9 deletions src/lib/state/flow.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,24 @@
/* From a site snapshot to the energy-flow component's planets.
/* From a site snapshot to the planets of the box's energy-flow component.
*
* The box's own dashboard builds this list in web/app.js from /api/status.
* Frozen fields on the 1 Hz stream are the fallback: five aggregates, no
* names, no kWh today. Same component, same corners and colours — "same
* views" only holds if the caller speaks to it the same way.
* names, no kWh today. Same corners, colours and words as the box — "same
* views" only holds if both build them the same way. Now folds them into
* the ring's nodes (ring.ts).
*
* The component knows nothing about field ids or HTTP. Everything it is
* told is decided here, which is what makes this file worth testing.
* Nothing downstream knows about field ids or HTTP. Everything it is told
* is decided here, which is what makes this file worth testing.
*/

import { withControlMarks, type ControlMark } from '$vendor/ftw/control-feedback.js'
import { FID } from '$lib/format/explanation'
import { FLOW_IDLE_W } from '$vendor/ftw/ftw-energy-flow.js'

/**
* At or below this a flow is idle: no light, no arc. The box's component
* uses the same 42 W, and a test holds the two equal, so the first frame
* need not load that whole component for one number.
*/
export const FLOW_IDLE_W = 42

export interface FlowDailyPart {
text: string
Expand Down Expand Up @@ -43,7 +50,8 @@ export interface FlowPlanet {
}

export interface FlowReadings {
load: number
/** The home's use in kW, or null when the box sends no such reading. */
load: number | null
planets: FlowPlanet[]
selfPoweredPctToday?: number | null
}
Expand Down Expand Up @@ -192,7 +200,8 @@ export function flowReadings(fields: ReadonlyMap<number, number>): FlowReadings
})
}

return { load: (fields.get(FID.LOAD_W) ?? 0) / 1000, planets }
const loadW = fields.get(FID.LOAD_W)
return { load: loadW === undefined ? null : loadW / 1000, planets }
}

/**
Expand Down Expand Up @@ -428,13 +437,14 @@ export function flowReadingsFromStatus(status: SiteStatus, live = true): FlowRea
}
}

const loadW = num(status.load_w)
let selfPoweredPctToday: number | null = null
if (loadKwhTotal > 0.001) {
selfPoweredPctToday = Math.max(0, Math.min(100, (1 - importKwh / loadKwhTotal) * 100))
}

return {
load: (num(status.load_w) ?? 0) / 1000,
load: loadW === null ? null : loadW / 1000,
planets: withControlMarks(planets, status.control_feedback, live),
selfPoweredPctToday,
}
Expand Down
82 changes: 82 additions & 0 deletions src/lib/state/ring-motion.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { describe, expect, it } from 'vitest'
import { ArcMotion, Follow, wave, waveSpeed, type ArcTarget } from './ring-motion'

const target = (p: Partial<ArcTarget> = {}): ArcTarget => ({
kw: 2, live: true, flowing: true, towardHome: true, fill: 0.5, ...p,
})

/** Steps an arc at 60 frames a second from `from` to `to` seconds. */
function run(m: ArcMotion, t: ArcTarget, from: number, to: number, reduce = false) {
let f = m.step(t, from, reduce)
for (let s = from; s <= to; s += 1 / 60) f = m.step(t, s, reduce)
return f
}

describe('the light on an arc', () => {
it('never glows on an arc that has never been live', () => {
const t = target({ live: false })
const f = run(new ArcMotion(t, 0), t, 0, 3)
expect(f.light).toBe(0)
expect(f.phase).toBe(0)
})

it('opens live data at full light without a flash', () => {
const f = new ArcMotion(target(), 0).step(target(), 0, false)
expect(f.light).toBe(1)
})

it('cools to ink within a second and a half once the box stops confirming it', () => {
const m = new ArcMotion(target(), 0)
run(m, target(), 0, 1)
const quiet = target({ live: false })
expect(run(m, quiet, 1, 1.2).light).toBeGreaterThan(0.4)
expect(run(m, quiet, 1.2, 2.6).light).toBeLessThan(0.05)
})

it('does not cool on screen after going quiet while nothing was drawn', () => {
const m = new ArcMotion(target(), 0)
m.step(target(), 0, false)
expect(m.step(target({ live: false }), 5, false).light).toBe(0)
})

it('runs the wave only while live, and not at all under reduced motion', () => {
const live = run(new ArcMotion(target(), 0), target(), 0, 1)
expect(live.phase).toBeGreaterThan(0.5)
const still = run(new ArcMotion(target(), 0), target(), 0, 1, true)
expect(still.phase).toBe(0)
})

it('grows the arc to a new reading without overshoot', () => {
const m = new ArcMotion(target({ fill: 0.2 }), 0)
let most = 0
for (let s = 0; s < 3; s += 1 / 60) most = Math.max(most, m.step(target({ fill: 0.8 }), s, false).fill)
expect(most).toBeLessThanOrEqual(0.8 + 1e-9)
expect(most).toBeGreaterThan(0.79)
})

it('snaps to a new reading under reduced motion', () => {
const m = new ArcMotion(target({ fill: 0.2 }), 0)
expect(m.step(target({ fill: 0.8 }), 0.1, true).fill).toBe(0.8)
})
})

describe('the wave', () => {
it('crests at 1 and dips to 0.45', () => {
expect(wave(0, 0)).toBeCloseTo(1)
expect(wave(15, 0)).toBeCloseTo(0.45)
})

it('runs faster with power, never racing', () => {
expect(waveSpeed(0.1)).toBeCloseTo(19.2, 0)
expect(waveSpeed(1)).toBeCloseTo(31.4, 0)
expect(waveSpeed(10)).toBeCloseTo(51.6, 0)
})

it('follows a step as one long frame lands where many short ones do', () => {
const a = new Follow(0)
const b = new Follow(0)
a.step(1, 1, 0.4)
for (let i = 0; i < 100; i++) b.step(1, 0.01, 0.4)
expect(a.value).toBeCloseTo(b.value, 6)
})
})
181 changes: 181 additions & 0 deletions src/lib/state/ring-motion.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
/* How the ring's light moves, frame to frame.
*
* Plain values stepped once per frame, so a test can run them without a
* screen. The constants are the iOS app's (Flow.swift): a change belongs in
* both, or the phone and the browser stop showing the same house.
*
* Light means live. An arc glows and its wave runs only while the box
* confirms the reading; when it stops, the glow cools like a CRT and the
* arc goes to ink. An arc that has never been live never glows, so a cache
* opens still.
*/

/** Pixels from one crest of the light to the next. */
export const WAVELENGTH = 30

/** How fast the light runs from source to sink, in px a second: 19 at
* 0.1 kW, 31 at 1 kW, 52 at 10 kW. Faster with power, never racing. */
export function waveSpeed(kw: number): number {
return 16 + 22 * Math.log10(1 + 4 * Math.abs(kw))
}

/** Brightness along an arc, from 0.45 to 1: soft crests a wavelength apart.
* `s` is px from the source, `phase` is wavelengths the light has run. */
export function wave(s: number, phase: number): number {
const c = (1 + Math.cos(2 * Math.PI * (s / WAVELENGTH - phase))) / 2
return 0.45 + 0.55 * c * c * c
}

/** A value that follows its target as a critically damped spring with time
* constant `tau`: it swells to a new value and never overshoots. Stepped
* exactly, so a long frame lands where many short ones would. */
export class Follow {
velocity = 0
constructor(public value: number) {}

step(target: number, dt: number, tau: number): void {
if (dt <= 0) return
if (tau <= 0) return this.snap(target)
const w = 1 / tau
const e = this.value - target
const k = Math.exp(-w * dt)
const a = this.velocity + w * e
this.value = target + (e + a * dt) * k
this.velocity = (this.velocity - w * a * dt) * k
}

snap(target: number): void {
this.value = target
this.velocity = 0
}
}

/** The phosphor, from 0 (ink, still) to 1 (live). It ignites on a spring
* (response 0.35 s, damping 0.85) and decays exponentially (τ 0.4 s) while
* its wave slows to rest in 0.35 s. Instant under reduced motion. */
export class Light {
static readonly decay = 0.4
static readonly rest = 0.35
static readonly response = 0.35
static readonly damping = 0.85

private since: number
private level0: number
private motion0: number

constructor(public live: boolean, t: number) {
this.since = t
this.level0 = live ? 1 : 0
this.motion0 = this.level0
}

set(live: boolean, t: number, instant = false): void {
if (live === this.live) return
const target = live ? 1 : 0
this.level0 = instant ? target : this.level(t)
this.motion0 = instant ? target : this.motion(t)
this.since = t
this.live = live
}

/** The glow, the wave's depth and the core's strength. */
level(t: number): number {
const dt = Math.max(0, t - this.since)
return this.live ? 1 - (1 - this.level0) * Light.residual(dt) : this.level0 * Math.exp(-dt / Light.decay)
}

/** How fast the wave runs, as a share of its speed. */
motion(t: number): number {
const dt = Math.max(0, t - this.since)
if (this.live) return Math.min(1, 1 - (1 - this.motion0) * Light.residual(dt))
const k = 1 - Math.min(1, dt / Light.rest)
return this.motion0 * k * k
}

/** What is left of a step after `t` seconds on the spring. */
static residual(t: number): number {
const w = (2 * Math.PI) / Light.response
const z = Light.damping
const wd = w * Math.sqrt(1 - z * z)
return Math.exp(-z * w * t) * (Math.cos(wd * t) + ((z * w) / wd) * Math.sin(wd * t))
}
}

/** What one arc shows for one node's reading. */
export interface ArcTarget {
/** Magnitude, never signed. */
kw: number
live: boolean
flowing: boolean
/** The light runs toward the home: clockwise. */
towardHome: boolean
/** How much of its quarter the arc lights, 0 to 1. */
fill: number
}

/** One arc's look in one frame. */
export interface ArcFrame {
/** Wavelengths the light has run, source to sink. */
phase: number
/** The phosphor: the glow's strength and the wave's depth. */
light: number
/** 0 for a flowing arc, 1 for a node at rest. */
idle: number
/** The share of its quarter the arc lights. */
fill: number
}

export const alpha = (f: ArcFrame) => 0.35 + 0.65 * Math.min(1, f.light)
export const depth = (f: ArcFrame) => Math.min(1, f.light)

/** One arc over time: its fill and speed follow the readings (τ 0.4 s), its
* light ignites and decays, its wave runs at the speed the power gives. */
export class ArcMotion {
static readonly tau = 0.4
/** Longer than this between two frames, nothing was drawn: the page was
* away or the loop rested. */
static readonly gap = 0.5

private speed: Follow
private idle: Follow
private fill: Follow
private light: Light
private phase = 0
private last: number

/** The first frame: everything where the reading puts it, with no ignite,
* so a page that opens on live data does not flash. */
constructor(target: ArcTarget, t: number) {
this.speed = new Follow(waveSpeed(target.kw))
this.idle = new Follow(target.flowing ? 0 : 1)
this.fill = new Follow(target.fill)
this.light = new Light(target.live, t)
this.last = t
}

step(target: ArcTarget, t: number, reduceMotion: boolean): ArcFrame {
const dt = Math.max(0, t - this.last)
this.last = Math.max(this.last, t)
// A reading that stopped being live while nothing was drawn went out at
// a moment nobody saw, so it never cools on screen afterwards.
this.light.set(target.live, t, reduceMotion || (!target.live && dt > ArcMotion.gap))
const v = waveSpeed(target.kw)
const rest = target.flowing ? 0 : 1
if (reduceMotion) {
this.speed.snap(v)
this.idle.snap(rest)
this.fill.snap(target.fill)
} else {
this.speed.step(v, dt, ArcMotion.tau)
this.idle.step(rest, dt, ArcMotion.tau / 2)
this.fill.step(target.fill, dt, ArcMotion.tau)
this.phase = ((this.phase + (this.speed.value * this.light.motion(t) * dt) / WAVELENGTH) % 1_000)
}
return {
phase: this.phase,
light: this.light.level(t),
idle: Math.min(1, Math.max(0, this.idle.value)),
fill: Math.min(1, Math.max(0, this.fill.value)),
}
}
}
Loading
Loading