@adcops/autocore-react 3.3.113 → 3.3.116

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 (69) hide show
  1. package/dist/components/index.d.ts +10 -2
  2. package/dist/components/index.d.ts.map +1 -1
  3. package/dist/components/index.js +1 -1
  4. package/dist/components/tis/CycleConfigDialog.d.ts +39 -0
  5. package/dist/components/tis/CycleConfigDialog.d.ts.map +1 -0
  6. package/dist/components/tis/CycleConfigDialog.js +1 -0
  7. package/dist/components/tis/CycleConfigForm.d.ts +22 -0
  8. package/dist/components/tis/CycleConfigForm.d.ts.map +1 -0
  9. package/dist/components/tis/CycleConfigForm.js +1 -0
  10. package/dist/components/tis/CycleStatusStrip.d.ts +10 -0
  11. package/dist/components/tis/CycleStatusStrip.d.ts.map +1 -0
  12. package/dist/components/tis/CycleStatusStrip.js +1 -0
  13. package/dist/components/tis/ResultHistoryTable.d.ts.map +1 -1
  14. package/dist/components/tis/ResultHistoryTable.js +1 -1
  15. package/dist/components/tis/ScienceTable.d.ts +15 -0
  16. package/dist/components/tis/ScienceTable.d.ts.map +1 -1
  17. package/dist/components/tis/ScienceTable.js +1 -1
  18. package/dist/components/tis/TestDataView.d.ts +14 -4
  19. package/dist/components/tis/TestDataView.d.ts.map +1 -1
  20. package/dist/components/tis/TestDataView.js +1 -1
  21. package/dist/components/tis/TestFieldRow.d.ts +151 -0
  22. package/dist/components/tis/TestFieldRow.d.ts.map +1 -0
  23. package/dist/components/tis/TestFieldRow.js +1 -0
  24. package/dist/components/tis/TestSetupForm.d.ts +15 -51
  25. package/dist/components/tis/TestSetupForm.d.ts.map +1 -1
  26. package/dist/components/tis/TestSetupForm.js +1 -1
  27. package/dist/components/tis/TestSetupView.d.ts.map +1 -1
  28. package/dist/components/tis/TestSetupView.js +1 -1
  29. package/dist/components/tis/TisProvider.d.ts +80 -0
  30. package/dist/components/tis/TisProvider.d.ts.map +1 -1
  31. package/dist/components/tis/TisProvider.js +1 -1
  32. package/dist/components/tis-editor/TisConfigEditor.d.ts.map +1 -1
  33. package/dist/components/tis-editor/TisConfigEditor.js +1 -1
  34. package/dist/components/tis-editor/editor/CycleConfigSection.d.ts +19 -0
  35. package/dist/components/tis-editor/editor/CycleConfigSection.d.ts.map +1 -0
  36. package/dist/components/tis-editor/editor/CycleConfigSection.js +1 -0
  37. package/dist/components/tis-editor/editor/FieldArrayEditor.d.ts.map +1 -1
  38. package/dist/components/tis-editor/editor/FieldArrayEditor.js +1 -1
  39. package/dist/components/tis-editor/editor/MethodFormEditor.d.ts.map +1 -1
  40. package/dist/components/tis-editor/editor/MethodFormEditor.js +1 -1
  41. package/dist/components/tis-editor/editor/TestFieldDialog.d.ts +3 -0
  42. package/dist/components/tis-editor/editor/TestFieldDialog.d.ts.map +1 -1
  43. package/dist/components/tis-editor/editor/TestFieldDialog.js +1 -1
  44. package/dist/components/tis-editor/editor/ViewsEditor.d.ts.map +1 -1
  45. package/dist/components/tis-editor/editor/ViewsEditor.js +1 -1
  46. package/dist/components/tis-editor/types.d.ts +28 -1
  47. package/dist/components/tis-editor/types.d.ts.map +1 -1
  48. package/dist/components/tis-editor/validation.d.ts.map +1 -1
  49. package/dist/components/tis-editor/validation.js +1 -1
  50. package/package.json +110 -110
  51. package/src/components/index.ts +14 -0
  52. package/src/components/tis/CycleConfigDialog.tsx +173 -0
  53. package/src/components/tis/CycleConfigForm.tsx +192 -0
  54. package/src/components/tis/CycleStatusStrip.tsx +84 -0
  55. package/src/components/tis/ResultHistoryTable.tsx +141 -1
  56. package/src/components/tis/ScienceTable.tsx +71 -12
  57. package/src/components/tis/TestDataView.tsx +82 -7
  58. package/src/components/tis/TestFieldRow.tsx +297 -0
  59. package/src/components/tis/TestSetupForm.tsx +52 -212
  60. package/src/components/tis/TestSetupView.tsx +60 -3
  61. package/src/components/tis/TisProvider.tsx +191 -0
  62. package/src/components/tis-editor/TisConfigEditor.tsx +1 -0
  63. package/src/components/tis-editor/editor/CycleConfigSection.tsx +93 -0
  64. package/src/components/tis-editor/editor/FieldArrayEditor.tsx +10 -0
  65. package/src/components/tis-editor/editor/MethodFormEditor.tsx +10 -4
  66. package/src/components/tis-editor/editor/TestFieldDialog.tsx +22 -2
  67. package/src/components/tis-editor/editor/ViewsEditor.tsx +3 -0
  68. package/src/components/tis-editor/types.ts +34 -1
  69. package/src/components/tis-editor/validation.ts +20 -2
