LINE Solver
MATLAB API documentation
Loading...
Searching...
No Matches
linemodel_load.m
1function model = linemodel_load(filename)
2% LINEMODEL_LOAD Load a LINE model from JSON.
3%
4% MODEL = LINEMODEL_LOAD(FILENAME) loads a model from the specified JSON
5% file (conforming to line-model.schema.json) and returns a Network,
6% LayeredNetwork, Workflow, or Environment object.
7%
8% Parameters:
9% filename - path to a .json file
10%
11% Returns:
12% model - Network, LayeredNetwork, Workflow, or Environment object
13%
14% Example:
15% model = linemodel_load('mm1.json');
16% solver = SolverMVA(model);
17% AvgTable = solver.getAvgTable();
18%
19% Copyright (c) 2012-2026, Imperial College London
20% All rights reserved.
21
22jsonText = fileread(filename);
23doc = jsondecode(jsonText);
24
25if ~isfield(doc, 'model')
26 error('linemodel_load:noModel', 'JSON file does not contain a "model" field.');
27end
28
29data = doc.model;
30mtype = data.type;
31
32switch mtype
33 case 'Network'
34 model = json2network(data, jsonText);
35 case 'LayeredNetwork'
36 model = json2layered(data);
37 case 'Workflow'
38 model = json2workflow(data);
39 case 'Environment'
40 model = json2environment(data, jsonText);
41 otherwise
42 error('linemodel_load:unknownType', 'Unsupported model type: %s', mtype);
43end
44end
45
46
47% =========================================================================
48% Network deserialization
49% =========================================================================
50
51function model = json2network(data, rawJson)
52% Reconstruct a Network from decoded JSON struct.
53% rawJson is the original text, used for parsing routing keys with commas.
54
55modelName = 'model';
56if isfield(data, 'name')
57 modelName = data.name;
58end
59model = Network(modelName);
60
61% --- Create nodes (before classes, since ClosedClass needs refstat) ---
62nodeList = {};
63if isfield(data, 'nodes')
64 nds = data.nodes;
65 if isstruct(nds)
66 nds = num2cell(nds);
67 end
68 for i = 1:length(nds)
69 nd = nds{i};
70 if isstruct(nd)
71 nd_name = nd.name;
72 nd_type = nd.type;
73 else
74 nd_name = nd('name');
75 nd_type = nd('type');
76 end
77 node = create_node(model, nd, nd_name, nd_type);
78 nodeList{end+1} = node; %#ok<AGROW>
79 end
80end
81node_map = containers.Map();
82for i = 1:length(nodeList)
83 node_map(nodeList{i}.name) = nodeList{i};
84end
85
86% --- Deferred node linking (Fork/Join, Fork tasksPerLink) ---
87if isfield(data, 'nodes')
88 nds2 = data.nodes;
89 if isstruct(nds2)
90 nds2 = num2cell(nds2);
91 end
92 for i = 1:length(nds2)
93 nd2 = nds2{i};
94 nd_name2 = nd2.name;
95 node2 = node_map(nd_name2);
96 % Join: link to paired Fork
97 if isfield(nd2, 'forkNode') && isa(node2, 'Join')
98 if node_map.isKey(nd2.forkNode)
99 node2.joinOf = node_map(nd2.forkNode);
100 end
101 end
102 % Fork: set tasksPerLink
103 if isfield(nd2, 'tasksPerLink') && isa(node2, 'Fork')
104 node2.setTasksPerLink(nd2.tasksPerLink);
105 end
106 end
107end
108
109% --- Create classes ---
110classesList = {};
111if isfield(data, 'classes')
112 cls = data.classes;
113 if isstruct(cls)
114 cls = num2cell(cls);
115 end
116 for i = 1:length(cls)
117 cd = cls{i};
118 cname = cd.name;
119 ctype = cd.type;
120 switch ctype
121 case 'Open'
122 prio = 0;
123 if isfield(cd, 'priority'), prio = cd.priority; end
124 jc = OpenClass(model, cname, prio);
125 case {'Closed', 'SelfLooping'}
126 pop = cd.population;
127 refNode = [];
128 if isfield(cd, 'refNode') && node_map.isKey(cd.refNode)
129 refNode = node_map(cd.refNode);
130 end
131 prio = 0;
132 if isfield(cd, 'priority'), prio = cd.priority; end
133 if isempty(refNode)
134 error('linemodel_load:noRefNode', ...
135 '%s class "%s" has no valid refNode.', ctype, cname);
136 end
137 if strcmp(ctype, 'SelfLooping')
138 jc = SelfLoopingClass(model, cname, pop, refNode, prio);
139 else
140 jc = ClosedClass(model, cname, pop, refNode, prio);
141 end
142 case 'Signal'
143 prio = 0;
144 if isfield(cd, 'priority'), prio = cd.priority; end
145 sigType = SignalType.NEGATIVE;
146 if isfield(cd, 'signalType')
147 sigType = SignalType.fromText(cd.signalType);
148 end
149 if ~isfield(cd, 'openOrClosed')
150 % Bare Signal: neither open nor closed, marked by omitting 'openOrClosed' -- see _kb/09-ldes-and-cache.md
151 jc = Signal(model, cname, sigType, prio);
152 elseif strcmp(cd.openOrClosed, 'Closed')
153 refNode = [];
154 if isfield(cd, 'refNode') && node_map.isKey(cd.refNode)
155 refNode = node_map(cd.refNode);
156 end
157 if isempty(refNode)
158 error('linemodel_load:noRefNode', ...
159 'ClosedSignal "%s" has no valid refNode.', cname);
160 end
161 jc = ClosedSignal(model, cname, sigType, refNode, prio);
162 else
163 jc = OpenSignal(model, cname, sigType, prio);
164 end
165 % Removal distribution
166 if isfield(cd, 'removalDistribution')
167 remDist = json2dist(cd.removalDistribution);
168 if ~isempty(remDist)
169 jc.setRemovalDistribution(remDist);
170 end
171 end
172 % Removal policy
173 if isfield(cd, 'removalPolicy')
174 jc.setRemovalPolicy(RemovalPolicy.fromText(cd.removalPolicy));
175 end
176 otherwise
177 jc = OpenClass(model, cname);
178 end
179 if isfield(cd, 'deadline') && isfinite(cd.deadline)
180 jc.deadline = cd.deadline;
181 end
182 if isfield(cd, 'isReferenceClass') && cd.isReferenceClass
183 jc.setReferenceClass(true);
184 end
185 % Class-level (global) patience. A node-scoped 'patience' entry, restored
186 % later, overrides this for the station it names.
187 if isfield(cd, 'patience') && ~isempty(cd.patience)
188 patDist = json2dist(cd.patience);
189 if ~isempty(patDist) && ~isa(patDist, 'Disabled')
190 if isfield(cd, 'impatienceType')
191 jc.setPatience(str_to_impatience(cd.impatienceType), patDist);
192 else
193 jc.setPatience(patDist);
194 end
195 end
196 end
197 classesList{end+1} = jc; %#ok<AGROW>
198 end
199end
200class_map = containers.Map();
201for i = 1:length(classesList)
202 class_map(classesList{i}.name) = classesList{i};
203end
204
205% --- Resolve signal targetClass associations ---
206if isfield(data, 'classes')
207 cls2 = data.classes;
208 if isstruct(cls2), cls2 = num2cell(cls2); end
209 for i = 1:length(cls2)
210 cd2 = cls2{i};
211 if isfield(cd2, 'type') && strcmp(cd2.type, 'Signal') && isfield(cd2, 'targetClass')
212 if class_map.isKey(cd2.name) && class_map.isKey(cd2.targetClass)
213 sigCls = class_map(cd2.name);
214 sigCls.forJobClass(class_map(cd2.targetClass));
215 end
216 end
217 % Reply signal binding (sn.syncreply). Resolved in a second pass because
218 % the reply class may be declared after the class that references it.
219 if isfield(cd2, 'replySignalClass') && class_map.isKey(cd2.name) ...
220 && class_map.isKey(cd2.replySignalClass)
221 class_map(cd2.name).setReplySignalClass(class_map(cd2.replySignalClass));
222 end
223 end
224end
225
226% --- Set service/arrival distributions ---
227if isfield(data, 'nodes')
228 nds = data.nodes;
229 if isstruct(nds)
230 nds = num2cell(nds);
231 end
232 for i = 1:length(nds)
233 nd = nds{i};
234 nd_name = nd.name;
235 node = node_map(nd_name);
236
237 % OI/PAS queues are parameterized by oiServiceRate below, not per-class distributions -- see _kb/09-ldes-and-cache.md
238 isOIQueue = isa(node, 'Queue') && ~isa(node, 'Delay') && ...
239 (node.schedStrategy == SchedStrategy.PAS || node.schedStrategy == SchedStrategy.OI);
240 if isfield(nd, 'service') && ~isempty(nd.service) && ~isOIQueue
241 svc = nd.service;
242 svcFields = fieldnames(svc);
243 for f = 1:length(svcFields)
244 cname = svcFields{f};
245 distJson = svc.(cname);
246 if ~class_map.isKey(cname)
247 continue;
248 end
249 jc = class_map(cname);
250 dist = json2dist(distJson);
251 if ~isempty(dist)
252 if isa(node, 'Source')
253 node.setArrival(jc, dist);
254 elseif isa(node, 'Place')
255 % setService turns the Place into a queueing Place, installing the server section from create_node's strategy
256 node.setService(jc, dist);
257 elseif isa(node, 'Queue') || isa(node, 'Delay')
258 % Pass DPS/GPS weight if available
259 weight = 1;
260 if isfield(nd, 'schedParams')
261 sp_tmp = nd.schedParams;
262 if isfield(sp_tmp, cname)
263 weight = sp_tmp.(cname);
264 end
265 end
266 node.setService(jc, dist, weight);
267 end
268 end
269 end
270 end
271
272 % Batch arrivals, applied after setArrival because setArrivalBatch
273 % validates the batch law independently of the interarrival process.
274 if isfield(nd, 'arrivalBatch') && ~isempty(nd.arrivalBatch) && isa(node, 'Source')
275 batchFields = fieldnames(nd.arrivalBatch);
276 for f = 1:numel(batchFields)
277 cname = batchFields{f};
278 if ~class_map.isKey(cname)
279 continue;
280 end
281 bdist = json2dist(nd.arrivalBatch.(cname));
282 if ~isempty(bdist)
283 node.setArrivalBatch(class_map(cname), bdist);
284 end
285 end
286 end
287
288 % Marked (MMAP) arrival: rebind the shared MarkedMAP so mark k drives the k-th listed class
289 if isfield(nd, 'markedClasses') && ~isempty(nd.markedClasses) && isa(node, 'Source')
290 markedNames = nd.markedClasses;
291 if ischar(markedNames), markedNames = {markedNames}; end
292 markedList = {};
293 for f = 1:numel(markedNames)
294 mnm = markedNames{f};
295 if class_map.isKey(mnm)
296 markedList{end+1} = class_map(mnm); %#ok<AGROW>
297 end
298 end
299 if ~isempty(markedList)
300 firstDist = node.getArrivalProcess(markedList{1}.index);
301 if isa(firstDist, 'MarkedMAP')
302 node.setMarkedArrival(firstDist, markedList);
303 end
304 end
305 end
306
307 % ClassSwitch matrix (dict format: classSwitchMatrix)
308 if isfield(nd, 'classSwitchMatrix') && isa(node, 'ClassSwitch')
309 csm_data = nd.classSwitchMatrix;
310 classes = model.getClasses();
311 K = length(classes);
312 mat = zeros(K);
313 class_idx = containers.Map();
314 for ci = 1:K
315 class_idx(classes{ci}.name) = ci;
316 end
317 fromFields = fieldnames(csm_data);
318 for fi = 1:length(fromFields)
319 fromName = fromFields{fi};
320 if ~class_idx.isKey(fromName), continue; end
321 ri = class_idx(fromName);
322 toStruct = csm_data.(fromName);
323 toFields = fieldnames(toStruct);
324 for ti = 1:length(toFields)
325 toName = toFields{ti};
326 if ~class_idx.isKey(toName), continue; end
327 ci = class_idx(toName);
328 mat(ri, ci) = toStruct.(toName);
329 end
330 end
331 node.server = node.server.updateClassSwitch(mat);
332 % Legacy 2D array format: csMatrix (from older JAR saves)
333 elseif isfield(nd, 'csMatrix') && isa(node, 'ClassSwitch')
334 mat = nd.csMatrix;
335 if iscell(mat)
336 mat = cell2mat(mat);
337 end
338 node.server = node.server.updateClassSwitch(mat);
339 end
340
341 % DPS scheduling parameters (weights already passed via setService above)
342
343 % Departure discipline read after the service loop, which creates the departureDiscipline slots
344 if isfield(nd, 'departureDiscipline') && isa(node, 'Place')
345 ddData = nd.departureDiscipline;
346 ddFields = fieldnames(ddData);
347 for ddi = 1:length(ddFields)
348 cname = ddFields{ddi};
349 if class_map.isKey(cname)
350 node.setDepartureDiscipline(class_map(cname), ...
351 str_to_depdisc(ddData.(cname)));
352 end
353 end
354 end
355
356 % Per-class buffer capacity. Station, not Queue: a queueing Place has a
357 % per-class capacity too and Place does not extend Queue.
358 if isfield(nd, 'classCap') && isa(node, 'Station')
359 ccData = nd.classCap;
360 ccFields = fieldnames(ccData);
361 for cci = 1:length(ccFields)
362 cname = ccFields{cci};
363 if class_map.isKey(cname)
364 jc = class_map(cname);
365 % Find class index
366 cls = model.getClasses();
367 for ci = 1:length(cls)
368 if strcmp(cls{ci}.name, cname)
369 node.classCap(ci) = ccData.(cname);
370 break;
371 end
372 end
373 end
374 end
375 end
376
377 % Drop rules
378 if isfield(nd, 'dropRule') && isa(node, 'Station')
379 drData = nd.dropRule;
380 drFields = fieldnames(drData);
381 for dri = 1:length(drFields)
382 cname = drFields{dri};
383 if class_map.isKey(cname)
384 cls = model.getClasses();
385 for ci = 1:length(cls)
386 if strcmp(cls{ci}.name, cname)
387 node.dropRule(ci) = str_to_droprule(drData.(cname));
388 break;
389 end
390 end
391 end
392 end
393 end
394
395 % OI/PAS mu(c) macrostate table + cutoffs (+ swap graph for PAS) -- see _kb/09-ldes-and-cache.md
396 if isfield(nd, 'oiServiceRate') && isa(node, 'Queue') && ...
397 (node.schedStrategy == SchedStrategy.PAS || node.schedStrategy == SchedStrategy.OI)
398 % OI queues keep a fixed zero swap graph (setSwapGraph rejects them).
399 if isfield(nd, 'swapGraph') && node.schedStrategy == SchedStrategy.PAS
400 node.setSwapGraph(json2mat(nd.swapGraph));
401 end
402 if isfield(nd, 'oiCutoffs')
403 oicut = round(double(oi_cutoffs_vec(nd.oiCutoffs)));
404 else
405 oicut = [];
406 end
407 node.setServiceRateFunction(oi_table_to_handle(nd.oiServiceRate, oicut));
408 end
409
410 % Load-dependent scaling
411 if isfield(nd, 'loadDependence') && isa(node, 'Queue')
412 ld = nd.loadDependence;
413 if isfield(ld, 'type') && strcmp(ld.type, 'loadDependent') && isfield(ld, 'scaling')
414 scaling = ld.scaling(:)';
415 node.setLoadDependence(scaling);
416 end
417 end
418
419 % Class-dependent scaling beta_{i,r}(n): rebuild the handle from the
420 % materialized lattice table (see cd_scaling_table in linemodel_save).
421 if isfield(nd, 'classDependence') && isa(node, 'Station')
422 cdep = nd.classDependence;
423 if isfield(cdep, 'type') && strcmp(cdep.type, 'classDependent') ...
424 && isfield(cdep, 'scaling')
425 if isfield(cdep, 'cutoffs')
426 cdcut = round(double(cdep.cutoffs(:)'));
427 else
428 cdcut = [];
429 end
430 cdHandle = cd_table_to_handle(cdep.scaling, cdcut);
431 if isfield(cdep, 'peak') && ~isempty(cdep.peak)
432 if iscell(cdep.peak)
433 cdPeak = double(cell2mat(cdep.peak));
434 else
435 cdPeak = double(cdep.peak(:)');
436 end
437 else
438 % Legacy JSON without an explicit peak: derive from the handle over the lattice, matching Util = T*S/peak
439 cdPeak = cd_peak_scaling(cdHandle, cdcut, numel(cdcut));
440 end
441 node.setLimitedClassDependence(cdHandle, cdPeak);
442 end
443 end
444
445 % Joint-dependent scaling eta_i(n) (non-product-form): rebuild the
446 % handle from the materialized lattice table, twin of classDependence.
447 if isfield(nd, 'jointDependence') && isa(node, 'Station')
448 jdep = nd.jointDependence;
449 if isfield(jdep, 'type') && strcmp(jdep.type, 'jointDependent') ...
450 && isfield(jdep, 'scaling')
451 if isfield(jdep, 'cutoffs')
452 jdcut = round(double(jdep.cutoffs(:)'));
453 else
454 jdcut = [];
455 end
456 jdHandle = cd_table_to_handle(jdep.scaling, jdcut);
457 if isfield(jdep, 'peak') && ~isempty(jdep.peak)
458 if iscell(jdep.peak)
459 jdPeak = double(cell2mat(jdep.peak));
460 else
461 jdPeak = double(jdep.peak(:)');
462 end
463 else
464 jdPeak = cd_peak_scaling(jdHandle, jdcut, numel(jdcut));
465 end
466 node.setLimitedJointDependence(jdHandle, jdPeak);
467 end
468 end
469
470 % Join strategy and quorum
471 if isa(node, 'Join')
472 if isfield(nd, 'joinStrategy')
473 jsStr = nd.joinStrategy;
474 classes = model.getClasses();
475 for ci = 1:length(classes)
476 switch jsStr
477 case 'STD'
478 node.input.setStrategy(classes{ci}, JoinStrategy.STD);
479 case {'PARTIAL', 'QUORUM', 'Quorum'}
480 node.input.setStrategy(classes{ci}, JoinStrategy.PARTIAL);
481 end
482 end
483 end
484 if isfield(nd, 'joinQuorum')
485 jq = nd.joinQuorum;
486 classes = model.getClasses();
487 for ci = 1:length(classes)
488 node.input.setRequired(classes{ci}, jq);
489 end
490 end
491 end
492
493 % Cache hit/miss/popularity: accept both nested MATLAB (nd.cache.*) and flat JAR/Python schema
494 if isa(node, 'Cache')
495 if isfield(nd, 'cache')
496 cc = nd.cache;
497 else
498 cc = struct();
499 if isfield(nd, 'hitClass'), cc.hitClass = nd.hitClass; end
500 if isfield(nd, 'missClass'), cc.missClass = nd.missClass; end
501 if isfield(nd, 'popularity'), cc.popularity = nd.popularity; end
502 if isfield(nd, 'accessGraph'), cc.accessGraph = nd.accessGraph; end
503 if isfield(nd, 'accessProb'), cc.accessProb = nd.accessProb; end
504 if isfield(nd, 'initialState'), cc.initialState = nd.initialState; end
505 end
506 % Prefer the nested copy, fall back to flat, so any of the three bridges' output loads the same way
507 if ~isfield(cc, 'admissionProb') && isfield(nd, 'admissionProb')
508 cc.admissionProb = nd.admissionProb;
509 end
510 if ~isfield(cc, 'retrievalSystem') && isfield(nd, 'retrievalSystem')
511 cc.retrievalSystem = nd.retrievalSystem;
512 end
513 % q-LRU admission probability on a miss
514 if isfield(cc, 'admissionProb')
515 node.setAdmissionProb(cc.admissionProb);
516 end
517 % Restores cache-internal bookkeeping only; classes/routing/service are rebuilt elsewhere -- see _kb/09-ldes-and-cache.md
518 if isfield(cc, 'retrievalSystem') && ~isempty(cc.retrievalSystem)
519 rs = cc.retrievalSystem;
520 if isfield(rs, 'capacity')
521 node.retrievalSystemCapacity = double(rs.capacity);
522 end
523 if isfield(rs, 'byClass')
524 bcNames = fieldnames(rs.byClass);
525 for bci = 1:length(bcNames)
526 inName = bcNames{bci};
527 if ~class_map.isKey(inName), continue; end
528 jobin = class_map(inName);
529 entry = rs.byClass.(inName);
530 if isfield(entry, 'queues')
531 qNames = cellify_string_array(entry.queues);
532 qIdx = [];
533 for qi = 1:numel(qNames)
534 if node_map.isKey(qNames{qi})
535 qIdx(end+1) = node_map(qNames{qi}).index; %#ok<AGROW>
536 end
537 end
538 node.retrievalSystemQueueIndices(int32(jobin.index - 1)) = qIdx;
539 end
540 if isfield(entry, 'items')
541 % jsondecode prefixes the 0-based item keys with 'x'
542 itNames = fieldnames(entry.items);
543 for iti = 1:length(itNames)
544 rcName = entry.items.(itNames{iti});
545 if ~class_map.isKey(rcName), continue; end
546 item0 = str2double(strrep(itNames{iti}, 'x', ''));
547 rcIdx = class_map(rcName).index;
548 node.setRetrievalClass(jobin, class_map(rcName), item0 + 1);
549 if ~any(node.retrievalClassIndices == rcIdx)
550 node.retrievalClassIndices(end+1) = rcIdx;
551 end
552 end
553 end
554 end
555 end
556 end
557 % Hit class mapping
558 if isfield(cc, 'hitClass')
559 hcData = cc.hitClass;
560 hcFields = fieldnames(hcData);
561 for hci = 1:length(hcFields)
562 inName = hcFields{hci};
563 outName = hcData.(inName);
564 if class_map.isKey(inName) && class_map.isKey(outName)
565 node.setHitClass(class_map(inName), class_map(outName));
566 end
567 end
568 end
569 % Miss class mapping
570 if isfield(cc, 'missClass')
571 mcData = cc.missClass;
572 mcFields = fieldnames(mcData);
573 for mci = 1:length(mcFields)
574 inName = mcFields{mci};
575 outName = mcData.(inName);
576 if class_map.isKey(inName) && class_map.isKey(outName)
577 node.setMissClass(class_map(inName), class_map(outName));
578 end
579 end
580 end
581 % Popularity distributions (setRead)
582 if isfield(cc, 'popularity')
583 popData = cc.popularity;
584 popFields = fieldnames(popData);
585 for pfi = 1:length(popFields)
586 cname = popFields{pfi};
587 if class_map.isKey(cname)
588 popDist = json2dist(popData.(cname));
589 if ~isempty(popDist) && ~isa(popDist, 'Disabled')
590 node.setRead(class_map(cname), popDist);
591 end
592 end
593 end
594 end
595 % Access-cost (list-move) structure: per-item graph shared by all
596 % classes, or full per-class accessProb matrices
597 if isfield(cc, 'accessGraph')
598 node.graph = json2matcell(cc.accessGraph);
599 elseif isfield(cc, 'accessProb')
600 apData = cc.accessProb;
601 if ~iscell(apData)
602 % jsondecode collapsed the uniform [class][item][r][c]
603 % nesting into a 4D numeric array; re-split per class
604 apData = arrayfun(@(k1) squeeze(apData(k1, :, :, :)), ...
605 1:size(apData, 1), 'UniformOutput', false);
606 end
607 Kap = numel(apData);
608 rows = cellfun(@json2matcell, apData, 'UniformOutput', false);
609 Nap = max(cellfun(@numel, rows));
610 R = cell(Kap, Nap);
611 for k1 = 1:Kap
612 R(k1, 1:numel(rows{k1})) = rows{k1};
613 end
614 node.setAccessProb(R);
615 end
616 % Initial cache state [class counts | contents | retrieval bitmap]
617 if isfield(cc, 'initialState')
618 node.setState(cc.initialState(:)');
619 end
620 end
621 % Heterogeneous server types
622 if isa(node, 'Queue') && isfield(nd, 'serverTypes')
623 stArr = nd.serverTypes;
624 if isstruct(stArr), stArr = num2cell(stArr); end
625 for si = 1:length(stArr)
626 stData = stArr{si};
627 stName = stData.name;
628 stCount = stData.count;
629 st = ServerType(stName, stCount);
630 % Compatible classes
631 if isfield(stData, 'compatibleClasses')
632 ccList = stData.compatibleClasses;
633 if ~iscell(ccList), ccList = {ccList}; end
634 for cci = 1:length(ccList)
635 if class_map.isKey(ccList{cci})
636 st.addCompatible(class_map(ccList{cci}));
637 end
638 end
639 end
640 node.addServerType(st);
641 % Per-class service distributions
642 if isfield(stData, 'service')
643 svcData = stData.service;
644 svcFields = fieldnames(svcData);
645 for fi = 1:length(svcFields)
646 cname = svcFields{fi};
647 if class_map.isKey(cname)
648 jc = class_map(cname);
649 dist = json2dist(svcData.(cname));
650 if ~isempty(dist)
651 node.setHeteroService(jc, st, dist);
652 end
653 end
654 end
655 end
656 end
657 % Scheduling policy
658 if isfield(nd, 'heteroSchedPolicy')
659 policy = HeteroSchedPolicy.fromText(nd.heteroSchedPolicy);
660 node.setHeteroSchedPolicy(policy);
661 end
662 end
663 end
664end
665
666% --- Restore Balking, Retrial, Patience ---
667if isfield(data, 'nodes')
668 ndsImp = data.nodes;
669 if isstruct(ndsImp), ndsImp = num2cell(ndsImp); end
670 for i = 1:length(ndsImp)
671 ndImp = ndsImp{i};
672 if ~node_map.isKey(ndImp.name), continue; end
673 node = node_map(ndImp.name);
674 % Assigned directly, not via setStatePrior: its node.space length check cannot pass before the solver builds the state space
675 if isfield(ndImp, 'statePrior') && isa(node, 'StatefulNode')
676 node.statePrior = double(ndImp.statePrior(:));
677 end
678 if ~isa(node, 'Queue'), continue; end
679 % Immediate feedback on self-loops, per class
680 if isfield(ndImp, 'immediateFeedback') && ~isempty(ndImp.immediateFeedback)
681 ifData = ndImp.immediateFeedback;
682 ifNames = fieldnames(ifData);
683 for fi = 1:length(ifNames)
684 cname = ifNames{fi};
685 if class_map.isKey(cname) && ifData.(cname)
686 node.setImmediateFeedback(class_map(cname));
687 end
688 end
689 end
690 % Orbit impatience (abandonment from the retrial orbit), per class
691 if isfield(ndImp, 'orbitImpatience') && ~isempty(ndImp.orbitImpatience)
692 orbData = ndImp.orbitImpatience;
693 orbNames = fieldnames(orbData);
694 for fi = 1:length(orbNames)
695 cname = orbNames{fi};
696 if ~class_map.isKey(cname), continue; end
697 orbDist = json2dist(orbData.(cname));
698 if ~isempty(orbDist) && ~isa(orbDist, 'Disabled')
699 node.setOrbitImpatience(class_map(cname), orbDist);
700 end
701 end
702 end
703 % Batch rejection probability (retrial queues), per class
704 if isfield(ndImp, 'batchRejectProb') && ~isempty(ndImp.batchRejectProb)
705 brpData = ndImp.batchRejectProb;
706 brpNames = fieldnames(brpData);
707 for fi = 1:length(brpNames)
708 cname = brpNames{fi};
709 if ~class_map.isKey(cname), continue; end
710 node.setBatchRejectProbability(class_map(cname), brpData.(cname));
711 end
712 end
713 % Balking
714 if isfield(ndImp, 'balking') && ~isempty(ndImp.balking)
715 balkData = ndImp.balking;
716 fnames = fieldnames(balkData);
717 for fi = 1:length(fnames)
718 className = fnames{fi};
719 if ~class_map.isKey(className), continue; end
720 jc = class_map(className);
721 bjc = balkData.(className);
722 % Parse strategy
723 switch bjc.strategy
724 case 'QUEUE_LENGTH', strategy = BalkingStrategy.QUEUE_LENGTH;
725 case 'EXPECTED_WAIT', strategy = BalkingStrategy.EXPECTED_WAIT;
726 case 'COMBINED', strategy = BalkingStrategy.COMBINED;
727 otherwise, continue;
728 end
729 % Parse thresholds
730 thData = bjc.thresholds;
731 if isstruct(thData), thData = num2cell(thData); end
732 thresholds = {};
733 for ti = 1:length(thData)
734 td = thData{ti};
735 maxJobs = td.maxJobs;
736 if maxJobs < 0, maxJobs = Inf; end
737 thresholds{end+1} = {td.minJobs, maxJobs, td.probability};
738 end
739 node.setBalking(jc, strategy, thresholds);
740 end
741 end
742 % Retrial
743 if isfield(ndImp, 'retrial') && ~isempty(ndImp.retrial)
744 retData = ndImp.retrial;
745 fnames = fieldnames(retData);
746 for fi = 1:length(fnames)
747 className = fnames{fi};
748 if ~class_map.isKey(className), continue; end
749 jc = class_map(className);
750 rjc = retData.(className);
751 delayDist = json2dist(rjc.delay);
752 maxAttempts = -1;
753 if isfield(rjc, 'maxAttempts')
754 maxAttempts = rjc.maxAttempts;
755 end
756 node.setRetrial(jc, delayDist, maxAttempts);
757 end
758 end
759 % Patience
760 if isfield(ndImp, 'patience') && ~isempty(ndImp.patience)
761 patData = ndImp.patience;
762 fnames = fieldnames(patData);
763 for fi = 1:length(fnames)
764 className = fnames{fi};
765 if ~class_map.isKey(className), continue; end
766 jc = class_map(className);
767 pjc = patData.(className);
768 patDist = json2dist(pjc.distribution);
769 if isfield(pjc, 'impatienceType')
770 impType = str_to_impatience(pjc.impatienceType);
771 else
772 impType = ImpatienceType.RENEGING;
773 end
774 node.setPatience(jc, impType, patDist);
775 end
776 end
777 end
778end
779
780% --- Configure Transition modes ---
781if isfield(data, 'nodes')
782 nds3 = data.nodes;
783 if isstruct(nds3)
784 nds3 = num2cell(nds3);
785 end
786 for i = 1:length(nds3)
787 nd3 = nds3{i};
788 if ~isfield(nd3, 'modes'), continue; end
789 if ~strcmp(nd3.type, 'Transition'), continue; end
790 tnode = node_map(nd3.name);
791 modesData = nd3.modes;
792 if isstruct(modesData)
793 modesData = num2cell(modesData);
794 end
795 for mi = 1:length(modesData)
796 md = modesData{mi};
797 modeName = 'Mode';
798 if isfield(md, 'name'), modeName = md.name; end
799 mode = tnode.addMode(modeName);
800 % Distribution
801 if isfield(md, 'distribution') && ~isempty(md.distribution)
802 dist = json2dist(md.distribution);
803 if ~isempty(dist)
804 tnode.setDistribution(mode, dist);
805 end
806 end
807 % Timing strategy
808 if isfield(md, 'timingStrategy')
809 if strcmp(md.timingStrategy, 'IMMEDIATE')
810 tnode.setTimingStrategy(mode, TimingStrategy.IMMEDIATE);
811 else
812 tnode.setTimingStrategy(mode, TimingStrategy.TIMED);
813 end
814 end
815 % Number of servers
816 if isfield(md, 'numServers')
817 nsVal = md.numServers;
818 if ischar(nsVal) || isstring(nsVal)
819 if strcmpi(nsVal, 'Infinity'), nsVal = Inf; else, nsVal = str2double(nsVal); end
820 end
821 if nsVal > 1
822 tnode.setNumberOfServers(mode, nsVal);
823 end
824 end
825 % Firing priority
826 if isfield(md, 'firingPriority')
827 tnode.setFiringPriorities(mode, md.firingPriority);
828 end
829 % Firing weight
830 if isfield(md, 'firingWeight')
831 tnode.setFiringWeights(mode, md.firingWeight);
832 end
833 % Enabling conditions
834 if isfield(md, 'enablingConditions')
835 ecList = md.enablingConditions;
836 if isstruct(ecList), ecList = num2cell(ecList); end
837 for ei = 1:length(ecList)
838 ec = ecList{ei};
839 if node_map.isKey(ec.node) && class_map.isKey(ec.class)
840 tnode.setEnablingConditions(mode, class_map(ec.class), node_map(ec.node), ec.count);
841 end
842 end
843 end
844 % Inhibiting conditions
845 if isfield(md, 'inhibitingConditions')
846 icList = md.inhibitingConditions;
847 if isstruct(icList), icList = num2cell(icList); end
848 for ii = 1:length(icList)
849 ic = icList{ii};
850 if node_map.isKey(ic.node) && class_map.isKey(ic.class)
851 tnode.setInhibitingConditions(mode, class_map(ic.class), node_map(ic.node), ic.count);
852 end
853 end
854 end
855 % Firing outcomes
856 if isfield(md, 'firingOutcomes')
857 foList = md.firingOutcomes;
858 if isstruct(foList), foList = num2cell(foList); end
859 for fi = 1:length(foList)
860 fo = foList{fi};
861 if node_map.isKey(fo.node) && class_map.isKey(fo.class)
862 tnode.setFiringOutcome(mode, class_map(fo.class), node_map(fo.node), fo.count);
863 end
864 end
865 end
866 % Marking-dependent firing-rate multiplier (after enabling/timing/
867 % distribution are set so the setter guard sees the final mode state).
868 if isfield(md, 'firingRateDependence') && ~isempty(md.firingRateDependence)
869 g = firingdep_table_to_handle(md.firingRateDependence, node_map, class_map);
870 if ~isempty(g)
871 tnode.setFiringRateDependence(mode, g);
872 end
873 end
874 end
875 end
876end
877
878% --- Restore initial state for Place nodes ---
879if isfield(data, 'nodes')
880 nds4 = data.nodes;
881 if isstruct(nds4), nds4 = num2cell(nds4); end
882 for i = 1:length(nds4)
883 nd4 = nds4{i};
884 if isfield(nd4, 'initialState') && node_map.isKey(nd4.name)
885 nodeObj = node_map(nd4.name);
886 if isa(nodeObj, 'Place')
887 stVal = nd4.initialState;
888 stVal = stVal(:)'; % Ensure row vector (jsondecode returns column vectors)
889 nodeObj.setState(stVal);
890 end
891 end
892 end
893end
894
895% --- Build routing ---
896if isfield(data, 'routing') && isfield(data.routing, 'type') && strcmp(data.routing.type, 'matrix')
897 P = model.initRoutingMatrix();
898 K = length(classesList);
899 M = length(nodeList);
900
901 % Build node/class index maps
902 nodeIdx = containers.Map();
903 for i = 1:M
904 nodeIdx(nodeList{i}.name) = i;
905 end
906 classIdx = containers.Map();
907 for i = 1:K
908 classIdx(classesList{i}.name) = i;
909 end
910
911 % Parse routing keys from raw JSON to preserve commas
912 routingEntries = parse_routing_keys(rawJson, class_map, node_map);
913
914 for e = 1:length(routingEntries)
915 re = routingEntries{e};
916 r = classIdx(re.className1);
917 s = classIdx(re.className2);
918 ii = nodeIdx(re.fromNode);
919 jj = nodeIdx(re.toNode);
920 P{r,s}(ii, jj) = re.prob;
921 end
922
923 model.link(P);
924end
925
926% --- Restore routing strategies ---
927if isfield(data, 'routingStrategies')
928 stratMap = containers.Map();
929 stratMap('RAND') = RoutingStrategy.RAND;
930 stratMap('RROBIN') = RoutingStrategy.RROBIN;
931 stratMap('WRROBIN') = RoutingStrategy.WRROBIN;
932 stratMap('JSQ') = RoutingStrategy.JSQ;
933 stratMap('SQ') = RoutingStrategy.SQ;
934 stratMap('FIRING') = RoutingStrategy.FIRING;
935 stratMap('RL') = RoutingStrategy.RL;
936 stratMap('DISABLED') = RoutingStrategy.DISABLED;
937
938 rsFields = fieldnames(data.routingStrategies);
939 for fi = 1:length(rsFields)
940 nodeName = rsFields{fi};
941 if node_map.isKey(nodeName)
942 nodeObj = node_map(nodeName);
943 classStrats = data.routingStrategies.(nodeName);
944 csFields = fieldnames(classStrats);
945 for ci = 1:length(csFields)
946 className = csFields{ci};
947 stratName = classStrats.(className);
948 if class_map.isKey(className) && stratMap.isKey(stratName)
949 % Skip RAND, PROB: already handled by routing matrix
950 % Skip WRROBIN: handled separately in routingWeights section
951 rs = stratMap(stratName);
952 if rs ~= RoutingStrategy.RAND && rs ~= RoutingStrategy.PROB && rs ~= RoutingStrategy.WRROBIN
953 nodeObj.setRouting(class_map(className), rs);
954 end
955 end
956 end
957 end
958 end
959end
960
961% --- Restore routing weights (WRROBIN) ---
962if isfield(data, 'routingWeights')
963 rwFields = fieldnames(data.routingWeights);
964 for fi = 1:length(rwFields)
965 nodeName = rwFields{fi};
966 if node_map.isKey(nodeName)
967 nodeObj = node_map(nodeName);
968 classWeights = data.routingWeights.(nodeName);
969 cwFields = fieldnames(classWeights);
970 for ci = 1:length(cwFields)
971 className = cwFields{ci};
972 destWeights = classWeights.(className);
973 if class_map.isKey(className)
974 % Clear existing routing entries for this class
975 % (link() may have set PROB entries that would accumulate)
976 classIdx = class_map(className).index;
977 if length(nodeObj.output.outputStrategy) >= classIdx && ...
978 length(nodeObj.output.outputStrategy{1, classIdx}) >= 3
979 nodeObj.output.outputStrategy{1, classIdx}{3} = {};
980 end
981 dwFields = fieldnames(destWeights);
982 for di = 1:length(dwFields)
983 destName = dwFields{di};
984 weight = destWeights.(destName);
985 if node_map.isKey(destName)
986 nodeObj.setRouting(class_map(className), RoutingStrategy.WRROBIN, node_map(destName), weight);
987 end
988 end
989 end
990 end
991 end
992 end
993end
994
995% --- Restore setup / delay-off, polling type and switchover times ---
996% jsondecode yields a cell array when node objects carry different field sets; normalized to cells here -- see _kb/12-interfaces-and-docs.md
997ndsSo = data.nodes;
998if isstruct(ndsSo), ndsSo = num2cell(ndsSo); end
999for ni = 1:length(ndsSo)
1000 nd = ndsSo{ni};
1001 if ~node_map.isKey(nd.name)
1002 continue;
1003 end
1004
1005 % Setup / delay-off. The writer emits the two maps together, keyed by
1006 % class name, because setDelayOff requires both distributions.
1007 if isfield(nd, 'setupTime') && ~isempty(nd.setupTime) && ...
1008 isfield(nd, 'delayOffTime') && ~isempty(nd.delayOffTime)
1009 nodeObj = node_map(nd.name);
1010 suNames = fieldnames(nd.setupTime);
1011 for fi = 1:length(suNames)
1012 cname = suNames{fi};
1013 if ~class_map.isKey(cname) || ~isfield(nd.delayOffTime, cname)
1014 continue;
1015 end
1016 suDist = json2dist(nd.setupTime.(cname));
1017 doffDist = json2dist(nd.delayOffTime.(cname));
1018 if ~isempty(suDist) && ~isempty(doffDist)
1019 nodeObj.setDelayOff(class_map(cname), suDist, doffDist);
1020 end
1021 end
1022 end
1023
1024 % Polling type restored by name, must precede switchover restore (setPollingType resets it to Immediate)
1025 if isfield(nd, 'pollingType') && ~isempty(nd.pollingType)
1026 nodeObj = node_map(nd.name);
1027 ptId = PollingType.fromName(nd.pollingType);
1028 if ptId == PollingType.KLIMITED
1029 if isfield(nd, 'pollingPar') && ~isempty(nd.pollingPar)
1030 nodeObj.setPollingType(ptId, nd.pollingPar);
1031 else
1032 nodeObj.setPollingType(ptId, 1);
1033 end
1034 else
1035 nodeObj.setPollingType(ptId);
1036 end
1037 end
1038
1039 % Switchover times: entries without a "to" field carry the per-class
1040 % polling form, entries with one the (from,to) pair form.
1041 if isfield(nd, 'switchoverTimes') && ~isempty(nd.switchoverTimes)
1042 nodeObj = node_map(nd.name);
1043 soArr = nd.switchoverTimes;
1044 if ~iscell(soArr)
1045 soArr = num2cell(soArr);
1046 end
1047 for si = 1:length(soArr)
1048 so = soArr{si};
1049 if ~class_map.isKey(so.from)
1050 continue;
1051 end
1052 fromCls = class_map(so.from);
1053 dist = json2dist(so.distribution);
1054 if isempty(dist)
1055 continue;
1056 end
1057 if isfield(so, 'to') && ~isempty(so.to)
1058 if ~class_map.isKey(so.to)
1059 continue;
1060 end
1061 nodeObj.setSwitchover(fromCls, class_map(so.to), dist);
1062 else
1063 nodeObj.setSwitchover(fromCls, dist);
1064 end
1065 end
1066 end
1067end
1068
1069% --- Restore finite capacity regions ---
1070if isfield(data, 'finiteCapacityRegions')
1071 fcrArr = data.finiteCapacityRegions;
1072 if ~iscell(fcrArr)
1073 fcrArr = {fcrArr};
1074 end
1075 classes = model.getClasses();
1076 for ri = 1:length(fcrArr)
1077 rj = fcrArr{ri};
1078 regNodes = {};
1079 % Support both old ("nodes" list) and new ("stations" array) format;
1080 % jsondecode yields a STRUCT ARRAY here, so multi-station regions need stArr(si) indexing -- see _kb/12-interfaces-and-docs.md
1081 if isfield(rj, 'stations')
1082 stArr = rj.stations;
1083 if iscell(stArr)
1084 for si = 1:length(stArr)
1085 nodeName = stArr{si}.node;
1086 if node_map.isKey(nodeName)
1087 regNodes{end+1} = node_map(nodeName); %#ok<AGROW>
1088 end
1089 end
1090 else
1091 for si = 1:length(stArr)
1092 nodeName = stArr(si).node;
1093 if node_map.isKey(nodeName)
1094 regNodes{end+1} = node_map(nodeName); %#ok<AGROW>
1095 end
1096 end
1097 end
1098 elseif isfield(rj, 'nodes')
1099 nodeNames = rj.nodes;
1100 if ~iscell(nodeNames), nodeNames = {nodeNames}; end
1101 for ni = 1:length(nodeNames)
1102 if node_map.isKey(nodeNames{ni})
1103 regNodes{end+1} = node_map(nodeNames{ni}); %#ok<AGROW>
1104 end
1105 end
1106 end
1107 maxJobs = FiniteCapacityRegion.UNBOUNDED;
1108 if isfield(rj, 'globalMaxJobs')
1109 maxJobs = rj.globalMaxJobs;
1110 end
1111 if ~isempty(regNodes)
1112 try
1113 region = model.addRegion(regNodes);
1114 if isfield(rj, 'name') && ~isempty(rj.name)
1115 region.setName(rj.name);
1116 end
1117 if maxJobs ~= FiniteCapacityRegion.UNBOUNDED
1118 region.setGlobalMaxJobs(maxJobs);
1119 end
1120 % globalMaxMemory
1121 if isfield(rj, 'globalMaxMemory')
1122 region.globalMaxMemory = rj.globalMaxMemory;
1123 end
1124 % classMaxJobs
1125 if isfield(rj, 'classMaxJobs')
1126 cmj = rj.classMaxJobs;
1127 cmjFields = fieldnames(cmj);
1128 for ci = 1:length(cmjFields)
1129 cname = cmjFields{ci};
1130 if class_map.isKey(cname)
1131 jc = class_map(cname);
1132 region.classMaxJobs(jc.index) = cmj.(cname);
1133 end
1134 end
1135 end
1136 % dropRule
1137 if isfield(rj, 'dropRule')
1138 drData = rj.dropRule;
1139 drFields = fieldnames(drData);
1140 for di = 1:length(drFields)
1141 cname = drFields{di};
1142 if class_map.isKey(cname)
1143 jc = class_map(cname);
1144 region.dropRule(jc.index) = str_to_droprule(drData.(cname));
1145 end
1146 end
1147 end
1148 % Per-station classWeight and classSize from stations array
1149 if isfield(rj, 'stations')
1150 stArr2 = rj.stations;
1151 if ~iscell(stArr2), stArr2 = {stArr2}; end
1152 for si = 1:length(stArr2)
1153 sj2 = stArr2{si};
1154 if isfield(sj2, 'classWeight')
1155 cwData = sj2.classWeight;
1156 cwFields = fieldnames(cwData);
1157 for ci = 1:length(cwFields)
1158 cname = cwFields{ci};
1159 if class_map.isKey(cname)
1160 jc = class_map(cname);
1161 region.classWeight(jc.index) = cwData.(cname);
1162 end
1163 end
1164 end
1165 if isfield(sj2, 'classSize')
1166 csData = sj2.classSize;
1167 csFields = fieldnames(csData);
1168 for ci = 1:length(csFields)
1169 cname = csFields{ci};
1170 if class_map.isKey(cname)
1171 jc = class_map(cname);
1172 region.classSize(jc.index) = csData.(cname);
1173 end
1174 end
1175 end
1176 end
1177 end
1178 % Linear constraints A * x <= b
1179 if isfield(rj, 'constraintA') && isfield(rj, 'constraintB')
1180 Adata = rj.constraintA;
1181 bdata = rj.constraintB;
1182 if iscell(Adata)
1183 nrows = length(Adata);
1184 K = length(region.classes);
1185 A = zeros(nrows, K);
1186 for ar = 1:nrows
1187 row = Adata{ar};
1188 A(ar, 1:length(row)) = row(:)';
1189 end
1190 else
1191 A = Adata;
1192 end
1193 region.setConstraint(A, bdata(:));
1194 end
1195 catch
1196 end
1197 end
1198 end
1199end
1200
1201% --- Rewards ---
1202if isfield(data, 'rewards')
1203 rewardsArr = data.rewards;
1204 if isstruct(rewardsArr), rewardsArr = num2cell(rewardsArr); end
1205 for i = 1:length(rewardsArr)
1206 rw = rewardsArr{i};
1207 if ~isfield(rw, 'name') || ~isfield(rw, 'type')
1208 line_warning(mfilename, 'Ignoring a reward entry without a "name" or "type" field.');
1209 continue;
1210 end
1211 rname = rw.name;
1212 rtype = rw.type;
1213 if ~isfield(rw, 'node') || isempty(rw.node) || ~node_map.isKey(rw.node)
1214 line_warning(mfilename, sprintf(['Reward "%s" refers to node "%s", which is not defined in this ' ...
1215 'model; the reward is ignored.'], rname, char(getfield_default(rw, 'node', ''))));
1216 continue;
1217 end
1218 rnode = node_map(rw.node);
1219 rclass = [];
1220 if isfield(rw, 'class') && ~isempty(rw.class)
1221 if ~class_map.isKey(rw.class)
1222 line_warning(mfilename, sprintf(['Reward "%s" refers to class "%s", which is not defined in ' ...
1223 'this model; the reward is ignored.'], rname, rw.class));
1224 continue;
1225 end
1226 rclass = class_map(rw.class);
1227 end
1228 switch rtype
1229 case 'QLen'
1230 if isempty(rclass)
1231 model.setReward(rname, Reward.queueLength(rnode));
1232 else
1233 model.setReward(rname, Reward.queueLength(rnode, rclass));
1234 end
1235 case 'Util'
1236 if isempty(rclass)
1237 model.setReward(rname, Reward.utilization(rnode));
1238 else
1239 model.setReward(rname, Reward.utilization(rnode, rclass));
1240 end
1241 case 'Blocking'
1242 model.setReward(rname, Reward.blocking(rnode));
1243 otherwise
1244 line_warning(mfilename, sprintf(['Reward "%s" has type "%s", for which no reward template is ' ...
1245 'implemented; the reward is ignored.'], rname, rtype));
1246 end
1247 end
1248end
1249end
1250
1251
1252function v = getfield_default(s, fieldName, defaultValue)
1253% Return s.(fieldName) when present, otherwise defaultValue.
1254if isfield(s, fieldName)
1255 v = s.(fieldName);
1256else
1257 v = defaultValue;
1258end
1259end
1260
1261
1262function node = create_node(model, nd, name, ntype)
1263% Create a node from JSON data.
1264switch ntype
1265 case 'Source'
1266 node = Source(model, name);
1267 case 'Sink'
1268 node = Sink(model, name);
1269 case 'Delay'
1270 node = Delay(model, name);
1271 case 'Queue'
1272 schedStr = 'FCFS';
1273 if isfield(nd, 'scheduling')
1274 schedStr = nd.scheduling;
1275 end
1276 schedId = str_to_sched_id(schedStr);
1277 node = Queue(model, name, schedId);
1278 if isfield(nd, 'servers')
1279 ns = servers_from_json(nd.servers);
1280 if isinf(ns) || ns > 1
1281 node.setNumberOfServers(ns);
1282 end
1283 end
1284 if isfield(nd, 'buffer') && isfinite(nd.buffer)
1285 node.cap = nd.buffer;
1286 end
1287 case 'Fork'
1288 node = Fork(model, name);
1289 case 'Join'
1290 node = Join(model, name);
1291 case 'Router'
1292 node = Router(model, name);
1293 case 'ClassSwitch'
1294 node = ClassSwitch(model, name);
1295 case 'Cache'
1296 % Accept both nested MATLAB (nd.cache.items/capacity/replacement) and flat JAR/Python (nd.numItems/...) schema
1297 cc = struct();
1298 if isfield(nd, 'cache')
1299 cc = nd.cache;
1300 end
1301 nitems = 10;
1302 if isfield(cc, 'items'), nitems = cc.items;
1303 elseif isfield(nd, 'numItems'), nitems = nd.numItems; end
1304 cap = 1;
1305 if isfield(cc, 'capacity'), cap = cc.capacity;
1306 elseif isfield(nd, 'itemLevelCap'), cap = nd.itemLevelCap(:)'; end
1307 replStr = 'LRU';
1308 if isfield(cc, 'replacement'), replStr = cc.replacement;
1309 elseif isfield(nd, 'replacementStrategy'), replStr = nd.replacementStrategy; end
1310 replId = str_to_repl_id(replStr);
1311 node = Cache(model, name, nitems, cap, replId);
1312 case 'Place'
1313 % Scheduling strategy supplied to the ctor: installQueueServer (first setService) picks the server section from it
1314 if isfield(nd, 'scheduling')
1315 node = Place(model, name, str_to_sched_id(nd.scheduling));
1316 else
1317 node = Place(model, name);
1318 end
1319 if isfield(nd, 'servers')
1320 node.numberOfServers = servers_from_json(nd.servers);
1321 end
1322 if isfield(nd, 'buffer') && isfinite(nd.buffer)
1323 node.cap = nd.buffer;
1324 end
1325 case 'Transition'
1326 node = Transition(model, name);
1327 otherwise
1328 node = Queue(model, name, SchedStrategy.FCFS);
1329end
1330end
1331
1332
1333function ns = servers_from_json(v)
1334% Decode a server count. Inf crosses the wire as the string "Infinity" (the form
1335% Place numServers already used); a numeric value is taken verbatim.
1336if ischar(v) || isstring(v)
1337 if strcmpi(v, 'Infinity')
1338 ns = Inf;
1339 else
1340 ns = str2double(v);
1341 end
1342else
1343 ns = double(v);
1344end
1345end
1346
1347
1348% =========================================================================
1349% LayeredNetwork deserialization
1350% =========================================================================
1351
1352function mult = mult_from_json(v)
1353% Decode a JSON multiplicity, mapping the infinite-multiplicity sentinel
1354% (Java's Integer.MAX_VALUE, as written by the JAR and by linemodel_save) back
1355% to Inf. Negative values are also treated as infinite: earlier versions of the
1356% Python writer emitted -1 for infinite-server hosts, so files in that format
1357% must keep loading rather than silently becoming single-server stations.
1358if isempty(v)
1359 mult = 1;
1360elseif v >= 2147483647 || v < 0
1361 mult = Inf;
1362else
1363 mult = v;
1364end
1365end
1366
1367function model = json2layered(data)
1368% Reconstruct a LayeredNetwork from decoded JSON struct.
1369
1370modelName = 'model';
1371if isfield(data, 'name')
1372 modelName = data.name;
1373end
1374model = LayeredNetwork(modelName);
1375
1376% --- Processors (Python schema: "processors", JAR schema: "hosts") ---
1377proc_map = containers.Map();
1378if isfield(data, 'processors')
1379 procs = data.processors;
1380elseif isfield(data, 'hosts')
1381 procs = data.hosts;
1382else
1383 procs = [];
1384end
1385if ~isempty(procs)
1386 if isstruct(procs), procs = num2cell(procs); end
1387 for i = 1:length(procs)
1388 pd = procs{i};
1389 pname = pd.name;
1390 mult = 1;
1391 if isfield(pd, 'multiplicity'), mult = mult_from_json(pd.multiplicity); end
1392 schedStr = 'INF';
1393 if isfield(pd, 'scheduling'), schedStr = pd.scheduling; end
1394 schedId = str_to_sched_id(schedStr);
1395 quantum = 0.001;
1396 if isfield(pd, 'quantum'), quantum = pd.quantum; end
1397 sf = 1.0;
1398 if isfield(pd, 'speedFactor'), sf = pd.speedFactor; end
1399 proc = Host(model, pname, mult, schedId, quantum, sf);
1400 if isfield(pd, 'replication') && pd.replication > 1
1401 proc.setReplication(pd.replication);
1402 end
1403 proc_map(pname) = proc;
1404 end
1405end
1406
1407% --- Tasks ---
1408task_map = containers.Map();
1409if isfield(data, 'tasks')
1410 tsks = data.tasks;
1411 if isstruct(tsks), tsks = num2cell(tsks); end
1412 for i = 1:length(tsks)
1413 td = tsks{i};
1414 tname = td.name;
1415 mult = 1;
1416 if isfield(td, 'multiplicity'), mult = mult_from_json(td.multiplicity); end
1417 schedStr = 'INF';
1418 if isfield(td, 'scheduling'), schedStr = td.scheduling; end
1419 schedId = str_to_sched_id(schedStr);
1420 taskType = 'Task';
1421 if isfield(td, 'taskType'), taskType = td.taskType; end
1422 if strcmp(taskType, 'FunctionTask')
1423 task = FunctionTask(model, tname, mult, schedId);
1424 elseif strcmp(taskType, 'CacheTask')
1425 totalItems = 1;
1426 if isfield(td, 'totalItems'), totalItems = td.totalItems; end
1427 cacheCap = 1;
1428 if isfield(td, 'cacheCapacity'), cacheCap = td.cacheCapacity; end
1429 rsStr = 'FIFO';
1430 if isfield(td, 'replacementStrategy'), rsStr = td.replacementStrategy; end
1431 rsMap = containers.Map({'RR','FIFO','SFIFO','LRU'}, ...
1432 {ReplacementStrategy.RR, ReplacementStrategy.FIFO, ...
1433 ReplacementStrategy.SFIFO, ReplacementStrategy.LRU});
1434 if rsMap.isKey(upper(rsStr))
1435 rs = rsMap(upper(rsStr));
1436 else
1437 rs = ReplacementStrategy.FIFO;
1438 end
1439 task = CacheTask(model, tname, totalItems, cacheCap, rs, mult, schedId);
1440 else
1441 task = Task(model, tname, mult, schedId);
1442 end
1443 % Assign to processor (Python schema: "processor", JAR schema: "host")
1444 procRef = '';
1445 if isfield(td, 'processor'), procRef = td.processor;
1446 elseif isfield(td, 'host'), procRef = td.host;
1447 end
1448 if ~isempty(procRef) && proc_map.isKey(procRef)
1449 task.on(proc_map(procRef));
1450 end
1451 % Think time (Python schema: "thinkTime" as dist, JAR schema: "thinkTimeMean"/"thinkTimeSCV")
1452 if isfield(td, 'thinkTime')
1453 dist = json2dist(td.thinkTime);
1454 if ~isempty(dist)
1455 task.setThinkTime(dist);
1456 end
1457 elseif isfield(td, 'thinkTimeMean') && td.thinkTimeMean > 0
1458 task.setThinkTime(Exp(1.0 / td.thinkTimeMean));
1459 end
1460 % Setup time
1461 if isfield(td, 'setupTime')
1462 dist = json2dist(td.setupTime);
1463 if ~isempty(dist)
1464 task.setSetupTime(dist);
1465 end
1466 elseif isfield(td, 'setupTimeMean') && td.setupTimeMean > 1e-8
1467 task.setSetupTime(Exp(1.0 / td.setupTimeMean));
1468 end
1469 % Delay-off time
1470 if isfield(td, 'delayOffTime')
1471 dist = json2dist(td.delayOffTime);
1472 if ~isempty(dist)
1473 task.setDelayOffTime(dist);
1474 end
1475 elseif isfield(td, 'delayOffTimeMean') && td.delayOffTimeMean > 1e-8
1476 task.setDelayOffTime(Exp(1.0 / td.delayOffTimeMean));
1477 end
1478 % Fan in
1479 if isfield(td, 'fanIn') && isstruct(td.fanIn)
1480 fnames = fieldnames(td.fanIn);
1481 for fi = 1:length(fnames)
1482 task.setFanIn(fnames{fi}, td.fanIn.(fnames{fi}));
1483 end
1484 end
1485 % Fan out
1486 if isfield(td, 'fanOut') && isstruct(td.fanOut)
1487 fnames = fieldnames(td.fanOut);
1488 for fi = 1:length(fnames)
1489 task.setFanOut(fnames{fi}, td.fanOut.(fnames{fi}));
1490 end
1491 end
1492 % Replication
1493 if isfield(td, 'replication') && td.replication > 1
1494 task.setReplication(td.replication);
1495 end
1496 task_map(tname) = task;
1497 end
1498end
1499
1500% --- Entries ---
1501entry_map = containers.Map();
1502if isfield(data, 'entries')
1503 ents = data.entries;
1504 if isstruct(ents), ents = num2cell(ents); end
1505 for i = 1:length(ents)
1506 ed = ents{i};
1507 ename = ed.name;
1508 entryType = 'Entry';
1509 if isfield(ed, 'entryType'), entryType = ed.entryType; end
1510 if strcmp(entryType, 'ItemEntry')
1511 totalItems = 1;
1512 if isfield(ed, 'totalItems'), totalItems = ed.totalItems; end
1513 accessProb = [];
1514 if isfield(ed, 'accessProb')
1515 ap = ed.accessProb;
1516 if isstruct(ap)
1517 accessProb = json2dist(ap);
1518 elseif isnumeric(ap)
1519 accessProb = DiscreteSampler(ap);
1520 end
1521 end
1522 if isempty(accessProb)
1523 % Default uniform distribution
1524 accessProb = DiscreteSampler(ones(1, totalItems) / totalItems);
1525 end
1526 entry = ItemEntry(model, ename, totalItems, accessProb);
1527 else
1528 entry = Entry(model, ename);
1529 end
1530 if isfield(ed, 'task') && task_map.isKey(ed.task)
1531 entry.on(task_map(ed.task));
1532 end
1533 % Entry arrival distribution
1534 if isfield(ed, 'arrival')
1535 dist = json2dist(ed.arrival);
1536 if ~isempty(dist)
1537 entry.setArrival(dist);
1538 end
1539 end
1540 entry_map(ename) = entry;
1541 end
1542end
1543
1544% --- Activities ---
1545act_map = containers.Map();
1546if isfield(data, 'activities')
1547 acts = data.activities;
1548 if isstruct(acts), acts = num2cell(acts); end
1549 for i = 1:length(acts)
1550 ad = acts{i};
1551 aname = ad.name;
1552
1553 % Host demand
1554 hd = GlobalConstants.FineTol;
1555 if isfield(ad, 'hostDemand')
1556 hdDist = json2dist(ad.hostDemand);
1557 if ~isempty(hdDist)
1558 hd = hdDist;
1559 end
1560 end
1561
1562 % Bound to entry (Python schema: "boundTo", JAR schema: "boundToEntry")
1563 bte = '';
1564 if isfield(ad, 'boundTo')
1565 bte = ad.boundTo;
1566 elseif isfield(ad, 'boundToEntry')
1567 bte = ad.boundToEntry;
1568 end
1569
1570 act = Activity(model, aname, hd, bte);
1571
1572 % Assign to task
1573 if isfield(ad, 'task') && task_map.isKey(ad.task)
1574 act.on(task_map(ad.task));
1575 end
1576
1577 % Replies to entry
1578 if isfield(ad, 'repliesTo') && entry_map.isKey(ad.repliesTo)
1579 act.repliesTo(entry_map(ad.repliesTo));
1580 end
1581
1582 % Synch calls (Python schema: "entry", JAR schema: "dest")
1583 if isfield(ad, 'synchCalls')
1584 scs = ad.synchCalls;
1585 if isstruct(scs), scs = num2cell(scs); end
1586 for j = 1:length(scs)
1587 sc = scs{j};
1588 if isfield(sc, 'entry'), ename = sc.entry;
1589 elseif isfield(sc, 'dest'), ename = sc.dest;
1590 else, continue;
1591 end
1592 meanCalls = 1.0;
1593 if isfield(sc, 'mean'), meanCalls = sc.mean; end
1594 if entry_map.isKey(ename)
1595 act.synchCall(entry_map(ename), meanCalls);
1596 end
1597 end
1598 end
1599
1600 % Asynch calls (Python schema: "entry", JAR schema: "dest")
1601 if isfield(ad, 'asynchCalls')
1602 acs = ad.asynchCalls;
1603 if isstruct(acs), acs = num2cell(acs); end
1604 for j = 1:length(acs)
1605 ac = acs{j};
1606 if isfield(ac, 'entry'), ename = ac.entry;
1607 elseif isfield(ac, 'dest'), ename = ac.dest;
1608 else, continue;
1609 end
1610 meanCalls = 1.0;
1611 if isfield(ac, 'mean'), meanCalls = ac.mean; end
1612 if entry_map.isKey(ename)
1613 act.asynchCall(entry_map(ename), meanCalls);
1614 end
1615 end
1616 end
1617
1618 act_map(aname) = act;
1619 end
1620end
1621
1622% --- Precedences (Python schema: "type"/"activities", JAR schema: "preActs"/"postActs"/"preType"/"postType") ---
1623if isfield(data, 'precedences')
1624 precs = data.precedences;
1625 if isstruct(precs), precs = num2cell(precs); end
1626 for i = 1:length(precs)
1627 pd = precs{i};
1628 if ~isfield(pd, 'task') || ~task_map.isKey(pd.task)
1629 continue;
1630 end
1631 task = task_map(pd.task);
1632
1633 if isfield(pd, 'preActs') || isfield(pd, 'postActs')
1634 % JAR schema
1635 preNames = {};
1636 postNames = {};
1637 if isfield(pd, 'preActs')
1638 preNames = pd.preActs;
1639 if ischar(preNames), preNames = {preNames}; end
1640 end
1641 if isfield(pd, 'postActs')
1642 postNames = pd.postActs;
1643 if ischar(postNames), postNames = {postNames}; end
1644 end
1645 preType = 'pre';
1646 postType = 'post';
1647 if isfield(pd, 'preType'), preType = pd.preType; end
1648 if isfield(pd, 'postType'), postType = pd.postType; end
1649
1650 % Normalize JAR naming convention to Python convention
1651 switch postType
1652 case 'post-AND', postType = 'and-fork';
1653 case 'post-OR', postType = 'or-fork';
1654 case 'post-LOOP', postType = 'loop';
1655 end
1656 switch preType
1657 case 'pre-AND', preType = 'and-join';
1658 case 'pre-OR', preType = 'or-join';
1659 end
1660
1661 % Extract postParams (JAR schema: probabilities/loopCount)
1662 postParams = [];
1663 if isfield(pd, 'postParams')
1664 postParams = pd.postParams;
1665 if iscell(postParams), postParams = cell2mat(postParams); end
1666 end
1667
1668 preActs = {};
1669 for ai = 1:length(preNames)
1670 if act_map.isKey(preNames{ai})
1671 preActs{end+1} = act_map(preNames{ai}); %#ok<AGROW>
1672 end
1673 end
1674 postActs = {};
1675 for ai = 1:length(postNames)
1676 if act_map.isKey(postNames{ai})
1677 postActs{end+1} = act_map(postNames{ai}); %#ok<AGROW>
1678 end
1679 end
1680
1681 if strcmp(preType, 'pre') && strcmp(postType, 'post')
1682 if length(preActs) == 1 && length(postActs) == 1
1683 ap = ActivityPrecedence.Serial(preActs{1}, postActs{1});
1684 task.addPrecedence(ap);
1685 end
1686 elseif strcmp(preType, 'pre') && strcmp(postType, 'and-fork')
1687 if ~isempty(preActs) && ~isempty(postActs)
1688 ap = ActivityPrecedence.AndFork(preActs{1}, postActs);
1689 task.addPrecedence(ap);
1690 end
1691 elseif strcmp(preType, 'and-join') && strcmp(postType, 'post')
1692 if ~isempty(preActs) && ~isempty(postActs)
1693 ap = ActivityPrecedence.AndJoin(preActs, postActs{1});
1694 task.addPrecedence(ap);
1695 end
1696 elseif strcmp(preType, 'pre') && strcmp(postType, 'or-fork')
1697 if ~isempty(preActs) && ~isempty(postActs)
1698 probs = [];
1699 if isfield(pd, 'probabilities')
1700 probs = pd.probabilities;
1701 if isstruct(probs), probs = cell2mat(struct2cell(probs)); end
1702 end
1703 if isempty(probs) && ~isempty(postParams)
1704 probs = postParams(:)';
1705 end
1706 if isempty(probs)
1707 n = length(postActs);
1708 probs = ones(1, n) / n;
1709 end
1710 ap = ActivityPrecedence.OrFork(preActs{1}, postActs, probs);
1711 task.addPrecedence(ap);
1712 end
1713 elseif strcmp(preType, 'or-join') && strcmp(postType, 'post')
1714 if ~isempty(preActs) && ~isempty(postActs)
1715 ap = ActivityPrecedence.OrJoin(preActs, postActs{1});
1716 task.addPrecedence(ap);
1717 end
1718 elseif strcmp(preType, 'pre') && strcmp(postType, 'loop')
1719 count = 1.0;
1720 if isfield(pd, 'loopCount'), count = pd.loopCount; end
1721 if count == 1.0 && ~isempty(postParams)
1722 count = postParams(1);
1723 end
1724 if ~isempty(preActs) && ~isempty(postActs)
1725 if length(postActs) > 1
1726 ap = ActivityPrecedence.Loop(preActs{1}, postActs(1:end-1), postActs{end}, count);
1727 else
1728 ap = ActivityPrecedence.Loop(preActs{1}, postActs, count);
1729 end
1730 task.addPrecedence(ap);
1731 end
1732 elseif strcmp(preType, 'pre') && strcmp(postType, 'post-CACHE')
1733 if ~isempty(preActs) && ~isempty(postActs)
1734 ap = ActivityPrecedence.CacheAccess(preActs{1}, postActs);
1735 task.addPrecedence(ap);
1736 end
1737 end
1738 else
1739 % Python schema
1740 ptype = pd.type;
1741 actNames = pd.activities;
1742 if ischar(actNames), actNames = {actNames}; end
1743
1744 % Resolve activity names to objects
1745 actObjs = {};
1746 for ai = 1:length(actNames)
1747 an = actNames{ai};
1748 if act_map.isKey(an)
1749 actObjs{end+1} = act_map(an); %#ok<AGROW>
1750 end
1751 end
1752 % A Loop's trigger is separate ('preActivity'), so its body may be a single activity -- see _kb/04-networkstruct.md
1753 isLoopWithPre = strcmp(ptype, 'Loop') && isfield(pd, 'preActivity') ...
1754 && act_map.isKey(pd.preActivity) && ~isempty(actObjs);
1755 if length(actObjs) < 2 && ~isLoopWithPre
1756 continue;
1757 end
1758
1759 switch ptype
1760 case 'Serial'
1761 ap = ActivityPrecedence.Serial(actObjs{:});
1762 task.addPrecedence(ap);
1763 case 'AndFork'
1764 ap = ActivityPrecedence.AndFork(actObjs{1}, actObjs(2:end));
1765 task.addPrecedence(ap);
1766 case 'AndJoin'
1767 ap = ActivityPrecedence.AndJoin(actObjs(1:end-1), actObjs{end});
1768 task.addPrecedence(ap);
1769 case 'OrFork'
1770 probs = [];
1771 if isfield(pd, 'probabilities')
1772 probs = pd.probabilities;
1773 if isstruct(probs), probs = cell2mat(struct2cell(probs)); end
1774 end
1775 if isempty(probs)
1776 n = length(actObjs) - 1;
1777 probs = ones(1, n) / n;
1778 end
1779 ap = ActivityPrecedence.OrFork(actObjs{1}, actObjs(2:end), probs);
1780 task.addPrecedence(ap);
1781 case 'OrJoin'
1782 ap = ActivityPrecedence.OrJoin(actObjs(1:end-1), actObjs{end});
1783 task.addPrecedence(ap);
1784 case 'Loop'
1785 count = 1.0;
1786 if isfield(pd, 'loopCount'), count = pd.loopCount; end
1787 % Check for explicit preActivity field (new format)
1788 if isfield(pd, 'preActivity') && act_map.isKey(pd.preActivity)
1789 preAct = act_map(pd.preActivity);
1790 ap = ActivityPrecedence.Loop(preAct, actObjs, count);
1791 elseif length(actObjs) >= 3
1792 % Legacy format: first is pre, rest is body+end
1793 ap = ActivityPrecedence.Loop(actObjs{1}, actObjs(2:end-1), actObjs{end}, count);
1794 else
1795 ap = ActivityPrecedence.Loop(actObjs{1}, actObjs(2:end), count);
1796 end
1797 task.addPrecedence(ap);
1798 case 'CacheAccess'
1799 if length(actObjs) >= 2
1800 ap = ActivityPrecedence.CacheAccess(actObjs{1}, actObjs(2:end));
1801 task.addPrecedence(ap);
1802 end
1803 end
1804 end
1805 end
1806end
1807end
1808
1809
1810% =========================================================================
1811% Workflow deserialization
1812% =========================================================================
1813
1814function model = json2workflow(data)
1815% Reconstruct a Workflow from decoded JSON struct.
1816
1817modelName = 'workflow';
1818if isfield(data, 'name')
1819 modelName = data.name;
1820end
1821model = Workflow(modelName);
1822
1823% --- Activities ---
1824if isfield(data, 'activities')
1825 acts = data.activities;
1826 if isstruct(acts), acts = num2cell(acts); end
1827 for i = 1:length(acts)
1828 ad = acts{i};
1829 actName = ad.name;
1830 if isfield(ad, 'hostDemand') && ~isempty(ad.hostDemand)
1831 dist = json2dist(ad.hostDemand);
1832 model.addActivity(actName, dist);
1833 else
1834 model.addActivity(actName, 1.0);
1835 end
1836 end
1837end
1838
1839% --- Precedences ---
1840if isfield(data, 'precedences')
1841 precs = data.precedences;
1842 if isstruct(precs), precs = num2cell(precs); end
1843 for i = 1:length(precs)
1844 pd = precs{i};
1845
1846 % preActs
1847 if isfield(pd, 'preActs')
1848 preActs = cellify_string_array(pd.preActs);
1849 else
1850 preActs = {};
1851 end
1852
1853 % postActs
1854 if isfield(pd, 'postActs')
1855 postActs = cellify_string_array(pd.postActs);
1856 else
1857 postActs = {};
1858 end
1859
1860 % preType / postType - convert JAR strings to numeric IDs
1861 preType = ActivityPrecedenceType.PRE_SEQ;
1862 if isfield(pd, 'preType')
1863 preType = str_to_prectype(pd.preType);
1864 end
1865 postType = ActivityPrecedenceType.POST_SEQ;
1866 if isfield(pd, 'postType')
1867 postType = str_to_prectype(pd.postType);
1868 end
1869
1870 % preParams / postParams
1871 preParams = [];
1872 if isfield(pd, 'preParams') && ~isempty(pd.preParams)
1873 preParams = pd.preParams(:)';
1874 end
1875 postParams = [];
1876 if isfield(pd, 'postParams') && ~isempty(pd.postParams)
1877 postParams = pd.postParams(:)';
1878 end
1879
1880 ap = ActivityPrecedence(preActs, postActs, preType, postType, preParams, postParams);
1881 model.addPrecedence(ap);
1882 end
1883end
1884end
1885
1886
1887% =========================================================================
1888% Environment deserialization
1889% =========================================================================
1890
1891function model = json2environment(data, rawJson)
1892% Reconstruct an Environment from decoded JSON struct.
1893
1894modelName = 'env';
1895if isfield(data, 'name')
1896 modelName = data.name;
1897end
1898numStages = 0;
1899if isfield(data, 'numStages')
1900 numStages = data.numStages;
1901end
1902model = Environment(modelName, numStages);
1903
1904% --- Node failures ---
1905% "nodeFailures" has two roles depending on whether DOWN_<node> stages are already declared -- see _kb/09-ldes-and-cache.md
1906nfArr = {};
1907if isfield(data, 'nodeFailures')
1908 nfArr = data.nodeFailures;
1909 if isstruct(nfArr), nfArr = num2cell(nfArr); end
1910end
1911
1912stages = {};
1913if isfield(data, 'stages')
1914 stages = data.stages;
1915 if isstruct(stages), stages = num2cell(stages); end
1916end
1917
1918declaredNames = cell(1, length(stages));
1919for i = 1:length(stages)
1920 if isfield(stages{i}, 'name')
1921 declaredNames{i} = stages{i}.name;
1922 else
1923 declaredNames{i} = sprintf('Stage%d', i);
1924 end
1925end
1926
1927% Expand the macro form only when no DOWN stage is declared for any entry.
1928macroMode = ~isempty(nfArr);
1929for k = 1:length(nfArr)
1930 if ~isfield(nfArr{k}, 'node')
1931 line_error(mfilename, 'A "nodeFailures" entry is missing the required "node" field.');
1932 end
1933 if any(strcmp(declaredNames, sprintf('DOWN_%s', nfArr{k}.node)))
1934 macroMode = false;
1935 break;
1936 end
1937end
1938
1939if macroMode
1940 if length(stages) ~= 1
1941 line_error(mfilename, ['"nodeFailures" expands the base model into the UP and DOWN_<node> stages, ' ...
1942 'so "stages" must declare exactly one stage, holding the base (UP) model.']);
1943 end
1944 if isfield(data, 'transitions') && ~isempty(data.transitions)
1945 line_error(mfilename, ['"nodeFailures" implies the breakdown and repair transitions; "transitions" ' ...
1946 'must not be declared alongside it.']);
1947 end
1948 if ~isfield(stages{1}, 'model') || isempty(stages{1}.model)
1949 line_error(mfilename, '"nodeFailures" requires the base stage to carry a "model".');
1950 end
1951 baseModel = json2network(stages{1}.model, rawJson);
1952 for k = 1:length(nfArr)
1953 nf = nfArr{k};
1954 [breakdownDist, repairDist, downServiceDist, resetB, resetR] = nodefailure_fields(nf);
1955 if isempty(repairDist)
1956 model.addNodeBreakdown(baseModel, nf.node, breakdownDist, downServiceDist, resetB);
1957 else
1958 model.addNodeFailureRepair(baseModel, nf.node, breakdownDist, repairDist, downServiceDist, ...
1959 resetB, resetR);
1960 end
1961 end
1962else
1963 % --- Stages ---
1964 stageNames = {};
1965 for i = 1:length(stages)
1966 sd = stages{i};
1967 stageName = declaredNames{i};
1968 stageNames{end+1} = stageName; %#ok<AGROW>
1969
1970 stageType = '';
1971 if isfield(sd, 'type')
1972 stageType = sd.type;
1973 end
1974
1975 stageModel = [];
1976 if isfield(sd, 'model') && ~isempty(sd.model)
1977 stageModel = json2network(sd.model, rawJson);
1978 end
1979
1980 if ~isempty(stageModel)
1981 model.addStage(stageName, stageType, stageModel);
1982 end
1983 end
1984
1985 % --- Transitions ---
1986 if isfield(data, 'transitions')
1987 trans = data.transitions;
1988 if isstruct(trans), trans = num2cell(trans); end
1989 for i = 1:length(trans)
1990 td = trans{i};
1991 fromIdx = td.from + 1; % Convert from 0-indexed (JAR) to 1-indexed (MATLAB)
1992 toIdx = td.to + 1; % Convert from 0-indexed (JAR) to 1-indexed (MATLAB)
1993 if isfield(td, 'distribution') && ~isempty(td.distribution)
1994 dist = json2dist(td.distribution);
1995 if ~isempty(dist) && ~isa(dist, 'Disabled')
1996 % Use stage names for MATLAB Environment API
1997 if fromIdx <= length(stageNames) && toIdx <= length(stageNames)
1998 model.addTransition(stageNames{fromIdx}, stageNames{toIdx}, dist);
1999 end
2000 end
2001 end
2002 end
2003 end
2004
2005 % Re-attach the node-failure descriptors and their reset policies to the
2006 % stages just built, so that the environment serializes back identically.
2007 for k = 1:length(nfArr)
2008 nf = nfArr{k};
2009 [breakdownDist, repairDist, downServiceDist, resetB, resetR] = nodefailure_fields(nf);
2010 model.registerNodeFailure(nf.node, breakdownDist, repairDist, downServiceDist, resetB, resetR);
2011 end
2012end
2013
2014model.init();
2015end
2016
2017
2018function [breakdownDist, repairDist, downServiceDist, resetB, resetR] = nodefailure_fields(nf)
2019% Decode the distributions and reset policies of one "nodeFailures" entry.
2020% Note that breakdownRate/repairRate carry full distributions, not scalar rates.
2021if ~isfield(nf, 'breakdownRate') || isempty(nf.breakdownRate)
2022 line_error(mfilename, sprintf('Node failure on "%s" is missing the required "breakdownRate" field.', nf.node));
2023end
2024if ~isfield(nf, 'downService') || isempty(nf.downService)
2025 line_error(mfilename, sprintf('Node failure on "%s" is missing the required "downService" field.', nf.node));
2026end
2027breakdownDist = json2dist(nf.breakdownRate);
2028downServiceDist = json2dist(nf.downService);
2029repairDist = [];
2030if isfield(nf, 'repairRate') && ~isempty(nf.repairRate)
2031 repairDist = json2dist(nf.repairRate);
2032end
2033resetB = 'keep';
2034if isfield(nf, 'breakdownResetPolicy') && ~isempty(nf.breakdownResetPolicy)
2035 resetB = nf.breakdownResetPolicy;
2036end
2037resetR = 'keep';
2038if isfield(nf, 'repairResetPolicy') && ~isempty(nf.repairResetPolicy)
2039 resetR = nf.repairResetPolicy;
2040end
2041end
2042
2043
2044% =========================================================================
2045% Distribution deserialization
2046% =========================================================================
2047
2048function dist = json2dist(d)
2049% Convert a JSON distribution struct to a MATLAB Distribution object.
2050if isempty(d)
2051 dist = [];
2052 return;
2053end
2054
2055dtype = d.type;
2056
2057switch dtype
2058 case 'Disabled'
2059 dist = Disabled.getInstance();
2060 return;
2061 case 'Immediate'
2062 dist = Immediate.getInstance();
2063 return;
2064 case 'Expolynomial'
2065 % Nested object (the density is a Sirio expression string); "Inf" carries
2066 % an unbounded latest firing time.
2067 ep = d.expolynomial;
2068 if ischar(ep.lft) || isstring(ep.lft)
2069 lft = Inf;
2070 else
2071 lft = double(ep.lft);
2072 end
2073 dist = Expolynomial(ep.density, double(ep.eft), lft);
2074 return;
2075end
2076
2077% Direct params
2078if isfield(d, 'params') && ~isempty(d.params)
2079 p = d.params;
2080 switch dtype
2081 case 'Exp'
2082 % 'lambda' is canonical; 'rate' is accepted as a read-side alias
2083 % (the manual's published LQN example uses it, and Python honours it).
2084 if isfield(p, 'lambda')
2085 lam = p.lambda;
2086 elseif isfield(p, 'rate')
2087 lam = p.rate;
2088 else
2089 line_error(mfilename, 'Exp distribution has neither "lambda" nor "rate".');
2090 end
2091 dist = Exp(lam);
2092 return;
2093 case 'Det'
2094 dist = Det(p.value);
2095 return;
2096 case 'Erlang'
2097 dist = Erlang(p.lambda, p.k);
2098 return;
2099 case 'HyperExp'
2100 % Two wire forms: 2-phase (scalar p) and n-phase (vector p,lambda) -- see _kb/09-ldes-and-cache.md
2101 pv = p.p(:)';
2102 lv = p.lambda(:)';
2103 if isscalar(pv)
2104 dist = HyperExp(pv, lv(1), lv(2));
2105 elseif numel(pv) == 2 && numel(lv) == 2
2106 dist = HyperExp(pv(1), lv(1), lv(2));
2107 else
2108 if numel(pv) ~= numel(lv)
2109 line_error(mfilename, sprintf(...
2110 'HyperExp has %d probabilities but %d rates.', numel(pv), numel(lv)));
2111 end
2112 dist = HyperExp(pv, lv);
2113 end
2114 return;
2115 case 'Gamma'
2116 dist = Gamma(p.alpha, p.beta);
2117 return;
2118 case 'Lognormal'
2119 dist = Lognormal(p.mu, p.sigma);
2120 return;
2121 case 'Uniform'
2122 dist = Uniform(p.a, p.b);
2123 return;
2124 case 'Zipf'
2125 dist = Zipf(p.s, p.n);
2126 return;
2127 case 'Pareto'
2128 dist = Pareto(p.alpha, p.scale);
2129 return;
2130 case 'Weibull'
2131 % JSON: alpha = scale (getParam(1)), beta = shape (getParam(2))
2132 % Constructor: Weibull(shape, scale)
2133 dist = Weibull(p.beta, p.alpha);
2134 return;
2135 case 'Normal'
2136 dist = Normal(p.mu, p.sigma);
2137 return;
2138 case 'Geometric'
2139 dist = Geometric(p.p);
2140 return;
2141 case 'Binomial'
2142 dist = Binomial(p.n, p.p);
2143 return;
2144 case 'Poisson'
2145 dist = Poisson(p.lambda);
2146 return;
2147 case 'Bernoulli'
2148 dist = Bernoulli(p.p);
2149 return;
2150 case 'DiscreteUniform'
2151 dist = DiscreteUniform(p.min, p.max);
2152 return;
2153 case 'DiscreteSampler'
2154 pv = p.p(:)';
2155 xv = p.x(:)';
2156 dist = DiscreteSampler(pv, xv);
2157 return;
2158 case 'Coxian'
2159 mu = p.mu(:)';
2160 phi = p.phi(:)';
2161 dist = Coxian(mu, phi);
2162 return;
2163 case 'MMPP2'
2164 dist = MMPP2(p.lambda0, p.lambda1, p.sigma0, p.sigma1);
2165 return;
2166 case 'MMDP2'
2167 dist = MMDP2(p.r0, p.r1, p.sigma0, p.sigma1);
2168 return;
2169 case 'NHPP'
2170 % Absent 'cyclic' means cyclic, matching the constructor default.
2171 if isfield(p, 'cyclic')
2172 cyc = logical(p.cyclic);
2173 else
2174 cyc = true;
2175 end
2176 dist = NHPP(p.breakpoints(:)', p.rates(:)', cyc);
2177 return;
2178 case 'ME'
2179 dist = ME(p.alpha(:)', json2mat(p.A));
2180 return;
2181 case 'RAP'
2182 dist = RAP(json2mat(p.H0), json2mat(p.H1));
2183 return;
2184 case 'DMAP'
2185 dist = DMAP(json2mat(p.D0), json2mat(p.D1));
2186 return;
2187 case 'BMAP'
2188 % D = {D0, D1, ..., Dk}, Dk driving batches of size k
2189 dist = BMAP(json2matcell(p.D));
2190 return;
2191 case 'MarkedMMPP'
2192 % D = {D0, D11, ..., D1K}; the ctor rebuilds the aggregate D1 from
2193 % the K == length(D)-1 form
2194 Dcell = json2matcell(p.D);
2195 if isfield(p, 'K')
2196 Kmarks = p.K;
2197 else
2198 Kmarks = numel(Dcell) - 1;
2199 end
2200 dist = MarkedMMPP(Dcell, Kmarks);
2201 return;
2202 case 'EmpiricalCDF'
2203 dist = EmpiricalCDF(p.x(:), p.F(:));
2204 return;
2205 case 'Replayer'
2206 % Try to load from file first
2207 if isfield(p, 'fileName') && exist(p.fileName, 'file') == 2
2208 dist = Replayer(p.fileName);
2209 return;
2210 end
2211 % Fallback to APH if available
2212 if isfield(d, 'ph') && ~isempty(d.ph)
2213 ph = d.ph;
2214 alpha = ph.alpha;
2215 T = ph.T;
2216 if ~isvector(alpha), alpha = alpha(:)'; end
2217 dist = PH(alpha, T);
2218 return;
2219 end
2220 % Fallback to Exp with stored mean
2221 m = 1.0;
2222 if isfield(p, 'mean'), m = p.mean; end
2223 dist = Exp(1.0 / m);
2224 return;
2225 end
2226end
2227
2228% Prior distribution (mixture of alternatives with prior probabilities)
2229if strcmp(dtype, 'Prior')
2230 if isfield(d, 'distributions') && isfield(d, 'probabilities')
2231 altJsons = d.distributions;
2232 probs = d.probabilities;
2233 if ~iscell(altJsons)
2234 % jsondecode may return struct array instead of cell
2235 altJsons = num2cell(altJsons);
2236 end
2237 alts = cell(1, length(altJsons));
2238 for ai = 1:length(altJsons)
2239 alts{ai} = json2dist(altJsons{ai});
2240 end
2241 probs = probs(:)';
2242 dist = Prior(alts, probs);
2243 return;
2244 end
2245end
2246
2247% PH/APH representation
2248if isfield(d, 'ph') && ~isempty(d.ph)
2249 ph = d.ph;
2250 alpha = ph.alpha;
2251 T = ph.T;
2252 alpha = alpha(:)'; % Ensure row vector (jsondecode returns column vectors)
2253 if strcmp(dtype, 'APH')
2254 dist = APH(alpha, T);
2255 else
2256 dist = PH(alpha, T);
2257 end
2258 return;
2259end
2260
2261% MAP representation
2262if isfield(d, 'map') && ~isempty(d.map)
2263 mapSpec = d.map;
2264 D0 = mapSpec.D0;
2265 D1 = mapSpec.D1;
2266 dist = MAP(D0, D1);
2267 return;
2268end
2269
2270% Marked MAP representation: {D0, per-mark D1k}; the aggregate D1 is
2271% rebuilt by the MarkedMAP constructor (K == length(D)-1 form)
2272if isfield(d, 'mmap') && ~isempty(d.mmap)
2273 mmapSpec = d.mmap;
2274 D0 = mmapSpec.D0;
2275 d1k = mmapSpec.D1k;
2276 if isnumeric(d1k)
2277 % jsondecode collapses equally-sized matrices into an ND array:
2278 % (K x n x n) with marks along the first dimension
2279 Kmarks = size(d1k, 1);
2280 Dcell = cell(1, 1 + Kmarks);
2281 Dcell{1} = D0;
2282 for km = 1:Kmarks
2283 Dcell{1+km} = squeeze(d1k(km, :, :));
2284 end
2285 else
2286 if ~iscell(d1k), d1k = num2cell(d1k); end
2287 Dcell = [{D0}, d1k(:)'];
2288 end
2289 dist = MarkedMAP(Dcell, numel(Dcell)-1);
2290 return;
2291end
2292
2293% Fit specification
2294if isfield(d, 'fit') && ~isempty(d.fit)
2295 fit = d.fit;
2296 method = fit.method;
2297 switch method
2298 case 'fitMean'
2299 m = fit.mean;
2300 switch dtype
2301 case 'Exp'
2302 dist = Exp(1.0 / m);
2303 case 'Det'
2304 dist = Det(m);
2305 otherwise
2306 dist = Exp(1.0 / m);
2307 end
2308 return;
2309 case 'fitMeanAndSCV'
2310 m = fit.mean;
2311 scv = fit.scv;
2312 switch dtype
2313 case 'Erlang'
2314 dist = Erlang.fitMeanAndSCV(m, scv);
2315 case 'HyperExp'
2316 dist = HyperExp.fitMeanAndSCV(m, scv);
2317 otherwise
2318 dist = Exp(1.0 / m);
2319 end
2320 return;
2321 case 'fitMeanAndOrder'
2322 m = fit.mean;
2323 order = fit.order;
2324 switch dtype
2325 case 'Erlang'
2326 dist = Erlang.fitMeanAndOrder(m, order);
2327 otherwise
2328 dist = Exp(1.0 / m);
2329 end
2330 return;
2331 end
2332end
2333
2334% Unrecognized type: rebuild an APH matching the writer's fallback mean/SCV, warn -- see _kb/09-ldes-and-cache.md
2335if isfield(d, 'params') && isfield(d.params, 'mean') && isfield(d.params, 'scv')
2336 line_warning(mfilename, sprintf(['Distribution type "%s" is not supported on load; ' ...
2337 'reconstructing an APH fitted to its mean and SCV.\n'], dtype));
2338 dist = APH.fitMeanAndSCV(d.params.mean, d.params.scv);
2339 return;
2340end
2341if isfield(d, 'params') && isfield(d.params, 'mean')
2342 line_warning(mfilename, sprintf(['Distribution type "%s" is not supported on load and ' ...
2343 'carries no SCV; reconstructing an Exp with its mean.\n'], dtype));
2344 dist = Exp(1.0 / d.params.mean);
2345 return;
2346end
2347line_error(mfilename, sprintf(['Distribution type "%s" is not supported on load and carries ' ...
2348 'no moments to fit.'], dtype));
2349end
2350
2351
2352% =========================================================================
2353% Routing parser (handles comma keys in JSON)
2354% =========================================================================
2355
2356function entries = parse_routing_keys(rawJson, class_map, node_map)
2357% Parse routing matrix from raw JSON text to handle keys with commas.
2358% Returns a cell array of structs with fields:
2359% className1, className2, fromNode, toNode, prob
2360entries = {};
2361
2362% Build reverse mapping: jsondecode-sanitized name -> original node name
2363% jsondecode uses matlab.lang.makeValidName which replaces spaces etc.
2364nodeNames = node_map.keys();
2365sanitized_map = containers.Map();
2366for ni = 1:length(nodeNames)
2367 origName = nodeNames{ni};
2368 sanitized = matlab.lang.makeValidName(origName);
2369 sanitized_map(sanitized) = origName;
2370end
2371
2372classNames = class_map.keys();
2373
2374% For each pair of class names, try to find the corresponding key in the JSON
2375for ri = 1:length(classNames)
2376 for si = 1:length(classNames)
2377 cn1 = classNames{ri};
2378 cn2 = classNames{si};
2379 keyStr = ['"', cn1, ',', cn2, '"'];
2380
2381 % Find this key in the raw JSON
2382 pos = strfind(rawJson, keyStr);
2383 if isempty(pos)
2384 continue;
2385 end
2386
2387 % For each occurrence, extract the nested from -> to -> prob structure
2388 for pidx = 1:length(pos)
2389 startPos = pos(pidx) + length(keyStr);
2390 % Skip whitespace and colon
2391 idx = startPos;
2392 while idx <= length(rawJson) && (rawJson(idx) == ' ' || rawJson(idx) == ':' || rawJson(idx) == newline || rawJson(idx) == char(13) || rawJson(idx) == char(9))
2393 idx = idx + 1;
2394 end
2395 if idx > length(rawJson) || rawJson(idx) ~= '{'
2396 continue;
2397 end
2398 % Extract the JSON object using brace counting
2399 objStr = extract_json_object(rawJson, idx);
2400 if isempty(objStr)
2401 continue;
2402 end
2403 % Parse the from -> to -> prob structure
2404 try
2405 fromTo = jsondecode(objStr);
2406 fromNames = fieldnames(fromTo);
2407 for fi = 1:length(fromNames)
2408 fromField = fromNames{fi};
2409 toStruct = fromTo.(fromField);
2410 toNames = fieldnames(toStruct);
2411 % Resolve sanitized field names back to original node names
2412 if sanitized_map.isKey(fromField)
2413 fromName = sanitized_map(fromField);
2414 else
2415 fromName = fromField;
2416 end
2417 for ti = 1:length(toNames)
2418 toField = toNames{ti};
2419 prob = toStruct.(toField);
2420 if sanitized_map.isKey(toField)
2421 toName = sanitized_map(toField);
2422 else
2423 toName = toField;
2424 end
2425 % Verify names exist in the model
2426 if node_map.isKey(fromName) && node_map.isKey(toName)
2427 re = struct();
2428 re.className1 = cn1;
2429 re.className2 = cn2;
2430 re.fromNode = fromName;
2431 re.toNode = toName;
2432 re.prob = prob;
2433 entries{end+1} = re; %#ok<AGROW>
2434 end
2435 end
2436 end
2437 catch
2438 % Skip if parsing fails
2439 end
2440 end
2441 end
2442end
2443end
2444
2445
2446function objStr = extract_json_object(str, startIdx)
2447% Extract a JSON object string starting at startIdx (must be '{').
2448if str(startIdx) ~= '{'
2449 objStr = '';
2450 return;
2451end
2452depth = 0;
2453inString = false;
2454escaped = false;
2455for i = startIdx:length(str)
2456 c = str(i);
2457 if escaped
2458 escaped = false;
2459 continue;
2460 end
2461 if c == '\'
2462 escaped = true;
2463 continue;
2464 end
2465 if c == '"'
2466 inString = ~inString;
2467 continue;
2468 end
2469 if ~inString
2470 if c == '{'
2471 depth = depth + 1;
2472 elseif c == '}'
2473 depth = depth - 1;
2474 if depth == 0
2475 objStr = str(startIdx:i);
2476 return;
2477 end
2478 end
2479 end
2480end
2481objStr = '';
2482end
2483
2484
2485% =========================================================================
2486% Helper functions
2487% =========================================================================
2488
2489function id = str_to_sched_id(str)
2490% Map a wire scheduling enum name to a SchedStrategy numeric ID.
2491%
2492% SchedStrategy.fromText case-folds and resolves the FCFSPRIO/HOL, LAS/FB and
2493% SET/SETF aliases, and errors on an unknown name. Do not reintroduce a
2494% hand-rolled whitelist here: the previous one covered 23 of 40 strategies,
2495% silently degraded the rest to FCFS, and lacked the PAS/OI cases that
2496% linemodel_save itself emits.
2497if iscell(str)
2498 str = str{1};
2499end
2500switch upper(str)
2501 case 'RAND'
2502 % Legacy alias kept for files written before SIRO was named: JMT calls
2503 % the same discipline "RAND". Not a SchedStrategy.fromText case.
2504 id = SchedStrategy.SIRO;
2505 otherwise
2506 id = SchedStrategy.fromText(lower(char(str)));
2507end
2508end
2509
2510
2511function id = str_to_depdisc(str)
2512% Map a departure discipline name to a DepartureDiscipline numeric ID. Matched
2513% case-insensitively, as the JAR reader does.
2514switch lower(char(str))
2515 case 'normal', id = DepartureDiscipline.NORMAL;
2516 case 'fifo', id = DepartureDiscipline.FIFO;
2517 otherwise
2518 line_error(mfilename, sprintf('Unrecognized departure discipline "%s".', str));
2519end
2520end
2521
2522
2523function id = str_to_impatience(str)
2524% Map an impatience type name to an ImpatienceType numeric ID.
2525switch lower(char(str))
2526 case 'reneging', id = ImpatienceType.RENEGING;
2527 case 'balking', id = ImpatienceType.BALKING;
2528 case 'retrial', id = ImpatienceType.RETRIAL;
2529 otherwise
2530 line_error(mfilename, sprintf('Unrecognized impatience type "%s".', str));
2531end
2532end
2533
2534
2535function id = str_to_repl_id(str)
2536% Map replacement strategy string to ReplacementStrategy numeric ID.
2537switch upper(str)
2538 case 'LRU', id = ReplacementStrategy.LRU;
2539 case 'FIFO', id = ReplacementStrategy.FIFO;
2540 case 'RR', id = ReplacementStrategy.RR;
2541 case 'SFIFO', id = ReplacementStrategy.SFIFO;
2542 case 'HLRU', id = ReplacementStrategy.HLRU;
2543 case 'CLIMB', id = ReplacementStrategy.CLIMB;
2544 case 'QLRU', id = ReplacementStrategy.QLRU;
2545 otherwise
2546 line_error(mfilename, sprintf('Unrecognized replacement strategy "%s".', str));
2547end
2548end
2549
2550
2551function id = str_to_prectype(str)
2552% Map JAR precedence type string to MATLAB ActivityPrecedenceType numeric ID.
2553switch str
2554 case 'pre', id = ActivityPrecedenceType.PRE_SEQ;
2555 case 'pre-AND', id = ActivityPrecedenceType.PRE_AND;
2556 case 'pre-OR', id = ActivityPrecedenceType.PRE_OR;
2557 case 'post', id = ActivityPrecedenceType.POST_SEQ;
2558 case 'post-AND', id = ActivityPrecedenceType.POST_AND;
2559 case 'post-OR', id = ActivityPrecedenceType.POST_OR;
2560 case 'post-LOOP', id = ActivityPrecedenceType.POST_LOOP;
2561 case 'post-CACHE', id = ActivityPrecedenceType.POST_CACHE;
2562 otherwise, id = ActivityPrecedenceType.PRE_SEQ;
2563end
2564end
2565
2566
2567function id = str_to_droprule(str)
2568% Map drop rule string to DropStrategy numeric ID.
2569switch str
2570 case 'drop', id = DropStrategy.DROP;
2571 case 'waitingQueue', id = DropStrategy.WAITQ;
2572 case 'blockingAfterService', id = DropStrategy.BAS;
2573 case 'retrial', id = DropStrategy.RETRIAL;
2574 case 'retrialWithLimit', id = DropStrategy.RETRIAL_WITH_LIMIT;
2575 otherwise, id = DropStrategy.WAITQ;
2576end
2577end
2578
2579
2580function m = json2mat(arr)
2581% Convert a JSON 2D array to a numeric matrix. jsondecode returns a numeric
2582% matrix for a rectangular array of numbers, but a cell array of row vectors
2583% when the rows differ in length or when the array was written through a cell
2584% wrapper.
2585if iscell(arr)
2586 m = cell2mat(cellfun(@(r) double(r(:)'), arr(:), 'UniformOutput', false));
2587else
2588 m = double(arr);
2589end
2590end
2591
2592function c = json2matcell(arr)
2593% Convert a JSON array of 2D matrices (which jsondecode returns as a cell
2594% array of matrices, or collapses into a 3D numeric array when all matrices
2595% have equal size) into a cell array of 2D matrices.
2596if iscell(arr)
2597 c = cell(1, numel(arr));
2598 for ci = 1:numel(arr)
2599 m = arr{ci};
2600 if iscell(m) % array of row arrays (ragged rows)
2601 c{ci} = cell2mat(cellfun(@(r) r(:)', m(:), 'UniformOutput', false));
2602 else
2603 c{ci} = m;
2604 end
2605 end
2606elseif ndims(arr) == 3
2607 c = cell(1, size(arr, 1));
2608 for ci = 1:size(arr, 1)
2609 c{ci} = squeeze(arr(ci, :, :));
2610 end
2611elseif ismatrix(arr)
2612 c = {arr}; % single matrix
2613else
2614 c = {};
2615end
2616end
2617
2618function c = cellify_string_array(arr)
2619% Convert a JSON string array (which may be decoded as a char, cell, or
2620% struct array) into a cell array of character vectors.
2621if ischar(arr)
2622 c = {arr};
2623elseif isstring(arr)
2624 c = cellstr(arr);
2625elseif iscell(arr)
2626 c = arr;
2627else
2628 % jsondecode can return a struct array or char matrix for string arrays
2629 c = cellstr(arr);
2630end
2631end
2632
2633function v = oi_cutoffs_vec(c)
2634% Decode the oiCutoffs array. The writer wraps it in a cell so that a
2635% single-class model still encodes as a JSON array rather than a bare scalar.
2636if iscell(c)
2637 v = cell2mat(c(:)');
2638else
2639 v = double(c(:)');
2640end
2641end
2642
2643function muFun = oi_table_to_handle(tblStruct, cutoffs)
2644% Rebuild an OI/PAS total-service-rate handle mu(c) from the materialized
2645% macrostate table written by OI_RATE_TABLE (linemodel_save). The table is keyed
2646% by the per-class counts, which is lossless because mu is order-independent;
2647% the handle therefore reduces the ordered microstate vector c (a list of class
2648% indices) to its class counts before looking up. Counts are clamped to CUTOFFS,
2649% so mu saturates beyond the tabulated range exactly as the table intends. As in
2650% CD_TABLE_TO_HANDLE, jsondecode mangles the JSON keys ("1,1") into valid MATLAB
2651% identifiers ("x1_1"), so the counts are parsed back out of the field names.
2652map = containers.Map('KeyType', 'char', 'ValueType', 'double');
2653fn = fieldnames(tblStruct);
2654K = numel(cutoffs);
2655for i = 1:numel(fn)
2656 nm = fn{i};
2657 parts = strsplit(nm(2:end), '_'); % drop the 'x' prefix jsondecode prepends
2658 n = cellfun(@str2double, parts);
2659 if K == 0
2660 K = numel(n);
2661 end
2662 map(cd_state_key(n)) = double(tblStruct.(nm));
2663end
2664muFun = @(c) oi_table_eval(c, map, cutoffs, K);
2665end
2666
2667function rate = oi_table_eval(c, map, cutoffs, K)
2668c = round(double(c(:)'));
2669cnt = zeros(1, K);
2670for i = 1:numel(c)
2671 ci = c(i);
2672 if ci >= 1 && ci <= K
2673 cnt(ci) = cnt(ci) + 1;
2674 end
2675end
2676if ~isempty(cutoffs)
2677 m = min(numel(cnt), numel(cutoffs));
2678 cnt(1:m) = min(cnt(1:m), cutoffs(1:m));
2679end
2680if sum(cnt) == 0
2681 rate = 0; % the empty state is omitted from the table: an idle queue
2682 return;
2683end
2684k = cd_state_key(cnt);
2685if isKey(map, k)
2686 rate = map(k);
2687else
2688 rate = 0;
2689end
2690end
2691
2692function beta = cd_table_to_handle(tblStruct, cutoffs)
2693% Rebuild a class-dependence handle beta(n) from the materialized lattice table
2694% written by CD_SCALING_TABLE (linemodel_save). jsondecode mangles the JSON keys
2695% ("1,1") into valid MATLAB identifiers ("x1_1"), so the per-class counts are
2696% parsed back out of the field names rather than reconstructed from them. The
2697% population is clamped to CUTOFFS, so beta saturates beyond the tabulated range
2698% exactly as the table intends.
2699map = containers.Map('KeyType', 'char', 'ValueType', 'any');
2700fn = fieldnames(tblStruct);
2701for i = 1:numel(fn)
2702 nm = fn{i};
2703 parts = strsplit(nm(2:end), '_'); % drop the 'x' prefix jsondecode prepends
2704 n = cellfun(@str2double, parts);
2705 v = double(tblStruct.(nm));
2706 map(cd_state_key(n)) = v(:)';
2707end
2708beta = @(ni) cd_table_eval(ni, map, cutoffs);
2709end
2710
2711function k = cd_state_key(n)
2712k = strjoin(arrayfun(@(x) sprintf('%d', x), n(:)', 'UniformOutput', false), ',');
2713end
2714
2715function g = firingdep_table_to_handle(frm, node_map, class_map)
2716% Rebuild the firing-rate dependence handle g(M) from the materialized lattice
2717% written by FIRINGMOD_SCALING_TABLE (linemodel_save). M is the node-indexed
2718% marking matrix; the enabling (place,class) slots are read off, clamped to the
2719% cutoffs, and the tabulated multiplier is looked up (default 1 outside range).
2720g = [];
2721if ~isfield(frm, 'slots') || ~isfield(frm, 'scaling'), return; end
2722slotsData = frm.slots;
2723if isstruct(slotsData), slotsData = num2cell(slotsData); end
2724slotIdx = zeros(numel(slotsData), 2);
2725for s = 1:numel(slotsData)
2726 sm = slotsData{s};
2727 if ~node_map.isKey(sm.node) || ~class_map.isKey(sm.class), return; end
2728 slotIdx(s,:) = [node_map(sm.node).index, class_map(sm.class).index];
2729end
2730cutoffs = [];
2731if isfield(frm, 'cutoffs')
2732 cutoffs = double(cell2mat_or_vec(frm.cutoffs));
2733end
2734map = containers.Map('KeyType', 'char', 'ValueType', 'any');
2735fn = fieldnames(frm.scaling);
2736for i = 1:numel(fn)
2737 nm = fn{i};
2738 parts = strsplit(nm(2:end), '_'); % drop the 'x' prefix jsondecode prepends
2739 c = cellfun(@str2double, parts);
2740 map(cd_state_key(c)) = double(frm.scaling.(nm));
2741end
2742g = @(M) firingdep_table_eval(M, slotIdx, map, cutoffs);
2743end
2744
2745function v = firingdep_table_eval(M, slotIdx, map, cutoffs)
2746P = size(slotIdx, 1);
2747c = zeros(1, P);
2748for s = 1:P
2749 c(s) = round(M(slotIdx(s,1), slotIdx(s,2)));
2750end
2751c(c < 0) = 0;
2752if ~isempty(cutoffs)
2753 m = min(numel(c), numel(cutoffs));
2754 c(1:m) = min(c(1:m), cutoffs(1:m));
2755end
2756k = cd_state_key(c);
2757if isKey(map, k)
2758 v = map(k);
2759else
2760 v = 1; % a marking absent from the table is neutral (no dependence)
2761end
2762end
2763
2764function v = cell2mat_or_vec(x)
2765% jsondecode yields a numeric column for a homogeneous array but a cell when the
2766% writer emitted num2cell; normalize both to a row vector.
2767if iscell(x)
2768 v = cell2mat(x(:)');
2769else
2770 v = double(x(:)');
2771end
2772end
2773
2774function v = cd_table_eval(ni, map, cutoffs)
2775n = round(double(ni(:)'));
2776n(n < 0) = 0;
2777if ~isempty(cutoffs)
2778 m = min(numel(n), numel(cutoffs));
2779 n(1:m) = min(n(1:m), cutoffs(1:m));
2780end
2781k = cd_state_key(n);
2782if isKey(map, k)
2783 v = map(k);
2784else
2785 v = 1; % a state absent from the table is neutral (no scaling)
2786end
2787end
Definition fjtag.m:161