Layer 0 — Numeric primitives + Sparse linear algebra¶
The foundation layer. Everything above uses it; nothing in it depends on anything else above. Choosing it once, correctly, lets every layer above compose naturally; choosing it poorly means v2 inherits v1's mistakes.
Public surface¶
// pulsim/numeric/types.hpp
namespace pulsim {
using Real = double; // overridable -DPULSIM_V2_REAL_TYPE=float
using Index = std::int32_t; // exactly 4 B, signed
using Size = std::size_t;
inline constexpr Index kInvalidIndex = -1;
inline constexpr Index kGround = -1;
}
// pulsim/numeric/dense.hpp
namespace pulsim {
using Vector = Eigen::Matrix<Real, Eigen::Dynamic, 1>;
using DenseMatrix = Eigen::Matrix<Real, Eigen::Dynamic, Eigen::Dynamic>;
using Vector2 / Vector3 / Vector4 / Matrix2 / Matrix3 / Matrix4;
}
// pulsim/numeric/concepts.hpp
namespace pulsim::numeric {
template <typename T> concept FloatingPoint;
template <typename T> concept IndexLike;
}
// pulsim/sparse/matrix.hpp
namespace pulsim::sparse {
using Matrix = Eigen::SparseMatrix<Real, Eigen::ColMajor, Index>;
using Triplet = Eigen::Triplet<Real, Index>;
Size stamp_dense(Matrix&, Index row, Index col, const DenseMatrix&);
void reserve_capacity(Matrix&, Size nnz_estimate);
void compress_in_place(Matrix&);
}
// pulsim/sparse/solver.hpp
namespace pulsim::sparse {
class DirectSolver; // abstract: analyze → factorize → solve
class SparseLuSolver; // concrete: Eigen::SparseLU
std::unique_ptr<DirectSolver> make_default_solver();
}
That's the entire Layer 0 contract. ~250 LOC across 5 headers.
Design decisions and why¶
Real = double by default, overridable to float¶
Future-proofs the codebase for embedded HIL / FPGA targets without
making the default API template-parameterized. A build with
-DPULSIM_V2_REAL_TYPE=float flips Real to single precision; the
entire tree recompiles in float. Layer 2 + 3 templates accept any
floating-point type (so AD scalars and float work in the same
template), Layer 4 + 5 work in Real for numeric stability.
Index = std::int32_t (signed, exactly 4 B)¶
- 4 B fits 2× more indices per cache line vs 8 B. Cache density matters in stamping hot loops.
- Matches int32 sparse-solver index arrays. KLU, UMFPACK, MKL Pardiso expect int32 by default — no wrapper, no copy.
- Signed allows
-1as sentinel for ground / "not found" without burning bit-31. 2^31 − 1 ≈ 2 G nodesis plenty for any conceivable power-electronics circuit (large industrial inverter ≤ 1000 nodes).
Locked in via static_assert(sizeof(Index) == 4) at the top of
numeric/types.hpp — accidentally widening to 8 B halts the build.
Sparse matrix: ColMajor, Index storage type¶
- ColMajor matches every direct sparse solver's native input. RowMajor would force a transpose-and-copy at every solver call.
- ColMajor matches the MNA stamping pattern. Each device touches a few entries in a few columns; ColMajor means those entries live next to each other in memory.
Indexas the storage type keeps the matrix's index arrays packed at int32.
DirectSolver separates analyze / factorize / solve¶
The lifecycle contract is the foundation of the Layer 4 PWL state-space cache:
| Step | Frequency | Cost |
|---|---|---|
analyze |
ONCE per topology change | O(n·log n) |
factorize |
ONCE per matrix-value change | O(n^1.5) typical |
solve |
EVERY step | O(n) triangular |
For a stable switch combination, sparsity AND values are constant
across many steps. analyze + factorize run ONCE; many solve calls
reuse the cached factor. This is the 5-10× speedup over v1's
current linear_factor_cache which conflates the two phases.
Out-of-order calls throw std::logic_error with a clear message
naming the missing prerequisite. The contract is enforced at the
type level, not by documentation.
SparseLuSolver as the reference backend¶
Layer 0 ships ONE concrete solver: Eigen::SparseLU with explicit
COLAMDOrdering<Index> (avoids the int32/int64 ordering-template
collision Eigen has by default).
Future backends (KLU, UMFPACK, MKL Pardiso, HYPRE) can register
through the same DirectSolver interface without modifying any
consumer. The factory make_default_solver() returns whatever the
runtime hint plus matrix-property heuristics select.
Eigen wrapping is INTENTIONALLY thin¶
Pure aliases + a handful of stamping utilities. We do NOT re-implement linear algebra. Eigen is mature, well-optimised, already a v1 dependency.
If Eigen ever needs replacing (e.g. GPU kernels via CUDA), Layer 0's
small surface IS the swap surface — not 50 files using Eigen::VectorXd
directly. The smaller the surface, the cheaper the future swap.
What Layer 0 does NOT do¶
- No device models. Real + SparseMatrix is not a device.
- No Newton-Raphson, no integrator, no event detection — those live in Layer 5.
- No graph / topology / KCL / KVL — those live in Layer 1.
- No state space — Layer 4.
Future layers compose by ADDING capability above Layer 0; Layer 0
never grows to accommodate them (a banded solver, for example,
would be a NEW class implementing DirectSolver, not a modification
of SparseLuSolver).
Validation¶
pulsim_v2_layer0_tests (built in build/core/) covers:
- Numeric type contracts (
sizeof(Index) == 4,is_signed,FloatingPointconcept selectivity). - Sparse matrix triplet assembly,
stamp_denseaccumulation,compress_in_placeround-trip. DirectSolverlifecycle: SPD 3×3 solves to 1e-12, out-of-order calls throw with diagnostic, multiple factorize calls reuse symbolic cache, polymorphic dispatch through abstract interface.
Current: 80 assertions / 19 test cases, all green.