2019-11-18 22:40:26 +00:00
|
|
|
# NanoVNASaver
|
|
|
|
# A python program to view and export Touchstone data from a NanoVNA
|
2019-09-02 13:20:02 +00:00
|
|
|
# Copyright (C) 2019. Rune B. Broberg
|
|
|
|
#
|
|
|
|
# This program is free software: you can redistribute it and/or modify
|
|
|
|
# it under the terms of the GNU 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
|
2019-11-19 09:03:46 +00:00
|
|
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
2019-09-02 13:20:02 +00:00
|
|
|
# GNU General Public License for more details.
|
|
|
|
#
|
|
|
|
# You should have received a copy of the GNU General Public License
|
|
|
|
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
2019-09-17 09:13:42 +00:00
|
|
|
import logging
|
2019-11-19 09:27:21 +00:00
|
|
|
import math
|
2019-11-18 22:40:26 +00:00
|
|
|
import cmath
|
2019-11-19 09:00:17 +00:00
|
|
|
import io
|
2019-11-18 22:40:26 +00:00
|
|
|
from NanoVNASaver.RFTools import Datapoint
|
2019-09-02 13:20:02 +00:00
|
|
|
|
2019-09-17 09:13:42 +00:00
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
2019-09-02 13:20:02 +00:00
|
|
|
|
2019-11-18 22:40:26 +00:00
|
|
|
class Options:
|
|
|
|
# Fun fact: In Touchstone 1.1 spec all params are optional unordered.
|
|
|
|
# Just the line has to start with "#"
|
|
|
|
UNIT_TO_FACTOR = {
|
|
|
|
"ghz": 10**9,
|
|
|
|
"mhz": 10**6,
|
|
|
|
"khz": 10**3,
|
|
|
|
"hz": 10**0,
|
|
|
|
}
|
2019-11-19 10:27:05 +00:00
|
|
|
VALID_UNITS = UNIT_TO_FACTOR.keys()
|
|
|
|
VALID_PARAMETERS = "syzgh"
|
|
|
|
VALID_FORMATS = ("ma", "db", "ri")
|
|
|
|
|
|
|
|
def __init__(self,
|
|
|
|
unit: str = "GHZ",
|
|
|
|
parameter: str = "S",
|
|
|
|
t_format: str = "ma",
|
|
|
|
resistance: int = 50):
|
2019-11-18 22:40:26 +00:00
|
|
|
# set defaults
|
2019-11-19 10:27:05 +00:00
|
|
|
assert unit.lower() in Options.VALID_UNITS
|
|
|
|
assert parameter.lower() in Options.VALID_PARAMETERS
|
|
|
|
assert t_format.lower() in Options.VALID_FORMATS
|
|
|
|
assert resistance > 0
|
|
|
|
self.unit = unit.lower()
|
|
|
|
self.parameter = parameter.lower()
|
|
|
|
self.format = t_format.lower()
|
|
|
|
self.resistance = resistance
|
|
|
|
|
|
|
|
@property
|
2019-11-19 10:34:15 +00:00
|
|
|
def factor(self) -> int:
|
2019-11-19 10:27:05 +00:00
|
|
|
return Options.UNIT_TO_FACTOR[self.unit]
|
|
|
|
|
2019-11-19 10:34:15 +00:00
|
|
|
def __str__(self) -> str:
|
2019-11-19 10:27:05 +00:00
|
|
|
return (
|
|
|
|
f"# {self.unit} {self.parameter}"
|
|
|
|
f" {self.format} r {self.resistance}"
|
|
|
|
).upper()
|
2019-11-18 22:40:26 +00:00
|
|
|
|
2019-11-19 10:34:15 +00:00
|
|
|
def parse(self, line: str):
|
2019-11-18 22:40:26 +00:00
|
|
|
if not line.startswith("#"):
|
|
|
|
raise TypeError("Not an option line: " + line)
|
2019-11-19 18:13:45 +00:00
|
|
|
punit = pparam = pformat = presist = False
|
2019-11-18 22:40:26 +00:00
|
|
|
params = iter(line[1:].lower().split())
|
|
|
|
for p in params:
|
2019-11-19 18:13:45 +00:00
|
|
|
if p in Options.VALID_UNITS and not punit:
|
|
|
|
self.unit = p
|
|
|
|
punit = True
|
2019-11-19 10:27:05 +00:00
|
|
|
elif p in Options.VALID_PARAMETERS and not pparam:
|
2019-11-18 22:40:26 +00:00
|
|
|
self.parameter = p
|
|
|
|
pparam = True
|
2019-11-19 10:27:05 +00:00
|
|
|
elif p in Options.VALID_FORMATS and not pformat:
|
2019-11-18 22:40:26 +00:00
|
|
|
self.format = p
|
|
|
|
pformat = True
|
|
|
|
elif p == "r" and not presist:
|
|
|
|
self.resistance = int(next(params))
|
|
|
|
else:
|
2019-11-19 10:49:43 +00:00
|
|
|
raise TypeError("Illegal option line: " + line)
|
2019-11-18 22:40:26 +00:00
|
|
|
|
|
|
|
|
2019-09-02 13:20:02 +00:00
|
|
|
class Touchstone:
|
|
|
|
|
2019-11-19 09:00:17 +00:00
|
|
|
def __init__(self, filename: str):
|
2019-09-02 13:20:02 +00:00
|
|
|
self.filename = filename
|
2019-11-19 09:00:17 +00:00
|
|
|
self.sdata = [[], [], [], []] # at max 4 data pairs
|
2019-11-18 22:40:26 +00:00
|
|
|
self.comments = []
|
|
|
|
self.opts = Options()
|
2019-09-17 09:13:42 +00:00
|
|
|
|
2019-11-19 09:00:17 +00:00
|
|
|
@property
|
|
|
|
def s11data(self) -> list:
|
|
|
|
return self.sdata[0]
|
2019-09-02 13:20:02 +00:00
|
|
|
|
2019-11-19 09:00:17 +00:00
|
|
|
@property
|
|
|
|
def s21data(self) -> list:
|
|
|
|
return self.sdata[1]
|
|
|
|
|
|
|
|
@property
|
|
|
|
def s12data(self) -> list:
|
|
|
|
return self.sdata[2]
|
|
|
|
|
|
|
|
@property
|
|
|
|
def s22data(self) -> list:
|
|
|
|
return self.sdata[3]
|
|
|
|
|
|
|
|
def _parse_comments(self, fp) -> str:
|
|
|
|
for line in fp:
|
|
|
|
line = line.strip()
|
|
|
|
if line.startswith("!"):
|
|
|
|
logger.info(line)
|
|
|
|
self.comments.append(line)
|
2019-11-19 10:27:05 +00:00
|
|
|
continue
|
|
|
|
return line
|
2019-11-19 09:00:17 +00:00
|
|
|
|
2019-11-19 09:27:21 +00:00
|
|
|
def _append_line_data(self, freq: float, data: list):
|
|
|
|
data_list = iter(self.sdata)
|
|
|
|
vals = iter(data)
|
|
|
|
for v in vals:
|
|
|
|
if self.opts.format == "ri":
|
|
|
|
next(data_list).append(
|
|
|
|
Datapoint(freq, float(v), float(next(vals))))
|
|
|
|
if self.opts.format == "ma":
|
2019-11-19 11:26:54 +00:00
|
|
|
z = cmath.polar(float(v),
|
|
|
|
math.radians(float(next(vals))))
|
2019-11-19 09:27:21 +00:00
|
|
|
next(data_list).append(Datapoint(freq, z.real, z.imag))
|
|
|
|
if self.opts.format == "db":
|
2019-11-19 11:26:54 +00:00
|
|
|
z = cmath.polar(math.exp(float(v) / 20),
|
|
|
|
math.radians(float(next(vals))))
|
2019-11-19 09:27:21 +00:00
|
|
|
next(data_list).append(Datapoint(freq, z.real, z.imag))
|
|
|
|
|
2019-11-19 09:00:17 +00:00
|
|
|
def load(self):
|
|
|
|
logger.info("Attempting to open file %s", self.filename)
|
|
|
|
try:
|
|
|
|
with open(self.filename) as infile:
|
|
|
|
self.loads(infile.read())
|
|
|
|
except TypeError as e:
|
|
|
|
logger.exception("Failed to parse %s: %s", self.filename, e)
|
|
|
|
except IOError as e:
|
|
|
|
logger.exception("Failed to open %s: %s", self.filename, e)
|
|
|
|
|
|
|
|
def loads(self, s: str):
|
|
|
|
"""Parse touchstone 1.1 string input
|
|
|
|
appends to existing sdata if Touchstone object exists
|
|
|
|
"""
|
|
|
|
with io.StringIO(s) as file:
|
|
|
|
opts_line = self._parse_comments(file)
|
|
|
|
self.opts.parse(opts_line)
|
|
|
|
|
|
|
|
prev_freq = 0.0
|
|
|
|
prev_len = 0
|
2019-11-18 22:40:26 +00:00
|
|
|
for line in file:
|
|
|
|
# ignore empty lines (even if not specified)
|
|
|
|
if not line.strip():
|
2019-09-02 13:20:02 +00:00
|
|
|
continue
|
|
|
|
|
2019-11-18 22:40:26 +00:00
|
|
|
# ignore comments at data end
|
|
|
|
data = line.split('!')[0]
|
|
|
|
data = data.split()
|
|
|
|
freq, data = float(data[0]) * self.opts.factor, data[1:]
|
|
|
|
data_len = len(data)
|
|
|
|
|
|
|
|
# consistency checks
|
|
|
|
if freq <= prev_freq:
|
2019-11-19 10:49:43 +00:00
|
|
|
raise TypeError("Frequency not ascending: " + line)
|
2019-11-18 22:40:26 +00:00
|
|
|
prev_freq = freq
|
|
|
|
|
|
|
|
if prev_len == 0:
|
|
|
|
prev_len = data_len
|
2019-11-19 09:27:21 +00:00
|
|
|
if data_len % 2:
|
|
|
|
raise TypeError("Data values aren't pairs: " + line)
|
2019-11-18 22:40:26 +00:00
|
|
|
elif data_len != prev_len:
|
|
|
|
raise TypeError("Inconsistent number of pairs: " + line)
|
|
|
|
|
2019-11-19 09:27:21 +00:00
|
|
|
self._append_line_data(freq, data)
|
2019-11-18 22:40:26 +00:00
|
|
|
|
2019-11-19 10:34:15 +00:00
|
|
|
def setFilename(self, filename: str):
|
2019-09-04 16:12:38 +00:00
|
|
|
self.filename = filename
|