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