@adcops/autocore-react 3.3.123 → 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 (55) 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 +5 -1
  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 +14 -0
  13. package/dist/components/tis/ScienceTable.d.ts.map +1 -1
  14. package/dist/components/tis/ScienceTable.js +1 -1
  15. package/dist/components/tis/TestDataView.d.ts +33 -4
  16. package/dist/components/tis/TestDataView.d.ts.map +1 -1
  17. package/dist/components/tis/TestDataView.js +1 -1
  18. package/dist/components/tis/TestFieldRow.d.ts +69 -7
  19. package/dist/components/tis/TestFieldRow.d.ts.map +1 -1
  20. package/dist/components/tis/TestFieldRow.js +1 -1
  21. package/dist/components/tis/TestSetupForm.d.ts.map +1 -1
  22. package/dist/components/tis/TestSetupForm.js +1 -1
  23. package/dist/components/tis/TisProvider.d.ts.map +1 -1
  24. package/dist/components/tis/TisProvider.js +1 -1
  25. package/dist/components/tis-editor/editor/ChartViewDialog.d.ts.map +1 -1
  26. package/dist/components/tis-editor/editor/ChartViewDialog.js +1 -1
  27. package/dist/components/tis-editor/types.d.ts +6 -0
  28. package/dist/components/tis-editor/types.d.ts.map +1 -1
  29. package/dist/core/AutoCoreTagContext.d.ts.map +1 -1
  30. package/dist/core/AutoCoreTagContext.js +1 -1
  31. package/dist/core/AutoCoreTagTypes.d.ts +82 -7
  32. package/dist/core/AutoCoreTagTypes.d.ts.map +1 -1
  33. package/dist/core/formatScaled.d.ts +16 -0
  34. package/dist/core/formatScaled.d.ts.map +1 -0
  35. package/dist/core/formatScaled.js +1 -0
  36. package/dist/hooks/useAutoCoreTag.d.ts +11 -3
  37. package/dist/hooks/useAutoCoreTag.d.ts.map +1 -1
  38. package/dist/hooks/useAutoCoreTag.js +1 -1
  39. package/package.json +1 -1
  40. package/src/components/UnitSystemSelector.tsx +88 -0
  41. package/src/components/UnitsEditor.tsx +317 -0
  42. package/src/components/index.ts +5 -1
  43. package/src/components/tis/ResultHistoryTable.tsx +96 -6
  44. package/src/components/tis/ScienceTable.tsx +33 -1
  45. package/src/components/tis/TestDataView.tsx +203 -33
  46. package/src/components/tis/TestFieldRow.tsx +126 -19
  47. package/src/components/tis/TestSetupForm.tsx +35 -7
  48. package/src/components/tis/TisProvider.tsx +46 -3
  49. package/src/components/tis-editor/editor/ChartViewDialog.tsx +27 -0
  50. package/src/components/tis-editor/types.ts +6 -0
  51. package/src/core/AutoCoreTagContext.tsx +165 -16
  52. package/src/core/AutoCoreTagTypes.ts +91 -7
  53. package/src/core/formatScaled.ts +72 -0
  54. package/src/hooks/useAutoCoreTag.ts +51 -3
  55. 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
@@ -3,9 +3,16 @@ import { Dialog } from 'primereact/dialog';
3
3
  import { Button } from 'primereact/button';
4
4
  import { InputText } from 'primereact/inputtext';
5
5
  import { Dropdown } from 'primereact/dropdown';
6
+ import { InputNumber } from 'primereact/inputnumber';
6
7
  import { FormRow } from '../../forms/FormRow';
8
+ import { DEFAULT_X_DECIMALS } from '../../tis/TestDataView';
7
9
  import type { ChartAxis, ChartSeries, ChartView } from '../types';
8
10
 
