@adcops/autocore-react 3.3.124 → 3.5.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/components/UnitSystemSelector.d.ts +17 -0
- package/dist/components/UnitSystemSelector.d.ts.map +1 -0
- package/dist/components/UnitSystemSelector.js +1 -0
- package/dist/components/UnitsEditor.d.ts +11 -0
- package/dist/components/UnitsEditor.d.ts.map +1 -0
- package/dist/components/UnitsEditor.js +1 -0
- package/dist/components/ams/AmsProvider.d.ts +14 -0
- package/dist/components/ams/AmsProvider.d.ts.map +1 -1
- package/dist/components/ams/AmsProvider.js +1 -1
- package/dist/components/ams/AssetDetailView.d.ts.map +1 -1
- package/dist/components/ams/AssetDetailView.js +1 -1
- package/dist/components/ams/AssetNameplateGrid.d.ts +13 -0
- package/dist/components/ams/AssetNameplateGrid.d.ts.map +1 -0
- package/dist/components/ams/AssetNameplateGrid.js +1 -0
- package/dist/components/ams/InstalledAssetPicker.d.ts +35 -0
- package/dist/components/ams/InstalledAssetPicker.d.ts.map +1 -0
- package/dist/components/ams/InstalledAssetPicker.js +1 -0
- package/dist/components/ams/index.d.ts +5 -0
- package/dist/components/ams/index.d.ts.map +1 -1
- package/dist/components/ams/index.js +1 -1
- package/dist/components/ams/useInstalledAsset.d.ts +26 -0
- package/dist/components/ams/useInstalledAsset.d.ts.map +1 -0
- package/dist/components/ams/useInstalledAsset.js +1 -0
- package/dist/components/index.d.ts +4 -0
- package/dist/components/index.d.ts.map +1 -1
- package/dist/components/index.js +1 -1
- package/dist/components/tis/ResultHistoryTable.d.ts.map +1 -1
- package/dist/components/tis/ResultHistoryTable.js +1 -1
- package/dist/components/tis/ScienceTable.d.ts.map +1 -1
- package/dist/components/tis/ScienceTable.js +1 -1
- package/dist/components/tis/TestDataView.d.ts +16 -4
- package/dist/components/tis/TestDataView.d.ts.map +1 -1
- package/dist/components/tis/TestDataView.js +1 -1
- package/dist/components/tis/TestFieldRow.d.ts +88 -7
- package/dist/components/tis/TestFieldRow.d.ts.map +1 -1
- package/dist/components/tis/TestFieldRow.js +1 -1
- package/dist/components/tis/TestSetupForm.d.ts.map +1 -1
- package/dist/components/tis/TestSetupForm.js +1 -1
- package/dist/components/tis/TisProvider.d.ts.map +1 -1
- package/dist/components/tis/TisProvider.js +1 -1
- package/dist/components/tis-editor/editor/TestFieldDialog.d.ts.map +1 -1
- package/dist/components/tis-editor/editor/TestFieldDialog.js +1 -1
- package/dist/components/tis-editor/types.d.ts +6 -2
- package/dist/components/tis-editor/types.d.ts.map +1 -1
- package/dist/core/AutoCoreTagContext.d.ts.map +1 -1
- package/dist/core/AutoCoreTagContext.js +1 -1
- package/dist/core/AutoCoreTagTypes.d.ts +82 -7
- package/dist/core/AutoCoreTagTypes.d.ts.map +1 -1
- package/dist/core/formatScaled.d.ts +16 -0
- package/dist/core/formatScaled.d.ts.map +1 -0
- package/dist/core/formatScaled.js +1 -0
- package/dist/hooks/useAutoCoreTag.d.ts +11 -3
- package/dist/hooks/useAutoCoreTag.d.ts.map +1 -1
- package/dist/hooks/useAutoCoreTag.js +1 -1
- package/package.json +1 -1
- package/src/components/UnitSystemSelector.tsx +88 -0
- package/src/components/UnitsEditor.tsx +317 -0
- package/src/components/ams/AmsProvider.tsx +36 -1
- package/src/components/ams/AssetDetailView.tsx +2 -20
- package/src/components/ams/AssetNameplateGrid.tsx +62 -0
- package/src/components/ams/InstalledAssetPicker.tsx +209 -0
- package/src/components/ams/index.ts +7 -0
- package/src/components/ams/useInstalledAsset.ts +108 -0
- package/src/components/index.ts +4 -0
- package/src/components/tis/ResultHistoryTable.tsx +89 -6
- package/src/components/tis/ScienceTable.tsx +13 -0
- package/src/components/tis/TestDataView.tsx +153 -35
- package/src/components/tis/TestFieldRow.tsx +157 -19
- package/src/components/tis/TestSetupForm.tsx +35 -7
- package/src/components/tis/TisProvider.tsx +46 -3
- package/src/components/tis-editor/editor/TestFieldDialog.tsx +29 -18
- package/src/components/tis-editor/types.ts +6 -2
- package/src/core/AutoCoreTagContext.tsx +165 -16
- package/src/core/AutoCoreTagTypes.ts +91 -7
- package/src/core/formatScaled.ts +72 -0
- package/src/hooks/useAutoCoreTag.ts +51 -3
- package/todo.md +6 -0
|
@@ -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
|
|
@@ -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
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
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
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
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
|
-
|
|
29
|
-
|
|
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,
|
|
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
|
+
}
|