Rugnux live page: MR scores and the placed model right under the timeline, carved difference maps

- Fo-Fc (and the anomalous map) are carved within the same 4.5 A of the atoms as 2Fo-Fc, not only
  cropped to the model's box + 6 A, and Fo-Fc starts at +-3.5 sigma: uncarved at 3 sigma it filled
  the frame with noise peaks. A departure from AareDB's viewer, which carves 2Fo-Fc only.
- The anomalous map is on by default only where the status says the data carry anomalous signal.
- The model section (molecular-replacement scores + viewer) sits directly under the timeline and
  slides in when the run ends with a model; the stage stays dark until model and maps are in, then
  fades up while the camera eases in from further out and turned ~35 deg (1.6 s, ends exactly at the
  reset view). prefers-reduced-motion turns all of it off.
- The page uses up to 2400 px instead of the 1300 px column; Processing and Result are 1:2, the
  summary tiles flow into as many columns as fit, the viewer is clamp(520px, 64vh, 820px) tall.
- Score tiles: placement (copies, group, MR time), TFZ (RFZ, LLG-Z, search resolution), R-free
  (R-work, unrefined), CC(model, data), fit over random orientations, 2Fo-Fc at atoms, and the
  strongest anomalous peak only where there is anomalous signal.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015CeEGgpKdfkCTJb3YNMJcu
(cherry picked from commit 3bb891c60acd2c5618bbc461fabcb7513f5df947)
This commit is contained in:
leonarski_f committed 2026-10-24 19:06:04 +02:00
1 parent 128830d15e
commit e374ea269f
6 files changed
+151 -37

No files matched your search

