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 @@ + + + + + + + + + + + + + + + + + Generic Block Encapsulation + + + + + Block Head + 16 Bytes (4 x 32-bit Ints) + Endian Markers & Byte Size + + + + Data Payload + Variable size (N bytes) + Header text OR Binary measurements + + + + Block Tail + 4 Bytes (1 x 32-bit Int) + Checksum (matches Byte Size) + + 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 @@ + + + + + + + + + + + + + + + + + + + Overall File Structure + + + + + + Header Block + Metadata (UTF-8 Text) + + + + + + + + Data Block 1 + Time Points 1 - 7 + + + + + + + + Data Block 2 + Time Points 8 - 14 + + + + + + + + Term. Block + 1.0e30 (EOF) + + 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. + +![Overall File Structure](file_structure.svg) + +--- + +## 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**. + +![Generic Block Encapsulation](block_structure.svg) + +### 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**. + +![Hybrid Precision Data Row](record_layout.svg) + +### 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 @@ + + + + + + + + + + + + + Hybrid Precision Data Row (e.g. 24-Byte Row) + + + + + TIME / scale variable + 8-Byte Double Precision (double) + Byte Offset: 0 + + + + v(in) + 4-Byte Float + Byte Offset: 8 + + + + v(out) + 4-Byte Float + Byte Offset: 12 + + + + v(0) + 4-Byte Float + Byte Offset: 16 + + + + i(v1) + 4-Byte Float + Byte Offset: 20 + + 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, )