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:
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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)]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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 : (
|
"false–true" 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>
|
||||||
|
|||||||
Reference in New Issue
Block a user