@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.
Files changed (77) 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/ams/AmsProvider.d.ts +14 -0
  8. package/dist/components/ams/AmsProvider.d.ts.map +1 -1
  9. package/dist/components/ams/AmsProvider.js +1 -1
  10. package/dist/components/ams/AssetDetailView.d.ts.map +1 -1
  11. package/dist/components/ams/AssetDetailView.js +1 -1
  12. package/dist/components/ams/AssetNameplateGrid.d.ts +13 -0
  13. package/dist/components/ams/AssetNameplateGrid.d.ts.map +1 -0
  14. package/dist/components/ams/AssetNameplateGrid.js +1 -0
  15. package/dist/components/ams/InstalledAssetPicker.d.ts +35 -0
  16. package/dist/components/ams/InstalledAssetPicker.d.ts.map +1 -0
  17. package/dist/components/ams/InstalledAssetPicker.js +1 -0
  18. package/dist/components/ams/index.d.ts +5 -0
  19. package/dist/components/ams/index.d.ts.map +1 -1
  20. package/dist/components/ams/index.js +1 -1
  21. package/dist/components/ams/useInstalledAsset.d.ts +26 -0
  22. package/dist/components/ams/useInstalledAsset.d.ts.map +1 -0
  23. package/dist/components/ams/useInstalledAsset.js +1 -0
  24. package/dist/components/index.d.ts +4 -0
  25. package/dist/components/index.d.ts.map +1 -1
  26. package/dist/components/index.js +1 -1
  27. package/dist/components/tis/ResultHistoryTable.d.ts.map +1 -1
  28. package/dist/components/tis/ResultHistoryTable.js +1 -1
  29. package/dist/components/tis/ScienceTable.d.ts.map +1 -1
  30. package/dist/components/tis/ScienceTable.js +1 -1
  31. package/dist/components/tis/TestDataView.d.ts +16 -4
  32. package/dist/components/tis/TestDataView.d.ts.map +1 -1
  33. package/dist/components/tis/TestDataView.js +1 -1
  34. package/dist/components/tis/TestFieldRow.d.ts +88 -7
  35. package/dist/components/tis/TestFieldRow.d.ts.map +1 -1
  36. package/dist/components/tis/TestFieldRow.js +1 -1
  37. package/dist/components/tis/TestSetupForm.d.ts.map +1 -1
  38. package/dist/components/tis/TestSetupForm.js +1 -1
  39. package/dist/components/tis/TisProvider.d.ts.map +1 -1
  40. package/dist/components/tis/TisProvider.js +1 -1
  41. package/dist/components/tis-editor/editor/TestFieldDialog.d.ts.map +1 -1
  42. package/dist/components/tis-editor/editor/TestFieldDialog.js +1 -1
  43. package/dist/components/tis-editor/types.d.ts +6 -2
  44. package/dist/components/tis-editor/types.d.ts.map +1 -1
  45. package/dist/core/AutoCoreTagContext.d.ts.map +1 -1
  46. package/dist/core/AutoCoreTagContext.js +1 -1
  47. package/dist/core/AutoCoreTagTypes.d.ts +82 -7
  48. package/dist/core/AutoCoreTagTypes.d.ts.map +1 -1
  49. package/dist/core/formatScaled.d.ts +16 -0
  50. package/dist/core/formatScaled.d.ts.map +1 -0
  51. package/dist/core/formatScaled.js +1 -0
  52. package/dist/hooks/useAutoCoreTag.d.ts +11 -3
  53. package/dist/hooks/useAutoCoreTag.d.ts.map +1 -1
  54. package/dist/hooks/useAutoCoreTag.js +1 -1
  55. package/package.json +1 -1
  56. package/src/components/UnitSystemSelector.tsx +88 -0
  57. package/src/components/UnitsEditor.tsx +317 -0
  58. package/src/components/ams/AmsProvider.tsx +36 -1
  59. package/src/components/ams/AssetDetailView.tsx +2 -20
  60. package/src/components/ams/AssetNameplateGrid.tsx +62 -0
  61. package/src/components/ams/InstalledAssetPicker.tsx +209 -0
  62. package/src/components/ams/index.ts +7 -0
  63. package/src/components/ams/useInstalledAsset.ts +108 -0
  64. package/src/components/index.ts +4 -0
  65. package/src/components/tis/ResultHistoryTable.tsx +89 -6
  66. package/src/components/tis/ScienceTable.tsx +13 -0
  67. package/src/components/tis/TestDataView.tsx +153 -35
  68. package/src/components/tis/TestFieldRow.tsx +157 -19
  69. package/src/components/tis/TestSetupForm.tsx +35 -7
  70. package/src/components/tis/TisProvider.tsx +46 -3
  71. package/src/components/tis-editor/editor/TestFieldDialog.tsx +29 -18
  72. package/src/components/tis-editor/types.ts +6 -2
  73. package/src/core/AutoCoreTagContext.tsx +165 -16
  74. package/src/core/AutoCoreTagTypes.ts +91 -7
  75. package/src/core/formatScaled.ts +72 -0
  76. package/src/hooks/useAutoCoreTag.ts +51 -3
  77. package/todo.md +6 -0
