diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e3ac2620..e5a7285c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,7 +28,7 @@ jobs: - name: Generate code coverage run: | - cargo tarpaulin --out xml --features sdp-accelerate --exclude-files "src/python/*,src/julia/*" + cargo tarpaulin --out xml --features sdp-accelerate,serde --exclude-files "src/python/*,src/julia/*" - name: Upload to codecov.io uses: codecov/codecov-action@v4 diff --git a/Cargo.toml b/Cargo.toml index 09bf562b..a4f509c2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -29,6 +29,10 @@ itertools = "0.11" # ------------------------------- [features] +default = ["serde"] + +# enable reading / writing of problems from json files +serde = ["dep:serde", "dep:serde_json"] # enables blas/lapack for SDP support, with blas/lapack src unspecified # also enable packages required for chordal decomposition @@ -41,14 +45,16 @@ sdp-openblas = ["sdp", "blas-src/openblas", "lapack-src/openblas"] sdp-mkl = ["sdp", "blas-src/intel-mkl", "lapack-src/intel-mkl"] sdp-r = ["sdp", "blas-src/r", "lapack-src/r"] + # build as the julia interface -julia = ["sdp", "dep:libc", "dep:num-derive", "dep:serde", "dep:serde_json"] +julia = ["sdp", "dep:libc", "dep:num-derive", "serde"] # build as the python interface via maturin. # NB: python builds use scipy shared libraries # for blas/lapack, and should *not* explicitly # enable a blas/lapack source package -python = ["sdp", "dep:libc", "dep:pyo3", "dep:num-derive"] +python = ["sdp", "dep:libc", "dep:pyo3", "dep:num-derive", "serde"] + # ------------------------------- @@ -109,6 +115,12 @@ name = "sdp" path = "examples/rust/example_sdp.rs" required-features = ["sdp"] +[[example]] +name = "json" +path = "examples/rust/example_json.rs" +required-features = ["serde"] + + # ------------------------------- # custom build profiles @@ -162,3 +174,8 @@ crate-type = ["lib","cdylib"] rustdoc-args = [ "--html-in-header", "./html/rustdocs-header.html" ] features = ["sdp","sdp-mkl"] + +# ------------------------------ +# testing, benchmarking etc +[dev-dependencies] +tempfile = "3" \ No newline at end of file diff --git a/examples/data/hs35.json b/examples/data/hs35.json new file mode 100644 index 00000000..99e22d7c --- /dev/null +++ b/examples/data/hs35.json @@ -0,0 +1 @@ +{"P":{"m":3,"n":3,"colptr":[0,1,3,5],"rowval":[0,0,1,0,2],"nzval":[4.000000000000001,2.0000000000000004,4.000000000000001,2.0,2.0]},"q":[-8.0,-6.0,-4.0],"A":{"m":4,"n":3,"colptr":[0,2,4,6],"rowval":[0,1,0,2,0,3],"nzval":[1.0,-1.0,1.0,-1.0,2.0,-1.0]},"b":[3.0,0.0,0.0,0.0],"cones":[{"NonnegativeConeT":4}],"settings":{"max_iter":200,"time_limit":1.7976931348623157e308,"verbose":true,"max_step_fraction":0.99,"tol_gap_abs":1e-8,"tol_gap_rel":1e-8,"tol_feas":1e-8,"tol_infeas_abs":1e-8,"tol_infeas_rel":1e-8,"tol_ktratio":1e-6,"reduced_tol_gap_abs":0.00005,"reduced_tol_gap_rel":0.00005,"reduced_tol_feas":0.0001,"reduced_tol_infeas_abs":0.00005,"reduced_tol_infeas_rel":0.00005,"reduced_tol_ktratio":0.0001,"equilibrate_enable":true,"equilibrate_max_iter":10,"equilibrate_min_scaling":0.0001,"equilibrate_max_scaling":10000.0,"linesearch_backtrack_step":0.8,"min_switch_step_length":0.1,"min_terminate_step_length":0.0001,"direct_kkt_solver":true,"direct_solve_method":"qdldl","static_regularization_enable":true,"static_regularization_constant":1e-8,"static_regularization_proportional":4.930380657631324e-32,"dynamic_regularization_enable":true,"dynamic_regularization_eps":1e-13,"dynamic_regularization_delta":2e-7,"iterative_refinement_enable":true,"iterative_refinement_reltol":1e-13,"iterative_refinement_abstol":1e-12,"iterative_refinement_max_iter":10,"iterative_refinement_stop_ratio":5.0,"presolve_enable":true,"chordal_decomposition_enable":true,"chordal_decomposition_merge_method":"clique_graph","chordal_decomposition_compact":true,"chordal_decomposition_complete_dual":true}} \ No newline at end of file diff --git a/examples/python/example_json.py b/examples/python/example_json.py new file mode 100644 index 00000000..310f9d40 --- /dev/null +++ b/examples/python/example_json.py @@ -0,0 +1,14 @@ +import clarabel +import os + +thisdir = os.path.dirname(__file__) +filename = os.path.join(thisdir, "../data/hs35.json") +print(filename) + +# Load problem data from JSON file +solver = clarabel.read_from_file(filename) +solution = solver.solve() + +# export problem data to JSON file +# filename = os.path.join(thisdir, "../data/out.json") +# solver.write_to_file(filename) diff --git a/examples/rust/example_json.rs b/examples/rust/example_json.rs new file mode 100644 index 00000000..76f339cb --- /dev/null +++ b/examples/rust/example_json.rs @@ -0,0 +1,19 @@ +#![allow(non_snake_case)] +use clarabel::solver::*; +use std::fs::File; + +fn main() { + // HS35 is a small problem QP problem + // from the Maros-Meszaros test set + + let filename = "./examples/data/hs35.json"; + let mut file = File::open(filename).unwrap(); + let mut solver = DefaultSolver::::read_from_file(&mut file).unwrap(); + solver.solve(); + + // to write the back to a new file + + // let outfile = "./examples/data/output.json"; + // let mut file = File::create(outfile).unwrap(); + // solver.write_to_file(&mut file).unwrap(); +} diff --git a/src/algebra/csc/core.rs b/src/algebra/csc/core.rs index 822fb246..ee87d28a 100644 --- a/src/algebra/csc/core.rs +++ b/src/algebra/csc/core.rs @@ -6,6 +6,9 @@ use crate::algebra::{Adjoint, MatrixShape, ShapedMatrix, SparseFormatError, Symm use num_traits::Num; use std::iter::{repeat, zip}; +#[cfg(feature = "serde")] +use serde::{de::DeserializeOwned, Deserialize, Serialize}; + /// Sparse matrix in standard Compressed Sparse Column (CSC) format /// /// __Example usage__ : To construct the 3 x 3 matrix @@ -38,6 +41,8 @@ use std::iter::{repeat, zip}; /// ``` /// +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[serde(bound = "T: Serialize + DeserializeOwned")] #[derive(Debug, Clone, PartialEq, Eq)] pub struct CscMatrix { /// number of rows diff --git a/src/algebra/utils.rs b/src/algebra/utils.rs index 62e5e824..94e63e8b 100644 --- a/src/algebra/utils.rs +++ b/src/algebra/utils.rs @@ -106,8 +106,8 @@ fn test_position_all() { let idx = test.iter().position_all(|&v| *v > 2); assert_eq!(idx, vec![0, 3, 4]); - let idx = test.iter().position_all(|&v| *v == 2); - assert_eq!(idx, vec![]); + let idx: Vec = test.iter().position_all(|&v| *v == 2); + assert_eq!(idx, Vec::::new()); } #[test] diff --git a/src/julia/ClarabelRs/src/interface.jl b/src/julia/ClarabelRs/src/interface.jl index 54a1f407..f3fec6da 100644 --- a/src/julia/ClarabelRs/src/interface.jl +++ b/src/julia/ClarabelRs/src/interface.jl @@ -39,6 +39,19 @@ function print_timers(solver::Solver) end +function write_to_file(solver::Solver, filename::String) + + solver_write_to_file_jlrs(solver::Solver, filename::String) + +end + +function read_from_file(filename::String) + + solver_read_from_file_jlrs(filename::String) + +end + + # ------------------------------------- # Wrappers for rust-side interface #-------------------------------------- @@ -77,6 +90,7 @@ function solver_solve_jlrs(solver::Solver) end + function solver_get_info_jlrs(solver::Solver) ccall(Libdl.dlsym(librust,:solver_get_info_jlrs),Clarabel.DefaultInfo{Float64}, @@ -92,6 +106,39 @@ function solver_print_timers_jlrs(solver::Solver) end +function solver_write_to_file_jlrs(solver::Solver, filename::String) + + status = ccall(Libdl.dlsym(librust,:solver_write_to_file_jlrs),Cint, + ( + Ptr{Cvoid}, + Cstring + ), + solver.ptr, + filename, + ) + + if status != 0 + error("Error writing to file $filename") + end + +end + +function solver_read_from_file_jlrs(filename::String) + + ptr = ccall(Libdl.dlsym(librust,:solver_read_from_file_jlrs),Ptr{Cvoid}, + ( + Cstring, + ), + filename, + ) + + if ptr == C_NULL + error("Error reading from file $filename") + end + return Solver{Float64}(ptr) + +end + function solver_drop_jlrs(solver::Solver) ccall(Libdl.dlsym(librust,:solver_drop_jlrs),Cvoid, (Ptr{Cvoid},), solver.ptr) diff --git a/src/julia/interface.rs b/src/julia/interface.rs index e2cde050..1bf2f0e1 100644 --- a/src/julia/interface.rs +++ b/src/julia/interface.rs @@ -10,7 +10,11 @@ use crate::solver::{ }; use num_traits::FromPrimitive; use serde_json::*; -use std::{ffi::CStr, os::raw::c_void}; +use std::fs::File; +use std::{ + ffi::CStr, + os::raw::{c_char, c_int, c_void}, +}; // functions for converting solver to / from c void pointers @@ -54,7 +58,7 @@ pub(crate) extern "C" fn solver_new_jlrs( A: &CscMatrixJLRS, b: &VectorJLRS, jlcones: &VectorJLRS, - json_settings: *const std::os::raw::c_char, + json_settings: *const c_char, ) -> *mut c_void { let P = P.to_CscMatrix(); let A = A.to_CscMatrix(); @@ -118,6 +122,69 @@ pub(crate) extern "C" fn solver_print_timers_jlrs(ptr: *mut c_void) { std::mem::forget(solver); } +// dump problem data to a file +// returns -1 on failure, 0 on success +#[no_mangle] +pub(crate) extern "C" fn solver_write_to_file_jlrs( + ptr: *mut c_void, + filename: *const std::os::raw::c_char, +) -> c_int { + let slice = unsafe { CStr::from_ptr(filename) }; + + let filename = match slice.to_str() { + Ok(s) => s, + Err(_) => { + return -1; + } + }; + + let mut file = match File::create(&filename) { + Ok(f) => f, + Err(_) => { + return -1; + } + }; + + let solver = from_ptr(ptr); + let status = solver.write_to_file(&mut file).is_ok(); + let status = if status { 0 } else { -1 } as c_int; + + // don't drop, since the memory is owned by Julia + std::mem::forget(solver); + + return status; +} + +// dump problem data to a file +// returns NULL on failure, pointer to solver on success +#[no_mangle] +pub(crate) extern "C" fn solver_read_from_file_jlrs( + filename: *const std::os::raw::c_char, +) -> *const c_void { + let slice = unsafe { CStr::from_ptr(filename) }; + + let filename = match slice.to_str() { + Ok(s) => s, + Err(_) => { + return std::ptr::null(); + } + }; + + let mut file = match File::open(&filename) { + Ok(f) => f, + Err(_) => { + return std::ptr::null(); + } + }; + + let solver = DefaultSolver::read_from_file(&mut file); + + match solver { + Ok(solver) => to_ptr(Box::new(solver)), + Err(_) => std::ptr::null(), + } +} + // safely drop a solver object through its pointer. // called by the Julia side finalizer when a solver // is out of scope diff --git a/src/python/impl_default_py.rs b/src/python/impl_default_py.rs index 72a13fb4..db09381b 100644 --- a/src/python/impl_default_py.rs +++ b/src/python/impl_default_py.rs @@ -448,4 +448,17 @@ impl PyDefaultSolver { None => println!("no timers enabled"), }; } + + fn write_to_file(&self, filename: &str) -> PyResult<()> { + let mut file = std::fs::File::create(filename)?; + self.inner.write_to_file(&mut file)?; + Ok(()) + } +} + +#[pyfunction(name = "read_from_file")] +pub fn read_from_file_py(filename: &str) -> PyResult { + let mut file = std::fs::File::open(filename)?; + let solver = DefaultSolver::::read_from_file(&mut file)?; + Ok(PyDefaultSolver { inner: solver }) } diff --git a/src/python/module_py.rs b/src/python/module_py.rs index a0fcc626..339a163e 100644 --- a/src/python/module_py.rs +++ b/src/python/module_py.rs @@ -39,6 +39,8 @@ fn clarabel(_py: Python, m: &PyModule) -> PyResult<()> { .unwrap(); m.add_function(wrap_pyfunction!(default_infinity_py, m)?) .unwrap(); + m.add_function(wrap_pyfunction!(read_from_file_py, m)?) + .unwrap(); // API Cone types m.add_class::()?; diff --git a/src/solver/core/cones/supportedcone.rs b/src/solver/core/cones/supportedcone.rs index f95020e8..5ea1b393 100644 --- a/src/solver/core/cones/supportedcone.rs +++ b/src/solver/core/cones/supportedcone.rs @@ -2,6 +2,8 @@ use super::*; #[cfg(feature = "sdp")] use crate::algebra::triangular_number; +#[cfg(feature = "serde")] +use serde::{Deserialize, Serialize}; // --------------------------------------------------- // We define some machinery here for enumerating the @@ -9,7 +11,8 @@ use crate::algebra::triangular_number; // --------------------------------------------------- /// API type describing the type of a conic constraint. -/// +/// +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] #[derive(Debug, Clone)] pub enum SupportedConeT { /// The zero cone (used for equality constraints). diff --git a/src/solver/implementations/default/json.rs b/src/solver/implementations/default/json.rs new file mode 100644 index 00000000..67eecc4c --- /dev/null +++ b/src/solver/implementations/default/json.rs @@ -0,0 +1,135 @@ +use crate::{ + algebra::*, + solver::{DefaultSettings, DefaultSolver, SupportedConeT}, +}; + +use serde::{de::DeserializeOwned, Deserialize, Serialize}; +use std::io::Write; +use std::{fs::File, io, io::Read}; + +// A struct very similar to the problem data, but containing only +// the data types provided by the user (i.e. no internal types). + +#[derive(Serialize, Deserialize)] +#[serde(bound = "T: Serialize + DeserializeOwned")] +struct JsonProblemData { + pub P: CscMatrix, + pub q: Vec, + pub A: CscMatrix, + pub b: Vec, + pub cones: Vec>, + pub settings: DefaultSettings, +} + +impl DefaultSolver +where + T: FloatT + DeserializeOwned + Serialize, +{ + pub fn write_to_file(&self, file: &mut File) -> Result<(), io::Error> { + let mut json_data = JsonProblemData { + P: self.data.P.clone(), + q: self.data.q.clone(), + A: self.data.A.clone(), + b: self.data.b.clone(), + cones: self.data.cones.clone(), + settings: self.settings.clone(), + }; + + // restore scaling to original + let dinv = &self.data.equilibration.dinv; + let einv = &self.data.equilibration.einv; + let c = &self.data.equilibration.c; + + json_data.P.lrscale(dinv, dinv); + json_data.q.hadamard(dinv); + json_data.P.scale(c.recip()); + json_data.q.scale(c.recip()); + + json_data.A.lrscale(einv, dinv); + json_data.b.hadamard(einv); + + // sanitize settings to remove values that + // can't be serialized, i.e. infs + sanitize_settings(&mut json_data.settings); + + // write to file + let json = serde_json::to_string(&json_data)?; + file.write_all(json.as_bytes())?; + + Ok(()) + } + + pub fn read_from_file(file: &mut File) -> Result { + // read file + let mut buffer = String::new(); + file.read_to_string(&mut buffer)?; + let mut json_data: JsonProblemData = serde_json::from_str(&buffer)?; + + // restore sanitized settings to their (likely) original values + desanitize_settings(&mut json_data.settings); + + // create a solver object + let P = json_data.P; + let q = json_data.q; + let A = json_data.A; + let b = json_data.b; + let cones = json_data.cones; + let settings = json_data.settings; + let solver = Self::new(&P, &q, &A, &b, &cones, settings); + + Ok(solver) + } +} + +fn sanitize_settings(settings: &mut DefaultSettings) { + if settings.time_limit == f64::INFINITY { + settings.time_limit = f64::MAX; + } +} + +fn desanitize_settings(settings: &mut DefaultSettings) { + if settings.time_limit == f64::MAX { + settings.time_limit = f64::INFINITY; + } +} + +#[test] +fn test_json_io() { + use crate::solver::IPSolver; + use std::io::{Seek, SeekFrom}; + + let P = CscMatrix { + m: 1, + n: 1, + colptr: vec![0, 1], + rowval: vec![0], + nzval: vec![2.0], + }; + let q = [1.0]; + let A = CscMatrix { + m: 1, + n: 1, + colptr: vec![0, 1], + rowval: vec![0], + nzval: vec![-1.0], + }; + let b = [-2.0]; + let cones = vec![crate::solver::SupportedConeT::NonnegativeConeT(1)]; + + let settings = crate::solver::DefaultSettingsBuilder::default() + .build() + .unwrap(); + + let mut solver = crate::solver::DefaultSolver::::new(&P, &q, &A, &b, &cones, settings); + solver.solve(); + + // write the problem to a file + let mut file = tempfile::tempfile().unwrap(); + solver.write_to_file(&mut file).unwrap(); + + // read the problem from the file + file.seek(SeekFrom::Start(0)).unwrap(); + let mut solver2 = crate::solver::DefaultSolver::::read_from_file(&mut file).unwrap(); + solver2.solve(); + assert_eq!(solver.solution.x, solver2.solution.x); +} diff --git a/src/solver/implementations/default/mod.rs b/src/solver/implementations/default/mod.rs index 1618d5c8..f0cea8c8 100644 --- a/src/solver/implementations/default/mod.rs +++ b/src/solver/implementations/default/mod.rs @@ -28,3 +28,6 @@ pub use settings::*; pub use solution::*; pub use solver::*; pub use variables::*; + +#[cfg(feature = "serde")] +mod json; diff --git a/src/solver/implementations/default/settings.rs b/src/solver/implementations/default/settings.rs index fc6518b9..d7aaee07 100644 --- a/src/solver/implementations/default/settings.rs +++ b/src/solver/implementations/default/settings.rs @@ -2,14 +2,15 @@ use crate::algebra::*; use crate::solver::core::traits::Settings; use derive_builder::Builder; -#[cfg(feature = "julia")] -use serde::{Deserialize, Serialize}; +#[cfg(feature = "serde")] +use serde::{de::DeserializeOwned, Deserialize, Serialize}; /// Standard-form solver type implementing the [`Settings`](crate::solver::core::traits::Settings) trait #[derive(Builder, Debug, Clone)] #[builder(build_fn(validate = "Self::validate"))] -#[cfg_attr(feature = "julia", derive(Serialize, Deserialize))] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[serde(bound = "T: Serialize + DeserializeOwned")] pub struct DefaultSettings { ///maximum number of iterations #[builder(default = "200")]