diff --git a/INCREMENTAL_DATA_SPEC.md b/INCREMENTAL_DATA_SPEC.md new file mode 100644 index 000000000..df500e6b0 --- /dev/null +++ b/INCREMENTAL_DATA_SPEC.md @@ -0,0 +1,221 @@ +# Incremental Simulation Data Feature - Implementation Spec + +## Goal + +Enable live waveform plotting in pyTesla by exposing ngspice's `SendData` callback data to Python. During a transient simulation, pyTesla needs to periodically read partial results and update the plot while the simulation is still running. + +## Current State + +The `send_data_callback` is already registered with ngspice in `simulator.cpp` line 71, but the handler `handle_data()` is empty - it discards the data. The `pvecvaluesall` struct that ngspice passes to this callback contains ALL vector values at each simulation timestep. + +The relevant data structures from `sharedspice.h`: + +```cpp +typedef struct vecvalues { + char* name; // Vector name (e.g., "v(top)", "i(l1)") + double creal; // Real value at this timestep + double cimag; // Imaginary value + NG_BOOL is_scale; // True if this is the sweep/time variable + NG_BOOL is_complex; +} vecvalues, *pvecvalues; + +typedef struct vecvaluesall { + int veccount; // Number of vectors + int vecindex; // Current step index + pvecvalues *vecsa; // Array of vector value pointers +} vecvaluesall, *pvecvaluesall; +``` + +## Required Changes + +### 1. Add incremental data buffer to Simulator class (`simulator.h`) + +Add these members to the `Simulator` class: + +```cpp +private: + // Incremental data buffer - written by SendData callback, read by Python + struct IncrementalBuffer { + std::vector vector_names; // Set once by SendInitData + std::vector> data; // [vector_index][step] = value + size_t read_cursor = 0; // How far Python has read + bool initialized = false; // True after SendInitData sets names + std::mutex mutex; // Separate mutex (callback is hot path) + }; + IncrementalBuffer incr_buffer_; +``` + +### 2. Implement handle_init_data() (`simulator.cpp`) + +When ngspice calls `SendInitData` before simulation starts, capture the vector names: + +```cpp +void Simulator::handle_init_data(pvecinfoall data) { + std::lock_guard lock(incr_buffer_.mutex); + incr_buffer_.vector_names.clear(); + incr_buffer_.data.clear(); + incr_buffer_.read_cursor = 0; + incr_buffer_.initialized = false; + + if (data && data->veccount > 0) { + for (int i = 0; i < data->veccount; i++) { + if (data->vecs[i] && data->vecs[i]->vecname) { + incr_buffer_.vector_names.push_back(data->vecs[i]->vecname); + } + } + incr_buffer_.data.resize(incr_buffer_.vector_names.size()); + incr_buffer_.initialized = true; + } +} +``` + +### 3. Implement handle_data() (`simulator.cpp`) + +When ngspice calls `SendData` during simulation, append the values: + +```cpp +void Simulator::handle_data(pvecvaluesall data, int count) { + if (!data || !incr_buffer_.initialized) return; + + std::lock_guard lock(incr_buffer_.mutex); + + for (int i = 0; i < data->veccount && i < (int)incr_buffer_.data.size(); i++) { + if (data->vecsa[i]) { + incr_buffer_.data[i].push_back(data->vecsa[i]->creal); + } + } +} +``` + +### 4. Add Python-accessible methods to Simulator (`simulator.h` / `simulator.cpp`) + +```cpp +// Returns vector names from the incremental buffer +std::vector get_incremental_vector_names() const; + +// Returns new data since last call. +// Returns map: vector_name -> vector of new values since last read. +// Advances the read cursor. +std::map> get_incremental_data(); + +// Returns total number of data points buffered so far +size_t get_incremental_count() const; + +// Clears the incremental buffer (call before starting a new simulation) +void clear_incremental_buffer(); +``` + +Implementation of `get_incremental_data()`: + +```cpp +std::map> Simulator::get_incremental_data() { + std::lock_guard lock(incr_buffer_.mutex); + std::map> result; + + if (!incr_buffer_.initialized || incr_buffer_.data.empty()) { + return result; + } + + size_t total = incr_buffer_.data[0].size(); + if (incr_buffer_.read_cursor >= total) { + return result; // No new data + } + + for (size_t i = 0; i < incr_buffer_.vector_names.size(); i++) { + auto begin = incr_buffer_.data[i].begin() + incr_buffer_.read_cursor; + auto end = incr_buffer_.data[i].end(); + result[incr_buffer_.vector_names[i]] = std::vector(begin, end); + } + + incr_buffer_.read_cursor = total; + return result; +} +``` + +### 5. Expose in pybind11 bindings (`module.cpp`) + +Add to the Simulator class bindings: + +```cpp +.def("get_incremental_vector_names", &ngspice::Simulator::get_incremental_vector_names, + "Get vector names available in the incremental buffer") + +.def("get_incremental_data", &ngspice::Simulator::get_incremental_data, + "Get new simulation data since last call. Returns dict of vector_name -> list of new values.") + +.def("get_incremental_count", &ngspice::Simulator::get_incremental_count, + "Get total number of timesteps buffered so far") + +.def("clear_incremental_buffer", &ngspice::Simulator::clear_incremental_buffer, + "Clear the incremental data buffer") +``` + +### 6. Clear buffer on reset (`simulator.cpp`) + +In the existing `reset()` method, add: + +```cpp +void Simulator::reset() { + // ... existing reset code ... + clear_incremental_buffer(); +} +``` + +## Usage Pattern (Python side - for reference only, don't implement) + +```python +sim = Simulator() +sim.initialize() +sim.load_netlist("circuit.net") +sim.command("set filetype=binary") +sim.clear_incremental_buffer() +sim.run_async() # Non-blocking + +while sim.is_running(): + new_data = sim.get_incremental_data() + if new_data: + # new_data is dict: {"time": [0.1, 0.2, ...], "v(top)": [1.5, 2.3, ...], ...} + update_plot(new_data) + time.sleep(0.1) + +# Final read to get any remaining data +final_data = sim.get_incremental_data() +``` + +## Important Notes + +- The `SendData` callback fires on ngspice's simulation thread, so the buffer mutex must be lightweight (no Python GIL interaction) +- `get_incremental_data()` is called from the Python main thread via pybind11, which handles GIL automatically +- The read cursor pattern avoids copying the entire buffer each poll - only new data is returned +- Don't forget to release the GIL when calling `run_async()` so the background thread can actually run (use `py::call_guard()` in the binding if not already done) +- After implementation, rebuild with: `pip install -e .` from the ngspice repo root + +## Files to Modify + +1. `C:\git\ngspice\src\cpp\simulator.h` - Add IncrementalBuffer struct and new methods +2. `C:\git\ngspice\src\cpp\simulator.cpp` - Implement handle_data(), handle_init_data(), and new methods +3. `C:\git\ngspice\src\bindings\module.cpp` - Add pybind11 bindings for new methods + +## Testing + +After building, verify with: + +```python +from pyngspice._pyngspice import Simulator +sim = Simulator() +sim.initialize() +# Load a simple transient netlist +sim.load_netlist("test.net") +sim.clear_incremental_buffer() +sim.run_async() +import time +while sim.is_running(): + data = sim.get_incremental_data() + if data: + for name, values in data.items(): + print(f" {name}: {len(values)} new points") + time.sleep(0.1) +# Final read +data = sim.get_incremental_data() +print(f"Final: {sum(len(v) for v in data.values()) // max(len(data),1)} total points per vector") +``` diff --git a/LICENSE b/LICENSE new file mode 100644 index 000000000..6e17ffb82 --- /dev/null +++ b/LICENSE @@ -0,0 +1,44 @@ +pyngspice - Python bindings for ngspice circuit simulator +========================================================= + +Copyright (C) 2025 pyngspice contributors + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . + + +Third-Party Licenses +==================== + +pyngspice statically links the ngspice circuit simulator, which contains +code under several licenses. See COPYING for the full ngspice license +details. Summary: + +- ngspice core: Modified BSD (3-clause) + Copyright 1985-2018, Regents of the University of California and others + +- XSPICE: Public domain (Georgia Tech Research Corporation) + Except src/xspice/icm/table: GPLv2 or later + +- numparam (src/frontend/numparam): LGPLv2 or later + +- KLU (src/maths/KLU): LGPLv2 + +- OSDI (src/osdi): Mozilla Public License 2.0 + +- cppduals (src/include/cppduals): Mozilla Public License 2.0 + +- admst (src/spicelib/devices/adms/admst): GPLv3 + +GPLv3 was chosen for pyngspice to ensure compatibility with all of +the above component licenses. diff --git a/pyngspice/netlist.py b/pyngspice/netlist.py index 7089ca389..5ab26fa99 100644 --- a/pyngspice/netlist.py +++ b/pyngspice/netlist.py @@ -83,11 +83,17 @@ def preprocess_netlist(content: str) -> str: # Insert extra components (series resistors) just before .end if extra_components: result = [] + found_end = False for line in output_lines: if line.strip().lower() == '.end': + found_end = True result.append('* --- pyngspice: inductor series resistance expansion ---') result.extend(extra_components) result.append(line) + if not found_end: + # No .end found - append at end to avoid dangling nodes + result.append('* --- pyngspice: inductor series resistance expansion ---') + result.extend(extra_components) output_lines = result # Insert 0V voltage source probes for capacitor current measurement @@ -249,10 +255,14 @@ def _expand_savecurrents(lines: List[str]) -> List[str]: # Insert before .end result = [] + found_end = False for line in lines: if line.strip().lower() == '.end': + found_end = True result.extend(save_lines) result.append(line) + if not found_end: + result.extend(save_lines) return result @@ -343,11 +353,16 @@ def _insert_capacitor_probes(lines: List[str]) -> List[str]: # Insert probe V sources before .end if probe_lines: result = [] + found_end = False for line in modified_lines: if line.strip().lower() == '.end': + found_end = True result.append('* --- pyngspice: capacitor current probes ---') result.extend(probe_lines) result.append(line) + if not found_end: + result.append('* --- pyngspice: capacitor current probes ---') + result.extend(probe_lines) return result return modified_lines @@ -399,11 +414,16 @@ def _insert_targeted_capacitor_probes(lines: List[str], target_caps: set) -> Lis # Insert probe V sources before .end if probe_lines: result = [] + found_end = False for line in modified_lines: if line.strip().lower() == '.end': + found_end = True result.append('* --- pyngspice: targeted capacitor current probes ---') result.extend(probe_lines) result.append(line) + if not found_end: + result.append('* --- pyngspice: targeted capacitor current probes ---') + result.extend(probe_lines) return result return modified_lines diff --git a/pyproject.toml b/pyproject.toml index 704290c55..eb95c6f8b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,7 +10,7 @@ build-backend = "scikit_build_core.build" name = "pyngspice" version = "43.0.0" description = "Python bindings for ngspice circuit simulator (pyTesla backend)" -license = {text = "BSD-3-Clause"} +license = {text = "GPL-3.0-or-later"} requires-python = ">=3.9" authors = [ {name = "ngspice team", email = "ngspice-devel@lists.sourceforge.net"}, @@ -20,7 +20,7 @@ classifiers = [ "Development Status :: 4 - Beta", "Intended Audience :: Developers", "Intended Audience :: Science/Research", - "License :: OSI Approved :: BSD License", + "License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)", "Operating System :: Microsoft :: Windows", "Operating System :: POSIX :: Linux", "Operating System :: MacOS", diff --git a/src/bindings/module.cpp b/src/bindings/module.cpp index 97b3839ed..2726d35f7 100644 --- a/src/bindings/module.cpp +++ b/src/bindings/module.cpp @@ -219,9 +219,10 @@ PYBIND11_MODULE(_pyngspice, m) { )pbdoc") .def("run_now", &ngspice::SimRunner::run_now, + py::call_guard(), py::arg("netlist"), R"pbdoc( - Run simulation synchronously (blocking). + Run simulation synchronously (blocking, releases GIL). Args: netlist: Path to the netlist file @@ -231,9 +232,10 @@ PYBIND11_MODULE(_pyngspice, m) { )pbdoc") .def("run", &ngspice::SimRunner::run, + py::call_guard(), py::arg("netlist"), R"pbdoc( - Queue a simulation for async execution. + Queue a simulation for async execution (releases GIL). Args: netlist: Path to the netlist file @@ -305,9 +307,11 @@ PYBIND11_MODULE(_pyngspice, m) { "Execute a SPICE command") .def("run", &ngspice::Simulator::run, - "Run the simulation (blocking)") + py::call_guard(), + "Run the simulation (blocking, releases GIL)") .def("run_async", &ngspice::Simulator::run_async, + py::call_guard(), "Run simulation in background thread") .def("is_running", &ngspice::Simulator::is_running, @@ -336,5 +340,17 @@ PYBIND11_MODULE(_pyngspice, m) { "Get accumulated output messages") .def("clear_output", &ngspice::Simulator::clear_output, - "Clear accumulated output"); + "Clear accumulated output") + + .def("get_incremental_vector_names", &ngspice::Simulator::get_incremental_vector_names, + "Get vector names available in the incremental buffer") + + .def("get_incremental_data", &ngspice::Simulator::get_incremental_data, + "Get new simulation data since last call. Returns dict of vector_name -> list of new values.") + + .def("get_incremental_count", &ngspice::Simulator::get_incremental_count, + "Get total number of timesteps buffered so far") + + .def("clear_incremental_buffer", &ngspice::Simulator::clear_incremental_buffer, + "Clear the incremental data buffer"); } diff --git a/src/cpp/simulator.cpp b/src/cpp/simulator.cpp index 50a398238..b2bfc2d60 100644 --- a/src/cpp/simulator.cpp +++ b/src/cpp/simulator.cpp @@ -128,13 +128,33 @@ void Simulator::handle_exit(int status, bool immediate, bool quit) { } void Simulator::handle_data(pvecvaluesall data, int count) { - // Called during simulation with vector values - // We don't store this - results are read from raw file + if (!data || !incr_buffer_.initialized) return; + + std::lock_guard lock(incr_buffer_.mutex); + + for (int i = 0; i < data->veccount && i < (int)incr_buffer_.data.size(); i++) { + if (data->vecsa[i]) { + incr_buffer_.data[i].push_back(data->vecsa[i]->creal); + } + } } void Simulator::handle_init_data(pvecinfoall data) { - // Called before simulation with vector info - // We don't need this - info is in raw file + std::lock_guard lock(incr_buffer_.mutex); + incr_buffer_.vector_names.clear(); + incr_buffer_.data.clear(); + incr_buffer_.read_cursor = 0; + incr_buffer_.initialized = false; + + if (data && data->veccount > 0) { + for (int i = 0; i < data->veccount; i++) { + if (data->vecs[i] && data->vecs[i]->vecname) { + incr_buffer_.vector_names.push_back(data->vecs[i]->vecname); + } + } + incr_buffer_.data.resize(incr_buffer_.vector_names.size()); + incr_buffer_.initialized = true; + } } void Simulator::handle_thread_status(bool running) { @@ -168,6 +188,16 @@ Simulator::Simulator(Simulator&& other) noexcept , output_callback_(std::move(other.output_callback_)) , status_callback_(std::move(other.status_callback_)) { + // Transfer incremental buffer under lock + { + std::lock_guard lock(other.incr_buffer_.mutex); + incr_buffer_.vector_names = std::move(other.incr_buffer_.vector_names); + incr_buffer_.data = std::move(other.incr_buffer_.data); + incr_buffer_.read_cursor = other.incr_buffer_.read_cursor; + incr_buffer_.initialized = other.incr_buffer_.initialized; + other.incr_buffer_.read_cursor = 0; + other.incr_buffer_.initialized = false; + } other.initialized_ = false; unregister_instance(&other); register_instance(this); @@ -187,6 +217,17 @@ Simulator& Simulator::operator=(Simulator&& other) noexcept { output_callback_ = std::move(other.output_callback_); status_callback_ = std::move(other.status_callback_); + // Transfer incremental buffer under lock + { + std::lock_guard lock(other.incr_buffer_.mutex); + incr_buffer_.vector_names = std::move(other.incr_buffer_.vector_names); + incr_buffer_.data = std::move(other.incr_buffer_.data); + incr_buffer_.read_cursor = other.incr_buffer_.read_cursor; + incr_buffer_.initialized = other.incr_buffer_.initialized; + other.incr_buffer_.read_cursor = 0; + other.incr_buffer_.initialized = false; + } + other.initialized_ = false; unregister_instance(&other); register_instance(this); @@ -332,6 +373,7 @@ void Simulator::reset() { command("reset"); status_ = SimulationStatus{}; accumulated_output_.clear(); + clear_incremental_buffer(); } SimulationStatus Simulator::get_status() const { @@ -430,4 +472,50 @@ void Simulator::clear_output() { accumulated_output_.clear(); } +// Incremental data methods + +std::vector Simulator::get_incremental_vector_names() { + std::lock_guard lock(incr_buffer_.mutex); + return incr_buffer_.vector_names; +} + +std::map> Simulator::get_incremental_data() { + std::lock_guard lock(incr_buffer_.mutex); + std::map> result; + + if (!incr_buffer_.initialized || incr_buffer_.data.empty()) { + return result; + } + + size_t total = incr_buffer_.data[0].size(); + if (incr_buffer_.read_cursor >= total) { + return result; // No new data + } + + for (size_t i = 0; i < incr_buffer_.vector_names.size(); i++) { + auto begin = incr_buffer_.data[i].begin() + incr_buffer_.read_cursor; + auto end = incr_buffer_.data[i].end(); + result[incr_buffer_.vector_names[i]] = std::vector(begin, end); + } + + incr_buffer_.read_cursor = total; + return result; +} + +size_t Simulator::get_incremental_count() { + std::lock_guard lock(incr_buffer_.mutex); + if (incr_buffer_.data.empty()) { + return 0; + } + return incr_buffer_.data[0].size(); +} + +void Simulator::clear_incremental_buffer() { + std::lock_guard lock(incr_buffer_.mutex); + incr_buffer_.vector_names.clear(); + incr_buffer_.data.clear(); + incr_buffer_.read_cursor = 0; + incr_buffer_.initialized = false; +} + } // namespace ngspice diff --git a/src/cpp/simulator.h b/src/cpp/simulator.h index 1a91fdab8..605228cb8 100644 --- a/src/cpp/simulator.h +++ b/src/cpp/simulator.h @@ -11,6 +11,7 @@ #include #include +#include #include #include #include @@ -245,6 +246,30 @@ public: */ void clear_output(); + /** + * @brief Get vector names from the incremental data buffer + */ + std::vector get_incremental_vector_names(); + + /** + * @brief Get new simulation data since last call + * + * Returns a map of vector_name -> list of new values since the + * last call. Advances an internal read cursor so each data point + * is returned exactly once. + */ + std::map> get_incremental_data(); + + /** + * @brief Get total number of timesteps buffered so far + */ + size_t get_incremental_count(); + + /** + * @brief Clear the incremental data buffer + */ + void clear_incremental_buffer(); + /** * @brief Get the output directory for raw files */ @@ -285,6 +310,16 @@ private: std::function output_callback_; std::function status_callback_; + // Incremental data buffer - written by SendData callback, read by Python + struct IncrementalBuffer { + std::vector vector_names; // Set once by SendInitData + std::vector> data; // [vector_index][step] = value + size_t read_cursor = 0; // How far Python has read + bool initialized = false; // True after SendInitData sets names + std::mutex mutex; // Separate mutex (callback is hot path) + }; + IncrementalBuffer incr_buffer_; + // Global instance map for callback routing static std::mutex instances_mutex_; static std::vector instances_;