@adcops/autocore-react 3.3.124 → 3.5.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/components/UnitSystemSelector.d.ts +17 -0
- package/dist/components/UnitSystemSelector.d.ts.map +1 -0
- package/dist/components/UnitSystemSelector.js +1 -0
- package/dist/components/UnitsEditor.d.ts +11 -0
- package/dist/components/UnitsEditor.d.ts.map +1 -0
- package/dist/components/UnitsEditor.js +1 -0
- package/dist/components/ams/AmsProvider.d.ts +14 -0
- package/dist/components/ams/AmsProvider.d.ts.map +1 -1
- package/dist/components/ams/AmsProvider.js +1 -1
- package/dist/components/ams/AssetDetailView.d.ts.map +1 -1
- package/dist/components/ams/AssetDetailView.js +1 -1
- package/dist/components/ams/AssetNameplateGrid.d.ts +13 -0
- package/dist/components/ams/AssetNameplateGrid.d.ts.map +1 -0
- package/dist/components/ams/AssetNameplateGrid.js +1 -0
- package/dist/components/ams/InstalledAssetPicker.d.ts +35 -0
- package/dist/components/ams/InstalledAssetPicker.d.ts.map +1 -0
- package/dist/components/ams/InstalledAssetPicker.js +1 -0
- package/dist/components/ams/index.d.ts +5 -0
- package/dist/components/ams/index.d.ts.map +1 -1
- package/dist/components/ams/index.js +1 -1
- package/dist/components/ams/useInstalledAsset.d.ts +26 -0
- package/dist/components/ams/useInstalledAsset.d.ts.map +1 -0
- package/dist/components/ams/useInstalledAsset.js +1 -0
- package/dist/components/index.d.ts +4 -0
- package/dist/components/index.d.ts.map +1 -1
- package/dist/components/index.js +1 -1
- package/dist/components/tis/ResultHistoryTable.d.ts.map +1 -1
- package/dist/components/tis/ResultHistoryTable.js +1 -1
- package/dist/components/tis/ScienceTable.d.ts.map +1 -1
- package/dist/components/tis/ScienceTable.js +1 -1
- package/dist/components/tis/TestDataView.d.ts +16 -4
- package/dist/components/tis/TestDataView.d.ts.map +1 -1
- package/dist/components/tis/TestDataView.js +1 -1
- package/dist/components/tis/TestFieldRow.d.ts +88 -7
- package/dist/components/tis/TestFieldRow.d.ts.map +1 -1
- package/dist/components/tis/TestFieldRow.js +1 -1
- package/dist/components/tis/TestSetupForm.d.ts.map +1 -1
- package/dist/components/tis/TestSetupForm.js +1 -1
- package/dist/components/tis/TisProvider.d.ts.map +1 -1
- package/dist/components/tis/TisProvider.js +1 -1
- package/dist/components/tis-editor/editor/TestFieldDialog.d.ts.map +1 -1
- package/dist/components/tis-editor/editor/TestFieldDialog.js +1 -1
- package/dist/components/tis-editor/types.d.ts +6 -2
- package/dist/components/tis-editor/types.d.ts.map +1 -1
- package/dist/core/AutoCoreTagContext.d.ts.map +1 -1
- package/dist/core/AutoCoreTagContext.js +1 -1
- package/dist/core/AutoCoreTagTypes.d.ts +82 -7
- package/dist/core/AutoCoreTagTypes.d.ts.map +1 -1
- package/dist/core/formatScaled.d.ts +16 -0
- package/dist/core/formatScaled.d.ts.map +1 -0
- package/dist/core/formatScaled.js +1 -0
- package/dist/hooks/useAutoCoreTag.d.ts +11 -3
- package/dist/hooks/useAutoCoreTag.d.ts.map +1 -1
- package/dist/hooks/useAutoCoreTag.js +1 -1
- package/package.json +1 -1
- package/src/components/UnitSystemSelector.tsx +88 -0
- package/src/components/UnitsEditor.tsx +317 -0
- package/src/components/ams/AmsProvider.tsx +36 -1
- package/src/components/ams/AssetDetailView.tsx +2 -20
- package/src/components/ams/AssetNameplateGrid.tsx +62 -0
- package/src/components/ams/InstalledAssetPicker.tsx +209 -0
- package/src/components/ams/index.ts +7 -0
- package/src/components/ams/useInstalledAsset.ts +108 -0
- package/src/components/index.ts +4 -0
- package/src/components/tis/ResultHistoryTable.tsx +89 -6
- package/src/components/tis/ScienceTable.tsx +13 -0
- package/src/components/tis/TestDataView.tsx +153 -35
- package/src/components/tis/TestFieldRow.tsx +157 -19
- package/src/components/tis/TestSetupForm.tsx +35 -7
- package/src/components/tis/TisProvider.tsx +46 -3
- package/src/components/tis-editor/editor/TestFieldDialog.tsx +29 -18
- package/src/components/tis-editor/types.ts +6 -2
- package/src/core/AutoCoreTagContext.tsx +165 -16
- package/src/core/AutoCoreTagTypes.ts +91 -7
- package/src/core/formatScaled.ts +72 -0
- package/src/hooks/useAutoCoreTag.ts +51 -3
- package/todo.md +6 -0
|
@@ -102,6 +102,18 @@ export const ScienceTable: React.FC<ScienceTableProps> = ({
|
|
|
102
102
|
onCellEdit,
|
|
103
103
|
}) => {
|
|
104
104
|
const virtual = scrollable && !!scrollHeight;
|
|
105
|
+
|
|
106
|
+
// Force a remount when the COLUMN DEFINITION changes — which is what a
|
|
107
|
+
// Metric/Imperial switch does (new label, new unit, new body formatter).
|
|
108
|
+
//
|
|
109
|
+
// PrimeReact memoises rendered rows against the row data, so with the same
|
|
110
|
+
// `rows` array it keeps the old cells even though every column's `body`
|
|
111
|
+
// closure is new: the header flipped to "lbf" while the numbers stayed in N,
|
|
112
|
+
// and the operator had to leave the tab and come back. Keying on the column
|
|
113
|
+
// signature (not on every render) confines the remount to an actual change.
|
|
114
|
+
const columnSignature = columns
|
|
115
|
+
.map((c) => `${c.field}\u0000${c.label}\u0000${c.units ?? ''}`)
|
|
116
|
+
.join('\u0001');
|
|
105
117
|
// Cell edit mode is switched on only when a column asks for it AND a commit
|
|
106
118
|
// handler exists, so a plain report table keeps DataTable's read-only path.
|
|
107
119
|
const anyEditable = !!onCellEdit && columns.some((c) => c.editable);
|
|
@@ -110,6 +122,7 @@ export const ScienceTable: React.FC<ScienceTableProps> = ({
|
|
|
110
122
|
{title != null && <div className="ac-science-table-title">{title}</div>}
|
|
111
123
|
<div className="ac-science-table-scroll">
|
|
112
124
|
<DataTable
|
|
125
|
+
key={columnSignature}
|
|
113
126
|
className="ac-science-table"
|
|
114
127
|
value={rows}
|
|
115
128
|
scrollable={scrollable}
|
|
@@ -43,6 +43,9 @@ import { Line } from 'react-chartjs-2';
|
|
|
43
43
|
import { EventEmitterContext } from '../../core/EventEmitterContext';
|
|
44
44
|
import { MessageType } from '../../hub/CommandMessage';
|
|
45
45
|
import { useTis } from './TisProvider';
|
|
46
|
+
import { AutoCoreTagContext } from '../../core/AutoCoreTagContext';
|
|
47
|
+
import { formatScaled } from '../../core/formatScaled';
|
|
48
|
+
import type { ScaleConfig } from '../../core/AutoCoreTagTypes';
|
|
46
49
|
import { useRawCycleData } from './useRawCycleData';
|
|
47
50
|
import { ScienceTable } from './ScienceTable';
|
|
48
51
|
import type { ScienceColumn } from './ScienceTable';
|
|
@@ -67,10 +70,22 @@ export interface TestFieldDef {
|
|
|
67
70
|
* prefer it over `name`; a header reading `cycle_count` is the wire
|
|
68
71
|
* key leaking into a screen that says "Cycles" everywhere else. */
|
|
69
72
|
label?: string;
|
|
70
|
-
/**
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
73
|
+
/**
|
|
74
|
+
* The QUANTITY this field measures — a scale-group name from the machine's
|
|
75
|
+
* `units.json` ("force", "position", …).
|
|
76
|
+
*
|
|
77
|
+
* When set, Cycle Data / Results render the value in the operator's ACTIVE
|
|
78
|
+
* unit system, at that system's precision, and the column header carries the
|
|
79
|
+
* active label. This is what makes a test run in Imperial read out in
|
|
80
|
+
* Imperial. Storage is untouched — conversion is a rendering step only.
|
|
81
|
+
*
|
|
82
|
+
* Takes precedence over `scale`/`units` below.
|
|
83
|
+
*/
|
|
84
|
+
quantity?: string;
|
|
85
|
+
/**
|
|
86
|
+
* Legacy display-time scale multiplier. `display = raw * scale`, default
|
|
87
|
+
* 1.0. Superseded by `quantity`; kept for methods not yet annotated.
|
|
88
|
+
*/
|
|
74
89
|
scale?: number;
|
|
75
90
|
/** Dropdown choices for this field. Each entry is either a bare scalar
|
|
76
91
|
* (label === stored value) or an explicit { label, value } pair.
|
|
@@ -238,6 +253,10 @@ export const TestDataView: React.FC<TestDataViewProps> = (props) => {
|
|
|
238
253
|
const schema = props.schema ?? (methodId ? (tis.schemas[methodId] as TestMethod) : undefined);
|
|
239
254
|
const { throttleMs = 100, cycleTableHeight = '400px', chartHeight = '320px' } = props;
|
|
240
255
|
const { invoke, subscribe, unsubscribe } = useContext(EventEmitterContext);
|
|
256
|
+
// The machine's resolved units table (quantity -> the active system's
|
|
257
|
+
// label/scalar/precision). Comes from the tag provider, so a live
|
|
258
|
+
// Metric/Imperial switch re-renders these tables with no reload.
|
|
259
|
+
const { scales: unitScales } = useContext(AutoCoreTagContext);
|
|
241
260
|
|
|
242
261
|
const [meta, setMeta] = useState<any>(null);
|
|
243
262
|
const [cycles, setCycles] = useState<any[]>([]);
|
|
@@ -537,11 +556,21 @@ export const TestDataView: React.FC<TestDataViewProps> = (props) => {
|
|
|
537
556
|
// cycle_scatter uses a CATEGORY x axis, so chart.js prints each
|
|
538
557
|
// label verbatim — an unrounded double lands on the axis with all
|
|
539
558
|
// 17 of its digits. Round here, at the point the label is made.
|
|
540
|
-
|
|
541
|
-
|
|
559
|
+
//
|
|
560
|
+
// Values are converted into the operator's active system first: the
|
|
561
|
+
// tables already followed the unit switch, so a chart left in
|
|
562
|
+
// backend units disagreed with the table beside it.
|
|
563
|
+
const xScale = unitScales?.[quantityOfField(schema, xField) ?? ''];
|
|
564
|
+
const decimals = selectedViewDef.x_decimals
|
|
565
|
+
?? xScale?.precision
|
|
566
|
+
?? DEFAULT_X_DECIMALS;
|
|
567
|
+
const xs = asc.map(c => formatAxisTick(toDisplayValue(c[xField], xScale), decimals));
|
|
542
568
|
const datasets = selectedViewDef.y.map((s, idx) => ({
|
|
543
569
|
label: s.label ?? s.field,
|
|
544
|
-
data: asc.map(c =>
|
|
570
|
+
data: asc.map(c => toDisplayValue(
|
|
571
|
+
c[s.field!],
|
|
572
|
+
unitScales?.[quantityOfField(schema, s.field) ?? ''],
|
|
573
|
+
)),
|
|
545
574
|
yAxisID: s.y_axis === 'right' ? 'y1' : 'y',
|
|
546
575
|
borderColor: palette(idx),
|
|
547
576
|
backgroundColor: palette(idx),
|
|
@@ -554,12 +583,18 @@ export const TestDataView: React.FC<TestDataViewProps> = (props) => {
|
|
|
554
583
|
if (!traceFetch.raw) return null;
|
|
555
584
|
const xCol = selectedViewDef.x.column;
|
|
556
585
|
if (!xCol) return null;
|
|
557
|
-
const
|
|
586
|
+
const xTraceScale = unitScales?.[quantityOfColumn(schema, xCol) ?? ''];
|
|
587
|
+
const rawXs = traceFetch.raw[xCol] ?? [];
|
|
588
|
+
const xs = xTraceScale
|
|
589
|
+
? (rawXs as number[]).map(v => toDisplayValue(v, xTraceScale))
|
|
590
|
+
: rawXs;
|
|
558
591
|
// Display-only smoothing (default on). Leaves traceFetch.raw
|
|
559
592
|
// untouched — only what chart.js draws changes.
|
|
560
593
|
const smoothOn = selectedViewDef.smooth !== false;
|
|
561
594
|
const datasets = selectedViewDef.y.map((s, idx) => {
|
|
562
|
-
const
|
|
595
|
+
const yScale = unitScales?.[quantityOfColumn(schema, s.column) ?? ''];
|
|
596
|
+
const rawYs = (traceFetch.raw![s.column!] ?? []) as number[];
|
|
597
|
+
const ys = yScale ? rawYs.map(v => toDisplayValue(v, yScale) as number) : rawYs;
|
|
563
598
|
const data = smoothOn
|
|
564
599
|
? smoothTraceForDisplay(xs, ys, selectedViewDef.smoothWindow)
|
|
565
600
|
: ys.map((y, i) => ({ x: xs[i], y }));
|
|
@@ -580,7 +615,7 @@ export const TestDataView: React.FC<TestDataViewProps> = (props) => {
|
|
|
580
615
|
return { datasets };
|
|
581
616
|
}
|
|
582
617
|
return null;
|
|
583
|
-
}, [selectedViewDef, cycles, traceFetch.raw]);
|
|
618
|
+
}, [selectedViewDef, cycles, traceFetch.raw, unitScales, schema]);
|
|
584
619
|
|
|
585
620
|
const usesRightAxis = selectedViewDef?.y.some(s => s.y_axis === 'right') ?? false;
|
|
586
621
|
|
|
@@ -608,7 +643,23 @@ export const TestDataView: React.FC<TestDataViewProps> = (props) => {
|
|
|
608
643
|
|
|
609
644
|
const chartOptions = useMemo(() => {
|
|
610
645
|
const isTrace = selectedViewDef?.type === 'raw_trace';
|
|
611
|
-
|
|
646
|
+
// Unit for one series, from whichever side of the schema it reads.
|
|
647
|
+
const unitForSeries = (sr: ChartSeries): string | undefined => {
|
|
648
|
+
const q = isTrace
|
|
649
|
+
? quantityOfColumn(schema, sr.column)
|
|
650
|
+
: quantityOfField(schema, sr.field);
|
|
651
|
+
return q ? unitScales?.[q]?.label : undefined;
|
|
652
|
+
};
|
|
653
|
+
const xQuantity = isTrace
|
|
654
|
+
? quantityOfColumn(schema, selectedViewDef?.x.column)
|
|
655
|
+
: quantityOfField(schema, selectedViewDef?.x.field);
|
|
656
|
+
const xUnit = xQuantity ? unitScales?.[xQuantity]?.label : undefined;
|
|
657
|
+
const xTitle = selectedViewDef?.x.label
|
|
658
|
+
? (xUnit ? `${selectedViewDef.x.label} [${xUnit}]` : selectedViewDef.x.label)
|
|
659
|
+
: undefined;
|
|
660
|
+
const xDecimals = selectedViewDef?.x_decimals
|
|
661
|
+
?? (xQuantity ? unitScales?.[xQuantity]?.precision : undefined)
|
|
662
|
+
?? DEFAULT_X_DECIMALS;
|
|
612
663
|
// A linear axis picks its own tick values, and zooming in drives them
|
|
613
664
|
// to arbitrary precision — so cap them here too, not just on the
|
|
614
665
|
// category labels built in `chartData`.
|
|
@@ -630,15 +681,15 @@ export const TestDataView: React.FC<TestDataViewProps> = (props) => {
|
|
|
630
681
|
x: isTrace
|
|
631
682
|
? { type: 'linear' as const,
|
|
632
683
|
ticks: xTicks,
|
|
633
|
-
title: { display: !!
|
|
684
|
+
title: { display: !!xTitle, text: xTitle } }
|
|
634
685
|
: { ticks: xTicks,
|
|
635
|
-
title: { display: !!
|
|
686
|
+
title: { display: !!xTitle, text: xTitle } },
|
|
636
687
|
y: { position: 'left' as const,
|
|
637
|
-
title: { display: true, text:
|
|
688
|
+
title: { display: true, text: axisLabel(selectedViewDef, 'left', unitForSeries) } },
|
|
638
689
|
...(usesRightAxis ? {
|
|
639
690
|
y1: { position: 'right' as const,
|
|
640
691
|
grid: { drawOnChartArea: false },
|
|
641
|
-
title: { display: true, text:
|
|
692
|
+
title: { display: true, text: axisLabel(selectedViewDef, 'right', unitForSeries) } },
|
|
642
693
|
} : {}),
|
|
643
694
|
},
|
|
644
695
|
plugins: {
|
|
@@ -659,7 +710,7 @@ export const TestDataView: React.FC<TestDataViewProps> = (props) => {
|
|
|
659
710
|
},
|
|
660
711
|
},
|
|
661
712
|
};
|
|
662
|
-
}, [selectedViewDef, usesRightAxis, activeRegions]);
|
|
713
|
+
}, [selectedViewDef, usesRightAxis, activeRegions, unitScales, schema]);
|
|
663
714
|
|
|
664
715
|
// -----------------------------------------------------------------
|
|
665
716
|
// View Raw Data dialog: lazy-fetch raw + filtered blobs the first
|
|
@@ -882,8 +933,8 @@ export const TestDataView: React.FC<TestDataViewProps> = (props) => {
|
|
|
882
933
|
// without an `editable` flag), while measured columns still
|
|
883
934
|
// have to opt in.
|
|
884
935
|
const cycleColumns: ScienceColumn[] = [
|
|
885
|
-
...(schema.cycle_config_fields ?? []).map(f => toScienceColumn(f, true)),
|
|
886
|
-
...(schema.cycle_fields ?? []).map(f => toScienceColumn(f, !!f.editable)),
|
|
936
|
+
...(schema.cycle_config_fields ?? []).map(f => toScienceColumn(f, true, unitScales)),
|
|
937
|
+
...(schema.cycle_fields ?? []).map(f => toScienceColumn(f, !!f.editable, unitScales)),
|
|
887
938
|
];
|
|
888
939
|
return (
|
|
889
940
|
<ScienceTable
|
|
@@ -911,7 +962,7 @@ export const TestDataView: React.FC<TestDataViewProps> = (props) => {
|
|
|
911
962
|
const values = { ...results, ...sampleSummary };
|
|
912
963
|
const hasValues = Object.keys(values).length > 0;
|
|
913
964
|
const resultFields = schema.results_fields ?? [];
|
|
914
|
-
const resultColumns: ScienceColumn[] = resultFields.map(f => toScienceColumn(f));
|
|
965
|
+
const resultColumns: ScienceColumn[] = resultFields.map(f => toScienceColumn(f, false, unitScales));
|
|
915
966
|
const hasProjectScoped = resultFields.some(isProjectScoped);
|
|
916
967
|
return (
|
|
917
968
|
<>
|
|
@@ -1518,11 +1569,47 @@ const formatAxisTick = (v: unknown, decimals: number): string => {
|
|
|
1518
1569
|
return Number.parseFloat(n.toFixed(decimals)).toString();
|
|
1519
1570
|
};
|
|
1520
1571
|
|
|
1572
|
+
/**
|
|
1573
|
+
* The quantity a cycle/results FIELD declares, if any.
|
|
1574
|
+
*
|
|
1575
|
+
* Derived from the method schema rather than a new key on the chart view, so a
|
|
1576
|
+
* method annotated once (for the tables) automatically gets converted charts.
|
|
1577
|
+
*/
|
|
1578
|
+
const quantityOfField = (schema: TestMethod | undefined, name?: string): string | undefined => {
|
|
1579
|
+
if (!schema || !name) return undefined;
|
|
1580
|
+
for (const arr of [schema.cycle_config_fields, schema.cycle_fields, schema.results_fields]) {
|
|
1581
|
+
const hit = (arr ?? []).find(f => f.name === name);
|
|
1582
|
+
if (hit?.quantity) return hit.quantity;
|
|
1583
|
+
}
|
|
1584
|
+
return undefined;
|
|
1585
|
+
};
|
|
1586
|
+
|
|
1587
|
+
/** The quantity a raw_data COLUMN declares, if any. */
|
|
1588
|
+
const quantityOfColumn = (schema: TestMethod | undefined, name?: string): string | undefined => {
|
|
1589
|
+
if (!schema || !name) return undefined;
|
|
1590
|
+
return (schema.raw_data?.columns?.[name] as any)?.quantity;
|
|
1591
|
+
};
|
|
1592
|
+
|
|
1593
|
+
/** backend -> display for a resolved scale (identity when there is none). */
|
|
1594
|
+
const toDisplayValue = (v: any, scale?: ScaleConfig): any =>
|
|
1595
|
+
scale && typeof v === 'number' && Number.isFinite(v)
|
|
1596
|
+
? v * scale.scale + (scale.offset ?? 0)
|
|
1597
|
+
: v;
|
|
1598
|
+
|
|
1521
1599
|
const seriesLabel = (s: ChartSeries) => s.label ?? s.field ?? s.column ?? '';
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1600
|
+
/** Series names for one side, with the active unit appended when they share one. */
|
|
1601
|
+
const axisLabel = (
|
|
1602
|
+
v: ChartView | undefined,
|
|
1603
|
+
side: 'left' | 'right',
|
|
1604
|
+
unitFor: (s: ChartSeries) => string | undefined,
|
|
1605
|
+
): string => {
|
|
1606
|
+
const series = v?.y.filter(s => (s.y_axis === 'right') === (side === 'right')) ?? [];
|
|
1607
|
+
const names = series.map(seriesLabel).join(' / ');
|
|
1608
|
+
const units = Array.from(new Set(series.map(unitFor).filter(Boolean)));
|
|
1609
|
+
// Only label the axis when every series on it shares a unit — a mixed axis
|
|
1610
|
+
// must not claim one.
|
|
1611
|
+
return units.length === 1 ? `${names} [${units[0]}]` : names;
|
|
1612
|
+
};
|
|
1526
1613
|
|
|
1527
1614
|
/**
|
|
1528
1615
|
* Format one value for a cycle / results / config cell.
|
|
@@ -1542,19 +1629,50 @@ const rightAxisLabel = (v?: ChartView) =>
|
|
|
1542
1629
|
*/
|
|
1543
1630
|
const isProjectScoped = (f: TestFieldDef): boolean => f.aggregate?.scope === 'project';
|
|
1544
1631
|
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1632
|
+
/**
|
|
1633
|
+
* One schema field -> one <ScienceTable> column.
|
|
1634
|
+
*
|
|
1635
|
+
* `unitScales` is the machine's resolved units table (quantity -> active
|
|
1636
|
+
* system's treatment). When the field declares a `quantity` present there, both
|
|
1637
|
+
* the header unit AND the cell values come from it, so the column can never show
|
|
1638
|
+
* a value in one unit under a header naming another.
|
|
1639
|
+
*/
|
|
1640
|
+
const toScienceColumn = (
|
|
1641
|
+
f: TestFieldDef,
|
|
1642
|
+
editable = false,
|
|
1643
|
+
unitScales?: Record<string, ScaleConfig>,
|
|
1644
|
+
): ScienceColumn => {
|
|
1645
|
+
const unitScale = f.quantity ? unitScales?.[f.quantity] : undefined;
|
|
1646
|
+
return {
|
|
1647
|
+
field: f.name,
|
|
1648
|
+
// A project-scoped rollup sits in the same row as this test's own
|
|
1649
|
+
// numbers, so it has to say so. Marked here and explained in a note
|
|
1650
|
+
// under the table — never in a tooltip.
|
|
1651
|
+
label: (f.label && f.label.length > 0 ? f.label : f.name) + (isProjectScoped(f) ? ' *' : ''),
|
|
1652
|
+
units: unitScale ? unitScale.label : f.units,
|
|
1653
|
+
editable,
|
|
1654
|
+
body: (row) => formatCell(row[f.name], f.type, f.scale, unitScale),
|
|
1655
|
+
};
|
|
1656
|
+
};
|
|
1657
|
+
|
|
1658
|
+
/**
|
|
1659
|
+
* Render one stored (backend) value for display.
|
|
1660
|
+
*
|
|
1661
|
+
* When the field declares a `quantity` and the machine has a units table, the
|
|
1662
|
+
* value is converted into the operator's active system and formatted at that
|
|
1663
|
+
* system's precision — so switching Metric/Imperial re-renders the table without
|
|
1664
|
+
* touching a byte on disk. Otherwise the legacy `scale` multiplier applies.
|
|
1665
|
+
*/
|
|
1666
|
+
const formatCell = (
|
|
1667
|
+
v: any,
|
|
1668
|
+
type: string,
|
|
1669
|
+
scale?: number,
|
|
1670
|
+
unitScale?: ScaleConfig,
|
|
1671
|
+
): string => {
|
|
1557
1672
|
if (v === null || v === undefined) return '';
|
|
1673
|
+
if (unitScale && typeof v === 'number' && Number.isFinite(v)) {
|
|
1674
|
+
return formatScaled(v * unitScale.scale + (unitScale.offset ?? 0), unitScale);
|
|
1675
|
+
}
|
|
1558
1676
|
if (scale && scale !== 1 && typeof v === 'number' && Number.isFinite(v)) {
|
|
1559
1677
|
v = v * scale;
|
|
1560
1678
|
}
|
|
@@ -14,7 +14,8 @@
|
|
|
14
14
|
* display = raw * scale raw = display / scale
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
|
-
import React from 'react';
|
|
17
|
+
import React, { useContext } from 'react';
|
|
18
|
+
import { AutoCoreTagContext } from '../../core/AutoCoreTagContext';
|
|
18
19
|
import { Button } from 'primereact/button';
|
|
19
20
|
import { Dropdown } from 'primereact/dropdown';
|
|
20
21
|
import { ValueInput } from '../ValueInput';
|
|
@@ -29,6 +30,13 @@ export interface TestFieldDef {
|
|
|
29
30
|
/** Canonical key — wire format, generated code, on-disk JSON. */
|
|
30
31
|
name: string;
|
|
31
32
|
type: string;
|
|
33
|
+
/**
|
|
34
|
+
* Scale-group name from the machine's `units.json` ("force", "position", …).
|
|
35
|
+
* When set, this field is displayed and entered in the operator's ACTIVE
|
|
36
|
+
* unit system and its label carries that unit; storage stays backend.
|
|
37
|
+
* Supersedes `units` / `scale` below.
|
|
38
|
+
*/
|
|
39
|
+
quantity?: string;
|
|
32
40
|
units?: string;
|
|
33
41
|
required?: boolean;
|
|
34
42
|
source?: string;
|
|
@@ -59,11 +67,20 @@ export interface TestFieldDef {
|
|
|
59
67
|
* Cycle and results values are scaled by the corresponding paths
|
|
60
68
|
* in TestDataView; the server scales CSV exports too. */
|
|
61
69
|
scale?: number;
|
|
62
|
-
/** Optional inclusive bounds
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
|
|
66
|
-
|
|
70
|
+
/** Optional inclusive bounds for the operator's numeric entry. The numeric
|
|
71
|
+
* input rejects values outside `[min, max]`; non-numeric fields ignore them.
|
|
72
|
+
*
|
|
73
|
+
* A `number` is authored in DISPLAY units (same convention as `default`
|
|
74
|
+
* and `scale`). A `string` is an FQDN token `"${gm.<var>}"`, resolved LIVE
|
|
75
|
+
* from that tag — for a bound that belongs to the MACHINE rather than the
|
|
76
|
+
* method, the load-cell force ceiling being the motivating case: hard-code
|
|
77
|
+
* it and it is wrong the moment a different cell is fitted, and the
|
|
78
|
+
* operator hits a wall the machine could have moved for them.
|
|
79
|
+
*
|
|
80
|
+
* `effectiveField` resolves tokens, so everything downstream of it
|
|
81
|
+
* (`rangeIssue`, the input's bounds) only ever sees numbers. */
|
|
82
|
+
min?: number | string;
|
|
83
|
+
max?: number | string;
|
|
67
84
|
/** Optional fixed set of choices. When present, the field renders
|
|
68
85
|
* as a dropdown and the operator must pick one of the declared
|
|
69
86
|
* values rather than typing freely. Each entry is either a bare
|
|
@@ -115,16 +132,123 @@ export type CycleConfigScope = 'run' | 'cycle';
|
|
|
115
132
|
* display value, so scales can change in project.json without
|
|
116
133
|
* invalidating historical records.
|
|
117
134
|
*/
|
|
118
|
-
export const rawToDisplay = (raw: any, scale: number | undefined): any => {
|
|
119
|
-
if (!scale || scale === 1) return raw;
|
|
135
|
+
export const rawToDisplay = (raw: any, scale: number | undefined, offset = 0): any => {
|
|
136
|
+
if ((!scale || scale === 1) && !offset) return raw;
|
|
120
137
|
if (typeof raw !== 'number' || !Number.isFinite(raw)) return raw;
|
|
121
|
-
return raw * scale;
|
|
138
|
+
return raw * (scale ?? 1) + offset;
|
|
122
139
|
};
|
|
123
140
|
|
|
124
|
-
export const displayToRaw = (display: any, scale: number | undefined): any => {
|
|
125
|
-
if (!scale || scale === 1) return display;
|
|
141
|
+
export const displayToRaw = (display: any, scale: number | undefined, offset = 0): any => {
|
|
142
|
+
if ((!scale || scale === 1) && !offset) return display;
|
|
126
143
|
if (typeof display !== 'number' || !Number.isFinite(display)) return display;
|
|
127
|
-
return display / scale;
|
|
144
|
+
return (display - offset) / (scale ?? 1);
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Resolve a field against the machine's units table.
|
|
149
|
+
*
|
|
150
|
+
* Returns the field with `scale` / `units` overridden by the operator's ACTIVE
|
|
151
|
+
* unit system when it declares a `quantity`. Doing it this way means every
|
|
152
|
+
* existing `rawToDisplay(v, f.scale)` / `labelOf(f)` call site keeps working and
|
|
153
|
+
* automatically follows a Metric/Imperial switch — the alternative was threading
|
|
154
|
+
* a units argument through a dozen call sites.
|
|
155
|
+
*
|
|
156
|
+
* A field with no `quantity` (or a machine with no units table) is returned
|
|
157
|
+
* unchanged, so an un-annotated method behaves exactly as before.
|
|
158
|
+
*/
|
|
159
|
+
export const effectiveField = (
|
|
160
|
+
f: TestFieldDef,
|
|
161
|
+
unitScales?: Record<string, { scale: number; label: string; offset?: number }>,
|
|
162
|
+
tags?: TagLookup,
|
|
163
|
+
): EffectiveField => {
|
|
164
|
+
const u = f.quantity ? unitScales?.[f.quantity] : undefined;
|
|
165
|
+
const scale = u ? u.scale : f.scale;
|
|
166
|
+
const offset = u ? u.offset ?? 0 : 0;
|
|
167
|
+
const base = u ? { ...f, scale, units: u.label, unitOffset: offset } : { ...f };
|
|
168
|
+
return {
|
|
169
|
+
...base,
|
|
170
|
+
min: resolveBound(f.min, scale, offset, tags),
|
|
171
|
+
max: resolveBound(f.max, scale, offset, tags),
|
|
172
|
+
};
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
/** What `effectiveField` needs to resolve an `${fqdn}` bound. */
|
|
176
|
+
export interface TagLookup {
|
|
177
|
+
findTagByFqdn: (fqdn: string) => { tagName: string } | undefined;
|
|
178
|
+
rawValues: Record<string, unknown>;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Matches a bound token like `${gm.press_force_min_n}` (whole-string). */
|
|
182
|
+
const BOUND_TOKEN_RE = /^\$\{\s*([^}]+?)\s*\}$/;
|
|
183
|
+
|
|
184
|
+
/** Is this string a well-formed FQDN bound token? */
|
|
185
|
+
export const isBoundToken = (s: string): boolean => BOUND_TOKEN_RE.test(s.trim());
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Render a bound for an authoring text field — the method editor's Min/Max.
|
|
189
|
+
*
|
|
190
|
+
* A text field, not a numeric one, because a bound is a number OR a token. The
|
|
191
|
+
* editor used `InputNumber`, which rendered a token as **NaN** and — worse —
|
|
192
|
+
* coerced anything non-numeric to `undefined` on change, so opening the dialog
|
|
193
|
+
* and saving silently deleted a token bound.
|
|
194
|
+
*/
|
|
195
|
+
export const boundText = (b: number | string | undefined): string =>
|
|
196
|
+
b === undefined || b === null ? '' : String(b);
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Parse what an author typed into a bound field. Blank clears it; a finite
|
|
200
|
+
* number stores a number; anything else is kept VERBATIM so a half-typed token
|
|
201
|
+
* survives the keystroke. Use `boundInvalid` to flag what will not resolve.
|
|
202
|
+
*/
|
|
203
|
+
export const parseBound = (text: string): number | string | undefined => {
|
|
204
|
+
const t = text.trim();
|
|
205
|
+
if (t === '') return undefined;
|
|
206
|
+
const n = Number(t);
|
|
207
|
+
if (Number.isFinite(n)) return n;
|
|
208
|
+
return t;
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
/** True when a bound is a string that is not a well-formed token. */
|
|
212
|
+
export const boundInvalid = (b: number | string | undefined): boolean =>
|
|
213
|
+
typeof b === 'string' && !isBoundToken(b);
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* A declared bound resolved to a plain DISPLAY-unit number, or `undefined` for
|
|
217
|
+
* "no bound".
|
|
218
|
+
*
|
|
219
|
+
* A number passes through — it was already authored in display units. A token
|
|
220
|
+
* is looked up as a tag, whose value is RAW, and converted with the same scale
|
|
221
|
+
* and offset as the value it will be compared against; otherwise an imperial or
|
|
222
|
+
* affine field would compare two numbers from different spaces.
|
|
223
|
+
*
|
|
224
|
+
* An unresolvable token yields `undefined`, i.e. NO bound — never zero, which
|
|
225
|
+
* would silently reject every entry. Fail-open is deliberate here: this is an
|
|
226
|
+
* authoring aid, and walling off a field because a tag has not arrived yet would
|
|
227
|
+
* make a machine unusable at startup. The interlock that matters lives in the
|
|
228
|
+
* machine, not in this form.
|
|
229
|
+
*/
|
|
230
|
+
export const resolveBound = (
|
|
231
|
+
bound: number | string | undefined,
|
|
232
|
+
scale: number | undefined,
|
|
233
|
+
offset: number,
|
|
234
|
+
tags?: TagLookup,
|
|
235
|
+
): number | undefined => {
|
|
236
|
+
if (typeof bound === 'number') return bound;
|
|
237
|
+
if (typeof bound !== 'string' || !tags) return undefined;
|
|
238
|
+
const m = bound.match(BOUND_TOKEN_RE);
|
|
239
|
+
if (!m) return undefined;
|
|
240
|
+
const tag = tags.findTagByFqdn(m[1]);
|
|
241
|
+
const raw = tag ? tags.rawValues[tag.tagName] : undefined;
|
|
242
|
+
if (typeof raw !== 'number' || !Number.isFinite(raw)) return undefined;
|
|
243
|
+
const disp = Number(rawToDisplay(raw, scale, offset));
|
|
244
|
+
return Number.isFinite(disp) ? disp : undefined;
|
|
245
|
+
};
|
|
246
|
+
|
|
247
|
+
/** A field with its units applied and its bounds resolved to numbers. */
|
|
248
|
+
export type EffectiveField = TestFieldDef & {
|
|
249
|
+
unitOffset?: number;
|
|
250
|
+
min?: number;
|
|
251
|
+
max?: number;
|
|
128
252
|
};
|
|
129
253
|
|
|
130
254
|
/** Field label with `[units]` appended when declared. */
|
|
@@ -153,13 +277,20 @@ export const fieldValueIsEmpty = (v: any): boolean =>
|
|
|
153
277
|
*/
|
|
154
278
|
export const rangeIssue = (f: TestFieldDef, raw: any): string | null => {
|
|
155
279
|
if (fieldValueIsEmpty(raw) || raw === '') return null;
|
|
156
|
-
|
|
157
|
-
|
|
280
|
+
// Only NUMERIC bounds constrain anything. A field that still carries an
|
|
281
|
+
// unresolved `${fqdn}` token has not been through `effectiveField`, and the
|
|
282
|
+
// honest answer is "unbounded" rather than a comparison against a string.
|
|
283
|
+
const min = typeof f.min === 'number' ? f.min : undefined;
|
|
284
|
+
const max = typeof f.max === 'number' ? f.max : undefined;
|
|
285
|
+
if (min == null && max == null) return null;
|
|
286
|
+
// Same offset the input uses, so an affine quantity (temperature) compares
|
|
287
|
+
// in one space rather than two.
|
|
288
|
+
const disp = Number(rawToDisplay(Number(raw), f.scale, (f as any).unitOffset ?? 0));
|
|
158
289
|
if (!Number.isFinite(disp)) return null;
|
|
159
290
|
// Bounds form an unordered interval — an author may write the range in
|
|
160
291
|
// magnitude order (min:-50, max:-1000), which is numerically inverted.
|
|
161
|
-
const lo =
|
|
162
|
-
const hi =
|
|
292
|
+
const lo = min != null && max != null ? Math.min(min, max) : min;
|
|
293
|
+
const hi = min != null && max != null ? Math.max(min, max) : max;
|
|
163
294
|
if (lo != null && disp < lo) return `must be ≥ ${lo}`;
|
|
164
295
|
if (hi != null && disp > hi) return `must be ≤ ${hi}`;
|
|
165
296
|
return null;
|
|
@@ -225,8 +356,15 @@ export interface TestFieldRowProps {
|
|
|
225
356
|
* validity icon. Returns a Fragment, so the caller owns the grid.
|
|
226
357
|
*/
|
|
227
358
|
export const TestFieldRow: React.FC<TestFieldRowProps> = ({
|
|
228
|
-
field, value, onChange, valid, disabled, control, onInfo,
|
|
359
|
+
field: declaredField, value, onChange, valid, disabled, control, onInfo,
|
|
229
360
|
}) => {
|
|
361
|
+
// Resolve the field against the machine's active unit system, so a config
|
|
362
|
+
// field annotated with a `quantity` is entered and displayed in the
|
|
363
|
+
// operator's units instead of always in backend units. Every consumer of
|
|
364
|
+
// this row gets that for free.
|
|
365
|
+
const { scales: unitScales, rawValues, findTagByFqdn } = useContext(AutoCoreTagContext);
|
|
366
|
+
const field = effectiveField(declaredField, unitScales as any, { findTagByFqdn, rawValues });
|
|
367
|
+
const unitOffset = (field as any).unitOffset ?? 0;
|
|
230
368
|
const ok = valid ?? fieldIsValid(field, value);
|
|
231
369
|
const dropdownOptions = !control && Array.isArray(field.options) && field.options.length > 0
|
|
232
370
|
? normalizeOptions(field.options)
|
|
@@ -250,12 +388,12 @@ export const TestFieldRow: React.FC<TestFieldRowProps> = ({
|
|
|
250
388
|
// Storage is RAW; render the DISPLAY value so the operator sees
|
|
251
389
|
// the units they configured. The change handler runs the inverse
|
|
252
390
|
// before committing back to storage.
|
|
253
|
-
value={value != null ? Number(rawToDisplay(Number(value), field.scale)) : null}
|
|
391
|
+
value={value != null ? Number(rawToDisplay(Number(value), field.scale, unitOffset)) : null}
|
|
254
392
|
// min/max are authored in display units, matching the value
|
|
255
393
|
// rendered above — ValueInput rejects out-of-range entries.
|
|
256
394
|
min={field.min}
|
|
257
395
|
max={field.max}
|
|
258
|
-
onValueChanged={(val) => onChange(displayToRaw(val, field.scale))}
|
|
396
|
+
onValueChanged={(val) => onChange(displayToRaw(val, field.scale, unitOffset))}
|
|
259
397
|
className={!ok ? 'p-invalid' : ''}
|
|
260
398
|
disabled={disabled}
|
|
261
399
|
/>
|
|
@@ -14,6 +14,7 @@ import { ConfigurationDialog, configLabelOf } from './ConfigurationDialog';
|
|
|
14
14
|
import {
|
|
15
15
|
TestFieldRow,
|
|
16
16
|
displayToRaw,
|
|
17
|
+
effectiveField,
|
|
17
18
|
fieldIsValid,
|
|
18
19
|
fieldIssue,
|
|
19
20
|
labelOf,
|
|
@@ -239,7 +240,7 @@ export const TestSetupForm: React.FC<TestSetupFormProps> = ({
|
|
|
239
240
|
}) => {
|
|
240
241
|
const tis = useTis();
|
|
241
242
|
const { invoke, write } = useContext(EventEmitterContext);
|
|
242
|
-
const { rawValues, findTagByFqdn } = useContext(AutoCoreTagContext);
|
|
243
|
+
const { rawValues, findTagByFqdn, scales: unitScales } = useContext(AutoCoreTagContext);
|
|
243
244
|
|
|
244
245
|
const methodIds = useMemo(() => Object.keys(tis.schemas), [tis.schemas]);
|
|
245
246
|
|
|
@@ -276,8 +277,14 @@ export const TestSetupForm: React.FC<TestSetupFormProps> = ({
|
|
|
276
277
|
const applyConfigOverridesInto = (cfg: TestConfiguration, target: any): any => {
|
|
277
278
|
for (const [fieldName, val] of Object.entries(cfg.defaults ?? {})) {
|
|
278
279
|
if (fieldName === 'sample_id') continue;
|
|
279
|
-
const
|
|
280
|
-
|
|
280
|
+
const declared = schema?.config_fields.find((f: TestFieldDef) => f.name === fieldName);
|
|
281
|
+
// Resolve against the active unit system: a configuration's defaults
|
|
282
|
+
// are authored in display units, so they must be converted with the
|
|
283
|
+
// same scale the operator is looking at.
|
|
284
|
+
const field = declared
|
|
285
|
+
? effectiveField(declared, unitScales as any, { findTagByFqdn, rawValues })
|
|
286
|
+
: undefined;
|
|
287
|
+
const rawVal = displayToRaw(val, field?.scale, (field as any)?.unitOffset ?? 0);
|
|
281
288
|
target[fieldName] = rawVal;
|
|
282
289
|
if (field?.source) {
|
|
283
290
|
void Promise.resolve()
|
|
@@ -457,7 +464,7 @@ export const TestSetupForm: React.FC<TestSetupFormProps> = ({
|
|
|
457
464
|
|
|
458
465
|
for (const field of schema.config_fields) {
|
|
459
466
|
if (field.name === 'sample_id') continue;
|
|
460
|
-
if (fieldIssue(field, config[field.name])) { valid = false; break; }
|
|
467
|
+
if (fieldIssue(effective(field), config[field.name])) { valid = false; break; }
|
|
461
468
|
}
|
|
462
469
|
|
|
463
470
|
setIsValid(valid);
|
|
@@ -480,6 +487,13 @@ export const TestSetupForm: React.FC<TestSetupFormProps> = ({
|
|
|
480
487
|
config: mergedConfig,
|
|
481
488
|
} as any).catch(e => console.error('[TestSetupForm] stage_test failed:', e));
|
|
482
489
|
}
|
|
490
|
+
// `rawValues` is deliberately NOT a dependency even though `effective`
|
|
491
|
+
// reads it: this effect calls `tis.stage_test` on every valid run, so
|
|
492
|
+
// waking it on every tag update would be an IPC storm. The closure is
|
|
493
|
+
// rebuilt each render, so a resolved bound is current whenever the
|
|
494
|
+
// operator touches anything — which is when the gate matters. The only
|
|
495
|
+
// gap is a bound that moves while a staged test sits untouched, and the
|
|
496
|
+
// machine refuses an out-of-range move regardless.
|
|
483
497
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
484
498
|
}, [
|
|
485
499
|
config, schema, projectId, methodId, sampleId,
|
|
@@ -487,7 +501,21 @@ export const TestSetupForm: React.FC<TestSetupFormProps> = ({
|
|
|
487
501
|
onValidationChange, invoke,
|
|
488
502
|
]);
|
|
489
503
|
|
|
490
|
-
|
|
504
|
+
/**
|
|
505
|
+
* A declared field with its active units applied and its `${fqdn}` bounds
|
|
506
|
+
* resolved — the form's single view of what a field actually allows.
|
|
507
|
+
*
|
|
508
|
+
* Every validity check goes through this, not the raw `schema.config_fields`
|
|
509
|
+
* entry. Two reasons: a token bound is a string until resolved, so an
|
|
510
|
+
* unresolved field reads as unbounded and the gate would disagree with the
|
|
511
|
+
* input that IS enforcing it; and the bounds are authored in display units,
|
|
512
|
+
* so a field whose scale comes from the active unit system has to be
|
|
513
|
+
* resolved before anything compares against them.
|
|
514
|
+
*/
|
|
515
|
+
const effective = (f: TestFieldDef) =>
|
|
516
|
+
effectiveField(f, unitScales as any, { findTagByFqdn, rawValues });
|
|
517
|
+
|
|
518
|
+
const isFieldValid = (field: TestFieldDef) => fieldIsValid(effective(field), config[field.name]);
|
|
491
519
|
|
|
492
520
|
const handleSampleIdChange = (value: string) => {
|
|
493
521
|
setSampleIdLocal(value);
|
|
@@ -618,8 +646,8 @@ export const TestSetupForm: React.FC<TestSetupFormProps> = ({
|
|
|
618
646
|
if (schema) {
|
|
619
647
|
for (const field of schema.config_fields) {
|
|
620
648
|
if (field.name === 'sample_id') continue;
|
|
621
|
-
const issue = fieldIssue(field, config[field.name]);
|
|
622
|
-
if (issue) issues.push(`"${labelOf(field)}" ${issue}.`);
|
|
649
|
+
const issue = fieldIssue(effective(field), config[field.name]);
|
|
650
|
+
if (issue) issues.push(`"${labelOf(effective(field))}" ${issue}.`);
|
|
623
651
|
}
|
|
624
652
|
}
|
|
625
653
|
return issues;
|