Skip to content

Commit 6c0c19a

Browse files
HanSur94claude
andcommitted
refactor(loadModuleMetadata): accept MATLAB table instead of module struct
Metadata comes as a MATLAB table with a 'Date' column (datetime) and state columns. The Date column is converted to datenum for StateChannel timestamps. Table column names are matched against ThresholdRule condition keys. - Replace struct input with table input - Add istable/Date column validation - Convert datetime Date column to datenum - Handle table column vectors (reshape to row) - Update all tests to use table-based API Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 13ae6f0 commit 6c0c19a

2 files changed

Lines changed: 89 additions & 120 deletions

File tree

libs/SensorThreshold/loadModuleMetadata.m

Lines changed: 39 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,22 @@
1-
function sensors = loadModuleMetadata(metadataStruct, sensors)
2-
%LOADMODULEMETADATA Attach state channels from metadata to sensors.
3-
% sensors = loadModuleMetadata(metadataStruct, sensors) reads discrete
4-
% state signals from metadataStruct, compresses them from dense to
1+
function sensors = loadModuleMetadata(metadataTable, sensors)
2+
%LOADMODULEMETADATA Attach state channels from metadata table to sensors.
3+
% sensors = loadModuleMetadata(metadataTable, sensors) reads discrete
4+
% state signals from a MATLAB table, compresses them from dense to
55
% sparse transitions, and attaches StateChannel objects to each sensor
6-
% whose ThresholdRules reference matching state keys.
6+
% whose ThresholdRules reference matching state column names.
77
%
8-
% metadataStruct must have the same format as module data: fields +
9-
% .doc with per-field .name/.datum entries. The .datum value names the
10-
% shared datenum field. State signals can be numeric arrays or cell
11-
% arrays of char.
8+
% metadataTable must be a MATLAB table with a 'Date' column (datetime)
9+
% and one or more state columns. The Date column is converted to
10+
% datenum for StateChannel timestamps. State columns can be numeric
11+
% or cell arrays of char.
1212
%
1313
% ThresholdRules must be attached to sensors before calling this
1414
% function. Sensors with no rules are skipped. Rules with empty
1515
% conditions (unconditional) contribute no state keys. State keys not
16-
% found in the metadata are skipped silently.
16+
% found in the table columns are skipped silently.
1717
%
1818
% Each sensor receives its own StateChannel instance (no shared
19-
% handles). Compressed data is cached so each field is processed once.
19+
% handles). Compressed data is cached so each column is processed once.
2020
%
2121
% Repeated calls add additional StateChannels without clearing existing
2222
% ones. Caller is responsible for avoiding duplicates.
@@ -25,16 +25,29 @@
2525

2626
narginchk(2, 2);
2727

28-
% --- Extract datenum field name from doc metadata ---
29-
datenumField = extractDatenumField(metadataStruct, 'loadModuleMetadata');
28+
% --- Validate table input ---
29+
if ~istable(metadataTable)
30+
error('loadModuleMetadata:notTable', ...
31+
'First argument must be a table, got %s.', class(metadataTable));
32+
end
33+
34+
colNames = metadataTable.Properties.VariableNames;
35+
36+
if ~ismember('Date', colNames)
37+
error('loadModuleMetadata:missingDate', ...
38+
'Metadata table must contain a ''Date'' column.');
39+
end
3040

3141
% --- Early exit for empty sensors ---
3242
if isempty(sensors)
3343
return;
3444
end
3545

36-
% --- Extract timestamps ---
37-
X = metadataStruct.(datenumField);
46+
% --- Extract timestamps (datetime -> datenum) ---
47+
X = datenum(metadataTable.Date);
48+
49+
% --- State column names (everything except Date) ---
50+
stateCols = colNames(~strcmp(colNames, 'Date'));
3851

3952
% --- Struct-based cache for compressed transitions (Octave-safe) ---
4053
cache = struct();
@@ -57,19 +70,25 @@
5770
end
5871
neededKeys = unique(neededKeys);
5972

60-
% Attach StateChannels for keys found in metadata
73+
% Attach StateChannels for keys found in table columns
6174
for k = 1:numel(neededKeys)
6275
key = neededKeys{k};
6376

64-
% Skip keys not in metadata (exclude doc and datenum)
65-
if ~isfield(metadataStruct, key) || ...
66-
strcmp(key, 'doc') || strcmp(key, datenumField)
77+
% Skip keys not in table
78+
if ~ismember(key, stateCols)
6779
continue;
6880
end
6981

