LINE Solver
MATLAB API documentation
Loading...
Searching...
No Matches
PYLINE.m
1classdef PYLINE
2 % PYLINE MATLAB-to-native-Python bridge (lang='python').
3 %
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.
10 %
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.
13 %
14 % Copyright (c) 2012-2026, Imperial College London
15 % All rights reserved.
16
17 methods (Static)
18
19 function pynetwork = line_to_pyline(model)
20 % LINE_TO_PYLINE Top-level entry: LINE model -> py.line_solver model.
21 switch class(model)
22 case {'Network', 'MNetwork'}
23 pynetwork = PYLINE.from_line_network(model);
24 case 'LayeredNetwork'
25 pynetwork = PYLINE.from_line_layered_network(model);
26 otherwise
27 line_error(mfilename, sprintf('PYLINE (lang=python) does not support model class ''%s'' yet.', class(model)));
28 end
29 end
30
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');
35
36 line_nodes = model.getNodes;
37 line_classes = model.getClasses;
38 nnodes = length(line_nodes);
39 nclasses = length(line_classes);
40
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);
48 return
49 end
50
51 pynet = L.Network(model.getName);
52
53 % 1) Nodes first (closed classes reference a station node)
54 pynodes = cell(1, nnodes);
55 for n = 1:nnodes
56 if isa(line_nodes{n}, 'ClassSwitch') && line_nodes{n}.autoAdded
57 continue; % Python link() re-adds auto ClassSwitch nodes
58 end
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);
62 else
63 pynodes{n} = PYLINE.from_line_node(line_nodes{n}, pynet, L, []);
64 end
65 end
66
67 % 2) Classes
68 pyclasses = cell(1, nclasses);
69 for r = 1:nclasses
70 pyclasses{r} = PYLINE.from_line_class(line_classes{r}, pynet, L, pynodes);
71 end
72
73 % 3) Service / arrival processes
74 for n = 1:nnodes
75 if isempty(pynodes{n})
76 continue;
77 end
78 PYLINE.set_service(line_nodes{n}, pynodes{n}, line_classes, pyclasses, L);
79 end
80
81 % 4) Routing
82 PYLINE.from_line_links(model, pynet, pynodes, pyclasses, L);
83 end
84
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.
101 if any(tpl(:) ~= 1)
102 pynode.setTasksPerLink(PYLINE.from_line_matrix(tpl(:)'));
103 end
104 end
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;
118 if isinf(nservers)
119 pynode.setNumberOfServers(int32(intmax('int32')));
120 elseif nservers > 1
121 pynode.setNumberOfServers(int32(nservers));
122 end
123 if ~isinf(line_node.cap)
124 pynode.setCapacity(int32(line_node.cap));
125 end
126 if ~isempty(line_node.lldScaling)
127 pynode.setLoadDependence(PYLINE.from_line_matrix(line_node.lldScaling));
128 end
129 else
130 line_error(mfilename, sprintf('PYLINE (lang=python) does not support node ''%s'' (%s) yet.', line_node.getName, class(line_node)));
131 end
132 end
133
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)));
137 try
138 pysched = L.SchedStrategy.(name);
139 catch
140 line_error(mfilename, sprintf('PYLINE (lang=python) does not support the %s scheduling strategy yet.', name));
141 end
142 end
143
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));
154 else
155 line_error(mfilename, sprintf('PYLINE (lang=python) does not support class type ''%s'' yet.', class(line_class)));
156 end
157 end
158
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')
163 return;
164 end
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')
169 continue;
170 end
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')
175 continue;
176 end
177 pynode.setService(pyclasses{r}, PYLINE.from_line_distribution(matlab_dist, L));
178 end
179 end
180 end
181
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();
246 else
247 line_error(mfilename, sprintf('PYLINE (lang=python) does not support distribution ''%s'' yet.', class(line_dist)));
248 end
249 end
250
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;
255 mu = mu(:)';
256 phi = phi(:)';
257 k = length(mu);
258 alpha = zeros(1, k);
259 alpha(1) = 1;
260 T = zeros(k, k);
261 for i = 1:k
262 T(i, i) = -mu(i);
263 if i < k
264 T(i, i+1) = mu(i) * (1 - phi(i));
265 end
266 end
267 end
268
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);
275
276 % MATLAB node index -> Python node index (skip auto ClassSwitch)
277 m2p = zeros(1, length(line_nodes));
278 pidx = 0;
279 for i = 1:length(line_nodes)
280 if isa(line_nodes{i}, 'ClassSwitch') && line_nodes{i}.autoAdded
281 m2p(i) = -1;
282 else
283 m2p(i) = pidx;
284 pidx = pidx + 1;
285 end
286 end
287
288 useLinkMethod = ~isempty(sn.rtorig);
289 hasAutoCS = any(cellfun(@(nd) isa(nd, 'ClassSwitch') && nd.autoAdded, line_nodes));
290
291 if useLinkMethod
292 rm = L.RoutingMatrix(pynet);
293 end
294
295 if useLinkMethod && hasAutoCS
296 % Class-switching routing lives in sn.rtorig (station-indexed,
297 % already excluding auto ClassSwitch nodes).
298 for r = 1:nclasses
299 for s = 1:nclasses
300 Prs = sn.rtorig{r, s};
301 if isempty(Prs)
302 continue;
303 end
304 [nrows, ncols] = size(Prs);
305 for i = 1:nrows
306 for j = 1:ncols
307 if Prs(i, j) > 0
308 rm.set(pyclasses{r}, pyclasses{s}, pynodes{i}, pynodes{j}, Prs(i, j));
309 end
310 end
311 end
312 end
313 end
314 pynet.link(rm);
315 return
316 end
317
318 for i = 1:size(connections, 1)
319 line_node = line_nodes{i};
320 if isa(line_node, 'ClassSwitch') && line_node.autoAdded
321 continue;
322 end
323 pi_idx = m2p(i);
324 for k = 1:nclasses
325 output_strat = line_node.output.outputStrategy{k};
326 strat = RoutingStrategy.fromText(output_strat{2});
327 switch strat
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);
332 if ~useLinkMethod
333 for j = find(connections(i, :))
334 if m2p(j) >= 0
335 pynet.addLink(pynodes{i}, pynodes{j});
336 end
337 end
338 end
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
346 if useLinkMethod
347 rm.set(pyclasses{k}, pyclasses{k}, pynodes{i}, pynodes{dest_idx}, probs{j}{2});
348 else
349 pynodes{i}.setProbRouting(pyclasses{k}, pynodes{dest_idx}, probs{j}{2});
350 end
351 end
352 end
353 end
354 case RoutingStrategy.RROBIN
355 if useLinkMethod
356 line_error(mfilename, 'RROBIN cannot be used together with the link() command.');
357 end
358 pynodes{i}.setRouting(pyclasses{k}, L.RoutingStrategy.RROBIN);
359 for j = find(connections(i, :))
360 if m2p(j) >= 0
361 pynet.addLink(pynodes{i}, pynodes{j});
362 end
363 end
364 case RoutingStrategy.JSQ
365 pynodes{i}.setRouting(pyclasses{k}, L.RoutingStrategy.JSQ);
366 if ~useLinkMethod
367 for j = find(connections(i, :))
368 if m2p(j) >= 0
369 pynet.addLink(pynodes{i}, pynodes{j});
370 end
371 end
372 end
373 case RoutingStrategy.WRROBIN
374 if useLinkMethod
375 line_error(mfilename, 'WRROBIN cannot be used together with the link() command.');
376 end
377 for j = find(connections(i, :))
378 if m2p(j) >= 0
379 pynet.addLink(pynodes{i}, pynodes{j});
380 end
381 end
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);
387 end
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);
393 else
394 pynodes{i}.setRouting(pyclasses{k}, L.RoutingStrategy.KCHOICES);
395 end
396 if ~useLinkMethod
397 for j = find(connections(i, :))
398 if m2p(j) >= 0
399 pynet.addLink(pynodes{i}, pynodes{j});
400 end
401 end
402 end
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.');
409 otherwise
410 line_error(mfilename, sprintf('PYLINE (lang=python) does not support the ''%s'' routing strategy.', output_strat{2}));
411 end
412 end
413 end
414
415 if useLinkMethod
416 pynet.link(rm);
417 end
418 end
419
420 %% ---- Marshalling helpers ----
421
422 function pyarr = from_line_matrix(matrix)
423 % FROM_LINE_MATRIX MATLAB double matrix -> numpy ndarray.
424 np = py.importlib.import_module('numpy');
425 if isempty(matrix)
426 pyarr = np.zeros(py.tuple({int32(0), int32(0)}));
427 return
428 end
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);
433 for r = 1:rows
434 rowsCell{r} = py.list(num2cell(double(matrix(r, :))));
435 end
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)}));
440 end
441 end
442
443 function matrix = from_pyline_matrix(pyarr)
444 % FROM_PYLINE_MATRIX numpy ndarray / scalar -> MATLAB double.
445 if isa(pyarr, 'py.NoneType')
446 matrix = [];
447 return
448 end
449 np = py.importlib.import_module('numpy');
450 matrix = double(np.asarray(pyarr, pyargs('dtype', 'float64')));
451 end
452
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);
459
460 cand = {}; % {nativeName, value} pairs
461 if isfield(options, 'tol') && ~isempty(options.tol)
462 cand = [cand, {'tol', options.tol}];
463 end
464 if isfield(options, 'iter_tol') && ~isempty(options.iter_tol)
465 cand = [cand, {'iter_tol', options.iter_tol}];
466 end
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)}];
471 end
472 if isfield(options, 'seed') && ~isempty(options.seed)
473 cand = [cand, {'seed', int32(options.seed)}];
474 end
475 if isfield(options, 'cutoff') && ~isempty(options.cutoff) && isfinite(options.cutoff)
476 cand = [cand, {'cutoff', int32(options.cutoff)}];
477 end
478 if isfield(options, 'samples') && ~isempty(options.samples)
479 cand = [cand, {'samples', int32(options.samples)}];
480 end
481 cand = [cand, {'verbose', false}];
482
483 args = {};
484 for i = 1:2:numel(cand)
485 if any(strcmp(cand{i}, accepted))
486 args = [args, {cand{i}, cand{i+1}}]; %#ok<AGROW>
487 end
488 end
489 pyopts = pyargs(args{:});
490 end
491
492 function names = acceptedKwargs(solverName)
493 % ACCEPTEDKWARGS Native SolverXOptions constructor parameter names.
494 switch solverName
495 case 'SolverMVA'
496 names = {'max_iter','tol','verbose','seed','cutoff','samples'};
497 case 'SolverNC'
498 names = {'tol','iter_max','iter_tol','verbose','seed','cutoff','samples'};
499 case 'SolverCTMC'
500 names = {'tol','cutoff','seed','samples','verbose'};
501 case 'SolverMAM'
502 names = {'tol','max_iter','verbose'};
503 case {'SolverFluid','SolverFLD'}
504 names = {'tol','iter_max','iter_tol','verbose','seed','cutoff','samples'};
505 case 'SolverSSA'
506 names = {'tol','samples','seed','cutoff','verbose'};
507 otherwise
508 names = {'verbose'};
509 end
510 end
511
512 %% ---- Solver constructors ----
513
514 function pysolver = Solver(name, pynet, options, L)
515 % SOLVER Dispatch to the native Python solver constructor by name.
516 method = 'default';
517 if isfield(options, 'method') && ~isempty(options.method)
518 method = char(options.method);
519 end
520 pyopts = PYLINE.parseSolverOptions(options, name);
521 switch name
522 case 'SolverMVA'
523 pysolver = L.SolverMVA(pynet, method, pyopts);
524 case 'SolverNC'
525 pysolver = L.SolverNC(pynet, method, pyopts);
526 case 'SolverCTMC'
527 pysolver = L.SolverCTMC(pynet, method, pyopts);
528 case 'SolverMAM'
529 pysolver = L.SolverMAM(pynet, method, pyopts);
530 case {'SolverFluid', 'SolverFLD'}
531 pysolver = L.SolverFluid(pynet, method, pyopts);
532 case 'SolverSSA'
533 pysolver = L.SolverSSA(pynet, method, pyopts);
534 case 'SolverAuto'
535 pysolver = L.SolverAuto(pynet, method, pyopts);
536 otherwise
537 line_error(mfilename, sprintf('PYLINE (lang=python) does not support %s yet.', name));
538 end
539 end
540
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');
544 Tstart = tic;
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);
556 end
557
558 %% ---- JSON model bridge (advanced Network features, Environment) ----
559
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);
571 end
572
573 %% ---- LayeredNetwork (LQN) ----
574
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);
588 end
589
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};
602 end
603 for t = 1:numel(model.tasks)
604 task = model.tasks{t};
605 if SchedStrategy.fromText(task.scheduling) == SchedStrategy.REF
606 continue
607 end
608 if isempty(task.thinkTimeMean) || task.thinkTimeMean <= 0
609 continue
610 end
611 if isKey(byName, task.name)
612 pytask = byName(task.name);
613 pytask.set_think_time(task.thinkTimeMean);
614 end
615 end
616 end
617
618 function tryDelete(f)
619 % TRYDELETE Best-effort temp-file removal.
620 if exist(f, 'file')
621 delete(f);
622 end
623 end
624
625 function pysolver = SolverLN(pynet, options, L)
626 % SOLVERLN Native SolverLN over an LQN model.
627 pysolver = L.SolverLN(pynet, PYLINE.parseSolverOptions(options, 'SolverLN'));
628 end
629
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');
640 Tstart = tic;
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);
651 end
652
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).
659 %
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);
666
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});
673 otherwise
674 line_error(mfilename, sprintf('Unknown option ''%s''.', varargin{a}));
675 end
676 end
677 if isempty(step)
678 pystep = py.None;
679 else
680 pystep = step;
681 end
682 T = pysolver.getSensitivityTable(pyargs('method', method, ...
683 'step', pystep, 'scheme', scheme));
684
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');
692
693 SensTable = table(Layer, Station, JobClass, dTput, dRespT, dQLen, dUtil, ...
694 'VariableNames', {'Layer', 'Station', 'JobClass', 'dTput_dRate', ...
695 'dRespT_dRate', 'dQLen_dRate', 'dUtil_dRate'});
696
697 attrs = T.attrs;
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')
703 methods{e} = '';
704 else
705 methods{e} = char(pyMethods{e});
706 end
707 end
708 SensTable.Properties.UserData = struct('method', summary, ...
709 'layerMethods', {methods});
710
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});
715 end
716 end
717
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});
724 end
725 end
726
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());
730 v = v(:);
731 end
732
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')
739 s = [];
740 return
741 end
742 s = struct();
743 fields = {'X','Q','U','R','dX','dQ','dU','dR','QCov','QVar', ...
744 'QTotVar','QCovAsym'};
745 for k = 1:numel(fields)
746 f = fields{k};
747 if ~py.hasattr(pyobj, f)
748 continue
749 end
750 val = py.getattr(pyobj, f);
751 if isa(val, 'py.NoneType')
752 continue
753 end
754 s.(f) = PYLINE.from_pyline_matrix(val);
755 end
756 if isfield(s, 'QCovAsym')
757 s.QCovAsym = double(s.QCovAsym);
758 end
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')));
764 P = numel(pyParams);
765 params = struct('type', cell(1, P), 'station', cell(1, P), ...
766 'class', cell(1, P));
767 for p = 1:P
768 d = pyParams{p};
769 params(p).type = char(d.get('type'));
770 st = double(d.get('station'));
771 if st < 0
772 params(p).station = 0;
773 else
774 params(p).station = st + 1;
775 end
776 params(p).class = double(d.get('jobclass')) + 1;
777 end
778 s.params = params;
779 end
780 end
781
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');
788 Tstart = tic;
789 penv = PYLINE.from_line_via_json(model);
790 method = 'statevec';
791 if isfield(options, 'method') && ~isempty(options.method)
792 method = char(options.method);
793 end
794 Tend = 100;
795 if isfield(options, 'timespan') && numel(options.timespan) == 2 && isfinite(options.timespan(2))
796 Tend = options.timespan(2);
797 end
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);
806 end
807
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.
811 v = v(:);
812 if numel(v) == nElem + 1
813 v = v(2:end);
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));
816 end
817 end
818
819 %% ---- Environment checks ----
820
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
829 return
830 end
831 pe = pyenv;
832 if strcmp(pe.Status, 'NotLoaded') && isempty(pe.Executable)
833 line_error(mfilename, 'lang=python requires a configured MATLAB Python environment (see pyenv).');
834 end
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');
841 try
842 py.importlib.import_module('line_solver');
843 catch ME
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));
845 end
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.
849 try
850 tp = py.importlib.import_module('threadpoolctl');
851 tpHandle = tp.threadpool_limits(pyargs('limits', int32(1)));
852 catch
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.
856 end
857 ready = true;
858 end
859
860 end
861end
Definition Station.m:287
Definition fjtag.m:157
Definition Station.m:245