@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.
- package/dist/components/UnitSystemSelector.d.ts +17 -0
- package/dist/components/UnitSystemSelector.d.ts.map +1 -0
- package/dist/components/UnitSystemSelector.js +1 -0
- package/dist/components/UnitsEditor.d.ts +11 -0
- package/dist/components/UnitsEditor.d.ts.map +1 -0
- package/dist/components/UnitsEditor.js +1 -0
- package/dist/components/index.d.ts +5 -1
- package/dist/components/index.d.ts.map +1 -1
- package/dist/components/index.js +1 -1
- package/dist/components/tis/ResultHistoryTable.d.ts.map +1 -1
- package/dist/components/tis/ResultHistoryTable.js +1 -1
- package/dist/components/tis/ScienceTable.d.ts +14 -0
- package/dist/components/tis/ScienceTable.d.ts.map +1 -1
- package/dist/components/tis/ScienceTable.js +1 -1
- package/dist/components/tis/TestDataView.d.ts +33 -4
- package/dist/components/tis/TestDataView.d.ts.map +1 -1
- package/dist/components/tis/TestDataView.js +1 -1
- package/dist/components/tis/TestFieldRow.d.ts +69 -7
- package/dist/components/tis/TestFieldRow.d.ts.map +1 -1
- package/dist/components/tis/TestFieldRow.js +1 -1
- package/dist/components/tis/TestSetupForm.d.ts.map +1 -1
- package/dist/components/tis/TestSetupForm.js +1 -1
- package/dist/components/tis/TisProvider.d.ts.map +1 -1
- package/dist/components/tis/TisProvider.js +1 -1
- package/dist/components/tis-editor/editor/ChartViewDialog.d.ts.map +1 -1
- package/dist/components/tis-editor/editor/ChartViewDialog.js +1 -1
- package/dist/components/tis-editor/types.d.ts +6 -0
- package/dist/components/tis-editor/types.d.ts.map +1 -1
- package/dist/core/AutoCoreTagContext.d.ts.map +1 -1
- package/dist/core/AutoCoreTagContext.js +1 -1
- package/dist/core/AutoCoreTagTypes.d.ts +82 -7
- package/dist/core/AutoCoreTagTypes.d.ts.map +1 -1
- package/dist/core/formatScaled.d.ts +16 -0
- package/dist/core/formatScaled.d.ts.map +1 -0
- package/dist/core/formatScaled.js +1 -0
- package/dist/hooks/useAutoCoreTag.d.ts +11 -3
- package/dist/hooks/useAutoCoreTag.d.ts.map +1 -1
- package/dist/hooks/useAutoCoreTag.js +1 -1
- package/package.json +1 -1
- package/src/components/UnitSystemSelector.tsx +88 -0
- package/src/components/UnitsEditor.tsx +317 -0
- package/src/components/index.ts +5 -1
- package/src/components/tis/ResultHistoryTable.tsx +96 -6
- package/src/components/tis/ScienceTable.tsx +33 -1
- package/src/components/tis/TestDataView.tsx +203 -33
- package/src/components/tis/TestFieldRow.tsx +126 -19
- package/src/components/tis/TestSetupForm.tsx +35 -7
- package/src/components/tis/TisProvider.tsx +46 -3
- package/src/components/tis-editor/editor/ChartViewDialog.tsx +27 -0
- package/src/components/tis-editor/types.ts +6 -0
- package/src/core/AutoCoreTagContext.tsx +165 -16
- package/src/core/AutoCoreTagTypes.ts +91 -7
- package/src/core/formatScaled.ts +72 -0
- package/src/hooks/useAutoCoreTag.ts +51 -3
- package/todo.md +6 -0
|
@@ -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
|
-
|
|
510
|
-
|
|
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,
|
|
305
|
+
const { valueType, codec } = tag;
|
|
306
|
+
const quantity = tag.quantity ?? tag.scale;
|
|
237
307
|
|
|
238
|
-
// 1) numeric scaling:
|
|
239
|
-
|
|
240
|
-
|
|
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,
|
|
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:
|
|
268
|
-
|
|
269
|
-
|
|
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
|
-
*
|
|
456
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
*
|
|
179
|
-
*
|
|
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
|
-
/**
|
|
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
|
-
*
|
|
241
|
-
* If present, provider will:
|
|
242
|
-
* - Multiply incoming numbers by the
|
|
243
|
-
* - Divide outgoing numbers
|
|
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(
|
|
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
|
-
|
|
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.
|