From 2e7277b0d0f640c1d4e4eb5b3e4c81a32b9d9949 Mon Sep 17 00:00:00 2001 From: jaseg Date: Sat, 19 May 2018 10:24:40 +0200 Subject: Add spice sims and spice plot prettifier --- firmware/ltspice_plot.py | 137 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 137 insertions(+) create mode 100755 firmware/ltspice_plot.py (limited to 'firmware/ltspice_plot.py') diff --git a/firmware/ltspice_plot.py b/firmware/ltspice_plot.py new file mode 100755 index 0000000..38145d9 --- /dev/null +++ b/firmware/ltspice_plot.py @@ -0,0 +1,137 @@ +#!/usr/bin/env python3 + +from matplotlib import pyplot as plt +import numpy as np +import ast +import re +import csv +import math + +MULTIPLIERS = { + 'a': 1e-18, + 'f': 1e-15, + 'p': 1e-12, + 'n': 1e-9, + 'u': 1e-6, + 'µ': 1e-6, + 'm': 1e-3, + 'k': 1e3, + 'M': 1e6, + 'G': 1e9, + 'T': 1e12, + 'P': 1e15, + 'E': 1e18, +} + +def load_ltspice_csv(filename): + with open(filename) as f: + reader = csv.DictReader(f, delimiter='\t') + fieldnames = reader.fieldnames + return np.array([ [float(field) for field in line.values()] for line in reader ]), fieldnames + +def parse_unit(val, **units): + for unit, scale in units.items(): + if val.endswith(unit): + val = val[:-len(unit)] + break + else: + scale = 1.0 + + if val[0] == '!': + val = '-'+val[1:] + + try: + return float(val)*scale + except: + match = re.match(r'(-?[0-9]*(\.[0-9]+)?)([afpnuµmkMGTPE])', val) + if not match: + raise ValueError(f'Invalid value: {val}') + + val, _, suffix = match.groups() + return float(val) * MULTIPLIERS[suffix] * scale + +def parse_range(text, sep='-', **units): + if text: + start, _, end = text.partition(sep) + return parse_unit(start, **units), parse_unit(end, **units) if end else math.inf + else: + return 0, math.inf + +def apply_style(ax): + ax.spines['top'].set_visible(False) + ax.spines['right'].set_visible(False) + ax.spines['bottom'].set_color('#08bdf9') + ax.spines['left'].set_color('#08bdf9') + ax.tick_params(axis='x', colors='#01769D') + ax.tick_params(axis='y', colors='#01769D') + ax.xaxis.label.set_color('#01769D') + ax.yaxis.label.set_color('#01769D') + ax.grid(color='#08bdf9', linestyle=':') + + +if __name__ == '__main__': + import argparse + import os + parser = argparse.ArgumentParser() + parser.add_argument('input_txt', action='store', nargs='+', help='LTSpice .txt data export') + parser.add_argument('-o', '--output', help='Output SVG file. Defaults to .svg.', default=None, nargs='?') + parser.add_argument('-s', '--span', default=None, help='Time span to plot, format: [time][unit]{-[time][unit]}') + parser.add_argument('-c', '--channels', default=[None], action='store', nargs='*', help='List of channels to plot. Comma-separated 0-based indices or signal names. Use multiple times for vertically-stacked subplots.') + parser.add_argument('-x', '--xlabel', default='$t\;(\mu s)$', help='Time axis label') + parser.add_argument('-y', '--ylabel', default=[None]*100, action='store', nargs='*', help='Y axis labels. Use multiple times for subplots.') + parser.add_argument('-r', '--yrange', default=[None]*100, action='store', nargs='*', help='Value ranges for y axes. Use multiple times for subplots. Use ! instead of prefix minus sign.') + parser.add_argument('-t', '--timescale', default='1us', help='Time axis unit') + parser.add_argument('--subplot-title', default=[None]*100, action='store', nargs='*', help='Subplot titles') + parser.add_argument('-f', '--figure-size', default='8x6', help='Plot size in [x]x[y] inches') + args = parser.parse_args() + + start, end = parse_range(args.span, s=1) + timescale = parse_unit(args.timescale, s=1) + + inputs = [] + for filename in args.input_txt: + data, fieldnames = load_ltspice_csv(filename) + data = data[(data[:,0] > start) & (data[:,0] < end)] + data[:,0] = (data[:,0] - start) / timescale + inputs.append((data, fieldnames)) + + fig, axs = plt.subplots(len(args.channels), 1, squeeze=False, sharex=True, figsize=parse_range(args.figure_size, sep='x')) + + for row, (ax, channelspec) in enumerate(zip(axs.flatten(), args.channels)): + channels = channelspec.split(',') if args.channels else range(0, 1000) + + apply_style(ax) + + n_plotted = 0 + name_plotted = 'V(out)' + for k, (data, fieldnames) in enumerate(inputs): + for i, name in enumerate(fieldnames[1:], start=1): + if not any(x in channels for x in [i, f'{i}', f'{k}:{i}', f'{name}', f'{k}:{name}']): + print(f'Not plotting channel {i} "{name}"') + continue + print(f'Plotting channel {i} "{name}"') + ax.plot(data[:,0], data[:,i], color='#fe3ea0') + n_plotted += 1 + name_plotted = name + + if args.yrange[row]: + ax.set_ylim(parse_range(args.yrange[row], A=1, V=1)) + + if args.ylabel[row]: + ax.set_ylabel(args.ylabel[row]) + else: # Guess label + unit = {'V': 'V', 'I': 'A'}[name_plotted[0]] + if n_plotted == 1: + ax.set_ylabel(f'${name_plotted}\;({unit})$') + else: + ax.set_ylabel(f'${name_plotted[0]}\;({unit})$') + + if args.subplot_title[row] not in (None, ''): + ax.set_title(args.subplot_title[row], color='#fe3ea0', fontname='Fredoka One') + + outfile = args.output if args.output else os.path.splitext(args.input_txt[0])[0] + '.svg' + + if args.xlabel: + axs.flatten()[-1].set_xlabel(args.xlabel) + plt.tight_layout() + fig.savefig(outfile) -- cgit