@adcops/autocore-react 3.3.124 → 3.5.3

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.
Files changed (48) hide show
  1. package/dist/components/UnitSystemSelector.d.ts +17 -0
  2. package/dist/components/UnitSystemSelector.d.ts.map +1 -0
  3. package/dist/components/UnitSystemSelector.js +1 -0
  4. package/dist/components/UnitsEditor.d.ts +11 -0
  5. package/dist/components/UnitsEditor.d.ts.map +1 -0
  6. package/dist/components/UnitsEditor.js +1 -0
  7. package/dist/components/index.d.ts +4 -0
  8. package/dist/components/index.d.ts.map +1 -1
  9. package/dist/components/index.js +1 -1
  10. package/dist/components/tis/ResultHistoryTable.d.ts.map +1 -1
  11. package/dist/components/tis/ResultHistoryTable.js +1 -1
  12. package/dist/components/tis/ScienceTable.d.ts.map +1 -1
  13. package/dist/components/tis/ScienceTable.js +1 -1
  14. package/dist/components/tis/TestDataView.d.ts +16 -4
  15. package/dist/components/tis/TestDataView.d.ts.map +1 -1
  16. package/dist/components/tis/TestDataView.js +1 -1
  17. package/dist/components/tis/TestFieldRow.d.ts +69 -7
  18. package/dist/components/tis/TestFieldRow.d.ts.map +1 -1
  19. package/dist/components/tis/TestFieldRow.js +1 -1
  20. package/dist/components/tis/TestSetupForm.d.ts.map +1 -1
  21. package/dist/components/tis/TestSetupForm.js +1 -1
  22. package/dist/components/tis/TisProvider.d.ts.map +1 -1
  23. package/dist/components/tis/TisProvider.js +1 -1
  24. package/dist/core/AutoCoreTagContext.d.ts.map +1 -1
  25. package/dist/core/AutoCoreTagContext.js +1 -1
  26. package/dist/core/AutoCoreTagTypes.d.ts +82 -7
  27. package/dist/core/AutoCoreTagTypes.d.ts.map +1 -1
  28. package/dist/core/formatScaled.d.ts +16 -0
  29. package/dist/core/formatScaled.d.ts.map +1 -0
  30. package/dist/core/formatScaled.js +1 -0
  31. package/dist/hooks/useAutoCoreTag.d.ts +11 -3
  32. package/dist/hooks/useAutoCoreTag.d.ts.map +1 -1
  33. package/dist/hooks/useAutoCoreTag.js +1 -1
  34. package/package.json +1 -1
  35. package/src/components/UnitSystemSelector.tsx +88 -0
  36. package/src/components/UnitsEditor.tsx +317 -0
  37. package/src/components/index.ts +4 -0
  38. package/src/components/tis/ResultHistoryTable.tsx +89 -6
  39. package/src/components/tis/ScienceTable.tsx +13 -0
  40. package/src/components/tis/TestDataView.tsx +153 -35
  41. package/src/components/tis/TestFieldRow.tsx +126 -19
  42. package/src/components/tis/TestSetupForm.tsx +35 -7
  43. package/src/components/tis/TisProvider.tsx +46 -3
  44. package/src/core/AutoCoreTagContext.tsx +165 -16
  45. package/src/core/AutoCoreTagTypes.ts +91 -7
  46. package/src/core/formatScaled.ts +72 -0
  47. package/src/hooks/useAutoCoreTag.ts +51 -3
  48. package/todo.md +6 -0
