diff --git a/.gitignore b/.gitignore
index d75b2e63..1d6f4225 100644
--- a/.gitignore
+++ b/.gitignore
@@ -14,9 +14,11 @@ tasks/SECRET_CONFIG.py
*.stderr
*.stdout
*~
+*.so
####################################################################################################
+.venv/
du.log
__pycache__
diff --git a/PySpice/Spice/HSpice/RawFile.py b/PySpice/Spice/HSpice/RawFile.py
new file mode 100644
index 00000000..b2982a11
--- /dev/null
+++ b/PySpice/Spice/HSpice/RawFile.py
@@ -0,0 +1,247 @@
+import logging
+
+import numpy as np
+
+from PySpice.Probe.WaveForm import (AcAnalysis, DcAnalysis, OperatingPoint,
+ TransientAnalysis, WaveForm)
+from PySpice.Unit import u_A, u_Degree, u_Hz, u_s, u_V
+
+
+class AnalysisList(list):
+ def __init__(self, analyses, measurements=None):
+ super().__init__(analyses)
+ self._measurements = measurements or {}
+
+ @property
+ def measurements(self):
+ return self._measurements
+
+ def __getattr__(self, name):
+ measurements = self.__dict__.get("_measurements", {})
+ if name in measurements:
+ return measurements[name]
+ if name.lower() in measurements:
+ return measurements[name.lower()]
+ raise AttributeError(name)
+
+ def __getitem__(self, item):
+ if isinstance(item, (int, slice)):
+ return super().__getitem__(item)
+ if isinstance(item, str):
+ if item in self._measurements:
+ return self._measurements[item]
+ if item.lower() in self._measurements:
+ return self._measurements[item.lower()]
+ raise IndexError(item)
+ raise KeyError(item)
+
+
+_module_logger = logging.getLogger(__name__)
+
+
+class HSpiceRawFile:
+ def __init__(
+ self,
+ data,
+ simulation=None,
+ measurements=None,
+ op_nodes=None,
+ op_branches=None,
+ analysis_type=None,
+ ):
+ self.data = data
+ self._simulation = simulation
+ self.measurements = measurements or {}
+ self.op_nodes = op_nodes or {}
+ self.op_branches = op_branches or {}
+ self._analysis_type = analysis_type
+
+ @property
+ def simulation(self):
+ return self._simulation
+
+ @simulation.setter
+ def simulation(self, value):
+ self._simulation = value
+
+ def to_analysis(self):
+ if self.data is None and self._analysis_type == "o":
+ # This is an operating point simulation!
+ nodes = [
+ WaveForm.from_unit_values(name, u_V(np.array([val])))
+ for name, val in self.op_nodes.items()
+ ]
+ branches = [
+ WaveForm.from_unit_values(name, u_A(np.array([val])))
+ for name, val in self.op_branches.items()
+ ]
+ return OperatingPoint(
+ simulation=self.simulation, nodes=nodes, branches=branches
+ )
+
+ # data is a list of sweeps returned by hspice_read
+ # data[0] is sweeps tuple (sweep, sweepValues, dataList)
+ sweeps = self.data[0][0]
+ scale_name_outer = self.data[0][1]
+ scale_name_inner, sweep_values, data_list = sweeps
+
+ if len(data_list) == 0:
+ analysis_type = self._analysis_type
+ if (
+ not analysis_type
+ and self.simulation
+ and hasattr(self.simulation, "_analyses")
+ ):
+ analyses_keys = set(self.simulation._analyses.keys())
+ if "ac" in analyses_keys:
+ analysis_type = "a"
+ elif "dc" in analyses_keys:
+ analysis_type = "s"
+ elif "op" in analyses_keys:
+ analysis_type = "o"
+ elif "tran" in analyses_keys:
+ analysis_type = "t"
+
+ analysis_type = analysis_type or "t"
+
+ if analysis_type == "a":
+ analysis = AcAnalysis(
+ simulation=self.simulation,
+ frequency=WaveForm.from_array("frequency", np.array([])),
+ nodes=[],
+ branches=[],
+ internal_parameters=[],
+ )
+ elif analysis_type == "s":
+ analysis = DcAnalysis(
+ simulation=self.simulation,
+ sweep=WaveForm.from_array("sweep", np.array([])),
+ nodes=[],
+ branches=[],
+ internal_parameters=[],
+ )
+ elif analysis_type == "o":
+ analysis = OperatingPoint(
+ simulation=self.simulation,
+ nodes=[],
+ branches=[],
+ )
+ else:
+ analysis = TransientAnalysis(
+ simulation=self.simulation,
+ time=WaveForm.from_array("time", np.array([])),
+ nodes=[],
+ branches=[],
+ internal_parameters=[],
+ )
+ analysis._measurements = dict(self.measurements)
+ return analysis
+
+ # Derive analysis type and scale unit once from the parser-provided name.
+ _sn_upper = scale_name_outer.upper()
+ if _sn_upper == "TIME":
+ _analysis_type_outer = "transient"
+ _scale_unit_outer = u_s
+ elif _sn_upper in ("FREQUENCY", "HERTZ"):
+ _analysis_type_outer = "ac"
+ _scale_unit_outer = u_Hz
+ else:
+ _analysis_type_outer = "dc"
+ if _sn_upper == "VOLTS":
+ _scale_unit_outer = u_V
+ elif _sn_upper == "AMPS":
+ _scale_unit_outer = u_A
+ elif _sn_upper == "DEG_C":
+ _scale_unit_outer = u_Degree
+ else:
+ _scale_unit_outer = None
+
+ analyses = []
+ for res_dict in data_list:
+ # Resolve the dict key using the parser-provided scale_name_outer.
+ _key_lower = scale_name_outer.lower()
+ if _key_lower in res_dict:
+ scale_name = _key_lower
+ else:
+ scale_name = next(
+ (k for k in res_dict if k.lower() == _key_lower), None
+ )
+ if scale_name is None:
+ raise KeyError(
+ f"Independent variable '{scale_name_outer}' not found in sweep dict "
+ f"(keys: {list(res_dict.keys())})"
+ )
+ scale_values = res_dict[scale_name]
+
+ analysis_type = _analysis_type_outer
+ scale_unit = _scale_unit_outer
+
+ # Build WaveForms
+ abscissa_name = scale_name_outer.lower()
+ if analysis_type == "ac":
+ abscissa_name = "frequency"
+
+ if scale_unit:
+ abscissa_waveform = WaveForm.from_unit_values(
+ abscissa_name, scale_unit(scale_values)
+ )
+ else:
+ abscissa_waveform = WaveForm.from_array(abscissa_name, scale_values)
+
+ nodes = []
+ branches = []
+
+ for k, v in res_dict.items():
+ if k == scale_name:
+ continue
+
+ # Determine if it's a current or voltage
+ if k.lower().startswith("i("):
+ # Simplify name: strip i( and trailing )
+ simplified_name = k[2:]
+ if simplified_name.endswith(")"):
+ simplified_name = simplified_name[:-1]
+ branches.append(
+ WaveForm.from_unit_values(
+ simplified_name, u_A(v), abscissa=abscissa_waveform
+ )
+ )
+ else:
+ simplified_name = k
+ nodes.append(
+ WaveForm.from_unit_values(
+ simplified_name, u_V(v), abscissa=abscissa_waveform
+ )
+ )
+
+ if analysis_type == "transient":
+ analysis = TransientAnalysis(
+ simulation=self.simulation,
+ time=abscissa_waveform,
+ nodes=nodes,
+ branches=branches,
+ internal_parameters=[],
+ )
+ elif analysis_type == "ac":
+ analysis = AcAnalysis(
+ simulation=self.simulation,
+ frequency=abscissa_waveform,
+ nodes=nodes,
+ branches=branches,
+ internal_parameters=[],
+ )
+ else:
+ analysis = DcAnalysis(
+ simulation=self.simulation,
+ sweep=abscissa_waveform,
+ nodes=nodes,
+ branches=branches,
+ internal_parameters=[],
+ )
+
+ analysis._measurements = dict(self.measurements)
+ analyses.append(analysis)
+
+ if len(analyses) == 1:
+ return analyses[0]
+ return AnalysisList(analyses, dict(self.measurements))
diff --git a/PySpice/Spice/HSpice/Server.py b/PySpice/Spice/HSpice/Server.py
new file mode 100644
index 00000000..c1e0e42f
--- /dev/null
+++ b/PySpice/Spice/HSpice/Server.py
@@ -0,0 +1,375 @@
+import fcntl
+import logging
+import os
+import re
+import shutil
+import subprocess
+import tempfile
+import time
+
+from .hspicefile import hspice_read
+from .RawFile import HSpiceRawFile
+
+_module_logger = logging.getLogger(__name__)
+
+
+def parse_spice_value(val_str):
+ val_str = val_str.strip().lower()
+ if val_str.endswith("f"):
+ return float(val_str[:-1]) * 1e-15
+ elif val_str.endswith("p"):
+ return float(val_str[:-1]) * 1e-12
+ elif val_str.endswith("n"):
+ return float(val_str[:-1]) * 1e-9
+ elif val_str.endswith("u"):
+ return float(val_str[:-1]) * 1e-6
+ elif val_str.endswith("meg"):
+ return float(val_str[:-3]) * 1e6
+ elif val_str.endswith("m"):
+ return float(val_str[:-1]) * 1e-3
+ elif val_str.endswith("k"):
+ return float(val_str[:-1]) * 1e3
+ elif val_str.endswith("g"):
+ return float(val_str[:-1]) * 1e9
+ else:
+ try:
+ return float(val_str)
+ except ValueError:
+ m = re.match(r"^([\d\.\-+e]+)", val_str)
+ if m:
+ return float(m.group(1))
+ raise
+
+
+def acquire_hspice_lock(limit, timeout=None):
+ """
+ Acquire one of the slot locks (0 to limit-1) using fcntl.flock.
+ Returns (lock_file_descriptor, slot_index).
+ Blocks until a slot becomes available.
+ """
+ lock_dir = os.path.join(
+ tempfile.gettempdir(), f"pyspice_hspice_locks_{os.getuid()}"
+ )
+ os.makedirs(lock_dir, mode=0o700, exist_ok=True)
+
+ # Ensure lock files exist with correct permissions
+ lock_files = []
+ for i in range(limit):
+ path = os.path.join(lock_dir, f"lock_{i}")
+ fd = os.open(path, os.O_RDWR | os.O_CREAT, 0o600)
+ lock_files.append((fd, i))
+
+ start = time.monotonic()
+ while True:
+ for fd, idx in lock_files:
+ try:
+ fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
+ for other_fd, other_idx in lock_files:
+ if other_idx != idx:
+ os.close(other_fd)
+ return fd, idx
+ except BlockingIOError:
+ continue
+ if timeout is not None and time.monotonic() - start > timeout:
+ for fd, _ in lock_files:
+ os.close(fd)
+ raise TimeoutError("Timed out waiting for an HSPICE slot.")
+ time.sleep(0.05)
+
+
+class HSpiceServer:
+ SPICE_COMMAND = "hspice"
+
+ def __init__(self, **kwargs):
+ self._spice_command = kwargs.get("spice_command") or self.SPICE_COMMAND
+ concurrency_limit = kwargs.get("concurrency_limit")
+ if concurrency_limit is None:
+ concurrency_limit = os.environ.get("PYSPICE_HSPICE_CONCURRENCY_LIMIT")
+ if concurrency_limit is None:
+ concurrency_limit = 4
+ self._concurrency_limit = int(concurrency_limit)
+ if self._concurrency_limit < 1:
+ raise ValueError("concurrency_limit must be at least 1")
+
+ timeout = kwargs.get("timeout")
+ if timeout is None:
+ timeout = os.environ.get("PYSPICE_HSPICE_TIMEOUT")
+ if timeout is None:
+ timeout = 300
+ self._timeout = float(timeout)
+
+ def __call__(self, spice_input):
+ logger = _module_logger.getChild("HSpiceServer")
+ logger.info("Running HSPICE simulation")
+
+ # Create temporary directory
+ tmp_dir = tempfile.mkdtemp(prefix="pyspice_hspice_")
+ try:
+ input_file = os.path.join(tmp_dir, "input.sp")
+ output_base = os.path.join(tmp_dir, "output")
+
+ netlist_str = str(spice_input)
+ if ".options post" not in netlist_str.lower():
+ netlist_str += "\n.options post=1\n"
+
+ with open(input_file, "w") as f:
+ f.write(netlist_str)
+
+ # Acquire the lock to limit the concurrency
+ lock_fd, slot_idx = acquire_hspice_lock(
+ self._concurrency_limit,
+ timeout=self._timeout,
+ )
+
+ try:
+ # Submit the job
+ # We run in the temporary directory to avoid cluttering current directory
+ cmd = [self._spice_command, "-i", "input.sp", "-o", "output"]
+ logger.info(f"Executing: {' '.join(cmd)}")
+ try:
+ res = subprocess.run(
+ cmd,
+ cwd=tmp_dir,
+ capture_output=True,
+ text=True,
+ timeout=self._timeout,
+ )
+ except subprocess.TimeoutExpired as e:
+ logger.error(
+ f"HSPICE simulation timed out after {self._timeout} seconds."
+ )
+ raise TimeoutError(
+ f"HSPICE simulation timed out after {self._timeout} seconds."
+ ) from e
+ finally:
+ os.close(lock_fd)
+
+ lis_file = os.path.join(tmp_dir, "output.lis")
+ lis_content = ""
+ if os.path.exists(lis_file):
+ with open(lis_file, "r", errors="ignore") as f:
+ lis_content = f.read()
+ # Check for errors in lis file
+ if (
+ "**error**" in lis_content.lower()
+ or "*error*" in lis_content.lower()
+ or "aborted" in lis_content.lower()
+ ):
+ raise NameError(
+ f"HSPICE simulation failed. Log output:\n{lis_content}"
+ )
+
+ if res.returncode != 0:
+ raise NameError(
+ f"HSPICE process exited with code {res.returncode}.\n"
+ f"Stderr: {res.stderr}\n"
+ f"Stdout: {res.stdout}\n"
+ f"Log: {lis_content}"
+ )
+
+ # Find generated binary raw output files
+ raw_file_path = None
+ analysis_type = None
+ # Prioritize standard waveform files: transient (.tr), ac (.ac), dc sweep (.sw)
+ for ext in (".tr", ".ac", ".sw"):
+ for f in os.listdir(tmp_dir):
+ if f.startswith("output.") and ext in f:
+ if (
+ f[-1].isdigit()
+ and not f.endswith(".lis")
+ and not f.endswith(".st0")
+ and not f.endswith(".ic0")
+ and not f.endswith(".mc0")
+ ):
+ raw_file_path = os.path.join(tmp_dir, f)
+
+ # Get "a,s or t" for analysis type
+ detected_type = ext[1]
+ if detected_type in "ast":
+ analysis_type = detected_type
+ break
+ if raw_file_path:
+ break
+
+ if raw_file_path is None:
+ # Fallback to any file starting with output. and ending with a digit
+ for f in os.listdir(tmp_dir):
+ if f.startswith("output.") and f[-1].isdigit():
+ if not (
+ f.endswith(".lis")
+ or f.endswith(".st0")
+ or f.endswith(".ic0")
+ or f.endswith(".mc0")
+ or f.endswith(".pa0")
+ ):
+ raw_file_path = os.path.join(tmp_dir, f)
+ break
+
+ if raw_file_path is None or not os.path.exists(raw_file_path):
+ # Let's check if it was an operating point simulation (OP)
+ ic0_file = os.path.join(tmp_dir, "output.ic0")
+ if os.path.exists(ic0_file) and os.path.exists(lis_file):
+ logger.info(
+ "Operating point simulation detected. Parsing .ic0 and .lis files..."
+ )
+
+ # Parse node voltages from .ic0
+ nodes = {}
+ with open(ic0_file, "r") as f:
+ lines = f.readlines()
+ in_nodeset = False
+ for line in lines:
+ line = line.strip()
+ if line.startswith(".nodeset"):
+ in_nodeset = True
+ continue
+ if in_nodeset:
+ if line.startswith("***"):
+ break
+ if line.startswith("+"):
+ parts = line[1:].split("=")
+ if len(parts) == 2:
+ node_name = parts[0].strip().lower()
+ if node_name.startswith(
+ "v("
+ ) and node_name.endswith(")"):
+ node_name = node_name[2:-1]
+ try:
+ node_val = parse_spice_value(parts[1].strip())
+ nodes[node_name] = node_val
+ except Exception:
+ pass
+
+ # Parse branch currents from .lis
+ branches = {}
+ with open(lis_file, "r", errors="ignore") as f:
+ lis_content = f.read()
+
+ lines = lis_content.splitlines()
+ in_section = False
+ section_lines = []
+ for line in lines:
+ if "voltage sources" in line.lower():
+ in_section = True
+ continue
+ if in_section:
+ if "total voltage source" in line.lower() or (
+ line.strip().startswith("****")
+ and "voltage sources" not in line.lower()
+ ):
+ break
+ section_lines.append(line)
+
+ elements = []
+ for line in section_lines:
+ tokens = line.strip().split()
+ if not tokens:
+ continue
+ if tokens[0].lower() == "element":
+ elements = tokens[1:]
+ elif tokens[0].lower() == "current":
+ currents = tokens[1:]
+ for el, curr in zip(elements, currents):
+ clean_el = re.sub(r"^\d+:", "", el).lower()
+ try:
+ current_val = parse_spice_value(curr)
+ branches[clean_el] = current_val
+ except Exception:
+ pass
+
+ return HSpiceRawFile(
+ data=None,
+ op_nodes=nodes,
+ op_branches=branches,
+ analysis_type="o",
+ )
+ else:
+ raise NameError(
+ f"No HSPICE raw output files (.tr0, .ac0, .sw0) were found in {tmp_dir}"
+ )
+
+ logger.info(f"Reading output file: {raw_file_path}")
+ data = hspice_read(raw_file_path, debug=0)
+ if data is None:
+ raise NameError(
+ f"Failed to parse HSPICE output file {raw_file_path} using hspice_read"
+ )
+
+ # Find and parse measurement files (e.g. output.mt0, output.ms0, output.ma0)
+ measurements = {}
+ for f in os.listdir(tmp_dir):
+ if f.startswith("output.m") and f[-1].isdigit():
+ detected_type = f[-2]
+ if detected_type in "ast":
+ analysis_type = detected_type
+ meas_file_path = os.path.join(tmp_dir, f)
+ logger.info(f"Parsing measurement file: {meas_file_path}")
+ try:
+ with open(meas_file_path, "r") as mf:
+ lines = mf.readlines()
+ content_lines = [
+ l.strip()
+ for l in lines
+ if l.strip()
+ and not l.startswith("$")
+ and not l.startswith(".")
+ ]
+ all_tokens = []
+ for line in content_lines:
+ all_tokens.extend(line.split())
+
+ def is_numeric_value(val):
+ try:
+ float(val)
+ return True
+ except ValueError:
+ return val.lower() == "failed"
+
+ header_len = 0
+ for token in all_tokens:
+ if is_numeric_value(token):
+ break
+ header_len += 1
+
+ names = all_tokens[:header_len]
+ data_tokens = all_tokens[header_len:]
+
+ if names and data_tokens:
+ if len(data_tokens) % header_len != 0:
+ raise ValueError(
+ "Measurement line width mismatch: "
+ f"expected rows of {header_len} values, "
+ f"got {len(data_tokens)} data tokens"
+ )
+ is_sweep = len(data_tokens) > header_len
+ for i, name in enumerate(names):
+ name_lower = name.lower()
+ if name_lower not in (
+ "temper",
+ "alter#",
+ "temper#",
+ "alter",
+ ):
+ vals = []
+ for j in range(i, len(data_tokens), header_len):
+ val = data_tokens[j]
+ try:
+ vals.append(float(val))
+ except ValueError:
+ pass
+ if vals:
+ if is_sweep:
+ measurements[name_lower] = vals
+ else:
+ measurements[name_lower] = vals[0]
+ except Exception as e:
+ logger.warning(
+ f"Failed to parse measurement file {meas_file_path}: {e}"
+ )
+
+ return HSpiceRawFile(
+ data, measurements=measurements, analysis_type=analysis_type
+ )
+
+ finally:
+ shutil.rmtree(tmp_dir)
diff --git a/PySpice/Spice/HSpice/Simulator.py b/PySpice/Spice/HSpice/Simulator.py
new file mode 100644
index 00000000..41b81b71
--- /dev/null
+++ b/PySpice/Spice/HSpice/Simulator.py
@@ -0,0 +1,33 @@
+import logging
+
+from ..SimulatorBase import Simulator
+from .Server import HSpiceServer
+
+_module_logger = logging.getLogger(__name__)
+
+
+class HSpiceSimulator(Simulator):
+ _logger = _module_logger.getChild("HSpiceSimulator")
+ SIMULATOR = "hspice"
+
+ def __init__(self, **kwargs):
+ server_kwargs = {
+ x: kwargs[x]
+ for x in ("spice_command", "concurrency_limit", "timeout")
+ if x in kwargs
+ }
+ self._spice_server = HSpiceServer(**server_kwargs)
+
+ @property
+ def version(self):
+ return ""
+
+ def customise(self, simulation):
+ # Add post option to simulation options if not present
+ if "post" not in simulation._options and "POST" not in simulation._options:
+ simulation.options(post=1)
+
+ def run(self, simulation, *args, **kwargs):
+ raw_file = self._spice_server(spice_input=str(simulation))
+ raw_file.simulation = simulation
+ return raw_file.to_analysis()
diff --git a/PySpice/Spice/HSpice/__init__.py b/PySpice/Spice/HSpice/__init__.py
new file mode 100644
index 00000000..bc4f4896
--- /dev/null
+++ b/PySpice/Spice/HSpice/__init__.py
@@ -0,0 +1 @@
+# PySpice HSpice backend package
diff --git a/PySpice/Spice/HSpice/hspicefile/__init__.py b/PySpice/Spice/HSpice/hspicefile/__init__.py
new file mode 100644
index 00000000..05b8f842
--- /dev/null
+++ b/PySpice/Spice/HSpice/hspicefile/__init__.py
@@ -0,0 +1,37 @@
+from typing import List, Tuple, Union
+
+try:
+ from .. import _hspice_read
+except ImportError:
+ _hspice_read = None
+
+
+def hspice_read(filename, debug=0) -> Union[List[Tuple], None]:
+ """
+ Read an HSPICE raw results file.
+
+ Parameters:
+ filename: Path to the HSPICE raw file to read.
+ debug: Debug level passed to the reader.
+
+ Returns:
+ A list containing a single tuple of:
+ - sweeps (tuple):
+ - sweep_name (str or None): Name of sweep parameter.
+ - sweep_values (np.ndarray or None): 1D array of sweep parameter values.
+ - data_list (list of dict): List of dicts mapping variable names to
+ 1D np.ndarray of values. Key names are lowercase with prefix "v(" stripped.
+ - scale_name (str): Name of independent variable.
+ - None (placeholder)
+ - title (str): Plot title.
+ - date (str): File creation date/time string.
+ - None (placeholder)
+
+ Raises:
+ ImportError: If the optional _hspice_read extension is unavailable.
+ """
+ if _hspice_read is None:
+ raise ImportError(
+ "HSpice read extension _hspice_read is not compiled/available."
+ )
+ return _hspice_read.hspice_read(filename, debug)
diff --git a/PySpice/Spice/HSpice/hspicefile/docs/block_structure.svg b/PySpice/Spice/HSpice/hspicefile/docs/block_structure.svg
new file mode 100644
index 00000000..8f16f114
--- /dev/null
+++ b/PySpice/Spice/HSpice/hspicefile/docs/block_structure.svg
@@ -0,0 +1,38 @@
+
diff --git a/PySpice/Spice/HSpice/hspicefile/docs/file_structure.svg b/PySpice/Spice/HSpice/hspicefile/docs/file_structure.svg
new file mode 100644
index 00000000..2ed16339
--- /dev/null
+++ b/PySpice/Spice/HSpice/hspicefile/docs/file_structure.svg
@@ -0,0 +1,55 @@
+
diff --git a/PySpice/Spice/HSpice/hspicefile/docs/hspice_file_structure.md b/PySpice/Spice/HSpice/hspicefile/docs/hspice_file_structure.md
new file mode 100644
index 00000000..b92ca352
--- /dev/null
+++ b/PySpice/Spice/HSpice/hspicefile/docs/hspice_file_structure.md
@@ -0,0 +1,162 @@
+# Synopsys HSPICE Binary Format Reference Manual
+
+This document provides a comprehensive technical reference for the Synopsys HSPICE post-processor binary database format (generated via `.options post=1`). It details the file-level block chaining, block-level encapsulation headers, header-block metadata, and data-block hybrid precision record layout.
+
+---
+
+## 1. File-Level Layout
+
+An HSPICE binary output database (such as `.tr0`, `.ac0`, or `.sw0` files) is structured as a contiguous sequence of self-describing **blocks**. Every file begins with exactly one **Header Block** (containing text-based metadata) followed by one or more **Data Blocks** containing the simulation records.
+
+
+
+---
+
+## 2. Block-Level Structure
+
+Each individual block is encapsulated in a wrapper consisting of a **16-byte Block Head**, a variable-size **Data Payload**, and a **4-byte Block Tail**.
+
+
+
+### 2.1 The Block Head (16 Bytes)
+The block head consists of four 32-bit integers (`int`):
+1. **Endian Marker 1** (Offset `0`): Constant `0x00000004` (in file endianness).
+2. **Unused / Record Count** (Offset `4`): Typically `1` or unused.
+3. **Endian Marker 2** (Offset `8`): Constant `0x00000004` (in file endianness).
+4. **Payload Size** (Offset `12`): 32-bit integer indicating the exact size in bytes of the **Data Payload** section.
+
+#### Endianness Determination
+During file loading, a parser reads the block head. By comparing the byte ordering of Endian Marker 1:
+* If bytes read are `04 00 00 00` (value `4` in little-endian), the file matches the current standard little-endian host architecture (`swap = 0`).
+* If bytes read are `00 00 00 04` (value `4` in big-endian), byte-swapping must be performed on all subsequent integers, floats, and doubles read from the file (`swap = 1`).
+
+### 2.2 The Block Tail (4 Bytes)
+The block tail consists of a single 32-bit integer representing the size in bytes of the preceding Data Payload section. It acts as a trailing verification checksum. If this value does not match the Payload Size in the Block Head, the file block is considered corrupted.
+
+---
+
+## 3. The Header Block Payload
+
+The payload of the very first block in the file contains plain text metadata (no newlines, pad spaces are used to maintain column alignment) and ends with the token `$&%#`.
+
+### 3.1 Format Descriptor String (First 20 or 24 characters)
+The very first characters of the Header Block form a fixed format descriptor string:
+* **Legacy Format Version (`9601`)**: 20 characters total:
+ * `0..3`: Number of monitored variables (e.g., `0005`, including scale variable).
+ * `4..7`: Number of monitored probes (`parsedProbes`).
+ * `8..11`: Number of sweep dimensions/parameters (`numOfSweeps`). `0000` means no sweep; `0001` means one swept dimension.
+ * `12..15`: Padding zeros (`0000`).
+ * `16..19`: Format version identifier string (`9601`).
+* **Modern Format Versions (`2001`, `2013`)**: 24 characters total:
+ * `0..3`: Number of monitored variables (e.g., `0005`, including scale variable).
+ * `4..7`: Number of monitored probes (`parsedProbes`).
+ * `8..11`: Number of sweep dimensions/parameters (`numOfSweeps`). `0000` means no sweep; `0001` means one swept dimension.
+ * `12..19`: Padding zeros (`00000000`).
+ * `20..23`: Format version identifier string (`2001` or `2013`).
+
+### 3.2 Metadata Fields
+Following the format descriptor, fixed character offsets define simulation properties. All offsets are relative to the start of the Header Block payload:
+
+| Offset (chars) | Length | Field |
+|---|---|---|
+| `0` | `4` | Number of variables (including scale) |
+| `4` | `4` | Number of probes |
+| `8` | `4` | Number of sweep dimensions (`0` or `1`) |
+| `24` | `64` | Simulation title string |
+| `88` | `24` | Creation date/time string |
+| `187` | `~16` | Number of swept points (`sweepSize`); space-delimited integer. Valid for **all** format versions. |
+| `256` | variable | Variable type codes and names (space-delimited tokens, see §3.3) |
+
+* **Variable Types**: The physical quantity type code of each variable:
+ * `1`: Voltage or standard MNA variable.
+ * `2`: Frequency or AC sweep variable (signals that the analysis is AC and circuit variables are complex).
+ * `3`: Parameter sweep variable (e.g., swept source voltage `VOLTS` or resistance parameter `rval`).
+ * `4`: Swept current source variable (e.g., `AMPS`).
+ * `8`: Current probe or time/scale variable.
+ * *Note: These codes represent physical quantities, not byte sizes.*
+* **Variable Names**: Space-separated names of the variables (e.g., `TIME`, `v(in)`, `v(out)`, `i(vinput)`).
+* **Termination Marker**: The header block data payload ends with the characters `$&%#`.
+
+> [!NOTE]
+> **Probe vs Variable Counts**: In some HSPICE configurations (such as combined probe + parameter sweep simulations, or standard sweep simulations), all monitored vectors are counted under `numVariables` with `numProbes` set to `0`. Parsers should always compute the total number of vectors in the file as $N = \text{numVariables} + \text{numProbes}$.
+
+### 3.3 Variable Type and Name Token Layout
+Starting at payload offset `256`, the space-delimited token sequence is:
+
+```text
+ ... ...
+```
+
+where `N = numVariables + numProbes` (total vectors), index `0` is the scale/independent variable, and indices `1..N-1` are the circuit variables. Types and names are in strict natural index order — `type_i` always corresponds to `name_i`.
+
+---
+
+## 4. The Data Block Payload & Record Layout
+
+HSPICE writes simulation data points in a **Hybrid Precision Record Layout**.
+
+
+
+### 4.1 Hybrid Precision Record Details
+
+Variable storage sizes depend on the format version and analysis type:
+
+| Variable | `9601` (legacy) | `2001` | `2013` |
+|---|---|---|---|
+| Scale (index 0, real) | 4-byte `float` | 8-byte `double` | 8-byte `double` |
+| Circuit variable, real (DC/Tran) | 4-byte `float` | 8-byte `double` | 4-byte `float` |
+| Circuit variable, complex (AC) | 4-byte `float` × 2 (re+im) | 8-byte `double` × 2 (re+im) | 4-byte `float` × 2 (re+im) |
+
+* **Scale Variable**: The first variable in each record is the abscissa (`TIME` for transient, `HERTZ` for AC, or the swept source value for DC).
+* **Circuit Variables**: All other signals (node voltages, branch currents, device probes).
+* **Row Size Calculation**:
+
+ $$\text{Row Size (Bytes)} = \text{Size}(\text{scale}) + \sum_{j=1}^{M} \text{Size}(\text{circuit variable } j)$$
+
+ #### DC & Transient Examples
+ For a transient simulation with 3 circuit variables in **2013** format:
+ $$\text{Row Size} = 8 \text{ (TIME double)} + 3 \times 4 \text{ (float)} = 20 \text{ bytes}$$
+
+ For the same simulation in **2001** format:
+ $$\text{Row Size} = 8 \text{ (TIME double)} + 3 \times 8 \text{ (double)} = 32 \text{ bytes}$$
+
+ For the same simulation in **9601** format:
+ $$\text{Row Size} = 4 \text{ (TIME float)} + 3 \times 4 \text{ (float)} = 16 \text{ bytes}$$
+
+ #### AC Example
+ For an AC simulation with 3 circuit variables in **2013** format:
+ $$\text{Row Size} = 8 \text{ (HERTZ double)} + 3 \times 8 \text{ (complex float)} = 32 \text{ bytes}$$
+
+ For the same in **2001** format:
+ $$\text{Row Size} = 8 \text{ (HERTZ double)} + 3 \times 16 \text{ (complex double)} = 56 \text{ bytes}$$
+
+### 4.2 Swept Simulations & Sweep Values
+
+If a simulation includes parameter sweeps (`numOfSweeps > 0`):
+1. The simulation data is partitioned into separate **sweep tables**, one per swept point value. The number of tables equals `sweepSize` read from header offset `187`.
+2. Each sweep table begins with the **sweep value** (e.g., temperature, source voltage, or parameter value):
+ - **`2013`, `9601`, `9007` formats**: stored as a **4-byte single-precision float**.
+ - **`2001` format**: stored as an **8-byte double-precision float** (consistent with its all-double data layout).
+3. Following the sweep value, the standard hybrid precision data rows are written consecutively until the sweep table termination block (see §4.3).
+
+### 4.3 Sweep Table Termination & Block Chaining
+
+> **Key finding**: The termination marker is **not** embedded inside a data payload. It is written as a **dedicated, standalone block** whose entire payload is the marker value. This block immediately follows the last data block of each sweep table.
+
+The termination marker block per format:
+
+| Format | Payload size | Payload content | Value |
+|---|---|---|---|
+| `2013` | 8 bytes | IEEE 754 double | `1.0e30` |
+| `2001` | 8 bytes | IEEE 754 double | `1.0e30` |
+| `9601` / `9007` | 4 bytes | IEEE 754 float | `1.0e30` |
+
+**Parsing algorithm**:
+1. Read data blocks sequentially, accumulating payload bytes into the current sweep table buffer.
+2. After reading each block, check if the block is a termination block:
+ - For `2013`/`2001`: block payload size is 8 and the value interpreted as a `double` is `> 9e29`.
+ - For `9601`/`9007`: block payload size is 4 and the value interpreted as a `float` is `> 9e29`.
+3. If a termination block is detected, close the current sweep table and start accumulating a new one.
+4. Repeat until EOF. The number of closed tables must equal `sweepSize`.
+5. For un-swept simulations (`numOfSweeps = 0`, `sweepSize = 1`), the single table is closed by the same termination block mechanism.
+
diff --git a/PySpice/Spice/HSpice/hspicefile/docs/record_layout.svg b/PySpice/Spice/HSpice/hspicefile/docs/record_layout.svg
new file mode 100644
index 00000000..648756e5
--- /dev/null
+++ b/PySpice/Spice/HSpice/hspicefile/docs/record_layout.svg
@@ -0,0 +1,46 @@
+
diff --git a/PySpice/Spice/HSpice/hspicefile/docs/tests/.clang-format b/PySpice/Spice/HSpice/hspicefile/docs/tests/.clang-format
new file mode 100644
index 00000000..ed36c0c2
--- /dev/null
+++ b/PySpice/Spice/HSpice/hspicefile/docs/tests/.clang-format
@@ -0,0 +1,104 @@
+# Language targeting C and C++ variants
+Language: Cpp
+
+# Based on the foundational LLVM style guide
+BasedOnStyle: LLVM
+
+# Indentation rules
+AccessModifierOffset: -4
+ConstructorInitializerIndentWidth: 4
+AlignAfterOpenBracket: Align
+AlignConsecutiveMacros: true
+AlignConsecutiveAssignments: false
+AlignConsecutiveDeclarations: false
+AlignEscapedNewlines: Right
+AlignOperands: true
+AlignTrailingComments: true
+
+# Brace wrapping settings - Forces readable, structural lines
+AllowAllArgumentsOnNextLine: false
+AllowAllConstructorInitializersOnNextLine: false
+AllowAllParametersOfDeclarationOnNextLine: false
+AllowShortBlocksOnASingleLine: false
+AllowShortCaseLabelsOnASingleLine: false
+AllowShortFunctionsOnASingleLine: None
+AllowShortLambdasOnASingleLine: All
+AllowShortIfStatementsOnASingleLine: Never
+AllowShortLoopsOnASingleLine: Never
+
+# Break behaviors
+AlwaysBreakAfterDefinitionReturnType: None
+AlwaysBreakAfterReturnType: None
+AlwaysBreakBeforeMultilineStrings: false
+AlwaysBreakTemplateDeclarations: MultiLine
+
+# Brace Wrapping Specifications (Simulates the clean layout from your code)
+BreakBeforeBraces: Custom
+BraceWrapping:
+ AfterCaseLabel: true
+ AfterClass: true
+ AfterControlStatement: Always
+ AfterEnum: true
+ AfterFunction: true
+ AfterNamespace: true
+ AfterObjCDeclaration: true
+ AfterStruct: true
+ AfterUnion: true
+ BeforeCatch: true
+ BeforeElse: true
+ IndentBraces: false
+ SplitEmptyFunction: true
+ SplitEmptyRecord: true
+ SplitEmptyNamespace: true
+
+BreakBeforeBinaryOperators: None
+BreakBeforeTernaryOperators: true
+BreakConstructorInitializers: BeforeColon
+BreakInheritanceList: BeforeColon
+BreakStringLiterals: true
+
+# Core sizing rules
+ColumnLimit: 120
+CommentPragmas: "^ IWYU pragma:"
+CompactNamespaces: false
+DerivePointerAlignment: false
+ExperimentalAutoDetectBinPacking: false
+FixNamespaceComments: true
+
+# Indent spacing
+IndentCaseLabels: true
+IndentPPDirectives: None
+IndentWidth: 4
+
+# Keep definitions clean
+KeepEmptyLinesAtTheStartOfBlocks: false
+MaxEmptyLinesToKeep: 2
+NamespaceIndentation: None
+
+# Pointer Alignment Style: Right (e.g., PyObject *HSpiceParseError)
+PointerAlignment: Right
+
+ReflowComments: true
+SortIncludes: true
+SortUsingDeclarations: true
+SpaceAfterCStyleCast: false
+SpaceAfterLogicalNot: false
+SpaceAfterTemplateKeyword: true
+SpaceBeforeAssignmentOperators: true
+SpaceBeforeCpp11BracedList: false
+SpaceBeforeCtorInitializerColon: true
+SpaceBeforeInheritanceColon: true
+SpaceBeforeParens: ControlStatements
+SpaceBeforeRangeBasedForLoopColon: true
+SpaceInEmptyParentheses: false
+SpacesBeforeTrailingComments: 2
+SpacesInAngles: false
+SpacesInCStyleCastParentheses: false
+SpacesInContainerLiterals: false
+SpacesInParentheses: false
+SpacesInSquareBrackets: false
+
+# Standard compilation target standard
+Standard: Latest
+TabWidth: 4
+UseTab: Never
diff --git a/PySpice/Spice/HSpice/hspicefile/docs/tests/.gitignore b/PySpice/Spice/HSpice/hspicefile/docs/tests/.gitignore
new file mode 100644
index 00000000..fa3c11f5
--- /dev/null
+++ b/PySpice/Spice/HSpice/hspicefile/docs/tests/.gitignore
@@ -0,0 +1,6 @@
+logs/
+work/
+scratch/
+
+simulate
+verify
diff --git a/PySpice/Spice/HSpice/hspicefile/docs/tests/Makefile b/PySpice/Spice/HSpice/hspicefile/docs/tests/Makefile
new file mode 100644
index 00000000..49b9fd29
--- /dev/null
+++ b/PySpice/Spice/HSpice/hspicefile/docs/tests/Makefile
@@ -0,0 +1,22 @@
+# Default target (running 'make' with no arguments will trigger this)
+.PHONY: all test clean
+all: test
+
+# 1. This runs only if run_simulations.py changes
+simulate: run_simulations.py
+ python3 ./run_simulations.py
+ touch simulate
+
+# 2. This runs if the 'simulate' file was just updated, or if verify_spec.py changes
+verify: simulate verify_spec.py
+ python3 ./verify_spec.py
+ python3 ./verify_all_types.py
+ touch verify
+
+# 3. This runs if the 'verify' file was just updated, or if test_hspice_read.py changes
+test: verify test_hspice_read.py
+ python3 ./test_hspice_read.py
+
+# Clean up build artifacts and sentinel files
+clean:
+ rm -rf work/ logs/ simulate verify
diff --git a/PySpice/Spice/HSpice/hspicefile/docs/tests/run_simulations.py b/PySpice/Spice/HSpice/hspicefile/docs/tests/run_simulations.py
new file mode 100644
index 00000000..cc0846c0
--- /dev/null
+++ b/PySpice/Spice/HSpice/hspicefile/docs/tests/run_simulations.py
@@ -0,0 +1,476 @@
+import os
+import shutil
+import subprocess
+import sys
+
+# Verify that hspice executable is available
+HSPICE_BIN = shutil.which("hspice")
+
+# Define the simulation option space templates
+# Format: { case_name: { "type": "dc"|"ac"|"tran", "template": SPICE_netlist } }
+# {version} will be substituted by the loop.
+templates = {
+ "dc_basic": {
+ "type": "dc",
+ "suffix": "sw0",
+ "netlist": """* DC Basic Sweep
+.option post=1 post_version={version}
+v1 node_a 0 1.0
+r1 node_a 0 10.0
+.dc v1 1.0 5.0 1.0
+.end
+""",
+ },
+ "dc_sweep_temp": {
+ "type": "dc",
+ "suffix": "sw0",
+ "netlist": """* DC Nested Sweep Temperature
+.option post=1 post_version={version}
+v1 node_a 0 1.0
+r1 node_a 0 10.0
+.dc temp -40 120 40
+.end
+""",
+ },
+ "dc_sweep_param": {
+ "type": "dc",
+ "suffix": "sw0",
+ "netlist": """* DC Parameter Sweep
+.option post=1 post_version={version}
+.param rval=10.0
+v1 node_a 0 1.0
+r1 node_a 0 rval
+.dc rval 10.0 50.0 10.0
+.end
+""",
+ },
+ "dc_nested_sweep": {
+ "type": "dc",
+ "suffix": "sw0",
+ "netlist": """* DC Nested Source and Parameter Sweep
+.option post=1 post_version={version}
+.param rval=10.0
+v1 node_a 0 1.0
+r1 node_a 0 rval
+.dc v1 1.0 5.0 1.0 sweep rval 10.0 30.0 10.0
+.end
+""",
+ },
+ "dc_probes_only": {
+ "type": "dc",
+ "suffix": "sw0",
+ "netlist": """* DC Probe Only
+.option post=1 post_version={version} probe
+v1 node_a 0 1.0
+r1 node_a node_b 10.0
+r2 node_b 0 20.0
+.dc v1 1.0 5.0 1.0
+.probe dc v(node_a) v(node_b) v(node_a,node_b) i(v1) i(r1) p(r1)
+.end
+""",
+ },
+ "dc_probe_and_sweep": {
+ "type": "dc",
+ "suffix": "sw0",
+ "netlist": """* DC Probe and Parameter Sweep Combination
+.option post=1 post_version={version} probe
+.param rval=10.0
+v1 node_a 0 1.0
+r1 node_a node_b rval
+r2 node_b 0 20.0
+.dc v1 1.0 5.0 1.0 sweep rval 10.0 30.0 10.0
+.probe dc v(node_a) v(node_b) i(v1) i(r1)
+.end
+""",
+ },
+ "dc_monte": {
+ "type": "dc",
+ "suffix": "sw0",
+ "netlist": """* DC Monte Carlo Sweep
+.option post=1 post_version={version}
+.param rval=gauss(10.0, 1.0, 3)
+v1 node_a 0 1.0
+r1 node_a 0 rval
+.dc v1 1.0 5.0 1.0 sweep monte=5
+.end
+""",
+ },
+ "ac_basic": {
+ "type": "ac",
+ "suffix": "ac0",
+ "netlist": """* AC Basic Sweep
+.option post=1 post_version={version}
+v1 node_a 0 ac 1.0
+r1 node_a node_b 10.0
+c1 node_b 0 1u
+.ac dec 5 100 10k
+.end
+""",
+ },
+ "ac_sweep_temp": {
+ "type": "ac",
+ "suffix": "ac0",
+ "netlist": """* AC Sweep with Temperature
+.option post=1 post_version={version}
+v1 node_a 0 ac 1.0
+r1 node_a node_b 10.0
+c1 node_b 0 1u
+.ac dec 5 100 10k sweep temp -40 120 40
+.end
+""",
+ },
+ "ac_sweep_param": {
+ "type": "ac",
+ "suffix": "ac0",
+ "netlist": """* AC Sweep with Parameter
+.option post=1 post_version={version}
+.param rval=10.0
+v1 node_a 0 ac 1.0
+r1 node_a node_b rval
+c1 node_b 0 1u
+.ac dec 5 100 10k sweep rval 10.0 30.0 10.0
+.end
+""",
+ },
+ "ac_probes_only": {
+ "type": "ac",
+ "suffix": "ac0",
+ "netlist": """* AC Probe Only
+.option post=1 post_version={version} probe
+v1 node_a 0 ac 1.0
+r1 node_a node_b 10.0
+c1 node_b 0 1u
+.ac dec 5 100 10k
+.probe ac v(node_a) v(node_b) v(node_a,node_b) i(v1) i(r1)
+.end
+""",
+ },
+ "ac_probe_and_sweep": {
+ "type": "ac",
+ "suffix": "ac0",
+ "netlist": """* AC Probe and Parameter Sweep Combination
+.option post=1 post_version={version} probe
+.param rval=10.0
+v1 node_a 0 ac 1.0
+r1 node_a node_b rval
+c1 node_b 0 1u
+.ac dec 5 100 10k sweep rval 10.0 30.0 10.0
+.probe ac v(node_a) v(node_b) i(v1)
+.end
+""",
+ },
+ "tran_basic": {
+ "type": "tran",
+ "suffix": "tr0",
+ "netlist": """* Transient Basic
+.option post=1 post_version={version}
+v1 node_a 0 pulse(0 1.0 0 1n 1n 10n 20n)
+r1 node_a node_b 10.0
+r2 node_b 0 20.0
+.tran 1n 20n
+.end
+""",
+ },
+ "tran_sweep_temp": {
+ "type": "tran",
+ "suffix": "tr0",
+ "netlist": """* Transient with Temperature Sweep
+.option post=1 post_version={version}
+v1 node_a 0 pulse(0 1.0 0 1n 1n 10n 20n)
+r1 node_a node_b 10.0
+r2 node_b 0 20.0
+.tran 1n 20n sweep temp -40 120 40
+.end
+""",
+ },
+ "tran_sweep_param": {
+ "type": "tran",
+ "suffix": "tr0",
+ "netlist": """* Transient with Parameter Sweep
+.option post=1 post_version={version}
+.param rval=10.0
+v1 node_a 0 pulse(0 1.0 0 1n 1n 10n 20n)
+r1 node_a node_b rval
+r2 node_b 0 20.0
+.tran 1n 20n sweep rval 10.0 30.0 10.0
+.end
+""",
+ },
+ "tran_sweep_source": {
+ "type": "tran",
+ "suffix": "tr0",
+ "netlist": """* Transient with Source Sweep
+.option post=1 post_version={version}
+v1 node_a 0 pulse(0 1.0 0 1n 1n 10n 20n)
+r1 node_a node_b 10.0
+r2 node_b 0 20.0
+v2 node_c 0 1.0
+.tran 1n 20n sweep v2 1.0 3.0 1.0
+.end
+""",
+ },
+ "tran_probes_only": {
+ "type": "tran",
+ "suffix": "tr0",
+ "netlist": """* Transient Probe Only
+.option post=1 post_version={version} probe
+v1 node_a 0 pulse(0 1.0 0 1n 1n 10n 20n)
+r1 node_a node_b 10.0
+r2 node_b 0 20.0
+.tran 1n 20n
+.probe tran v(node_a) v(node_b) v(node_a,node_b) i(v1) i(r1) p(r1)
+.end
+""",
+ },
+ "tran_probe_and_sweep": {
+ "type": "tran",
+ "suffix": "tr0",
+ "netlist": """* Transient Probe and Parameter Sweep Combination
+.option post=1 post_version={version} probe
+.param rval=10.0
+v1 node_a 0 pulse(0 1.0 0 1n 1n 10n 20n)
+r1 node_a node_b rval
+r2 node_b 0 20.0
+.tran 1n 20n sweep rval 10.0 30.0 10.0
+.probe tran v(node_a) v(node_b) i(v1)
+.end
+""",
+ },
+ "tran_monte": {
+ "type": "tran",
+ "suffix": "tr0",
+ "netlist": """* Transient Monte Carlo Sweep
+.option post=1 post_version={version}
+.param rval=gauss(10.0, 1.0, 3)
+v1 node_a 0 pulse(0 1.0 0 1n 1n 10n 20n)
+r1 node_a 0 rval
+.tran 1n 20n sweep monte=5
+.end
+""",
+ },
+ "op_basic": {
+ "type": "op",
+ "suffix": "ic0",
+ "netlist": """* Operating Point Basic
+.option post=1 post_version={version}
+v1 node_a 0 1.0
+r1 node_a node_b 10.0
+r2 node_b 0 20.0
+.op
+.end
+""",
+ },
+ "op_with_probes": {
+ "type": "op",
+ "suffix": "ic0",
+ "netlist": """* Operating Point with Probes and Currents
+.option post=1 post_version={version}
+v1 node_a 0 2.0
+r1 node_a node_b 5.0
+v2 node_b 0 1.0
+.op
+.end
+""",
+ },
+ "dc_meas": {
+ "type": "dc",
+ "suffix": "sw0",
+ "netlist": """* DC Sweep with Measurement
+.option post=1 post_version={version}
+v1 node_a 0 1.0
+r1 node_a node_b 10.0
+r2 node_b 0 20.0
+.dc v1 1.0 5.0 1.0
+.meas dc v_out_max max v(node_b)
+.end
+""",
+ },
+ "ac_meas": {
+ "type": "ac",
+ "suffix": "ac0",
+ "netlist": """* AC Sweep with Measurement
+.option post=1 post_version={version}
+v1 node_a 0 ac 1.0
+r1 node_a node_b 10.0
+c1 node_b 0 1u
+.ac dec 5 100 10k
+.meas ac v_out_at_1k find v(node_b) at 1000
+.end
+""",
+ },
+ "tran_meas": {
+ "type": "tran",
+ "suffix": "tr0",
+ "netlist": """* Transient with Measurement
+.option post=1 post_version={version}
+v1 node_a 0 pulse(0 1.0 0 1n 1n 10n 20n)
+r1 node_a node_b 10.0
+r2 node_b 0 20.0
+.tran 1n 20n
+.meas tran v_out_max max v(node_b)
+.end
+""",
+ },
+ "dc_meas_sweep": {
+ "type": "dc",
+ "suffix": "sw0",
+ "netlist": """* DC Sweep with Outer Param Sweep and Measurement
+.option post=1 post_version={version}
+.param rval=10.0
+v1 node_a 0 1.0
+r1 node_a node_b rval
+r2 node_b 0 20.0
+.dc v1 1.0 5.0 1.0 sweep rval 10.0 30.0 10.0
+.meas dc v_out_max max v(node_b)
+.end
+""",
+ },
+ "ac_meas_sweep": {
+ "type": "ac",
+ "suffix": "ac0",
+ "netlist": """* AC Sweep with Outer Param Sweep and Measurement
+.option post=1 post_version={version}
+.param rval=10.0
+v1 node_a 0 ac 1.0
+r1 node_a node_b rval
+c1 node_b 0 1u
+.ac dec 5 100 10k sweep rval 10.0 30.0 10.0
+.meas ac v_out_at_1k find v(node_b) at 1000
+.end
+""",
+ },
+ "tran_meas_sweep": {
+ "type": "tran",
+ "suffix": "tr0",
+ "netlist": """* Transient Sweep with Outer Param Sweep and Measurement
+.option post=1 post_version={version}
+.param rval=10.0
+v1 node_a 0 pulse(0 1.0 0 1n 1n 10n 20n)
+r1 node_a node_b rval
+r2 node_b 0 20.0
+.tran 1n 20n sweep rval 10.0 30.0 10.0
+.meas tran v_out_max max v(node_b)
+.end
+""",
+ },
+}
+
+versions = ["9007", "9601", "2001", "2013", "ascii"]
+
+
+def main():
+ # Ensure the logs directory exists and is clean
+ logs_dir = "logs"
+ if os.path.exists(logs_dir):
+ shutil.rmtree(logs_dir)
+ os.makedirs(logs_dir, exist_ok=True)
+
+ # Ensure the work directory exists and is clean
+ work_dir = "work"
+ if os.path.exists(work_dir):
+ shutil.rmtree(work_dir)
+ os.makedirs(work_dir, exist_ok=True)
+
+ # Summary log file
+ run_log_path = os.path.join(logs_dir, "run_simulations.log")
+ run_log = open(run_log_path, "w")
+
+ def log_and_print(msg):
+ print(msg)
+ run_log.write(msg + "\n")
+ run_log.flush()
+
+ log_and_print("==================================================")
+ log_and_print("HSPICE Simulation Runner for Option Space Coverage")
+ log_and_print("==================================================")
+ log_and_print(f"HSPICE Executable: {HSPICE_BIN}")
+
+ passed_count = 0
+ failed_count = 0
+
+ for case_name, info in templates.items():
+ for version in versions:
+ name_prefix = f"{case_name}_{version}"
+ sp_filename = os.path.join(work_dir, f"{name_prefix}.sp")
+ lis_filename = os.path.join(work_dir, f"{name_prefix}.lis")
+
+ # Write netlist to .sp file
+ if version == "ascii":
+ netlist_content = info["netlist"].replace(
+ ".option post=1 post_version={version}", ".option post=2"
+ )
+ else:
+ netlist_content = info["netlist"].format(version=version)
+
+ with open(sp_filename, "w") as f:
+ f.write(netlist_content)
+
+ log_and_print(f"\nRunning case: {name_prefix}...")
+
+ # Command to run HSPICE
+ cmd = [
+ HSPICE_BIN,
+ "-i",
+ sp_filename,
+ "-o",
+ os.path.join(work_dir, name_prefix),
+ ]
+
+ try:
+ # Run with timeout to prevent hanging if license is busy
+ result = subprocess.run(
+ cmd,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ text=True,
+ timeout=30,
+ )
+
+ # Save command output to logs
+ cmd_log_path = os.path.join(logs_dir, f"{name_prefix}_cmd.log")
+ with open(cmd_log_path, "w") as lf:
+ lf.write(f"COMMAND: {' '.join(cmd)}\n")
+ lf.write(f"RETURN CODE: {result.returncode}\n")
+ lf.write("--- STDOUT ---\n")
+ lf.write(result.stdout)
+ lf.write("--- STDERR ---\n")
+ lf.write(result.stderr)
+
+ # Check if run was successful and expected binary file was created
+ expected_bin = os.path.join(work_dir, f"{name_prefix}.{info['suffix']}")
+ if result.returncode != 0:
+ log_and_print(
+ f" [FAILED] HSPICE execution exited with non-zero code {result.returncode}!"
+ )
+ failed_count += 1
+ elif os.path.exists(expected_bin):
+ log_and_print(f" [SUCCESS] Created {expected_bin}")
+ passed_count += 1
+ else:
+ log_and_print(
+ f" [FAILED] Binary/ASCII file {expected_bin} was not created!"
+ )
+ failed_count += 1
+
+ except subprocess.TimeoutExpired:
+ log_and_print(
+ f" [TIMEOUT] HSPICE execution timed out for {name_prefix}"
+ )
+ failed_count += 1
+ except Exception as e:
+ log_and_print(f" [ERROR] Failed to execute {name_prefix}: {str(e)}")
+ failed_count += 1
+
+ log_and_print("\n==================================================")
+ log_and_print(f"Simulation Summary: {passed_count} passed, {failed_count} failed")
+ log_and_print("==================================================")
+ run_log.close()
+
+ if failed_count > 0:
+ sys.exit(1)
+ else:
+ sys.exit(0)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/PySpice/Spice/HSpice/hspicefile/docs/tests/test_hspice_read.py b/PySpice/Spice/HSpice/hspicefile/docs/tests/test_hspice_read.py
new file mode 100644
index 00000000..e5e0ed2e
--- /dev/null
+++ b/PySpice/Spice/HSpice/hspicefile/docs/tests/test_hspice_read.py
@@ -0,0 +1,910 @@
+import os
+import sys
+import traceback
+
+import numpy as np
+
+WORK_DIR = "work"
+
+# ---------------------------------------------------------------------------
+# test_hspice_read.py
+#
+# Post-implementation regression test for the Python hspice_read() function.
+# Must be run from the sims/ directory after simulations have been generated.
+#
+# Imports hspice_read from PySpice.Spice.HSpice.hspicefile and asserts the
+# full return structure for every binary file in the simulation set.
+#
+# Return structure (from C implementation):
+# hspice_read(filename) -> list[result]
+# result = tuple(sweeps, scale_name, None, title, date, None)
+# sweeps = tuple(sweep_var_name_or_None, sweep_values_ndarray_or_None, data_list)
+# data_list = list[dict{ var_name: numpy.ndarray, ... }]
+#
+# Variable name normalisation (applied by the C parser):
+# - All names lowercased.
+# - "v(node_a)" -> "node_a" (v( prefix and ) suffix stripped)
+# - "i(v1)" -> "i(v1" (i( prefix retained, closing paren not stripped)
+# - Scale names (TIME, HERTZ, VOLTS, DEG_C, rval...) returned as-is lowercased.
+#
+# Arrays are numpy.ndarray:
+# - Real variables: dtype float64 (NPY_DOUBLE)
+# - Complex variables: dtype complex128 (NPY_CDOUBLE)
+#
+# Logs to logs/test_hspice_read.log.
+# ---------------------------------------------------------------------------
+
+sys.path.insert(
+ 0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../../../../../.."))
+)
+
+from PySpice.Spice.HSpice.hspicefile import hspice_read
+
+# ---------------------------------------------------------------------------
+# Helper utilities
+# ---------------------------------------------------------------------------
+
+
+def _close(a, b, rtol=1e-3):
+ """Relative tolerance float comparison."""
+ if b == 0:
+ return abs(a) < 1e-30
+ return abs(a - b) / abs(b) < rtol
+
+
+def _find(data_dict, partial):
+ """Find a key that equals or starts with `partial` (handles truncated names)."""
+ for k in data_dict:
+ if k == partial or k.startswith(partial):
+ return k
+ return None
+
+
+def _unpack(result):
+ """
+ Unpack the hspice_read return value into a convenient structure.
+
+ Returns:
+ title: str
+ date: str
+ scale_name: str (the independent variable name, lowercased)
+ sweep_name: str or None (outer sweep variable, e.g. 'rval')
+ sweep_vals: numpy.ndarray or None (outer sweep point values)
+ data_list: list[dict] (one dict per sweep point)
+ """
+ assert (
+ isinstance(result, list) and len(result) == 1
+ ), f"Expected list of length 1, got {type(result)} len={len(result)}"
+ entry = result[0]
+ assert (
+ isinstance(entry, tuple) and len(entry) == 6
+ ), f"Expected 6-tuple, got {type(entry)} len={len(entry)}"
+ sweeps, scale_name, _, title, date, _ = entry
+ assert (
+ isinstance(sweeps, tuple) and len(sweeps) == 3
+ ), f"Expected 3-tuple sweeps, got {type(sweeps)} len={len(sweeps)}"
+ sweep_name, sweep_vals, data_list = sweeps
+ assert isinstance(
+ data_list, list
+ ), f"data_list should be list, got {type(data_list)}"
+ return title, date, scale_name, sweep_name, sweep_vals, data_list
+
+
+# ---------------------------------------------------------------------------
+# Test case factory
+# ---------------------------------------------------------------------------
+
+
+def make_tests():
+ """
+ Return list of (filename, case_name, assertion_fn).
+ assertion_fn(title, date, scale_name, sweep_name, sweep_vals, data_list)
+ -> list[str] of error messages (empty = PASS)
+ """
+ tests = []
+
+ # ------------------------------------------------------------------
+ # DC BASIC: v1 swept 1..5 step 1 => 1 table, 5 rows, scale=VOLTS
+ # data_list[0] has keys: scale_name, 'node_a', possibly '0'
+ # ------------------------------------------------------------------
+ def dc_basic(title, date, scale_name, sweep_name, sweep_vals, data_list):
+ errs = []
+ if len(data_list) != 1:
+ errs.append(f"Expected 1 table, got {len(data_list)}")
+ return errs
+ t = data_list[0]
+ sn_lower = scale_name.lower()
+ sn_upper = scale_name.upper()
+
+ if sn_lower in t:
+ sn = sn_lower
+ elif sn_upper in t:
+ sn = sn_upper
+ else:
+ errs.append(f"Scale '{scale_name}' not found (keys={list(t.keys())})")
+ return errs
+
+ arr = t[sn]
+ if not isinstance(arr, np.ndarray):
+ errs.append(f"Scale array is {type(arr)}, expected numpy.ndarray")
+ if len(arr) != 5:
+ errs.append(f"Expected 5 rows, got {len(arr)}")
+ if arr.dtype not in (np.float64, np.float32):
+ errs.append(f"Scale dtype {arr.dtype}, expected float64")
+ if not _close(float(arr[0]), 1.0):
+ errs.append(f"arr[0]={arr[0]}, expected 1.0")
+ if not _close(float(arr[-1]), 5.0):
+ errs.append(f"arr[-1]={arr[-1]}, expected 5.0")
+ node_a = _find(t, "node_a")
+ if node_a is None:
+ errs.append(f"'node_a' variable not found in keys={list(t.keys())}")
+ else:
+ if not _close(float(t[node_a][0]), 1.0, rtol=1e-4):
+ errs.append(f"v(node_a)[0]={t[node_a][0]}, expected ~1.0")
+ return errs
+
+ for version in ["9601", "2001", "2013"]:
+ tests.append((f"dc_basic_{version}.sw0", f"dc_basic_{version}", dc_basic))
+
+ # ------------------------------------------------------------------
+ # DC SWEEP TEMP: .dc temp -40 120 40 => 1 table, 5 rows, scale=deg_c
+ # Temperature is the primary DC sweep axis, not an outer parameter.
+ # ------------------------------------------------------------------
+ def dc_sweep_temp(title, date, scale_name, sweep_name, sweep_vals, data_list):
+ errs = []
+ if len(data_list) != 1:
+ errs.append(f"Expected 1 table, got {len(data_list)}")
+ return errs
+ t = data_list[0]
+ sn_lower = scale_name.lower()
+ sn_upper = scale_name.upper()
+
+ if sn_lower in t:
+ sn = sn_lower
+ elif sn_upper in t:
+ sn = sn_upper
+ else:
+ errs.append(f"Scale '{scale_name}' not found (keys={list(t.keys())})")
+ return errs
+
+ arr = t[sn]
+ if len(arr) != 5:
+ errs.append(f"Expected 5 rows, got {len(arr)}")
+ if not _close(float(arr[0]), -40.0, rtol=1e-3):
+ errs.append(f"arr[0]={arr[0]}, expected -40.0")
+ if not _close(float(arr[-1]), 120.0, rtol=1e-3):
+ errs.append(f"arr[-1]={arr[-1]}, expected 120.0")
+ return errs
+
+ for version in ["9601", "2001", "2013"]:
+ tests.append(
+ (f"dc_sweep_temp_{version}.sw0", f"dc_sweep_temp_{version}", dc_sweep_temp)
+ )
+
+ # ------------------------------------------------------------------
+ # DC SWEEP PARAM: .dc rval 10 50 10 => 1 table, 5 rows, scale=rval
+ # rval is the primary DC sweep axis, not an outer parameter.
+ # ------------------------------------------------------------------
+ def dc_sweep_param(title, date, scale_name, sweep_name, sweep_vals, data_list):
+ errs = []
+ if len(data_list) != 1:
+ errs.append(f"Expected 1 table, got {len(data_list)}")
+ return errs
+ t = data_list[0]
+ sn_lower = scale_name.lower()
+ sn_upper = scale_name.upper()
+
+ if sn_lower in t:
+ sn = sn_lower
+ elif sn_upper in t:
+ sn = sn_upper
+ else:
+ errs.append(f"Scale '{scale_name}' not found (keys={list(t.keys())})")
+ return errs
+
+ arr = t[sn]
+ if len(arr) != 5:
+ errs.append(f"Expected 5 rows, got {len(arr)}")
+ if not _close(float(arr[0]), 10.0, rtol=1e-3):
+ errs.append(f"arr[0]={arr[0]}, expected 10.0")
+ if not _close(float(arr[-1]), 50.0, rtol=1e-3):
+ errs.append(f"arr[-1]={arr[-1]}, expected 50.0")
+ return errs
+
+ for version in ["9601", "2001", "2013"]:
+ tests.append(
+ (
+ f"dc_sweep_param_{version}.sw0",
+ f"dc_sweep_param_{version}",
+ dc_sweep_param,
+ )
+ )
+
+ # ------------------------------------------------------------------
+ # DC MONTE: 5 Monte Carlo runs, each with 5 rows (v1 1..5 step 1)
+ # data_list has 5 entries (one per Monte Carlo sample)
+ # ------------------------------------------------------------------
+ def dc_monte(title, date, scale_name, sweep_name, sweep_vals, data_list):
+ errs = []
+ if len(data_list) != 5:
+ errs.append(f"Expected 5 Monte Carlo tables, got {len(data_list)}")
+ return errs
+ sn_lower = scale_name.lower()
+ sn_upper = scale_name.upper()
+
+ for i, t in enumerate(data_list):
+ if sn_lower in t:
+ sn = sn_lower
+ elif sn_upper in t:
+ sn = sn_upper
+ else:
+ errs.append(f"Scale '{scale_name}' not found (keys={list(t.keys())})")
+ return errs
+
+ arr = t[sn]
+ if not isinstance(arr, np.ndarray):
+ errs.append(f"Table {i}: scale is {type(arr)}, expected ndarray")
+ if len(arr) != 5:
+ errs.append(f"Table {i}: expected 5 rows, got {len(arr)}")
+ return errs
+
+ for version in ["9601", "2001", "2013"]:
+ tests.append((f"dc_monte_{version}.sw0", f"dc_monte_{version}", dc_monte))
+
+ # ------------------------------------------------------------------
+ # DC NESTED SWEEP: .dc v1 1 5 1 sweep rval 10 30 10
+ # => sweeps tuple has sweep_name='rval', sweep_vals=[10,20,30]
+ # data_list has 3 entries, each with 5 rows
+ # ------------------------------------------------------------------
+ def dc_nested_sweep(title, date, scale_name, sweep_name, sweep_vals, data_list):
+ errs = []
+ if sweep_name is None:
+ errs.append("Expected outer sweep variable, got None")
+ else:
+ if sweep_name.lower() != "rval":
+ errs.append(f"Expected sweep_name='rval', got '{sweep_name}'")
+ if sweep_vals is None:
+ errs.append("Expected sweep_vals array, got None")
+ else:
+ if not isinstance(sweep_vals, np.ndarray):
+ errs.append(f"sweep_vals is {type(sweep_vals)}, expected ndarray")
+ elif len(sweep_vals) != 3:
+ errs.append(f"Expected 3 sweep_vals, got {len(sweep_vals)}")
+ else:
+ for i, ev in enumerate([10.0, 20.0, 30.0]):
+ if not _close(float(sweep_vals[i]), ev, rtol=1e-3):
+ errs.append(f"sweep_vals[{i}]={sweep_vals[i]}, expected {ev}")
+ if len(data_list) != 3:
+ errs.append(f"Expected 3 tables (one per rval), got {len(data_list)}")
+ return errs
+ sn_lower = scale_name.lower()
+ sn_upper = scale_name.upper()
+
+ for i, t in enumerate(data_list):
+ if sn_lower in t:
+ sn = sn_lower
+ elif sn_upper in t:
+ sn = sn_upper
+ else:
+ errs.append(f"Table {i}: scale '{sn}' missing")
+ continue
+ if len(t[sn]) != 5:
+ errs.append(f"Table {i}: expected 5 rows (v1 1..5), got {len(t[sn])}")
+ return errs
+
+ for version in ["9601", "2001", "2013"]:
+ tests.append(
+ (
+ f"dc_nested_sweep_{version}.sw0",
+ f"dc_nested_sweep_{version}",
+ dc_nested_sweep,
+ )
+ )
+
+ # ------------------------------------------------------------------
+ # DC PROBES ONLY: no sweep, explicit probes:
+ # v(node_a), v(node_b), v(node_a,node_b), i(v1), i(r1)
+ # scale=VOLTS, 1 table, 5 rows
+ # C parser strips v(...) => "node_a", "node_b", "node_a,node_b"
+ # ------------------------------------------------------------------
+ def dc_probes_only(title, date, scale_name, sweep_name, sweep_vals, data_list):
+ errs = []
+ if len(data_list) != 1:
+ errs.append(f"Expected 1 table, got {len(data_list)}")
+ return errs
+ t = data_list[0]
+ for p in ["node_a", "node_b", "i(v1", "i(r1"]:
+ if _find(t, p) is None:
+ errs.append(f"Expected probe '{p}' not found. Keys: {list(t.keys())}")
+ for k, v in t.items():
+ if not isinstance(v, np.ndarray):
+ errs.append(f"'{k}' is {type(v)}, expected ndarray")
+ return errs
+
+ for version in ["9601", "2001", "2013"]:
+ tests.append(
+ (
+ f"dc_probes_only_{version}.sw0",
+ f"dc_probes_only_{version}",
+ dc_probes_only,
+ )
+ )
+
+ # ------------------------------------------------------------------
+ # DC PROBE AND SWEEP: rval sweep 10..30 step 10 + probes v(node_a), v(node_b)
+ # => 3 outer sweep tables; each dict has 'node_a', 'node_b'
+ # ------------------------------------------------------------------
+ def dc_probe_and_sweep(title, date, scale_name, sweep_name, sweep_vals, data_list):
+ errs = []
+ if len(data_list) != 3:
+ errs.append(f"Expected 3 tables, got {len(data_list)}")
+ return errs
+ for i, t in enumerate(data_list):
+ for p in ["node_a", "node_b"]:
+ if _find(t, p) is None:
+ errs.append(f"Table {i}: '{p}' not found. Keys: {list(t.keys())}")
+ return errs
+
+ for version in ["9601", "2001", "2013"]:
+ tests.append(
+ (
+ f"dc_probe_and_sweep_{version}.sw0",
+ f"dc_probe_and_sweep_{version}",
+ dc_probe_and_sweep,
+ )
+ )
+
+ # ------------------------------------------------------------------
+ # AC BASIC: dec 5 100 10k, no sweep => 1 table, complex circuit vars
+ # scale=hertz (or 'freq'), freq range 100..10000 Hz
+ # ------------------------------------------------------------------
+ def ac_basic(title, date, scale_name, sweep_name, sweep_vals, data_list):
+ errs = []
+ if len(data_list) != 1:
+ errs.append(f"Expected 1 table, got {len(data_list)}")
+ return errs
+ t = data_list[0]
+ sn_lower = scale_name.lower()
+ sn_upper = scale_name.upper()
+
+ if sn_lower in t:
+ sn = sn_lower
+ elif sn_upper in t:
+ sn = sn_upper
+ else:
+ errs.append(f"Scale '{scale_name}' not found (keys={list(t.keys())})")
+ return errs
+
+ freqs = t[sn]
+ if len(freqs) < 5:
+ errs.append(f"Too few frequency points: {len(freqs)}")
+ if not _close(float(freqs[0]), 100.0, rtol=0.02):
+ errs.append(f"freqs[0]={freqs[0]}, expected ~100 Hz")
+ if not _close(float(freqs[-1]), 10000.0, rtol=0.02):
+ errs.append(f"freqs[-1]={freqs[-1]}, expected ~10000 Hz")
+ # All circuit vars must be complex128
+ for k, v in t.items():
+ if k == sn:
+ continue
+ if not isinstance(v, np.ndarray):
+ errs.append(f"'{k}' is {type(v)}, expected ndarray")
+ continue
+ if v.dtype != np.complex128:
+ errs.append(f"AC var '{k}' dtype={v.dtype}, expected complex128")
+ return errs
+
+ for version in ["9601", "2001", "2013"]:
+ tests.append((f"ac_basic_{version}.ac0", f"ac_basic_{version}", ac_basic))
+
+ # ------------------------------------------------------------------
+ # AC SWEEP PARAM: rval 10 30 10, 3 outer tables, each with freq points
+ # ------------------------------------------------------------------
+ def ac_sweep_param(title, date, scale_name, sweep_name, sweep_vals, data_list):
+ errs = []
+ if len(data_list) != 3:
+ errs.append(f"Expected 3 tables, got {len(data_list)}")
+ return errs
+ if sweep_vals is None:
+ errs.append("Expected sweep_vals, got None")
+ elif len(sweep_vals) != 3:
+ errs.append(f"Expected 3 sweep_vals, got {len(sweep_vals)}")
+ else:
+ for i, ev in enumerate([10.0, 20.0, 30.0]):
+ if not _close(float(sweep_vals[i]), ev, rtol=1e-3):
+ errs.append(f"sweep_vals[{i}]={sweep_vals[i]}, expected {ev}")
+ sn_lower = scale_name.lower()
+ sn_upper = scale_name.upper()
+ for i, t in enumerate(data_list):
+ if sn_lower in t:
+ sn = sn_lower
+ elif sn_upper in t:
+ sn = sn_upper
+ else:
+ errs.append(f"Table {i}: scale '{sn}' missing")
+ continue
+ if len(t[sn]) < 5:
+ errs.append(f"Table {i}: too few freq points ({len(t[sn])})")
+ return errs
+
+ for version in ["9601", "2001", "2013"]:
+ tests.append(
+ (
+ f"ac_sweep_param_{version}.ac0",
+ f"ac_sweep_param_{version}",
+ ac_sweep_param,
+ )
+ )
+
+ # ------------------------------------------------------------------
+ # AC SWEEP TEMP: temp -40 120 40, 5 outer tables, complex circuit vars
+ # ------------------------------------------------------------------
+ def ac_sweep_temp(title, date, scale_name, sweep_name, sweep_vals, data_list):
+ errs = []
+ if len(data_list) != 5:
+ errs.append(f"Expected 5 tables, got {len(data_list)}")
+ return errs
+ if sweep_vals is not None and len(sweep_vals) == 5:
+ for i, ev in enumerate([-40.0, 0.0, 40.0, 80.0, 120.0]):
+ if not _close(float(sweep_vals[i]), ev, rtol=1e-3):
+ errs.append(f"sweep_vals[{i}]={sweep_vals[i]}, expected {ev}")
+ for i, t in enumerate(data_list):
+ sn = _find(t, scale_name)
+ if sn is None:
+ errs.append(f"Table {i}: scale '{scale_name}' missing")
+ continue
+ if len(t[sn]) != 11:
+ errs.append(
+ f"Table {i}: expected 11 frequency points, got {len(t[sn])}"
+ )
+ return errs
+
+ for version in ["9601", "2001", "2013"]:
+ tests.append(
+ (f"ac_sweep_temp_{version}.ac0", f"ac_sweep_temp_{version}", ac_sweep_temp)
+ )
+
+ # ------------------------------------------------------------------
+ # AC PROBES ONLY: explicit probes, 1 table, all circuit vars complex128
+ # ------------------------------------------------------------------
+ def ac_probes_only(title, date, scale_name, sweep_name, sweep_vals, data_list):
+ errs = []
+ if len(data_list) != 1:
+ errs.append(f"Expected 1 table, got {len(data_list)}")
+ return errs
+ t = data_list[0]
+ sn_lower = scale_name.lower()
+ sn_upper = scale_name.upper()
+ for k, v in t.items():
+ if k == sn_lower or k == sn_upper:
+ continue
+ if isinstance(v, np.ndarray) and v.dtype != np.complex128:
+ errs.append(f"AC probe '{k}' dtype={v.dtype}, expected complex128")
+ return errs
+
+ for version in ["9601", "2001", "2013"]:
+ tests.append(
+ (
+ f"ac_probes_only_{version}.ac0",
+ f"ac_probes_only_{version}",
+ ac_probes_only,
+ )
+ )
+
+ # ------------------------------------------------------------------
+ # TRAN BASIC: time 0..20ns step 1ns, 1 table, real (float64) vars
+ # ------------------------------------------------------------------
+ def tran_basic(title, date, scale_name, sweep_name, sweep_vals, data_list):
+ errs = []
+ if len(data_list) != 1:
+ errs.append(f"Expected 1 table, got {len(data_list)}")
+ return errs
+ t = data_list[0]
+ sn_lower = scale_name.lower()
+ sn_upper = scale_name.upper()
+ if sn_lower in t:
+ sn = sn_lower
+ elif sn_upper in t:
+ sn = sn_upper
+ else:
+ errs.append(f"Scale '{sn}' not found (keys={list(t.keys())})")
+ return errs
+ times = t[sn]
+ if len(times) < 5:
+ errs.append(f"Too few time points: {len(times)}")
+ if float(times[0]) > 1e-12:
+ errs.append(f"times[0]={times[0]}, expected ~0")
+ if not _close(float(times[-1]), 20e-9, rtol=0.01):
+ errs.append(f"times[-1]={times[-1]:.4e}, expected ~20ns")
+ for k, v in t.items():
+ if k == sn:
+ continue
+ if isinstance(v, np.ndarray) and v.dtype == np.complex128:
+ errs.append(f"Tran var '{k}' is complex; should be real float64")
+ return errs
+
+ for version in ["9601", "2001", "2013"]:
+ tests.append((f"tran_basic_{version}.tr0", f"tran_basic_{version}", tran_basic))
+
+ # ------------------------------------------------------------------
+ # TRAN SWEEP TEMP: temp -40..120 step 40, 5 outer tables
+ # ------------------------------------------------------------------
+ def tran_sweep_temp(title, date, scale_name, sweep_name, sweep_vals, data_list):
+ errs = []
+ if len(data_list) != 5:
+ errs.append(f"Expected 5 tables, got {len(data_list)}")
+ return errs
+ if sweep_vals is not None and len(sweep_vals) == 5:
+ for i, ev in enumerate([-40.0, 0.0, 40.0, 80.0, 120.0]):
+ if not _close(float(sweep_vals[i]), ev, rtol=1e-3):
+ errs.append(f"sweep_vals[{i}]={sweep_vals[i]}, expected {ev}")
+ sn_lower = scale_name.lower()
+ sn_upper = scale_name.upper()
+ for i, t in enumerate(data_list):
+ if sn_lower in t:
+ sn = sn_lower
+ elif sn_upper in t:
+ sn = sn_upper
+ else:
+ errs.append(f"Table {i}: scale '{scale_name}' missing")
+ continue
+ if len(t[sn]) < 2:
+ errs.append(f"Table {i}: too few time points")
+ return errs
+
+ for version in ["9601", "2001", "2013"]:
+ tests.append(
+ (
+ f"tran_sweep_temp_{version}.tr0",
+ f"tran_sweep_temp_{version}",
+ tran_sweep_temp,
+ )
+ )
+
+ # ------------------------------------------------------------------
+ # TRAN SWEEP PARAM: rval 10..30 step 10, 3 outer tables
+ # ------------------------------------------------------------------
+ def tran_sweep_param(title, date, scale_name, sweep_name, sweep_vals, data_list):
+ errs = []
+ if len(data_list) != 3:
+ errs.append(f"Expected 3 tables, got {len(data_list)}")
+ return errs
+ if sweep_vals is not None and len(sweep_vals) == 3:
+ for i, ev in enumerate([10.0, 20.0, 30.0]):
+ if not _close(float(sweep_vals[i]), ev, rtol=1e-3):
+ errs.append(f"sweep_vals[{i}]={sweep_vals[i]}, expected {ev}")
+ return errs
+
+ for version in ["9601", "2001", "2013"]:
+ tests.append(
+ (
+ f"tran_sweep_param_{version}.tr0",
+ f"tran_sweep_param_{version}",
+ tran_sweep_param,
+ )
+ )
+
+ # ------------------------------------------------------------------
+ # TRAN SWEEP SOURCE: v2 1..3 step 1, 3 outer tables
+ # ------------------------------------------------------------------
+ def tran_sweep_source(title, date, scale_name, sweep_name, sweep_vals, data_list):
+ errs = []
+ if len(data_list) != 3:
+ errs.append(f"Expected 3 tables, got {len(data_list)}")
+ return errs
+ if sweep_vals is not None and len(sweep_vals) == 3:
+ for i, ev in enumerate([1.0, 2.0, 3.0]):
+ if not _close(float(sweep_vals[i]), ev, rtol=1e-3):
+ errs.append(f"sweep_vals[{i}]={sweep_vals[i]}, expected {ev}")
+ return errs
+
+ for version in ["9601", "2001", "2013"]:
+ tests.append(
+ (
+ f"tran_sweep_source_{version}.tr0",
+ f"tran_sweep_source_{version}",
+ tran_sweep_source,
+ )
+ )
+
+ # ------------------------------------------------------------------
+ # TRAN PROBE AND SWEEP: rval 10..30 step 10 + probes v(node_a) v(node_b)
+ # => 3 tables, each dict has 'node_a', 'node_b' keys
+ # ------------------------------------------------------------------
+ def tran_probe_and_sweep(
+ title, date, scale_name, sweep_name, sweep_vals, data_list
+ ):
+ errs = []
+ if len(data_list) != 3:
+ errs.append(f"Expected 3 tables, got {len(data_list)}")
+ return errs
+ for i, t in enumerate(data_list):
+ for p in ["node_a", "node_b"]:
+ if _find(t, p) is None:
+ errs.append(f"Table {i}: '{p}' not found. Keys: {list(t.keys())}")
+ return errs
+
+ for version in ["9601", "2001", "2013"]:
+ tests.append(
+ (
+ f"tran_probe_and_sweep_{version}.tr0",
+ f"tran_probe_and_sweep_{version}",
+ tran_probe_and_sweep,
+ )
+ )
+
+ # ------------------------------------------------------------------
+ # TRAN MONTE: 5 Monte Carlo runs, each with time-domain real data
+ # ------------------------------------------------------------------
+ def tran_monte(title, date, scale_name, sweep_name, sweep_vals, data_list):
+ errs = []
+ if len(data_list) != 5:
+ errs.append(f"Expected 5 Monte Carlo tables, got {len(data_list)}")
+ return errs
+ sn_lower = scale_name.lower()
+ sn_upper = scale_name.upper()
+ for i, t in enumerate(data_list):
+ if sn_lower in t:
+ sn = sn_lower
+ elif sn_upper in t:
+ sn = sn_upper
+ else:
+ errs.append(f"Table {i}: scale '{scale_name}' missing")
+ continue
+ if len(t[sn]) < 2:
+ errs.append(f"Table {i}: too few time points ({len(t[sn])})")
+ for k, v in t.items():
+ if k == sn:
+ continue
+ if isinstance(v, np.ndarray) and v.dtype == np.complex128:
+ errs.append(f"Table {i}: var '{k}' is complex; should be real")
+ return errs
+
+ for version in ["9601", "2001", "2013"]:
+ tests.append((f"tran_monte_{version}.tr0", f"tran_monte_{version}", tran_monte))
+
+ # ------------------------------------------------------------------
+ # AC PROBE AND SWEEP: 3 parameter sweep points (rval 10..30 step 10)
+ # ------------------------------------------------------------------
+ def ac_probe_and_sweep(title, date, scale_name, sweep_name, sweep_vals, data_list):
+ errs = []
+ if len(data_list) != 3:
+ errs.append(f"Expected 3 tables, got {len(data_list)}")
+ return errs
+ for i, t in enumerate(data_list):
+ sn = _find(t, scale_name)
+ if sn is None:
+ errs.append(f"Table {i}: scale '{scale_name}' missing")
+ continue
+ if len(t[sn]) != 11:
+ errs.append(
+ f"Table {i}: expected 11 frequency points, got {len(t[sn])}"
+ )
+ for p in ["node_a", "node_b"]:
+ pn = _find(t, p)
+ if pn is None:
+ errs.append(f"Table {i}: '{p}' probe missing")
+ elif t[pn].dtype != np.complex128:
+ errs.append(
+ f"Table {i}: '{pn}' expected complex128, got {t[pn].dtype}"
+ )
+ return errs
+
+ for version in ["9601", "2001", "2013"]:
+ tests.append(
+ (
+ f"ac_probe_and_sweep_{version}.ac0",
+ f"ac_probe_and_sweep_{version}",
+ ac_probe_and_sweep,
+ )
+ )
+
+ # ------------------------------------------------------------------
+ # TRAN PROBES ONLY: transient analysis with probe statements only
+ # ------------------------------------------------------------------
+ def tran_probes_only(title, date, scale_name, sweep_name, sweep_vals, data_list):
+ errs = []
+ if len(data_list) != 1:
+ errs.append(f"Expected 1 table, got {len(data_list)}")
+ return errs
+ t = data_list[0]
+ sn = _find(t, scale_name)
+ if sn is None:
+ errs.append(f"Scale '{scale_name}' missing")
+ if len(t) < 3:
+ errs.append(f"Expected multiple probes, got keys {list(t.keys())}")
+ return errs
+
+ for version in ["9601", "2001", "2013"]:
+ tests.append(
+ (
+ f"tran_probes_only_{version}.tr0",
+ f"tran_probes_only_{version}",
+ tran_probes_only,
+ )
+ )
+
+ # ------------------------------------------------------------------
+ # DC MEAS / SWEEP MEAS: files containing measurements
+ # ------------------------------------------------------------------
+ def dc_meas(title, date, scale_name, sweep_name, sweep_vals, data_list):
+ errs = []
+ if len(data_list) != 1:
+ errs.append(f"Expected 1 table, got {len(data_list)}")
+ return errs
+ t = data_list[0]
+ if len(t) < 2:
+ errs.append("No circuit variables found")
+ return errs
+
+ for version in ["9601", "2001", "2013"]:
+ tests.append((f"dc_meas_{version}.sw0", f"dc_meas_{version}", dc_meas))
+
+ def ac_meas(title, date, scale_name, sweep_name, sweep_vals, data_list):
+ errs = []
+ if len(data_list) != 1:
+ errs.append(f"Expected 1 table, got {len(data_list)}")
+ return errs
+ t = data_list[0]
+ if len(t) < 2:
+ errs.append("No circuit variables found")
+ return errs
+
+ for version in ["9601", "2001", "2013"]:
+ tests.append((f"ac_meas_{version}.ac0", f"ac_meas_{version}", ac_meas))
+
+ def tran_meas(title, date, scale_name, sweep_name, sweep_vals, data_list):
+ errs = []
+ if len(data_list) != 1:
+ errs.append(f"Expected 1 table, got {len(data_list)}")
+ return errs
+ t = data_list[0]
+ if len(t) < 2:
+ errs.append("No circuit variables found")
+ return errs
+
+ for version in ["9601", "2001", "2013"]:
+ tests.append((f"tran_meas_{version}.tr0", f"tran_meas_{version}", tran_meas))
+
+ def dc_meas_sweep(title, date, scale_name, sweep_name, sweep_vals, data_list):
+ errs = []
+ if len(data_list) != 3:
+ errs.append(f"Expected 3 tables, got {len(data_list)}")
+ return errs
+ return errs
+
+ for version in ["9601", "2001", "2013"]:
+ tests.append(
+ (
+ f"dc_meas_sweep_{version}.sw0",
+ f"dc_meas_sweep_{version}",
+ dc_meas_sweep,
+ )
+ )
+
+ def ac_meas_sweep(title, date, scale_name, sweep_name, sweep_vals, data_list):
+ errs = []
+ if len(data_list) != 3:
+ errs.append(f"Expected 3 tables, got {len(data_list)}")
+ return errs
+ return errs
+
+ for version in ["9601", "2001", "2013"]:
+ tests.append(
+ (
+ f"ac_meas_sweep_{version}.ac0",
+ f"ac_meas_sweep_{version}",
+ ac_meas_sweep,
+ )
+ )
+
+ def tran_meas_sweep(title, date, scale_name, sweep_name, sweep_vals, data_list):
+ errs = []
+ if len(data_list) != 3:
+ errs.append(f"Expected 3 tables, got {len(data_list)}")
+ return errs
+ return errs
+
+ for version in ["9601", "2001", "2013"]:
+ tests.append(
+ (
+ f"tran_meas_sweep_{version}.tr0",
+ f"tran_meas_sweep_{version}",
+ tran_meas_sweep,
+ )
+ )
+
+ return tests
+
+
+# ---------------------------------------------------------------------------
+# Runner
+# ---------------------------------------------------------------------------
+
+
+def main():
+ logs_dir = "logs"
+ os.makedirs(logs_dir, exist_ok=True)
+ log_path = os.path.join(logs_dir, "test_hspice_read.log")
+
+ with open(log_path, "w") as lf:
+
+ def log(msg):
+ print(msg)
+ lf.write(msg + "\n")
+ lf.flush()
+
+ log("=" * 70)
+ log("hspice_read() Post-Implementation Test Suite")
+ log("=" * 70)
+ log(f"Work directory: {os.path.abspath(WORK_DIR)}")
+
+ tests = make_tests()
+ passed = 0
+ failed = 0
+ skipped = 0
+
+ for filename, case_name, assertion_fn in tests:
+ log(f"\n--- {case_name} ---")
+ log(f" File: {filename}")
+
+ full_path = os.path.join(WORK_DIR, filename)
+ if not os.path.exists(full_path):
+ log(f" SKIP: file not found ({full_path})")
+ skipped += 1
+ continue
+
+ try:
+ result = hspice_read(full_path)
+ except Exception as e:
+ log(f" FAIL: hspice_read raised {type(e).__name__}: {e}")
+ log(traceback.format_exc())
+ failed += 1
+ continue
+
+ try:
+ title, date, scale_name, sweep_name, sweep_vals, data_list = _unpack(
+ result
+ )
+ except AssertionError as e:
+ log(f" FAIL: return structure wrong: {e}")
+ failed += 1
+ continue
+
+ log(f" title={title.strip()!r}")
+ log(
+ f" scale={scale_name!r} sweep={sweep_name!r}"
+ f" sweep_vals={sweep_vals} tables={len(data_list)}"
+ )
+ if data_list:
+ log(f" table[0] keys: {list(data_list[0].keys())}")
+
+ try:
+ errs = assertion_fn(
+ title, date, scale_name, sweep_name, sweep_vals, data_list
+ )
+ except Exception as e:
+ log(f" FAIL: assertion raised {type(e).__name__}: {e}")
+ log(traceback.format_exc())
+ failed += 1
+ continue
+
+ if errs:
+ for e in errs:
+ log(f" ASSERTION FAILED: {e}")
+ failed += 1
+ else:
+ log(f" PASS")
+ passed += 1
+
+ log("\n" + "=" * 70)
+ log(
+ f"Summary: {passed} passed, {failed} failed, {skipped} skipped"
+ f" out of {len(tests)} cases"
+ )
+ log("=" * 70)
+
+ sys.exit(0 if failed == 0 else 1)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/PySpice/Spice/HSpice/hspicefile/docs/tests/verify_all_types.py b/PySpice/Spice/HSpice/hspicefile/docs/tests/verify_all_types.py
new file mode 100644
index 00000000..9f27c778
--- /dev/null
+++ b/PySpice/Spice/HSpice/hspicefile/docs/tests/verify_all_types.py
@@ -0,0 +1,1367 @@
+import glob
+import os
+import re
+import struct
+import sys
+import traceback
+
+WORK_DIR = "work"
+
+# ---------------------------------------------------------------------------
+# verify_all_types.py
+#
+# Post-implementation test: parses every binary file produced by
+# run_simulations.py and cross-checks numerical values against the matching
+# ASCII golden reference file produced with .option post=2.
+#
+# Does NOT depend on PySpice or the hspice_read C extension — uses only the
+# pure-Python binary parser validated by verify_spec.py.
+#
+# Logs to logs/verify_all_types.log.
+# ---------------------------------------------------------------------------
+
+# ---------------------------------------------------------------------------
+# Minimal binary parser (spec-compliant, verified by verify_spec.py)
+# ---------------------------------------------------------------------------
+
+
+def _read_header(f):
+ """Return (payload_bytes, fmt_char). Raises on corruption."""
+ head = f.read(16)
+ if len(head) < 16:
+ raise IOError("Header block head truncated")
+ e1, _, e2, psz = struct.unpack("IIII", head)
+ else:
+ raise IOError(f"Bad endian markers 0x{e1:08x}/0x{e2:08x}")
+ payload = f.read(psz)
+ if len(payload) < psz:
+ raise IOError("Header payload truncated")
+ tail = struct.unpack(fmt + "I", f.read(4))[0]
+ if tail != psz:
+ raise IOError(f"Header head/tail mismatch ({psz} vs {tail})")
+ return payload, fmt
+
+
+def _parse_header_meta(payload):
+ """Return metadata dict parsed from header payload bytes."""
+ c = payload.decode("ascii", errors="ignore")
+ v16 = c[16:20].strip()
+ v20 = c[20:24].strip()
+ if v16 in ("9007", "9601"):
+ version = v16
+ elif v20 in ("2001", "2013"):
+ version = v20
+ else:
+ raise ValueError(f"Unknown version at [16:20]={repr(v16)} [20:24]={repr(v20)}")
+ num_vars = int(c[0:4])
+ num_probes = int(c[4:8])
+ num_sweeps = int(c[8:12])
+ sweep_size = 1
+ if num_sweeps > 0:
+ tok = c[187:203].strip().split()
+ sweep_size = int(tok[0]) if tok else 1
+ title = c[24:88].strip()
+ num_vectors = num_vars + num_probes
+ # Parse variable types and names from offset 256
+ tokens = c[256:].split()
+ if tokens and tokens[-1] == "$&%#":
+ tokens = tokens[:-1]
+ var_types = [0] * num_vectors
+ var_names = [""] * num_vectors
+ if len(tokens) >= 2 * num_vectors:
+ # Token layout (natural order):
+ # tokens[0..N-1] = type codes: type_0 (scale), type_1, ..., type_{N-1}
+ # tokens[N] = name_0 (scale variable name)
+ # tokens[N+1..2N-1] = names of circuit vars 1..N-1
+ for i in range(num_vectors):
+ var_types[i] = int(tokens[i])
+ var_names[0] = tokens[num_vectors]
+ for i in range(num_vectors - 1):
+ var_names[i + 1] = tokens[num_vectors + 1 + i]
+ return {
+ "version": version,
+ "num_vars": num_vars,
+ "num_probes": num_probes,
+ "num_sweeps": num_sweeps,
+ "sweep_size": sweep_size,
+ "num_vectors": num_vectors,
+ "title": title,
+ "var_types": var_types,
+ "var_names": var_names,
+ }
+
+
+def _is_term_block(payload, version, fmt):
+ sz = len(payload)
+ if version in ("2013", "2001"):
+ if sz == 8:
+ return struct.unpack(fmt + "d", payload)[0] > 9e29
+ if sz >= 8:
+ return struct.unpack(fmt + "d", payload[-8:])[0] > 9e29
+ else:
+ if sz == 4:
+ return struct.unpack(fmt + "f", payload)[0] > 9e29
+ if sz >= 4:
+ return struct.unpack(fmt + "f", payload[-4:])[0] > 9e29
+ return False
+
+
+def _var_sizes(meta):
+ version = meta["version"]
+ is_complex = meta["num_vectors"] > 1 and meta["var_types"][0] == 2
+ sizes = []
+ for i in range(meta["num_vectors"]):
+ if version == "2013":
+ s = 8 if i == 0 else (8 if is_complex else 4)
+ elif version == "2001":
+ s = 8 if (i == 0 or not is_complex) else 16
+ else:
+ s = 4 if (i == 0 or not is_complex) else 8
+ sizes.append(s)
+ return sizes, is_complex
+
+
+def parse_spice_value(val_str):
+ val_str = val_str.strip().lower()
+ if val_str.endswith("f"):
+ return float(val_str[:-1]) * 1e-15
+ elif val_str.endswith("p"):
+ return float(val_str[:-1]) * 1e-12
+ elif val_str.endswith("n"):
+ return float(val_str[:-1]) * 1e-9
+ elif val_str.endswith("u"):
+ return float(val_str[:-1]) * 1e-6
+ elif val_str.endswith("meg"):
+ return float(val_str[:-3]) * 1e6
+ elif val_str.endswith("m"):
+ return float(val_str[:-1]) * 1e-3
+ elif val_str.endswith("k"):
+ return float(val_str[:-1]) * 1e3
+ elif val_str.endswith("g"):
+ return float(val_str[:-1]) * 1e9
+ else:
+ try:
+ return float(val_str)
+ except ValueError:
+ m = re.match(r"^([\d\.\-+e]+)", val_str)
+ if m:
+ return float(m.group(1))
+ raise
+
+
+def parse_meas_file(filepath):
+ if not os.path.exists(filepath):
+ return None
+ with open(filepath, "r", errors="ignore") as f:
+ lines = f.readlines()
+ content_lines = [
+ l.strip()
+ for l in lines
+ if l.strip() and not l.startswith("$") and not l.startswith(".")
+ ]
+ if len(content_lines) < 2:
+ return None
+ names = [n.lower() for n in content_lines[0].split()]
+ is_sweep = len(content_lines) > 2
+ measurements = {}
+ for val_line in content_lines[1:]:
+ vals = val_line.split()
+ if len(vals) != len(names):
+ continue
+ for name, val in zip(names, vals):
+ try:
+ parsed_val = parse_spice_value(val)
+ except Exception:
+ parsed_val = val
+ if is_sweep:
+ if name not in measurements:
+ measurements[name] = []
+ measurements[name].append(parsed_val)
+ else:
+ measurements[name] = parsed_val
+ return measurements
+
+
+def parse_binary(filename):
+ """
+ Parse a binary HSPICE output file.
+ Returns a list of sweep tables; each table is a dict:
+ { var_name: list_of_float_or_complex, ... }
+ For AC, circuit values are complex.
+ Also returns the metadata dict.
+ """
+ with open(filename, "rb") as f:
+ first = f.read(1)
+ if not first or first[0] >= 32:
+ return None, None # ASCII file — skip
+ f.seek(0)
+ header_payload, fmt = _read_header(f)
+ meta = _parse_header_meta(header_payload)
+
+ vsizes, is_complex = _var_sizes(meta)
+ row_size = sum(vsizes)
+ version = meta["version"]
+
+ # Accumulate data blocks into sweep tables
+ tables_raw = []
+ current = bytearray()
+ while True:
+ head_raw = f.read(16)
+ if not head_raw:
+ if current:
+ tables_raw.append(current)
+ break
+ if len(head_raw) < 16:
+ break
+ _, _, _, psz = struct.unpack(fmt + "IIII", head_raw)
+ payload = f.read(psz)
+ f.read(4) # tail
+ if _is_term_block(payload, version, fmt):
+ tables_raw.append(current)
+ current = bytearray()
+ else:
+ current.extend(payload)
+
+ # Decode each sweep table into a dict of arrays
+ sweep_val_size = 8 if version == "2001" else 4
+ sweep_val_fmt = "d" if version == "2001" else "f"
+ tables = []
+ for t_raw in tables_raw:
+ offset = 0
+ table = {name: [] for name in meta["var_names"]}
+ sweep_val = None
+ if meta["num_sweeps"] > 0:
+ sweep_val = struct.unpack(fmt + sweep_val_fmt, t_raw[:sweep_val_size])[
+ 0
+ ]
+ offset += sweep_val_size
+ data_len = len(t_raw) - offset
+ num_rows = data_len // row_size
+ for _ in range(num_rows):
+ c = offset
+ for j, name in enumerate(meta["var_names"]):
+ vsz = vsizes[j]
+ chunk = t_raw[c : c + vsz]
+ if is_complex and j > 0:
+ if vsz == 16:
+ real_val, imag_val = struct.unpack(fmt + "dd", chunk)
+ else:
+ real_val, imag_val = struct.unpack(fmt + "ff", chunk)
+ table[name].append(complex(real_val, imag_val))
+ else:
+ vfmt = "d" if vsz == 8 else "f"
+ table[name].append(struct.unpack(fmt + vfmt, chunk)[0])
+ c += vsz
+ offset += row_size
+ if sweep_val is not None:
+ table["__sweep_val__"] = sweep_val
+ tables.append(table)
+
+ tables_res = tables
+ meta_res = meta
+
+ # Parse measurements if any exist
+ meta_res["measurements"] = {}
+ for ext in [".ms0", ".mt0", ".ma0"]:
+ meas_path = filename.rsplit(".", 1)[0] + ext
+ if os.path.exists(meas_path):
+ meas = parse_meas_file(meas_path)
+ if meas is not None:
+ meta_res["measurements"] = meas
+
+ return tables_res, meta_res
+
+
+# ---------------------------------------------------------------------------
+# ASCII golden reference parser
+# ---------------------------------------------------------------------------
+
+
+def parse_ascii_to_flat_floats(filename):
+ """
+ Extract every 13-character float from the post-terminator data section.
+ """
+ if not os.path.exists(filename):
+ return None
+ with open(filename, "r", errors="ignore") as f:
+ lines = f.readlines()
+
+ header_ended = False
+ vals = []
+ for line in lines:
+ if not header_ended:
+ if "$&%#" in line:
+ header_ended = True
+ continue
+
+ stripped = line.replace("\n", "").replace("\r", "")
+ if not stripped:
+ continue
+ # Split this line into 13-character chunks starting at index 0 of the line
+ for i in range(0, len(stripped), 13):
+ chunk = stripped[i : i + 13].strip()
+ if chunk:
+ try:
+ vals.append(float(chunk))
+ except ValueError:
+ pass
+ return vals
+
+
+def parse_ascii(filename, meta):
+ """
+ Parse an HSPICE ASCII post=2 output file guided by binary file metadata.
+ Returns a list of tables; each table is a dict of name -> list of float/complex.
+ """
+ flat_vals = parse_ascii_to_flat_floats(filename)
+ if flat_vals is None:
+ return None
+
+ version = meta["version"]
+ num_sweeps = meta["num_sweeps"]
+ num_vectors = meta["num_vectors"]
+ var_names = meta["var_names"]
+ var_types = meta["var_types"]
+ is_complex = num_vectors > 1 and var_types[0] == 2
+
+ tables = []
+ idx = 0
+
+ while idx < len(flat_vals):
+ if idx >= len(flat_vals):
+ break
+ table = {name: [] for name in var_names}
+ sweep_val = None
+ if num_sweeps > 0:
+ sweep_val = flat_vals[idx]
+ idx += 1
+ while idx < len(flat_vals):
+ if flat_vals[idx] > 9e29:
+ idx += 1
+ break
+ table[var_names[0]].append(flat_vals[idx])
+ idx += 1
+ for j in range(1, num_vectors):
+ if idx + (2 if is_complex else 1) > len(flat_vals):
+ break
+ if is_complex:
+ re = flat_vals[idx]
+ im = flat_vals[idx + 1]
+ table[var_names[j]].append(complex(re, im))
+ idx += 2
+ else:
+ table[var_names[j]].append(flat_vals[idx])
+ idx += 1
+ if sweep_val is not None:
+ table["__sweep_val__"] = sweep_val
+ tables.append(table)
+ return tables
+
+
+# ---------------------------------------------------------------------------
+# Test cases: (binary_file, ascii_file, assertions)
+# Each assertion is (description, callable(tables, meta) -> bool)
+# ---------------------------------------------------------------------------
+
+
+def _close(a, b, rtol=1e-3, atol=1e-30):
+ """Relative and absolute tolerance comparison for floats."""
+ if abs(a - b) < atol:
+ return True
+ if b == 0:
+ return abs(a) < atol
+ return abs(a - b) / abs(b) < rtol
+
+
+def make_tests():
+ """
+ Return list of (binary_file, ascii_file, case_name, list_of_(desc, fn)).
+ """
+ tests = []
+
+ # Helper: look up variable by partial name match (handles 'v(node_a' without closing paren)
+ def find_var(table, partial):
+ for k in table:
+ if k.startswith(partial) or k == partial:
+ return k
+ return None
+
+ # ------------------------------------------------------------------
+ # DC BASIC — no sweep, voltage source
+ # ------------------------------------------------------------------
+ def dc_basic_assertions(tables, meta):
+ errs = []
+ if len(tables) != 1:
+ errs.append(f"Expected 1 table, got {len(tables)}")
+ return errs
+ t = tables[0]
+ scale = find_var(t, meta["var_names"][0])
+ if scale is None:
+ errs.append("Scale variable not found")
+ return errs
+ # v1 sweeps 1.0..5.0 in steps of 1.0 => 5 points
+ if len(t[scale]) != 5:
+ errs.append(f"Expected 5 scale points, got {len(t[scale])}")
+ if not _close(t[scale][0], 1.0):
+ errs.append(f"Scale[0] expected 1.0, got {t[scale][0]}")
+ if not _close(t[scale][-1], 5.0):
+ errs.append(f"Scale[-1] expected 5.0, got {t[scale][-1]}")
+ # node_a == v1 voltage (DC wire)
+ node_a = find_var(t, "node_a")
+ if node_a:
+ for i, (vs, va) in enumerate(zip(t[scale], t[node_a])):
+ if not _close(vs, va, rtol=1e-4):
+ errs.append(f"Row {i}: v(node_a)={va} != VOLTS={vs}")
+ return errs
+
+ for version in ["9601", "2001", "2013"]:
+ tests.append(
+ (
+ f"dc_basic_{version}.sw0",
+ "dc_basic_ascii.sw0",
+ f"dc_basic_{version}",
+ dc_basic_assertions,
+ )
+ )
+
+ # ------------------------------------------------------------------
+ # DC SWEEP TEMP — temperature is the primary .dc axis (DEG_C scale var),
+ # NOT a secondary outer sweep. Produces 1 table with 5 rows.
+ # ------------------------------------------------------------------
+ def dc_sweep_temp_assertions(tables, meta):
+ errs = []
+ if len(tables) != 1:
+ errs.append(
+ f"Expected 1 table (temp is primary .dc axis), got {len(tables)}"
+ )
+ return errs
+ t = tables[0]
+ scale = meta["var_names"][0] # should be DEG_C
+ if scale not in t:
+ errs.append(f"Scale '{scale}' missing from table")
+ return errs
+ if len(t[scale]) != 5:
+ errs.append(
+ f"Expected 5 rows (temp -40 to 120 step 40), got {len(t[scale])}"
+ )
+ if not _close(t[scale][0], -40.0, rtol=1e-3):
+ errs.append(f"Scale[0] expected -40.0, got {t[scale][0]}")
+ if not _close(t[scale][-1], 120.0, rtol=1e-3):
+ errs.append(f"Scale[-1] expected 120.0, got {t[scale][-1]}")
+ return errs
+
+ for version in ["9601", "2001", "2013"]:
+ tests.append(
+ (
+ f"dc_sweep_temp_{version}.sw0",
+ "dc_sweep_temp_ascii.sw0",
+ f"dc_sweep_temp_{version}",
+ dc_sweep_temp_assertions,
+ )
+ )
+
+ # ------------------------------------------------------------------
+ # DC SWEEP PARAM — rval is the primary .dc axis (rval scale var),
+ # NOT a secondary outer sweep. Produces 1 table with 5 rows.
+ # ------------------------------------------------------------------
+ def dc_sweep_param_assertions(tables, meta):
+ errs = []
+ if len(tables) != 1:
+ errs.append(
+ f"Expected 1 table (rval is primary .dc axis), got {len(tables)}"
+ )
+ return errs
+ t = tables[0]
+ scale = meta["var_names"][0] # should be rval
+ if scale not in t:
+ errs.append(f"Scale '{scale}' missing from table")
+ return errs
+ if len(t[scale]) != 5:
+ errs.append(f"Expected 5 rows (rval 10 to 50 step 10), got {len(t[scale])}")
+ if not _close(t[scale][0], 10.0, rtol=1e-3):
+ errs.append(f"Scale[0] expected 10.0, got {t[scale][0]}")
+ if not _close(t[scale][-1], 50.0, rtol=1e-3):
+ errs.append(f"Scale[-1] expected 50.0, got {t[scale][-1]}")
+ return errs
+
+ for version in ["9601", "2001", "2013"]:
+ tests.append(
+ (
+ f"dc_sweep_param_{version}.sw0",
+ "dc_sweep_param_ascii.sw0",
+ f"dc_sweep_param_{version}",
+ dc_sweep_param_assertions,
+ )
+ )
+
+ # ------------------------------------------------------------------
+ # DC MONTE — 5 Monte Carlo runs, 5 data points each (v1 1..5 step 1)
+ # ------------------------------------------------------------------
+ def dc_monte_assertions(tables, meta):
+ errs = []
+ if len(tables) != 5:
+ errs.append(f"Expected 5 Monte Carlo tables, got {len(tables)}")
+ return errs
+ scale = meta["var_names"][0]
+ for i, t in enumerate(tables):
+ if scale not in t:
+ errs.append(f"Table {i}: scale var '{scale}' missing")
+ continue
+ if len(t[scale]) != 5:
+ errs.append(f"Table {i}: expected 5 rows, got {len(t[scale])}")
+ return errs
+
+ for version in ["9601", "2001", "2013"]:
+ tests.append(
+ (
+ f"dc_monte_{version}.sw0",
+ None, # Monte Carlo output is non-deterministic; no golden reference check
+ f"dc_monte_{version}",
+ dc_monte_assertions,
+ )
+ )
+
+ # ------------------------------------------------------------------
+ # AC BASIC — no sweep, 5 frequency points (dec 5 100 10k => 21 pts)
+ # ------------------------------------------------------------------
+ def ac_basic_assertions(tables, meta):
+ errs = []
+ if len(tables) != 1:
+ errs.append(f"Expected 1 table, got {len(tables)}")
+ return errs
+ t = tables[0]
+ scale = meta["var_names"][0]
+ if scale not in t:
+ errs.append(f"Scale '{scale}' not in table")
+ return errs
+ freqs = t[scale]
+ # dec 5 100 10k gives (5*2)+1 = 11 pts per decade, 2 decades => 11 pts
+ if len(freqs) < 5:
+ errs.append(f"Expected >=5 frequency points, got {len(freqs)}")
+ if freqs[0] > freqs[-1]:
+ errs.append("Frequency should be increasing")
+ if not _close(freqs[0], 100.0, rtol=0.01):
+ errs.append(f"First freq expected ~100 Hz, got {freqs[0]}")
+ if not _close(freqs[-1], 10000.0, rtol=0.01):
+ errs.append(f"Last freq expected ~10000 Hz, got {freqs[-1]}")
+ # All circuit vars should be complex
+ for name in meta["var_names"][1:]:
+ if name not in t:
+ errs.append(f"Var '{name}' missing")
+ continue
+ sample = t[name][0]
+ if not isinstance(sample, complex):
+ errs.append(f"Var '{name}' should be complex, got {type(sample)}")
+ return errs
+
+ for version in ["9601", "2001", "2013"]:
+ tests.append(
+ (
+ f"ac_basic_{version}.ac0",
+ "ac_basic_ascii.ac0",
+ f"ac_basic_{version}",
+ ac_basic_assertions,
+ )
+ )
+
+ # ------------------------------------------------------------------
+ # AC SWEEP PARAM — 3 sweep points for rval (10, 20, 30)
+ # ------------------------------------------------------------------
+ def ac_sweep_param_assertions(tables, meta):
+ errs = []
+ if len(tables) != 3:
+ errs.append(f"Expected 3 sweep tables, got {len(tables)}")
+ return errs
+ expected_rvals = [10.0, 20.0, 30.0]
+ for i, (t, er) in enumerate(zip(tables, expected_rvals)):
+ sv = t.get("__sweep_val__")
+ if sv is None:
+ errs.append(f"Table {i}: no sweep value")
+ elif not _close(sv, er, rtol=1e-3):
+ errs.append(f"Table {i}: sweep_val={sv}, expected {er}")
+ scale = meta["var_names"][0]
+ if scale in t and len(t[scale]) < 5:
+ errs.append(f"Table {i}: too few frequency points ({len(t[scale])})")
+ return errs
+
+ for version in ["9601", "2001", "2013"]:
+ tests.append(
+ (
+ f"ac_sweep_param_{version}.ac0",
+ "ac_sweep_param_ascii.ac0",
+ f"ac_sweep_param_{version}",
+ ac_sweep_param_assertions,
+ )
+ )
+
+ # ------------------------------------------------------------------
+ # AC SWEEP TEMP — AC sweep with temperature (-40 to 120 step 40 => 5 tables)
+ # ------------------------------------------------------------------
+ def ac_sweep_temp_assertions(tables, meta):
+ errs = []
+ if len(tables) != 5:
+ errs.append(f"Expected 5 outer sweep tables (temp), got {len(tables)}")
+ return errs
+ scale = meta["var_names"][0]
+ for i, t in enumerate(tables):
+ if scale not in t:
+ errs.append(f"Table {i}: scale '{scale}' missing")
+ continue
+ if len(t[scale]) != 11:
+ errs.append(
+ f"Table {i}: expected 11 frequency points, got {len(t[scale])}"
+ )
+ return errs
+
+ for version in ["9601", "2001", "2013"]:
+ tests.append(
+ (
+ f"ac_sweep_temp_{version}.ac0",
+ "ac_sweep_temp_ascii.ac0",
+ f"ac_sweep_temp_{version}",
+ ac_sweep_temp_assertions,
+ )
+ )
+
+ # ------------------------------------------------------------------
+ # TRAN BASIC — no sweep, time 0..20ns step 1ns
+ # ------------------------------------------------------------------
+ def tran_basic_assertions(tables, meta):
+ errs = []
+ if len(tables) != 1:
+ errs.append(f"Expected 1 table, got {len(tables)}")
+ return errs
+ t = tables[0]
+ scale = meta["var_names"][0]
+ if scale not in t:
+ errs.append(f"Scale '{scale}' missing")
+ return errs
+ times = t[scale]
+ if len(times) < 5:
+ errs.append(f"Too few time points: {len(times)}")
+ if times[0] > 1e-12:
+ errs.append(f"Time[0] expected ~0, got {times[0]}")
+ if not _close(times[-1], 20e-9, rtol=0.01):
+ errs.append(f"Time[-1] expected ~20ns, got {times[-1]:.4e}")
+ # All circuit vars should be real (non-complex)
+ for name in meta["var_names"][1:]:
+ if name not in t:
+ errs.append(f"Var '{name}' missing")
+ continue
+ sample = t[name][0]
+ if isinstance(sample, complex):
+ errs.append(f"Var '{name}' should be real in transient, got complex")
+ return errs
+
+ for version in ["9601", "2001", "2013"]:
+ tests.append(
+ (
+ f"tran_basic_{version}.tr0",
+ "tran_basic_ascii.tr0",
+ f"tran_basic_{version}",
+ tran_basic_assertions,
+ )
+ )
+
+ # ------------------------------------------------------------------
+ # TRAN SWEEP TEMP — 5 temperature sweep points
+ # ------------------------------------------------------------------
+ def tran_sweep_temp_assertions(tables, meta):
+ errs = []
+ if len(tables) != 5:
+ errs.append(f"Expected 5 tables, got {len(tables)}")
+ return errs
+ expected_temps = [-40.0, 0.0, 40.0, 80.0, 120.0]
+ for i, (t, et) in enumerate(zip(tables, expected_temps)):
+ sv = t.get("__sweep_val__")
+ if sv is None:
+ errs.append(f"Table {i}: no sweep value")
+ elif not _close(sv, et, rtol=1e-3):
+ errs.append(f"Table {i}: sweep_val={sv}, expected {et}")
+ return errs
+
+ for version in ["9601", "2001", "2013"]:
+ tests.append(
+ (
+ f"tran_sweep_temp_{version}.tr0",
+ "tran_sweep_temp_ascii.tr0",
+ f"tran_sweep_temp_{version}",
+ tran_sweep_temp_assertions,
+ )
+ )
+
+ # ------------------------------------------------------------------
+ # TRAN SWEEP PARAM — rval 10..30 step 10, 3 tables
+ # ------------------------------------------------------------------
+ def tran_sweep_param_assertions(tables, meta):
+ errs = []
+ if len(tables) != 3:
+ errs.append(f"Expected 3 tables, got {len(tables)}")
+ return errs
+ expected_rvals = [10.0, 20.0, 30.0]
+ for i, (t, er) in enumerate(zip(tables, expected_rvals)):
+ sv = t.get("__sweep_val__")
+ if sv is None:
+ errs.append(f"Table {i}: no sweep value")
+ elif not _close(sv, er, rtol=1e-3):
+ errs.append(f"Table {i}: sweep_val={sv}, expected {er}")
+ return errs
+
+ for version in ["9601", "2001", "2013"]:
+ tests.append(
+ (
+ f"tran_sweep_param_{version}.tr0",
+ "tran_sweep_param_ascii.tr0",
+ f"tran_sweep_param_{version}",
+ tran_sweep_param_assertions,
+ )
+ )
+
+ # ------------------------------------------------------------------
+ # TRAN SWEEP SOURCE — v2 sweep 1..3 step 1, 3 tables
+ # ------------------------------------------------------------------
+ def tran_sweep_source_assertions(tables, meta):
+ errs = []
+ if len(tables) != 3:
+ errs.append(f"Expected 3 tables, got {len(tables)}")
+ return errs
+ expected_vals = [1.0, 2.0, 3.0]
+ for i, (t, ev) in enumerate(zip(tables, expected_vals)):
+ sv = t.get("__sweep_val__")
+ if sv is None:
+ errs.append(f"Table {i}: no sweep value")
+ elif not _close(sv, ev, rtol=1e-3):
+ errs.append(f"Table {i}: sweep_val={sv}, expected {ev}")
+ return errs
+
+ for version in ["9601", "2001", "2013"]:
+ tests.append(
+ (
+ f"tran_sweep_source_{version}.tr0",
+ "tran_sweep_source_ascii.tr0",
+ f"tran_sweep_source_{version}",
+ tran_sweep_source_assertions,
+ )
+ )
+
+ # ------------------------------------------------------------------
+ # TRAN PROBES ONLY — transient analysis with probe statements only
+ # ------------------------------------------------------------------
+ def tran_probes_only_assertions(tables, meta):
+ errs = []
+ if len(tables) != 1:
+ errs.append(f"Expected 1 table, got {len(tables)}")
+ return errs
+ t = tables[0]
+ scale = meta["var_names"][0]
+ if scale not in t:
+ errs.append(f"Scale '{scale}' missing")
+ if len(meta["var_names"]) < 3:
+ errs.append(f"Expected multiple probes, got {meta['var_names']}")
+ return errs
+
+ for version in ["9601", "2001", "2013"]:
+ tests.append(
+ (
+ f"tran_probes_only_{version}.tr0",
+ "tran_probes_only_ascii.tr0",
+ f"tran_probes_only_{version}",
+ tran_probes_only_assertions,
+ )
+ )
+
+ # ------------------------------------------------------------------
+ # TRAN PROBE AND SWEEP — rval 10..30 step 10, explicit probes
+ # ------------------------------------------------------------------
+ def tran_probe_and_sweep_assertions(tables, meta):
+ errs = []
+ if len(tables) != 3:
+ errs.append(f"Expected 3 tables, got {len(tables)}")
+ return errs
+ # Probes: v(node_a), v(node_b), i(v1) — verify they are present
+ expected_probe_partials = ["node_a", "node_b"]
+ for i, t in enumerate(tables):
+ for partial in expected_probe_partials:
+ found = any(partial in k for k in t if k != "__sweep_val__")
+ if not found:
+ errs.append(
+ f"Table {i}: expected probe containing '{partial}' not found. Keys={list(t.keys())}"
+ )
+ return errs
+
+ for version in ["9601", "2001", "2013"]:
+ tests.append(
+ (
+ f"tran_probe_and_sweep_{version}.tr0",
+ "tran_probe_and_sweep_ascii.tr0",
+ f"tran_probe_and_sweep_{version}",
+ tran_probe_and_sweep_assertions,
+ )
+ )
+
+ # ------------------------------------------------------------------
+ # TRAN MONTE — 5 runs, all real
+ # ------------------------------------------------------------------
+ def tran_monte_assertions(tables, meta):
+ errs = []
+ if len(tables) != 5:
+ errs.append(f"Expected 5 Monte Carlo tables, got {len(tables)}")
+ return errs
+ scale = meta["var_names"][0]
+ for i, t in enumerate(tables):
+ if scale not in t:
+ errs.append(f"Table {i}: scale '{scale}' missing")
+ continue
+ if len(t[scale]) < 2:
+ errs.append(f"Table {i}: too few time points ({len(t[scale])})")
+ return errs
+
+ for version in ["9601", "2001", "2013"]:
+ tests.append(
+ (
+ f"tran_monte_{version}.tr0",
+ None,
+ f"tran_monte_{version}",
+ tran_monte_assertions,
+ )
+ )
+
+ # ------------------------------------------------------------------
+ # DC PROBES ONLY — v(node_a), v(node_b), differential, i(v1), i(r1), p(r1)
+ # ------------------------------------------------------------------
+ def dc_probes_only_assertions(tables, meta):
+ errs = []
+ if len(tables) != 1:
+ errs.append(f"Expected 1 table, got {len(tables)}")
+ return errs
+ t = tables[0]
+ if len(meta["var_names"]) < 3:
+ errs.append(f"Expected multiple probes, got {meta['var_names']}")
+ return errs
+
+ for version in ["9601", "2001", "2013"]:
+ tests.append(
+ (
+ f"dc_probes_only_{version}.sw0",
+ "dc_probes_only_ascii.sw0",
+ f"dc_probes_only_{version}",
+ dc_probes_only_assertions,
+ )
+ )
+
+ # ------------------------------------------------------------------
+ # DC PROBE AND SWEEP — rval 10..30 step 10, explicit probes (5 points inner)
+ # ------------------------------------------------------------------
+ def dc_probe_and_sweep_assertions(tables, meta):
+ errs = []
+ if len(tables) != 3:
+ errs.append(f"Expected 3 outer sweep tables (rval), got {len(tables)}")
+ return errs
+ scale = meta["var_names"][0]
+ for i, t in enumerate(tables):
+ if scale not in t:
+ errs.append(f"Table {i}: scale '{scale}' missing")
+ continue
+ if len(t[scale]) != 5:
+ errs.append(f"Table {i}: expected 5 inner points, got {len(t[scale])}")
+ for p in ["node_a", "node_b"]:
+ found = any(p in k for k in t if k != "__sweep_val__")
+ if not found:
+ errs.append(f"Table {i}: '{p}' probe missing")
+ return errs
+
+ for version in ["9601", "2001", "2013"]:
+ tests.append(
+ (
+ f"dc_probe_and_sweep_{version}.sw0",
+ "dc_probe_and_sweep_ascii.sw0",
+ f"dc_probe_and_sweep_{version}",
+ dc_probe_and_sweep_assertions,
+ )
+ )
+
+ # ------------------------------------------------------------------
+ # AC PROBES ONLY — v(node_a), v(node_b), differential, i(v1), i(r1)
+ # ------------------------------------------------------------------
+ def ac_probes_only_assertions(tables, meta):
+ errs = []
+ if len(tables) != 1:
+ errs.append(f"Expected 1 table, got {len(tables)}")
+ return errs
+ t = tables[0]
+ scale = meta["var_names"][0]
+ if scale not in t:
+ errs.append(f"Scale '{scale}' missing")
+ # All circuit vars must be complex
+ for name in meta["var_names"][1:]:
+ if name not in t:
+ errs.append(f"Var '{name}' missing")
+ continue
+ if t[name] and not isinstance(t[name][0], complex):
+ errs.append(f"Var '{name}' should be complex in AC")
+ return errs
+
+ for version in ["9601", "2001", "2013"]:
+ tests.append(
+ (
+ f"ac_probes_only_{version}.ac0",
+ "ac_probes_only_ascii.ac0",
+ f"ac_probes_only_{version}",
+ ac_probes_only_assertions,
+ )
+ )
+
+ # ------------------------------------------------------------------
+ # AC PROBE AND SWEEP — rval 10..30 step 10, explicit probes (11 points inner)
+ # ------------------------------------------------------------------
+ def ac_probe_and_sweep_assertions(tables, meta):
+ errs = []
+ if len(tables) != 3:
+ errs.append(f"Expected 3 outer sweep tables (rval), got {len(tables)}")
+ return errs
+ scale = meta["var_names"][0]
+ for i, t in enumerate(tables):
+ if scale not in t:
+ errs.append(f"Table {i}: scale '{scale}' missing")
+ continue
+ if len(t[scale]) != 11:
+ errs.append(
+ f"Table {i}: expected 11 frequency points, got {len(t[scale])}"
+ )
+ for p in ["node_a", "node_b"]:
+ pv = next((k for k in t if p in k and k != "__sweep_val__"), None)
+ if pv is None:
+ errs.append(f"Table {i}: '{p}' probe missing")
+ elif t[pv] and not isinstance(t[pv][0], complex):
+ errs.append(f"Table {i}: '{pv}' should be complex in AC")
+ return errs
+
+ for version in ["9601", "2001", "2013"]:
+ tests.append(
+ (
+ f"ac_probe_and_sweep_{version}.ac0",
+ "ac_probe_and_sweep_ascii.ac0",
+ f"ac_probe_and_sweep_{version}",
+ ac_probe_and_sweep_assertions,
+ )
+ )
+
+ # ------------------------------------------------------------------
+ # DC NESTED SWEEP — v1 1..5 sweep rval 10..30, 3 outer sweep tables
+ # ------------------------------------------------------------------
+ def dc_nested_sweep_assertions(tables, meta):
+ errs = []
+ if len(tables) != 3:
+ errs.append(f"Expected 3 outer sweep tables (rval), got {len(tables)}")
+ return errs
+ scale = meta["var_names"][0]
+ for i, t in enumerate(tables):
+ if scale not in t:
+ errs.append(f"Table {i}: scale '{scale}' missing")
+ continue
+ if len(t[scale]) != 5:
+ errs.append(
+ f"Table {i}: expected 5 inner points (v1 1..5), got {len(t[scale])}"
+ )
+ return errs
+
+ for version in ["9601", "2001", "2013"]:
+ tests.append(
+ (
+ f"dc_nested_sweep_{version}.sw0",
+ "dc_nested_sweep_ascii.sw0",
+ f"dc_nested_sweep_{version}",
+ dc_nested_sweep_assertions,
+ )
+ )
+
+ # ------------------------------------------------------------------
+ # OP BASIC
+ # ------------------------------------------------------------------
+ def op_basic_assertions(tables, meta):
+ errs = []
+ if len(tables) != 1:
+ errs.append(f"Expected 1 table, got {len(tables)}")
+ return errs
+ t = tables[0]
+ node_a = find_var(t, "node_a")
+ node_b = find_var(t, "node_b")
+ if not node_a or not _close(t[node_a][0], 1.0):
+ errs.append(f"node_a expected 1.0, got {t.get(node_a)}")
+ if not node_b or not _close(t[node_b][0], 0.66667):
+ errs.append(f"node_b expected 0.66667, got {t.get(node_b)}")
+ v1 = find_var(t, "i(v1")
+ if not v1 or not _close(t[v1][0], -0.033333):
+ errs.append(f"i(v1) expected -0.033333, got {t.get(v1)}")
+ return errs
+
+ for version in ["9601", "2001", "2013", "ascii"]:
+ tests.append(
+ (
+ f"op_basic_{version}.ic0",
+ "op_basic_ascii.ic0",
+ f"op_basic_{version}",
+ op_basic_assertions,
+ )
+ )
+
+ # ------------------------------------------------------------------
+ # OP WITH PROBES
+ # ------------------------------------------------------------------
+ def op_with_probes_assertions(tables, meta):
+ errs = []
+ if len(tables) != 1:
+ errs.append(f"Expected 1 table, got {len(tables)}")
+ return errs
+ t = tables[0]
+ node_a = find_var(t, "node_a")
+ node_b = find_var(t, "node_b")
+ if not node_a or not _close(t[node_a][0], 2.0):
+ errs.append(f"node_a expected 2.0, got {t.get(node_a)}")
+ if not node_b or not _close(t[node_b][0], 1.0):
+ errs.append(f"node_b expected 1.0, got {t.get(node_b)}")
+ v1 = find_var(t, "i(v1")
+ v2 = find_var(t, "i(v2")
+ if not v1 or not _close(t[v1][0], -0.2):
+ errs.append(f"i(v1) expected -0.2, got {t.get(v1)}")
+ if not v2 or not _close(t[v2][0], 0.2):
+ errs.append(f"i(v2) expected 0.2, got {t.get(v2)}")
+ return errs
+
+ for version in ["9601", "2001", "2013", "ascii"]:
+ tests.append(
+ (
+ f"op_with_probes_{version}.ic0",
+ "op_with_probes_ascii.ic0",
+ f"op_with_probes_{version}",
+ op_with_probes_assertions,
+ )
+ )
+
+ # ------------------------------------------------------------------
+ # DC MEASUREMENT
+ # ------------------------------------------------------------------
+ def dc_meas_assertions(tables, meta):
+ errs = []
+ meas = meta.get("measurements", {})
+ if "v_out_max" not in meas:
+ errs.append(
+ f"Expected measurement 'v_out_max' not found (keys={list(meas.keys())})"
+ )
+ else:
+ val = meas["v_out_max"]
+ if not _close(val, 3.3333):
+ errs.append(f"v_out_max {val} != expected 3.3333")
+ return errs
+
+ for version in ["9601", "2001", "2013", "ascii"]:
+ tests.append(
+ (
+ f"dc_meas_{version}.sw0",
+ "dc_meas_ascii.sw0",
+ f"dc_meas_{version}",
+ dc_meas_assertions,
+ )
+ )
+
+ # ------------------------------------------------------------------
+ # AC MEASUREMENT
+ # ------------------------------------------------------------------
+ def ac_meas_assertions(tables, meta):
+ errs = []
+ meas = meta.get("measurements", {})
+ if "v_out_at_1k" not in meas:
+ errs.append(
+ f"Expected measurement 'v_out_at_1k' not found (keys={list(meas.keys())})"
+ )
+ else:
+ val = meas["v_out_at_1k"]
+ if not _close(val, 0.998):
+ errs.append(f"v_out_at_1k {val} != expected 0.998")
+ return errs
+
+ for version in ["9601", "2001", "2013", "ascii"]:
+ tests.append(
+ (
+ f"ac_meas_{version}.ac0",
+ "ac_meas_ascii.ac0",
+ f"ac_meas_{version}",
+ ac_meas_assertions,
+ )
+ )
+
+ # ------------------------------------------------------------------
+ # TRAN MEASUREMENT
+ # ------------------------------------------------------------------
+ def tran_meas_assertions(tables, meta):
+ errs = []
+ meas = meta.get("measurements", {})
+ if "v_out_max" not in meas:
+ errs.append(
+ f"Expected measurement 'v_out_max' not found (keys={list(meas.keys())})"
+ )
+ else:
+ val = meas["v_out_max"]
+ if not _close(val, 0.66667):
+ errs.append(f"v_out_max {val} != expected 0.66667")
+ return errs
+
+ for version in ["9601", "2001", "2013", "ascii"]:
+ tests.append(
+ (
+ f"tran_meas_{version}.tr0",
+ "tran_meas_ascii.tr0",
+ f"tran_meas_{version}",
+ tran_meas_assertions,
+ )
+ )
+
+ # ------------------------------------------------------------------
+ # DC MEASUREMENT SWEEP
+ # ------------------------------------------------------------------
+ def dc_meas_sweep_assertions(tables, meta):
+ errs = []
+ meas = meta.get("measurements", {})
+ if "v_out_max" not in meas:
+ errs.append("Expected measurement 'v_out_max' not found")
+ else:
+ vals = meas["v_out_max"]
+ expected = [3.3333, 2.5, 2.0]
+ if (
+ not isinstance(vals, list)
+ or len(vals) != len(expected)
+ or not all(_close(x, y) for x, y in zip(vals, expected))
+ ):
+ errs.append(f"v_out_max {vals} != expected {expected}")
+ return errs
+
+ for version in ["9601", "2001", "2013", "ascii"]:
+ tests.append(
+ (
+ f"dc_meas_sweep_{version}.sw0",
+ "dc_meas_sweep_ascii.sw0",
+ f"dc_meas_sweep_{version}",
+ dc_meas_sweep_assertions,
+ )
+ )
+
+ # ------------------------------------------------------------------
+ # AC MEASUREMENT SWEEP
+ # ------------------------------------------------------------------
+ def ac_meas_sweep_assertions(tables, meta):
+ errs = []
+ meas = meta.get("measurements", {})
+ if "v_out_at_1k" not in meas:
+ errs.append("Expected measurement 'v_out_at_1k' not found")
+ else:
+ vals = meas["v_out_at_1k"]
+ expected = [0.998, 0.9922, 0.9827]
+ if (
+ not isinstance(vals, list)
+ or len(vals) != len(expected)
+ or not all(_close(x, y) for x, y in zip(vals, expected))
+ ):
+ errs.append(f"v_out_at_1k {vals} != expected {expected}")
+ return errs
+
+ for version in ["9601", "2001", "2013", "ascii"]:
+ tests.append(
+ (
+ f"ac_meas_sweep_{version}.ac0",
+ "ac_meas_sweep_ascii.ac0",
+ f"ac_meas_sweep_{version}",
+ ac_meas_sweep_assertions,
+ )
+ )
+
+ # ------------------------------------------------------------------
+ # TRAN MEASUREMENT SWEEP
+ # ------------------------------------------------------------------
+ def tran_meas_sweep_assertions(tables, meta):
+ errs = []
+ meas = meta.get("measurements", {})
+ if "v_out_max" not in meas:
+ errs.append("Expected measurement 'v_out_max' not found")
+ else:
+ vals = meas["v_out_max"]
+ expected = [0.66667, 0.5, 0.4]
+ if (
+ not isinstance(vals, list)
+ or len(vals) != len(expected)
+ or not all(_close(x, y) for x, y in zip(vals, expected))
+ ):
+ errs.append(f"v_out_max {vals} != expected {expected}")
+ return errs
+
+ for version in ["9601", "2001", "2013", "ascii"]:
+ tests.append(
+ (
+ f"tran_meas_sweep_{version}.tr0",
+ "tran_meas_sweep_ascii.tr0",
+ f"tran_meas_sweep_{version}",
+ tran_meas_sweep_assertions,
+ )
+ )
+
+ return tests
+
+
+# ---------------------------------------------------------------------------
+# Runner
+# ---------------------------------------------------------------------------
+
+
+def main():
+ logs_dir = "logs"
+ os.makedirs(logs_dir, exist_ok=True)
+ log_path = os.path.join(logs_dir, "verify_all_types.log")
+
+ with open(log_path, "w") as lf:
+
+ def log(msg):
+ print(msg)
+ lf.write(msg + "\n")
+ lf.flush()
+
+ log("=" * 70)
+ log("HSPICE Simulation Type Verification (post-implementation test)")
+ log("=" * 70)
+ log(f"Work directory: {os.path.abspath(WORK_DIR)}")
+
+ tests = make_tests()
+ passed = 0
+ failed = 0
+ skipped = 0
+
+ for bin_file, ascii_file, case_name, assertion_fn in tests:
+ log(f"\n--- {case_name} ---")
+ log(f" Binary: {bin_file}")
+
+ full_path = os.path.join(WORK_DIR, bin_file)
+ if not os.path.exists(full_path):
+ log(f" SKIP: file not found ({full_path})")
+ skipped += 1
+ continue
+
+ try:
+ tables, meta = parse_binary(full_path)
+ except Exception:
+ log(f" FAIL: parse_binary raised exception")
+ log(traceback.format_exc())
+ failed += 1
+ continue
+
+ if tables is None:
+ log(f" SKIP: ASCII file detected (not a binary)")
+ skipped += 1
+ continue
+
+ log(
+ f" Version: {meta['version']} Vectors: {meta['num_vectors']} "
+ f"SweepSize: {meta['sweep_size']} Tables: {len(tables)}"
+ )
+ log(f" Var names: {meta['var_names']}")
+ log(f" Var types: {meta['var_types']}")
+
+ try:
+ errs = assertion_fn(tables, meta)
+ except Exception:
+ log(f" FAIL: assertion_fn raised exception")
+ log(traceback.format_exc())
+ failed += 1
+ continue
+
+ # Load and compare against ASCII golden file
+ if ascii_file is not None and ascii_file != bin_file:
+ ascii_path = os.path.join(WORK_DIR, ascii_file)
+ try:
+ ascii_tables = parse_ascii(ascii_path, meta)
+ if ascii_tables is None:
+ errs.append(
+ f"ASCII golden file not found or failed to parse ({ascii_path})"
+ )
+ elif len(tables) != len(ascii_tables):
+ errs.append(
+ f"Number of tables mismatch: binary has {len(tables)}, ASCII has {len(ascii_tables)}"
+ )
+ else:
+ for t_idx, (bin_table, ascii_table) in enumerate(
+ zip(tables, ascii_tables)
+ ):
+ bin_keys = [
+ k for k in bin_table.keys() if k != "__sweep_val__"
+ ]
+ ascii_keys = [
+ k for k in ascii_table.keys() if k != "__sweep_val__"
+ ]
+ if len(bin_keys) != len(ascii_keys):
+ errs.append(
+ f"Table {t_idx} variable count mismatch: binary has {len(bin_keys)}, ASCII has {len(ascii_keys)}"
+ )
+ continue
+ for key in bin_keys:
+ bin_vals = bin_table[key]
+ ascii_vals = ascii_table[key]
+ if len(bin_vals) != len(ascii_vals):
+ errs.append(
+ f"Table {t_idx} variable '{key}' length mismatch: binary has {len(bin_vals)}, ASCII has {len(ascii_vals)}"
+ )
+ continue
+ for val_idx, (b_v, a_v) in enumerate(
+ zip(bin_vals, ascii_vals)
+ ):
+ if isinstance(b_v, complex):
+ if not _close(
+ b_v.real, a_v.real, rtol=1e-3
+ ) or not _close(b_v.imag, a_v.imag, rtol=1e-3):
+ errs.append(
+ f"Table {t_idx} variable '{key}' row {val_idx} value mismatch: binary={b_v}, ASCII={a_v}"
+ )
+ break
+ else:
+ if not _close(b_v, a_v, rtol=1e-3):
+ errs.append(
+ f"Table {t_idx} variable '{key}' row {val_idx} value mismatch: binary={b_v}, ASCII={a_v}"
+ )
+ break
+ if "__sweep_val__" in bin_table:
+ if "__sweep_val__" not in ascii_table:
+ errs.append(
+ f"Table {t_idx} sweep value missing in ASCII table"
+ )
+ elif not _close(
+ bin_table["__sweep_val__"],
+ ascii_table["__sweep_val__"],
+ rtol=1e-3,
+ ):
+ errs.append(
+ f"Table {t_idx} sweep value mismatch: binary={bin_table['__sweep_val__']}, ASCII={ascii_table['__sweep_val__']}"
+ )
+ except Exception as ae:
+ errs.append(f"ASCII parser/comparison raised exception: {str(ae)}")
+
+ if errs:
+ for e in errs:
+ log(f" ASSERTION FAILED: {e}")
+ failed += 1
+ else:
+ log(f" PASS")
+ passed += 1
+
+ log("\n" + "=" * 70)
+ log(
+ f"Summary: {passed} passed, {failed} failed, {skipped} skipped"
+ f" out of {len(tests)} cases"
+ )
+ log("=" * 70)
+
+ sys.exit(0 if failed == 0 else 1)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/PySpice/Spice/HSpice/hspicefile/docs/tests/verify_spec.py b/PySpice/Spice/HSpice/hspicefile/docs/tests/verify_spec.py
new file mode 100644
index 00000000..9ff2ecae
--- /dev/null
+++ b/PySpice/Spice/HSpice/hspicefile/docs/tests/verify_spec.py
@@ -0,0 +1,688 @@
+import glob
+import os
+import re
+import struct
+import sys
+import traceback
+
+WORK_DIR = "work"
+
+
+def parse_header_payload(payload):
+ clean = payload.decode("ascii", errors="ignore")
+
+ if len(clean) < 24:
+ raise ValueError(f"Header payload too short: {len(clean)} bytes")
+
+ # Detect version from fixed positions
+ version_at_16 = clean[16:20].strip()
+ version_at_20 = clean[20:24].strip()
+
+ if version_at_16 in ("9007", "9601"):
+ version = version_at_16
+ is_legacy = True
+ elif version_at_20 in ("2001", "2013"):
+ version = version_at_20
+ is_legacy = False
+ else:
+ raise ValueError(
+ f"Unknown format version: pos16={repr(version_at_16)}, pos20={repr(version_at_20)}"
+ )
+
+ try:
+ num_vars = int(clean[0:4])
+ num_probes = int(clean[4:8])
+ num_sweeps = int(clean[8:12])
+ except ValueError as e:
+ raise ValueError(f"Failed to parse format descriptor '{clean[:24]}': {e}")
+
+ # Sweep size field: offset 187 works for all modern formats.
+ # For legacy formats the same offset (187) also holds the sweep size value,
+ # because the copyright string ends before it.
+ # We parse whatever integer is at position 187 if num_sweeps > 0.
+ sweep_size = 1
+ if num_sweeps > 0:
+ sweep_size_str = clean[187:203].strip().split()
+ if sweep_size_str:
+ try:
+ sweep_size = int(sweep_size_str[0])
+ except ValueError:
+ sweep_size = 1 # fallback
+
+ title = clean[24:88].strip()
+ date = clean[88:112].strip()
+
+ # Variable types and names start at offset 256 in the header payload.
+ tokens = clean[256:].split()
+ if tokens and tokens[-1] == "$&%#":
+ tokens = tokens[:-1]
+
+ num_vectors = num_vars + num_probes
+ var_types = [0] * num_vectors
+ var_names = [""] * num_vectors
+
+ if len(tokens) >= 2 * num_vectors:
+ # Token layout (natural order):
+ # tokens[0..N-1] = type codes in order: type_0 (scale), type_1, ..., type_{N-1}
+ # tokens[N] = name_0 (scale variable name)
+ # tokens[N+1..2N-1] = names of circuit vars 1..N-1
+ for idx in range(num_vectors):
+ var_types[idx] = int(tokens[idx])
+
+ var_names[0] = tokens[num_vectors]
+ for idx in range(num_vectors - 1):
+ var_names[idx + 1] = tokens[num_vectors + 1 + idx]
+
+ return {
+ "version": version,
+ "is_legacy": is_legacy,
+ "num_vars": num_vars,
+ "num_probes": num_probes,
+ "num_sweeps": num_sweeps,
+ "sweep_size": sweep_size,
+ "num_vectors": num_vectors,
+ "title": title,
+ "date": date,
+ "var_types": var_types,
+ "var_names": var_names,
+ }
+
+
+def read_block(f, fmt):
+ """Read one block (head + payload + tail). Returns (payload_bytes, is_term_block) or None on EOF."""
+ head_raw = f.read(16)
+ if len(head_raw) == 0:
+ return None, False # clean EOF
+ if len(head_raw) < 16:
+ raise IOError(f"Truncated block head: only {len(head_raw)} bytes")
+
+ e1, rc, e2, payload_size = struct.unpack(fmt + "IIII", head_raw)
+ if e1 != 4 or e2 != 4:
+ raise IOError(f"Bad endian markers: 0x{e1:08x}/0x{e2:08x}")
+
+ payload = f.read(payload_size)
+ if len(payload) < payload_size:
+ raise IOError(f"Truncated payload: got {len(payload)} of {payload_size}")
+
+ tail_raw = f.read(4)
+ if len(tail_raw) < 4:
+ raise IOError("Truncated block tail")
+ tail = struct.unpack(fmt + "I", tail_raw)[0]
+ if tail != payload_size:
+ raise IOError(f"Block head/tail mismatch: {payload_size} vs {tail}")
+
+ return payload, payload_size
+
+
+def is_term_block(payload, version, fmt):
+ """
+ Termination detection rule (from C source and observed binary layout):
+ - 2013: a dedicated block whose entire payload is an 8-byte double == 1e30.
+ Also accepted if the last 8 bytes of a larger payload are > 9e29.
+ - 2001: a dedicated block whose entire payload is an 8-byte double == 1e30.
+ - 9601/9007 (legacy): a dedicated block whose entire payload is a 4-byte float == 1e30.
+ """
+ size = len(payload)
+ if version in ("2013", "2001"):
+ if size == 8:
+ val = struct.unpack(fmt + "d", payload)[0]
+ return val > 9e29
+ if size >= 8:
+ val = struct.unpack(fmt + "d", payload[-8:])[0]
+ return val > 9e29
+ else: # legacy 9601/9007
+ if size == 4:
+ val = struct.unpack(fmt + "f", payload)[0]
+ return val > 9e29
+ if size >= 4:
+ val = struct.unpack(fmt + "f", payload[-4:])[0]
+ return val > 9e29
+ return False
+
+
+def row_sizes_for(meta):
+ version = meta["version"]
+ is_complex = meta["num_vectors"] > 1 and meta["var_types"][0] == 2
+
+ sizes = []
+ for idx in range(meta["num_vectors"]):
+ if version == "2013":
+ if idx == 0:
+ s = 8 # scale: double
+ elif is_complex:
+ s = 8 # complex float: 4B real + 4B imag
+ else:
+ s = 4 # float
+ elif version == "2001":
+ if is_complex and idx > 0:
+ s = 16 # complex double: 8B real + 8B imag
+ else:
+ s = 8 # double
+ else: # 9601/9007 legacy
+ if is_complex and idx > 0:
+ s = 8 # complex float: 4B real + 4B imag
+ else:
+ s = 4 # float
+ sizes.append(s)
+ return sizes, sum(sizes), is_complex
+
+
+def parse_row(raw, offset, meta, var_sizes, is_complex, fmt):
+ """Parse one data row starting at `offset`, return (scale_val, circuit_vals)."""
+ version = meta["version"]
+ row = raw[offset:]
+ c_offset = 0
+
+ scale_sz = var_sizes[0]
+ scale_fmt = "d" if scale_sz == 8 else "f"
+ scale_val = struct.unpack(fmt + scale_fmt, row[c_offset : c_offset + scale_sz])[0]
+ c_offset += scale_sz
+
+ circuit_vals = []
+ for idx in range(1, meta["num_vectors"]):
+ vsz = var_sizes[idx]
+ if is_complex and idx > 0:
+ if vsz == 16:
+ re, im = struct.unpack(fmt + "dd", row[c_offset : c_offset + 16])
+ else:
+ re, im = struct.unpack(fmt + "ff", row[c_offset : c_offset + 8])
+ circuit_vals.append(complex(re, im))
+ else:
+ vfmt = "d" if vsz == 8 else "f"
+ circuit_vals.append(
+ struct.unpack(fmt + vfmt, row[c_offset : c_offset + vsz])[0]
+ )
+ c_offset += vsz
+
+ return scale_val, circuit_vals
+
+
+def parse_spice_value(val_str):
+ val_str = val_str.strip().lower()
+ if val_str.endswith("f"):
+ return float(val_str[:-1]) * 1e-15
+ elif val_str.endswith("p"):
+ return float(val_str[:-1]) * 1e-12
+ elif val_str.endswith("n"):
+ return float(val_str[:-1]) * 1e-9
+ elif val_str.endswith("u"):
+ return float(val_str[:-1]) * 1e-6
+ elif val_str.endswith("meg"):
+ return float(val_str[:-3]) * 1e6
+ elif val_str.endswith("m"):
+ return float(val_str[:-1]) * 1e-3
+ elif val_str.endswith("k"):
+ return float(val_str[:-1]) * 1e3
+ elif val_str.endswith("g"):
+ return float(val_str[:-1]) * 1e9
+ else:
+ try:
+ return float(val_str)
+ except ValueError:
+ m = re.match(r"^([\d\.\-+e]+)", val_str)
+ if m:
+ return float(m.group(1))
+ raise
+
+
+def verify_op_file(filename, log):
+ log(f"\n{'='*70}")
+ log(f"VERIFYING OPERATING POINT FILE: {filename}")
+ log(f"{'='*70}")
+
+ # Parse node voltages from .ic0
+ nodes = {}
+ try:
+ with open(filename, "r") as f:
+ lines = f.readlines()
+ except Exception as e:
+ log(f"ERROR: could not read {filename}: {e}")
+ return False
+
+ in_nodeset = False
+ for line in lines:
+ line = line.strip()
+ if line.startswith(".nodeset"):
+ in_nodeset = True
+ continue
+ if in_nodeset:
+ if line.startswith("***"):
+ break
+ if line.startswith("+"):
+ parts = line[1:].split("=")
+ if len(parts) == 2:
+ node_name = parts[0].strip().lower()
+ if node_name.startswith("v(") and node_name.endswith(")"):
+ node_name = node_name[2:-1]
+ try:
+ nodes[node_name] = parse_spice_value(parts[1].strip())
+ except Exception:
+ pass
+
+ log(f"Parsed Node Voltages: {nodes}")
+
+ # Parse branch currents from .lis
+ lis_filename = filename.replace(".ic0", ".lis")
+ if not os.path.exists(lis_filename):
+ log(f"ERROR: corresponding .lis file {lis_filename} not found")
+ return False
+
+ branches = {}
+ try:
+ with open(lis_filename, "r", errors="ignore") as f:
+ lis_content = f.read()
+ except Exception as e:
+ log(f"ERROR: could not read {lis_filename}: {e}")
+ return False
+
+ lines = lis_content.splitlines()
+ in_section = False
+ section_lines = []
+ for line in lines:
+ if "voltage sources" in line.lower():
+ in_section = True
+ continue
+ if in_section:
+ if "total voltage source" in line.lower() or (
+ line.strip().startswith("****")
+ and "voltage sources" not in line.lower()
+ ):
+ break
+ section_lines.append(line)
+
+ elements = []
+ for line in section_lines:
+ tokens = line.strip().split()
+ if not tokens:
+ continue
+ if tokens[0].lower() == "element":
+ elements = tokens[1:]
+ elif tokens[0].lower() == "current":
+ currents = tokens[1:]
+ for el, curr in zip(elements, currents):
+ clean_el = el.split(":")[-1].lower() if ":" in el else el.lower()
+ try:
+ branches[clean_el] = parse_spice_value(curr)
+ except Exception:
+ pass
+
+ log(f"Parsed Branch Currents: {branches}")
+
+ # Verify values
+ def is_close(a, b, tol=1e-3):
+ return abs(a - b) < tol
+
+ if "op_basic" in filename:
+ if "node_a" not in nodes or not is_close(nodes["node_a"], 1.0):
+ log(f"ERROR: node_a voltage {nodes.get('node_a')} != 1.0")
+ return False
+ if "node_b" not in nodes or not is_close(nodes["node_b"], 0.66667):
+ log(f"ERROR: node_b voltage {nodes.get('node_b')} != 0.66667")
+ return False
+ if "v1" not in branches or not is_close(branches["v1"], -0.033333):
+ log(f"ERROR: v1 current {branches.get('v1')} != -0.033333")
+ return False
+
+ elif "op_with_probes" in filename:
+ if "node_a" not in nodes or not is_close(nodes["node_a"], 2.0):
+ log(f"ERROR: node_a voltage {nodes.get('node_a')} != 2.0")
+ return False
+ if "node_b" not in nodes or not is_close(nodes["node_b"], 1.0):
+ log(f"ERROR: node_b voltage {nodes.get('node_b')} != 1.0")
+ return False
+ if "v1" not in branches or not is_close(branches["v1"], -0.2):
+ log(f"ERROR: v1 current {branches.get('v1')} != -0.2")
+ return False
+ if "v2" not in branches or not is_close(branches["v2"], 0.2):
+ log(f"ERROR: v2 current {branches.get('v2')} != 0.2")
+ return False
+
+ # Verify measurements
+ if not verify_meas_file(filename, log):
+ return False
+
+ log("VERIFICATION PASSED")
+ return True
+
+
+def parse_meas_file(filepath):
+ if not os.path.exists(filepath):
+ return None
+ with open(filepath, "r", errors="ignore") as f:
+ lines = f.readlines()
+ content_lines = [
+ l.strip()
+ for l in lines
+ if l.strip() and not l.startswith("$") and not l.startswith(".")
+ ]
+ if len(content_lines) < 2:
+ return None
+ names = [n.lower() for n in content_lines[0].split()]
+ is_sweep = len(content_lines) > 2
+ measurements = {}
+ for val_line in content_lines[1:]:
+ vals = val_line.split()
+ if len(vals) != len(names):
+ continue
+ for name, val in zip(names, vals):
+ try:
+ parsed_val = parse_spice_value(val)
+ except Exception:
+ parsed_val = val
+ if is_sweep:
+ if name not in measurements:
+ measurements[name] = []
+ measurements[name].append(parsed_val)
+ else:
+ measurements[name] = parsed_val
+ return measurements
+
+
+def verify_meas_file(filename, log):
+ def is_close(a, b, tol=1e-3):
+ return abs(a - b) < tol
+
+ for ext in [".ms0", ".mt0", ".ma0"]:
+ meas_path = filename.rsplit(".", 1)[0] + ext
+ if os.path.exists(meas_path):
+ meas = parse_meas_file(meas_path)
+ if meas is not None:
+ log(f" Parsed measurements from {os.path.basename(meas_path)}: {meas}")
+ if "dc_meas" in filename and "sweep" not in filename:
+ if "v_out_max" not in meas or not is_close(
+ meas["v_out_max"], 3.3333
+ ):
+ log(f"ERROR: v_out_max {meas.get('v_out_max')} != 3.3333")
+ return False
+ elif "ac_meas" in filename and "sweep" not in filename:
+ if "v_out_at_1k" not in meas or not is_close(
+ meas["v_out_at_1k"], 0.998
+ ):
+ log(f"ERROR: v_out_at_1k {meas.get('v_out_at_1k')} != 0.998")
+ return False
+ elif "tran_meas" in filename and "sweep" not in filename:
+ if "v_out_max" not in meas or not is_close(
+ meas["v_out_max"], 0.66667
+ ):
+ log(f"ERROR: v_out_max {meas.get('v_out_max')} != 0.66667")
+ return False
+ # Swept measurements:
+ elif "dc_meas_sweep" in filename:
+ expected = [3.3333, 2.5, 2.0]
+ vals = meas.get("v_out_max")
+ if (
+ not isinstance(vals, list)
+ or len(vals) != len(expected)
+ or not all(is_close(x, y) for x, y in zip(vals, expected))
+ ):
+ log(f"ERROR: v_out_max {vals} != expected {expected}")
+ return False
+ elif "ac_meas_sweep" in filename:
+ expected = [0.998, 0.9922, 0.9827]
+ vals = meas.get("v_out_at_1k")
+ if (
+ not isinstance(vals, list)
+ or len(vals) != len(expected)
+ or not all(is_close(x, y) for x, y in zip(vals, expected))
+ ):
+ log(f"ERROR: v_out_at_1k {vals} != expected {expected}")
+ return False
+ elif "tran_meas_sweep" in filename:
+ expected = [0.66667, 0.5, 0.4]
+ vals = meas.get("v_out_max")
+ if (
+ not isinstance(vals, list)
+ or len(vals) != len(expected)
+ or not all(is_close(x, y) for x, y in zip(vals, expected))
+ ):
+ log(f"ERROR: v_out_max {vals} != expected {expected}")
+ return False
+ return True
+
+
+def verify_file(filename, log):
+ log(f"\n{'='*70}")
+ log(f"VERIFYING: {filename}")
+ log(f"{'='*70}")
+
+ file_size = os.path.getsize(filename)
+ log(f"File size: {file_size} bytes")
+
+ with open(filename, "rb") as f:
+
+ # -------------------------------------------------------
+ # Detect ASCII vs binary
+ # -------------------------------------------------------
+ first = f.read(1)
+ if not first:
+ log("ERROR: empty file")
+ return False
+ if first[0] >= 32:
+ if filename.endswith(".ic0") or ".ic" in filename:
+ return verify_op_file(filename, log)
+ log("Skipping: ASCII format file")
+ return True
+ f.seek(0)
+
+ # -------------------------------------------------------
+ # Header block
+ # -------------------------------------------------------
+ head_raw = f.read(16)
+ if len(head_raw) < 16:
+ log("ERROR: truncated header block")
+ return False
+ e1, rc, e2, payload_size = struct.unpack("IIII", head_raw)
+ log("Endianness: Big-Endian")
+ else:
+ log(f"ERROR: bad endian markers 0x{e1:08x}/0x{e2:08x}")
+ return False
+
+ log(f"Header Block Head: RecordCount={rc}, PayloadSize={payload_size}")
+ payload = f.read(payload_size)
+ if len(payload) < payload_size:
+ log("ERROR: truncated header payload")
+ return False
+
+ tail_raw = f.read(4)
+ tail = struct.unpack(fmt + "I", tail_raw)[0]
+ if tail != payload_size:
+ log(f"ERROR: header head/tail mismatch ({payload_size} vs {tail})")
+ return False
+
+ term_idx = payload.find(b"$&%#")
+ if term_idx < 0:
+ log("ERROR: '$&%#' termination marker not found in header payload")
+ return False
+ log(f"Header '$&%#' terminator at offset {term_idx}")
+
+ try:
+ meta = parse_header_payload(payload)
+ except Exception as e:
+ log(f"ERROR parsing header: {e}")
+ return False
+
+ log(f" Version: {meta['version']}")
+ log(f" Title: {meta['title']}")
+ log(f" Date: {meta['date']}")
+ log(f" Num Variables: {meta['num_vars']}")
+ log(f" Num Probes: {meta['num_probes']}")
+ log(f" Num Sweeps: {meta['num_sweeps']}")
+ log(f" Sweep Size: {meta['sweep_size']}")
+ log(f" Total Vectors: {meta['num_vectors']}")
+ log(f" Var Names: {meta['var_names']}")
+ log(f" Var Types: {meta['var_types']}")
+
+ var_sizes, row_size, is_complex = row_sizes_for(meta)
+ log(f"Analysis type: {'COMPLEX (AC)' if is_complex else 'REAL (DC/TRAN)'}")
+ log(f"Var byte sizes: {var_sizes} => row_size={row_size} bytes")
+
+ # -------------------------------------------------------
+ # Data blocks: accumulate by sweep table
+ # Each sweep table ends with a dedicated termination block.
+ # -------------------------------------------------------
+ version = meta["version"]
+ tables_raw = [] # list of bytearray, one per sweep table
+ current = bytearray()
+ block_idx = 0
+
+ while True:
+ try:
+ blk_payload, blk_size = read_block(f, fmt)
+ except IOError as e:
+ log(f"ERROR reading block {block_idx + 1}: {e}")
+ return False
+
+ if blk_payload is None:
+ # Clean EOF — if there is accumulated data treat it as final table
+ if current:
+ tables_raw.append(current)
+ break
+
+ block_idx += 1
+
+ if is_term_block(blk_payload, version, fmt):
+ # This block is a pure termination marker — close current table
+ tables_raw.append(current)
+ current = bytearray()
+ else:
+ current.extend(blk_payload)
+
+ log(
+ f"Read {block_idx} data blocks => {len(tables_raw)} sweep tables (expected {meta['sweep_size']})"
+ )
+
+ if len(tables_raw) != meta["sweep_size"]:
+ log(
+ f"ERROR: got {len(tables_raw)} tables but expected {meta['sweep_size']}"
+ )
+ return False
+
+ # -------------------------------------------------------
+ # Validate each sweep table
+ # -------------------------------------------------------
+ for t_idx, t_raw in enumerate(tables_raw):
+ log(f" Table {t_idx}: raw_size={len(t_raw)} bytes")
+ offset = 0
+
+ # Sweep value width:
+ # 2001 format stores sweep value as 8-byte double
+ # all other formats (2013, 9601, 9007) use 4-byte float
+ if meta["num_sweeps"] > 0:
+ if meta["version"] == "2001":
+ if len(t_raw) < 8:
+ log(f" ERROR: too short for 8-byte sweep value")
+ return False
+ sweep_val = struct.unpack(fmt + "d", t_raw[:8])[0]
+ log(f" Sweep value (double): {sweep_val:.4f}")
+ offset += 8
+ else:
+ if len(t_raw) < 4:
+ log(f" ERROR: too short for 4-byte sweep value")
+ return False
+ sweep_val = struct.unpack(fmt + "f", t_raw[:4])[0]
+ log(f" Sweep value (float): {sweep_val:.4f}")
+ offset += 4
+
+ data_len = len(t_raw) - offset
+
+ if data_len % row_size != 0:
+ log(
+ f" ERROR: data_len={data_len} is not a multiple of row_size={row_size} (rem={data_len % row_size})"
+ )
+ return False
+
+ num_rows = data_len // row_size
+ log(f" Rows: {num_rows}")
+
+ if num_rows == 0:
+ log(" WARNING: table contains no data rows")
+ continue
+
+ # First row
+ sv, cv = parse_row(t_raw, offset, meta, var_sizes, is_complex, fmt)
+ log(f" First row: {meta['var_names'][0]}={sv:.4e} circuit={cv}")
+
+ # Last row
+ if num_rows > 1:
+ last_off = offset + (num_rows - 1) * row_size
+ sv_last, cv = parse_row(
+ t_raw, last_off, meta, var_sizes, is_complex, fmt
+ )
+ log(
+ f" Last row: {meta['var_names'][0]}={sv_last:.4e} circuit={cv}"
+ )
+
+ # Verify measurements
+ if not verify_meas_file(filename, log):
+ return False
+
+ log("VERIFICATION PASSED")
+ return True
+
+
+def main():
+ logs_dir = "logs"
+ os.makedirs(logs_dir, exist_ok=True)
+
+ log_path = os.path.join(logs_dir, "verify_spec.log")
+
+ with open(log_path, "w") as lf:
+
+ def log(msg):
+ print(msg)
+ lf.write(msg + "\n")
+ lf.flush()
+
+ log("=" * 70)
+ log("HSPICE Binary Specification Verification Report")
+ log("=" * 70)
+ log(f"Work directory: {os.path.abspath(WORK_DIR)}")
+
+ bin_files = sorted(
+ glob.glob(os.path.join(WORK_DIR, "*.sw*"))
+ + glob.glob(os.path.join(WORK_DIR, "*.ac*"))
+ + glob.glob(os.path.join(WORK_DIR, "*.tr*"))
+ + glob.glob(os.path.join(WORK_DIR, "*.ic*"))
+ )
+ bin_files = [
+ f
+ for f in bin_files
+ if not f.endswith((".sp", ".lis", ".py", ".sh", ".log"))
+ ]
+
+ if not bin_files:
+ log("No binary files found!")
+ sys.exit(1)
+
+ passed = 0
+ failed = 0
+
+ for f in bin_files:
+ try:
+ ok = verify_file(f, log)
+ except Exception as e:
+ log(f"CRITICAL ERROR for {f}: {e}")
+ lf.write(traceback.format_exc())
+ ok = False
+
+ if ok:
+ passed += 1
+ else:
+ failed += 1
+
+ log("\n" + "=" * 70)
+ log(f"Summary: {passed} passed, {failed} failed out of {len(bin_files)} files")
+ log("=" * 70)
+
+ sys.exit(0 if failed == 0 else 1)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/PySpice/Spice/HSpice/hspicefile/hspice_read.c b/PySpice/Spice/HSpice/hspicefile/hspice_read.c
new file mode 100644
index 00000000..0d329aa9
--- /dev/null
+++ b/PySpice/Spice/HSpice/hspicefile/hspice_read.c
@@ -0,0 +1,1191 @@
+/*
+ * Copyright (c) 2026 Daniel Schmeer
+ * Copyright (c) 2009 Janez Puhan (PyOPUS Project)
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero 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 Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+// (c) Janez Puhan
+// Date: 18.5.2009
+// HSpice binary file import module
+// Modifications:
+// 1. All vector names are converted to lowercase.
+// 2. In vector names 'v(*' is converted to '*'.
+// 3. No longer try to close a file after failed fopen (caused a crash).
+// Author: Arpad Buermen
+
+// Note that in Windows we do not use Debug compile because we don't have the
+// debug version of Python libraries and interpreter. We use Release version
+// instead where optimizations are disabled. Such a Release version can be
+// debugged.
+
+#define NPY_NO_DEPRECATED_API NPY_1_7_API_VERSION
+#include "hspice_read.h"
+#include "Python.h"
+#include "numpy/arrayobject.h"
+#include "numpy/npy_math.h"
+#include
+#include
+
+// Methods table
+static PyMethodDef module_methods[] = {
+ {"hspice_read", HSpiceRead, METH_VARARGS, "Read hspice plot file.\n"},
+ {NULL, NULL, 0, NULL} // Marks the end of this structure.
+};
+
+static struct PyModuleDef module = {
+ PyModuleDef_HEAD_INIT,
+ "_hspice_read", /* name of module */
+ "HSPICE plot file import module.\n", /* module documentation, may be NULL */
+ /* TODO: in future set this to 0 so that this module will work with
+ sub-interpreters */
+ -1, /* size of per-interpreter state of the module, or -1 if the module
+ keeps state in global variables. */
+ module_methods,
+ NULL, /* Slots for multi phase initialization */
+ NULL, /* Traversal function for GC */
+ NULL, /* Clear function for clearing the module */
+ NULL, /* Function for deallocating the module */
+};
+
+/* Module initialization
+ Module name must be _hspice_read in compile and link */
+PyMODINIT_FUNC PyInit__hspice_read() {
+ PyObject *m = PyModule_Create(&module);
+
+ /* For using NumPy */
+ import_array();
+
+ if (m == NULL)
+ return NULL;
+
+ // Initialize exceptions
+ HSpiceParseError =
+ PyErr_NewException("hspice_parser.HSpiceParseError", NULL, NULL);
+ if (HSpiceParseError == NULL) {
+ Py_DECREF(m);
+ return NULL;
+ }
+
+ // Export to the module
+ if (PyModule_AddObjectRef(m, "HSpiceParseError", HSpiceParseError) < 0) {
+ Py_DECREF(HSpiceParseError);
+ Py_CLEAR(HSpiceParseError);
+ Py_DECREF(m);
+ return NULL;
+ }
+
+ return m;
+}
+
+#define debugFile stdout
+
+// Header character positions
+#define blockHeaderSize 4
+#define numOfVariablesPosition 0
+#define numOfProbesPosition 4
+#define numOfSweepsPosition 8
+#define numOfSweepsEndPosition 12
+#define postStartPosition1 16
+#define postStartPosition2 20
+#define numOfPostCharacters 4
+#define dateStartPosition 88
+#define dateEndPosition 112
+#define titleStartPosition 24
+#define sweepSizePosition 187
+#define vectorDescriptionStartPosition 256
+#define frequency 2
+#define complex_var 1
+#define real_var 0
+
+// Perform endian swap on array of numbers. Arguments:
+// block ... pointer to array of numbers
+// size ... size of the array
+// itemSize ... size of one number in the array in bytes
+void do_swap(char *block, int size, int itemSize) {
+ int i;
+ for (i = 0; i < size; i++) {
+ int j;
+ for (j = 0; j < itemSize / 2; j++) {
+ char tmp = block[j];
+ block[j] = block[itemSize - j - 1];
+ block[itemSize - j - 1] = tmp;
+ }
+ block = block + itemSize;
+ }
+}
+
+// Read block header. Returns:
+// -1 ... block header corrupted
+// 0 ... success
+// 1 ... eof
+// Arguments:
+// ctx ... parser context
+// blockHeader ... array of four integers consisting block header
+// size ... size of items in block
+int readBlockHeader(struct ParserContext *ctx, int *blockHeader, int size) {
+ int num, blockSize;
+ num = fread(blockHeader, sizeof(int), blockHeaderSize, ctx->f);
+ if (num != blockHeaderSize) {
+ if (num == 0 && feof(ctx->f)) {
+ return 1; // Normal EOF
+ }
+ PyErr_Format(HSpiceParseError, "Failed to read block header from file %s.",
+ ctx->fileName);
+
+ return -1; // Error.
+ }
+
+ // Block header check and swap.
+ if (blockHeader[0] == 0x00000004 && blockHeader[2] == 0x00000004) {
+ ctx->swap = false;
+ } else if (blockHeader[0] == 0x04000000 && blockHeader[2] == 0x04000000) {
+ ctx->swap = true;
+ } else {
+ PyErr_Format(HSpiceParseError, "Corrupted block header.");
+ return -1;
+ }
+
+ if (ctx->swap) {
+ do_swap((char *)blockHeader, blockHeaderSize, sizeof(int));
+ }
+
+ // Block size check
+ blockSize = blockHeader[blockHeaderSize - 1];
+ if (blockSize <= 0) {
+ PyErr_Format(HSpiceParseError, "Block size %d is zero or negative.",
+ blockSize);
+ return -1;
+ }
+ if (blockSize % size != 0) {
+ PyErr_Format(HSpiceParseError, "Block size %d is misaligned for size %d.",
+ blockSize, size);
+ return -1;
+ }
+ if (blockSize > 100000000) {
+ PyErr_Format(HSpiceParseError, "Block size %d exceeds limit.", blockSize);
+ return -1;
+ }
+ blockHeader[0] = blockHeader[blockHeaderSize - 1] / size;
+
+ if (ctx->debugMode >= 2) {
+ fprintf(debugFile, "Got block header, size=%d, bytes=%d, swap=%d\n",
+ blockHeader[1], blockHeader[3], ctx->swap);
+ }
+ return 0;
+}
+
+// Read block data. Returns:
+// -1 ... reading failed
+// 0 ... reading performed normally
+// Arguments:
+// ctx ... parser context
+// ptr ... pointer to reserved space for data
+// offset ... pointer to reserved space size,
+// increased for current block size
+// itemSize ... size of one item in block
+// numOfItems ... number of items in block
+// swap ... perform endian swap flag
+int readBlockData(struct ParserContext *ctx, void *ptr, int *offset,
+ int itemSize, int numOfItems) {
+ int num = fread(ptr, itemSize, numOfItems, ctx->f);
+ if (num != numOfItems) {
+ PyErr_Format(HSpiceParseError, "Failed to read block from file %s.",
+ ctx->fileName);
+ return -1; // Error.
+ }
+ *offset = *offset + numOfItems;
+ if (ctx->swap) {
+ do_swap((char *)ptr, numOfItems, itemSize); // Endian swap.
+ }
+ if (ctx->debugMode >= 2) {
+ fprintf(debugFile, "Got block data, item_size=%d, count=%d, bytes=%d\n",
+ itemSize, numOfItems, itemSize * numOfItems);
+ }
+ return 0;
+}
+
+// Read block trailer. Returns:
+// -1 ... block trailer corrupted
+// 0 ... reading performed normally
+// Arguments:
+// ctx ... parser context
+// swap ... perform endian swap flag
+// header ... block size from header
+int readBlockTrailer(struct ParserContext *ctx, int header) {
+ int trailer, num;
+ num = fread(&trailer, sizeof(int), 1, ctx->f);
+ if (num != 1) {
+ PyErr_Format(HSpiceParseError, "Failed to read block trailer from file %s.",
+ ctx->fileName);
+ return -1; // Error.
+ }
+ if (ctx->swap) {
+ do_swap((char *)(&trailer), 1, sizeof(int)); // Endian swap.
+ }
+
+ // Block header and trailer match check.
+ if (header != trailer) {
+ PyErr_Format(HSpiceParseError, "Block header and trailer mismatch.");
+ return -1; // Error.
+ }
+ if (ctx->debugMode >= 2) {
+ fprintf(debugFile,
+ "Got block trailer, itemsize=%ld, count=%d, bytes=%ld,\n",
+ sizeof(int), 1, sizeof(int) * 1);
+ }
+
+ return 0;
+}
+
+// Reallocate space. Returns:
+// NULL ... reallocation failed
+// pointer ... address of reallocated space
+// Arguments:
+// debugMode ... debug messages flag
+// ptr ... pointer to already allocated space
+// size ... new size in bytes
+void *reallocate(int debugMode, void *ptr, int size) {
+ // Allocate space for raw data.
+ void *tmp = PyMem_Realloc(ptr, size);
+ if (tmp == NULL) {
+ PyErr_Format(PyExc_MemoryError, "Failed to reallocate buffer space.");
+ }
+ return tmp;
+}
+
+// Read one file header block. Returns:
+// -1 ... error occured during reading the block
+// 0 ... this was the last block
+// 1 ... there is at least one more block left
+// Arguments:
+// ctx ... parser context
+// buf ... pointer to header buffer,
+// enlarged (reallocated) for current block
+// bufOffset ... pointer to buffer size, increased for current block size
+int readHeaderBlock(struct ParserContext *ctx, char **buf, int *bufOffset) {
+ char *tmpBuf;
+ int error;
+ int blockHeader[blockHeaderSize];
+
+ if (ctx->debugMode >= 2) {
+ fprintf(debugFile, "Reading header block @0x%lx\n", ftell(ctx->f));
+ }
+
+ // Get size of file header block.
+ error = readBlockHeader(ctx, blockHeader, sizeof(char));
+ if (error < 0) {
+ return -1; // Error.
+ }
+
+ // Allocate space for buffer.
+ if (blockHeader[0] > INT_MAX - *bufOffset - 1) {
+ PyErr_Format(HSpiceParseError, "Buffer offset overflow.");
+ return -1;
+ }
+ tmpBuf = reallocate(ctx->debugMode, *buf,
+ (*bufOffset + blockHeader[0] + 1) * sizeof(char));
+ if (tmpBuf == NULL) {
+ return -1; // Error.
+ }
+ *buf = tmpBuf;
+
+ // Read file header block.
+ error = readBlockData(ctx, *buf + *bufOffset, bufOffset, sizeof(char),
+ blockHeader[0]);
+ if (error < 0) {
+ return -1; // Error.
+ }
+ (*buf)[*bufOffset] = 0;
+
+ // Read trailer of file header block.
+ error = readBlockTrailer(ctx, blockHeader[blockHeaderSize - 1]);
+ if (error < 0) {
+ return -1; // Error.
+ }
+
+ if (strstr(*buf, "$&%#")) {
+ return 0; // End of block.
+ }
+
+ return 1; // There is more.
+}
+
+static int parse_int(const char *str, int *out_val) {
+ char *endptr;
+ long val;
+ if (str == NULL || *str == '\0') {
+ return -1;
+ }
+ errno = 0;
+ val = strtol(str, &endptr, 10);
+ if (endptr == str) {
+ return -1;
+ }
+ if (errno == ERANGE || val < INT_MIN || val > INT_MAX) {
+ return -1;
+ }
+ while (*endptr != '\0') {
+ if (*endptr != ' ' && *endptr != '\t' && *endptr != '\n' &&
+ *endptr != '\r') {
+ return -1;
+ }
+ endptr++;
+ }
+ *out_val = (int)val;
+ return 0;
+}
+
+// Get sweep information from file header block. Returns:
+// -1 ... error occurred
+// 0 ... performed normally
+// Arguments:
+// debugMode ... debug messages flag
+// sweep ... acquired sweep parameter name, new reference created
+// buf ... header string
+// sweepSize ... acquired number of sweep points
+// sweepValues ... sweep points array, new reference created
+// faSweep ... pointer to fast access structure for sweep array
+int getSweepInfo(int debugMode, PyObject **sweep, char *buf, int *sweepSize,
+ PyArrayObject **sweepValues, struct FastArray *faSweep) {
+ char *sweepName = NULL;
+ npy_intp dims;
+
+ if (debugMode >= 2) {
+ fprintf(debugFile, "Reading sweep information.\n");
+ }
+
+ sweepName = strtok(NULL, " \t\n"); // Get sweep parameter name.
+ if (sweepName == NULL) {
+ PyErr_Format(HSpiceParseError, "Failed to extract sweep name.");
+ return -1;
+ }
+ *sweep = PyUnicode_InternFromString(sweepName);
+ if (*sweep == NULL) {
+ PyErr_Format(HSpiceParseError, "Failed to create sweep name string.");
+ return -1;
+ }
+
+ // Get number of sweep points.
+ if (parse_int(&buf[sweepSizePosition], sweepSize) < 0 || *sweepSize <= 0) {
+ PyErr_Format(HSpiceParseError,
+ "Failed to parse sweep size as a positive integer.");
+ return -1;
+ }
+
+ // Create array for sweep parameter values.
+ dims = *sweepSize;
+ *sweepValues = (PyArrayObject *)PyArray_SimpleNew(1, &dims, NPY_DOUBLE);
+ if (*sweepValues == NULL) {
+ PyErr_Format(HSpiceParseError, "Failed to create array.");
+ return -1;
+ }
+
+ // Prepare fast access structure.
+ faSweep->data = PyArray_DATA(*sweepValues);
+ faSweep->pos = PyArray_DATA(*sweepValues);
+ faSweep->stride =
+ PyArray_STRIDE(*sweepValues, PyArray_NDIM(*sweepValues) - 1);
+ faSweep->length = PyArray_SIZE(*sweepValues);
+
+ return 0;
+}
+
+// Read one data block. Returns:
+// -1 ... error occured during reading the block
+// 0 ... this was the last block
+// 1 ... there is at least one more block left
+// Arguments:
+// ctx ... parser context
+// rawData ... pointer to data array,
+// enlarged (reallocated) for current block
+// rawDataOffset ... pointer to data array size, increased for current block
+// size
+int readDataBlock(struct ParserContext *ctx, float **rawData,
+ int *rawDataOffset) {
+ int error;
+ int blockHeader[blockHeaderSize];
+ float *tmpRawData;
+ double lastVal;
+
+ if (ctx->debugMode >= 2) {
+ fprintf(debugFile, "Reading data block @0x%lx\n", ftell(ctx->f));
+ }
+
+ // Get size of raw data block.
+ error = readBlockHeader(ctx, blockHeader, sizeof(float));
+ if (error == 1) {
+ return 0; // Normal EOF / End of block.
+ } else if (error < 0) {
+ return -1; // Error.
+ }
+
+ // Allocate space for raw data.
+ if (blockHeader[0] > (INT_MAX / (int)sizeof(float)) - *rawDataOffset) {
+ PyErr_Format(HSpiceParseError, "Raw data offset overflow.");
+ return -1;
+ }
+ tmpRawData = reallocate(ctx->debugMode, *rawData,
+ (*rawDataOffset + blockHeader[0]) * sizeof(float));
+ if (tmpRawData == NULL) {
+ return -1; // Error.
+ }
+ *rawData = tmpRawData;
+
+ // Read raw data block.
+ error = readBlockData(ctx, *rawData + *rawDataOffset, rawDataOffset,
+ sizeof(float), blockHeader[0]);
+ if (error < 0) {
+ return -1; // Error.
+ }
+ // Read trailer of file header block.
+ error = readBlockTrailer(ctx, blockHeader[blockHeaderSize - 1]);
+ if (error < 0) {
+ return -1; // Error.
+ }
+
+ if (ctx->format == HSPICE_FORMAT_2013 || ctx->format == HSPICE_FORMAT_2001) {
+ if (*rawDataOffset >= 2) {
+ memcpy(&lastVal, *rawData + *rawDataOffset - 2, 8);
+ if (ctx->swap) {
+ do_swap((char *)&lastVal, 1, 8);
+ }
+ if (lastVal > 9e29) {
+ return 0; // End of block.
+ }
+ }
+ } else {
+ if ((*rawData)[*rawDataOffset - 1] > 9e29) {
+ return 0; // End of block.
+ }
+ }
+ return 1; // There is more.
+}
+
+// Read one table for one sweep value. Returns:
+// -1 ... error occurred
+// 0 ... performed normally
+// Arguments:
+// ctx ... parser context
+// sweep ... sweep parameter name
+// numOfVariables ... number of variables in table
+// type ... type of variables with exception of scale
+// numOfVectors ... number of variables and probes in table
+// varSizes ... array of vector sizes
+// faSweep ... pointer to fast access structure for sweep array
+// tmpArray ... array of pointers to arrays
+// faPtr ... array of fast access structures for vector arrays
+// scale ... scale name
+// name ... array of vector names
+// dataList ... list of data dictionaries
+int readTable(struct ParserContext *ctx, PyObject *sweep, int numOfVariables,
+ int type, int numOfVectors, int *varSizes,
+ struct FastArray *faSweep, PyArrayObject **tmpArray,
+ struct FastArray *faPtr, char *scale, char **name,
+ PyObject *dataList) {
+ int i, j, num, offset = 0, numOfColumns = numOfVectors, rowSize = 0,
+ tableBytes = 0, dataBytes = 0;
+ int varSize;
+ npy_intp dims;
+ float *rawData = NULL;
+ PyObject *data = NULL;
+ char *bytePtr = NULL;
+ double lastVal, val, re_d, im_d, dSweepVal;
+ float re, im, fval, fSweepVal, re_f, im_f, fval_f;
+ struct FastArray *faPos;
+ int sweepHeaderSize = 0;
+
+ if (ctx->debugMode >= 2) {
+ fprintf(debugFile, "Reading table for one sweep point.\n");
+ }
+
+ // 1. Read raw data blocks via stream control
+ do {
+ num = readDataBlock(ctx, &rawData, &offset);
+ if (num < 0) {
+ goto readTableFailed;
+ }
+ } while (num > 0);
+
+ data = PyDict_New(); // Create an empty dictionary.
+ if (data == NULL) {
+ PyErr_Format(HSpiceParseError, "Failed to create data dictionary.");
+ goto readTableFailed;
+ }
+
+ bytePtr = (char *)rawData;
+ tableBytes = offset * sizeof(float);
+
+ // 2. Clear out the dedicated termination block bytes from the payload
+ // calculations
+ if (ctx->format == HSPICE_FORMAT_2013) {
+ if (tableBytes >= 8) {
+ memcpy(&lastVal, (char *)rawData + tableBytes - 8, 8);
+ if (ctx->swap) {
+ do_swap((char *)&lastVal, 1, 8);
+ }
+ if (lastVal > 9e29) {
+ tableBytes -= 8;
+ }
+ }
+ } else {
+ if (tableBytes >= 4) {
+ tableBytes -= 4; // 9601, 9007, and 2001 use 4 or matching natural widths
+ }
+ }
+
+ // Calculate total layout width for one circuit row
+ for (j = 0; j < numOfVectors; j++) {
+ rowSize += varSizes[j];
+ }
+
+ // 3. Clean and parse the dynamic Sweep Value Header (Specification §4.2)
+ dataBytes = tableBytes;
+ if (sweep != NULL) {
+ if (ctx->format == HSPICE_FORMAT_2001) {
+ sweepHeaderSize = 8; // 2001 format stores sweep value as an 8-byte double
+ } else {
+ sweepHeaderSize = 4; // 2013, 9601, 9007 store it as a 4-byte float
+ }
+ dataBytes -= sweepHeaderSize;
+ }
+
+ // Establish matrix entry dimension count
+ num = dataBytes / rowSize;
+
+ // Process and record the parameter sweep coordinate if applicable
+ if (sweep != NULL) {
+ if (ctx->format == HSPICE_FORMAT_2001) {
+ memcpy(&dSweepVal, bytePtr, 8);
+ if (ctx->swap) {
+ do_swap((char *)&dSweepVal, 1, 8);
+ }
+ *((npy_double *)(faSweep->pos)) = dSweepVal;
+ } else {
+ memcpy(&fSweepVal, bytePtr, 4);
+ if (ctx->swap) {
+ do_swap((char *)&fSweepVal, 1, 4);
+ }
+ *((npy_double *)(faSweep->pos)) = (double)fSweepVal;
+ }
+ bytePtr += sweepHeaderSize;
+ faSweep->pos = faSweep->pos + faSweep->stride;
+ }
+
+ // Increase number of columns if circuit variables are complex
+ if (type == complex_var) {
+ numOfColumns = numOfColumns + numOfVariables - 1;
+ }
+
+ // 4. Construct Destination NumPy Arrays
+ for (i = 0; i < numOfVectors; i++) {
+ dims = num;
+ if (type == complex_var && i > 0 && i < numOfVariables) {
+ tmpArray[i] = (PyArrayObject *)PyArray_SimpleNew(1, &dims, NPY_CDOUBLE);
+ } else {
+ tmpArray[i] = (PyArrayObject *)PyArray_SimpleNew(1, &dims, NPY_DOUBLE);
+ }
+ if (tmpArray[i] == NULL) {
+ if (ctx->debugMode) {
+ fprintf(debugFile, "HSpiceRead: failed to create array.\n");
+ }
+ for (j = 0; j < i + 1; j++) {
+ Py_XDECREF(tmpArray[j]);
+ }
+ goto readTableFailed;
+ }
+ }
+
+ for (i = 0; i < numOfVectors; i++) // Prepare fast access structures.
+ {
+ faPtr[i].data = PyArray_DATA(tmpArray[i]);
+ faPtr[i].pos = PyArray_DATA(tmpArray[i]);
+ faPtr[i].stride =
+ PyArray_STRIDE(tmpArray[i], PyArray_NDIM(tmpArray[i]) - 1);
+ faPtr[i].length = PyArray_SIZE(tmpArray[i]);
+ }
+
+ // 5. Populate Hybrid Arrays from Data Payload Slices
+ if (ctx->format == HSPICE_FORMAT_2013) {
+ for (i = 0; i < num; i++) {
+ faPos = faPtr;
+ for (j = 0; j < numOfVectors; j++) {
+ varSize = varSizes[j];
+ if (type == complex_var && j > 0 && j < numOfVariables) {
+ memcpy(&re, bytePtr, 4);
+ memcpy(&im, bytePtr + 4, 4);
+ if (ctx->swap) {
+ do_swap((char *)&re, 1, 4);
+ do_swap((char *)&im, 1, 4);
+ }
+ *(npy_cdouble *)(faPos->pos) = npy_cpack(re, im);
+ } else if (varSize == 8) {
+ memcpy(&val, bytePtr, 8);
+ if (ctx->swap) {
+ do_swap((char *)&val, 1, 8);
+ }
+ *((double *)(faPos->pos)) = val;
+ } else {
+ memcpy(&fval, bytePtr, 4);
+ if (ctx->swap) {
+ do_swap((char *)&fval, 1, 4);
+ }
+ *((double *)(faPos->pos)) = (double)fval;
+ }
+ bytePtr += varSize;
+ faPos->pos = faPos->pos + faPos->stride;
+ faPos = faPos + 1;
+ }
+ }
+ } else if (ctx->format == HSPICE_FORMAT_2001) {
+ for (i = 0; i < num; i++) {
+ faPos = faPtr;
+ for (j = 0; j < numOfVectors; j++) {
+ varSize = varSizes[j];
+ if (type == complex_var && j > 0 && j < numOfVariables) {
+ memcpy(&re_d, bytePtr, 8);
+ memcpy(&im_d, bytePtr + 8, 8);
+ if (ctx->swap) {
+ do_swap((char *)&re_d, 1, 8);
+ do_swap((char *)&im_d, 1, 8);
+ }
+ *(npy_cdouble *)(faPos->pos) = npy_cpack(re_d, im_d);
+ } else {
+ memcpy(&val, bytePtr, 8);
+ if (ctx->swap) {
+ do_swap((char *)&val, 1, 8);
+ }
+ *((double *)(faPos->pos)) = val;
+ }
+ bytePtr += varSize;
+ faPos->pos = faPos->pos + faPos->stride;
+ faPos = faPos + 1;
+ }
+ }
+ } else {
+ for (i = 0; i < num; i++) {
+ faPos = faPtr;
+ for (j = 0; j < numOfVectors; j++) {
+ varSize = varSizes[j];
+ if (type == complex_var && j > 0 && j < numOfVariables) {
+ memcpy(&re_f, bytePtr, 4);
+ memcpy(&im_f, bytePtr + 4, 4);
+ if (ctx->swap) {
+ do_swap((char *)&re_f, 1, 4);
+ do_swap((char *)&im_f, 1, 4);
+ }
+ *(npy_cdouble *)(faPos->pos) = npy_cpack(re_f, im_f);
+ } else {
+ memcpy(&fval_f, bytePtr, 4);
+ if (ctx->swap) {
+ do_swap((char *)&fval_f, 1, 4);
+ }
+ *((double *)(faPos->pos)) = (double)fval_f;
+ }
+ bytePtr += varSize;
+ faPos->pos = faPos->pos + faPos->stride;
+ faPos = faPos + 1;
+ }
+ }
+ }
+
+ PyMem_Free(rawData);
+ rawData = NULL;
+
+ // 6. Map Parsed NumPy structures back to the PyDict Object Mapping
+ num = PyDict_SetItemString(data, scale, (PyObject *)(tmpArray[0]));
+ i = -1;
+ if (num == 0) {
+ for (i = 0; i < numOfVectors - 1; i++) {
+ num = PyDict_SetItemString(data, name[i], (PyObject *)(tmpArray[i + 1]));
+ if (num != 0) {
+ break;
+ }
+ }
+ }
+ for (j = 0; j < numOfVectors; j++) {
+ Py_XDECREF(tmpArray[j]);
+ }
+ if (num) {
+ if (i == -1) {
+ PyErr_Format(HSpiceParseError,
+ "Failed to insert vector %s into dictionary.", scale);
+ } else {
+ PyErr_Format(HSpiceParseError,
+ "Failed to insert vector %s into dictionary.", name[i]);
+ }
+
+ goto readTableFailed;
+ }
+
+ num = PyList_Append(dataList, data);
+ if (num) {
+ PyErr_Format(HSpiceParseError, "Failed to append table to the list "
+ "of data dictionaries.");
+
+ goto readTableFailed;
+ }
+ Py_XDECREF(data);
+ data = NULL;
+
+ if (ctx->debugMode >= 2) {
+ fprintf(debugFile, "Finished reading one sweep point.\n");
+ }
+
+ return 0;
+
+readTableFailed:
+ PyMem_Free(rawData);
+ Py_XDECREF(data);
+ return -1;
+}
+
+// This is the first prototype version of HSpiceRead function for reading
+// HSpice output files.
+// TODO:
+// ascii format support
+// different vector types support (like voltage, current ..., although I do
+// not
+// know what it would be good for)
+// scale monotonity check
+static PyObject *HSpiceRead(PyObject *self, PyObject *args) {
+ const char *fileName;
+ char *token, *buf = NULL, **name = NULL;
+ int debugMode, num, numOfVectors, numOfVariables, type,
+ sweepSize = 1, i = dateStartPosition - 1, offset = 0;
+ int parsedProbes;
+ struct FastArray faSweep, *faPtr = NULL;
+ PyObject *date = NULL, *title = NULL, *scale = NULL, *sweep = NULL,
+ *dataList = NULL, *sweeps = NULL, *tuple = NULL, *list = NULL;
+ PyArrayObject *sweepValues = NULL, **tmpArray = NULL;
+ int *varTypes = NULL;
+ int *varSizes = NULL;
+ struct ParserContext ctx;
+ HSpiceFormat parsedFormat = HSPICE_FORMAT_UNKNOWN;
+
+ // Get hspice_read() arguments.
+ if (!PyArg_ParseTuple(args, "si", &fileName, &debugMode)) {
+ return NULL;
+ }
+
+ if (debugMode) {
+ fprintf(debugFile, "HSpiceRead: reading file %s.\n", fileName);
+ }
+
+ ctx.f = fopen(fileName, "rb"); // Open the file.
+ if (ctx.f == NULL) {
+ PyErr_SetFromErrnoWithFilename(PyExc_OSError, fileName);
+ goto failed;
+ }
+ ctx.fileName = fileName;
+ ctx.debugMode = debugMode;
+ ctx.format = HSPICE_FORMAT_2013;
+ ctx.swap = 0;
+
+ num = getc(ctx.f);
+ ungetc(num, ctx.f);
+ if (num == EOF) // Test if there is data in the file.
+ {
+ PyErr_Format(PyExc_ValueError, "File '%s' contains no data (0 bytes).",
+ fileName);
+ goto failed;
+ }
+ if ((num & 0x000000ff) >= ' ') // Test if the file is in ascii format.
+ {
+ PyErr_Format(PyExc_ValueError,
+ "File '%s' is encoded in ASCII text format, "
+ "but this parser only supports HSPICE binary format.",
+ fileName);
+ goto failed;
+ }
+
+ // Read file header blocks.
+ do {
+ num = readHeaderBlock(&ctx, &buf, &offset);
+ if (num < 0) {
+ goto failed;
+ }
+ } while (num > 0);
+
+ if (offset < vectorDescriptionStartPosition + 1) {
+ PyErr_Format(PyExc_ValueError, "File '%s' header is too short.", fileName);
+ goto failed;
+ }
+
+ // Check version of post format.
+ // --- Check version of post format (Specification §3.1) ---
+ // We safely map out format definitions using the spec's offset positions
+ // First check for Modern formats (24 characters, version string at position
+ // 20)
+ if (strncmp(&buf[postStartPosition2], "2013", numOfPostCharacters) == 0) {
+ parsedFormat = HSPICE_FORMAT_2013;
+ } else if (strncmp(&buf[postStartPosition2], "2001", numOfPostCharacters) ==
+ 0) {
+ parsedFormat = HSPICE_FORMAT_2001;
+ }
+ // Fall back to checking Legacy formats (20 characters, version string at
+ // position 16)
+ else if (strncmp(&buf[postStartPosition1], "9601", numOfPostCharacters) ==
+ 0) {
+ parsedFormat = HSPICE_FORMAT_9601;
+ } else if (strncmp(&buf[postStartPosition1], "9007", numOfPostCharacters) ==
+ 0) {
+ parsedFormat = HSPICE_FORMAT_9007;
+ }
+
+ // If it doesn't match any of the spec definitions, throw a ValueError
+ if (parsedFormat == HSPICE_FORMAT_UNKNOWN) {
+ // Extract a snapshot of what was actually there for debugging
+ char hiddenVersion[5] = {0};
+ strncpy(hiddenVersion, &buf[postStartPosition2], 4);
+
+ PyErr_Format(PyExc_ValueError,
+ "HSpiceRead: Unsupported or corrupt HSPICE post format "
+ "version (found '%s'). "
+ "Expected '9007', '9601', '2001', or '2013'.",
+ hiddenVersion);
+ goto failed;
+ }
+
+ // Assign back to context framework for downstream row routing tracking
+ ctx.format = parsedFormat;
+
+ buf[dateEndPosition] = 0;
+ date =
+ PyUnicode_InternFromString(&buf[dateStartPosition]); // Get creation date.
+ if (date == NULL) {
+ PyErr_Format(HSpiceParseError, "Failed to create date string.");
+ goto failed;
+ }
+
+ while (buf[i] == ' ') {
+ i--;
+ }
+ buf[i + 1] = 0;
+ title = PyUnicode_InternFromString(&buf[titleStartPosition]); // Get title.
+ if (title == NULL) {
+ PyErr_Format(HSpiceParseError, "Failed to create title string.");
+ goto failed;
+ }
+
+ buf[numOfSweepsEndPosition] = 0; // Check number of sweep parameters.
+ if (parse_int(&buf[numOfSweepsPosition], &num) < 0) {
+ PyErr_Format(HSpiceParseError,
+ "Failed to parse number of sweeps as valid integer.");
+ goto failed;
+ }
+ if (num < 0 || num > 1) {
+ PyErr_Format(PyExc_ValueError, "Only single dimension sweeps supported.");
+ goto failed;
+ }
+
+ buf[numOfSweepsPosition] = 0; // Get number of vectors (variables and probes).
+ if (parse_int(&buf[numOfProbesPosition], &parsedProbes) < 0) {
+ PyErr_Format(HSpiceParseError,
+ "Failed to parse number of probes as valid integer.");
+ goto failed;
+ }
+ buf[numOfProbesPosition] = 0;
+ if (parse_int(&buf[numOfVariablesPosition], &numOfVariables) < 0) {
+ PyErr_Format(HSpiceParseError,
+ "Failed to parse number of variables as valid integer.");
+ goto failed;
+ }
+ if (parsedProbes < 0 || numOfVariables <= 0 ||
+ parsedProbes > INT_MAX - numOfVariables) {
+ if (debugMode) {
+ fprintf(debugFile,
+ "HSpiceRead: invalid probe or variable counts (probes=%d, "
+ "variables=%d).\n",
+ parsedProbes, numOfVariables);
+ }
+ goto failed;
+ }
+ numOfVectors = parsedProbes + numOfVariables;
+ if (numOfVectors > INT_MAX / (int)sizeof(PyArrayObject *)) {
+ PyErr_Format(PyExc_ValueError, "Total number of vectors is too large.");
+
+ goto failed;
+ }
+
+ // Allocate space for types and sizes
+ varTypes = (int *)PyMem_Malloc(numOfVectors * sizeof(int));
+ if (varTypes == NULL) {
+ PyErr_Format(PyExc_MemoryError, "Failed to allocate vector types.");
+ goto failed;
+ }
+ varSizes = (int *)PyMem_Malloc(numOfVectors * sizeof(int));
+ if (varSizes == NULL) {
+ PyErr_Format(PyExc_MemoryError, "Failed to allocate vector sizes.");
+ goto failed;
+ }
+
+ // Get type of variables. Scale is always real.
+ token = strtok(&buf[vectorDescriptionStartPosition], " \t\n");
+ if (token == NULL) {
+ PyErr_Format(HSpiceParseError, "Failed to to extract vector description.");
+
+ goto failed;
+ }
+ if (numOfVectors > 1) {
+ if (parse_int(token, &varTypes[1]) < 0) {
+ PyErr_Format(HSpiceParseError,
+ "Failed to parse vector type as valid integer.");
+ goto failed;
+ }
+ type = varTypes[1];
+ } else {
+ if (parse_int(token, &varTypes[0]) < 0) {
+ PyErr_Format(HSpiceParseError,
+ "Failed to parse vector type as valid integer.");
+ goto failed;
+ }
+ type = varTypes[0];
+ }
+ if (type == frequency) {
+ type = complex_var;
+ } else {
+ type = real_var;
+ }
+
+ for (i = 0; i < numOfVectors; i++) {
+ token = strtok(NULL, " \t\n");
+ if (token == NULL) {
+ PyErr_Format(HSpiceParseError, "Failed to extract vector types/scale.");
+ goto failed;
+ }
+ if (numOfVectors > 1) {
+ if (i < numOfVectors - 2) {
+ if (parse_int(token, &varTypes[i + 2]) < 0) {
+ PyErr_Format(HSpiceParseError,
+ "Failed to parse vector type as valid integer.");
+ goto failed;
+ }
+ } else if (i == numOfVectors - 2) {
+ if (parse_int(token, &varTypes[0]) < 0) {
+ PyErr_Format(HSpiceParseError,
+ "Failed to parse vector type as valid integer.");
+ goto failed;
+ }
+ }
+ }
+ }
+
+ scale = PyUnicode_InternFromString(token); // Get independent variable name.
+ if (scale == NULL) {
+ PyErr_Format(HSpiceParseError,
+ "Failed to create independent variable name string.");
+ goto failed;
+ }
+
+ for (i = 0; i < numOfVectors; i++) {
+ if (ctx.format == HSPICE_FORMAT_2013) {
+ if (i == 0) {
+ varSizes[i] = 8;
+ } else if (type == complex_var && i > 0) {
+ varSizes[i] = 8;
+ } else {
+ varSizes[i] = 4;
+ }
+ } else if (ctx.format == HSPICE_FORMAT_2001) {
+ if (type == complex_var && i > 0) {
+ varSizes[i] = 16;
+ } else {
+ varSizes[i] = 8;
+ }
+ } else {
+ if (type == complex_var && i > 0) {
+ varSizes[i] = 8;
+ } else {
+ varSizes[i] = 4;
+ }
+ }
+ if (debugMode >= 2) {
+ fprintf(debugFile, "Vector %d: type=%d, size=%d\n", i, varTypes[i],
+ varSizes[i]);
+ }
+ }
+
+ // Allocate space for pointers to vector names.
+ name = (char **)PyMem_Malloc((numOfVectors - 1) * sizeof(char *));
+ if (name == NULL) {
+ PyErr_Format(PyExc_MemoryError,
+ "Failed to allocate pointers to vector names.");
+ goto failed;
+ }
+
+ for (i = 0; i < numOfVectors - 1; i++) // Get vector names.
+ {
+ name[i] = strtok(NULL, " \t\n");
+ if (name[i] == NULL) {
+ PyErr_Format(HSpiceParseError, "Failed to extract vector names.");
+ goto failed;
+ }
+ }
+
+ // Process vector names: make name lowercase, remove v( in front of name
+ for (i = 0; i < numOfVectors - 1; i++) {
+ int j;
+ for (j = 0; name[i][j]; j++) {
+ if (name[i][j] >= 'A' && name[i][j] <= 'Z') {
+ name[i][j] -= 'A' - 'a';
+ }
+ }
+ if (name[i][0] == 'v' && name[i][1] == '(') {
+ int len = 0;
+ while (name[i][len]) {
+ len++;
+ }
+ if (len > 2 && name[i][len - 1] == ')') {
+ for (j = 2; j < len - 1; j++) {
+ name[i][j - 2] = name[i][j];
+ }
+ name[i][len - 3] = 0;
+ } else {
+ for (j = 2; name[i][j]; j++) {
+ name[i][j - 2] = name[i][j];
+ }
+ name[i][j - 2] = 0;
+ }
+ }
+ }
+
+ if (num == 1) // Get sweep information.
+ {
+ int num = getSweepInfo(debugMode, &sweep, buf, &sweepSize, &sweepValues,
+ &faSweep);
+ if (num < 0) {
+ goto failed;
+ }
+ }
+
+ dataList = PyList_New(0); // Create an empty list for data dictionaries.
+ if (dataList == NULL) {
+ PyErr_Format(PyExc_MemoryError, "Failed to create data list.");
+ goto failed;
+ }
+
+ // Allocate space for pointers to arrays.
+ tmpArray =
+ (PyArrayObject **)PyMem_Malloc(numOfVectors * sizeof(PyArrayObject *));
+ if (tmpArray == NULL) {
+ PyErr_Format(PyExc_MemoryError, "Failed to allocate pointers to arrays.");
+ goto failed;
+ }
+
+ // Allocate space for fast array pointers.
+ faPtr =
+ (struct FastArray *)PyMem_Malloc(numOfVectors * sizeof(struct FastArray));
+ if (faPtr == NULL) {
+ PyErr_Format(PyExc_MemoryError, "Failed to create array.");
+ goto failed;
+ }
+
+ for (i = 0; i < sweepSize; i++) // Read i-th table.
+ {
+ num = readTable(&ctx, sweep, numOfVariables, type, numOfVectors, varSizes,
+ &faSweep, tmpArray, faPtr, token, name, dataList);
+ if (num < 0) {
+ goto failed;
+ }
+ }
+ fclose(ctx.f);
+ ctx.f = NULL;
+ PyMem_Free(faPtr);
+ faPtr = NULL;
+ PyMem_Free(buf);
+ buf = NULL;
+ PyMem_Free(name);
+ name = NULL;
+ PyMem_Free(tmpArray);
+ tmpArray = NULL;
+ PyMem_Free(varTypes);
+ varTypes = NULL;
+ PyMem_Free(varSizes);
+ varSizes = NULL;
+
+ // Create sweeps tuple.
+ if (sweep == NULL) {
+ sweeps = PyTuple_Pack(3, Py_None, Py_None, dataList);
+ } else {
+ sweeps = PyTuple_Pack(3, sweep, sweepValues, dataList);
+ }
+ if (sweeps == NULL) {
+ PyErr_Format(PyExc_MemoryError, "Failed to create tuple with sweeps.");
+ goto failed;
+ }
+ Py_XDECREF(sweep);
+ sweep = NULL;
+ Py_XDECREF(sweepValues);
+ sweepValues = NULL;
+ Py_XDECREF(dataList);
+ dataList = NULL;
+
+ // Prepare return tuple.
+ tuple = PyTuple_Pack(6, sweeps, scale, Py_None, title, date, Py_None);
+ if (tuple == NULL) {
+ PyErr_Format(PyExc_MemoryError, "Failed to create tuple with read data.");
+ goto failed;
+ }
+ Py_XDECREF(date);
+ date = NULL;
+ Py_XDECREF(title);
+ title = NULL;
+ Py_XDECREF(scale);
+ scale = NULL;
+ Py_XDECREF(sweeps);
+ sweeps = NULL;
+
+ list = PyList_New(0); // Create an empty list.
+ if (list == NULL) {
+ PyErr_Format(PyExc_MemoryError, "Failed to create return list.");
+ goto failed;
+ }
+
+ num = PyList_Append(list, tuple); // Insert tuple into return list.
+ if (num) {
+ PyErr_Format(PyExc_MemoryError, "Failed to append tuple to return list.");
+ goto failed;
+ }
+ Py_XDECREF(tuple);
+
+ return list;
+
+failed: // Error occured. Close open file, release memory and python
+ // references.
+ if (ctx.f) {
+ fclose(ctx.f);
+ }
+ PyMem_Free(buf);
+ PyMem_Free(varTypes);
+ PyMem_Free(varSizes);
+ Py_XDECREF(date);
+ Py_XDECREF(title);
+ Py_XDECREF(scale);
+ PyMem_Free(name);
+ Py_XDECREF(sweep);
+ Py_XDECREF(sweepValues);
+ Py_XDECREF(dataList);
+ PyMem_Free(tmpArray);
+ PyMem_Free(faPtr);
+ Py_XDECREF(sweeps);
+ Py_XDECREF(tuple);
+ Py_XDECREF(list);
+
+ if (PyErr_Occurred()) {
+ return NULL;
+ }
+ Py_INCREF(Py_None);
+ return Py_None;
+}
diff --git a/PySpice/Spice/HSpice/hspicefile/hspice_read.h b/PySpice/Spice/HSpice/hspicefile/hspice_read.h
new file mode 100644
index 00000000..6132e762
--- /dev/null
+++ b/PySpice/Spice/HSpice/hspicefile/hspice_read.h
@@ -0,0 +1,59 @@
+/*
+ * Copyright (c) 2026 Daniel Schmeer
+ * Copyright (c) 2009 Arpad Buermen (PyOPUS Project)
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero 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 Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+#ifndef HSPICE_READ_H
+#define HSPICE_READ_H
+
+#include "Python.h"
+#include
+
+// Structure for fast vector access
+struct FastArray {
+ char *data;
+ char *pos;
+ Py_ssize_t stride;
+ Py_ssize_t length;
+};
+
+typedef enum {
+ HSPICE_FORMAT_UNKNOWN,
+ HSPICE_FORMAT_9007,
+ HSPICE_FORMAT_9601,
+ HSPICE_FORMAT_2001,
+ HSPICE_FORMAT_2013
+} HSpiceFormat;
+
+struct ParserContext {
+ HSpiceFormat format;
+ bool swap;
+ FILE *f;
+ const char *fileName;
+ int debugMode;
+};
+
+// Python callable function
+static PyObject *HSpiceRead(PyObject *self, PyObject *args);
+
+// Python exceptions
+static PyObject *HSpiceParseError;
+
+#ifdef LINUX
+#define __declspec(a) extern
+#endif
+
+#endif // HSPICE_READ_H
diff --git a/PySpice/Spice/HSpice/hspicefile/release_note b/PySpice/Spice/HSpice/hspicefile/release_note
new file mode 100644
index 00000000..aceda5b3
--- /dev/null
+++ b/PySpice/Spice/HSpice/hspicefile/release_note
@@ -0,0 +1,16 @@
+# HSPICE Binary Format Qualification & Release Note
+
+This document contains verification status and qualification details for the HSPICE binary format parser, validating implementation against reference simulations.
+
+## 1. Verification Status
+All structural rules in the reference manual have been empirically verified against 135 binary output files generated by **HSPICE**, covering:
+* **Format versions**: `9601`, `2001`, and `2013`
+* **Analysis types**: DC, AC, and Transient
+* **Sweep configurations**: No-sweep, parameter sweep, temperature sweep, source sweep, nested sweep, Monte Carlo, probe-only, and combined probe+sweep configurations.
+
+ASCII output files (produced via `.option post=2`) were used as the golden numerical reference to validate the binary parser outputs.
+
+## 2. Format Version Upgrade Behavior
+* **HSPICE behavior**: Silently upgrades `post_version=9007` to `2013` format on disk.
+* **Disk structure**: Files produced with `.option post_version=9007` are identical in structure to `2013` files. The version string inside the header block payload reads `2013`, not `9007`.
+* **Parser requirement**: Parsers should automatically treat `9007`-requested output files as format version `2013`.
diff --git a/PySpice/Spice/NgSpice/Simulator.py b/PySpice/Spice/NgSpice/Simulator.py
index 74d4023a..e2a84510 100644
--- a/PySpice/Spice/NgSpice/Simulator.py
+++ b/PySpice/Spice/NgSpice/Simulator.py
@@ -27,7 +27,7 @@
####################################################################################################
-from ..Simulator import Simulator
+from ..SimulatorBase import Simulator
from .Server import SpiceServer
from .Shared import NgSpiceShared
diff --git a/PySpice/Spice/Simulator.py b/PySpice/Spice/Simulator.py
index 2ceb4902..5798cc51 100644
--- a/PySpice/Spice/Simulator.py
+++ b/PySpice/Spice/Simulator.py
@@ -18,147 +18,26 @@
#
####################################################################################################
-__all__ = ['Simulator']
-
-####################################################################################################
-
-"""This module provides the base class for simulator and a factory method.
-
-"""
-
-####################################################################################################
-
-import logging
-
-####################################################################################################
-
-from ..Config import ConfigInstall
-from .Simulation import Simulation
-
-####################################################################################################
-
-_module_logger = logging.getLogger(__name__)
-
-####################################################################################################
-
-# Fixme: DOC: Each analysis mode is performed by a method that return the measured probes.
-
-class Simulator:
-
- """Base class to implement a simulator.
-
- """
-
- _logger = _module_logger.getChild('Simulator')
-
- #: Define the default simulator
- DEFAULT_SIMULATOR = None
- if ConfigInstall.OS.on_windows:
- DEFAULT_SIMULATOR = 'ngspice-shared'
- else:
- # DEFAULT_SIMULATOR = 'ngspice-subprocess'
- DEFAULT_SIMULATOR = 'ngspice-shared'
- # DEFAULT_SIMULATOR = 'xyce-serial'
- # DEFAULT_SIMULATOR = 'xyce-parallel'
-
- SIMULATORS = (
- 'ngspice',
- 'ngspice-shared',
- 'ngspice-subprocess',
- 'xyce',
- 'xyce-serial',
- 'xyce-parallel',
- )
-
- SIMULATOR = None # for subclass
-
- ##############################################
-
- @classmethod
- def factory(cls, *args, **kwargs):
-
- """Factory to instantiate a simulator.
-
- By default, it instantiates the simulator defined in :obj:`DEFAULT_SIMULATOR`, however you
- can set the simulator using the :obj:`simulator` parameter.
-
- Available simulators are:
-
- * :code:`ngspice` **alias for shared**
- * :code:`ngspice-shared` **DEFAULT**
- * :code:`ngspice-subprocess`
- * :code:`xyce` **alias for serial**
- * :code:`xyce-serial`
- * :code:`xyce-parallel`
-
- Return a :obj:`PySpice.Spice.Simulator` subclass.
-
- """
-
- # Fixme: purpose ??? simplify import...
-
- simulator = kwargs.pop('simulator', cls.DEFAULT_SIMULATOR)
- sub_cls = None
-
- if simulator not in cls.SIMULATORS:
- raise NameError(f"Unknown simulator {simulator}")
-
- if simulator.startswith('ngspice'):
- if simulator == 'ngspice-subprocess':
- from .NgSpice.Simulator import NgSpiceSubprocessSimulator
- sub_cls = NgSpiceSubprocessSimulator
- elif simulator in ('ngspice', 'ngspice-shared'):
- from .NgSpice.Simulator import NgSpiceSharedSimulator
- sub_cls = NgSpiceSharedSimulator
-
- elif simulator.startswith('xyce'):
- from .Xyce.Simulator import XyceSimulator
- sub_cls = XyceSimulator
- if simulator == 'xyce-parallel':
- kwargs['parallel'] = True
-
- if sub_cls is not None:
- obj = sub_cls(*args, **kwargs)
- obj._AS_SIMULATOR = simulator
- return obj
- else:
- raise ValueError('Unknown simulator')
-
- ##############################################
-
- def __getstate__(self):
- # Pickle: protection for cffi
- return self.__class__.__name__
-
- ##############################################
-
- def simulation(self, circuit, **kwargs):
- """Create a new simulation for the circuit.
-
- Return a :obj:`PySpice.Spice.Simulation` instance`
-
- """
- # Note: simulation is simulator dependent, thus subclass this method if needed
- return Simulation(self, circuit, **kwargs)
-
- ##############################################
-
- @property
- def name(self):
- return self._AS_SIMULATOR
-
- @property
- def version(self):
- raise NotImplementedError
-
- ##############################################
-
- def customise(self, simulation):
- """Customise the simulation"""
- pass
-
- ##############################################
-
- def run(self, simulation):
- """Run the simulation and return the waveforms."""
- raise NotImplementedError
+__all__ = ["Simulator"]
+
+# Import base class and all subclass implementations at module load time to register them
+from .SimulatorBase import Simulator
+from .NgSpice.Simulator import NgSpiceSubprocessSimulator, NgSpiceSharedSimulator
+from .Xyce.Simulator import XyceSimulator
+from .HSpice.Simulator import HSpiceSimulator
+
+SIMULATOR_MAP = {
+ "ngspice-subprocess": NgSpiceSubprocessSimulator,
+ "ngspice": NgSpiceSharedSimulator,
+ "ngspice-shared": NgSpiceSharedSimulator,
+ "xyce": XyceSimulator,
+ "xyce-serial": XyceSimulator,
+ "xyce-parallel": XyceSimulator,
+ "hspice": HSpiceSimulator,
+}
+
+# Register all simulator subclasses
+for name, cls in SIMULATOR_MAP.items():
+ Simulator.register_simulator_class(name, cls)
+
+SIMULATORS = tuple(SIMULATOR_MAP.keys())
diff --git a/PySpice/Spice/SimulatorBase.py b/PySpice/Spice/SimulatorBase.py
new file mode 100644
index 00000000..c84c5eb1
--- /dev/null
+++ b/PySpice/Spice/SimulatorBase.py
@@ -0,0 +1,108 @@
+###################################################################################################
+#
+# PySpice - A Spice Package for Python
+# Copyright (C) 2021 Fabrice Salvaire
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero 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 Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+#
+####################################################################################################
+
+__all__ = ["Simulator"]
+
+import logging
+
+from ..Config import ConfigInstall
+from .Simulation import Simulation
+
+_module_logger = logging.getLogger(__name__)
+
+
+class Simulator:
+ """Base class to implement a simulator."""
+
+ _logger = _module_logger.getChild("Simulator")
+
+ #: Define the default simulator
+ DEFAULT_SIMULATOR = None
+ if ConfigInstall.OS.on_windows:
+ DEFAULT_SIMULATOR = "ngspice-shared"
+ else:
+ DEFAULT_SIMULATOR = "ngspice-shared"
+
+ SIMULATOR = None # for subclass
+ _SIMULATOR_CLASSES = {}
+
+ @classmethod
+ def register_simulator_class(cls, name, sub_cls):
+ cls._SIMULATOR_CLASSES[name] = sub_cls
+
+ @classmethod
+ def factory(cls, *args, **kwargs):
+ """Factory to instantiate a simulator.
+
+ By default, it instantiates the simulator defined in :obj:`DEFAULT_SIMULATOR`, however you
+ can set the simulator using the :obj:`simulator` parameter.
+
+ Available simulators are:
+
+ * :code:`ngspice` **alias for shared**
+ * :code:`ngspice-shared` **DEFAULT**
+ * :code:`ngspice-subprocess`
+ * :code:`xyce` **alias for serial**
+ * :code:`xyce-serial`
+ * :code:`xyce-parallel`
+ * :code:`hspice`
+
+ Return a :obj:`PySpice.Spice.Simulator` subclass.
+
+ """
+ simulator = kwargs.pop("simulator", cls.DEFAULT_SIMULATOR)
+
+ if simulator not in cls._SIMULATOR_CLASSES:
+ raise NameError(f"Unknown simulator {simulator}")
+
+ sub_cls = cls._SIMULATOR_CLASSES[simulator]
+
+ obj = sub_cls(*args, simulator=simulator, **kwargs)
+ obj._AS_SIMULATOR = simulator
+ return obj
+
+ def __getstate__(self):
+ # Pickle: protection for cffi
+ return self.__class__.__name__
+
+ def simulation(self, circuit, **kwargs):
+ """Create a new simulation for the circuit.
+
+ Return a :obj:`PySpice.Spice.Simulation` instance`
+
+ """
+ # Note: simulation is simulator dependent, thus subclass this method if needed
+ return Simulation(self, circuit, **kwargs)
+
+ @property
+ def name(self):
+ return self._AS_SIMULATOR
+
+ @property
+ def version(self):
+ raise NotImplementedError
+
+ def customise(self, simulation):
+ """Customise the simulation"""
+ pass
+
+ def run(self, simulation):
+ """Run the simulation and return the waveforms."""
+ raise NotImplementedError
diff --git a/PySpice/Spice/Xyce/Simulator.py b/PySpice/Spice/Xyce/Simulator.py
index b01d6116..06d86563 100644
--- a/PySpice/Spice/Xyce/Simulator.py
+++ b/PySpice/Spice/Xyce/Simulator.py
@@ -27,7 +27,7 @@
####################################################################################################
-from ..Simulator import Simulator
+from ..SimulatorBase import Simulator
from .Server import XyceServer
from .Simulation import XyceSimulation
@@ -49,6 +49,7 @@ def __init__(self, **kwargs):
# super().__init__(**kwargs)
xyce_command = kwargs.get('xyce_command', None)
self._xyce_server = XyceServer(xyce_command=xyce_command)
+ self._parallel = kwargs.get('parallel', False) or kwargs.get('simulator') == 'xyce-parallel'
##############################################
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 00000000..cec95721
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,3 @@
+[build-system]
+requires = ["setuptools", "wheel", "oldest-supported-numpy"]
+build-backend = "setuptools.build_meta"
diff --git a/setup.py b/setup.py
index cfcdcdba..360d2efd 100644
--- a/setup.py
+++ b/setup.py
@@ -22,8 +22,13 @@
####################################################################################################
+import os
import sys
+setup_dir = os.path.dirname(os.path.abspath(__file__)) if '__file__' in globals() else os.getcwd()
+if setup_dir not in sys.path:
+ sys.path.insert(0, setup_dir)
+
from setuptools import setup
####################################################################################################
diff --git a/setup_data.py b/setup_data.py
index 48a1d322..eca40a33 100644
--- a/setup_data.py
+++ b/setup_data.py
@@ -23,6 +23,33 @@
####################################################################################################
import os
+import sys
+from setuptools import Extension
+from setuptools.command.build_ext import build_ext
+
+class LazyBuildExt(build_ext):
+ def finalize_options(self):
+ super().finalize_options()
+ import numpy as np
+
+ for ext in self.extensions:
+ ext.include_dirs.append(np.get_include())
+
+
+macros = []
+if sys.platform.startswith("linux"):
+ macros.append(("LINUX", None))
+
+ext_modules = [
+ Extension(
+ "PySpice.Spice.HSpice._hspice_read",
+ ["PySpice/Spice/HSpice/hspicefile/hspice_read.c"],
+ include_dirs=["PySpice/Spice/HSpice/hspicefile"],
+ define_macros=macros,
+ )
+]
+
+cmdclass = {"build_ext": LazyBuildExt}
####################################################################################################
@@ -74,4 +101,6 @@ def read_readme(file_name):
setup_dict = dict(
long_description=long_description,
+ ext_modules=ext_modules,
+ cmdclass=cmdclass,
)