From a4386fe339c4a98e5b01a82443b144ca27f714f1 Mon Sep 17 00:00:00 2001 From: Peter Chang Date: Thu, 25 Sep 2025 16:04:20 +0100 Subject: [PATCH 1/3] Rationalize models Aggregate plot id into endpoint models, remove message types and processing layer, add custom selection deserialization --- client/component/package.json | 2 +- client/component/src/AxialSelectionConfig.tsx | 4 +- client/component/src/ConnectedPlot.tsx | 251 +++++------- client/component/src/LinePlot.tsx | 4 +- .../component/src/LinearSelectionConfig.tsx | 4 +- .../src/RectangularSelectionConfig.tsx | 4 +- client/component/src/models.ts | 2 +- .../src/selections/AxialSelection.tsx | 14 +- .../src/selections/BaseSelection.tsx | 25 +- .../selections/CircularSectorialSelection.tsx | 4 +- .../src/selections/CircularSelection.tsx | 4 +- .../src/selections/EllipticalSelection.tsx | 4 +- .../src/selections/LinearSelection.tsx | 16 +- .../src/selections/OrientableSelection.tsx | 14 +- .../src/selections/PolygonalSelection.tsx | 10 +- .../src/selections/RectangularSelection.tsx | 16 +- client/component/src/selections/utils.tsx | 32 +- client/component/vite.config.ts | 4 +- server/davidia/main.py | 69 +++- server/davidia/models/messages.py | 213 +++++----- server/davidia/models/parameters.py | 2 +- server/davidia/models/selections.py | 6 +- server/davidia/plot.py | 125 +++--- server/davidia/server/benchmarks.py | 13 +- server/davidia/server/fastapi_utils.py | 51 ++- server/davidia/server/plotserver.py | 385 ++++++++++-------- server/davidia/server/processor.py | 217 ---------- server/davidia/tests/test_api.py | 70 ++-- server/davidia/tests/test_plotserver.py | 315 +++++++------- server/demos/simple.py | 2 + server/pyproject.toml | 3 +- 31 files changed, 875 insertions(+), 1010 deletions(-) delete mode 100644 server/davidia/server/processor.py diff --git a/client/component/package.json b/client/component/package.json index 9f0b57ae..ba0f3885 100644 --- a/client/component/package.json +++ b/client/component/package.json @@ -42,7 +42,7 @@ "websocket": "^1.0.35" }, "peerDependencies": { - "@h5web/lib": "14.0.1", + "@h5web/lib": "^15.0.0", "@react-three/drei": "^9.122.0", "@react-three/fiber": "^8.17.0", "ndarray": "^1.0.19", diff --git a/client/component/src/AxialSelectionConfig.tsx b/client/component/src/AxialSelectionConfig.tsx index 359c8f96..3aa15377 100644 --- a/client/component/src/AxialSelectionConfig.tsx +++ b/client/component/src/AxialSelectionConfig.tsx @@ -29,7 +29,7 @@ function AxialSelectionConfig(props: AxialSelectionConfigProps) { return selection.dimension === 0 ? ( { - console.log('%s: sending', plotId, message); - const status: PlotMessage = { - plotId: plotId, - type, - params: message, - }; - sendMessage(encode(status)); + (data: ClientMessage) => { + console.log('%s: sending', plotId, data); + sendMessage(encode(data)); }, [plotId, sendMessage] ); const sendStatusMessage = useCallback( (message: string) => { - sendClientMessage('status', message); + sendClientMessage({ status: message } as ClientStatusMessage); }, [sendClientMessage] ); const sendBatonRequestMessage = () => { - sendClientMessage('baton_request', uuid); + sendClientMessage({ requester: uuid } as BatonRequestMessage); }; const offerBatonRequest = (uuid: string) => { - sendClientMessage('baton_offer', uuid); + sendClientMessage({ receiver: uuid } as BatonDonateMessage); }; const [batonProps, setBatonProps] = useState({ @@ -437,19 +411,19 @@ function ConnectedPlot({ if (old === -1) { console.log('Line with key', key, 'cannot be found'); return prevLineData; - } else { - const all = [...prevLineData]; - console.debug('Replacing old line params with', params); - all[old] = { ...all[old], ...params }; - return all; } + const all = [...prevLineData]; + console.debug('Replacing old line params with', params); + all[old] = { ...all[old], ...params }; + if (broadcast) { - sendClientMessage('client_update_line_parameters', { + sendClientMessage({ key: key, lineParams: params, } as ClientLineParametersMessage); } + return all; }; const updateScatterParams = (newSize: number, broadcast = true) => { @@ -458,7 +432,7 @@ function ConnectedPlot({ } if (broadcast) { - sendClientMessage('client_update_scatter_parameters', { + sendClientMessage({ pointSize: newSize, } as ClientScatterParametersMessage); } @@ -488,8 +462,8 @@ function ConnectedPlot({ }); }; - const appendMultilineData = (message: AppendLineDataMessage) => { - const newPointsData = message.alData.map((l) => createLineData(l)); + const appendMultilineData = (message: MultiLineMessage) => { + const newPointsData = message.mlData.map((l) => createLineData(l)); console.log('%s: appending line data', plotId, Object.keys(newPointsData)); const lineData = lineDataRef.current; const l = Math.max(lineData.length, newPointsData.length); @@ -500,20 +474,24 @@ function ConnectedPlot({ updateLineData(newLineData); }; - const plotMultilineData = (message: MultiLineDataMessage) => { + const plotMultilineData = (message: MultiLineMessage) => { const plotConfig = createPlotConfig(message.plotConfig); - const multilineData = message.mlData - .map((l) => createLineData(l)) - .filter((d) => d !== null); - console.log( - '%s: new line data', - plotId, - multilineData.map((o: LineData) => o.key) - ); - updateLineData(multilineData, plotConfig); + if (message.append) { + appendMultilineData(message); + } else { + const multilineData = message.mlData + .map((l) => createLineData(l)) + .filter((d) => d !== null); + console.log( + '%s: new line data', + plotId, + multilineData.map((o: LineData) => o.key) + ); + updateLineData(multilineData, plotConfig); + } }; - const plotNewImageData = (message: ImageDataMessage) => { + const plotNewImageData = (message: ImageMessage) => { const imageData = createImageData(message.imData); const imagePlotConfig = createPlotConfig(message.plotConfig); if (isHeatmapData(imageData)) { @@ -532,7 +510,7 @@ function ConnectedPlot({ } }; - const plotNewScatterData = (message: ScatterDataMessage) => { + const plotNewScatterData = (message: ScatterMessage) => { const scatterData = createScatterData(message.scData); console.log('%s: new scatter data', plotId, Object.keys(scatterData)); const scatterPlotConfig = createPlotConfig(message.plotConfig); @@ -544,7 +522,7 @@ function ConnectedPlot({ }); }; - const plotNewSurfaceData = (message: SurfaceDataMessage) => { + const plotNewSurfaceData = (message: SurfaceMessage) => { const surfaceData = createSurfaceData(message.suData); console.log('%s: new surface data', plotId, Object.keys(surfaceData)); const surfacePlotConfig = createPlotConfig(message.plotConfig); @@ -554,7 +532,7 @@ function ConnectedPlot({ }); }; - const displayNewTableData = (message: TableDataMessage) => { + const displayNewTableData = (message: TableMessage) => { const tableData = createTableData(message.taData); console.log('%s: new table data', plotId, Object.keys(tableData)); setPlotProps({ @@ -562,24 +540,29 @@ function ConnectedPlot({ }); }; - const updateSelections = (message: UpdateSelectionsMessage) => { - const updatedSelections = message.updateSelections + const updateSelections = (message: SelectionsMessage) => { + const newSelections = message.setSelections .map((s) => cloneSelection(s)) .filter((s) => s !== null) as SelectionBase[]; - console.log('%s: update selections', plotId, updatedSelections); - setSelections((prevSelections) => { - const ns = [...prevSelections]; - for (const s of updatedSelections) { - const id = s.id; - const old = ns.findIndex((n) => n.id === id); - if (old === -1) { - ns.push(s); - } else { - ns[old] = s; + if (message.update) { + console.log('%s: update selections', plotId, newSelections); + setSelections((prevSelections) => { + const ns = [...prevSelections]; + for (const s of newSelections) { + const id = s.id; + const old = ns.findIndex((n) => n.id === id); + if (old === -1) { + ns.push(s); + } else { + ns[old] = s; + } } - } - return ns; - }); + return ns; + }); + } else { + console.log('%s: new selections', plotId, newSelections); + setSelections(newSelections); + } }; const clearSelections = (message: ClearSelectionsMessage) => { @@ -600,14 +583,6 @@ function ConnectedPlot({ } }; - const replaceSelections = (message: SelectionsMessage) => { - const newSelections = message.setSelections - .map((s) => cloneSelection(s)) - .filter((s) => s !== null) as SelectionBase[]; - console.log('%s: new selections', plotId, newSelections); - setSelections(newSelections); - }; - const updateBaton = (message: BatonMessage) => { console.log('%s: updating baton with msg:', plotId, message, 'for', uuid); const baton = message.baton; @@ -647,8 +622,6 @@ function ConnectedPlot({ let report = true; if ('mlData' in decodedMessage) { plotMultilineData(decodedMessage); - } else if ('alData' in decodedMessage) { - appendMultilineData(decodedMessage); } else if ('imData' in decodedMessage) { plotNewImageData(decodedMessage); } else if ('scData' in decodedMessage) { @@ -657,12 +630,10 @@ function ConnectedPlot({ plotNewSurfaceData(decodedMessage); } else if ('taData' in decodedMessage) { displayNewTableData(decodedMessage); - } else if ('updateSelections' in decodedMessage) { - updateSelections(decodedMessage); } else if ('selectionIds' in decodedMessage) { clearSelections(decodedMessage); } else if ('setSelections' in decodedMessage) { - replaceSelections(decodedMessage); + updateSelections(decodedMessage); } else if ('baton' in decodedMessage) { updateBaton(decodedMessage); } else if ('requester' in decodedMessage) { diff --git a/client/component/src/LinePlot.tsx b/client/component/src/LinePlot.tsx index 3a5f920e..527f23d0 100644 --- a/client/component/src/LinePlot.tsx +++ b/client/component/src/LinePlot.tsx @@ -203,12 +203,12 @@ export function LineVisCanvas(props: Props) { }} > + + {lineData.map((d, index) => { const lp = initLineParams?.get(d.key) ?? d.lineParams; return createDataCurve(d, lp, index); })} - - {canSelect && ( ; diff --git a/client/component/src/selections/AxialSelection.tsx b/client/component/src/selections/AxialSelection.tsx index 2e900287..4b88f6ed 100644 --- a/client/component/src/selections/AxialSelection.tsx +++ b/client/component/src/selections/AxialSelection.tsx @@ -4,7 +4,7 @@ import type { SelectionBase } from './utils'; /** Class to select part of an axis */ export default class AxialSelection extends BaseSelection { - readonly defaultColour = '#882255'; // wine + readonly _defaultColour = '#882255'; // wine /** length in dimension */ length: number; /** dimension (x=0, y=1) */ @@ -13,17 +13,17 @@ export default class AxialSelection extends BaseSelection { super(start); this.length = length; this.dimension = dimension; - this.colour = this.defaultColour; + this.colour = this._defaultColour; } _getPoint(fraction = 1): Vector3 { const v = new Vector3(0, 0, 1); v.setComponent(this.dimension, this.length * fraction); - return v.add(this.vStart); + return v.add(this._vStart); } override getPoints(): Vector3[] { - return [this.vStart.clone(), this._getPoint()]; + return [this._vStart.clone(), this._getPoint()]; } override toString(): string { @@ -57,7 +57,7 @@ export default class AxialSelection extends BaseSelection { this.start[d] = ev; this.length = bv - ev; } - this.vStart.setComponent(d, this.start[d]); + this._vStart.setComponent(d, this.start[d]); } static createFromPoints(points: Vector3[], dimension: number) { @@ -90,7 +90,7 @@ export default class AxialSelection extends BaseSelection { const db = b - o.getComponent(d); const nb = r.start[d] + db; r.start[d] = nb; - r.vStart.setComponent(d, nb); + r._vStart.setComponent(d, nb); return r; } @@ -99,7 +99,7 @@ export default class AxialSelection extends BaseSelection { const ce = r.start[d] + r.length - c; if (ce >= 0) { r.start[d] = c; - r.vStart.setComponent(d, c); + r._vStart.setComponent(d, c); r.length = ce; } } else { diff --git a/client/component/src/selections/BaseSelection.tsx b/client/component/src/selections/BaseSelection.tsx index aa7c48e0..005b8172 100644 --- a/client/component/src/selections/BaseSelection.tsx +++ b/client/component/src/selections/BaseSelection.tsx @@ -9,20 +9,21 @@ import type { SelectionBase } from './utils'; /** Base class for all selections */ export default class BaseSelection implements SelectionBase { + /* NOTE: use attributes beginning with underscore to avoid serialization to pydantic models */ id: string; name = ''; - defaultColour: string = '#000000'; // black + _defaultColour: string = '#000000'; // black colour?: string; alpha = 0.3; fixed = false; start: [number, number]; - asDashed?: boolean; + _asDashed?: boolean; /** Vector3 copy of start coordinate */ - vStart: Vector3; + _vStart: Vector3; constructor(start: [number, number]) { this.id = crypto.randomUUID().slice(-8); // use last 8 characters only this.start = start; - this.vStart = new Vector3(...start); + this._vStart = new Vector3(...start); } toString() { @@ -32,18 +33,18 @@ export default class BaseSelection implements SelectionBase { setStart(i: number, v: number) { this.start[i] = v; if (i == 0) { - this.vStart.x = v; + this._vStart.x = v; } else if (i == 1) { - this.vStart.y = v; + this._vStart.y = v; } else if (i == 2) { - this.vStart.z = v; + this._vStart.z = v; } else { console.log('Index error (%i > 2) on setting start', i); } } getPoints() { - return [this.vStart.clone()]; + return [this._vStart.clone()]; } setProperties(other: BaseSelection) { @@ -51,7 +52,7 @@ export default class BaseSelection implements SelectionBase { this.name = other.name; this.colour = other.colour; this.alpha = other.alpha; - this.asDashed = other.asDashed; + this._asDashed = other._asDashed; this.fixed = other.fixed; } @@ -72,12 +73,12 @@ export default class BaseSelection implements SelectionBase { const x = pos[0]; if (x !== undefined) { b.start[0] = x; - b.vStart.x = x; + b._vStart.x = x; } const y = pos[1]; if (y !== undefined) { b.start[1] = y; - b.vStart.y = y; + b._vStart.y = y; } return b; } @@ -85,6 +86,6 @@ export default class BaseSelection implements SelectionBase { } static isShape(s: BaseSelection | SelectionBase): s is BaseSelection { - return 'vStart' in s; + return '_vStart' in s; } } diff --git a/client/component/src/selections/CircularSectorialSelection.tsx b/client/component/src/selections/CircularSectorialSelection.tsx index 425a4385..2c927bd2 100644 --- a/client/component/src/selections/CircularSectorialSelection.tsx +++ b/client/component/src/selections/CircularSectorialSelection.tsx @@ -5,7 +5,7 @@ import type { SelectionBase } from './utils'; /** Class to select a circular sector */ export default class CircularSectorialSelection extends BaseSelection { - readonly defaultColour = '#117733'; // green + readonly _defaultColour = '#117733'; // green /** inner and outer radii */ radii: [number, number]; /** start and end angles (radians) */ @@ -18,7 +18,7 @@ export default class CircularSectorialSelection extends BaseSelection { super(start); this.radii = radii; this.angles = angles; - this.colour = this.defaultColour; + this.colour = this._defaultColour; } static clicks() { diff --git a/client/component/src/selections/CircularSelection.tsx b/client/component/src/selections/CircularSelection.tsx index 01f56232..90a3144b 100644 --- a/client/component/src/selections/CircularSelection.tsx +++ b/client/component/src/selections/CircularSelection.tsx @@ -4,13 +4,13 @@ import type { SelectionBase } from './utils'; /** Class to select a circle */ export default class CircularSelection extends BaseSelection { - readonly defaultColour = '#332288'; // indigo + readonly _defaultColour = '#332288'; // indigo /** radius of circle */ radius: number; constructor(start: [number, number], radius: number) { super(start); this.radius = radius; - this.colour = this.defaultColour; + this.colour = this._defaultColour; } static clicks() { diff --git a/client/component/src/selections/EllipticalSelection.tsx b/client/component/src/selections/EllipticalSelection.tsx index 56dd9d9a..f22b0881 100644 --- a/client/component/src/selections/EllipticalSelection.tsx +++ b/client/component/src/selections/EllipticalSelection.tsx @@ -5,13 +5,13 @@ import type { SelectionBase } from './utils'; /** Class to select an ellipse */ export default class EllipticalSelection extends OrientableSelection { - readonly defaultColour = '#999933'; // olive + readonly _defaultColour = '#999933'; // olive /** lengths of major and minor semi-axes */ semiAxes: [number, number]; constructor(start: [number, number], semiAxes: [number, number], angle = 0) { super(start, angle); this.semiAxes = semiAxes; - this.colour = this.defaultColour; + this.colour = this._defaultColour; } static clicks() { diff --git a/client/component/src/selections/LinearSelection.tsx b/client/component/src/selections/LinearSelection.tsx index affde7aa..27b8a132 100644 --- a/client/component/src/selections/LinearSelection.tsx +++ b/client/component/src/selections/LinearSelection.tsx @@ -6,23 +6,23 @@ import type { SelectionBase } from './utils'; /** Class to select a line */ export default class LinearSelection extends OrientableSelection { - readonly defaultColour = '#44aa99'; // teal + readonly _defaultColour = '#44aa99'; // teal /** length of line */ length: number; constructor(start: [number, number] = [0, 0], length = 1, angle = 0) { super(start, angle); this.length = length; - this.colour = this.defaultColour; + this.colour = this._defaultColour; } _getPoint(fraction = 1): Vector3 { return new Vector3(this.length * fraction, 0, 1) - .applyMatrix3(this.transform) - .add(this.vStart); + .applyMatrix3(this._transform) + .add(this._vStart); } override getPoints(): Vector3[] { - return [this.vStart.clone(), this._getPoint(), this._getPoint(0.5)]; + return [this._vStart.clone(), this._getPoint(), this._getPoint(0.5)]; } override toString() { @@ -45,8 +45,8 @@ export default class LinearSelection extends OrientableSelection { _setFromPoints(points: Vector3[]) { const b = points[0]; this.start = [b.x, b.y]; - this.vStart.x = b.x; - this.vStart.y = b.y; + this._vStart.x = b.x; + this._vStart.y = b.y; const l = new Vector3().subVectors(points[1], b); const pl = polar(l); this.length = pl[0]; @@ -70,7 +70,7 @@ export default class LinearSelection extends OrientableSelection { pos: [number | undefined, number | undefined] ) { const l = LinearSelection.createFromSelection(this); - const b = l.vStart; + const b = l._vStart; let e; switch (i) { case 0: diff --git a/client/component/src/selections/OrientableSelection.tsx b/client/component/src/selections/OrientableSelection.tsx index 5fe6abec..68e9f4da 100644 --- a/client/component/src/selections/OrientableSelection.tsx +++ b/client/component/src/selections/OrientableSelection.tsx @@ -6,24 +6,24 @@ import type { SelectionBase } from './utils'; export default class OrientableSelection extends BaseSelection { /** angle of selection (radians) */ angle: number; - protected transform: Matrix3; - protected invTransform: Matrix3; + protected _transform: Matrix3; + protected _invTransform: Matrix3; constructor(start: [number, number], angle = 0) { super(start); this.angle = angle; - this.transform = new Matrix3().identity().rotate(-this.angle); - this.invTransform = new Matrix3().identity().rotate(this.angle); + this._transform = new Matrix3().identity().rotate(-this.angle); + this._invTransform = new Matrix3().identity().rotate(this.angle); } setAngle(angle: number) { this.angle = angle; - this.transform = new Matrix3().identity().rotate(-this.angle); - this.invTransform = new Matrix3().identity().rotate(this.angle); + this._transform = new Matrix3().identity().rotate(-this.angle); + this._invTransform = new Matrix3().identity().rotate(this.angle); } static override isShape( s: OrientableSelection | SelectionBase ): s is OrientableSelection { - return 'transform' in s; + return '_transform' in s; } } diff --git a/client/component/src/selections/PolygonalSelection.tsx b/client/component/src/selections/PolygonalSelection.tsx index 4cc348d1..8f1a7639 100644 --- a/client/component/src/selections/PolygonalSelection.tsx +++ b/client/component/src/selections/PolygonalSelection.tsx @@ -4,9 +4,9 @@ import type { SelectionBase } from './utils'; /** Class to select a polygon */ export default class PolygonalSelection extends BaseSelection { - readonly defaultOpenColour = '#88ccee'; // cyan - readonly defaultClosedColour = '#ffa07a'; // lightsalmon - readonly defaultColour = this.defaultOpenColour; + readonly _defaultOpenColour = '#88ccee'; // cyan + readonly _defaultClosedColour = '#ffa07a'; // lightsalmon + readonly _defaultColour = this._defaultOpenColour; /** array of point coordinates */ points: [number, number][]; /** if true, polygon is closed by joining last and first point */ @@ -16,8 +16,8 @@ export default class PolygonalSelection extends BaseSelection { this.points = points; this.closed = !!closed; this.colour = this.closed - ? this.defaultClosedColour - : this.defaultOpenColour; + ? this._defaultClosedColour + : this._defaultOpenColour; } override getPoints(): Vector3[] { diff --git a/client/component/src/selections/RectangularSelection.tsx b/client/component/src/selections/RectangularSelection.tsx index bdc0ea32..2c6ef6a9 100644 --- a/client/component/src/selections/RectangularSelection.tsx +++ b/client/component/src/selections/RectangularSelection.tsx @@ -4,13 +4,13 @@ import type { SelectionBase } from './utils'; /** Class to select a rectangle */ export default class RectangularSelection extends OrientableSelection { - readonly defaultColour = '#ddcc77'; // sand + readonly _defaultColour = '#ddcc77'; // sand /** lengths of major and minor sides */ lengths: [number, number]; constructor(start: [number, number], lengths: [number, number], angle = 0) { super(start, angle); this.lengths = [...lengths]; - this.colour = this.defaultColour; + this.colour = this._defaultColour; } override getPoints(): Vector3[] { @@ -40,7 +40,7 @@ export default class RectangularSelection extends OrientableSelection { return null; } - return v.applyMatrix3(this.transform).add(this.vStart); + return v.applyMatrix3(this._transform).add(this._vStart); } override toString() { @@ -103,7 +103,7 @@ export default class RectangularSelection extends OrientableSelection { const y = pos[1] ?? 0; if (i === 4) { const d = new Vector3(x, y).sub(o); - const s = r.vStart; + const s = r._vStart; s.x += d.x; s.y += d.y; r.start[0] = s.x; @@ -111,7 +111,7 @@ export default class RectangularSelection extends OrientableSelection { return r; } - const d = new Vector3(x, y).sub(o).applyMatrix3(this.invTransform); + const d = new Vector3(x, y).sub(o).applyMatrix3(this._invTransform); const [rx, ry] = r.lengths; // limit start point to interior of current rectangle and new lengths are non-negative let lx, ly; @@ -139,11 +139,11 @@ export default class RectangularSelection extends OrientableSelection { break; } if (p !== undefined) { - p.applyMatrix3(this.transform).add(this.vStart); + p.applyMatrix3(this._transform).add(this._vStart); r.start[0] = p.x; r.start[1] = p.y; - r.vStart.x = p.x; - r.vStart.y = p.y; + r._vStart.x = p.x; + r._vStart.y = p.y; } r.lengths[0] = lx; r.lengths[1] = ly; diff --git a/client/component/src/selections/utils.tsx b/client/component/src/selections/utils.tsx index de214046..29cadbc2 100644 --- a/client/component/src/selections/utils.tsx +++ b/client/component/src/selections/utils.tsx @@ -76,7 +76,7 @@ interface SelectionBase { /** name */ name: string; /** default colour */ - defaultColour: string; + _defaultColour: string; /** outline colour */ colour?: string; /** opacity [0,1] */ @@ -86,7 +86,7 @@ interface SelectionBase { /** start (or centre) point coordinate */ start: [number, number]; /** if true, outline is dashed */ - asDashed?: boolean; + _asDashed?: boolean; /** set start */ setStart: (i: number, v: number) => void; /** retrieve points */ @@ -160,6 +160,17 @@ function cloneSelection(selection: SelectionBase) { } } +/** + * Serialize selections by filtering extraneous entries + * @param selection + * @returns serialized selection + */ +function serializeSelection(selection: SelectionBase) { + return Object.fromEntries( + Object.entries(selection).filter((e) => !e[0].startsWith('_')) + ); +} + /** * Create selection from points * @param selectionType type of selection @@ -367,7 +378,7 @@ function pointsToShape( s.getPoints(), alpha, size, - colour ?? s.colour ?? s.defaultColour, + colour ?? s.colour ?? s._defaultColour, undefined, true ); @@ -423,8 +434,8 @@ function SelectionShape(props: SelectionShapeProps) { htmlSelection, selection.alpha, size, - selection.colour ?? selection.defaultColour, - selection.asDashed, + selection.colour ?? selection._defaultColour, + selection._asDashed, selection.fixed || !showHandles, combinedUpdate(selection) ) @@ -581,7 +592,7 @@ function useSelections( const [canSelect, enableSelect] = useState(true); const updateSelection: SelectionHandler = useCallback( - (selection, dragging = false, clear = false) => { + (selection: SelectionBase | null, dragging = false, clear = false) => { let id: string | null = null; if (!selection) { if (clear) { @@ -592,12 +603,12 @@ function useSelections( id = selection.id; if (clear) { console.debug('Clearing selection:', id); - setSelections((prevSelections) => + setSelections((prevSelections: SelectionBase[]) => prevSelections.filter((s) => s.id !== id) ); listener?.(SelectionsEventType.removed, dragging, selection); } else { - setSelections((prevSelections) => { + setSelections((prevSelections: SelectionBase[]) => { const old = prevSelections.findIndex((s) => s.id === id); isNewSelection.current = old === -1; if (isNewSelection.current) { @@ -632,7 +643,7 @@ function useSelections( * @param {SelectionBase} s - The selection to modify. */ function undashSelection(s: SelectionBase) { - s.asDashed = false; + s._asDashed = false; } /** @@ -640,7 +651,7 @@ function undashSelection(s: SelectionBase) { * @param {SelectionBase} s - The selection to modify. */ function dashSelection(s: SelectionBase) { - s.asDashed = true; + s._asDashed = true; } export { @@ -660,6 +671,7 @@ export { dashSelection, undashSelection, toSelectionType, + serializeSelection, }; export type { diff --git a/client/component/vite.config.ts b/client/component/vite.config.ts index 5d5983ea..fa0aace6 100644 --- a/client/component/vite.config.ts +++ b/client/component/vite.config.ts @@ -14,9 +14,9 @@ export const externals = [ // https://vitejs.dev/config/ export default defineConfig({ plugins: [ - react(), + react(), // @ts-ignore - dts({ bundleTypes: true }) + dts({ bundleTypes: true }), ], build: { lib: { diff --git a/server/davidia/main.py b/server/davidia/main.py index df513d67..1e4f5624 100644 --- a/server/davidia/main.py +++ b/server/davidia/main.py @@ -5,24 +5,30 @@ import pathlib import uvicorn -from davidia.models.messages import PlotMessage -from davidia.models.selections import AnySelection -from davidia.server.benchmarks import BenchmarkParams -from davidia.server.fastapi_utils import message_unpack -from davidia.server.plotserver import PlotServer, handle_client from fastapi import FastAPI, WebSocket from fastapi.middleware.cors import CORSMiddleware # comment this on deployment from fastapi.openapi.utils import get_openapi from fastapi.staticfiles import StaticFiles +from pydantic import TypeAdapter -_PM_SCHEMA = PlotMessage.model_json_schema( - ref_template="#/components/schemas/{model}", by_alias=True -) -_PM_NESTED_MODELS = _PM_SCHEMA.pop("$defs") +from davidia import __version__ + +from davidia.models.messages import EndPointMessage +from davidia.models.selections import AnySelection +from davidia.server.benchmarks import BenchmarkParams +from davidia.server.fastapi_utils import message_unpack +from davidia.server.plotserver import PlotServer, handle_client logger = logging.getLogger("main") -from davidia import __version__ + +_epta = TypeAdapter(EndPointMessage) + +_EP_SCHEMA = _epta.json_schema( + ref_template="#/components/schemas/{model}", by_alias=True +) +_EP_NESTED_MODELS = _EP_SCHEMA.pop("$defs") + def _create_bare_app(add_benchmark=False): app = FastAPI() @@ -31,8 +37,10 @@ def customize_openapi(): if app.openapi_schema: return app.openapi_schema - o_schema = get_openapi(title="Davidia API", version=__version__, routes=app.routes) - o_schema["components"]["schemas"].update(_PM_NESTED_MODELS) + o_schema = get_openapi( + title="Davidia API", version=__version__, routes=app.routes + ) + o_schema["components"]["schemas"].update(_EP_NESTED_MODELS) app.openapi_schema = o_schema return o_schema @@ -58,35 +66,60 @@ async def websocket(websocket: WebSocket, uuid: str, plot_id: str): openapi_extra={ "requestBody": { "content": { - "application/x-yaml": {"schema": _PM_SCHEMA}, + "application/x-yaml": {"schema": _EP_SCHEMA}, }, "required": True, } }, ) @message_unpack - async def push_data(data: PlotMessage) -> str: + async def push_data(data: EndPointMessage) -> str: """ Push data to plot + + Parameters + ---------- + data - plot data """ - await ps.prepare_data(data) + if data is None: + logger.error("No data posted!") + return "None" + await ps.update(data) await ps.send_next_message() return "data sent" @app.put("/clear_data/{plot_id}") async def clear_data(plot_id: str) -> str: """ - Clear plot + Clear plot with ID + + Parameters + ---------- + plot_id - ID of plot """ await ps.clear_plots_and_queues(plot_id) return "data cleared" @app.get("/get_plot_ids") def get_plot_ids() -> list[str]: + """ + Get plot IDs + + Returns + ------- + List of plot IDs + """ return ps.get_plot_ids() @app.get("/get_regions/{plot_id}") async def get_regions(plot_id: str) -> list[AnySelection]: + """ + Get regions + + Returns + ------- + List of regions + """ return await ps.get_regions(plot_id) if add_benchmark: @@ -178,9 +211,7 @@ def create_app(client_path=CLIENT_BUILD_PATH, benchmark=False): return app -def run_app( - client_path=CLIENT_BUILD_PATH, benchmark=False, host="127.0.0.1", port=80 -): +def run_app(client_path=CLIENT_BUILD_PATH, benchmark=False, host="127.0.0.1", port=80): app = create_app(client_path=client_path, benchmark=benchmark) uvicorn.run(app, host=host, port=port, log_level="info", access_log=False) diff --git a/server/davidia/models/messages.py b/server/davidia/models/messages.py index dc89f6d1..6d4f2696 100644 --- a/server/davidia/models/messages.py +++ b/server/davidia/models/messages.py @@ -19,28 +19,6 @@ from .selections import AnySelection, Float, FloatTuple -class MsgType(AutoNameEnum): - """Class for message type.""" - - status = auto() - baton_request = auto() - baton_offer = auto() - new_multiline_data = auto() - append_line_data = auto() - new_image_data = auto() - new_scatter_data = auto() - new_surface_data = auto() - new_table_data = auto() - new_selection_data = auto() - update_selection_data = auto() - clear_selection_data = auto() - clear_data = auto() - client_new_selection = auto() - client_update_selection = auto() - client_update_line_parameters = auto() - client_update_scatter_parameters = auto() - - class StatusType(AutoNameEnum): """Class for status type.""" @@ -250,31 +228,45 @@ class BatonMessage(DvDModel): uuids: list[str] +class BatonDonateMessage(DvDModel): + """Class for representing a baton donate message.""" + + receiver: str + + class BatonRequestMessage(DvDModel): """Class for representing a baton request message.""" requester: str -class DataMessage(DvDNpModel): - """Class for representing a data message +class _BasePlotMessage(DvDModel): + """ + Base class for a plot message - Make sure subclasses have unique fields so client can distinguish - message type + Attributes + ---------- + plot_id : str + ID of plot to which to send data message """ - plot_config: PlotConfig = PlotConfig() + plot_id: str = "plot_0" -class AppendLineDataMessage(DataMessage): - """Class for representing an append line data message.""" +class _PlotDataMessage(DvDNpModel, _BasePlotMessage): + """Class for representing a plot message - al_data: list[LineData] + Make sure subclasses have unique fields so client can distinguish + message type + """ + + plot_config: PlotConfig = PlotConfig() -class MultiLineDataMessage(DataMessage): - """Class for representing a multiline data message.""" +class MultiLineMessage(_PlotDataMessage): + """Class for representing a multiline message.""" + append: bool = False ml_data: list[LineData] @field_validator("ml_data") @@ -284,151 +276,137 @@ def ml_data_is_not_empty(cls, v): return v raise ValueError("ml_data contains no LineData", v) - @field_validator("ml_data") - @classmethod - def default_indices_match(cls, v): - default_indices = [ld.default_indices for ld in v] + @model_validator(mode="after") + def default_indices_match(self): + if self.append: + return self + + default_indices = [ld.default_indices for ld in self.ml_data] if all(default_indices) or not any(default_indices): - return v + return self raise ValueError( - "default_indices must be all True or all False in every LineData object", v + "default_indices must be all True or all False in every LineData object", + self.ml_data, ) -class ImageDataMessage(DataMessage): - """Class for representing an image data message.""" +class ScatterMessage(_PlotDataMessage): + """Class for representing a scatter message.""" - im_data: ImageData | HeatmapData + sc_data: ScatterData -class ScatterDataMessage(DataMessage): - """Class for representing a scatter data message.""" +class ImageMessage(_PlotDataMessage): + """Class for representing an image message.""" - sc_data: ScatterData + im_data: ImageData | HeatmapData -class SurfaceDataMessage(DataMessage): - """Class for representing a surface data message.""" +class SurfaceMessage(_PlotDataMessage): + """Class for representing a surface message.""" su_data: SurfaceData -class TableDataMessage(DataMessage): - """Class for representing a table data message.""" +class TableMessage(_PlotDataMessage): + """Class for representing a table message.""" ta_data: TableData -class ClearPlotsMessage(DvDModel): - """Class for representing a request to clear all plots.""" +class _BaseSelectionsMessage(_BasePlotMessage): + """Class to mark selections""" - plot_id: str +class SelectionsMessage(_BaseSelectionsMessage): + """Class for representing selections to set""" -class SelectionMessage(DvDModel): - """Class to mark selections""" + update: bool = False + set_selections: list[AnySelection] -class ClientSelectionMessage(SelectionMessage): - """Class for representing a client selection""" +class ClearSelectionsMessage(_BaseSelectionsMessage): + """Class for representing selections to clear""" - selection: AnySelection + selection_ids: list[str] -class ClientLineParametersMessage(DvDModel): +class ClientSelectionMessage(DvDModel): """Class for representing a client selection""" - line_params: LineParams - key: str + selection: AnySelection = Field(union_mode="left_to_right") -class ClientScatterParametersMessage(DvDModel): - """Class for representing client scatter parameters""" +class ClearPlotMessage(DvDModel): + """Class for representing a request to clear plot.""" - point_size: Float + plot_id: str -class SelectionsMessage(SelectionMessage): - """Class for representing a request to set selections""" +class ClientStatusMessage(DvDModel): + """Class for representing a client status""" - set_selections: list[AnySelection] + status: str -class UpdateSelectionsMessage(SelectionMessage): - """Class for representing a request to update selections""" +class ClientLineParametersMessage(DvDModel): + """Class for representing a client selection""" - update_selections: list[AnySelection] + line_params: LineParams + key: str -class ClearSelectionsMessage(SelectionMessage): - """Class for representing a request to clear listed or all selections.""" +class ClientScatterParametersMessage(DvDModel): + """Class for representing client scatter parameters""" - selection_ids: list[str] + point_size: Float -ANY_PM_PARAMS = ( - str - | list[LineData] - | HeatmapData - | ImageData - | ScatterData - | SurfaceData - | TableData - | ClientSelectionMessage - | ClientLineParametersMessage - | ClientScatterParametersMessage +EndPointMessage = ( + MultiLineMessage + | ScatterMessage + | ImageMessage + | SurfaceMessage + | TableMessage | SelectionsMessage - | UpdateSelectionsMessage | ClearSelectionsMessage ) -class PlotMessage(DvDNpModel): - """ - Class for communication messages to push_data endpoint - - Attributes - ---------- - plot_id : str - ID of plot to which to send data message - type : MsgType - The message type represented as a MsgType enum - params : Any - The message params - plot_config: PlotConfig - The plot configuration parameters - """ - - plot_id: str - type: MsgType - params: ANY_PM_PARAMS = Field(union_mode="left_to_right") - plot_config: PlotConfig | None = None +ClientMessage = ( + ClientStatusMessage + | ClientSelectionMessage + | ClientLineParametersMessage + | ClientScatterParametersMessage + | ClearSelectionsMessage + | BatonRequestMessage + | BatonDonateMessage +) ALL_MESSAGES = ( - ClearPlotsMessage, - AppendLineDataMessage, - MultiLineDataMessage, - ImageDataMessage, - ScatterDataMessage, - TableDataMessage, + MultiLineMessage, + ScatterMessage, + ImageMessage, + SurfaceMessage, + TableMessage, + BatonMessage, + BatonRequestMessage, + SelectionsMessage, + ClearSelectionsMessage, + ClientStatusMessage, ClientSelectionMessage, ClientLineParametersMessage, ClientScatterParametersMessage, - SelectionsMessage, - UpdateSelectionsMessage, - ClearSelectionsMessage, - BatonMessage, - BatonRequestMessage, + ClearPlotMessage, ) ALL_MODELS = ( - PlotMessage, LineData, LineParams, + ScatterData, HeatmapData, ImageData, - ScatterData, SurfaceData, TableData, PlotConfig, @@ -437,4 +415,5 @@ class PlotMessage(DvDNpModel): if __name__ == "__main__": import json - print(json.dumps(PlotMessage.model_json_schema(), indent=2)) + for m in ALL_MESSAGES: + print(json.dumps(m.model_json_schema(), indent=2)) diff --git a/server/davidia/models/parameters.py b/server/davidia/models/parameters.py index bd5d1ebf..d0e9fd05 100644 --- a/server/davidia/models/parameters.py +++ b/server/davidia/models/parameters.py @@ -8,7 +8,7 @@ from pydantic_numpy.model import NumpyModel DvDNDArray = Annotated[ - np.ndarray[Any, None], + np.ndarray[Any, np.dtype[Any]], NpArrayPydanticAnnotation.factory( data_type=None, dimensions=None, diff --git a/server/davidia/models/selections.py b/server/davidia/models/selections.py index 81d27fd6..a6069dad 100644 --- a/server/davidia/models/selections.py +++ b/server/davidia/models/selections.py @@ -9,7 +9,7 @@ from math import atan2, cos, degrees, hypot, pi, radians, sin from uuid import uuid4 -from pydantic import BeforeValidator, Field, model_validator +from pydantic import BeforeValidator, ConfigDict, Field, model_validator from typing import Annotated, Type from .parameters import DvDModel @@ -41,6 +41,10 @@ def _make_tuple_floats(v: tuple[Number, Number]) -> tuple[float, float]: class SelectionBase(DvDModel, validate_assignment=True): """Base class for representing any selection""" + model_config = ConfigDict( + extra="forbid" + ) # need this to ensure dict validating to correct sub-models + id: str = Field(default_factory=_make_id) name: str = "" colour: str | None = None diff --git a/server/davidia/plot.py b/server/davidia/plot.py index c4bd82b2..10f4f123 100644 --- a/server/davidia/plot.py +++ b/server/davidia/plot.py @@ -1,7 +1,3 @@ -"""colourMap str options are listed in INTERPOLATORS in h5web https://github.com/silx-kit/h5web/blob/main/packages/lib/src/vis/heatmap/interpolators.ts -ScaleType enum options are linear, log, symlog, sqrt, gamma -""" - from __future__ import annotations import logging @@ -10,7 +6,9 @@ from typing import Any import numpy as np +from numpy.typing import ArrayLike import requests + from davidia.models.messages import ( ClearSelectionsMessage, ColourMap, @@ -19,15 +17,18 @@ ImageData, LineData, LineParams, - MsgType, - PlotMessage, ScaleType, ScatterData, SelectionsMessage, SurfaceData, TableData, - UpdateSelectionsMessage, + MultiLineMessage, + ScatterMessage, + ImageMessage, + SurfaceMessage, + TableMessage, ) + from davidia.models.parameters import PlotConfig, TableDisplayParams, TableDisplayType from davidia.models.selections import ( AnySelection, @@ -40,7 +41,6 @@ RectangularSelection, ) from davidia.server.fastapi_utils import j_dumps, j_loads, ws_pack -from numpy.typing import ArrayLike OptionalArrayLike = ArrayLike | None OptionalLists = OptionalArrayLike | list[OptionalArrayLike] | None @@ -69,15 +69,10 @@ def _prepare_request(self, data): def _post( self, - params, - msg_type=MsgType.new_multiline_data, - plot_config=None, + data, endpoint="push_data", ): url = self.url_prefix + endpoint - data = PlotMessage( - plot_id=self.plot_id, type=msg_type, params=params, plot_config=plot_config - ) logging.debug("posting PM: %s", data) start = time_ns() data, headers = self._prepare_request(data) @@ -170,11 +165,6 @@ def line( if yf is None: return - if append: - msg_type = MsgType.append_line_data - else: - msg_type = MsgType.new_multiline_data - def _asanyarray(x): return x if x is None else np.asanyarray(x) @@ -252,7 +242,50 @@ def _asanyarray(x): **global_attribs, ) ] - return self._post(lds, msg_type=msg_type, plot_config=plot_config) + + return self._post( + MultiLineMessage( + plot_id=self.plot_id, + plot_config=plot_config, + append=append, + ml_data=lds, + ) + ) + + def scatter( + self, + x: ArrayLike, + y: ArrayLike, + point_values: OptionalLists, + domain: tuple[float, float], + plot_config: dict[str, Any] | None = None, + **attribs, + ): + """Plot scatter data + Parameters + ---------- + x: x coordinates + y: y coordinates + point_values: array + domain: tuple + plot_config: plot config + Returns + ------- + response: Response + Response from push_data POST request + """ + sc = ScatterData( + x=np.asanyarray(x), + y=np.asanyarray(y), + point_values=np.asanyarray(point_values), + domain=domain, + **attribs, + ) + plot_config = PlotConnection._populate_plot_config(plot_config) + + return self._post( + ScatterMessage(plot_id=self.plot_id, plot_config=plot_config, sc_data=sc) + ) def image( self, @@ -285,40 +318,9 @@ def image( else: raise ValueError("Data cannot be interpreted as heatmap or image data") plot_config = PlotConnection._populate_plot_config(plot_config, x, y) - return self._post(im, msg_type=MsgType.new_image_data, plot_config=plot_config) - def scatter( - self, - x: ArrayLike, - y: ArrayLike, - point_values: OptionalLists, - domain: tuple[float, float], - plot_config: dict[str, Any] | None = None, - **attribs, - ): - """Plot scatter data - Parameters - ---------- - x: x coordinates - y: y coordinates - point_values: array - domain: tuple - plot_config: plot config - Returns - ------- - response: Response - Response from push_data POST request - """ - sc = ScatterData( - x=np.asanyarray(x), - y=np.asanyarray(y), - point_values=np.asanyarray(point_values), - domain=domain, - **attribs, - ) - plot_config = PlotConnection._populate_plot_config(plot_config) return self._post( - sc, msg_type=MsgType.new_scatter_data, plot_config=plot_config + ImageMessage(plot_id=self.plot_id, plot_config=plot_config, im_data=im) ) def surface( @@ -346,8 +348,9 @@ def surface( """ su = SurfaceData(height_values=np.asanyarray(values), domain=domain, **attribs) plot_config = PlotConnection._populate_plot_config(plot_config, x, y) + return self._post( - su, msg_type=MsgType.new_surface_data, plot_config=plot_config + SurfaceMessage(plot_id=self.plot_id, plot_config=plot_config, su_data=su) ) def table( @@ -381,7 +384,8 @@ def table( ), **attribs, ) - return self._post(ta, msg_type=MsgType.new_table_data) + + return self._post(TableMessage(plot_id=self.plot_id, ta_data=ta)) def clear(self) -> requests.Response: """Sends request to clear a plot @@ -431,17 +435,14 @@ def region( remove = [] else: remove = [s.id for s in selections] - msg_type = MsgType.clear_selection_data - sm = ClearSelectionsMessage(selection_ids=remove) - elif update and selections is not None: - msg_type = MsgType.update_selection_data - sm = UpdateSelectionsMessage(update_selections=selections) + sm = ClearSelectionsMessage(plot_id=self.plot_id, selection_ids=remove) elif selections is not None: - msg_type = MsgType.new_selection_data - sm = SelectionsMessage(set_selections=selections) + sm = SelectionsMessage( + plot_id=self.plot_id, update=update, set_selections=selections + ) else: raise ValueError("Should not be reached") - return self._post(sm, msg_type=msg_type) + return self._post(sm) _ALL_PLOTS: dict[str, PlotConnection] = dict() diff --git a/server/davidia/server/benchmarks.py b/server/davidia/server/benchmarks.py index 67db9204..1a62fa1c 100644 --- a/server/davidia/server/benchmarks.py +++ b/server/davidia/server/benchmarks.py @@ -8,13 +8,12 @@ from pydantic import BaseModel from ..models.messages import ( - AppendLineDataMessage, HeatmapData, ImageData, - ImageDataMessage, + ImageMessage, LineData, LineParams, - MultiLineDataMessage, + MultiLineMessage, ) from ..models.parameters import Aspect, PlotConfig, ScaleType @@ -75,7 +74,7 @@ def multiline(params: list[int | float]): multilines.append( LineData(key=_timestamp(), line_params=LineParams(), x=xi, y=y) ) - yield MultiLineDataMessage(ml_data=multilines) + yield MultiLineMessage(ml_data=multilines) AD_DEFAULT_PARAMS = [5, 10240, 512, 32] @@ -115,7 +114,7 @@ def add_data(params: list[int | float]): ) total += added - yield AppendLineDataMessage(al_data=multilines) + yield MultiLineMessage(append=True, ml_data=multilines) def get_image(cache: list, name: str, i: int): @@ -151,7 +150,7 @@ def image(params: list[int | float]): y_values=y_values, title="image benchmarking plot", ) - yield ImageDataMessage(im_data=data, plot_config=plot_config) + yield ImageMessage(im_data=data, plot_config=plot_config) HEATMAPS_CACHE = [] @@ -187,4 +186,4 @@ def heatmap(params: list[int | float]): y_values=y_values, title="heatmap benchmarking plot", ) - yield ImageDataMessage(im_data=data, plot_config=plot_config) + yield ImageMessage(im_data=data, plot_config=plot_config) diff --git a/server/davidia/server/fastapi_utils.py b/server/davidia/server/fastapi_utils.py index f70355da..93a73cee 100644 --- a/server/davidia/server/fastapi_utils.py +++ b/server/davidia/server/fastapi_utils.py @@ -1,6 +1,7 @@ import inspect import logging -from typing import Any +from types import UnionType +from typing import Any, get_args import numpy as np import orjson @@ -8,9 +9,10 @@ from msgpack import packb as _mp_packb # max_buffer_size=100MB from msgpack import unpackb as _mp_unpackb from pydantic import BaseModel, ValidationError +from pydantic.alias_generators import to_camel from ..models.messages import ALL_MODELS, DvDNDArray -from ..models.selections import as_selection +from ..models.selections import AnySelection, as_selection logger = logging.getLogger("main") @@ -19,8 +21,6 @@ def as_model(raw: dict) -> BaseModel | None: for m in ALL_MODELS: try: return m.model_validate(raw) - except ValidationError: - logger.debug("Maybe not %s", m) except Exception: pass return None @@ -49,16 +49,35 @@ def _deserialize_selection(item): def _deserialize_any(item): if isinstance(item, dict): - m = as_model(item) - return _deserialize_selection(item) if m is None else m + try: + return _deserialize_selection(item) + except ValueError: + return as_model(item) elif isinstance(item, list): return [_deserialize_any(i) for i in item] return item +def extract_selection(raw: dict, entry: str) -> AnySelection | list[AnySelection]: + if entry not in raw: + camel_entry = to_camel(entry) + if camel_entry == entry or camel_entry not in raw: + raise ValueError( + f"{entry} and its camelCase {camel_entry} not found in message" + ) + entry = camel_entry + raw_selection = raw[entry] + if isinstance(raw_selection, list): + return [as_selection(s) for s in raw_selection] + return as_selection(raw_selection) + + def j_loads(data): d = orjson.loads(data) - return _deserialize_any(d) + try: + return _deserialize_any(d) + except ValidationError: + logger.error("Could not deserialize: %s", d, exc_info=True) def decode_ndarray(obj) -> DvDNDArray: @@ -128,14 +147,28 @@ async def root_request(payload: MyModel) -> OtherModel: f_class = f_params.pop("return") def _instantiate_obj(model_class, obj): + if isinstance(model_class, UnionType): + for m in get_args(model_class): + try: + return m.model_validate(obj) + except ValidationError: + logger.warning( + "Could not validate as %s: %s", m, obj, exc_info=True + ) + logger.error("No valid models for", obj) + return None + if isinstance(obj, BaseModel): return obj elif hasattr(model_class, "model_validate"): try: return model_class.model_validate(obj) - except ValidationError as v_err: - logger.warning("Could not validate: %s", v_err) + except ValidationError: + logger.warning( + "Could not validate as %s: %s", model_class, obj, exc_info=True + ) return None + return model_class(**obj) async def wrapper(request: Request) -> Response: diff --git a/server/davidia/server/plotserver.py b/server/davidia/server/plotserver.py index e07628c8..51392b9b 100644 --- a/server/davidia/server/plotserver.py +++ b/server/davidia/server/plotserver.py @@ -10,35 +10,35 @@ from pydantic import ValidationError from . import benchmarks as _benchmark +from ..models.parameters import DvDNDArray from ..models.messages import ( - AppendLineDataMessage, + BatonDonateMessage, BatonRequestMessage, BatonMessage, - ClearPlotsMessage, + _BasePlotMessage, + _PlotDataMessage, + ClearPlotMessage, ClearSelectionsMessage, + ClientSelectionMessage, + ClientStatusMessage, ClientLineParametersMessage, ClientScatterParametersMessage, ColourMap, - DataMessage, - DvDNDArray, HeatmapData, - ImageDataMessage, + ImageMessage, LineData, - MsgType, - MultiLineDataMessage, - PlotMessage, - SelectionMessage, + MultiLineMessage, + _BaseSelectionsMessage, SelectionsMessage, ScatterData, - ScatterDataMessage, + ScatterMessage, StatusType, SurfaceData, - SurfaceDataMessage, - UpdateSelectionsMessage, + SurfaceMessage, + ClientMessage, ) from ..models.selections import SelectionBase -from .fastapi_utils import ws_pack, ws_unpack -from .processor import Processor +from .fastapi_utils import ws_pack, ws_unpack, as_model logger = logging.getLogger("main") @@ -110,7 +110,7 @@ def __init__( self.new_data_message: bytes | None = new_data_message self.new_selections_message: bytes | None = new_selections_message self.new_baton_message: bytes | None = new_baton_message - self.current_data: DataMessage | None = current_data + self.current_data: _PlotDataMessage | None = current_data self.current_selections: list[SelectionBase] | None = current_selections self.current_baton: str | None = current_baton self.lock = Lock() @@ -144,7 +144,6 @@ class PlotServer: """ def __init__(self): - self.processor: Processor = Processor() self._clients: dict[str, list[PlotClient]] = defaultdict(list) self.client_status: StatusType = StatusType.busy self.uuids: list[str] = [] @@ -191,7 +190,7 @@ async def update_baton(self): processed_msg = BatonMessage(baton=self.baton, uuids=self.uuids) logger.debug("Baton updated: %s", processed_msg) for p, cl in self._clients.items(): - msg = await self.update_plot_states_with_message(processed_msg, p) + msg = await self.update_plot_states_with_message(p, processed_msg) if msg is None: continue for c in cl: @@ -239,13 +238,13 @@ async def remove_client(self, plot_id: str, client: PlotClient) -> bool: self.baton = None return False - async def send_baton_approval_request(self, message: PlotMessage) -> None: + async def send_baton_approval_request(self, message: BatonRequestMessage) -> None: """Sends message to current baton holder to request baton Parameters ---------- - message : PlotMessage + message : ClientMessage """ - requester = message.params + requester = message.requester if self.baton is None: logger.warning("Ignoring baton request as client does not have baton") elif requester in self.uuids: @@ -259,17 +258,17 @@ async def send_baton_approval_request(self, message: PlotMessage) -> None: else: logger.warning("Ignoring baton request by unknown %s", requester) - async def take_baton(self, message: PlotMessage) -> bool: + async def take_baton(self, message: BatonDonateMessage) -> bool: """Updates baton and sends new baton messages Parameters ---------- - message : PlotMessage + message : ClientMessage Returns ------- True if messages updated """ - uuid = message.params + uuid = message.receiver if uuid in self.uuids: self.baton = uuid await self.update_baton() @@ -335,14 +334,14 @@ async def clear_queues(self, plot_id: str): async def clear_plots(self, plot_id: str): """ - Sends message to clear plots to clients for a given plot_id + Sends message to clear plot to clients for a given plot_id Parameters ---------- plot_id : str ID of plot to which to send data message. """ - msg = ws_pack(ClearPlotsMessage(plot_id=plot_id)) + msg = ws_pack(ClearPlotMessage(plot_id=plot_id)) if msg is not None: for c in self._clients[plot_id]: await c.add_message(msg) @@ -382,7 +381,7 @@ async def benchmark(self, plot_id: str, params: _benchmark.BenchmarkParams) -> s else: await sleep(pause) - return f"Finished in {int((time_ns() - start)/1000000)}ms" + return f"Finished in {int((time_ns() - start) / 1000000)}ms" async def send_next_message(self): """Sends the next response on the response list and updates the client status""" @@ -393,8 +392,8 @@ async def send_next_message(self): await c.send_next_message() def convert_line_params_to_data_message( - self, plot_id: str, line_param_msg: ClientLineParametersMessage - ) -> MultiLineDataMessage: + self, plot_id: str, line_params: ClientLineParametersMessage + ) -> MultiLineMessage: """ Creates new MultiLineDataMessage from existing line data and new parameters @@ -407,18 +406,18 @@ def convert_line_params_to_data_message( """ ml_data_msg = self.plot_states[plot_id].current_data - if not isinstance(ml_data_msg, MultiLineDataMessage): + if not isinstance(ml_data_msg, MultiLineMessage): raise ValueError( f"Wrong type of message given: MultiLineDataMessage expected: {type(ml_data_msg)}" ) current_lines = ml_data_msg.ml_data - modified_line_params = line_param_msg.line_params + modified_line_params = line_params.line_params updated_lines = [] key_found = False for line in current_lines: - if not key_found and line.key == line_param_msg.key: + if not key_found and line.key == line_params.key: updated_line = LineData( key=line.key, line_params=modified_line_params, @@ -433,18 +432,18 @@ def convert_line_params_to_data_message( if not key_found: raise ValueError( - f"No line with key {line_param_msg.key} found in current line data {current_lines}" + f"No line with key {line_params.key} found in current line data {current_lines}" ) add_colour_to_lines(updated_lines) - return MultiLineDataMessage( + return MultiLineMessage( ml_data=updated_lines, plot_config=ml_data_msg.plot_config ) def convert_scatter_params_to_data_message( - self, plot_id: str, scatter_param_msg: ClientScatterParametersMessage - ) -> ScatterDataMessage: + self, plot_id: str, scatter_params: ClientScatterParametersMessage + ) -> ScatterMessage: """ Creates new ScatterDataMessage from existing scatter data and new parameters @@ -457,7 +456,7 @@ def convert_scatter_params_to_data_message( """ sc_data_msg = self.plot_states[plot_id].current_data - if not isinstance(sc_data_msg, ScatterDataMessage): + if not isinstance(sc_data_msg, ScatterMessage): raise ValueError( f"Wrong type of message given: ScatterDataMessage expected: {type(sc_data_msg)}" ) @@ -470,16 +469,14 @@ def convert_scatter_params_to_data_message( point_values=sc_data.point_values, domain=sc_data.domain, colour_map=sc_data.colour_map, - point_size=scatter_param_msg.point_size, + point_size=scatter_params.point_size, ) - return ScatterDataMessage( - sc_data=updated_data, plot_config=sc_data_msg.plot_config - ) + return ScatterMessage(sc_data=updated_data, plot_config=sc_data_msg.plot_config) def combine_line_messages( - self, plot_id: str, new_points_msg: AppendLineDataMessage - ) -> tuple[MultiLineDataMessage, AppendLineDataMessage]: + self, plot_id: str, new_points_msg: MultiLineMessage + ) -> tuple[MultiLineMessage, MultiLineMessage]: """ Adds indices to data message and appends points to current multi-line data message @@ -491,15 +488,18 @@ def combine_line_messages( new_points_msg : AppendLineDataMessage new points to append to current data lines. """ + if not new_points_msg.append: + raise ValueError(f"New data is not marked as append: {new_points_msg}") + ml_data_msg = self.plot_states[plot_id].current_data - if not isinstance(ml_data_msg, MultiLineDataMessage): + if not isinstance(ml_data_msg, MultiLineMessage): raise ValueError( f"Wrong type of message given: MultiLineDataMessage expected: {type(ml_data_msg)}" ) current_lines = ml_data_msg.ml_data - add_colour_to_lines(new_points_msg.al_data) - new_points = new_points_msg.al_data + add_colour_to_lines(new_points_msg.ml_data) + new_points = new_points_msg.ml_data default_indices = current_lines[0].default_indices current_lines_len = len(current_lines) new_points_len = len(new_points) @@ -577,17 +577,24 @@ def _append(a: DvDNDArray | None, b: DvDNDArray | None): combined_lines += extra_indexed_lines indexed_lines += extra_indexed_lines - new_points_msg.al_data = indexed_lines + new_points_msg.ml_data = indexed_lines return ( - MultiLineDataMessage( + MultiLineMessage( ml_data=combined_lines, plot_config=ml_data_msg.plot_config ), new_points_msg, ) async def update_plot_states_with_message( - self, msg: DataMessage | SelectionMessage | BatonMessage, plot_id: str + self, + plot_id: str, + msg: _BasePlotMessage + | _BaseSelectionsMessage + | BatonMessage + | ClientSelectionMessage + | ClientLineParametersMessage + | ClientScatterParametersMessage, ) -> bytes | None: """Indexes and combines line messages if needed and updates plot states @@ -598,6 +605,7 @@ async def update_plot_states_with_message( """ plot_state = self.plot_states[plot_id] new_msg = None + logger.debug("Updating plot state with %s", type(msg)) async with plot_state.lock: match msg: case BatonMessage(): @@ -605,27 +613,29 @@ async def update_plot_states_with_message( new_msg = plot_state.new_baton_message = ws_pack(msg) case SelectionsMessage(): - plot_state.current_selections = msg.set_selections - new_msg = plot_state.new_selections_message = ws_pack(msg) - - case UpdateSelectionsMessage(): - current = plot_state.current_selections - if current is None: - current = plot_state.current_selections = [] - extra = [] - for u in msg.update_selections: - uid = u.id - for i, c in enumerate(current): - if uid == c.id: - current[i] = u - break - else: - extra.append(u) - current.extend(extra) - plot_state.new_selections_message = ws_pack( - SelectionsMessage(set_selections=current) - ) - new_msg = ws_pack(msg) + logger.debug(msg) + if msg.update: + current = plot_state.current_selections + if current is None: + current = plot_state.current_selections = [] + extra = [] + for u in msg.set_selections: + uid = u.id + for i, c in enumerate(current): + if uid == c.id: + current[i] = u + break + else: + extra.append(u) + current.extend(extra) + plot_state.new_selections_message = ws_pack( + SelectionsMessage(set_selections=current) + ) + logger.debug("Updated selections for %s: %s", plot_id, current) + new_msg = ws_pack(msg) + else: + plot_state.current_selections = msg.set_selections + new_msg = plot_state.new_selections_message = ws_pack(msg) case ClientLineParametersMessage(): msg = plot_state.current_data = ( @@ -655,19 +665,38 @@ async def update_plot_states_with_message( ) new_msg = ws_pack(msg) - case AppendLineDataMessage(): - if isinstance(plot_state.current_data, MultiLineDataMessage): - combined_msgs, indexed_append_msgs = self.combine_line_messages( - plot_id, msg - ) - plot_state.current_data = combined_msgs - plot_state.new_data_message = ws_pack(plot_state.current_data) - msg = indexed_append_msgs + case MultiLineMessage(): + if any([not isinstance(d, LineData) for d in msg.ml_data]): + logger.warning("Not all line data!") + + data = [ + LineData.model_validate(d) if not isinstance(d, LineData) else d + for d in msg.ml_data + ] + msg.ml_data = check_line_names(data) + + if msg.append: + if isinstance(plot_state.current_data, MultiLineMessage): + combined_msgs, indexed_append_msgs = ( + self.combine_line_messages(plot_id, msg) + ) + plot_state.current_data = combined_msgs + plot_state.new_data_message = ws_pack( + plot_state.current_data + ) + msg = indexed_append_msgs + else: + add_default_indices(msg) + add_colour_to_lines(msg.ml_data) + else: - msg = convert_append_to_multi_line_data_message(msg) - new_msg = ws_pack(msg) + add_indices(msg) + add_colour_to_lines(msg.ml_data) + + plot_state.current_data = msg + new_msg = plot_state.new_data_message = ws_pack(msg) - case DataMessage(): + case _PlotDataMessage(): def check_cm( cm_id: str, data: HeatmapData | ScatterData | SurfaceData @@ -677,26 +706,25 @@ def check_cm( else: data.colour_map = self.last_colour_maps[cm_id] - if isinstance(msg, MultiLineDataMessage): - msg = add_indices(msg) - add_colour_to_lines(msg.ml_data) - elif isinstance(msg, ImageDataMessage) and isinstance( + if isinstance(msg, ImageMessage) and isinstance( msg.im_data, HeatmapData ): check_cm("HM:" + plot_id, msg.im_data) - elif isinstance(msg, ScatterDataMessage): + elif isinstance(msg, ScatterMessage): check_cm("SC:" + plot_id, msg.sc_data) - elif isinstance(msg, SurfaceDataMessage): + elif isinstance(msg, SurfaceMessage): check_cm("SU:" + plot_id, msg.su_data) plot_state.current_data = msg new_msg = plot_state.new_data_message = ws_pack(msg) + case _: + logger.warning("Did not handle update of %s", msg) + new_msg = None + return new_msg - async def prepare_data( - self, msg: PlotMessage, omit_client: PlotClient | None = None - ): + async def update(self, msg: _BasePlotMessage): """Processes PlotMessage into a client message and adds that to any client Parameters @@ -704,20 +732,42 @@ async def prepare_data( msg : PlotMessage A client message for processing. """ - plot_id = msg.plot_id - logger.debug("prepare_data %s: %s", type(msg), msg) - try: - processed_msg = self.processor.process(msg) - except Exception: - logger.error("Could not process message: %s", msg, exc_info=True) - return + await self._update_and_add_message(msg.plot_id, msg, None) - new_msg = await self.update_plot_states_with_message(processed_msg, plot_id) + async def _update_and_add_message(self, plot_id, processed_msg, omit_client): + new_msg = await self.update_plot_states_with_message(plot_id, processed_msg) if new_msg is not None: for c in self._clients[plot_id]: if c is not omit_client: await c.add_message(new_msg) + async def prepare_client( + self, plot_id: str, msg: ClientMessage, omit_client: PlotClient | None = None + ): + """Processes PlotMessage into a client message and adds that to any client + + Parameters + ---------- + msg : PlotMessage + A client message for processing. + """ + logger.debug("prepare_client %s: %s", type(msg), msg) + match msg: + case ClientSelectionMessage(): + processed_msg = SelectionsMessage( + update=True, set_selections=[msg.selection] + ) + case ( + ClientLineParametersMessage() + | ClientScatterParametersMessage() + | ClearSelectionsMessage() + ): + processed_msg = msg + case _: + raise ValueError(f"Client message unknown type: {type(msg)}") + + await self._update_and_add_message(plot_id, processed_msg, omit_client) + def clients_with_uuid(self, uuid: str): return (c for cl in self._clients.values() for c in cl if c.uuid == uuid) @@ -736,52 +786,51 @@ async def handle_client(server: PlotServer, plot_id: str, socket: WebSocket, uui message = ws_unpack(message["bytes"]) try: - received_message = PlotMessage.model_validate(message) - except ValidationError as v_err: - logger.warn("Ignoring message which may be corrupted: %s", v_err) - # continue - - if received_message.type == MsgType.status: - if received_message.params == StatusType.ready: - if initialize: - await client.send_next_message() - await client.send_next_message() # in case there are selections - - initialize = False + received_message = as_model(message) + except ValidationError: + logger.warning( + "Ignoring message which may be corrupted: %s", + message, + exc_info=True, + ) + continue + + match received_message: + case ClientStatusMessage(): + status = received_message.status + if status == StatusType.ready: + if initialize: + await client.send_next_message() + await ( + client.send_next_message() + ) # in case there are selections + + initialize = False + else: + server.client_status = StatusType.ready + update_all = True + elif status == StatusType.closing: + logger.info( + "Websocket closing for %s:%s", client.name, client.uuid + ) + update_all = await server.remove_client(plot_id, client) + break + case BatonRequestMessage(): + await server.send_baton_approval_request(received_message) + case BatonDonateMessage(): + if uuid == server.baton: + update_all = await server.take_baton(received_message) else: - server.client_status = StatusType.ready - update_all = True - elif received_message.params == StatusType.closing: - logger.info("Websocket closing for %s:%s", client.name, client.uuid) - update_all = await server.remove_client(plot_id, client) - break - - elif received_message.type == MsgType.baton_request: - await server.send_baton_approval_request(received_message) - - elif received_message.type == MsgType.baton_offer: - if uuid == server.baton: - update_all = await server.take_baton(received_message) - else: - logger.warning("Baton approval received from non-baton holder") - - else: # should process events from client (if that client is in control) - omit = None - mtype = received_message.type - - is_valid = True - if ( - mtype == MsgType.client_new_selection - or mtype == MsgType.client_update_selection - or mtype == MsgType.clear_selection_data - or mtype == MsgType.client_update_line_parameters - ): + logger.warning("Baton approval received from non-baton holder") + case _: + omit = None + + is_valid = True logger.debug( - "Got from %s (%s) from client %s: %s", + "Got from %s from client %s: %s", plot_id, - mtype, client.uuid, - received_message.params, + received_message, ) is_valid = client.uuid == server.baton if is_valid: @@ -792,9 +841,11 @@ async def handle_client(server: PlotServer, plot_id: str, socket: WebSocket, uui client.uuid, ) - if is_valid: - await server.prepare_data(received_message, omit_client=omit) - update_all = True + if is_valid: + await server.prepare_client( + plot_id, received_message, omit_client=omit + ) + update_all = True if update_all: await server.send_next_message() @@ -809,46 +860,48 @@ async def handle_client(server: PlotServer, plot_id: str, socket: WebSocket, uui await server.send_next_message() -def add_indices(msg: MultiLineDataMessage) -> MultiLineDataMessage: - """Adds default indices to a multi-line data message if default indices flag +def check_line_names(lines: list[LineData]) -> list[LineData]: + """Autonames lines that do not have names""" + used_names = set(line.line_params.name for line in lines if line.line_params.name) + index = 0 + for line in lines: + if not line.line_params.name: + new_name = f"Line {index}" + index += 1 + while new_name in used_names: + new_name = f"Line {index}" + index += 1 + line.line_params.name = new_name + used_names.add(new_name) + return lines + + +def add_indices(msg: MultiLineMessage) -> None: + """Adds default indices to a multiline message if default indices flag is True Parameters ---------- msg : MultiLineDataMessage A multi-line data message to which to add indices. - - Returns the new multi-line data message """ if msg.ml_data[0].default_indices: for m in msg.ml_data: m.x = np.arange(m.y.size, dtype=np.min_scalar_type(m.y.size)) - return msg -def convert_append_to_multi_line_data_message( - msg: AppendLineDataMessage, -) -> MultiLineDataMessage: - """Converts append line data message to a multi-line data message and adds default - indices if any default indices flag is True - indices if any default indices flag is True +def add_default_indices( + msg: MultiLineMessage, +) -> None: + """Adds default indices if any default indices flag is True Parameters ---------- - msg : AppendLineDataMessage - An append line data message to convert to a multi-line data message. - - Returns the new multi-line data message + msg : MultiLineMessage + A multiline message to add default indices """ - default_indices = any([a.default_indices for a in msg.al_data]) + default_indices = any([a.default_indices for a in msg.ml_data]) if default_indices: - for m in msg.al_data: + for m in msg.ml_data: m.x = np.arange(m.y.size, dtype=np.min_scalar_type(m.y.size)) m.default_indices = True - - add_colour_to_lines(msg.al_data) - - return MultiLineDataMessage( - plot_config=msg.plot_config, - ml_data=msg.al_data, - ) diff --git a/server/davidia/server/processor.py b/server/davidia/server/processor.py deleted file mode 100644 index cac8e069..00000000 --- a/server/davidia/server/processor.py +++ /dev/null @@ -1,217 +0,0 @@ -from __future__ import annotations - -from pydantic.alias_generators import to_camel - -from ..models.messages import ( - AppendLineDataMessage, - PlotConfig, - ClearSelectionsMessage, - ClientScatterParametersMessage, - ClientSelectionMessage, - ClientLineParametersMessage, - HeatmapData, - ImageData, - ImageDataMessage, - LineData, - LineParams, - MsgType, - MultiLineDataMessage, - PlotMessage, - ScatterData, - ScatterDataMessage, - SelectionsMessage, - SurfaceData, - SurfaceDataMessage, - TableData, - TableDataMessage, - UpdateSelectionsMessage, -) -from ..models.selections import AnySelection, as_selection - - -def check_line_names(lines: list[LineData]) -> list[LineData]: - """Autonames lines that do not have names""" - used_names = set(line.line_params.name for line in lines if line.line_params.name) - index = 0 - for line in lines: - if not line.line_params.name: - new_name = f"Line {index}" - index += 1 - while new_name in used_names: - new_name = f"Line {index}" - index += 1 - line.line_params.name = new_name - used_names.add(new_name) - return lines - - -def extract_selection(raw: dict, entry: str) -> AnySelection | list[AnySelection]: - if entry not in raw: - camel_entry = to_camel(entry) - if camel_entry == entry or camel_entry not in raw: - raise ValueError( - f"{entry} and its camelCase {camel_entry} not found in message" - ) - entry = camel_entry - raw_selection = raw[entry] - if isinstance(raw_selection, list): - return [as_selection(s) for s in raw_selection] - return as_selection(raw_selection) - - -class Processor: - """ - A Processor class used to convert new data request messages to data messages - - ... - - Methods - ------- - process(message: PlotMessage) -> MultiLineDataMessage | ImageDataMessage - Converts a PlotMessage to a processed data message - prepare_new_multiline_data_message(params: list(LineData) - ) -> MultiLineDataMessage: - Converts parameters for new lines to a new multiline data messages - """ - - def __init__(self): - pass - - def process( - self, message: PlotMessage - ) -> ( - ClearSelectionsMessage - | MultiLineDataMessage - | AppendLineDataMessage - | ImageDataMessage - | ScatterDataMessage - | SelectionsMessage - | SurfaceDataMessage - | TableDataMessage - | UpdateSelectionsMessage - ): - """Converts a PlotMessage to processed data - - Parameters - ---------- - message : PlotMessage - The message for processing - - Returns - ------- - ClearSelectionsMessage | MultiLineDataMessage | AppendLineDataMessage - | ImageDataMessage | ScatterDataMessage | SurfaceDataMessage | TableDataMessage - | UpdateSelectionsMessage - The processed data as a message - - Raises - ------ - ValueError - If message type is unexpected. - """ - - plot_config = getattr(message, "plot_config", None) - if plot_config is None: - plot_config = PlotConfig() - elif not isinstance(plot_config, PlotConfig): - plot_config = PlotConfig.model_validate(plot_config) - - params = message.params - match message.type: - case MsgType.new_multiline_data | MsgType.append_line_data: - params = [ - LineData.model_validate(p) if not isinstance(p, LineData) else p - for p in params - ] - append = message.type == MsgType.append_line_data - return self.prepare_new_multiline_data_message( - params, plot_config, append=append - ) - case MsgType.new_image_data: - if not isinstance(params, ImageData): - if "domain" in params: - params = HeatmapData.model_validate(params) - else: - params = ImageData.model_validate(params) - return ImageDataMessage(im_data=params, plot_config=plot_config) - case MsgType.new_scatter_data: - if not isinstance(params, ScatterData): - params = ScatterData.model_validate(params) - return ScatterDataMessage(sc_data=params, plot_config=plot_config) - case MsgType.new_surface_data: - if not isinstance(params, SurfaceData): - params = SurfaceData.model_validate(params) - return SurfaceDataMessage(su_data=params, plot_config=plot_config) - case MsgType.new_table_data: - if not isinstance(params, TableData): - params = TableData.model_validate(params) - return TableDataMessage(ta_data=params, plot_config=plot_config) - case MsgType.client_new_selection | MsgType.client_update_selection: - if not isinstance(params, ClientSelectionMessage): - params = ClientSelectionMessage( - selection=extract_selection(params, "selection") - ) - return UpdateSelectionsMessage(update_selections=[params.selection]) - case MsgType.client_update_line_parameters: - if not isinstance(params, ClientLineParametersMessage): - key = params["key"] - line = params["line_params"] - line_params = ( - LineParams.model_validate(line) - if not isinstance(line, LineParams) - else line - ) - params = ClientLineParametersMessage( - key=key, line_params=line_params - ) - return params - case MsgType.client_update_scatter_parameters: - if not isinstance(params, ClientScatterParametersMessage): - params = ClientScatterParametersMessage( - point_size=params["point_size"] - ) - return params - case MsgType.new_selection_data: - if not isinstance(params, SelectionsMessage): - params = SelectionsMessage( - set_selections=extract_selection(params, "set_selections") - ) - return params - case MsgType.update_selection_data: - if not isinstance(params, UpdateSelectionsMessage): - params = UpdateSelectionsMessage( - update_selections=extract_selection(params, "update_selections") - ) - return params - case MsgType.clear_selection_data: - if not isinstance(params, ClearSelectionsMessage): - params = ClearSelectionsMessage.model_validate(params) - return params - case _: - # not covered by tests - raise ValueError(f"message type not in list: {message.type}") - - def prepare_new_multiline_data_message( - self, params: list[LineData], plot_config: PlotConfig, append=False - ) -> MultiLineDataMessage | AppendLineDataMessage: - """Converts parameters for a new line to processed new line data - - Parameters - ---------- - params : list[LineData] - List of line data parameters to be processed to new multiline data - plot_config : PlotConfig - Plot configuration parameters - append : returns AppendLineDataMessage - - Returns - ------- - MultiLineDataMessage | AppendLineDataMessage - New multiline data message. - """ - - params = check_line_names(params) - - if append: - return AppendLineDataMessage(al_data=params, plot_config=plot_config) - return MultiLineDataMessage(ml_data=params, plot_config=plot_config) diff --git a/server/davidia/tests/test_api.py b/server/davidia/tests/test_api.py index 31dbfd9e..53bb5b48 100644 --- a/server/davidia/tests/test_api.py +++ b/server/davidia/tests/test_api.py @@ -14,9 +14,7 @@ DvDNDArray, LineData, LineParams, - MsgType, - MultiLineDataMessage, - PlotMessage, + MultiLineMessage, StatusType, ) from davidia.server.fastapi_utils import ( @@ -27,12 +25,13 @@ ws_unpack, ) from fastapi.testclient import TestClient -from httpx import AsyncClient +from httpx import AsyncClient, ASGITransport from pydantic import BaseModel from pydantic_numpy.model import NumpyModel -def test_status_ws(): +@pytest.mark.asyncio +async def test_status_ws(): data_0 = [ LineData( key="line_0", @@ -60,10 +59,8 @@ def test_status_ws(): y=[0, 10, 40, 10, 0], ), ] - plot_msg_0 = PlotMessage( - plot_id="plot_0", type=MsgType.new_multiline_data, params=data_0 - ) - msg_0 = plot_msg_0.model_dump(by_alias=True) + plot_msg_0 = MultiLineMessage(plot_id="plot_0", ml_data=data_0) + data_1 = [ LineData( key="line_0", @@ -91,10 +88,7 @@ def test_status_ws(): y=[0, 20, 30, 10, 10], ), ] - plot_msg_1 = PlotMessage( - plot_id="plot_1", type=MsgType.new_multiline_data, params=data_1 - ) - msg_1 = plot_msg_1.model_dump(by_alias=True) + plot_msg_1 = MultiLineMessage(plot_id="plot_1", ml_data=data_1) data_2 = LineData( key="new_line", @@ -102,14 +96,11 @@ def test_status_ws(): x=[10, 20, 30], y=[-3, -1, 5], ) - plot_msg_2 = PlotMessage( - plot_id="plot_0", type=MsgType.new_multiline_data, params=[data_2] - ) - msg_2 = plot_msg_2.model_dump(by_alias=True) + plot_msg_2 = MultiLineMessage(plot_id="plot_0", ml_data=[data_2]) app = _create_bare_app() - with TestClient(app) as client: + with TestClient(app=app) as client: with client.websocket_connect("/plot/30064551/plot_0") as ws_0: with client.websocket_connect("/plot/30064551/plot_1") as ws_1: ps = getattr(app, "_plot_server") @@ -130,7 +121,8 @@ def test_status_ws(): assert plot_state_1.current_data is None assert plot_state_1.current_selections is None - ws_0.send_bytes(ws_pack(msg_0)) + await ps.update(plot_msg_0) + await ps.send_next_message() time.sleep(1) assert ps.client_status == StatusType.busy assert plot_state_0.current_data @@ -142,7 +134,8 @@ def test_status_ws(): assert plot_state_1.current_data is None assert plot_state_1.current_selections is None - ws_1.send_bytes(ws_pack(msg_1)) + await ps.update(plot_msg_1) + await ps.send_next_message() time.sleep(1) assert ps.client_status == StatusType.busy assert len(client_0) == 1 @@ -154,9 +147,7 @@ def test_status_ws(): ws_0.send_bytes( ws_pack( { - "plot_id": "plot_0", - "type": "status", - "params": "ready", + "status": "ready", } ) ) @@ -183,9 +174,7 @@ def test_status_ws(): ws_1.send_bytes( ws_pack( { - "plot_id": "plot_1", - "type": "status", - "params": "ready", + "status": "ready", } ) ) @@ -202,12 +191,13 @@ def test_status_ws(): rec_text_1_1["mlData"][1]["x"], np.array([3, 5, 7, 9, 11]) ) - ws_0.send_bytes(ws_pack(msg_2)) + await ps.update(plot_msg_2) + await ps.send_next_message() time.sleep(1) assert ps.client_status == StatusType.busy received_new_line = ws_0.receive() rec_data = ws_unpack(received_new_line["bytes"]) - line_msg = MultiLineDataMessage.model_validate(rec_data) + line_msg = MultiLineMessage.model_validate(rec_data) assert line_msg is not None @@ -234,13 +224,13 @@ async def test_get_data(send, receive): y=[20, 30, 40, 50, 60], ) - new_line = PlotMessage( - plot_id="plot_0", type=MsgType.new_multiline_data, params=[line] - ) + new_line = MultiLineMessage(plot_id="plot_0", ml_data=[line]) app = _create_bare_app() - async with AsyncClient(app=app, base_url="http://test") as ac: + async with AsyncClient( + transport=ASGITransport(app=app), base_url="http://test" + ) as ac: headers = {} if send.mime_type: headers["Content-Type"] = send.mime_type @@ -261,7 +251,9 @@ async def test_clear_data_via_message(): with client.websocket_connect("/plot/8123f452/plot_0"): with client.websocket_connect("/plot/fc8ed0e5/plot_1"): - async with AsyncClient(app=app, base_url="http://test") as ac: + async with AsyncClient( + transport=ASGITransport(app=app), base_url="http://test" + ) as ac: response = await ac.put( "/clear_data/plot_0", params={}, @@ -296,9 +288,7 @@ async def test_push_points(): x=x, y=y, ) - new_line = PlotMessage( - plot_id="plot_0", type=MsgType.new_multiline_data, params=[line] - ) + new_line = MultiLineMessage(plot_id="plot_0", ml_data=[line]) msg = ws_pack(new_line) headers = { "Content-Type": "application/x-msgpack", @@ -308,7 +298,9 @@ async def test_push_points(): with TestClient(app) as client: with client.websocket_connect("/plot/99a81b01/plot_0"): - async with AsyncClient(app=app, base_url="http://test") as ac: + async with AsyncClient( + transport=ASGITransport(app=app), base_url="http://test" + ) as ac: response = await ac.post("/push_data", content=msg, headers=headers) assert response.status_code == 200 assert ws_unpack(response._content) == "data sent" @@ -381,7 +373,9 @@ async def pydantic_test(data: TrialA) -> TrialB: ) return result - async with AsyncClient(app=app, base_url="http://test") as ac: + async with AsyncClient( + transport=ASGITransport(app=app), base_url="http://test" + ) as ac: headers = {} if send.mime_type: headers["Content-Type"] = send.mime_type diff --git a/server/davidia/tests/test_plotserver.py b/server/davidia/tests/test_plotserver.py index 9691c06f..da27fa3a 100644 --- a/server/davidia/tests/test_plotserver.py +++ b/server/davidia/tests/test_plotserver.py @@ -8,20 +8,15 @@ import numpy as np import pytest from davidia.models.messages import ( - AppendLineDataMessage, ClearSelectionsMessage, - DataMessage, DvDNDArray, LineData, LineParams, - MsgType, - MultiLineDataMessage, - PlotMessage, + MultiLineMessage, SelectionsMessage, StatusType, TableData, - TableDataMessage, - UpdateSelectionsMessage, + TableMessage, ) from davidia.models.parameters import PlotConfig from davidia.models.selections import LinearSelection, RectangularSelection @@ -31,9 +26,8 @@ PlotServer, PlotState, add_indices, - convert_append_to_multi_line_data_message, -) -from davidia.server.processor import ( + add_default_indices, + add_colour_to_lines, check_line_names, ) @@ -74,25 +68,20 @@ async def test_send_points(): x = np.array([i for i in range(50)]) y = np.array([j % 10 for j in x]) time_id = datetime.datetime.now().strftime("%Y%m%d%H%M%S") - new_line = PlotMessage( + new_line = MultiLineMessage( plot_id="plot_0", - type=MsgType.new_multiline_data, - params=[ - { - "key": time_id, - "x": x, - "y": y, - "line_params": LineParams(colour="purple"), - } + ml_data=[ + LineData(key=time_id, x=x, y=y, line_params=LineParams(colour="purple")) ], ) - processed_line = ps.processor.process(new_line) + await ps.update(new_line) + processed_line = new_line line_as_dict = processed_line.model_dump(by_alias=True) msg = ws_pack(line_as_dict) assert msg is not None - plot_state_0.current_data = line_as_dict # pyright: ignore[reportGeneralTypeIssues] + plot_state_0.current_data = line_as_dict # pyrefly: ignore[bad-argument-type] plot_state_0.new_data_message = msg assert not ps.clients_available() @@ -157,21 +146,13 @@ def line_params_are_equal(a: LineParams, b: LineParams) -> bool: def assert_line_data_messages_are_equal( - a: MultiLineDataMessage | AppendLineDataMessage, - b: MultiLineDataMessage | AppendLineDataMessage, + a: MultiLineMessage, + b: MultiLineMessage, ): assert a.plot_config == b.plot_config - if isinstance(a, MultiLineDataMessage) and isinstance(b, MultiLineDataMessage): - assert len(a.ml_data) == len(b.ml_data) - assert all([line_data_are_equal(c, d) for c, d in zip(a.ml_data, b.ml_data)]) - elif isinstance(a, AppendLineDataMessage) and isinstance(b, AppendLineDataMessage): - assert len(a.al_data) == len(b.al_data) - assert all([line_data_are_equal(c, d) for c, d in zip(a.al_data, b.al_data)]) - else: - raise AssertionError( - "a and b must both be either MultiLineDataMessage or" - f" AppendLineDataMessage: {a}, {b}" - ) + assert a.append == b.append + assert len(a.ml_data) == len(b.ml_data) + assert all([line_data_are_equal(c, d) for c, d in zip(a.ml_data, b.ml_data)]) def generate_test_data( @@ -232,109 +213,116 @@ def generate_test_data( ( "equal_length_no_default", "plot_0", - MultiLineDataMessage( + MultiLineMessage( ml_data=[generate_test_data("100"), generate_test_data("200")] ), - AppendLineDataMessage( - al_data=[generate_test_data("010"), generate_test_data("020")] + MultiLineMessage( + append=True, + ml_data=[generate_test_data("010"), generate_test_data("020")], ), ( - MultiLineDataMessage( + MultiLineMessage( ml_data=[ generate_test_data("100", combined=True), generate_test_data("200", combined=True), ] ), - AppendLineDataMessage( - al_data=[ + MultiLineMessage( + append=True, + ml_data=[ generate_test_data("010", colour="#009e73"), generate_test_data("020", colour="#e69d00"), - ] + ], ), ), ), ( "more_current_data_no_default", "plot_0", - MultiLineDataMessage( + MultiLineMessage( ml_data=[ generate_test_data("100"), generate_test_data("200", x=True, high=True), generate_test_data("300", high=True), ] ), - AppendLineDataMessage( - al_data=[ + MultiLineMessage( + append=True, + ml_data=[ generate_test_data("010"), generate_test_data("020", high=True), - ] + ], ), ( - MultiLineDataMessage( + MultiLineMessage( ml_data=[ generate_test_data("100", combined=True), generate_test_data("200", combined=True, high=True), generate_test_data("300", high=True), ] ), - AppendLineDataMessage( - al_data=[ + MultiLineMessage( + append=True, + ml_data=[ generate_test_data("010", colour="#009e73"), generate_test_data("020", high=True, colour="#e69d00"), - ] + ], ), ), ), ( "more_append_data_no_default", "plot_0", - MultiLineDataMessage( + MultiLineMessage( ml_data=[ generate_test_data("100"), generate_test_data("200", high=True), ] ), - AppendLineDataMessage( - al_data=[ + MultiLineMessage( + append=True, + ml_data=[ generate_test_data("010"), generate_test_data("020", high=True), generate_test_data("030", high=True), - ] + ], ), ( - MultiLineDataMessage( + MultiLineMessage( ml_data=[ generate_test_data("100", combined=True), generate_test_data("200", combined=True, high=True), generate_test_data("030", high=True, colour="#56b3e9"), ] ), - AppendLineDataMessage( - al_data=[ + MultiLineMessage( + append=True, + ml_data=[ generate_test_data("010", colour="#009e73"), generate_test_data("020", high=True, colour="#e69d00"), generate_test_data("030", high=True, colour="#56b3e9"), - ] + ], ), ), ), ( "equal_length_default_indices", "plot_1", - MultiLineDataMessage( + MultiLineMessage( ml_data=[ generate_test_data("100", default_indices=True, colour="#009e73"), generate_test_data("200", default_indices=True, colour="#e69d00"), ] ), - AppendLineDataMessage( - al_data=[ + MultiLineMessage( + append=True, + ml_data=[ generate_test_data("010", x=False), generate_test_data("020", high=False), - ] + ], ), ( - MultiLineDataMessage( + MultiLineMessage( ml_data=[ generate_test_data( "100", default_indices=True, combined=True, colour="#009e73" @@ -344,108 +332,114 @@ def generate_test_data( ), ] ), - AppendLineDataMessage( - al_data=[ + MultiLineMessage( + append=True, + ml_data=[ generate_test_data( "010", default_indices=True, colour="#009e73" ), generate_test_data( "020", default_indices=True, colour="#e69d00" ), - ] + ], ), ), ), ( "more_current_data_default_indices", "plot_0", - MultiLineDataMessage( + MultiLineMessage( ml_data=[ generate_test_data("100", default_indices=True), generate_test_data("200", default_indices=True), generate_test_data("300", default_indices=True), ] ), - AppendLineDataMessage( - al_data=[ + MultiLineMessage( + append=True, + ml_data=[ generate_test_data("010", x=False), generate_test_data("020", x=False), - ] + ], ), ( - MultiLineDataMessage( + MultiLineMessage( ml_data=[ generate_test_data("100", default_indices=True, combined=True), generate_test_data("200", default_indices=True, combined=True), generate_test_data("300", default_indices=True), ] ), - AppendLineDataMessage( - al_data=[ + MultiLineMessage( + append=True, + ml_data=[ generate_test_data( "010", default_indices=True, colour="#009e73" ), generate_test_data( "020", default_indices=True, colour="#e69d00" ), - ] + ], ), ), ), ( "more_current_data_default_indices_some_indices_given", "plot_0", - MultiLineDataMessage( + MultiLineMessage( ml_data=[ generate_test_data("100", default_indices=True), generate_test_data("200", default_indices=True), generate_test_data("300", default_indices=True), ] ), - AppendLineDataMessage( - al_data=[ + MultiLineMessage( + append=True, + ml_data=[ generate_test_data("010", x=False), generate_test_data("020", high=True), - ] + ], ), ( - MultiLineDataMessage( + MultiLineMessage( ml_data=[ generate_test_data("100", default_indices=True, combined=True), generate_test_data("200", default_indices=True, combined=True), generate_test_data("300", default_indices=True), ] ), - AppendLineDataMessage( - al_data=[ + MultiLineMessage( + append=True, + ml_data=[ generate_test_data( "010", default_indices=True, colour="#009e73" ), generate_test_data( "020", default_indices=True, colour="#e69d00" ), - ] + ], ), ), ), ( "more_append_data_default_indices_indices_given", "plot_0", - MultiLineDataMessage( + MultiLineMessage( ml_data=[ generate_test_data("100", default_indices=True), generate_test_data("200", default_indices=True), ] ), - AppendLineDataMessage( - al_data=[ + MultiLineMessage( + append=True, + ml_data=[ generate_test_data("010"), generate_test_data("020", high=True), generate_test_data("030", high=True), - ] + ], ), ( - MultiLineDataMessage( + MultiLineMessage( ml_data=[ generate_test_data("100", default_indices=True, combined=True), generate_test_data("200", default_indices=True, combined=True), @@ -454,8 +448,9 @@ def generate_test_data( ), ] ), - AppendLineDataMessage( - al_data=[ + MultiLineMessage( + append=True, + ml_data=[ generate_test_data( "010", default_indices=True, colour="#009e73" ), @@ -465,28 +460,29 @@ def generate_test_data( generate_test_data( "030", default_indices=True, colour="#56b3e9" ), - ] + ], ), ), ), ( "more_append_data_default_indices", "plot_0", - MultiLineDataMessage( + MultiLineMessage( ml_data=[ generate_test_data("100", default_indices=True), generate_test_data("200", default_indices=True), ] ), - AppendLineDataMessage( - al_data=[ + MultiLineMessage( + append=True, + ml_data=[ generate_test_data("010", x=False), generate_test_data("020", x=False), generate_test_data("030", x=False), - ] + ], ), ( - MultiLineDataMessage( + MultiLineMessage( ml_data=[ generate_test_data("100", default_indices=True, combined=True), generate_test_data("200", default_indices=True, combined=True), @@ -495,8 +491,9 @@ def generate_test_data( ), ] ), - AppendLineDataMessage( - al_data=[ + MultiLineMessage( + append=True, + ml_data=[ generate_test_data( "010", default_indices=True, colour="#009e73" ), @@ -506,7 +503,7 @@ def generate_test_data( generate_test_data( "030", default_indices=True, colour="#56b3e9" ), - ] + ], ), ), ), @@ -515,9 +512,9 @@ def generate_test_data( def test_combine_line_messages( name: str, plot_id: str, - current_data: DataMessage, - new_points_msg: AppendLineDataMessage, - expected: tuple[MultiLineDataMessage, AppendLineDataMessage], + current_data: MultiLineMessage, + new_points_msg: MultiLineMessage, + expected: tuple[MultiLineMessage, MultiLineMessage], ): ps = PlotServer() ps.plot_states[plot_id].current_data = current_data @@ -541,8 +538,8 @@ async def test_add_and_remove_clients(caplog): plot_state_0 = ps.plot_states["plot_0"] plot_state_0.new_data_message = msg_00 plot_state_0.new_selections_message = msg_01 - plot_state_0.current_data = data_0 # pyright: ignore[reportGeneralTypeIssues] - plot_state_0.current_selections = selection_0 # pyright: ignore[reportGeneralTypeIssues] + plot_state_0.current_data = data_0 # pyrefly: ignore[bad-argument-type] + plot_state_0.current_selections = selection_0 # pyrefly: ignore[bad-argument-type] def update_plot_state(pc, bytes): time.sleep(2) @@ -574,12 +571,14 @@ def update_plot_state(pc, bytes): assert ps._clients["plot_0"] == [pc_0, pc_1] await ps.remove_client("plot_0", pc_0) - + assert len(caplog.text) == 0 assert ps._clients == defaultdict(list, {"plot_0": [pc_1]}) + # try to remove unknown client await ps.remove_client("plot_0", pc_0) assert ps._clients == defaultdict(list, {"plot_0": [pc_1]}) assert "Client plot_0:0 does not exist for plot_0" in caplog.text + assert f"Uuid {pc_0.uuid} not in uuids {ps.uuids}" in caplog.text def test_get_plot_ids(): @@ -587,8 +586,8 @@ def test_get_plot_ids(): assert ps.get_plot_ids() == [] - ps._clients["plot_0"].append("0") # pyright: ignore[reportGeneralTypeIssues] - ps._clients["plot_1"].append("1") # pyright: ignore[reportGeneralTypeIssues] + ps._clients["plot_0"].append("0") # ppyrefly: ignore[bad-argument-type] + ps._clients["plot_1"].append("1") # pyrefly: ignore[bad-argument-type] assert ps.get_plot_ids() == ["plot_0", "plot_1"] @@ -603,11 +602,10 @@ async def test_regions(): ] ps = PlotServer() pid = "plot_7" - await ps.prepare_data( - PlotMessage( + await ps.update( + SelectionsMessage( plot_id=pid, - type=MsgType.new_selection_data, - params=SelectionsMessage(set_selections=regions), + set_selections=regions, ) ) new_regions = await ps.get_regions(pid) @@ -618,11 +616,11 @@ async def test_regions(): new_region = RectangularSelection( start=(4.3, -5.6), lengths=(6, 7.8), degrees=0, colour="orange", alpha=0.7 ) - await ps.prepare_data( - PlotMessage( + await ps.update( + SelectionsMessage( plot_id=pid, - type=MsgType.update_selection_data, - params=UpdateSelectionsMessage(update_selections=[new_region]), + update=True, + set_selections=[new_region], ) ) new_regions = await ps.get_regions(pid) @@ -635,11 +633,11 @@ async def test_regions(): update = 2 updated_region = expected_regions[update] updated_region.angle = 123.4 - await ps.prepare_data( - PlotMessage( + await ps.update( + SelectionsMessage( plot_id=pid, - type=MsgType.update_selection_data, - params=UpdateSelectionsMessage(update_selections=[new_region]), + update=True, + set_selections=[new_region], ) ) new_regions = await ps.get_regions(pid) @@ -649,11 +647,10 @@ async def test_regions(): assert e == a remove = 1 - await ps.prepare_data( - PlotMessage( + await ps.update( + ClearSelectionsMessage( plot_id=pid, - type=MsgType.clear_selection_data, - params=ClearSelectionsMessage(selection_ids=[new_regions[remove].id]), + selection_ids=[new_regions[remove].id], ) ) new_regions = await ps.get_regions(pid) @@ -662,11 +659,10 @@ async def test_regions(): for e, a in zip(expected_regions, new_regions): assert e == a - await ps.prepare_data( - PlotMessage( + await ps.update( + ClearSelectionsMessage( plot_id=pid, - type=MsgType.clear_selection_data, - params=ClearSelectionsMessage(selection_ids=[]), + selection_ids=[], ) ) new_regions = await ps.get_regions(pid) @@ -678,8 +674,8 @@ def test_clients_available(): assert not ps.clients_available() - ps._clients["plot_0"].append("0") # pyright: ignore[reportGeneralTypeIssues] - ps._clients["plot_1"].append("1") # pyright: ignore[reportGeneralTypeIssues] + ps._clients["plot_0"].append("0") # pyrefly: ignore[bad-argument-type] + ps._clients["plot_1"].append("1") # pyrefly: ignore[bad-argument-type] assert ps.clients_available() @@ -759,7 +755,7 @@ def add_current_data(plotserver: PlotServer, plot_id: str): plot_state_1.current_data = { "a": 10, "b": 20, - } # pyright: ignore[reportGeneralTypeIssues] + } # pyrefly: ignore[bad-argument-type] with before_after.after( "davidia.server.plotserver.PlotServer.clear_plot_states", add_current_data @@ -770,14 +766,14 @@ def add_current_data(plotserver: PlotServer, plot_id: str): @pytest.mark.asyncio -async def test_prepare_data(): +async def test_update(): ps = PlotServer() websocket_0 = AsyncMock() pc_0 = PlotClient(websocket_0, "fc1e5b22") pc_0.name = "plot_client_0" ps.client_total = 1 ps._clients["plot_0"].append(pc_0) - data_0 = MultiLineDataMessage( + data_0 = MultiLineMessage( ml_data=[ LineData( key="", @@ -790,10 +786,10 @@ async def test_prepare_data(): selection_0 = {"selection0": 30} msg_00 = ws_pack(data_0) msg_01 = ws_pack(selection_0) - append_line = PlotMessage( + append_line = MultiLineMessage( plot_id="plot_0", - type=MsgType.append_line_data, - params=[ + append=True, + ml_data=[ { "line_params": LineParams(colour="purple"), "x": np.array([3, 4, 5]), @@ -812,18 +808,19 @@ async def test_prepare_data(): def change_plot_states(plotserver, plot_id, new_points_msg): if not plot_state_0.lock.locked(): - ta_msg = TableDataMessage( + ta_msg = TableMessage( + plot_id="plot_0", ta_data=TableData( key="", cell_values=np.array([[1, 2], [3, 4]]), cell_width=120 - ) + ), ) plot_state_0.current_data = ta_msg with before_after.after( "davidia.server.plotserver.PlotServer.combine_line_messages", change_plot_states ): - await ps.prepare_data(append_line) - assert isinstance(plot_state_0.current_data, MultiLineDataMessage) + await ps.update(append_line) + assert isinstance(plot_state_0.current_data, MultiLineMessage) @pytest.mark.parametrize( @@ -831,25 +828,25 @@ def change_plot_states(plotserver, plot_id, new_points_msg): [ ( "no_default_indices", - MultiLineDataMessage( + MultiLineMessage( plot_config=PlotConfig(), ml_data=[generate_test_data("100"), generate_test_data("200")], ), - MultiLineDataMessage( + MultiLineMessage( plot_config=PlotConfig(), ml_data=[generate_test_data("100"), generate_test_data("200")], ), ), ( "default_indices", - MultiLineDataMessage( + MultiLineMessage( plot_config=PlotConfig(), ml_data=[ generate_test_data("100", x=False), generate_test_data("030", x=False), ], ), - MultiLineDataMessage( + MultiLineMessage( plot_config=PlotConfig(), ml_data=[ generate_test_data("100", default_indices=True), @@ -859,10 +856,10 @@ def change_plot_states(plotserver, plot_id, new_points_msg): ), ], ) -def test_add_indices(name, msg: MultiLineDataMessage, expected: MultiLineDataMessage): - message = add_indices(msg) +def test_add_indices(name, msg: MultiLineMessage, expected: MultiLineMessage): + add_indices(msg) - assert_line_data_messages_are_equal(message, expected) + assert_line_data_messages_are_equal(msg, expected) @pytest.mark.parametrize( @@ -870,11 +867,12 @@ def test_add_indices(name, msg: MultiLineDataMessage, expected: MultiLineDataMes [ ( "no_default_indices", - AppendLineDataMessage( + MultiLineMessage( + append=True, plot_config=PlotConfig(), - al_data=[generate_test_data("100"), generate_test_data("200")], + ml_data=[generate_test_data("100"), generate_test_data("200")], ), - MultiLineDataMessage( + MultiLineMessage( plot_config=PlotConfig(), ml_data=[ generate_test_data("100", colour="#009e73"), @@ -884,14 +882,15 @@ def test_add_indices(name, msg: MultiLineDataMessage, expected: MultiLineDataMes ), ( "all_default_indices", - AppendLineDataMessage( + MultiLineMessage( + append=True, plot_config=PlotConfig(), - al_data=[ + ml_data=[ generate_test_data("100", x=False), generate_test_data("030", x=False), ], ), - MultiLineDataMessage( + MultiLineMessage( plot_config=PlotConfig(), ml_data=[ generate_test_data("100", default_indices=True, colour="#009e73"), @@ -901,11 +900,12 @@ def test_add_indices(name, msg: MultiLineDataMessage, expected: MultiLineDataMes ), ( "some_default_indices", - AppendLineDataMessage( + MultiLineMessage( + append=True, plot_config=PlotConfig(), - al_data=[generate_test_data("100"), generate_test_data("030", x=False)], + ml_data=[generate_test_data("100"), generate_test_data("030", x=False)], ), - MultiLineDataMessage( + MultiLineMessage( plot_config=PlotConfig(), ml_data=[ generate_test_data("100", default_indices=True, colour="#009e73"), @@ -916,11 +916,12 @@ def test_add_indices(name, msg: MultiLineDataMessage, expected: MultiLineDataMes ], ) def test_convert_append_to_multi_line_data_message( - name, msg: AppendLineDataMessage, expected: MultiLineDataMessage + name, msg: MultiLineMessage, expected: MultiLineMessage ): - message = convert_append_to_multi_line_data_message(msg) - - assert_line_data_messages_are_equal(message, expected) + add_default_indices(msg) + add_colour_to_lines(msg.ml_data) + msg.append = False + assert_line_data_messages_are_equal(msg, expected) @pytest.mark.parametrize( diff --git a/server/demos/simple.py b/server/demos/simple.py index d1a0e1fe..63783379 100644 --- a/server/demos/simple.py +++ b/server/demos/simple.py @@ -270,6 +270,7 @@ def run_all_demos(wait=3, repeats=5): p = 1 - p clear(f"plot_{p}") + def create_parser(): from argparse import ArgumentParser, ArgumentDefaultsHelpFormatter @@ -295,6 +296,7 @@ def create_parser(): ) return parser + def start_and_run_all_demos(): from threading import Thread from time import sleep diff --git a/server/pyproject.toml b/server/pyproject.toml index 7fbccbab..cf7bee61 100644 --- a/server/pyproject.toml +++ b/server/pyproject.toml @@ -19,7 +19,7 @@ requires-python = ">= 3.10" dependencies = [ "before-after", "fastapi", - "httpx", + "httpx>=0.13", "msgpack", "pillow", "pydantic-numpy", @@ -57,6 +57,7 @@ version = {attr = "davidia.__version__"} [tool.pytest.ini_options] log_cli = true +asyncio_default_fixture_loop_scope = "class" [tool.mypy] plugins = "numpy.typing.mypy_plugin" From 8087ad307a92d7d81a0ffcb312068214d7542e8d Mon Sep 17 00:00:00 2001 From: Peter Chang Date: Mon, 15 Dec 2025 16:21:01 +0000 Subject: [PATCH 2/3] Update code docs and error messages --- client/component/src/ConnectedPlot.tsx | 2 +- server/davidia/server/plotserver.py | 29 +++++++++++++++----------- 2 files changed, 18 insertions(+), 13 deletions(-) diff --git a/client/component/src/ConnectedPlot.tsx b/client/component/src/ConnectedPlot.tsx index adec1c33..5c465271 100644 --- a/client/component/src/ConnectedPlot.tsx +++ b/client/component/src/ConnectedPlot.tsx @@ -158,7 +158,7 @@ type ClientMessage = | BatonDonateMessage; /** - * A clear plots message + * A clear plot message */ interface ClearPlotMessage { /** The plot ID */ diff --git a/server/davidia/server/plotserver.py b/server/davidia/server/plotserver.py index 51392b9b..d7426afa 100644 --- a/server/davidia/server/plotserver.py +++ b/server/davidia/server/plotserver.py @@ -395,7 +395,7 @@ def convert_line_params_to_data_message( self, plot_id: str, line_params: ClientLineParametersMessage ) -> MultiLineMessage: """ - Creates new MultiLineDataMessage from existing line data and new parameters + Creates new MultiLineMessage from existing line data and new parameters Parameters ---------- @@ -408,7 +408,7 @@ def convert_line_params_to_data_message( ml_data_msg = self.plot_states[plot_id].current_data if not isinstance(ml_data_msg, MultiLineMessage): raise ValueError( - f"Wrong type of message given: MultiLineDataMessage expected: {type(ml_data_msg)}" + f"Wrong type of message given: MultiLineMessage expected: {type(ml_data_msg)}" ) current_lines = ml_data_msg.ml_data @@ -445,7 +445,7 @@ def convert_scatter_params_to_data_message( self, plot_id: str, scatter_params: ClientScatterParametersMessage ) -> ScatterMessage: """ - Creates new ScatterDataMessage from existing scatter data and new parameters + Creates new ScatterMessage from existing scatter data and new parameters Parameters ---------- @@ -458,7 +458,7 @@ def convert_scatter_params_to_data_message( sc_data_msg = self.plot_states[plot_id].current_data if not isinstance(sc_data_msg, ScatterMessage): raise ValueError( - f"Wrong type of message given: ScatterDataMessage expected: {type(sc_data_msg)}" + f"Wrong type of message given: ScatterMessage expected: {type(sc_data_msg)}" ) sc_data = sc_data_msg.sc_data @@ -484,8 +484,8 @@ def combine_line_messages( Parameters ---------- plot_id: str - id of plot to append data to - new_points_msg : AppendLineDataMessage + id of plot to combine line for + new_points_msg : MultiLineMessage new points to append to current data lines. """ if not new_points_msg.append: @@ -494,7 +494,7 @@ def combine_line_messages( ml_data_msg = self.plot_states[plot_id].current_data if not isinstance(ml_data_msg, MultiLineMessage): raise ValueError( - f"Wrong type of message given: MultiLineDataMessage expected: {type(ml_data_msg)}" + f"Wrong type of message given: MultiLineMessage expected: {type(ml_data_msg)}" ) current_lines = ml_data_msg.ml_data @@ -600,7 +600,10 @@ async def update_plot_states_with_message( Parameters ---------- - msg : DataMessage | SelectionMessage + plot_id: str + id of plot to update + msg : _BasePlotMessage | _BaseSelectionsMessage | BatonMessage | ClientSelectionMessage | + ClientLineParametersMessage | ClientScatterParametersMessage A message for plot states. """ plot_state = self.plot_states[plot_id] @@ -725,11 +728,11 @@ def check_cm( return new_msg async def update(self, msg: _BasePlotMessage): - """Processes PlotMessage into a client message and adds that to any client + """Processes any plot message into a client message and adds that to any client Parameters ---------- - msg : PlotMessage + msg : _BasePlotMessage A client message for processing. """ await self._update_and_add_message(msg.plot_id, msg, None) @@ -748,7 +751,9 @@ async def prepare_client( Parameters ---------- - msg : PlotMessage + plot_id: str + id of plot to prepare client for + msg : ClientMessage A client message for processing. """ logger.debug("prepare_client %s: %s", type(msg), msg) @@ -882,7 +887,7 @@ def add_indices(msg: MultiLineMessage) -> None: Parameters ---------- - msg : MultiLineDataMessage + msg : MultiLineMessage A multi-line data message to which to add indices. """ if msg.ml_data[0].default_indices: From e16645d7ed8352a20e5896dd67d8730689d05bb3 Mon Sep 17 00:00:00 2001 From: Peter Chang Date: Mon, 15 Dec 2025 16:33:26 +0000 Subject: [PATCH 3/3] Remove unnecessary linter directive --- client/component/src/shapes/DvdDragHandle.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/client/component/src/shapes/DvdDragHandle.tsx b/client/component/src/shapes/DvdDragHandle.tsx index 671bd1a3..6992b954 100644 --- a/client/component/src/shapes/DvdDragHandle.tsx +++ b/client/component/src/shapes/DvdDragHandle.tsx @@ -139,6 +139,5 @@ function DvdDragHandle(props: DvdDragHandleProps) { ); } -// eslint-disable-next-line react-refresh/only-export-components export { DvdDragHandle, HANDLE_SIZE }; export type { DvdDragHandleProps };