@@ -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
- /** Optional display-time scale multiplier. `display = raw * scale`,
71
- * default 1.0 = no conversion. Cycle Data and Results panels
72
- * apply this when formatting numeric cells. Charts plot raw
73
- * values (axis labels already carry units). Storage stays raw. */
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
- const decimals = selectedViewDef.x_decimals ?? DEFAULT_X_DECIMALS;
541
- const xs = asc.map(c => formatAxisTick(c[xField], decimals));
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 => c[s.field!]),
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 xs = traceFetch.raw[xCol] ?? [];
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 ys = (traceFetch.raw![s.column!] ?? []) as number[];
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
- const xDecimals = selectedViewDef?.x_decimals ?? DEFAULT_X_DECIMALS;
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: !!selectedViewDef?.x.label, text: selectedViewDef?.x.label } }
684
+ title: { display: !!xTitle, text: xTitle } }
634
685
  : { ticks: xTicks,
635
- title: { display: !!selectedViewDef?.x.label, text: selectedViewDef?.x.label } },
686
+ title: { display: !!xTitle, text: xTitle } },
636
687
  y: { position: 'left' as const,
637
- title: { display: true, text: leftAxisLabel(selectedViewDef) } },
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: rightAxisLabel(selectedViewDef) } },
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
- const leftAxisLabel = (v?: ChartView) =>
1523
- v?.y.filter(s => s.y_axis !== 'right').map(seriesLabel).join(' / ') ?? '';
1524
- const rightAxisLabel = (v?: ChartView) =>
1525
- v?.y.filter(s => s.y_axis === 'right').map(seriesLabel).join(' / ') ?? '';
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
- const toScienceColumn = (f: TestFieldDef, editable = false): ScienceColumn => ({
1546
- field: f.name,
1547
- // A project-scoped rollup sits in the same row as this test's own
1548
- // numbers, so it has to say so. Marked here and explained in a note
1549
- // under the table never in a tooltip.
1550
- label: (f.label && f.label.length > 0 ? f.label : f.name) + (isProjectScoped(f) ? ' *' : ''),
1551
- units: f.units,
1552
- editable,
1553
- body: (row) => formatCell(row[f.name], f.type, f.scale),
1554
- });
1555
-
1556
- const formatCell = (v: any, type: string, scale?: number): string => {
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 (display units, same convention as `default`
63
- * and `scale`) for the operator's numeric entry. The numeric input
64
- * rejects values outside `[min, max]`; non-numeric fields ignore them. */
65
- min?: number;
66
- max?: number;
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,92 @@ 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
+ /**
185
+ * A declared bound resolved to a plain DISPLAY-unit number, or `undefined` for
186
+ * "no bound".
187
+ *
188
+ * A number passes through — it was already authored in display units. A token
189
+ * is looked up as a tag, whose value is RAW, and converted with the same scale
190
+ * and offset as the value it will be compared against; otherwise an imperial or
191
+ * affine field would compare two numbers from different spaces.
192
+ *
193
+ * An unresolvable token yields `undefined`, i.e. NO bound — never zero, which
194
+ * would silently reject every entry. Fail-open is deliberate here: this is an
195
+ * authoring aid, and walling off a field because a tag has not arrived yet would
196
+ * make a machine unusable at startup. The interlock that matters lives in the
197
+ * machine, not in this form.
198
+ */
199
+ export const resolveBound = (
200
+ bound: number | string | undefined,
201
+ scale: number | undefined,
202
+ offset: number,
203
+ tags?: TagLookup,
204
+ ): number | undefined => {
205
+ if (typeof bound === 'number') return bound;
206
+ if (typeof bound !== 'string' || !tags) return undefined;
207
+ const m = bound.match(BOUND_TOKEN_RE);
208
+ if (!m) return undefined;
209
+ const tag = tags.findTagByFqdn(m[1]);
210
+ const raw = tag ? tags.rawValues[tag.tagName] : undefined;
211
+ if (typeof raw !== 'number' || !Number.isFinite(raw)) return undefined;
212
+ const disp = Number(rawToDisplay(raw, scale, offset));
213
+ return Number.isFinite(disp) ? disp : undefined;
214
+ };
215
+
216
+ /** A field with its units applied and its bounds resolved to numbers. */
217
+ export type EffectiveField = TestFieldDef & {
218
+ unitOffset?: number;
219
+ min?: number;
220
+ max?: number;
128
221
  };
129
222
 
130
223
  /** Field label with `[units]` appended when declared. */
@@ -153,13 +246,20 @@ export const fieldValueIsEmpty = (v: any): boolean =>
153
246
  */
154
247
  export const rangeIssue = (f: TestFieldDef, raw: any): string | null => {
155
248
  if (fieldValueIsEmpty(raw) || raw === '') return null;
156
- if (f.min == null && f.max == null) return null;
157
- const disp = Number(rawToDisplay(Number(raw), f.scale));
249
+ // Only NUMERIC bounds constrain anything. A field that still carries an
250
+ // unresolved `${fqdn}` token has not been through `effectiveField`, and the
251
+ // honest answer is "unbounded" rather than a comparison against a string.
252
+ const min = typeof f.min === 'number' ? f.min : undefined;
253
+ const max = typeof f.max === 'number' ? f.max : undefined;
254
+ if (min == null && max == null) return null;
255
+ // Same offset the input uses, so an affine quantity (temperature) compares
256
+ // in one space rather than two.
257
+ const disp = Number(rawToDisplay(Number(raw), f.scale, (f as any).unitOffset ?? 0));
158
258
  if (!Number.isFinite(disp)) return null;
159
259
  // Bounds form an unordered interval — an author may write the range in
160
260
  // magnitude order (min:-50, max:-1000), which is numerically inverted.
161
- const lo = f.min != null && f.max != null ? Math.min(f.min, f.max) : f.min;
162
- const hi = f.min != null && f.max != null ? Math.max(f.min, f.max) : f.max;
261
+ const lo = min != null && max != null ? Math.min(min, max) : min;
262
+ const hi = min != null && max != null ? Math.max(min, max) : max;
163
263
  if (lo != null && disp < lo) return `must be ≥ ${lo}`;
164
264
  if (hi != null && disp > hi) return `must be ≤ ${hi}`;
165
265
  return null;
@@ -225,8 +325,15 @@ export interface TestFieldRowProps {
225
325
  * validity icon. Returns a Fragment, so the caller owns the grid.
226
326
  */
227
327
  export const TestFieldRow: React.FC<TestFieldRowProps> = ({
228
- field, value, onChange, valid, disabled, control, onInfo,
328
+ field: declaredField, value, onChange, valid, disabled, control, onInfo,
229
329
  }) => {
330
+ // Resolve the field against the machine's active unit system, so a config
331
+ // field annotated with a `quantity` is entered and displayed in the
332
+ // operator's units instead of always in backend units. Every consumer of
333
+ // this row gets that for free.
334
+ const { scales: unitScales, rawValues, findTagByFqdn } = useContext(AutoCoreTagContext);
335
+ const field = effectiveField(declaredField, unitScales as any, { findTagByFqdn, rawValues });
336
+ const unitOffset = (field as any).unitOffset ?? 0;
230
337
  const ok = valid ?? fieldIsValid(field, value);
231
338
  const dropdownOptions = !control && Array.isArray(field.options) && field.options.length > 0
232
339
  ? normalizeOptions(field.options)
@@ -250,12 +357,12 @@ export const TestFieldRow: React.FC<TestFieldRowProps> = ({
250
357
  // Storage is RAW; render the DISPLAY value so the operator sees
251
358
  // the units they configured. The change handler runs the inverse
252
359
  // before committing back to storage.
253
- value={value != null ? Number(rawToDisplay(Number(value), field.scale)) : null}
360
+ value={value != null ? Number(rawToDisplay(Number(value), field.scale, unitOffset)) : null}
254
361
  // min/max are authored in display units, matching the value
255
362
  // rendered above — ValueInput rejects out-of-range entries.
256
363
  min={field.min}
257
364
  max={field.max}
258
- onValueChanged={(val) => onChange(displayToRaw(val, field.scale))}
365
+ onValueChanged={(val) => onChange(displayToRaw(val, field.scale, unitOffset))}
259
366
  className={!ok ? 'p-invalid' : ''}
260
367
  disabled={disabled}
261
368
  />
@@ -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 field = schema?.config_fields.find((f: TestFieldDef) => f.name === fieldName);
280
- const rawVal = displayToRaw(val, field?.scale);
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
- const isFieldValid = (field: TestFieldDef) => fieldIsValid(field, config[field.name]);
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;
@@ -391,7 +391,7 @@ export interface TisProviderProps {
391
391
  }
392
392
 
393
393
  export const TisProvider: React.FC<TisProviderProps> = ({ children, defaultMethodId: initialDefault }) => {
394
- const { invoke, subscribe, unsubscribe } = useContext(EventEmitterContext);
394
+ const { invoke, read, subscribe, unsubscribe, isConnected } = useContext(EventEmitterContext);
395
395
 
396
396
  const [schemas, setSchemas] = useState<SchemaRegistry>({});
397
397
  const [projectAssetRefs, setProjectAssetRefs] = useState<TisProjectAssetRef[]>([]);
@@ -506,8 +506,51 @@ export const TisProvider: React.FC<TisProviderProps> = ({ children, defaultMetho
506
506
  subscribe('tis.active_run_id', (v: any) => dispatch({ kind: 'active_run_id', value: String(v ?? '') })),
507
507
  subscribe('tis.last_start_error', (v: any) => dispatch({ kind: 'last_start_error', value: String(v ?? '') })),
508
508
  ];
509
- return () => { subs.forEach(unsubscribe); };
510
- }, [subscribe, unsubscribe]);
509
+ // SEED from the current values. Subscriptions only deliver CHANGES, so
510
+ // on a page refresh mid-test every scalar below would sit empty until
511
+ // the run's state happened to move — which is why <TestDataView> came
512
+ // back saying "No test selected" while a test was plainly open, with no
513
+ // way to select it (an active run clears the History pins).
514
+ //
515
+ // Read the GM SCALARS, not the `tis.*` broadcast topics: `tis.active_run_id`
516
+ // is what the TIS module *publishes*, and it is not a readable endpoint.
517
+ // The backing variable `gm.tis_active_run_id` is.
518
+ //
519
+ // Failures are ignored: a machine without TIS has nothing to seed.
520
+ const SEED: { fqdn: string; kind: StateAction["kind"]; bool?: boolean }[] = [
521
+ { fqdn: 'gm.tis_staged', kind: 'staged', bool: true },
522
+ { fqdn: 'gm.tis_staged_project_id', kind: 'staged_project_id' },
523
+ { fqdn: 'gm.tis_staged_method_id', kind: 'staged_method_id' },
524
+ { fqdn: 'gm.tis_staged_sample_id', kind: 'staged_sample_id' },
525
+ { fqdn: 'gm.tis_active', kind: 'active', bool: true },
526
+ { fqdn: 'gm.tis_active_project_id', kind: 'active_project_id' },
527
+ { fqdn: 'gm.tis_active_method_id', kind: 'active_method_id' },
528
+ { fqdn: 'gm.tis_active_sample_id', kind: 'active_sample_id' },
529
+ { fqdn: 'gm.tis_active_run_id', kind: 'active_run_id' },
530
+ ];
531
+ let cancelled = false;
532
+ // Only worth attempting once the hub is up — on a cold page load this
533
+ // effect can run before the socket connects, and a read that fails then
534
+ // would leave the state empty exactly as before. `isConnected` is in the
535
+ // deps, so the seed runs again the moment the connection lands.
536
+ void (async () => {
537
+ if (!isConnected) return;
538
+ for (const { fqdn, kind, bool } of SEED) {
539
+ try {
540
+ const resp: any = await read(fqdn);
541
+ if (cancelled) return;
542
+ if (resp && resp.success === false) continue;
543
+ const v = resp?.data;
544
+ if (v === undefined || v === null) continue;
545
+ dispatch({ kind, value: bool ? !!v : String(v) } as StateAction);
546
+ } catch {
547
+ /* scalar absent on this machine — nothing to seed */
548
+ }
549
+ }
550
+ })();
551
+
552
+ return () => { cancelled = true; subs.forEach(unsubscribe); };
553
+ }, [subscribe, unsubscribe, read, isConnected]);
511
554
 
512
555
  // -----------------------------------------------------------------
513
556
  // Run cache — broadcasts of cycles/results land here so detail