Source code for spicelib.log.ltsteps

#!/usr/bin/env python

# -------------------------------------------------------------------------------
#
#  ███████╗██████╗ ██╗ ██████╗███████╗██╗     ██╗██████╗
#  ██╔════╝██╔══██╗██║██╔════╝██╔════╝██║     ██║██╔══██╗
#  ███████╗██████╔╝██║██║     █████╗  ██║     ██║██████╔╝
#  ╚════██║██╔═══╝ ██║██║     ██╔══╝  ██║     ██║██╔══██╗
#  ███████║██║     ██║╚██████╗███████╗███████╗██║██████╔╝
#  ╚══════╝╚═╝     ╚═╝ ╚═════╝╚══════╝╚══════╝╚═╝╚═════╝
#
# Name:        ltsteps.py
# Purpose:     Process LTSpice output files and align data for usage in a spread-
#              sheet tool such as Excel, or Calc.
#
# Author:      Nuno Brum (nuno.brum@gmail.com)
#
# License:     refer to the LICENSE file
# -------------------------------------------------------------------------------

"""
This module allows to process data generated by LTSpice during simulation. There are three types of files that are
handled by this module.

    + log files - Files with the extension '.log' that are automatically generated during simulation, and that are
      normally accessible with the shortcut Ctrl+L after a simulation is ran.Log files are interesting for two reasons.

            1. If .STEP primitives are used, the log file contain the correspondence between the step run and the step
            value configuration.

            2. If .MEAS primitives are used in the schematic, the log file contains the measurements made on the output
            data.

      ltsteps.py can be used to retrieve both step and measurement information from log files.

    + txt files - Files exported from the Plot File -> Export data as text menu. This file is an text file where data is
      saved in the text format. The reason to use spicelib instead of another popular lib as pandas, is because the data
      format when .STEPS are used in the simulation is not not very practical. The spicelib ltsteps.py can be used to
      reformat the text, so that the run parameter is added to the data as an additional column instead of a table
      divider. Please Check LTSpiceExport class for more information.

    + mout files - Files generated by the Plot File -> Execute .MEAS Script menu. This command allows the user to run
      predefined .MEAS commands which create a .mout file. A .mout file has the measurement information stored in the
      following format:

      .. code-block:: text

            Measurement: Vout_rms
            step	RMS(V(OUT))	FROM	TO
             1	1.41109	0	0.001
             2	1.40729	0	0.001

            Measurement: Vin_rms
              step	RMS(V(IN))	FROM	TO
                 1	0.706221	0	0.001
                 2	0.704738	0	0.001

            Measurement: gain
              step	Vout_rms/Vin_rms
                 1	1.99809
                 2	1.99689


The ltsteps can be used directly from a command line if the Python's Scripts folder is included in the PATH
environment variable.

.. code-block:: text

    $ ltsteps <path_to_filename>

If `<path_to_filename>` is a log file, it will create a file with the same name, but with extension .tout that is a
tab separated value (tsv) file, which contains the .STEP and .MEAS information collected.

If `<path_to_filename>` is a txt exported file, it will create a file with the same name, but with extension .tsv a
tab separated value (tsv) file, which contains data reformatted with the step number as one of the columns. Please
consult the reformat_LTSpice_export() function for more information.

If `<path_to_filename>` is a mout file, it will create a file with the same name, but with extension .tmout that is a
tab separated value (tsv) file, which contains the .MEAS information collected, but adding the STEP run information
as one of the columns.

If `<path_to_filename>` argument is ommited, the script will automatically search for the newest .log/.txt/.mout file
and use it.

"""
__author__ = "Nuno Canto Brum <nuno.brum@gmail.com>"
__copyright__ = "Copyright 2023, Fribourg Switzerland"

import dataclasses
import os.path
import re

from .logfile_data import LogfileData, try_convert_value
from ..utils.detect_encoding import detect_encoding
import logging
_logger = logging.getLogger("spicelib.LTSteps")


