From a4bdac14887a060cc5a75da20b6501529e178fef Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Jan 2026 15:00:47 +0000 Subject: [PATCH] libsimlin: add JSON APIs for model variable retrieval Add three new functions to libsimlin for directly retrieving variable details as JSON arrays: simlin_model_get_stocks_json, simlin_model_get_flows_json, and simlin_model_get_auxs_json. These functions allow callers to get stocks, flows, or auxiliaries without serializing the entire project. The new APIs follow the existing libsimlin memory ownership pattern - the returned buffer is owned by the caller and must be freed with simlin_free(). This provides a significant simplification for engine2 and pysimlin, which previously had to serialize the entire project to JSON just to access variable information. Changes include: - libsimlin: Three new FFI functions with proper documentation and tests - engine2: WASM bindings and Model class simplification using direct APIs - pysimlin: FFI bindings and Model class simplification using direct APIs The simplification is particularly beneficial for large projects where serializing the entire project is expensive when only variable info is needed. https://claude.ai/code/session_01PujQhTADskFsjJVY3GLuUQ --- src/engine2/src/internal/model.ts | 127 ++++++++ src/engine2/src/model.ts | 27 +- src/libsimlin/simlin.h | 57 ++++ src/libsimlin/src/lib.rs | 516 ++++++++++++++++++++++++++++++ src/pysimlin/simlin/_ffi.py | 78 +++++ src/pysimlin/simlin/_ffi_build.py | 3 + src/pysimlin/simlin/model.py | 23 +- 7 files changed, 818 insertions(+), 13 deletions(-) diff --git a/src/engine2/src/internal/model.ts b/src/engine2/src/internal/model.ts index 72191f8e..24b4c1b1 100644 --- a/src/engine2/src/internal/model.ts +++ b/src/engine2/src/internal/model.ts @@ -14,6 +14,7 @@ import { readOutPtr, allocOutUsize, readOutUsize, + copyFromWasm, } from './memory'; import { SimlinModelPtr, SimlinLinksPtr } from './types'; import { @@ -282,3 +283,129 @@ export function simlin_model_get_incoming_links(model: SimlinModelPtr, varName: free(outErrPtr); } } + +/** + * Get all stocks in a model as JSON bytes. + * @param model Model pointer + * @returns JSON bytes (UTF-8 encoded array of stock objects) + */ +export function simlin_model_get_stocks_json(model: SimlinModelPtr): Uint8Array { + const exports = getExports(); + const fn = exports.simlin_model_get_stocks_json as ( + model: number, + outBuf: number, + outLen: number, + outErr: number, + ) => void; + + const outBufPtr = allocOutPtr(); + const outLenPtr = allocOutUsize(); + const outErrPtr = allocOutPtr(); + + try { + fn(model, outBufPtr, outLenPtr, outErrPtr); + const errPtr = readOutPtr(outErrPtr); + + if (errPtr !== 0) { + const code = simlin_error_get_code(errPtr); + const message = simlin_error_get_message(errPtr) ?? 'Unknown error'; + const details = readAllErrorDetails(errPtr); + simlin_error_free(errPtr); + throw new SimlinError(message, code, details); + } + + const bufPtr = readOutPtr(outBufPtr); + const len = readOutUsize(outLenPtr); + const data = copyFromWasm(bufPtr, len); + free(bufPtr); + return data; + } finally { + free(outBufPtr); + free(outLenPtr); + free(outErrPtr); + } +} + +/** + * Get all flows in a model as JSON bytes. + * @param model Model pointer + * @returns JSON bytes (UTF-8 encoded array of flow objects) + */ +export function simlin_model_get_flows_json(model: SimlinModelPtr): Uint8Array { + const exports = getExports(); + const fn = exports.simlin_model_get_flows_json as ( + model: number, + outBuf: number, + outLen: number, + outErr: number, + ) => void; + + const outBufPtr = allocOutPtr(); + const outLenPtr = allocOutUsize(); + const outErrPtr = allocOutPtr(); + + try { + fn(model, outBufPtr, outLenPtr, outErrPtr); + const errPtr = readOutPtr(outErrPtr); + + if (errPtr !== 0) { + const code = simlin_error_get_code(errPtr); + const message = simlin_error_get_message(errPtr) ?? 'Unknown error'; + const details = readAllErrorDetails(errPtr); + simlin_error_free(errPtr); + throw new SimlinError(message, code, details); + } + + const bufPtr = readOutPtr(outBufPtr); + const len = readOutUsize(outLenPtr); + const data = copyFromWasm(bufPtr, len); + free(bufPtr); + return data; + } finally { + free(outBufPtr); + free(outLenPtr); + free(outErrPtr); + } +} + +/** + * Get all auxiliaries in a model as JSON bytes. + * @param model Model pointer + * @returns JSON bytes (UTF-8 encoded array of auxiliary objects) + */ +export function simlin_model_get_auxs_json(model: SimlinModelPtr): Uint8Array { + const exports = getExports(); + const fn = exports.simlin_model_get_auxs_json as ( + model: number, + outBuf: number, + outLen: number, + outErr: number, + ) => void; + + const outBufPtr = allocOutPtr(); + const outLenPtr = allocOutUsize(); + const outErrPtr = allocOutPtr(); + + try { + fn(model, outBufPtr, outLenPtr, outErrPtr); + const errPtr = readOutPtr(outErrPtr); + + if (errPtr !== 0) { + const code = simlin_error_get_code(errPtr); + const message = simlin_error_get_message(errPtr) ?? 'Unknown error'; + const details = readAllErrorDetails(errPtr); + simlin_error_free(errPtr); + throw new SimlinError(message, code, details); + } + + const bufPtr = readOutPtr(outBufPtr); + const len = readOutUsize(outLenPtr); + const data = copyFromWasm(bufPtr, len); + free(bufPtr); + return data; + } finally { + free(outBufPtr); + free(outLenPtr); + free(outErrPtr); + } +} diff --git a/src/engine2/src/model.ts b/src/engine2/src/model.ts index 641c8ba6..95c536ac 100644 --- a/src/engine2/src/model.ts +++ b/src/engine2/src/model.ts @@ -15,6 +15,9 @@ import { simlin_model_get_incoming_links, simlin_model_get_links, simlin_model_get_latex_equation, + simlin_model_get_stocks_json, + simlin_model_get_flows_json, + simlin_model_get_auxs_json, } from './internal/model'; import { readLinks, simlin_free_links } from './internal/analysis'; import { SimlinModelPtr, SimlinLinkPolarity, Link as LowLevelLink } from './internal/types'; @@ -215,8 +218,12 @@ export class Model { return this._cachedStocks; } - const model = this.getModelJson(); - this._cachedStocks = (model.stocks || []).map((s: JsonStock) => ({ + // Use direct JSON API instead of serializing entire project + const jsonBytes = simlin_model_get_stocks_json(this._ptr); + const jsonStr = new TextDecoder().decode(jsonBytes); + const stocksJson: JsonStock[] = JSON.parse(jsonStr); + + this._cachedStocks = stocksJson.map((s: JsonStock) => ({ type: 'stock' as const, name: s.name, initialEquation: this.extractEquation(s.initialEquation, s.arrayedEquation, 'initialEquation'), @@ -240,8 +247,12 @@ export class Model { return this._cachedFlows; } - const model = this.getModelJson(); - this._cachedFlows = (model.flows || []).map((f: JsonFlow) => { + // Use direct JSON API instead of serializing entire project + const jsonBytes = simlin_model_get_flows_json(this._ptr); + const jsonStr = new TextDecoder().decode(jsonBytes); + const flowsJson: JsonFlow[] = JSON.parse(jsonStr); + + this._cachedFlows = flowsJson.map((f: JsonFlow) => { let gf: GraphicalFunction | undefined; if (f.graphicalFunction) { gf = this.parseJsonGraphicalFunction(f.graphicalFunction); @@ -271,8 +282,12 @@ export class Model { return this._cachedAuxs; } - const model = this.getModelJson(); - this._cachedAuxs = (model.auxiliaries || []).map((a: JsonAuxiliary) => { + // Use direct JSON API instead of serializing entire project + const jsonBytes = simlin_model_get_auxs_json(this._ptr); + const jsonStr = new TextDecoder().decode(jsonBytes); + const auxsJson: JsonAuxiliary[] = JSON.parse(jsonStr); + + this._cachedAuxs = auxsJson.map((a: JsonAuxiliary) => { let gf: GraphicalFunction | undefined; if (a.graphicalFunction) { gf = this.parseJsonGraphicalFunction(a.graphicalFunction); diff --git a/src/libsimlin/simlin.h b/src/libsimlin/simlin.h index 01184b0f..a9740514 100644 --- a/src/libsimlin/simlin.h +++ b/src/libsimlin/simlin.h @@ -388,6 +388,63 @@ char *simlin_model_get_latex_equation(SimlinModel *model, const char *ident, SimlinError **out_error); +// Returns all stocks in a model as a JSON array. +// +// The returned JSON is an array of stock objects, each containing fields like +// `name`, `initialEquation`, `inflows`, `outflows`, `units`, etc. +// +// # Safety +// - `model` must be a valid pointer to a SimlinModel +// - `out_buffer` and `out_len` must be valid pointers where the serialized +// bytes and length will be written +// - `out_error` may be null or a valid pointer to receive error details +// +// # Ownership +// - The returned buffer is exclusively owned by the caller and MUST be freed with `simlin_free`. +// - The caller is responsible for freeing the buffer even if subsequent operations fail. +void simlin_model_get_stocks_json(SimlinModel *model, + uint8_t **out_buffer, + uintptr_t *out_len, + SimlinError **out_error); + +// Returns all flows in a model as a JSON array. +// +// The returned JSON is an array of flow objects, each containing fields like +// `name`, `equation`, `units`, `nonNegative`, `graphicalFunction`, etc. +// +// # Safety +// - `model` must be a valid pointer to a SimlinModel +// - `out_buffer` and `out_len` must be valid pointers where the serialized +// bytes and length will be written +// - `out_error` may be null or a valid pointer to receive error details +// +// # Ownership +// - The returned buffer is exclusively owned by the caller and MUST be freed with `simlin_free`. +// - The caller is responsible for freeing the buffer even if subsequent operations fail. +void simlin_model_get_flows_json(SimlinModel *model, + uint8_t **out_buffer, + uintptr_t *out_len, + SimlinError **out_error); + +// Returns all auxiliaries in a model as a JSON array. +// +// The returned JSON is an array of auxiliary objects, each containing fields like +// `name`, `equation`, `initialEquation`, `units`, `graphicalFunction`, etc. +// +// # Safety +// - `model` must be a valid pointer to a SimlinModel +// - `out_buffer` and `out_len` must be valid pointers where the serialized +// bytes and length will be written +// - `out_error` may be null or a valid pointer to receive error details +// +// # Ownership +// - The returned buffer is exclusively owned by the caller and MUST be freed with `simlin_free`. +// - The caller is responsible for freeing the buffer even if subsequent operations fail. +void simlin_model_get_auxs_json(SimlinModel *model, + uint8_t **out_buffer, + uintptr_t *out_len, + SimlinError **out_error); + // Creates a new simulation context // // # Safety diff --git a/src/libsimlin/src/lib.rs b/src/libsimlin/src/lib.rs index 180c5cad..072ae162 100644 --- a/src/libsimlin/src/lib.rs +++ b/src/libsimlin/src/lib.rs @@ -1434,6 +1434,295 @@ pub unsafe extern "C" fn simlin_model_get_latex_equation( } } +/// Returns all stocks in a model as a JSON array. +/// +/// The returned JSON is an array of stock objects, each containing fields like +/// `name`, `initialEquation`, `inflows`, `outflows`, `units`, etc. +/// +/// # Safety +/// - `model` must be a valid pointer to a SimlinModel +/// - `out_buffer` and `out_len` must be valid pointers where the serialized +/// bytes and length will be written +/// - `out_error` may be null or a valid pointer to receive error details +/// +/// # Ownership +/// - The returned buffer is exclusively owned by the caller and MUST be freed with `simlin_free`. +/// - The caller is responsible for freeing the buffer even if subsequent operations fail. +#[no_mangle] +pub unsafe extern "C" fn simlin_model_get_stocks_json( + model: *mut SimlinModel, + out_buffer: *mut *mut u8, + out_len: *mut usize, + out_error: *mut *mut SimlinError, +) { + clear_out_error(out_error); + if out_buffer.is_null() || out_len.is_null() { + store_error( + out_error, + SimlinError::new(SimlinErrorCode::Generic) + .with_message("output pointers must not be NULL"), + ); + return; + } + + *out_buffer = ptr::null_mut(); + *out_len = 0; + + let model_ref = match require_model(model) { + Ok(m) => m, + Err(err) => { + store_anyhow_error(out_error, err); + return; + } + }; + + let project_locked = (*model_ref.project).project.lock().unwrap(); + + let datamodel_model = match project_locked.datamodel.get_model(&model_ref.model_name) { + Some(m) => m, + None => { + store_error( + out_error, + SimlinError::new(SimlinErrorCode::BadModelName) + .with_message(format!("model '{}' not found", model_ref.model_name)), + ); + return; + } + }; + + // Extract stocks from the datamodel and convert to JSON types + let stocks: Vec = datamodel_model + .variables + .iter() + .filter_map(|var| match var { + engine::datamodel::Variable::Stock(stock) => { + Some(engine::json::Stock::from(stock.clone())) + } + _ => None, + }) + .collect(); + + let bytes = match serde_json::to_vec(&stocks) { + Ok(data) => data, + Err(err) => { + store_error( + out_error, + SimlinError::new(SimlinErrorCode::Generic) + .with_message(format!("failed to encode stocks JSON: {err}")), + ); + return; + } + }; + + let len = bytes.len(); + let buf = simlin_malloc(len); + if buf.is_null() { + store_error( + out_error, + SimlinError::new(SimlinErrorCode::Generic) + .with_message("allocation failed while serializing stocks"), + ); + return; + } + + std::ptr::copy_nonoverlapping(bytes.as_ptr(), buf, len); + + *out_buffer = buf; + *out_len = len; +} + +/// Returns all flows in a model as a JSON array. +/// +/// The returned JSON is an array of flow objects, each containing fields like +/// `name`, `equation`, `units`, `nonNegative`, `graphicalFunction`, etc. +/// +/// # Safety +/// - `model` must be a valid pointer to a SimlinModel +/// - `out_buffer` and `out_len` must be valid pointers where the serialized +/// bytes and length will be written +/// - `out_error` may be null or a valid pointer to receive error details +/// +/// # Ownership +/// - The returned buffer is exclusively owned by the caller and MUST be freed with `simlin_free`. +/// - The caller is responsible for freeing the buffer even if subsequent operations fail. +#[no_mangle] +pub unsafe extern "C" fn simlin_model_get_flows_json( + model: *mut SimlinModel, + out_buffer: *mut *mut u8, + out_len: *mut usize, + out_error: *mut *mut SimlinError, +) { + clear_out_error(out_error); + if out_buffer.is_null() || out_len.is_null() { + store_error( + out_error, + SimlinError::new(SimlinErrorCode::Generic) + .with_message("output pointers must not be NULL"), + ); + return; + } + + *out_buffer = ptr::null_mut(); + *out_len = 0; + + let model_ref = match require_model(model) { + Ok(m) => m, + Err(err) => { + store_anyhow_error(out_error, err); + return; + } + }; + + let project_locked = (*model_ref.project).project.lock().unwrap(); + + let datamodel_model = match project_locked.datamodel.get_model(&model_ref.model_name) { + Some(m) => m, + None => { + store_error( + out_error, + SimlinError::new(SimlinErrorCode::BadModelName) + .with_message(format!("model '{}' not found", model_ref.model_name)), + ); + return; + } + }; + + // Extract flows from the datamodel and convert to JSON types + let flows: Vec = datamodel_model + .variables + .iter() + .filter_map(|var| match var { + engine::datamodel::Variable::Flow(flow) => Some(engine::json::Flow::from(flow.clone())), + _ => None, + }) + .collect(); + + let bytes = match serde_json::to_vec(&flows) { + Ok(data) => data, + Err(err) => { + store_error( + out_error, + SimlinError::new(SimlinErrorCode::Generic) + .with_message(format!("failed to encode flows JSON: {err}")), + ); + return; + } + }; + + let len = bytes.len(); + let buf = simlin_malloc(len); + if buf.is_null() { + store_error( + out_error, + SimlinError::new(SimlinErrorCode::Generic) + .with_message("allocation failed while serializing flows"), + ); + return; + } + + std::ptr::copy_nonoverlapping(bytes.as_ptr(), buf, len); + + *out_buffer = buf; + *out_len = len; +} + +/// Returns all auxiliaries in a model as a JSON array. +/// +/// The returned JSON is an array of auxiliary objects, each containing fields like +/// `name`, `equation`, `initialEquation`, `units`, `graphicalFunction`, etc. +/// +/// # Safety +/// - `model` must be a valid pointer to a SimlinModel +/// - `out_buffer` and `out_len` must be valid pointers where the serialized +/// bytes and length will be written +/// - `out_error` may be null or a valid pointer to receive error details +/// +/// # Ownership +/// - The returned buffer is exclusively owned by the caller and MUST be freed with `simlin_free`. +/// - The caller is responsible for freeing the buffer even if subsequent operations fail. +#[no_mangle] +pub unsafe extern "C" fn simlin_model_get_auxs_json( + model: *mut SimlinModel, + out_buffer: *mut *mut u8, + out_len: *mut usize, + out_error: *mut *mut SimlinError, +) { + clear_out_error(out_error); + if out_buffer.is_null() || out_len.is_null() { + store_error( + out_error, + SimlinError::new(SimlinErrorCode::Generic) + .with_message("output pointers must not be NULL"), + ); + return; + } + + *out_buffer = ptr::null_mut(); + *out_len = 0; + + let model_ref = match require_model(model) { + Ok(m) => m, + Err(err) => { + store_anyhow_error(out_error, err); + return; + } + }; + + let project_locked = (*model_ref.project).project.lock().unwrap(); + + let datamodel_model = match project_locked.datamodel.get_model(&model_ref.model_name) { + Some(m) => m, + None => { + store_error( + out_error, + SimlinError::new(SimlinErrorCode::BadModelName) + .with_message(format!("model '{}' not found", model_ref.model_name)), + ); + return; + } + }; + + // Extract auxiliaries from the datamodel and convert to JSON types + let auxs: Vec = datamodel_model + .variables + .iter() + .filter_map(|var| match var { + engine::datamodel::Variable::Aux(aux) => { + Some(engine::json::Auxiliary::from(aux.clone())) + } + _ => None, + }) + .collect(); + + let bytes = match serde_json::to_vec(&auxs) { + Ok(data) => data, + Err(err) => { + store_error( + out_error, + SimlinError::new(SimlinErrorCode::Generic) + .with_message(format!("failed to encode auxiliaries JSON: {err}")), + ); + return; + } + }; + + let len = bytes.len(); + let buf = simlin_malloc(len); + if buf.is_null() { + store_error( + out_error, + SimlinError::new(SimlinErrorCode::Generic) + .with_message("allocation failed while serializing auxiliaries"), + ); + return; + } + + std::ptr::copy_nonoverlapping(bytes.as_ptr(), buf, len); + + *out_buffer = buf; + *out_len = len; +} + /// Helper function to create a VM for a given project and model fn create_vm(project: &engine::Project, model_name: &str) -> Result { let compiler = engine::Simulation::new(project, model_name)?; @@ -9742,4 +10031,231 @@ mod tests { simlin_project_unref(proj); } } + + #[test] + fn test_model_get_stocks_json() { + let datamodel = TestProject::new("stocks_json") + .stock("population", "100", &["births"], &["deaths"], None) + .stock("resources", "500", &[], &["consumption"], None) + .flow("births", "population * 0.02", None) + .flow("deaths", "population * 0.01", None) + .flow("consumption", "resources * 0.1", None) + .build_datamodel(); + let proj = open_project_from_datamodel(&datamodel); + + unsafe { + let mut err: *mut SimlinError = ptr::null_mut(); + let model = simlin_project_get_model(proj, ptr::null(), &mut err); + assert!(!model.is_null()); + assert!(err.is_null()); + + let mut out_buffer: *mut u8 = ptr::null_mut(); + let mut out_len: usize = 0; + let mut out_error: *mut SimlinError = ptr::null_mut(); + + simlin_model_get_stocks_json(model, &mut out_buffer, &mut out_len, &mut out_error); + + assert!(out_error.is_null(), "expected no error getting stocks JSON"); + assert!(!out_buffer.is_null(), "expected stocks JSON buffer"); + assert!(out_len > 0, "expected non-empty stocks JSON"); + + let slice = std::slice::from_raw_parts(out_buffer, out_len); + let json_str = std::str::from_utf8(slice).expect("valid utf-8 JSON"); + + let stocks: Vec = + serde_json::from_str(json_str).expect("valid JSON array of stocks"); + + assert_eq!(stocks.len(), 2, "expected 2 stocks"); + + let stock_names: Vec<&str> = stocks.iter().map(|s| s.name.as_str()).collect(); + assert!(stock_names.contains(&"population")); + assert!(stock_names.contains(&"resources")); + + // Verify stock details + let pop_stock = stocks.iter().find(|s| s.name == "population").unwrap(); + assert_eq!(pop_stock.initial_equation, "100"); + assert!(pop_stock.inflows.contains(&"births".to_string())); + assert!(pop_stock.outflows.contains(&"deaths".to_string())); + + simlin_free(out_buffer); + simlin_model_unref(model); + simlin_project_unref(proj); + } + } + + #[test] + fn test_model_get_flows_json() { + let datamodel = TestProject::new("flows_json") + .stock("population", "100", &["births"], &["deaths"], None) + .flow("births", "population * 0.02", None) + .flow("deaths", "population * 0.01", None) + .aux("growth_rate", "0.02", None) + .build_datamodel(); + let proj = open_project_from_datamodel(&datamodel); + + unsafe { + let mut err: *mut SimlinError = ptr::null_mut(); + let model = simlin_project_get_model(proj, ptr::null(), &mut err); + assert!(!model.is_null()); + assert!(err.is_null()); + + let mut out_buffer: *mut u8 = ptr::null_mut(); + let mut out_len: usize = 0; + let mut out_error: *mut SimlinError = ptr::null_mut(); + + simlin_model_get_flows_json(model, &mut out_buffer, &mut out_len, &mut out_error); + + assert!(out_error.is_null(), "expected no error getting flows JSON"); + assert!(!out_buffer.is_null(), "expected flows JSON buffer"); + assert!(out_len > 0, "expected non-empty flows JSON"); + + let slice = std::slice::from_raw_parts(out_buffer, out_len); + let json_str = std::str::from_utf8(slice).expect("valid utf-8 JSON"); + + let flows: Vec = + serde_json::from_str(json_str).expect("valid JSON array of flows"); + + assert_eq!(flows.len(), 2, "expected 2 flows"); + + let flow_names: Vec<&str> = flows.iter().map(|f| f.name.as_str()).collect(); + assert!(flow_names.contains(&"births")); + assert!(flow_names.contains(&"deaths")); + + // Verify flow details + let births_flow = flows.iter().find(|f| f.name == "births").unwrap(); + assert_eq!(births_flow.equation, "population * 0.02"); + + simlin_free(out_buffer); + simlin_model_unref(model); + simlin_project_unref(proj); + } + } + + #[test] + fn test_model_get_auxs_json() { + let datamodel = TestProject::new("auxs_json") + .stock("population", "100", &["births"], &[], None) + .flow("births", "population * growth_rate", None) + .aux("growth_rate", "0.02", None) + .aux("carrying_capacity", "1000", None) + .aux("density", "population / carrying_capacity", None) + .build_datamodel(); + let proj = open_project_from_datamodel(&datamodel); + + unsafe { + let mut err: *mut SimlinError = ptr::null_mut(); + let model = simlin_project_get_model(proj, ptr::null(), &mut err); + assert!(!model.is_null()); + assert!(err.is_null()); + + let mut out_buffer: *mut u8 = ptr::null_mut(); + let mut out_len: usize = 0; + let mut out_error: *mut SimlinError = ptr::null_mut(); + + simlin_model_get_auxs_json(model, &mut out_buffer, &mut out_len, &mut out_error); + + assert!(out_error.is_null(), "expected no error getting auxs JSON"); + assert!(!out_buffer.is_null(), "expected auxs JSON buffer"); + assert!(out_len > 0, "expected non-empty auxs JSON"); + + let slice = std::slice::from_raw_parts(out_buffer, out_len); + let json_str = std::str::from_utf8(slice).expect("valid utf-8 JSON"); + + let auxs: Vec = + serde_json::from_str(json_str).expect("valid JSON array of auxiliaries"); + + assert_eq!(auxs.len(), 3, "expected 3 auxiliaries"); + + let aux_names: Vec<&str> = auxs.iter().map(|a| a.name.as_str()).collect(); + assert!(aux_names.contains(&"growth_rate")); + assert!(aux_names.contains(&"carrying_capacity")); + assert!(aux_names.contains(&"density")); + + // Verify aux details + let growth_rate = auxs.iter().find(|a| a.name == "growth_rate").unwrap(); + assert_eq!(growth_rate.equation, "0.02"); + + simlin_free(out_buffer); + simlin_model_unref(model); + simlin_project_unref(proj); + } + } + + #[test] + fn test_model_get_stocks_json_empty() { + // Test model with no stocks + let datamodel = TestProject::new("no_stocks") + .aux("constant", "42", None) + .build_datamodel(); + let proj = open_project_from_datamodel(&datamodel); + + unsafe { + let mut err: *mut SimlinError = ptr::null_mut(); + let model = simlin_project_get_model(proj, ptr::null(), &mut err); + assert!(!model.is_null()); + + let mut out_buffer: *mut u8 = ptr::null_mut(); + let mut out_len: usize = 0; + let mut out_error: *mut SimlinError = ptr::null_mut(); + + simlin_model_get_stocks_json(model, &mut out_buffer, &mut out_len, &mut out_error); + + assert!(out_error.is_null()); + assert!(!out_buffer.is_null()); + + let slice = std::slice::from_raw_parts(out_buffer, out_len); + let json_str = std::str::from_utf8(slice).unwrap(); + + let stocks: Vec = serde_json::from_str(json_str).unwrap(); + assert!(stocks.is_empty(), "expected empty stocks array"); + + simlin_free(out_buffer); + simlin_model_unref(model); + simlin_project_unref(proj); + } + } + + #[test] + fn test_model_get_stocks_json_null_model() { + unsafe { + let mut out_buffer: *mut u8 = ptr::null_mut(); + let mut out_len: usize = 0; + let mut out_error: *mut SimlinError = ptr::null_mut(); + + simlin_model_get_stocks_json( + ptr::null_mut(), + &mut out_buffer, + &mut out_len, + &mut out_error, + ); + + assert!(!out_error.is_null(), "expected error for NULL model"); + assert_eq!(simlin_error_get_code(out_error), SimlinErrorCode::Generic); + simlin_error_free(out_error); + } + } + + #[test] + fn test_model_get_stocks_json_null_out_buffer() { + let datamodel = TestProject::new("null_buffer").build_datamodel(); + let proj = open_project_from_datamodel(&datamodel); + + unsafe { + let mut err: *mut SimlinError = ptr::null_mut(); + let model = simlin_project_get_model(proj, ptr::null(), &mut err); + assert!(!model.is_null()); + + let mut out_len: usize = 0; + let mut out_error: *mut SimlinError = ptr::null_mut(); + + simlin_model_get_stocks_json(model, ptr::null_mut(), &mut out_len, &mut out_error); + + assert!(!out_error.is_null(), "expected error for NULL out_buffer"); + assert_eq!(simlin_error_get_code(out_error), SimlinErrorCode::Generic); + simlin_error_free(out_error); + + simlin_model_unref(model); + simlin_project_unref(proj); + } + } } diff --git a/src/pysimlin/simlin/_ffi.py b/src/pysimlin/simlin/_ffi.py index 6d7e58f2..f4a793ed 100644 --- a/src/pysimlin/simlin/_ffi.py +++ b/src/pysimlin/simlin/_ffi.py @@ -246,6 +246,81 @@ def open_json(json_data: bytes) -> Any: return project_ptr +def get_stocks_json(model_ptr: Any) -> bytes: + """Get all stocks in a model as JSON. + + Args: + model_ptr: Pointer to a SimlinModel + + Returns: + JSON-encoded array of stock objects (UTF-8 bytes) + + Raises: + SimlinRuntimeError: If the operation fails + """ + output_ptr = ffi.new("uint8_t **") + output_len_ptr = ffi.new("uintptr_t *") + err_ptr = ffi.new("SimlinError **") + + lib.simlin_model_get_stocks_json(model_ptr, output_ptr, output_len_ptr, err_ptr) + check_out_error(err_ptr, "Get stocks JSON") + + try: + return bytes(ffi.buffer(output_ptr[0], output_len_ptr[0])) + finally: + lib.simlin_free(output_ptr[0]) + + +def get_flows_json(model_ptr: Any) -> bytes: + """Get all flows in a model as JSON. + + Args: + model_ptr: Pointer to a SimlinModel + + Returns: + JSON-encoded array of flow objects (UTF-8 bytes) + + Raises: + SimlinRuntimeError: If the operation fails + """ + output_ptr = ffi.new("uint8_t **") + output_len_ptr = ffi.new("uintptr_t *") + err_ptr = ffi.new("SimlinError **") + + lib.simlin_model_get_flows_json(model_ptr, output_ptr, output_len_ptr, err_ptr) + check_out_error(err_ptr, "Get flows JSON") + + try: + return bytes(ffi.buffer(output_ptr[0], output_len_ptr[0])) + finally: + lib.simlin_free(output_ptr[0]) + + +def get_auxs_json(model_ptr: Any) -> bytes: + """Get all auxiliaries in a model as JSON. + + Args: + model_ptr: Pointer to a SimlinModel + + Returns: + JSON-encoded array of auxiliary objects (UTF-8 bytes) + + Raises: + SimlinRuntimeError: If the operation fails + """ + output_ptr = ffi.new("uint8_t **") + output_len_ptr = ffi.new("uintptr_t *") + err_ptr = ffi.new("SimlinError **") + + lib.simlin_model_get_auxs_json(model_ptr, output_ptr, output_len_ptr, err_ptr) + check_out_error(err_ptr, "Get auxiliaries JSON") + + try: + return bytes(ffi.buffer(output_ptr[0], output_len_ptr[0])) + finally: + lib.simlin_free(output_ptr[0]) + + __all__ = [ "ffi", "lib", @@ -259,6 +334,9 @@ def open_json(json_data: bytes) -> Any: "apply_patch_json", "serialize_json", "open_json", + "get_stocks_json", + "get_flows_json", + "get_auxs_json", "_register_finalizer", "_finalizer_refs", ] diff --git a/src/pysimlin/simlin/_ffi_build.py b/src/pysimlin/simlin/_ffi_build.py index d88912af..8169b602 100644 --- a/src/pysimlin/simlin/_ffi_build.py +++ b/src/pysimlin/simlin/_ffi_build.py @@ -149,6 +149,9 @@ void simlin_model_get_var_names(SimlinModel *model, char **result, uintptr_t max, uintptr_t *out_written, OutError out_error); void simlin_model_get_incoming_links(SimlinModel *model, const char *var_name, char **result, uintptr_t max, uintptr_t *out_written, OutError out_error); SimlinLinks *simlin_model_get_links(SimlinModel *model, OutError out_error); +void simlin_model_get_stocks_json(SimlinModel *model, uint8_t **out_buffer, uintptr_t *out_len, OutError out_error); +void simlin_model_get_flows_json(SimlinModel *model, uint8_t **out_buffer, uintptr_t *out_len, OutError out_error); +void simlin_model_get_auxs_json(SimlinModel *model, uint8_t **out_buffer, uintptr_t *out_len, OutError out_error); SimlinSim *simlin_sim_new(SimlinModel *model, bool enable_ltm, OutError out_error); void simlin_sim_ref(SimlinSim *sim); void simlin_sim_unref(SimlinSim *sim); diff --git a/src/pysimlin/simlin/model.py b/src/pysimlin/simlin/model.py index 4e51ed16..c911ef3e 100644 --- a/src/pysimlin/simlin/model.py +++ b/src/pysimlin/simlin/model.py @@ -7,7 +7,7 @@ from types import TracebackType from ._dt import parse_dt -from ._ffi import ffi, lib, string_to_c, c_to_string, free_c_string, _register_finalizer, check_out_error +from ._ffi import ffi, lib, string_to_c, c_to_string, free_c_string, _register_finalizer, check_out_error, get_stocks_json, get_flows_json, get_auxs_json from .errors import SimlinRuntimeError, ErrorCode from .analysis import Link, LinkPolarity, Loop from .types import Stock, Flow, Aux, TimeSpec, GraphicalFunction, GraphicalFunctionScale, ModelIssue @@ -399,7 +399,10 @@ def stocks(self) -> tuple[Stock, ...]: Tuple of Stock objects representing all stocks in the model """ if self._cached_stocks is None: - model = self._get_model_json() + # Use direct JSON API instead of serializing entire project + json_bytes = get_stocks_json(self._ptr) + stocks_json = json.loads(json_bytes.decode("utf-8")) + stocks_list = [converter.structure(s, JsonStock) for s in stocks_json] self._cached_stocks = tuple( Stock( name=s.name, @@ -413,7 +416,7 @@ def stocks(self) -> tuple[Stock, ...]: dimensions=tuple(s.arrayed_equation.dimensions) if s.arrayed_equation else (), non_negative=s.non_negative, ) - for s in model.stocks + for s in stocks_list ) return self._cached_stocks @@ -426,10 +429,13 @@ def flows(self) -> tuple[Flow, ...]: Tuple of Flow objects representing all flows in the model """ if self._cached_flows is None: - model = self._get_model_json() + # Use direct JSON API instead of serializing entire project + json_bytes = get_flows_json(self._ptr) + flows_json = json.loads(json_bytes.decode("utf-8")) + flows_data = [converter.structure(f, JsonFlow) for f in flows_json] flows_list = [] - for f in model.flows: + for f in flows_data: gf = None if f.graphical_function: gf = self._parse_json_graphical_function(f.graphical_function) @@ -457,10 +463,13 @@ def auxs(self) -> tuple[Aux, ...]: Tuple of Aux objects representing all auxiliary variables in the model """ if self._cached_auxs is None: - model = self._get_model_json() + # Use direct JSON API instead of serializing entire project + json_bytes = get_auxs_json(self._ptr) + auxs_json = json.loads(json_bytes.decode("utf-8")) + auxs_data = [converter.structure(a, JsonAuxiliary) for a in auxs_json] auxs_list = [] - for a in model.auxiliaries: + for a in auxs_data: gf = None if a.graphical_function: gf = self._parse_json_graphical_function(a.graphical_function)