summaryrefslogtreecommitdiff
path: root/firmware/ltspice_plot.py
blob: 38145d966382c7c7def1ef3c0b253a21f15491a2 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
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 <input file name>.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, '<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)