+4 -3
View File
@@ -105,8 +105,9 @@ function PanelColumns({ columns = 2, children }: { columns?: number; children: R
// An item either renders a panel inside the app (render) or links out to an
// external page in a new tab (href), e.g. the source repository.
// wide: the page uses the screen's width instead of the 1300 px reading column
type NavItem = { id: string; label: string; icon: ReactNode; disabled?: boolean;
href?: string; render?: () => ReactNode };
href?: string; render?: () => ReactNode; wide?: boolean };
type NavSection = { subheader?: string; items: NavItem[] };
/** Documentation / API reference shown inside the app area, with a new-tab escape hatch. */
@@ -192,7 +193,7 @@ function App() {
{ id: 'data-collection', label: 'Data collection', icon: <VideocamIcon/>, render: () => (
<DataCollection frame_time_us={s.detector_settings?.frame_time_us ?? 500}/>
)},
{ id: 'rugnux', label: 'Rugnux live', icon: <AutoGraphIcon/>, render: () => (
{ id: 'rugnux', label: 'Rugnux live', icon: <AutoGraphIcon/>, wide: true, render: () => (
<RugnuxPage measuring={isMeasuring || s.broker?.state === 'Busy'}/>
)},
]},
@@ -308,7 +309,7 @@ function App() {
<Box component="main" sx={{ flexGrow: 1, p: 3, pb: '84px', minWidth: 0 }}>
<Toolbar/>
<ErrorMessage s={s.broker}/>
<Box sx={{ maxWidth: 1300, mx: 'auto', mt: 2 }}>
<Box sx={{ maxWidth: activeItem.wide ? 2400 : 1300, mx: 'auto', mt: 2 }}>
{activeItem.render?.()}
</Box>
</Box>
+33 -15
View File
@@ -7,21 +7,26 @@ import {useTokenRequired} from "../auth";
import EmptyState from "./EmptyState";
import RugnuxTimeline from "./RugnuxTimeline";
import RugnuxSettings from "./RugnuxSettings";
import {CutAlert, lastImageTime, ProbeAlert, RunMessage, StateChip, StepTable, VerdictAlert} from "./RugnuxParts";
import {CutAlert, lastImageTime, ModelScores, ProbeAlert, RunMessage, StateChip, StepTable, VerdictAlert} from "./RugnuxParts";
import BoltIcon from "@mui/icons-material/Bolt";
import {lazy, Suspense} from "react";
import {keyframes} from "@mui/system";
// Mol* is large: loaded only when there is a model to show.
const RugnuxStructureViewer = lazy(() => import("./RugnuxStructureViewer"));
// The summary rows rugnux writes; the ones a crystallographer looks at first get a stronger tile, and
// space group and cell share a row.
// The summary rows rugnux writes; the ones a crystallographer looks at first get a stronger tile.
const KEY_ROW = /unit cell|space group|resolution|signal|r-?free|model/i;
const PAIRED_ROW = /space group|unit cell/i;
function Section({title, children}: { title: string, children: React.ReactNode }) {
// The model section's entrance under the timeline, once the run has placed a model
const slideIn = keyframes`
from { opacity: 0; transform: translateY(24px); }
to { opacity: 1; transform: none; }
`;
function Section({title, children, sx}: { title: string, children: React.ReactNode, sx?: object }) {
return (
<Paper sx={{p: 3, height: "100%"}}>
<Paper sx={{p: 3, height: "100%", ...sx}}>
<Typography sx={{fontSize: "0.8rem", fontWeight: 700, letterSpacing: ".08em", textTransform: "uppercase",
color: "primary.dark", mb: 2}}>
{title}
@@ -86,12 +91,12 @@ function SummaryTiles({s}: { s: rugnux_live_status }) {
if (s.summary.length === 0)
return null;
return (
<Box sx={{display: "grid", gridTemplateColumns: {xs: "1fr", sm: "1fr 1fr"}, gap: 1.5, mt: 2}}>
<Box sx={{display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(300px, 1fr))", gap: 1.5, mt: 2}}>
{s.summary.map((row, i) => {
const key = KEY_ROW.test(row.label);
const short = row.value.length <= 24;
return (
<Box key={i} sx={{p: 1.5, borderRadius: 2, gridColumn: PAIRED_ROW.test(row.label) ? "auto" : "1 / -1",
<Box key={i} sx={{p: 1.5, borderRadius: 2,
bgcolor: key ? indigo[50] : grey[50],
border: "1px solid", borderColor: key ? indigo[100] : grey[200]}}>
<Typography sx={{fontSize: "0.75rem", fontWeight: 700, letterSpacing: ".06em",
@@ -109,6 +114,23 @@ function SummaryTiles({s}: { s: rugnux_live_status }) {
);
}
function ModelSection({s}: { s: rugnux_live_status }) {
const m = s.model!;
const mrSeconds = s.steps.find(step => step.title === "Molecular replacement")?.duration_s;
return (
<Section title={(m.placement === "MR" ? "Molecular replacement" : "Model") + (m.predicted ? " · predicted model" : "")}
sx={{animation: `${slideIn} 700ms ease-out`, "@media (prefers-reduced-motion: reduce)": {animation: "none"}}}>
<ModelScores m={m} mrSeconds={mrSeconds} anomalousSignal={s.anomalous_signal}/>
{s.placed_model && <Box sx={{mt: 2}}>
<Suspense fallback={<Typography color="text.secondary">Loading viewer...</Typography>}>
<RugnuxStructureViewer resultKey={`${s.elapsed_s}`} anomalous={s.anomalous_map}
anomalousSignal={s.anomalous_signal}/>
</Suspense>
</Box>}
</Section>
);
}
function RugnuxPage({measuring}: { measuring: boolean }) {
const {data: settings} = useQuery({...getConfigRugnuxOptions(), refetchInterval: 2000});
const enabled = settings?.enable === true;
@@ -138,7 +160,9 @@ function RugnuxPage({measuring}: { measuring: boolean }) {
<Section title="Timeline">
<RugnuxTimeline s={s} deadline_s={settings?.deadline_s ?? 0}/>
</Section>
<Box sx={{display: "grid", gridTemplateColumns: {xs: "1fr", lg: "1fr 1fr"}, gap: 2}}>
{s.model && s.state !== 'Running' && <ModelSection s={s}/>}
<Box sx={{display: "grid", gridTemplateColumns: {xs: "1fr", lg: "minmax(400px, 1fr) 2fr"}, gap: 2,
alignItems: "start"}}>
<Section title="Processing">
<CurrentStep s={s}/>
<StepTable steps={s.steps}/>
@@ -155,12 +179,6 @@ function RugnuxPage({measuring}: { measuring: boolean }) {
</Stack>
</Section>
</Box>
{s.placed_model && s.state !== 'Running' &&
<Section title="Placed model and electron density">
<Suspense fallback={<Typography color="text.secondary">Loading viewer...</Typography>}>
<RugnuxStructureViewer resultKey={`${s.elapsed_s}`} anomalous={s.anomalous_map}/>
</Suspense>
</Section>}
</Stack>
);
+56 -1
View File
@@ -1,7 +1,7 @@
import {Alert, AlertTitle, Box, Chip, Paper, Table, TableBody, TableCell, TableContainer, TableRow, Typography} from "@mui/material";
import {indigo, lime, deepOrange, grey} from "@mui/material/colors";
import {SxProps, Theme} from "@mui/material/styles";
import {rugnux_live_status, rugnux_live_step} from "../client";
import {rugnux_live_model, rugnux_live_status, rugnux_live_step} from "../client";
// Pieces of the rugnux live status shared by the dashboard card and the Rugnux page.
@@ -113,3 +113,58 @@ export function StepTable({steps}: { steps: rugnux_live_step[] }) {
</TableContainer>
);
}
function ScoreTile({label, value, sub, accent}: { label: string, value: string, sub?: string, accent?: boolean }) {
return (
<Box sx={{p: 1.5, borderRadius: 2, border: "1px solid", minWidth: 0,
bgcolor: accent ? indigo[50] : grey[50], borderColor: accent ? indigo[100] : grey[200]}}>
<Typography sx={{fontSize: "0.72rem", fontWeight: 700, letterSpacing: ".06em", textTransform: "uppercase",
color: accent ? "primary.main" : "text.secondary", whiteSpace: "nowrap"}}>{label}</Typography>
<Typography sx={{fontSize: "1.6rem", lineHeight: 1.2, fontWeight: 700, color: "primary.dark",
fontVariantNumeric: "tabular-nums", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis"}}>
{value}</Typography>
{sub && <Typography sx={{fontSize: "0.8rem", color: "text.secondary"}}>{sub}</Typography>}
</Box>
);
}
const PLACEMENT: Record<string, string> = {MR: "MR", RIGID_BODY: "Rigid body", NOT_PLACED: "Not placed"};
// What the run made of the model, in the numbers of the report's MODEL VALIDATION section; mrSeconds is how long
// the molecular-replacement step took. Nothing is refined, so the R-factors are those of the model as placed.
// The strongest anomalous peak is shown only where the data carry anomalous signal: without it, it is noise.
export function ModelScores({m, mrSeconds, anomalousSignal}:
{ m: rugnux_live_model, mrSeconds?: number, anomalousSignal: boolean }) {
const tiles: { label: string, value: string, sub?: string, accent?: boolean }[] = [];
let placedSub = m.reason || m.mr_decision;
if (m.placement === "MR" && m.copies !== undefined)
placedSub = `${m.copies} ${m.copies === 1 ? "copy" : "copies"} in ${m.mr_space_group}`
+ (mrSeconds !== undefined ? `, found in ${mrSeconds.toFixed(1)} s` : "");
else if (m.placement === "RIGID_BODY")
placedSub = m.rigid_body_shift_A !== undefined
? `moved ${m.rigid_body_angle_deg?.toFixed(1)}° and ${m.rigid_body_shift_A.toFixed(2)} Å`
: "fits where it is";
tiles.push({label: "Placement", value: PLACEMENT[m.placement] ?? m.placement, sub: placedSub,
accent: m.placement !== "NOT_PLACED"});
if (m.tf_z !== undefined)
tiles.push({label: "TFZ", value: m.tf_z.toFixed(1),
sub: [m.rf_z !== undefined && `RFZ ${m.rf_z.toFixed(1)}`, m.llg_z !== undefined && `LLG-Z ${m.llg_z.toFixed(1)}`,
m.mr_d_min !== undefined && `search to ${m.mr_d_min.toFixed(1)} Å`].filter(Boolean).join(" · ")});
if (m.r_free !== undefined)
tiles.push({label: "R-free", value: m.r_free.toFixed(3), accent: true,
sub: `R-work ${m.r_work?.toFixed(3)} · model as placed, unrefined`});
if (m.cc_model !== undefined)
tiles.push({label: "CC (model, data)", value: m.cc_model.toFixed(2), sub: "intensities, all reflections"});
if (m.fit_sigma !== undefined)
tiles.push({label: "Fit over random", value: `${m.fit_sigma >= 0 ? "+" : ""}${m.fit_sigma.toFixed(1)} σ`,
sub: "R-work against the model in random orientations"});
if (m.map_atom_sigma !== undefined)
tiles.push({label: "2Fo−Fc at atoms", value: `${m.map_atom_sigma.toFixed(1)} σ`, sub: "mean at the atom centres"});
if (anomalousSignal && m.anomalous_site && m.anomalous_site_sigma !== undefined)
tiles.push({label: "Anomalous peak", value: `${m.anomalous_site_sigma.toFixed(1)} σ`, sub: m.anomalous_site});
return (
<Box sx={{display: "grid", gap: 1.5, gridTemplateColumns: "repeat(auto-fit, minmax(190px, 1fr))"}}>
{tiles.map(t => <ScoreTile key={t.label} {...t}/>)}
</Box>
);
}
@@ -7,11 +7,14 @@ import {applyModelDisplay, clearScene, getStructureAtoms, getStructureBox, Loade
from "./structure/loadModel";
import {loadMapLayer, MAP_COLORS, MapLayer, setLayerVisible, updateLayerSigma} from "./structure/loadMaps";
import {fullCellFromCcp4, prepareMap} from "./structure/densityCrop";
import {revealCamera} from "./structure/reveal";
// The model rugnux live placed, with its 2mFo-DFc, mFo-DFc and (where the merge kept the Bijvoet pairs) anomalous
// difference maps, in Mol*. Set up as aaredb's structure viewer (gitea.psi.ch/mx/aaredb, pages/StructureView.tsx
// and components/Structure/, the PSI MX group's own code): its plugin, Coot mouse controls, model presets, map
// meshes, crop and carve, panel and defaults - on a black stage. Mol* is only loaded with this component
// meshes, crop and carve, panel and defaults - on a black stage. Departures for the live page: every map is carved
// within the same radius of the atoms (aaredb carves 2Fo-Fc only), Fo-Fc starts at +-3.5 sigma, the anomalous map
// is shown at first only where the data carry anomalous signal, and the first model makes an entrance (reveal.ts). Mol* is only loaded with this component
// (React.lazy in RugnuxPage), and every Mol* import lives in ./structure.
type Kind = 'twofofc' | 'fofc' | 'anom';
@@ -60,8 +63,14 @@ async function fetchBytes(path: string, signal: AbortSignal): Promise<ArrayBuffe
return res.arrayBuffer();
}
// resultKey: changes when a new result is there to load; anomalous: it has an anomalous difference map.
function RugnuxStructureViewer({resultKey, anomalous}: { resultKey: string, anomalous: boolean }) {
const REVEAL_MS = 1600;
const reducedMotion = () => window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false;
const nextFrames = () => new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)));
// resultKey: changes when a new result is there to load; anomalous: it has an anomalous difference map;
// anomalousSignal: the data carry usable anomalous signal, so that map is worth showing from the start.
function RugnuxStructureViewer({resultKey, anomalous, anomalousSignal}:
{ resultKey: string, anomalous: boolean, anomalousSignal: boolean }) {
const pluginRef = useRef<PluginContext | null>(null);
const [pluginReady, setPluginReady] = useState(0);
const [error, setError] = useState<string>();
@@ -73,10 +82,14 @@ function RugnuxStructureViewer({resultKey, anomalous}: { resultKey: string, anom
// First load frames the molecule; later results keep the user's camera and clip (aaredb StructureView.tsx:117-121)
const sceneLoadedRef = useRef(false);
const [hasMap, setHasMap] = useState<Record<Kind, boolean>>({twofofc: false, fofc: false, anom: false});
// The stage stays dark until the first model and its maps are in, then fades up as the camera eases in.
const [revealed, setRevealed] = useState(false);
// aaredb factory defaults (state/viewerDefaults.ts:16-23); anomalous as a difference map, +4 sigma
const [show, setShow] = useState<Record<Kind, boolean>>({twofofc: true, fofc: true, anom: true});
const [sigma, setSigma] = useState<Record<Kind, number>>({twofofc: 1.0, fofc: 3.0, anom: 4.0});
// aaredb factory defaults (state/viewerDefaults.ts:16-23), but Fo-Fc at +-3.5 sigma rather than 3: carved or not,
// a difference map at 3 sigma over a whole molecule shows dozens of noise peaks; anomalous as a difference map,
// +4 sigma
const [show, setShow] = useState<Record<Kind, boolean>>({twofofc: true, fofc: true, anom: anomalousSignal});
const [sigma, setSigma] = useState<Record<Kind, number>>({twofofc: 1.0, fofc: 3.5, anom: 4.0});
const [reprMode, setReprMode] = useState<ReprMode>('cartoon');
const [showWaters, setShowWaters] = useState(false);
const displayRef = useRef({show, sigma, reprMode, showWaters});
@@ -87,8 +100,8 @@ function RugnuxStructureViewer({resultKey, anomalous}: { resultKey: string, anom
setPluginReady(n => n + 1);
}, []);
// The model, then its maps (aaredb StructureView.tsx maybeLoad / computeAndAttachMaps, 212-295): 2Fo-Fc cropped
// to the model's box + 6 A and carved 4.5 A around the atoms; the difference maps cropped only.
// The model, then its maps (aaredb StructureView.tsx maybeLoad / computeAndAttachMaps, 212-295): each map cropped
// to the model's box + 6 A and carved 4.5 A around the atoms.
useEffect(() => {
const plugin = pluginRef.current;
if (!plugin)
@@ -113,7 +126,8 @@ function RugnuxStructureViewer({resultKey, anomalous}: { resultKey: string, anom
setError("No placed model");
return;
}
const loaded = await loadModel(plugin, cif, 'model', {resetCamera: !sceneLoadedRef.current, format: 'mmcif'});
const firstScene = !sceneLoadedRef.current;
const loaded = await loadModel(plugin, cif, 'model', {resetCamera: firstScene, format: 'mmcif'});
sceneLoadedRef.current = true;
modelRefsRef.current = loaded.refs;
const d = displayRef.current;
@@ -128,14 +142,18 @@ function RugnuxStructureViewer({resultKey, anomalous}: { resultKey: string, anom
for (const [kind, name, bytes] of maps) {
if (!bytes || abort.signal.aborted)
continue;
const computed = prepareMap(name, fullCellFromCcp4(bytes), crop,
kind === 'twofofc' && atoms.length ? atoms : null);
const computed = prepareMap(name, fullCellFromCcp4(bytes), crop, atoms.length ? atoms : null);
const layer = await loadMapLayer(plugin, computed, name, d.sigma[kind]);
layersRef.current[kind] = layer;
if (!displayRef.current.show[kind])
setLayerVisible(plugin, layer, false);
setHasMap(h => ({...h, [kind]: true}));
}
if (firstScene && !abort.signal.aborted) {
await nextFrames(); // the camera reset loadModel asked for is applied on a draw
if (!reducedMotion())
revealCamera(plugin, REVEAL_MS);
}
} catch (e) {
if (!abort.signal.aborted)
setError(e instanceof Error ? e.message : String(e));
@@ -143,6 +161,7 @@ function RugnuxStructureViewer({resultKey, anomalous}: { resultKey: string, anom
if (!abort.signal.aborted) {
setModelLoading(false);
setMapsLoading(false);
setRevealed(true);
}
}
})();
@@ -182,11 +201,15 @@ function RugnuxStructureViewer({resultKey, anomalous}: { resultKey: string, anom
return (
<Box sx={{display: 'grid', gridTemplateColumns: {xs: '1fr', md: '1fr 300px'}, border: '1px solid',
borderColor: 'divider', borderRadius: 1, overflow: 'hidden'}}>
<Box sx={{position: 'relative', bgcolor: '#000', height: {xs: '55vh', md: 560}}}>
<MolstarCanvas onReady={onPluginReady}/>
{modelLoading && !error && <Box sx={{position: 'absolute', inset: 0, display: 'flex', alignItems: 'center',
<Box sx={{position: 'relative', bgcolor: '#000', height: {xs: '55vh', md: 'clamp(520px, 64vh, 820px)'}}}>
<Box sx={{position: 'absolute', inset: 0, opacity: revealed ? 1 : 0,
transition: 'opacity 900ms ease-out', '@media (prefers-reduced-motion: reduce)': {transition: 'none'}}}>
<MolstarCanvas onReady={onPluginReady}/>
</Box>
{(modelLoading || (mapsLoading && !revealed)) && !error && <Box sx={{position: 'absolute', inset: 0, display: 'flex', alignItems: 'center',
justifyContent: 'center', pointerEvents: 'none'}}>
<Typography sx={{fontSize: '0.875rem', color: '#8aa0b5'}}>loading model…</Typography>
<Typography sx={{fontSize: '0.875rem', color: '#8aa0b5'}}>
{modelLoading ? 'loading model…' : 'computing density maps…'}</Typography>
</Box>}
</Box>
<Box sx={{borderLeft: {md: '1px solid'}, borderColor: {md: 'divider'}, p: 1.75}}>
@@ -1,6 +1,6 @@
// The density preparation of aaredb's density worker (gitea.psi.ch/mx/aaredb, the PSI MX group's own code,
// frontend/public/structure-worker/density-worker.js at 8ece2c2): crop the periodic map to the model's box plus a
// margin, carve the 2Fo-Fc map around the atoms, encode as CCP4 with the full cell's mean and RMS. aaredb computes
// margin, carve it around the atoms, encode as CCP4 with the full cell's mean and RMS. aaredb computes
// the full-cell map from an MTZ with gemmi wasm; here the broker serves it (/rugnux/map.ccp4), so it is first read
// back into aaredb's full-cell z-fastest grid (fullCellFromCcp4).
@@ -226,8 +226,9 @@ function carveBlock(block: Float32Array, start: number[], dims: number[], n: num
}
}
/* One map as the worker's onmessage loop makes it: cropped to `crop`, carved around `carveAtoms` (2Fo-Fc only -
* never a difference map: unmodelled blobs sit away from every atom), with the full cell's mean and RMS. */
/* One map as the worker's onmessage loop makes it: cropped to `crop`, carved around `carveAtoms`, with the full
* cell's mean and RMS. aaredb carves the 2Fo-Fc map only; the live page carves the difference maps as well, within
* the same radius, so what it shows is the density about the model - unmodelled blobs further away are left out. */
export function prepareMap(name: string, full: FullCellMap, crop: CropBox | null, carveAtoms: Float32Array | null,
carveRadiusA = 4.5): ComputedMap {
const [nx, ny, nz] = full.n;
@@ -0,0 +1,16 @@
import { PluginContext } from 'molstar/lib/mol-plugin/context';
import { Quat, Vec3 } from 'molstar/lib/mol-math/linear-algebra';
// The entrance of a freshly placed model: the camera starts further out and turned a little about the vertical,
// then eases in to the view a plain reset frames. Only the camera moves, and it ends exactly where the reset put
// it, so nothing after the reveal differs from a viewer without one.
export function revealCamera(plugin: PluginContext, durationMs: number): void {
const camera = plugin.canvas3d?.camera;
if (!camera) return;
const end = camera.getSnapshot();
const dir = Vec3.sub(Vec3(), end.position, end.target);
Vec3.transformQuat(dir, dir, Quat.setAxisAngle(Quat(), end.up, -0.6)); // about 35 degrees
Vec3.scale(dir, dir, 2.2);
camera.setState({ ...end, position: Vec3.add(Vec3(), end.target, dir) }, 0);
camera.setState(end, durationMs, { easing: 'cubic-out' });
}