split table into measurements and records, add units to value display
This commit is contained in:
@@ -19,6 +19,14 @@ import numpy as np
|
||||
COLORMAPS = ("viridis", "gray", "hot", "jet", "plasma", "inferno", "terrain",
|
||||
"cividis", "magma", "copper", "afmhot")
|
||||
|
||||
|
||||
class RecordTable(list):
|
||||
"""Tabular rows with a shared schema, e.g. particle statistics."""
|
||||
|
||||
|
||||
class MeasureTable(list):
|
||||
"""Named scalar measurements, typically rows of quantity/value/unit."""
|
||||
|
||||
@dataclass
|
||||
class DataField:
|
||||
data: np.ndarray # shape (yres, xres), dtype float64
|
||||
|
||||
@@ -50,7 +50,7 @@ class ExecutionEngine:
|
||||
on_table: Callable[[str, list], None] | None = None,
|
||||
on_mesh: Callable[[str, dict], None] | None = None,
|
||||
on_overlay: Callable[[str, str], None] | None = None,
|
||||
on_value: Callable[[str, float], None] | None = None,
|
||||
on_value: Callable[[str, Any], None] | None = None,
|
||||
on_warning: Callable[[str, str], None] | None = None,
|
||||
) -> dict[str, tuple]:
|
||||
"""
|
||||
@@ -64,6 +64,7 @@ class ExecutionEngine:
|
||||
on_preview : called with (node_id, data_uri) when a display node runs
|
||||
on_table : called with (node_id, table_list) when PrintTable runs
|
||||
on_overlay : called with (node_id, data_uri) for interactive overlays
|
||||
on_value : called with (node_id, scalar-payload) for scalar displays
|
||||
on_warning : called with (node_id, message) for node warnings
|
||||
|
||||
Returns
|
||||
@@ -104,7 +105,7 @@ class ExecutionEngine:
|
||||
node_outputs[node_id] = result
|
||||
|
||||
# Auto-preview: broadcast a thumbnail for any DATA_FIELD,
|
||||
# IMAGE, or TABLE output so every node shows its result.
|
||||
# IMAGE, or table-like output so every node shows its result.
|
||||
if on_preview or on_table:
|
||||
self._auto_preview(cls, node_id, result, on_preview, on_table)
|
||||
|
||||
@@ -226,7 +227,7 @@ class ExecutionEngine:
|
||||
) -> None:
|
||||
"""
|
||||
After every node executes, inspect its outputs and broadcast
|
||||
a preview for the first DATA_FIELD, IMAGE, or TABLE found.
|
||||
a preview for the first DATA_FIELD, IMAGE, or table-like output found.
|
||||
Skip nodes that broadcast their own custom preview.
|
||||
"""
|
||||
import numpy as np
|
||||
@@ -260,7 +261,7 @@ class ExecutionEngine:
|
||||
on_preview(node_id, preview)
|
||||
return
|
||||
|
||||
if type_name == "TABLE" and isinstance(value, list) and on_table:
|
||||
if type_name in ("TABLE", "MEASURE_TABLE", "RECORD_TABLE") and isinstance(value, list) and on_table:
|
||||
on_table(node_id, value)
|
||||
return
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ from __future__ import annotations
|
||||
import numpy as np
|
||||
from typing import Callable
|
||||
from backend.node_registry import register_node
|
||||
from backend.data_types import DataField, datafield_to_uint8, encode_preview
|
||||
from backend.data_types import DataField, MeasureTable, RecordTable, datafield_to_uint8, encode_preview
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -29,7 +29,7 @@ class StatisticsNode:
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("TABLE",)
|
||||
RETURN_TYPES = ("MEASURE_TABLE",)
|
||||
RETURN_NAMES = ("stats",)
|
||||
FUNCTION = "process"
|
||||
CATEGORY = "analysis"
|
||||
@@ -45,7 +45,7 @@ class StatisticsNode:
|
||||
skewness = float(np.mean(((d - mean) / rms) ** 3)) if rms > 0 else 0.0
|
||||
kurtosis = float(np.mean(((d - mean) / rms) ** 4)) if rms > 0 else 0.0
|
||||
|
||||
table = [
|
||||
table = MeasureTable([
|
||||
{"quantity": "min", "value": float(d.min()), "unit": field.si_unit_z},
|
||||
{"quantity": "max", "value": float(d.max()), "unit": field.si_unit_z},
|
||||
{"quantity": "mean", "value": mean, "unit": field.si_unit_z},
|
||||
@@ -54,7 +54,7 @@ class StatisticsNode:
|
||||
{"quantity": "skewness", "value": skewness, "unit": ""},
|
||||
{"quantity": "kurtosis", "value": kurtosis, "unit": ""},
|
||||
{"quantity": "range", "value": float(d.max() - d.min()), "unit": field.si_unit_z},
|
||||
]
|
||||
])
|
||||
return (table,)
|
||||
|
||||
|
||||
@@ -78,7 +78,7 @@ class HeightHistogram:
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("TABLE",)
|
||||
RETURN_TYPES = ("MEASURE_TABLE",)
|
||||
RETURN_NAMES = ("measurements",)
|
||||
FUNCTION = "process"
|
||||
CATEGORY = "analysis"
|
||||
@@ -147,14 +147,14 @@ class HeightHistogram:
|
||||
},
|
||||
)
|
||||
|
||||
table = [
|
||||
table = MeasureTable([
|
||||
{"quantity": "A position", "value": xa, "unit": field.si_unit_z},
|
||||
{"quantity": "A count", "value": ya, "unit": count_unit},
|
||||
{"quantity": "B position", "value": xb, "unit": field.si_unit_z},
|
||||
{"quantity": "B count", "value": yb, "unit": count_unit},
|
||||
{"quantity": "delta X", "value": xb - xa, "unit": field.si_unit_z},
|
||||
{"quantity": "delta Y", "value": yb - ya, "unit": count_unit},
|
||||
]
|
||||
])
|
||||
return (table,)
|
||||
|
||||
|
||||
@@ -181,7 +181,7 @@ class LineCursors:
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("TABLE",)
|
||||
RETURN_TYPES = ("MEASURE_TABLE",)
|
||||
RETURN_NAMES = ("measurement",)
|
||||
FUNCTION = "process"
|
||||
CATEGORY = "analysis"
|
||||
@@ -242,14 +242,14 @@ class LineCursors:
|
||||
)
|
||||
|
||||
# --- Output table ---
|
||||
table = [
|
||||
table = MeasureTable([
|
||||
{"quantity": "A position", "value": xa, "unit": ""},
|
||||
{"quantity": "A value", "value": ya, "unit": ""},
|
||||
{"quantity": "B position", "value": xb, "unit": ""},
|
||||
{"quantity": "B value", "value": yb, "unit": ""},
|
||||
{"quantity": "delta X", "value": xb - xa, "unit": ""},
|
||||
{"quantity": "delta Y", "value": yb - ya, "unit": ""},
|
||||
]
|
||||
])
|
||||
return (table,)
|
||||
|
||||
|
||||
@@ -614,7 +614,7 @@ class LineMath:
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("TABLE",)
|
||||
RETURN_TYPES = ("MEASURE_TABLE",)
|
||||
RETURN_NAMES = ("result",)
|
||||
FUNCTION = "process"
|
||||
CATEGORY = "analysis"
|
||||
@@ -627,12 +627,12 @@ class LineMath:
|
||||
z = np.asarray(line, dtype=np.float64).ravel()
|
||||
fn, unit = LINE_OPS[operation]
|
||||
value = fn(z)
|
||||
table = [{"quantity": operation, "value": value, "unit": unit}]
|
||||
table = MeasureTable([{"quantity": operation, "value": value, "unit": unit}])
|
||||
return (table,)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TableMath — scalar measurement from a numeric TABLE column
|
||||
# TableMath — scalar measurement from a numeric record-table column
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
TABLE_OPS: dict[str, Callable[[np.ndarray], float]] = {
|
||||
@@ -663,9 +663,62 @@ ARRAY_OPS: dict[str, Callable[[np.ndarray], float]] = {
|
||||
}
|
||||
|
||||
|
||||
def _square_unit(unit: str) -> str:
|
||||
unit = str(unit or "").strip()
|
||||
if not unit:
|
||||
return ""
|
||||
if any(token in unit for token in ("^", "(", ")", "/", "*", " ")):
|
||||
return f"({unit})^2"
|
||||
return f"{unit}^2"
|
||||
|
||||
|
||||
def _apply_scalar_unit(base_unit: str, operation: str) -> str:
|
||||
unit = str(base_unit or "").strip()
|
||||
if operation == "count":
|
||||
return "count"
|
||||
if not unit:
|
||||
return ""
|
||||
if operation == "variance":
|
||||
return _square_unit(unit)
|
||||
return unit
|
||||
|
||||
|
||||
def _common_table_unit(table: list, column: str) -> str:
|
||||
candidates = []
|
||||
seen = set()
|
||||
unit_key = f"{column}_unit"
|
||||
|
||||
for row in table:
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
unit = None
|
||||
if unit_key in row and isinstance(row.get(unit_key), str):
|
||||
unit = row.get(unit_key)
|
||||
elif column == "value" and isinstance(row.get("unit"), str):
|
||||
unit = row.get("unit")
|
||||
if unit is None:
|
||||
continue
|
||||
unit = unit.strip()
|
||||
if not unit or unit in seen:
|
||||
continue
|
||||
seen.add(unit)
|
||||
candidates.append(unit)
|
||||
|
||||
if len(candidates) == 1:
|
||||
return candidates[0]
|
||||
return ""
|
||||
|
||||
|
||||
def _scalar_payload(value: float, unit: str = "") -> dict:
|
||||
payload = {"value": float(value)}
|
||||
if isinstance(unit, str) and unit.strip():
|
||||
payload["unit"] = unit.strip()
|
||||
return payload
|
||||
|
||||
|
||||
@register_node(display_name="Table Math")
|
||||
class TableMath:
|
||||
"""Compute a scalar reduction over one numeric column in a TABLE."""
|
||||
"""Compute a scalar reduction over one numeric column in a record table."""
|
||||
|
||||
_broadcast_value_fn = None
|
||||
_current_node_id: str = ""
|
||||
@@ -674,7 +727,7 @@ class TableMath:
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"table": ("TABLE",),
|
||||
"table": ("RECORD_TABLE",),
|
||||
"column": ("STRING", {
|
||||
"default": "value",
|
||||
"choices_from_table_input": "table",
|
||||
@@ -688,13 +741,15 @@ class TableMath:
|
||||
FUNCTION = "process"
|
||||
CATEGORY = "analysis"
|
||||
DESCRIPTION = (
|
||||
"Compute a scalar reduction over one numeric TABLE column. "
|
||||
"Compute a scalar reduction over one numeric record-table column. "
|
||||
"Useful for max, min, avg, median, sum, range, std, variance, and count."
|
||||
)
|
||||
|
||||
def process(self, table: list, column: str, operation: str) -> tuple:
|
||||
if isinstance(table, MeasureTable):
|
||||
raise ValueError("Table Math only accepts record tables, not measurement tables.")
|
||||
if not isinstance(table, list) or not table:
|
||||
raise ValueError("Table Math requires a non-empty TABLE input.")
|
||||
raise ValueError("Table Math requires a non-empty record table input.")
|
||||
|
||||
column_name = resolve_table_column_name(table, column)
|
||||
values = extract_numeric_table_values(table, column_name)
|
||||
@@ -759,7 +814,7 @@ def resolve_table_column_name(table: list, column: str) -> str:
|
||||
|
||||
@register_node(display_name="Stats")
|
||||
class Stats:
|
||||
"""Polymorphic scalar stats node for LINE, TABLE, DATA_FIELD, or IMAGE inputs."""
|
||||
"""Polymorphic scalar stats node for LINE, RECORD_TABLE, DATA_FIELD, or IMAGE inputs."""
|
||||
|
||||
_broadcast_value_fn = None
|
||||
_current_node_id: str = ""
|
||||
@@ -773,14 +828,14 @@ class Stats:
|
||||
"default": "value",
|
||||
"choices_from_table_input": "input",
|
||||
"show_when_source_type": {
|
||||
"input": ["TABLE"],
|
||||
"input": ["RECORD_TABLE"],
|
||||
},
|
||||
}),
|
||||
"operation": ("STRING", {
|
||||
"default": "mean",
|
||||
"choices_by_source_type": {
|
||||
"LINE": list(LINE_OPS.keys()),
|
||||
"TABLE": list(TABLE_OPS.keys()),
|
||||
"RECORD_TABLE": list(TABLE_OPS.keys()),
|
||||
"DATA_FIELD": list(ARRAY_OPS.keys()),
|
||||
"IMAGE": list(ARRAY_OPS.keys()),
|
||||
},
|
||||
@@ -794,14 +849,14 @@ class Stats:
|
||||
FUNCTION = "process"
|
||||
CATEGORY = "analysis"
|
||||
DESCRIPTION = (
|
||||
"Compute a contextual scalar statistic from a LINE, TABLE, DATA_FIELD, or IMAGE. "
|
||||
"Compute a contextual scalar statistic from a LINE, record table, DATA_FIELD, or IMAGE. "
|
||||
"The available operations adapt to the connected input type."
|
||||
)
|
||||
|
||||
def process(self, input, operation: str, column: str = "value") -> tuple:
|
||||
source_type, values = self._resolve_input_values(input, column)
|
||||
source_type, values, resolved_column = self._resolve_input_values(input, column)
|
||||
|
||||
if source_type == "TABLE":
|
||||
if source_type == "RECORD_TABLE":
|
||||
ops = TABLE_OPS
|
||||
elif source_type == "LINE":
|
||||
ops = LINE_OPS
|
||||
@@ -815,29 +870,49 @@ class Stats:
|
||||
fn = op_entry[0] if isinstance(op_entry, tuple) else op_entry
|
||||
result = fn(values)
|
||||
if Stats._broadcast_value_fn is not None:
|
||||
Stats._broadcast_value_fn(Stats._current_node_id, result)
|
||||
Stats._broadcast_value_fn(
|
||||
Stats._current_node_id,
|
||||
_scalar_payload(result, self._resolve_output_unit(input, source_type, resolved_column, operation)),
|
||||
)
|
||||
return (result,)
|
||||
|
||||
def _resolve_input_values(self, input_value, column: str) -> tuple[str, np.ndarray]:
|
||||
def _resolve_output_unit(self, input_value, source_type: str, column: str | None, operation: str) -> str:
|
||||
if source_type == "DATA_FIELD" and isinstance(input_value, DataField):
|
||||
return _apply_scalar_unit(input_value.si_unit_z, operation)
|
||||
|
||||
if source_type == "LINE":
|
||||
line_entry = LINE_OPS.get(operation)
|
||||
explicit_unit = line_entry[1] if isinstance(line_entry, tuple) and len(line_entry) > 1 else ""
|
||||
return _apply_scalar_unit(explicit_unit, operation)
|
||||
|
||||
if source_type == "RECORD_TABLE" and isinstance(input_value, list) and column:
|
||||
return _apply_scalar_unit(_common_table_unit(input_value, column), operation)
|
||||
|
||||
return ""
|
||||
|
||||
def _resolve_input_values(self, input_value, column: str) -> tuple[str, np.ndarray, str | None]:
|
||||
if isinstance(input_value, DataField):
|
||||
values = np.asarray(input_value.data, dtype=np.float64)
|
||||
return ("DATA_FIELD", values.ravel())
|
||||
return ("DATA_FIELD", values.ravel(), None)
|
||||
|
||||
if isinstance(input_value, MeasureTable):
|
||||
raise ValueError("Stats only accepts record tables, not measurement tables.")
|
||||
|
||||
if isinstance(input_value, list):
|
||||
if not input_value:
|
||||
raise ValueError("Stats requires a non-empty TABLE input.")
|
||||
raise ValueError("Stats requires a non-empty record table input.")
|
||||
column_name = resolve_table_column_name(input_value, column)
|
||||
values = extract_numeric_table_values(input_value, column_name)
|
||||
if not values:
|
||||
raise ValueError(f"Column '{column_name}' has no numeric values.")
|
||||
return ("TABLE", np.asarray(values, dtype=np.float64))
|
||||
return ("RECORD_TABLE", np.asarray(values, dtype=np.float64), column_name)
|
||||
|
||||
if isinstance(input_value, np.ndarray):
|
||||
values = np.asarray(input_value, dtype=np.float64)
|
||||
if values.size == 0:
|
||||
raise ValueError("Stats requires a non-empty input.")
|
||||
if values.ndim == 1:
|
||||
return ("LINE", values.ravel())
|
||||
return ("IMAGE", values.ravel())
|
||||
return ("LINE", values.ravel(), None)
|
||||
return ("IMAGE", values.ravel(), None)
|
||||
|
||||
raise ValueError(f"Unsupported Stats input type: {type(input_value).__name__}")
|
||||
|
||||
@@ -10,10 +10,55 @@ from __future__ import annotations
|
||||
import numpy as np
|
||||
from backend.node_registry import register_node
|
||||
from backend.data_types import (
|
||||
DataField, COLORMAPS, datafield_to_uint8, image_to_uint8, encode_preview, normalize_for_colormap,
|
||||
DataField, MeasureTable, COLORMAPS, datafield_to_uint8, image_to_uint8, encode_preview, normalize_for_colormap,
|
||||
)
|
||||
|
||||
|
||||
def _measurement_names(table: list) -> list[str]:
|
||||
names = []
|
||||
for row in table:
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
quantity = row.get("quantity")
|
||||
if isinstance(quantity, str) and quantity and quantity not in names:
|
||||
names.append(quantity)
|
||||
return names
|
||||
|
||||
|
||||
def _measurement_entry(table: list, selection: str) -> dict:
|
||||
names = _measurement_names(table)
|
||||
if not names:
|
||||
raise ValueError("Measurement table has no selectable rows.")
|
||||
|
||||
target = selection if selection in names else names[0]
|
||||
for row in table:
|
||||
if isinstance(row, dict) and row.get("quantity") == target:
|
||||
return row
|
||||
|
||||
raise ValueError(f"Measurement '{target}' was not found.")
|
||||
|
||||
|
||||
def _measurement_value(table: list, selection: str) -> float:
|
||||
row = _measurement_entry(table, selection)
|
||||
value = row.get("value")
|
||||
if isinstance(value, bool):
|
||||
raise ValueError(f"Measurement '{row.get('quantity', selection)}' does not have a numeric value.")
|
||||
try:
|
||||
numeric = float(value)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ValueError(f"Measurement '{row.get('quantity', selection)}' does not have a numeric value.") from exc
|
||||
if np.isfinite(numeric):
|
||||
return numeric
|
||||
raise ValueError(f"Measurement '{row.get('quantity', selection)}' does not have a numeric value.")
|
||||
|
||||
|
||||
def _scalar_payload(value: float, unit: str = "") -> dict:
|
||||
payload = {"value": float(value)}
|
||||
if isinstance(unit, str) and unit.strip():
|
||||
payload["unit"] = unit.strip()
|
||||
return payload
|
||||
|
||||
|
||||
@register_node(display_name="Preview")
|
||||
class PreviewImage:
|
||||
@classmethod
|
||||
@@ -156,7 +201,7 @@ class PrintTable:
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"table": ("TABLE",),
|
||||
"table": ("ANY_TABLE",),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -164,7 +209,7 @@ class PrintTable:
|
||||
FUNCTION = "print_table"
|
||||
CATEGORY = "display"
|
||||
OUTPUT_NODE = True
|
||||
DESCRIPTION = "Send a TABLE to the browser as a WebSocket message for display."
|
||||
DESCRIPTION = "Send a measurement or record table to the browser as a WebSocket message for display."
|
||||
|
||||
_broadcast_table_fn = None
|
||||
_current_node_id: str = ""
|
||||
@@ -181,7 +226,14 @@ class ValueDisplay:
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"value": ("FLOAT",),
|
||||
"value": ("VALUE_SOURCE",),
|
||||
"measurement": ("STRING", {
|
||||
"default": "",
|
||||
"choices_from_measure_input": "value",
|
||||
"show_when_source_type": {
|
||||
"value": ["MEASURE_TABLE"],
|
||||
},
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -189,13 +241,19 @@ class ValueDisplay:
|
||||
RETURN_NAMES = ("value",)
|
||||
FUNCTION = "display_value"
|
||||
CATEGORY = "display"
|
||||
DESCRIPTION = "Display a FLOAT in the graph and pass the same value through unchanged."
|
||||
DESCRIPTION = "Display a FLOAT, or a selected numeric row from a measurement table, and pass the value through unchanged."
|
||||
|
||||
_broadcast_value_fn = None
|
||||
_current_node_id: str = ""
|
||||
|
||||
def display_value(self, value: float) -> tuple:
|
||||
numeric = float(value)
|
||||
def display_value(self, value, measurement: str = "") -> tuple:
|
||||
unit = ""
|
||||
if isinstance(value, MeasureTable):
|
||||
row = _measurement_entry(value, measurement)
|
||||
numeric = _measurement_value(value, measurement)
|
||||
unit = row.get("unit", "") if isinstance(row.get("unit"), str) else ""
|
||||
else:
|
||||
numeric = float(value)
|
||||
if ValueDisplay._broadcast_value_fn is not None:
|
||||
ValueDisplay._broadcast_value_fn(ValueDisplay._current_node_id, numeric)
|
||||
ValueDisplay._broadcast_value_fn(ValueDisplay._current_node_id, _scalar_payload(numeric, unit))
|
||||
return (numeric,)
|
||||
|
||||
@@ -8,7 +8,7 @@ Gwyddion equivalents:
|
||||
from __future__ import annotations
|
||||
import numpy as np
|
||||
from backend.node_registry import register_node
|
||||
from backend.data_types import DataField
|
||||
from backend.data_types import DataField, RecordTable
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -27,7 +27,7 @@ class ParticleAnalysis:
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("TABLE",)
|
||||
RETURN_TYPES = ("RECORD_TABLE",)
|
||||
RETURN_NAMES = ("particle_stats",)
|
||||
FUNCTION = "process"
|
||||
CATEGORY = "particles"
|
||||
@@ -45,7 +45,7 @@ class ParticleAnalysis:
|
||||
|
||||
pixel_area = field.dx * field.dy # m^2 per pixel
|
||||
|
||||
rows = []
|
||||
rows = RecordTable()
|
||||
for pid in range(1, n_particles + 1):
|
||||
particle_pixels = labeled == pid
|
||||
area_px = int(particle_pixels.sum())
|
||||
|
||||
@@ -16,7 +16,7 @@ WebSocket message types sent to clients
|
||||
{"type": "executing", "data": {"node": "...", "prompt_id": "..."}}
|
||||
{"type": "preview", "data": {"node_id": "...", "image": "data:..."}}
|
||||
{"type": "table", "data": {"node_id": "...", "rows": [...]}}
|
||||
{"type": "scalar", "data": {"node_id": "...", "value": 1.23}}
|
||||
{"type": "scalar", "data": {"node_id": "...", "value": 1.23, "unit": "nm"}}
|
||||
{"type": "execution_error", "data": {"node_id": "...", "message": "..."}}
|
||||
{"type": "execution_complete", "data": {"prompt_id": "..."}}
|
||||
"""
|
||||
@@ -115,8 +115,18 @@ def create_app(loop: asyncio.AbstractEventLoop) -> web.Application:
|
||||
def on_overlay(node_id: str, overlay_data) -> None:
|
||||
broadcast({"type": "overlay", "data": {"node_id": node_id, "overlay": overlay_data}})
|
||||
|
||||
def on_value(node_id: str, value: float) -> None:
|
||||
broadcast({"type": "scalar", "data": {"node_id": node_id, "value": value}})
|
||||
def on_value(node_id: str, payload) -> None:
|
||||
if isinstance(payload, dict):
|
||||
value = payload.get("value")
|
||||
unit = payload.get("unit", "")
|
||||
else:
|
||||
value = payload
|
||||
unit = ""
|
||||
|
||||
data = {"node_id": node_id, "value": value}
|
||||
if isinstance(unit, str) and unit.strip():
|
||||
data["unit"] = unit.strip()
|
||||
broadcast({"type": "scalar", "data": data})
|
||||
|
||||
def on_warning(node_id: str, message: str) -> None:
|
||||
broadcast({"type": "node_warning", "data": {"node_id": node_id, "message": message}})
|
||||
|
||||
Reference in New Issue
Block a user