def reformat_LTSpice_export(export_file: str, tabular_file: str):
    """
    Reads an LTSpice File Export file and writes it back in a format that is more convenient for data treatment.

    When using the "Export data as text" in the raw file menu the data is already exported in a tabular format.
    However, if steps are being used, the step information doesn't appear on the table.  Instead the successive STEP
    runs are stacked on one after another, separated by the following text:

    .. code-block:: text

        Step Information: Ton=400m  (Run: 2/2)

    What would be desirable would be that the step number (Run number) and the STEP variable would be placed within the
    columns.  This allows, for example, using Excel functionality known as Pivot Tables to filter out data, or some other
    database selection function.
    The tab is chosen as separator because it is normally compatible with pasting data into Excel.

    :param export_file: Filename of the .txt file generated by the "Export Data as Text"
    :param tabular_file: Filename of the tab separated values (TSV) file that
    :return: Nothing
    """
    encoding = detect_encoding(export_file)
    fin = open(export_file, encoding=encoding)
    fout = open(tabular_file, 'w', encoding=encoding)

    headers = fin.readline()
    # writing header
    go_header = True
    run_no = 0  # Just to avoid warning, this is later overridden by the step information
    param_values = ""  # Just to avoid warning, this is later overridden by the step information
    regx = re.compile(r"Step Information: ([\w=\d\. \-]+) +\((?:Run|Step): (\d*)/\d*\)\n")
    for line in fin:
        if line.startswith("Step Information:"):
            match = regx.match(line)
            if match:
                step, run_no = match.groups()
                params = []
                for param in step.split():
                    params.append(param.split('=')[1])
                param_values = "\t".join(params)

                if go_header:
                    header_keys = []
                    for param in step.split():
                        header_keys.append(param.split('=')[0])
                    param_header = "\t".join(header_keys)
                    msg = f"Run\t{param_header}\t{headers}"
                    fout.write(msg)
                    _logger.debug(msg)
                    go_header = False
        else:
            fout.write(f"{run_no}\t{param_values}\t{line}")

    fin.close()
    fout.close()


class LTSpiceExport:
    """
    Opens and reads LTSpice export data when using the "Export data as text" in the File Menu on the waveform window.

    The data is then accessible by using the following attributes implemented in this class.

    :property headers: list containing the headers on the exported data
    :property dataset: dictionary in which the keys are the the headers and the export file and the values are
        lists. When reading STEPed data, a new key called 'runno' is added to the dataset.

    **Examples**

    ::

        export_data = LTSpiceExport("export_data_file.txt")
        for value in export_data.dataset['i(v1)']:
            print(f"Do something with this value {value}")

    :param export_filename: path to the Export file.
    :type export_filename: str
    """

    def __init__(self, export_filename: str):
        self.encoding = detect_encoding(export_filename)
        fin = open(export_filename, encoding=self.encoding)
        file_header = fin.readline()

        self.headers = file_header.split('\t')
        # Set to read header
        go_header = True

        curr_dic = {}
        self.dataset = {}

        regx = re.compile(r"Step Information: ([\w=\d\. -]+) +\(Run: (\d*)/\d*\)\n")
        for line in fin:
            if line.startswith("Step Information:"):
                match = regx.match(line)
                if match:
                    step, run_no = match.groups()
                    curr_dic['runno'] = run_no
                    for param in step.split():
                        key, value = param.split('=')
                        curr_dic[key] = try_convert_value(value)

                    if go_header:
                        go_header = False  # This is executed only once
                        for key in self.headers:
                            self.dataset[key.lower()] = []  # Initializes an empty list

                        for key in curr_dic:
                            self.dataset[key.lower()] = []  # Initializes an empty list

            else:
                values = line.split('\t')

                for key in curr_dic:
                    self.dataset[key.lower()].append(curr_dic[key])

                for i in range(len(values)):
                    self.dataset[self.headers[i].lower()].append(try_convert_value(values[i]))

        fin.close()


@dataclasses.dataclass
class HarmonicData:
    harmonic_number: int
    frequency: float
    fourier_component: float
    normalized_component: float
    phase: float
    normalized_phase: float
    # units: dict = dataclasses.field(default_factory=dict)

    @classmethod
    def from_line(cls, line: str):
        tokens = line.split()
        harmonic_number = int(tokens[0])
        frequency = float(tokens[1])
        fourier_component = float(tokens[2])
        normalized_component = float(tokens[3])
        phase = float(tokens[4].rstrip('°'))
        normalized_phase = float(tokens[5].rstrip('°'))
        return cls(harmonic_number, frequency, fourier_component, normalized_component, phase, normalized_phase)


@dataclasses.dataclass
class FourierData:
    signal: str
    n_periods: int
    dc_component: float
    phd: float  # Partial Harmonic Distortion
    thd: float  # Total Harmonic Distortion
    harmonics: list[HarmonicData]
    step: int

    @property
    def fundamental(self):
        return self.harmonics[0].frequency

    def __getitem__(self, item):
        return self.harmonics[item]

    def __iter__(self):
        return iter(self.harmonics)

    def __len__(self):
        return len(self.harmonics)


