Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions apps/visr/helm/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,17 @@ ui-base:
external:
uri: https://workflows.diamond.ac.uk

- id: data
- id: tiled
path: /api/data/
rewriteTarget: /
rewriteTarget: /api/v1/
target:
service:
name: b01-1-tiled
port: 8000

- id: dataserver
path: /api/data/events
rewriteTarget: /events
target:
service:
name: dataserver
Expand Down
2 changes: 1 addition & 1 deletion apps/visr/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@
"@types/relay-runtime": "^19.0.2",
"@types/three": "^0.164.0",
"@vitejs/plugin-react-swc": "^3.11.0",
"ajv": "^8.17.1",
"ajv": "^8.20.0",
"babel-plugin-relay": "^20.1.1",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.20",
Expand Down
104 changes: 23 additions & 81 deletions apps/visr/src/components/spectroscopy/SpectroscopyPlots.tsx
Original file line number Diff line number Diff line change
@@ -1,87 +1,15 @@
import { ImagePlot, type NDT } from "@diamondlightsource/davidia";
import ndarray from "ndarray";
import { useSpectroscopyData, type RGBColour } from "./useSpectroscopyData";
import { ImagePlot } from "@diamondlightsource/davidia";
import { useSpectroscopyData } from "./useSpectroscopyData";
import ReactGridLayout, { useContainerWidth } from "react-grid-layout";
import { useMemo, type ComponentProps } from "react";
import { useMemo } from "react";
import { Box } from "@mui/material";

function toNDT(matrix: (number | null)[][], colour: RGBColour): NDT {
if (!matrix?.length || !matrix[0]?.length) {
return EMPTY_NDT; // skip invalid input
}
const height = matrix.length;
const width = matrix[0].length;

// Flatten and filter out nulls for normalisation
const flat = matrix.flat();
const valid = flat.filter((v): v is number => v !== null && !isNaN(v));

// Avoid crashes when no valid values
const min = valid.length ? Math.min(...valid) : 0;
const max = valid.length ? Math.max(...valid) : 1;
const scale = max > min ? 255 / (max - min) : 1;

const rgb = new Uint8Array(width * height * 3);

for (let i = 0; i < flat.length; i++) {
const v = flat[i];
let scaled = 0;
if (v !== null && !isNaN(v)) {
scaled = Math.round((v - min) * scale);
} // else stays 0 (black)

switch (colour) {
case "red":
rgb[i * 3] = scaled;
break;
case "green":
rgb[i * 3 + 1] = scaled;
break;
case "blue":
rgb[i * 3 + 2] = scaled;
break;
case "gray":
rgb[i * 3] = scaled;
rgb[i * 3 + 1] = scaled;
rgb[i * 3 + 2] = scaled;
break;
}
}

return ndarray(rgb, [height, width, 3]) as NDT;
}
/** Placeholder empty gray dataset */
const EMPTY_NDT = toNDT([[0]], "gray");

/** Return type of `/api/data/map` */
interface MapResponse {
values: (number | null)[][];
}

async function fetchMap(
filepath: string,
datapath: string,
colour: RGBColour,
snake: boolean,
) {
const url = `/api/data/map?filepath=${encodeURIComponent(filepath)}&datapath=${encodeURIComponent(datapath)}&snake=${encodeURIComponent(snake)}`;
const resp = await fetch(url);
if (!resp.ok) throw new Error(resp.statusText);
const mapResponse: MapResponse = await resp.json();
return toNDT(mapResponse.values, colour);
}

const CHANNELS = [
{ key: "red", label: "Red channel" },
{ key: "green", label: "Green channel" },
{ key: "blue", label: "Blue channel" },
//{ key: "gray", label: "Gray channel" }, // using gray channel to stop typing errors
] as const;

type ChannelKey = (typeof CHANNELS)[number]["key"];
type PlotValues = ComponentProps<typeof ImagePlot>["values"];
export type SpectroscopyData = Partial<Record<ChannelKey, PlotValues>>;