11
+ /** Upper bound on the editor's X-decimals spinner. Past ~6 the tick labels
12
+ * are wider than the gap between them, which is the problem this setting
13
+ * exists to solve. */
14
+ const MAX_X_DECIMALS = 8;
15
+
9
16
  const VIEW_TYPES = [
10
17
  { label: 'Cycle scatter', value: 'cycle_scatter' },
11
18
  { label: 'Raw trace', value: 'raw_trace' },
@@ -115,6 +122,26 @@ export const ChartViewDialog: React.FC<ChartViewDialogProps> = ({
115
122
  onChange={(e) => setAxis({ label: e.target.value })}
116
123
  />
117
124
  </FormRow>
125
+ <FormRow
126
+ label="X decimals"
127
+ hint={`Decimal places on X-axis tick labels. Blank uses the default (${DEFAULT_X_DECIMALS}). Display only — stored data keeps full precision.`}
128
+ >
129
+ <InputNumber
130
+ value={draft.x_decimals ?? null}
131
+ onValueChange={(e) => {
132
+ // Blank clears the override so the view falls back to
133
+ // the default, rather than pinning it to 0 decimals.
134
+ const next = { ...draft };
135
+ if (e.value == null) delete next.x_decimals;
136
+ else next.x_decimals = e.value;
137
+ setDraft(next);
138
+ }}
139
+ min={0}
140
+ max={MAX_X_DECIMALS}
141
+ placeholder={String(DEFAULT_X_DECIMALS)}
142
+ showButtons
143
+ />
144
+ </FormRow>
118
145
 
119
146
  <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', margin: '1rem 0 0.5rem' }}>
120
147
  <strong>Y series</strong>
@@ -89,6 +89,12 @@ export interface ChartView {
89
89
  type: ChartViewType | string;
90
90
  x: ChartAxis;
91
91
  y: ChartSeries[];
92
+ /**
93
+ * Max decimal places on X-axis tick labels (display only; default 2).
94
+ * See `ChartView.x_decimals` in TestDataView for why an uncapped axis
95
+ * prints 17-digit ticks.
96
+ */
97
+ x_decimals?: number;
92
98
  }
93
99
 
94
100
  export type RawColumnSource = 'time' | 'derived' | string; // also `ni.<daq>.channels.<name>`
@@ -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
+ }
@@ -14,7 +14,7 @@
14
14
  */
15
15
 
16
16
  import { useCallback, useContext, useMemo } from "react";
17
- import type { BaseContextValue, TagConfig, TagValueMap } from "../core/AutoCoreTagTypes";
17
+ import type { BaseContextValue, ScaleConfig, TagConfig, TagValueMap } from "../core/AutoCoreTagTypes";
18
18
 
19
19
  export function makeAutoCoreTagHooks<Spec extends readonly TagConfig[]>(
20
20
  Context: React.Context<BaseContextValue<TagValueMap<Spec>>>,
@@ -96,12 +96,60 @@ export function makeAutoCoreTagHooks<Spec extends readonly TagConfig[]>(
96
96
 
97
97
  /**
98
98
  * Access scale configurations and update functions.
99
+ *
100
+ * `getScale` NEVER returns undefined. Callers destructure it directly
101
+ * (`const { label } = getScale("load")`), so handing back undefined for an
102
+ * unknown group throws inside render and white-screens the whole HMI — on a
103
+ * machine, that is the operator losing the screen over a missing unit label.
104
+ * An unknown group yields an identity scale instead: no conversion, empty
105
+ * label, which is the same thing the provider did before units.json existed.
99
106
  */
100
107
  const useScales = () => {
101
108
  const { scales, updateScale } = useContext(Context);
102
- const getScale = useCallback((scaleName: string) => scales[scaleName], [scales]);
109
+ const getScale = useCallback(
110
+ (scaleName: string): ScaleConfig =>
111
+ scales[scaleName] ?? { name: scaleName, scale: 1, label: "" },
112
+ [scales],
113
+ );
103
114
  return { scales, updateScale, getScale } as const;
104
115
  };
105
116
 
106
- return { useAutoCoreTag, useAutoCoreTags, useAutoCoreSelect, useScales } as const;
117
+ /**
118
+ * Access the machine's units table, the active display system, the live
119
+ * switch, and the shared formatter.
120
+ *
121
+ * `format(quantity, backendValue)` is the one place display precision is
122
+ * applied — use it rather than `toFixed` so a readout, a table cell and a
123
+ * chart tick can never disagree. It takes a BACKEND value; conversion to the
124
+ * active system happens inside.
125
+ */
126
+ const useUnits = () => {
127
+ const { units, activeSystem, setUnitSystem, formatQuantity, scales } =
128
+ useContext(Context);
129
+ const label = useCallback(
130
+ (quantity: string) => scales[quantity]?.label ?? "",
131
+ [scales],
132
+ );
133
+ const format = useCallback(
134
+ (quantity: string, backendValue: number) =>
135
+ formatQuantity ? formatQuantity(quantity, backendValue) : String(backendValue),
136
+ [formatQuantity],
137
+ );
138
+ return {
139
+ units: units ?? null,
140
+ activeSystem: activeSystem ?? null,
141
+ systems: units?.systems ?? [],
142
+ setUnitSystem,
143
+ label,
144
+ format,
145
+ } as const;
146
+ };
147
+
148
+ return {
149
+ useAutoCoreTag,
150
+ useAutoCoreTags,
151
+ useAutoCoreSelect,
152
+ useScales,
153
+ useUnits,
154
+ } as const;
107
155
  }
package/todo.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # TODO
2
2
 
3
+ ## 3.5.0 — Units & scales
4
+ <!-- NOT 4.0: that is a separate in-progress branch. -->
5
+ - [ ] Replace `ScaleConfig` / `acScales` with quantity-based derivation from the
6
+ project `units` block + GNV `active_unit_system`. Design plan:
7
+ **`autocore-server/UNITS_PLAN.md`** (§5.3 is the autocore-react section).
8
+
3
9
  ## Integration
4
10
  - [x] Hub class to route to and from the back end
5
11
  - -[x] The Hub should also be the dispatcher. I can't think of a reason they should be separate.