7082
% Compress on first access, cache for reuse
7183
if ~isfield(cache, key)
72-
cache.(key) = compressTransitions(X, metadataStruct.(key));
84+
colData = metadataTable.(key);
85+
% Table columns are column vectors — transpose for row
86+
if isnumeric(colData)
87+
colData = reshape(colData, 1, []);
88+
elseif iscell(colData)
89+
colData = reshape(colData, 1, []);
90+
end
91+
cache.(key) = compressTransitions(X, colData);
7392
end
7493
cached = cache.(key);
7594

tests/suite/TestLoadModuleMetadata.m

Lines changed: 50 additions & 100 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,15 @@ function addPaths(testCase)
88
end
99

1010
methods (Static)
11-
function ms = makeMetadataStruct(stateKeys, nPoints)
12-
%MAKEMETADATASTRUCT Build a fake metadata struct for testing.
13-
ms.time_utc = linspace(datenum(2024,1,1), datenum(2024,1,2), nPoints);
11+
function t = makeMetadataTable(stateKeys, nPoints)
12+
%MAKEMETADATATABLE Build a fake metadata table for testing.
13+
Date = datetime(2024,1,1) + linspace(0, 1, nPoints)';
14+
args = {'Date', Date};
1415
for i = 1:numel(stateKeys)
15-
ms.(stateKeys{i}) = zeros(1, nPoints);
16-
ms.doc.(stateKeys{i}).name = stateKeys{i};
17-
ms.doc.(stateKeys{i}).datum = 'time_utc';
16+
args{end+1} = stateKeys{i}; %#ok<AGROW>
17+
args{end+1} = zeros(nPoints, 1); %#ok<AGROW>
1818
end
19+
t = table(args{:});
1920
end
2021

2122
function s = makeSensorWithRule(key, conditionStruct, value)
@@ -34,11 +35,11 @@ function testBasicNumericState(testCase)
3435
s = TestLoadModuleMetadata.makeSensorWithRule( ...
3536
'temp', struct('machine', 1), 50);
3637

37-
ms = TestLoadModuleMetadata.makeMetadataStruct({'machine'}, 100);
38+
t = TestLoadModuleMetadata.makeMetadataTable({'machine'}, 100);
3839
% Set state: 0 for first 50 points, 1 for last 50
39-
ms.machine(51:100) = 1;
40+
t.machine(51:100) = 1;
4041

41-
sensors = loadModuleMetadata(ms, {s});
42+
sensors = loadModuleMetadata(t, {s});
4243

4344
testCase.verifyEqual(numel(sensors), 1, 'returns_sensors');
4445
testCase.verifyEqual(numel(sensors{1}.StateChannels), 1, 'one_sc');
@@ -54,12 +55,11 @@ function testCellStringState(testCase)
5455
s = TestLoadModuleMetadata.makeSensorWithRule( ...
5556
'temp', struct('recipe', 'bake'), 80);
5657

57-
ms.doc.recipe.name = 'recipe';
58-
ms.doc.recipe.datum = 'time_utc';
59-
ms.time_utc = linspace(datenum(2024,1,1), datenum(2024,1,2), 6);
60-
ms.recipe = {'idle', 'idle', 'bake', 'bake', 'bake', 'idle'};
58+
Date = datetime(2024,1,1) + linspace(0, 1, 6)';
59+
recipe = {'idle'; 'idle'; 'bake'; 'bake'; 'bake'; 'idle'};
60+
t = table(Date, recipe);
6161

62-
sensors = loadModuleMetadata(ms, {s});
62+
sensors = loadModuleMetadata(t, {s});
6363

6464
sc = sensors{1}.StateChannels{1};
6565
testCase.verifyEqual(sc.Key, 'recipe', 'sc_key');
@@ -75,10 +75,10 @@ function testMultipleSensorsGetIndependentHandles(testCase)
7575
s2 = TestLoadModuleMetadata.makeSensorWithRule( ...
7676
'press', struct('machine', 1), 100);
7777

78-
ms = TestLoadModuleMetadata.makeMetadataStruct({'machine'}, 100);
79-
ms.machine(51:100) = 1;
78+
t = TestLoadModuleMetadata.makeMetadataTable({'machine'}, 100);
79+
t.machine(51:100) = 1;
8080

81-
sensors = loadModuleMetadata(ms, {s1, s2});
81+
sensors = loadModuleMetadata(t, {s1, s2});
8282

8383
sc1 = sensors{1}.StateChannels{1};
8484
sc2 = sensors{2}.StateChannels{1};
@@ -96,21 +96,21 @@ function testSensorWithNoRulesSkipped(testCase)
9696
s = Sensor('temp');
9797
s.X = [1 2 3]; s.Y = [4 5 6];
9898

99-
ms = TestLoadModuleMetadata.makeMetadataStruct({'machine'}, 100);
99+
t = TestLoadModuleMetadata.makeMetadataTable({'machine'}, 100);
100100

