From eff1ec1abef57040b895649d364fa8198ce1b65f Mon Sep 17 00:00:00 2001 From: Daniel Schmeer Date: Tue, 23 Jun 2026 14:00:14 +0100 Subject: [PATCH 01/21] Update simulator class to facilitate other simulators --- PySpice/Spice/NgSpice/Simulator.py | 2 +- PySpice/Spice/Simulator.py | 150 ++--------------------------- PySpice/Spice/SimulatorBase.py | 125 ++++++++++++++++++++++++ PySpice/Spice/Xyce/Simulator.py | 2 +- 4 files changed, 137 insertions(+), 142 deletions(-) create mode 100644 PySpice/Spice/SimulatorBase.py 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..d04ddd5c 100644 --- a/PySpice/Spice/Simulator.py +++ b/PySpice/Spice/Simulator.py @@ -20,145 +20,15 @@ __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'): +# 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 - 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 +# Register all simulator subclasses +Simulator.register_simulator_class('ngspice-subprocess', NgSpiceSubprocessSimulator) +Simulator.register_simulator_class('ngspice', NgSpiceSharedSimulator) +Simulator.register_simulator_class('ngspice-shared', NgSpiceSharedSimulator) +Simulator.register_simulator_class('xyce', XyceSimulator) +Simulator.register_simulator_class('xyce-serial', XyceSimulator) +Simulator.register_simulator_class('xyce-parallel', XyceSimulator) diff --git a/PySpice/Spice/SimulatorBase.py b/PySpice/Spice/SimulatorBase.py new file mode 100644 index 00000000..5c988bff --- /dev/null +++ b/PySpice/Spice/SimulatorBase.py @@ -0,0 +1,125 @@ +################################################################################################### +# +# 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' + + SIMULATORS = ( + 'ngspice', + 'ngspice-shared', + 'ngspice-subprocess', + 'xyce', + 'xyce-serial', + 'xyce-parallel', + 'hspice', + ) + + 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.SIMULATORS: + raise NameError(f"Unknown simulator {simulator}") + + sub_cls = cls._SIMULATOR_CLASSES.get(simulator) + if sub_cls is None: + raise ValueError(f"Simulator class not registered for {simulator}") + + if simulator == 'xyce-parallel': + kwargs['parallel'] = True + + obj = sub_cls(*args, **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..9fa02d21 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 From 1238a97c29bca13f0e7176d04a6edfc2c4e9da36 Mon Sep 17 00:00:00 2001 From: Daniel Schmeer Date: Tue, 23 Jun 2026 14:45:43 +0100 Subject: [PATCH 02/21] Integrate HSPICE backend with binary reader and concurrency limits Implement HSpiceSimulator , HSpiceServer , and HSpiceRawFile to support HSPICE simulations. Vendor the HSPICE raw binary result parser ( hspice_read.c / hspice_read.h ) from PyOPUS under PySpice/Spice/HSpice/hspicefile/ Add docs of hspice binary file structure --- PySpice/Spice/HSpice/RawFile.py | 122 +++ PySpice/Spice/HSpice/Server.py | 241 +++++ PySpice/Spice/HSpice/Simulator.py | 26 + PySpice/Spice/HSpice/__init__.py | 1 + PySpice/Spice/HSpice/hspicefile/__init__.py | 8 + PySpice/Spice/HSpice/hspicefile/build.py | 24 + .../hspicefile/docs/block_structure.svg | 38 + .../HSpice/hspicefile/docs/file_structure.svg | 55 + .../hspicefile/docs/hspice_file_structure.md | 87 ++ .../HSpice/hspicefile/docs/record_layout.svg | 46 + PySpice/Spice/HSpice/hspicefile/hspice_read.c | 972 ++++++++++++++++++ PySpice/Spice/HSpice/hspicefile/hspice_read.h | 18 + PySpice/Spice/Simulator.py | 4 +- setup_data.py | 5 +- 14 files changed, 1645 insertions(+), 2 deletions(-) create mode 100644 PySpice/Spice/HSpice/RawFile.py create mode 100644 PySpice/Spice/HSpice/Server.py create mode 100644 PySpice/Spice/HSpice/Simulator.py create mode 100644 PySpice/Spice/HSpice/__init__.py create mode 100644 PySpice/Spice/HSpice/hspicefile/__init__.py create mode 100644 PySpice/Spice/HSpice/hspicefile/build.py create mode 100644 PySpice/Spice/HSpice/hspicefile/docs/block_structure.svg create mode 100644 PySpice/Spice/HSpice/hspicefile/docs/file_structure.svg create mode 100644 PySpice/Spice/HSpice/hspicefile/docs/hspice_file_structure.md create mode 100644 PySpice/Spice/HSpice/hspicefile/docs/record_layout.svg create mode 100644 PySpice/Spice/HSpice/hspicefile/hspice_read.c create mode 100644 PySpice/Spice/HSpice/hspicefile/hspice_read.h diff --git a/PySpice/Spice/HSpice/RawFile.py b/PySpice/Spice/HSpice/RawFile.py new file mode 100644 index 00000000..e38d7a35 --- /dev/null +++ b/PySpice/Spice/HSpice/RawFile.py @@ -0,0 +1,122 @@ +import logging +import numpy as np +from PySpice.Unit import u_V, u_A, u_s, u_Hz +from PySpice.Probe.WaveForm import TransientAnalysis, DcAnalysis, AcAnalysis, WaveForm, OperatingPoint + +_module_logger = logging.getLogger(__name__) + +class HSpiceRawFile: + def __init__(self, data, simulation=None, measurements=None, op_nodes=None, op_branches=None): + self.data = data + self._simulation = simulation + self.measurements = measurements or {} + self.op_nodes = op_nodes or {} + self.op_branches = op_branches or {} + + @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: + # 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, sweep_values, data_list = sweeps + + # data_list is a list of dictionaries, each dict represents a table. + # Typically there is only 1 table if no outer sweep. + res_dict = data_list[0] + + # Let's find the scale/abscissa variable (typically TIME or FREQUENCY or HERTZ) + scale_name = None + for k in res_dict.keys(): + if k.upper() in ('TIME', 'FREQUENCY', 'HERTZ'): + scale_name = k + break + + if scale_name is None: + # Check if there is another scale variable (e.g. dc sweep) + scale_name = list(res_dict.keys())[0] + + scale_values = res_dict[scale_name] + + # Determine analysis type + if scale_name.upper() == 'TIME': + analysis_type = 'transient' + scale_unit = u_s + elif scale_name.upper() in ('FREQUENCY', 'HERTZ'): + analysis_type = 'ac' + scale_unit = u_Hz + else: + analysis_type = 'dc' + scale_unit = None + + # Build WaveForms + abscissa_name = scale_name.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 = self.measurements + return analysis diff --git a/PySpice/Spice/HSpice/Server.py b/PySpice/Spice/HSpice/Server.py new file mode 100644 index 00000000..ace6b519 --- /dev/null +++ b/PySpice/Spice/HSpice/Server.py @@ -0,0 +1,241 @@ +import fcntl +import logging +import multiprocessing +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('m'): + if val_str.endswith('meg'): + return float(val_str[:-3]) * 1e6 + 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): + """ + 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 = "/tmp/pyspice_hspice_locks" + os.makedirs(lock_dir, 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, 0o666) + lock_files.append((fd, i)) + + 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 + time.sleep(0.05) + +class HSpiceServer: + SPICE_COMMAND = 'hspice' + + def __init__(self, **kwargs): + self._spice_command = kwargs.get('spice_command') or self.SPICE_COMMAND + self._concurrency_limit = int(os.environ.get('PYSPICE_HSPICE_CONCURRENCY_LIMIT') or kwargs.get('concurrency_limit') or 4) + + def __call__(self, spice_input): + logger = _module_logger.getChild('HSpiceServer') + logger.info("Running HSPICE simulation") + + # Create temporary directory + tmp_dir = tempfile.mkdtemp(dir=os.path.dirname(tempfile.mktemp())) + 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) + + 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)}") + res = subprocess.run(cmd, cwd=tmp_dir, capture_output=True, text=True) + 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 + # 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"): + raw_file_path = os.path.join(tmp_dir, f) + 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(".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() + 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) + 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(): + 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('.')] + if len(content_lines) >= 2: + names = content_lines[0].split() + for val_line in content_lines[1:]: + vals = val_line.split() + for name, val in zip(names, vals): + if name.lower() not in ('temper', 'alter#', 'temper#', 'alter'): + try: + measurements[name.lower()] = float(val) + except ValueError: + pass + except Exception as e: + logger.warning(f"Failed to parse measurement file {meas_file_path}: {e}") + + return HSpiceRawFile(data, measurements=measurements) + + 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..75a6b052 --- /dev/null +++ b/PySpice/Spice/HSpice/Simulator.py @@ -0,0 +1,26 @@ +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') 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 + 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..f62e2081 --- /dev/null +++ b/PySpice/Spice/HSpice/hspicefile/__init__.py @@ -0,0 +1,8 @@ +# (c) Janez Puhan, Arpad Buermen. Imported from PyOPUS project. +from .. import _hspice_read + +def hspice_read(filename, debug=0): + """ + Wrapper for PyOPUS-based binary HSPICE raw result reader. + """ + return _hspice_read.hspice_read(filename, debug) diff --git a/PySpice/Spice/HSpice/hspicefile/build.py b/PySpice/Spice/HSpice/hspicefile/build.py new file mode 100644 index 00000000..467807aa --- /dev/null +++ b/PySpice/Spice/HSpice/hspicefile/build.py @@ -0,0 +1,24 @@ +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()) + +ext_modules = [ + Extension( + 'PySpice.Spice.HSpice._hspice_read', + ['PySpice/Spice/HSpice/hspicefile/hspice_read.c'], + include_dirs=[ + 'PySpice/Spice/HSpice/hspicefile' + ], + define_macros=[('LINUX', None)], + ) +] + +cmdclass = { + 'build_ext': LazyBuildExt +} 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..76a2848a --- /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 + + + + + + + + Block N + EOF (End) + + 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..e3f122b5 --- /dev/null +++ b/PySpice/Spice/HSpice/hspicefile/docs/hspice_file_structure.md @@ -0,0 +1,87 @@ +# 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 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 Version (e.g. `2001`)**: 20 characters total: + * `0..3`: Number of monitored variables (e.g., `0005` for 5 variables). + * `4..15`: Padding zeros (`000000000000`). + * `16..19`: Format version identifier string (`2001`). +* **Modern Version (e.g. `2013`)**: 24 characters total: + * `0..3`: Number of monitored variables (e.g., `0005`). + * `4..19`: Padding zeros (`0000000000000000`). + * `20..23`: Format version identifier string (`2013`). + +### 3.2 Metadata Fields +Following the format descriptor, fixed character offsets define simulation properties: +* **Title**: 64-character title of the simulation. +* **Date**: 24-character creation date string. +* **Sweeps Count**: Number of swept parameter values (`numOfSweeps`). +* **Probes Count**: Number of probes/signals monitored. +* **Variable Types**: The type code of each variable: + * `1`: Real floating-point signal (4-byte single-precision float). + * `2`: Complex floating-point signal (8-byte, composed of two 4-byte floats). + * `8`: Double-precision float (8-byte, used for scale/time variables). +* **Variable Names**: Null/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 `$&%#`. + +--- + +## 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 +* **Scale Variable**: The first variable in each record is the abscissa (e.g. `TIME` for transient analysis, `FREQUENCY` for AC, or the swept source value for DC sweep). In modern HSPICE output formats (e.g. version `2013`), the scale variable is always written as a **double-precision float (8-byte IEEE double)**. +* **Circuit Variables**: Node voltages, branch currents, and device measurements are written as **single-precision floats (4-byte IEEE float)**, except for AC analysis where they are stored as complex pairs of single-precision floats (8 bytes total). +* **Row Size calculation**: + $$\text{Row Size (Bytes)} = 8 \text{ (scale double)} + \sum_{j=1}^{M} \text{Size of Variable } j$$ + For a transient analysis with 4 node voltage signals, the record length is: + $$8 \text{ bytes (TIME)} + 4 \text{ bytes} \times 4 \text{ (signals)} = 24 \text{ bytes}$$ + +### 4.2 Record Iteration and EOF Chaining +1. A parser reads the data blocks one by one. +2. Each data block contains a variable number of data bytes (always a multiple of 4 bytes). +3. The parser groups the payload bytes into data rows of size `Row Size`. +4. Once a data block payload is fully consumed, the parser reads the next Block Head. +5. If reading the Block Head fails due to EOF (`num == 0` and `feof(f)`), or if the final record contains the legacy termination float marker `> 9e29` (representing `1e30`), the parsing process finishes successfully. 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/hspice_read.c b/PySpice/Spice/HSpice/hspicefile/hspice_read.c new file mode 100644 index 00000000..e17a4056 --- /dev/null +++ b/PySpice/Spice/HSpice/hspicefile/hspice_read.c @@ -0,0 +1,972 @@ +// (c) Janez Puhan, Arpad Buermen. Imported from PyOPUS project. +// 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 "Python.h" +#include "numpy/arrayobject.h" +#include "numpy/npy_math.h" +#include "hspice_read.h" + +// 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(); + + return m; +} + + +#define debugFile stdout + +static int isModernFormatGlobal = 0; +static int swapGlobal = 0; + +// 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 postString11 "9007" +#define postString12 "9601" +#define postString21 "2001" +#define postString22 "2013" +#define numOfPostCharacters 4 +#define dateStartPosition 88 +#define dateEndPosition 112 +#define titleStartPosition 24 +#define sweepSizePosition1 176 +#define sweepSizePosition2 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 ... endian swap not performed +// 1 ... endian swap performed +// Arguments: +// f ... pointer to file for reading +// fileName ... name of the file +// debugMode ... debug messages flag +// blockHeader ... array of four integers consisting block header +// size ... size of items in block +int readBlockHeader(FILE *f, const char *fileName, int debugMode, int *blockHeader, + int size) +{ + int swap, num = fread(blockHeader, sizeof(int), blockHeaderSize, f); + if(num != blockHeaderSize) + { + if (num == 0 && feof(f)) { + return -2; // Normal EOF + } + if(debugMode) fprintf(debugFile, + "HSpiceRead: failed to read block header from file %s.\n", + fileName); + return -1; // Error. + } + + // Block header check and swap. + if(blockHeader[0] == 0x00000004 && blockHeader[2] == 0x00000004) swap = 0; + else if(blockHeader[0] == 0x04000000 && blockHeader[2] == 0x04000000) swap = 1; + else + { + if(debugMode) fprintf(debugFile, "HSpiceRead: corrupted block header.\n"); + return -1; + } + if(swap == 1) do_swap((char *)blockHeader, blockHeaderSize, sizeof(int)); + blockHeader[0] = blockHeader[blockHeaderSize - 1] / size; + if (debugMode>=2) { + fprintf(debugFile, "Got block header, size=%d, bytes=%d, swap=%d\n", blockHeader[1], blockHeader[3], swap); + } + return swap; +} + +// Read block data. Returns: +// 0 ... reading performed normally +// 1 ... reading failed +// Arguments: +// f ... pointer to file for reading +// fileName ... name of the file +// debugMode ... debug messages flag +// 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(FILE *f, const char *fileName, int debugMode, void *ptr, + int *offset, int itemSize, int numOfItems, int swap) +{ + int num = fread(ptr, itemSize, numOfItems, f); + if(num != numOfItems) + { + if(debugMode) fprintf(debugFile, + "HSpiceRead: failed to read block from file %s.\n", + fileName); + return 1; // Error. + } + *offset = *offset + numOfItems; + if(swap > 0) do_swap((char *)ptr, numOfItems, itemSize); // Endian swap. + if (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: +// 0 ... reading performed normally +// 1 ... block trailer corrupted +// Arguments: +// f ... pointer to file for reading +// fileName ... name of the file +// debugMode ... debug messages flag +// swap ... perform endian swap flag +// header ... block size from header +int readBlockTrailer(FILE *f, const char *fileName, int debugMode, int swap, + int header) +{ + int trailer, num; + num = fread(&trailer, sizeof(int), 1, f); + if(num != 1) + { + if(debugMode) fprintf(debugFile, + "HSpiceRead: failed to read block trailer from file %s.\n", + fileName); + return 1; // Error. + } + if(swap > 0) do_swap((char *)(&trailer), 1, sizeof(int)); // Endian swap. + + // Block header and trailer match check. + if(header != trailer) + { + if(debugMode) + fprintf(debugFile, "HSpiceRead: block header and trailer mismatch.\n"); + return 1; // Error. + } + if (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 && debugMode) + fprintf(debugFile, "HSpiceRead: cannot allocate.\n"); + return tmp; +} + +// Read one file header block. Returns: +// -1 ... this was the last block +// 0 ... there is at least one more block left +// 1 ... error occured during reading the block +// Arguments: +// f ... pointer to file for reading +// debugMode ... debug messages flag +// fileName ... name of the file +// buf ... pointer to header buffer, +// enlarged (reallocated) for current block +// bufOffset ... pointer to buffer size, increased for current block size +int readHeaderBlock(FILE *f, int debugMode, const char *fileName, char **buf, + int *bufOffset) +{ + char *tmpBuf; + int error, blockHeader[blockHeaderSize], swap; + + if (debugMode>=2) { + fprintf(debugFile, "Reading header block @0x%lx\n", ftell(f)); + } + + // Get size of file header block. + swap = readBlockHeader(f, fileName, debugMode, blockHeader, sizeof(char)); + if(swap < 0) return 1; // Error. + + // Allocate space for buffer. + tmpBuf = reallocate(debugMode, *buf, + (*bufOffset + blockHeader[0] + 1) * sizeof(char)); + if(tmpBuf == NULL) return 1; // Error. + *buf = tmpBuf; + + // Read file header block. + error = readBlockData(f, fileName, debugMode, *buf + *bufOffset, bufOffset, + sizeof(char), blockHeader[0], 0); + if(error == 1) return 1; // Error. + (*buf)[*bufOffset] = 0; + + // Read trailer of file header block. + error = readBlockTrailer(f, fileName, debugMode, swap, + blockHeader[blockHeaderSize - 1]); + if(error == 1) return 1; // Error. + + if(strstr(*buf, "$&%#")) return -1; // End of block. + + return 0; // There is more. +} + +// Get sweep information from file header block. Returns: +// 0 ... performed normally +// 1 ... error occurred +// 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) + { + if(debugMode) + fprintf(debugFile, "HSpiceRead: failed to extract sweep name.\n"); + return 1; + } + *sweep = PyUnicode_InternFromString(sweepName); + if(*sweep == NULL) + { + if(debugMode) + fprintf(debugFile, "HSpiceRead: failed to create sweep name string.\n"); + return 1; + } + + // Get number of sweep points. + if(strncmp(&buf[postStartPosition2], postString21, numOfPostCharacters) != 0) + *sweepSize = atoi(&buf[sweepSizePosition1]); + else *sweepSize = atoi(&buf[sweepSizePosition2]); + + // Create array for sweep parameter values. + dims=*sweepSize; + *sweepValues = (PyArrayObject *)PyArray_SimpleNew(1, &dims, NPY_DOUBLE); + if(*sweepValues == NULL) + { + if(debugMode) fprintf(debugFile, "HSpiceRead: failed to create array.\n"); + 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 ... this was the last block +// 0 ... there is at least one more block left +// 1 ... error occured during reading the block +// Arguments: +// f ... pointer to file for reading +// debugMode ... debug messages flag +// fileName ... name of the file +// rawData ... pointer to data array, +// enlarged (reallocated) for current block +// rawDataOffset ... pointer to data array size, increased for current block size +int readDataBlock(FILE *f, int debugMode, const char *fileName, float **rawData, + int *rawDataOffset) +{ + int error, blockHeader[blockHeaderSize], swap; + float *tmpRawData; + + if (debugMode>=2) { + fprintf(debugFile, "Reading data block @0x%lx\n", ftell(f)); + } + + // Get size of raw data block. + swap = readBlockHeader(f, fileName, debugMode, blockHeader, sizeof(float)); + if(swap == -2) return -1; // Normal EOF / End of block. + if(swap < 0) return 1; // Error. + + // Allocate space for raw data. + tmpRawData = reallocate(debugMode, *rawData, + (*rawDataOffset + blockHeader[0]) * sizeof(float)); + if(tmpRawData == NULL) return 1; // Error. + *rawData = tmpRawData; + + // Read raw data block. + error = readBlockData(f, fileName, debugMode, *rawData + *rawDataOffset, + rawDataOffset, sizeof(float), blockHeader[0], swap); + if(error == 1) return 1; // Error. + + // Read trailer of file header block. + error = readBlockTrailer(f, fileName, debugMode, swap, + blockHeader[blockHeaderSize - 1]); + if(error == 1) return 1; // Error. + + if((*rawData)[*rawDataOffset - 1] > 9e29) return -1; // End of block. + + return 0; // There is more. +} + +// Read one table for one sweep value. Returns: +// 0 ... performed normally +// 1 ... error occurred +// Arguments: +// f ... pointer to file for reading +// debugMode ... debug messages flag +// fileName ... name of the file +// sweep ... sweep parameter name +// numOfVariables ... number of variables in table +// type ... type of variables with exeption of scale +// numOfVectors ... number of variables and probes in table +// 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(FILE *f, int debugMode, const char *fileName, 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; + npy_intp dims; + float *rawData = NULL; + PyObject *data = NULL; + char *bytePtr = NULL; + int rowSize = 0; + int tableBytes = 0; + int dataBytes = 0; + + if (debugMode>=2) { + fprintf(debugFile, "Reading table for one sweep point.\n"); + } + + // Read raw data blocks. + do num = readDataBlock(f, debugMode, fileName, &rawData, &offset); + while(num == 0); + if(num > 0) goto readTableFailed; + + data = PyDict_New(); // Create an empty dictionary. + if(data == NULL) + { + if(debugMode) + fprintf(debugFile, "HSpiceRead: failed to create data dictionary.\n"); + goto readTableFailed; + } + + bytePtr = (char *)rawData; + tableBytes = offset * sizeof(float); + + if (isModernFormatGlobal) { + // Check for 1e30 double end marker + if (tableBytes >= 8) { + double lastVal; + memcpy(&lastVal, (char *)rawData + tableBytes - 8, 8); + if (swapGlobal) { + do_swap((char *)&lastVal, 1, 8); + } + if (lastVal > 9e29) { + tableBytes -= 8; + } + } + } else { + if (tableBytes >= 4) { + tableBytes -= 4; + } + } + + if (isModernFormatGlobal) { + for (j = 0; j < numOfVectors; j++) { + rowSize += varSizes[j]; + } + } else { + rowSize = numOfVectors * 4; + } + + dataBytes = tableBytes; + if (sweep != NULL) { + dataBytes -= (isModernFormatGlobal ? 8 : 4); + } + + num = dataBytes / rowSize; + + if (sweep != NULL) { + if (isModernFormatGlobal) { + double sweepVal; + memcpy(&sweepVal, bytePtr, 8); + if (swapGlobal) { + do_swap((char *)&sweepVal, 1, 8); + } + *((npy_double *)(faSweep->pos)) = sweepVal; + bytePtr += 8; + } else { + float sweepVal = *(float *)bytePtr; + *((npy_double *)(faSweep->pos)) = sweepVal; + bytePtr += 4; + } + faSweep->pos = faSweep->pos + faSweep->stride; + } + + // Increase number of columns if variables with exception of scale are complex. + if(type == complex_var) numOfColumns = numOfColumns + numOfVariables - 1; + + for(i = 0; i < numOfVectors; i++) + { + // Create array for i-th vector. + 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(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]); + } + + if (isModernFormatGlobal) { + for(i = 0; i < num; i++) + { + struct FastArray *faPos = faPtr; + for(j = 0; j < numOfVectors; j++) + { + int varSize = varSizes[j]; + if(type == complex_var && j > 0 && j < numOfVariables) + { + float re, im; + memcpy(&re, bytePtr, 4); + memcpy(&im, bytePtr + 4, 4); + if (swapGlobal) { + do_swap((char *)&re, 1, 4); + do_swap((char *)&im, 1, 4); + } + *(npy_cdouble *)(faPos->pos) = npy_cpack(re, im); + } + else if (varSize == 8) + { + double val; + memcpy(&val, bytePtr, 8); + if (swapGlobal) { + do_swap((char *)&val, 1, 8); + } + *((double *)(faPos->pos)) = val; + } + else + { + float val; + memcpy(&val, bytePtr, 4); + if (swapGlobal) { + do_swap((char *)&val, 1, 4); + } + *((double *)(faPos->pos)) = val; + } + bytePtr += varSize; + faPos->pos = faPos->pos + faPos->stride; + faPos = faPos + 1; + } + } + } else { + float *rawDataPos = (float *)bytePtr; + for(i = 0; i < num; i++) + { + struct FastArray *faPos = faPtr; + for(j = 0; j < numOfVectors; j++) + { + if(type == complex_var && j > 0 && j < numOfVariables) + { + *(npy_cdouble *)(faPos->pos) = npy_cpack(*rawDataPos, *(rawDataPos+1)); + rawDataPos = rawDataPos + 1; + } else *((double *)(faPos->pos)) = *rawDataPos; + rawDataPos = rawDataPos + 1; + faPos->pos = faPos->pos + faPos->stride; + faPos = faPos + 1; + } + } + } + + PyMem_Free(rawData); + rawData = NULL; + + // Insert vectors into dictionary. + 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(debugMode) if(i == -1) fprintf(debugFile, + "HSpiceRead: failed to insert vector %s into dictionary.\n", + scale); + else fprintf(debugFile, + "HSpiceRead: failed to insert vector %s into dictionary.\n", + name[i]); + goto readTableFailed; + } + + // Insert table into the list of data dictionaries. + num = PyList_Append(dataList, data); + if(num) + { + if(debugMode) fprintf(debugFile, + "HSpiceRead: failed to append table to the list of data dictionaries.\n"); + goto readTableFailed; + } + Py_XDECREF(data); + data = NULL; + + if (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. +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; + struct FastArray faSweep, *faPtr = NULL; + FILE *f = 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; + + // Get hspice_read() arguments. + if(!PyArg_ParseTuple(args, "si", &fileName, &debugMode)) { + Py_INCREF(Py_None); + return Py_None; + } + + if(debugMode) fprintf(debugFile, "HSpiceRead: reading file %s.\n", fileName); + + f = fopen(fileName, "rb"); // Open the file. + if(f == NULL) + { + if(debugMode) + fprintf(debugFile, "HSpiceRead: cannot open file %s.\n", fileName); + goto failed; + } + + num = getc(f); + ungetc(num, f); + if(num == EOF) // Test if there is data in the file. + { + if(debugMode) + fprintf(debugFile, "HSpiceRead: file %s is empty.\n", fileName); + goto failed; + } + if((num & 0x000000ff) >= ' ') // Test if the file is in ascii format. + { + if(debugMode) fprintf(debugFile, + "HSpiceRead: file %s is in ascii format.\n", + fileName); + goto failed; + } + + // Read file header blocks. + do num = readHeaderBlock(f, debugMode, fileName, &buf, &offset); + while(num == 0); + if(num > 0) goto failed; + + isModernFormatGlobal = 0; + // Check version of post format. + if(strncmp(&buf[postStartPosition1], postString11, numOfPostCharacters) != 0 && + strncmp(&buf[postStartPosition1], postString12, numOfPostCharacters) != 0 && + strncmp(&buf[postStartPosition2], postString21, numOfPostCharacters) != 0) + { + int sub_len = 4; + int idx; + int found2013 = 0; + for (idx = 0; idx <= offset - sub_len; idx++) { + if (memcmp(buf + idx, "2013", sub_len) == 0) { + isModernFormatGlobal = 1; + found2013 = 1; + break; + } + } + if (!found2013) { + if(debugMode) fprintf(debugFile, "HSpiceRead: unknown post format.\n"); + goto failed; + } + } + + buf[dateEndPosition] = 0; + date = PyUnicode_InternFromString(&buf[dateStartPosition]); // Get creation date. + if(date == NULL) + { + if(debugMode) + fprintf(debugFile, "HSpiceRead: failed to create date string.\n"); + goto failed; + } + + while(buf[i] == ' ') i--; + buf[i + 1] = 0; + title = PyUnicode_InternFromString(&buf[titleStartPosition]); // Get title. + if(title == NULL) + { + if(debugMode) + fprintf(debugFile, "HSpiceRead: failed to create title string.\n"); + goto failed; + } + + buf[numOfSweepsEndPosition] = 0; // Check number of sweep parameters. + num = atoi(&buf[numOfSweepsPosition]); + if(num < 0 || num > 1) + { + if(debugMode) fprintf(debugFile, + "HSpiceRead: only onedimensional sweep supported.\n"); + goto failed; + } + + buf[numOfSweepsPosition] = 0; // Get number of vectors (variables and probes). + numOfVectors = atoi(&buf[numOfProbesPosition]); + buf[numOfProbesPosition] = 0; + numOfVariables = atoi(&buf[numOfVariablesPosition]); // Scale included. + numOfVectors = numOfVectors + numOfVariables; + + // Allocate space for types and sizes + varTypes = (int *)PyMem_Malloc(numOfVectors * sizeof(int)); + if(varTypes == NULL) + { + if(debugMode) fprintf(debugFile, + "HSpiceRead: cannot allocate vector types.\n"); + goto failed; + } + varSizes = (int *)PyMem_Malloc(numOfVectors * sizeof(int)); + if(varSizes == NULL) + { + if(debugMode) fprintf(debugFile, + "HSpiceRead: cannot allocate vector sizes.\n"); + goto failed; + } + + // Get type of variables. Scale is always real. + token = strtok(&buf[vectorDescriptionStartPosition], " \t\n"); + if(token == NULL) + { + if(debugMode) fprintf(debugFile, + "HSpiceRead: failed to extract vector description.\n"); + goto failed; + } + if (numOfVectors > 1) { + varTypes[1] = atoi(token); + type = varTypes[1]; + } else { + varTypes[0] = atoi(token); + 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) + { + if(debugMode) fprintf(debugFile, + "HSpiceRead: failed to extract vector types/scale.\n"); + goto failed; + } + if (numOfVectors > 1) { + if (i < numOfVectors - 2) { + varTypes[i + 2] = atoi(token); + } else if (i == numOfVectors - 2) { + varTypes[0] = atoi(token); + } + } + } + + scale = PyUnicode_InternFromString(token); // Get independent variable name. + if(scale == NULL) + { + if(debugMode) fprintf(debugFile, + "HSpiceRead: failed to create independent variable name string.\n"); + goto failed; + } + + for (i = 0; i < numOfVectors; i++) { + if (isModernFormatGlobal) { + if (type == complex_var && i > 0 && i < numOfVariables) { + varSizes[i] = 8; + } else if (varTypes[i] == 8) { + varSizes[i] = 8; + } else if (varTypes[i] == 2) { + varSizes[i] = 8; + } else { + varSizes[i] = 4; + } + } else { + if (varTypes[i] == 2) { + 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) + { + if(debugMode) fprintf(debugFile, + "HSpiceRead: cannot allocate pointers to vector names.\n"); + goto failed; + } + + for(i = 0; i < numOfVectors - 1; i++) // Get vector names. + { + name[i] = strtok(NULL, " \t\n"); + if(name[i] == NULL) + { + if(debugMode) fprintf(debugFile, + "HSpiceRead: failed to extract vector names.\n"); + 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]=='(') { + 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) goto failed; + } + + dataList = PyList_New(0); // Create an empty list for data dictionaries. + if(dataList == NULL) + { + if(debugMode) + fprintf(debugFile, "HSpiceRead: failed to create data list.\n"); + goto failed; + } + + // Allocate space for pointers to arrays. + tmpArray = (PyArrayObject **)PyMem_Malloc(numOfVectors * sizeof(PyArrayObject *)); + if(tmpArray == NULL) + { + if(debugMode) + fprintf(debugFile, "HSpiceRead: cannot allocate pointers to arrays.\n"); + goto failed; + } + + // Allocate space for fast array pointers. + faPtr = + (struct FastArray *)PyMem_Malloc(numOfVectors * sizeof(struct FastArray)); + if(faPtr == NULL) + { + if(debugMode) fprintf(debugFile, "HSpiceRead: failed to create array.\n"); + goto failed; + } + + for(i = 0; i < sweepSize; i++) // Read i-th table. + { + num = readTable(f, debugMode, fileName, sweep, numOfVariables, type, + numOfVectors, varSizes, &faSweep, tmpArray, faPtr, token, name, + dataList); + if(num) goto failed; + } + fclose(f); + 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) + { + if(debugMode) fprintf(debugFile, + "HSpiceRead: failed to create tuple with sweeps.\n"); + goto failed; + } + Py_XDECREF(sweep); + Py_XDECREF(sweepValues); + Py_XDECREF(dataList); + + // Prepare return tuple. + tuple = PyTuple_Pack(6, sweeps, scale, Py_None, title, date, Py_None); + if(tuple == NULL) + { + if(debugMode) fprintf(debugFile, + "HSpiceRead: failed to create tuple with read data.\n"); + 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) + { + if(debugMode) + fprintf(debugFile, "HSpiceRead: failed to create return list.\n"); + goto failed; + } + + num = PyList_Append(list, tuple); // Insert tuple into return list. + if(num) + { + if(debugMode) fprintf(debugFile, + "HSpiceRead: failed to append tuple to return list.\n"); + goto failed; + } + Py_XDECREF(tuple); + + return list; + +failed: // Error occured. Close open file, relese memory and python references. + if (f) + fclose(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); + PyMem_Free(varTypes); + PyMem_Free(varSizes); + Py_XDECREF(sweeps); + Py_XDECREF(tuple); + Py_XDECREF(list); + + 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..82e3bf7a --- /dev/null +++ b/PySpice/Spice/HSpice/hspicefile/hspice_read.h @@ -0,0 +1,18 @@ +// (c) Janez Puhan, Arpad Buermen. Imported from PyOPUS project. +#include "Python.h" + +// Structure for fast vector access +struct FastArray +{ + char *data; + char *pos; + Py_ssize_t stride; + Py_ssize_t length; +}; + +// Python callable function +static PyObject *HSpiceRead(PyObject *self, PyObject *args); + +#ifdef LINUX +#define __declspec(a) extern +#endif diff --git a/PySpice/Spice/Simulator.py b/PySpice/Spice/Simulator.py index d04ddd5c..4d38bd92 100644 --- a/PySpice/Spice/Simulator.py +++ b/PySpice/Spice/Simulator.py @@ -23,7 +23,8 @@ # 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 .Xyce.Simulator import XyceSimulator +from .HSpice.Simulator import HSpiceSimulator # Register all simulator subclasses Simulator.register_simulator_class('ngspice-subprocess', NgSpiceSubprocessSimulator) @@ -32,3 +33,4 @@ Simulator.register_simulator_class('xyce', XyceSimulator) Simulator.register_simulator_class('xyce-serial', XyceSimulator) Simulator.register_simulator_class('xyce-parallel', XyceSimulator) +Simulator.register_simulator_class('hspice', HSpiceSimulator) diff --git a/setup_data.py b/setup_data.py index 48a1d322..53d10d29 100644 --- a/setup_data.py +++ b/setup_data.py @@ -23,6 +23,7 @@ #################################################################################################### import os +from PySpice.Spice.HSpice.hspicefile.build import ext_modules, cmdclass #################################################################################################### @@ -73,5 +74,7 @@ def read_readme(file_name): #################################################################################################### setup_dict = dict( - long_description=long_description, + long_description=long_description, + ext_modules=ext_modules, + cmdclass=cmdclass, ) From 3519e7be8fb515dfede7ab7ea8056c6b164c0740 Mon Sep 17 00:00:00 2001 From: Daniel Schmeer Date: Wed, 24 Jun 2026 10:18:36 +0100 Subject: [PATCH 03/21] Address comments related to simulator import and classes. Move simulator specific arguments to the relevant class --- PySpice/Spice/Simulator.py | 21 ++++++++++++++------- PySpice/Spice/SimulatorBase.py | 21 +++------------------ PySpice/Spice/Xyce/Simulator.py | 1 + 3 files changed, 18 insertions(+), 25 deletions(-) diff --git a/PySpice/Spice/Simulator.py b/PySpice/Spice/Simulator.py index 4d38bd92..9f493229 100644 --- a/PySpice/Spice/Simulator.py +++ b/PySpice/Spice/Simulator.py @@ -26,11 +26,18 @@ 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 -Simulator.register_simulator_class('ngspice-subprocess', NgSpiceSubprocessSimulator) -Simulator.register_simulator_class('ngspice', NgSpiceSharedSimulator) -Simulator.register_simulator_class('ngspice-shared', NgSpiceSharedSimulator) -Simulator.register_simulator_class('xyce', XyceSimulator) -Simulator.register_simulator_class('xyce-serial', XyceSimulator) -Simulator.register_simulator_class('xyce-parallel', XyceSimulator) -Simulator.register_simulator_class('hspice', HSpiceSimulator) +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 index 5c988bff..57644c2b 100644 --- a/PySpice/Spice/SimulatorBase.py +++ b/PySpice/Spice/SimulatorBase.py @@ -42,16 +42,6 @@ class Simulator: else: DEFAULT_SIMULATOR = 'ngspice-shared' - SIMULATORS = ( - 'ngspice', - 'ngspice-shared', - 'ngspice-subprocess', - 'xyce', - 'xyce-serial', - 'xyce-parallel', - 'hspice', - ) - SIMULATOR = None # for subclass _SIMULATOR_CLASSES = {} @@ -81,17 +71,12 @@ def factory(cls, *args, **kwargs): """ simulator = kwargs.pop('simulator', cls.DEFAULT_SIMULATOR) - if simulator not in cls.SIMULATORS: + if simulator not in cls._SIMULATOR_CLASSES: raise NameError(f"Unknown simulator {simulator}") - sub_cls = cls._SIMULATOR_CLASSES.get(simulator) - if sub_cls is None: - raise ValueError(f"Simulator class not registered for {simulator}") - - if simulator == 'xyce-parallel': - kwargs['parallel'] = True + sub_cls = cls._SIMULATOR_CLASSES[simulator] - obj = sub_cls(*args, **kwargs) + obj = sub_cls(*args, simulator=simulator, **kwargs) obj._AS_SIMULATOR = simulator return obj diff --git a/PySpice/Spice/Xyce/Simulator.py b/PySpice/Spice/Xyce/Simulator.py index 9fa02d21..06d86563 100644 --- a/PySpice/Spice/Xyce/Simulator.py +++ b/PySpice/Spice/Xyce/Simulator.py @@ -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' ############################################## From 373ce32d11d931991df086eb5472cad3ae0cd3ee Mon Sep 17 00:00:00 2001 From: Daniel Schmeer Date: Wed, 24 Jun 2026 10:19:04 +0100 Subject: [PATCH 04/21] Address AI review comments --- PySpice/Spice/HSpice/RawFile.py | 17 +++++++----- PySpice/Spice/HSpice/Server.py | 27 +++++++++++++++---- PySpice/Spice/HSpice/hspicefile/__init__.py | 9 +++++-- PySpice/Spice/HSpice/hspicefile/build.py | 9 +++++-- PySpice/Spice/HSpice/hspicefile/hspice_read.c | 12 ++++----- 5 files changed, 52 insertions(+), 22 deletions(-) diff --git a/PySpice/Spice/HSpice/RawFile.py b/PySpice/Spice/HSpice/RawFile.py index e38d7a35..ec28b9ec 100644 --- a/PySpice/Spice/HSpice/RawFile.py +++ b/PySpice/Spice/HSpice/RawFile.py @@ -36,24 +36,27 @@ def to_analysis(self): # data[0] is sweeps tuple (sweep, sweepValues, dataList) sweeps = self.data[0][0] scale_name_outer, sweep_values, data_list = sweeps - + + if len(data_list) != 1: + raise ValueError(f"Expected exactly 1 table in data_list, but got {len(data_list)}.") + # data_list is a list of dictionaries, each dict represents a table. # Typically there is only 1 table if no outer sweep. res_dict = data_list[0] - + # Let's find the scale/abscissa variable (typically TIME or FREQUENCY or HERTZ) scale_name = None for k in res_dict.keys(): if k.upper() in ('TIME', 'FREQUENCY', 'HERTZ'): scale_name = k break - + if scale_name is None: # Check if there is another scale variable (e.g. dc sweep) scale_name = list(res_dict.keys())[0] scale_values = res_dict[scale_name] - + # Determine analysis type if scale_name.upper() == 'TIME': analysis_type = 'transient' @@ -74,14 +77,14 @@ def to_analysis(self): 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 ) diff --git a/PySpice/Spice/HSpice/Server.py b/PySpice/Spice/HSpice/Server.py index ace6b519..89b38fc5 100644 --- a/PySpice/Spice/HSpice/Server.py +++ b/PySpice/Spice/HSpice/Server.py @@ -1,6 +1,5 @@ import fcntl import logging -import multiprocessing import os import re import shutil @@ -22,9 +21,9 @@ def parse_spice_value(val_str): 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'): - if val_str.endswith('meg'): - return float(val_str[:-3]) * 1e6 return float(val_str[:-1]) * 1e-3 elif val_str.endswith('k'): return float(val_str[:-1]) * 1e3 @@ -72,7 +71,21 @@ class HSpiceServer: def __init__(self, **kwargs): self._spice_command = kwargs.get('spice_command') or self.SPICE_COMMAND - self._concurrency_limit = int(os.environ.get('PYSPICE_HSPICE_CONCURRENCY_LIMIT') or kwargs.get('concurrency_limit') or 4) + 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') @@ -99,7 +112,11 @@ def __call__(self, spice_input): # 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)}") - res = subprocess.run(cmd, cwd=tmp_dir, capture_output=True, text=True) + 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) diff --git a/PySpice/Spice/HSpice/hspicefile/__init__.py b/PySpice/Spice/HSpice/hspicefile/__init__.py index f62e2081..ba2a8778 100644 --- a/PySpice/Spice/HSpice/hspicefile/__init__.py +++ b/PySpice/Spice/HSpice/hspicefile/__init__.py @@ -1,8 +1,13 @@ -# (c) Janez Puhan, Arpad Buermen. Imported from PyOPUS project. -from .. import _hspice_read +try: + from .. import _hspice_read +except ImportError: + _hspice_read = None def hspice_read(filename, debug=0): """ Wrapper for PyOPUS-based binary HSPICE raw result reader. """ + 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/build.py b/PySpice/Spice/HSpice/hspicefile/build.py index 467807aa..b58c6e27 100644 --- a/PySpice/Spice/HSpice/hspicefile/build.py +++ b/PySpice/Spice/HSpice/hspicefile/build.py @@ -1,13 +1,18 @@ +import sys +import numpy as np 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', @@ -15,7 +20,7 @@ def finalize_options(self): include_dirs=[ 'PySpice/Spice/HSpice/hspicefile' ], - define_macros=[('LINUX', None)], + define_macros=macros, ) ] diff --git a/PySpice/Spice/HSpice/hspicefile/hspice_read.c b/PySpice/Spice/HSpice/hspicefile/hspice_read.c index e17a4056..57d7f298 100644 --- a/PySpice/Spice/HSpice/hspicefile/hspice_read.c +++ b/PySpice/Spice/HSpice/hspicefile/hspice_read.c @@ -356,6 +356,7 @@ int readDataBlock(FILE *f, int debugMode, const char *fileName, float **rawData, swap = readBlockHeader(f, fileName, debugMode, blockHeader, sizeof(float)); if(swap == -2) return -1; // Normal EOF / End of block. if(swap < 0) return 1; // Error. + swapGlobal = swap; // Allocate space for raw data. tmpRawData = reallocate(debugMode, *rawData, @@ -365,7 +366,7 @@ int readDataBlock(FILE *f, int debugMode, const char *fileName, float **rawData, // Read raw data block. error = readBlockData(f, fileName, debugMode, *rawData + *rawDataOffset, - rawDataOffset, sizeof(float), blockHeader[0], swap); + rawDataOffset, sizeof(float), blockHeader[0], isModernFormatGlobal ? 0 : swap); if(error == 1) return 1; // Error. // Read trailer of file header block. @@ -633,11 +634,12 @@ static PyObject *HSpiceRead(PyObject *self, PyObject *args) // Get hspice_read() arguments. if(!PyArg_ParseTuple(args, "si", &fileName, &debugMode)) { - Py_INCREF(Py_None); - return Py_None; + return NULL; } - if(debugMode) fprintf(debugFile, "HSpiceRead: reading file %s.\n", fileName); + if(debugMode) { + fprintf(debugFile, "HSpiceRead: reading file %s.\n", fileName); + } f = fopen(fileName, "rb"); // Open the file. if(f == NULL) @@ -961,8 +963,6 @@ static PyObject *HSpiceRead(PyObject *self, PyObject *args) Py_XDECREF(dataList); PyMem_Free(tmpArray); PyMem_Free(faPtr); - PyMem_Free(varTypes); - PyMem_Free(varSizes); Py_XDECREF(sweeps); Py_XDECREF(tuple); Py_XDECREF(list); From 309c97ee02b84e27f49a7897a024079bdfa6f52b Mon Sep 17 00:00:00 2001 From: Daniel Schmeer Date: Wed, 24 Jun 2026 11:02:21 +0100 Subject: [PATCH 05/21] Minor fixes for latest comments --- PySpice/Spice/HSpice/hspicefile/build.py | 2 +- PySpice/Spice/HSpice/hspicefile/hspice_read.c | 34 +++++++++++++++---- 2 files changed, 28 insertions(+), 8 deletions(-) diff --git a/PySpice/Spice/HSpice/hspicefile/build.py b/PySpice/Spice/HSpice/hspicefile/build.py index b58c6e27..7b1e44d0 100644 --- a/PySpice/Spice/HSpice/hspicefile/build.py +++ b/PySpice/Spice/HSpice/hspicefile/build.py @@ -1,11 +1,11 @@ import sys -import numpy as np 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()) diff --git a/PySpice/Spice/HSpice/hspicefile/hspice_read.c b/PySpice/Spice/HSpice/hspicefile/hspice_read.c index 57d7f298..ac54c45f 100644 --- a/PySpice/Spice/HSpice/hspicefile/hspice_read.c +++ b/PySpice/Spice/HSpice/hspicefile/hspice_read.c @@ -584,11 +584,11 @@ int readTable(FILE *f, int debugMode, const char *fileName, PyObject *sweep, if(num) { if(debugMode) if(i == -1) fprintf(debugFile, - "HSpiceRead: failed to insert vector %s into dictionary.\n", - scale); + "HSpiceRead: failed to insert vector %s into dictionary.\n", + scale); else fprintf(debugFile, - "HSpiceRead: failed to insert vector %s into dictionary.\n", - name[i]); + "HSpiceRead: failed to insert vector %s into dictionary.\n", + name[i]); goto readTableFailed; } @@ -670,6 +670,12 @@ static PyObject *HSpiceRead(PyObject *self, PyObject *args) while(num == 0); if(num > 0) goto failed; + if (offset < dateEndPosition + 1) + { + if(debugMode) fprintf(debugFile, "HSpiceRead: file header is too short.\n"); + goto failed; + } + isModernFormatGlobal = 0; // Check version of post format. if(strncmp(&buf[postStartPosition1], postString11, numOfPostCharacters) != 0 && @@ -837,10 +843,21 @@ static PyObject *HSpiceRead(PyObject *self, PyObject *args) } } if (name[i][0]=='v' && name[i][1]=='(') { - for(j=2;name[i][j];j++) { - name[i][j-2]=name[i][j]; + 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; } - name[i][j-2]=0; } } @@ -967,6 +984,9 @@ static PyObject *HSpiceRead(PyObject *self, PyObject *args) Py_XDECREF(tuple); Py_XDECREF(list); + if (PyErr_Occurred()) { + return NULL; + } Py_INCREF(Py_None); return Py_None; } From 077b7cd7d6af180d31326394e2ecd227bb05ef10 Mon Sep 17 00:00:00 2001 From: Daniel Schmeer Date: Wed, 24 Jun 2026 11:03:01 +0100 Subject: [PATCH 06/21] Whitespace formatting --- PySpice/Spice/HSpice/hspicefile/hspice_read.c | 1752 +++++++++-------- 1 file changed, 921 insertions(+), 831 deletions(-) diff --git a/PySpice/Spice/HSpice/hspicefile/hspice_read.c b/PySpice/Spice/HSpice/hspicefile/hspice_read.c index ac54c45f..c50fa50d 100644 --- a/PySpice/Spice/HSpice/hspicefile/hspice_read.c +++ b/PySpice/Spice/HSpice/hspicefile/hspice_read.c @@ -7,30 +7,32 @@ // 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. +// 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 "hspice_read.h" // 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 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_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. */ + /* 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 */ @@ -38,65 +40,60 @@ static struct PyModuleDef 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); +PyMODINIT_FUNC PyInit__hspice_read() { + PyObject *m = PyModule_Create(&module); - /* For using NumPy */ - import_array(); + /* For using NumPy */ + import_array(); - return m; + return m; } - -#define debugFile stdout +#define debugFile stdout static int isModernFormatGlobal = 0; static int swapGlobal = 0; // 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 postString11 "9007" -#define postString12 "9601" -#define postString21 "2001" -#define postString22 "2013" -#define numOfPostCharacters 4 -#define dateStartPosition 88 -#define dateEndPosition 112 -#define titleStartPosition 24 -#define sweepSizePosition1 176 -#define sweepSizePosition2 187 -#define vectorDescriptionStartPosition 256 -#define frequency 2 -#define complex_var 1 -#define real_var 0 +#define blockHeaderSize 4 +#define numOfVariablesPosition 0 +#define numOfProbesPosition 4 +#define numOfSweepsPosition 8 +#define numOfSweepsEndPosition 12 +#define postStartPosition1 16 +#define postStartPosition2 20 +#define postString11 "9007" +#define postString12 "9601" +#define postString21 "2001" +#define postString22 "2013" +#define numOfPostCharacters 4 +#define dateStartPosition 88 +#define dateEndPosition 112 +#define titleStartPosition 24 +#define sweepSizePosition1 176 +#define sweepSizePosition2 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; - } +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: @@ -109,35 +106,41 @@ void do_swap(char *block, int size, int itemSize) // debugMode ... debug messages flag // blockHeader ... array of four integers consisting block header // size ... size of items in block -int readBlockHeader(FILE *f, const char *fileName, int debugMode, int *blockHeader, - int size) -{ - int swap, num = fread(blockHeader, sizeof(int), blockHeaderSize, f); - if(num != blockHeaderSize) - { - if (num == 0 && feof(f)) { - return -2; // Normal EOF - } - if(debugMode) fprintf(debugFile, - "HSpiceRead: failed to read block header from file %s.\n", - fileName); - return -1; // Error. - } - - // Block header check and swap. - if(blockHeader[0] == 0x00000004 && blockHeader[2] == 0x00000004) swap = 0; - else if(blockHeader[0] == 0x04000000 && blockHeader[2] == 0x04000000) swap = 1; - else - { - if(debugMode) fprintf(debugFile, "HSpiceRead: corrupted block header.\n"); - return -1; - } - if(swap == 1) do_swap((char *)blockHeader, blockHeaderSize, sizeof(int)); - blockHeader[0] = blockHeader[blockHeaderSize - 1] / size; - if (debugMode>=2) { - fprintf(debugFile, "Got block header, size=%d, bytes=%d, swap=%d\n", blockHeader[1], blockHeader[3], swap); - } - return swap; +int readBlockHeader(FILE *f, const char *fileName, int debugMode, + int *blockHeader, int size) { + int swap, num = fread(blockHeader, sizeof(int), blockHeaderSize, f); + if (num != blockHeaderSize) { + if (num == 0 && feof(f)) { + return -2; // Normal EOF + } + if (debugMode) { + fprintf(debugFile, + "HSpiceRead: failed to read block header from file %s.\n", + fileName); + } + return -1; // Error. + } + + // Block header check and swap. + if (blockHeader[0] == 0x00000004 && blockHeader[2] == 0x00000004) { + swap = 0; + } else if (blockHeader[0] == 0x04000000 && blockHeader[2] == 0x04000000) { + swap = 1; + } else { + if (debugMode) { + fprintf(debugFile, "HSpiceRead: corrupted block header.\n"); + } + return -1; + } + if (swap == 1) { + do_swap((char *)blockHeader, blockHeaderSize, sizeof(int)); + } + blockHeader[0] = blockHeader[blockHeaderSize - 1] / size; + if (debugMode >= 2) { + fprintf(debugFile, "Got block header, size=%d, bytes=%d, swap=%d\n", + blockHeader[1], blockHeader[3], swap); + } + return swap; } // Read block data. Returns: @@ -154,22 +157,25 @@ int readBlockHeader(FILE *f, const char *fileName, int debugMode, int *blockHead // numOfItems ... number of items in block // swap ... perform endian swap flag int readBlockData(FILE *f, const char *fileName, int debugMode, void *ptr, - int *offset, int itemSize, int numOfItems, int swap) -{ - int num = fread(ptr, itemSize, numOfItems, f); - if(num != numOfItems) - { - if(debugMode) fprintf(debugFile, - "HSpiceRead: failed to read block from file %s.\n", - fileName); - return 1; // Error. - } - *offset = *offset + numOfItems; - if(swap > 0) do_swap((char *)ptr, numOfItems, itemSize); // Endian swap. - if (debugMode>=2) { - fprintf(debugFile, "Got block data, item_size=%d, count=%d, bytes=%d\n", itemSize, numOfItems, itemSize*numOfItems); - } - return 0; + int *offset, int itemSize, int numOfItems, int swap) { + int num = fread(ptr, itemSize, numOfItems, f); + if (num != numOfItems) { + if (debugMode) { + fprintf(debugFile, + "HSpiceRead: failed to read block from file %s.\n", + fileName); + } + return 1; // Error. + } + *offset = *offset + numOfItems; + if (swap > 0) { + do_swap((char *)ptr, numOfItems, itemSize); // Endian swap. + } + if (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: @@ -182,31 +188,36 @@ int readBlockData(FILE *f, const char *fileName, int debugMode, void *ptr, // swap ... perform endian swap flag // header ... block size from header int readBlockTrailer(FILE *f, const char *fileName, int debugMode, int swap, - int header) -{ - int trailer, num; - num = fread(&trailer, sizeof(int), 1, f); - if(num != 1) - { - if(debugMode) fprintf(debugFile, - "HSpiceRead: failed to read block trailer from file %s.\n", - fileName); - return 1; // Error. - } - if(swap > 0) do_swap((char *)(&trailer), 1, sizeof(int)); // Endian swap. - - // Block header and trailer match check. - if(header != trailer) - { - if(debugMode) - fprintf(debugFile, "HSpiceRead: block header and trailer mismatch.\n"); - return 1; // Error. - } - if (debugMode>=2) { - fprintf(debugFile, "Got block trailer, itemsize=%ld, count=%d, bytes=%ld,\n", sizeof(int), 1, sizeof(int)*1); - } - - return 0; + int header) { + int trailer, num; + num = fread(&trailer, sizeof(int), 1, f); + if (num != 1) { + if (debugMode) { + fprintf(debugFile, + "HSpiceRead: failed to read block trailer from file %s.\n", + fileName); + } + return 1; // Error. + } + if (swap > 0) { + do_swap((char *)(&trailer), 1, sizeof(int)); // Endian swap. + } + + // Block header and trailer match check. + if (header != trailer) { + if (debugMode) { + fprintf(debugFile, + "HSpiceRead: block header and trailer mismatch.\n"); + } + return 1; // Error. + } + if (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: @@ -216,13 +227,13 @@ int readBlockTrailer(FILE *f, const char *fileName, int debugMode, int swap, // 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 && debugMode) - fprintf(debugFile, "HSpiceRead: cannot allocate.\n"); - return tmp; +void *reallocate(int debugMode, void *ptr, int size) { + // Allocate space for raw data. + void *tmp = PyMem_Realloc(ptr, size); + if (tmp == NULL && debugMode) { + fprintf(debugFile, "HSpiceRead: cannot allocate.\n"); + } + return tmp; } // Read one file header block. Returns: @@ -237,39 +248,48 @@ void *reallocate(int debugMode, void *ptr, int size) // enlarged (reallocated) for current block // bufOffset ... pointer to buffer size, increased for current block size int readHeaderBlock(FILE *f, int debugMode, const char *fileName, char **buf, - int *bufOffset) -{ - char *tmpBuf; - int error, blockHeader[blockHeaderSize], swap; - - if (debugMode>=2) { - fprintf(debugFile, "Reading header block @0x%lx\n", ftell(f)); - } - - // Get size of file header block. - swap = readBlockHeader(f, fileName, debugMode, blockHeader, sizeof(char)); - if(swap < 0) return 1; // Error. - - // Allocate space for buffer. - tmpBuf = reallocate(debugMode, *buf, - (*bufOffset + blockHeader[0] + 1) * sizeof(char)); - if(tmpBuf == NULL) return 1; // Error. - *buf = tmpBuf; - - // Read file header block. - error = readBlockData(f, fileName, debugMode, *buf + *bufOffset, bufOffset, - sizeof(char), blockHeader[0], 0); - if(error == 1) return 1; // Error. - (*buf)[*bufOffset] = 0; - - // Read trailer of file header block. - error = readBlockTrailer(f, fileName, debugMode, swap, - blockHeader[blockHeaderSize - 1]); - if(error == 1) return 1; // Error. - - if(strstr(*buf, "$&%#")) return -1; // End of block. - - return 0; // There is more. + int *bufOffset) { + char *tmpBuf; + int error, blockHeader[blockHeaderSize], swap; + + if (debugMode >= 2) { + fprintf(debugFile, "Reading header block @0x%lx\n", ftell(f)); + } + + // Get size of file header block. + swap = readBlockHeader(f, fileName, debugMode, blockHeader, sizeof(char)); + if (swap < 0) { + return 1; // Error. + } + + // Allocate space for buffer. + tmpBuf = reallocate(debugMode, *buf, + (*bufOffset + blockHeader[0] + 1) * sizeof(char)); + if (tmpBuf == NULL) { + return 1; // Error. + } + *buf = tmpBuf; + + // Read file header block. + error = readBlockData(f, fileName, debugMode, *buf + *bufOffset, bufOffset, + sizeof(char), blockHeader[0], 0); + if (error == 1) { + return 1; // Error. + } + (*buf)[*bufOffset] = 0; + + // Read trailer of file header block. + error = readBlockTrailer(f, fileName, debugMode, swap, + blockHeader[blockHeaderSize - 1]); + if (error == 1) { + return 1; // Error. + } + + if (strstr(*buf, "$&%#")) { + return -1; // End of block. + } + + return 0; // There is more. } // Get sweep information from file header block. Returns: @@ -283,52 +303,56 @@ int readHeaderBlock(FILE *f, int debugMode, const char *fileName, char **buf, // 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) - { - if(debugMode) - fprintf(debugFile, "HSpiceRead: failed to extract sweep name.\n"); - return 1; - } - *sweep = PyUnicode_InternFromString(sweepName); - if(*sweep == NULL) - { - if(debugMode) - fprintf(debugFile, "HSpiceRead: failed to create sweep name string.\n"); - return 1; - } - - // Get number of sweep points. - if(strncmp(&buf[postStartPosition2], postString21, numOfPostCharacters) != 0) - *sweepSize = atoi(&buf[sweepSizePosition1]); - else *sweepSize = atoi(&buf[sweepSizePosition2]); - - // Create array for sweep parameter values. - dims=*sweepSize; - *sweepValues = (PyArrayObject *)PyArray_SimpleNew(1, &dims, NPY_DOUBLE); - if(*sweepValues == NULL) - { - if(debugMode) fprintf(debugFile, "HSpiceRead: failed to create array.\n"); - 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; + 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) { + if (debugMode) { + fprintf(debugFile, "HSpiceRead: failed to extract sweep name.\n"); + } + return 1; + } + *sweep = PyUnicode_InternFromString(sweepName); + if (*sweep == NULL) { + if (debugMode) { + fprintf(debugFile, + "HSpiceRead: failed to create sweep name string.\n"); + } + return 1; + } + + // Get number of sweep points. + if (strncmp(&buf[postStartPosition2], postString21, numOfPostCharacters) != + 0) { + *sweepSize = atoi(&buf[sweepSizePosition1]); + } else { + *sweepSize = atoi(&buf[sweepSizePosition2]); + } + + // Create array for sweep parameter values. + dims = *sweepSize; + *sweepValues = (PyArrayObject *)PyArray_SimpleNew(1, &dims, NPY_DOUBLE); + if (*sweepValues == NULL) { + if (debugMode) { + fprintf(debugFile, "HSpiceRead: failed to create array.\n"); + } + 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: @@ -341,42 +365,55 @@ int getSweepInfo(int debugMode, PyObject **sweep, char *buf, int *sweepSize, // fileName ... name of the file // rawData ... pointer to data array, // enlarged (reallocated) for current block -// rawDataOffset ... pointer to data array size, increased for current block size +// rawDataOffset ... pointer to data array size, increased for current block +// size int readDataBlock(FILE *f, int debugMode, const char *fileName, float **rawData, - int *rawDataOffset) -{ - int error, blockHeader[blockHeaderSize], swap; - float *tmpRawData; - - if (debugMode>=2) { - fprintf(debugFile, "Reading data block @0x%lx\n", ftell(f)); - } - - // Get size of raw data block. - swap = readBlockHeader(f, fileName, debugMode, blockHeader, sizeof(float)); - if(swap == -2) return -1; // Normal EOF / End of block. - if(swap < 0) return 1; // Error. - swapGlobal = swap; - - // Allocate space for raw data. - tmpRawData = reallocate(debugMode, *rawData, - (*rawDataOffset + blockHeader[0]) * sizeof(float)); - if(tmpRawData == NULL) return 1; // Error. - *rawData = tmpRawData; - - // Read raw data block. - error = readBlockData(f, fileName, debugMode, *rawData + *rawDataOffset, - rawDataOffset, sizeof(float), blockHeader[0], isModernFormatGlobal ? 0 : swap); - if(error == 1) return 1; // Error. - - // Read trailer of file header block. - error = readBlockTrailer(f, fileName, debugMode, swap, - blockHeader[blockHeaderSize - 1]); - if(error == 1) return 1; // Error. - - if((*rawData)[*rawDataOffset - 1] > 9e29) return -1; // End of block. - - return 0; // There is more. + int *rawDataOffset) { + int error, blockHeader[blockHeaderSize], swap; + float *tmpRawData; + + if (debugMode >= 2) { + fprintf(debugFile, "Reading data block @0x%lx\n", ftell(f)); + } + + // Get size of raw data block. + swap = readBlockHeader(f, fileName, debugMode, blockHeader, sizeof(float)); + if (swap == -2) { + return -1; // Normal EOF / End of block. + } + if (swap < 0) { + return 1; // Error. + } + swapGlobal = swap; + + // Allocate space for raw data. + tmpRawData = reallocate(debugMode, *rawData, + (*rawDataOffset + blockHeader[0]) * sizeof(float)); + if (tmpRawData == NULL) { + return 1; // Error. + } + *rawData = tmpRawData; + + // Read raw data block. + error = readBlockData(f, fileName, debugMode, *rawData + *rawDataOffset, + rawDataOffset, sizeof(float), blockHeader[0], + isModernFormatGlobal ? 0 : swap); + if (error == 1) { + return 1; // Error. + } + + // Read trailer of file header block. + error = readBlockTrailer(f, fileName, debugMode, swap, + blockHeader[blockHeaderSize - 1]); + if (error == 1) { + return 1; // Error. + } + + if ((*rawData)[*rawDataOffset - 1] > 9e29) { + return -1; // End of block. + } + + return 0; // There is more. } // Read one table for one sweep value. Returns: @@ -397,596 +434,649 @@ int readDataBlock(FILE *f, int debugMode, const char *fileName, float **rawData, // name ... array of vector names // dataList ... list of data dictionaries int readTable(FILE *f, int debugMode, const char *fileName, 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; - npy_intp dims; - float *rawData = NULL; - PyObject *data = NULL; - char *bytePtr = NULL; - int rowSize = 0; - int tableBytes = 0; - int dataBytes = 0; - - if (debugMode>=2) { - fprintf(debugFile, "Reading table for one sweep point.\n"); - } - - // Read raw data blocks. - do num = readDataBlock(f, debugMode, fileName, &rawData, &offset); - while(num == 0); - if(num > 0) goto readTableFailed; - - data = PyDict_New(); // Create an empty dictionary. - if(data == NULL) - { - if(debugMode) - fprintf(debugFile, "HSpiceRead: failed to create data dictionary.\n"); - goto readTableFailed; - } - - bytePtr = (char *)rawData; - tableBytes = offset * sizeof(float); - - if (isModernFormatGlobal) { - // Check for 1e30 double end marker - if (tableBytes >= 8) { - double lastVal; - memcpy(&lastVal, (char *)rawData + tableBytes - 8, 8); - if (swapGlobal) { - do_swap((char *)&lastVal, 1, 8); - } - if (lastVal > 9e29) { - tableBytes -= 8; - } - } - } else { - if (tableBytes >= 4) { - tableBytes -= 4; - } - } - - if (isModernFormatGlobal) { - for (j = 0; j < numOfVectors; j++) { - rowSize += varSizes[j]; - } - } else { - rowSize = numOfVectors * 4; - } - - dataBytes = tableBytes; - if (sweep != NULL) { - dataBytes -= (isModernFormatGlobal ? 8 : 4); - } - - num = dataBytes / rowSize; - - if (sweep != NULL) { - if (isModernFormatGlobal) { - double sweepVal; - memcpy(&sweepVal, bytePtr, 8); - if (swapGlobal) { - do_swap((char *)&sweepVal, 1, 8); - } - *((npy_double *)(faSweep->pos)) = sweepVal; - bytePtr += 8; - } else { - float sweepVal = *(float *)bytePtr; - *((npy_double *)(faSweep->pos)) = sweepVal; - bytePtr += 4; - } - faSweep->pos = faSweep->pos + faSweep->stride; - } - - // Increase number of columns if variables with exception of scale are complex. - if(type == complex_var) numOfColumns = numOfColumns + numOfVariables - 1; - - for(i = 0; i < numOfVectors; i++) - { - // Create array for i-th vector. - 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(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]); - } - - if (isModernFormatGlobal) { - for(i = 0; i < num; i++) - { - struct FastArray *faPos = faPtr; - for(j = 0; j < numOfVectors; j++) - { - int varSize = varSizes[j]; - if(type == complex_var && j > 0 && j < numOfVariables) - { - float re, im; - memcpy(&re, bytePtr, 4); - memcpy(&im, bytePtr + 4, 4); - if (swapGlobal) { - do_swap((char *)&re, 1, 4); - do_swap((char *)&im, 1, 4); - } - *(npy_cdouble *)(faPos->pos) = npy_cpack(re, im); - } - else if (varSize == 8) - { - double val; - memcpy(&val, bytePtr, 8); - if (swapGlobal) { - do_swap((char *)&val, 1, 8); - } - *((double *)(faPos->pos)) = val; - } - else - { - float val; - memcpy(&val, bytePtr, 4); - if (swapGlobal) { - do_swap((char *)&val, 1, 4); - } - *((double *)(faPos->pos)) = val; - } - bytePtr += varSize; - faPos->pos = faPos->pos + faPos->stride; - faPos = faPos + 1; - } - } - } else { - float *rawDataPos = (float *)bytePtr; - for(i = 0; i < num; i++) - { - struct FastArray *faPos = faPtr; - for(j = 0; j < numOfVectors; j++) - { - if(type == complex_var && j > 0 && j < numOfVariables) - { - *(npy_cdouble *)(faPos->pos) = npy_cpack(*rawDataPos, *(rawDataPos+1)); - rawDataPos = rawDataPos + 1; - } else *((double *)(faPos->pos)) = *rawDataPos; - rawDataPos = rawDataPos + 1; - faPos->pos = faPos->pos + faPos->stride; - faPos = faPos + 1; - } - } - } - - PyMem_Free(rawData); - rawData = NULL; - - // Insert vectors into dictionary. - 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(debugMode) if(i == -1) fprintf(debugFile, - "HSpiceRead: failed to insert vector %s into dictionary.\n", - scale); - else fprintf(debugFile, - "HSpiceRead: failed to insert vector %s into dictionary.\n", - name[i]); - goto readTableFailed; - } - - // Insert table into the list of data dictionaries. - num = PyList_Append(dataList, data); - if(num) - { - if(debugMode) fprintf(debugFile, - "HSpiceRead: failed to append table to the list of data dictionaries.\n"); - goto readTableFailed; - } - Py_XDECREF(data); - data = NULL; - - if (debugMode>=2) { - fprintf(debugFile, "Finished reading one sweep point.\n"); - } - - return 0; + 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; + npy_intp dims; + float *rawData = NULL; + PyObject *data = NULL; + char *bytePtr = NULL; + int rowSize = 0; + int tableBytes = 0; + int dataBytes = 0; + + if (debugMode >= 2) { + fprintf(debugFile, "Reading table for one sweep point.\n"); + } + + // Read raw data blocks. + do { + num = readDataBlock(f, debugMode, fileName, &rawData, &offset); + } while (num == 0); + if (num > 0) { + goto readTableFailed; + } + + data = PyDict_New(); // Create an empty dictionary. + if (data == NULL) { + if (debugMode) { + fprintf(debugFile, + "HSpiceRead: failed to create data dictionary.\n"); + } + goto readTableFailed; + } + + bytePtr = (char *)rawData; + tableBytes = offset * sizeof(float); + + if (isModernFormatGlobal) { + // Check for 1e30 double end marker + if (tableBytes >= 8) { + double lastVal; + memcpy(&lastVal, (char *)rawData + tableBytes - 8, 8); + if (swapGlobal) { + do_swap((char *)&lastVal, 1, 8); + } + if (lastVal > 9e29) { + tableBytes -= 8; + } + } + } else { + if (tableBytes >= 4) { + tableBytes -= 4; + } + } + + if (isModernFormatGlobal) { + for (j = 0; j < numOfVectors; j++) { + rowSize += varSizes[j]; + } + } else { + rowSize = numOfVectors * 4; + } + + dataBytes = tableBytes; + if (sweep != NULL) { + dataBytes -= (isModernFormatGlobal ? 8 : 4); + } + + num = dataBytes / rowSize; + + if (sweep != NULL) { + if (isModernFormatGlobal) { + double sweepVal; + memcpy(&sweepVal, bytePtr, 8); + if (swapGlobal) { + do_swap((char *)&sweepVal, 1, 8); + } + *((npy_double *)(faSweep->pos)) = sweepVal; + bytePtr += 8; + } else { + float sweepVal = *(float *)bytePtr; + *((npy_double *)(faSweep->pos)) = sweepVal; + bytePtr += 4; + } + faSweep->pos = faSweep->pos + faSweep->stride; + } + + // Increase number of columns if variables with exception of scale are + // complex. + if (type == complex_var) { + numOfColumns = numOfColumns + numOfVariables - 1; + } + + for (i = 0; i < numOfVectors; i++) { + // Create array for i-th vector. + 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 (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]); + } + + if (isModernFormatGlobal) { + for (i = 0; i < num; i++) { + struct FastArray *faPos = faPtr; + for (j = 0; j < numOfVectors; j++) { + int varSize = varSizes[j]; + if (type == complex_var && j > 0 && j < numOfVariables) { + float re, im; + memcpy(&re, bytePtr, 4); + memcpy(&im, bytePtr + 4, 4); + if (swapGlobal) { + do_swap((char *)&re, 1, 4); + do_swap((char *)&im, 1, 4); + } + *(npy_cdouble *)(faPos->pos) = npy_cpack(re, im); + } else if (varSize == 8) { + double val; + memcpy(&val, bytePtr, 8); + if (swapGlobal) { + do_swap((char *)&val, 1, 8); + } + *((double *)(faPos->pos)) = val; + } else { + float val; + memcpy(&val, bytePtr, 4); + if (swapGlobal) { + do_swap((char *)&val, 1, 4); + } + *((double *)(faPos->pos)) = val; + } + bytePtr += varSize; + faPos->pos = faPos->pos + faPos->stride; + faPos = faPos + 1; + } + } + } else { + float *rawDataPos = (float *)bytePtr; + for (i = 0; i < num; i++) { + struct FastArray *faPos = faPtr; + for (j = 0; j < numOfVectors; j++) { + if (type == complex_var && j > 0 && j < numOfVariables) { + *(npy_cdouble *)(faPos->pos) = + npy_cpack(*rawDataPos, *(rawDataPos + 1)); + rawDataPos = rawDataPos + 1; + } else { + *((double *)(faPos->pos)) = *rawDataPos; + } + rawDataPos = rawDataPos + 1; + faPos->pos = faPos->pos + faPos->stride; + faPos = faPos + 1; + } + } + } + + PyMem_Free(rawData); + rawData = NULL; + + // Insert vectors into dictionary. + 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 (debugMode) { + if (i == -1) { + fprintf( + debugFile, + "HSpiceRead: failed to insert vector %s into dictionary.\n", + scale); + } else { + fprintf( + debugFile, + "HSpiceRead: failed to insert vector %s into dictionary.\n", + name[i]); + } + } + goto readTableFailed; + } + + // Insert table into the list of data dictionaries. + num = PyList_Append(dataList, data); + if (num) { + if (debugMode) { + fprintf(debugFile, "HSpiceRead: failed to append table to the list " + "of data dictionaries.\n"); + } + goto readTableFailed; + } + Py_XDECREF(data); + data = NULL; + + if (debugMode >= 2) { + fprintf(debugFile, "Finished reading one sweep point.\n"); + } + + return 0; readTableFailed: - PyMem_Free(rawData); - Py_XDECREF(data); - return 1; + PyMem_Free(rawData); + Py_XDECREF(data); + return 1; } // This is the first prototype version of HSpiceRead function for reading HSpice // output files. -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; - struct FastArray faSweep, *faPtr = NULL; - FILE *f = 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; - - // Get hspice_read() arguments. - if(!PyArg_ParseTuple(args, "si", &fileName, &debugMode)) { - return NULL; - } - - if(debugMode) { - fprintf(debugFile, "HSpiceRead: reading file %s.\n", fileName); - } - - f = fopen(fileName, "rb"); // Open the file. - if(f == NULL) - { - if(debugMode) - fprintf(debugFile, "HSpiceRead: cannot open file %s.\n", fileName); - goto failed; - } - - num = getc(f); - ungetc(num, f); - if(num == EOF) // Test if there is data in the file. - { - if(debugMode) - fprintf(debugFile, "HSpiceRead: file %s is empty.\n", fileName); - goto failed; - } - if((num & 0x000000ff) >= ' ') // Test if the file is in ascii format. - { - if(debugMode) fprintf(debugFile, - "HSpiceRead: file %s is in ascii format.\n", - fileName); - goto failed; - } - - // Read file header blocks. - do num = readHeaderBlock(f, debugMode, fileName, &buf, &offset); - while(num == 0); - if(num > 0) goto failed; - - if (offset < dateEndPosition + 1) - { - if(debugMode) fprintf(debugFile, "HSpiceRead: file header is too short.\n"); - goto failed; - } - - isModernFormatGlobal = 0; - // Check version of post format. - if(strncmp(&buf[postStartPosition1], postString11, numOfPostCharacters) != 0 && - strncmp(&buf[postStartPosition1], postString12, numOfPostCharacters) != 0 && - strncmp(&buf[postStartPosition2], postString21, numOfPostCharacters) != 0) - { - int sub_len = 4; - int idx; - int found2013 = 0; - for (idx = 0; idx <= offset - sub_len; idx++) { - if (memcmp(buf + idx, "2013", sub_len) == 0) { - isModernFormatGlobal = 1; - found2013 = 1; - break; - } - } - if (!found2013) { - if(debugMode) fprintf(debugFile, "HSpiceRead: unknown post format.\n"); - goto failed; - } - } - - buf[dateEndPosition] = 0; - date = PyUnicode_InternFromString(&buf[dateStartPosition]); // Get creation date. - if(date == NULL) - { - if(debugMode) - fprintf(debugFile, "HSpiceRead: failed to create date string.\n"); - goto failed; - } - - while(buf[i] == ' ') i--; - buf[i + 1] = 0; - title = PyUnicode_InternFromString(&buf[titleStartPosition]); // Get title. - if(title == NULL) - { - if(debugMode) - fprintf(debugFile, "HSpiceRead: failed to create title string.\n"); - goto failed; - } - - buf[numOfSweepsEndPosition] = 0; // Check number of sweep parameters. - num = atoi(&buf[numOfSweepsPosition]); - if(num < 0 || num > 1) - { - if(debugMode) fprintf(debugFile, - "HSpiceRead: only onedimensional sweep supported.\n"); - goto failed; - } - - buf[numOfSweepsPosition] = 0; // Get number of vectors (variables and probes). - numOfVectors = atoi(&buf[numOfProbesPosition]); - buf[numOfProbesPosition] = 0; - numOfVariables = atoi(&buf[numOfVariablesPosition]); // Scale included. - numOfVectors = numOfVectors + numOfVariables; - - // Allocate space for types and sizes - varTypes = (int *)PyMem_Malloc(numOfVectors * sizeof(int)); - if(varTypes == NULL) - { - if(debugMode) fprintf(debugFile, - "HSpiceRead: cannot allocate vector types.\n"); - goto failed; - } - varSizes = (int *)PyMem_Malloc(numOfVectors * sizeof(int)); - if(varSizes == NULL) - { - if(debugMode) fprintf(debugFile, - "HSpiceRead: cannot allocate vector sizes.\n"); - goto failed; - } - - // Get type of variables. Scale is always real. - token = strtok(&buf[vectorDescriptionStartPosition], " \t\n"); - if(token == NULL) - { - if(debugMode) fprintf(debugFile, - "HSpiceRead: failed to extract vector description.\n"); - goto failed; - } - if (numOfVectors > 1) { - varTypes[1] = atoi(token); - type = varTypes[1]; - } else { - varTypes[0] = atoi(token); - 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) - { - if(debugMode) fprintf(debugFile, - "HSpiceRead: failed to extract vector types/scale.\n"); - goto failed; - } - if (numOfVectors > 1) { - if (i < numOfVectors - 2) { - varTypes[i + 2] = atoi(token); - } else if (i == numOfVectors - 2) { - varTypes[0] = atoi(token); - } - } - } - - scale = PyUnicode_InternFromString(token); // Get independent variable name. - if(scale == NULL) - { - if(debugMode) fprintf(debugFile, - "HSpiceRead: failed to create independent variable name string.\n"); - goto failed; - } - - for (i = 0; i < numOfVectors; i++) { - if (isModernFormatGlobal) { - if (type == complex_var && i > 0 && i < numOfVariables) { - varSizes[i] = 8; - } else if (varTypes[i] == 8) { - varSizes[i] = 8; - } else if (varTypes[i] == 2) { - varSizes[i] = 8; - } else { - varSizes[i] = 4; - } - } else { - if (varTypes[i] == 2) { - 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) - { - if(debugMode) fprintf(debugFile, - "HSpiceRead: cannot allocate pointers to vector names.\n"); - goto failed; - } - - for(i = 0; i < numOfVectors - 1; i++) // Get vector names. - { - name[i] = strtok(NULL, " \t\n"); - if(name[i] == NULL) - { - if(debugMode) fprintf(debugFile, - "HSpiceRead: failed to extract vector names.\n"); - 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) goto failed; - } - - dataList = PyList_New(0); // Create an empty list for data dictionaries. - if(dataList == NULL) - { - if(debugMode) - fprintf(debugFile, "HSpiceRead: failed to create data list.\n"); - goto failed; - } - - // Allocate space for pointers to arrays. - tmpArray = (PyArrayObject **)PyMem_Malloc(numOfVectors * sizeof(PyArrayObject *)); - if(tmpArray == NULL) - { - if(debugMode) - fprintf(debugFile, "HSpiceRead: cannot allocate pointers to arrays.\n"); - goto failed; - } - - // Allocate space for fast array pointers. - faPtr = - (struct FastArray *)PyMem_Malloc(numOfVectors * sizeof(struct FastArray)); - if(faPtr == NULL) - { - if(debugMode) fprintf(debugFile, "HSpiceRead: failed to create array.\n"); - goto failed; - } - - for(i = 0; i < sweepSize; i++) // Read i-th table. - { - num = readTable(f, debugMode, fileName, sweep, numOfVariables, type, - numOfVectors, varSizes, &faSweep, tmpArray, faPtr, token, name, - dataList); - if(num) goto failed; - } - fclose(f); - 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) - { - if(debugMode) fprintf(debugFile, - "HSpiceRead: failed to create tuple with sweeps.\n"); - goto failed; - } - Py_XDECREF(sweep); - Py_XDECREF(sweepValues); - Py_XDECREF(dataList); - - // Prepare return tuple. - tuple = PyTuple_Pack(6, sweeps, scale, Py_None, title, date, Py_None); - if(tuple == NULL) - { - if(debugMode) fprintf(debugFile, - "HSpiceRead: failed to create tuple with read data.\n"); - 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) - { - if(debugMode) - fprintf(debugFile, "HSpiceRead: failed to create return list.\n"); - goto failed; - } - - num = PyList_Append(list, tuple); // Insert tuple into return list. - if(num) - { - if(debugMode) fprintf(debugFile, - "HSpiceRead: failed to append tuple to return list.\n"); - goto failed; - } - Py_XDECREF(tuple); - - return list; - -failed: // Error occured. Close open file, relese memory and python references. - if (f) - fclose(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; +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; + struct FastArray faSweep, *faPtr = NULL; + FILE *f = 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; + + // Get hspice_read() arguments. + if (!PyArg_ParseTuple(args, "si", &fileName, &debugMode)) { + return NULL; + } + + if (debugMode) { + fprintf(debugFile, "HSpiceRead: reading file %s.\n", fileName); + } + + f = fopen(fileName, "rb"); // Open the file. + if (f == NULL) { + if (debugMode) { + fprintf(debugFile, "HSpiceRead: cannot open file %s.\n", fileName); + } + goto failed; + } + + num = getc(f); + ungetc(num, f); + if (num == EOF) // Test if there is data in the file. + { + if (debugMode) { + fprintf(debugFile, "HSpiceRead: file %s is empty.\n", fileName); + } + goto failed; + } + if ((num & 0x000000ff) >= ' ') // Test if the file is in ascii format. + { + if (debugMode) { + fprintf(debugFile, "HSpiceRead: file %s is in ascii format.\n", + fileName); + } + goto failed; + } + + // Read file header blocks. + do { + num = readHeaderBlock(f, debugMode, fileName, &buf, &offset); + } while (num == 0); + if (num > 0) { + goto failed; + } + + if (offset < dateEndPosition + 1) { + if (debugMode) { + fprintf(debugFile, "HSpiceRead: file header is too short.\n"); + } + goto failed; + } + + isModernFormatGlobal = 0; + // Check version of post format. + if (strncmp(&buf[postStartPosition1], postString11, numOfPostCharacters) != + 0 && + strncmp(&buf[postStartPosition1], postString12, numOfPostCharacters) != + 0 && + strncmp(&buf[postStartPosition2], postString21, numOfPostCharacters) != + 0) { + int sub_len = 4; + int idx; + int found2013 = 0; + for (idx = 0; idx <= offset - sub_len; idx++) { + if (memcmp(buf + idx, "2013", sub_len) == 0) { + isModernFormatGlobal = 1; + found2013 = 1; + break; + } + } + if (!found2013) { + if (debugMode) { + fprintf(debugFile, "HSpiceRead: unknown post format.\n"); + } + goto failed; + } + } + + buf[dateEndPosition] = 0; + date = PyUnicode_InternFromString( + &buf[dateStartPosition]); // Get creation date. + if (date == NULL) { + if (debugMode) { + fprintf(debugFile, "HSpiceRead: failed to create date string.\n"); + } + goto failed; + } + + while (buf[i] == ' ') { + i--; + } + buf[i + 1] = 0; + title = PyUnicode_InternFromString(&buf[titleStartPosition]); // Get title. + if (title == NULL) { + if (debugMode) { + fprintf(debugFile, "HSpiceRead: failed to create title string.\n"); + } + goto failed; + } + + buf[numOfSweepsEndPosition] = 0; // Check number of sweep parameters. + num = atoi(&buf[numOfSweepsPosition]); + if (num < 0 || num > 1) { + if (debugMode) { + fprintf(debugFile, + "HSpiceRead: only onedimensional sweep supported.\n"); + } + goto failed; + } + + buf[numOfSweepsPosition] = + 0; // Get number of vectors (variables and probes). + numOfVectors = atoi(&buf[numOfProbesPosition]); + buf[numOfProbesPosition] = 0; + numOfVariables = atoi(&buf[numOfVariablesPosition]); // Scale included. + numOfVectors = numOfVectors + numOfVariables; + + // Allocate space for types and sizes + varTypes = (int *)PyMem_Malloc(numOfVectors * sizeof(int)); + if (varTypes == NULL) { + if (debugMode) { + fprintf(debugFile, "HSpiceRead: cannot allocate vector types.\n"); + } + goto failed; + } + varSizes = (int *)PyMem_Malloc(numOfVectors * sizeof(int)); + if (varSizes == NULL) { + if (debugMode) { + fprintf(debugFile, "HSpiceRead: cannot allocate vector sizes.\n"); + } + goto failed; + } + + // Get type of variables. Scale is always real. + token = strtok(&buf[vectorDescriptionStartPosition], " \t\n"); + if (token == NULL) { + if (debugMode) { + fprintf(debugFile, + "HSpiceRead: failed to extract vector description.\n"); + } + goto failed; + } + if (numOfVectors > 1) { + varTypes[1] = atoi(token); + type = varTypes[1]; + } else { + varTypes[0] = atoi(token); + 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) { + if (debugMode) { + fprintf(debugFile, + "HSpiceRead: failed to extract vector types/scale.\n"); + } + goto failed; + } + if (numOfVectors > 1) { + if (i < numOfVectors - 2) { + varTypes[i + 2] = atoi(token); + } else if (i == numOfVectors - 2) { + varTypes[0] = atoi(token); + } + } + } + + scale = PyUnicode_InternFromString(token); // Get independent variable name. + if (scale == NULL) { + if (debugMode) { + fprintf(debugFile, "HSpiceRead: failed to create independent " + "variable name string.\n"); + } + goto failed; + } + + for (i = 0; i < numOfVectors; i++) { + if (isModernFormatGlobal) { + if (type == complex_var && i > 0 && i < numOfVariables) { + varSizes[i] = 8; + } else if (varTypes[i] == 8) { + varSizes[i] = 8; + } else if (varTypes[i] == 2) { + varSizes[i] = 8; + } else { + varSizes[i] = 4; + } + } else { + if (varTypes[i] == 2) { + 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) { + if (debugMode) { + fprintf(debugFile, + "HSpiceRead: cannot allocate pointers to vector names.\n"); + } + goto failed; + } + + for (i = 0; i < numOfVectors - 1; i++) // Get vector names. + { + name[i] = strtok(NULL, " \t\n"); + if (name[i] == NULL) { + if (debugMode) { + fprintf(debugFile, + "HSpiceRead: failed to extract vector names.\n"); + } + 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) { + goto failed; + } + } + + dataList = PyList_New(0); // Create an empty list for data dictionaries. + if (dataList == NULL) { + if (debugMode) { + fprintf(debugFile, "HSpiceRead: failed to create data list.\n"); + } + goto failed; + } + + // Allocate space for pointers to arrays. + tmpArray = + (PyArrayObject **)PyMem_Malloc(numOfVectors * sizeof(PyArrayObject *)); + if (tmpArray == NULL) { + if (debugMode) { + fprintf(debugFile, + "HSpiceRead: cannot allocate pointers to arrays.\n"); + } + goto failed; + } + + // Allocate space for fast array pointers. + faPtr = (struct FastArray *)PyMem_Malloc(numOfVectors * + sizeof(struct FastArray)); + if (faPtr == NULL) { + if (debugMode) { + fprintf(debugFile, "HSpiceRead: failed to create array.\n"); + } + goto failed; + } + + for (i = 0; i < sweepSize; i++) // Read i-th table. + { + num = readTable(f, debugMode, fileName, sweep, numOfVariables, type, + numOfVectors, varSizes, &faSweep, tmpArray, faPtr, + token, name, dataList); + if (num) { + goto failed; + } + } + fclose(f); + 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) { + if (debugMode) { + fprintf(debugFile, + "HSpiceRead: failed to create tuple with sweeps.\n"); + } + goto failed; + } + Py_XDECREF(sweep); + Py_XDECREF(sweepValues); + Py_XDECREF(dataList); + + // Prepare return tuple. + tuple = PyTuple_Pack(6, sweeps, scale, Py_None, title, date, Py_None); + if (tuple == NULL) { + if (debugMode) { + fprintf(debugFile, + "HSpiceRead: failed to create tuple with read data.\n"); + } + 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) { + if (debugMode) { + fprintf(debugFile, "HSpiceRead: failed to create return list.\n"); + } + goto failed; + } + + num = PyList_Append(list, tuple); // Insert tuple into return list. + if (num) { + if (debugMode) { + fprintf(debugFile, + "HSpiceRead: failed to append tuple to return list.\n"); + } + goto failed; + } + Py_XDECREF(tuple); + + return list; + +failed: // Error occured. Close open file, relese memory and python references. + if (f) { + fclose(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; } From 9a2336f19a8c46afc91fe4fff816147ee89d89ee Mon Sep 17 00:00:00 2001 From: Daniel Schmeer Date: Wed, 24 Jun 2026 15:32:39 +0100 Subject: [PATCH 07/21] Add support for native simulation sweeps. May be useful to increase simulator performance later on when running larger libraries etc --- PySpice/Spice/HSpice/RawFile.py | 172 +++++++++++++++++--------------- PySpice/Spice/HSpice/Server.py | 12 ++- 2 files changed, 101 insertions(+), 83 deletions(-) diff --git a/PySpice/Spice/HSpice/RawFile.py b/PySpice/Spice/HSpice/RawFile.py index ec28b9ec..a84201da 100644 --- a/PySpice/Spice/HSpice/RawFile.py +++ b/PySpice/Spice/HSpice/RawFile.py @@ -37,89 +37,99 @@ def to_analysis(self): sweeps = self.data[0][0] scale_name_outer, sweep_values, data_list = sweeps - if len(data_list) != 1: - raise ValueError(f"Expected exactly 1 table in data_list, but got {len(data_list)}.") - - # data_list is a list of dictionaries, each dict represents a table. - # Typically there is only 1 table if no outer sweep. - res_dict = data_list[0] - - # Let's find the scale/abscissa variable (typically TIME or FREQUENCY or HERTZ) - scale_name = None - for k in res_dict.keys(): - if k.upper() in ('TIME', 'FREQUENCY', 'HERTZ'): - scale_name = k - break - - if scale_name is None: - # Check if there is another scale variable (e.g. dc sweep) - scale_name = list(res_dict.keys())[0] - - scale_values = res_dict[scale_name] - - # Determine analysis type - if scale_name.upper() == 'TIME': - analysis_type = 'transient' - scale_unit = u_s - elif scale_name.upper() in ('FREQUENCY', 'HERTZ'): - analysis_type = 'ac' - scale_unit = u_Hz - else: - analysis_type = 'dc' - scale_unit = None - - # Build WaveForms - abscissa_name = scale_name.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': + if len(data_list) == 0: 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, + time=WaveForm.from_array('time', np.array([])), + nodes=[], + branches=[], internal_parameters=[] ) + analysis._measurements = self.measurements + return analysis + + analyses = [] + for res_dict in data_list: + # Let's find the scale/abscissa variable (typically TIME or FREQUENCY or HERTZ) + scale_name = None + for k in res_dict.keys(): + if k.upper() in ('TIME', 'FREQUENCY', 'HERTZ'): + scale_name = k + break + + if scale_name is None: + # Check if there is another scale variable (e.g. dc sweep) + scale_name = list(res_dict.keys())[0] + + scale_values = res_dict[scale_name] + + # Determine analysis type + if scale_name.upper() == 'TIME': + analysis_type = 'transient' + scale_unit = u_s + elif scale_name.upper() in ('FREQUENCY', 'HERTZ'): + analysis_type = 'ac' + scale_unit = u_Hz + else: + analysis_type = 'dc' + scale_unit = None - analysis._measurements = self.measurements - return analysis + # Build WaveForms + abscissa_name = scale_name.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 = self.measurements + analyses.append(analysis) + + if len(analyses) == 1: + return analyses[0] + return analyses diff --git a/PySpice/Spice/HSpice/Server.py b/PySpice/Spice/HSpice/Server.py index 89b38fc5..b394bf3a 100644 --- a/PySpice/Spice/HSpice/Server.py +++ b/PySpice/Spice/HSpice/Server.py @@ -241,12 +241,20 @@ def __call__(self, spice_input): content_lines = [l.strip() for l in lines if l.strip() and not l.startswith('$') and not l.startswith('.')] if len(content_lines) >= 2: names = content_lines[0].split() + is_sweep = len(content_lines) > 2 for val_line in content_lines[1:]: vals = val_line.split() for name, val in zip(names, vals): - if name.lower() not in ('temper', 'alter#', 'temper#', 'alter'): + name_lower = name.lower() + if name_lower not in ('temper', 'alter#', 'temper#', 'alter'): try: - measurements[name.lower()] = float(val) + float_val = float(val) + if is_sweep: + if name_lower not in measurements: + measurements[name_lower] = [] + measurements[name_lower].append(float_val) + else: + measurements[name_lower] = float_val except ValueError: pass except Exception as e: From 88375d29b0176e5e34596fdcac2ef7762a0fa6b5 Mon Sep 17 00:00:00 2001 From: Daniel Schmeer Date: Wed, 24 Jun 2026 16:28:51 +0100 Subject: [PATCH 08/21] Run black to enforce formatting in added/updated files, add full copyright notices to hspice_read files --- PySpice/Spice/HSpice/RawFile.py | 77 ++- PySpice/Spice/HSpice/Server.py | 501 ++++++++++-------- PySpice/Spice/HSpice/Simulator.py | 33 +- PySpice/Spice/HSpice/hspicefile/__init__.py | 20 +- PySpice/Spice/HSpice/hspicefile/build.py | 35 +- PySpice/Spice/HSpice/hspicefile/hspice_read.c | 19 +- PySpice/Spice/HSpice/hspicefile/hspice_read.h | 18 +- PySpice/Spice/Simulator.py | 18 +- PySpice/Spice/SimulatorBase.py | 18 +- 9 files changed, 426 insertions(+), 313 deletions(-) diff --git a/PySpice/Spice/HSpice/RawFile.py b/PySpice/Spice/HSpice/RawFile.py index a84201da..d1d33bf2 100644 --- a/PySpice/Spice/HSpice/RawFile.py +++ b/PySpice/Spice/HSpice/RawFile.py @@ -1,12 +1,21 @@ import logging import numpy as np from PySpice.Unit import u_V, u_A, u_s, u_Hz -from PySpice.Probe.WaveForm import TransientAnalysis, DcAnalysis, AcAnalysis, WaveForm, OperatingPoint +from PySpice.Probe.WaveForm import ( + TransientAnalysis, + DcAnalysis, + AcAnalysis, + WaveForm, + OperatingPoint, +) _module_logger = logging.getLogger(__name__) + class HSpiceRawFile: - def __init__(self, data, simulation=None, measurements=None, op_nodes=None, op_branches=None): + def __init__( + self, data, simulation=None, measurements=None, op_nodes=None, op_branches=None + ): self.data = data self._simulation = simulation self.measurements = measurements or {} @@ -24,12 +33,16 @@ def simulation(self, value): def to_analysis(self): if self.data is None: # 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()] + 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 + simulation=self.simulation, nodes=nodes, branches=branches ) # data is a list of sweeps returned by hspice_read @@ -40,10 +53,10 @@ def to_analysis(self): if len(data_list) == 0: analysis = TransientAnalysis( simulation=self.simulation, - time=WaveForm.from_array('time', np.array([])), + time=WaveForm.from_array("time", np.array([])), nodes=[], branches=[], - internal_parameters=[] + internal_parameters=[], ) analysis._measurements = self.measurements return analysis @@ -53,7 +66,7 @@ def to_analysis(self): # Let's find the scale/abscissa variable (typically TIME or FREQUENCY or HERTZ) scale_name = None for k in res_dict.keys(): - if k.upper() in ('TIME', 'FREQUENCY', 'HERTZ'): + if k.upper() in ("TIME", "FREQUENCY", "HERTZ"): scale_name = k break @@ -64,23 +77,25 @@ def to_analysis(self): scale_values = res_dict[scale_name] # Determine analysis type - if scale_name.upper() == 'TIME': - analysis_type = 'transient' + if scale_name.upper() == "TIME": + analysis_type = "transient" scale_unit = u_s - elif scale_name.upper() in ('FREQUENCY', 'HERTZ'): - analysis_type = 'ac' + elif scale_name.upper() in ("FREQUENCY", "HERTZ"): + analysis_type = "ac" scale_unit = u_Hz else: - analysis_type = 'dc' + analysis_type = "dc" scale_unit = None # Build WaveForms abscissa_name = scale_name.lower() - if analysis_type == 'ac': - abscissa_name = 'frequency' + if analysis_type == "ac": + abscissa_name = "frequency" if scale_unit: - abscissa_waveform = WaveForm.from_unit_values(abscissa_name, scale_unit(scale_values)) + abscissa_waveform = WaveForm.from_unit_values( + abscissa_name, scale_unit(scale_values) + ) else: abscissa_waveform = WaveForm.from_array(abscissa_name, scale_values) @@ -92,31 +107,39 @@ def to_analysis(self): continue # Determine if it's a current or voltage - if k.lower().startswith('i('): + if k.lower().startswith("i("): # Simplify name: strip i( and trailing ) simplified_name = k[2:] - if simplified_name.endswith(')'): + if simplified_name.endswith(")"): simplified_name = simplified_name[:-1] - branches.append(WaveForm.from_unit_values(simplified_name, u_A(v), abscissa=abscissa_waveform)) + 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)) + nodes.append( + WaveForm.from_unit_values( + simplified_name, u_V(v), abscissa=abscissa_waveform + ) + ) - if analysis_type == 'transient': + if analysis_type == "transient": analysis = TransientAnalysis( simulation=self.simulation, time=abscissa_waveform, nodes=nodes, branches=branches, - internal_parameters=[] + internal_parameters=[], ) - elif analysis_type == 'ac': + elif analysis_type == "ac": analysis = AcAnalysis( simulation=self.simulation, frequency=abscissa_waveform, nodes=nodes, branches=branches, - internal_parameters=[] + internal_parameters=[], ) else: analysis = DcAnalysis( @@ -124,7 +147,7 @@ def to_analysis(self): sweep=abscissa_waveform, nodes=nodes, branches=branches, - internal_parameters=[] + internal_parameters=[], ) analysis._measurements = self.measurements diff --git a/PySpice/Spice/HSpice/Server.py b/PySpice/Spice/HSpice/Server.py index b394bf3a..c66c11b5 100644 --- a/PySpice/Spice/HSpice/Server.py +++ b/PySpice/Spice/HSpice/Server.py @@ -11,256 +11,311 @@ _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 + 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): - """ - 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 = "/tmp/pyspice_hspice_locks" - os.makedirs(lock_dir, exist_ok=True) + """ + 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 = "/tmp/pyspice_hspice_locks" + os.makedirs(lock_dir, 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, 0o666) + lock_files.append((fd, i)) - # 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, 0o666) - lock_files.append((fd, i)) + 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 + time.sleep(0.05) - 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 - time.sleep(0.05) class HSpiceServer: - SPICE_COMMAND = 'hspice' + 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") + 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) + 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") + def __call__(self, spice_input): + logger = _module_logger.getChild("HSpiceServer") + logger.info("Running HSPICE simulation") - # Create temporary directory - tmp_dir = tempfile.mkdtemp(dir=os.path.dirname(tempfile.mktemp())) - try: - input_file = os.path.join(tmp_dir, "input.sp") - output_base = os.path.join(tmp_dir, "output") + # Create temporary directory + tmp_dir = tempfile.mkdtemp(dir=os.path.dirname(tempfile.mktemp())) + 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" + 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) + 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) + # Acquire the lock to limit the concurrency + lock_fd, slot_idx = acquire_hspice_lock(self._concurrency_limit) - 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) + 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}") + 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}" - ) + 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 - # 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"): - raw_file_path = os.path.join(tmp_dir, f) - break - if raw_file_path: - break + # Find generated binary raw output files + raw_file_path = 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") + ): + raw_file_path = os.path.join(tmp_dir, f) + 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(".pa0")): - raw_file_path = os.path.join(tmp_dir, f) - 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(".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...") + 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() - try: - node_val = parse_spice_value(parts[1].strip()) - nodes[node_name] = node_val - except Exception: - pass + # 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() + 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() + # 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) + 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 + 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) - else: - raise NameError(f"No HSPICE raw output files (.tr0, .ac0, .sw0) were found in {tmp_dir}") + return HSpiceRawFile( + data=None, op_nodes=nodes, op_branches=branches + ) + 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") + 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(): - 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('.')] - if len(content_lines) >= 2: - names = content_lines[0].split() - is_sweep = len(content_lines) > 2 - for val_line in content_lines[1:]: - vals = val_line.split() - for name, val in zip(names, vals): - name_lower = name.lower() - if name_lower not in ('temper', 'alter#', 'temper#', 'alter'): + # 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(): + meas_file_path = os.path.join(tmp_dir, f) + logger.info(f"Parsing measurement file: {meas_file_path}") try: - float_val = float(val) - if is_sweep: - if name_lower not in measurements: - measurements[name_lower] = [] - measurements[name_lower].append(float_val) - else: - measurements[name_lower] = float_val - except ValueError: - pass - except Exception as e: - logger.warning(f"Failed to parse measurement file {meas_file_path}: {e}") + 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(".") + ] + if len(content_lines) >= 2: + names = content_lines[0].split() + is_sweep = len(content_lines) > 2 + for val_line in content_lines[1:]: + vals = val_line.split() + for name, val in zip(names, vals): + name_lower = name.lower() + if name_lower not in ( + "temper", + "alter#", + "temper#", + "alter", + ): + try: + float_val = float(val) + if is_sweep: + if name_lower not in measurements: + measurements[name_lower] = [] + measurements[name_lower].append( + float_val + ) + else: + measurements[name_lower] = float_val + except ValueError: + pass + except Exception as e: + logger.warning( + f"Failed to parse measurement file {meas_file_path}: {e}" + ) - return HSpiceRawFile(data, measurements=measurements) + return HSpiceRawFile(data, measurements=measurements) - finally: - shutil.rmtree(tmp_dir) + finally: + shutil.rmtree(tmp_dir) diff --git a/PySpice/Spice/HSpice/Simulator.py b/PySpice/Spice/HSpice/Simulator.py index 75a6b052..ee03c5c9 100644 --- a/PySpice/Spice/HSpice/Simulator.py +++ b/PySpice/Spice/HSpice/Simulator.py @@ -4,23 +4,26 @@ _module_logger = logging.getLogger(__name__) + class HSpiceSimulator(Simulator): - _logger = _module_logger.getChild('HSpiceSimulator') - SIMULATOR = 'hspice' + _logger = _module_logger.getChild("HSpiceSimulator") + SIMULATOR = "hspice" - def __init__(self, **kwargs): - server_kwargs = {x:kwargs[x] for x in ('spice_command', 'concurrency_limit') if x in kwargs} - self._spice_server = HSpiceServer(**server_kwargs) + def __init__(self, **kwargs): + server_kwargs = { + x: kwargs[x] for x in ("spice_command", "concurrency_limit") if x in kwargs + } + self._spice_server = HSpiceServer(**server_kwargs) - @property - def version(self): - return '' + @property + def version(self): + return "" - def customise(self, simulation): - # Add post option to simulation options if not present - simulation.options(post=1) + def customise(self, simulation): + # Add post option to simulation options if not present + 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() + 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/hspicefile/__init__.py b/PySpice/Spice/HSpice/hspicefile/__init__.py index ba2a8778..5e4d1f7e 100644 --- a/PySpice/Spice/HSpice/hspicefile/__init__.py +++ b/PySpice/Spice/HSpice/hspicefile/__init__.py @@ -1,13 +1,15 @@ try: - from .. import _hspice_read + from .. import _hspice_read except ImportError: - _hspice_read = None + _hspice_read = None -def hspice_read(filename, debug=0): - """ - Wrapper for PyOPUS-based binary HSPICE raw result reader. - """ - if _hspice_read is None: - raise ImportError("HSpice read extension _hspice_read is not compiled/available.") - return _hspice_read.hspice_read(filename, debug) +def hspice_read(filename, debug=0): + """ + Wrapper for PyOPUS-based binary HSPICE raw result reader. + """ + 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/build.py b/PySpice/Spice/HSpice/hspicefile/build.py index 7b1e44d0..c530e5d3 100644 --- a/PySpice/Spice/HSpice/hspicefile/build.py +++ b/PySpice/Spice/HSpice/hspicefile/build.py @@ -2,28 +2,27 @@ 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()) + 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)) +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, - ) + 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 -} +cmdclass = {"build_ext": LazyBuildExt} diff --git a/PySpice/Spice/HSpice/hspicefile/hspice_read.c b/PySpice/Spice/HSpice/hspicefile/hspice_read.c index c50fa50d..866cab50 100644 --- a/PySpice/Spice/HSpice/hspicefile/hspice_read.c +++ b/PySpice/Spice/HSpice/hspicefile/hspice_read.c @@ -1,4 +1,21 @@ -// (c) Janez Puhan, Arpad Buermen. Imported from PyOPUS project. +/* + * 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 . + */ + // Date: 18.5.2009 // HSpice binary file import module // Modifications: diff --git a/PySpice/Spice/HSpice/hspicefile/hspice_read.h b/PySpice/Spice/HSpice/hspicefile/hspice_read.h index 82e3bf7a..fc5e93be 100644 --- a/PySpice/Spice/HSpice/hspicefile/hspice_read.h +++ b/PySpice/Spice/HSpice/hspicefile/hspice_read.h @@ -1,4 +1,20 @@ -// (c) Janez Puhan, Arpad Buermen. Imported from PyOPUS project. +/* + * 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 . + */ #include "Python.h" // Structure for fast vector access diff --git a/PySpice/Spice/Simulator.py b/PySpice/Spice/Simulator.py index 9f493229..da80b9ea 100644 --- a/PySpice/Spice/Simulator.py +++ b/PySpice/Spice/Simulator.py @@ -18,7 +18,7 @@ # #################################################################################################### -__all__ = ['Simulator'] +__all__ = ["Simulator"] # Import base class and all subclass implementations at module load time to register them from .SimulatorBase import Simulator @@ -27,17 +27,17 @@ 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, + "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) + Simulator.register_simulator_class(name, cls) SIMULATORS = tuple(SIMULATOR_MAP.keys()) diff --git a/PySpice/Spice/SimulatorBase.py b/PySpice/Spice/SimulatorBase.py index 57644c2b..c84c5eb1 100644 --- a/PySpice/Spice/SimulatorBase.py +++ b/PySpice/Spice/SimulatorBase.py @@ -18,7 +18,7 @@ # #################################################################################################### -__all__ = ['Simulator'] +__all__ = ["Simulator"] import logging @@ -27,22 +27,20 @@ _module_logger = logging.getLogger(__name__) -class Simulator: - - """Base class to implement a simulator. - """ +class Simulator: + """Base class to implement a simulator.""" - _logger = _module_logger.getChild('Simulator') + _logger = _module_logger.getChild("Simulator") #: Define the default simulator DEFAULT_SIMULATOR = None if ConfigInstall.OS.on_windows: - DEFAULT_SIMULATOR = 'ngspice-shared' + DEFAULT_SIMULATOR = "ngspice-shared" else: - DEFAULT_SIMULATOR = 'ngspice-shared' + DEFAULT_SIMULATOR = "ngspice-shared" - SIMULATOR = None # for subclass + SIMULATOR = None # for subclass _SIMULATOR_CLASSES = {} @classmethod @@ -69,7 +67,7 @@ def factory(cls, *args, **kwargs): Return a :obj:`PySpice.Spice.Simulator` subclass. """ - simulator = kwargs.pop('simulator', cls.DEFAULT_SIMULATOR) + simulator = kwargs.pop("simulator", cls.DEFAULT_SIMULATOR) if simulator not in cls._SIMULATOR_CLASSES: raise NameError(f"Unknown simulator {simulator}") From 4ef1382c4673db96235607678c31a04945773729 Mon Sep 17 00:00:00 2001 From: Daniel Schmeer Date: Thu, 25 Jun 2026 17:20:04 +0100 Subject: [PATCH 09/21] Update simulator class to facilitate other simulators --- PySpice/Spice/NgSpice/Simulator.py | 2 +- PySpice/Spice/Simulator.py | 165 ++++------------------------- PySpice/Spice/SimulatorBase.py | 107 +++++++++++++++++++ PySpice/Spice/Xyce/Simulator.py | 3 +- 4 files changed, 131 insertions(+), 146 deletions(-) create mode 100644 PySpice/Spice/SimulatorBase.py 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..b9ff3285 100644 --- a/PySpice/Spice/Simulator.py +++ b/PySpice/Spice/Simulator.py @@ -18,147 +18,24 @@ # #################################################################################################### -__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 + +SIMULATOR_MAP = { + "ngspice-subprocess": NgSpiceSubprocessSimulator, + "ngspice": NgSpiceSharedSimulator, + "ngspice-shared": NgSpiceSharedSimulator, + "xyce": XyceSimulator, + "xyce-serial": XyceSimulator, + "xyce-parallel": XyceSimulator, +} + +# 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..b6041050 --- /dev/null +++ b/PySpice/Spice/SimulatorBase.py @@ -0,0 +1,107 @@ +################################################################################################### +# +# 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` + + 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' ############################################## From 0de11b25f94e877219acf433957565558bd8558d Mon Sep 17 00:00:00 2001 From: Daniel Schmeer Date: Mon, 29 Jun 2026 12:02:50 +0100 Subject: [PATCH 10/21] Python implementation and hspice format verification scripts --- PySpice/Spice/HSpice/RawFile.py | 239 ++++ PySpice/Spice/HSpice/Server.py | 339 +++++ PySpice/Spice/HSpice/Simulator.py | 29 + PySpice/Spice/HSpice/__init__.py | 1 + PySpice/Spice/HSpice/hspicefile/__init__.py | 37 + .../hspicefile/docs/block_structure.svg | 38 + .../HSpice/hspicefile/docs/file_structure.svg | 55 + .../hspicefile/docs/hspice_file_structure.md | 162 +++ .../HSpice/hspicefile/docs/record_layout.svg | 46 + .../HSpice/hspicefile/docs/tests/.gitignore | 6 + .../HSpice/hspicefile/docs/tests/Makefile | 21 + .../hspicefile/docs/tests/run_simulations.py | 460 +++++++ .../hspicefile/docs/tests/test_hspice_read.py | 605 +++++++++ .../hspicefile/docs/tests/verify_all_types.py | 1135 +++++++++++++++++ .../hspicefile/docs/tests/verify_spec.py | 640 ++++++++++ PySpice/Spice/Simulator.py | 16 +- PySpice/Spice/SimulatorBase.py | 1 + 17 files changed, 3823 insertions(+), 7 deletions(-) create mode 100644 PySpice/Spice/HSpice/RawFile.py create mode 100644 PySpice/Spice/HSpice/Server.py create mode 100644 PySpice/Spice/HSpice/Simulator.py create mode 100644 PySpice/Spice/HSpice/__init__.py create mode 100644 PySpice/Spice/HSpice/hspicefile/__init__.py create mode 100644 PySpice/Spice/HSpice/hspicefile/docs/block_structure.svg create mode 100644 PySpice/Spice/HSpice/hspicefile/docs/file_structure.svg create mode 100644 PySpice/Spice/HSpice/hspicefile/docs/hspice_file_structure.md create mode 100644 PySpice/Spice/HSpice/hspicefile/docs/record_layout.svg create mode 100644 PySpice/Spice/HSpice/hspicefile/docs/tests/.gitignore create mode 100644 PySpice/Spice/HSpice/hspicefile/docs/tests/Makefile create mode 100644 PySpice/Spice/HSpice/hspicefile/docs/tests/run_simulations.py create mode 100644 PySpice/Spice/HSpice/hspicefile/docs/tests/test_hspice_read.py create mode 100644 PySpice/Spice/HSpice/hspicefile/docs/tests/verify_all_types.py create mode 100644 PySpice/Spice/HSpice/hspicefile/docs/tests/verify_spec.py diff --git a/PySpice/Spice/HSpice/RawFile.py b/PySpice/Spice/HSpice/RawFile.py new file mode 100644 index 00000000..e863564c --- /dev/null +++ b/PySpice/Spice/HSpice/RawFile.py @@ -0,0 +1,239 @@ +import logging +import numpy as np +from PySpice.Unit import u_V, u_A, u_s, u_Hz, u_Degree +from PySpice.Probe.WaveForm import ( + TransientAnalysis, + DcAnalysis, + AcAnalysis, + WaveForm, + OperatingPoint, +) + +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): + if name in self._measurements: + return self._measurements[name] + if name.lower() in self._measurements: + return self._measurements[name.lower()] + raise AttributeError(f"'AnalysisList' object has no attribute '{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 = 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 = self.measurements + analyses.append(analysis) + + if len(analyses) == 1: + return analyses[0] + return AnalysisList(analyses, self.measurements) diff --git a/PySpice/Spice/HSpice/Server.py b/PySpice/Spice/HSpice/Server.py new file mode 100644 index 00000000..79e1b020 --- /dev/null +++ b/PySpice/Spice/HSpice/Server.py @@ -0,0 +1,339 @@ +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): + """ + 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 = "/tmp/pyspice_hspice_locks" + os.makedirs(lock_dir, 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, 0o666) + lock_files.append((fd, i)) + + 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 + 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(dir=os.path.dirname(tempfile.mktemp())) + 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) + + 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(".") + ] + if len(content_lines) >= 2: + names = content_lines[0].split() + is_sweep = len(content_lines) > 2 + for val_line in content_lines[1:]: + vals = val_line.split() + if len(vals) != len(names): + logger.warning( + f"Measurement line width mismatch: expected {len(names)} values, got {len(vals)}" + ) + continue + for name, val in zip(names, vals): + name_lower = name.lower() + if name_lower not in ( + "temper", + "alter#", + "temper#", + "alter", + ): + try: + float_val = float(val) + if is_sweep: + if name_lower not in measurements: + measurements[name_lower] = [] + measurements[name_lower].append( + float_val + ) + else: + measurements[name_lower] = float_val + except ValueError: + pass + 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..131344eb --- /dev/null +++ b/PySpice/Spice/HSpice/Simulator.py @@ -0,0 +1,29 @@ +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 + 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..53522b3c --- /dev/null +++ b/PySpice/Spice/HSpice/hspicefile/__init__.py @@ -0,0 +1,37 @@ +from typing import Union, List, Tuple +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..b7da2698 --- /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: + +``` + ... ... +``` + +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/.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..4d8d91b5 --- /dev/null +++ b/PySpice/Spice/HSpice/hspicefile/docs/tests/Makefile @@ -0,0 +1,21 @@ +# Default target (running 'make' with no arguments will trigger this) +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..0f66e073 --- /dev/null +++ b/PySpice/Spice/HSpice/hspicefile/docs/tests/run_simulations.py @@ -0,0 +1,460 @@ +import os +import sys +import subprocess +import shutil + +# 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..2eb54166 --- /dev/null +++ b/PySpice/Spice/HSpice/hspicefile/docs/tests/test_hspice_read.py @@ -0,0 +1,605 @@ +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 = scale_name.lower() + if sn not in t: + errs.append(f"Scale key '{sn}' not in dict (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 = scale_name.lower() + if sn not in t: + errs.append(f"Scale key '{sn}' 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 = scale_name.lower() + if sn not in t: + errs.append(f"Scale key '{sn}' 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 = scale_name.lower() + for i, t in enumerate(data_list): + if sn not in t: + errs.append(f"Table {i}: scale '{sn}' missing (keys={list(t.keys())})") + continue + 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 = scale_name.lower() + for i, t in enumerate(data_list): + if sn not in t: + 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 = scale_name.lower() + if sn not in t: + errs.append(f"Scale key '{sn}' 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 = scale_name.lower() + for i, t in enumerate(data_list): + if sn not in t: + 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}") + 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 = scale_name.lower() + for k, v in t.items(): + if k == sn: + 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 = scale_name.lower() + if sn not in t: + 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 = scale_name.lower() + for i, t in enumerate(data_list): + if sn not in t: + errs.append(f"Table {i}: scale missing") + elif 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 = scale_name.lower() + for i, t in enumerate(data_list): + if sn not in t: + errs.append(f"Table {i}: scale '{sn}' 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)) + + 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..9c8a4a00 --- /dev/null +++ b/PySpice/Spice/HSpice/hspicefile/docs/tests/verify_all_types.py @@ -0,0 +1,1135 @@ +import os +import sys +import struct +import glob +import traceback +import re + +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: + re, im = struct.unpack(fmt + "dd", chunk) + else: + re, im = struct.unpack(fmt + "ff", chunk) + table[name].append(complex(re, im)) + 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, + )) + + # ------------------------------------------------------------------ + # 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 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, + )) + + # ------------------------------------------------------------------ + # 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, + )) + + # ------------------------------------------------------------------ + # 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..d86cabff --- /dev/null +++ b/PySpice/Spice/HSpice/hspicefile/docs/tests/verify_spec.py @@ -0,0 +1,640 @@ +import os +import glob +import struct +import sys +import re +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/Simulator.py b/PySpice/Spice/Simulator.py index b9ff3285..5798cc51 100644 --- a/PySpice/Spice/Simulator.py +++ b/PySpice/Spice/Simulator.py @@ -24,18 +24,20 @@ 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, + "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) + Simulator.register_simulator_class(name, cls) SIMULATORS = tuple(SIMULATOR_MAP.keys()) diff --git a/PySpice/Spice/SimulatorBase.py b/PySpice/Spice/SimulatorBase.py index b6041050..c84c5eb1 100644 --- a/PySpice/Spice/SimulatorBase.py +++ b/PySpice/Spice/SimulatorBase.py @@ -62,6 +62,7 @@ def factory(cls, *args, **kwargs): * :code:`xyce` **alias for serial** * :code:`xyce-serial` * :code:`xyce-parallel` + * :code:`hspice` Return a :obj:`PySpice.Spice.Simulator` subclass. From 5775d32ab06d9e397465acb2a21e31f4609cec35 Mon Sep 17 00:00:00 2001 From: Daniel Schmeer Date: Tue, 30 Jun 2026 12:32:32 +0100 Subject: [PATCH 11/21] Add HSPICE binary parser and expand test coverage --- .gitignore | 2 + PySpice/Spice/HSpice/hspicefile/build.py | 28 + .../hspicefile/docs/tests/test_hspice_read.py | 1394 ++++++++++------- .../hspicefile/docs/tests/verify_all_types.py | 109 ++ PySpice/Spice/HSpice/hspicefile/hspice_read.c | 1134 ++++++++++++++ PySpice/Spice/HSpice/hspicefile/hspice_read.h | 53 + PySpice/Spice/HSpice/hspicefile/release_note | 16 + setup_data.py | 3 + 8 files changed, 2201 insertions(+), 538 deletions(-) create mode 100644 PySpice/Spice/HSpice/hspicefile/build.py create mode 100644 PySpice/Spice/HSpice/hspicefile/hspice_read.c create mode 100644 PySpice/Spice/HSpice/hspicefile/hspice_read.h create mode 100644 PySpice/Spice/HSpice/hspicefile/release_note 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/hspicefile/build.py b/PySpice/Spice/HSpice/hspicefile/build.py new file mode 100644 index 00000000..c530e5d3 --- /dev/null +++ b/PySpice/Spice/HSpice/hspicefile/build.py @@ -0,0 +1,28 @@ +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} diff --git a/PySpice/Spice/HSpice/hspicefile/docs/tests/test_hspice_read.py b/PySpice/Spice/HSpice/hspicefile/docs/tests/test_hspice_read.py index 2eb54166..3cd1cf5c 100644 --- a/PySpice/Spice/HSpice/hspicefile/docs/tests/test_hspice_read.py +++ b/PySpice/Spice/HSpice/hspicefile/docs/tests/test_hspice_read.py @@ -34,572 +34,890 @@ # Logs to logs/test_hspice_read.log. # --------------------------------------------------------------------------- -sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../../../.."))) +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 + """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 + """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 + """ + 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 = scale_name.lower() - if sn not in t: - errs.append(f"Scale key '{sn}' not in dict (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 = scale_name.lower() - if sn not in t: - errs.append(f"Scale key '{sn}' 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 = scale_name.lower() - if sn not in t: - errs.append(f"Scale key '{sn}' 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 = scale_name.lower() - for i, t in enumerate(data_list): - if sn not in t: - errs.append(f"Table {i}: scale '{sn}' missing (keys={list(t.keys())})") - continue - 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 = scale_name.lower() - for i, t in enumerate(data_list): - if sn not in t: - 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 = scale_name.lower() - if sn not in t: - errs.append(f"Scale key '{sn}' 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 = scale_name.lower() - for i, t in enumerate(data_list): - if sn not in t: - 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}") - 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 = scale_name.lower() - for k, v in t.items(): - if k == sn: - 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 = scale_name.lower() - if sn not in t: - 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 = scale_name.lower() - for i, t in enumerate(data_list): - if sn not in t: - errs.append(f"Table {i}: scale missing") - elif 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 = scale_name.lower() - for i, t in enumerate(data_list): - if sn not in t: - errs.append(f"Table {i}: scale '{sn}' 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)) - - return 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}") + 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 SWEEP TEMP: 5 temperature sweep points (-40 to 120 step 40) + # ------------------------------------------------------------------ + 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 + 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 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) + 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() - + 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 index 9c8a4a00..532f2e0c 100644 --- a/PySpice/Spice/HSpice/hspicefile/docs/tests/verify_all_types.py +++ b/PySpice/Spice/HSpice/hspicefile/docs/tests/verify_all_types.py @@ -579,6 +579,31 @@ def ac_sweep_param_assertions(tables, meta): 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 # ------------------------------------------------------------------ @@ -692,6 +717,30 @@ def tran_sweep_source_assertions(tables, meta): 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 # ------------------------------------------------------------------ @@ -763,6 +812,35 @@ def dc_probes_only_assertions(tables, meta): 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"]: + if find_var(t, p) is None: + 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) # ------------------------------------------------------------------ @@ -792,6 +870,37 @@ def ac_probes_only_assertions(tables, meta): 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 = find_var(t, p) + 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 # ------------------------------------------------------------------ diff --git a/PySpice/Spice/HSpice/hspicefile/hspice_read.c b/PySpice/Spice/HSpice/hspicefile/hspice_read.c new file mode 100644 index 00000000..aef9b32c --- /dev/null +++ b/PySpice/Spice/HSpice/hspicefile/hspice_read.c @@ -0,0 +1,1134 @@ +/* + * 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 + +// 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. +} + +// 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. + *sweepSize = atoi(&buf[sweepSizePosition]); + + // 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)) { + Py_INCREF(Py_None); + return Py_None; + } + + 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. + num = atoi(&buf[numOfSweepsPosition]); + 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). + parsedProbes = atoi(&buf[numOfProbesPosition]); + buf[numOfProbesPosition] = 0; + numOfVariables = atoi(&buf[numOfVariablesPosition]); // Scale included. + 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) { + varTypes[1] = atoi(token); + type = varTypes[1]; + } else { + varTypes[0] = atoi(token); + 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) { + varTypes[i + 2] = atoi(token); + } else if (i == numOfVectors - 2) { + varTypes[0] = atoi(token); + } + } + } + + 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..598ad9ee --- /dev/null +++ b/PySpice/Spice/HSpice/hspicefile/hspice_read.h @@ -0,0 +1,53 @@ +/* + * 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 . + */ +#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 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/setup_data.py b/setup_data.py index 48a1d322..4e4c5e36 100644 --- a/setup_data.py +++ b/setup_data.py @@ -23,6 +23,7 @@ #################################################################################################### import os +from PySpice.Spice.HSpice.hspicefile.build import ext_modules, cmdclass #################################################################################################### @@ -74,4 +75,6 @@ def read_readme(file_name): setup_dict = dict( long_description=long_description, + ext_modules=ext_modules, + cmdclass=cmdclass, ) From 49a4ebf925f84d3922f2ebe0ecc6465ec0d56c59 Mon Sep 17 00:00:00 2001 From: Daniel Schmeer Date: Tue, 7 Jul 2026 07:49:20 +0100 Subject: [PATCH 12/21] Fixes after AI review and python formatting --- PySpice/Spice/HSpice/RawFile.py | 79 +- PySpice/Spice/HSpice/Server.py | 32 +- PySpice/Spice/HSpice/Simulator.py | 5 +- PySpice/Spice/HSpice/hspicefile/__init__.py | 58 +- PySpice/Spice/HSpice/hspicefile/build.py | 1 + .../hspicefile/docs/hspice_file_structure.md | 2 +- .../hspicefile/docs/tests/.clang-format | 104 + .../HSpice/hspicefile/docs/tests/Makefile | 1 + .../hspicefile/docs/tests/run_simulations.py | 508 ++-- .../hspicefile/docs/tests/test_hspice_read.py | 43 +- .../hspicefile/docs/tests/verify_all_types.py | 2456 +++++++++-------- .../hspicefile/docs/tests/verify_spec.py | 1202 ++++---- 12 files changed, 2395 insertions(+), 2096 deletions(-) create mode 100644 PySpice/Spice/HSpice/hspicefile/docs/tests/.clang-format diff --git a/PySpice/Spice/HSpice/RawFile.py b/PySpice/Spice/HSpice/RawFile.py index e863564c..6c7119da 100644 --- a/PySpice/Spice/HSpice/RawFile.py +++ b/PySpice/Spice/HSpice/RawFile.py @@ -1,40 +1,38 @@ import logging + import numpy as np -from PySpice.Unit import u_V, u_A, u_s, u_Hz, u_Degree -from PySpice.Probe.WaveForm import ( - TransientAnalysis, - DcAnalysis, - AcAnalysis, - WaveForm, - OperatingPoint, -) + +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): - if name in self._measurements: - return self._measurements[name] - if name.lower() in self._measurements: - return self._measurements[name.lower()] - raise AttributeError(f"'AnalysisList' object has no attribute '{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) + def __init__(self, analyses, measurements=None): + super().__init__(analyses) + self._measurements = measurements or {} + + @property + def measurements(self): + return self._measurements + + def __getattr__(self, name): + if name in self._measurements: + return self._measurements[name] + if name.lower() in self._measurements: + return self._measurements[name.lower()] + raise AttributeError(f"'AnalysisList' object has no attribute '{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__) @@ -42,7 +40,13 @@ def __getitem__(self, item): class HSpiceRawFile: def __init__( - self, data, simulation=None, measurements=None, op_nodes=None, op_branches=None, analysis_type=None + self, + data, + simulation=None, + measurements=None, + op_nodes=None, + op_branches=None, + analysis_type=None, ): self.data = data self._simulation = simulation @@ -82,7 +86,11 @@ def to_analysis(self): if len(data_list) == 0: analysis_type = self._analysis_type - if not analysis_type and self.simulation and hasattr(self.simulation, "_analyses"): + 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" @@ -128,7 +136,6 @@ def to_analysis(self): analysis._measurements = 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": diff --git a/PySpice/Spice/HSpice/Server.py b/PySpice/Spice/HSpice/Server.py index 79e1b020..1b42c49a 100644 --- a/PySpice/Spice/HSpice/Server.py +++ b/PySpice/Spice/HSpice/Server.py @@ -6,6 +6,7 @@ import subprocess import tempfile import time + from .hspicefile import hspice_read from .RawFile import HSpiceRawFile @@ -46,14 +47,16 @@ def acquire_hspice_lock(limit): Returns (lock_file_descriptor, slot_index). Blocks until a slot becomes available. """ - lock_dir = "/tmp/pyspice_hspice_locks" - os.makedirs(lock_dir, exist_ok=True) + 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, 0o666) + fd = os.open(path, os.O_RDWR | os.O_CREAT, 0o600) lock_files.append((fd, i)) while True: @@ -95,7 +98,7 @@ def __call__(self, spice_input): logger.info("Running HSPICE simulation") # Create temporary directory - tmp_dir = tempfile.mkdtemp(dir=os.path.dirname(tempfile.mktemp())) + 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") @@ -219,7 +222,9 @@ def __call__(self, spice_input): parts = line[1:].split("=") if len(parts) == 2: node_name = parts[0].strip().lower() - if node_name.startswith("v(") and node_name.endswith(")"): + if node_name.startswith( + "v(" + ) and node_name.endswith(")"): node_name = node_name[2:-1] try: node_val = parse_spice_value(parts[1].strip()) @@ -265,7 +270,10 @@ def __call__(self, spice_input): pass return HSpiceRawFile( - data=None, op_nodes=nodes, op_branches=branches, analysis_type="o" + data=None, + op_nodes=nodes, + op_branches=branches, + analysis_type="o", ) else: raise NameError( @@ -304,10 +312,10 @@ def __call__(self, spice_input): for val_line in content_lines[1:]: vals = val_line.split() if len(vals) != len(names): - logger.warning( - f"Measurement line width mismatch: expected {len(names)} values, got {len(vals)}" - ) - continue + logger.warning( + f"Measurement line width mismatch: expected {len(names)} values, got {len(vals)}" + ) + continue for name, val in zip(names, vals): name_lower = name.lower() if name_lower not in ( @@ -333,7 +341,9 @@ def __call__(self, spice_input): f"Failed to parse measurement file {meas_file_path}: {e}" ) - return HSpiceRawFile(data, measurements=measurements, analysis_type=analysis_type) + 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 index 131344eb..c34bf114 100644 --- a/PySpice/Spice/HSpice/Simulator.py +++ b/PySpice/Spice/HSpice/Simulator.py @@ -1,4 +1,5 @@ import logging + from ..SimulatorBase import Simulator from .Server import HSpiceServer @@ -11,7 +12,9 @@ class HSpiceSimulator(Simulator): def __init__(self, **kwargs): server_kwargs = { - x: kwargs[x] for x in ("spice_command", "concurrency_limit", "timeout") if x in kwargs + x: kwargs[x] + for x in ("spice_command", "concurrency_limit", "timeout") + if x in kwargs } self._spice_server = HSpiceServer(**server_kwargs) diff --git a/PySpice/Spice/HSpice/hspicefile/__init__.py b/PySpice/Spice/HSpice/hspicefile/__init__.py index 53522b3c..05b8f842 100644 --- a/PySpice/Spice/HSpice/hspicefile/__init__.py +++ b/PySpice/Spice/HSpice/hspicefile/__init__.py @@ -1,37 +1,37 @@ -from typing import Union, List, Tuple +from typing import List, Tuple, Union + try: - from .. import _hspice_read + from .. import _hspice_read except ImportError: - _hspice_read = None + _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. + """ + Read an HSPICE raw results file. - 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) + Parameters: + filename: Path to the HSPICE raw file to read. + debug: Debug level passed to the reader. - 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) + 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/build.py b/PySpice/Spice/HSpice/hspicefile/build.py index c530e5d3..5c506cf0 100644 --- a/PySpice/Spice/HSpice/hspicefile/build.py +++ b/PySpice/Spice/HSpice/hspicefile/build.py @@ -1,4 +1,5 @@ import sys + from setuptools import Extension from setuptools.command.build_ext import build_ext diff --git a/PySpice/Spice/HSpice/hspicefile/docs/hspice_file_structure.md b/PySpice/Spice/HSpice/hspicefile/docs/hspice_file_structure.md index b7da2698..b92ca352 100644 --- a/PySpice/Spice/HSpice/hspicefile/docs/hspice_file_structure.md +++ b/PySpice/Spice/HSpice/hspicefile/docs/hspice_file_structure.md @@ -83,7 +83,7 @@ Following the format descriptor, fixed character offsets define simulation prope ### 3.3 Variable Type and Name Token Layout Starting at payload offset `256`, the space-delimited token sequence is: -``` +```text ... ... ``` 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/Makefile b/PySpice/Spice/HSpice/hspicefile/docs/tests/Makefile index 4d8d91b5..49b9fd29 100644 --- a/PySpice/Spice/HSpice/hspicefile/docs/tests/Makefile +++ b/PySpice/Spice/HSpice/hspicefile/docs/tests/Makefile @@ -1,4 +1,5 @@ # 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 diff --git a/PySpice/Spice/HSpice/hspicefile/docs/tests/run_simulations.py b/PySpice/Spice/HSpice/hspicefile/docs/tests/run_simulations.py index 0f66e073..cc0846c0 100644 --- a/PySpice/Spice/HSpice/hspicefile/docs/tests/run_simulations.py +++ b/PySpice/Spice/HSpice/hspicefile/docs/tests/run_simulations.py @@ -1,7 +1,7 @@ import os -import sys -import subprocess import shutil +import subprocess +import sys # Verify that hspice executable is available HSPICE_BIN = shutil.which("hspice") @@ -10,56 +10,56 @@ # 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 + "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 +""", + }, + "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 +""", + }, + "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 +""", + }, + "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 +""", + }, + "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 @@ -67,12 +67,12 @@ .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 +""", + }, + "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 @@ -81,48 +81,48 @@ .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 +""", + }, + "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 +""", + }, + "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 +""", + }, + "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 +""", + }, + "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 @@ -130,12 +130,12 @@ 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 +""", + }, + "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 @@ -143,12 +143,12 @@ .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 +""", + }, + "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 @@ -157,36 +157,36 @@ .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 +""", + }, + "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 +""", + }, + "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 +""", + }, + "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) @@ -194,12 +194,12 @@ 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 +""", + }, + "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 @@ -207,12 +207,12 @@ 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 +""", + }, + "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 @@ -220,12 +220,12 @@ .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 +""", + }, + "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) @@ -234,48 +234,48 @@ .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 +""", + }, + "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 +""", + }, + "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 +""", + }, + "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 +""", + }, + "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 @@ -283,12 +283,12 @@ .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 +""", + }, + "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 @@ -296,12 +296,12 @@ .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 +""", + }, + "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 @@ -309,12 +309,12 @@ .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 +""", + }, + "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 @@ -323,12 +323,12 @@ .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 +""", + }, + "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 @@ -337,12 +337,12 @@ .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 +""", + }, + "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) @@ -351,110 +351,126 @@ .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 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) - # 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") - # 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() - 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}") - 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 - 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") - 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) - # 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) - with open(sp_filename, "w") as f: - f.write(netlist_content) + log_and_print(f"\nRunning case: {name_prefix}...") - 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), + ] - # 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, + ) - 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) - # 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 - # 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 - 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() - 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 failed_count > 0: - sys.exit(1) - else: - sys.exit(0) if __name__ == "__main__": - 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 index 3cd1cf5c..65d2a3b8 100644 --- a/PySpice/Spice/HSpice/hspicefile/docs/tests/test_hspice_read.py +++ b/PySpice/Spice/HSpice/hspicefile/docs/tests/test_hspice_read.py @@ -447,6 +447,15 @@ def ac_sweep_temp(title, date, scale_name, sweep_name, sweep_vals, data_list): 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"]: @@ -653,32 +662,6 @@ def tran_monte(title, date, scale_name, sweep_name, sweep_vals, data_list): for version in ["9601", "2001", "2013"]: tests.append((f"tran_monte_{version}.tr0", f"tran_monte_{version}", tran_monte)) - # ------------------------------------------------------------------ - # AC SWEEP TEMP: 5 temperature sweep points (-40 to 120 step 40) - # ------------------------------------------------------------------ - 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 - 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 PROBE AND SWEEP: 3 parameter sweep points (rval 10..30 step 10) # ------------------------------------------------------------------ @@ -693,13 +676,17 @@ def ac_probe_and_sweep(title, date, scale_name, sweep_name, sweep_vals, data_lis 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])}") + 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}") + errs.append( + f"Table {i}: '{pn}' expected complex128, got {t[pn].dtype}" + ) return errs for version in ["9601", "2001", "2013"]: diff --git a/PySpice/Spice/HSpice/hspicefile/docs/tests/verify_all_types.py b/PySpice/Spice/HSpice/hspicefile/docs/tests/verify_all_types.py index 532f2e0c..3165ee1c 100644 --- a/PySpice/Spice/HSpice/hspicefile/docs/tests/verify_all_types.py +++ b/PySpice/Spice/HSpice/hspicefile/docs/tests/verify_all_types.py @@ -1,9 +1,9 @@ +import glob import os -import sys +import re import struct -import glob +import sys import traceback -import re WORK_DIR = "work" @@ -24,342 +24,343 @@ # 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 + """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, - } + """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 + 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 - + 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 + 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 + 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) + """ + 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() - 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: - re, im = struct.unpack(fmt + "dd", chunk) + 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: - re, im = struct.unpack(fmt + "ff", chunk) - table[name].append(complex(re, im)) - 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 + 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 + """ + 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 + """ + 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 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 + 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 # --------------------------------------------------------------------------- @@ -367,878 +368,999 @@ def parse_ascii(filename, meta): # 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 + """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"]: - if find_var(t, p) is None: - 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 = find_var(t, p) - 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 + """ + 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"]: + if find_var(t, p) is None: + 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 = find_var(t, p) + 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)}") + 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 - 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 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() + main() diff --git a/PySpice/Spice/HSpice/hspicefile/docs/tests/verify_spec.py b/PySpice/Spice/HSpice/hspicefile/docs/tests/verify_spec.py index d86cabff..9ff2ecae 100644 --- a/PySpice/Spice/HSpice/hspicefile/docs/tests/verify_spec.py +++ b/PySpice/Spice/HSpice/hspicefile/docs/tests/verify_spec.py @@ -1,640 +1,688 @@ -import os import glob +import os +import re import struct import sys -import re 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, - } + 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") + """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}") + 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}") + 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}") + 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 + 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 + """ + 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 + 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 + """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 + 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 + 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 + 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 -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 + 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 -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") + 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 - 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") + 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 - 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") + 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 - # 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}") + + 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 - 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}") + 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 - 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}") + 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 - 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})") + # Verify measurements + if not verify_meas_file(filename, log): return False - num_rows = data_len // row_size - log(f" Rows: {num_rows}") + log("VERIFICATION PASSED") + return True - 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}") +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 - # 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 +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 - log("VERIFICATION PASSED") - return True +def verify_file(filename, log): + log(f"\n{'='*70}") + log(f"VERIFYING: {filename}") + log(f"{'='*70}") -def main(): - logs_dir = "logs" - os.makedirs(logs_dir, exist_ok=True) + file_size = os.path.getsize(filename) + log(f"File size: {file_size} bytes") + with open(filename, "rb") as f: - log_path = os.path.join(logs_dir, "verify_spec.log") + # ------------------------------------------------------- + # 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 - with open(log_path, "w") as lf: - def log(msg): - print(msg) - lf.write(msg + "\n") - lf.flush() + 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 - log("="*70) - log("HSPICE Binary Specification Verification Report") - log("="*70) - log(f"Work directory: {os.path.abspath(WORK_DIR)}") + 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 - 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"))] + 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 - if not bin_files: - log("No binary files found!"); sys.exit(1) + 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 - passed = 0 - failed = 0 + # ------------------------------------------------------- + # 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 - 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 + log("VERIFICATION PASSED") + return True - 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) +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) - sys.exit(0 if failed == 0 else 1) if __name__ == "__main__": - main() + main() From c045f912879a86be24ac3122bbc6b3519ee2a3f4 Mon Sep 17 00:00:00 2001 From: Daniel Schmeer Date: Tue, 7 Jul 2026 08:20:37 +0100 Subject: [PATCH 13/21] AI solutions for AI review --- PySpice/Spice/HSpice/RawFile.py | 16 ++++++++++------ PySpice/Spice/HSpice/Simulator.py | 3 ++- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/PySpice/Spice/HSpice/RawFile.py b/PySpice/Spice/HSpice/RawFile.py index 6c7119da..399c3372 100644 --- a/PySpice/Spice/HSpice/RawFile.py +++ b/PySpice/Spice/HSpice/RawFile.py @@ -16,12 +16,16 @@ def __init__(self, analyses, measurements=None): def measurements(self): return self._measurements - def __getattr__(self, name): - if name in self._measurements: - return self._measurements[name] - if name.lower() in self._measurements: - return self._measurements[name.lower()] - raise AttributeError(f"'AnalysisList' object has no attribute '{name}'") + def __getattribute__(self, name): + try: + measurements = object.__getattribute__(self, "_measurements") + except AttributeError: + measurements = {} + if name in measurements: + return measurements[name] + if name.lower() in measurements: + return measurements[name.lower()] + return object.__getattribute__(self, name) def __getitem__(self, item): if isinstance(item, (int, slice)): diff --git a/PySpice/Spice/HSpice/Simulator.py b/PySpice/Spice/HSpice/Simulator.py index c34bf114..41b81b71 100644 --- a/PySpice/Spice/HSpice/Simulator.py +++ b/PySpice/Spice/HSpice/Simulator.py @@ -24,7 +24,8 @@ def version(self): def customise(self, simulation): # Add post option to simulation options if not present - simulation.options(post=1) + 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)) From 2a50710fbbad1e699fc236d9880a435b4b1a86b6 Mon Sep 17 00:00:00 2001 From: Daniel Schmeer Date: Tue, 7 Jul 2026 08:51:03 +0100 Subject: [PATCH 14/21] Tiny fixes for failing tests --- .../Spice/HSpice/hspicefile/docs/tests/verify_all_types.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/PySpice/Spice/HSpice/hspicefile/docs/tests/verify_all_types.py b/PySpice/Spice/HSpice/hspicefile/docs/tests/verify_all_types.py index 3165ee1c..9f27c778 100644 --- a/PySpice/Spice/HSpice/hspicefile/docs/tests/verify_all_types.py +++ b/PySpice/Spice/HSpice/hspicefile/docs/tests/verify_all_types.py @@ -870,7 +870,8 @@ def dc_probe_and_sweep_assertions(tables, meta): 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"]: - if find_var(t, p) is None: + 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 @@ -933,7 +934,7 @@ def ac_probe_and_sweep_assertions(tables, meta): f"Table {i}: expected 11 frequency points, got {len(t[scale])}" ) for p in ["node_a", "node_b"]: - pv = find_var(t, p) + 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): From 6b48d031d1be9e7080291b7944b86469b510a2f8 Mon Sep 17 00:00:00 2001 From: Daniel Schmeer Date: Tue, 7 Jul 2026 09:54:22 +0100 Subject: [PATCH 15/21] Address ai code review comments part 2 --- PySpice/Spice/HSpice/RawFile.py | 15 ++-- PySpice/Spice/HSpice/Server.py | 7 +- PySpice/Spice/HSpice/Simulator.py | 1 + PySpice/Spice/HSpice/hspicefile/build.py | 1 + .../hspicefile/docs/tests/test_hspice_read.py | 2 +- PySpice/Spice/HSpice/hspicefile/hspice_read.c | 74 +++++++++++++++++-- PySpice/Spice/HSpice/hspicefile/hspice_read.h | 6 ++ 7 files changed, 87 insertions(+), 19 deletions(-) diff --git a/PySpice/Spice/HSpice/RawFile.py b/PySpice/Spice/HSpice/RawFile.py index 399c3372..b2982a11 100644 --- a/PySpice/Spice/HSpice/RawFile.py +++ b/PySpice/Spice/HSpice/RawFile.py @@ -16,16 +16,13 @@ def __init__(self, analyses, measurements=None): def measurements(self): return self._measurements - def __getattribute__(self, name): - try: - measurements = object.__getattribute__(self, "_measurements") - except AttributeError: - 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()] - return object.__getattribute__(self, name) + raise AttributeError(name) def __getitem__(self, item): if isinstance(item, (int, slice)): @@ -137,7 +134,7 @@ def to_analysis(self): branches=[], internal_parameters=[], ) - analysis._measurements = self.measurements + analysis._measurements = dict(self.measurements) return analysis # Derive analysis type and scale unit once from the parser-provided name. @@ -242,9 +239,9 @@ def to_analysis(self): internal_parameters=[], ) - analysis._measurements = self.measurements + analysis._measurements = dict(self.measurements) analyses.append(analysis) if len(analyses) == 1: return analyses[0] - return AnalysisList(analyses, self.measurements) + return AnalysisList(analyses, dict(self.measurements)) diff --git a/PySpice/Spice/HSpice/Server.py b/PySpice/Spice/HSpice/Server.py index 1b42c49a..a9178d1d 100644 --- a/PySpice/Spice/HSpice/Server.py +++ b/PySpice/Spice/HSpice/Server.py @@ -41,7 +41,7 @@ def parse_spice_value(val_str): raise -def acquire_hspice_lock(limit): +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). @@ -59,6 +59,7 @@ def acquire_hspice_lock(limit): 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: @@ -69,6 +70,10 @@ def acquire_hspice_lock(limit): 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) diff --git a/PySpice/Spice/HSpice/Simulator.py b/PySpice/Spice/HSpice/Simulator.py index cdeff7c0..41b81b71 100644 --- a/PySpice/Spice/HSpice/Simulator.py +++ b/PySpice/Spice/HSpice/Simulator.py @@ -1,4 +1,5 @@ import logging + from ..SimulatorBase import Simulator from .Server import HSpiceServer diff --git a/PySpice/Spice/HSpice/hspicefile/build.py b/PySpice/Spice/HSpice/hspicefile/build.py index c530e5d3..5c506cf0 100644 --- a/PySpice/Spice/HSpice/hspicefile/build.py +++ b/PySpice/Spice/HSpice/hspicefile/build.py @@ -1,4 +1,5 @@ import sys + from setuptools import Extension from setuptools.command.build_ext import build_ext diff --git a/PySpice/Spice/HSpice/hspicefile/docs/tests/test_hspice_read.py b/PySpice/Spice/HSpice/hspicefile/docs/tests/test_hspice_read.py index 65d2a3b8..e5e0ed2e 100644 --- a/PySpice/Spice/HSpice/hspicefile/docs/tests/test_hspice_read.py +++ b/PySpice/Spice/HSpice/hspicefile/docs/tests/test_hspice_read.py @@ -35,7 +35,7 @@ # --------------------------------------------------------------------------- sys.path.insert( - 0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../../../..")) + 0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../../../../../..")) ) from PySpice.Spice.HSpice.hspicefile import hspice_read diff --git a/PySpice/Spice/HSpice/hspicefile/hspice_read.c b/PySpice/Spice/HSpice/hspicefile/hspice_read.c index aef9b32c..c8f73284 100644 --- a/PySpice/Spice/HSpice/hspicefile/hspice_read.c +++ b/PySpice/Spice/HSpice/hspicefile/hspice_read.c @@ -35,6 +35,7 @@ #include "Python.h" #include "numpy/arrayobject.h" #include "numpy/npy_math.h" +#include #include // Methods table @@ -321,6 +322,31 @@ int readHeaderBlock(struct ParserContext *ctx, char **buf, int *bufOffset) { 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 @@ -352,7 +378,11 @@ int getSweepInfo(int debugMode, PyObject **sweep, char *buf, int *sweepSize, } // Get number of sweep points. - *sweepSize = atoi(&buf[sweepSizePosition]); + if (parse_int(&buf[sweepSizePosition], sweepSize) < 0) { + PyErr_Format(HSpiceParseError, + "Failed to parse sweep size as valid integer."); + return -1; + } // Create array for sweep parameter values. dims = *sweepSize; @@ -851,16 +881,28 @@ static PyObject *HSpiceRead(PyObject *self, PyObject *args) { } buf[numOfSweepsEndPosition] = 0; // Check number of sweep parameters. - num = atoi(&buf[numOfSweepsPosition]); + 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). - parsedProbes = atoi(&buf[numOfProbesPosition]); + if (parse_int(&buf[numOfProbesPosition], &parsedProbes) < 0) { + PyErr_Format(HSpiceParseError, + "Failed to parse number of probes as valid integer."); + goto failed; + } buf[numOfProbesPosition] = 0; - numOfVariables = atoi(&buf[numOfVariablesPosition]); // Scale included. + 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) { @@ -898,10 +940,18 @@ static PyObject *HSpiceRead(PyObject *self, PyObject *args) { goto failed; } if (numOfVectors > 1) { - varTypes[1] = atoi(token); + if (parse_int(token, &varTypes[1]) < 0) { + PyErr_Format(HSpiceParseError, + "Failed to parse vector type as valid integer."); + goto failed; + } type = varTypes[1]; } else { - varTypes[0] = atoi(token); + 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) { @@ -918,9 +968,17 @@ static PyObject *HSpiceRead(PyObject *self, PyObject *args) { } if (numOfVectors > 1) { if (i < numOfVectors - 2) { - varTypes[i + 2] = atoi(token); + 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) { - varTypes[0] = atoi(token); + if (parse_int(token, &varTypes[0]) < 0) { + PyErr_Format(HSpiceParseError, + "Failed to parse vector type as valid integer."); + goto failed; + } } } } diff --git a/PySpice/Spice/HSpice/hspicefile/hspice_read.h b/PySpice/Spice/HSpice/hspicefile/hspice_read.h index 598ad9ee..6132e762 100644 --- a/PySpice/Spice/HSpice/hspicefile/hspice_read.h +++ b/PySpice/Spice/HSpice/hspicefile/hspice_read.h @@ -15,6 +15,10 @@ * 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 @@ -51,3 +55,5 @@ static PyObject *HSpiceParseError; #ifdef LINUX #define __declspec(a) extern #endif + +#endif // HSPICE_READ_H From 7198fcaf8f763798fe0a287bcb1b3213ced30ab9 Mon Sep 17 00:00:00 2001 From: Daniel Schmeer Date: Tue, 7 Jul 2026 13:38:00 +0100 Subject: [PATCH 16/21] Address Null return on error comment --- PySpice/Spice/HSpice/hspicefile/hspice_read.c | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/PySpice/Spice/HSpice/hspicefile/hspice_read.c b/PySpice/Spice/HSpice/hspicefile/hspice_read.c index c8f73284..64873887 100644 --- a/PySpice/Spice/HSpice/hspicefile/hspice_read.c +++ b/PySpice/Spice/HSpice/hspicefile/hspice_read.c @@ -776,8 +776,7 @@ static PyObject *HSpiceRead(PyObject *self, PyObject *args) { // Get hspice_read() arguments. if (!PyArg_ParseTuple(args, "si", &fileName, &debugMode)) { - Py_INCREF(Py_None); - return Py_None; + return NULL; } if (debugMode) { From 32b2320e46d7c43facd6a4f1c01737e735773cb6 Mon Sep 17 00:00:00 2001 From: Daniel Schmeer Date: Tue, 7 Jul 2026 13:51:39 +0100 Subject: [PATCH 17/21] ai code review comments part 3 --- PySpice/Spice/HSpice/Server.py | 8 ++++---- PySpice/Spice/HSpice/hspicefile/hspice_read.c | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/PySpice/Spice/HSpice/Server.py b/PySpice/Spice/HSpice/Server.py index a9178d1d..06868430 100644 --- a/PySpice/Spice/HSpice/Server.py +++ b/PySpice/Spice/HSpice/Server.py @@ -70,10 +70,10 @@ def acquire_hspice_lock(limit, timeout=None): 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.") + 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) diff --git a/PySpice/Spice/HSpice/hspicefile/hspice_read.c b/PySpice/Spice/HSpice/hspicefile/hspice_read.c index 64873887..0d329aa9 100644 --- a/PySpice/Spice/HSpice/hspicefile/hspice_read.c +++ b/PySpice/Spice/HSpice/hspicefile/hspice_read.c @@ -378,9 +378,9 @@ int getSweepInfo(int debugMode, PyObject **sweep, char *buf, int *sweepSize, } // Get number of sweep points. - if (parse_int(&buf[sweepSizePosition], sweepSize) < 0) { + if (parse_int(&buf[sweepSizePosition], sweepSize) < 0 || *sweepSize <= 0) { PyErr_Format(HSpiceParseError, - "Failed to parse sweep size as valid integer."); + "Failed to parse sweep size as a positive integer."); return -1; } From 761e2ad52c5ffdabe248a2edd2e94c13f77ca93a Mon Sep 17 00:00:00 2001 From: Daniel Schmeer Date: Wed, 8 Jul 2026 21:56:48 +0100 Subject: [PATCH 18/21] Fix a bug where when building the PySpice package it fails with a PEP517 error. Becasue setup_data refers to build.py it was trying to import the whole module during packaging and key dependencies are not avialable during that process --- PySpice/Spice/HSpice/hspicefile/build.py | 29 ------------------------ setup_data.py | 28 ++++++++++++++++++++++- 2 files changed, 27 insertions(+), 30 deletions(-) delete mode 100644 PySpice/Spice/HSpice/hspicefile/build.py diff --git a/PySpice/Spice/HSpice/hspicefile/build.py b/PySpice/Spice/HSpice/hspicefile/build.py deleted file mode 100644 index 5c506cf0..00000000 --- a/PySpice/Spice/HSpice/hspicefile/build.py +++ /dev/null @@ -1,29 +0,0 @@ -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} diff --git a/setup_data.py b/setup_data.py index 4e4c5e36..eca40a33 100644 --- a/setup_data.py +++ b/setup_data.py @@ -23,7 +23,33 @@ #################################################################################################### import os -from PySpice.Spice.HSpice.hspicefile.build import ext_modules, cmdclass +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} #################################################################################################### From 9e1ad19587812d2b1dbb701a87291f52000b7725 Mon Sep 17 00:00:00 2001 From: Daniel Schmeer Date: Wed, 8 Jul 2026 22:26:01 +0100 Subject: [PATCH 19/21] Additional fix for numpy dependency on C module --- pyproject.toml | 3 +++ setup.py | 5 +++++ 2 files changed, 8 insertions(+) create mode 100644 pyproject.toml diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..4973c843 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["setuptools", "wheel", "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 #################################################################################################### From f175f470694fe6a175df865101c90ad19b5ba696 Mon Sep 17 00:00:00 2001 From: Daniel Schmeer Date: Thu, 9 Jul 2026 14:13:54 +0100 Subject: [PATCH 20/21] Fix simulation measurement parsing failures from ascii files, the parser was struggling with whitespace so this has been resolved in a different way --- PySpice/Spice/HSpice/Server.py | 66 ++++++++++++++++++++-------------- 1 file changed, 39 insertions(+), 27 deletions(-) diff --git a/PySpice/Spice/HSpice/Server.py b/PySpice/Spice/HSpice/Server.py index 06868430..27a6ea5e 100644 --- a/PySpice/Spice/HSpice/Server.py +++ b/PySpice/Spice/HSpice/Server.py @@ -311,36 +311,48 @@ def __call__(self, spice_input): and not l.startswith("$") and not l.startswith(".") ] - if len(content_lines) >= 2: - names = content_lines[0].split() - is_sweep = len(content_lines) > 2 - for val_line in content_lines[1:]: - vals = val_line.split() - if len(vals) != len(names): - logger.warning( - f"Measurement line width mismatch: expected {len(names)} values, got {len(vals)}" - ) - continue - for name, val in zip(names, vals): - name_lower = name.lower() - if name_lower not in ( - "temper", - "alter#", - "temper#", - "alter", - ): + 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: + 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: - float_val = float(val) - if is_sweep: - if name_lower not in measurements: - measurements[name_lower] = [] - measurements[name_lower].append( - float_val - ) - else: - measurements[name_lower] = float_val + 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}" From a15e764e9ab99b6d3e256f3baccc1981f95173b6 Mon Sep 17 00:00:00 2001 From: Daniel Schmeer Date: Thu, 9 Jul 2026 16:35:11 +0100 Subject: [PATCH 21/21] Address AI review comments --- PySpice/Spice/HSpice/Server.py | 11 ++++++++++- pyproject.toml | 2 +- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/PySpice/Spice/HSpice/Server.py b/PySpice/Spice/HSpice/Server.py index 27a6ea5e..c1e0e42f 100644 --- a/PySpice/Spice/HSpice/Server.py +++ b/PySpice/Spice/HSpice/Server.py @@ -116,7 +116,10 @@ def __call__(self, spice_input): f.write(netlist_str) # Acquire the lock to limit the concurrency - lock_fd, slot_idx = acquire_hspice_lock(self._concurrency_limit) + lock_fd, slot_idx = acquire_hspice_lock( + self._concurrency_limit, + timeout=self._timeout, + ) try: # Submit the job @@ -332,6 +335,12 @@ def is_numeric_value(val): 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() diff --git a/pyproject.toml b/pyproject.toml index 4973c843..cec95721 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,3 +1,3 @@ [build-system] -requires = ["setuptools", "wheel", "numpy"] +requires = ["setuptools", "wheel", "oldest-supported-numpy"] build-backend = "setuptools.build_meta"