nanovna-saver/NanoVNASaver/Calibration.py

379 wiersze
14 KiB
Python

# NanoVNASaver
#
# A python program to view and export Touchstone data from a NanoVNA
# Copyright (C) 2019, 2020 Rune B. Broberg
# Copyright (C) 2020,2021 NanoVNA-Saver Authors
#
# 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
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# 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/>.
import logging
import cmath
import math
import os
import re
from collections import defaultdict, UserDict
from typing import List
from scipy.interpolate import interp1d
from NanoVNASaver.RFTools import Datapoint
RXP_CAL_LINE = re.compile(r"""^\s*
(?P<freq>\d+) \s+
(?P<shortr>[-0-9Ee.]+) \s+ (?P<shorti>[-0-9Ee.]+) \s+
(?P<openr>[-0-9Ee.]+) \s+ (?P<openi>[-0-9Ee.]+) \s+
(?P<loadr>[-0-9Ee.]+) \s+ (?P<loadi>[-0-9Ee.]+)(?: \s
(?P<throughr>[-0-9Ee.]+) \s+ (?P<throughi>[-0-9Ee.]+) \s+
(?P<isolationr>[-0-9Ee.]+) \s+ (?P<isolationi>[-0-9Ee.]+)
)?
""", re.VERBOSE)
logger = logging.getLogger(__name__)
def correct_delay(d: Datapoint, delay: float, reflect: bool = False):
mult = 2 if reflect else 1
corr_data = d.z * cmath.exp(
complex(0, 1) * 2 * math.pi * d.freq * delay * -1 * mult)
return Datapoint(d.freq, corr_data.real, corr_data.imag)
class CalData(UserDict):
def __init__(self):
data = {
"short": None,
"open": None,
"load": None,
"through": None,
"isolation": None,
# the frequence
"freq": 0,
# 1 Port
"e00": 0.0, # Directivity
"e11": 0.0, # Port match
"delta_e": 0.0, # Tracking
# 2 port
"e30": 0.0, # Port match
"e10e32": 0.0, # Transmission
}
super().__init__(data)
def __str__(self):
d = self.data
s = (f'{d["freq"]}'
f' {d["short"].re} {d["short"].im}'
f' {d["open"].re} {d["open"].im}'
f' {d["load"].re} {d["load"].im}')
if d["through"] is not None:
s += (f' {d["through"].re} {d["through"].im}'
f' {d["isolation"].re} {d["isolation"].im}')
return s
class CalDataSet:
def __init__(self):
self.data = defaultdict(CalData)
def insert(self, name: str, dp: Datapoint):
if name not in self.data[dp.freq]:
raise KeyError(name)
self.data[dp.freq]["freq"] = dp.freq
self.data[dp.freq][name] = dp
def frequencies(self) -> List[int]:
return sorted(self.data.keys())
def get(self, freq: int) -> CalData:
return self.data[freq]
def items(self):
yield from self.data.items()
def values(self):
for freq in self.frequencies():
yield self.get(freq)
def size_of(self, name: str) -> int:
return len([v for v in self.data.values() if v[name] is not None])
def complete1port(self) -> bool:
for val in self.data.values():
for name in ("short", "open", "load"):
if val[name] is None:
return False
return any(self.data)
def complete2port(self) -> bool:
for val in self.data.values():
for name in ("short", "open", "load", "through", "isolation"):
if val[name] is None:
return False
return any(self.data)
class Calibration:
CAL_NAMES = ("short", "open", "load", "through", "isolation",)
IDEAL_SHORT = complex(-1, 0)
IDEAL_OPEN = complex(1, 0)
IDEAL_LOAD = complex(0, 0)
def __init__(self):
self.notes = []
self.dataset = CalDataSet()
self.interp = {}
self.useIdealShort = True
self.shortL0 = 5.7 * 10E-12
self.shortL1 = -8960 * 10E-24
self.shortL2 = -1100 * 10E-33
self.shortL3 = -41200 * 10E-42
self.shortLength = -34.2 # Picoseconfrequenciesds
# These numbers look very large, considering what Keysight
# suggests their numbers are.
self.useIdealOpen = True
# Subtract 50fF for the nanoVNA calibration if nanoVNA is
# calibrated?
self.openC0 = 2.1 * 10E-14
self.openC1 = 5.67 * 10E-23
self.openC2 = -2.39 * 10E-31
self.openC3 = 2.0 * 10E-40
self.openLength = 0
self.useIdealLoad = True
self.loadR = 25
self.loadL = 0
self.loadC = 0
self.loadLength = 0
self.useIdealThrough = True
self.throughLength = 0
self.isCalculated = False
self.source = "Manual"
def insert(self, name: str, data: List[Datapoint]):
for dp in data:
self.dataset.insert(name, dp)
def size(self) -> int:
return len(self.dataset.frequencies())
def data_size(self, name) -> int:
return self.dataset.size_of(name)
def isValid1Port(self) -> bool:
return self.dataset.complete1port()
def isValid2Port(self) -> bool:
return self.dataset.complete2port()
def calc_corrections(self):
if not self.isValid1Port():
logger.warning(
"Tried to calibrate from insufficient data.")
raise ValueError(
"All of short, open and load calibration steps"
"must be completed for calibration to be applied.")
logger.debug("Calculating calibration for %d points.", self.size())
for freq, caldata in self.dataset.items():
g1 = self.gamma_short(freq)
g2 = self.gamma_open(freq)
g3 = self.gamma_load(freq)
gm1 = caldata["short"].z
gm2 = caldata["open"].z
gm3 = caldata["load"].z
try:
denominator = (g1 * (g2 - g3) * gm1 +
g2 * g3 * gm2 - g2 * g3 * gm3 -
(g2 * gm2 - g3 * gm3) * g1)
caldata["e00"] = - ((g2 * gm3 - g3 * gm3) * g1 * gm2 -
(g2 * g3 * gm2 - g2 * g3 * gm3 -
(g3 * gm2 - g2 * gm3) * g1) * gm1
) / denominator
caldata["e11"] = ((g2 - g3) * gm1 - g1 * (gm2 - gm3) +
g3 * gm2 - g2 * gm3) / denominator
caldata["delta_e"] = - ((g1 * (gm2 - gm3) - g2 * gm2 + g3 *
gm3) * gm1 + (g2 * gm3 - g3 * gm3) *
gm2) / denominator
except ZeroDivisionError as exc:
self.isCalculated = False
logger.error(
"Division error - did you use the same measurement"
" for two of short, open and load?")
raise ValueError(
f"Two of short, open and load returned the same"
f" values at frequency {freq}Hz.") from exc
if self.isValid2Port():
caldata["e30"] = caldata["isolation"].z
gt = self.gamma_through(freq)
caldata["e10e32"] = (caldata["through"].z / gt - caldata["e30"]
) * (1 - caldata["e11"]**2)
self.gen_interpolation()
self.isCalculated = True
logger.debug("Calibration correctly calculated.")
def gamma_short(self, freq: int) -> complex:
g = Calibration.IDEAL_SHORT
if not self.useIdealShort:
logger.debug("Using short calibration set values.")
Zsp = complex(0, 1) * 2 * math.pi * freq * (
self.shortL0 + self.shortL1 * freq +
self.shortL2 * freq**2 + self.shortL3 * freq**3)
# Referencing https://arxiv.org/pdf/1606.02446.pdf (18) - (21)
g = (Zsp / 50 - 1) / (Zsp / 50 + 1) * cmath.exp(
complex(0, 1) * 2 * math.pi * 2 * freq *
self.shortLength * -1)
return g
def gamma_open(self, freq: int) -> complex:
g = Calibration.IDEAL_OPEN
if not self.useIdealOpen:
logger.debug("Using open calibration set values.")
divisor = (2 * math.pi * freq * (
self.openC0 + self.openC1 * freq +
self.openC2 * freq**2 + self.openC3 * freq**3))
if divisor != 0:
Zop = complex(0, -1) / divisor
g = ((Zop / 50 - 1) / (Zop / 50 + 1)) * cmath.exp(
complex(0, 1) * 2 * math.pi *
2 * freq * self.openLength * -1)
return g
def gamma_load(self, freq: int) -> complex:
g = Calibration.IDEAL_LOAD
if not self.useIdealLoad:
logger.debug("Using load calibration set values.")
Zl = self.loadR + (complex(0, 1) * 2 *
math.pi * freq * self.loadL)
g = (Zl / 50 - 1) / (Zl / 50 + 1) * cmath.exp(
complex(0, 1) * 2 * math.pi *
2 * freq * self.loadLength * -1)
return g
def gamma_through(self, freq: int) -> complex:
g = complex(1, 0)
if not self.useIdealThrough:
logger.debug("Using through calibration set values.")
g = cmath.exp(complex(0, 1) * 2 * math.pi *
self.throughLength * freq * -1)
return g
def gen_interpolation(self):
freq = []
e00 = []
e11 = []
delta_e = []
e30 = []
e10e32 = []
for caldata in self.dataset.values():
freq.append(caldata["freq"])
e00.append(caldata["e00"])
e11.append(caldata["e11"])
delta_e.append(caldata["delta_e"])
e30.append(caldata["e30"])
e10e32.append(caldata["e10e32"])
self.interp = {
"e00": interp1d(freq, e00,
kind="slinear", bounds_error=False,
fill_value=(e00[0], e00[-1])),
"e11": interp1d(freq, e11,
kind="slinear", bounds_error=False,
fill_value=(e11[0], e11[-1])),
"delta_e": interp1d(freq, delta_e,
kind="slinear", bounds_error=False,
fill_value=(delta_e[0], delta_e[-1])),
"e30": interp1d(freq, e30,
kind="slinear", bounds_error=False,
fill_value=(e30[0], e30[-1])),
"e10e32": interp1d(freq, e10e32,
kind="slinear", bounds_error=False,
fill_value=(e10e32[0], e10e32[-1])),
}
def correct11(self, dp: Datapoint):
i = self.interp
s11 = (dp.z - i["e00"](dp.freq)) / (
(dp.z * i["e11"](dp.freq)) - i["delta_e"](dp.freq))
return Datapoint(dp.freq, s11.real, s11.imag)
def correct21(self, dp: Datapoint):
i = self.interp
s21 = (dp.z - i["e30"](dp.freq)) / i["e10e32"](dp.freq)
return Datapoint(dp.freq, s21.real, s21.imag)
# TODO: implement tests
def save(self, filename: str):
# Save the calibration data to file
if not self.isValid1Port():
raise ValueError("Not a valid 1-Port calibration")
with open(f"{filename}", "w") as calfile:
calfile.write("# Calibration data for NanoVNA-Saver\n")
for note in self.notes:
calfile.write(f"! {note}\n")
calfile.write(
"# Hz ShortR ShortI OpenR OpenI LoadR LoadI"
" ThroughR ThroughI IsolationR IsolationI\n")
for freq in self.dataset.frequencies():
calfile.write(f"{self.dataset.get(freq)}\n")
# TODO: implement tests
# TODO: Exception should be catched by caller
def load(self, filename):
self.source = os.path.basename(filename)
self.dataset = CalDataSet()
self.notes = []
parsed_header = False
with open(filename) as calfile:
for i, line in enumerate(calfile):
line = line.strip()
if line.startswith("!"):
note = line[2:]
self.notes.append(note)
continue
if line.startswith("#"):
if not parsed_header and line == (
"# Hz ShortR ShortI OpenR OpenI LoadR LoadI"
" ThroughR ThroughI IsolationR IsolationI"):
parsed_header = True
continue
if not parsed_header:
logger.warning(
"Warning: Read line without having read header: %s",
line)
continue
m = RXP_CAL_LINE.search(line)
if not m:
logger.warning("Illegal data in cal file. Line %i", i)
cal = m.groupdict()
nr_cals = 5 if cal["throughr"] else 3
for name in Calibration.CAL_NAMES[:nr_cals]:
self.dataset.insert(
name,
Datapoint(int(cal["freq"]),
float(cal[f"{name}r"]),
float(cal[f"{name}i"])))