1function linemodel_save(model, filename)
2% LINEMODEL_SAVE Save a LINE model to JSON.
4% LINEMODEL_SAVE(MODEL, FILENAME) saves the model to the specified JSON
5% file, conforming to the line-model.schema.json specification.
8% model - Network, LayeredNetwork, Workflow, or Environment
object
9% filename - output file path (should end in .json)
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;
22% linemodel_save(model,
'mm1.json');
24% Copyright (c) 2012-2026, Imperial College London
27if isa(model,
'LayeredNetwork')
28 modelMap = layered2json(model);
29elseif isa(model, 'Workflow')
30 modelMap = workflow2json(model);
31elseif isa(model, 'Environment')
32 modelMap = environment2json(model);
34 modelMap = network2json(model);
37% Build the full document
40sb{end+1} =
' "format": "line-model",';
41sb{end+1} =
' "version": "1.0",';
42sb{end+1} = [
' "model": ', encode_value(modelMap, 2)];
44jsonStr = strjoin(sb, newline);
46fid = fopen(filename,
'w');
48 error(
'linemodel_save:fileOpen',
'Cannot open file: %s', filename);
50cleanupObj = onCleanup(@() fclose(fid));
51fprintf(fid,
'%s\n', jsonStr);
55% =========================================================================
56% Network serialization
57% =========================================================================
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();
65nodes = model.getNodes();
66classes = model.getClasses();
75 % Skip implicit ClassSwitch
nodes (
auto-created by link())
76 if isa(node, 'ClassSwitch') && isprop(node, 'autoAdded') && node.autoAdded
80 nj = containers.Map();
81 nj('name') = node.name;
82 nj('type') = node_type_str(node);
84 % Place extends
Station not Queue, so isa(node,'Queue') alone would drop its scheduling strategy
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);
91 elseif isa(node, 'Queue')
92 sched = node.schedStrategy;
94 nj('scheduling') = sched_id_to_str(sched);
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
107 %
Station, not Queue: Place extends
Station directly (see scheduling note above)
110 if ~isempty(c) && isfinite(c) && c > 0
115 % Per-class buffer capacity
116 if isa(node, '
Station') && ~isempty(node.classCap)
117 ccMap = containers.Map();
120 if r <= length(node.classCap) && isfinite(node.classCap(r))
121 ccMap(jc.name) = node.classCap(r);
125 nj(
'classCap') = ccMap;
130 if isa(node,
'Station') && ~isempty(node.dropRule)
131 drMap = containers.Map();
134 if r <= length(node.dropRule)
135 dr = node.dropRule(r);
136 drStr = droprule_to_str(dr);
138 drMap(jc.name) = drStr;
143 nj('dropRule') = drMap;
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;
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)
159 if isa(classes{r},
'ClosedClass') && isfinite(classes{r}.population)
160 maxc(r) = round(classes{r}.population);
162 maxc(r) = 10; % open-
class saturation cutoff (beta clamped beyond)
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;
174 pk = repmat(pk, 1, K);
176 cdMap('peak
') = num2cell(double(pk(:)'));
177 nj(
'classDependence') = cdMap;
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)
186 if isa(classes{r},
'ClosedClass') && isfinite(classes{r}.population)
187 maxc(r) = round(classes{r}.population);
189 maxc(r) = 10; % open-
class saturation cutoff (eta clamped beyond)
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;
198 pk = repmat(pk, 1, K);
200 jdMap('peak
') = num2cell(double(pk(:)'));
201 nj(
'jointDependence') = jdMap;
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();
210 if isa(node,
'Source')
212 dist = node.getArrivalProcess(jc);
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};
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
227 dist = node.getService(jc);
233 if ~isempty(dist) && ~isa(dist, 'Disabled')
234 dj = dist2json(dist);
237 if isa(node, 'Place') && numel(node.departureDiscipline) >= jc.index
238 depDisc(jc.name) = depdisc_to_str(node.departureDiscipline(jc.index));
247 nj('departureDiscipline') = depDisc;
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)
254 if isa(classes{r},
'ClosedClass') && isfinite(classes{r}.population)
255 maxc(r) = round(classes{r}.population);
257 maxc(r) = 10; % open-
class saturation cutoff (mu constant beyond)
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, :)));
270 nj('swapGraph
') = sgRows;
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();
279 if numel(node.arrivalBatch) >= r && ~isempty(node.arrivalBatch{r})
280 bj = dist2json(node.arrivalBatch{r});
282 batchMap(classes{r}.name) = bj;
286 if batchMap.Count > 0
287 nj('arrivalBatch
') = batchMap;
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;
297 nj('markedClasses
') = markedNames;
301 if isa(node, 'ClassSwitch
')
302 csm = node.server.csMatrix;
304 csDict = containers.Map();
306 row = containers.Map();
308 if ri <= size(csm,1) && ci <= size(csm,2) && csm(ri,ci) ~= 0
309 row(classes{ci}.name) = csm(ri,ci);
313 csDict(classes{ri}.name) = row;
317 nj('classSwitchMatrix
') = csDict;
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
329 cc('capacity
') = ilc;
331 cc('capacity
') = ilc(:)';
333 cc(
'replacement') = repl_to_str(node.replacestrategy);
334 if isprop(node,
'admissionProb') && ~isempty(node.admissionProb)
335 cc('admissionProb') = node.admissionProb;
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;
349 cc(
'hitClass') = hitMap;
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;
360 cc(
'missClass') = missMap;
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;
380 cc(
'popularity') = popMap;
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});
390 cc(
'accessGraph') = gArr;
391 elseif ~isempty(node.accessProb)
392 [Kap, Nap] = size(node.accessProb);
393 apArr = cell(1, Kap);
395 rowArr = cell(1, Nap);
397 if ~isempty(node.accessProb{k1, k2})
398 rowArr{k2} = full(node.accessProb{k1, k2});
405 cc(
'accessProb') = apArr;
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, :))));
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});
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
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;
446 entry('queues
') = qnames;
447 itemsMap = containers.Map();
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;
456 if itemsMap.Count > 0
457 entry('items
') = itemsMap;
459 byClass(classes{inIdx}.name) = entry;
462 rsRoot = containers.Map();
463 rsRoot('capacity
') = double(node.retrievalSystemCapacity);
464 rsRoot('byClass
') = byClass;
465 nj('retrievalSystem
') = rsRoot;
472 if ~isempty(node.output) && isprop(node.output, 'tasksPerLink
') && node.output.tasksPerLink > 1
473 nj('tasksPerLink
') = node.output.tasksPerLink;
477 % Join paired fork and join strategy
479 if ~isempty(node.joinOf)
480 nj('forkNode
') = node.joinOf.name;
482 % Serialize per-class join strategy if non-default
483 if ~isempty(node.input) && isprop(node.input, 'joinStrategy
') && ~isempty(node.input.joinStrategy)
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
';
496 if ~isempty(node.input) && isprop(node.input, 'joinRequired
') && ~isempty(node.input.joinRequired)
499 if r <= length(node.input.joinRequired) && ~isempty(node.input.joinRequired{r})
500 jq = node.input.joinRequired{r};
502 nj('joinQuorum
') = jq;
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();
518 w = node.schedStrategyPar(r);
519 if ~isempty(w) && isfinite(w) && w > 0
526 nj('schedParams
') = sp;
532 if isa(node, 'Transition
')
534 nModes = node.getNumberOfModes();
535 allNodes = model.getNodes();
537 mj = containers.Map();
538 if mi <= length(node.modeNames) && ~isempty(node.modeNames{mi})
539 mj('name
') = node.modeNames{mi};
541 mj('name
') = sprintf('Mode%d
', mi);
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});
550 mj(
'distribution') = dj;
554 if mi <= length(node.timingStrategies)
555 if node.timingStrategies(mi) == TimingStrategy.TIMED
556 mj('timingStrategy') = 'TIMED';
558 mj('timingStrategy') = 'IMMEDIATE';
562 if mi <= length(node.numberOfServers) && node.numberOfServers(mi) > 1
563 mj('numServers') = node.numberOfServers(mi);
566 if mi <= length(node.firingPriorities) && node.firingPriorities(mi) > 0
567 mj('firingPriority') = node.firingPriorities(mi);
570 if mi <= length(node.firingWeights) && node.firingWeights(mi) ~= 1.0
571 mj('firingWeight') = node.firingWeights(mi);
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)
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>
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;
604 % Enabling conditions
605 if mi <= length(node.enablingConditions)
606 ecMat = node.enablingConditions{mi};
608 for ni = 1:size(ecMat, 1)
609 for ci = 1:size(ecMat, 2)
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>
620 mj('enablingConditions
') = ecList;
623 % Inhibiting conditions
624 if mi <= length(node.inhibitingConditions)
625 icMat = node.inhibitingConditions{mi};
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>
639 mj('inhibitingConditions
') = icList;
643 if mi <= length(node.firingOutcomes)
644 foMat = node.firingOutcomes{mi};
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>
658 mj('firingOutcomes
') = foList;
661 modesJson{end+1} = mj; %#ok<AGROW>
663 if ~isempty(modesJson)
664 nj('modes
') = modesJson;
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(:)'));
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;
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)));
685 spaceRows = cell(1, size(space, 1));
686 for si = 1:size(space, 1)
687 spaceRows{si} = num2cell(space(si, :));
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
');
696 nodesJson{end+1} = nj; %#ok<AGROW>
698result('nodes') = nodesJson;
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;
713 if ~isempty(jc.removalDistribution)
714 cj('removalDistribution
') = dist2json(jc.removalDistribution);
716 if ~isempty(jc.removalPolicy) && jc.removalPolicy ~= RemovalPolicy.RANDOM
717 cj('removalPolicy
') = RemovalPolicy.toText(jc.removalPolicy);
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;
726 if ~isempty(jc.targetJobClass)
727 cj('targetClass
') = jc.targetJobClass.name;
729 if ~isempty(jc.removalDistribution)
730 cj('removalDistribution
') = dist2json(jc.removalDistribution);
732 if ~isempty(jc.removalPolicy) && jc.removalPolicy ~= RemovalPolicy.RANDOM
733 cj('removalPolicy
') = RemovalPolicy.toText(jc.removalPolicy);
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;
742 if ~isempty(jc.removalDistribution)
743 cj('removalDistribution
') = dist2json(jc.removalDistribution);
745 if ~isempty(jc.removalPolicy) && jc.removalPolicy ~= RemovalPolicy.RANDOM
746 cj('removalPolicy
') = RemovalPolicy.toText(jc.removalPolicy);
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;
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;
761 elseif isa(jc, 'OpenClass
')
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;
771 cj('priority
') = jc.priority;
773 if isprop(jc, 'deadline
') && isfinite(jc.deadline)
774 cj('deadline
') = jc.deadline;
776 if jc.isReferenceClass()
777 cj('isReferenceClass
') = true;
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;
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;
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);
797 classesJson{end+1} = cj; %#ok<AGROW>
799result('classes
') = classesJson;
802routingMap = containers.Map();
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})
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;
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);
827 if issparse(Prs); Prs = full(Prs); end
828 total = total + Prs(ii, jj);
830 cs_same(s, ii, jj) = total;
837 fromTo = containers.Map();
844 % For explicit CS, use same-class routing only
847 val = cs_same(s, ii, jj);
849 ni = sn.nodenames{ii};
850 njn = sn.nodenames{jj};
852 fromTo(ni) = containers.Map();
860 % Skip cross-class entries from explicit CS
866 ni = sn.nodenames{ii};
867 njn = sn.nodenames{jj};
869 fromTo(ni) = containers.Map();
878 key = char(sprintf('%s,%s
', classes{r}.name, classes{s}.name));
879 routingMap(key) = fromTo;
883 elseif ~isempty(sn) && isfield(sn, 'rtnodes
') && ~isempty(sn.rtnodes)
884 % Fallback to rtnodes if rtorig not available
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;
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);
904 total = total + rt((ii-1)*K+r, (jj-1)*K+s);
906 cs_same(s, ii, jj) = total;
913 fromTo = containers.Map();
916 % For explicit CS, use same-class routing only
919 val = cs_same(s, ii, jj);
921 ni = sn.nodenames{ii};
922 njn = sn.nodenames{jj};
924 fromTo(ni) = containers.Map();
932 % Skip cross-class entries from explicit CS
936 val = rt((ii-1)*K+r, (jj-1)*K+s);
938 ni = sn.nodenames{ii};
939 njn = sn.nodenames{jj};
941 fromTo(ni) = containers.Map();
950 key = char(sprintf('%s,%s
', classes{r}.name, classes{s}.name));
951 routingMap(key) = fromTo;
957 % If struct not available, routing stays empty
960routing = containers.Map();
961routing('type
') = 'matrix
';
962routing('matrix
') = routingMap;
963result('routing
') = routing;
965% --- Routing Strategies ---
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
';
980 nodeStrats = containers.Map();
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);
987 if nodeStrats.Count > 0
988 routingStrategies(sn2.nodenames{i}) = nodeStrats;
991 if routingStrategies.Count > 0
992 result('routingStrategies
') = routingStrategies;
995 % WRROBIN weights indexed by NODE index i, not station index -- see _kb/09-ldes-and-cache.md
996 routingWeights = containers.Map();
999 nodeClassWeights = containers.Map();
1001 if int32(sn2.routing(i, r)) == int32(RoutingStrategy.WRROBIN)
1002 os = nodeObj2.output.outputStrategy;
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};
1016 if destWeights.Count > 0
1017 nodeClassWeights(classes{r}.name) = destWeights;
1023 if nodeClassWeights.Count > 0
1024 routingWeights(sn2.nodenames{i}) = nodeClassWeights;
1027 if routingWeights.Count > 0
1028 result('routingWeights
') = routingWeights;
1034% --- Setup / Delay-Off, Polling Type and Switchover Times ---
1035nodesCellTmp = result('nodes');
1038 if ~isa(nodeObj, 'Queue
') || isa(nodeObj, 'Delay
')
1042 for nj_idx = 1:length(nodesCellTmp)
1043 if strcmp(nodesCellTmp{nj_idx}('name
'), nodeObj.name)
1051 nj = nodesCellTmp{njIdx};
1053 % Setup and delay-off emitted as a pair: setDelayOff requires both on reload
1054 setupMap = containers.Map();
1055 delayOffMap = containers.Map();
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);
1067 if setupMap.Count > 0
1068 nj('setupTime
') = setupMap;
1069 nj('delayOffTime
') = delayOffMap;
1072 isPolling = SchedStrategy.toId(nodeObj.schedStrategy) == SchedStrategy.POLLING;
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;
1083 % Under POLLING, switchover is indexed by departing class alone (no "to" field); otherwise a KxK (from,to) cell
1085 if ~isempty(nodeObj.switchoverTime)
1086 [soRows, soCols] = size(nodeObj.switchoverTime);
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;
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;
1112 if ~isempty(soTimes)
1113 nj('switchoverTimes
') = soTimes;
1115 nodesCellTmp{njIdx} = nj;
1117result('nodes') = nodesCellTmp;
1119% --- Heterogeneous Server Types ---
1121 nodesCellTmp = result('nodes');
1124 if isa(nodeObj, 'Queue
') && nodeObj.isHeterogeneous()
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
1133 for cci = 1:length(st.compatibleClasses)
1134 ccNames{end+1} = st.compatibleClasses{cci}.name; %#ok<AGROW>
1136 if ~isempty(ccNames)
1137 stj('compatibleClasses
') = ccNames;
1139 % Per-class service distributions
1140 svcMap = containers.Map();
1143 dist = nodeObj.getHeteroService(jc, st);
1144 if ~isempty(dist) && ~isa(dist, 'Disabled
')
1145 svcMap(jc.name) = dist2json(dist);
1149 stj('service
') = svcMap;
1151 stArr{end+1} = stj; %#ok<AGROW>
1154 for nj_idx = 1:length(nodesCellTmp)
1155 nj = nodesCellTmp{nj_idx};
1156 if strcmp(nj('name
'), nodeObj.name)
1157 nj('serverTypes
') = stArr;
1159 policy = nodeObj.getHeteroSchedPolicy();
1160 if ~isempty(policy) && policy ~= HeteroSchedPolicy.ORDER
1161 nj('heteroSchedPolicy
') = HeteroSchedPolicy.toText(policy);
1163 nodesCellTmp{nj_idx} = nj;
1170 result('nodes') = nodesCellTmp;
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();
1179 nodeByName(nodes{i}.name) = nodes{i};
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();
1189 if nodeObj.hasImmediateFeedback(classes{r})
1190 ifMap(classes{r}.name) = true;
1194 nj('immediateFeedback
') = ifMap;
1197 if isa(nodeObj, 'Queue
')
1199 balkJson = containers.Map();
1202 if nodeObj.hasBalking(jc)
1203 [strategy, thresholds] = nodeObj.getBalking(jc);
1204 bjc = containers.Map();
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
';
1211 for ti = 1:length(thresholds)
1212 th = thresholds{ti};
1213 tjson = containers.Map();
1214 tjson('minJobs
') = th{1};
1216 tjson('maxJobs
') = -1;
1218 tjson('maxJobs
') = th{2};
1220 tjson('probability
') = th{3};
1221 thArr{end+1} = tjson;
1223 bjc('thresholds
') = thArr;
1224 balkJson(jc.name) = bjc;
1227 if balkJson.Count > 0
1228 nj('balking
') = balkJson;
1231 retrialJson = containers.Map();
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;
1242 if retrialJson.Count > 0
1243 nj('retrial
') = retrialJson;
1246 patienceJson = containers.Map();
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);
1257 patienceJson(jc.name) = pjc;
1260 if patienceJson.Count > 0
1261 nj('patience
') = patienceJson;
1263 % Orbit impatience (abandonment from the retrial orbit), distinct from
1264 % the queue patience above.
1265 orbitJson = containers.Map();
1268 orbDist = nodeObj.getOrbitImpatience(jc);
1269 if ~isempty(orbDist) && ~isa(orbDist, 'Disabled
')
1270 orbitJson(jc.name) = dist2json(orbDist);
1273 if orbitJson.Count > 0
1274 nj('orbitImpatience
') = orbitJson;
1276 % Batch rejection probability (retrial queues), per class
1277 brpJson = containers.Map();
1280 brp = nodeObj.getBatchRejectProbability(jc);
1281 if ~isempty(brp) && brp > 0
1282 brpJson(jc.name) = brp;
1285 if brpJson.Count > 0
1286 nj('batchRejectProb
') = brpJson;
1289 nodesCellTmp{nj_idx} = nj;
1291result('nodes') = nodesCellTmp;
1293% --- Finite Capacity Regions ---
1295 regions = model.regions;
1296 if ~isempty(regions)
1298 for ri = 1:length(regions)
1300 rj = containers.Map();
1301 rj('name
') = reg.name;
1302 % Stations with per-class details
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();
1312 if r <= length(reg.classMaxJobs) && isfinite(reg.classMaxJobs(r))
1313 ccMap(jc.name) = reg.classMaxJobs(r);
1317 sj('classCap
') = ccMap;
1320 % Per-class classWeight
1321 if isprop(reg, 'classWeight
') && ~isempty(reg.classWeight)
1322 cwMap = containers.Map();
1325 if r <= length(reg.classWeight) && reg.classWeight(r) ~= 1
1326 cwMap(jc.name) = reg.classWeight(r);
1330 sj('classWeight
') = cwMap;
1333 % Per-class classSize
1334 if isprop(reg, 'classSize
') && ~isempty(reg.classSize)
1335 csMap = containers.Map();
1338 if r <= length(reg.classSize) && reg.classSize(r) ~= 1
1339 csMap(jc.name) = reg.classSize(r);
1343 sj('classSize
') = csMap;
1346 stationsJson{end+1} = sj; %#ok<AGROW>
1348 rj('stations
') = stationsJson;
1349 if isprop(reg, 'globalMaxJobs
') && isfinite(reg.globalMaxJobs)
1350 rj('globalMaxJobs
') = reg.globalMaxJobs;
1352 if isprop(reg, 'globalMaxMemory
') && isfinite(reg.globalMaxMemory)
1353 rj('globalMaxMemory
') = reg.globalMaxMemory;
1355 % Per-class classMaxJobs at region level
1356 if isprop(reg, 'classMaxJobs
') && ~isempty(reg.classMaxJobs)
1357 cmjMap = containers.Map();
1360 if r <= length(reg.classMaxJobs) && isfinite(reg.classMaxJobs(r))
1361 cmjMap(jc.name) = reg.classMaxJobs(r);
1365 rj('classMaxJobs
') = cmjMap;
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();
1373 if r <= length(reg.classMaxMemory) && isfinite(reg.classMaxMemory(r)) ...
1374 && reg.classMaxMemory(r) >= 0
1375 cmmMap(jc.name) = reg.classMaxMemory(r);
1379 rj('classMaxMemory
') = cmmMap;
1383 if isprop(reg, 'dropRule
') && ~isempty(reg.dropRule)
1384 drMap = containers.Map();
1387 if r <= length(reg.dropRule)
1388 drStr = droprule_to_str(reg.dropRule(r));
1390 drMap(jc.name) = drStr;
1395 rj('dropRule
') = drMap;
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,:);
1407 rj('constraintA
') = Acell;
1408 rj('constraintB
') = b(:)';
1411 fcrArray{end+1} = rj; %#ok<AGROW>
1413 if ~isempty(fcrArray)
1414 result(
'finiteCapacityRegions') = fcrArray;
1421rewardsJson = rewards2json(model);
1422if ~isempty(rewardsJson)
1423 result(
'rewards') = rewardsJson;
1428% =========================================================================
1429% Reward serialization
1430% =========================================================================
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.
1441if isempty(sn) || ~isfield(sn, 'reward
') || isempty(sn.reward)
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;
1449[~, order] = sort(rewardNames);
1450for oi = 1:length(order)
1451 rw = sn.reward{order(oi)};
1453 if isfield(rw,
'descriptor')
1454 descriptor = rw.descriptor;
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));
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));
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));
1475 rj('node') = descriptor.node.name;
1477 rj('class') = descriptor.
jobclass.name;
1479 rewardsJson{end+1} = rj; %#ok<AGROW>
1484% =========================================================================
1485% LayeredNetwork serialization
1486% =========================================================================
1488function result = layered2json(model)
1489result = containers.Map();
1490result(
'type') =
'LayeredNetwork';
1491result(
'name') = model.getName();
1496for i = 1:length(hosts)
1498 pj = containers.Map();
1499 pj(
'name') = h.name;
1500 mult = h.multiplicity;
1502 pj(
'multiplicity') = inf_multiplicity();
1504 pj(
'multiplicity') = mult;
1506 schedStr = h.scheduling;
1507 if ~isempty(schedStr) && ~strcmpi(schedStr,
'inf')
1508 pj('scheduling') = upper(schedStr);
1511 if q > 0 && q ~= 0.001
1516 pj('speedFactor') = sf;
1518 repl = h.replication;
1520 pj('replication') = repl;
1522 procsJson{end+1} = pj; %#ok<AGROW>
1524result(
'hosts') = procsJson;
1528tasksList = model.tasks;
1529for i = 1:length(tasksList)
1531 tj = containers.Map();
1532 tj(
'name') = t.name;
1533 if ~isempty(t.parent)
1534 tj('host') = t.parent.name;
1536 mult = t.multiplicity;
1538 tj('multiplicity') = inf_multiplicity();
1540 tj('multiplicity') = mult;
1542 schedStr = t.scheduling;
1543 if ~isempty(schedStr)
1544 tj('scheduling') = upper(schedStr);
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);
1552 params = containers.Map();
1553 params('lambda') = 1.0 / ttMean;
1554 dj = containers.Map();
1556 dj('params') = params;
1557 tj('thinkTime') = dj;
1561 if ~isempty(t.fanInSource) && ischar(t.fanInSource) && ~isempty(t.fanInSource)
1562 fi = containers.Map();
1563 fi(t.fanInSource) = t.fanInValue;
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);
1574 repl = t.replication;
1576 tj(
'replication') = repl;
1578 % FunctionTask detection
1579 if isa(t,
'FunctionTask')
1580 tj('taskType') = 'FunctionTask';
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);
1589 if ~isempty(t.delayOffTime) && isa(t.delayOffTime, 'Distribution')
1590 dotMean = t.delayOffTimeMean;
1591 if dotMean > GlobalConstants.FineTol
1592 tj('delayOffTime') = dist2json(t.delayOffTime);
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);
1607 tj(
'replacementStrategy') =
'FIFO';
1610 tasksJson{end+1} = tj; %#ok<AGROW>
1612result(
'tasks') = tasksJson;
1616entriesList = model.entries;
1617for i = 1:length(entriesList)
1619 ej = containers.Map();
1620 ej(
'name') = e.name;
1621 if ~isempty(e.parent)
1622 ej('task') = e.parent.name;
1624 % Entry arrival distribution
1625 if ~isempty(e.arrival) && isa(e.arrival, 'Distribution')
1626 ej('arrival') = dist2json(e.arrival);
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);
1638 entriesJson{end+1} = ej; %#ok<AGROW>
1640result(
'entries') = entriesJson;
1642% --- Build reply
map: activityName -> entryName ---
1643replyMap = containers.Map();
1644for i = 1:length(entriesList)
1646 if ~isempty(e.replyActivity)
1647 for j = 1:length(e.replyActivity)
1648 replyMap(e.replyActivity{j}) = e.name;
1655actsList = model.activities;
1656for i = 1:length(actsList)
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;
1668 elseif ~isempty(a.parentName) && ischar(a.parentName)
1669 aj('task') = a.parentName;
1672 if ~isempty(a.hostDemand) && isa(a.hostDemand, 'Distribution')
1673 if ~isa(a.hostDemand, 'Immediate')
1674 aj('hostDemand') = dist2json(a.hostDemand);
1676 elseif ~isempty(a.hostDemandMean) && a.hostDemandMean > GlobalConstants.FineTol
1677 params = containers.Map();
1678 params('lambda') = 1.0 / a.hostDemandMean;
1679 dj = containers.Map();
1681 dj('params') = params;
1682 aj('hostDemand') = dj;
1685 if ~isempty(a.boundToEntry)
1686 aj('boundToEntry') = a.boundToEntry;
1689 if replyMap.isKey(a.name)
1690 aj('repliesTo') = replyMap(a.name);
1693 if ~isempty(a.syncCallDests)
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);
1701 synchCalls{end+1} = sc; %#ok<AGROW>
1703 aj(
'synchCalls') = synchCalls;
1706 if ~isempty(a.asyncCallDests)
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);
1714 asynchCalls{end+1} = ac; %#ok<AGROW>
1716 aj(
'asynchCalls') = asynchCalls;
1718 actsJson{end+1} = aj; %#ok<AGROW>
1720result(
'activities') = actsJson;
1722% --- Precedences ---
1724for i = 1:length(tasksList)
1726 precs = t.precedences;
1727 if isempty(precs),
continue; end
1728 for j = 1:length(precs)
1730 pj = containers.Map();
1731 pj(
'task') = t.name;
1733 preType = p.preType;
1734 postType = p.postType;
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(:)';
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};
1762 if ~isempty(p.postParams)
1763 pj('loopCount') = p.postParams(1);
1765 elseif preType == ActivityPrecedenceType.PRE_SEQ && postType == ActivityPrecedenceType.POST_CACHE
1766 pj('type') = 'CacheAccess';
1767 pj('activities') = [p.preActs, p.postActs];
1771 precsJson{end+1} = pj; %#ok<AGROW>
1774if ~isempty(precsJson)
1775 result(
'precedences') = precsJson;
1780% =========================================================================
1781% Workflow serialization
1782% =========================================================================
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();
1792acts = model.activities;
1793for i = 1:length(acts)
1795 aj = containers.Map();
1796 aj(
'name') = act.name;
1797 if ~isempty(act.hostDemand) && isa(act.hostDemand,
'Distribution')
1798 dj = dist2json(act.hostDemand);
1800 aj('hostDemand') = dj;
1803 actsJson{end+1} = aj; %#ok<AGROW>
1805result(
'activities') = actsJson;
1807% --- Precedences ---
1809precs = model.precedences;
1810for i = 1:length(precs)
1812 pj = containers.Map();
1816 for a = 1:length(p.preActs)
1817 preActsJson{end+1} = p.preActs{a}; %#ok<AGROW>
1819 pj(
'preActs') = preActsJson;
1823 for a = 1:length(p.postActs)
1824 postActsJson{end+1} = p.postActs{a}; %#ok<AGROW>
1826 pj(
'postActs') = postActsJson;
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);
1833 if ~isempty(p.preParams)
1834 pj('preParams') = p.preParams(:)';
1838 if ~isempty(p.postParams)
1839 pj('postParams') = p.postParams(:)';
1842 precsJson{end+1} = pj; %#ok<AGROW>
1844result(
'precedences') = precsJson;
1848% =========================================================================
1849% Environment serialization
1850% =========================================================================
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();
1858E = height(model.envGraph.Nodes);
1859result(
'numStages') = 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});
1870 stagesJson{end+1} = sj; %#ok<AGROW>
1872result('stages
') = stagesJson;
1874% --- Transitions ---
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});
1885 tj('distribution
') = dj;
1886 transJson{end+1} = tj; %#ok<AGROW>
1891result('transitions
') = transJson;
1893% --- Node failures ---
1894% Declarative record of addNodeBreakdown/addNodeRepair; carries the queue-length reset policies (function handles), otherwise unrecoverable
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);
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));
1906 nj('breakdownRate
') = bj;
1907 if ~isempty(nf.repair)
1908 rj = dist2json(nf.repair);
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));
1914 nj('repairRate
') = rj;
1916 dj = dist2json(nf.downService);
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));
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));
1927 nj('breakdownResetPolicy
') = nf.breakdownResetPolicy;
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));
1934 nj('repairResetPolicy
') = nf.repairResetPolicy;
1937 nfJson{end+1} = nj; %#ok<AGROW>
1940 result('nodeFailures
') = nfJson;
1945% =========================================================================
1946% Multiplicity serialization
1947% =========================================================================
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.
1958% =========================================================================
1959% Distribution serialization
1960% =========================================================================
1962function d = dist2json(dist)
1963% Convert a Distribution to a containers.Map
for JSON output.
1968d = containers.Map();
1969cn = builtin(
'class', dist);
1972 d(
'type') =
'Disabled';
1974 d(
'type') =
'Immediate';
1977 params = containers.Map();
1978 params(
'lambda') = dist.getParam(1).paramValue;
1979 d(
'params') = params;
1982 params = containers.Map();
1983 params(
'value') = dist.getParam(1).paramValue;
1984 d(
'params') = params;
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;
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;
1999 params(
'p') = [p, 1-p];
2000 params(
'lambda') = [l1, l2];
2002 params(
'p') = p(:)
';
2003 if isscalar(l1) && isscalar(l2)
2004 params('lambda
') = [l1, l2];
2006 % n-phase: param2 already holds all n rates (param3 duplicates it)
2007 params('lambda
') = l1(:)';
2010 d(
'params') = params;
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;
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;
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;
2031 params = containers.Map();
2032 params(
's') = dist.getParam(3).paramValue;
2033 params(
'n') = dist.getParam(4).paramValue;
2034 d(
'params') = params;
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;
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;
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;
2054 d(
'type') =
'Geometric';
2055 params = containers.Map();
2056 params(
'p') = dist.getParam(1).paramValue;
2057 d(
'params') = params;
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;
2065 d(
'type') =
'Poisson';
2066 params = containers.Map();
2067 params(
'lambda') = dist.getParam(1).paramValue;
2068 d(
'params') = params;
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;
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.
2090 ph = containers.Map();
2091 alpha = dist.getInitProb();
2092 T = dist.getSubgenerator();
2094 ph(
'alpha') = alpha(:)
';
2096 ph('alpha
') = alpha;
2102 mapSpec = containers.Map();
2103 mapSpec('D0
') = dist.getParam(1).paramValue;
2104 mapSpec('D1
') = dist.getParam(2).paramValue;
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.
2110 params = containers.Map();
2111 params('D0
') = dist.getParam(1).paramValue;
2112 params('D1
') = dist.getParam(2).paramValue;
2113 d('params
') = params;
2115 % CME goes on the wire as its (alpha, A) ME representation; the subclass tag is not preserved
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;
2124 params = containers.Map();
2125 params(
'H0') = dist.getParam(1).paramValue;
2126 params(
'H1') = dist.getParam(2).paramValue;
2127 d(
'params') = params;
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
2131 params = containers.Map();
2132 Kb = dist.getNumberOfTypes;
2133 dArr = cell(1, Kb + 1);
2134 dArr{1} = dist.getParam(1).paramValue; % D0
2136 dArr{1+kb} = dist.getParam(2+kb).paramValue; % Dk, batch size k
2139 d(
'params') = params;
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;
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
2156 dArr{1+km} = dist.getParam(2+km).paramValue; % D1k
2160 d(
'params') = params;
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();
2167 params(
'F') = ecdf(:, 1)
';
2168 params('x
') = ecdf(:, 2)';
2169 d(
'params') = params;
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;
2183 d(
'expolynomial') = ep;
2185 % Marked MAP: {D0, per-mark D1k}; the aggregate D1
is rebuilt on load
2187 mmapSpec = containers.Map();
2188 mmapSpec(
'D0') = dist.getParam(1).paramValue;
2189 Kmarks = dist.getNumberOfTypes;
2190 d1k = cell(1, Kmarks);
2192 d1k{km} = dist.getParam(2+km).paramValue;
2194 mmapSpec(
'D1k') = d1k;
2195 d(
'mmap') = mmapSpec;
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;
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;
2222 d(
'type') =
'Replayer';
2223 params = containers.Map();
2224 params(
'fileName') = dist.getParam(1).paramValue;
2226 params(
'mean') = dist.getMean();
2229 d(
'params') = params;
2230 % Save APH fit as fallback
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;
2238 ph('alpha') = alpha(:)';
2240 ph('alpha') = alpha;
2248 d('type') = 'Prior';
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>
2257 d(
'distributions') = alts;
2258 d(
'probabilities') = dist.probabilities(:)
';
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));
2267 params = containers.Map();
2270 d('params
') = params;
2275% =========================================================================
2277% =========================================================================
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);
2285if isa(val, 'containers.Map
')
2290 parts = cell(1, length(ks));
2291 for i = 1:length(ks)
2294 parts{i} = sprintf('%s
"%s": %s
', pad2, json_escape(k), encode_value(v, indent + 2));
2296 s = sprintf('{\n%s\n%s}
', strjoin(parts, sprintf(',\n
')), pad);
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)
2306 if val > 0, s = '"Infinity"'; else, s = '"-Infinity"'; end
2307 elseif val == floor(val) && abs(val) < 1e15
2308 s = sprintf('%d
', val);
2310 s = sprintf('%.15g
', val);
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);
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);
2323 s = ['[
', strjoin(rows, ',
'), ']
'];
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));
2332 s = sprintf('[\n%s\n%s]
', strjoin(parts, sprintf(',\n
')), pad);
2334elseif isstruct(val) && isscalar(val)
2335 fnames = fieldnames(val);
2339 parts = cell(1, length(fnames));
2340 for i = 1:length(fnames)
2343 parts{i} = sprintf('%s
"%s": %s
', pad2, json_escape(fn), encode_value(fv, indent + 2));
2345 s = sprintf('{\n%s\n%s}
', strjoin(parts, sprintf(',\n
')), pad);
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');
2362% =========================================================================
2364% =========================================================================
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';
2383function s = sched_id_to_str(id)
2384% Map a SchedStrategy numeric ID to the wire enum name.
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)));
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';
2404 line_error(mfilename, sprintf('Unrecognized replacement strategy id %d.', id));
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';
2415 line_error(mfilename, sprintf('Unrecognized departure discipline id %d.', id));
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';
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');
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;
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');
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
2466 v = repmat(double(v), 1, K);
2470 v(~isfinite(v)) = 0;
2471 key = strjoin(arrayfun(@(x) sprintf('%d', x), n, 'UniformOutput', false), ',');
2472 tbl(key) = num2cell(v);
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);
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
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), ',');
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';
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.
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