diff --git a/matlab/CHANGELOG.md b/matlab/CHANGELOG.md new file mode 100644 index 00000000..c116abcd --- /dev/null +++ b/matlab/CHANGELOG.md @@ -0,0 +1,24 @@ +# Change Log + +All notable changes to the MATLAB interface will be documented in this file. + +The format is based on [Keep a Changelog](http://keepachangelog.com/) +and this project adheres to [Semantic Versioning](http://semver.org/). + +Note: This is the Changelog file for the MATLAB interface of OpEn. + + +## [0.1.0] - Unreleased + +### Added + +- New MATLAB TCP client in `matlab/api/OpEnTcpOptimizer.m` for TCP-enabled optimizers generated in Python. +- Convenience constructor helper `matlab/api/createOpEnTcpOptimizer.m`. +- Support for parametric optimizers over TCP using calls of the form `response = client.solve(p)`. +- Support for OCP optimizers over TCP by loading `optimizer_manifest.json` and allowing named-parameter calls such as `response = client.solve('x0', x0, 'xref', xref)`. +- Automatic packing of named OCP parameter blocks according to the manifest order, including support for manifest defaults. +- MATLAB-side helpers for `ping`, `kill`, warm-start options, and normalized solver responses. + +### Changed + +- Added a dedicated MATLAB API area under `matlab/api` for the current interface, separate from the legacy MATLAB code. diff --git a/matlab/README.md b/matlab/README.md index 3d49644a..5a5ae63d 100644 --- a/matlab/README.md +++ b/matlab/README.md @@ -1,5 +1,205 @@ -# MATLAB OpEn Interface +# OpEn MATLAB API -This is the matlab interface of **Optimization Engine**. +This directory contains the MATLAB interface of **Optimization Engine (OpEn)**. -Read the [detailed documentation ](https://alphaville.github.io/optimization-engine/docs/matlab-interface). \ No newline at end of file +The current MATLAB API lives in [`matlab/api`](./api). It communicates with +optimizers generated in Python that expose a TCP server interface. + +The legacy MATLAB code is preserved in [`matlab/legacy`](./legacy). + +## Capabilities + +The current MATLAB toolbox supports: + +- Connecting to TCP-enabled optimizers generated in Python +- Calling standard parametric optimizers using a flat parameter vector +- Calling OCP-generated optimizers using named parameter blocks from + `optimizer_manifest.json` +- Loading OCP manifests and, when available, automatically reading the TCP + endpoint from the sibling `optimizer.yml` +- Sending `ping` and `kill` requests to the optimizer server +- Providing optional warm-start data through: + - `InitialGuess` + - `InitialLagrangeMultipliers` + - `InitialPenalty` +- Returning normalized solver responses with an `ok` flag and solver + diagnostics +- Returning stage-wise `inputs` for OCP optimizers and `states` for + multiple-shooting OCP optimizers + +The main entry points are: + +- [`matlab/api/OpEnTcpOptimizer.m`](./api/OpEnTcpOptimizer.m) +- [`matlab/api/createOpEnTcpOptimizer.m`](./api/createOpEnTcpOptimizer.m) + +## Getting Started + +Add the MATLAB API folder to your path: + +```matlab +addpath(fullfile(pwd, 'matlab', 'api')); +``` + +Make sure the target optimizer TCP server is already running. + +## Simple Optimizers + +### Connect to a parametric optimizer + +Use a TCP port directly. The IP defaults to `127.0.0.1`. + +```matlab +client = OpEnTcpOptimizer(3301); +pong = client.ping(); +disp(pong.Pong); +``` + +You can also specify the endpoint explicitly: + +```matlab +client = OpEnTcpOptimizer('127.0.0.1', 3301); +``` + +### Solve a parametric optimizer + +For a standard parametric optimizer, pass the flat parameter vector: + +```matlab +response = client.solve([2.0, 10.0]); + +if response.ok + disp(response.solution); + disp(response.cost); +else + error('OpEn:SolverError', '%s', response.message); +end +``` + +### Solve with warm-start information + +```matlab +response1 = client.solve([2.0, 10.0]); + +response2 = client.solve( ... + [2.0, 10.0], ... + 'InitialGuess', response1.solution, ... + 'InitialLagrangeMultipliers', response1.lagrange_multipliers, ... + 'InitialPenalty', response1.penalty); +``` + +### Stop the server + +```matlab +client.kill(); +``` + +## OCP Optimizers + +For OCP-generated optimizers, MATLAB uses **name-value pairs** to provide the +parameter blocks listed in `optimizer_manifest.json`. + +### Load an OCP optimizer from its manifest + +If `optimizer_manifest.json` and `optimizer.yml` are in the same generated +optimizer directory, the client can infer the TCP endpoint automatically: + +```matlab +manifestPath = fullfile( ... + pwd, ... + 'open-codegen', ... + '.python_test_build_ocp', ... + 'ocp_single_tcp', ... + 'optimizer_manifest.json'); + +client = OpEnTcpOptimizer('ManifestPath', manifestPath); +disp(client.parameterNames()); +``` + +You can also override the endpoint explicitly: + +```matlab +client = OpEnTcpOptimizer(3391, 'ManifestPath', manifestPath); +``` + +### Solve a single-shooting OCP optimizer + +The following example matches the OCP manifest in +`open-codegen/.python_test_build_ocp/ocp_single_tcp`: + +```matlab +response = client.solve( ... + 'x0', [1.0, -1.0], ... + 'xref', [0.0, 0.0]); + +if response.ok + disp(response.solution); + disp(response.inputs); + disp(response.exit_status); +else + error('OpEn:SolverError', '%s', response.message); +end +``` + +If the manifest defines default values for some parameters, you only need to +provide the required ones: + +```matlab +manifestPath = fullfile( ... + pwd, ... + 'open-codegen', ... + '.python_test_build_ocp', ... + 'ocp_manifest_bindings', ... + 'optimizer_manifest.json'); + +client = OpEnTcpOptimizer('ManifestPath', manifestPath); +response = client.solve('x0', [1.0, 0.0]); +``` + +### Solve a multiple-shooting OCP optimizer + +For multiple-shooting OCPs, the MATLAB client also returns the state +trajectory reconstructed from the manifest slices: + +```matlab +manifestPath = fullfile( ... + pwd, ... + 'open-codegen', ... + '.python_test_build_ocp', ... + 'ocp_multiple_tcp', ... + 'optimizer_manifest.json'); + +client = OpEnTcpOptimizer('ManifestPath', manifestPath); + +response = client.solve( ... + 'x0', [1.0, -1.0], ... + 'xref', [0.0, 0.0]); + +disp(response.inputs); +disp(response.states); +``` + +### OCP warm-start example + +Warm-start options can be combined with named OCP parameters: + +```matlab +response1 = client.solve( ... + 'x0', [1.0, -1.0], ... + 'xref', [0.0, 0.0]); + +response2 = client.solve( ... + 'x0', [1.0, -1.0], ... + 'xref', [0.0, 0.0], ... + 'InitialGuess', response1.solution, ... + 'InitialLagrangeMultipliers', response1.lagrange_multipliers, ... + 'InitialPenalty', response1.penalty); +``` + +## Notes + +- The MATLAB API does not start the optimizer server; it connects to a server + that is already running. +- For plain parametric optimizers, use `client.solve(p)`. +- For OCP optimizers, use `client.solve('name1', value1, 'name2', value2, ...)`. +- The helper function `createOpEnTcpOptimizer(...)` is a thin wrapper around + `OpEnTcpOptimizer(...)`. diff --git a/matlab/api/OpEnTcpOptimizer.m b/matlab/api/OpEnTcpOptimizer.m new file mode 100644 index 00000000..a0eceb5d --- /dev/null +++ b/matlab/api/OpEnTcpOptimizer.m @@ -0,0 +1,898 @@ +classdef OpEnTcpOptimizer < handle + %OPENTCPOPTIMIZER TCP client for Python-generated OpEn optimizers. + % CLIENT = OPENTCPOPTIMIZER(PORT) creates a client that connects to a + % TCP-enabled optimizer running on 127.0.0.1:PORT. + % + % CLIENT = OPENTCPOPTIMIZER(PORT, IP) connects to the optimizer at + % the specified IP address and TCP port. + % + % CLIENT = OPENTCPOPTIMIZER(IP, PORT) is also accepted for callers + % who prefer to provide the endpoint in IP/port order. + % + % CLIENT = OPENTCPOPTIMIZER(..., 'ManifestPath', MANIFESTPATH) loads + % an OCP optimizer manifest created by Python's ``ocp`` module. Once + % a manifest is loaded, the client also supports named-parameter + % calls such as: + % + % response = client.solve(x0=[1; 0], xref=[0; 0]); + % + % Name-value pairs: + % 'ManifestPath' Path to optimizer_manifest.json + % 'Timeout' Socket timeout in seconds (default 10) + % 'MaxResponseBytes' Maximum response size in bytes + % (default 1048576) + % + % If only a manifest path is provided, the constructor attempts to + % read ``optimizer.yml`` next to the manifest and use its TCP/IP + % endpoint automatically. + % + % This interface is intended for optimizers generated in Python with + % the TCP interface enabled. The optimizer server must already be + % running; this class only communicates with it. + % + % Examples: + % client = OpEnTcpOptimizer(3301); + % response = client.solve([2.0, 10.0]); + % + % client = OpEnTcpOptimizer('ManifestPath', 'optimizer_manifest.json'); + % response = client.solve(x0=[1.0, -1.0], xref=[0.0, 0.0]); + + properties (SetAccess = private) + %IP IPv4 address or host name of the optimizer server. + ip + + %PORT TCP port of the optimizer server. + port + + %TIMEOUT Connect and read timeout in seconds. + timeout + + %MAXRESPONSEBYTES Safety limit for incoming payload size. + maxResponseBytes + + %MANIFESTPATH Absolute path to an optional OCP manifest. + manifestPath + + %MANIFEST Decoded OCP manifest data. + manifest + end + + methods + function obj = OpEnTcpOptimizer(varargin) + %OPENTCPOPTIMIZER Construct a TCP client for a generated optimizer. + % + % Supported call patterns: + % OpEnTcpOptimizer(port) + % OpEnTcpOptimizer(port, ip) + % OpEnTcpOptimizer(ip, port) + % OpEnTcpOptimizer(..., 'ManifestPath', path) + % OpEnTcpOptimizer('ManifestPath', path) + + obj.manifestPath = ''; + obj.manifest = []; + + [endpointArgs, options] = OpEnTcpOptimizer.parseConstructorInputs(varargin); + + if ~isempty(options.ManifestPath) + obj.loadManifest(options.ManifestPath); + end + + [port, ip] = obj.resolveEndpoint(endpointArgs); + + obj.port = double(port); + obj.ip = OpEnTcpOptimizer.textToChar(ip); + obj.timeout = double(options.Timeout); + obj.maxResponseBytes = double(options.MaxResponseBytes); + end + + function obj = loadManifest(obj, manifestPath) + %LOADMANIFEST Load an OCP optimizer manifest. + % OBJ = LOADMANIFEST(OBJ, MANIFESTPATH) loads an + % ``optimizer_manifest.json`` file created by the Python OCP + % module. After loading the manifest, the client accepts + % named-parameter calls such as ``solve(x0=..., xref=...)``. + + if ~OpEnTcpOptimizer.isTextScalar(manifestPath) + error('OpEnTcpOptimizer:InvalidManifestPath', ... + 'ManifestPath must be a character vector or string scalar.'); + end + manifestPath = OpEnTcpOptimizer.textToChar(manifestPath); + manifestPath = OpEnTcpOptimizer.validateManifestPath(manifestPath); + manifestText = fileread(manifestPath); + manifestData = jsondecode(manifestText); + + if ~isstruct(manifestData) + error('OpEnTcpOptimizer:InvalidManifest', ... + 'The manifest must decode to a MATLAB struct.'); + end + + if ~isfield(manifestData, 'parameters') + error('OpEnTcpOptimizer:InvalidManifest', ... + 'The manifest does not contain a "parameters" field.'); + end + + OpEnTcpOptimizer.validateManifestParameters(manifestData.parameters); + + obj.manifestPath = manifestPath; + obj.manifest = manifestData; + end + + function tf = hasManifest(obj) + %HASMANIFEST True if an OCP manifest has been loaded. + tf = ~isempty(obj.manifest); + end + + function names = parameterNames(obj) + %PARAMETERNAMES Return the ordered OCP parameter names. + if ~obj.hasManifest() + names = {}; + return; + end + + definitions = OpEnTcpOptimizer.manifestParametersAsCell(obj.manifest.parameters); + names = cell(size(definitions)); + for i = 1:numel(definitions) + names{i} = definitions{i}.name; + end + end + + function response = ping(obj) + %PING Check whether the optimizer server is reachable. + % RESPONSE = PING(OBJ) sends {"Ping":1} and returns the + % decoded JSON response, typically a struct with field "Pong". + response = obj.sendRequest('{"Ping":1}', true); + end + + function kill(obj) + %KILL Ask the optimizer server to stop gracefully. + % KILL(OBJ) sends {"Kill":1}. The server closes the + % connection without returning a JSON payload. + obj.sendRequest('{"Kill":1}', false); + end + + function response = solve(obj, varargin) + %SOLVE Run a parametric or OCP optimizer over TCP. + % RESPONSE = SOLVE(OBJ, P) sends the flat parameter vector P + % to a standard parametric optimizer. + % + % RESPONSE = SOLVE(OBJ, x0=..., xref=..., ...) packs the named + % parameter blocks declared in the loaded OCP manifest and + % sends the resulting flat parameter vector to the solver. + % + % In both modes, the optional solver warm-start arguments are: + % InitialGuess + % InitialLagrangeMultipliers + % InitialPenalty + % + % On success, RESPONSE contains the solver fields returned by + % the server, plus: + % ok = true + % raw = original decoded JSON response + % f1_infeasibility = delta_y_norm_over_c + % + % For OCP solves, RESPONSE also contains: + % packed_parameter = flat parameter vector sent to server + % inputs = stage-wise control inputs, when available + % states = state trajectory for multiple shooting OCPs + + [parameterVector, solverOptions, solveMode] = obj.prepareSolveInputs(varargin); + rawResponse = obj.runSolveRequest(parameterVector, solverOptions); + response = OpEnTcpOptimizer.normalizeSolverResponse(rawResponse); + + if strcmp(solveMode, 'ocp') + response.packed_parameter = parameterVector; + if response.ok + response = obj.enrichOcpResponse(response, parameterVector); + end + end + end + + function response = call(obj, varargin) + %CALL Alias for SOLVE to match the Python TCP interface. + response = obj.solve(varargin{:}); + end + + function response = consume(obj, varargin) + %CONSUME Alias for SOLVE to ease migration from older MATLAB code. + response = obj.solve(varargin{:}); + end + end + + methods (Access = private) + function [parameterVector, solverOptions, solveMode] = prepareSolveInputs(obj, inputArgs) + %PREPARESOLVEINPUTS Parse parametric or OCP solve inputs. + + if isempty(inputArgs) + error('OpEnTcpOptimizer:MissingSolveArguments', ... + 'Provide either a flat parameter vector or named OCP parameters.'); + end + + if OpEnTcpOptimizer.isVectorNumeric(inputArgs{1}) + solveMode = 'parametric'; + parameterVector = OpEnTcpOptimizer.toRowVector(inputArgs{1}, 'parameter'); + solverOptions = OpEnTcpOptimizer.parseSolverOptions(inputArgs(2:end)); + return; + end + + if ~obj.hasManifest() + error('OpEnTcpOptimizer:ManifestRequired', ... + ['Named parameter solves require an OCP manifest. Load one with ' ... + 'loadManifest(...) or the constructor option ''ManifestPath''.']); + end + + [parameterVector, solverOptions] = obj.packOcpParameters(inputArgs); + solveMode = 'ocp'; + end + + function [parameterVector, solverOptions] = packOcpParameters(obj, inputArgs) + %PACKOCPPARAMETERS Pack named OCP parameters using the manifest. + + pairs = OpEnTcpOptimizer.normalizeNameValuePairs(inputArgs, 'OpEnTcpOptimizer.solve'); + solverOptions = OpEnTcpOptimizer.emptySolverOptions(); + providedValues = containers.Map('KeyType', 'char', 'ValueType', 'any'); + + for i = 1:size(pairs, 1) + name = pairs{i, 1}; + value = pairs{i, 2}; + lowerName = lower(name); + + switch lowerName + case 'initialguess' + solverOptions.InitialGuess = value; + case 'initiallagrangemultipliers' + solverOptions.InitialLagrangeMultipliers = value; + case 'initialpenalty' + solverOptions.InitialPenalty = value; + otherwise + if isKey(providedValues, name) + error('OpEnTcpOptimizer:DuplicateParameter', ... + 'Parameter "%s" was provided more than once.', name); + end + providedValues(name) = value; + end + end + + solverOptions = OpEnTcpOptimizer.validateSolverOptions(solverOptions); + + definitions = OpEnTcpOptimizer.manifestParametersAsCell(obj.manifest.parameters); + parameterVector = []; + missing = {}; + + for i = 1:numel(definitions) + definition = definitions{i}; + if isKey(providedValues, definition.name) + value = providedValues(definition.name); + remove(providedValues, definition.name); + else + value = definition.default; + end + + if isempty(value) + missing{end + 1} = definition.name; %#ok + continue; + end + + parameterVector = [parameterVector, ... %#ok + OpEnTcpOptimizer.normalizeParameterBlock( ... + value, definition.size, definition.name)]; + end + + if ~isempty(missing) + error('OpEnTcpOptimizer:MissingOcpParameters', ... + 'Missing values for parameters: %s.', strjoin(missing, ', ')); + end + + remainingNames = sort(keys(providedValues)); + if ~isempty(remainingNames) + error('OpEnTcpOptimizer:UnknownOcpParameters', ... + 'Unknown OCP parameter(s): %s.', strjoin(remainingNames, ', ')); + end + end + + function response = enrichOcpResponse(obj, response, packedParameters) + %ENRICHOCPRESPONSE Add OCP-oriented views to a successful solve. + + response.inputs = obj.extractOcpInputs(response.solution); + + if strcmp(obj.manifest.shooting, 'multiple') + response.states = obj.extractMultipleShootingStates( ... + response.solution, packedParameters); + end + end + + function inputs = extractOcpInputs(obj, flatSolution) + %EXTRACTOCPINPUTS Extract stage-wise input blocks from the solution. + + flatSolution = OpEnTcpOptimizer.toRowVector(flatSolution, 'solution'); + + if strcmp(obj.manifest.shooting, 'single') + nu = double(obj.manifest.nu); + horizon = double(obj.manifest.horizon); + inputs = cell(1, horizon); + + for stageIdx = 1:horizon + startIdx = (stageIdx - 1) * nu + 1; + stopIdx = stageIdx * nu; + inputs{stageIdx} = flatSolution(startIdx:stopIdx); + end + return; + end + + sliceMatrix = obj.manifest.input_slices; + inputs = cell(1, size(sliceMatrix, 1)); + for i = 1:size(sliceMatrix, 1) + startIdx = sliceMatrix(i, 1) + 1; + stopIdx = sliceMatrix(i, 2); + inputs{i} = flatSolution(startIdx:stopIdx); + end + end + + function states = extractMultipleShootingStates(obj, flatSolution, packedParameters) + %EXTRACTMULTIPLESHOOTINGSTATES Extract the state trajectory. + % + % For multiple shooting OCPs the manifest contains the state + % slices directly, so no extra CasADi dependency is needed in + % MATLAB to reconstruct the state trajectory. + + flatSolution = OpEnTcpOptimizer.toRowVector(flatSolution, 'solution'); + packedParameters = OpEnTcpOptimizer.toRowVector(packedParameters, 'packedParameters'); + + stateSlices = obj.manifest.state_slices; + states = cell(1, size(stateSlices, 1) + 1); + states{1} = obj.extractParameterByName(packedParameters, 'x0'); + + for i = 1:size(stateSlices, 1) + startIdx = stateSlices(i, 1) + 1; + stopIdx = stateSlices(i, 2); + states{i + 1} = flatSolution(startIdx:stopIdx); + end + end + + function value = extractParameterByName(obj, packedParameters, parameterName) + %EXTRACTPARAMETERBYNAME Extract one named parameter block. + + definitions = OpEnTcpOptimizer.manifestParametersAsCell(obj.manifest.parameters); + offset = 0; + + for i = 1:numel(definitions) + definition = definitions{i}; + nextOffset = offset + definition.size; + if strcmp(definition.name, parameterName) + value = packedParameters(offset + 1:nextOffset); + return; + end + offset = nextOffset; + end + + error('OpEnTcpOptimizer:MissingManifestParameter', ... + 'The manifest does not define a parameter named "%s".', parameterName); + end + + function rawResponse = runSolveRequest(obj, parameterVector, solverOptions) + %RUNSOLVEREQUEST Serialize and send a solver execution request. + + request = struct(); + request.Run = struct(); + request.Run.parameter = OpEnTcpOptimizer.toRowVector(parameterVector, 'parameter'); + + if ~isempty(solverOptions.InitialGuess) + request.Run.initial_guess = OpEnTcpOptimizer.toRowVector( ... + solverOptions.InitialGuess, 'InitialGuess'); + end + + if ~isempty(solverOptions.InitialLagrangeMultipliers) + request.Run.initial_lagrange_multipliers = OpEnTcpOptimizer.toRowVector( ... + solverOptions.InitialLagrangeMultipliers, 'InitialLagrangeMultipliers'); + end + + if ~isempty(solverOptions.InitialPenalty) + request.Run.initial_penalty = double(solverOptions.InitialPenalty); + end + + rawResponse = obj.sendRequest(jsonencode(request), true); + end + + function [port, ip] = resolveEndpoint(obj, endpointArgs) + %RESOLVEENDPOINT Resolve the TCP endpoint from inputs or manifest. + + if isempty(endpointArgs) + if ~obj.hasManifest() + error('OpEnTcpOptimizer:MissingEndpoint', ... + 'Provide a TCP endpoint or a manifest with a matching optimizer.yml file.'); + end + + tcpDefaults = OpEnTcpOptimizer.readTcpDefaultsFromManifest(obj.manifestPath); + if isempty(tcpDefaults) + error('OpEnTcpOptimizer:MissingEndpoint', ... + ['No TCP endpoint was provided and no TCP settings could be read from ' ... + 'optimizer.yml next to the manifest.']); + end + + port = tcpDefaults.port; + ip = tcpDefaults.ip; + return; + end + + if numel(endpointArgs) == 1 + if ~OpEnTcpOptimizer.isValidPort(endpointArgs{1}) + error('OpEnTcpOptimizer:InvalidEndpoint', ... + 'A single endpoint argument must be a TCP port.'); + end + port = endpointArgs{1}; + ip = '127.0.0.1'; + return; + end + + if numel(endpointArgs) == 2 + [port, ip] = OpEnTcpOptimizer.normalizeEndpointArguments( ... + endpointArgs{1}, endpointArgs{2}); + return; + end + + error('OpEnTcpOptimizer:InvalidEndpoint', ... + 'Specify the endpoint as (port), (port, ip), or (ip, port).'); + end + + function response = sendRequest(obj, requestText, expectReply) + %SENDREQUEST Send a JSON request and optionally decode a JSON reply. + % + % The generated Rust server reads until the client closes its + % write side. We therefore use Java sockets so we can call + % shutdownOutput() after transmitting the JSON payload. + + socket = []; + cleanup = []; + + try + socket = java.net.Socket(); + timeoutMs = max(1, round(1000 * obj.timeout)); + socket.connect(java.net.InetSocketAddress(obj.ip, obj.port), timeoutMs); + socket.setSoTimeout(timeoutMs); + cleanup = onCleanup(@() OpEnTcpOptimizer.closeSocketQuietly(socket)); + + outputStream = socket.getOutputStream(); + requestBytes = int8(unicode2native(char(requestText), 'UTF-8')); + outputStream.write(requestBytes); + outputStream.flush(); + socket.shutdownOutput(); + + if ~expectReply + return; + end + + inputStream = socket.getInputStream(); + responseBytes = obj.readFully(inputStream); + if isempty(responseBytes) + error('OpEnTcpOptimizer:EmptyResponse', ... + 'The optimizer server closed the connection without sending a response.'); + end + + responseText = native2unicode(responseBytes, 'UTF-8'); + response = jsondecode(responseText); + catch err + % Ensure the socket is closed before rethrowing transport errors. + clear cleanup; + OpEnTcpOptimizer.closeSocketQuietly(socket); + rethrow(err); + end + + clear cleanup; + end + + function bytes = readFully(obj, inputStream) + %READFULLY Read the complete server reply until EOF. + % + % The server sends a single JSON document per connection and + % closes the connection afterwards, so EOF marks the end of + % the response payload. + + byteStream = java.io.ByteArrayOutputStream(); + + while true + nextByte = inputStream.read(); + if nextByte == -1 + break; + end + + byteStream.write(nextByte); + if byteStream.size() > obj.maxResponseBytes + error('OpEnTcpOptimizer:ResponseTooLarge', ... + 'The optimizer response exceeded %d bytes.', obj.maxResponseBytes); + end + end + + rawBytes = uint8(mod(double(byteStream.toByteArray()), 256)); + bytes = reshape(rawBytes, 1, []); + end + end + + methods (Static, Access = private) + function [endpointArgs, options] = parseConstructorInputs(inputArgs) + %PARSECONSTRUCTORINPUTS Split constructor endpoint and options. + + endpointArgs = {}; + options = struct( ... + 'ManifestPath', '', ... + 'Timeout', 10, ... + 'MaxResponseBytes', 1048576); + + idx = 1; + while idx <= numel(inputArgs) + token = inputArgs{idx}; + if OpEnTcpOptimizer.isRecognizedConstructorOption(token) + if idx == numel(inputArgs) + error('OpEnTcpOptimizer:InvalidConstructorInput', ... + 'Missing value for option "%s".', OpEnTcpOptimizer.textToChar(token)); + end + + name = lower(OpEnTcpOptimizer.textToChar(token)); + value = inputArgs{idx + 1}; + switch name + case 'manifestpath' + if ~OpEnTcpOptimizer.isTextScalar(value) + error('OpEnTcpOptimizer:InvalidManifestPath', ... + 'ManifestPath must be a character vector or string scalar.'); + end + options.ManifestPath = OpEnTcpOptimizer.textToChar(value); + case 'timeout' + options.Timeout = value; + case 'maxresponsebytes' + options.MaxResponseBytes = value; + end + idx = idx + 2; + else + endpointArgs{end + 1} = token; %#ok + idx = idx + 1; + end + end + + if numel(endpointArgs) == 1 && OpEnTcpOptimizer.isManifestPathToken(endpointArgs{1}) + options.ManifestPath = OpEnTcpOptimizer.textToChar(endpointArgs{1}); + endpointArgs = {}; + end + + if ~(OpEnTcpOptimizer.isValidTimeout(options.Timeout)) + error('OpEnTcpOptimizer:InvalidTimeout', ... + 'Timeout must be a positive scalar.'); + end + + if ~(OpEnTcpOptimizer.isValidMaxResponseBytes(options.MaxResponseBytes)) + error('OpEnTcpOptimizer:InvalidMaxResponseBytes', ... + 'MaxResponseBytes must be a positive integer.'); + end + end + + function tf = isRecognizedConstructorOption(token) + %ISRECOGNIZEDCONSTRUCTOROPTION True for constructor option names. + tf = OpEnTcpOptimizer.isTextScalar(token) && any(strcmpi( ... + OpEnTcpOptimizer.textToChar(token), {'ManifestPath', 'Timeout', 'MaxResponseBytes'})); + end + + function tf = isManifestPathToken(token) + %ISMANIFESTPATHTOKEN Heuristic for a positional manifest path. + if ~OpEnTcpOptimizer.isTextScalar(token) + tf = false; + return; + end + + token = OpEnTcpOptimizer.textToChar(token); + [~, ~, ext] = fileparts(token); + tf = strcmpi(ext, '.json') && isfile(token); + end + + function manifestPath = validateManifestPath(manifestPath) + %VALIDATEMANIFESTPATH Validate and absolutize a manifest path. + if ~isfile(manifestPath) + error('OpEnTcpOptimizer:ManifestNotFound', ... + 'Manifest file not found: %s', manifestPath); + end + + [folder, name, ext] = fileparts(manifestPath); + if ~strcmpi(ext, '.json') + error('OpEnTcpOptimizer:InvalidManifestPath', ... + 'The manifest path must point to a JSON file.'); + end + + manifestPath = fullfile(folder, [name, ext]); + end + + function validateManifestParameters(parameters) + %VALIDATEMANIFESTPARAMETERS Validate manifest parameter entries. + definitions = OpEnTcpOptimizer.manifestParametersAsCell(parameters); + for i = 1:numel(definitions) + definition = definitions{i}; + if ~isfield(definition, 'name') || ~isfield(definition, 'size') + error('OpEnTcpOptimizer:InvalidManifest', ... + 'Each manifest parameter needs "name" and "size" fields.'); + end + if ~ischar(definition.name) && ~isstring(definition.name) + error('OpEnTcpOptimizer:InvalidManifest', ... + 'Manifest parameter names must be text values.'); + end + if ~OpEnTcpOptimizer.isValidPositiveInteger(definition.size) + error('OpEnTcpOptimizer:InvalidManifest', ... + 'Manifest parameter sizes must be positive integers.'); + end + end + end + + function defaults = readTcpDefaultsFromManifest(manifestPath) + %READTCPDEFAULTSFROMMANIFEST Read TCP defaults from optimizer.yml. + defaults = []; + optimizerDir = fileparts(manifestPath); + yamlPath = fullfile(optimizerDir, 'optimizer.yml'); + + if ~isfile(yamlPath) + return; + end + + defaults = OpEnTcpOptimizer.parseOptimizerYaml(yamlPath); + end + + function defaults = parseOptimizerYaml(yamlPath) + %PARSEOPTIMIZERYAML Read the tcp.ip and tcp.port fields. + defaults = []; + yamlText = fileread(yamlPath); + lines = regexp(yamlText, '\r\n|\n|\r', 'split'); + + inTcpBlock = false; + ip = ''; + port = []; + + for i = 1:numel(lines) + line = lines{i}; + trimmed = strtrim(line); + + if isempty(trimmed) + continue; + end + + if strcmp(trimmed, 'tcp:') + inTcpBlock = true; + continue; + end + + if inTcpBlock && ~isempty(line) && ~isspace(line(1)) + break; + end + + if inTcpBlock + if startsWith(trimmed, 'ip:') + ip = strtrim(extractAfter(trimmed, 3)); + elseif startsWith(trimmed, 'port:') + portText = strtrim(extractAfter(trimmed, 5)); + port = str2double(portText); + end + end + end + + if ~isempty(ip) && OpEnTcpOptimizer.isValidPort(port) + defaults = struct('ip', ip, 'port', port); + end + end + + function response = normalizeSolverResponse(rawResponse) + %NORMALIZESOLVERRESPONSE Add MATLAB-friendly fields to server data. + + response = rawResponse; + response.raw = rawResponse; + + if isfield(rawResponse, 'type') && strcmp(rawResponse.type, 'Error') + response.ok = false; + return; + end + + response.ok = true; + if isfield(rawResponse, 'delta_y_norm_over_c') + response.f1_infeasibility = rawResponse.delta_y_norm_over_c; + end + end + + function solverOptions = parseSolverOptions(inputArgs) + %PARSESOLVEROPTIONS Parse warm-start related name-value pairs. + pairs = OpEnTcpOptimizer.normalizeNameValuePairs(inputArgs, 'OpEnTcpOptimizer.solve'); + solverOptions = OpEnTcpOptimizer.emptySolverOptions(); + + for i = 1:size(pairs, 1) + name = lower(pairs{i, 1}); + value = pairs{i, 2}; + + switch name + case 'initialguess' + solverOptions.InitialGuess = value; + case 'initiallagrangemultipliers' + solverOptions.InitialLagrangeMultipliers = value; + case 'initialpenalty' + solverOptions.InitialPenalty = value; + otherwise + error('OpEnTcpOptimizer:UnknownSolveOption', ... + 'Unknown solve option "%s".', pairs{i, 1}); + end + end + + solverOptions = OpEnTcpOptimizer.validateSolverOptions(solverOptions); + end + + function solverOptions = validateSolverOptions(solverOptions) + %VALIDATESOLVEROPTIONS Validate optional warm-start inputs. + if ~OpEnTcpOptimizer.isOptionalVectorNumeric(solverOptions.InitialGuess) + error('OpEnTcpOptimizer:InvalidInitialGuess', ... + 'InitialGuess must be a numeric vector or [].'); + end + + if ~OpEnTcpOptimizer.isOptionalVectorNumeric(solverOptions.InitialLagrangeMultipliers) + error('OpEnTcpOptimizer:InvalidInitialLagrangeMultipliers', ... + 'InitialLagrangeMultipliers must be a numeric vector or [].'); + end + + if ~OpEnTcpOptimizer.isOptionalScalarNumeric(solverOptions.InitialPenalty) + error('OpEnTcpOptimizer:InvalidInitialPenalty', ... + 'InitialPenalty must be a numeric scalar or [].'); + end + end + + function solverOptions = emptySolverOptions() + %EMPTYSOLVEROPTIONS Return the default solve option bundle. + solverOptions = struct( ... + 'InitialGuess', [], ... + 'InitialLagrangeMultipliers', [], ... + 'InitialPenalty', []); + end + + function pairs = normalizeNameValuePairs(inputArgs, functionName) + %NORMALIZENAMEVALUEPAIRS Validate and normalize name-value pairs. + if isempty(inputArgs) + pairs = cell(0, 2); + return; + end + + if mod(numel(inputArgs), 2) ~= 0 + error('OpEnTcpOptimizer:InvalidNameValueInput', ... + '%s expects name-value arguments in pairs.', functionName); + end + + pairs = cell(numel(inputArgs) / 2, 2); + pairIdx = 1; + for i = 1:2:numel(inputArgs) + name = inputArgs{i}; + if ~OpEnTcpOptimizer.isTextScalar(name) + error('OpEnTcpOptimizer:InvalidNameValueInput', ... + 'Expected a text parameter name at argument position %d.', i); + end + + pairs{pairIdx, 1} = OpEnTcpOptimizer.textToChar(name); + pairs{pairIdx, 2} = inputArgs{i + 1}; + pairIdx = pairIdx + 1; + end + end + + function blocks = manifestParametersAsCell(parameters) + %MANIFESTPARAMETERSASCELL Normalize decoded parameter definitions. + if isempty(parameters) + blocks = {}; + return; + end + + if isstruct(parameters) + blocks = cell(1, numel(parameters)); + for i = 1:numel(parameters) + blocks{i} = parameters(i); + end + return; + end + + error('OpEnTcpOptimizer:InvalidManifest', ... + 'Manifest parameters must decode to a struct array.'); + end + + function vector = normalizeParameterBlock(value, expectedSize, parameterName) + %NORMALIZEPARAMETERBLOCK Normalize one OCP parameter block. + if expectedSize == 1 && isnumeric(value) && isscalar(value) && isfinite(value) + vector = double(value); + return; + end + + if ~OpEnTcpOptimizer.isVectorNumeric(value) + error('OpEnTcpOptimizer:InvalidOcpParameter', ... + 'Parameter "%s" must be a numeric vector.', parameterName); + end + + vector = reshape(double(value), 1, []); + if numel(vector) ~= double(expectedSize) + error('OpEnTcpOptimizer:InvalidOcpParameterDimension', ... + 'Parameter "%s" must have length %d.', parameterName, double(expectedSize)); + end + end + + function vector = toRowVector(value, argumentName) + %TOROWVECTOR Validate a numeric vector and serialize it as a row. + if ~OpEnTcpOptimizer.isVectorNumeric(value) + error('OpEnTcpOptimizer:InvalidVector', ... + '%s must be a numeric vector.', argumentName); + end + + vector = reshape(double(value), 1, []); + end + + function closeSocketQuietly(socket) + %CLOSESOCKETQUIETLY Best-effort socket close for cleanup paths. + if isempty(socket) + return; + end + + try + socket.close(); + catch + % Ignore close errors during cleanup. + end + end + + function [port, ip] = normalizeEndpointArguments(arg1, arg2) + %NORMALIZEENDPOINTARGUMENTS Support both (port, ip) and (ip, port). + if OpEnTcpOptimizer.isValidPort(arg1) && OpEnTcpOptimizer.isTextScalar(arg2) + port = arg1; + ip = arg2; + return; + end + + if OpEnTcpOptimizer.isTextScalar(arg1) && OpEnTcpOptimizer.isValidPort(arg2) + port = arg2; + ip = arg1; + return; + end + + error('OpEnTcpOptimizer:InvalidEndpoint', ... + ['Specify the endpoint as (port), (port, ip), or (ip, port), ' ... + 'where port is an integer in [1, 65535].']); + end + + function tf = isValidPort(value) + %ISVALIDPORT Validate a TCP port number. + tf = OpEnTcpOptimizer.isValidPositiveInteger(value) ... + && double(value) >= 1 && double(value) <= 65535; + end + + function tf = isValidTimeout(value) + %ISVALIDTIMEOUT Validate a positive timeout. + tf = isnumeric(value) && isscalar(value) && isfinite(value) && value > 0; + end + + function tf = isValidMaxResponseBytes(value) + %ISVALIDMAXRESPONSEBYTES Validate the maximum response size. + tf = OpEnTcpOptimizer.isValidPositiveInteger(value); + end + + function tf = isValidPositiveInteger(value) + %ISVALIDPOSITIVEINTEGER Validate a positive integer scalar. + tf = isnumeric(value) && isscalar(value) && isfinite(value) ... + && value == fix(value) && value > 0; + end + + function tf = isTextScalar(value) + %ISTEXTSCALAR True for character vectors and string scalars. + tf = ischar(value) || (isstring(value) && isscalar(value)); + end + + function value = textToChar(value) + %TEXTTOCHAR Convert a MATLAB text scalar to a character vector. + if isstring(value) + value = char(value); + end + end + + function tf = isVectorNumeric(value) + %ISVECTORNUMERIC True for finite numeric vectors. + tf = isnumeric(value) && isvector(value) && all(isfinite(value)); + end + + function tf = isOptionalVectorNumeric(value) + %ISOPTIONALVECTORNUMERIC True for [] or a numeric vector. + tf = isempty(value) || OpEnTcpOptimizer.isVectorNumeric(value); + end + + function tf = isOptionalScalarNumeric(value) + %ISOPTIONALSCALARNUMERIC True for [] or a finite numeric scalar. + tf = isempty(value) || (isnumeric(value) && isscalar(value) && isfinite(value)); + end + end +end diff --git a/matlab/api/createOpEnTcpOptimizer.m b/matlab/api/createOpEnTcpOptimizer.m new file mode 100644 index 00000000..fd1fb57a --- /dev/null +++ b/matlab/api/createOpEnTcpOptimizer.m @@ -0,0 +1,20 @@ +function client = createOpEnTcpOptimizer(varargin) +%CREATEOPENTCPOPTIMIZER Create a MATLAB TCP client for an OpEn optimizer. +% CLIENT = CREATEOPENTCPOPTIMIZER(PORT) connects to a TCP-enabled +% generated optimizer on 127.0.0.1:PORT. +% +% CLIENT = CREATEOPENTCPOPTIMIZER(PORT, IP) connects to the specified +% IP address and port. +% +% CLIENT = CREATEOPENTCPOPTIMIZER(IP, PORT) is also accepted. +% +% CLIENT = CREATEOPENTCPOPTIMIZER('ManifestPath', MANIFESTPATH) creates +% a manifest-aware OCP TCP client and tries to read the endpoint from the +% sibling ``optimizer.yml`` file. +% +% CLIENT = CREATEOPENTCPOPTIMIZER(..., Name, Value) forwards all +% remaining name-value pairs to the OPENTCPOPTIMIZER constructor. See +% "help OpEnTcpOptimizer" for the supported options and methods. + + client = OpEnTcpOptimizer(varargin{:}); +end diff --git a/matlab/@OpEnConstraints/OpEnConstraints.m b/matlab/legacy/@OpEnConstraints/OpEnConstraints.m similarity index 100% rename from matlab/@OpEnConstraints/OpEnConstraints.m rename to matlab/legacy/@OpEnConstraints/OpEnConstraints.m diff --git a/matlab/@OpEnOptimizer/OpEnOptimizer.m b/matlab/legacy/@OpEnOptimizer/OpEnOptimizer.m similarity index 100% rename from matlab/@OpEnOptimizer/OpEnOptimizer.m rename to matlab/legacy/@OpEnOptimizer/OpEnOptimizer.m diff --git a/matlab/@OpEnOptimizerBuilder/OpEnOptimizerBuilder.m b/matlab/legacy/@OpEnOptimizerBuilder/OpEnOptimizerBuilder.m similarity index 100% rename from matlab/@OpEnOptimizerBuilder/OpEnOptimizerBuilder.m rename to matlab/legacy/@OpEnOptimizerBuilder/OpEnOptimizerBuilder.m diff --git a/matlab/@OpEnOptimizerBuilder/build.m b/matlab/legacy/@OpEnOptimizerBuilder/build.m similarity index 100% rename from matlab/@OpEnOptimizerBuilder/build.m rename to matlab/legacy/@OpEnOptimizerBuilder/build.m diff --git a/matlab/legacy/README.md b/matlab/legacy/README.md new file mode 100644 index 00000000..3d49644a --- /dev/null +++ b/matlab/legacy/README.md @@ -0,0 +1,5 @@ +# MATLAB OpEn Interface + +This is the matlab interface of **Optimization Engine**. + +Read the [detailed documentation ](https://alphaville.github.io/optimization-engine/docs/matlab-interface). \ No newline at end of file diff --git a/matlab/examples/example_open_1.m b/matlab/legacy/examples/example_open_1.m similarity index 100% rename from matlab/examples/example_open_1.m rename to matlab/legacy/examples/example_open_1.m diff --git a/matlab/examples/example_open_lv.m b/matlab/legacy/examples/example_open_lv.m similarity index 100% rename from matlab/examples/example_open_lv.m rename to matlab/legacy/examples/example_open_lv.m diff --git a/matlab/examples/example_open_nav.m b/matlab/legacy/examples/example_open_nav.m similarity index 100% rename from matlab/examples/example_open_nav.m rename to matlab/legacy/examples/example_open_nav.m diff --git a/matlab/helpers/casadi_generate_c_code.m b/matlab/legacy/helpers/casadi_generate_c_code.m similarity index 100% rename from matlab/helpers/casadi_generate_c_code.m rename to matlab/legacy/helpers/casadi_generate_c_code.m diff --git a/matlab/helpers/rosenbrock.m b/matlab/legacy/helpers/rosenbrock.m similarity index 100% rename from matlab/helpers/rosenbrock.m rename to matlab/legacy/helpers/rosenbrock.m diff --git a/matlab/matlab_open_root.m b/matlab/legacy/matlab_open_root.m similarity index 100% rename from matlab/matlab_open_root.m rename to matlab/legacy/matlab_open_root.m diff --git a/matlab/private/codegen_get_cache.txt b/matlab/legacy/private/codegen_get_cache.txt similarity index 100% rename from matlab/private/codegen_get_cache.txt rename to matlab/legacy/private/codegen_get_cache.txt diff --git a/matlab/private/codegen_head.txt b/matlab/legacy/private/codegen_head.txt similarity index 100% rename from matlab/private/codegen_head.txt rename to matlab/legacy/private/codegen_head.txt diff --git a/matlab/private/codegen_main_2.txt b/matlab/legacy/private/codegen_main_2.txt similarity index 100% rename from matlab/private/codegen_main_2.txt rename to matlab/legacy/private/codegen_main_2.txt diff --git a/matlab/private/codegen_main_3.txt b/matlab/legacy/private/codegen_main_3.txt similarity index 100% rename from matlab/private/codegen_main_3.txt rename to matlab/legacy/private/codegen_main_3.txt diff --git a/matlab/private/codegen_main_fn_def.txt b/matlab/legacy/private/codegen_main_fn_def.txt similarity index 100% rename from matlab/private/codegen_main_fn_def.txt rename to matlab/legacy/private/codegen_main_fn_def.txt diff --git a/matlab/setup_open.m b/matlab/legacy/setup_open.m similarity index 100% rename from matlab/setup_open.m rename to matlab/legacy/setup_open.m