diff --git a/e2e/units.spec.ts b/e2e/units.spec.ts new file mode 100644 index 00000000..a6559ae3 --- /dev/null +++ b/e2e/units.spec.ts @@ -0,0 +1,45 @@ +import { expect, test } from "@playwright/test"; +import { createBlankProject, gotoApp, waitForEngine } from "./helpers"; + +for (const [units, time, pressure] of [ + ["real", "fs", "atm"], + ["metal", "ps", "bar"], +]) { + test(`real engine chart labels use ${units} units from an included file`, async ({ + page, + }) => { + await gotoApp(page); + await createBlankProject(page, "Chart units", "chart-units"); + await page.getByTestId("upload-input").setInputFiles([ + { + name: "settings.inc", + mimeType: "text/plain", + buffer: Buffer.from(`units ${units}\n`), + }, + { + name: "in.units", + mimeType: "text/plain", + buffer: Buffer.from( + "include settings.inc\natom_style atomic\nregion box block 0 4 0 4 0 4\ncreate_box 1 box\ncreate_atoms 1 single 1 1 1\nmass * 1\npair_style zero 1\npair_coeff * *\nrun 0\n", + ), + }, + ]); + await waitForEngine(page); + await page.getByTestId("run-file-in.units").click(); + await expect + .poll( + () => + page.evaluate(async () => { + const path = "/atomify/src/store/index.ts"; + const s = (await import(path)).default.getState().simulationStatus; + return { + units: s.unitStyle, + x: s.computes.thermo_press?.xLabel, + y: s.computes.thermo_press?.yLabel, + }; + }), + { timeout: 60_000 }, + ) + .toEqual({ units, x: `Time (${time})`, y: `Pressure (${pressure})` }); + }); +} diff --git a/src/components/SelectedAtomsInfo.tsx b/src/components/SelectedAtomsInfo.tsx index 96d8c83b..4827a157 100644 --- a/src/components/SelectedAtomsInfo.tsx +++ b/src/components/SelectedAtomsInfo.tsx @@ -1,3 +1,4 @@ +import { UNIT_SYSTEMS } from "../utils/units"; import { Button } from "antd"; import { Particles } from "omovi"; import { useMemo, useState, useEffect, useRef } from "react"; @@ -144,6 +145,10 @@ const SelectedAtomsInfo = ({ const prevRunningRef = useRef(false); // Get running state from store + const unitStyle = useStoreState((state) => state.simulationStatus.unitStyle); + const lengthUnit = unitStyle + ? UNIT_SYSTEMS[unitStyle].length + : "simulation units"; const running = useStoreState((state) => state.simulation.running); // Clear time-series data when simulation starts (running goes from false to true) @@ -414,7 +419,7 @@ const SelectedAtomsInfo = ({ 0, getOrigoPointer: () => 48, + getUnitStyle: vi.fn(() => "metal"), getDimension: () => 3, getTimesteps: () => 42, getNumAtoms: () => 256, @@ -53,13 +54,20 @@ describe("post-timestep status publication", () => { numAtoms: status.numAtoms, memoryUsage: status.memoryUsage, runType: status.runType, + unitStyle: status.unitStyle, }); previous = status; } }); await store.getActions().processing.runPostTimestep(false); expect(observed).toEqual([ - { timesteps: 42, numAtoms: 256, memoryUsage: 8192, runType: "Dynamics" }, + { + timesteps: 42, + numAtoms: 256, + memoryUsage: 8192, + runType: "Dynamics", + unitStyle: "metal", + }, ]); expect(store.getState().simulationStatus).toMatchObject({ runTimesteps: 40, diff --git a/src/store/processing.ts b/src/store/processing.ts index ce8f6422..7f5e4e0b 100644 --- a/src/store/processing.ts +++ b/src/store/processing.ts @@ -217,6 +217,7 @@ export const processingModel: ProcessingModel = { box: getSimulationBox(lammps, wasm, currentStatus.box), origo: getSimulationOrigo(lammps, wasm, currentStatus.origo), dimension: lammps.getDimension(), + unitStyle: lammps.getUnitStyle?.(), timesteps: lammps.getTimesteps(), numAtoms: lammps.getNumAtoms(), runTimesteps: lammps.getRunTimesteps(), diff --git a/src/store/simulationstatus.ts b/src/store/simulationstatus.ts index 2678b014..0b240240 100644 --- a/src/store/simulationstatus.ts +++ b/src/store/simulationstatus.ts @@ -19,6 +19,7 @@ export interface SimulationStatusData { computes: { [key: string]: Compute }; fixes: { [key: string]: Fix }; variables: { [key: string]: Variable }; + unitStyle?: import("../utils/units").UnitStyle; dimension: number; walls: Wall[]; } @@ -169,6 +170,7 @@ export const simulationStatusModel: SimulationStatusModel = { state.box = undefined; state.origo = undefined; state.dimension = 3; + state.unitStyle = undefined; state.walls = []; }), }; diff --git a/src/types.ts b/src/types.ts index a865158f..2cea5b65 100644 --- a/src/types.ts +++ b/src/types.ts @@ -67,6 +67,7 @@ export type LammpsWeb = { computeBonds: () => number; computeParticles: () => number; + getUnitStyle?: () => import("./utils/units").UnitStyle | undefined; getDimension: () => number; getWalls: () => CPPArray; }; diff --git a/src/utils/units.test.ts b/src/utils/units.test.ts new file mode 100644 index 00000000..ddff0820 --- /dev/null +++ b/src/utils/units.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from "vitest"; +import { + modifierAxisLabels as labels, + parseUnitStyle, + UNIT_SYSTEMS, +} from "./units"; + +describe("LAMMPS axis units", () => { + it("recognizes runtime setup output without interpreting script text", () => { + expect(parseUnitStyle(" Unit style : metal")).toBe("metal"); + expect(parseUnitStyle("units real")).toBeUndefined(); + expect(parseUnitStyle("Unit style : unknown")).toBeUndefined(); + }); + it("distinguishes pressure and time in real, metal, and reduced units", () => { + expect(labels("compute", "pressure", "Time", "Pressure", "real")).toEqual({ + xLabel: "Time (fs)", + yLabel: "Pressure (atm)", + }); + expect(labels("compute", "pressure", "Time", "Pressure", "metal")).toEqual({ + xLabel: "Time (ps)", + yLabel: "Pressure (bar)", + }); + expect(labels("compute", "temp", "Time", "Value", "lj").yLabel).toBe( + "Temperature (reduced LJ)", + ); + }); + it("uses distance for RDF and squared dimensions for correlation plots", () => { + expect(labels("compute", "rdf", "r", "RDF", "si")).toEqual({ + xLabel: "r (m)", + yLabel: "RDF (dimensionless)", + }); + expect(labels("compute", "msd", "Time", "MSD", "nano").yLabel).toBe( + "Mean square displacement (nm²)", + ); + expect(labels("compute", "vacf", "Time", "VACF", "electron").yLabel).toBe( + "VACF ((Bohr/atomic time unit)²)", + ); + }); + it("does not invent dimensions for user expressions or unavailable metadata", () => { + expect( + labels("variable", "equal", "Time", "Value", "real").yLabel, + ).toContain("user-defined units"); + expect( + labels("compute", "custom", "Time", "Value", "real").yLabel, + ).toContain("compute-defined units"); + expect(labels("compute", "temp", "Time", "Value").yLabel).toBe( + "Temperature (simulation units)", + ); + expect(Object.keys(UNIT_SYSTEMS)).toHaveLength(8); + }); +}); diff --git a/src/utils/units.ts b/src/utils/units.ts new file mode 100644 index 00000000..cbee561d --- /dev/null +++ b/src/utils/units.ts @@ -0,0 +1,112 @@ +/** Native LAMMPS output units: https://docs.lammps.org/units.html */ +export const UNIT_SYSTEMS = { + lj: { + time: "τ", + length: "σ", + energy: "ε", + temperature: "reduced LJ", + pressure: "ε/σ³", + velocity: "σ/τ", + }, + real: { + time: "fs", + length: "Å", + energy: "kcal/mol", + temperature: "K", + pressure: "atm", + velocity: "Å/fs", + }, + metal: { + time: "ps", + length: "Å", + energy: "eV", + temperature: "K", + pressure: "bar", + velocity: "Å/ps", + }, + si: { + time: "s", + length: "m", + energy: "J", + temperature: "K", + pressure: "Pa", + velocity: "m/s", + }, + cgs: { + time: "s", + length: "cm", + energy: "erg", + temperature: "K", + pressure: "dyn/cm²", + velocity: "cm/s", + }, + electron: { + time: "fs", + length: "Bohr", + energy: "Hartree", + temperature: "K", + pressure: "Pa", + velocity: "Bohr/atomic time unit", + }, + micro: { + time: "µs", + length: "µm", + energy: "pg·µm²/µs²", + temperature: "K", + pressure: "pg/(µm·µs²)", + velocity: "µm/µs", + }, + nano: { + time: "ns", + length: "nm", + energy: "ag·nm²/ns²", + temperature: "K", + pressure: "ag/(nm·ns²)", + velocity: "nm/ns", + }, +} as const; +export type UnitStyle = keyof typeof UNIT_SYSTEMS; + +export function parseUnitStyle(line: string): UnitStyle | undefined { + const match = /^\s*Unit style\s*:\s*(\w+)\s*$/.exec(line); + return match && Object.hasOwn(UNIT_SYSTEMS, match[1]) + ? (match[1] as UnitStyle) + : undefined; +} + +export function modifierAxisLabels( + category: string, + style: string, + xLabel: string, + yLabel: string, + units?: UnitStyle, +) { + const u = units && UNIT_SYSTEMS[units]; + const label = (name: string, unit?: string) => + `${name} (${unit ?? "simulation units"})`; + const x = + xLabel === "Time" + ? label("Time", u?.time) + : category === "compute" && style === "rdf" + ? label(xLabel, u?.length) + : xLabel; + if (category !== "compute") + return { xLabel: x, yLabel: label(yLabel, "user-defined units") }; + const known: Record = { + temp: ["Temperature", u?.temperature], + pressure: ["Pressure", u?.pressure], + pe: ["Potential energy", u?.energy], + ke: ["Kinetic energy", u?.energy], + msd: ["Mean square displacement", u && `${u.length}²`], + vacf: ["VACF", u && `(${u.velocity})²`], + rdf: [yLabel, "dimensionless"], + gyration: ["Radius of gyration", u?.length], + }; + const dimension = known[style.replace(/\/kk$/, "")]; + return { + xLabel: x, + yLabel: dimension + ? label(...dimension) + : label(yLabel, "compute-defined units"), + }; +} diff --git a/src/wasm/LammpsWorkerProxy.ts b/src/wasm/LammpsWorkerProxy.ts index 677b8376..e20afae1 100644 --- a/src/wasm/LammpsWorkerProxy.ts +++ b/src/wasm/LammpsWorkerProxy.ts @@ -104,6 +104,7 @@ export class LammpsWorkerProxy implements LammpsWeb { private cBondCount = 0; private cStep = 0; private cDimension = 3; + private cUnitStyle?: import("../utils/units").UnitStyle; private cRunMode = 0; private cRunStepsDone = 0; private cRunStepsTotal = 0; @@ -282,6 +283,7 @@ export class LammpsWorkerProxy implements LammpsWeb { this.cBondCount = step.bondCount; this.cStep = step.step; this.cDimension = step.dimension; + this.cUnitStyle = step.unitStyle; this.cRunMode = step.runMode; this.cRunStepsDone = step.runStepsDone; this.cRunStepsTotal = step.runStepsTotal; @@ -610,6 +612,9 @@ export class LammpsWorkerProxy implements LammpsWeb { getOrigoPointer() { return this.origPtr; } + getUnitStyle() { + return this.cUnitStyle; + } getDimension() { return this.cDimension; } diff --git a/src/wasm/lammps.worker.ts b/src/wasm/lammps.worker.ts index 6cc25d4d..26bd76dd 100644 --- a/src/wasm/lammps.worker.ts +++ b/src/wasm/lammps.worker.ts @@ -223,6 +223,7 @@ function streamStep() { boxMatrix: boxMatrix.buffer, origin: origin.buffer, dimension: box.dimension, + unitStyle: adapter?.getUnitStyle(), runMode: native.getRunMode(), runStepsDone: native.getRunStepsDone(), runStepsTotal: native.getRunStepsTotal(), @@ -243,8 +244,11 @@ async function load() { post({ type: "ready" }); return; } - const printLine = (...args: unknown[]) => - post({ type: "printed", text: args.join(" ") }); + const printLine = (...args: unknown[]) => { + const text = args.join(" "); + adapter?.observeOutput(text); + post({ type: "printed", text }); + }; // Fetch the atomify emscripten glue (embedded wasm, ~50 MB) once and keep it // as a Blob. Load createModule from that same blob URL (?url + @vite-ignore diff --git a/src/wasm/lammpsAdapter.ts b/src/wasm/lammpsAdapter.ts index 16ef575e..1eb2d659 100644 --- a/src/wasm/lammpsAdapter.ts +++ b/src/wasm/lammpsAdapter.ts @@ -1,3 +1,8 @@ +import { + modifierAxisLabels, + parseUnitStyle, + type UnitStyle, +} from "../utils/units"; import { v4 as uuidv4 } from "uuid"; import type { LAMMPSWeb as NativeLammps, @@ -153,6 +158,14 @@ export class LammpsAdapter implements LammpsWeb { private readonly module: AtomifyWasmModule; private readonly native: NativeLammps; + private unitStyle?: UnitStyle; + observeOutput(line: string) { + const units = parseUnitStyle(line); + if (units) this.unitStyle = units; + } + getUnitStyle() { + return this.unitStyle; + } // Private atom variables expose optional native properties without parsing // atom_style commands (hybrid styles and read_restart work too). private readonly radiusFlagName = `atomify_radius_flag_${uuidv4().replaceAll("-", "")}`; @@ -299,6 +312,7 @@ export class LammpsAdapter implements LammpsWeb { } start(): boolean { + this.unitStyle = undefined; this.lastError = ""; this.cancelRequested = false; // Startup args are CONSTANT for the module's whole life: Kokkos::initialize @@ -651,8 +665,13 @@ export class LammpsAdapter implements LammpsWeb { isPerAtom: snap?.isPerAtom ?? info.isPerAtom, hasScalar: snap?.hasScalar ?? info.hasScalar, clearPerSync: snap?.clearPerSync ?? info.clearPerSync, - xLabel: snap?.xLabel ?? info.xLabel, - yLabel: snap?.yLabel ?? info.yLabel, + ...modifierAxisLabels( + info.category, + info.style, + snap?.xLabel ?? info.xLabel, + snap?.yLabel ?? info.yLabel, + this.unitStyle, + ), scalar: snap?.scalar ?? 0, series, }; diff --git a/src/wasm/workerMessages.ts b/src/wasm/workerMessages.ts index 497588c0..64641381 100644 --- a/src/wasm/workerMessages.ts +++ b/src/wasm/workerMessages.ts @@ -105,6 +105,7 @@ export interface WorkerStepData { /** 3 float32 box origin. */ origin: ArrayBuffer; dimension: number; + unitStyle?: import("../utils/units").UnitStyle; runMode: number; runStepsDone: number; runStepsTotal: number;