101-
sensors = loadModuleMetadata(ms, {s});
101+
sensors = loadModuleMetadata(t, {s});
102102

103103
testCase.verifyTrue(isempty(sensors{1}.StateChannels), 'no_sc');
104104
end
105105

106106
function testRuleReferencesUnknownState(testCase)
107-
% Rule references 'recipe' but metadata only has 'machine'
107+
% Rule references 'recipe' but table only has 'machine'
108108
s = TestLoadModuleMetadata.makeSensorWithRule( ...
109109
'temp', struct('recipe', 1), 50);
110110

111-
ms = TestLoadModuleMetadata.makeMetadataStruct({'machine'}, 100);
111+
t = TestLoadModuleMetadata.makeMetadataTable({'machine'}, 100);
112112

113-
sensors = loadModuleMetadata(ms, {s});
113+
sensors = loadModuleMetadata(t, {s});
114114

115115
testCase.verifyTrue(isempty(sensors{1}.StateChannels), ...
116116
'no_sc_for_unknown_key');
@@ -124,12 +124,12 @@ function testMultipleConditionFields(testCase)
124124
s.addThresholdRule(struct('machine', 1, 'recipe', 2), 50, ...
125125
'Direction', 'upper', 'Label', 'test');
126126

127-
ms = TestLoadModuleMetadata.makeMetadataStruct( ...
127+
t = TestLoadModuleMetadata.makeMetadataTable( ...
128128
{'machine', 'recipe'}, 100);
129-
ms.machine(51:100) = 1;
130-
ms.recipe(31:60) = 2;
129+
t.machine(51:100) = 1;
130+
t.recipe(31:60) = 2;
131131

132-
sensors = loadModuleMetadata(ms, {s});
132+
sensors = loadModuleMetadata(t, {s});
133133

134134
testCase.verifyEqual(numel(sensors{1}.StateChannels), 2, 'two_scs');
135135
keys = cellfun(@(c) c.Key, sensors{1}.StateChannels, ...
@@ -143,116 +143,66 @@ function testAllIdenticalValues(testCase)
143143
s = TestLoadModuleMetadata.makeSensorWithRule( ...
144144
'temp', struct('machine', 0), 50);
145145

146-
ms = TestLoadModuleMetadata.makeMetadataStruct({'machine'}, 100);
147-
% machine stays 0 everywhere (default)
146+
t = TestLoadModuleMetadata.makeMetadataTable({'machine'}, 100);
148147

149-
sensors = loadModuleMetadata(ms, {s});
148+
sensors = loadModuleMetadata(t, {s});
150149

151150
sc = sensors{1}.StateChannels{1};
152151
testCase.verifyEqual(numel(sc.X), 1, 'single_point');
153152
testCase.verifyEqual(sc.Y, 0, 'single_value');
154153
end
155154

156155
function testSinglePointMetadata(testCase)
157-
% Metadata with only one time point
156+
% Table with only one row
158157
s = TestLoadModuleMetadata.makeSensorWithRule( ...
159158
'temp', struct('machine', 1), 50);
160159

161-
ms.doc.machine.name = 'machine';
162-
ms.doc.machine.datum = 'time_utc';
163-
ms.time_utc = datenum(2024,1,1);
164-
ms.machine = 1;
160+
t = table(datetime(2024,1,1), 1, ...
161+
'VariableNames', {'Date', 'machine'});
165162

166-
sensors = loadModuleMetadata(ms, {s});
163+
sensors = loadModuleMetadata(t, {s});
167164

168165
sc = sensors{1}.StateChannels{1};
169166
testCase.verifyEqual(numel(sc.X), 1, 'single_pt_X');
170167
testCase.verifyEqual(sc.Y, 1, 'single_pt_Y');
171168
end
172169

173-
function testColumnVectorInputs(testCase)
174-
% Column vector inputs must produce row vector StateChannel
175-
s = TestLoadModuleMetadata.makeSensorWithRule( ...
176-
'temp', struct('machine', 1), 50);
177-
178-
ms.doc.machine.name = 'machine';
179-
ms.doc.machine.datum = 'time_utc';
180-
ms.time_utc = linspace(datenum(2024,1,1), datenum(2024,1,2), 6)';
181-
ms.machine = [0; 0; 1; 1; 0; 0];
182-
183-
sensors = loadModuleMetadata(ms, {s});
184-
185-
sc = sensors{1}.StateChannels{1};
186-
testCase.verifyEqual(size(sc.X, 1), 1, 'X_is_row');
187-
testCase.verifyEqual(size(sc.Y, 1), 1, 'Y_is_row');
188-
end
189-
190170
function testEmptySensors(testCase)
191-
ms = TestLoadModuleMetadata.makeMetadataStruct({'machine'}, 100);
192-
sensors = loadModuleMetadata(ms, {});
171+
t = TestLoadModuleMetadata.makeMetadataTable({'machine'}, 100);
172+
sensors = loadModuleMetadata(t, {});
193173
testCase.verifyTrue(isempty(sensors), 'empty_passthrough');
194174
end
195175

196-
function testMissingDocErrors(testCase)
197-
ms = struct('machine', [1 2 3]);
198-
threw = false;
199-
try
200-
loadModuleMetadata(ms, {});
201-
catch
202-
threw = true;
203-
end
204-
testCase.verifyTrue(threw, 'missing_doc_throws');
205-
end
206-
207-
function testDocMissingDatumErrors(testCase)
208-
% doc entry without .datum field
209-
ms.doc.machine.name = 'machine'; % no .datum
210-
ms.machine = [1 2 3];
211-
threw = false;
212-
try
213-
loadModuleMetadata(ms, {});
214-
catch
215-
threw = true;
216-
end
217-
testCase.verifyTrue(threw, 'missing_datum_throws');
218-
end
219-
220-
function testDatenumFieldNotInStructErrors(testCase)
221-
ms.doc.machine.name = 'machine';
222-
ms.doc.machine.datum = 'nonexistent';
223-
ms.machine = [1 2 3];
176+
function testNotTableErrors(testCase)
224177
threw = false;
225178
try
226-
loadModuleMetadata(ms, {});
179+
loadModuleMetadata(struct('x', 1), {});
227180
catch
228181
threw = true;
229182
end
230-
testCase.verifyTrue(threw, 'bad_datenum_ref_throws');
183+
testCase.verifyTrue(threw, 'not_table_throws');
231184
end
232185

233-
function testDatumNotCharErrors(testCase)
234-
% Defensive test: validates datum type
235-
ms.doc.machine.name = 'machine';
236-
ms.doc.machine.datum = 42;
237-
ms.machine = [1 2 3];
186+
function testMissingDateColumnErrors(testCase)
187+
t = table([1; 2; 3], 'VariableNames', {'machine'});
238188
threw = false;
239189
try
240-
loadModuleMetadata(ms, {});
190+
loadModuleMetadata(t, {});
241191
catch
242192
threw = true;
243193
end
244-
testCase.verifyTrue(threw, 'non_char_datum_throws');
194+
testCase.verifyTrue(threw, 'missing_date_throws');
245195
end
246196

247197
function testOutputRowOrientation(testCase)
248198
% StateChannel X/Y must be row vectors (1xN)
249199
s = TestLoadModuleMetadata.makeSensorWithRule( ...
250200
'temp', struct('machine', 1), 50);
251201

252-
ms = TestLoadModuleMetadata.makeMetadataStruct({'machine'}, 100);
253-
ms.machine(51:100) = 1;
202+
t = TestLoadModuleMetadata.makeMetadataTable({'machine'}, 100);
203+
t.machine(51:100) = 1;
254204

255-
sensors = loadModuleMetadata(ms, {s});
205+
sensors = loadModuleMetadata(t, {s});
256206

257207
sc = sensors{1}.StateChannels{1};
258208
testCase.verifyEqual(size(sc.X, 1), 1, 'X_is_row');
@@ -266,9 +216,9 @@ function testUnconditionalRuleNoStateChannel(testCase)
266216
s.addThresholdRule(struct(), 50, ...
267217
'Direction', 'upper', 'Label', 'always');
268218

269-
ms = TestLoadModuleMetadata.makeMetadataStruct({'machine'}, 100);
219+
t = TestLoadModuleMetadata.makeMetadataTable({'machine'}, 100);
270220

271-
sensors = loadModuleMetadata(ms, {s});
221+
sensors = loadModuleMetadata(t, {s});
272222

273223
testCase.verifyTrue(isempty(sensors{1}.StateChannels), ...
274224
'unconditional_no_sc');
@@ -279,11 +229,11 @@ function testRepeatedCallAccumulatesChannels(testCase)
279229
s = TestLoadModuleMetadata.makeSensorWithRule( ...
280230
'temp', struct('machine', 1), 50);
281231

282-
ms = TestLoadModuleMetadata.makeMetadataStruct({'machine'}, 100);
283-
ms.machine(51:100) = 1;
232+
t = TestLoadModuleMetadata.makeMetadataTable({'machine'}, 100);
233+
t.machine(51:100) = 1;
284234

285-
loadModuleMetadata(ms, {s});
286-
loadModuleMetadata(ms, {s});
235+
loadModuleMetadata(t, {s});
236+
loadModuleMetadata(t, {s});
287237

288238
testCase.verifyEqual(numel(s.StateChannels), 2, ...
289239
'duplicates_accumulated');

0 commit comments

Comments
 (0)