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 % 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);
44 return
45 end
46
47 pynet = L.Network(model.getName);
48
49 % 1) Nodes first (closed classes reference a station node)
50 pynodes = cell(1, nnodes);
51 for n = 1:nnodes
52 if isa(line_nodes{n}, 'ClassSwitch') && line_nodes{n}.autoAdded
53 continue; % Python link() re-adds auto ClassSwitch nodes
54 end
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);
58 else
59 pynodes{n} = PYLINE.from_line_node(line_nodes{n}, pynet, L, []);
60 end
61 end
62
63 % 2) Classes
64 pyclasses = cell(1, nclasses);
65 for r = 1:nclasses
66 pyclasses{r} = PYLINE.from_line_class(line_classes{r}, pynet, L, pynodes);
67 end
68
69 % 3) Service / arrival processes
70 for n = 1:nnodes
71 if isempty(pynodes{n})
72 continue;
73 end
74 PYLINE.set_service(line_nodes{n}, pynodes{n}, line_classes, pyclasses, L);
75 end
76
77 % 4) Routing
78 PYLINE.from_line_links(model, pynet, pynodes, pyclasses, L);
79 end
80
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.
97 if any(tpl(:) ~= 1)
98 pynode.setTasksPerLink(PYLINE.from_line_matrix(tpl(:)'));
99 end
100 end
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;
114 if isinf(nservers)
115 pynode.setNumberOfServers(int32(intmax('int32')));
116 elseif nservers > 1
117 pynode.setNumberOfServers(int32(nservers));
118 end
119 if ~isinf(line_node.cap)
120 pynode.setCapacity(int32(line_node.cap));
121 end
122 if ~isempty(line_node.lldScaling)
123 pynode.setLoadDependence(PYLINE.from_line_matrix(line_node.lldScaling));
124 end
125 else
126 line_error(mfilename, sprintf('PYLINE (lang=python) does not support node ''%s'' (%s) yet.', line_node.getName, class(line_node)));
127 end
128 end
129
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)));
133 try
134 pysched = L.SchedStrategy.(name);
135 catch
136 line_error(mfilename, sprintf('PYLINE (lang=python) does not support the %s scheduling strategy yet.', name));
137 end
138 end
139
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));
150 else
151 line_error(mfilename, sprintf('PYLINE (lang=python) does not support class type ''%s'' yet.', class(line_class)));
152 end
153 end
154
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')
159 return;
160 end
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')
165 continue;
166 end
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')
171 continue;
172 end
173 pynode.setService(pyclasses{r}, PYLINE.from_line_distribution(matlab_dist, L));
174 end
175 end
176 end
177
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();
242 else
243 line_error(mfilename, sprintf('PYLINE (lang=python) does not support distribution ''%s'' yet.', class(line_dist)));
244 end
245 end
246
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;
251 mu = mu(:)';
252 phi = phi(:)';
253 k = length(mu);
254 alpha = zeros(1, k);
255 alpha(1) = 1;
256 T = zeros(k, k);
257 for i = 1:k
258 T(i, i) = -mu(i);
259 if i < k
260 T(i, i+1) = mu(i) * (1 - phi(i));
261 end
262 end
263 end
264
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);
271
272 % MATLAB node index -> Python node index (skip auto ClassSwitch)
273 m2p = zeros(1, length(line_nodes));
274 pidx = 0;
275 for i = 1:length(line_nodes)
276 if isa(line_nodes{i}, 'ClassSwitch') && line_nodes{i}.autoAdded
277 m2p(i) = -1;
278 else
279 m2p(i) = pidx;
280 pidx = pidx + 1;
281 end
282 end
283
284 useLinkMethod = ~isempty(sn.rtorig);
285 hasAutoCS = any(cellfun(@(nd) isa(nd, 'ClassSwitch') && nd.autoAdded, line_nodes));
286
287 if useLinkMethod
288 rm = L.RoutingMatrix(pynet);
289 end
290
291 if useLinkMethod && hasAutoCS
292 % Class-switching routing lives in sn.rtorig (station-indexed,
293 % already excluding auto ClassSwitch nodes).
294 for r = 1:nclasses
295 for s = 1:nclasses
296 Prs = sn.rtorig{r, s};
297 if isempty(Prs)
298 continue;
299 end
300 [nrows, ncols] = size(Prs);
301 for i = 1:nrows
302 for j = 1:ncols
303 if Prs(i, j) > 0
304 rm.set(pyclasses{r}, pyclasses{s}, pynodes{i}, pynodes{j}, Prs(i, j));
305 end
306 end
307 end
308 end
309 end
310 pynet.link(rm);
311 return
312 end
313
314 for i = 1:size(connections, 1)
315 line_node = line_nodes{i};
316 if isa(line_node, 'ClassSwitch') && line_node.autoAdded
317 continue;
318 end
319 pi_idx = m2p(i);
320 for k = 1:nclasses
321 output_strat = line_node.output.outputStrategy{k};
322 strat = RoutingStrategy.fromText(output_strat{2});
323 switch strat
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);
328 if ~useLinkMethod
329 for j = find(connections(i, :))
330 if m2p(j) >= 0
331 pynet.addLink(pynodes{i}, pynodes{j});
332 end
333 end
334 end
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
342 if useLinkMethod
343 rm.set(pyclasses{k}, pyclasses{k}, pynodes{i}, pynodes{dest_idx}, probs{j}{2});
344 else
345 pynodes{i}.setProbRouting(pyclasses{k}, pynodes{dest_idx}, probs{j}{2});
346 end
347 end
348 end
349 end
350 case RoutingStrategy.RROBIN
351 if useLinkMethod
352 line_error(mfilename, 'RROBIN cannot be used together with the link() command.');
353 end
354 pynodes{i}.setRouting(pyclasses{k}, L.RoutingStrategy.RROBIN);
355 for j = find(connections(i, :))
356 if m2p(j) >= 0
357 pynet.addLink(pynodes{i}, pynodes{j});
358 end
359 end
360 case RoutingStrategy.JSQ
361 pynodes{i}.setRouting(pyclasses{k}, L.RoutingStrategy.JSQ);
362 if ~useLinkMethod
363 for j = find(connections(i, :))
364 if m2p(j) >= 0
365 pynet.addLink(pynodes{i}, pynodes{j});
366 end
367 end
368 end
369 case RoutingStrategy.WRROBIN
370 if useLinkMethod
371 line_error(mfilename, 'WRROBIN cannot be used together with the link() command.');
372 end
373 for j = find(connections(i, :))
374 if m2p(j) >= 0
375 pynet.addLink(pynodes{i}, pynodes{j});
376 end
377 end
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);
383 end
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));
388 else
389 pynodes{i}.setRouting(pyclasses{k}, L.RoutingStrategy.SQ);
390 end
391 if ~useLinkMethod
392 for j = find(connections(i, :))
393 if m2p(j) >= 0
394 pynet.addLink(pynodes{i}, pynodes{j});
395 end
396 end
397 end
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.');
404 otherwise
405 line_error(mfilename, sprintf('PYLINE (lang=python) does not support the ''%s'' routing strategy.', output_strat{2}));
406 end
407 end
408 end
409
410 if useLinkMethod
411 pynet.link(rm);
412 end
413 end
414
415 %% ---- Marshalling helpers ----
416
417 function pyarr = from_line_matrix(matrix)
418 % FROM_LINE_MATRIX MATLAB double matrix -> numpy ndarray.
419 np = py.importlib.import_module('numpy');
420 if isempty(matrix)
421 pyarr = np.zeros(py.tuple({int32(0), int32(0)}));
422 return
423 end
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);
428 for r = 1:rows
429 rowsCell{r} = py.list(num2cell(double(matrix(r, :))));
430 end
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)}));
435 end
436 end
437
438 function matrix = from_pyline_matrix(pyarr)
439 % FROM_PYLINE_MATRIX numpy ndarray / scalar -> MATLAB double.
440 if isa(pyarr, 'py.NoneType')
441 matrix = [];
442 return
443 end
444 np = py.importlib.import_module('numpy');
445 matrix = double(np.asarray(pyarr, pyargs('dtype', 'float64')));
446 end
447
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);
454
455 cand = {}; % {nativeName, value} pairs
456 if isfield(options, 'tol') && ~isempty(options.tol)
457 cand = [cand, {'tol', options.tol}];
458 end
459 if isfield(options, 'iter_tol') && ~isempty(options.iter_tol)
460 cand = [cand, {'iter_tol', options.iter_tol}];
461 end
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)}];
466 end
467 if isfield(options, 'seed') && ~isempty(options.seed)
468 cand = [cand, {'seed', int32(options.seed)}];
469 end
470 if isfield(options, 'cutoff') && ~isempty(options.cutoff) && isfinite(options.cutoff)
471 cand = [cand, {'cutoff', int32(options.cutoff)}];
472 end
473 if isfield(options, 'samples') && ~isempty(options.samples)
474 cand = [cand, {'samples', int32(options.samples)}];
475 end
476 cand = [cand, {'verbose', false}];
477
478 args = {};
479 for i = 1:2:numel(cand)
480 if any(strcmp(cand{i}, accepted))
481 args = [args, {cand{i}, cand{i+1}}]; %#ok<AGROW>
482 end
483 end
484 pyopts = pyargs(args{:});
485 end
486
487 function names = acceptedKwargs(solverName)
488 % ACCEPTEDKWARGS Native SolverXOptions constructor parameter names.
489 switch solverName
490 case 'SolverMVA'
491 names = {'max_iter','tol','verbose','seed','cutoff','samples'};
492 case 'SolverNC'
493 names = {'tol','iter_max','iter_tol','verbose','seed','cutoff','samples'};
494 case 'SolverCTMC'
495 names = {'tol','cutoff','seed','samples','verbose'};
496 case 'SolverMAM'
497 names = {'tol','max_iter','verbose'};
498 case {'SolverFluid','SolverFLD'}
499 names = {'tol','iter_max','iter_tol','verbose','seed','cutoff','samples'};
500 case 'SolverSSA'
501 names = {'tol','samples','seed','cutoff','verbose'};
502 otherwise
503 names = {'verbose'};
504 end
505 end
506
507 %% ---- Solver constructors ----
508
509 function pysolver = Solver(name, pynet, options, L)
510 % SOLVER Dispatch to the native Python solver constructor by name.
511 method = 'default';
512 if isfield(options, 'method') && ~isempty(options.method)
513 method = char(options.method);
514 end
515 pyopts = PYLINE.parseSolverOptions(options, name);
516 switch name
517 case 'SolverMVA'
518 pysolver = L.SolverMVA(pynet, method, pyopts);
519 case 'SolverNC'
520 pysolver = L.SolverNC(pynet, method, pyopts);
521 case 'SolverCTMC'
522 pysolver = L.SolverCTMC(pynet, method, pyopts);
523 case 'SolverMAM'
524 pysolver = L.SolverMAM(pynet, method, pyopts);
525 case {'SolverFluid', 'SolverFLD'}
526 pysolver = L.SolverFluid(pynet, method, pyopts);
527 case 'SolverSSA'
528 pysolver = L.SolverSSA(pynet, method, pyopts);
529 case 'SolverAuto'
530 pysolver = L.SolverAuto(pynet, method, pyopts);
531 otherwise
532 line_error(mfilename, sprintf('PYLINE (lang=python) does not support %s yet.', name));
533 end
534 end
535
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');
539 Tstart = tic;
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);
551 end
552
553 %% ---- JSON model bridge (advanced Network features, Environment) ----
554
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);
566 end
567
568 %% ---- LayeredNetwork (LQN) ----
569
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);
583 end
584
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};
597 end
598 for t = 1:numel(model.tasks)
599 task = model.tasks{t};
600 if SchedStrategy.fromText(task.scheduling) == SchedStrategy.REF
601 continue
602 end
603 if isempty(task.thinkTimeMean) || task.thinkTimeMean <= 0
604 continue
605 end
606 if isKey(byName, task.name)
607 pytask = byName(task.name);
608 pytask.set_think_time(task.thinkTimeMean);
609 end
610 end
611 end
612
613 function tryDelete(f)
614 % TRYDELETE Best-effort temp-file removal.
615 if exist(f, 'file')
616 delete(f);
617 end
618 end
619
620 function pysolver = SolverLN(pynet, options, L)
621 % SOLVERLN Native SolverLN over an LQN model.
622 pysolver = L.SolverLN(pynet, PYLINE.parseSolverOptions(options, 'SolverLN'));
623 end
624
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');
635 Tstart = tic;
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);
646 end
647
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).
654 %
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);
661
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});
668 otherwise
669 line_error(mfilename, sprintf('Unknown option ''%s''.', varargin{a}));
670 end
671 end
672 if isempty(step)
673 pystep = py.None;
674 else
675 pystep = step;
676 end
677 T = pysolver.getSensitivityTable(pyargs('method', method, ...
678 'step', pystep, 'scheme', scheme));
679
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');
687
688 SensTable = table(Layer, Station, JobClass, dTput, dRespT, dQLen, dUtil, ...
689 'VariableNames', {'Layer', 'Station', 'JobClass', 'dTput_dRate', ...
690 'dRespT_dRate', 'dQLen_dRate', 'dUtil_dRate'});
691
692 attrs = T.attrs;
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')
698 methods{e} = '';
699 else
700 methods{e} = char(pyMethods{e});
701 end
702 end
703 SensTable.Properties.UserData = struct('method', summary, ...
704 'layerMethods', {methods});
705
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});
710 end
711 end
712
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});
719 end
720 end
721
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());
725 v = v(:);
726 end
727
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')
734 s = [];
735 return
736 end
737 s = struct();
738 fields = {'X','Q','U','R','dX','dQ','dU','dR','QCov','QVar', ...
739 'QTotVar','QCovAsym'};
740 for k = 1:numel(fields)
741 f = fields{k};
742 if ~py.hasattr(pyobj, f)
743 continue
744 end
745 val = py.getattr(pyobj, f);
746 if isa(val, 'py.NoneType')
747 continue
748 end
749 s.(f) = PYLINE.from_pyline_matrix(val);
750 end
751 if isfield(s, 'QCovAsym')
752 s.QCovAsym = double(s.QCovAsym);
753 end
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')));
759 P = numel(pyParams);
760 params = struct('type', cell(1, P), 'station', cell(1, P), ...
761 'class', cell(1, P));
762 for p = 1:P
763 d = pyParams{p};
764 params(p).type = char(d.get('type'));
765 st = double(d.get('station'));
766 if st < 0
767 params(p).station = 0;
768 else
769 params(p).station = st + 1;
770 end
771 params(p).class = double(d.get('jobclass')) + 1;
772 end
773 s.params = params;
774 end
775 end
776
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');
783 Tstart = tic;
784 penv = PYLINE.from_line_via_json(model);
785 method = 'statevec';
786 if isfield(options, 'method') && ~isempty(options.method)
787 method = char(options.method);
788 end
789 Tend = 100;
790 if isfield(options, 'timespan') && numel(options.timespan) == 2 && isfinite(options.timespan(2))
791 Tend = options.timespan(2);
792 end
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);
801 end
802
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.
806 v = v(:);
807 if numel(v) == nElem + 1
808 v = v(2:end);
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));
811 end
812 end
813
814 %% ---- Environment checks ----
815
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
824 return
825 end
826 pe = pyenv;
827 if strcmp(pe.Status, 'NotLoaded') && isempty(pe.Executable)
828 line_error(mfilename, 'lang=python requires a configured MATLAB Python environment (see pyenv).');
829 end
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');
836 try
837 py.importlib.import_module('line_solver');
838 catch ME
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));
840 end
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.
844 try
845 tp = py.importlib.import_module('threadpoolctl');
846 tpHandle = tp.threadpool_limits(pyargs('limits', int32(1)));
847 catch
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.
851 end
852 ready = true;
853 end
854
855 end
856end
Definition fjtag.m:161