From e9d881df9f5e2dda404b9aa6a7000913d4452b26 Mon Sep 17 00:00:00 2001 From: xts-Michi Date: Wed, 27 Aug 2025 11:05:49 +0200 Subject: [PATCH 1/6] feat(solver): add ojAlgo backend (ExpressionsBasedModel, bounds, linear EQ/LEQ/GEQ, objective weight flip) --- .../org/optsolvx/solver/OjAlgoSolver.java | 109 ++++++++++++++++++ .../optsolvx/tests/lp/OjAlgoSolverTest.java | 29 +++++ 2 files changed, 138 insertions(+) create mode 100644 src/main/java/org/optsolvx/solver/OjAlgoSolver.java create mode 100644 src/test/java/org/optsolvx/tests/lp/OjAlgoSolverTest.java diff --git a/src/main/java/org/optsolvx/solver/OjAlgoSolver.java b/src/main/java/org/optsolvx/solver/OjAlgoSolver.java new file mode 100644 index 0000000..d1697f7 --- /dev/null +++ b/src/main/java/org/optsolvx/solver/OjAlgoSolver.java @@ -0,0 +1,109 @@ +package org.optsolvx.solver; + +import org.optsolvx.model.AbstractLPModel; +import org.optsolvx.model.Constraint; +import org.optsolvx.model.OptimizationDirection; + +import org.ojalgo.optimisation.ExpressionsBasedModel; +import org.ojalgo.optimisation.Expression; +import org.ojalgo.optimisation.Optimisation; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * ojAlgo backend for OptSolvX. + * + * Builds an ExpressionsBasedModel with variables/bounds/linear constraints. + * Equality -> level(rhs), <= -> upper(rhs), >= -> lower(rhs). + * We always call minimise(); for MAX we flip objective weight to -1. + * Objective value is recomputed from the returned variable values. + */ +public final class OjAlgoSolver implements LPSolverAdapter { + + @Override + public LPSolution solve(AbstractLPModel model) { + if (model == null) throw new IllegalArgumentException("Model must not be null."); + if (!model.isBuilt()) model.build(); + + final List vars = model.getVariables(); + final int n = vars.size(); + + // ----- ojAlgo model ----- + final ExpressionsBasedModel ebm = new ExpressionsBasedModel(); + final Map oj = new LinkedHashMap<>(n); + + // Variables + bounds + for (org.optsolvx.model.Variable v : vars) { + final org.ojalgo.optimisation.Variable ov = ebm.addVariable(v.getName()); + final double lb = v.getLowerBound(); + final double ub = v.getUpperBound(); + if (!Double.isInfinite(lb)) ov.lower(lb); + if (!Double.isInfinite(ub)) ov.upper(ub); + oj.put(v.getName(), ov); + } + + // Linear constraints + for (Constraint c : model.getConstraints()) { + final Expression ex = ebm.addExpression(c.getName()); + for (Map.Entry term : c.getCoefficients().entrySet()) { + final org.ojalgo.optimisation.Variable ov = oj.get(term.getKey()); + if (ov != null) ex.set(ov, term.getValue()); + } + switch (c.getRelation()) { + case LEQ: ex.upper(c.getRhs()); break; + case GEQ: ex.lower(c.getRhs()); break; + case EQ: ex.level(c.getRhs()); break; + default: throw new IllegalArgumentException("Unknown relation: " + c.getRelation()); + } + } + + // Objective (weight flip for MAX) + final Expression obj = ebm.addExpression("objective"); + for (Map.Entry e : model.getObjectiveCoefficients().entrySet()) { + final org.ojalgo.optimisation.Variable ov = oj.get(e.getKey()); + if (ov != null) obj.set(ov, e.getValue()); + } + final boolean maximise = model.getDirection() == OptimizationDirection.MAXIMIZE; + obj.weight(maximise ? -1.0 : +1.0); + + // ----- Solve ----- + boolean feasible; + Optimisation.Result result; + try { + result = ebm.minimise(); // weight handles MAX + feasible = result != null && result.getState() != null && result.getState().isFeasible(); + } catch (Throwable t) { + feasible = false; + result = null; + } + + // Values in declared order + final Map values = new LinkedHashMap<>(n); + for (org.optsolvx.model.Variable v : vars) { + final org.ojalgo.optimisation.Variable ov = oj.get(v.getName()); + double val = 0.0; + if (ov != null) { + try { + final Number num = ov.getValue(); + if (num != null) val = num.doubleValue(); + } catch (Throwable ignored) {} + } + values.put(v.getName(), val); + } + + // Recompute objective (backend-independent) + double objectiveValue = Double.NaN; + if (feasible) { + double sum = 0.0; + for (Map.Entry e : model.getObjectiveCoefficients().entrySet()) { + final Double x = values.get(e.getKey()); + if (x != null) sum += e.getValue() * x; + } + objectiveValue = sum; + } + + return new LPSolution(values, objectiveValue, feasible); + } +} diff --git a/src/test/java/org/optsolvx/tests/lp/OjAlgoSolverTest.java b/src/test/java/org/optsolvx/tests/lp/OjAlgoSolverTest.java new file mode 100644 index 0000000..f39faee --- /dev/null +++ b/src/test/java/org/optsolvx/tests/lp/OjAlgoSolverTest.java @@ -0,0 +1,29 @@ +package org.optsolvx.tests.lp; + +import org.junit.jupiter.api.Test; +import org.optsolvx.model.AbstractLPModel; +import org.optsolvx.model.OptimizationDirection; +import org.optsolvx.solver.*; + +import static org.junit.jupiter.api.Assertions.*; + +public class OjAlgoSolverTest { + + @Test + public void solvesSimpleLP() { + AbstractLPModel m = new AbstractLPModel() + .direction(OptimizationDirection.MAXIMIZE) + .var("x", 0, 10).obj("x", 1.0) + .var("y", 0, 10).obj("y", 2.0) + .leq("c1", 1.0, "x", 1.0, "y", 8.0) // x + y <= 8 + .build(); + + LPSolverAdapter solver = new OjAlgoSolver(); + LPSolution sol = solver.solve(m); + + assertTrue(sol.isFeasible()); + assertEquals(16.0, sol.getObjectiveValue(), 1e-9); // y=8, x=0 + assertEquals(0.0, sol.getVariableValues().get("x"), 1e-9); + assertEquals(8.0, sol.getVariableValues().get("y"), 1e-9); + } +} \ No newline at end of file From 9283f5567f7d7cf14b35c609b0794a397ed65924 Mon Sep 17 00:00:00 2001 From: xts-Michi Date: Fri, 29 Aug 2025 11:51:18 +0200 Subject: [PATCH 2/6] feat(solver): add OjAlgoSolver backend with JUnit tests and pom dependency --- .../org/optsolvx/model/AbstractLPModel.java | 6 ++++ .../org/optsolvx/solver/OjAlgoSolver.java | 23 +++++++++----- .../tests/lp/CommonsMathSolverTest.java | 2 +- .../optsolvx/tests/lp/OjAlgoSolverTest.java | 30 ++++--------------- 4 files changed, 28 insertions(+), 33 deletions(-) diff --git a/src/main/java/org/optsolvx/model/AbstractLPModel.java b/src/main/java/org/optsolvx/model/AbstractLPModel.java index fe99fbe..c8b826f 100644 --- a/src/main/java/org/optsolvx/model/AbstractLPModel.java +++ b/src/main/java/org/optsolvx/model/AbstractLPModel.java @@ -209,6 +209,12 @@ public void setDirection(OptimizationDirection direction) { this.direction = direction; } + /** Fluent builder: set optimization direction and return this. */ + public AbstractLPModel direction(OptimizationDirection dir) { + setDirection(dir); + return this; + } + /** * @return true if build() has been called and the model is finalized */ diff --git a/src/main/java/org/optsolvx/solver/OjAlgoSolver.java b/src/main/java/org/optsolvx/solver/OjAlgoSolver.java index d1697f7..c1395d3 100644 --- a/src/main/java/org/optsolvx/solver/OjAlgoSolver.java +++ b/src/main/java/org/optsolvx/solver/OjAlgoSolver.java @@ -14,7 +14,6 @@ /** * ojAlgo backend for OptSolvX. - * * Builds an ExpressionsBasedModel with variables/bounds/linear constraints. * Equality -> level(rhs), <= -> upper(rhs), >= -> lower(rhs). * We always call minimise(); for MAX we flip objective weight to -1. @@ -52,10 +51,17 @@ public LPSolution solve(AbstractLPModel model) { if (ov != null) ex.set(ov, term.getValue()); } switch (c.getRelation()) { - case LEQ: ex.upper(c.getRhs()); break; - case GEQ: ex.lower(c.getRhs()); break; - case EQ: ex.level(c.getRhs()); break; - default: throw new IllegalArgumentException("Unknown relation: " + c.getRelation()); + case LEQ: + ex.upper(c.getRhs()); + break; + case GEQ: + ex.lower(c.getRhs()); + break; + case EQ: + ex.level(c.getRhs()); + break; + default: + throw new IllegalArgumentException("Unknown relation: " + c.getRelation()); } } @@ -73,7 +79,7 @@ public LPSolution solve(AbstractLPModel model) { Optimisation.Result result; try { result = ebm.minimise(); // weight handles MAX - feasible = result != null && result.getState() != null && result.getState().isFeasible(); + feasible = result.getState() != null && result.getState().isFeasible(); // 'result != null' entfernen } catch (Throwable t) { feasible = false; result = null; @@ -88,7 +94,8 @@ public LPSolution solve(AbstractLPModel model) { try { final Number num = ov.getValue(); if (num != null) val = num.doubleValue(); - } catch (Throwable ignored) {} + } catch (Throwable ignored) { + } } values.put(v.getName(), val); } @@ -106,4 +113,4 @@ public LPSolution solve(AbstractLPModel model) { return new LPSolution(values, objectiveValue, feasible); } -} +} \ No newline at end of file diff --git a/src/test/java/org/optsolvx/tests/lp/CommonsMathSolverTest.java b/src/test/java/org/optsolvx/tests/lp/CommonsMathSolverTest.java index 49d1eb8..eac33e5 100644 --- a/src/test/java/org/optsolvx/tests/lp/CommonsMathSolverTest.java +++ b/src/test/java/org/optsolvx/tests/lp/CommonsMathSolverTest.java @@ -3,7 +3,7 @@ import org.optsolvx.solver.LPSolverAdapter; import org.optsolvx.solver.CommonsMathSolver; -public class CommonsMathSolverTest extends BaseLPSolverTest{ +public class CommonsMathSolverTest extends BaseLPSolverTest { @Override protected LPSolverAdapter getSolver() { return new CommonsMathSolver(); diff --git a/src/test/java/org/optsolvx/tests/lp/OjAlgoSolverTest.java b/src/test/java/org/optsolvx/tests/lp/OjAlgoSolverTest.java index f39faee..4b9747b 100644 --- a/src/test/java/org/optsolvx/tests/lp/OjAlgoSolverTest.java +++ b/src/test/java/org/optsolvx/tests/lp/OjAlgoSolverTest.java @@ -1,29 +1,11 @@ package org.optsolvx.tests.lp; -import org.junit.jupiter.api.Test; -import org.optsolvx.model.AbstractLPModel; -import org.optsolvx.model.OptimizationDirection; -import org.optsolvx.solver.*; +import org.optsolvx.solver.LPSolverAdapter; +import org.optsolvx.solver.OjAlgoSolver; -import static org.junit.jupiter.api.Assertions.*; - -public class OjAlgoSolverTest { - - @Test - public void solvesSimpleLP() { - AbstractLPModel m = new AbstractLPModel() - .direction(OptimizationDirection.MAXIMIZE) - .var("x", 0, 10).obj("x", 1.0) - .var("y", 0, 10).obj("y", 2.0) - .leq("c1", 1.0, "x", 1.0, "y", 8.0) // x + y <= 8 - .build(); - - LPSolverAdapter solver = new OjAlgoSolver(); - LPSolution sol = solver.solve(m); - - assertTrue(sol.isFeasible()); - assertEquals(16.0, sol.getObjectiveValue(), 1e-9); // y=8, x=0 - assertEquals(0.0, sol.getVariableValues().get("x"), 1e-9); - assertEquals(8.0, sol.getVariableValues().get("y"), 1e-9); +public class OjAlgoSolverTest extends BaseLPSolverTest { + @Override + protected LPSolverAdapter getSolver() { + return new OjAlgoSolver(); } } \ No newline at end of file From aee8c630225c4d271224f17db51358dc5727ab45 Mon Sep 17 00:00:00 2001 From: xts-Michi Date: Fri, 29 Aug 2025 12:56:41 +0200 Subject: [PATCH 3/6] feat(solver): add OptSolvXConfig + SolverRegistry for pluggable backend selection; per-model override in AbstractLPModel; minor formatting --- .../org/optsolvx/model/AbstractLPModel.java | 97 ++++++++++------- .../org/optsolvx/solver/OptSolvXConfig.java | 100 ++++++++++++++++++ .../org/optsolvx/solver/SolverRegistry.java | 65 ++++++++++++ 3 files changed, 221 insertions(+), 41 deletions(-) create mode 100644 src/main/java/org/optsolvx/solver/OptSolvXConfig.java create mode 100644 src/main/java/org/optsolvx/solver/SolverRegistry.java diff --git a/src/main/java/org/optsolvx/model/AbstractLPModel.java b/src/main/java/org/optsolvx/model/AbstractLPModel.java index c8b826f..aabd334 100644 --- a/src/main/java/org/optsolvx/model/AbstractLPModel.java +++ b/src/main/java/org/optsolvx/model/AbstractLPModel.java @@ -1,8 +1,10 @@ package org.optsolvx.model; import static java.text.MessageFormat.format; + import java.util.*; import java.util.logging.Logger; + import org.optsolvx.model.OptimizationDirection; /** @@ -37,31 +39,29 @@ public class AbstractLPModel { // The optimization direction of the model (MAXIMIZE or MINIMIZE). Default ist Maximize. private OptimizationDirection direction = OptimizationDirection.MAXIMIZE; + // Optional per-model solver preference (e.g., "ojalgo", "commons-math", ...). + private String preferredSolver; + // True after build() is called; no further changes allowed private boolean built = false; private void beforeModelChange() { if (built) { built = false; - if (debug) LOGGER.warning( - format( - "{0}: Model was changed after build(); 'built' status reset. " + - "Please call build() again before solving.", - getClass().getSimpleName() - ) - ); + if (debug) + LOGGER.warning(format("{0}: Model was changed after build(); 'built' status reset. " + "Please call build() again before solving.", getClass().getSimpleName())); } } /** * Adds a new variable to the model. * - * @param name unique name of the variable + * @param name unique name of the variable * @param lower lower bound (inclusive) * @param upper upper bound (inclusive) * @return index of the variable in the variables list * @throws IllegalArgumentException if the name already exists - * @throws IllegalStateException if the model is already built + * @throws IllegalStateException if the model is already built */ public int addVariable(String name, double lower, double upper) { beforeModelChange(); @@ -69,10 +69,8 @@ public int addVariable(String name, double lower, double upper) { if (debug) LOGGER.warning("Duplicate variable name: " + name); throw new IllegalArgumentException("Variable name already exists: " + name); } - if (debug) LOGGER.info(format( - "{0}: Added variable: {1} [{2,number,0.####}, {3,number,0.####}]", - getClass().getSimpleName(), name, lower, upper - )); + if (debug) + LOGGER.info(format("{0}: Added variable: {1} [{2,number,0.####}, {3,number,0.####}]", getClass().getSimpleName(), name, lower, upper)); Variable var = new Variable(name, lower, upper); int idx = variables.size(); variables.add(var); @@ -83,13 +81,13 @@ public int addVariable(String name, double lower, double upper) { /** * Adds a new linear constraint to the model. * - * @param name unique name of the constraint + * @param name unique name of the constraint * @param coeffs map of variable name to coefficient in the constraint - * @param rel type of constraint (LEQ, GEQ, EQ) - * @param rhs right-hand side value of the constraint + * @param rel type of constraint (LEQ, GEQ, EQ) + * @param rhs right-hand side value of the constraint * @return the new Constraint object * @throws IllegalArgumentException if the name already exists - * @throws IllegalStateException if the model is already built + * @throws IllegalStateException if the model is already built */ public Constraint addConstraint(String name, Map coeffs, Constraint.Relation rel, double rhs) { beforeModelChange(); @@ -97,10 +95,8 @@ public Constraint addConstraint(String name, Map coeffs, Constra if (debug) LOGGER.warning("Duplicate constraint name: " + name); throw new IllegalArgumentException("Constraint name already exists: " + name); } - if (debug) LOGGER.info(format( - "{0}: Added constraint {1} ({2}) rhs={3, number,0.####}, vars={4}", - getClass().getSimpleName(), name, rel, rhs, coeffs.keySet() - )); + if (debug) + LOGGER.info(format("{0}: Added constraint {1} ({2}) rhs={3, number,0.####}, vars={4}", getClass().getSimpleName(), name, rel, rhs, coeffs.keySet())); Constraint c = new Constraint(name, coeffs, rel, rhs); int idx = constraints.size(); constraints.add(c); @@ -111,7 +107,7 @@ public Constraint addConstraint(String name, Map coeffs, Constra /** * Sets the objective function for the model. * - * @param coeffs map of variable name to objective coefficient + * @param coeffs map of variable name to objective coefficient * @param direction the optimization direction (MAXIMIZE or MINIMIZE) * @throws IllegalStateException if the model is already built */ @@ -122,44 +118,59 @@ public void setObjective(Map coeffs, OptimizationDirection direc this.direction = direction; } + /** + * Returns the optional per-model solver preference, or null if not set. + */ + public String getPreferredSolver() { + return preferredSolver; + } + + /** + * Sets the optional per-model solver preference (normalized, nullable). + */ + public void setPreferredSolver(String name) { + this.preferredSolver = (name == null ? null : name.trim()); + } + /** * Finalizes the model, assigns indices to variables and constraints. * After calling build(), no further variables or constraints can be added * until the model is changed again. If the model is changed after build(), * the 'built' flag will be reset and build() must be called again before solving. - *

* Logs a summary when the model is finalized. - *

*/ public void build() { if (built) return; - if (debug) LOGGER.info(format( - "{0}: Building model with {1} variables and {2} constraints.", - getClass().getSimpleName(), variables.size(), constraints.size() - )); + if (debug) + LOGGER.info(format("{0}: Building model with {1} variables and {2} constraints.", getClass().getSimpleName(), variables.size(), constraints.size())); built = true; - if (debug) LOGGER.info(format( - "{0}: Model finalized. No further modifications allowed.", - getClass().getSimpleName() - )); + if (debug) + LOGGER.info(format("{0}: Model finalized. No further modifications allowed.", getClass().getSimpleName())); } /** * Returns the internal list of variables. * Modifications to this list affect the model directly. + * * @return the variables list */ - public List getVariables() { return variables; } + public List getVariables() { + return variables; + } /** * Returns the internal list of constraints. * Modifications to this list affect the model directly. + * * @return the constraint list */ - public List getConstraints() { return constraints; } + public List getConstraints() { + return constraints; + } /** * Returns the variable object with the specified name. + * * @param name the variable name * @return the Variable object * @throws IllegalArgumentException if not found @@ -167,12 +178,13 @@ public void build() { public Variable getVariable(String name) { Integer idx = variableIndices.get(name); if (idx == null) throw new IllegalArgumentException("No such variable: " + name); - return variables.get(idx); + return variables.get(idx); } /** * Returns the constraint object with the specified name. + * * @param name the constraint name * @return the Constraint object * @throws IllegalArgumentException if not found @@ -183,8 +195,6 @@ public Constraint getConstraint(String name) { return constraints.get(idx); } - - /** * Returns an unmodifiable map of the objective function coefficients. */ @@ -194,6 +204,7 @@ public Map getObjectiveCoefficients() { /** * Returns the current optimization direction (MAXIMIZE or MINIMIZE). + * * @return the optimization direction */ public OptimizationDirection getDirection() { @@ -202,6 +213,7 @@ public OptimizationDirection getDirection() { /** * Sets the optimization direction for this model. + * * @param direction the optimization direction (MAXIMIZE or MINIMIZE) */ public void setDirection(OptimizationDirection direction) { @@ -209,7 +221,9 @@ public void setDirection(OptimizationDirection direction) { this.direction = direction; } - /** Fluent builder: set optimization direction and return this. */ + /** + * Fluent builder: set optimization direction and return this. + */ public AbstractLPModel direction(OptimizationDirection dir) { setDirection(dir); return this; @@ -224,6 +238,7 @@ public boolean isBuilt() { /** * Returns the index of a variable by its name. + * * @param name the variable name * @return index of the variable in the model * @throws IllegalArgumentException if not found @@ -236,6 +251,7 @@ public int getVariableIndex(String name) { /** * Returns the index of a constraint by its name. + * * @param name the constraint name * @return index of the constraint in the model * @throws IllegalArgumentException if not found @@ -258,8 +274,7 @@ public String toString() { for (Variable v : variables) sb.append(" ").append(v).append("\n"); sb.append("Constraints:\n"); for (Constraint c : constraints) sb.append(" ").append(c).append("\n"); - sb.append("Objective: ").append(objectiveCoefficients) - .append(" direction=").append(direction).append("\n"); + sb.append("Objective: ").append(objectiveCoefficients).append(" direction=").append(direction).append("\n"); return sb.toString(); } @@ -268,7 +283,7 @@ public String toString() { * Logging is OFF by default. * Call setDebug(true) before model building to activate. * Example: model.setDebug(true); // Logging on - * model.setDebug(false); // Logging off + * model.setDebug(false); // Logging off * */ public void setDebug(boolean debug) { diff --git a/src/main/java/org/optsolvx/solver/OptSolvXConfig.java b/src/main/java/org/optsolvx/solver/OptSolvXConfig.java new file mode 100644 index 0000000..c3d8043 --- /dev/null +++ b/src/main/java/org/optsolvx/solver/OptSolvXConfig.java @@ -0,0 +1,100 @@ +package org.optsolvx.solver; + +import org.optsolvx.model.AbstractLPModel; + +import java.io.*; +import java.nio.file.*; +import java.util.Locale; +import java.util.Properties; + +/** + * Global and per-process configuration for selecting an LP solver by name. + * Resolution priority: + * 1) explicit override (API argument) + * 2) per-model preference (AbstractLPModel#getPreferredSolver) + * 3) Java system property (-Doptsolvx.solver=ojalgo) + * 4) Environment variable (OPTSOLVX_SOLVER=ojalgo) + * 5) User config file ($HOME/.optsolvx/config.properties, key=solver) + * 6) fallback ("commons-math") + */ +public final class OptSolvXConfig { + + /** + * Java system property key for solver selection (e.g., -Doptsolvx.solver=ojalgo). + */ + public static final String PROP = "optsolvx.solver"; + + /** + * Environment variable for solver selection (e.g., export OPTSOLVX_SOLVER=ojalgo). + */ + public static final String ENV = "OPTSOLVX_SOLVER"; + + /** + * User-level config file (home-relative): contains 'solver='. + */ + public static final String FILE = ".optsolvx/config.properties"; + + /** + * Cached global choice; initialized lazily by {@link #getGlobalSolver()}. + */ + private static volatile String globalSolver = null; + + /** + * Sets the process-wide solver choice (e.g., from a settings UI). + */ + public static void setGlobalSolver(String name) { + globalSolver = name; + } + + /** + * Returns the current process-wide solver name. The first call lazily resolves + * from system property, environment, user config file, then falls back. + */ + public static String getGlobalSolver() { + if (globalSolver != null) return globalSolver; + + // 1) Java system property + String v = System.getProperty(PROP); + if (v != null && !v.isEmpty()) return globalSolver = v; + + // 2) Environment variable + v = System.getenv(ENV); + if (v != null && !v.isEmpty()) return globalSolver = v; + + // 3) User config file: $HOME/.optsolvx/config.properties + try { + Path p = Paths.get(System.getProperty("user.home"), FILE); + if (Files.isRegularFile(p)) { + Properties props = new Properties(); + try (InputStream in = Files.newInputStream(p)) { + props.load(in); + } + v = props.getProperty("solver"); + if (v != null && !v.isEmpty()) return globalSolver = v; + } + } catch (Exception ignored) { + } + + // 4) Fallback + return globalSolver = "commons-math"; + } + + /** + * Resolves an {@link LPSolverAdapter} according to the documented priority: + * explicitOverride > model preference > global setting > fallback. + */ + public static LPSolverAdapter resolve(AbstractLPModel model, String explicitOverride) { + if (explicitOverride != null && !explicitOverride.isEmpty()) { + return SolverRegistry.create(explicitOverride); + } + if (model != null && model.getPreferredSolver() != null && !model.getPreferredSolver().isEmpty()) { + return SolverRegistry.create(model.getPreferredSolver()); + } + String global = getGlobalSolver(); + if (SolverRegistry.has(global)) return SolverRegistry.create(global); + return SolverRegistry.create("commons-math"); + } + + private OptSolvXConfig() { + } +} diff --git a/src/main/java/org/optsolvx/solver/SolverRegistry.java b/src/main/java/org/optsolvx/solver/SolverRegistry.java new file mode 100644 index 0000000..91fe5ef --- /dev/null +++ b/src/main/java/org/optsolvx/solver/SolverRegistry.java @@ -0,0 +1,65 @@ +package org.optsolvx.solver; + +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Supplier; + +public final class SolverRegistry { + private static final Map> SUPPLIERS = new ConcurrentHashMap<>(); + + static { + // Built-ins + register("commons-math", CommonsMathSolver::new); + register("ojalgo", OjAlgoSolver::new); + + // Example aliases + registerAlias("commonsmath", "commons-math"); + registerAlias("cm", "commons-math"); + registerAlias("oj", "ojalgo"); + } + + private static String norm(String s) { + return s == null ? null : s.trim().toLowerCase(Locale.ROOT); + } + + public static void register(String name, Supplier supplier) { + Objects.requireNonNull(name, "name"); + Objects.requireNonNull(supplier, "supplier"); + SUPPLIERS.put(norm(name), supplier); + } + + /** + * Optional helper to map an alias to an existing canonical name. + */ + public static void registerAlias(String alias, String canonical) { + Objects.requireNonNull(alias, "alias"); + Objects.requireNonNull(canonical, "canonical"); + Supplier s = SUPPLIERS.get(norm(canonical)); + if (s == null) throw new IllegalArgumentException("Unknown canonical solver: " + canonical); + SUPPLIERS.put(norm(alias), s); + } + + public static boolean has(String name) { + String n = norm(name); + return n != null && SUPPLIERS.containsKey(n); + } + + public static LPSolverAdapter create(String name) { + String n = norm(name); + if (n == null || n.isEmpty()) { + throw new IllegalArgumentException("Solver name must not be null/empty. Known: " + SUPPLIERS.keySet()); + } + Supplier s = SUPPLIERS.get(n); + if (s == null) { + throw new IllegalArgumentException("Unknown solver: " + name + " (known: " + SUPPLIERS.keySet() + ")"); + } + return s.get(); + } + + public static Set names() { + return Collections.unmodifiableSet(SUPPLIERS.keySet()); + } + + private SolverRegistry() { + } +} From 1520b13831b37b50364440f8fd79e92132c6d1a3 Mon Sep 17 00:00:00 2001 From: xts-Michi Date: Fri, 29 Aug 2025 13:01:35 +0200 Subject: [PATCH 4/6] style: reformat codebase for consistency (no functional changes) --- README.md | 14 +++++++++----- .../org/optsolvx/model/AbstractLPModel.java | 1 - .../java/org/optsolvx/model/Constraint.java | 19 +++++++++++++------ .../java/org/optsolvx/model/Variable.java | 17 +++++++++++++---- .../optsolvx/solver/CommonsMathSolver.java | 3 +-- .../java/org/optsolvx/solver/LPSolution.java | 14 +++++++++++--- .../java/org/optsolvx/solver/SolverDemo.java | 1 + 7 files changed, 48 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 6c636a2..6036cdd 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,8 @@ Place for licenses, java links and passings. Inspired and copied from SBSCL and ![Code Size](https://img.shields.io/github/languages/code-size/draeger-lab/OptSolvX.svg?style=plastic) ![Downloads of all releases](https://img.shields.io/github/downloads/draeger-lab/OptSolvX/total.svg?style=plastic) -OptSolvX is a flexible Java library for solving linear programming (LP) problems with multiple interchangeable solver backends. +OptSolvX is a flexible Java library for solving linear programming (LP) problems with multiple interchangeable solver +backends. It provides a clean, test-driven API for building, comparing and extending LP solvers. OptSolvX is intended for applications in mathematics, research, and systems biology. @@ -29,7 +30,6 @@ OptSolvX is intended for applications in mathematics, research, and systems biol - Clean logging & validation (build checks, bounds, relations) - Easy to extend with custom backends; demo included - ► Status ---------------------------- @@ -37,11 +37,10 @@ OptSolvX is intended for applications in mathematics, research, and systems biol - Backends: Commons Math adapter ready; ojAlgo adapter planned - Builds: Java 22 by default; optional Java 8 bytecode via compat8 profile (classifier jdk8) - ► Installation ---------------------------- -Requirements: Maven ≥ 3.9, Java 22 (default). +Requirements: Maven ≥ 3.9, Java 22 (default). Optional: build an additional Java 8 bytecode artifact via profile compat8. @@ -51,20 +50,22 @@ cd OptSolvX ``` Default (Java 22) - installs to local Maven repo + ``` mvn clean install ``` Optional: Java 8 bytecode JAR (classifier jdk8) + ``` mvn -P compat8 -DskipTests clean package ``` Artifacts + - target/optsolvx-.jar - Java 22 (default) - target/optsolvx--jdk8.jar - Java 8 bytecode (compatibility) - ► Testing ---------------------------- @@ -97,6 +98,7 @@ Run the built-in demo (max x + y with two constraints) using the Commons Math ba **From IDE:** run `org.optsolvx.solver.SolverDemo`. **From Maven (CLI):** + ```bash mvn -q exec:java # If needed: @@ -104,6 +106,7 @@ mvn -q exec:java ``` Expected Output: + ```bash Variable values: {x=3.0, y=0.5} Objective: 3.5 @@ -111,6 +114,7 @@ Feasible: true ``` ***Optional debug:*** enable verbose model logging in the demo: + ```java model.setDebug(true); // call before model.build() ``` diff --git a/src/main/java/org/optsolvx/model/AbstractLPModel.java b/src/main/java/org/optsolvx/model/AbstractLPModel.java index aabd334..d5585ac 100644 --- a/src/main/java/org/optsolvx/model/AbstractLPModel.java +++ b/src/main/java/org/optsolvx/model/AbstractLPModel.java @@ -179,7 +179,6 @@ public Variable getVariable(String name) { Integer idx = variableIndices.get(name); if (idx == null) throw new IllegalArgumentException("No such variable: " + name); return variables.get(idx); - } /** diff --git a/src/main/java/org/optsolvx/model/Constraint.java b/src/main/java/org/optsolvx/model/Constraint.java index f202e93..0f2155e 100644 --- a/src/main/java/org/optsolvx/model/Constraint.java +++ b/src/main/java/org/optsolvx/model/Constraint.java @@ -1,30 +1,35 @@ package org.optsolvx.model; import org.apache.commons.math3.optim.linear.Relationship; + import java.util.Collections; import java.util.Map; /** * Represents a single linear constraint in the LP model. - *

* Stores a map from variable names to their coefficients, * the constraint relationship (≤, ≥ or =) and the right-hand side value. * The index of a constraint is managed by the parent LP model class (AbstractLPModel), * not by this object. - *

*/ public class Constraint { - /** Unique, user-defined name of this constraint. */ + /** + * Unique, user-defined name of this constraint. + */ private final String name; - public enum Relation { LEQ, GEQ, EQ } + public enum Relation {LEQ, GEQ, EQ} - /** Immutable map of variable names to their coefficients. */ + /** + * Immutable map of variable names to their coefficients. + */ private final Map coefficients; private final Relation relation; - /** Right-hand side value of the constraint. */ + /** + * Right-hand side value of the constraint. + */ private final double rhs; @@ -75,6 +80,7 @@ public double getRhs() { /** * Returns the internal relation type (LEQ, GEQ, EQ). + * * @return constraint relation enum */ public Relation getRelation() { @@ -83,6 +89,7 @@ public Relation getRelation() { /** * Returns the right-hand side value (alternative getter). + * * @return right-hand side value */ public double getRightHandSide() { diff --git a/src/main/java/org/optsolvx/model/Variable.java b/src/main/java/org/optsolvx/model/Variable.java index c5e1edc..ff3c89a 100644 --- a/src/main/java/org/optsolvx/model/Variable.java +++ b/src/main/java/org/optsolvx/model/Variable.java @@ -15,7 +15,8 @@ public class Variable { /** * Creates a new variable with the given name and bounds. - * @param name unique name + * + * @param name unique name * @param lowerBound lower bound (inclusive) * @param upperBound upper bound (inclusive) */ @@ -26,11 +27,19 @@ public Variable(String name, double lowerBound, double upperBound) { } // @return variable name - public String getName() { return name; } + public String getName() { + return name; + } + // @return lower bound - public double getLowerBound() { return lowerBound; } + public double getLowerBound() { + return lowerBound; + } + // @return upper bound - public double getUpperBound() { return upperBound; } + public double getUpperBound() { + return upperBound; + } /** * Returns a debug string with variable details. diff --git a/src/main/java/org/optsolvx/solver/CommonsMathSolver.java b/src/main/java/org/optsolvx/solver/CommonsMathSolver.java index bf15227..4ffe429 100644 --- a/src/main/java/org/optsolvx/solver/CommonsMathSolver.java +++ b/src/main/java/org/optsolvx/solver/CommonsMathSolver.java @@ -23,13 +23,12 @@ /** * Commons Math 3 backend for OptSolvX. - * * Implementation notes: * - Equality constraints are represented as two inequalities (<= and >=). * - Variable bounds are added explicitly as linear constraints (lb/ub). * - NonNegativeConstraint(false) is required to allow negative fluxes. * - If the problem is infeasible or unbounded, the solution is marked infeasible - * and the objective is reported as NaN to mirror legacy behavior. + * and the objective is reported as NaN to mirror legacy behavior. */ public final class CommonsMathSolver implements LPSolverAdapter { diff --git a/src/main/java/org/optsolvx/solver/LPSolution.java b/src/main/java/org/optsolvx/solver/LPSolution.java index 2147fc1..54db704 100644 --- a/src/main/java/org/optsolvx/solver/LPSolution.java +++ b/src/main/java/org/optsolvx/solver/LPSolution.java @@ -14,7 +14,15 @@ public LPSolution(Map variableValues, double objectValue, boolea this.feasible = feasible; } - public Map getVariableValues() { return variableValues; } - public double getObjectiveValue() { return objectiveValue; } - public boolean isFeasible() { return feasible; } + public Map getVariableValues() { + return variableValues; + } + + public double getObjectiveValue() { + return objectiveValue; + } + + public boolean isFeasible() { + return feasible; + } } diff --git a/src/main/java/org/optsolvx/solver/SolverDemo.java b/src/main/java/org/optsolvx/solver/SolverDemo.java index fa7b553..a51668a 100644 --- a/src/main/java/org/optsolvx/solver/SolverDemo.java +++ b/src/main/java/org/optsolvx/solver/SolverDemo.java @@ -1,6 +1,7 @@ package org.optsolvx.solver; import org.optsolvx.model.*; + import java.util.LinkedHashMap; import java.util.Map; From 28d80a2eb0d5ddaddf92d39ad77635dd43e2904e Mon Sep 17 00:00:00 2001 From: xts-Michi Date: Fri, 29 Aug 2025 17:30:00 +0200 Subject: [PATCH 5/6] chore(build): enforce JDK 22 via Maven Enforcer plugin --- README.md | 10 ++++++++++ pom.xml | 31 +++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+) diff --git a/README.md b/README.md index 6036cdd..95b755c 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,16 @@ Artifacts - target/optsolvx-.jar - Java 22 (default) - target/optsolvx--jdk8.jar - Java 8 bytecode (compatibility) +► Java Version +---------------------------- + +OptSolvX requires **Java 22** to build and run. +The build enforces this via the Maven Enforcer plugin. + +If a different JDK is active, the build will fail early with a clear message. +Optional: use the `compat8` profile to produce a Java 8 bytecode JAR. + + ► Testing ---------------------------- diff --git a/pom.xml b/pom.xml index a8e3e85..9629885 100644 --- a/pom.xml +++ b/pom.xml @@ -64,6 +64,37 @@ maven-surefire-plugin 3.2.5 + + + org.apache.maven.plugins + maven-enforcer-plugin + 3.5.0 + + + + + + [22,23) + + OptSolvX requires JDK 22 to build. Set JAVA_HOME to 22.x or use toolchains. + + + + true + + + + + + enforce-java + validate + + enforce + + + + + From 725dd66ef219d2a764f97b93d71e3a960fe34c9f Mon Sep 17 00:00:00 2001 From: xts-Michi Date: Sun, 31 Aug 2025 09:35:04 +0200 Subject: [PATCH 6/6] docs: add UML figure for LP core --- ...OptSolvX Linear Programming Core Model.svg | 102 ++++++++++++++++++ 1 file changed, 102 insertions(+) create mode 100644 docs/figures/OptSolvX Linear Programming Core Model.svg diff --git a/docs/figures/OptSolvX Linear Programming Core Model.svg b/docs/figures/OptSolvX Linear Programming Core Model.svg new file mode 100644 index 0000000..104ddff --- /dev/null +++ b/docs/figures/OptSolvX Linear Programming Core Model.svg @@ -0,0 +1,102 @@ +

produces

1..*
0..*

AbstractLPModel

+addVariable(name, lb, ub)

+addConstraint(name, coeffs, rel, rhs)

+setObjective(coeffs, dir)

+build()

+getVariables()

+getConstraints()

+getObjectiveCoefficients()

+getDirection()

Variable

+name: String

+lowerBound: double

+upperBound: double

Constraint

+name: String

+coefficients: Map

+relation: Relation

+rhs: double

Relation

<> LEQ; GEQ; EQ

OptimizationDirection

<> MAXIMIZE; MINIMIZE

LPSolution

+variableValues: Map

+objectiveValue: double

+feasible: boolean

\ No newline at end of file