Docs / docs (push) Successful in 19s
Playwright Tests / test-playwright (1, 2) (push) Failing after 1m4s
Playwright Tests / test-playwright (2, 2) (push) Failing after 19s
pre-commit / pre-commit (push) Failing after 2m2s
Test Backend / test-backend (push) Failing after 45s
Compose Smoke Test / test-compose (push) Failing after 18s
Playwright Tests / merge-reports (push) Failing after 1m9s
A chart that named no palette took the ramp in order, so three lines drew slots 1, 2 and 3 — adjacent steps of a ramp that carries identity by lightness alone, which at a 2px stroke read as close to one picture. Each count now takes the slots furthest apart that the ramp allows: 1 -> 1, 2 -> 1/5, 3 -> 1/3/5, 4 -> 1/2/4/5. Five lines are unchanged. Moving a default is only safe because the mechanism around it is inert: a palette a dashboard wrote down is still drawn exactly as written, so no stored document means anything different than it did. `palette.check.ts` now pins the spread table and says which of the two properties is permanent. An unnamed palette is empty rather than the whole ramp, so the settings panel shows nothing picked when a dashboard is on automatic — and deselecting the last colour is now the way back to it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016ZeGnqVsf5VHQqvz4HdUhN
1404 lines
48 KiB
Python
1404 lines
48 KiB
Python
#!/usr/bin/env python
|
|
"""Seed the hosted demo: a house panel, the flows behind it, and a batch run.
|
|
|
|
This is an **operational script for the hosted instance**, not part of any
|
|
deployment. Nothing installs it, nothing calls it on first run, and a
|
|
self-hosted fluksio never sees it — it exists so whoever operates the public
|
|
demo can point it at that instance and get the same panel back every time.
|
|
|
|
What it builds::
|
|
|
|
home the house: readings, the controls, the week ahead
|
|
home_history answers the temperature chart's questions
|
|
pv_model a batch flow that fits the array's yield curve
|
|
demo one dashboard, three sections, every widget type
|
|
|
|
The panel is a small-house energy and climate display — the thing a person
|
|
would actually hang in a hallway — and it is also the widget gallery: all
|
|
sixteen types are on it, including the variants no editor field can write
|
|
(a switch drawn as a button, a segmented dropdown, a bar with a nested
|
|
reading, an icon ladder with labels, fixed chart axes, decimal places).
|
|
|
|
It **wipes and recreates**. The three flows and the dashboard are deleted
|
|
first, which also drops the values and history they left behind, so running it
|
|
twice gives the same panel — and a scheduled reset is just another run.
|
|
|
|
Nothing here needs a database, a broker or a connector package. The querying
|
|
chart is answered by a plain Python node, which is the whole point of the
|
|
exchange: the widget asks for a window and draws what comes back, and what
|
|
serves it is the flow's business.
|
|
|
|
Run it against a stack that is already up::
|
|
|
|
make -C app seed-hosted-demo # local
|
|
API_URL=https://api.example.com make -C app seed-hosted-demo
|
|
|
|
Environment:
|
|
API_URL default http://api.localhost; https works
|
|
FIRST_SUPERUSER, FIRST_SUPERUSER_PASSWORD
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import sys
|
|
import time
|
|
from typing import Any
|
|
|
|
import httpx
|
|
|
|
API = os.environ.get("API_URL", "http://api.localhost")
|
|
EMAIL = os.environ.get("FIRST_SUPERUSER", "")
|
|
PASSWORD = os.environ.get("FIRST_SUPERUSER_PASSWORD", "")
|
|
|
|
HOUSE = "home"
|
|
HISTORY = "home_history"
|
|
MODEL = "pv_model"
|
|
PANEL = "demo"
|
|
|
|
#: The alert channel this seed owns. Anything else configured is left alone.
|
|
CHANNEL = "demo_panel"
|
|
|
|
#: What the notification widget says when nothing has gone wrong.
|
|
ALL_QUIET = {
|
|
"title": "Engine healthy",
|
|
"body": "Nothing has failed since this panel was seeded.",
|
|
"severity": "info",
|
|
}
|
|
|
|
|
|
def msg(flow: str, name: str) -> str:
|
|
"""A message name as the engine qualifies it."""
|
|
return f"{flow}.{name}"
|
|
|
|
|
|
# -----------------------------------------------------------------------------
|
|
# home — the house
|
|
# -----------------------------------------------------------------------------
|
|
|
|
HOUSE_SOURCE = '''"""The house, read every five seconds.
|
|
|
|
Nothing here talks to a device: the readings are a plausible curve over the
|
|
clock, so the panel has something to show wherever it is stood up. In a real
|
|
installation this node is an MQTT or Modbus one and everything downstream of it
|
|
is unchanged — which is what it means for the message to be the wiring.
|
|
|
|
The three control inputs do not wake this node (``trigger: false``); they are
|
|
read on the next tick, which is what a setpoint is for.
|
|
"""
|
|
|
|
import math
|
|
import time
|
|
|
|
#: What the array does at noon in June, in watts.
|
|
PEAK_PV = 6400.0
|
|
#: What the house draws with nothing switched on.
|
|
STANDBY = 320.0
|
|
#: Where each mode puts the room, relative to the slider.
|
|
MODES = {"eco": -1.5, "comfort": 0.0, "boost": 1.5}
|
|
|
|
|
|
def _sun(hour):
|
|
"""How much of the array's peak the sun is worth at this hour."""
|
|
if hour < 6.5 or hour > 20.5:
|
|
return 0.0
|
|
return math.sin((hour - 6.5) / 14.0 * math.pi) ** 1.4
|
|
|
|
|
|
def _peak(hour, at, width):
|
|
"""A meal, or anything else that happens around one time of day."""
|
|
return math.exp(-((hour - at) ** 2) / (2 * width * width))
|
|
|
|
|
|
def process(tick, mode="comfort", away=False, setpoint=21.0):
|
|
clock = time.localtime()
|
|
hour = clock.tm_hour + clock.tm_min / 60.0 + clock.tm_sec / 3600.0
|
|
seconds = time.time()
|
|
|
|
# A fast wobble and a slow drift, so a window of any width has some shape.
|
|
wobble = math.sin(seconds / 210.0 * 2 * math.pi)
|
|
drift = math.sin(seconds / 5400.0 * 2 * math.pi)
|
|
|
|
pv = PEAK_PV * _sun(hour) * (0.84 + 0.16 * wobble)
|
|
|
|
# What the controls are worth. Away drops the room and stops the
|
|
# appliances; the mode moves the target the slider set.
|
|
target = float(setpoint) + MODES.get(str(mode), 0.0) - (3.0 if away else 0.0)
|
|
|
|
outdoor = 11.5 + 7.5 * math.sin((hour - 9.5) / 24.0 * 2 * math.pi) + 0.6 * drift
|
|
indoor = target - 0.3 + 0.5 * wobble
|
|
humidity = 55.0 + 5.0 * drift - 0.9 * (indoor - 21.0)
|
|
|
|
kitchen = 0.0
|
|
if not away:
|
|
kitchen = 1400.0 * _peak(hour, 7.5, 0.8) + 1900.0 * _peak(hour, 18.5, 1.1)
|
|
heating = max(0.0, target - outdoor) * 95.0 * (0.55 if away else 1.0)
|
|
load = STANDBY + kitchen + heating
|
|
|
|
# The battery fills through the afternoon and is drawn down overnight.
|
|
battery = 58.0 - 34.0 * math.cos((hour - 5.0) / 24.0 * 2 * math.pi)
|
|
|
|
return {
|
|
"pv_watts": round(pv, 1),
|
|
"load_watts": round(load, 1),
|
|
"grid_watts": round(load - pv, 1),
|
|
"battery": round(battery, 1),
|
|
"indoor": round(indoor, 2),
|
|
"outdoor": round(outdoor, 2),
|
|
"humidity": round(humidity, 1),
|
|
# Not a reading: what the panel on the wall should wear. The dashboard
|
|
# binds its `theme` setting to this, which is what "switch the panel at
|
|
# sunset" looks like here — a flow publishing, not a rule in the
|
|
# dashboard.
|
|
"panel_theme": "dark" if hour < 7.0 or hour >= 20.0 else "light",
|
|
}
|
|
'''
|
|
|
|
METER_SOURCE = '''"""What the meter says: the cost, and how much of it the roof paid.
|
|
|
|
The tariff is a message like any other, so the field on the dashboard changes
|
|
this the moment it is committed. Exporting costs nothing, which is why only the
|
|
import is charged for.
|
|
|
|
``self_use_kw`` is the part of the house's draw the roof covered, which is a
|
|
share of it by construction — that is what lets the panel draw one inside the
|
|
other and have the picture mean something.
|
|
"""
|
|
|
|
|
|
def process(pv_kw, load_kw, grid_kw, tariff=32.0):
|
|
return {
|
|
"cost_now": round(max(0.0, grid_kw) * float(tariff) / 100.0, 2),
|
|
"self_use_kw": round(min(pv_kw, load_kw), 2),
|
|
}
|
|
'''
|
|
|
|
PLAN_SOURCE = '''"""The week ahead: the sky, and what is written in the calendar.
|
|
|
|
Both are lists of records, which is the shape the forecast and agenda widgets
|
|
read — every column is ``{label, icon, value}``, every entry ``{title, ts}``.
|
|
The flow decides what a day is called; the widget only draws it. A real
|
|
installation puts an HTTP node where these constants are and changes nothing
|
|
else.
|
|
|
|
Run once when the flow starts and again at six every morning, from a cron on
|
|
the inject beside it. The current climate is read but does not wake it: a
|
|
forecast is a daily thing.
|
|
"""
|
|
|
|
import time
|
|
|
|
DAY_S = 86400
|
|
|
|
#: A week of sky, and how far each day sits from what it is doing now.
|
|
WEATHER = [
|
|
("sun", "primary", 2.0),
|
|
("sun", "primary", 1.0),
|
|
("cloudy", "default", -1.0),
|
|
("cloud-rain", "primary", -3.0),
|
|
("wind", "muted", -2.0),
|
|
("cloud-drizzle", "default", -1.5),
|
|
("sun", "primary", 0.5),
|
|
]
|
|
|
|
#: The household calendar: days from today, hour, minute, what it is, all-day.
|
|
DIARY = [
|
|
(0, 18, 30, "Dishwasher on the night tariff", False),
|
|
(1, 8, 0, "Chimney sweep", False),
|
|
(2, 0, 0, "Bin day — paper", True),
|
|
(4, 19, 0, "Read the meter", False),
|
|
(6, 0, 0, "Service the heat pump", True),
|
|
]
|
|
|
|
|
|
def process(day_start, climate=None):
|
|
now = time.time()
|
|
# Anchored on what it is doing outside, once the flow has got that far; the
|
|
# first plan after a cold start has nothing to anchor to yet.
|
|
outside = float((climate or {}).get("outdoor") or 12.0)
|
|
midnight = time.mktime(time.localtime(now)[:3] + (0, 0, 0, 0, 0, -1))
|
|
|
|
forecast = []
|
|
for index, (icon, colour, delta) in enumerate(WEATHER):
|
|
when = time.localtime(midnight + index * DAY_S)
|
|
forecast.append(
|
|
{
|
|
"label": "Today" if index == 0 else time.strftime("%a", when),
|
|
"icon": icon,
|
|
"color": colour,
|
|
"value": "{:.0f}°".format(outside + delta),
|
|
}
|
|
)
|
|
|
|
agenda = [
|
|
{
|
|
"title": title,
|
|
"ts": midnight + days * DAY_S + hour * 3600 + minute * 60,
|
|
"all_day": all_day,
|
|
}
|
|
for days, hour, minute, title, all_day in DIARY
|
|
]
|
|
return {"forecast": forecast, "agenda": agenda}
|
|
'''
|
|
|
|
LAMP_SOURCE = '''"""What the hallway lamp was set to, said in words.
|
|
|
|
The wheel on the panel publishes ``[h, s, v]`` — hue in degrees, saturation and
|
|
value in percent, which is what a DMX encoder divides down. A real installation
|
|
puts an MQTT or Art-Net node here; this only names the colour, so the panel can
|
|
show that the control reached something.
|
|
"""
|
|
|
|
#: Where each name starts, in degrees. Read from the bottom up, so the last row
|
|
#: a hue has passed wins — and red is at both ends, because the wheel wraps.
|
|
NAMES = [
|
|
(0, "Red"),
|
|
(15, "Amber"),
|
|
(45, "Yellow"),
|
|
(70, "Lime"),
|
|
(100, "Green"),
|
|
(160, "Cyan"),
|
|
(200, "Blue"),
|
|
(260, "Violet"),
|
|
(290, "Magenta"),
|
|
(320, "Pink"),
|
|
(350, "Red"),
|
|
]
|
|
|
|
|
|
def process(light_color):
|
|
hue, saturation, value = (list(light_color) + [0, 0, 0])[:3]
|
|
if value < 2:
|
|
return {"light_state": "Off"}
|
|
if saturation < 10:
|
|
return {"light_state": "White at {:.0f}%".format(value)}
|
|
name = next(label for at, label in reversed(NAMES) if hue >= at)
|
|
return {"light_state": "{} at {:.0f}%".format(name, value)}
|
|
'''
|
|
|
|
HOUSE_INPUTS = [
|
|
# What the controls on the panel write to. The initial values are what the
|
|
# dashboard reads back before anyone touches anything, which is why the
|
|
# segmented picker opens on "Comfort" rather than on nothing.
|
|
{"spec": {"name": "mode", "dtype": "str"}, "initial": "comfort"},
|
|
{"spec": {"name": "away", "dtype": "bool"}, "initial": False},
|
|
{"spec": {"name": "setpoint", "dtype": "float"}, "initial": 21.0},
|
|
{"spec": {"name": "tariff", "dtype": "float"}, "initial": 32.0},
|
|
# Three numbers rather than a record: [hue, saturation, value], which is
|
|
# what the colour wheel sends and what a DMX encoder reads.
|
|
{
|
|
"spec": {"name": "light_color", "dtype": "list", "item": "float"},
|
|
"initial": [38, 72, 80],
|
|
},
|
|
# False rather than nothing: a declared message with no starting value is
|
|
# a node that can never run, and the engine says so on the canvas.
|
|
{"spec": {"name": "boost", "dtype": "bool"}, "initial": False},
|
|
# Where the engine's own failures land. Declared here rather than appearing
|
|
# from nowhere: a dashboard channel writes to a message a flow owns.
|
|
{"spec": {"name": "engine_alert", "dtype": "record"}, "initial": ALL_QUIET},
|
|
]
|
|
|
|
HOUSE_NODES = [
|
|
{
|
|
"id": "every_5s",
|
|
"type": "inject",
|
|
"title": "Every five seconds",
|
|
"params": {"interval": 5, "at_start": True, "payload": 1},
|
|
"provides": [{"name": "tick", "dtype": "float"}],
|
|
},
|
|
{
|
|
"id": "at_six",
|
|
"type": "inject",
|
|
"title": "Six every morning",
|
|
# A schedule rather than an interval: the plan below is a daily thing.
|
|
"params": {"cron": "0 6 * * *", "at_start": True, "start_delay": 2.0},
|
|
"provides": [{"name": "day_start", "dtype": "float"}],
|
|
},
|
|
{
|
|
"id": "house",
|
|
"type": "python",
|
|
"title": "Read the house",
|
|
"requires": [
|
|
{"name": "tick", "dtype": "float"},
|
|
# Read on the next tick, never a reason to run: a setpoint is a
|
|
# standing instruction, not an event.
|
|
{"name": "mode", "dtype": "str", "trigger": False},
|
|
{"name": "away", "dtype": "bool", "trigger": False},
|
|
{"name": "setpoint", "dtype": "float", "trigger": False},
|
|
],
|
|
"provides": [
|
|
{"name": "pv_watts", "dtype": "float"},
|
|
{"name": "load_watts", "dtype": "float"},
|
|
{"name": "grid_watts", "dtype": "float"},
|
|
{"name": "battery", "dtype": "float"},
|
|
{"name": "indoor", "dtype": "float"},
|
|
# The probe outside is worth reading once a minute; everything
|
|
# downstream of it is paced by this rather than by the tick.
|
|
{"name": "outdoor", "dtype": "float", "interval": 60},
|
|
{"name": "humidity", "dtype": "float"},
|
|
# Twice a day at most, so it is paced rather than sent every tick.
|
|
{"name": "panel_theme", "dtype": "str", "interval": 60},
|
|
],
|
|
},
|
|
{
|
|
"id": "to_kw",
|
|
"type": "change",
|
|
"title": "Watts to kilowatts",
|
|
"params": {"scale": 0.001, "round_to": 2},
|
|
"requires": [
|
|
{"name": "pv_watts", "dtype": "float"},
|
|
{"name": "load_watts", "dtype": "float"},
|
|
{"name": "grid_watts", "dtype": "float"},
|
|
],
|
|
"provides": [
|
|
{"name": "pv_kw", "dtype": "float"},
|
|
{"name": "load_kw", "dtype": "float"},
|
|
{"name": "grid_kw", "dtype": "float"},
|
|
],
|
|
},
|
|
{
|
|
"id": "meter",
|
|
"type": "python",
|
|
"title": "Read the meter",
|
|
"requires": [
|
|
{"name": "pv_kw", "dtype": "float"},
|
|
{"name": "load_kw", "dtype": "float"},
|
|
{"name": "grid_kw", "dtype": "float"},
|
|
{"name": "tariff", "dtype": "float", "trigger": False},
|
|
],
|
|
"provides": [
|
|
{"name": "cost_now", "dtype": "float"},
|
|
{"name": "self_use_kw", "dtype": "float"},
|
|
],
|
|
},
|
|
{
|
|
"id": "settle",
|
|
"type": "rbe",
|
|
"title": "Only when it moves",
|
|
"params": {"deadband": 0.1},
|
|
"requires": [{"name": "indoor", "dtype": "float"}],
|
|
"provides": [{"name": "indoor_stable", "dtype": "float"}],
|
|
},
|
|
{
|
|
"id": "climate",
|
|
"type": "join",
|
|
"title": "One climate reading",
|
|
# Synchronous by nature: every input has to be fresh, so this runs at
|
|
# the pace of the slowest of them — the minute-limited probe outside.
|
|
"params": {"mode": "object"},
|
|
"requires": [
|
|
{"name": "indoor_stable", "dtype": "float"},
|
|
{"name": "outdoor", "dtype": "float"},
|
|
{"name": "humidity", "dtype": "float"},
|
|
],
|
|
"provides": [{"name": "climate", "dtype": "record"}],
|
|
},
|
|
{
|
|
"id": "plan",
|
|
"type": "python",
|
|
"title": "The week ahead",
|
|
"requires": [
|
|
{"name": "day_start", "dtype": "float"},
|
|
{"name": "climate", "dtype": "record", "trigger": False},
|
|
],
|
|
"provides": [
|
|
{"name": "forecast", "dtype": "list", "item": "record"},
|
|
{"name": "agenda", "dtype": "list", "item": "record"},
|
|
],
|
|
},
|
|
{
|
|
"id": "water",
|
|
"type": "trigger",
|
|
"title": "Boost, then let it go",
|
|
# The whole shape of a trigger: on now, off again once things have been
|
|
# quiet for a quarter of an hour. A second press starts the wait over.
|
|
"params": {"first": True, "then": False, "wait": 900, "extend": True},
|
|
"requires": [{"name": "boost", "dtype": "bool"}],
|
|
"provides": [{"name": "hot_water", "dtype": "bool"}],
|
|
},
|
|
{
|
|
"id": "lamp",
|
|
"type": "python",
|
|
"title": "Name the lamp's colour",
|
|
"requires": [{"name": "light_color", "dtype": "list", "item": "float"}],
|
|
"provides": [{"name": "light_state", "dtype": "str"}],
|
|
},
|
|
{
|
|
"id": "water_label",
|
|
"type": "change",
|
|
"title": "Say it in words",
|
|
"params": {"mapping": {"True": "Heating water", "False": "Idle"}},
|
|
"requires": [{"name": "hot_water", "dtype": "bool"}],
|
|
"provides": [{"name": "water_state", "dtype": "str"}],
|
|
},
|
|
]
|
|
|
|
# -----------------------------------------------------------------------------
|
|
# home_history — the querying chart's other half
|
|
# -----------------------------------------------------------------------------
|
|
|
|
HISTORY_SOURCE = '''"""Answer a chart's question about the last however-long.
|
|
|
|
There is no database in this demo, so the window is computed the same way the
|
|
house's readings are — from the clock. The part worth looking at is the shape of
|
|
the exchange rather than where the numbers came from: the chart publishes the
|
|
window and the resolution it wants, this answers with a series, and the answer
|
|
carries the window it was computed for. A widget throws away an answer to a
|
|
different question, which is what makes the range picker trustworthy.
|
|
|
|
The target line comes from the *other* flow — ``home.setpoint``, the message the
|
|
slider on the panel publishes — so moving the slider redraws this chart too.
|
|
"""
|
|
|
|
import math
|
|
import time
|
|
|
|
|
|
def _outdoor(when):
|
|
"""The same curve the house node reads, evaluated anywhere in the past."""
|
|
clock = time.localtime(when)
|
|
hour = clock.tm_hour + clock.tm_min / 60.0 + clock.tm_sec / 3600.0
|
|
return (
|
|
11.5
|
|
+ 7.5 * math.sin((hour - 9.5) / 24.0 * 2 * math.pi)
|
|
+ 0.6 * math.sin(when / 5400.0 * 2 * math.pi)
|
|
)
|
|
|
|
|
|
def process(chart_request, setpoint=21.0):
|
|
span = int(chart_request["range_s"])
|
|
every = max(1, int(chart_request["interval_s"]))
|
|
now = int(time.time())
|
|
stamps = list(range(now - span, now + 1, every))
|
|
target = float(setpoint)
|
|
|
|
def line(label, at):
|
|
return {"label": label, "points": [[ts, round(at(ts), 2)] for ts in stamps]}
|
|
|
|
return {
|
|
"climate_series": {
|
|
# The echo the widget matches against its own request. Without it
|
|
# every answer looks like an answer to whatever was last asked.
|
|
"range_s": chart_request["range_s"],
|
|
"interval_s": chart_request["interval_s"],
|
|
"lines": [
|
|
line(
|
|
"Inside",
|
|
lambda ts: target - 0.3 + 0.4 * math.sin(ts / 5400.0 * 2 * math.pi),
|
|
),
|
|
line("Outside", _outdoor),
|
|
line("Target", lambda ts: target),
|
|
],
|
|
}
|
|
}
|
|
'''
|
|
|
|
HISTORY_INPUTS = [
|
|
# The request arrives from the panel, not from a node upstream. Saying so
|
|
# is what stops the canvas reporting this node as waiting on something
|
|
# nothing provides. The initial is the widget's own default window, so the
|
|
# flow has an answer ready before anyone opens the dashboard.
|
|
{
|
|
"spec": {"name": "chart_request", "dtype": "record"},
|
|
"initial": {"range_s": 3600, "interval_s": 60},
|
|
}
|
|
]
|
|
|
|
HISTORY_NODES = [
|
|
{
|
|
"id": "answer",
|
|
"type": "python",
|
|
"title": "Draw the window",
|
|
"requires": [
|
|
{"name": "chart_request", "dtype": "record"},
|
|
# Another flow's message, named in full. This is the only thing
|
|
# that makes the canvas draw the two flows as related.
|
|
{"name": msg(HOUSE, "setpoint"), "dtype": "float"},
|
|
],
|
|
"provides": [{"name": "climate_series", "dtype": "series"}],
|
|
}
|
|
]
|
|
|
|
# -----------------------------------------------------------------------------
|
|
# pv_model — the batch half
|
|
#
|
|
# The same flow `scripts/seed_demo_training.py` builds, dressed for this panel:
|
|
# a supervised fit of the array's yield against normalised irradiance. Nothing
|
|
# structural is different, because the structure is the demonstration — a batch
|
|
# flow, a streaming output, artifacts between the stages, and a node bound to a
|
|
# GPU worker that falls back to the engine when there is none.
|
|
# -----------------------------------------------------------------------------
|
|
|
|
PREPARE_SOURCE = '''"""Make the training set out of the logged readings, as an artifact.
|
|
|
|
Runs on the engine: no device, so it goes to the local worker pool. Only the
|
|
standard library, because the engine's venv is its own and this node has no
|
|
business asking it for anything.
|
|
|
|
The samples are a fortnight of the array's own history — irradiance as a
|
|
deviation from the mean, and what the inverter made of it.
|
|
"""
|
|
|
|
import json
|
|
import random
|
|
|
|
import fluksio
|
|
|
|
#: What the array is actually worth, and what the fit has to recover.
|
|
KW_PER_UNIT = 3.0
|
|
KW_AT_MEAN_LIGHT = 2.0
|
|
|
|
|
|
def process(seed, noise, samples):
|
|
rng = random.Random(int(seed))
|
|
rows = [
|
|
[x, KW_PER_UNIT * x + KW_AT_MEAN_LIGHT + rng.gauss(0.0, float(noise))]
|
|
for x in (rng.uniform(-1.0, 1.0) for _ in range(int(samples)))
|
|
]
|
|
payload = json.dumps(
|
|
{
|
|
"rows": rows,
|
|
"truth": {"slope": KW_PER_UNIT, "intercept": KW_AT_MEAN_LIGHT},
|
|
}
|
|
).encode()
|
|
# Data between stages is an artifact, not a message: the reference is what
|
|
# travels, and it stays valid on whichever machine opens it.
|
|
return {
|
|
"dataset": fluksio.save_artifact(payload, "readings.json", "application/json"),
|
|
"sample_count": len(rows),
|
|
}
|
|
'''
|
|
|
|
TRAIN_SOURCE = '''"""Fit the yield curve, publishing the loss as it goes.
|
|
|
|
A generator, so this node produces values over time: every ``yield`` is a dict
|
|
keyed by output port and is published the moment it happens, and what the
|
|
function returns at the end is the node's result. Nothing here logs anything —
|
|
the loss is an output of the graph, which is why a chart can bind to it.
|
|
|
|
Bound to ``device: gpu``. With a worker carrying that label attached it runs
|
|
there; without one it falls back to the engine, and ``trained_on`` says which
|
|
happened. numpy is used when the machine it landed on has it, which is how the
|
|
two environments tell themselves apart.
|
|
"""
|
|
|
|
import json
|
|
import platform
|
|
import time
|
|
|
|
import fluksio
|
|
|
|
try:
|
|
import numpy as np
|
|
except ImportError: # The engine's own venv has no numpy; a GPU box will.
|
|
np = None
|
|
|
|
|
|
def process(dataset, learning_rate, epochs, pace):
|
|
with open(fluksio.load_artifact(dataset)) as handle:
|
|
data = json.load(handle)
|
|
|
|
xs = [row[0] for row in data["rows"]]
|
|
ys = [row[1] for row in data["rows"]]
|
|
count = len(xs)
|
|
total = int(epochs)
|
|
rate = float(learning_rate)
|
|
slope, intercept = 0.0, 0.0
|
|
|
|
if np is not None:
|
|
axis_x, axis_y = np.array(xs), np.array(ys)
|
|
|
|
def report(epoch):
|
|
# Published from inside a helper, where a yield cannot reach — the
|
|
# shape a training framework's callback has. Same port, same checking.
|
|
fluksio.emit(progress=round(100.0 * (epoch + 1) / total, 1))
|
|
|
|
loss = 0.0
|
|
for epoch in range(total):
|
|
if np is not None:
|
|
error = (slope * axis_x + intercept) - axis_y
|
|
loss = float((error**2).mean())
|
|
slope -= rate * float((2 * error * axis_x).mean())
|
|
intercept -= rate * float((2 * error).mean())
|
|
else:
|
|
error = [slope * x + intercept - y for x, y in zip(xs, ys)]
|
|
loss = sum(e * e for e in error) / count
|
|
slope -= rate * sum(2 * e * x for e, x in zip(error, xs)) / count
|
|
intercept -= rate * sum(2 * e for e in error) / count
|
|
|
|
yield {"loss": round(loss, 6)}
|
|
report(epoch)
|
|
# Only so the curve is watchable; a real epoch takes as long as it takes.
|
|
if float(pace) > 0:
|
|
time.sleep(float(pace))
|
|
|
|
weights = json.dumps({"slope": slope, "intercept": intercept}).encode()
|
|
return {
|
|
"weights": fluksio.save_artifact(weights, "weights.json", "application/json"),
|
|
"final_loss": round(loss, 6),
|
|
"trained_on": "{} ({})".format(
|
|
platform.node(),
|
|
"numpy " + np.__version__ if np is not None else "pure python",
|
|
),
|
|
}
|
|
'''
|
|
|
|
EVALUATE_SOURCE = '''"""Score the fit, back on the engine.
|
|
|
|
Opens two artifacts: the readings this run prepared and the weights the
|
|
training produced — which may have been written on another machine entirely. A
|
|
reference names content, so where it came from does not matter.
|
|
"""
|
|
|
|
import json
|
|
|
|
import fluksio
|
|
|
|
|
|
def process(weights, dataset):
|
|
with open(fluksio.load_artifact(weights)) as handle:
|
|
fit = json.load(handle)
|
|
with open(fluksio.load_artifact(dataset)) as handle:
|
|
data = json.load(handle)
|
|
|
|
rows = data["rows"]
|
|
truth = data["truth"]
|
|
mean = sum(y for _x, y in rows) / len(rows)
|
|
total = sum((y - mean) ** 2 for _x, y in rows)
|
|
residual = sum((y - (fit["slope"] * x + fit["intercept"])) ** 2 for x, y in rows)
|
|
r2 = 1.0 - residual / total if total else 0.0
|
|
|
|
good = r2 > 0.9
|
|
return {
|
|
"accuracy": round(100.0 * max(0.0, r2), 2),
|
|
"report": {
|
|
"title": "{:.2f} kW per unit of light".format(fit["slope"]),
|
|
"body": (
|
|
"The array is worth {} kW per unit and {} kW at mean light. "
|
|
"R² = {:.4f} over {} readings.".format(
|
|
truth["slope"], truth["intercept"], r2, len(rows)
|
|
)
|
|
),
|
|
"severity": "info" if good else "warning",
|
|
},
|
|
}
|
|
'''
|
|
|
|
MODEL_NODES = [
|
|
{
|
|
"id": "prepare",
|
|
"type": "python",
|
|
"title": "Take the readings",
|
|
"requires": [
|
|
{"name": "seed", "dtype": "int"},
|
|
{"name": "noise", "dtype": "float"},
|
|
{"name": "samples", "dtype": "int"},
|
|
],
|
|
"provides": [
|
|
{"name": "dataset", "dtype": "artifact"},
|
|
{"name": "sample_count", "dtype": "int"},
|
|
],
|
|
},
|
|
{
|
|
"id": "train",
|
|
"type": "python",
|
|
"title": "Fit the curve",
|
|
# Runs on a worker carrying this label; falls back here without one,
|
|
# which is what makes the demo work before a GPU box is attached.
|
|
"device": "gpu",
|
|
"device_policy": "prefer",
|
|
# Generous, and an *idle* timeout: a node that keeps publishing keeps
|
|
# its deadline reset, so this is how long it may go quiet.
|
|
"timeout": 300,
|
|
# A setting: a constant of this node's own code, reaching `process` as
|
|
# an argument like the ports beside it. Only so a human can watch the
|
|
# curve arrive — set it to 0 in the node panel for a sweep.
|
|
"params": {"pace": 0.15},
|
|
"requires": [
|
|
{"name": "dataset", "dtype": "artifact"},
|
|
{"name": "learning_rate", "dtype": "float"},
|
|
{"name": "epochs", "dtype": "int"},
|
|
],
|
|
"provides": [
|
|
# The curve. `stream` says this port publishes repeatedly while the
|
|
# node runs, and is what makes a run keep every value it takes.
|
|
{"name": "loss", "dtype": "float", "stream": True},
|
|
{"name": "progress", "dtype": "float", "stream": True},
|
|
{"name": "weights", "dtype": "artifact"},
|
|
{"name": "final_loss", "dtype": "float"},
|
|
{"name": "trained_on", "dtype": "str"},
|
|
],
|
|
},
|
|
{
|
|
"id": "evaluate",
|
|
"type": "python",
|
|
"title": "Score it",
|
|
"requires": [
|
|
{"name": "weights", "dtype": "artifact"},
|
|
{"name": "dataset", "dtype": "artifact"},
|
|
],
|
|
"provides": [
|
|
{"name": "report", "dtype": "record"},
|
|
{"name": "accuracy", "dtype": "float"},
|
|
],
|
|
},
|
|
]
|
|
|
|
#: The run's parameters, with the values a run gets when it names none.
|
|
MODEL_INPUTS = [
|
|
{"spec": {"name": "seed", "dtype": "int"}, "initial": 1},
|
|
{"spec": {"name": "noise", "dtype": "float"}, "initial": 0.25},
|
|
{"spec": {"name": "samples", "dtype": "int"}, "initial": 200},
|
|
{"spec": {"name": "learning_rate", "dtype": "float"}, "initial": 0.2},
|
|
{"spec": {"name": "epochs", "dtype": "int"}, "initial": 40},
|
|
]
|
|
|
|
#: What a run reports as its result. Everything else the flow computed stays in
|
|
#: the run's own state and is dropped with it.
|
|
MODEL_OUTPUTS = ["report", "accuracy", "final_loss", "trained_on", "sample_count"]
|
|
|
|
|
|
# -----------------------------------------------------------------------------
|
|
# demo — the panel
|
|
# -----------------------------------------------------------------------------
|
|
|
|
#: Sixteen columns on a 27" landscape screen, said out loud rather than left to
|
|
#: the defaults: an arrangement belongs to the panel it will hang on.
|
|
COLUMNS = 16
|
|
CANVAS = (2560, 1600)
|
|
|
|
|
|
def at(x: int, y: int, w: int, h: int) -> dict[str, Any]:
|
|
"""Where a widget sits. Only ``lg`` is read; the client derives the rest.
|
|
|
|
Every widget says where it goes: the grid ignores placement entirely when
|
|
nothing has been placed, and lays the page out itself instead.
|
|
"""
|
|
return {"lg": {"x": x, "y": y, "w": w, "h": h}}
|
|
|
|
|
|
NOW_WIDGETS = [
|
|
{
|
|
"id": "wall",
|
|
"type": "clock",
|
|
"title": "",
|
|
"layout": at(0, 0, 3, 2),
|
|
# Binds nothing at all: it reads the wall, like a clock.
|
|
"config": {},
|
|
},
|
|
{
|
|
"id": "solar",
|
|
"type": "icon",
|
|
"title": "Solar",
|
|
"layout": at(3, 0, 2, 2),
|
|
"config": {
|
|
"message": msg(HOUSE, "pv_kw"),
|
|
"dtype": "float",
|
|
# A ladder, read top down: the first row whose threshold the
|
|
# reading has passed wins. Every row names itself in words as well,
|
|
# because a colour is not a label.
|
|
"rules": [
|
|
{"at": 4.0, "icon": "sun", "color": "primary", "label": "Strong"},
|
|
{"at": 1.5, "icon": "cloudy", "color": "default", "label": "Good"},
|
|
{"at": 0.2, "icon": "cloud-fog", "color": "muted", "label": "Weak"},
|
|
{"at": 0.0, "icon": "moon", "color": "muted", "label": "Dark"},
|
|
],
|
|
# Drawn when nothing has arrived yet, or nothing matched.
|
|
"icon": "house",
|
|
},
|
|
},
|
|
{
|
|
"id": "outside",
|
|
"type": "stat",
|
|
"title": "Outside",
|
|
"layout": at(5, 0, 3, 2),
|
|
"config": {
|
|
"message": msg(HOUSE, "outdoor"),
|
|
"dtype": "float",
|
|
"precision": 1,
|
|
"unit": "°C",
|
|
},
|
|
},
|
|
{
|
|
"id": "inside",
|
|
"type": "stat",
|
|
"title": "Inside",
|
|
"layout": at(8, 0, 3, 2),
|
|
"config": {
|
|
"message": msg(HOUSE, "indoor"),
|
|
"dtype": "float",
|
|
"precision": 1,
|
|
"unit": "°C",
|
|
},
|
|
},
|
|
{
|
|
"id": "damp",
|
|
"type": "stat",
|
|
"title": "Humidity",
|
|
"layout": at(11, 0, 2, 2),
|
|
"config": {
|
|
"message": msg(HOUSE, "humidity"),
|
|
"dtype": "float",
|
|
"precision": 0,
|
|
"unit": "%",
|
|
},
|
|
},
|
|
{
|
|
"id": "water",
|
|
"type": "stat",
|
|
"title": "Hot water",
|
|
"layout": at(13, 0, 3, 2),
|
|
"config": {"message": msg(HOUSE, "water_state"), "dtype": "str"},
|
|
},
|
|
{
|
|
"id": "week",
|
|
"type": "forecast",
|
|
"title": "The week",
|
|
"layout": at(0, 2, 6, 2),
|
|
"config": {"message": msg(HOUSE, "forecast"), "dtype": "list", "count": 5},
|
|
},
|
|
{
|
|
"id": "diary",
|
|
"type": "agenda",
|
|
"title": "Coming up",
|
|
"layout": at(6, 2, 5, 2),
|
|
"config": {"message": msg(HOUSE, "agenda"), "dtype": "list", "count": 4},
|
|
},
|
|
{
|
|
"id": "draw",
|
|
"type": "bar",
|
|
"title": "House draw, and the share the roof covered",
|
|
"layout": at(11, 2, 5, 2),
|
|
"config": {
|
|
"message": msg(HOUSE, "load_kw"),
|
|
"dtype": "float",
|
|
# A second reading nested inside the first, on the same scale, so
|
|
# containment is what the picture shows rather than something to
|
|
# work out. It only means anything because the flow computed a
|
|
# share: the roof's whole output is not part of the house's draw.
|
|
"inner": msg(HOUSE, "self_use_kw"),
|
|
"inner_dtype": "float",
|
|
"min": 0,
|
|
"max": 9,
|
|
"precision": 2,
|
|
"unit": " kW",
|
|
},
|
|
},
|
|
]
|
|
|
|
ENERGY_WIDGETS = [
|
|
{
|
|
"id": "power",
|
|
"type": "chart",
|
|
"title": "Power",
|
|
"layout": at(0, 0, 7, 4),
|
|
"config": {
|
|
"series": [
|
|
{"message": msg(HOUSE, "pv_kw"), "dtype": "float", "label": "Roof"},
|
|
{"message": msg(HOUSE, "load_kw"), "dtype": "float", "label": "House"},
|
|
{"message": msg(HOUSE, "grid_kw"), "dtype": "float", "label": "Grid"},
|
|
],
|
|
"history": {"points": 720},
|
|
"unit": " kW",
|
|
"y_label": "kW",
|
|
# Twelve hours of readings a minute apart: the shape is what is
|
|
# being read, not any single sample.
|
|
"smooth": True,
|
|
# Fixed, so the picture does not rescale under you every time the
|
|
# sun goes behind something. Negative is exporting.
|
|
"y_min": -7,
|
|
"y_max": 10,
|
|
},
|
|
},
|
|
{
|
|
"id": "climate",
|
|
"type": "chart",
|
|
"title": "Temperature",
|
|
"layout": at(7, 0, 6, 4),
|
|
"config": {
|
|
# The other kind of chart: it asks, rather than reading the ring
|
|
# the engine keeps. The range picker on the tile's title line is
|
|
# what publishes the request.
|
|
"source": "query",
|
|
"request": msg(HISTORY, "chart_request"),
|
|
"request_dtype": "record",
|
|
"message": msg(HISTORY, "climate_series"),
|
|
"dtype": "series",
|
|
"range_s": 3600,
|
|
"unit": "°C",
|
|
"y_label": "°C",
|
|
"y_min": 0,
|
|
"y_max": 32,
|
|
},
|
|
},
|
|
{
|
|
"id": "battery",
|
|
"type": "gauge",
|
|
"title": "Battery",
|
|
"layout": at(13, 0, 3, 4),
|
|
"config": {
|
|
"message": msg(HOUSE, "battery"),
|
|
"dtype": "float",
|
|
"min": 0,
|
|
"max": 100,
|
|
"precision": 0,
|
|
"unit": "%",
|
|
},
|
|
},
|
|
{
|
|
"id": "away",
|
|
"type": "switch",
|
|
"title": "Away",
|
|
"layout": at(0, 4, 2, 2),
|
|
# Drawn as a control that stays in rather than a track — the shape that
|
|
# reads across a room.
|
|
"config": {"target": msg(HOUSE, "away"), "dtype": "bool", "style": "button"},
|
|
},
|
|
{
|
|
"id": "mode",
|
|
"type": "dropdown",
|
|
"title": "Mode",
|
|
"layout": at(2, 4, 4, 2),
|
|
"config": {
|
|
"target": msg(HOUSE, "mode"),
|
|
"dtype": "str",
|
|
# Every choice on show at once, for the same reason.
|
|
"style": "segmented",
|
|
"options": [
|
|
{"label": "Eco", "value": "eco"},
|
|
{"label": "Comfort", "value": "comfort"},
|
|
{"label": "Boost", "value": "boost"},
|
|
],
|
|
},
|
|
},
|
|
{
|
|
"id": "target",
|
|
"type": "slider",
|
|
"title": "Target",
|
|
"layout": at(6, 4, 4, 2),
|
|
"config": {
|
|
"target": msg(HOUSE, "setpoint"),
|
|
"dtype": "float",
|
|
"min": 16,
|
|
"max": 25,
|
|
"step": 0.5,
|
|
"unit": "°C",
|
|
},
|
|
},
|
|
{
|
|
"id": "tariff",
|
|
"type": "input",
|
|
"title": "Tariff, ct/kWh",
|
|
"layout": at(10, 4, 2, 2),
|
|
"config": {"target": msg(HOUSE, "tariff"), "dtype": "float"},
|
|
},
|
|
{
|
|
"id": "spend",
|
|
"type": "stat",
|
|
# "Buying" rather than "Costing": while the roof covers the whole draw
|
|
# this reads zero, and zero is the answer rather than a broken tile.
|
|
"title": "Buying",
|
|
"layout": at(12, 4, 2, 2),
|
|
"config": {
|
|
"message": msg(HOUSE, "cost_now"),
|
|
"dtype": "float",
|
|
"precision": 2,
|
|
"unit": "€/h",
|
|
},
|
|
},
|
|
{
|
|
"id": "boost",
|
|
"type": "button",
|
|
"title": "Hot water",
|
|
"layout": at(14, 4, 2, 2),
|
|
"config": {
|
|
"target": msg(HOUSE, "boost"),
|
|
"dtype": "bool",
|
|
"value": True,
|
|
# What the button says, rather than the tile's own title.
|
|
"label": "Boost 15 min",
|
|
},
|
|
},
|
|
{
|
|
"id": "mood",
|
|
"type": "color",
|
|
"title": "Hallway lamp",
|
|
# Wider than it is tall, which is what the wheel lays itself out for:
|
|
# the ring takes the row's height and the two components sit beside it.
|
|
"layout": at(0, 6, 11, 2),
|
|
"config": {
|
|
"target": msg(HOUSE, "light_color"),
|
|
"dtype": "list",
|
|
# The default, said out loud: [h, s, v] as a DMX encoder reads it.
|
|
# "rgb" sends three 0-255 channels, "hex" a "#rrggbb" string.
|
|
"format": "hsv",
|
|
},
|
|
},
|
|
{
|
|
"id": "lamp",
|
|
"type": "stat",
|
|
"title": "Lamp",
|
|
"layout": at(11, 6, 5, 2),
|
|
# What the flow made of the colour — the proof that the wheel reached
|
|
# something, the way the hot-water button has its own state beside it.
|
|
"config": {"message": msg(HOUSE, "light_state"), "dtype": "str"},
|
|
},
|
|
]
|
|
|
|
MODEL_WIDGETS = [
|
|
{
|
|
"id": "loss",
|
|
"type": "chart",
|
|
"title": "Training loss",
|
|
"layout": at(0, 0, 6, 5),
|
|
"config": {
|
|
# Bound to the port the node yields on — the same binding a
|
|
# temperature uses. Nothing here knows what a metric is.
|
|
"series": [
|
|
{"message": msg(MODEL, "loss"), "dtype": "float", "label": "MSE"}
|
|
],
|
|
"history": {"points": 600},
|
|
"y_label": "MSE",
|
|
},
|
|
},
|
|
{
|
|
"id": "fit",
|
|
"type": "gauge",
|
|
"title": "Fit quality (R²)",
|
|
"layout": at(6, 0, 3, 3),
|
|
"config": {
|
|
"message": msg(MODEL, "accuracy"),
|
|
"dtype": "float",
|
|
"min": 0,
|
|
"max": 100,
|
|
"precision": 1,
|
|
"unit": "%",
|
|
},
|
|
},
|
|
{
|
|
"id": "final",
|
|
"type": "stat",
|
|
"title": "Final loss",
|
|
"layout": at(6, 3, 3, 2),
|
|
"config": {
|
|
"message": msg(MODEL, "final_loss"),
|
|
"dtype": "float",
|
|
"precision": 4,
|
|
},
|
|
},
|
|
{
|
|
"id": "engine",
|
|
"type": "notification",
|
|
"title": "Engine",
|
|
"layout": at(9, 0, 4, 3),
|
|
# Where the alerting configuration below delivers. Nothing on the panel
|
|
# polls for this: a failure is pushed here when it happens.
|
|
"config": {"message": msg(HOUSE, "engine_alert"), "dtype": "record"},
|
|
},
|
|
{
|
|
"id": "where",
|
|
"type": "stat",
|
|
"title": "Trained on",
|
|
"layout": at(9, 3, 4, 2),
|
|
"config": {"message": msg(MODEL, "trained_on"), "dtype": "str"},
|
|
},
|
|
{
|
|
"id": "samples",
|
|
"type": "stat",
|
|
"title": "Readings used",
|
|
"layout": at(13, 0, 3, 2),
|
|
"config": {"message": msg(MODEL, "sample_count"), "dtype": "int"},
|
|
},
|
|
{
|
|
"id": "how",
|
|
"type": "markdown",
|
|
"title": "",
|
|
"layout": at(13, 2, 3, 3),
|
|
"config": {
|
|
# Headings and bullets; the widget renders nothing inline.
|
|
"content": (
|
|
"## This panel\n"
|
|
"- Everything on it comes from three flows you can open"
|
|
" and edit.\n"
|
|
"- The controls publish into the graph; the readings answer.\n"
|
|
"- The temperature chart asks a flow for its window — no"
|
|
" database involved.\n"
|
|
"- Press Run on pv_model and watch the curve fill in."
|
|
)
|
|
},
|
|
},
|
|
]
|
|
|
|
PAGES = [
|
|
{
|
|
"id": "main",
|
|
"title": "House",
|
|
# Several sections on one page rather than several pages: a wall panel
|
|
# opens one URL and shows what is on it, and a second page would need
|
|
# someone standing there to click it.
|
|
"sections": [
|
|
{"id": "now", "title": "Right now", "widgets": NOW_WIDGETS},
|
|
{"id": "energy", "title": "Energy and comfort", "widgets": ENERGY_WIDGETS},
|
|
{"id": "model", "title": "Yield model", "widgets": MODEL_WIDGETS},
|
|
],
|
|
}
|
|
]
|
|
|
|
|
|
# -----------------------------------------------------------------------------
|
|
# The API
|
|
# -----------------------------------------------------------------------------
|
|
|
|
|
|
class Api:
|
|
def __init__(self) -> None:
|
|
self.http = httpx.Client(base_url=f"{API}/api/v1", timeout=60)
|
|
token = self.http.post(
|
|
"/login/access-token",
|
|
data={"username": EMAIL, "password": PASSWORD},
|
|
).json()["access_token"]
|
|
self.http.headers["Authorization"] = f"Bearer {token}"
|
|
|
|
def __call__(self, method: str, path: str, body: Any = None) -> Any:
|
|
response = self.http.request(method, path, json=body)
|
|
response.raise_for_status()
|
|
return response.json() if response.content else None
|
|
|
|
def drop(self, path: str) -> None:
|
|
"""Delete something that may not be there."""
|
|
try:
|
|
self("DELETE", path)
|
|
except httpx.HTTPStatusError:
|
|
pass
|
|
|
|
|
|
def seed_flow(
|
|
api: Api,
|
|
name: str,
|
|
title: str,
|
|
nodes: list,
|
|
sources: dict,
|
|
inputs: list | None = None,
|
|
mode: str = "live",
|
|
outputs: list | None = None,
|
|
) -> None:
|
|
"""Write a flow and publish it, replacing whatever was there before.
|
|
|
|
Deleting first is what makes this a reset rather than a merge: it takes the
|
|
flow's values and history with it, so the second run of this script leaves
|
|
exactly what the first one did.
|
|
"""
|
|
api.drop(f"/flows/{name}")
|
|
api(
|
|
"PUT",
|
|
f"/flows/{name}",
|
|
{
|
|
"name": name,
|
|
"title": title,
|
|
"mode": mode,
|
|
"outputs": outputs or [],
|
|
"nodes": nodes,
|
|
"inputs": inputs or [],
|
|
},
|
|
)
|
|
for node_id, code in sources.items():
|
|
api("PUT", f"/flows/{name}/nodes/{node_id}/source", {"code": code})
|
|
version = api("GET", f"/flows/{name}?draft=true")["definition"]["version"]
|
|
published = api("POST", f"/flows/{name}/publish", {"version": version})
|
|
print(f" flow '{name}': {len(nodes)} nodes, published")
|
|
for issue in published.get("issues") or []:
|
|
print(f" ! {issue['message']}")
|
|
|
|
|
|
def seed_dashboard(api: Api) -> None:
|
|
"""Write the panel and publish it — a wall reads only the published one."""
|
|
api.drop(f"/dashboards/{PANEL}")
|
|
api("POST", f"/dashboards/{PANEL}")
|
|
current = api("GET", f"/dashboards/{PANEL}?draft=true")
|
|
api(
|
|
"PUT",
|
|
f"/dashboards/{PANEL}",
|
|
{
|
|
**current,
|
|
"title": "Home",
|
|
"columns": COLUMNS,
|
|
"canvas_width": CANVAS[0],
|
|
"canvas_height": CANVAS[1],
|
|
# The settings channel: the dashboard itself as a receiver. Costs
|
|
# no tile, which is why it fits a panel with all 17 rows spoken
|
|
# for. `system` is the fallback until the flow first says
|
|
# otherwise; `locked` is left unset, which is the static default
|
|
# and keeps every control on the demo usable.
|
|
"settings": {
|
|
"theme": {
|
|
"value": "system",
|
|
"message": msg(HOUSE, "panel_theme"),
|
|
"dtype": "str",
|
|
},
|
|
# The same three slots a three-line chart would spread onto
|
|
# by itself, in the opposite order — so the Power chart reads
|
|
# just as clearly while the panel shows a palette that was
|
|
# chosen rather than one that was defaulted into. Naming none
|
|
# here would demonstrate nothing.
|
|
"palette": {"value": ["5", "3", "1"]},
|
|
},
|
|
"pages": PAGES,
|
|
},
|
|
)
|
|
version = api("GET", f"/dashboards/{PANEL}?draft=true")["version"]
|
|
api("POST", f"/dashboards/{PANEL}/publish", {"version": version})
|
|
|
|
count = sum(len(section["widgets"]) for section in PAGES[0]["sections"])
|
|
print(
|
|
f" dashboard '{PANEL}': {count} widgets in "
|
|
f"{len(PAGES[0]['sections'])} sections, published"
|
|
)
|
|
|
|
|
|
def wire_alerts(api: Api) -> None:
|
|
"""Point the engine's own failures at the notification tile.
|
|
|
|
Merged into whatever is already configured rather than replacing it: this
|
|
script owns one channel and one rule, and an operator's ntfy or mail
|
|
channel is none of its business.
|
|
"""
|
|
config = api("GET", "/alerts/config") or {}
|
|
channels = [c for c in config.get("channels") or [] if c["name"] != CHANNEL]
|
|
rules = [
|
|
r for r in config.get("rules") or [] if CHANNEL not in (r.get("channels") or [])
|
|
]
|
|
channels.append(
|
|
{
|
|
"name": CHANNEL,
|
|
"kind": "dashboard",
|
|
"enabled": True,
|
|
"config": {"message": msg(HOUSE, "engine_alert")},
|
|
}
|
|
)
|
|
# No event list: everything worth waking someone for goes to the panel.
|
|
rules.append({"events": [], "channels": [CHANNEL], "cooldown_s": 900})
|
|
api(
|
|
"PUT",
|
|
"/alerts/config",
|
|
{"enabled": True, "channels": channels, "rules": rules},
|
|
)
|
|
|
|
# The only way to prove the channel works is to use it — and the only way
|
|
# to leave the panel reading sensibly afterwards is to say so again.
|
|
api("POST", f"/alerts/test/{CHANNEL}")
|
|
api("POST", f"/messages/{msg(HOUSE, 'engine_alert')}", {"value": ALL_QUIET})
|
|
print(f" alerts: '{CHANNEL}' delivers to {msg(HOUSE, 'engine_alert')}, tested")
|
|
|
|
|
|
def run_the_model(api: Api) -> None:
|
|
"""Submit one run, so the panel has a curve and a score to show.
|
|
|
|
A batch flow computes nothing until something asks it to, and waiting for
|
|
it here is the difference between a training section and three empty tiles.
|
|
|
|
A run's messages live in a state of its own — that isolation is what lets
|
|
two runs of one flow go in parallel — and it expires with the run. The
|
|
panel reads the live namespace, so the finished run's result is put there
|
|
afterwards: the same numbers, where a wall can see them. Press Run again
|
|
and the tiles follow that one as it happens.
|
|
"""
|
|
run = api("POST", f"/runs/flows/{MODEL}", {"params": {}})
|
|
deadline = time.time() + 180
|
|
status = run["status"]
|
|
while status in ("queued", "running") and time.time() < deadline:
|
|
time.sleep(2)
|
|
status = api("GET", f"/runs/{run['id']}")["status"]
|
|
if status != "ok":
|
|
print(f" run {run['id']}: {status} — the model section will be empty")
|
|
return
|
|
|
|
result = api("GET", f"/runs/{run['id']}").get("result") or {}
|
|
for name in ("sample_count", "accuracy", "final_loss", "trained_on"):
|
|
if name in result:
|
|
api("POST", f"/messages/{msg(MODEL, name)}", {"value": result[name]})
|
|
|
|
# The curve is a streaming output, so the run kept every value it took.
|
|
curve = api("GET", f"/runs/{run['id']}/metrics?name={msg(MODEL, 'loss')}")
|
|
for point in curve:
|
|
api("POST", f"/messages/{msg(MODEL, 'loss')}", {"value": point["value"]})
|
|
# Spaced out, because the chart's axis is time: forty values inside one
|
|
# second draw the right curve under labels nobody can read.
|
|
time.sleep(0.2)
|
|
|
|
print(f" run {run['id']}: finished, {len(curve)} epochs on the panel")
|
|
|
|
|
|
def main() -> int:
|
|
if not EMAIL or not PASSWORD:
|
|
print(
|
|
"FIRST_SUPERUSER / FIRST_SUPERUSER_PASSWORD are unset. Either run\n"
|
|
" make -C app seed-hosted-demo\n"
|
|
"which reads them from app/.env, or export them yourself:\n"
|
|
' export FIRST_SUPERUSER="admin@example.com"\n'
|
|
' export FIRST_SUPERUSER_PASSWORD="..."\n'
|
|
' export API_URL="https://api.example.com" # optional',
|
|
file=sys.stderr,
|
|
)
|
|
return 1
|
|
|
|
print(f"Seeding the demo at {API}\n")
|
|
api = Api()
|
|
|
|
seed_flow(
|
|
api,
|
|
HOUSE,
|
|
"House",
|
|
HOUSE_NODES,
|
|
{
|
|
"house": HOUSE_SOURCE,
|
|
"meter": METER_SOURCE,
|
|
"plan": PLAN_SOURCE,
|
|
"lamp": LAMP_SOURCE,
|
|
},
|
|
inputs=HOUSE_INPUTS,
|
|
)
|
|
seed_flow(
|
|
api,
|
|
HISTORY,
|
|
"House history",
|
|
HISTORY_NODES,
|
|
{"answer": HISTORY_SOURCE},
|
|
inputs=HISTORY_INPUTS,
|
|
)
|
|
seed_flow(
|
|
api,
|
|
MODEL,
|
|
"PV yield model",
|
|
MODEL_NODES,
|
|
{
|
|
"prepare": PREPARE_SOURCE,
|
|
"train": TRAIN_SOURCE,
|
|
"evaluate": EVALUATE_SOURCE,
|
|
},
|
|
inputs=MODEL_INPUTS,
|
|
mode="batch",
|
|
outputs=MODEL_OUTPUTS,
|
|
)
|
|
|
|
seed_dashboard(api)
|
|
|
|
# The hot water is off until someone presses the button. Saying so is the
|
|
# difference between a tile reading "Idle" and one reading a dash.
|
|
api("POST", f"/messages/{msg(HOUSE, 'water_state')}", {"value": "Idle"})
|
|
|
|
wire_alerts(api)
|
|
run_the_model(api)
|
|
|
|
print(
|
|
f"\nOpen /view/{PANEL}.\n"
|
|
"The live chart fills in over the first few minutes; the forecast and\n"
|
|
"the agenda land within a couple of seconds. Every control on it\n"
|
|
"publishes for real — the tariff field changes what the meter costs,\n"
|
|
"the slider moves the target line on the temperature chart."
|
|
)
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|