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 % 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.
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);
94 elseif isa(node, 'Queue')
95 sched = node.schedStrategy;
97 nj('scheduling') = sched_id_to_str(sched);
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
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.
124 if ~isempty(c) && isfinite(c) && c > 0
129 % Per-class buffer capacity
130 if isa(node, '
Station') && ~isempty(node.classCap)
131 ccMap = containers.Map();
134 if r <= length(node.classCap) && isfinite(node.classCap(r))
135 ccMap(jc.name) = node.classCap(r);
139 nj(
'classCap') = ccMap;
144 if isa(node,
'Station') && ~isempty(node.dropRule)
145 drMap = containers.Map();
148 if r <= length(node.dropRule)
149 dr = node.dropRule(r);
150 drStr = droprule_to_str(dr);
152 drMap(jc.name) = drStr;
157 nj('dropRule') = drMap;
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;
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
175 if isa(node,
'Station') && ~isempty(node.lcdScaling)
178 if isa(classes{r},
'ClosedClass') && isfinite(classes{r}.population)
179 maxc(r) = round(classes{r}.population);
181 maxc(r) = 10; % open-
class saturation cutoff (beta clamped beyond)
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;
193 pk = repmat(pk, 1, K);
195 cdMap('peak
') = num2cell(double(pk(:)'));
196 nj(
'classDependence') = cdMap;
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();
208 if isa(node,
'Source')
210 dist = node.getArrivalProcess(jc);
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};
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
230 dist = node.getService(jc);
236 if ~isempty(dist) && ~isa(dist, 'Disabled')
237 dj = dist2json(dist);
240 if isa(node, 'Place') && numel(node.departureDiscipline) >= jc.index
241 depDisc(jc.name) = depdisc_to_str(node.departureDiscipline(jc.index));
250 nj('departureDiscipline') = depDisc;
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)
261 if isa(classes{r},
'ClosedClass') && isfinite(classes{r}.population)
262 maxc(r) = round(classes{r}.population);
264 maxc(r) = 10; % open-
class saturation cutoff (mu constant beyond)
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, :)));
277 nj('swapGraph
') = sgRows;
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();
286 if numel(node.arrivalBatch) >= r && ~isempty(node.arrivalBatch{r})
287 bj = dist2json(node.arrivalBatch{r});
289 batchMap(classes{r}.name) = bj;
293 if batchMap.Count > 0
294 nj('arrivalBatch
') = batchMap;
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;
304 nj('markedClasses
') = markedNames;
308 if isa(node, 'ClassSwitch
')
309 csm = node.server.csMatrix;
311 csDict = containers.Map();
313 row = containers.Map();
315 if ri <= size(csm,1) && ci <= size(csm,2) && csm(ri,ci) ~= 0
316 row(classes{ci}.name) = csm(ri,ci);
320 csDict(classes{ri}.name) = row;
324 nj('classSwitchMatrix
') = csDict;
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.
340 cc('capacity
') = ilc;
342 cc('capacity
') = ilc(:)';
344 cc(
'replacement') = repl_to_str(node.replacestrategy);
345 if isprop(node,
'admissionProb') && ~isempty(node.admissionProb)
346 cc('admissionProb') = node.admissionProb;
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;
360 cc(
'hitClass') = hitMap;
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;
371 cc(
'missClass') = missMap;
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;
391 cc(
'popularity') = popMap;
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});
403 cc(
'accessGraph') = gArr;
404 elseif ~isempty(node.accessProb)
405 [Kap, Nap] = size(node.accessProb);
406 apArr = cell(1, Kap);
408 rowArr = cell(1, Nap);
410 if ~isempty(node.accessProb{k1, k2})
411 rowArr{k2} = full(node.accessProb{k1, k2});
418 cc(
'accessProb') = apArr;
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, :))));
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});
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
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;
470 entry('queues
') = qnames;
471 itemsMap = containers.Map();
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;
480 if itemsMap.Count > 0
481 entry('items
') = itemsMap;
483 byClass(classes{inIdx}.name) = entry;
486 rsRoot = containers.Map();
487 rsRoot('capacity
') = double(node.retrievalSystemCapacity);
488 rsRoot('byClass
') = byClass;
489 nj('retrievalSystem
') = rsRoot;
496 if ~isempty(node.output) && isprop(node.output, 'tasksPerLink
') && node.output.tasksPerLink > 1
497 nj('tasksPerLink
') = node.output.tasksPerLink;
501 % Join paired fork and join strategy
503 if ~isempty(node.joinOf)
504 nj('forkNode
') = node.joinOf.name;
506 % Serialize per-class join strategy if non-default
507 if ~isempty(node.input) && isprop(node.input, 'joinStrategy
') && ~isempty(node.input.joinStrategy)
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
';
520 if ~isempty(node.input) && isprop(node.input, 'joinRequired
') && ~isempty(node.input.joinRequired)
523 if r <= length(node.input.joinRequired) && ~isempty(node.input.joinRequired{r})
524 jq = node.input.joinRequired{r};
526 nj('joinQuorum
') = jq;
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();
545 w = node.schedStrategyPar(r);
546 if ~isempty(w) && isfinite(w) && w > 0
553 nj(
'schedParams') = sp;
559 if isa(node,
'Transition')
561 nModes = node.getNumberOfModes();
562 allNodes = model.getNodes();
564 mj = containers.Map();
565 if mi <= length(node.modeNames) && ~isempty(node.modeNames{mi})
566 mj('name') = node.modeNames{mi};
568 mj(
'name') = sprintf(
'Mode%d', mi);
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
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});
582 mj(
'distribution') = dj;
586 if mi <= length(node.timingStrategies)
587 if node.timingStrategies(mi) == TimingStrategy.TIMED
588 mj('timingStrategy') = 'TIMED';
590 mj('timingStrategy') = 'IMMEDIATE';
594 if mi <= length(node.numberOfServers) && node.numberOfServers(mi) > 1
595 mj('numServers') = node.numberOfServers(mi);
598 if mi <= length(node.firingPriorities) && node.firingPriorities(mi) > 0
599 mj('firingPriority') = node.firingPriorities(mi);
602 if mi <= length(node.firingWeights) && node.firingWeights(mi) ~= 1.0
603 mj('firingWeight') = node.firingWeights(mi);
605 % Enabling conditions
606 if mi <= length(node.enablingConditions)
607 ecMat = node.enablingConditions{mi};
609 for ni = 1:size(ecMat, 1)
610 for ci = 1:size(ecMat, 2)
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>
621 mj(
'enablingConditions') = ecList;
624 % Inhibiting conditions
625 if mi <= length(node.inhibitingConditions)
626 icMat = node.inhibitingConditions{mi};
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>
640 mj(
'inhibitingConditions') = icList;
644 if mi <= length(node.firingOutcomes)
645 foMat = node.firingOutcomes{mi};
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>
659 mj(
'firingOutcomes') = foList;
662 modesJson{end+1} = mj; %#ok<AGROW>
664 if ~isempty(modesJson)
665 nj(
'modes') = modesJson;
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(:)'));
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
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
689 trivialPrior = numel(prior) == 1 && abs(prior(1) - 1.0) < 1e-12;
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)));
697 spaceRows = cell(1, size(space, 1));
698 for si = 1:size(space, 1)
699 spaceRows{si} = num2cell(space(si, :));
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');
710 nodesJson{end+1} = nj; %#ok<AGROW>
712result('nodes') = nodesJson;
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;
727 if ~isempty(jc.removalDistribution)
728 cj('removalDistribution') = dist2json(jc.removalDistribution);
730 if ~isempty(jc.removalPolicy) && jc.removalPolicy ~= RemovalPolicy.RANDOM
731 cj('removalPolicy') = RemovalPolicy.toText(jc.removalPolicy);
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;
740 if ~isempty(jc.targetJobClass)
741 cj('targetClass') = jc.targetJobClass.name;
743 if ~isempty(jc.removalDistribution)
744 cj('removalDistribution') = dist2json(jc.removalDistribution);
746 if ~isempty(jc.removalPolicy) && jc.removalPolicy ~= RemovalPolicy.RANDOM
747 cj('removalPolicy') = RemovalPolicy.toText(jc.removalPolicy);
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;
761 if ~isempty(jc.removalDistribution)
762 cj('removalDistribution') = dist2json(jc.removalDistribution);
764 if ~isempty(jc.removalPolicy) && jc.removalPolicy ~= RemovalPolicy.RANDOM
765 cj('removalPolicy') = RemovalPolicy.toText(jc.removalPolicy);
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;
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;
782 elseif isa(jc, 'OpenClass')
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;
794 cj('priority') = jc.priority;
796 if isprop(jc, 'deadline') && isfinite(jc.deadline)
797 cj('deadline') = jc.deadline;
799 if jc.isReferenceClass()
800 cj('isReferenceClass') = true;
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;
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;
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);
820 classesJson{end+1} = cj; %#ok<AGROW>
822result('classes') = classesJson;
825routingMap = containers.Map();
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})
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;
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);
858 if issparse(Prs); Prs = full(Prs); end
859 total = total + Prs(ii, jj);
861 cs_same(s, ii, jj) = total;
868 fromTo = containers.Map();
875 % For explicit CS, use same-class routing only
878 val = cs_same(s, ii, jj);
880 ni = sn.nodenames{ii};
881 njn = sn.nodenames{jj};
883 fromTo(ni) = containers.Map();
891 % Skip cross-class entries from explicit CS
897 ni = sn.nodenames{ii};
898 njn = sn.nodenames{jj};
900 fromTo(ni) = containers.Map();
909 key = char(sprintf('%s,%s', classes{r}.name, classes{s}.name));
910 routingMap(key) = fromTo;
914 elseif ~isempty(sn) && isfield(sn, 'rtnodes') && ~isempty(sn.rtnodes)
915 % Fallback to rtnodes if rtorig not available
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;
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);
941 total = total + rt((ii-1)*K+r, (jj-1)*K+s);
943 cs_same(s, ii, jj) = total;
950 fromTo = containers.Map();
953 % For explicit CS, use same-class routing only
956 val = cs_same(s, ii, jj);
958 ni = sn.nodenames{ii};
959 njn = sn.nodenames{jj};
961 fromTo(ni) = containers.Map();
969 % Skip cross-class entries from explicit CS
973 val = rt((ii-1)*K+r, (jj-1)*K+s);
975 ni = sn.nodenames{ii};
976 njn = sn.nodenames{jj};
978 fromTo(ni) = containers.Map();
987 key = char(sprintf('%s,%s', classes{r}.name, classes{s}.name));
988 routingMap(key) = fromTo;
994 % If struct not available, routing stays empty
997routing = containers.Map();
998routing('type') = 'matrix';
999routing('matrix') = routingMap;
1000result('routing') = routing;
1002% --- Routing Strategies ---
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();
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);
1024 if nodeStrats.Count > 0
1025 routingStrategies(sn2.nodenames{i}) = nodeStrats;
1028 if routingStrategies.Count > 0
1029 result('routingStrategies') = routingStrategies;
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
1036 routingWeights = containers.Map();
1037 for i = 1:sn2.nnodes
1038 nodeObj2 = nodes{i};
1039 nodeClassWeights = containers.Map();
1041 if int32(sn2.routing(i, r)) == int32(RoutingStrategy.WRROBIN)
1042 os = nodeObj2.output.outputStrategy;
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};
1056 if destWeights.Count > 0
1057 nodeClassWeights(classes{r}.name) = destWeights;
1063 if nodeClassWeights.Count > 0
1064 routingWeights(sn2.nodenames{i}) = nodeClassWeights;
1067 if routingWeights.Count > 0
1068 result('routingWeights') = routingWeights;
1074% --- Setup / Delay-Off, Polling Type and Switchover Times ---
1075nodesCellTmp = result('nodes');
1078 if ~isa(nodeObj, 'Queue') || isa(nodeObj, 'Delay')
1082 for nj_idx = 1:length(nodesCellTmp)
1083 if strcmp(nodesCellTmp{nj_idx}('name'), nodeObj.name)
1091 nj = nodesCellTmp{njIdx};
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();
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);
1108 if setupMap.Count > 0
1109 nj('setupTime') = setupMap;
1110 nj('delayOffTime') = delayOffMap;
1113 isPolling = SchedStrategy.toId(nodeObj.schedStrategy) == SchedStrategy.POLLING;
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;
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.
1129 if ~isempty(nodeObj.switchoverTime)
1130 [soRows, soCols] = size(nodeObj.switchoverTime);
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;
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;
1156 if ~isempty(soTimes)
1157 nj('switchoverTimes') = soTimes;
1159 nodesCellTmp{njIdx} = nj;
1161result('nodes') = nodesCellTmp;
1163% --- Heterogeneous Server Types ---
1165 nodesCellTmp = result('nodes');
1168 if isa(nodeObj, 'Queue') && nodeObj.isHeterogeneous()
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
1177 for cci = 1:length(st.compatibleClasses)
1178 ccNames{end+1} = st.compatibleClasses{cci}.name; %#ok<AGROW>
1180 if ~isempty(ccNames)
1181 stj('compatibleClasses') = ccNames;
1183 % Per-class service distributions
1184 svcMap = containers.Map();
1187 dist = nodeObj.getHeteroService(jc, st);
1188 if ~isempty(dist) && ~isa(dist, 'Disabled')
1189 svcMap(jc.name) = dist2json(dist);
1193 stj('service') = svcMap;
1195 stArr{end+1} = stj; %#ok<AGROW>
1198 for nj_idx = 1:length(nodesCellTmp)
1199 nj = nodesCellTmp{nj_idx};
1200 if strcmp(nj('name'), nodeObj.name)
1201 nj('serverTypes') = stArr;
1203 policy = nodeObj.getHeteroSchedPolicy();
1204 if ~isempty(policy) && policy ~= HeteroSchedPolicy.ORDER
1205 nj('heteroSchedPolicy') = HeteroSchedPolicy.toText(policy);
1207 nodesCellTmp{nj_idx} = nj;
1214 result('nodes') = nodesCellTmp;
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();
1226 nodeByName(nodes{i}.name) = nodes{i};
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();
1236 if nodeObj.hasImmediateFeedback(classes{r})
1237 ifMap(classes{r}.name) = true;
1241 nj('immediateFeedback') = ifMap;
1244 if isa(nodeObj, 'Queue')
1246 balkJson = containers.Map();
1249 if nodeObj.hasBalking(jc)
1250 [strategy, thresholds] = nodeObj.getBalking(jc);
1251 bjc = containers.Map();
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';
1258 for ti = 1:length(thresholds)
1259 th = thresholds{ti};
1260 tjson = containers.Map();
1261 tjson('minJobs') = th{1};
1263 tjson('maxJobs') = -1;
1265 tjson('maxJobs') = th{2};
1267 tjson('probability') = th{3};
1268 thArr{end+1} = tjson;
1270 bjc('thresholds') = thArr;
1271 balkJson(jc.name) = bjc;
1274 if balkJson.Count > 0
1275 nj('balking') = balkJson;
1278 retrialJson = containers.Map();
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;
1289 if retrialJson.Count > 0
1290 nj('retrial') = retrialJson;
1293 patienceJson = containers.Map();
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);
1304 patienceJson(jc.name) = pjc;
1307 if patienceJson.Count > 0
1308 nj('patience') = patienceJson;
1310 % Orbit impatience (abandonment from the retrial orbit), distinct from
1311 % the queue patience above.
1312 orbitJson = containers.Map();
1315 orbDist = nodeObj.getOrbitImpatience(jc);
1316 if ~isempty(orbDist) && ~isa(orbDist, 'Disabled')
1317 orbitJson(jc.name) = dist2json(orbDist);
1320 if orbitJson.Count > 0
1321 nj('orbitImpatience') = orbitJson;
1323 % Batch rejection probability (retrial queues), per class
1324 brpJson = containers.Map();
1327 brp = nodeObj.getBatchRejectProbability(jc);
1328 if ~isempty(brp) && brp > 0
1329 brpJson(jc.name) = brp;
1332 if brpJson.Count > 0
1333 nj('batchRejectProb') = brpJson;
1336 nodesCellTmp{nj_idx} = nj;
1338result('nodes') = nodesCellTmp;
1340% --- Finite Capacity Regions ---
1342 regions = model.regions;
1343 if ~isempty(regions)
1345 for ri = 1:length(regions)
1347 rj = containers.Map();
1348 rj('name') = reg.name;
1349 % Stations with per-class details
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();
1359 if r <= length(reg.classMaxJobs) && isfinite(reg.classMaxJobs(r))
1360 ccMap(jc.name) = reg.classMaxJobs(r);
1364 sj('classCap') = ccMap;
1367 % Per-class classWeight
1368 if isprop(reg, 'classWeight') && ~isempty(reg.classWeight)
1369 cwMap = containers.Map();
1372 if r <= length(reg.classWeight) && reg.classWeight(r) ~= 1
1373 cwMap(jc.name) = reg.classWeight(r);
1377 sj('classWeight') = cwMap;
1380 % Per-class classSize
1381 if isprop(reg, 'classSize') && ~isempty(reg.classSize)
1382 csMap = containers.Map();
1385 if r <= length(reg.classSize) && reg.classSize(r) ~= 1
1386 csMap(jc.name) = reg.classSize(r);
1390 sj('classSize') = csMap;
1393 stationsJson{end+1} = sj; %#ok<AGROW>
1395 rj('stations') = stationsJson;
1396 if isprop(reg, 'globalMaxJobs') && isfinite(reg.globalMaxJobs)
1397 rj('globalMaxJobs') = reg.globalMaxJobs;
1399 if isprop(reg, 'globalMaxMemory') && isfinite(reg.globalMaxMemory)
1400 rj('globalMaxMemory') = reg.globalMaxMemory;
1402 % Per-class classMaxJobs at region level
1403 if isprop(reg, 'classMaxJobs') && ~isempty(reg.classMaxJobs)
1404 cmjMap = containers.Map();
1407 if r <= length(reg.classMaxJobs) && isfinite(reg.classMaxJobs(r))
1408 cmjMap(jc.name) = reg.classMaxJobs(r);
1412 rj('classMaxJobs') = cmjMap;
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();
1423 if r <= length(reg.classMaxMemory) && isfinite(reg.classMaxMemory(r)) ...
1424 && reg.classMaxMemory(r) >= 0
1425 cmmMap(jc.name) = reg.classMaxMemory(r);
1429 rj('classMaxMemory') = cmmMap;
1433 if isprop(reg, 'dropRule') && ~isempty(reg.dropRule)
1434 drMap = containers.Map();
1437 if r <= length(reg.dropRule)
1438 drStr = droprule_to_str(reg.dropRule(r));
1440 drMap(jc.name) = drStr;
1445 rj('dropRule') = drMap;
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,:);
1457 rj('constraintA') = Acell;
1458 rj('constraintB') = b(:)';
1461 fcrArray{end+1} = rj; %#ok<AGROW>
1463 if ~isempty(fcrArray)
1464 result('finiteCapacityRegions') = fcrArray;
1471rewardsJson = rewards2json(model);
1472if ~isempty(rewardsJson)
1473 result('rewards') = rewardsJson;
1478% =========================================================================
1479% Reward serialization
1480% =========================================================================
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.
1491if isempty(sn) || ~isfield(sn, 'reward') || isempty(sn.reward)
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;
1501[~, order] = sort(rewardNames);
1502for oi = 1:length(order)
1503 rw = sn.reward{order(oi)};
1505 if isfield(rw, 'descriptor')
1506 descriptor = rw.descriptor;
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));
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));
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));
1527 rj('node') = descriptor.node.name;
1528 if ~isempty(descriptor.jobclass)
1529 rj('class') = descriptor.jobclass.name;
1531 rewardsJson{end+1} = rj; %#ok<AGROW>
1536% =========================================================================
1537% LayeredNetwork serialization
1538% =========================================================================
1540function result = layered2json(model)
1541result = containers.Map();
1542result('type') = 'LayeredNetwork';
1543result('name') = model.getName();
1548for i = 1:length(hosts)
1550 pj = containers.Map();
1551 pj('name') = h.name;
1552 mult = h.multiplicity;
1554 pj('multiplicity') = inf_multiplicity();
1556 pj('multiplicity') = mult;
1558 schedStr = h.scheduling;
1559 if ~isempty(schedStr) && ~strcmpi(schedStr, 'inf')
1560 pj('scheduling') = upper(schedStr);
1563 if q > 0 && q ~= 0.001
1568 pj('speedFactor') = sf;
1570 repl = h.replication;
1572 pj('replication') = repl;
1574 procsJson{end+1} = pj; %#ok<AGROW>
1576result('hosts') = procsJson;
1580tasksList = model.tasks;
1581for i = 1:length(tasksList)
1583 tj = containers.Map();
1584 tj('name') = t.name;
1585 if ~isempty(t.parent)
1586 tj('host') = t.parent.name;
1588 mult = t.multiplicity;
1590 tj('multiplicity') = inf_multiplicity();
1592 tj('multiplicity') = mult;
1594 schedStr = t.scheduling;
1595 if ~isempty(schedStr)
1596 tj('scheduling') = upper(schedStr);
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);
1604 params = containers.Map();
1605 params('lambda') = 1.0 / ttMean;
1606 dj = containers.Map();
1608 dj('params') = params;
1609 tj('thinkTime') = dj;
1613 if ~isempty(t.fanInSource) && ischar(t.fanInSource) && ~isempty(t.fanInSource)
1614 fi = containers.Map();
1615 fi(t.fanInSource) = t.fanInValue;
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);
1626 repl = t.replication;
1628 tj('replication') = repl;
1630 % FunctionTask detection
1631 if isa(t, 'FunctionTask')
1632 tj('taskType') = 'FunctionTask';
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);
1641 if ~isempty(t.delayOffTime) && isa(t.delayOffTime, 'Distribution')
1642 dotMean = t.delayOffTimeMean;
1643 if dotMean > GlobalConstants.FineTol
1644 tj('delayOffTime') = dist2json(t.delayOffTime);
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);
1659 tj('replacementStrategy') = 'FIFO';
1662 tasksJson{end+1} = tj; %#ok<AGROW>
1664result('tasks') = tasksJson;
1668entriesList = model.entries;
1669for i = 1:length(entriesList)
1671 ej = containers.Map();
1672 ej('name') = e.name;
1673 if ~isempty(e.parent)
1674 ej('task') = e.parent.name;
1676 % Entry arrival distribution
1677 if ~isempty(e.arrival) && isa(e.arrival, 'Distribution')
1678 ej('arrival') = dist2json(e.arrival);
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);
1690 entriesJson{end+1} = ej; %#ok<AGROW>
1692result('entries') = entriesJson;
1694% --- Build reply map: activityName -> entryName ---
1695replyMap = containers.Map();
1696for i = 1:length(entriesList)
1698 if ~isempty(e.replyActivity)
1699 for j = 1:length(e.replyActivity)
1700 replyMap(e.replyActivity{j}) = e.name;
1707actsList = model.activities;
1708for i = 1:length(actsList)
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;
1720 elseif ~isempty(a.parentName) && ischar(a.parentName)
1721 aj('task') = a.parentName;
1724 if ~isempty(a.hostDemand) && isa(a.hostDemand, 'Distribution')
1725 if ~isa(a.hostDemand, 'Immediate')
1726 aj('hostDemand') = dist2json(a.hostDemand);
1728 elseif ~isempty(a.hostDemandMean) && a.hostDemandMean > GlobalConstants.FineTol
1729 params = containers.Map();
1730 params('lambda') = 1.0 / a.hostDemandMean;
1731 dj = containers.Map();
1733 dj('params') = params;
1734 aj('hostDemand') = dj;
1737 if ~isempty(a.boundToEntry)
1738 aj('boundToEntry') = a.boundToEntry;
1741 if replyMap.isKey(a.name)
1742 aj('repliesTo') = replyMap(a.name);
1745 if ~isempty(a.syncCallDests)
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);
1753 synchCalls{end+1} = sc; %#ok<AGROW>
1755 aj('synchCalls') = synchCalls;
1758 if ~isempty(a.asyncCallDests)
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);
1766 asynchCalls{end+1} = ac; %#ok<AGROW>
1768 aj('asynchCalls') = asynchCalls;
1770 actsJson{end+1} = aj; %#ok<AGROW>
1772result('activities') = actsJson;
1774% --- Precedences ---
1776for i = 1:length(tasksList)
1778 precs = t.precedences;
1779 if isempty(precs), continue; end
1780 for j = 1:length(precs)
1782 pj = containers.Map();
1783 pj('task') = t.name;
1785 preType = p.preType;
1786 postType = p.postType;
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(:)';
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};
1814 if ~isempty(p.postParams)
1815 pj('loopCount') = p.postParams(1);
1817 elseif preType == ActivityPrecedenceType.PRE_SEQ && postType == ActivityPrecedenceType.POST_CACHE
1818 pj('type') = 'CacheAccess';
1819 pj('activities') = [p.preActs, p.postActs];
1823 precsJson{end+1} = pj; %#ok<AGROW>
1826if ~isempty(precsJson)
1827 result('precedences') = precsJson;
1832% =========================================================================
1833% Workflow serialization
1834% =========================================================================
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();
1844acts = model.activities;
1845for i = 1:length(acts)
1847 aj = containers.Map();
1848 aj('name') = act.name;
1849 if ~isempty(act.hostDemand) && isa(act.hostDemand, 'Distribution')
1850 dj = dist2json(act.hostDemand);
1852 aj('hostDemand') = dj;
1855 actsJson{end+1} = aj; %#ok<AGROW>
1857result('activities') = actsJson;
1859% --- Precedences ---
1861precs = model.precedences;
1862for i = 1:length(precs)
1864 pj = containers.Map();
1868 for a = 1:length(p.preActs)
1869 preActsJson{end+1} = p.preActs{a}; %#ok<AGROW>
1871 pj('preActs') = preActsJson;
1875 for a = 1:length(p.postActs)
1876 postActsJson{end+1} = p.postActs{a}; %#ok<AGROW>
1878 pj('postActs') = postActsJson;
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);
1885 if ~isempty(p.preParams)
1886 pj('preParams') = p.preParams(:)';
1890 if ~isempty(p.postParams)
1891 pj('postParams') = p.postParams(:)';
1894 precsJson{end+1} = pj; %#ok<AGROW>
1896result('precedences') = precsJson;
1900% =========================================================================
1901% Environment serialization
1902% =========================================================================
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();
1910E = height(model.envGraph.Nodes);
1911result('numStages') = 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});
1922 stagesJson{end+1} = sj; %#ok<AGROW>
1924result('stages') = stagesJson;
1926% --- Transitions ---
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});
1937 tj('distribution') = dj;
1938 transJson{end+1} = tj; %#ok<AGROW>
1943result('transitions') = transJson;
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.
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);
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));
1961 nj('breakdownRate') = bj;
1962 if ~isempty(nf.repair)
1963 rj = dist2json(nf.repair);
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));
1969 nj('repairRate') = rj;
1971 dj = dist2json(nf.downService);
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));
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));
1982 nj('breakdownResetPolicy') = nf.breakdownResetPolicy;
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));
1989 nj('repairResetPolicy') = nf.repairResetPolicy;
1992 nfJson{end+1} = nj; %#ok<AGROW>
1995 result('nodeFailures') = nfJson;
2000% =========================================================================
2001% Multiplicity serialization
2002% =========================================================================
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.
2013% =========================================================================
2014% Distribution serialization
2015% =========================================================================
2017function d = dist2json(dist)
2018% Convert a Distribution to a containers.Map for JSON output.
2023d = containers.Map();
2024cn = builtin('class', dist);
2027 d('type') = 'Disabled';
2029 d('type') = 'Immediate';
2032 params = containers.Map();
2033 params('lambda') = dist.getParam(1).paramValue;
2034 d('params') = params;
2037 params = containers.Map();
2038 params('value') = dist.getParam(1).paramValue;
2039 d('params') = params;
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;
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;
2058 params('p') = [p, 1-p];
2059 params('lambda') = [l1, l2];
2061 params('p') = p(:)';
2062 if isscalar(l1) && isscalar(l2)
2063 params('lambda') = [l1, l2];
2065 % n-phase: param2 already holds all n rates (param3 duplicates it)
2066 params('lambda') = l1(:)';
2069 d('params') = params;
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;
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;
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;
2090 params = containers.Map();
2091 params('s') = dist.getParam(3).paramValue;
2092 params('n') = dist.getParam(4).paramValue;
2093 d('params') = params;
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;
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;
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;
2113 d('type') = 'Geometric';
2114 params = containers.Map();
2115 params('p') = dist.getParam(1).paramValue;
2116 d('params') = params;
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;
2124 d('type') = 'Poisson';
2125 params = containers.Map();
2126 params('lambda') = dist.getParam(1).paramValue;
2127 d('params') = params;
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;
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.
2149 ph = containers.Map();
2150 alpha = dist.getInitProb();
2151 T = dist.getSubgenerator();
2153 ph('alpha') = alpha(:)';
2155 ph('alpha') = alpha;
2161 mapSpec = containers.Map();
2162 mapSpec('D0') = dist.getParam(1).paramValue;
2163 mapSpec('D1') = dist.getParam(2).paramValue;
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.
2169 params = containers.Map();
2170 params('D0') = dist.getParam(1).paramValue;
2171 params('D1') = dist.getParam(2).paramValue;
2172 d('params') = params;
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.
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;
2185 params = containers.Map();
2186 params('H0') = dist.getParam(1).paramValue;
2187 params('H1') = dist.getParam(2).paramValue;
2188 d('params') = params;
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.
2195 params = containers.Map();
2196 Kb = dist.getNumberOfTypes;
2197 dArr = cell(1, Kb + 1);
2198 dArr{1} = dist.getParam(1).paramValue; % D0
2200 dArr{1+kb} = dist.getParam(2+kb).paramValue; % Dk, batch size k
2203 d('params') = params;
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;
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
2222 dArr{1+km} = dist.getParam(2+km).paramValue; % D1k
2226 d('params') = params;
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();
2233 params('F') = ecdf(:, 1)';
2234 params('x') = ecdf(:, 2)';
2235 d('params') = params;
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;
2249 d('expolynomial') = ep;
2251 % Marked MAP: {D0, per-mark D1k}; the aggregate D1 is rebuilt on load
2253 mmapSpec = containers.Map();
2254 mmapSpec('D0') = dist.getParam(1).paramValue;
2255 Kmarks = dist.getNumberOfTypes;
2256 d1k = cell(1, Kmarks);
2258 d1k{km} = dist.getParam(2+km).paramValue;
2260 mmapSpec('D1k') = d1k;
2261 d('mmap') = mmapSpec;
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;
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;
2288 d('type') = 'Replayer';
2289 params = containers.Map();
2290 params('fileName') = dist.getParam(1).paramValue;
2292 params('mean') = dist.getMean();
2295 d('params') = params;
2296 % Save APH fit as fallback
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;
2304 ph('alpha') = alpha(:)';
2306 ph('alpha') = alpha;
2314 d('type') = 'Prior';
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>
2323 d('distributions') = alts;
2324 d('probabilities') = dist.probabilities(:)';
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));
2337 params = containers.Map();
2340 d('params') = params;
2345% =========================================================================
2347% =========================================================================
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);
2355if isa(val, 'containers.Map')
2360 parts = cell(1, length(ks));
2361 for i = 1:length(ks)
2364 parts{i} = sprintf('%s"%s": %s', pad2, json_escape(k), encode_value(v, indent + 2));
2366 s = sprintf('{\n%s\n%s}', strjoin(parts, sprintf(',\n')), pad);
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)
2376 if val > 0, s = '"Infinity"'; else, s = '"-Infinity"'; end
2377 elseif val == floor(val) && abs(val) < 1e15
2378 s = sprintf('%d', val);
2380 s = sprintf('%.15g', val);
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);
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);
2393 s = ['[', strjoin(rows, ', '), ']'];
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));
2402 s = sprintf('[\n%s\n%s]', strjoin(parts, sprintf(',\n')), pad);
2404elseif isstruct(val) && isscalar(val)
2405 fnames = fieldnames(val);
2409 parts = cell(1, length(fnames));
2410 for i = 1:length(fnames)
2413 parts{i} = sprintf('%s"%s": %s', pad2, json_escape(fn), encode_value(fv, indent + 2));
2415 s = sprintf('{\n%s\n%s}', strjoin(parts, sprintf(',\n')), pad);
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');
2432% =========================================================================
2434% =========================================================================
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';
2453function s = sched_id_to_str(id)
2454% Map a SchedStrategy numeric ID to the wire enum name.
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)));
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';
2474 line_error(mfilename, sprintf('Unrecognized replacement strategy id %d.', id));
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';
2485 line_error(mfilename, sprintf('Unrecognized departure discipline id %d.', id));
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';
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');
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;
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');
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
2536 v = repmat(double(v), 1, K);
2540 v(~isfinite(v)) = 0;
2541 key = strjoin(arrayfun(@(x) sprintf('%d', x), n, 'UniformOutput', false), ',');
2542 tbl(key) = num2cell(v);
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';
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.
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