[docs] class LTSpiceLogReader(LogfileData): """ Reads an LTSpice log file and retrieves the step information if it exists. The step information is then accessible by using the 'stepset' property of this class. This class is intended to be used together with the RawRead to retrieve the runs that are associated with a given parameter setting. This class constructor only reads the step information of the log file. If the measures are needed, then the user should call the get_measures() method. :property stepset: dictionary in which the keys are the variables that were STEP'ed during the simulation and the associated value is a list representing the sequence of assigned values during simulation. :property headers: list containing the headers on the exported data. This is only populated when the *read_measures* optional parameter is set to False. :property dataset: dictionary in which the keys are the headers and the export file and the values are lists. This is information is only populated when the *read_measures* optional parameter is set to False. :param log_filename: path to the Export file. :type log_filename: str :param read_measures: Optional parameter to skip measuring data reading. :type read_measures: boolean :param step_set: Optional parameter to provide the steps from another file. This is used to process .mout files. :type step_set: dict """ def __init__(self, log_filename: str, read_measures=True, step_set: dict = None, encoding=None): super().__init__(step_set) self.logname = log_filename self.fourier = {} if encoding is None: self.encoding = detect_encoding(log_filename, r"^((.*\n)?Circuit:|([\s\S]*)--- Expanded Netlist ---)") else: self.encoding = encoding # Preparing a stepless measurement read regular expression # there are only measures taken in the format parameter: measurement # A few examples of readings # vout_rms: RMS(v(out))=1.41109 FROM 0 TO 0.001 => Interval # vin_rms: RMS(v(in))=0.70622 FROM 0 TO 0.001 => Interval # gain: vout_rms/vin_rms=1.99809 => Parameter # vout1m: v(out)=-0.0186257 at 0.001 => Point # fcut: v(vout)=vmax/sqrt(2) AT 252.921 # fcutac=8.18166e+006 FROM 1.81834e+006 TO 1e+007 => AC Find Computation regx = re.compile( # r"^(?P<name>\w+)(:\s+.*)?=(?P<value>[\d(inf)\.E+\-\(\)dB,°]+)(( FROM (?P<from>[\d\.E+-]*) TO (?P<to>[\d\.E+-]*))|( at (?P<at>[\d\.E+-]*)))?", r"^(?P<name>\w+)(:\s+.*)?=(?P<value>[\d(inf)E+\-\(\)dB,°(-/\w]+)( FROM (?P<from>[\d\.E+-]*) TO (?P<to>[\d\.E+-]*)|( at (?P<at>[\d\.E+-]*)))?", re.IGNORECASE) _logger.debug(f"Processing LOG file:{log_filename}") with open(log_filename, encoding=self.encoding) as fin: line = fin.readline() # init variables, just in case. Not needed really, but helps debugging signal = None n_periods = 0 dc_component = 0 while line: if len(line.strip()) == 0: # skip empty lines pass elif line.startswith("N-Period"): # Read number of periods n_periods = line.strip('\r\n').split("=")[-1].strip() if n_periods == 'all': n_periods = -1 else: n_periods = float(n_periods) elif line.startswith("Fourier components of"): # Read signal name line = line.strip('\r\n') signal = line.split(" of ")[-1].strip() elif line.startswith("DC component:"): # Read DC component line = line.strip('\r\n') dc_component = float(line.split(':')[-1].strip()) elif line.startswith("Harmonic"): # Skip next header line fin.readline() # Read Harmonics table phd = thd = None harmonics = [] while True: line = fin.readline().strip('\r\n') if line.startswith("Total Harmonic"): # Find THD thd = float(re.search(r"\d+.\d+", line).group()) elif line.startswith("Partial Harmonic"): # Find PHD phd = float(re.search(r"\d+.\d+", line).group()) elif line == "": # End of the table break else: harmonics.append(HarmonicData.from_line(line)) fourier_data = FourierData(signal, n_periods, dc_component, phd, thd, harmonics, self.step_count - 1) if signal in self.fourier: self.fourier[signal].append(fourier_data) else: self.fourier[signal] = [fourier_data] elif line.startswith(".step"): valid_step = False tokens = line.strip('\r\n').split(' ') for tok in tokens[1:]: # Some log files have a .step line without assignments if "=" not in tok: continue valid_step = True lhs, rhs = tok.split("=") # Try to convert to int or float rhs = try_convert_value(rhs) lhs = lhs.lower() ll = self.stepset.get(lhs, None) if ll: ll.append(rhs) else: self.stepset[lhs] = [rhs] # Only increment if the .step was valid if valid_step: self.step_count += 1 elif line.startswith("Measurement:"): if not read_measures: fin.close() return else: break # Jumps to the section that reads measurements if self.step_count == 0: # then there are no steps, match = regx.match(line) if match: # Get the data dataname = match.group('name') if match.group('from'): headers = [dataname, dataname + "_FROM", dataname + "_TO"] measurements = [match.group('value'), match.group('from'), match.group('to')] elif match.group('at'): headers = [dataname, dataname + "_at"] measurements = [match.group('value'), match.group('at')] else: headers = [dataname] measurements = [match.group('value')] self.measure_count += 1 for k, title in enumerate(headers): self.dataset[title.lower()] = [ try_convert_value(measurements[k])] # need to be a list for compatibility line = fin.readline() dataname = None headers = [] # Initializing an empty parameters measurements = [] while line: line = line.strip('\r\n') if line.startswith("Measurement: "): if dataname: # If previous measurement was saved # store the info if len(measurements): _logger.debug("Storing Measurement %s (count %d)" % (dataname, len(measurements))) self.measure_count += len(measurements) for k, title in enumerate(headers): self.dataset[title.lower()] = [measure[k] for measure in measurements] headers = [] measurements = [] dataname = line[13:] # text which is after "Measurement: ". len("Measurement: ") -> 13 _logger.debug("Reading Measurement %s" % line[13:]) else: tokens = line.split("\t") if len(tokens) >= 2: try: int(tokens[0]) # This instruction only serves to trigger the exception meas = tokens[1:] # remove the first token measurements.append(try_convert_value(meas)) self.measure_count += 1 except ValueError: if len(tokens) >= 3 and (tokens[2] == "FROM" or tokens[2] == 'at'): tokens[2] = dataname + '_' + tokens[2] if len(tokens) >= 4 and tokens[3] == "TO": tokens[3] = dataname + "_TO" headers = [dataname] + tokens[2:] measurements = [] else: _logger.debug("->" + line) line = fin.readline() # advance to the next line # storing the last data into the dataset if dataname: _logger.debug("Storing Measurement %s (count %d)" % (dataname, len(measurements))) if len(measurements): self.measure_count += len(measurements) for k, title in enumerate(headers): self.dataset[title.lower()] = [measure[k] for measure in measurements] _logger.debug("%d measurements" % len(self.dataset)) _logger.info("Identified %d steps, read %d measurements" % (self.step_count, self.measure_count))
[docs] def export_data(self, export_file: str, encoding=None, append_with_line_prefix=None): """Aside from exporting the data, it also exports fourier data if it exists""" super().export_data(export_file, encoding, append_with_line_prefix) fourier_export_file = os.path.splitext(export_file)[0] + "_fourier.txt" if self.fourier: with open(fourier_export_file, "w", encoding=encoding) as fout: if self.step_count > 0: fout.write("\t".join(self.stepset.keys()) + "\t") fout.write("Signal\tN-Periods\tDC Component\tFundamental\tN-Harmonics\tPHD\tTHD\n") for signal in self.fourier: if self.step_count > 0: for step_no in range(self.step_count): step_values = [f"{self.stepset[step][step_no]}" for step in self.stepset] for analysis in self.fourier[signal]: if analysis.step == step_no: fout.write('\t'.join(step_values) + '\t') if analysis.n_periods < 1: n_periods = 'all' else: n_periods = analysis.n_periods fout.write(f"{signal}\t" f"{n_periods}\t" f"{analysis.dc_component}\t" f"{analysis.fundamental}\t" f"{len(analysis)}\t" f"{analysis.phd}\t" f"{analysis.thd}\n") else: for analysis in self.fourier[signal]: if analysis.n_periods == -1: n_periods = 'all' else: n_periods = analysis.n_periods fout.write(f"{signal}\t" f"{n_periods}\t" f"{analysis.dc_component}\t" f"{analysis.fundamental}" f"\t{len(analysis)}\t" f"{analysis.phd}\t" f"{analysis.thd}\n") fout.write("\n\nHarmonic Analysis\n") fout.write("\t".join(self.stepset.keys()) + "\t") fout.write("Signal\tN-Periods\tHarmonic\tFrequency\tFourier\tNormalized\tPhase\tNormalized\n") for signal in self.fourier: for analysis in self.fourier[signal]: if self.step_count > 0: for step_no in range(self.step_count): if analysis.step == step_no: step_values = [f"{self.stepset[step][step_no]}" for step in self.stepset] for harmonic in analysis: fout.write('\t'.join(step_values) + '\t') fout.write( f"{signal}\t" f"{analysis.n_periods}\t" f"{harmonic.harmonic_number}\t" f"{harmonic.frequency}\t" f"{harmonic.fourier_component}\t" f"{harmonic.normalized_component}\t" f"{harmonic.phase}\t" f"{harmonic.normalized_phase}\n" ) else: for harmonic in analysis: fout.write(f"{signal}\t" f"{analysis.n_periods}\t" f"{harmonic.harmonic_number}\t" f"{harmonic.frequency}\t" f"{harmonic.fourier_component}\t" f"{harmonic.normalized_component}\t" f"{harmonic.phase}\t" f"{harmonic.normalized_phase}\n" ) fout.write("\n")