Skip to content
Merged
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
43 changes: 24 additions & 19 deletions src/core/datamodel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1464,28 +1464,33 @@ export function projectToJson(project: Project): JsonProject {

export function projectAttachData(project: Project, data: ReadonlyMap<string, Series>, modelName: string): Project {
const model = defined(project.models.get(modelName));
const variables = mapValues(model.variables, (v: Variable) => {
if (data.has(v.ident)) {
return { ...v, data: [defined(data.get(v.ident))] };
}
if (!variableIsArrayed(v)) {
return v;
}
const eqn = variableEquation(v);
if (!eqn || (eqn.type !== 'applyToAll' && eqn.type !== 'arrayed')) {
return v;

// Group every result series by its base variable ident. A scalar variable's
// series is keyed by the bare canonical ident; an arrayed variable's
// per-element series are keyed `ident[<canonical subscripts>]` for any
// dimensionality (1-D `x[a]`, multi-D `x[a,b]`). Grouping by the ident before
// the first `[` attaches every element series -- so multi-dimensional
// variables are plotted too -- and matches whatever the simulation emitted
// rather than reconstructing keys from a Dimension's (original-case)
// subscripts, which avoids the element-name canonicalization mismatch
// entirely.
const seriesByIdent = new Map<string, Series[]>();
for (const [key, s] of data) {
const open = key.indexOf('[');
const ident = open === -1 ? key : key.slice(0, open);
const existing = seriesByIdent.get(ident);
if (existing) {
existing.push(s);
} else {
seriesByIdent.set(ident, [s]);
}
const dimNames = eqn.dimensionNames;
if (dimNames.length !== 1) {
}

const variables = mapValues(model.variables, (v: Variable) => {
const series = seriesByIdent.get(v.ident);
if (!series || series.length === 0) {
return v;
}
const ident = v.ident;
const dim = defined(project.dimensions.get(dimNames[0]));
const series = dim.subscripts
.map((element) => data.get(`${ident}[${element}]`))
.filter((d) => d !== undefined)
.map((d) => defined(d));

return { ...v, data: series };
});
const updatedModel: Model = { ...model, variables };
Expand Down
133 changes: 133 additions & 0 deletions src/core/tests/datamodel.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import {
modelToJson,
projectFromJson,
projectToJson,
projectAttachData,
} from '../datamodel';

import type {
Expand Down Expand Up @@ -68,6 +69,7 @@ import type {
JsonLinkViewElement,
JsonCloudViewElement,
} from '@simlin/engine';
import { defined, type Series } from '../common';

describe('GraphicalFunctionScale', () => {
it('should roundtrip correctly', () => {
Expand Down Expand Up @@ -1244,3 +1246,134 @@ describe('Project', () => {
expect(restored.source).toBeUndefined();
});
});

describe('projectAttachData', () => {
const series = (name: string, values: number[]): Series => ({
name,
time: new Float64Array([0, 1, 2]),
values: new Float64Array(values),
});

const arrayedAux = (ident: string, dimensionNames: string[]): Aux => ({
type: 'aux',
ident,
equation: { type: 'applyToAll', dimensionNames, equation: '1' },
documentation: '',
units: '',
gf: undefined,
canBeModuleInput: false,
isPublic: false,
data: undefined,
errors: undefined,
unitErrors: undefined,
uid: 1,
});

const projectWith = (variables: Variable[], dimensions: Dimension[]): Project => ({
name: 'test',
simSpecs: {
start: 0,
stop: 2,
dt: { value: 1, isReciprocal: false },
saveStep: undefined,
simMethod: 'euler',
timeUnits: '',
},
models: new Map([
[
'main',
{
name: 'main',
variables: new Map<string, Variable>(variables.map((v) => [v.ident, v])),
views: [],
loopMetadata: [],
groups: [],
},
],
]),
dimensions: new Map(dimensions.map((d) => [d.name, d])),
hasNoEquations: false,
source: undefined,
});

// Regression test for arrayed-variable sparklines: the simulation keys its
// per-element series by CANONICALIZED element names (e.g.
// `temperature[high_2xco2_sensitivity]`), but a dimension preserves the
// model's ORIGINAL-case subscript names. projectAttachData must canonicalize
// each element when building the lookup key, or every arrayed variable whose
// dimension elements aren't already lowercase gets no data.
it('attaches per-element data for a 1-D arrayed variable with original-case subscripts', () => {
const project = projectWith(
[arrayedAux('temperature', ['scenario'])],
[{ name: 'scenario', subscripts: ['Deterministic', 'Low_2xCO2_sensitivity', 'High_2xCO2_sensitivity'] }],
);
const data = new Map<string, Series>([
['temperature[deterministic]', series('temperature[deterministic]', [1, 2, 3])],
['temperature[low_2xco2_sensitivity]', series('temperature[low_2xco2_sensitivity]', [4, 5, 6])],
['temperature[high_2xco2_sensitivity]', series('temperature[high_2xco2_sensitivity]', [7, 8, 9])],
]);

const attached = projectAttachData(project, data, 'main');
const v = defined(attached.models.get('main')).variables.get('temperature');

expect(v?.data).toBeDefined();
// ordered by the dimension's declared subscript order
expect((v?.data ?? []).map((s) => Array.from(s.values))).toEqual([
[1, 2, 3],
[4, 5, 6],
[7, 8, 9],
]);
});

it('attaches data for an already-canonical 1-D arrayed variable', () => {
const project = projectWith(
[arrayedAux('population', ['region'])],
[{ name: 'region', subscripts: ['boston', 'nyc'] }],
);
const data = new Map<string, Series>([
['population[boston]', series('population[boston]', [10, 11])],
['population[nyc]', series('population[nyc]', [20, 21])],
]);

const attached = projectAttachData(project, data, 'main');
const v = defined(attached.models.get('main')).variables.get('population');

expect((v?.data ?? []).length).toBe(2);
});

// The "plot all the series" behavior must also cover multi-dimensional
// arrayed variables: the simulation emits one series per element of the
// cartesian product (`flux[co2,deterministic]`, ...), and every one should
// be attached so the chart/sparkline can draw them all.
it('attaches all per-element series for a multi-dimensional arrayed variable', () => {
const project = projectWith(
[arrayedAux('flux', ['gas', 'scenario'])],
[
{ name: 'gas', subscripts: ['CO2', 'CH4'] },
{ name: 'scenario', subscripts: ['Deterministic', 'High_2xCO2_sensitivity'] },
],
);
const data = new Map<string, Series>([
['flux[co2,deterministic]', series('flux[co2,deterministic]', [1, 1])],
['flux[co2,high_2xco2_sensitivity]', series('flux[co2,high_2xco2_sensitivity]', [2, 2])],
['flux[ch4,deterministic]', series('flux[ch4,deterministic]', [3, 3])],
['flux[ch4,high_2xco2_sensitivity]', series('flux[ch4,high_2xco2_sensitivity]', [4, 4])],
]);

const attached = projectAttachData(project, data, 'main');
const v = defined(attached.models.get('main')).variables.get('flux');

expect((v?.data ?? []).length).toBe(4);
});

it('leaves a variable with no result series unchanged', () => {
const project = projectWith(
[arrayedAux('unused', ['scenario'])],
[{ name: 'scenario', subscripts: ['Deterministic'] }],
);
const attached = projectAttachData(project, new Map<string, Series>(), 'main');
const v = defined(attached.models.get('main')).variables.get('unused');

expect(v?.data).toBeUndefined();
});
});
50 changes: 29 additions & 21 deletions src/diagram/VariableDetails.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import { plainDeserialize, plainSerialize } from './drawing/common';
import { CustomElement, FormattedText, CustomEditor } from './drawing/SlateEditor';
import { caretOffsetForClick, caretOffsetWithinSpan, RenderedGlyph } from './equation-caret';
import { LookupEditor } from './LookupEditor';
import { variableDetailsView } from './variable-details-display';
import { errorCodeDescription } from '@simlin/engine';

import styles from './VariableDetails.module.css';
Expand Down Expand Up @@ -386,31 +387,38 @@ export class VariableDetails extends React.PureComponent<VariableDetailsProps, V
initialUnits !== stringFromDescendants(this.state.unitsContents) ||
initialDocs !== stringFromDescendants(this.state.notesContents);

let chartOrErrors;
const errors = this.props.variable.errors;
const unitErrors = this.props.variable.unitErrors;
if (errors || unitErrors) {
const errorList: Array<React.ReactElement> = [];
if (errors) {
errors.forEach((error) => {
errorList.push(<div className={styles.errorList}>error: {errorCodeDescription(error.code)}</div>);
});
}
if (unitErrors) {
unitErrors.forEach((error) => {
const details = error.details;
errorList.push(
<div className={styles.errorList}>
unit error: {errorCodeDescription(error.code)}
{details ? `: ${details}` : undefined}
</div>,
);
});
}
chartOrErrors = errorList;
const detailsView = variableDetailsView(this.props.variable);
// Unit errors are non-fatal warnings: the variable still simulates and has
// data. They are rendered beneath the chart (or alongside equation errors)
// rather than replacing the results.
const unitWarnings = detailsView.unitWarnings.map((error, i) => {
const details = error.details;
return (
<div key={`unit-${i}`} className={styles.errorList}>
unit error: {errorCodeDescription(error.code)}
{details ? `: ${details}` : undefined}
</div>
);
});

let chartOrErrors;
if (!detailsView.showChart) {
// Equation/compile errors mean the variable produced no valid data, so
// the error list replaces the chart.
const errorList = detailsView.equationErrors.map((error, i) => (
<div key={`eqn-${i}`} className={styles.errorList}>
error: {errorCodeDescription(error.code)}
</div>
));
chartOrErrors = [...errorList, ...unitWarnings];
} else {
chartOrErrors = (
<LineChart height={300} series={chartSeries} yDomain={[yMin, yMax]} tooltipFormatter={this.formatValue} />
<>
<LineChart height={300} series={chartSeries} yDomain={[yMin, yMax]} tooltipFormatter={this.formatValue} />
{unitWarnings}
</>
);
}

Expand Down
65 changes: 65 additions & 0 deletions src/diagram/tests/variable-details-display.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
// Copyright 2026 The Simlin Authors. All rights reserved.
// Use of this source code is governed by the Apache License,
// Version 2.0, that can be found in the LICENSE file.

import { ErrorCode } from '@simlin/core/datamodel';
import type { Aux, EquationError, UnitError, Variable } from '@simlin/core/datamodel';

import { variableDetailsView } from '../variable-details-display';

function aux(overrides: Partial<Aux> = {}): Variable {
return {
type: 'aux',
ident: 'x',
equation: { type: 'scalar', equation: '1' },
documentation: '',
units: '',
gf: undefined,
canBeModuleInput: false,
isPublic: false,
data: undefined,
errors: undefined,
unitErrors: undefined,
uid: 1,
...overrides,
};
}

const equationError: EquationError = { code: ErrorCode.EmptyEquation, start: 0, end: 0 };
const unitError: UnitError = {
code: ErrorCode.BadTable,
start: 0,
end: 0,
isConsistencyError: true,
details: 'dimensions are not equal',
};

describe('variableDetailsView', () => {
it('shows the chart when the variable has no errors', () => {
expect(variableDetailsView(aux())).toEqual({
showChart: true,
equationErrors: [],
unitWarnings: [],
});
});

it('keeps the chart and surfaces unit errors as warnings (not fatal)', () => {
const view = variableDetailsView(aux({ unitErrors: [unitError] }));
expect(view.showChart).toBe(true);
expect(view.unitWarnings).toEqual([unitError]);
expect(view.equationErrors).toEqual([]);
});

it('replaces the chart with equation/compile errors (no valid data)', () => {
const view = variableDetailsView(aux({ errors: [equationError] }));
expect(view.showChart).toBe(false);
expect(view.equationErrors).toEqual([equationError]);
});

it('prefers equation errors over the chart even when unit errors also exist', () => {
const view = variableDetailsView(aux({ errors: [equationError], unitErrors: [unitError] }));
expect(view.showChart).toBe(false);
expect(view.equationErrors).toEqual([equationError]);
expect(view.unitWarnings).toEqual([unitError]);
});
});
34 changes: 34 additions & 0 deletions src/diagram/variable-details-display.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
// Copyright 2026 The Simlin Authors. All rights reserved.
// Use of this source code is governed by the Apache License,
// Version 2.0, that can be found in the LICENSE file.

import type { EquationError, UnitError, Variable } from '@simlin/core/datamodel';

export interface VariableDetailsView {
/**
* Whether to render the results chart. False only when the variable has
* equation/compile errors -- those mean it produced no valid data, so the
* error list takes the chart's place.
*/
readonly showChart: boolean;
/** Equation/compile errors; rendered as the error list when showChart is false. */
readonly equationErrors: readonly EquationError[];
/** Non-fatal unit errors; surfaced as warnings beside the chart. */
readonly unitWarnings: readonly UnitError[];
}

/**
* Decide what the variable-details panel shows for a variable. Unit errors are
* non-fatal -- the variable still simulates and has data -- so they no longer
* hide the chart; only genuine equation/compile errors (which leave the
* variable with no valid data) replace it.
*/
export function variableDetailsView(variable: Variable): VariableDetailsView {
const equationErrors = variable.errors ?? [];
const unitWarnings = variable.unitErrors ?? [];
return {
showChart: equationErrors.length === 0,
equationErrors,
unitWarnings,
};
}
Loading