Record flags in history and draw them as steps

A bool was excluded from the history as "not a measurement", so a true/false
port had no curve in the node panel and none on an edge — only the word. It is
recorded as 0/1 now and drawn as steps, since a bezier through two states
slopes through readings that never happened. The axis is pinned to 0..1, so a
flag that was never on sits at the floor rather than mid-box.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-27 15:54:06 +02:00
co-authored by Claude Opus 5
parent 168aadb217
commit 6bc71ddaf3
7 changed files with 59 additions and 31 deletions
+1 -1
View File
@@ -837,7 +837,7 @@ def read_message_history(
"""The recent values of one message, for plotting. """The recent values of one message, for plotting.
``message`` may be given bare or qualified; a message that never carried a ``message`` may be given bare or qualified; a message that never carried a
number comes back with an empty series. number or a flag comes back with an empty series.
""" """
_read_flow(controller, name) _read_flow(controller, name)
key = qualify(name, message) key = qualify(name, message)
+1 -1
View File
@@ -102,7 +102,7 @@ async def publish_message(
@router.get("/{name}/history", response_model=MessagePoints) @router.get("/{name}/history", response_model=MessagePoints)
def read_message_history(name: str, controller: FlowControllerDep) -> Any: def read_message_history(name: str, controller: FlowControllerDep) -> Any:
"""The series behind a chart. Numbers only — nothing else plots.""" """The series behind a chart. Numbers and flags — nothing else plots."""
if controller.pipeline is None: if controller.pipeline is None:
return MessagePoints(message=name, numeric=False, points=[]) return MessagePoints(message=name, numeric=False, points=[])
series = controller.state.history(name) series = controller.state.history(name)
+10 -6
View File
@@ -24,16 +24,20 @@ import redis
#: not JSON at all, and a datetime serialises rather than raising. #: not JSON at all, and a datetime serialises rather than raising.
_JSON_OPTS = orjson.OPT_NON_STR_KEYS _JSON_OPTS = orjson.OPT_NON_STR_KEYS
# A sparkline only means something for numbers, so the history keeps the values # A sparkline only means something for a value that can be placed on an axis,
# it can plot and nothing else. 120 points fill a panel-wide chart while leaving # so the history keeps those and nothing else. 120 points fill a panel-wide
# Redis a cache rather than a time-series database. # chart while leaving Redis a cache rather than a time-series database.
HISTORY_LIMIT = 120 HISTORY_LIMIT = 120
def as_number(value: Any) -> float | None: def as_number(value: Any) -> float | None:
"""The plottable form of a value, or None if it is not a number.""" """The plottable form of a value, or None if it cannot be placed on an axis."""
# bool is an int subclass; a flag is not a measurement. # A flag is not a measurement, but when it was on is worth seeing, and 0/1
if isinstance(value, bool) or not isinstance(value, (int, float)): # is what a step curve is drawn from. bool is an int subclass, so it would
# fall through the isinstance below either way.
if isinstance(value, bool):
return 1.0 if value else 0.0
if not isinstance(value, (int, float)):
return None return None
return float(value) return float(value)
+7 -3
View File
@@ -45,12 +45,16 @@ def test_the_series_is_capped():
assert points[-1] == (139.0, 139.0) assert points[-1] == (139.0, 139.0)
def test_only_numbers_are_recorded(): def test_numbers_and_flags_are_recorded_and_text_is_not():
"""A flag plots as the step between 0 and 1; text has no axis to sit on."""
state = MemoryState() state = MemoryState()
state.append_history({"f.text": "warm", "f.flag": True, "f.temp": 21}, ts=1.0) state.append_history(
{"f.text": "warm", "f.flag": True, "f.off": False, "f.temp": 21}, ts=1.0
)
assert state.history("f.text") == [] assert state.history("f.text") == []
assert state.history("f.flag") == [] assert state.history("f.flag") == [(1.0, 1.0)]
assert state.history("f.off") == [(1.0, 0.0)]
assert state.history("f.temp") == [(1.0, 21.0)] assert state.history("f.temp") == [(1.0, 21.0)]
+27 -12
View File
@@ -28,15 +28,17 @@ const READOUT_DIGITS = 4
* that never moved has no span to divide by, and one spanning decades is only * that never moved has no span to divide by, and one spanning decades is only
* legible once the exponent is what varies. * legible once the exponent is what varies.
*/ */
function shape(points: HistoryPoint[]) { function shape(points: HistoryPoint[], step = false) {
const values = points.map((point) => point.value) const values = points.map((point) => point.value)
const low = Math.min(...values) const low = Math.min(...values)
const high = Math.max(...values) const high = Math.max(...values)
// Logs need every reading on the same side of zero. // Logs need every reading on the same side of zero.
const logged = low > 0 && high / low >= LOG_RATIO const logged = !step && low > 0 && high / low >= LOG_RATIO
const project = (value: number) => (logged ? Math.log10(value) : value) const project = (value: number) => (logged ? Math.log10(value) : value)
const floor = project(low) // A flag's axis is the two values it has, so one that was never on sits at
const span = project(high) - floor // the floor rather than in the middle of the box the way a flat curve does.
const floor = step ? 0 : project(low)
const span = step ? 1 : project(high) - floor
const y = (value: number) => const y = (value: number) =>
span === 0 span === 0
@@ -46,10 +48,15 @@ function shape(points: HistoryPoint[]) {
points.length === 1 ? 100 : (index / (points.length - 1)) * 100 points.length === 1 ? 100 : (index / (points.length - 1)) * 100
const line = points const line = points
.map( .map((point, index) => {
(point, index) => const at = `${x(index).toFixed(2)},${y(point.value).toFixed(2)}`
`${index ? "L" : "M"}${x(index).toFixed(2)},${y(point.value).toFixed(2)}`, if (!index) return `M${at}`
) // A flag holds its last value until it changes, so it turns a corner
// rather than sloping through readings it never took.
return step
? `L${x(index).toFixed(2)},${y(points[index - 1].value).toFixed(2)} L${at}`
: `L${at}`
})
.join(" ") .join(" ")
return { return {
@@ -79,6 +86,7 @@ export function Sparkline({
height = "h-8", height = "h-8",
dot = true, dot = true,
readout = true, readout = true,
bool = false,
}: { }: {
points: HistoryPoint[] points: HistoryPoint[]
/** The token the curve, its area and the dot are drawn in. */ /** The token the curve, its area and the dot are drawn in. */
@@ -89,9 +97,11 @@ export function Sparkline({
dot?: boolean dot?: boolean
/** The current value and the range, beside the curve. */ /** The current value and the range, beside the curve. */
readout?: boolean readout?: boolean
/** A flag: drawn as steps between 0 and 1, and read as true/false. */
bool?: boolean
}) { }) {
const id = useId() const id = useId()
const { low, high, line, area, end } = shape(points) const { low, high, line, area, end } = shape(points, bool)
return ( return (
// The gap leaves the live dot room to sit on the last reading without // The gap leaves the live dot room to sit on the last reading without
@@ -181,10 +191,15 @@ export function Sparkline({
{readout ? ( {readout ? (
<div className="min-w-24 shrink-0 whitespace-nowrap text-right font-mono text-xs leading-tight"> <div className="min-w-24 shrink-0 whitespace-nowrap text-right font-mono text-xs leading-tight">
<div className="font-medium"> <div className="font-medium">
{si(points[points.length - 1].value, READOUT_DIGITS)} {bool
? points[points.length - 1].value
? "true"
: "false"
: si(points[points.length - 1].value, READOUT_DIGITS)}
</div> </div>
{/* A series that never moved has no range worth repeating. */} {/* A series that never moved has no range worth repeating, and
{low === high ? null : ( "falsetrue" is not a range anyone reads. */}
{low === high || bool ? null : (
<div className="text-muted-foreground"> <div className="text-muted-foreground">
{si(low, READOUT_DIGITS)}{si(high, READOUT_DIGITS)} {si(low, READOUT_DIGITS)}{si(high, READOUT_DIGITS)}
</div> </div>
@@ -132,11 +132,11 @@ export function EdgeInspector({
</div> </div>
{/* The same curve the node panel draws for this message: one value {/* The same curve the node panel draws for this message: one value
says little, how it has been moving says the rest. Only for a says little, how it has been moving says the rest. Only for what is
number, because only numbers are recorded — for anything else the recorded — a number, or a flag as the steps between 0 and 1. For
sparkline falls through to its own copy of the value, which the anything else the sparkline falls through to its own copy of the
popover already shows below. */} value, which the popover already shows below. */}
{typeof live?.value === "number" ? ( {typeof live?.value === "number" || typeof live?.value === "boolean" ? (
<div className="mt-2"> <div className="mt-2">
<MessageSparkline <MessageSparkline
flow={flow} flow={flow}
@@ -83,11 +83,13 @@ export function MessageSparkline({
const ts = live?.ts const ts = live?.ts
const value = live?.value const value = live?.value
useEffect(() => { useEffect(() => {
if (typeof value !== "number" || !ts) return // A flag is recorded as 0/1, the way the server keeps it.
if ((typeof value !== "number" && typeof value !== "boolean") || !ts) return
const reading = Number(value)
setTail((previous) => setTail((previous) =>
previous[previous.length - 1]?.ts === ts previous[previous.length - 1]?.ts === ts
? previous ? previous
: [...previous, { ts, value }].slice(-WINDOW), : [...previous, { ts, value: reading }].slice(-WINDOW),
) )
}, [ts, value]) }, [ts, value])
@@ -127,7 +129,10 @@ export function MessageSparkline({
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<div> <div>
<Sparkline points={points} /> <Sparkline
points={points}
bool={dtype === "bool" || typeof live?.value === "boolean"}
/>
</div> </div>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent>{caption(points)}</TooltipContent> <TooltipContent>{caption(points)}</TooltipContent>