@@ -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
@@ -8,6 +8,10 @@ import { Checkbox } from 'primereact/checkbox';
8
8
  import { InputNumber } from 'primereact/inputnumber';
9
9
  import { FormRow } from '../../forms/FormRow';
10
10
  import type { TestField, AggFn, AggFrom, AggScope, AggregateSpec } from '../types';
11
+ // One home for bound parsing/formatting — shared with the operator-facing
12
+ // form so the editor and the runtime can never disagree on what a bound is.
13
+ import { boundText, parseBound, boundInvalid } from '../../tis/TestFieldRow';
14
+
11
15
 
12
16
  const FIELD_TYPES: { label: string; value: string }[] = [
13
17
  { label: 'string', value: 'string' },
@@ -116,6 +120,13 @@ export const TestFieldDialog: React.FC<TestFieldDialogProps> = ({
116
120
  if (typeof f.min === 'number' && typeof f.max === 'number' && f.min > f.max) {
117
121
  return `Min (${f.min}) cannot be greater than Max (${f.max}).`;
118
122
  }
123
+ // A bound that is neither a number nor a well-formed token resolves to
124
+ // NO bound at run time — silently unlimited. Refuse the save instead.
125
+ for (const which of ['min', 'max'] as const) {
126
+ if (boundInvalid(f[which])) {
127
+ return `${which === 'min' ? 'Min' : 'Max'} must be a number or an FQDN token like \${gm.some_var} — "${f[which]}" is neither.`;
128
+ }
129
+ }
119
130
  if (f.aggregate && f.aggregate.fn !== 'count' && !f.aggregate.of?.trim()) {
120
131
  return `Aggregate "${f.aggregate.fn}" needs a target field (the field to ${f.aggregate.fn} across tests).`;
121
132
  }
@@ -241,26 +252,26 @@ export const TestFieldDialog: React.FC<TestFieldDialogProps> = ({
241
252
  maxFractionDigits={9}
242
253
  />
243
254
  </FormRow>
244
- <FormRow label="Min" hint="Optional lower bound the operator can enter (display units). Blank = no minimum.">
245
- <InputNumber
246
- value={draft.min ?? null}
247
- onValueChange={(e) =>
248
- setDraft({ ...draft, min: typeof e.value === 'number' ? e.value : undefined })
249
- }
250
- mode="decimal"
251
- minFractionDigits={0}
252
- maxFractionDigits={9}
255
+ <FormRow
256
+ label="Min"
257
+ hint="Optional lower bound the operator can enter (display units). Blank = no minimum. Or bind it to the machine with an FQDN token like ${gm.press_force_min_n} for a bound that moves, e.g. a load-cell force ceiling."
258
+ >
259
+ <InputText
260
+ value={boundText(draft.min)}
261
+ onChange={(e) => setDraft({ ...draft, min: parseBound(e.target.value) })}
262
+ className={boundInvalid(draft.min) ? 'p-invalid' : ''}
263
+ placeholder="number or ${gm.…}"
253
264
  />
254
265
  </FormRow>
255
- <FormRow label="Max" hint="Optional upper bound the operator can enter (display units). Blank = no maximum.">
256
- <InputNumber
257
- value={draft.max ?? null}
258
- onValueChange={(e) =>
259
- setDraft({ ...draft, max: typeof e.value === 'number' ? e.value : undefined })
260
- }
261
- mode="decimal"
262
- minFractionDigits={0}
263
- maxFractionDigits={9}
266
+ <FormRow
267
+ label="Max"
268
+ hint="Optional upper bound the operator can enter (display units). Blank = no maximum. Or bind it to the machine with an FQDN token like ${gm.press_force_max_n}."
269
+ >
270
+ <InputText
271
+ value={boundText(draft.max)}
272
+ onChange={(e) => setDraft({ ...draft, max: parseBound(e.target.value) })}
273
+ className={boundInvalid(draft.max) ? 'p-invalid' : ''}
274
+ placeholder="number or ${gm.…}"
264
275
  />
265
276
  </FormRow>
266
277
  <FormRow label="Default" hint="Applied every time the method loads (operator can override). A literal (display units) like 5, or an FQDN token like ${gm.safe_speed} that snapshots that value on load.">
@@ -25,8 +25,12 @@ export interface TestField {
25
25
  /** Optional inclusive bounds for the operator's numeric value, authored
26
26
  * in display units (same convention as `default`/`scale`). The form's
27
27
  * numeric input rejects values outside `[min, max]`. */
28
- min?: number;
29
- max?: number;
28
+ /** Inclusive bounds. A number is in display units; a string is an FQDN
29
+ * token `"${gm.<var>}"` resolved live from the machine — see
30
+ * `TestFieldRow.resolveBound`. The editor must preserve a token it does
31
+ * not understand rather than coercing it to a number. */
32
+ min?: number | string;
33
+ max?: number | string;
30
34
  /** results_fields only: a cross-test rollup derived server-side over the
31
35
  * non-excluded runs sharing this test's sample_id. When set, the field
32
36
  * is NOT supplied by the analysis script — the server computes it. */
@@ -72,10 +72,13 @@ import { EventEmitterContext } from "./EventEmitterContext";
72
72
  import type {
73
73
  BaseContextValue,
74
74
  TagConfig,
75
- ScaleConfig
75
+ ScaleConfig,
76
+ UnitsTable,
77
+ UnitSystemScale
76
78
  } from "./AutoCoreTagTypes";
77
79
 
78
80
  import { MessageType } from "../hub/CommandMessage";
81
+ import { formatScaled } from "./formatScaled";
79
82
 
80
83
  /**
81
84
  * Runtime type for the values map - allows any tag name to map to any value type.
@@ -227,19 +230,87 @@ export const AutoCoreTagProvider: React.FC<{
227
230
 
228
231
  const [isLoading, setIsLoading] = useState(true);
229
232
 
233
+ // ── units.json ──────────────────────────────────────────────────────────
234
+ //
235
+ // The machine's single definition of units, fetched from `system.get_units`
236
+ // and resolved against the active system into the `scaleValues` map the
237
+ // rescale machinery below already understands. Switching systems is
238
+ // therefore a re-derive + rescale-from-raw — no reload, no restart, and no
239
+ // stored value is ever touched. See autocore-server/UNITS_PLAN.md §6.3.
240
+ const [units, setUnits] = useState<UnitsTable | null>(null);
241
+ const [activeSystem, setActiveSystem] = useState<string | null>(null);
242
+ const activeSystemRef = useRef<string | null>(null);
243
+ const unitsRef = useRef<UnitsTable | null>(null);
244
+ useEffect(() => { activeSystemRef.current = activeSystem; }, [activeSystem]);
245
+ useEffect(() => { unitsRef.current = units; }, [units]);
246
+
247
+ /**
248
+ * Resolve a units table + system into the provider's scale map.
249
+ *
250
+ * Keyed by quantity name, which is what a tag's `quantity` references — so
251
+ * every existing rescale path keeps working unchanged.
252
+ */
253
+ const resolveScales = useCallback(
254
+ (table: UnitsTable, system: string): Record<string, ScaleConfig> => {
255
+ const out: Record<string, ScaleConfig> = {};
256
+ for (const [quantity, row] of Object.entries(table.scales ?? {})) {
257
+ const entry = (row as any)?.[system] as UnitSystemScale | undefined;
258
+ if (!entry || typeof entry.scalar !== "number") continue;
259
+ out[quantity] = {
260
+ name: quantity,
261
+ scale: entry.scalar,
262
+ label: entry.label ?? "",
263
+ // Only an `absolute` quantity takes the offset. A temperature
264
+ // DELTA must not: a 10 °C rise is 18 °F, not 50 °F.
265
+ offset: (row as any)?.kind === "absolute" ? (entry.offset ?? 0) : 0,
266
+ precision: entry.precision,
267
+ precisionMode: entry.precision_mode,
268
+ };
269
+ }
270
+ return out;
271
+ },
272
+ [],
273
+ );
274
+
275
+ /** Re-derive every scale and recompute all displayed values from raw. */
276
+ const applyUnitSystem = useCallback(
277
+ (table: UnitsTable, system: string) => {
278
+ setUnits(table);
279
+ setActiveSystem(system);
280
+ const resolved = resolveScales(table, system);
281
+ scaleRef.current = resolved; // so the rescale below sees the new factors
282
+ setScaleValues(resolved);
283
+ setValues(prev => {
284
+ const next = { ...prev };
285
+ for (const tag of tags) {
286
+ const q = tag.quantity ?? tag.scale;
287
+ if (!q || !resolved[q]) continue;
288
+ const raw = rawRef.current[tag.tagName];
289
+ if (typeof raw !== "number") continue;
290
+ const s = resolved[q];
291
+ next[tag.tagName] = raw * s.scale + (s.offset ?? 0);
292
+ }
293
+ return next;
294
+ });
295
+ },
296
+ [resolveScales, tags],
297
+ );
298
+
230
299
  /**
231
300
  * Converts a raw controller value to a display value.
232
301
  *
233
302
  * Pipeline: Raw -> [Codec Decode] -> [Scale Multiply] -> Display
234
303
  */
235
304
  const toDisplay = useCallback((tag: TagConfig, raw: unknown): unknown => {
236
- const { valueType, scale, codec } = tag;
305
+ const { valueType, codec } = tag;
306
+ const quantity = tag.quantity ?? tag.scale;
237
307
 
238
- // 1) numeric scaling: multiply raw by scale factor
239
- if (valueType === "number" && typeof raw === "number" && scale) {
240
- const s = scaleRef.current[scale];
308
+ // 1) numeric scaling: backend -> display for the active unit system.
309
+ // `offset` is non-zero only for an absolute quantity (temperature).
310
+ if (valueType === "number" && typeof raw === "number" && quantity) {
311
+ const s = scaleRef.current[quantity];
241
312
  const factor = s?.scale ?? 1;
242
- return raw * factor;
313
+ return raw * factor + (s?.offset ?? 0);
243
314
  }
244
315
 
245
316
  // 2) codec for json (optional): decode server representation
@@ -257,18 +328,20 @@ export const AutoCoreTagProvider: React.FC<{
257
328
  * Pipeline: Display -> [Scale Divide] -> [Codec Encode] -> Raw
258
329
  */
259
330
  const toServer = useCallback((tag: TagConfig, display: unknown): unknown => {
260
- const { valueType, scale, codec } = tag;
331
+ const { valueType, codec } = tag;
332
+ const quantity = tag.quantity ?? tag.scale;
261
333
 
262
334
  // 1) invert codec first (json): encode for server
263
335
  if (valueType === "json" && codec?.toServer) {
264
336
  try { display = codec.toServer(display as any); } catch { /* fall through */ }
265
337
  }
266
338
 
267
- // 2) inverse numeric scaling: divide by scale factor
268
- if (valueType === "number" && typeof display === "number" && scale) {
269
- const s = scaleRef.current[scale];
339
+ // 2) inverse numeric scaling: display -> backend. Operator entry is the
340
+ // only place this runs; stored values are never converted.
341
+ if (valueType === "number" && typeof display === "number" && quantity) {
342
+ const s = scaleRef.current[quantity];
270
343
  const factor = s?.scale ?? 1;
271
- return display / factor;
344
+ return (display - (s?.offset ?? 0)) / factor;
272
345
  }
273
346
 
274
347
  return display;
@@ -282,7 +355,7 @@ export const AutoCoreTagProvider: React.FC<{
282
355
  * This prevents floating point error accumulation from repeated scaling.
283
356
  */
284
357
  const rescaleFromRaw = useCallback((scaleName: string) => {
285
- const affected = tags.filter(t => t.scale === scaleName);
358
+ const affected = tags.filter(t => (t.quantity ?? t.scale) === scaleName);
286
359
  if (!affected.length) return;
287
360
 
288
361
  setValues(prev => {
@@ -452,8 +525,64 @@ export const AutoCoreTagProvider: React.FC<{
452
525
  );
453
526
 
454
527
  /**
455
- * Reads scale configurations from the server (if configured).
456
- * Ensures display units match the server's persisted configuration.
528
+ * Fetch `units.json` from the server and resolve it against the machine's
529
+ * active system.
530
+ *
531
+ * This replaces the hand-authored `acScales` map: units are now defined once
532
+ * per machine, in a file the operator edits, and every consumer reads the
533
+ * same table. Failure is non-fatal — a machine with no units sidecar renders
534
+ * raw backend values rather than a blank screen (UNITS_PLAN.md §3.1).
535
+ */
536
+ const pullUnits = useCallback(async () => {
537
+ try {
538
+ const resp: any = await invoke("system.get_units", MessageType.Request, {});
539
+ const payload = resp?.data ?? resp;
540
+ const table = payload?.units as UnitsTable | undefined | null;
541
+ if (!table || !table.scales) {
542
+ console.warn("[units] no units.json on this machine — showing backend values");
543
+ return;
544
+ }
545
+ const system =
546
+ (payload?.active_system as string | undefined) ?? table.default_system;
547
+ applyUnitSystem(table, system);
548
+ } catch (e) {
549
+ console.warn("[units] system.get_units failed; showing backend values", e);
550
+ }
551
+ }, [invoke, applyUnitSystem]);
552
+
553
+ /**
554
+ * Switch the machine's display system. Persisted server-side (GNV) so it
555
+ * survives a restart and so CSV export follows it; every connected HMI
556
+ * switches on the broadcast, with no reload.
557
+ */
558
+ const setUnitSystem = useCallback(async (system: string) => {
559
+ await invoke("system.set_unit_system", MessageType.Request, { system });
560
+ // Optimistic local switch so the operator sees it immediately; the
561
+ // broadcast below confirms it for every other client.
562
+ const table = units;
563
+ if (table) applyUnitSystem(table, system);
564
+ }, [invoke, units, applyUnitSystem]);
565
+
566
+ /**
567
+ * Live switch: the server broadcasts when the active system changes.
568
+ * Re-derives every scale and recomputes displays from raw — no reload, and
569
+ * nothing stored is touched.
570
+ */
571
+ useEffect(() => {
572
+ if (!isConnected) return;
573
+ const id = subscribe("system.unit_system_changed", (data: any) => {
574
+ const next = (data?.value?.system ?? data?.system) as string | undefined;
575
+ if (!next) return;
576
+ const table = unitsRef.current;
577
+ if (table && next !== activeSystemRef.current) applyUnitSystem(table, next);
578
+ });
579
+ return () => { unsubscribe(id); };
580
+ }, [isConnected, subscribe, unsubscribe, applyUnitSystem]);
581
+
582
+ /**
583
+ * Legacy per-scale server pull (pre-4.0 `acScales` + `serverTag`).
584
+ * Retained only for a project that has not been migrated to `units.json`;
585
+ * it is a no-op once `scales` is omitted from the provider.
457
586
  */
458
587
  const pullServerScales = useCallback(async () => {
459
588
  // Use actualScales to ensure we iterate over a stable object
@@ -499,7 +628,9 @@ export const AutoCoreTagProvider: React.FC<{
499
628
 
500
629
  const registerAndSubscribe = async () => {
501
630
  try {
502
- // 1. Load scales first so initial values are correct
631
+ // 1. Load units first so initial values are already in the
632
+ // operator's system rather than flashing backend values.
633
+ await pullUnits();
503
634
  await pullServerScales();
504
635
 
505
636
  // 2. Subscribe to all tags
@@ -752,6 +883,19 @@ export const AutoCoreTagProvider: React.FC<{
752
883
  [tagsByFqdn],
753
884
  );
754
885
 
886
+ /**
887
+ * Render a BACKEND value in the active system at that quantity's precision.
888
+ * Single formatter so a table, a chart tick and a readout never disagree.
889
+ */
890
+ const formatQuantity = useCallback(
891
+ (quantity: string, backendValue: number): string => {
892
+ const s = scaleValues[quantity];
893
+ if (!s || !Number.isFinite(backendValue)) return String(backendValue);
894
+ return formatScaled(backendValue * s.scale + (s.offset ?? 0), s);
895
+ },
896
+ [scaleValues],
897
+ );
898
+
755
899
  /**
756
900
  * Construct context value. Memoized to prevent consumers from re-rendering
757
901
  * unless actual data changes.
@@ -768,7 +912,12 @@ export const AutoCoreTagProvider: React.FC<{
768
912
  scales: scaleValues,
769
913
  updateScale,
770
914
  findTagByFqdn,
771
- }), [values, rawValues, isLoading, write, tap, press, release, scaleValues, updateScale, findTagByFqdn]);
915
+ units,
916
+ activeSystem,
917
+ setUnitSystem,
918
+ formatQuantity,
919
+ }), [values, rawValues, isLoading, write, tap, press, release, scaleValues,
920
+ updateScale, findTagByFqdn, units, activeSystem, setUnitSystem, formatQuantity]);
772
921
 
773
922
 
774
923
  return (
@@ -174,17 +174,67 @@ export interface SubscriptionOptions {
174
174
  args?: Record<string, unknown>;
175
175
  }
176
176
 
177
+ /** How a resolved scale's `precision` is interpreted. */
178
+ export type PrecisionMode = "decimals" | "significant";
179
+
180
+ /**
181
+ * One system's display treatment of one quantity, as it appears in `units.json`.
182
+ * Mirrors `mechutil::units::SystemScale`.
183
+ */
184
+ export interface UnitSystemScale {
185
+ /** Display label ("mm", "lbf", "°C"). */
186
+ label: string;
187
+ /** `display = backend * scalar (+ offset)`. */
188
+ scalar: number;
189
+ /** Additive term — absolute quantities (temperature) only. */
190
+ offset?: number;
191
+ /** Digits, interpreted per `precision_mode`. */
192
+ precision?: number;
193
+ precision_mode?: PrecisionMode;
194
+ }
195
+
196
+ /** One quantity row of `units.json`. Mirrors `mechutil::units::Scale`. */
197
+ export interface UnitScale {
198
+ /** The unit GM/GNV actually holds on this machine. */
199
+ backend: string;
200
+ /** `"absolute"` takes the offset; `"delta"` (default) does not. */
201
+ kind?: "delta" | "absolute";
202
+ /** system name → display treatment. Flattened in the JSON. */
203
+ [system: string]: unknown;
204
+ }
205
+
177
206
  /**
178
- * Configuration for a named scale group.
179
- * Apply by setting `scale: "<name>"` on numeric tags.
207
+ * The whole `units.json` document the single definition of units for a
208
+ * machine. Fetched from `system.get_units`; see `autocore-server/UNITS_PLAN.md`.
209
+ */
210
+ export interface UnitsTable {
211
+ /** Named systems, in display order. Any names, not a fixed pair. */
212
+ systems: string[];
213
+ default_system: string;
214
+ /** quantity name → row. Quantities are DATA, not a fixed enum. */
215
+ scales: Record<string, UnitScale>;
216
+ }
217
+
218
+ /**
219
+ * A quantity resolved against the active system — what the UI actually renders
220
+ * with. Produced by the provider from `UnitsTable` + active system.
221
+ *
222
+ * `scale`/`label` keep their historical names so the provider's existing
223
+ * rescale-from-raw machinery is unchanged; `precision` is new and is what makes
224
+ * "too many decimals" configurable rather than hard-coded.
180
225
  */
181
226
  export interface ScaleConfig {
182
- /** Unique name for this scale group (e.g., "position", "load") */
227
+ /** Quantity name this resolves (e.g., "position", "force") */
183
228
  name: string;
184
229
  /** Current scale factor — incoming values ×= scale; outgoing values /= scale */
185
230
  scale: number;
186
231
  /** Units/label for display (e.g., "mm", "in", "lbs") */
187
232
  label: string;
233
+ /** Additive term for absolute quantities (temperature). Default 0. */
234
+ offset?: number;
235
+ /** Display digits. */
236
+ precision?: number;
237
+ precisionMode?: PrecisionMode;
188
238
  /** Optional description for debugging/documentation */
189
239
  description?: string;
190
240
  /**
@@ -237,10 +287,22 @@ export type TagConfig<
237
287
 
238
288
 
239
289
  /**
240
- * Optional scale group for numeric tags.
241
- * If present, provider will:
242
- * - Multiply incoming numbers by the group's `scale`
243
- * - Divide outgoing numbers by the group's `scale`
290
+ * The QUANTITY this tag measures — a row name in `units.json`
291
+ * ("position", "force", "torque", …). If present, the provider will:
292
+ * - Multiply incoming numbers by the active system's `scalar` (+ offset)
293
+ * - Divide outgoing numbers back to backend units
294
+ * - Render them at the active system's `precision`
295
+ *
296
+ * Quantities are data, not an enum: a machine with a channel nobody
297
+ * anticipated just gets a new row in `units.json`.
298
+ */
299
+ quantity?: string;
300
+
301
+ /**
302
+ * @deprecated Renamed to {@link quantity} in 4.0, when hand-authored
303
+ * `acScales` was replaced by the server's `units.json`. Still read as a
304
+ * fallback so a project can be migrated without its HMI going dark, but it
305
+ * will be removed — regenerate with `acctl codegen-tags`.
244
306
  */
245
307
  scale?: string;
246
308
 
@@ -306,6 +368,28 @@ export interface BaseContextValue<VMap extends Record<string, any>> {
306
368
  /** Current app-visible tag values (already scaled/decoded). */
307
369
  values: Partial<VMap>;
308
370
 
371
+ /**
372
+ * The machine's units table (`units.json`), or `null` when the machine has
373
+ * none — in which case values render in backend units.
374
+ */
375
+ units?: UnitsTable | null;
376
+
377
+ /** The display system currently in effect (e.g. "Metric"). */
378
+ activeSystem?: string | null;
379
+
380
+ /**
381
+ * Switch the machine's display system. Persisted server-side and broadcast,
382
+ * so every connected client switches live with no reload.
383
+ */
384
+ setUnitSystem?: (system: string) => Promise<void>;
385
+
386
+ /**
387
+ * Format a BACKEND value for display in the active system, honouring that
388
+ * quantity's label precision. Rounding is a rendering step only — this never
389
+ * changes a stored value.
390
+ */
391
+ formatQuantity?: (quantity: string, backendValue: number) => string;
392
+
309
393
  /** Last raw (controller) values, as received (pre-scale, pre-codec). */
310
394
  rawValues: Record<string, unknown>;
311
395
 
@@ -0,0 +1,72 @@
1
+ /*
2
+ * Copyright (C) 2026 Automated Design Corp. All Rights Reserved.
3
+ *
4
+ * Number formatting for a resolved unit scale — the single place display
5
+ * precision is applied, so a table cell, a chart tick and a live readout can
6
+ * never disagree about how many digits a value has.
7
+ *
8
+ * Deliberately mirrors `mechutil::units::format_value` (Rust) so a value
9
+ * rendered in the HMI and the same value rendered into a server-side CSV export
10
+ * look identical. If you change one, change the other.
11
+ *
12
+ * Rounding here is a RENDERING step. It is never applied on the way into GM,
13
+ * GNV or any stored file — see autocore-server/UNITS_PLAN.md §6.1.
14
+ */
15
+
16
+ import type { PrecisionMode, ScaleConfig } from "./AutoCoreTagTypes";
17
+
18
+ /** Digits used when a scale declares no precision. */
19
+ export const DEFAULT_PRECISION = 3;
20
+
21
+ /**
22
+ * Format an already-converted (display-unit) value.
23
+ *
24
+ * Precision is a **cap, not padding**: a clean 0/1/2 axis stays "0", "1", "2"
25
+ * rather than becoming "0.000", which is what made fixed `toFixed` unusable for
26
+ * chart ticks.
27
+ */
28
+ export function formatValue(
29
+ value: number,
30
+ precision: number = DEFAULT_PRECISION,
31
+ mode: PrecisionMode = "decimals",
32
+ ): string {
33
+ if (!Number.isFinite(value)) return String(value);
34
+
35
+ let s: string;
36
+ if (mode === "significant") {
37
+ const digits = Math.max(1, precision);
38
+ if (value === 0) {
39
+ s = "0";
40
+ } else {
41
+ // Track magnitude so a channel spanning 0.001–5000 reads sensibly at
42
+ // both ends, which fixed decimals cannot do.
43
+ const exp = Math.floor(Math.log10(Math.abs(value)));
44
+ const decimals = Math.max(0, digits - 1 - exp);
45
+ s = value.toFixed(Math.min(decimals, 100));
46
+ }
47
+ } else {
48
+ s = value.toFixed(Math.max(0, Math.min(precision, 100)));
49
+ }
50
+
51
+ if (s.includes(".")) {
52
+ s = s.replace(/0+$/, "").replace(/\.$/, "");
53
+ if (s === "" || s === "-") s = "0";
54
+ }
55
+ return s;
56
+ }
57
+
58
+ /** Format a display-unit value using a resolved scale's precision settings. */
59
+ export function formatScaled(displayValue: number, scale?: ScaleConfig): string {
60
+ return formatValue(
61
+ displayValue,
62
+ scale?.precision ?? DEFAULT_PRECISION,
63
+ scale?.precisionMode ?? "decimals",
64
+ );
65
+ }
66
+
67
+ /** `"12.5 mm"` — value plus the active system's label, when there is one. */
68
+ export function formatScaledWithLabel(displayValue: number, scale?: ScaleConfig): string {
69
+ const text = formatScaled(displayValue, scale);
70
+ const label = scale?.label?.trim();
71
+ return label ? `${text} ${label}` : text;
72
+ }