LINE Solver
MATLAB API documentation
Loading...
Searching...
No Matches
solveCli.m
1function data = solveCli(self, options, extraFlags)
2% DATA = SOLVECLI(OPTIONS, EXTRAFLAGS)
3%
4% Fully JSON-mediated LDES solve. Serializes the model natively to model.json
5% (linemodel_save, no in-process Java object marshalling), runs the LDES engine
6% as a subprocess exchanging only JSON ("solve model.json -o result.json"), and
7% returns the jsondecode'd result struct. Returns [] on any failure.
8%
9% The engine is either the native GraalVM binary (common/ldes) or, failing that,
10% "java -jar common/ldes.jar" (same shaded engine). No JPype/JLINE path remains.
11%
12% EXTRAFLAGS is an optional cellstr of additional CLI tokens, e.g.
13% {'--timespan','0,100','--trajectory'} or {'--export-histogram'}.
14
15if nargin < 3 || isempty(extraFlags)
16 extraFlags = {};
17end
18data = [];
19
20tmpDir = tempname;
21if ~mkdir(tmpDir)
22 return;
23end
24cleaner = onCleanup(@() rmdirQuiet(tmpDir)); %#ok<NASGU>
25modelPath = fullfile(tmpDir, 'model.json');
26resultPath = fullfile(tmpDir, 'result.json');
27
28% Serialize the model to the CLI model.json format natively (no LINE2JLINE).
29try
30 linemodel_save(self.model, modelPath);
31catch ME
32 line_debug('LDES solveCli: linemodel_save failed: %s', ME.message);
33 return;
34end
35if exist(modelPath, 'file') ~= 2
36 return;
37end
38
39% Remote engine, if options.rest_url names an LDES REST server. The container
40% owns the engine in that case, so no local binary or jar is required.
41restUrl = '';
42if isfield(options, 'rest_url') && ~isempty(options.rest_url)
43 restUrl = char(options.rest_url);
44end
45
46% Resolve the ordered engine command prefixes (native binary, then jar).
47runners = {};
48if isempty(restUrl)
49 runners = SolverLDES.getLdesRunners();
50 if isempty(runners)
51 line_error(mfilename, ['No runnable LDES engine found (neither the native ' ...
52 'common/ldes binary nor a Java runtime for common/ldes.jar).']);
53 end
54end
55
56% see _kb/06-solver-catalog.md (Wrappers: LDES runner ordering vs stale AOT native binary)
57if numel(runners) > 1
58 preferJar = false;
59 % A flag the AOT native binary predates must run on the jar, which is
60 % rebuilt with the sources. --respt-samples is the current instance.
61 for fi = 1:numel(extraFlags)
62 if ischar(extraFlags{fi}) && strcmp(extraFlags{fi}, '--respt-samples')
63 preferJar = true;
64 break;
65 end
66 end
67 for ni = 1:numel(self.model.nodes)
68 nd = self.model.nodes{ni};
69 if isa(nd, 'Source') && ~isempty(nd.markedClasses)
70 preferJar = true;
71 break;
72 end
73 if isa(nd, 'Station') && ~isempty(nd.lcdScaling)
74 preferJar = true;
75 break;
76 end
77 end
78 if preferJar
79 runners = runners(end:-1:1);
80 end
81end
82
83% Flags shared across runners (mirror the Python-native flag set).
84flags = sprintf('-s %d --seed %d', round(options.samples), round(options.seed));
85if isfield(options, 'method') && (ischar(options.method) || isstring(options.method)) ...
86 && ~strcmpi(char(options.method), 'default')
87 flags = sprintf('%s --method %s', flags, char(options.method));
88end
89% Warm-start placement (station-major vector) via --initsol.
90if isfield(options, 'init_sol') && ~isempty(options.init_sol)
91 v = options.init_sol(:).';
92 flags = sprintf('%s --initsol %s', flags, strjoin(arrayfun(@(x) num2str(x, '%.10g'), v, ...
93 'UniformOutput', false), ','));
94end
95% Discrete-time (slotted) mode. --slotlength implies --slotted on the CLI side,
96% but both are emitted when the length is non-default so the command line states
97% the intent explicitly.
98slottedOn = logical(ldesOpt(options, 'slotted', false));
99if slottedOn
100 flags = sprintf('%s --slotted', flags);
101 slotLen = ldesOpt(options, 'slotlength', 1);
102 if slotLen ~= 1
103 flags = sprintf('%s --slotlength %.10g', flags, slotLen);
104 end
105end
106% Transient (warmup) filter and confidence-interval estimator. Only non-default
107% values are emitted, so a default run keeps a minimal command line that an
108% older AOT native binary still understands.
109tranfilter = ldesOpt(options, 'tranfilter', 'mser5');
110if ischar(tranfilter) || isstring(tranfilter)
111 tranfilter = char(tranfilter);
112 if ~strcmpi(tranfilter, 'mser5')
113 flags = sprintf('%s --tranfilter %s', flags, tranfilter);
114 end
115end
116warmupfrac = ldesOpt(options, 'warmupfrac', 0.2);
117if isnumeric(warmupfrac) && isscalar(warmupfrac) && isfinite(warmupfrac) && warmupfrac ~= 0.2
118 flags = sprintf('%s --warmupfrac %.10g', flags, warmupfrac);
119end
120mserbatch = ldesOpt(options, 'mserbatch', 5);
121if isnumeric(mserbatch) && isscalar(mserbatch) && mserbatch ~= 5
122 flags = sprintf('%s --mserbatch %d', flags, round(mserbatch));
123end
124cimethod = ldesOpt(options, 'cimethod', 'obm');
125if ischar(cimethod) || isstring(cimethod)
126 cimethod = char(cimethod);
127 if ~strcmpi(cimethod, 'obm')
128 flags = sprintf('%s --cimethod %s', flags, cimethod);
129 end
130end
131obmoverlap = ldesOpt(options, 'obmoverlap', 0.5);
132if isnumeric(obmoverlap) && isscalar(obmoverlap) && isfinite(obmoverlap) && obmoverlap ~= 0.5
133 flags = sprintf('%s --obmoverlap %.10g', flags, obmoverlap);
134end
135ciminbatch = ldesOpt(options, 'ciminbatch', 10);
136if isnumeric(ciminbatch) && isscalar(ciminbatch) && ciminbatch ~= 10
137 flags = sprintf('%s --ciminbatch %d', flags, round(ciminbatch));
138end
139ciminobs = ldesOpt(options, 'ciminobs', 100);
140if isnumeric(ciminobs) && isscalar(ciminobs) && ciminobs ~= 100
141 flags = sprintf('%s --ciminobs %d', flags, round(ciminobs));
142end
143spectralfrac = ldesOpt(options, 'spectrallowfreqfrac', 0.25);
144if isnumeric(spectralfrac) && isscalar(spectralfrac) && isfinite(spectralfrac) && spectralfrac ~= 0.25
145 flags = sprintf('%s --spectrallowfreqfrac %.10g', flags, spectralfrac);
146end
147% Convergence-based stopping. The tolerance is options.config.cnvgtol, the field
148% SolverOptions declares for LDES; it is not options.iter_tol, which is the
149% fixed-point tolerance of the analytical solvers and has an unrelated default.
150if logical(ldesOpt(options, 'cnvgon', false))
151 flags = sprintf('%s --cnvgon', flags);
152 cnvgtol = ldesOpt(options, 'cnvgtol', 0.05);
153 if isnumeric(cnvgtol) && isscalar(cnvgtol) && isfinite(cnvgtol) && cnvgtol ~= 0.05
154 flags = sprintf('%s --cnvgtol %.10g', flags, cnvgtol);
155 end
156 cnvgbatch = ldesOpt(options, 'cnvgbatch', 20);
157 if isnumeric(cnvgbatch) && isscalar(cnvgbatch) && cnvgbatch ~= 20
158 flags = sprintf('%s --cnvgbatch %d', flags, round(cnvgbatch));
159 end
160 cnvgchk = ldesOpt(options, 'cnvgchk', 0);
161 if isnumeric(cnvgchk) && isscalar(cnvgchk) && cnvgchk ~= 0
162 flags = sprintf('%s --cnvgchk %d', flags, round(cnvgchk));
163 end
164end
165% Independent replications and the worker pool that runs them. Emitted here for
166% every analysis, steady-state included; runTransientJson no longer adds them.
167% A caller that already supplied the flag through extraFlags wins.
168reps = ldesOpt(options, 'replications', 1);
169if isnumeric(reps) && isscalar(reps) && reps > 1 && ~hasFlag(extraFlags, '--replications')
170 flags = sprintf('%s --replications %d', flags, round(reps));
171 nthreads = ldesOpt(options, 'numthreads', []);
172 if isnumeric(nthreads) && isscalar(nthreads) && nthreads > 0 ...
173 && ~hasFlag(extraFlags, '--numthreads')
174 flags = sprintf('%s --numthreads %d', flags, round(nthreads));
175 end
176end
177% Cooperative wall-clock budget. The SSJ event loop checks it and stops early
178% with stoppingReason='max_time'; the subprocess timeout remains the hard bound.
179timeout = ldesOpt(options, 'timeout', Inf);
180if isnumeric(timeout) && isscalar(timeout) && isfinite(timeout) && timeout > 0 ...
181 && ~hasFlag(extraFlags, '--maxtime')
182 flags = sprintf('%s --maxtime %.10g', flags, timeout);
183end
184for i = 1:numel(extraFlags)
185 flags = sprintf('%s %s', flags, extraFlags{i});
186end
187
188% Remote engine: POST the same model.json and flags the CLI would have used.
189if ~isempty(restUrl)
190 data = solveRest(restUrl, modelPath, flags, options);
191 return;
192end
193
194% Try each runner in order; the native binary may lack some reflective features
195% (e.g. fork-join MMT serialization), so fall through to the JVM jar on failure.
196lastOut = '';
197for ri = 1:numel(runners)
198 if exist(resultPath, 'file') == 2
199 delete(resultPath);
200 end
201 cmd = sprintf('%s solve "%s" -o "%s" %s', runners{ri}, modelPath, resultPath, flags);
202 [status, cmdout] = system(cmd);
203 lastOut = cmdout;
204 if status == 0 && exist(resultPath, 'file') == 2
205 try
206 data = jsondecode(fileread(resultPath));
207 catch ME
208 line_error(mfilename, sprintf('LDES result JSON parse failed: %s', ME.message));
209 end
210 if isstruct(data) && isfield(data, 'error')
211 line_error(mfilename, sprintf('LDES engine error: %s', data.error));
212 end
213 return;
214 end
215 line_debug('LDES runner %d/%d failed (status=%d); trying next.', ri, numel(runners), status);
216end
217
218line_error(mfilename, sprintf('LDES engine failed on all %d runner(s): %s', ...
219 numel(runners), lastOut));
220end
221
222function data = solveRest(restUrl, modelPath, flags, options)
223% DATA = SOLVEREST(RESTURL, MODELPATH, FLAGS, OPTIONS)
224%
225% Solve through an LDES REST server (the imperialqore/ldes container). The wire
226% format is the same model.json the CLI reads and the same ldes-result document
227% it writes, so the returned struct is identical to the subprocess path for a
228% fixed seed and sample count.
229
230url = regexprep(char(restUrl), '/+$', '');
231if isempty(regexp(url, '/api/v\d+/solve$', 'once'))
232 url = [url '/api/v1/solve'];
233end
234
235req = struct();
236req.model = struct('content', fileread(modelPath), 'base64', false);
237% The server accepts the CLI long-form flags verbatim; values never contain
238% whitespace, so a whitespace split reproduces the argument vector exactly.
239tokens = strsplit(strtrim(flags));
240tokens = tokens(~cellfun(@isempty, tokens));
241req.flags = tokens;
242
243timeout = 3600;
244if isfield(options, 'timeout') && ~isempty(options.timeout) && isfinite(options.timeout)
245 timeout = options.timeout;
246end
247wopts = weboptions('MediaType', 'application/json', 'ContentType', 'json', ...
248 'RequestMethod', 'post', 'Timeout', timeout);
249
250try
251 resp = webwrite(url, req, wopts);
252catch ME
253 line_error(mfilename, sprintf('LDES REST request to %s failed: %s', url, ME.message));
254end
255
256if ~isstruct(resp) || ~isfield(resp, 'status')
257 line_error(mfilename, sprintf('LDES REST server at %s returned an unexpected payload.', url));
258end
259if ~strcmpi(resp.status, 'ok')
260 msg = 'unspecified error';
261 if isfield(resp, 'message') && ~isempty(resp.message)
262 msg = resp.message;
263 end
264 detail = '';
265 if isfield(resp, 'stderr') && ~isempty(resp.stderr)
266 detail = sprintf(' Engine stderr: %s', strtrim(resp.stderr));
267 end
268 line_error(mfilename, sprintf('LDES REST solve failed: %s.%s', msg, detail));
269end
270
271data = resp.result;
272if isstruct(data) && isfield(data, 'error')
273 line_error(mfilename, sprintf('LDES engine error: %s', data.error));
274end
275end
276
277function rmdirQuiet(d)
278% RMDIRQUIET Remove directory d and contents, ignoring errors.
279try
280 if exist(d, 'dir')
281 rmdir(d, 's');
282 end
283catch
284end
285end
286
287function v = ldesOpt(options, name, default)
288% V = LDESOPT(OPTIONS, NAME, DEFAULT)
289% Read an LDES option under either spelling. SolverOptions declares the engine
290% knobs under options.config, but a caller may set the flat field, and
291% Solver.parseOptions replaces the defaults wholesale when the caller passes its
292% own struct, so neither spelling can be assumed present.
293v = default;
294if isfield(options, name) && ~isempty(options.(name))
295 v = options.(name);
296elseif isfield(options, 'config') && isstruct(options.config) ...
297 && isfield(options.config, name) && ~isempty(options.config.(name))
298 v = options.config.(name);
299end
300end
301
302function tf = hasFlag(extraFlags, flag)
303% TF = HASFLAG(EXTRAFLAGS, FLAG)
304% True if the caller already supplied FLAG among the extra CLI tokens.
305tf = false;
306for i = 1:numel(extraFlags)
307 if (ischar(extraFlags{i}) || isstring(extraFlags{i})) ...
308 && strcmp(char(extraFlags{i}), flag)
309 tf = true;
310 return;
311 end
312end
313end
314
Definition fjtag.m:161