interface SpectroscopyPlotsProps {
expanded: boolean;
plotAspectRatio: number;
Expand All @@ -91,7 +19,18 @@ function SpectroscopyPlots({
expanded,
plotAspectRatio,
}: SpectroscopyPlotsProps) {
const { data: channels } = useSpectroscopyData(fetchMap);
const { data: channels } = useSpectroscopyData();
console.debug(
"channel shapes (r, g, b)",
channels.red.shape,
channels.green.shape,
channels.blue.shape,
);
console.debug(
"x, y axes sizes",
channels.xValues.size,
channels.yValues.size,
);
const { width, containerRef, mounted } = useContainerWidth();
const h = 10;
const w = 1;
Expand All @@ -107,12 +46,11 @@ function SpectroscopyPlots({
h: h,
static: true,
},
// { i: "3", x: !expanded ? 3 : 1, !expanded ? 0 : h, w: w, h: h, static: true },
];

const plots = useMemo(
() =>
CHANNELS.map(({ key }, i) => (
CHANNELS.map(({ key, label }, i) => (
<Box
key={i}
sx={{
Expand All @@ -123,10 +61,14 @@ function SpectroscopyPlots({
<ImagePlot
key={i}
aspect={plotAspectRatio}
plotConfig={{ title: key + " channel" }}
plotConfig={{
title: label,
xValues: channels.xValues,
yValues: channels.yValues,
}}
customToolbarChildren={null}
values={channels[key] ?? EMPTY_NDT}
//tightAxes //requires Davidia 1.1.0
values={channels[key]}
// tightAxes //requires Davidia 1.1.0
/>
</Box>
)),
Expand Down
150 changes: 116 additions & 34 deletions apps/visr/src/components/spectroscopy/useSpectroscopyData.ts
Original file line number Diff line number Diff line change
@@ -1,33 +1,116 @@
import { useEffect, useRef, useState } from "react";
import { type NDT } from "@diamondlightsource/davidia";
import ndarray from "ndarray";
import { useEffect, useRef, useState } from "react";
import { useScanEvents } from "../../hooks/scanEvents/useScanEvents";

export type RGBColour = "red" | "green" | "blue" | "gray";
/** SpectroscopyData wrapped as NDT for ease of Davidia plotting */
export interface DataChannels {
red: NDT;
green: NDT;
blue: NDT;
xValues: NDT;
yValues: NDT;
}

/** Return type of `/api/data/binned` */
type SpectroscopyData = {
RedTotal: number[][];
GreenTotal: number[][];
BlueTotal: number[][];
/** X bin edges with size of the above datasets + 1 */
x_limits: number[];
/** Y bin edges with size of the above datasets + 1 */
y_limits: number[];
};

export type FetchMapFunction = (
filepath: string,
datapath: string,
colour: RGBColour,
snake: boolean,
) => Promise<NDT>;
type RGBColour = "red" | "green" | "blue" | "gray";

export interface DataChannels {
red: NDT | null;
green: NDT | null;
blue: NDT | null;
// gray: NDT | null;
function toRgbNdt(matrix: (number | null)[][], colour: RGBColour): NDT {
if (!matrix?.length || !matrix[0]?.length) {
return EMPTY_NDT; // skip invalid input
}
const height = matrix.length;
const width = matrix[0].length;

// Flatten and filter out nulls for normalisation
const flat = matrix.flat();
const valid = flat.filter((v): v is number => v !== null && !isNaN(v));

// Avoid crashes when no valid values
const min = valid.length ? Math.min(...valid) : 0;
const max = valid.length ? Math.max(...valid) : 1;
const scale = max > min ? 255 / (max - min) : 1;

const rgb = new Uint8Array(width * height * 3);

for (let i = 0; i < flat.length; i++) {
const v = flat[i];
let scaled = 0;
if (v !== null && !isNaN(v)) {
scaled = Math.round((v - min) * scale);
} // else stays 0 (black)

switch (colour) {
case "red":
rgb[i * 3] = scaled;
break;
case "green":
rgb[i * 3 + 1] = scaled;
break;
case "blue":
rgb[i * 3 + 2] = scaled;
break;
case "gray":
rgb[i * 3] = scaled;
rgb[i * 3 + 1] = scaled;
rgb[i * 3 + 2] = scaled;
break;
}
}

return ndarray(rgb, [height, width, 3]) as NDT;
}
/** Placeholder empty gray dataset */
const EMPTY_NDT = toRgbNdt([[0, 0, 0]], "gray");

/** given array of edges size L, returns the centre points, size L-1, as NDT */
export function binEdgesToCentrePoints(edges: number[]): NDT {
if (edges.length < 2) {
throw new Error("At least two bin edges are required");
}

const centres = new Float64Array(edges.length - 1);

for (let i = 0; i < centres.length; i++) {
centres[i] = (edges[i] + edges[i + 1]) / 2;
}

return ndarray(centres, [centres.length]);
}

export function useSpectroscopyData(fetchMap: FetchMapFunction) {
async function fetchData(uuid: string): Promise<SpectroscopyData> {
const url = `/api/data/binned/${uuid}`;
const resp = await fetch(url);
if (!resp.ok) throw new Error(resp.statusText);
return await resp.json(); // here we should use zod
}

// initial axes must have min three points...
const initialAxes = binEdgesToCentrePoints([-0.25, 0.25, 0.5, 0.75]);

export function useSpectroscopyData(): {
data: DataChannels;
running: boolean;
} {
const scanEvent = useScanEvents();
const [running, setRunning] = useState<boolean>(false);
const [filepath, setFilepath] = useState<string | null>(null);
const [snake, setSnake] = useState<boolean>(false);
const [uuid, setUuid] = useState<string | null>(null);
const [data, setData] = useState<DataChannels>({
red: null,
green: null,
blue: null,
// gray: null,
red: EMPTY_NDT,
green: EMPTY_NDT,
blue: EMPTY_NDT,
xValues: initialAxes,
yValues: initialAxes,
});

/** Cached interval id */
Expand All @@ -39,8 +122,7 @@ export function useSpectroscopyData(fetchMap: FetchMapFunction) {

if (scanEvent.status === "running") {
setRunning(true);
setFilepath(scanEvent.filepath);
setSnake(scanEvent.snake);
setUuid(scanEvent.uuid);
} else if (
scanEvent.status === "finished" ||
scanEvent.status === "failed"
Expand All @@ -52,24 +134,24 @@ export function useSpectroscopyData(fetchMap: FetchMapFunction) {
// Poll during scan + once more afterwards
useEffect(() => {
async function poll() {
if (!filepath) return;
if (!uuid) return;
try {
const basePath = "/entry/instrument/spectroscopy_detector/";
const [red, green, blue] = await Promise.all([
fetchMap(filepath, basePath + "RedTotal", "red", snake),
fetchMap(filepath, basePath + "GreenTotal", "green", snake),
fetchMap(filepath, basePath + "BlueTotal", "blue", snake),
// fetchMap(filepath, basePath + "GrayTotal", "gray", snake),
]);
setData({ red, green, blue });
const resp: SpectroscopyData = await fetchData(uuid);
setData({
red: toRgbNdt(resp.RedTotal, "red"),
green: toRgbNdt(resp.GreenTotal, "green"),
blue: toRgbNdt(resp.BlueTotal, "blue"),
xValues: binEdgesToCentrePoints(resp.x_limits),
yValues: binEdgesToCentrePoints(resp.y_limits),
});
} catch (err) {
console.error("Polling error:", err);
}
}

if (running && filepath) {
if (running && uuid) {
// start polling
pollInterval.current = setInterval(poll, 500); // 2 Hz
pollInterval.current = setInterval(poll, 100); // 10 Hz
} else if (!running && pollInterval.current) {
// poll once more then clear interval
poll().finally(() => {
Expand All @@ -84,7 +166,7 @@ export function useSpectroscopyData(fetchMap: FetchMapFunction) {
pollInterval.current = null;
}
};
}, [running, filepath, snake, fetchMap]);
}, [running, uuid]);

return { data, running };
}
14 changes: 13 additions & 1 deletion apps/visr/src/mocks/handlers.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { http, HttpResponse, graphql } from "msw";
import workflowsResponse from "./workflows-response.json";
import plansResponse from "./plans-response.json";
import { mapData } from "./mock_data";
import { binnedReadback, binnedSetpoint, mapData } from "./mock_data";
import type { ScanEventMessage } from "../hooks/scanEvents";
import instrumentSessionResponse from "./instrumentSessions-response.json";

Expand Down Expand Up @@ -58,6 +58,18 @@ export const handlers = [
return HttpResponse.json("IDLE");
}),

http.get("/api/data/binned/:uuid", ({ request }) => {
const url = new URL(request.url);
const setPoints = url.searchParams.get("setpoints");
let data;
if (String(setPoints).toLowerCase() !== "true") {
data = binnedSetpoint;
} else {
data = binnedReadback;
}
return HttpResponse.json(data);
}),

http.get("/api/data/map", ({ request }) => {
const url = new URL(request.url);
const filepath = url.searchParams.get("filepath");
Expand Down
Loading
Loading