2 % PYLINE MATLAB-to-native-Python bridge (lang=
'python').
4 % Static helpers that construct a native line_solver (pure Python, no JVM)
5 % model element-by-element through MATLAB
's in-process Python interface
6 % (py.*), run a native Python solver, and marshal results back. Mirrors the
7 % structure of JLINE.m (line_to_jline/from_line_network/from_line_node/...)
8 % with the jline.* Java backend replaced by py.line_solver.* and the Java
9 % Matrix class replaced by numpy arrays.
11 % Requirements: MATLAB pyenv must point at a CPython where the in-tree
12 % python/ line_solver package is importable. No JPype/JVM is used.
14 % Copyright (c) 2012-2026, Imperial College London
15 % All rights reserved.
19 function pynetwork = line_to_pyline(model)
20 % LINE_TO_PYLINE Top-level entry: LINE model -> py.line_solver model.
22 case {'Network
', 'MNetwork
'}
23 pynetwork = PYLINE.from_line_network(model);
25 pynetwork = PYLINE.from_line_layered_network(model);
27 line_error(mfilename, sprintf('PYLINE (lang=python) does not support model
class ''%s
'' yet.
', class(model)));
31 function pynet = from_line_network(model)
32 % FROM_LINE_NETWORK Build a native Python Network from a LINE Network.
33 PYLINE.assertPythonReady();
34 L = py.importlib.import_module('line_solver
');
36 line_nodes = model.getNodes;
37 line_classes = model.getClasses;
38 nnodes = length(line_nodes);
39 nclasses = length(line_classes);
41 % Advanced nodes (Cache, SPN Place/Transition) carry rich state
42 % (item popularity, transition modes/enabling/firing, token markings)
43 % that is faithfully captured by the canonical line-model JSON schema.
44 % Route such models through the JSON round-trip (linemodel_save ->
45 % native load_model) rather than re-porting every setter over py.*.
46 if any(cellfun(@(nd) isa(nd, 'Cache
') || isa(nd, 'Place
') || isa(nd, 'Transition
'), line_nodes))
47 pynet = PYLINE.from_line_via_json(model);
51 pynet = L.Network(model.getName);
53 % 1) Nodes first (closed classes reference a station node)
54 pynodes = cell(1, nnodes);
56 if isa(line_nodes{n}, 'ClassSwitch
') && line_nodes{n}.autoAdded
57 continue; % Python link() re-adds auto ClassSwitch nodes
59 if isa(line_nodes{n}, 'Join
')
60 forkNode = pynodes{line_nodes{n}.joinOf.index};
61 pynodes{n} = PYLINE.from_line_node(line_nodes{n}, pynet, L, forkNode);
63 pynodes{n} = PYLINE.from_line_node(line_nodes{n}, pynet, L, []);
68 pyclasses = cell(1, nclasses);
70 pyclasses{r} = PYLINE.from_line_class(line_classes{r}, pynet, L, pynodes);
73 % 3) Service / arrival processes
75 if isempty(pynodes{n})
78 PYLINE.set_service(line_nodes{n}, pynodes{n}, line_classes, pyclasses, L);
82 PYLINE.from_line_links(model, pynet, pynodes, pyclasses, L);
85 function pynode = from_line_node(line_node, pynet, L, forkNode)
86 % FROM_LINE_NODE LINE node -> py.line_solver node.
87 if isa(line_node, 'Source
')
88 pynode = L.Source(pynet, line_node.getName);
89 elseif isa(line_node, 'Sink
')
90 pynode = L.Sink(pynet, line_node.getName);
91 elseif isa(line_node, 'Router
')
92 pynode = L.Router(pynet, line_node.getName);
93 elseif isa(line_node, 'Delay
')
94 pynode = L.Delay(pynet, line_node.getName);
95 elseif isa(line_node, 'Fork
')
96 pynode = L.Fork(pynet, line_node.getName);
97 if ~isempty(line_node.output) && isprop(line_node.output, 'tasksPerLink
') && ~isempty(line_node.output.tasksPerLink)
98 tpl = double(line_node.output.tasksPerLink);
99 % Standard forks use tasksPerLink=1 (the native default); only
100 % override when a quorum / task multiplier is actually set.
102 pynode.setTasksPerLink(PYLINE.from_line_matrix(tpl(:)'));
105 elseif isa(line_node,
'Join')
106 pynode = L.Join(pynet, line_node.getName, forkNode);
107 elseif isa(line_node, 'ClassSwitch')
108 % Explicit ClassSwitch:
the K x K switch-probability matrix lives
109 % in server.csMatrix;
the node routes class-preserving to/from its
110 % neighbours (switching happens inside
the node). Auto-added
111 % ClassSwitch
nodes are skipped upstream and re-added by link().
112 csMatrix = line_node.server.csMatrix;
113 pynode = L.ClassSwitch(pynet, line_node.getName, PYLINE.from_line_matrix(csMatrix));
114 elseif isa(line_node, 'Queue')
115 pysched = PYLINE.to_py_sched(line_node.schedStrategy, L);
116 pynode = L.Queue(pynet, line_node.getName, pysched);
117 nservers = line_node.getNumberOfServers;
119 pynode.setNumberOfServers(int32(intmax('int32')));
121 pynode.setNumberOfServers(int32(nservers));
123 if ~isinf(line_node.cap)
124 pynode.setCapacity(int32(line_node.cap));
126 if ~isempty(line_node.lldScaling)
127 pynode.setLoadDependence(PYLINE.from_line_matrix(line_node.lldScaling));
130 line_error(mfilename, sprintf('PYLINE (lang=python) does not support node ''%s'' (%s) yet.', line_node.getName, class(line_node)));
134 function pysched = to_py_sched(schedId, L)
135 % TO_PY_SCHED SchedStrategy
id -> py.line_solver.SchedStrategy enum.
136 name =
char(SchedStrategy.toProperty(SchedStrategy.toText(schedId)));
138 pysched = L.SchedStrategy.(name);
140 line_error(mfilename, sprintf('PYLINE (lang=python) does not support
the %s scheduling strategy yet.', name));
144 function pyclass = from_line_class(line_class, pynet, L, pynodes)
145 % FROM_LINE_CLASS LINE job class -> py.line_solver class.
146 if isa(line_class, 'OpenClass')
147 pyclass = L.OpenClass(pynet, line_class.getName, int32(line_class.priority));
148 elseif isa(line_class, 'SelfLoopingClass')
149 refnode = pynodes{line_class.refstat.index};
150 pyclass = L.SelfLoopingClass(pynet, line_class.getName, int32(line_class.population), refnode, int32(line_class.priority));
151 elseif isa(line_class,
'ClosedClass')
152 refnode = pynodes{line_class.refstat.index};
153 pyclass = L.ClosedClass(pynet, line_class.getName, int32(line_class.population), refnode, int32(line_class.priority));
155 line_error(mfilename, sprintf(
'PYLINE (lang=python) does not support class type ''%s'' yet.',
class(line_class)));
159 function set_service(line_node, pynode, line_classes, pyclasses, L)
160 % SET_SERVICE Transfer arrival/service processes.
161 if isa(line_node,
'Sink') || isa(line_node,
'Router') || isa(line_node,
'ClassSwitch') || ...
162 isa(line_node,
'Fork') || isa(line_node,
'Join') || isa(line_node,
'Cache') || isa(line_node,
'Logger')
165 for r = 1:length(line_classes)
166 if isa(line_node, 'Source')
167 matlab_dist = line_node.getArrivalProcess(line_classes{r});
168 if isempty(matlab_dist) || isa(matlab_dist,
'Disabled')
171 pynode.setArrival(pyclasses{r}, PYLINE.from_line_distribution(matlab_dist, L));
172 elseif isa(line_node,
'Queue') || isa(line_node,
'Delay')
173 matlab_dist = line_node.getService(line_classes{r});
174 if isempty(matlab_dist) || isa(matlab_dist,
'Disabled')
177 pynode.setService(pyclasses{r}, PYLINE.from_line_distribution(matlab_dist, L));
182 function pydist = from_line_distribution(line_dist, L)
183 % FROM_LINE_DISTRIBUTION LINE distribution -> py.line_solver process.
184 if isa(line_dist,
'Exp')
185 pydist = L.Exp(line_dist.getParam(1).paramValue);
186 elseif isa(line_dist, 'Erlang')
187 pydist = L.Erlang(line_dist.getParam(1).paramValue, int32(line_dist.getParam(2).paramValue));
188 elseif isa(line_dist, 'HyperExp')
189 pydist = L.HyperExp(line_dist.getParam(1).paramValue, line_dist.getParam(2).paramValue, line_dist.getParam(3).paramValue);
190 elseif isa(line_dist, 'APH') || isa(line_dist, 'PH')
191 alpha = line_dist.getParam(1).paramValue;
192 T = line_dist.getParam(2).paramValue;
193 pydist = L.PH(PYLINE.from_line_matrix(alpha(:)'), PYLINE.from_line_matrix(T));
194 elseif isa(line_dist, 'Coxian') % includes Cox2
195 [alpha, T] = PYLINE.coxian_to_ph(line_dist);
196 pydist = L.PH(PYLINE.from_line_matrix(alpha(:)'), PYLINE.from_line_matrix(T));
197 elseif isa(line_dist, 'Det')
198 pydist = L.Det(line_dist.getParam(1).paramValue);
199 elseif isa(line_dist, 'Gamma')
200 pydist = L.Gamma(line_dist.getParam(1).paramValue, line_dist.getParam(2).paramValue);
201 elseif isa(line_dist, 'Pareto')
202 pydist = L.Pareto(line_dist.getParam(1).paramValue, line_dist.getParam(2).paramValue);
203 elseif isa(line_dist, 'Weibull')
204 pydist = L.Weibull(line_dist.getParam(1).paramValue, line_dist.getParam(2).paramValue);
205 elseif isa(line_dist, 'Lognormal')
206 pydist = L.Lognormal(line_dist.getParam(1).paramValue, line_dist.getParam(2).paramValue);
207 elseif isa(line_dist, 'Uniform')
208 pydist = L.Uniform(line_dist.getParam(1).paramValue, line_dist.getParam(2).paramValue);
209 elseif isa(line_dist, 'Normal')
210 pydist = L.Normal(line_dist.getParam(1).paramValue, line_dist.getParam(2).paramValue);
211 elseif isa(line_dist, 'MMPP2')
212 pydist = L.MMPP2(line_dist.getParam(1).paramValue, line_dist.getParam(2).paramValue, ...
213 line_dist.getParam(3).paramValue, line_dist.getParam(4).paramValue);
214 elseif isa(line_dist, 'MAP')
215 pydist = L.MAP(PYLINE.from_line_matrix(line_dist.D(0)), PYLINE.from_line_matrix(line_dist.D(1)));
216 elseif isa(line_dist, 'MMPP')
217 pydist = L.MAP(PYLINE.from_line_matrix(line_dist.D(0)), PYLINE.from_line_matrix(line_dist.D(1)));
218 elseif isa(line_dist, 'ME')
219 pydist = L.ME(PYLINE.from_line_matrix(line_dist.getParam(1).paramValue), PYLINE.from_line_matrix(line_dist.getParam(2).paramValue));
220 elseif isa(line_dist, 'RAP')
221 pydist = L.RAP(PYLINE.from_line_matrix(line_dist.getParam(1).paramValue), PYLINE.from_line_matrix(line_dist.getParam(2).paramValue));
222 elseif isa(line_dist, 'NHPP')
223 pydist = L.NHPP(PYLINE.from_line_matrix(line_dist.getBreakpoints()), PYLINE.from_line_matrix(line_dist.getRates()), logical(line_dist.isCyclic()));
224 elseif isa(line_dist, 'Zipf')
225 pydist = L.Zipf(line_dist.getParam(3).paramValue, int32(line_dist.getParam(4).paramValue));
226 elseif isa(line_dist, 'Trace') % before Replayer (Trace < Replayer)
227 pydist = L.Trace(line_dist.params{1}.paramValue);
228 elseif isa(line_dist,
'Replayer')
229 pydist = L.Replayer(line_dist.params{1}.paramValue);
230 elseif isa(line_dist,
'Bernoulli')
231 pydist = L.Bernoulli(line_dist.getParam(1).paramValue);
232 elseif isa(line_dist, 'Binomial')
233 pydist = L.Binomial(int32(line_dist.getParam(1).paramValue), line_dist.getParam(2).paramValue);
234 elseif isa(line_dist, 'Geometric')
235 pydist = L.Geometric(line_dist.getParam(1).paramValue);
236 elseif isa(line_dist, 'Poisson')
237 pydist = L.Poisson(line_dist.getParam(1).paramValue);
238 elseif isa(line_dist, 'DiscreteUniform')
239 pydist = L.DiscreteUniform(int32(line_dist.getParam(1).paramValue), int32(line_dist.getParam(2).paramValue));
240 elseif isa(line_dist, 'DiscreteSampler')
241 pydist = L.DiscreteSampler(PYLINE.from_line_matrix(line_dist.getParam(1).paramValue), PYLINE.from_line_matrix(line_dist.getParam(2).paramValue));
242 elseif isa(line_dist, 'Immediate')
243 pydist = L.Immediate();
244 elseif isempty(line_dist) || isa(line_dist, 'Disabled')
245 pydist = L.Disabled();
247 line_error(mfilename, sprintf('PYLINE (lang=python) does not support distribution ''%s'' yet.', class(line_dist)));
251 function [alpha, T] = coxian_to_ph(line_dist)
252 % COXIAN_TO_PH Bidiagonal (alpha,T) PH representation of a Coxian.
253 mu = line_dist.getParam(1).paramValue;
254 phi = line_dist.getParam(2).paramValue;
264 T(i, i+1) = mu(i) * (1 - phi(i));
269 function from_line_links(model, pynet, pynodes, pyclasses, L)
270 % FROM_LINE_LINKS Reconstruct routing on
the Python model.
271 connections = model.getConnectionMatrix();
272 line_nodes = model.getNodes;
273 sn = model.getStruct;
274 nclasses = length(pyclasses);
276 % MATLAB node index -> Python node index (skip auto ClassSwitch)
277 m2p = zeros(1, length(line_nodes));
279 for i = 1:length(line_nodes)
280 if isa(line_nodes{i},
'ClassSwitch') && line_nodes{i}.autoAdded
288 useLinkMethod = ~isempty(sn.rtorig);
289 hasAutoCS = any(cellfun(@(nd) isa(nd,
'ClassSwitch') && nd.autoAdded, line_nodes));
292 rm = L.RoutingMatrix(pynet);
295 if useLinkMethod && hasAutoCS
296 % Class-switching routing lives in sn.rtorig (station-indexed,
297 % already excluding
auto ClassSwitch
nodes).
300 Prs = sn.rtorig{r, s};
304 [nrows, ncols] = size(Prs);
308 rm.set(pyclasses{r}, pyclasses{s}, pynodes{i}, pynodes{j}, Prs(i, j));
318 for i = 1:size(connections, 1)
319 line_node = line_nodes{i};
320 if isa(line_node,
'ClassSwitch') && line_node.autoAdded
325 output_strat = line_node.output.outputStrategy{k};
326 strat = RoutingStrategy.fromText(output_strat{2});
328 case RoutingStrategy.DISABLED
329 pynodes{i}.setRouting(pyclasses{k}, L.RoutingStrategy.DISABLED);
330 case RoutingStrategy.RAND
331 pynodes{i}.setRouting(pyclasses{k}, L.RoutingStrategy.RAND);
333 for j = find(connections(i, :))
335 pynet.addLink(pynodes{i}, pynodes{j});
339 case RoutingStrategy.PROB
340 pynodes{i}.setRouting(pyclasses{k}, L.RoutingStrategy.PROB);
341 if length(output_strat) >= 3
342 probs = output_strat{3};
343 for j = 1:length(probs)
344 dest_idx = probs{j}{1}.index;
345 if connections(i, dest_idx) ~= 0 && m2p(dest_idx) >= 0
347 rm.set(pyclasses{k}, pyclasses{k}, pynodes{i}, pynodes{dest_idx}, probs{j}{2});
349 pynodes{i}.setProbRouting(pyclasses{k}, pynodes{dest_idx}, probs{j}{2});
354 case RoutingStrategy.RROBIN
356 line_error(mfilename,
'RROBIN cannot be used together with the link() command.');
358 pynodes{i}.setRouting(pyclasses{k}, L.RoutingStrategy.RROBIN);
359 for j = find(connections(i, :))
361 pynet.addLink(pynodes{i}, pynodes{j});
364 case RoutingStrategy.JSQ
365 pynodes{i}.setRouting(pyclasses{k}, L.RoutingStrategy.JSQ);
367 for j = find(connections(i, :))
369 pynet.addLink(pynodes{i}, pynodes{j});
373 case RoutingStrategy.WRROBIN
375 line_error(mfilename,
'WRROBIN cannot be used together with the link() command.');
377 for j = find(connections(i, :))
379 pynet.addLink(pynodes{i}, pynodes{j});
382 % output_strat{3} = list of {targetNode, weight} pairs
383 for j = 1:length(output_strat{3})
384 tgt = output_strat{3}{j}{1};
385 weight = output_strat{3}{j}{2};
386 pynodes{i}.setRouting(pyclasses{k}, L.RoutingStrategy.WRROBIN, pynodes{tgt.index}, weight);
388 case RoutingStrategy.KCHOICES
389 if length(output_strat) >= 3 && length(output_strat{3}) >= 2
390 kparam = output_strat{3}{1};
391 memparam = logical(output_strat{3}{2});
392 pynodes{i}.setRouting(pyclasses{k}, L.RoutingStrategy.KCHOICES, int32(kparam), memparam);
394 pynodes{i}.setRouting(pyclasses{k}, L.RoutingStrategy.KCHOICES);
397 for j = find(connections(i, :))
399 pynet.addLink(pynodes{i}, pynodes{j});
403 case RoutingStrategy.RL
404 % The native line_solver exposes no API to carry an RL
405 % value function / action
map, so it cannot reproduce a
406 % learned RL policy. Fail clearly rather than silently
407 % solving a different (
default) policy.
408 line_error(mfilename,
'PYLINE (lang=python) cannot bridge RL routing: the native line_solver has no value-function API to reproduce the learned policy. Use lang=''matlab'' for RL routing.');
410 line_error(mfilename, sprintf(
'PYLINE (lang=python) does not support the ''%s'' routing strategy.', output_strat{2}));
420 %% ---- Marshalling helpers ----
422 function pyarr = from_line_matrix(matrix)
423 % FROM_LINE_MATRIX MATLAB
double matrix -> numpy ndarray.
424 np = py.importlib.import_module(
'numpy');
426 pyarr = np.zeros(py.tuple({int32(0), int32(0)}));
429 [rows, cols] = size(matrix);
430 % Build a nested Python list to avoid ambiguous
auto-conversion,
431 % then let numpy assemble
the 2-D array.
432 rowsCell = cell(1, rows);
434 rowsCell{r} = py.list(num2cell(
double(matrix(r, :))));
436 pyarr = np.array(py.list(rowsCell));
437 % Preserve
column-vector / row-vector shape when a singleton dim.
438 if rows == 1 || cols == 1
439 pyarr = np.reshape(pyarr, py.tuple({int32(rows), int32(cols)}));
443 function matrix = from_pyline_matrix(pyarr)
444 % FROM_PYLINE_MATRIX numpy ndarray / scalar -> MATLAB
double.
445 if isa(pyarr,
'py.NoneType')
449 np = py.importlib.import_module('numpy');
450 matrix =
double(np.asarray(pyarr, pyargs('dtype', 'float64')));
453 function pyopts = parseSolverOptions(options, solverName)
454 % PARSESOLVEROPTIONS MATLAB options struct -> native solver kwargs.
455 % The native SolverXOptions constructors have per-solver parameter
456 % lists, so build a candidate native-name->value
map and forward
457 % only
the keys
the target options class accepts.
458 accepted = PYLINE.acceptedKwargs(solverName);
460 cand = {}; % {nativeName, value} pairs
461 if isfield(options,
'tol') && ~isempty(options.tol)
462 cand = [cand, {
'tol', options.tol}];
464 if isfield(options,
'iter_tol') && ~isempty(options.iter_tol)
465 cand = [cand, {
'iter_tol', options.iter_tol}];
467 if isfield(options,
'iter_max') && ~isempty(options.iter_max)
468 % Different options classes name
this max_iter vs iter_max.
469 cand = [cand, {
'max_iter', int32(options.iter_max), ...
470 'iter_max', int32(options.iter_max)}];
472 if isfield(options,
'seed') && ~isempty(options.seed)
473 cand = [cand, {
'seed', int32(options.seed)}];
475 if isfield(options,
'cutoff') && ~isempty(options.cutoff) && isfinite(options.cutoff)
476 cand = [cand, {
'cutoff', int32(options.cutoff)}];
478 if isfield(options,
'samples') && ~isempty(options.samples)
479 cand = [cand, {
'samples', int32(options.samples)}];
481 cand = [cand, {
'verbose',
false}];
484 for i = 1:2:numel(cand)
485 if any(strcmp(cand{i}, accepted))
486 args = [args, {cand{i}, cand{i+1}}]; %#ok<AGROW>
489 pyopts = pyargs(args{:});
492 function names = acceptedKwargs(solverName)
493 % ACCEPTEDKWARGS Native SolverXOptions constructor parameter names.
496 names = {
'max_iter',
'tol',
'verbose',
'seed',
'cutoff',
'samples'};
498 names = {
'tol',
'iter_max',
'iter_tol',
'verbose',
'seed',
'cutoff',
'samples'};
500 names = {
'tol',
'cutoff',
'seed',
'samples',
'verbose'};
502 names = {
'tol',
'max_iter',
'verbose'};
503 case {
'SolverFluid',
'SolverFLD'}
504 names = {
'tol',
'iter_max',
'iter_tol',
'verbose',
'seed',
'cutoff',
'samples'};
506 names = {
'tol',
'samples',
'seed',
'cutoff',
'verbose'};
512 %% ---- Solver constructors ----
514 function pysolver = Solver(name, pynet, options, L)
515 % SOLVER Dispatch to
the native Python solver constructor by name.
517 if isfield(options,
'method') && ~isempty(options.method)
518 method =
char(options.method);
520 pyopts = PYLINE.parseSolverOptions(options, name);
523 pysolver = L.SolverMVA(pynet, method, pyopts);
525 pysolver = L.SolverNC(pynet, method, pyopts);
527 pysolver = L.SolverCTMC(pynet, method, pyopts);
529 pysolver = L.SolverMAM(pynet, method, pyopts);
530 case {
'SolverFluid',
'SolverFLD'}
531 pysolver = L.SolverFluid(pynet, method, pyopts);
533 pysolver = L.SolverSSA(pynet, method, pyopts);
535 pysolver = L.SolverAuto(pynet, method, pyopts);
537 line_error(mfilename, sprintf(
'PYLINE (lang=python) does not support %s yet.', name));
541 function [QN, UN, RN, TN, AN, WN, runtime] = getAvg(solverName, model, options)
542 % GETAVG Build
the native model+solver, run getAvg(), marshal back.
543 L = py.importlib.import_module(
'line_solver');
545 pynet = PYLINE.line_to_pyline(model);
546 pysolver = PYLINE.Solver(solverName, pynet, options, L);
547 res = cell(pysolver.getAvg());
548 % Native getAvg() returns (Q,U,R,T,A,W) as (M x R) numpy arrays.
549 QN = PYLINE.from_pyline_matrix(res{1});
550 UN = PYLINE.from_pyline_matrix(res{2});
551 RN = PYLINE.from_pyline_matrix(res{3});
552 TN = PYLINE.from_pyline_matrix(res{4});
553 AN = PYLINE.from_pyline_matrix(res{5});
554 WN = PYLINE.from_pyline_matrix(res{6});
555 runtime = toc(Tstart);
558 %% ---- JSON model bridge (advanced Network features, Environment) ----
560 function pynet = from_line_via_json(model)
561 % FROM_LINE_VIA_JSON Bridge a model through
the canonical line-model
562 % JSON schema: MATLAB linemodel_save -> temp .json -> native
563 % load_model. Used
for models whose element-by-element construction
564 %
is impractical over py.* (Cache, SPN Place/Transition, Environment).
565 PYLINE.assertPythonReady();
566 L = py.importlib.import_module(
'line_solver');
567 jsonfile = [tempname,
'.json'];
568 linemodel_save(model, jsonfile);
569 cleanup = onCleanup(@() PYLINE.tryDelete(jsonfile)); %#ok<NASGU>
570 pynet = L.load_model(jsonfile);
573 %% ---- LayeredNetwork (LQN) ----
575 function pynet = from_line_layered_network(model)
576 % FROM_LINE_LAYERED_NETWORK LINE LayeredNetwork -> native LQN.
577 % Bridged through
the canonical LQN XML interchange format:
the
578 % element-by-element LQN graph (processors/tasks/entries/activities/
579 % calls/precedences)
is large and error-prone to marshal over py.*,
580 % whereas writeXML/parseXML
is a faithful, well-established round-trip.
581 PYLINE.assertPythonReady();
582 L = py.importlib.import_module(
'line_solver');
583 xmlfile = [tempname,
'.lqnx'];
584 model.writeXML(xmlfile);
585 cleanup = onCleanup(@() PYLINE.tryDelete(xmlfile));
586 pynet = L.LayeredNetwork.parseXML(xmlfile);
587 PYLINE.restoreNonRefThinkTimes(model, pynet);
590 function restoreNonRefThinkTimes(model, pynet)
591 % RESTORENONREFTHINKTIMES Reapply
the one piece of LINE state that
592 %
the .lqnx interchange cannot carry. lqns rejects think-time on a
593 % non-reference task, so writeXML omits it there; LINE nonetheless
594 % gives such a think time to
the task
's callers as a delay, and
595 % without this the bridged model would be a DIFFERENT model (the
596 % layer of the called task loses its delay, which moves that layer's
597 % throughput and every quantity derived from it).
598 pytasks = cell(py.list(pynet.tasks));
599 byName = containers.Map(
'KeyType',
'char',
'ValueType',
'any');
600 for k = 1:numel(pytasks)
601 byName(
char(pytasks{k}.name)) = pytasks{k};
603 for t = 1:numel(model.tasks)
604 task = model.tasks{t};
605 if SchedStrategy.fromText(task.scheduling) == SchedStrategy.REF
608 if isempty(task.thinkTimeMean) || task.thinkTimeMean <= 0
611 if isKey(byName, task.name)
612 pytask = byName(task.name);
613 pytask.set_think_time(task.thinkTimeMean);
618 function tryDelete(f)
619 % TRYDELETE Best-effort temp-file removal.
625 function pysolver = SolverLN(pynet, options, L)
626 % SOLVERLN Native SolverLN over an LQN model.
627 pysolver = L.SolverLN(pynet, PYLINE.parseSolverOptions(options, 'SolverLN'));
630 function [QN, UN, RN, TN, AN, WN, runtime] = getEnsembleAvg(model, options, nElem)
631 % GETENSEMBLEAVG Build
the native LQN + SolverLN, run
632 % get_ensemble_avg(), marshal
the per-element metric vectors back.
633 % Native get_ensemble_avg() returns (Q,U,R,T,A,W) as
column vectors
634 % positionally aligned with
the LQN element index. The native LQN
635 % struct keeps a leading index-0 placeholder (empty name), so
the
636 % vectors are length nidx+1; MATLAB self.lqn.names has nidx entries.
637 % nElem = numel(self.lqn.names)
is used to drop that placeholder so
638 %
the vectors line up 1:1 with
the MATLAB element order.
639 L = py.importlib.import_module('line_solver');
641 pynet = PYLINE.from_line_layered_network(model);
642 pysolver = PYLINE.SolverLN(pynet, options, L);
643 res = cell(pysolver.get_ensemble_avg());
644 QN = PYLINE.trimLNVector(PYLINE.from_pyline_matrix(res{1}), nElem);
645 UN = PYLINE.trimLNVector(PYLINE.from_pyline_matrix(res{2}), nElem);
646 RN = PYLINE.trimLNVector(PYLINE.from_pyline_matrix(res{3}), nElem);
647 TN = PYLINE.trimLNVector(PYLINE.from_pyline_matrix(res{4}), nElem);
648 AN = PYLINE.trimLNVector(PYLINE.from_pyline_matrix(res{5}), nElem);
649 WN = PYLINE.trimLNVector(PYLINE.from_pyline_matrix(res{6}), nElem);
650 runtime = toc(Tstart);
653 function [SensTable, sens] = getLNSensitivityTable(model, options, varargin)
654 % GETLNSENSITIVITYTABLE Build
the native LQN + SolverLN and marshal
655 % get_sensitivity_table() back into
the MATLAB layer-wise table.
656 % The native call runs
the fixed-point loop itself when
the layers
657 % have not been solved, so
the layer derivatives are taken at
the
658 % converged parameterization (see solver_ln.py getSensitivityTable).
660 % The name-value options are those of
661 % @NetworkSolver/getSensitivityTable ('method', 'step', 'scheme');
662 % an empty step forwards as None, which selects
the native default.
663 L = py.importlib.import_module('line_solver');
664 pynet = PYLINE.from_line_layered_network(model);
665 pysolver = PYLINE.SolverLN(pynet, options, L);
667 method = 'auto'; step = []; scheme = 'forward';
668 for a = 1:2:numel(varargin)
669 switch lower(varargin{a})
670 case 'method', method = lower(varargin{a+1});
671 case 'step', step = varargin{a+1};
672 case 'scheme', scheme = lower(varargin{a+1});
674 line_error(mfilename, sprintf(
'Unknown option ''%s''.', varargin{a}));
682 T = pysolver.getSensitivityTable(pyargs(
'method', method, ...
683 'step', pystep,
'scheme', scheme));
685 Layer = PYLINE.pyStringColumn(T,
'Layer');
686 Station = PYLINE.pyStringColumn(T,
'Station');
687 JobClass = PYLINE.pyStringColumn(T,
'JobClass');
688 dTput = PYLINE.pyNumericColumn(T,
'dTput_dRate');
689 dRespT = PYLINE.pyNumericColumn(T,
'dRespT_dRate');
690 dQLen = PYLINE.pyNumericColumn(T,
'dQLen_dRate');
691 dUtil = PYLINE.pyNumericColumn(T,
'dUtil_dRate');
693 SensTable = table(Layer,
Station, JobClass, dTput, dRespT, dQLen, dUtil, ...
694 'VariableNames', {
'Layer',
'Station',
'JobClass',
'dTput_dRate', ...
695 'dRespT_dRate',
'dQLen_dRate',
'dUtil_dRate'});
698 summary = char(attrs.get(
'method'));
699 pyMethods = cell(py.list(attrs.get(
'layer_methods')));
700 methods = cell(1, numel(pyMethods));
701 for e = 1:numel(pyMethods)
702 if isa(pyMethods{e},
'py.NoneType')
705 methods{e} = char(pyMethods{e});
708 SensTable.Properties.UserData =
struct(
'method', summary, ...
709 'layerMethods', {methods});
711 pySens = cell(py.list(attrs.get(
'sens')));
712 sens = cell(1, numel(pySens));
713 for e = 1:numel(pySens)
714 sens{e} = PYLINE.pySensStruct(pySens{e});
718 function c = pyStringColumn(T, name)
719 % PYSTRINGCOLUMN pandas
column of str ->
column cell of
char.
720 vals = cell(py.list(T.get(name).tolist()));
721 c = cell(numel(vals), 1);
722 for i = 1:numel(vals)
723 c{i} = char(vals{i});
727 function v = pyNumericColumn(T, name)
728 % PYNUMERICCOLUMN pandas
column of
float ->
column double vector.
729 v = PYLINE.from_pyline_matrix(T.get(name).to_numpy());
733 function s = pySensStruct(pyobj)
734 % PYSENSSTRUCT native pfqn_sens result ->
the MATLAB pfqn_sens
735 %
struct returned as
the second output of getSensitivityTable.
736 % Empty
for a layer that took
the finite-difference branch, which
737 % carries no analytic Jacobian.
738 if isempty(pyobj) || isa(pyobj,
'py.NoneType')
743 fields = {
'X',
'Q',
'U',
'R',
'dX',
'dQ',
'dU',
'dR',
'QCov',
'QVar', ...
744 'QTotVar',
'QCovAsym'};
745 for k = 1:numel(fields)
747 if ~py.hasattr(pyobj, f)
750 val = py.getattr(pyobj, f);
751 if isa(val,
'py.NoneType')
754 s.(f) = PYLINE.from_pyline_matrix(val);
756 if isfield(s, 'QCovAsym')
757 s.QCovAsym =
double(s.QCovAsym);
759 % Native params
is a list of dicts with 0-based station/
jobclass
760 % (station -1 for a think-time parameter); pfqn_sens.m returns a
761 % 1 x
P struct array with 1-based .station (0 for Z) and .class.
762 if py.hasattr(pyobj, 'params')
763 pyParams = cell(py.list(py.getattr(pyobj, 'params')));
765 params = struct('type', cell(1,
P), 'station', cell(1,
P), ...
766 'class', cell(1,
P));
769 params(p).type = char(d.get(
'type'));
770 st = double(d.get(
'station'));
772 params(p).station = 0;
774 params(p).station = st + 1;
776 params(p).class = double(d.get(
'jobclass')) + 1;
782 function [QN, UN, TN, runtime] = getEnvAvg(model, options)
783 % GETENVAVG Build a native Environment (via JSON) + SolverENV and
784 % marshal
the environment-weighted (Q,U,T) metrics back. Uses
the
785 % statevec method (
the documented bit-identical env analyzer); its
786 % inner per-stage solver
is a CTMC with a finite transient timespan.
787 L = py.importlib.import_module(
'line_solver');
789 penv = PYLINE.from_line_via_json(model);
791 if isfield(options,
'method') && ~isempty(options.method)
792 method =
char(options.method);
795 if isfield(options, 'timespan') && numel(options.timespan) == 2 && isfinite(options.timespan(2))
796 Tend = options.timespan(2);
798 facsrc = sprintf('lambda m: __import__("line_solver").SolverCTMC(m, timespan=[0.0,%.15g], verbose=False)', Tend);
799 fac = py.eval(facsrc, py.dict());
800 psolver = L.SolverENV(penv, fac, py.dict(pyargs('method', method)));
801 res = cell(psolver.getAvg());
802 QN = PYLINE.from_pyline_matrix(res{1});
803 UN = PYLINE.from_pyline_matrix(res{2});
804 TN = PYLINE.from_pyline_matrix(res{3});
805 runtime = toc(Tstart);
808 function v = trimLNVector(v, nElem)
809 % TRIMLNVECTOR Align a native LQN metric vector to nElem MATLAB
810 % elements: drop
the leading index-0 placeholder when present.
812 if numel(v) == nElem + 1
814 elseif numel(v) ~= nElem
815 line_error(mfilename, sprintf(
'PYLINE (lang=python) LQN result length (%d) does not match the expected element count (%d or %d).', numel(v), nElem, nElem+1));
819 %% ---- Environment checks ----
821 function assertPythonReady()
822 % ASSERTPYTHONREADY Verify
the native line_solver
is importable and
823 % pin
the embedded interpreter
's BLAS/OpenMP thread pools to a single
824 % thread. Running numpy/scipy in-process (py.*) alongside MATLAB's own
825 % worker threads otherwise causes BLAS oversubscription that spins or
826 % deadlocks
the session on heavy linear-algebra algorithms (e.g. CTMC).
827 persistent ready tpHandle %#ok<PUSE>
828 if ~isempty(ready) && ready
832 if strcmp(pe.Status,
'NotLoaded') && isempty(pe.Executable)
833 line_error(mfilename, 'lang=python requires a configured MATLAB Python environment (see pyenv).');
835 % Set before
the first numpy import so a fresh interpreter picks
836 % single-threaded BLAS from
the start.
837 setenv('OMP_NUM_THREADS', '1');
838 setenv('OPENBLAS_NUM_THREADS', '1');
839 setenv('MKL_NUM_THREADS', '1');
840 setenv('NUMEXPR_NUM_THREADS', '1');
842 py.importlib.import_module('line_solver');
844 line_error(mfilename, sprintf('lang=python could not import
the native line_solver package via pyenv (%s). Set pyenv to a CPython where python/
is on sys.path.', ME.message));
846 % Runtime fallback: if numpy was already loaded with multithreaded
847 % BLAS in this session, clamp
the live thread pools too. The handle
848 %
is kept persistent so
the limit
is not reverted on GC.
850 tp = py.importlib.import_module('threadpoolctl');
851 tpHandle = tp.threadpool_limits(pyargs('limits', int32(1)));
853 % threadpoolctl unavailable;
the env vars above cover fresh
854 % interpreters. On an already-loaded multithreaded numpy without
855 % threadpoolctl, restart MATLAB after setting
the env vars.