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 % see CLAUDE.md (lang='python
' backend: Model bridge) for rationale
42 if any(cellfun(@(nd) isa(nd, 'Cache
') || isa(nd, 'Place
') || isa(nd, 'Transition
'), line_nodes))
43 pynet = PYLINE.from_line_via_json(model);
47 pynet = L.Network(model.getName);
49 % 1) Nodes first (closed classes reference a station node)
50 pynodes = cell(1, nnodes);
52 if isa(line_nodes{n}, 'ClassSwitch
') && line_nodes{n}.autoAdded
53 continue; % Python link() re-adds auto ClassSwitch nodes
55 if isa(line_nodes{n}, 'Join
')
56 forkNode = pynodes{line_nodes{n}.joinOf.index};
57 pynodes{n} = PYLINE.from_line_node(line_nodes{n}, pynet, L, forkNode);
59 pynodes{n} = PYLINE.from_line_node(line_nodes{n}, pynet, L, []);
64 pyclasses = cell(1, nclasses);
66 pyclasses{r} = PYLINE.from_line_class(line_classes{r}, pynet, L, pynodes);
69 % 3) Service / arrival processes
71 if isempty(pynodes{n})
74 PYLINE.set_service(line_nodes{n}, pynodes{n}, line_classes, pyclasses, L);
78 PYLINE.from_line_links(model, pynet, pynodes, pyclasses, L);
81 function pynode = from_line_node(line_node, pynet, L, forkNode)
82 % FROM_LINE_NODE LINE node -> py.line_solver node.
83 if isa(line_node, 'Source
')
84 pynode = L.Source(pynet, line_node.getName);
85 elseif isa(line_node, 'Sink
')
86 pynode = L.Sink(pynet, line_node.getName);
87 elseif isa(line_node, 'Router
')
88 pynode = L.Router(pynet, line_node.getName);
89 elseif isa(line_node, 'Delay
')
90 pynode = L.Delay(pynet, line_node.getName);
91 elseif isa(line_node, 'Fork
')
92 pynode = L.Fork(pynet, line_node.getName);
93 if ~isempty(line_node.output) && isprop(line_node.output, 'tasksPerLink
') && ~isempty(line_node.output.tasksPerLink)
94 tpl = double(line_node.output.tasksPerLink);
95 % Standard forks use tasksPerLink=1 (the native default); only
96 % override when a quorum / task multiplier is actually set.
98 pynode.setTasksPerLink(PYLINE.from_line_matrix(tpl(:)'));
101 elseif isa(line_node,
'Join')
102 pynode = L.Join(pynet, line_node.getName, forkNode);
103 elseif isa(line_node, 'ClassSwitch')
104 % Explicit ClassSwitch: the K x K switch-probability matrix lives
105 % in server.csMatrix; the node routes class-preserving to/from its
106 % neighbours (switching happens inside the node). Auto-added
107 % ClassSwitch
nodes are skipped upstream and re-added by link().
108 csMatrix = line_node.server.csMatrix;
109 pynode = L.ClassSwitch(pynet, line_node.getName, PYLINE.from_line_matrix(csMatrix));
110 elseif isa(line_node, 'Queue')
111 pysched = PYLINE.to_py_sched(line_node.schedStrategy, L);
112 pynode = L.Queue(pynet, line_node.getName, pysched);
113 nservers = line_node.getNumberOfServers;
115 pynode.setNumberOfServers(int32(intmax('int32')));
117 pynode.setNumberOfServers(int32(nservers));
119 if ~isinf(line_node.cap)
120 pynode.setCapacity(int32(line_node.cap));
122 if ~isempty(line_node.lldScaling)
123 pynode.setLoadDependence(PYLINE.from_line_matrix(line_node.lldScaling));
126 line_error(mfilename, sprintf('PYLINE (lang=python) does not support node ''%s'' (%s) yet.', line_node.getName, class(line_node)));
130 function pysched = to_py_sched(schedId, L)
131 % TO_PY_SCHED SchedStrategy
id -> py.line_solver.SchedStrategy enum.
132 name =
char(SchedStrategy.toProperty(SchedStrategy.toText(schedId)));
134 pysched = L.SchedStrategy.(name);
136 line_error(mfilename, sprintf('PYLINE (lang=python) does not support the %s scheduling strategy yet.', name));
140 function pyclass = from_line_class(line_class, pynet, L, pynodes)
141 % FROM_LINE_CLASS LINE job class -> py.line_solver class.
142 if isa(line_class, 'OpenClass')
143 pyclass = L.OpenClass(pynet, line_class.getName, int32(line_class.priority));
144 elseif isa(line_class, 'SelfLoopingClass')
145 refnode = pynodes{line_class.refstat.index};
146 pyclass = L.SelfLoopingClass(pynet, line_class.getName, int32(line_class.population), refnode, int32(line_class.priority));
147 elseif isa(line_class,
'ClosedClass')
148 refnode = pynodes{line_class.refstat.index};
149 pyclass = L.ClosedClass(pynet, line_class.getName, int32(line_class.population), refnode, int32(line_class.priority));
151 line_error(mfilename, sprintf(
'PYLINE (lang=python) does not support class type ''%s'' yet.',
class(line_class)));
155 function set_service(line_node, pynode, line_classes, pyclasses, L)
156 % SET_SERVICE Transfer arrival/service processes.
157 if isa(line_node,
'Sink') || isa(line_node,
'Router') || isa(line_node,
'ClassSwitch') || ...
158 isa(line_node,
'Fork') || isa(line_node,
'Join') || isa(line_node,
'Cache') || isa(line_node,
'Logger')
161 for r = 1:length(line_classes)
162 if isa(line_node, 'Source')
163 matlab_dist = line_node.getArrivalProcess(line_classes{r});
164 if isempty(matlab_dist) || isa(matlab_dist,
'Disabled')
167 pynode.setArrival(pyclasses{r}, PYLINE.from_line_distribution(matlab_dist, L));
168 elseif isa(line_node,
'Queue') || isa(line_node,
'Delay')
169 matlab_dist = line_node.getService(line_classes{r});
170 if isempty(matlab_dist) || isa(matlab_dist,
'Disabled')
173 pynode.setService(pyclasses{r}, PYLINE.from_line_distribution(matlab_dist, L));
178 function pydist = from_line_distribution(line_dist, L)
179 % FROM_LINE_DISTRIBUTION LINE distribution -> py.line_solver process.
180 if isa(line_dist,
'Exp')
181 pydist = L.Exp(line_dist.getParam(1).paramValue);
182 elseif isa(line_dist, 'Erlang')
183 pydist = L.Erlang(line_dist.getParam(1).paramValue, int32(line_dist.getParam(2).paramValue));
184 elseif isa(line_dist, 'HyperExp')
185 pydist = L.HyperExp(line_dist.getParam(1).paramValue, line_dist.getParam(2).paramValue, line_dist.getParam(3).paramValue);
186 elseif isa(line_dist, 'APH') || isa(line_dist, 'PH')
187 alpha = line_dist.getParam(1).paramValue;
188 T = line_dist.getParam(2).paramValue;
189 pydist = L.PH(PYLINE.from_line_matrix(alpha(:)'), PYLINE.from_line_matrix(T));
190 elseif isa(line_dist, 'Coxian') % includes Cox2
191 [alpha, T] = PYLINE.coxian_to_ph(line_dist);
192 pydist = L.PH(PYLINE.from_line_matrix(alpha(:)'), PYLINE.from_line_matrix(T));
193 elseif isa(line_dist, 'Det')
194 pydist = L.Det(line_dist.getParam(1).paramValue);
195 elseif isa(line_dist, 'Gamma')
196 pydist = L.Gamma(line_dist.getParam(1).paramValue, line_dist.getParam(2).paramValue);
197 elseif isa(line_dist, 'Pareto')
198 pydist = L.Pareto(line_dist.getParam(1).paramValue, line_dist.getParam(2).paramValue);
199 elseif isa(line_dist, 'Weibull')
200 pydist = L.Weibull(line_dist.getParam(1).paramValue, line_dist.getParam(2).paramValue);
201 elseif isa(line_dist, 'Lognormal')
202 pydist = L.Lognormal(line_dist.getParam(1).paramValue, line_dist.getParam(2).paramValue);
203 elseif isa(line_dist, 'Uniform')
204 pydist = L.Uniform(line_dist.getParam(1).paramValue, line_dist.getParam(2).paramValue);
205 elseif isa(line_dist, 'Normal')
206 pydist = L.Normal(line_dist.getParam(1).paramValue, line_dist.getParam(2).paramValue);
207 elseif isa(line_dist, 'MMPP2')
208 pydist = L.MMPP2(line_dist.getParam(1).paramValue, line_dist.getParam(2).paramValue, ...
209 line_dist.getParam(3).paramValue, line_dist.getParam(4).paramValue);
210 elseif isa(line_dist, 'MAP')
211 pydist = L.MAP(PYLINE.from_line_matrix(line_dist.D(0)), PYLINE.from_line_matrix(line_dist.D(1)));
212 elseif isa(line_dist, 'MMPP')
213 pydist = L.MAP(PYLINE.from_line_matrix(line_dist.D(0)), PYLINE.from_line_matrix(line_dist.D(1)));
214 elseif isa(line_dist, 'ME')
215 pydist = L.ME(PYLINE.from_line_matrix(line_dist.getParam(1).paramValue), PYLINE.from_line_matrix(line_dist.getParam(2).paramValue));
216 elseif isa(line_dist, 'RAP')
217 pydist = L.RAP(PYLINE.from_line_matrix(line_dist.getParam(1).paramValue), PYLINE.from_line_matrix(line_dist.getParam(2).paramValue));
218 elseif isa(line_dist, 'NHPP')
219 pydist = L.NHPP(PYLINE.from_line_matrix(line_dist.getBreakpoints()), PYLINE.from_line_matrix(line_dist.getRates()), logical(line_dist.isCyclic()));
220 elseif isa(line_dist, 'Zipf')
221 pydist = L.Zipf(line_dist.getParam(3).paramValue, int32(line_dist.getParam(4).paramValue));
222 elseif isa(line_dist, 'Trace') % before Replayer (Trace < Replayer)
223 pydist = L.Trace(line_dist.params{1}.paramValue);
224 elseif isa(line_dist,
'Replayer')
225 pydist = L.Replayer(line_dist.params{1}.paramValue);
226 elseif isa(line_dist,
'Bernoulli')
227 pydist = L.Bernoulli(line_dist.getParam(1).paramValue);
228 elseif isa(line_dist, 'Binomial')
229 pydist = L.Binomial(int32(line_dist.getParam(1).paramValue), line_dist.getParam(2).paramValue);
230 elseif isa(line_dist, 'Geometric')
231 pydist = L.Geometric(line_dist.getParam(1).paramValue);
232 elseif isa(line_dist, 'Poisson')
233 pydist = L.Poisson(line_dist.getParam(1).paramValue);
234 elseif isa(line_dist, 'DiscreteUniform')
235 pydist = L.DiscreteUniform(int32(line_dist.getParam(1).paramValue), int32(line_dist.getParam(2).paramValue));
236 elseif isa(line_dist, 'DiscreteSampler')
237 pydist = L.DiscreteSampler(PYLINE.from_line_matrix(line_dist.getParam(1).paramValue), PYLINE.from_line_matrix(line_dist.getParam(2).paramValue));
238 elseif isa(line_dist, 'Immediate')
239 pydist = L.Immediate();
240 elseif isempty(line_dist) || isa(line_dist, 'Disabled')
241 pydist = L.Disabled();
243 line_error(mfilename, sprintf('PYLINE (lang=python) does not support distribution ''%s'' yet.', class(line_dist)));
247 function [alpha, T] = coxian_to_ph(line_dist)
248 % COXIAN_TO_PH Bidiagonal (alpha,T) PH representation of a Coxian.
249 mu = line_dist.getParam(1).paramValue;
250 phi = line_dist.getParam(2).paramValue;
260 T(i, i+1) = mu(i) * (1 - phi(i));
265 function from_line_links(model, pynet, pynodes, pyclasses, L)
266 % FROM_LINE_LINKS Reconstruct routing on the Python model.
267 connections = model.getConnectionMatrix();
268 line_nodes = model.getNodes;
269 sn = model.getStruct;
270 nclasses = length(pyclasses);
272 % MATLAB node index -> Python node index (skip auto ClassSwitch)
273 m2p = zeros(1, length(line_nodes));
275 for i = 1:length(line_nodes)
276 if isa(line_nodes{i},
'ClassSwitch') && line_nodes{i}.autoAdded
284 useLinkMethod = ~isempty(sn.rtorig);
285 hasAutoCS = any(cellfun(@(nd) isa(nd,
'ClassSwitch') && nd.autoAdded, line_nodes));
288 rm = L.RoutingMatrix(pynet);
291 if useLinkMethod && hasAutoCS
292 % Class-switching routing lives in sn.rtorig (station-indexed,
293 % already excluding
auto ClassSwitch
nodes).
296 Prs = sn.rtorig{r, s};
300 [nrows, ncols] = size(Prs);
304 rm.set(pyclasses{r}, pyclasses{s}, pynodes{i}, pynodes{j}, Prs(i, j));
314 for i = 1:size(connections, 1)
315 line_node = line_nodes{i};
316 if isa(line_node,
'ClassSwitch') && line_node.autoAdded
321 output_strat = line_node.output.outputStrategy{k};
322 strat = RoutingStrategy.fromText(output_strat{2});
324 case RoutingStrategy.DISABLED
325 pynodes{i}.setRouting(pyclasses{k}, L.RoutingStrategy.DISABLED);
326 case RoutingStrategy.RAND
327 pynodes{i}.setRouting(pyclasses{k}, L.RoutingStrategy.RAND);
329 for j = find(connections(i, :))
331 pynet.addLink(pynodes{i}, pynodes{j});
335 case RoutingStrategy.PROB
336 pynodes{i}.setRouting(pyclasses{k}, L.RoutingStrategy.PROB);
337 if length(output_strat) >= 3
338 probs = output_strat{3};
339 for j = 1:length(probs)
340 dest_idx = probs{j}{1}.index;
341 if connections(i, dest_idx) ~= 0 && m2p(dest_idx) >= 0
343 rm.set(pyclasses{k}, pyclasses{k}, pynodes{i}, pynodes{dest_idx}, probs{j}{2});
345 pynodes{i}.setProbRouting(pyclasses{k}, pynodes{dest_idx}, probs{j}{2});
350 case RoutingStrategy.RROBIN
352 line_error(mfilename,
'RROBIN cannot be used together with the link() command.');
354 pynodes{i}.setRouting(pyclasses{k}, L.RoutingStrategy.RROBIN);
355 for j = find(connections(i, :))
357 pynet.addLink(pynodes{i}, pynodes{j});
360 case RoutingStrategy.JSQ
361 pynodes{i}.setRouting(pyclasses{k}, L.RoutingStrategy.JSQ);
363 for j = find(connections(i, :))
365 pynet.addLink(pynodes{i}, pynodes{j});
369 case RoutingStrategy.WRROBIN
371 line_error(mfilename,
'WRROBIN cannot be used together with the link() command.');
373 for j = find(connections(i, :))
375 pynet.addLink(pynodes{i}, pynodes{j});
378 % output_strat{3} = list of {targetNode, weight} pairs
379 for j = 1:length(output_strat{3})
380 tgt = output_strat{3}{j}{1};
381 weight = output_strat{3}{j}{2};
382 pynodes{i}.setRouting(pyclasses{k}, L.RoutingStrategy.WRROBIN, pynodes{tgt.index}, weight);
384 case RoutingStrategy.SQ
385 if length(output_strat) >= 3 && ~isempty(output_strat{3})
386 dparam = output_strat{3}{1};
387 pynodes{i}.setRouting(pyclasses{k}, L.RoutingStrategy.SQ, int32(dparam));
389 pynodes{i}.setRouting(pyclasses{k}, L.RoutingStrategy.SQ);
392 for j = find(connections(i, :))
394 pynet.addLink(pynodes{i}, pynodes{j});
398 case RoutingStrategy.RL
399 % The native line_solver exposes no API to carry an RL
400 % value function / action
map, so it cannot reproduce a
401 % learned RL policy. Fail clearly rather than silently
402 % solving a different (
default) policy.
403 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.');
405 line_error(mfilename, sprintf(
'PYLINE (lang=python) does not support the ''%s'' routing strategy.', output_strat{2}));
415 %% ---- Marshalling helpers ----
417 function pyarr = from_line_matrix(matrix)
418 % FROM_LINE_MATRIX MATLAB
double matrix -> numpy ndarray.
419 np = py.importlib.import_module(
'numpy');
421 pyarr = np.zeros(py.tuple({int32(0), int32(0)}));
424 [rows, cols] = size(matrix);
425 % Build a nested Python list to avoid ambiguous
auto-conversion,
426 % then let numpy assemble the 2-D array.
427 rowsCell = cell(1, rows);
429 rowsCell{r} = py.list(num2cell(
double(matrix(r, :))));
431 pyarr = np.array(py.list(rowsCell));
432 % Preserve
column-vector / row-vector shape when a singleton dim.
433 if rows == 1 || cols == 1
434 pyarr = np.reshape(pyarr, py.tuple({int32(rows), int32(cols)}));
438 function matrix = from_pyline_matrix(pyarr)
439 % FROM_PYLINE_MATRIX numpy ndarray / scalar -> MATLAB
double.
440 if isa(pyarr,
'py.NoneType')
444 np = py.importlib.import_module('numpy');
445 matrix =
double(np.asarray(pyarr, pyargs('dtype', 'float64')));
448 function pyopts = parseSolverOptions(options, solverName)
449 % PARSESOLVEROPTIONS MATLAB options struct -> native solver kwargs.
450 % The native SolverXOptions constructors have per-solver parameter
451 % lists, so build a candidate native-name->value
map and forward
452 % only the keys the target options class accepts.
453 accepted = PYLINE.acceptedKwargs(solverName);
455 cand = {}; % {nativeName, value} pairs
456 if isfield(options,
'tol') && ~isempty(options.tol)
457 cand = [cand, {
'tol', options.tol}];
459 if isfield(options,
'iter_tol') && ~isempty(options.iter_tol)
460 cand = [cand, {
'iter_tol', options.iter_tol}];
462 if isfield(options,
'iter_max') && ~isempty(options.iter_max)
463 % Different options classes name
this max_iter vs iter_max.
464 cand = [cand, {
'max_iter', int32(options.iter_max), ...
465 'iter_max', int32(options.iter_max)}];
467 if isfield(options,
'seed') && ~isempty(options.seed)
468 cand = [cand, {
'seed', int32(options.seed)}];
470 if isfield(options,
'cutoff') && ~isempty(options.cutoff) && isfinite(options.cutoff)
471 cand = [cand, {
'cutoff', int32(options.cutoff)}];
473 if isfield(options,
'samples') && ~isempty(options.samples)
474 cand = [cand, {
'samples', int32(options.samples)}];
476 cand = [cand, {
'verbose',
false}];
479 for i = 1:2:numel(cand)
480 if any(strcmp(cand{i}, accepted))
481 args = [args, {cand{i}, cand{i+1}}]; %#ok<AGROW>
484 pyopts = pyargs(args{:});
487 function names = acceptedKwargs(solverName)
488 % ACCEPTEDKWARGS Native SolverXOptions constructor parameter names.
491 names = {
'max_iter',
'tol',
'verbose',
'seed',
'cutoff',
'samples'};
493 names = {
'tol',
'iter_max',
'iter_tol',
'verbose',
'seed',
'cutoff',
'samples'};
495 names = {
'tol',
'cutoff',
'seed',
'samples',
'verbose'};
497 names = {
'tol',
'max_iter',
'verbose'};
498 case {
'SolverFluid',
'SolverFLD'}
499 names = {
'tol',
'iter_max',
'iter_tol',
'verbose',
'seed',
'cutoff',
'samples'};
501 names = {
'tol',
'samples',
'seed',
'cutoff',
'verbose'};
507 %% ---- Solver constructors ----
509 function pysolver = Solver(name, pynet, options, L)
510 % SOLVER Dispatch to the native Python solver constructor by name.
512 if isfield(options,
'method') && ~isempty(options.method)
513 method =
char(options.method);
515 pyopts = PYLINE.parseSolverOptions(options, name);
518 pysolver = L.SolverMVA(pynet, method, pyopts);
520 pysolver = L.SolverNC(pynet, method, pyopts);
522 pysolver = L.SolverCTMC(pynet, method, pyopts);
524 pysolver = L.SolverMAM(pynet, method, pyopts);
525 case {
'SolverFluid',
'SolverFLD'}
526 pysolver = L.SolverFluid(pynet, method, pyopts);
528 pysolver = L.SolverSSA(pynet, method, pyopts);
530 pysolver = L.SolverAuto(pynet, method, pyopts);
532 line_error(mfilename, sprintf(
'PYLINE (lang=python) does not support %s yet.', name));
536 function [QN, UN, RN, TN, AN, WN, runtime] = getAvg(solverName, model, options)
537 % GETAVG Build the native model+solver, run getAvg(), marshal back.
538 L = py.importlib.import_module(
'line_solver');
540 pynet = PYLINE.line_to_pyline(model);
541 pysolver = PYLINE.Solver(solverName, pynet, options, L);
542 res = cell(pysolver.getAvg());
543 % Native getAvg() returns (Q,U,R,T,A,W) as (M x R) numpy arrays.
544 QN = PYLINE.from_pyline_matrix(res{1});
545 UN = PYLINE.from_pyline_matrix(res{2});
546 RN = PYLINE.from_pyline_matrix(res{3});
547 TN = PYLINE.from_pyline_matrix(res{4});
548 AN = PYLINE.from_pyline_matrix(res{5});
549 WN = PYLINE.from_pyline_matrix(res{6});
550 runtime = toc(Tstart);
553 %% ---- JSON model bridge (advanced Network features, Environment) ----
555 function pynet = from_line_via_json(model)
556 % FROM_LINE_VIA_JSON Bridge a model through the canonical line-model
557 % JSON schema: MATLAB linemodel_save -> temp .json -> native
558 % load_model. Used
for models whose element-by-element construction
559 %
is impractical over py.* (Cache, SPN Place/Transition, Environment).
560 PYLINE.assertPythonReady();
561 L = py.importlib.import_module(
'line_solver');
562 jsonfile = [tempname,
'.json'];
563 linemodel_save(model, jsonfile);
564 cleanup = onCleanup(@() PYLINE.tryDelete(jsonfile)); %#ok<NASGU>
565 pynet = L.load_model(jsonfile);
568 %% ---- LayeredNetwork (LQN) ----
570 function pynet = from_line_layered_network(model)
571 % FROM_LINE_LAYERED_NETWORK LINE LayeredNetwork -> native LQN.
572 % Bridged through the canonical LQN XML interchange format: the
573 % element-by-element LQN graph (processors/tasks/entries/activities/
574 % calls/precedences)
is large and error-prone to marshal over py.*,
575 % whereas writeXML/parseXML
is a faithful, well-established round-trip.
576 PYLINE.assertPythonReady();
577 L = py.importlib.import_module(
'line_solver');
578 xmlfile = [tempname,
'.lqnx'];
579 model.writeXML(xmlfile);
580 cleanup = onCleanup(@() PYLINE.tryDelete(xmlfile));
581 pynet = L.LayeredNetwork.parseXML(xmlfile);
582 PYLINE.restoreNonRefThinkTimes(model, pynet);
585 function restoreNonRefThinkTimes(model, pynet)
586 % RESTORENONREFTHINKTIMES Reapply the one piece of LINE state that
587 % the .lqnx interchange cannot carry. lqns rejects think-time on a
588 % non-reference task, so writeXML omits it there; LINE nonetheless
589 % gives such a think time to the task
's callers as a delay, and
590 % without this the bridged model would be a DIFFERENT model (the
591 % layer of the called task loses its delay, which moves that layer's
592 % throughput and every quantity derived from it).
593 pytasks = cell(py.list(pynet.tasks));
594 byName = containers.Map(
'KeyType',
'char',
'ValueType',
'any');
595 for k = 1:numel(pytasks)
596 byName(
char(pytasks{k}.name)) = pytasks{k};
598 for t = 1:numel(model.tasks)
599 task = model.tasks{t};
600 if SchedStrategy.fromText(task.scheduling) == SchedStrategy.REF
603 if isempty(task.thinkTimeMean) || task.thinkTimeMean <= 0
606 if isKey(byName, task.name)
607 pytask = byName(task.name);
608 pytask.set_think_time(task.thinkTimeMean);
613 function tryDelete(f)
614 % TRYDELETE Best-effort temp-file removal.
620 function pysolver = SolverLN(pynet, options, L)
621 % SOLVERLN Native SolverLN over an LQN model.
622 pysolver = L.SolverLN(pynet, PYLINE.parseSolverOptions(options, 'SolverLN'));
625 function [QN, UN, RN, TN, AN, WN, runtime] = getEnsembleAvg(model, options, nElem)
626 % GETENSEMBLEAVG Build the native LQN + SolverLN, run
627 % get_ensemble_avg(), marshal the per-element metric vectors back.
628 % Native get_ensemble_avg() returns (Q,U,R,T,A,W) as
column vectors
629 % positionally aligned with the LQN element index. The native LQN
630 % struct keeps a leading index-0 placeholder (empty name), so the
631 % vectors are length nidx+1; MATLAB self.lqn.names has nidx entries.
632 % nElem = numel(self.lqn.names)
is used to drop that placeholder so
633 % the vectors line up 1:1 with the MATLAB element order.
634 L = py.importlib.import_module('line_solver');
636 pynet = PYLINE.from_line_layered_network(model);
637 pysolver = PYLINE.SolverLN(pynet, options, L);
638 res = cell(pysolver.get_ensemble_avg());
639 QN = PYLINE.trimLNVector(PYLINE.from_pyline_matrix(res{1}), nElem);
640 UN = PYLINE.trimLNVector(PYLINE.from_pyline_matrix(res{2}), nElem);
641 RN = PYLINE.trimLNVector(PYLINE.from_pyline_matrix(res{3}), nElem);
642 TN = PYLINE.trimLNVector(PYLINE.from_pyline_matrix(res{4}), nElem);
643 AN = PYLINE.trimLNVector(PYLINE.from_pyline_matrix(res{5}), nElem);
644 WN = PYLINE.trimLNVector(PYLINE.from_pyline_matrix(res{6}), nElem);
645 runtime = toc(Tstart);
648 function [SensTable, sens] = getLNSensitivityTable(model, options, varargin)
649 % GETLNSENSITIVITYTABLE Build the native LQN + SolverLN and marshal
650 % get_sensitivity_table() back into the MATLAB layer-wise table.
651 % The native call runs the fixed-point loop itself when the layers
652 % have not been solved, so the layer derivatives are taken at the
653 % converged parameterization (see solver_ln.py getSensitivityTable).
655 % The name-value options are those of
656 % @NetworkSolver/getSensitivityTable ('method', 'step', 'scheme');
657 % an empty step forwards as None, which selects the native default.
658 L = py.importlib.import_module('line_solver');
659 pynet = PYLINE.from_line_layered_network(model);
660 pysolver = PYLINE.SolverLN(pynet, options, L);
662 method = 'auto'; step = []; scheme = 'forward';
663 for a = 1:2:numel(varargin)
664 switch lower(varargin{a})
665 case 'method', method = lower(varargin{a+1});
666 case 'step', step = varargin{a+1};
667 case 'scheme', scheme = lower(varargin{a+1});
669 line_error(mfilename, sprintf(
'Unknown option ''%s''.', varargin{a}));
677 T = pysolver.getSensitivityTable(pyargs(
'method', method, ...
678 'step', pystep,
'scheme', scheme));
680 Layer = PYLINE.pyStringColumn(T,
'Layer');
681 Station = PYLINE.pyStringColumn(T,
'Station');
682 JobClass = PYLINE.pyStringColumn(T,
'JobClass');
683 dTput = PYLINE.pyNumericColumn(T,
'dTput_dRate');
684 dRespT = PYLINE.pyNumericColumn(T,
'dRespT_dRate');
685 dQLen = PYLINE.pyNumericColumn(T,
'dQLen_dRate');
686 dUtil = PYLINE.pyNumericColumn(T,
'dUtil_dRate');
688 SensTable = table(Layer,
Station, JobClass, dTput, dRespT, dQLen, dUtil, ...
689 'VariableNames', {
'Layer',
'Station',
'JobClass',
'dTput_dRate', ...
690 'dRespT_dRate',
'dQLen_dRate',
'dUtil_dRate'});
693 summary = char(attrs.get(
'method'));
694 pyMethods = cell(py.list(attrs.get(
'layer_methods')));
695 methods = cell(1, numel(pyMethods));
696 for e = 1:numel(pyMethods)
697 if isa(pyMethods{e},
'py.NoneType')
700 methods{e} = char(pyMethods{e});
703 SensTable.Properties.UserData =
struct(
'method', summary, ...
704 'layerMethods', {methods});
706 pySens = cell(py.list(attrs.get(
'sens')));
707 sens = cell(1, numel(pySens));
708 for e = 1:numel(pySens)
709 sens{e} = PYLINE.pySensStruct(pySens{e});
713 function c = pyStringColumn(T, name)
714 % PYSTRINGCOLUMN pandas
column of str ->
column cell of
char.
715 vals = cell(py.list(T.get(name).tolist()));
716 c = cell(numel(vals), 1);
717 for i = 1:numel(vals)
718 c{i} = char(vals{i});
722 function v = pyNumericColumn(T, name)
723 % PYNUMERICCOLUMN pandas
column of
float ->
column double vector.
724 v = PYLINE.from_pyline_matrix(T.get(name).to_numpy());
728 function s = pySensStruct(pyobj)
729 % PYSENSSTRUCT native pfqn_sens result -> the MATLAB pfqn_sens
730 %
struct returned as the second output of getSensitivityTable.
731 % Empty
for a layer that took the finite-difference branch, which
732 % carries no analytic Jacobian.
733 if isempty(pyobj) || isa(pyobj,
'py.NoneType')
738 fields = {
'X',
'Q',
'U',
'R',
'dX',
'dQ',
'dU',
'dR',
'QCov',
'QVar', ...
739 'QTotVar',
'QCovAsym'};
740 for k = 1:numel(fields)
742 if ~py.hasattr(pyobj, f)
745 val = py.getattr(pyobj, f);
746 if isa(val,
'py.NoneType')
749 s.(f) = PYLINE.from_pyline_matrix(val);
751 if isfield(s, 'QCovAsym')
752 s.QCovAsym =
double(s.QCovAsym);
754 % Native params
is a list of dicts with 0-based station/
jobclass
755 % (station -1 for a think-time parameter); pfqn_sens.m returns a
756 % 1 x
P struct array with 1-based .station (0 for Z) and .class.
757 if py.hasattr(pyobj, 'params')
758 pyParams = cell(py.list(py.getattr(pyobj, 'params')));
760 params = struct('type', cell(1,
P), 'station', cell(1,
P), ...
761 'class', cell(1,
P));
764 params(p).type = char(d.get(
'type'));
765 st = double(d.get(
'station'));
767 params(p).station = 0;
769 params(p).station = st + 1;
771 params(p).class = double(d.get(
'jobclass')) + 1;
777 function [QN, UN, TN, runtime] = getEnvAvg(model, options)
778 % GETENVAVG Build a native Environment (via JSON) + SolverENV and
779 % marshal the environment-weighted (Q,U,T) metrics back. Uses the
780 % statevec method (the documented bit-identical env analyzer); its
781 % inner per-stage solver
is a CTMC with a finite transient timespan.
782 L = py.importlib.import_module(
'line_solver');
784 penv = PYLINE.from_line_via_json(model);
786 if isfield(options,
'method') && ~isempty(options.method)
787 method =
char(options.method);
790 if isfield(options, 'timespan') && numel(options.timespan) == 2 && isfinite(options.timespan(2))
791 Tend = options.timespan(2);
793 facsrc = sprintf('lambda m: __import__("line_solver").SolverCTMC(m, timespan=[0.0,%.15g], verbose=False)', Tend);
794 fac = py.eval(facsrc, py.dict());
795 psolver = L.SolverENV(penv, fac, py.dict(pyargs('method', method)));
796 res = cell(psolver.getAvg());
797 QN = PYLINE.from_pyline_matrix(res{1});
798 UN = PYLINE.from_pyline_matrix(res{2});
799 TN = PYLINE.from_pyline_matrix(res{3});
800 runtime = toc(Tstart);
803 function v = trimLNVector(v, nElem)
804 % TRIMLNVECTOR Align a native LQN metric vector to nElem MATLAB
805 % elements: drop the leading index-0 placeholder when present.
807 if numel(v) == nElem + 1
809 elseif numel(v) ~= nElem
810 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));
814 %% ---- Environment checks ----
816 function assertPythonReady()
817 % ASSERTPYTHONREADY Verify the native line_solver
is importable and
818 % pin the embedded interpreter
's BLAS/OpenMP thread pools to a single
819 % thread. Running numpy/scipy in-process (py.*) alongside MATLAB's own
820 % worker threads otherwise causes BLAS oversubscription that spins or
821 % deadlocks the session on heavy linear-algebra algorithms (e.g. CTMC).
822 persistent ready tpHandle %#ok<PUSE>
823 if ~isempty(ready) && ready
827 if strcmp(pe.Status,
'NotLoaded') && isempty(pe.Executable)
828 line_error(mfilename, 'lang=python requires a configured MATLAB Python environment (see pyenv).');
830 % Set before the first numpy import so a fresh interpreter picks
831 % single-threaded BLAS from the start.
832 setenv('OMP_NUM_THREADS', '1');
833 setenv('OPENBLAS_NUM_THREADS', '1');
834 setenv('MKL_NUM_THREADS', '1');
835 setenv('NUMEXPR_NUM_THREADS', '1');
837 py.importlib.import_module('line_solver');
839 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));
841 % Runtime fallback: if numpy was already loaded with multithreaded
842 % BLAS in this session, clamp the live thread pools too. The handle
843 %
is kept persistent so the limit
is not reverted on GC.
845 tp = py.importlib.import_module('threadpoolctl');
846 tpHandle = tp.threadpool_limits(pyargs('limits', int32(1)));
848 % threadpoolctl unavailable; the env vars above cover fresh
849 % interpreters. On an already-loaded multithreaded numpy without
850 % threadpoolctl, restart MATLAB after setting the env vars.