LINE Solver
MATLAB API documentation
Loading...
Searching...
No Matches
linemodel_save.m
1function linemodel_save(model, filename)
2% LINEMODEL_SAVE Save a LINE model to JSON.
3%
4% LINEMODEL_SAVE(MODEL, FILENAME) saves the model to the specified JSON
5% file, conforming to the line-model.schema.json specification.
6%
7% Parameters:
8% model - Network, LayeredNetwork, Workflow, or Environment object
9% filename - output file path (should end in .json)
10%
11% Example:
12% model = Network('M/M/1');
13% source = Source(model, 'Source');
14% queue = Queue(model, 'Queue', SchedStrategy.FCFS);
15% sink = Sink(model, 'Sink');
16% oclass = OpenClass(model, 'Class1');
17% source.setArrival(oclass, Exp(1.0));
18% queue.setService(oclass, Exp(2.0));
19% P = model.initRoutingMatrix();
20% P{1}(1,2) = 1; P{1}(2,3) = 1;
21% model.link(P);
22% linemodel_save(model, 'mm1.json');
23%
24% Copyright (c) 2012-2026, Imperial College London
25% All rights reserved.
26
27if isa(model, 'LayeredNetwork')
28 modelMap = layered2json(model);
29elseif isa(model, 'Workflow')
30 modelMap = workflow2json(model);
31elseif isa(model, 'Environment')
32 modelMap = environment2json(model);
33else
34 modelMap = network2json(model);
35end
36
37% Build the full document
38sb = {};
39sb{end+1} = '{';
40sb{end+1} = ' "format": "line-model",';
41sb{end+1} = ' "version": "1.0",';
42sb{end+1} = [' "model": ', encode_value(modelMap, 2)];
43sb{end+1} = '}';
44jsonStr = strjoin(sb, newline);
45
46fid = fopen(filename, 'w');
47if fid == -1
48 error('linemodel_save:fileOpen', 'Cannot open file: %s', filename);
49end
50cleanupObj = onCleanup(@() fclose(fid));
51fprintf(fid, '%s\n', jsonStr);
52end
53
54
55% =========================================================================
56% Network serialization
57% =========================================================================
58
59function result = network2json(model)
60% Convert a Network to a containers.Map (preserves key ordering/commas)
61result = containers.Map();
62result('type') = 'Network';
63result('name') = model.getName();
64
65nodes = model.getNodes();
66classes = model.getClasses();
67K = length(classes);
68M = length(nodes);
69
70% --- Nodes ---
71nodesJson = {};
72for i = 1:M
73 node = nodes{i};
74
75 % Skip implicit ClassSwitch nodes (auto-created by link())
76 if isa(node, 'ClassSwitch') && isprop(node, 'autoAdded') && node.autoAdded
77 continue;
78 end
79
80 nj = containers.Map();
81 nj('name') = node.name;
82 nj('type') = node_type_str(node);
83
84 % Place extends Station not Queue, so isa(node,'Queue') alone would drop its scheduling strategy
85 if isa(node, 'Delay')
86 nj('scheduling') = 'INF';
87 elseif isa(node, 'Place')
88 if node.isQueueing() && ~isempty(node.schedStrategy)
89 nj('scheduling') = sched_id_to_str(node.schedStrategy);
90 end
91 elseif isa(node, 'Queue')
92 sched = node.schedStrategy;
93 if ~isempty(sched)
94 nj('scheduling') = sched_id_to_str(sched);
95 end
96 end
97
98 % Infinite server count deliberately not emitted -- see _kb/09-ldes-and-cache.md
99 if isa(node, 'Station') && ~isa(node, 'Delay') && ...
100 (isa(node, 'Queue') || (isa(node, 'Place') && node.isQueueing()))
101 ns = node.numberOfServers;
102 if isfinite(ns) && ns > 1
103 nj('servers') = ns;
104 end
105 end
106
107 % Station, not Queue: Place extends Station directly (see scheduling note above)
108 if isa(node, 'Station')
109 c = node.cap;
110 if ~isempty(c) && isfinite(c) && c > 0
111 nj('buffer') = c;
112 end
113 end
114
115 % Per-class buffer capacity
116 if isa(node, 'Station') && ~isempty(node.classCap)
117 ccMap = containers.Map();
118 for r = 1:K
119 jc = classes{r};
120 if r <= length(node.classCap) && isfinite(node.classCap(r))
121 ccMap(jc.name) = node.classCap(r);
122 end
123 end
124 if ccMap.Count > 0
125 nj('classCap') = ccMap;
126 end
127 end
128
129 % Drop rules
130 if isa(node, 'Station') && ~isempty(node.dropRule)
131 drMap = containers.Map();
132 for r = 1:K
133 jc = classes{r};
134 if r <= length(node.dropRule)
135 dr = node.dropRule(r);
136 drStr = droprule_to_str(dr);
137 if ~isempty(drStr)
138 drMap(jc.name) = drStr;
139 end
140 end
141 end
142 if drMap.Count > 0
143 nj('dropRule') = drMap;
144 end
145 end
146
147 % Load-dependent scaling
148 if isa(node, 'Station') && ~isempty(node.lldScaling)
149 ldMap = containers.Map();
150 ldMap('type') = 'loadDependent';
151 ldMap('scaling') = node.lldScaling(:)';
152 nj('loadDependence') = ldMap;
153 end
154
155 % beta_{i,r}(n) handle materialized over the per-class box lattice (cannot cross JSON as a handle)
156 if isa(node, 'Station') && ~isempty(node.lcdScaling)
157 maxc = zeros(1, K);
158 for r = 1:K
159 if isa(classes{r}, 'ClosedClass') && isfinite(classes{r}.population)
160 maxc(r) = round(classes{r}.population);
161 else
162 maxc(r) = 10; % open-class saturation cutoff (beta clamped beyond)
163 end
164 end
165 cdMap = containers.Map();
166 cdMap('type') = 'classDependent';
167 % num2cell keeps a single-class (1x1) vector from collapsing to a scalar.
168 cdMap('cutoffs') = num2cell(double(maxc(:)'));
169 cdMap('scaling') = cd_scaling_table(node.lcdScaling, maxc, K);
170 % Declared peak rate scaling per class (Util = T*S/peak). Broadcast a
171 % scalar to K entries so the reader restores a per-class vector.
172 pk = node.lcdScalingPeak;
173 if isscalar(pk)
174 pk = repmat(pk, 1, K);
175 end
176 cdMap('peak') = num2cell(double(pk(:)'));
177 nj('classDependence') = cdMap;
178 end
179
180 % eta_i(n) joint-dependence handle (non-product-form), materialized over the
181 % same per-class box lattice. Wire key "jointDependence", twin of the
182 % classDependence block above; matches the JAR/Python readers.
183 if isa(node, 'Station') && ~isempty(node.ljdScaling)
184 maxc = zeros(1, K);
185 for r = 1:K
186 if isa(classes{r}, 'ClosedClass') && isfinite(classes{r}.population)
187 maxc(r) = round(classes{r}.population);
188 else
189 maxc(r) = 10; % open-class saturation cutoff (eta clamped beyond)
190 end
191 end
192 jdMap = containers.Map();
193 jdMap('type') = 'jointDependent';
194 jdMap('cutoffs') = num2cell(double(maxc(:)'));
195 jdMap('scaling') = cd_scaling_table(node.ljdScaling, maxc, K);
196 pk = node.ljdScalingPeak;
197 if isscalar(pk)
198 pk = repmat(pk, 1, K);
199 end
200 jdMap('peak') = num2cell(double(pk(:)'));
201 nj('jointDependence') = jdMap;
202 end
203
204 % A queueing Place carries per-class service processes plus departure discipline; an ordinary Place has neither
205 svc = containers.Map();
206 depDisc = containers.Map();
207 for r = 1:K
208 jc = classes{r};
209 dist = [];
210 if isa(node, 'Source')
211 try
212 dist = node.getArrivalProcess(jc);
213 catch
214 dist = [];
215 end
216 elseif isa(node, 'Place')
217 if node.isQueueing() && numel(node.serviceProcess) >= jc.index ...
218 && ~isempty(node.serviceProcess{jc.index})
219 dist = node.serviceProcess{jc.index};
220 end
221 elseif isa(node, 'Queue') || isa(node, 'Delay')
222 if isa(node, 'Queue') && ~isempty(node.svcRateFun)
223 % OI/PAS queue: mu(c) carried below as oiServiceRate, not via getService -- see _kb/09-ldes-and-cache.md
224 dist = [];
225 else
226 try
227 dist = node.getService(jc);
228 catch
229 dist = [];
230 end
231 end
232 end
233 if ~isempty(dist) && ~isa(dist, 'Disabled')
234 dj = dist2json(dist);
235 if ~isempty(dj)
236 svc(jc.name) = dj;
237 if isa(node, 'Place') && numel(node.departureDiscipline) >= jc.index
238 depDisc(jc.name) = depdisc_to_str(node.departureDiscipline(jc.index));
239 end
240 end
241 end
242 end
243 if svc.Count > 0
244 nj('service') = svc;
245 end
246 if depDisc.Count > 0
247 nj('departureDiscipline') = depDisc;
248 end
249
250 % OI/PAS mu(c) serialized as a macrostate table keyed by per-class counts -- see _kb/09-ldes-and-cache.md
251 if isa(node, 'Queue') && ~isempty(node.svcRateFun)
252 maxc = zeros(1, K);
253 for r = 1:K
254 if isa(classes{r}, 'ClosedClass') && isfinite(classes{r}.population)
255 maxc(r) = round(classes{r}.population);
256 else
257 maxc(r) = 10; % open-class saturation cutoff (mu constant beyond)
258 end
259 end
260 rateMap = oi_rate_table(node.svcRateFun, maxc, K);
261 nj('oiServiceRate') = rateMap;
262 % num2cell keeps jsonencode from collapsing a single-class (1x1) vector
263 % to a scalar, which the JAR reader parses as an array.
264 nj('oiCutoffs') = num2cell(double(maxc(:)'));
265 if node.schedStrategy == SchedStrategy.PAS && ~isempty(node.swapGraph)
266 sgRows = cell(1, size(node.swapGraph, 1));
267 for sgi = 1:size(node.swapGraph, 1)
268 sgRows{sgi} = num2cell(double(node.swapGraph(sgi, :)));
269 end
270 nj('swapGraph') = sgRows;
271 end
272 end
273
274 % Batch arrivals: the batch-size law released at each arrival epoch, per
275 % class. Separate from 'service' above, which only spaces the epochs.
276 if isa(node, 'Source') && ~isempty(node.arrivalBatch)
277 batchMap = containers.Map();
278 for r = 1:K
279 if numel(node.arrivalBatch) >= r && ~isempty(node.arrivalBatch{r})
280 bj = dist2json(node.arrivalBatch{r});
281 if ~isempty(bj)
282 batchMap(classes{r}.name) = bj;
283 end
284 end
285 end
286 if batchMap.Count > 0
287 nj('arrivalBatch') = batchMap;
288 end
289 end
290
291 % Marked (MMAP) arrival binding: class names ordered by mark
292 if isa(node, 'Source') && ~isempty(node.markedClasses)
293 markedNames = cell(1, numel(node.markedClasses));
294 for km = 1:numel(node.markedClasses)
295 markedNames{km} = classes{node.markedClasses(km)}.name;
296 end
297 nj('markedClasses') = markedNames;
298 end
299
300 % ClassSwitch matrix
301 if isa(node, 'ClassSwitch')
302 csm = node.server.csMatrix;
303 if ~isempty(csm)
304 csDict = containers.Map();
305 for ri = 1:K
306 row = containers.Map();
307 for ci = 1:K
308 if ri <= size(csm,1) && ci <= size(csm,2) && csm(ri,ci) ~= 0
309 row(classes{ci}.name) = csm(ri,ci);
310 end
311 end
312 if row.Count > 0
313 csDict(classes{ri}.name) = row;
314 end
315 end
316 if csDict.Count > 0
317 nj('classSwitchMatrix') = csDict;
318 end
319 end
320 end
321
322 % Cache config
323 if isa(node, 'Cache')
324 cc = containers.Map();
325 cc('items') = node.items.nitems;
326 ilc = node.itemLevelCap;
327 % Replacement policy emitted verbatim; CLIMB is rewritten at solve time, never here -- see _kb/09-ldes-and-cache.md
328 if isscalar(ilc)
329 cc('capacity') = ilc;
330 else
331 cc('capacity') = ilc(:)';
332 end
333 cc('replacement') = repl_to_str(node.replacestrategy);
334 if isprop(node,'admissionProb') && ~isempty(node.admissionProb)
335 cc('admissionProb') = node.admissionProb;
336 end
337
338 % Hit/miss class mappings
339 hc = full(node.server.hitClass);
340 mc = full(node.server.missClass);
341 if ~isempty(hc) && any(hc > 0)
342 hitMap = containers.Map();
343 for hi = 1:length(hc)
344 if hc(hi) > 0 && hi <= K && hc(hi) <= K
345 hitMap(classes{hi}.name) = classes{hc(hi)}.name;
346 end
347 end
348 if hitMap.Count > 0
349 cc('hitClass') = hitMap;
350 end
351 end
352 if ~isempty(mc) && any(mc > 0)
353 missMap = containers.Map();
354 for mi = 1:length(mc)
355 if mc(mi) > 0 && mi <= K && mc(mi) <= K
356 missMap(classes{mi}.name) = classes{mc(mi)}.name;
357 end
358 end
359 if missMap.Count > 0
360 cc('missClass') = missMap;
361 end
362 end
363
364 % Read popularity distributions (setRead)
365 if ~isempty(node.popularity)
366 popMap = containers.Map();
367 for pi = 1:size(node.popularity, 1)
368 for pj = 1:size(node.popularity, 2)
369 if pi <= size(node.popularity, 1) && pj <= size(node.popularity, 2) ...
370 && ~isempty(node.popularity{pi, pj})
371 popDist = node.popularity{pi, pj};
372 dj = dist2json(popDist);
373 if ~isempty(dj) && pj <= K
374 popMap(classes{pj}.name) = dj;
375 end
376 end
377 end
378 end
379 if popMap.Count > 0
380 cc('popularity') = popMap;
381 end
382 end
383
384 % Access-cost graph shared by all classes, or full per-class accessProb; default super-diagonal rebuilt on load
385 if ~isempty(node.graph)
386 gArr = cell(1, numel(node.graph));
387 for gi = 1:numel(node.graph)
388 gArr{gi} = full(node.graph{gi});
389 end
390 cc('accessGraph') = gArr;
391 elseif ~isempty(node.accessProb)
392 [Kap, Nap] = size(node.accessProb);
393 apArr = cell(1, Kap);
394 for k1 = 1:Kap
395 rowArr = cell(1, Nap);
396 for k2 = 1:Nap
397 if ~isempty(node.accessProb{k1, k2})
398 rowArr{k2} = full(node.accessProb{k1, k2});
399 else
400 rowArr{k2} = [];
401 end
402 end
403 apArr{k1} = rowArr;
404 end
405 cc('accessProb') = apArr;
406 end
407
408 % Initial cache state [class counts | contents | retrieval bitmap]
409 cacheState = node.getState;
410 if ~isempty(cacheState)
411 cc('initialState') = num2cell(double(full(cacheState(1, :))));
412 end
413
414 nj('cache') = cc;
415
416 % Flat cache fields for the Java LineModelIO reader, mirrored from cc so both forms agree -- see _kb/09-ldes-and-cache.md
417 nj('numItems') = double(node.items.nitems);
418 nj('itemLevelCap') = num2cell(double(ilc(:)'));
419 nj('replacementStrategy') = cc('replacement');
420 flatKeys = {'hitClass', 'missClass', 'popularity', 'accessGraph', ...
421 'accessProb', 'initialState', 'admissionProb'};
422 for fk = 1:numel(flatKeys)
423 if isKey(cc, flatKeys{fk})
424 nj(flatKeys{fk}) = cc(flatKeys{fk});
425 end
426 end
427
428 % Retrieval system flat block (setRetrievalSystem only) -- see _kb/09-ldes-and-cache.md
429 if ~isempty(node.retrievalSystemCapacity) && node.retrievalSystemCapacity > 0
430 byClass = containers.Map();
431 nItemsR = node.items.nitems;
432 rc = node.server.retrievalClasses;
433 qKeys = node.retrievalSystemQueueIndices.keys();
434 for kk = 1:numel(qKeys)
435 key0 = qKeys{kk}; % jobinClass.index - 1 (0-based)
436 inIdx = double(key0) + 1;
437 if inIdx < 1 || inIdx > K
438 continue;
439 end
440 entry = containers.Map();
441 qidxs = node.retrievalSystemQueueIndices(key0);
442 qnames = cell(1, numel(qidxs));
443 for qi = 1:numel(qidxs)
444 qnames{qi} = nodes{qidxs(qi)}.name;
445 end
446 entry('queues') = qnames;
447 itemsMap = containers.Map();
448 for it = 1:nItemsR
449 if size(rc, 1) >= it && size(rc, 2) >= inIdx
450 rClassIdx = rc(it, inIdx);
451 if rClassIdx > 0 && rClassIdx <= K
452 itemsMap(num2str(it - 1)) = classes{rClassIdx}.name;
453 end
454 end
455 end
456 if itemsMap.Count > 0
457 entry('items') = itemsMap;
458 end
459 byClass(classes{inIdx}.name) = entry;
460 end
461 if byClass.Count > 0
462 rsRoot = containers.Map();
463 rsRoot('capacity') = double(node.retrievalSystemCapacity);
464 rsRoot('byClass') = byClass;
465 nj('retrievalSystem') = rsRoot;
466 end
467 end
468 end
469
470 % Fork tasksPerLink
471 if isa(node, 'Fork')
472 if ~isempty(node.output) && isprop(node.output, 'tasksPerLink') && node.output.tasksPerLink > 1
473 nj('tasksPerLink') = node.output.tasksPerLink;
474 end
475 end
476
477 % Join paired fork and join strategy
478 if isa(node, 'Join')
479 if ~isempty(node.joinOf)
480 nj('forkNode') = node.joinOf.name;
481 end
482 % Serialize per-class join strategy if non-default
483 if ~isempty(node.input) && isprop(node.input, 'joinStrategy') && ~isempty(node.input.joinStrategy)
484 for r = 1:K
485 jc = classes{r};
486 if r <= length(node.input.joinStrategy) && ~isempty(node.input.joinStrategy{r})
487 js = node.input.joinStrategy{r};
488 if js ~= JoinStrategy.STD
489 if js == JoinStrategy.PARTIAL
490 nj('joinStrategy') = 'PARTIAL';
491 end
492 end
493 end
494 end
495 end
496 if ~isempty(node.input) && isprop(node.input, 'joinRequired') && ~isempty(node.input.joinRequired)
497 for r = 1:K
498 jc = classes{r};
499 if r <= length(node.input.joinRequired) && ~isempty(node.input.joinRequired{r})
500 jq = node.input.joinRequired{r};
501 if jq > 0
502 nj('joinQuorum') = jq;
503 end
504 end
505 end
506 end
507 end
508
509 % DPS/GPS weights, including DPSPRIO/GPSPRIO (same schedStrategyPar); omitting them resets weights to 1 on reload
510 if isa(node, 'Queue') && ~isa(node, 'Delay')
511 sched = node.schedStrategy;
512 if ~isempty(sched) && (sched == SchedStrategy.DPS || sched == SchedStrategy.GPS || ...
513 sched == SchedStrategy.DPSPRIO || sched == SchedStrategy.GPSPRIO)
514 sp = containers.Map();
515 for r = 1:K
516 jc = classes{r};
517 try
518 w = node.schedStrategyPar(r);
519 if ~isempty(w) && isfinite(w) && w > 0
520 sp(jc.name) = w;
521 end
522 catch
523 end
524 end
525 if sp.Count > 0
526 nj('schedParams') = sp;
527 end
528 end
529 end
530
531 % Transition modes
532 if isa(node, 'Transition')
533 modesJson = {};
534 nModes = node.getNumberOfModes();
535 allNodes = model.getNodes();
536 for mi = 1:nModes
537 mj = containers.Map();
538 if mi <= length(node.modeNames) && ~isempty(node.modeNames{mi})
539 mj('name') = node.modeNames{mi};
540 else
541 mj('name') = sprintf('Mode%d', mi);
542 end
543 % Immediate mode's Exp(1) placeholder distribution is omitted, else a reader sees a timed mode at rate 1
544 isImmediateMode = mi <= length(node.timingStrategies) && ...
545 node.timingStrategies(mi) == TimingStrategy.IMMEDIATE;
546 if ~isImmediateMode && mi <= length(node.distributions) && ...
547 ~isempty(node.distributions{mi})
548 dj = dist2json(node.distributions{mi});
549 if ~isempty(dj)
550 mj('distribution') = dj;
551 end
552 end
553 % Timing strategy
554 if mi <= length(node.timingStrategies)
555 if node.timingStrategies(mi) == TimingStrategy.TIMED
556 mj('timingStrategy') = 'TIMED';
557 else
558 mj('timingStrategy') = 'IMMEDIATE';
559 end
560 end
561 % Number of servers
562 if mi <= length(node.numberOfServers) && node.numberOfServers(mi) > 1
563 mj('numServers') = node.numberOfServers(mi);
564 end
565 % Firing priority
566 if mi <= length(node.firingPriorities) && node.firingPriorities(mi) > 0
567 mj('firingPriority') = node.firingPriorities(mi);
568 end
569 % Firing weight
570 if mi <= length(node.firingWeights) && node.firingWeights(mi) ~= 1.0
571 mj('firingWeight') = node.firingWeights(mi);
572 end
573 % Marking-dependent firing-rate multiplier g_m(marking), materialized
574 % over the enabling (place,class) box lattice (cannot cross JSON as a
575 % handle). Timed modes only; empty handle => omitted (unit multiplier).
576 if ~isImmediateMode && mi <= length(node.firingRateDependence) ...
577 && ~isempty(node.firingRateDependence{mi})
578 g = node.firingRateDependence{mi};
579 ecMat = node.enablingConditions{mi};
580 slots = {}; slotIdx = []; caps = [];
581 for ni = 1:size(ecMat, 1)
582 for ci = 1:size(ecMat, 2)
583 if ecMat(ni, ci) > 0
584 sm = containers.Map();
585 sm('node') = allNodes{ni}.name;
586 sm('class') = classes{ci}.name;
587 slots{end+1} = sm; %#ok<AGROW>
588 slotIdx(end+1,:) = [ni, ci]; %#ok<AGROW>
589 pcap = allNodes{ni}.cap;
590 if ~isfinite(pcap), pcap = 10; end % open-place saturation cutoff
591 caps(end+1) = round(pcap); %#ok<AGROW>
592 end
593 end
594 end
595 if ~isempty(slots)
596 frm = containers.Map();
597 frm('slots') = slots;
598 frm('cutoffs') = num2cell(double(caps(:)'));
599 nnodes_all = length(allNodes);
600 frm('scaling') = firingdep_scaling_table(g, slotIdx, caps, nnodes_all, K);
601 mj('firingRateDependence') = frm;
602 end
603 end
604 % Enabling conditions
605 if mi <= length(node.enablingConditions)
606 ecMat = node.enablingConditions{mi};
607 ecList = {};
608 for ni = 1:size(ecMat, 1)
609 for ci = 1:size(ecMat, 2)
610 if ecMat(ni, ci) > 0
611 ec = containers.Map();
612 ec('node') = allNodes{ni}.name;
613 ec('class') = classes{ci}.name;
614 ec('count') = ecMat(ni, ci);
615 ecList{end+1} = ec; %#ok<AGROW>
616 end
617 end
618 end
619 if ~isempty(ecList)
620 mj('enablingConditions') = ecList;
621 end
622 end
623 % Inhibiting conditions
624 if mi <= length(node.inhibitingConditions)
625 icMat = node.inhibitingConditions{mi};
626 icList = {};
627 for ni = 1:size(icMat, 1)
628 for ci = 1:size(icMat, 2)
629 if isfinite(icMat(ni, ci))
630 ic = containers.Map();
631 ic('node') = allNodes{ni}.name;
632 ic('class') = classes{ci}.name;
633 ic('count') = icMat(ni, ci);
634 icList{end+1} = ic; %#ok<AGROW>
635 end
636 end
637 end
638 if ~isempty(icList)
639 mj('inhibitingConditions') = icList;
640 end
641 end
642 % Firing outcomes
643 if mi <= length(node.firingOutcomes)
644 foMat = node.firingOutcomes{mi};
645 foList = {};
646 for ni = 1:size(foMat, 1)
647 for ci = 1:size(foMat, 2)
648 if foMat(ni, ci) ~= 0
649 fo = containers.Map();
650 fo('node') = allNodes{ni}.name;
651 fo('class') = classes{ci}.name;
652 fo('count') = foMat(ni, ci);
653 foList{end+1} = fo; %#ok<AGROW>
654 end
655 end
656 end
657 if ~isempty(foList)
658 mj('firingOutcomes') = foList;
659 end
660 end
661 modesJson{end+1} = mj; %#ok<AGROW>
662 end
663 if ~isempty(modesJson)
664 nj('modes') = modesJson;
665 end
666 end
667
668 % Place token counts wrapped in a cell so a single-class marking still encodes as a JSON array
669 if isa(node, 'Place') && ~isempty(node.state)
670 nj('initialState') = num2cell(double(node.state(:)'));
671 end
672
673 % Emitted in the node loop before getStruct() overwrites statePrior with the default -- see _kb/09-ldes-and-cache.md
674 if isa(node, 'StatefulNode') && ~isempty(node.statePrior)
675 prior = double(node.statePrior(:));
676 % Trivial prior [1] is rebuilt by initDefault/initFromMarginal, so not emitted
677 trivialPrior = numel(prior) == 1 && abs(prior(1) - 1.0) < 1e-12;
678 if ~trivialPrior
679 space = double(full(node.space));
680 if isempty(space) || size(space, 1) ~= numel(prior)
681 line_warning(mfilename, sprintf(['Node %s carries a state prior over %d states but a ' ...
682 'state space of %d rows; the prior is not saved.'], ...
683 node.getName(), numel(prior), size(space, 1)));
684 else
685 spaceRows = cell(1, size(space, 1));
686 for si = 1:size(space, 1)
687 spaceRows{si} = num2cell(space(si, :));
688 end
689 nj('stateSpace') = spaceRows;
690 % num2cell keeps a single-state prior from collapsing to a bare scalar (reader requires an array)
691 nj('statePrior') = num2cell(prior');
692 end
693 end
694 end
695
696 nodesJson{end+1} = nj; %#ok<AGROW>
697end
698result('nodes') = nodesJson;
699
700% --- Classes ---
701classesJson = {};
702for r = 1:K
703 jc = classes{r};
704 cj = containers.Map();
705 cj('name') = jc.name;
706 if isa(jc, 'OpenSignal')
707 cj('type') = 'Signal';
708 cj('openOrClosed') = 'Open';
709 cj('signalType') = SignalType.toText(jc.signalType);
710 if ~isempty(jc.targetJobClass)
711 cj('targetClass') = jc.targetJobClass.name;
712 end
713 if ~isempty(jc.removalDistribution)
714 cj('removalDistribution') = dist2json(jc.removalDistribution);
715 end
716 if ~isempty(jc.removalPolicy) && jc.removalPolicy ~= RemovalPolicy.RANDOM
717 cj('removalPolicy') = RemovalPolicy.toText(jc.removalPolicy);
718 end
719 elseif isa(jc, 'ClosedSignal')
720 cj('type') = 'Signal';
721 cj('openOrClosed') = 'Closed';
722 cj('signalType') = SignalType.toText(jc.signalType);
723 if ~isempty(jc.refstat) && isprop(jc.refstat, 'name')
724 cj('refNode') = jc.refstat.name;
725 end
726 if ~isempty(jc.targetJobClass)
727 cj('targetClass') = jc.targetJobClass.name;
728 end
729 if ~isempty(jc.removalDistribution)
730 cj('removalDistribution') = dist2json(jc.removalDistribution);
731 end
732 if ~isempty(jc.removalPolicy) && jc.removalPolicy ~= RemovalPolicy.RANDOM
733 cj('removalPolicy') = RemovalPolicy.toText(jc.removalPolicy);
734 end
735 elseif isa(jc, 'Signal')
736 % Bare Signal (not OpenSignal/ClosedSignal): 'openOrClosed' omitted deliberately -- see _kb/09-ldes-and-cache.md
737 cj('type') = 'Signal';
738 cj('signalType') = SignalType.toText(jc.signalType);
739 if ~isempty(jc.targetJobClass)
740 cj('targetClass') = jc.targetJobClass.name;
741 end
742 if ~isempty(jc.removalDistribution)
743 cj('removalDistribution') = dist2json(jc.removalDistribution);
744 end
745 if ~isempty(jc.removalPolicy) && jc.removalPolicy ~= RemovalPolicy.RANDOM
746 cj('removalPolicy') = RemovalPolicy.toText(jc.removalPolicy);
747 end
748 elseif isa(jc, 'SelfLoopingClass')
749 % Must be tested before ClosedClass (which it subclasses), else it reloads as an ordinary closed class
750 cj('type') = 'SelfLooping';
751 cj('population') = jc.population;
752 if ~isempty(jc.refstat) && isprop(jc.refstat, 'name')
753 cj('refNode') = jc.refstat.name;
754 end
755 elseif isa(jc, 'ClosedClass')
756 cj('type') = 'Closed';
757 cj('population') = jc.population;
758 if ~isempty(jc.refstat) && isprop(jc.refstat, 'name')
759 cj('refNode') = jc.refstat.name;
760 end
761 elseif isa(jc, 'OpenClass')
762 cj('type') = 'Open';
763 % refstat is normally re-derived as Source on load; carry it only when setReferenceStation overrode it
764 if ~isempty(jc.refstat) && isprop(jc.refstat, 'name') && ~isa(jc.refstat, 'Source')
765 cj('refNode') = jc.refstat.name;
766 end
767 else
768 cj('type') = 'Open';
769 end
770 if jc.priority ~= 0
771 cj('priority') = jc.priority;
772 end
773 if isprop(jc, 'deadline') && isfinite(jc.deadline)
774 cj('deadline') = jc.deadline;
775 end
776 if jc.isReferenceClass()
777 cj('isReferenceClass') = true;
778 end
779 % Reply signal binding (sn.syncreply). Without it a REPLY signal class is
780 % inert after a round-trip: nothing unblocks the servers waiting on it.
781 if isprop(jc, 'replySignalClass') && ~isempty(jc.replySignalClass)
782 cj('replySignalClass') = jc.replySignalClass.name;
783 end
784 % Spawn-on-completion binding (sn.classspawn): the class injected at the
785 % same station whenever a job of this class completes service.
786 if isprop(jc, 'spawnClass') && ~isempty(jc.spawnClass)
787 cj('spawnClass') = jc.spawnClass.name;
788 end
789 % Class-level (global) patience, distinct from the node-scoped 'patience'
790 % emitted per Queue. A node-scoped entry overrides this one on load.
791 if isprop(jc, 'patience') && ~isempty(jc.patience) && ~isa(jc.patience, 'Disabled')
792 cj('patience') = dist2json(jc.patience);
793 if ~isempty(jc.impatienceType)
794 cj('impatienceType') = ImpatienceType.toText(jc.impatienceType);
795 end
796 end
797 classesJson{end+1} = cj; %#ok<AGROW>
798end
799result('classes') = classesJson;
800
801% --- Routing ---
802routingMap = containers.Map();
803try
804 sn = model.getStruct();
805 % Prefer rtorig (original P matrix before ClassSwitch expansion)
806 if ~isempty(sn) && isfield(sn, 'rtorig') && iscell(sn.rtorig) && ~isempty(sn.rtorig) && ~isempty(sn.rtorig{1,1})
807 P_orig = sn.rtorig;
808 M_orig = size(P_orig{1,1}, 1);
809 % Only non-identity explicit ClassSwitch nodes need same-class collapsing -- see _kb/04-networkstruct.md
810 nodes = model.getNodes();
811 explicit_cs = false(1, M_orig);
812 for ii = 1:min(M_orig, length(nodes))
813 if isa(nodes{ii}, 'ClassSwitch') && ~nodes{ii}.autoAdded ...
814 && ~isIdentityClassSwitch(nodes{ii}.server.csMatrix, K)
815 explicit_cs(ii) = true;
816 end
817 end
818 % P_same(s,ii,jj) = sum_r P_orig{r,s}(ii,jj): same-class-only entries to avoid double-switching -- see _kb/04-networkstruct.md
819 cs_same = zeros(K, M_orig, M_orig);
820 for ii = 1:M_orig
821 if explicit_cs(ii)
822 for s = 1:K
823 for jj = 1:M_orig
824 total = 0;
825 for r = 1:K
826 Prs = P_orig{r,s};
827 if issparse(Prs); Prs = full(Prs); end
828 total = total + Prs(ii, jj);
829 end
830 cs_same(s, ii, jj) = total;
831 end
832 end
833 end
834 end
835 for r = 1:K
836 for s = 1:K
837 fromTo = containers.Map();
838 Prs = P_orig{r,s};
839 if issparse(Prs)
840 Prs = full(Prs);
841 end
842 for ii = 1:M_orig
843 if explicit_cs(ii)
844 % For explicit CS, use same-class routing only
845 if r == s
846 for jj = 1:M_orig
847 val = cs_same(s, ii, jj);
848 if val > 1e-14
849 ni = sn.nodenames{ii};
850 njn = sn.nodenames{jj};
851 if ~fromTo.isKey(ni)
852 fromTo(ni) = containers.Map();
853 end
854 dest = fromTo(ni);
855 dest(njn) = val;
856 fromTo(ni) = dest;
857 end
858 end
859 end
860 % Skip cross-class entries from explicit CS
861 continue;
862 end
863 for jj = 1:M_orig
864 val = Prs(ii, jj);
865 if val > 1e-14
866 ni = sn.nodenames{ii};
867 njn = sn.nodenames{jj};
868 if ~fromTo.isKey(ni)
869 fromTo(ni) = containers.Map();
870 end
871 dest = fromTo(ni);
872 dest(njn) = val;
873 fromTo(ni) = dest;
874 end
875 end
876 end
877 if fromTo.Count > 0
878 key = char(sprintf('%s,%s', classes{r}.name, classes{s}.name));
879 routingMap(key) = fromTo;
880 end
881 end
882 end
883 elseif ~isempty(sn) && isfield(sn, 'rtnodes') && ~isempty(sn.rtnodes)
884 % Fallback to rtnodes if rtorig not available
885 rt = sn.rtnodes;
886 N = sn.nnodes;
887 % rtnodes folds CS switching into cross-class entries; mirror the rtorig branch (same-class only) -- see _kb/04-networkstruct.md
888 nodes = model.getNodes();
889 explicit_cs = false(1, N);
890 for ii = 1:min(N, length(nodes))
891 if isa(nodes{ii}, 'ClassSwitch') && ~nodes{ii}.autoAdded ...
892 && ~isIdentityClassSwitch(nodes{ii}.server.csMatrix, K)
893 explicit_cs(ii) = true;
894 end
895 end
896 % cs_same(s,ii,jj) = sum_r rt((ii,r),(jj,s)), same formula as the rtorig branch above
897 cs_same = zeros(K, N, N);
898 for ii = 1:N
899 if explicit_cs(ii)
900 for s = 1:K
901 for jj = 1:N
902 total = 0;
903 for r = 1:K
904 total = total + rt((ii-1)*K+r, (jj-1)*K+s);
905 end
906 cs_same(s, ii, jj) = total;
907 end
908 end
909 end
910 end
911 for r = 1:K
912 for s = 1:K
913 fromTo = containers.Map();
914 for ii = 1:N
915 if explicit_cs(ii)
916 % For explicit CS, use same-class routing only
917 if r == s
918 for jj = 1:N
919 val = cs_same(s, ii, jj);
920 if val > 1e-14
921 ni = sn.nodenames{ii};
922 njn = sn.nodenames{jj};
923 if ~fromTo.isKey(ni)
924 fromTo(ni) = containers.Map();
925 end
926 dest = fromTo(ni);
927 dest(njn) = val;
928 fromTo(ni) = dest;
929 end
930 end
931 end
932 % Skip cross-class entries from explicit CS
933 continue;
934 end
935 for jj = 1:N
936 val = rt((ii-1)*K+r, (jj-1)*K+s);
937 if val > 1e-14
938 ni = sn.nodenames{ii};
939 njn = sn.nodenames{jj};
940 if ~fromTo.isKey(ni)
941 fromTo(ni) = containers.Map();
942 end
943 dest = fromTo(ni);
944 dest(njn) = val;
945 fromTo(ni) = dest;
946 end
947 end
948 end
949 if fromTo.Count > 0
950 key = char(sprintf('%s,%s', classes{r}.name, classes{s}.name));
951 routingMap(key) = fromTo;
952 end
953 end
954 end
955 end
956catch
957 % If struct not available, routing stays empty
958end
959
960routing = containers.Map();
961routing('type') = 'matrix';
962routing('matrix') = routingMap;
963result('routing') = routing;
964
965% --- Routing Strategies ---
966try
967 sn2 = model.getStruct();
968 if ~isempty(sn2) && isfield(sn2, 'routing') && ~isempty(sn2.routing)
969 routingStrategies = containers.Map();
970 stratNames = containers.Map('KeyType','int32','ValueType','char');
971 stratNames(int32(RoutingStrategy.RAND)) = 'RAND';
972 stratNames(int32(RoutingStrategy.RROBIN)) = 'RROBIN';
973 stratNames(int32(RoutingStrategy.WRROBIN)) = 'WRROBIN';
974 stratNames(int32(RoutingStrategy.JSQ)) = 'JSQ';
975 stratNames(int32(RoutingStrategy.SQ)) = 'SQ';
976 stratNames(int32(RoutingStrategy.FIRING)) = 'FIRING';
977 stratNames(int32(RoutingStrategy.RL)) = 'RL';
978 stratNames(int32(RoutingStrategy.DISABLED)) = 'DISABLED';
979 for i = 1:sn2.nnodes
980 nodeStrats = containers.Map();
981 for r = 1:K
982 routVal = int32(sn2.routing(i, r));
983 if routVal ~= int32(RoutingStrategy.PROB) && routVal ~= int32(RoutingStrategy.RAND) && stratNames.isKey(routVal)
984 nodeStrats(classes{r}.name) = stratNames(routVal);
985 end
986 end
987 if nodeStrats.Count > 0
988 routingStrategies(sn2.nodenames{i}) = nodeStrats;
989 end
990 end
991 if routingStrategies.Count > 0
992 result('routingStrategies') = routingStrategies;
993 end
994
995 % WRROBIN weights indexed by NODE index i, not station index -- see _kb/09-ldes-and-cache.md
996 routingWeights = containers.Map();
997 for i = 1:sn2.nnodes
998 nodeObj2 = nodes{i};
999 nodeClassWeights = containers.Map();
1000 for r = 1:K
1001 if int32(sn2.routing(i, r)) == int32(RoutingStrategy.WRROBIN)
1002 os = nodeObj2.output.outputStrategy;
1003 if size(os,2) >= r
1004 osEntry = os{1, r};
1005 % osEntry = {className, stratName, forwardLinks}
1006 if length(osEntry) >= 3
1007 fwdLinks = osEntry{3};
1008 destWeights = containers.Map();
1009 for fi = 1:length(fwdLinks)
1010 link = fwdLinks{fi};
1011 % link = {destNode, weight}
1012 if iscell(link) && length(link) >= 2 && isa(link{1}, 'Node')
1013 destWeights(link{1}.name) = link{2};
1014 end
1015 end
1016 if destWeights.Count > 0
1017 nodeClassWeights(classes{r}.name) = destWeights;
1018 end
1019 end
1020 end
1021 end
1022 end
1023 if nodeClassWeights.Count > 0
1024 routingWeights(sn2.nodenames{i}) = nodeClassWeights;
1025 end
1026 end
1027 if routingWeights.Count > 0
1028 result('routingWeights') = routingWeights;
1029 end
1030 end
1031catch
1032end
1033
1034% --- Setup / Delay-Off, Polling Type and Switchover Times ---
1035nodesCellTmp = result('nodes');
1036for i = 1:M
1037 nodeObj = nodes{i};
1038 if ~isa(nodeObj, 'Queue') || isa(nodeObj, 'Delay')
1039 continue;
1040 end
1041 njIdx = 0;
1042 for nj_idx = 1:length(nodesCellTmp)
1043 if strcmp(nodesCellTmp{nj_idx}('name'), nodeObj.name)
1044 njIdx = nj_idx;
1045 break;
1046 end
1047 end
1048 if njIdx == 0
1049 continue;
1050 end
1051 nj = nodesCellTmp{njIdx};
1052
1053 % Setup and delay-off emitted as a pair: setDelayOff requires both on reload
1054 setupMap = containers.Map();
1055 delayOffMap = containers.Map();
1056 for r = 1:K
1057 if r <= length(nodeObj.setupTime) && r <= length(nodeObj.delayoffTime)
1058 suDist = nodeObj.setupTime{1,r};
1059 doffDist = nodeObj.delayoffTime{1,r};
1060 if ~isempty(suDist) && ~isempty(doffDist) && ...
1061 ~isa(suDist, 'Disabled') && ~isa(doffDist, 'Disabled')
1062 setupMap(classes{r}.name) = dist2json(suDist);
1063 delayOffMap(classes{r}.name) = dist2json(doffDist);
1064 end
1065 end
1066 end
1067 if setupMap.Count > 0
1068 nj('setupTime') = setupMap;
1069 nj('delayOffTime') = delayOffMap;
1070 end
1071
1072 isPolling = SchedStrategy.toId(nodeObj.schedStrategy) == SchedStrategy.POLLING;
1073
1074 % Polling type written by name: ids agree with Java but Python assigns via auto()
1075 if isPolling && ~isempty(nodeObj.pollingType)
1076 ptId = PollingType.toId(nodeObj.pollingType{1,1});
1077 nj('pollingType') = PollingType.toName(ptId);
1078 if ptId == PollingType.KLIMITED && ~isempty(nodeObj.pollingPar)
1079 nj('pollingPar') = nodeObj.pollingPar;
1080 end
1081 end
1082
1083 % Under POLLING, switchover is indexed by departing class alone (no "to" field); otherwise a KxK (from,to) cell
1084 soTimes = {};
1085 if ~isempty(nodeObj.switchoverTime)
1086 [soRows, soCols] = size(nodeObj.switchoverTime);
1087 if isPolling
1088 for r = 1:min(K, soCols)
1089 dist = nodeObj.switchoverTime{1,r};
1090 if ~isempty(dist) && ~isa(dist, 'Disabled')
1091 so = containers.Map();
1092 so('from') = classes{r}.name;
1093 so('distribution') = dist2json(dist);
1094 soTimes{end+1} = so;
1095 end
1096 end
1097 else
1098 for r = 1:min(K, soRows)
1099 for s = 1:min(K, soCols)
1100 dist = nodeObj.switchoverTime{r,s};
1101 if ~isempty(dist) && ~isa(dist, 'Disabled')
1102 so = containers.Map();
1103 so('from') = classes{r}.name;
1104 so('to') = classes{s}.name;
1105 so('distribution') = dist2json(dist);
1106 soTimes{end+1} = so;
1107 end
1108 end
1109 end
1110 end
1111 end
1112 if ~isempty(soTimes)
1113 nj('switchoverTimes') = soTimes;
1114 end
1115 nodesCellTmp{njIdx} = nj;
1116end
1117result('nodes') = nodesCellTmp;
1118
1119% --- Heterogeneous Server Types ---
1120try
1121 nodesCellTmp = result('nodes');
1122 for i = 1:M
1123 nodeObj = nodes{i};
1124 if isa(nodeObj, 'Queue') && nodeObj.isHeterogeneous()
1125 stArr = {};
1126 for ti = 1:length(nodeObj.serverTypes)
1127 st = nodeObj.serverTypes{ti};
1128 stj = containers.Map();
1129 stj('name') = st.name;
1130 stj('count') = st.numOfServers;
1131 % Compatible classes
1132 ccNames = {};
1133 for cci = 1:length(st.compatibleClasses)
1134 ccNames{end+1} = st.compatibleClasses{cci}.name; %#ok<AGROW>
1135 end
1136 if ~isempty(ccNames)
1137 stj('compatibleClasses') = ccNames;
1138 end
1139 % Per-class service distributions
1140 svcMap = containers.Map();
1141 for r = 1:K
1142 jc = classes{r};
1143 dist = nodeObj.getHeteroService(jc, st);
1144 if ~isempty(dist) && ~isa(dist, 'Disabled')
1145 svcMap(jc.name) = dist2json(dist);
1146 end
1147 end
1148 if svcMap.Count > 0
1149 stj('service') = svcMap;
1150 end
1151 stArr{end+1} = stj; %#ok<AGROW>
1152 end
1153 if ~isempty(stArr)
1154 for nj_idx = 1:length(nodesCellTmp)
1155 nj = nodesCellTmp{nj_idx};
1156 if strcmp(nj('name'), nodeObj.name)
1157 nj('serverTypes') = stArr;
1158 % Scheduling policy
1159 policy = nodeObj.getHeteroSchedPolicy();
1160 if ~isempty(policy) && policy ~= HeteroSchedPolicy.ORDER
1161 nj('heteroSchedPolicy') = HeteroSchedPolicy.toText(policy);
1162 end
1163 nodesCellTmp{nj_idx} = nj;
1164 break;
1165 end
1166 end
1167 end
1168 end
1169 end
1170 result('nodes') = nodesCellTmp;
1171catch
1172end
1173
1174% --- Balking, Retrial, Patience, Orbit Impatience, Immediate Feedback ---
1175% Deliberately no try/catch: a bare catch here used to silently drop this entire block
1176nodesCellTmp = result('nodes');
1177nodeByName = containers.Map();
1178for i = 1:M
1179 nodeByName(nodes{i}.name) = nodes{i};
1180end
1181for nj_idx = 1:length(nodesCellTmp)
1182 nj = nodesCellTmp{nj_idx};
1183 nodeName = nj('name');
1184 nodeObj = nodeByName(nodeName);
1185 % Immediate feedback is a per-class node property on any Station.
1186 if isa(nodeObj, 'Queue')
1187 ifMap = containers.Map();
1188 for r = 1:K
1189 if nodeObj.hasImmediateFeedback(classes{r})
1190 ifMap(classes{r}.name) = true;
1191 end
1192 end
1193 if ifMap.Count > 0
1194 nj('immediateFeedback') = ifMap;
1195 end
1196 end
1197 if isa(nodeObj, 'Queue')
1198 % Balking
1199 balkJson = containers.Map();
1200 for r = 1:K
1201 jc = classes{r};
1202 if nodeObj.hasBalking(jc)
1203 [strategy, thresholds] = nodeObj.getBalking(jc);
1204 bjc = containers.Map();
1205 switch strategy
1206 case BalkingStrategy.QUEUE_LENGTH, bjc('strategy') = 'QUEUE_LENGTH';
1207 case BalkingStrategy.EXPECTED_WAIT, bjc('strategy') = 'EXPECTED_WAIT';
1208 case BalkingStrategy.COMBINED, bjc('strategy') = 'COMBINED';
1209 end
1210 thArr = {};
1211 for ti = 1:length(thresholds)
1212 th = thresholds{ti};
1213 tjson = containers.Map();
1214 tjson('minJobs') = th{1};
1215 if isinf(th{2})
1216 tjson('maxJobs') = -1;
1217 else
1218 tjson('maxJobs') = th{2};
1219 end
1220 tjson('probability') = th{3};
1221 thArr{end+1} = tjson;
1222 end
1223 bjc('thresholds') = thArr;
1224 balkJson(jc.name) = bjc;
1225 end
1226 end
1227 if balkJson.Count > 0
1228 nj('balking') = balkJson;
1229 end
1230 % Retrial
1231 retrialJson = containers.Map();
1232 for r = 1:K
1233 jc = classes{r};
1234 if nodeObj.hasRetrial(jc)
1235 [delayDist, maxAttempts] = nodeObj.getRetrial(jc);
1236 rjc = containers.Map();
1237 rjc('delay') = dist2json(delayDist);
1238 rjc('maxAttempts') = maxAttempts;
1239 retrialJson(jc.name) = rjc;
1240 end
1241 end
1242 if retrialJson.Count > 0
1243 nj('retrial') = retrialJson;
1244 end
1245 % Patience
1246 patienceJson = containers.Map();
1247 for r = 1:K
1248 jc = classes{r};
1249 patDist = nodeObj.getPatience(jc);
1250 if ~isempty(patDist) && ~isa(patDist, 'Disabled')
1251 pjc = containers.Map();
1252 pjc('distribution') = dist2json(patDist);
1253 impType = nodeObj.getImpatienceType(jc);
1254 if ~isempty(impType)
1255 pjc('impatienceType') = ImpatienceType.toText(impType);
1256 end
1257 patienceJson(jc.name) = pjc;
1258 end
1259 end
1260 if patienceJson.Count > 0
1261 nj('patience') = patienceJson;
1262 end
1263 % Orbit impatience (abandonment from the retrial orbit), distinct from
1264 % the queue patience above.
1265 orbitJson = containers.Map();
1266 for r = 1:K
1267 jc = classes{r};
1268 orbDist = nodeObj.getOrbitImpatience(jc);
1269 if ~isempty(orbDist) && ~isa(orbDist, 'Disabled')
1270 orbitJson(jc.name) = dist2json(orbDist);
1271 end
1272 end
1273 if orbitJson.Count > 0
1274 nj('orbitImpatience') = orbitJson;
1275 end
1276 % Batch rejection probability (retrial queues), per class
1277 brpJson = containers.Map();
1278 for r = 1:K
1279 jc = classes{r};
1280 brp = nodeObj.getBatchRejectProbability(jc);
1281 if ~isempty(brp) && brp > 0
1282 brpJson(jc.name) = brp;
1283 end
1284 end
1285 if brpJson.Count > 0
1286 nj('batchRejectProb') = brpJson;
1287 end
1288 end
1289 nodesCellTmp{nj_idx} = nj;
1290end
1291result('nodes') = nodesCellTmp;
1292
1293% --- Finite Capacity Regions ---
1294try
1295 regions = model.regions;
1296 if ~isempty(regions)
1297 fcrArray = {};
1298 for ri = 1:length(regions)
1299 reg = regions{ri};
1300 rj = containers.Map();
1301 rj('name') = reg.name;
1302 % Stations with per-class details
1303 stationsJson = {};
1304 for ni = 1:length(reg.nodes)
1305 sj = containers.Map();
1306 sj('node') = reg.nodes{ni}.name;
1307 % Per-class classCap
1308 if isprop(reg, 'classMaxJobs') && ~isempty(reg.classMaxJobs)
1309 ccMap = containers.Map();
1310 for r = 1:K
1311 jc = classes{r};
1312 if r <= length(reg.classMaxJobs) && isfinite(reg.classMaxJobs(r))
1313 ccMap(jc.name) = reg.classMaxJobs(r);
1314 end
1315 end
1316 if ccMap.Count > 0
1317 sj('classCap') = ccMap;
1318 end
1319 end
1320 % Per-class classWeight
1321 if isprop(reg, 'classWeight') && ~isempty(reg.classWeight)
1322 cwMap = containers.Map();
1323 for r = 1:K
1324 jc = classes{r};
1325 if r <= length(reg.classWeight) && reg.classWeight(r) ~= 1
1326 cwMap(jc.name) = reg.classWeight(r);
1327 end
1328 end
1329 if cwMap.Count > 0
1330 sj('classWeight') = cwMap;
1331 end
1332 end
1333 % Per-class classSize
1334 if isprop(reg, 'classSize') && ~isempty(reg.classSize)
1335 csMap = containers.Map();
1336 for r = 1:K
1337 jc = classes{r};
1338 if r <= length(reg.classSize) && reg.classSize(r) ~= 1
1339 csMap(jc.name) = reg.classSize(r);
1340 end
1341 end
1342 if csMap.Count > 0
1343 sj('classSize') = csMap;
1344 end
1345 end
1346 stationsJson{end+1} = sj; %#ok<AGROW>
1347 end
1348 rj('stations') = stationsJson;
1349 if isprop(reg, 'globalMaxJobs') && isfinite(reg.globalMaxJobs)
1350 rj('globalMaxJobs') = reg.globalMaxJobs;
1351 end
1352 if isprop(reg, 'globalMaxMemory') && isfinite(reg.globalMaxMemory)
1353 rj('globalMaxMemory') = reg.globalMaxMemory;
1354 end
1355 % Per-class classMaxJobs at region level
1356 if isprop(reg, 'classMaxJobs') && ~isempty(reg.classMaxJobs)
1357 cmjMap = containers.Map();
1358 for r = 1:K
1359 jc = classes{r};
1360 if r <= length(reg.classMaxJobs) && isfinite(reg.classMaxJobs(r))
1361 cmjMap(jc.name) = reg.classMaxJobs(r);
1362 end
1363 end
1364 if cmjMap.Count > 0
1365 rj('classMaxJobs') = cmjMap;
1366 end
1367 end
1368 % Region classMaxMemory folds into the equivalent job cap on read -- see _kb/09-ldes-and-cache.md
1369 if isprop(reg, 'classMaxMemory') && ~isempty(reg.classMaxMemory)
1370 cmmMap = containers.Map();
1371 for r = 1:K
1372 jc = classes{r};
1373 if r <= length(reg.classMaxMemory) && isfinite(reg.classMaxMemory(r)) ...
1374 && reg.classMaxMemory(r) >= 0
1375 cmmMap(jc.name) = reg.classMaxMemory(r);
1376 end
1377 end
1378 if cmmMap.Count > 0
1379 rj('classMaxMemory') = cmmMap;
1380 end
1381 end
1382 % Drop rule
1383 if isprop(reg, 'dropRule') && ~isempty(reg.dropRule)
1384 drMap = containers.Map();
1385 for r = 1:K
1386 jc = classes{r};
1387 if r <= length(reg.dropRule)
1388 drStr = droprule_to_str(reg.dropRule(r));
1389 if ~isempty(drStr)
1390 drMap(jc.name) = drStr;
1391 end
1392 end
1393 end
1394 if drMap.Count > 0
1395 rj('dropRule') = drMap;
1396 end
1397 end
1398 % Linear constraints (A * x <= b) if present
1399 if ismethod(reg, 'hasLinearConstraints') && reg.hasLinearConstraints()
1400 [A, b] = reg.getLinearConstraints();
1401 if ~isempty(A) && ~isempty(b)
1402 % Serialize A row-by-row as cell of arrays for JSON compat
1403 Acell = cell(1, size(A,1));
1404 for ri = 1:size(A,1)
1405 Acell{ri} = A(ri,:);
1406 end
1407 rj('constraintA') = Acell;
1408 rj('constraintB') = b(:)';
1409 end
1410 end
1411 fcrArray{end+1} = rj; %#ok<AGROW>
1412 end
1413 if ~isempty(fcrArray)
1414 result('finiteCapacityRegions') = fcrArray;
1415 end
1416 end
1417catch
1418end
1419
1420% --- Rewards ---
1421rewardsJson = rewards2json(model);
1422if ~isempty(rewardsJson)
1423 result('rewards') = rewardsJson;
1424end
1425end
1426
1427
1428% =========================================================================
1429% Reward serialization
1430% =========================================================================
1431
1432function rewardsJson = rewards2json(model)
1433% Serialize the model's reward definitions in the declarative form
1434% {name, type, node, class}
1435% Only rewards created through a Reward.* template carry the structural
1436% metadata needed to reproduce them. A reward defined from a bare function
1437% handle (or via Reward.custom) is not reproducible from JSON: warn and omit
1438% it rather than emit a reward that would be wrong on reload.
1439rewardsJson = {};
1440sn = model.sn;
1441if isempty(sn) || ~isfield(sn, 'reward') || isempty(sn.reward)
1442 return;
1443end
1444% Emitted in name order to match Python/JAR (JAR's HashMap has no insertion order)
1445rewardNames = cell(1, length(sn.reward));
1446for i = 1:length(sn.reward)
1447 rewardNames{i} = sn.reward{i}.name;
1448end
1449[~, order] = sort(rewardNames);
1450for oi = 1:length(order)
1451 rw = sn.reward{order(oi)};
1452 descriptor = [];
1453 if isfield(rw, 'descriptor')
1454 descriptor = rw.descriptor;
1455 end
1456 if isempty(descriptor) || ~isa(descriptor, 'RewardDescriptor')
1457 line_warning(mfilename, sprintf(['Reward "%s" is defined by a bare function handle and cannot be ' ...
1458 'serialized to JSON; it is omitted from the saved model. Use a Reward.* template ' ...
1459 '(Reward.queueLength/utilization/blocking) for a serializable reward.'], rw.name));
1460 continue;
1461 end
1462 if strcmp(descriptor.kind, 'Custom')
1463 line_warning(mfilename, sprintf(['Reward "%s" is a custom reward wrapping an arbitrary function and ' ...
1464 'cannot be serialized to JSON; it is omitted from the saved model.'], rw.name));
1465 continue;
1466 end
1467 rj = containers.Map();
1468 rj('name') = rw.name;
1469 rj('type') = descriptor.kind;
1470 if isempty(descriptor.node)
1471 line_warning(mfilename, sprintf(['Reward "%s" of type %s has no associated node and cannot be ' ...
1472 'serialized to JSON; it is omitted from the saved model.'], rw.name, descriptor.kind));
1473 continue;
1474 end
1475 rj('node') = descriptor.node.name;
1476 if ~isempty(descriptor.jobclass)
1477 rj('class') = descriptor.jobclass.name;
1478 end
1479 rewardsJson{end+1} = rj; %#ok<AGROW>
1480end
1481end
1482
1483
1484% =========================================================================
1485% LayeredNetwork serialization
1486% =========================================================================
1487
1488function result = layered2json(model)
1489result = containers.Map();
1490result('type') = 'LayeredNetwork';
1491result('name') = model.getName();
1492
1493% --- Processors ---
1494procsJson = {};
1495hosts = model.hosts;
1496for i = 1:length(hosts)
1497 h = hosts{i};
1498 pj = containers.Map();
1499 pj('name') = h.name;
1500 mult = h.multiplicity;
1501 if ~isfinite(mult)
1502 pj('multiplicity') = inf_multiplicity();
1503 elseif mult > 1
1504 pj('multiplicity') = mult;
1505 end
1506 schedStr = h.scheduling;
1507 if ~isempty(schedStr) && ~strcmpi(schedStr, 'inf')
1508 pj('scheduling') = upper(schedStr);
1509 end
1510 q = h.quantum;
1511 if q > 0 && q ~= 0.001
1512 pj('quantum') = q;
1513 end
1514 sf = h.speedFactor;
1515 if sf ~= 1.0
1516 pj('speedFactor') = sf;
1517 end
1518 repl = h.replication;
1519 if repl > 1
1520 pj('replication') = repl;
1521 end
1522 procsJson{end+1} = pj; %#ok<AGROW>
1523end
1524result('hosts') = procsJson;
1525
1526% --- Tasks ---
1527tasksJson = {};
1528tasksList = model.tasks;
1529for i = 1:length(tasksList)
1530 t = tasksList{i};
1531 tj = containers.Map();
1532 tj('name') = t.name;
1533 if ~isempty(t.parent)
1534 tj('host') = t.parent.name;
1535 end
1536 mult = t.multiplicity;
1537 if ~isfinite(mult)
1538 tj('multiplicity') = inf_multiplicity();
1539 elseif mult > 1
1540 tj('multiplicity') = mult;
1541 end
1542 schedStr = t.scheduling;
1543 if ~isempty(schedStr)
1544 tj('scheduling') = upper(schedStr);
1545 end
1546 % Think time
1547 ttMean = t.thinkTimeMean;
1548 if ~isempty(ttMean) && ttMean > GlobalConstants.FineTol
1549 if ~isempty(t.thinkTime) && isa(t.thinkTime, 'Distribution')
1550 tj('thinkTime') = dist2json(t.thinkTime);
1551 else
1552 params = containers.Map();
1553 params('lambda') = 1.0 / ttMean;
1554 dj = containers.Map();
1555 dj('type') = 'Exp';
1556 dj('params') = params;
1557 tj('thinkTime') = dj;
1558 end
1559 end
1560 % Fan in
1561 if ~isempty(t.fanInSource) && ischar(t.fanInSource) && ~isempty(t.fanInSource)
1562 fi = containers.Map();
1563 fi(t.fanInSource) = t.fanInValue;
1564 tj('fanIn') = fi;
1565 end
1566 % Fan out
1567 if ~isempty(t.fanOutDest)
1568 fo = containers.Map();
1569 for fi_idx = 1:length(t.fanOutDest)
1570 fo(t.fanOutDest{fi_idx}) = t.fanOutValue(fi_idx);
1571 end
1572 tj('fanOut') = fo;
1573 end
1574 repl = t.replication;
1575 if repl > 1
1576 tj('replication') = repl;
1577 end
1578 % FunctionTask detection
1579 if isa(t, 'FunctionTask')
1580 tj('taskType') = 'FunctionTask';
1581 end
1582 % Setup time / delay-off time (on any Task)
1583 if ~isempty(t.setupTime) && isa(t.setupTime, 'Distribution')
1584 stMean = t.setupTimeMean;
1585 if stMean > GlobalConstants.FineTol
1586 tj('setupTime') = dist2json(t.setupTime);
1587 end
1588 end
1589 if ~isempty(t.delayOffTime) && isa(t.delayOffTime, 'Distribution')
1590 dotMean = t.delayOffTimeMean;
1591 if dotMean > GlobalConstants.FineTol
1592 tj('delayOffTime') = dist2json(t.delayOffTime);
1593 end
1594 end
1595 % CacheTask detection
1596 if isa(t, 'CacheTask')
1597 tj('taskType') = 'CacheTask';
1598 tj('totalItems') = t.items;
1599 tj('cacheCapacity') = t.itemLevelCap;
1600 rs = t.replacestrategy;
1601 rsNameMap = containers.Map({ReplacementStrategy.RR, ReplacementStrategy.FIFO, ...
1602 ReplacementStrategy.SFIFO, ReplacementStrategy.LRU}, ...
1603 {'RR', 'FIFO', 'SFIFO', 'LRU'});
1604 if rsNameMap.isKey(rs)
1605 tj('replacementStrategy') = rsNameMap(rs);
1606 else
1607 tj('replacementStrategy') = 'FIFO';
1608 end
1609 end
1610 tasksJson{end+1} = tj; %#ok<AGROW>
1611end
1612result('tasks') = tasksJson;
1613
1614% --- Entries ---
1615entriesJson = {};
1616entriesList = model.entries;
1617for i = 1:length(entriesList)
1618 e = entriesList{i};
1619 ej = containers.Map();
1620 ej('name') = e.name;
1621 if ~isempty(e.parent)
1622 ej('task') = e.parent.name;
1623 end
1624 % Entry arrival distribution
1625 if ~isempty(e.arrival) && isa(e.arrival, 'Distribution')
1626 ej('arrival') = dist2json(e.arrival);
1627 end
1628 % ItemEntry detection
1629 if isa(e, 'ItemEntry')
1630 ej('entryType') = 'ItemEntry';
1631 ej('totalItems') = e.cardinality;
1632 if ~isempty(e.popularity)
1633 if isa(e.popularity, 'Distribution')
1634 ej('accessProb') = dist2json(e.popularity);
1635 end
1636 end
1637 end
1638 entriesJson{end+1} = ej; %#ok<AGROW>
1639end
1640result('entries') = entriesJson;
1641
1642% --- Build reply map: activityName -> entryName ---
1643replyMap = containers.Map();
1644for i = 1:length(entriesList)
1645 e = entriesList{i};
1646 if ~isempty(e.replyActivity)
1647 for j = 1:length(e.replyActivity)
1648 replyMap(e.replyActivity{j}) = e.name;
1649 end
1650 end
1651end
1652
1653% --- Activities ---
1654actsJson = {};
1655actsList = model.activities;
1656for i = 1:length(actsList)
1657 a = actsList{i};
1658 aj = containers.Map();
1659 aj('name') = a.name;
1660 if ~isempty(a.parent)
1661 if isa(a.parent, 'Task') || isa(a.parent, 'Entry')
1662 aj('task') = a.parent.name;
1663 elseif ischar(a.parent) || isstring(a.parent)
1664 aj('task') = char(a.parent);
1665 elseif ischar(a.parentName) && ~isempty(a.parentName)
1666 aj('task') = a.parentName;
1667 end
1668 elseif ~isempty(a.parentName) && ischar(a.parentName)
1669 aj('task') = a.parentName;
1670 end
1671 % Host demand
1672 if ~isempty(a.hostDemand) && isa(a.hostDemand, 'Distribution')
1673 if ~isa(a.hostDemand, 'Immediate')
1674 aj('hostDemand') = dist2json(a.hostDemand);
1675 end
1676 elseif ~isempty(a.hostDemandMean) && a.hostDemandMean > GlobalConstants.FineTol
1677 params = containers.Map();
1678 params('lambda') = 1.0 / a.hostDemandMean;
1679 dj = containers.Map();
1680 dj('type') = 'Exp';
1681 dj('params') = params;
1682 aj('hostDemand') = dj;
1683 end
1684 % Bound to entry
1685 if ~isempty(a.boundToEntry)
1686 aj('boundToEntry') = a.boundToEntry;
1687 end
1688 % Replies to entry
1689 if replyMap.isKey(a.name)
1690 aj('repliesTo') = replyMap(a.name);
1691 end
1692 % Synch calls
1693 if ~isempty(a.syncCallDests)
1694 synchCalls = {};
1695 for j = 1:length(a.syncCallDests)
1696 sc = containers.Map();
1697 sc('dest') = a.syncCallDests{j};
1698 if j <= length(a.syncCallMeans) && a.syncCallMeans(j) ~= 1.0
1699 sc('mean') = a.syncCallMeans(j);
1700 end
1701 synchCalls{end+1} = sc; %#ok<AGROW>
1702 end
1703 aj('synchCalls') = synchCalls;
1704 end
1705 % Asynch calls
1706 if ~isempty(a.asyncCallDests)
1707 asynchCalls = {};
1708 for j = 1:length(a.asyncCallDests)
1709 ac = containers.Map();
1710 ac('dest') = a.asyncCallDests{j};
1711 if j <= length(a.asyncCallMeans) && a.asyncCallMeans(j) ~= 1.0
1712 ac('mean') = a.asyncCallMeans(j);
1713 end
1714 asynchCalls{end+1} = ac; %#ok<AGROW>
1715 end
1716 aj('asynchCalls') = asynchCalls;
1717 end
1718 actsJson{end+1} = aj; %#ok<AGROW>
1719end
1720result('activities') = actsJson;
1721
1722% --- Precedences ---
1723precsJson = {};
1724for i = 1:length(tasksList)
1725 t = tasksList{i};
1726 precs = t.precedences;
1727 if isempty(precs), continue; end
1728 for j = 1:length(precs)
1729 p = precs(j);
1730 pj = containers.Map();
1731 pj('task') = t.name;
1732
1733 preType = p.preType;
1734 postType = p.postType;
1735
1736 % Determine JSON precedence type and collect activity names
1737 if preType == ActivityPrecedenceType.PRE_SEQ && postType == ActivityPrecedenceType.POST_SEQ
1738 pj('type') = 'Serial';
1739 pj('activities') = [p.preActs, p.postActs];
1740 elseif preType == ActivityPrecedenceType.PRE_SEQ && postType == ActivityPrecedenceType.POST_AND
1741 pj('type') = 'AndFork';
1742 pj('activities') = [p.preActs, p.postActs];
1743 elseif preType == ActivityPrecedenceType.PRE_AND && postType == ActivityPrecedenceType.POST_SEQ
1744 pj('type') = 'AndJoin';
1745 pj('activities') = [p.preActs, p.postActs];
1746 elseif preType == ActivityPrecedenceType.PRE_SEQ && postType == ActivityPrecedenceType.POST_OR
1747 pj('type') = 'OrFork';
1748 pj('activities') = [p.preActs, p.postActs];
1749 if ~isempty(p.postParams)
1750 pj('probabilities') = p.postParams(:)';
1751 end
1752 elseif preType == ActivityPrecedenceType.PRE_OR && postType == ActivityPrecedenceType.POST_SEQ
1753 pj('type') = 'OrJoin';
1754 pj('activities') = [p.preActs, p.postActs];
1755 elseif postType == ActivityPrecedenceType.POST_LOOP
1756 pj('type') = 'Loop';
1757 % For Loop, preActs is the trigger, postActs is the loop body
1758 pj('activities') = p.postActs;
1759 if ~isempty(p.preActs)
1760 pj('preActivity') = p.preActs{1};
1761 end
1762 if ~isempty(p.postParams)
1763 pj('loopCount') = p.postParams(1);
1764 end
1765 elseif preType == ActivityPrecedenceType.PRE_SEQ && postType == ActivityPrecedenceType.POST_CACHE
1766 pj('type') = 'CacheAccess';
1767 pj('activities') = [p.preActs, p.postActs];
1768 else
1769 continue;
1770 end
1771 precsJson{end+1} = pj; %#ok<AGROW>
1772 end
1773end
1774if ~isempty(precsJson)
1775 result('precedences') = precsJson;
1776end
1777end
1778
1779
1780% =========================================================================
1781% Workflow serialization
1782% =========================================================================
1783
1784function result = workflow2json(model)
1785% Convert a Workflow to a containers.Map for JSON output.
1786result = containers.Map();
1787result('type') = 'Workflow';
1788result('name') = model.getName();
1789
1790% --- Activities ---
1791actsJson = {};
1792acts = model.activities;
1793for i = 1:length(acts)
1794 act = acts{i};
1795 aj = containers.Map();
1796 aj('name') = act.name;
1797 if ~isempty(act.hostDemand) && isa(act.hostDemand, 'Distribution')
1798 dj = dist2json(act.hostDemand);
1799 if ~isempty(dj)
1800 aj('hostDemand') = dj;
1801 end
1802 end
1803 actsJson{end+1} = aj; %#ok<AGROW>
1804end
1805result('activities') = actsJson;
1806
1807% --- Precedences ---
1808precsJson = {};
1809precs = model.precedences;
1810for i = 1:length(precs)
1811 p = precs(i);
1812 pj = containers.Map();
1813
1814 % preActs
1815 preActsJson = {};
1816 for a = 1:length(p.preActs)
1817 preActsJson{end+1} = p.preActs{a}; %#ok<AGROW>
1818 end
1819 pj('preActs') = preActsJson;
1820
1821 % postActs
1822 postActsJson = {};
1823 for a = 1:length(p.postActs)
1824 postActsJson{end+1} = p.postActs{a}; %#ok<AGROW>
1825 end
1826 pj('postActs') = postActsJson;
1827
1828 % preType / postType - convert numeric IDs to JAR-compatible strings
1829 pj('preType') = prectype_to_str(p.preType);
1830 pj('postType') = prectype_to_str(p.postType);
1831
1832 % preParams
1833 if ~isempty(p.preParams)
1834 pj('preParams') = p.preParams(:)';
1835 end
1836
1837 % postParams
1838 if ~isempty(p.postParams)
1839 pj('postParams') = p.postParams(:)';
1840 end
1841
1842 precsJson{end+1} = pj; %#ok<AGROW>
1843end
1844result('precedences') = precsJson;
1845end
1846
1847
1848% =========================================================================
1849% Environment serialization
1850% =========================================================================
1851
1852function result = environment2json(model)
1853% Convert an Environment to a containers.Map for JSON output.
1854result = containers.Map();
1855result('type') = 'Environment';
1856result('name') = model.getName();
1857
1858E = height(model.envGraph.Nodes);
1859result('numStages') = E;
1860
1861% --- Stages ---
1862stagesJson = {};
1863for e = 1:E
1864 sj = containers.Map();
1865 sj('name') = model.envGraph.Nodes.Name{e};
1866 % Serialize the stage's Network model
1867 if e <= length(model.ensemble) && ~isempty(model.ensemble{e})
1868 sj('model') = network2json(model.ensemble{e});
1869 end
1870 stagesJson{end+1} = sj; %#ok<AGROW>
1871end
1872result('stages') = stagesJson;
1873
1874% --- Transitions ---
1875transJson = {};
1876for e = 1:E
1877 for h = 1:E
1878 if ~isempty(model.env) && e <= size(model.env, 1) && h <= size(model.env, 2) ...
1879 && ~isempty(model.env{e,h}) && ~isa(model.env{e,h}, 'Disabled')
1880 tj = containers.Map();
1881 tj('from') = e - 1; % Convert to 0-indexed for JAR compatibility
1882 tj('to') = h - 1; % Convert to 0-indexed for JAR compatibility
1883 dj = dist2json(model.env{e,h});
1884 if ~isempty(dj)
1885 tj('distribution') = dj;
1886 transJson{end+1} = tj; %#ok<AGROW>
1887 end
1888 end
1889 end
1890end
1891result('transitions') = transJson;
1892
1893% --- Node failures ---
1894% Declarative record of addNodeBreakdown/addNodeRepair; carries the queue-length reset policies (function handles), otherwise unrecoverable
1895nfJson = {};
1896for i = 1:length(model.nodeFailures)
1897 nf = model.nodeFailures{i};
1898 nj = containers.Map();
1899 nj('node') = nf.node;
1900 bj = dist2json(nf.breakdown);
1901 if isempty(bj)
1902 line_warning(mfilename, sprintf(['Node failure on "%s" has a breakdown distribution that cannot be ' ...
1903 'serialized; the nodeFailures entry is omitted.'], nf.node));
1904 continue;
1905 end
1906 nj('breakdownRate') = bj;
1907 if ~isempty(nf.repair)
1908 rj = dist2json(nf.repair);
1909 if isempty(rj)
1910 line_warning(mfilename, sprintf(['Node failure on "%s" has a repair distribution that cannot be ' ...
1911 'serialized; the nodeFailures entry is omitted.'], nf.node));
1912 continue;
1913 end
1914 nj('repairRate') = rj;
1915 end
1916 dj = dist2json(nf.downService);
1917 if isempty(dj)
1918 line_warning(mfilename, sprintf(['Node failure on "%s" has a down-service distribution that cannot ' ...
1919 'be serialized; the nodeFailures entry is omitted.'], nf.node));
1920 continue;
1921 end
1922 nj('downService') = dj;
1923 if strcmp(nf.breakdownResetPolicy, 'custom')
1924 line_warning(mfilename, sprintf(['Node failure on "%s" uses a custom breakdown reset function, which ' ...
1925 'cannot be serialized to JSON; the saved model falls back to the ''keep'' policy on reload.'], nf.node));
1926 else
1927 nj('breakdownResetPolicy') = nf.breakdownResetPolicy;
1928 end
1929 if ~isempty(nf.repairResetPolicy)
1930 if strcmp(nf.repairResetPolicy, 'custom')
1931 line_warning(mfilename, sprintf(['Node failure on "%s" uses a custom repair reset function, which ' ...
1932 'cannot be serialized to JSON; the saved model falls back to the ''keep'' policy on reload.'], nf.node));
1933 else
1934 nj('repairResetPolicy') = nf.repairResetPolicy;
1935 end
1936 end
1937 nfJson{end+1} = nj; %#ok<AGROW>
1938end
1939if ~isempty(nfJson)
1940 result('nodeFailures') = nfJson;
1941end
1942end
1943
1944
1945% =========================================================================
1946% Multiplicity serialization
1947% =========================================================================
1948
1949function v = inf_multiplicity()
1950% Wire sentinel for infinite host/task multiplicity: Java's Integer.MAX_VALUE,
1951% which the JAR uses as its infinite-multiplicity marker. Written as a literal
1952% rather than taken from GlobalConstants.MaxInt, which is settable at runtime:
1953% a sentinel that varies per session would not survive a round-trip between two
1954% differently configured readers.
1955v = 2147483647;
1956end
1957
1958% =========================================================================
1959% Distribution serialization
1960% =========================================================================
1961
1962function d = dist2json(dist)
1963% Convert a Distribution to a containers.Map for JSON output.
1964if isempty(dist)
1965 d = [];
1966 return;
1967end
1968d = containers.Map();
1969cn = builtin('class', dist);
1970switch cn
1971 case 'Disabled'
1972 d('type') = 'Disabled';
1973 case 'Immediate'
1974 d('type') = 'Immediate';
1975 case 'Exp'
1976 d('type') = 'Exp';
1977 params = containers.Map();
1978 params('lambda') = dist.getParam(1).paramValue;
1979 d('params') = params;
1980 case 'Det'
1981 d('type') = 'Det';
1982 params = containers.Map();
1983 params('value') = dist.getParam(1).paramValue;
1984 d('params') = params;
1985 case 'Erlang'
1986 d('type') = 'Erlang';
1987 params = containers.Map();
1988 params('lambda') = dist.getParam(1).paramValue;
1989 params('k') = dist.getParam(2).paramValue;
1990 d('params') = params;
1991 case 'HyperExp'
1992 % param2/param3 both store the FULL n-phase rate vector -- see _kb/09-ldes-and-cache.md
1993 d('type') = 'HyperExp';
1994 params = containers.Map();
1995 p = dist.getParam(1).paramValue;
1996 l1 = dist.getParam(2).paramValue;
1997 l2 = dist.getParam(3).paramValue;
1998 if isscalar(p)
1999 params('p') = [p, 1-p];
2000 params('lambda') = [l1, l2];
2001 else
2002 params('p') = p(:)';
2003 if isscalar(l1) && isscalar(l2)
2004 params('lambda') = [l1, l2];
2005 else
2006 % n-phase: param2 already holds all n rates (param3 duplicates it)
2007 params('lambda') = l1(:)';
2008 end
2009 end
2010 d('params') = params;
2011 case 'Gamma'
2012 d('type') = 'Gamma';
2013 params = containers.Map();
2014 params('alpha') = dist.getParam(1).paramValue;
2015 params('beta') = dist.getParam(2).paramValue;
2016 d('params') = params;
2017 case 'Lognormal'
2018 d('type') = 'Lognormal';
2019 params = containers.Map();
2020 params('mu') = dist.getParam(1).paramValue;
2021 params('sigma') = dist.getParam(2).paramValue;
2022 d('params') = params;
2023 case 'Uniform'
2024 d('type') = 'Uniform';
2025 params = containers.Map();
2026 params('a') = dist.getParam(1).paramValue;
2027 params('b') = dist.getParam(2).paramValue;
2028 d('params') = params;
2029 case 'Zipf'
2030 d('type') = 'Zipf';
2031 params = containers.Map();
2032 params('s') = dist.getParam(3).paramValue;
2033 params('n') = dist.getParam(4).paramValue;
2034 d('params') = params;
2035 case 'Pareto'
2036 d('type') = 'Pareto';
2037 params = containers.Map();
2038 params('alpha') = dist.getParam(1).paramValue;
2039 params('scale') = dist.getParam(2).paramValue;
2040 d('params') = params;
2041 case 'Weibull'
2042 d('type') = 'Weibull';
2043 params = containers.Map();
2044 params('alpha') = dist.getParam(1).paramValue;
2045 params('beta') = dist.getParam(2).paramValue;
2046 d('params') = params;
2047 case 'Normal'
2048 d('type') = 'Normal';
2049 params = containers.Map();
2050 params('mu') = dist.getParam(1).paramValue;
2051 params('sigma') = dist.getParam(2).paramValue;
2052 d('params') = params;
2053 case 'Geometric'
2054 d('type') = 'Geometric';
2055 params = containers.Map();
2056 params('p') = dist.getParam(1).paramValue;
2057 d('params') = params;
2058 case 'Binomial'
2059 d('type') = 'Binomial';
2060 params = containers.Map();
2061 params('n') = dist.getParam(1).paramValue;
2062 params('p') = dist.getParam(2).paramValue;
2063 d('params') = params;
2064 case 'Poisson'
2065 d('type') = 'Poisson';
2066 params = containers.Map();
2067 params('lambda') = dist.getParam(1).paramValue;
2068 d('params') = params;
2069 case 'Bernoulli'
2070 d('type') = 'Bernoulli';
2071 params = containers.Map();
2072 params('p') = dist.getParam(1).paramValue;
2073 d('params') = params;
2074 case 'DiscreteUniform'
2075 d('type') = 'DiscreteUniform';
2076 params = containers.Map();
2077 params('min') = dist.getParam(1).paramValue;
2078 params('max') = dist.getParam(2).paramValue;
2079 d('params') = params;
2080 case {'Coxian', 'Cox2'}
2081 d('type') = 'Coxian';
2082 params = containers.Map();
2083 params('mu') = dist.getMu()';
2084 params('phi') = dist.getPhi()';
2085 d('params') = params;
2086 case {'PH', 'APH'}
2087 % Keep the concrete class: an APH written back as a generic PH is a
2088 % lossy downgrade, since solver feature sets admit APH but not PH.
2089 d('type') = cn;
2090 ph = containers.Map();
2091 alpha = dist.getInitProb();
2092 T = dist.getSubgenerator();
2093 if isvector(alpha)
2094 ph('alpha') = alpha(:)';
2095 else
2096 ph('alpha') = alpha;
2097 end
2098 ph('T') = T;
2099 d('ph') = ph;
2100 case 'MAP'
2101 d('type') = 'MAP';
2102 mapSpec = containers.Map();
2103 mapSpec('D0') = dist.getParam(1).paramValue;
2104 mapSpec('D1') = dist.getParam(2).paramValue;
2105 d('map') = mapSpec;
2106 case 'DMAP'
2107 % Discrete-time MAP. Distinct from MAP on the wire: D0+D1 is stochastic,
2108 % not an infinitesimal generator, so a reader must not rebuild it as MAP.
2109 d('type') = 'DMAP';
2110 params = containers.Map();
2111 params('D0') = dist.getParam(1).paramValue;
2112 params('D1') = dist.getParam(2).paramValue;
2113 d('params') = params;
2114 case {'ME', 'CME'}
2115 % CME goes on the wire as its (alpha, A) ME representation; the subclass tag is not preserved
2116 d('type') = 'ME';
2117 params = containers.Map();
2118 alphaME = dist.getParam(1).paramValue;
2119 params('alpha') = alphaME(:)';
2120 params('A') = dist.getParam(2).paramValue;
2121 d('params') = params;
2122 case 'RAP'
2123 d('type') = 'RAP';
2124 params = containers.Map();
2125 params('H0') = dist.getParam(1).paramValue;
2126 params('H1') = dist.getParam(2).paramValue;
2127 d('params') = params;
2128 case 'BMAP'
2129 % Must never be emitted through the MMAP branch (mark index is a batch size, not a class) -- see _kb/09-ldes-and-cache.md
2130 d('type') = 'BMAP';
2131 params = containers.Map();
2132 Kb = dist.getNumberOfTypes;
2133 dArr = cell(1, Kb + 1);
2134 dArr{1} = dist.getParam(1).paramValue; % D0
2135 for kb = 1:Kb
2136 dArr{1+kb} = dist.getParam(2+kb).paramValue; % Dk, batch size k
2137 end
2138 params('D') = dArr;
2139 d('params') = params;
2140 case 'MMDP2'
2141 d('type') = 'MMDP2';
2142 params = containers.Map();
2143 params('r0') = dist.getParam(1).paramValue;
2144 params('r1') = dist.getParam(2).paramValue;
2145 params('sigma0') = dist.getParam(3).paramValue;
2146 params('sigma1') = dist.getParam(4).paramValue;
2147 d('params') = params;
2148 case 'MarkedMMPP'
2149 % Extends MarkovModulated, not MarkedMAP, so needs its own branch -- see _kb/09-ldes-and-cache.md
2150 d('type') = 'MarkedMMPP';
2151 params = containers.Map();
2152 Km = dist.getNumberOfTypes;
2153 dArr = cell(1, Km + 1);
2154 dArr{1} = dist.getParam(1).paramValue; % D0
2155 for km = 1:Km
2156 dArr{1+km} = dist.getParam(2+km).paramValue; % D1k
2157 end
2158 params('D') = dArr;
2159 params('K') = Km;
2160 d('params') = params;
2161 case 'EmpiricalCDF'
2162 % data is [F, x] rows (cdf value, support point), as assembled by the
2163 % two-argument ctor; emit the two columns separately.
2164 d('type') = 'EmpiricalCDF';
2165 params = containers.Map();
2166 ecdf = dist.data;
2167 params('F') = ecdf(:, 1)';
2168 params('x') = ecdf(:, 2)';
2169 d('params') = params;
2170 case 'Expolynomial'
2171 % Nested object, mirroring the Python writer (the density is a Sirio
2172 % expression string, not a numeric parameter).
2173 d('type') = 'Expolynomial';
2174 ep = containers.Map();
2175 ep('density') = dist.getParam(1).paramValue;
2176 ep('eft') = dist.getParam(2).paramValue;
2177 lft = dist.getParam(3).paramValue;
2178 if isfinite(lft)
2179 ep('lft') = lft;
2180 else
2181 ep('lft') = 'Inf';
2182 end
2183 d('expolynomial') = ep;
2184 case 'MarkedMAP'
2185 % Marked MAP: {D0, per-mark D1k}; the aggregate D1 is rebuilt on load
2186 d('type') = 'MMAP';
2187 mmapSpec = containers.Map();
2188 mmapSpec('D0') = dist.getParam(1).paramValue;
2189 Kmarks = dist.getNumberOfTypes;
2190 d1k = cell(1, Kmarks);
2191 for km = 1:Kmarks
2192 d1k{km} = dist.getParam(2+km).paramValue;
2193 end
2194 mmapSpec('D1k') = d1k;
2195 d('mmap') = mmapSpec;
2196 case 'MMPP2'
2197 d('type') = 'MMPP2';
2198 params = containers.Map();
2199 params('lambda0') = dist.getParam(1).paramValue;
2200 params('lambda1') = dist.getParam(2).paramValue;
2201 params('sigma0') = dist.getParam(3).paramValue;
2202 params('sigma1') = dist.getParam(4).paramValue;
2203 d('params') = params;
2204 case 'NHPP'
2205 d('type') = 'NHPP';
2206 params = containers.Map();
2207 % num2cell keeps a single-segment (1x1) rate vector from collapsing to
2208 % a JSON scalar, which the Gson reader would reject.
2209 nhppBp = double(dist.getBreakpoints());
2210 nhppRt = double(dist.getRates());
2211 params('breakpoints') = num2cell(nhppBp(:)');
2212 params('rates') = num2cell(nhppRt(:)');
2213 params('cyclic') = dist.isCyclic();
2214 d('params') = params;
2215 case 'DiscreteSampler'
2216 d('type') = 'DiscreteSampler';
2217 params = containers.Map();
2218 params('p') = dist.getParam(1).paramValue(:)';
2219 params('x') = dist.getParam(2).paramValue(:)';
2220 d('params') = params;
2221 case 'Replayer'
2222 d('type') = 'Replayer';
2223 params = containers.Map();
2224 params('fileName') = dist.getParam(1).paramValue;
2225 try
2226 params('mean') = dist.getMean();
2227 catch
2228 end
2229 d('params') = params;
2230 % Save APH fit as fallback
2231 try
2232 aphDist = dist.fitAPH();
2233 if ~isempty(aphDist) && isa(aphDist, 'Distribution')
2234 ph = containers.Map();
2235 alpha = aphDist.getParam(1).paramValue;
2236 T = aphDist.getParam(2).paramValue;
2237 if isvector(alpha)
2238 ph('alpha') = alpha(:)';
2239 else
2240 ph('alpha') = alpha;
2241 end
2242 ph('T') = T;
2243 d('ph') = ph;
2244 end
2245 catch
2246 end
2247 case 'Prior'
2248 d('type') = 'Prior';
2249 alts = {};
2250 for ai = 1:dist.getNumAlternatives()
2251 altDist = dist.getAlternative(ai);
2252 altJson = dist2json(altDist);
2253 if ~isempty(altJson)
2254 alts{end+1} = altJson; %#ok<AGROW>
2255 end
2256 end
2257 d('distributions') = alts;
2258 d('probabilities') = dist.probabilities(:)';
2259 otherwise
2260 % Unmatched type: warn and emit real type name + mean/SCV, not a silently mislabeled Exp -- see _kb/09-ldes-and-cache.md
2261 line_warning(mfilename, sprintf(['Distribution "%s" has no JSON representation; ' ...
2262 'saving its mean and SCV only. The reloaded model will use an APH fitted ' ...
2263 'to those two moments.\n'], cn));
2264 m = dist.getMean();
2265 s = dist.getSCV();
2266 d('type') = cn;
2267 params = containers.Map();
2268 params('mean') = m;
2269 params('scv') = s;
2270 d('params') = params;
2271end
2272end
2273
2274
2275% =========================================================================
2276% JSON encoding
2277% =========================================================================
2278
2279function s = encode_value(val, indent)
2280% Recursively encode a MATLAB value to JSON string.
2281if nargin < 2, indent = 0; end
2282pad = repmat(' ', 1, indent);
2283pad2 = repmat(' ', 1, indent + 2);
2284
2285if isa(val, 'containers.Map')
2286 ks = val.keys();
2287 if isempty(ks)
2288 s = '{}';
2289 else
2290 parts = cell(1, length(ks));
2291 for i = 1:length(ks)
2292 k = ks{i};
2293 v = val(k);
2294 parts{i} = sprintf('%s"%s": %s', pad2, json_escape(k), encode_value(v, indent + 2));
2295 end
2296 s = sprintf('{\n%s\n%s}', strjoin(parts, sprintf(',\n')), pad);
2297 end
2298elseif ischar(val) || isstring(val)
2299 s = sprintf('"%s"', json_escape(char(val)));
2300elseif islogical(val) && isscalar(val)
2301 if val, s = 'true'; else, s = 'false'; end
2302elseif isnumeric(val) && isscalar(val)
2303 if isnan(val)
2304 s = 'null';
2305 elseif isinf(val)
2306 if val > 0, s = '"Infinity"'; else, s = '"-Infinity"'; end
2307 elseif val == floor(val) && abs(val) < 1e15
2308 s = sprintf('%d', val);
2309 else
2310 s = sprintf('%.15g', val);
2311 end
2312elseif isnumeric(val) && isvector(val) && ~isscalar(val)
2313 parts = cell(1, length(val));
2314 for i = 1:length(val)
2315 parts{i} = encode_value(val(i), 0);
2316 end
2317 s = ['[', strjoin(parts, ', '), ']'];
2318elseif isnumeric(val) && ismatrix(val) && ~isvector(val)
2319 rows = cell(1, size(val, 1));
2320 for i = 1:size(val, 1)
2321 rows{i} = encode_value(val(i,:), 0);
2322 end
2323 s = ['[', strjoin(rows, ', '), ']'];
2324elseif iscell(val)
2325 if isempty(val)
2326 s = '[]';
2327 else
2328 parts = cell(1, length(val));
2329 for i = 1:length(val)
2330 parts{i} = sprintf('%s%s', pad2, encode_value(val{i}, indent + 2));
2331 end
2332 s = sprintf('[\n%s\n%s]', strjoin(parts, sprintf(',\n')), pad);
2333 end
2334elseif isstruct(val) && isscalar(val)
2335 fnames = fieldnames(val);
2336 if isempty(fnames)
2337 s = '{}';
2338 else
2339 parts = cell(1, length(fnames));
2340 for i = 1:length(fnames)
2341 fn = fnames{i};
2342 fv = val.(fn);
2343 parts{i} = sprintf('%s"%s": %s', pad2, json_escape(fn), encode_value(fv, indent + 2));
2344 end
2345 s = sprintf('{\n%s\n%s}', strjoin(parts, sprintf(',\n')), pad);
2346 end
2347else
2348 s = 'null';
2349end
2350end
2351
2352function s = json_escape(str)
2353% Escape special characters for JSON strings.
2354s = strrep(str, '\', '\\');
2355s = strrep(s, '"', '\"');
2356s = strrep(s, sprintf('\n'), '\n');
2357s = strrep(s, sprintf('\r'), '\r');
2358s = strrep(s, sprintf('\t'), '\t');
2359end
2360
2361
2362% =========================================================================
2363% Helper functions
2364% =========================================================================
2365
2366function s = node_type_str(node)
2367% Get the JSON node type string for a node object.
2368if isa(node, 'Source'), s = 'Source';
2369elseif isa(node, 'Sink'), s = 'Sink';
2370elseif isa(node, 'Delay'), s = 'Delay';
2371elseif isa(node, 'Cache'), s = 'Cache';
2372elseif isa(node, 'Place'), s = 'Place';
2373elseif isa(node, 'Transition'), s = 'Transition';
2374elseif isa(node, 'Queue'), s = 'Queue';
2375elseif isa(node, 'Fork'), s = 'Fork';
2376elseif isa(node, 'Join'), s = 'Join';
2377elseif isa(node, 'Router'), s = 'Router';
2378elseif isa(node, 'ClassSwitch'), s = 'ClassSwitch';
2379else, s = 'Queue';
2380end
2381end
2382
2383function s = sched_id_to_str(id)
2384% Map a SchedStrategy numeric ID to the wire enum name.
2385%
2386% The wire carries enum NAMES, uppercased, matching the JAR enum constants
2387% (jline.lang.constant.SchedStrategy) one-for-one for all 40 strategies. Do not
2388% reintroduce a hand-rolled whitelist here: the previous one covered 23 of 40 and
2389% silently degraded SRPT/FSP/EDD/EDF/FB/LCFSPI/PSJF/LRPT/SETF/LPS/... to FCFS.
2390% SchedStrategy.toText errors on an unknown id rather than inventing a default.
2391s = upper(SchedStrategy.toText(SchedStrategy.toId(id)));
2392end
2393
2394function s = repl_to_str(id)
2395% Map ReplacementStrategy numeric ID to the wire enum name.
2396if id == ReplacementStrategy.LRU, s = 'LRU';
2397elseif id == ReplacementStrategy.FIFO, s = 'FIFO';
2398elseif id == ReplacementStrategy.RR, s = 'RR';
2399elseif id == ReplacementStrategy.SFIFO, s = 'SFIFO';
2400elseif id == ReplacementStrategy.HLRU, s = 'HLRU';
2401elseif id == ReplacementStrategy.CLIMB, s = 'CLIMB';
2402elseif id == ReplacementStrategy.QLRU, s = 'QLRU';
2403else
2404 line_error(mfilename, sprintf('Unrecognized replacement strategy id %d.', id));
2405end
2406end
2407
2408function s = depdisc_to_str(id)
2409% Map a DepartureDiscipline numeric ID to the wire name. The names are the JAR
2410% enum constants (jline.lang.constant.DepartureDiscipline), which is what the
2411% JAR writer emits and what its reader matches case-insensitively.
2412if id == DepartureDiscipline.NORMAL, s = 'Normal';
2413elseif id == DepartureDiscipline.FIFO, s = 'FIFO';
2414else
2415 line_error(mfilename, sprintf('Unrecognized departure discipline id %d.', id));
2416end
2417end
2418
2419function s = droprule_to_str(id)
2420% Map DropStrategy numeric ID to schema-compatible string.
2421if id == DropStrategy.DROP, s = 'drop';
2422elseif id == DropStrategy.WAITQ, s = 'waitingQueue';
2423elseif id == DropStrategy.BAS, s = 'blockingAfterService';
2424elseif id == DropStrategy.RETRIAL, s = 'retrial';
2425elseif id == DropStrategy.RETRIAL_WITH_LIMIT, s = 'retrialWithLimit';
2426else, s = '';
2427end
2428end
2429
2430function rateMap = oi_rate_table(muFun, maxc, K)
2431% Build the OI/PAS macrostate rate table: for every per-class count vector cnt
2432% on the box lattice 0 <= cnt(r) <= maxc(r), evaluate mu on a canonical ordered
2433% microstate holding cnt(r) copies of class r (any ordering is valid since mu is
2434% order-independent). Keyed by the comma-joined 0-based class counts, matching
2435% the JAR reader (LineModelIO.oiServiceRate). The empty state is omitted.
2436rateMap = containers.Map('KeyType', 'char', 'ValueType', 'double');
2437shp = maxc + 1;
2438total = prod(shp);
2439for i = 1:total
2440 li = i - 1; cnt = zeros(1, K);
2441 for d = 1:K, cnt(d) = mod(li, shp(d)); li = floor(li / shp(d)); end
2442 if sum(cnt) == 0, continue, end
2443 micro = repelem(1:K, cnt);
2444 rate = muFun(micro);
2445 if ~isfinite(rate), rate = 0; end
2446 key = strjoin(arrayfun(@(x) sprintf('%d', x), cnt, 'UniformOutput', false), ',');
2447 rateMap(key) = rate;
2448end
2449end
2450
2451function tbl = cd_scaling_table(beta, maxc, K)
2452% Materialize the class-dependence handle beta(n) over the box lattice
2453% 0 <= n(r) <= maxc(r). Keyed by the comma-joined 0-based per-class counts,
2454% matching the JAR reader (LineModelIO, "classDependence"). The handle may
2455% return a scalar (one scaling shared by every class) or a length-K vector of
2456% per-class scalings; the scalar form is broadcast to K entries here so that the
2457% reader is uniform and need not re-derive which form was used.
2458tbl = containers.Map('KeyType', 'char', 'ValueType', 'any');
2459shp = maxc + 1;
2460total = prod(shp);
2461for i = 1:total
2462 li = i - 1; n = zeros(1, K);
2463 for d = 1:K, n(d) = mod(li, shp(d)); li = floor(li / shp(d)); end
2464 v = beta(n);
2465 if isscalar(v)
2466 v = repmat(double(v), 1, K);
2467 else
2468 v = double(v(:)');
2469 end
2470 v(~isfinite(v)) = 0;
2471 key = strjoin(arrayfun(@(x) sprintf('%d', x), n, 'UniformOutput', false), ',');
2472 tbl(key) = num2cell(v);
2473end
2474end
2475
2476function tbl = firingdep_scaling_table(g, slotIdx, caps, nnodes, nclasses)
2477% Materialize the firing-rate dependence handle g(M) over the box lattice of the
2478% enabling (place,class) slots, 0 <= count(s) <= caps(s). M is the full
2479% node-indexed marking matrix (nnodes x nclasses); only the enabling slots vary,
2480% all other entries are held at 0. Keyed by the comma-joined 0-based slot counts,
2481% matching the JAR/Python readers. g returns a positive scalar multiplier.
2482tbl = containers.Map('KeyType', 'char', 'ValueType', 'any');
2483P = size(slotIdx, 1);
2484shp = caps + 1;
2485total = prod(shp);
2486for i = 1:total
2487 li = i - 1; c = zeros(1, P);
2488 for d = 1:P, c(d) = mod(li, shp(d)); li = floor(li / shp(d)); end
2489 M = zeros(nnodes, nclasses);
2490 for d = 1:P, M(slotIdx(d,1), slotIdx(d,2)) = c(d); end
2491 v = double(g(M));
2492 if ~isscalar(v), v = v(1); end
2493 if ~isfinite(v), v = 0; end
2494 key = strjoin(arrayfun(@(x) sprintf('%d', x), c, 'UniformOutput', false), ',');
2495 tbl(key) = v;
2496end
2497end
2498
2499function s = prectype_to_str(id)
2500% Map ActivityPrecedenceType numeric ID to JAR-compatible string.
2501if id == ActivityPrecedenceType.PRE_SEQ, s = 'pre';
2502elseif id == ActivityPrecedenceType.PRE_AND, s = 'pre-AND';
2503elseif id == ActivityPrecedenceType.PRE_OR, s = 'pre-OR';
2504elseif id == ActivityPrecedenceType.POST_SEQ, s = 'post';
2505elseif id == ActivityPrecedenceType.POST_AND, s = 'post-AND';
2506elseif id == ActivityPrecedenceType.POST_OR, s = 'post-OR';
2507elseif id == ActivityPrecedenceType.POST_LOOP, s = 'post-LOOP';
2508elseif id == ActivityPrecedenceType.POST_CACHE, s = 'post-CACHE';
2509else, s = 'pre';
2510end
2511end
2512
2513function tf = isIdentityClassSwitch(csm, K)
2514% True if the classSwitchMatrix CSM is the K x K identity (or empty/unset,
2515% which defaults to identity). A non-identity matrix carries the class switch
2516% and must be collapsed to same-class routing on export; an identity matrix
2517% means the switch is expressed through the routing and must be kept verbatim.
2518tf = true;
2519if isempty(csm)
2520 return;
2521end
2522for rr = 1:min(K, size(csm,1))
2523 for ss = 1:min(K, size(csm,2))
2524 if abs(csm(rr,ss) - double(rr==ss)) > 1e-12
2525 tf = false;
2526 return;
2527 end
2528 end
2529end
2530end
Definition fjtag.m:161