@@ -29,6 +29,7 @@ import React, {
29
29
  } from 'react';
30
30
  import { EventEmitterContext } from '../../core/EventEmitterContext';
31
31
  import { MessageType } from '../../hub/CommandMessage';
32
+ import type { CycleConfigScope, TestFieldDef } from './TestFieldRow';
32
33
 
33
34
  // -------------------------------------------------------------------------
34
35
  // Types
@@ -68,6 +69,37 @@ export interface TisLiveState {
68
69
  lastStartError: string;
69
70
  }
70
71
 
72
+ /**
73
+ * Live Cycle Configuration state for the selected (project, method) —
74
+ * the operator's per-row annotation. Mirrors the server's
75
+ * `tis.cycle_config` payload one-for-one; the server owns every rule
76
+ * (which boundary latches, when autoclear fires, whether the gate is
77
+ * satisfied) so the HMI never has to re-derive them and can never
78
+ * disagree with the `tis.cycle_ready` scalar the machine gates on.
79
+ */
80
+ export interface TisCycleConfigState {
81
+ projectId: string;
82
+ methodId: string;
83
+ /** `'run'` = one latch per Start; `'cycle'` = one per cycle. */
84
+ scope: CycleConfigScope;
85
+ /** Method asks for the entry dialog at each boundary. */
86
+ promptBeforeCycle: boolean;
87
+ /** What the operator has entered but no row has consumed yet (RAW). */
88
+ values: Record<string, any>;
89
+ /** The snapshot the cycle now running will be stamped with, or null
90
+ * when nothing is latched. Non-null means the fields are locked for
91
+ * the row in flight — editing them targets the *next* row. */
92
+ latched: Record<string, any> | null;
93
+ /** False while any `required` field is empty. The same condition
94
+ * drives `tis.cycle_ready`, which gates the machine. */
95
+ ready: boolean;
96
+ /** Names of the required-but-empty fields, in declared order. */
97
+ missing: string[];
98
+ /** A `prompt_before_cycle` method has reached a boundary with nothing
99
+ * latched — the edge on which <CycleConfigDialog> opens. */
100
+ promptDue: boolean;
101
+ }
102
+
71
103
  export interface TisSelection {
72
104
  projectId: string;
73
105
  methodId: string;
@@ -208,6 +240,31 @@ export interface TisContextValue {
208
240
  * operator accepts a different one in the picker dialog. */
209
241
  setConfigurationName: (name: string) => void;
210
242
 
243
+ // -----------------------------------------------------------------
244
+ // Cycle Configuration — the operator's per-row annotation. Lives on
245
+ // the provider (like `stagedConfig`) so the Cycle tab, the status
246
+ // strip beside Start, and the prompt dialog all read one state and
247
+ // the values survive a tab switch.
248
+ // -----------------------------------------------------------------
249
+
250
+ /** Live state for the selected method. Server-owned; every mutation
251
+ * round-trips so the HMI and the `tis.cycle_ready` gate agree. */
252
+ cycleConfig: TisCycleConfigState;
253
+ /** The selected method's `cycle_config_fields`, in declared order.
254
+ * Empty when the method doesn't use the feature — which is the
255
+ * signal to hide the Cycle tab entirely. */
256
+ cycleConfigFields: TestFieldDef[];
257
+ /** Merge a sparse patch of RAW values into the operator's pending
258
+ * entry. An explicit null/'' clears a field. Resolves once the
259
+ * server has applied it and the state has been refreshed. */
260
+ setCycleConfigValues: (patch: Record<string, any>) => Promise<void>;
261
+ /** Wipe every pending value regardless of `autoclear`. Leaves the
262
+ * latch alone — a cycle already running keeps its label. */
263
+ clearCycleConfig: () => Promise<void>;
264
+ /** Re-read Cycle Configuration state from the server. Called on
265
+ * selection change; broadcasts keep it fresh after that. */
266
+ refreshCycleConfig: () => Promise<void>;
267
+
211
268
  /** Fetch the run list for a (project, method?) pair. Method may be
212
269
  * omitted to aggregate runs across every method in the project —
213
270
  * the History tab uses this. */
@@ -232,6 +289,29 @@ const EMPTY_SELECTION: TisSelection = {
232
289
  projectId: '', methodId: '', sampleId: '', runId: '',
233
290
  };
234
291
 
292
+ /** No method selected, or a method that declares no annotation columns.
293
+ * `ready: true` is the safe default — a method without cycle_config_fields
294
+ * must never look gated. */
295
+ const EMPTY_CYCLE_CONFIG: TisCycleConfigState = {
296
+ projectId: '', methodId: '', scope: 'run', promptBeforeCycle: false,
297
+ values: {}, latched: null, ready: true, missing: [], promptDue: false,
298
+ };
299
+
300
+ /** Normalise a server `tis.cycle_config` payload into provider state. */
301
+ const cycleConfigFromPayload = (p: any): TisCycleConfigState => ({
302
+ projectId: String(p?.project_id ?? ''),
303
+ methodId: String(p?.method_id ?? ''),
304
+ scope: p?.scope === 'cycle' ? 'cycle' : 'run',
305
+ promptBeforeCycle: !!p?.prompt_before_cycle,
306
+ values: (p?.values && typeof p.values === 'object') ? p.values : {},
307
+ latched: (p?.latched && typeof p.latched === 'object') ? p.latched : null,
308
+ // Absent `ready` means a server that predates the field — treat as
309
+ // ungated rather than locking an operator out of a working machine.
310
+ ready: p?.ready !== false,
311
+ missing: Array.isArray(p?.missing) ? p.missing : [],
312
+ promptDue: !!p?.prompt_due,
313
+ });
314
+
235
315
  const TisContext = createContext<TisContextValue>({
236
316
  schemas: {},
237
317
  projectAssetRefs: [],
@@ -255,6 +335,11 @@ const TisContext = createContext<TisContextValue>({
255
335
  markDefaultsAppliedForMethod: () => {},
256
336
  configurationName: '',
257
337
  setConfigurationName: () => {},
338
+ cycleConfig: EMPTY_CYCLE_CONFIG,
339
+ cycleConfigFields: [],
340
+ setCycleConfigValues: async () => {},
341
+ clearCycleConfig: async () => {},
342
+ refreshCycleConfig: async () => {},
258
343
  fetchRuns: async () => [],
259
344
  fetchRun: async () => null,
260
345
  runCache: {},
@@ -699,6 +784,90 @@ export const TisProvider: React.FC<TisProviderProps> = ({ children, defaultMetho
699
784
  setConfigurationName('');
700
785
  }, []);
701
786
 
787
+ // -----------------------------------------------------------------
788
+ // Cycle Configuration
789
+ //
790
+ // The server owns the rules — which boundary latches, when autoclear
791
+ // fires, whether the gate is satisfied — so this is a cache of its
792
+ // state, refreshed on selection change and kept live by the
793
+ // `tis.cycle_config` broadcast. Every mutation round-trips and adopts
794
+ // the reply, so the HMI can't drift from the `tis.cycle_ready` scalar
795
+ // the machine is actually gated on.
796
+ // -----------------------------------------------------------------
797
+ const [cycleConfig, setCycleConfigStateRaw] = useState<TisCycleConfigState>(EMPTY_CYCLE_CONFIG);
798
+
799
+ const cycleConfigFields = useMemo<TestFieldDef[]>(() => {
800
+ const arr = selection.methodId
801
+ ? schemas[selection.methodId]?.cycle_config_fields
802
+ : undefined;
803
+ return Array.isArray(arr) ? arr : [];
804
+ }, [schemas, selection.methodId]);
805
+
806
+ const refreshCycleConfig = useCallback(async () => {
807
+ const projectId = selection.projectId;
808
+ const methodId = selection.methodId;
809
+ if (!methodId) { setCycleConfigStateRaw(EMPTY_CYCLE_CONFIG); return; }
810
+ try {
811
+ const resp: any = await invoke('tis.cycle_config' as any, MessageType.Request, {
812
+ project_id: projectId, method_id: methodId,
813
+ } as any);
814
+ if (resp?.success) {
815
+ setCycleConfigStateRaw(cycleConfigFromPayload(resp.data));
816
+ return;
817
+ }
818
+ // An older server without the verb, or a method the project
819
+ // doesn't declare. Either way there is nothing to gate on.
820
+ setCycleConfigStateRaw(EMPTY_CYCLE_CONFIG);
821
+ } catch (e) {
822
+ console.warn('[TisProvider] tis.cycle_config failed:', e);
823
+ setCycleConfigStateRaw(EMPTY_CYCLE_CONFIG);
824
+ }
825
+ }, [invoke, selection.projectId, selection.methodId]);
826
+
827
+ const setCycleConfigValues = useCallback(async (patch: Record<string, any>) => {
828
+ const projectId = selection.projectId;
829
+ const methodId = selection.methodId;
830
+ if (!methodId) return;
831
+ try {
832
+ const resp: any = await invoke('tis.set_cycle_config' as any, MessageType.Request, {
833
+ project_id: projectId, method_id: methodId, values: patch,
834
+ } as any);
835
+ if (resp?.success) setCycleConfigStateRaw(cycleConfigFromPayload(resp.data));
836
+ else console.warn('[TisProvider] tis.set_cycle_config rejected:', resp?.error_message);
837
+ } catch (e) {
838
+ console.error('[TisProvider] tis.set_cycle_config failed:', e);
839
+ }
840
+ }, [invoke, selection.projectId, selection.methodId]);
841
+
842
+ const clearCycleConfig = useCallback(async () => {
843
+ const projectId = selection.projectId;
844
+ const methodId = selection.methodId;
845
+ if (!methodId) return;
846
+ try {
847
+ const resp: any = await invoke('tis.clear_cycle_config' as any, MessageType.Request, {
848
+ project_id: projectId, method_id: methodId,
849
+ } as any);
850
+ if (resp?.success) setCycleConfigStateRaw(cycleConfigFromPayload(resp.data));
851
+ } catch (e) {
852
+ console.error('[TisProvider] tis.clear_cycle_config failed:', e);
853
+ }
854
+ }, [invoke, selection.projectId, selection.methodId]);
855
+
856
+ useEffect(() => { void refreshCycleConfig(); }, [refreshCycleConfig]);
857
+
858
+ useEffect(() => {
859
+ // Server-pushed state: latch taken, autoclear fired, gate re-armed.
860
+ // Payloads for another method are ignored so a multi-method HMI
861
+ // doesn't show one method's annotation while another is selected.
862
+ const onCycleConfig = (payload: any) => {
863
+ const mid = String(payload?.method_id ?? '');
864
+ if (mid && selection.methodId && mid !== selection.methodId) return;
865
+ setCycleConfigStateRaw(cycleConfigFromPayload(payload));
866
+ };
867
+ const id = subscribe('tis.cycle_config', onCycleConfig);
868
+ return () => { unsubscribe(id); };
869
+ }, [subscribe, unsubscribe, selection.methodId]);
870
+
702
871
  const value: TisContextValue = useMemo(() => ({
703
872
  schemas, projectAssetRefs, defaultMethodId, schemasLoaded,
704
873
  state, selection, setSelection,
@@ -707,6 +876,8 @@ export const TisProvider: React.FC<TisProviderProps> = ({ children, defaultMetho
707
876
  stagedConfig, setStagedConfig, clearStagedConfig,
708
877
  defaultsAppliedForMethod, markDefaultsAppliedForMethod,
709
878
  configurationName, setConfigurationName,
879
+ cycleConfig, cycleConfigFields,
880
+ setCycleConfigValues, clearCycleConfig, refreshCycleConfig,
710
881
  fetchRuns, fetchRun, runCache,
711
882
  }), [
712
883
  schemas, projectAssetRefs, defaultMethodId, schemasLoaded,
@@ -716,6 +887,8 @@ export const TisProvider: React.FC<TisProviderProps> = ({ children, defaultMetho
716
887
  stagedConfig, setStagedConfig, clearStagedConfig,
717
888
  defaultsAppliedForMethod, markDefaultsAppliedForMethod,
718
889
  configurationName, setConfigurationName,
890
+ cycleConfig, cycleConfigFields,
891
+ setCycleConfigValues, clearCycleConfig, refreshCycleConfig,
719
892
  fetchRuns, fetchRun, runCache,
720
893
  ]);
721
894
 
@@ -729,6 +902,24 @@ export const TisProvider: React.FC<TisProviderProps> = ({ children, defaultMetho
729
902
  export const useTis = () => useContext(TisContext);
730
903
  export const useTisSchemas = () => useContext(TisContext).schemas;
731
904
  export const useTisState = () => useContext(TisContext).state;
905
+
906
+ /**
907
+ * Cycle Configuration for the selected method: the live state, the field
908
+ * declarations, and the two mutators.
909
+ *
910
+ * `fields.length === 0` means the method doesn't use per-row annotation —
911
+ * hide the Cycle tab and the status strip rather than showing an empty one.
912
+ */
913
+ export const useTisCycleConfig = () => {
914
+ const { cycleConfig, cycleConfigFields, setCycleConfigValues, clearCycleConfig } =
915
+ useContext(TisContext);
916
+ return {
917
+ ...cycleConfig,
918
+ fields: cycleConfigFields,
919
+ setValues: setCycleConfigValues,
920
+ clear: clearCycleConfig,
921
+ };
922
+ };
732
923
  /**
733
924
  * Tuple of `[selection, setSelection]`.
734
925
  *
@@ -50,6 +50,7 @@ const EMPTY_METHOD: TestMethod = {
50
50
  description: '',
51
51
  project_fields: [],
52
52
  config_fields: [],
53
+ cycle_config_fields: [],
53
54
  cycle_fields: [],
54
55
  results_fields: [],
55
56
  views: {},
@@ -0,0 +1,93 @@
1
+ /**
2
+ * CycleConfigSection — the two method-level settings that decide how the
3
+ * operator's per-row annotation behaves. The columns themselves are
4
+ * authored in the Fields tab; this is the state machine around them.
5
+ *
6
+ * The scope choice is the load-bearing one. A machine that records many
7
+ * cycles from one Start is not pausing for input between them, so its
8
+ * annotation has to latch once per Start ('run'). A machine the operator
9
+ * drives one cycle at a time — the six-position sweep — annotates each row
10
+ * separately ('cycle'), which means the gate re-arms between cycles and
11
+ * the control program must wait on `tis.cycle_ready`.
12
+ */
13
+
14
+ import { Dropdown } from 'primereact/dropdown';
15
+ import { Checkbox } from 'primereact/checkbox';
16
+ import { FormSection } from '../../forms/FormSection';
17
+ import { FormRow } from '../../forms/FormRow';
18
+ import type { CycleConfigScope, TestField, TestMethod } from '../types';
19
+
20
+ const SCOPES: { label: string; value: CycleConfigScope }[] = [
21
+ { label: 'run — one entry per Start, stamped on every cycle of the run', value: 'run' },
22
+ { label: 'cycle — one entry per cycle, re-entered between cycles', value: 'cycle' },
23
+ ];
24
+
25
+ export interface CycleConfigSectionProps {
26
+ method: TestMethod;
27
+ onChange: (next: TestMethod) => void;
28
+ }
29
+
30
+ export const CycleConfigSection: React.FC<CycleConfigSectionProps> = ({ method, onChange }) => {
31
+ const fields = (method.cycle_config_fields as TestField[]) ?? [];
32
+ const scope: CycleConfigScope = method.cycle_config_scope === 'cycle' ? 'cycle' : 'run';
33
+
34
+ /**
35
+ * Write a key only when it differs from the server-side default,
36
+ * deleting it otherwise. Both of these are `#[serde(default)]` in Rust,
37
+ * so materialising `"cycle_config_scope": "run"` into every method would
38
+ * add noise to test_methods.json that means nothing — and an editor that
39
+ * writes defaults it was only shown is how a review session ends up
40
+ * changing methods nobody touched.
41
+ */
42
+ const setOrDrop = <K extends 'cycle_config_scope' | 'prompt_before_cycle'>(
43
+ key: K, value: TestMethod[K], isDefault: boolean,
44
+ ) => {
45
+ const next: TestMethod = { ...method };
46
+ if (isDefault) delete next[key];
47
+ else next[key] = value;
48
+ onChange(next);
49
+ };
50
+
51
+ return (
52
+ <FormSection
53
+ title="Cycle Configuration"
54
+ description={
55
+ 'How the operator-entered annotation columns behave. Add the columns themselves ' +
56
+ 'on the Fields tab under "Cycle Configuration Fields".'
57
+ }
58
+ >
59
+ {fields.length === 0 && (
60
+ <div style={{ color: '#6b7280', marginBottom: '0.75rem' }}>
61
+ This method declares no Cycle Configuration fields, so these settings have no effect.
62
+ </div>
63
+ )}
64
+ <FormRow
65
+ label="Scope"
66
+ hint={
67
+ 'When the operator\'s entry is latched and cleared. Choose "cycle" only if the ' +
68
+ 'machine actually waits between cycles — under "cycle" the gate drops after every ' +
69
+ 'row and the control program must hold until tis.cycle_ready comes back true.'
70
+ }
71
+ >
72
+ <Dropdown
73
+ value={scope}
74
+ options={SCOPES}
75
+ onChange={(e) => setOrDrop('cycle_config_scope', e.value, e.value === 'run')}
76
+ />
77
+ </FormRow>
78
+ <FormRow
79
+ label="Prompt before cycle"
80
+ hint={
81
+ 'Open the entry dialog at the boundary instead of relying on the operator having ' +
82
+ 'filled the Cycle tab. Costs a confirmation each time — worth it when a mislabelled ' +
83
+ 'row means re-running the sample, not worth it for a non-critical note.'
84
+ }
85
+ >
86
+ <Checkbox
87
+ checked={!!method.prompt_before_cycle}
88
+ onChange={(e) => setOrDrop('prompt_before_cycle', !!e.checked, !e.checked)}
89
+ />
90
+ </FormRow>
91
+ </FormSection>
92
+ );
93
+ };
@@ -9,6 +9,7 @@ import type { TestField, TestMethod, FieldArrayKey } from '../types';
9
9
  const TITLES: Record<FieldArrayKey, string> = {
10
10
  project_fields: 'Project Fields',
11
11
  config_fields: 'Config Fields',
12
+ cycle_config_fields: 'Cycle Configuration Fields',
12
13
  cycle_fields: 'Cycle Fields',
13
14
  results_fields: 'Results Fields',
14
15
  };
@@ -16,6 +17,7 @@ const TITLES: Record<FieldArrayKey, string> = {
16
17
  const DESCRIPTIONS: Record<FieldArrayKey, string> = {
17
18
  project_fields: 'System-level fields (operator, station). Filled by the HMI but not cycled.',
18
19
  config_fields: 'Operator-input config (speeds, loads). Snapshotted into test.json on start.',
20
+ cycle_config_fields: 'Operator-entered annotation recorded onto every cycle row (e.g. which of six positions this is). Shown on the HMI Cycle tab and written as the first Cycle Data columns after the row index. Mark them Required so the machine cannot start unannotated, and Autoclear so the next row cannot inherit a stale value.',
19
21
  cycle_fields: 'Per-cycle capture. One row appended to cycles.jsonl per cycle.',
20
22
  results_fields: 'Post-test summary (min/max/avg, pass/fail). Written once at finish. A field can also be a cross-test Aggregate (avg/min/max/stddev/count over tests sharing a sample_id).',
21
23
  };
@@ -130,6 +132,13 @@ export const FieldArrayEditor: React.FC<FieldArrayEditorProps> = ({ arrayKey, me
130
132
  style={{ width: '8rem' }}
131
133
  />
132
134
  )}
135
+ {arrayKey === 'cycle_config_fields' && (
136
+ <Column
137
+ header="Autoclear"
138
+ body={(r: TestField) => r.autoclear ? '✓' : ''}
139
+ style={{ width: '6rem' }}
140
+ />
141
+ )}
133
142
  <Column header="" body={rowActions} style={{ width: '10rem' }} />
134
143
  </DataTable>
135
144
  </FormSection>
@@ -138,6 +147,7 @@ export const FieldArrayEditor: React.FC<FieldArrayEditorProps> = ({ arrayKey, me
138
147
  initial={editingIdx !== null ? (fields[editingIdx] ?? null) : null}
139
148
  siblingNames={fields.map(f => f.name)}
140
149
  isResultsField={arrayKey === 'results_fields'}
150
+ isCycleConfigField={arrayKey === 'cycle_config_fields'}
141
151
  cycleFieldNames={(method.cycle_fields ?? []).map(f => f.name).filter(Boolean)}
142
152
  resultsFieldNames={(method.results_fields ?? []).map(f => f.name).filter(Boolean)}
143
153
  onCancel={() => setDialogOpen(false)}
@@ -13,6 +13,7 @@ import { Button } from 'primereact/button';
13
13
  import { CodeEditor } from '../../CodeEditor';
14
14
  import { IdentitySection } from './IdentitySection';
15
15
  import { FieldArrayEditor } from './FieldArrayEditor';
16
+ import { CycleConfigSection } from './CycleConfigSection';
16
17
  import { ConfigurationsEditor } from './ConfigurationsEditor';
17
18
  import { ViewsEditor } from './ViewsEditor';
18
19
  import { RawDataEditor } from './RawDataEditor';
@@ -136,10 +137,15 @@ export const MethodFormEditor: React.FC<MethodFormEditorProps> = ({
136
137
  <IdentitySection method={draft} onChange={handleFormChange} />
137
138
  </TabPanel>
138
139
  <TabPanel header="Fields">
139
- <FieldArrayEditor arrayKey="project_fields" method={draft} onChange={handleFormChange} />
140
- <FieldArrayEditor arrayKey="config_fields" method={draft} onChange={handleFormChange} />
141
- <FieldArrayEditor arrayKey="cycle_fields" method={draft} onChange={handleFormChange} />
142
- <FieldArrayEditor arrayKey="results_fields" method={draft} onChange={handleFormChange} />
140
+ <FieldArrayEditor arrayKey="project_fields" method={draft} onChange={handleFormChange} />
141
+ <FieldArrayEditor arrayKey="config_fields" method={draft} onChange={handleFormChange} />
142
+ {/* Cycle Configuration sits directly above Cycle
143
+ Fields: the two together are one row, annotation
144
+ columns first. */}
145
+ <FieldArrayEditor arrayKey="cycle_config_fields" method={draft} onChange={handleFormChange} />
146
+ <CycleConfigSection method={draft} onChange={handleFormChange} />
147
+ <FieldArrayEditor arrayKey="cycle_fields" method={draft} onChange={handleFormChange} />
148
+ <FieldArrayEditor arrayKey="results_fields" method={draft} onChange={handleFormChange} />
143
149
  </TabPanel>
144
150
  <TabPanel header="Configurations">
145
151
  <ConfigurationsEditor method={draft} onChange={handleFormChange} />
@@ -45,6 +45,9 @@ export interface TestFieldDialogProps {
45
45
  /** True when this field belongs to `results_fields` — only then is the
46
46
  * cross-test Aggregate section shown. */
47
47
  isResultsField?: boolean;
48
+ /** True when this field belongs to `cycle_config_fields` — only then is
49
+ * the Autoclear option shown, and only there does it do anything. */
50
+ isCycleConfigField?: boolean;
48
51
  /** Candidate target field names for an aggregate's `of`, by source. */
49
52
  cycleFieldNames?: string[];
50
53
  resultsFieldNames?: string[];
@@ -54,7 +57,8 @@ const blank: TestField = { name: '', type: 'f32' };
54
57
 
55
58
  export const TestFieldDialog: React.FC<TestFieldDialogProps> = ({
56
59
  visible, initial, onCancel, onSave, siblingNames,
57
- isResultsField = false, cycleFieldNames = [], resultsFieldNames = [],
60
+ isResultsField = false, isCycleConfigField = false,
61
+ cycleFieldNames = [], resultsFieldNames = [],
58
62
  }) => {
59
63
  const [draft, setDraft] = useState<TestField>(blank);
60
64
  // `default` is edited as free text so a numeric literal, a string, or an
@@ -162,12 +166,28 @@ export const TestFieldDialog: React.FC<TestFieldDialogProps> = ({
162
166
  onChange={(e) => setDraft({ ...draft, label: e.target.value || undefined })}
163
167
  />
164
168
  </FormRow>
165
- <FormRow label="Required">
169
+ <FormRow
170
+ label="Required"
171
+ hint={isCycleConfigField
172
+ ? 'Blocks the machine until the operator fills this in — tis.cycle_ready stays false and start_test is refused. This is what stops an unannotated row from being recorded.'
173
+ : undefined}
174
+ >
166
175
  <Checkbox
167
176
  checked={!!draft.required}
168
177
  onChange={(e) => setDraft({ ...draft, required: !!e.checked })}
169
178
  />
170
179
  </FormRow>
180
+ {isCycleConfigField && (
181
+ <FormRow
182
+ label="Autoclear"
183
+ hint="Clear the operator's entry once a row has recorded it, so the next row can't inherit a stale value. Fires after each cycle under 'cycle' scope, at the end of the run under 'run' scope. Pair with Required — on its own it turns stale data into silently missing data."
184
+ >
185
+ <Checkbox
186
+ checked={!!draft.autoclear}
187
+ onChange={(e) => setDraft({ ...draft, autoclear: !!e.checked })}
188
+ />
189
+ </FormRow>
190
+ )}
171
191
  <FormRow label="Source" hint="Optional gm.* variable to bind this field to.">
172
192
  <InputText
173
193
  value={draft.source ?? ''}
@@ -31,6 +31,9 @@ export const ViewsEditor: React.FC<ViewsEditorProps> = ({ method, onChange }) =>
31
31
  const knownKeys: string[] = [
32
32
  ...((method.project_fields ?? []) as TestField[]).map(f => f.name),
33
33
  ...((method.config_fields ?? []) as TestField[]).map(f => f.name),
34
+ // Annotation columns live on the cycle row like any measured one, so
35
+ // a cycle_scatter can plot against them (e.g. peak load by position).
36
+ ...((method.cycle_config_fields ?? []) as TestField[]).map(f => f.name),
34
37
  ...((method.cycle_fields ?? []) as TestField[]).map(f => f.name),
35
38
  ...((method.results_fields ?? []) as TestField[]).map(f => f.name),
36
39
  ...Object.keys((method.raw_data as any)?.columns ?? {}),
@@ -31,6 +31,15 @@ export interface TestField {
31
31
  * non-excluded runs sharing this test's sample_id. When set, the field
32
32
  * is NOT supplied by the analysis script — the server computes it. */
33
33
  aggregate?: AggregateSpec;
34
+ /** cycle_fields only: operator-correctable after recording. Measured
35
+ * columns must stay unflagged. cycle_config_fields don't need it —
36
+ * they're annotation and are always correctable. */
37
+ editable?: boolean;
38
+ /** cycle_config_fields only: clear the operator's entry once a row has
39
+ * recorded it, so the next row can't inherit a stale annotation. Pair
40
+ * it with `required` — on its own it turns stale data into silently
41
+ * missing data. */
42
+ autoclear?: boolean;
34
43
  }
35
44
 
36
45
  export type AggFn = 'avg' | 'min' | 'max' | 'stddev' | 'count';
@@ -113,11 +122,30 @@ export interface TestConfiguration {
113
122
  defaults?: Record<string, unknown>;
114
123
  }
115
124
 
125
+ /**
126
+ * Which boundary an operator annotates `cycle_config_fields` at.
127
+ *
128
+ * 'run' one latch per Start — the same values stamp every cycle of the
129
+ * run and clear when it closes. Default; correct for a machine
130
+ * that records many cycles from one Start.
131
+ * 'cycle' one latch per cycle — values clear after each recorded row and
132
+ * the gate re-arms, so every row is annotated separately.
133
+ */
134
+ export type CycleConfigScope = 'run' | 'cycle';
135
+
116
136
  export interface TestMethod {
117
137
  label?: string;
118
138
  description?: string;
119
139
  project_fields?: TestField[];
120
140
  config_fields?: TestField[];
141
+ /** Operator-entered per-row annotation, recorded as the first Cycle Data
142
+ * columns after the row index. */
143
+ cycle_config_fields?: TestField[];
144
+ /** Boundary at which cycle_config_fields latch and autoclear. */
145
+ cycle_config_scope?: CycleConfigScope;
146
+ /** Open the Cycle Configuration dialog at the boundary rather than
147
+ * relying on the operator having filled the Cycle tab. */
148
+ prompt_before_cycle?: boolean;
121
149
  cycle_fields?: TestField[];
122
150
  results_fields?: TestField[];
123
151
  raw_data?: RawDataShape | null;
@@ -128,4 +156,9 @@ export interface TestMethod {
128
156
  [key: string]: unknown; // tolerate unknown server-side fields
129
157
  }
130
158
 
131
- export type FieldArrayKey = 'project_fields' | 'config_fields' | 'cycle_fields' | 'results_fields';
159
+ export type FieldArrayKey =
160
+ | 'project_fields'
161
+ | 'config_fields'
162
+ | 'cycle_config_fields'
163
+ | 'cycle_fields'
164
+ | 'results_fields';
@@ -10,7 +10,9 @@ import type {
10
10
  TestField, TestMethod, ChartAxis, ChartSeries,
11
11
  } from './types';
12
12
 
13
- const FIELD_ARRAY_KEYS = ['project_fields', 'config_fields', 'cycle_fields', 'results_fields'] as const;
13
+ const FIELD_ARRAY_KEYS = [
14
+ 'project_fields', 'config_fields', 'cycle_config_fields', 'cycle_fields', 'results_fields',
15
+ ] as const;
14
16
 
15
17
  export interface ValidationError {
16
18
  /** Dotted path: "<arrayKey>.<index>.<sub>" or "views.<viewId>.x" etc. */
@@ -49,7 +51,7 @@ export function validateMethod(methodId: string, m: TestMethod): ValidationError
49
51
  const resultTypes = new Map<string, string>(
50
52
  ((m.results_fields as TestField[] | undefined) ?? []).map((f) => [f.name, String(f.type)])
51
53
  );
52
- for (const key of ['project_fields', 'config_fields', 'cycle_fields'] as const) {
54
+ for (const key of ['project_fields', 'config_fields', 'cycle_config_fields', 'cycle_fields'] as const) {
53
55
  ((m[key] as TestField[] | undefined) ?? []).forEach((f, i) => {
54
56
  if (f.aggregate) {
55
57
  errs.push({ path: `${key}.${i}.aggregate`, message: `${methodId}.${key}.${f.name}: aggregate is only valid on results_fields` });
@@ -74,6 +76,22 @@ export function validateMethod(methodId: string, m: TestMethod): ValidationError
74
76
  }
75
77
  });
76
78
 
79
+ // A cycle_config_field and a cycle_field share one row namespace in
80
+ // cycles.jsonl — the same name in both means the operator's annotation
81
+ // and a measured value would fight over one column. Mirrors the
82
+ // server-side check in validate_method.
83
+ const cycleFieldNames = new Set<string>(
84
+ ((m.cycle_fields as TestField[] | undefined) ?? []).map(f => f.name).filter(Boolean),
85
+ );
86
+ ((m.cycle_config_fields as TestField[] | undefined) ?? []).forEach((f, i) => {
87
+ if (f.name && cycleFieldNames.has(f.name)) {
88
+ errs.push({
89
+ path: `cycle_config_fields.${i}.name`,
90
+ message: `${methodId}: "${f.name}" is declared as both a cycle_config_field and a cycle_field. They share one row namespace; rename one.`,
91
+ });
92
+ }
93
+ });
94
+
77
95
  // Build sets for axis-reference resolution.
78
96
  const knownFields = new Set<string>();
79
97
  for (const key of FIELD_ARRAY_KEYS) {