nanovna-saver/NanoVNASaver/Calibration.py

349 wiersze
13 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 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 math
import os
import re
import numpy as np
from .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__)
# TODO: make a real class of calibration
class Calibration:
CAL_NAMES = ("short", "open", "load", "through", "isolation",)
def __init__(self):
self.notes = []
self.cals = {}
self._reset_cals()
self.frequencies = []
# 1-port
self.e00 = [] # Directivity
self.e11 = [] # Port match
self.deltaE = [] # Tracking
# 2-port
self.e30 = [] # Port match
self.e10e32 = [] # Transmission
self.shortIdeal = np.complex(-1, 0)
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 # Picoseconds
# These numbers look very large, considering what Keysight
# suggests their numbers are.
self.useIdealOpen = True
self.openIdeal = np.complex(1, 0)
# 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.loadIdeal = np.complex(0, 0)
self.useIdealThrough = True
self.throughLength = 0
self.isCalculated = False
self.source = "Manual"
def _reset_cals(self):
for name in Calibration.CAL_NAMES:
self.cals[name] = []
def isValid1Port(self):
lengths = [len(self.cals[x])
for x in Calibration.CAL_NAMES[:3]]
return min(lengths) > 0 and min(lengths) == max(lengths)
def isValid2Port(self):
lengths = [len(self.cals[x]) for x in Calibration.CAL_NAMES]
return min(lengths) > 0 and min(lengths) == max(lengths)
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.")
nr_points = len(self.cals["short"])
logger.debug("Calculating calibration for %d points.", nr_points)
self.frequencies = []
self.e00 = [np.complex] * nr_points
self.e11 = [np.complex] * nr_points
self.deltaE = [np.complex] * nr_points
self.e30 = [np.complex] * nr_points
self.e10e32 = [np.complex] * nr_points
if self.useIdealShort:
logger.debug("Using ideal values.")
else:
logger.debug("Using calibration set values.")
if self.isValid2Port():
logger.debug("Calculating 2-port calibration.")
else:
logger.debug("Calculating 1-port calibration.")
for i, cur_short in enumerate(self.cals["short"]):
cur_open = self.cals["open"][i]
cur_load = self.cals["load"][i]
f = cur_short.freq
self.frequencies.append(f)
pi = math.pi
if self.useIdealShort:
g1 = self.shortIdeal
else:
Zsp = (
np.complex(0, 1) * 2 * pi *
f * (self.shortL0 +
self.shortL1 * f +
self.shortL2 * f**2 +
self.shortL3 * f**3))
gammaShort = ((Zsp/50) - 1) / ((Zsp/50) + 1)
# (lower case) gamma = 2*pi*f
# e^j*2*gamma*length
# Referencing https://arxiv.org/pdf/1606.02446.pdf (18) - (21)
g1 = gammaShort * np.exp(
np.complex(0, 1) * 2 * math.pi *
2 * f * self.shortLength * -1)
if self.useIdealOpen:
g2 = self.openIdeal
else:
divisor = (
2 * pi * f * (
self.openC0 + self.openC1 * f +
self.openC2 * f**2 + self.openC3 * f**3)
)
if divisor != 0:
Zop = np.complex(0, -1) / divisor
gammaOpen = ((Zop/50) - 1) / ((Zop/50) + 1)
g2 = gammaOpen * np.exp(
np.complex(0, 1) * 2 * math.pi *
2 * f * self.openLength * -1)
else:
g2 = self.openIdeal
if self.useIdealLoad:
g3 = self.loadIdeal
else:
Zl = self.loadR + (
np.complex(0, 1) * 2 * math.pi * f * self.loadL)
g3 = ((Zl/50)-1) / ((Zl/50)+1)
g3 = g3 * np.exp(
np.complex(0, 1) * 2 * math.pi *
2 * f * self.loadLength * -1)
gm1 = np.complex(cur_short.re, cur_short.im)
gm2 = np.complex(cur_open.re, cur_open.im)
gm3 = np.complex(cur_load.re, cur_load.im)
try:
denominator = (
g1 * (g2 - g3) * gm1 +
g2 * g3 * gm2 -
g2 * g3 * gm3 -
(g2 * gm2 - g3 * gm3) * g1)
self.e00[i] = - (
(g2 * gm3 - g3 * gm3) * g1 * gm2 -
(g2 * g3 * gm2 - g2 * g3 * gm3 -
(g3 * gm2 - g2 * gm3) * g1) * gm1
) / denominator
self.e11[i] = (
(g2 - g3) * gm1 - g1 * (gm2 - gm3) +
g3 * gm2 - g2 * gm3
) / denominator
self.deltaE[i] = - (
(g1 * (gm2 - gm3) - g2 * gm2 + g3 * gm3) * gm1 +
(g2 * gm3 - g3 * gm3) * gm2
) / denominator
except ZeroDivisionError:
self.isCalculated = False
logger.error(
"Division error - did you use the same measurement"
" for two of short, open and load?")
logger.debug(
"Division error at index %d"
" Short == Load: %s Short == Open: %s"
" Open == Load: %s", i,
cur_short == cur_load, cur_short == cur_open,
cur_open == cur_load)
raise ValueError(
f"Two of short, open and load returned the same"
f" values at frequency {f}Hz.")
if self.isValid2Port():
cur_through = self.cals["through"][i]
cur_isolation = self.cals["isolation"][i]
self.e30[i] = np.complex(
cur_isolation.re, cur_isolation.im)
s21m = np.complex(cur_through.re, cur_through.im)
if not self.useIdealThrough:
gammaThrough = np.exp(
np.complex(0, 1) * 2 * math.pi *
self.throughLength * f * -1)
s21m = s21m / gammaThrough
self.e10e32[i] = (s21m - self.e30[i]) * (
1 - (self.e11[i] * self.e11[i]))
self.isCalculated = True
logger.debug("Calibration correctly calculated.")
def correct11(self, re, im, freq):
s11m = np.complex(re, im)
distance = 10**10
index = 0
for i, cur_short in enumerate(self.cals["short"]):
if abs(cur_short.freq - freq) < distance:
index = i
distance = abs(cur_short.freq - freq)
# TODO: Interpolate with the adjacent data point
# to get better corrections?
s11 = (s11m - self.e00[index]) / (
(s11m * self.e11[index]) - self.deltaE[index])
return s11.real, s11.imag
def correct21(self, re, im, freq):
s21m = np.complex(re, im)
distance = 10**10
index = 0
for i, cur_through in enumerate(self.cals["through"]):
if abs(cur_through.freq - freq) < distance:
index = i
distance = abs(cur_through.freq - freq)
s21 = (s21m - self.e30[index]) / self.e10e32[index]
return s21.real, s21.imag
@staticmethod
def correctDelay11(d: Datapoint, delay):
input_val = np.complex(d.re, d.im)
output = input_val * np.exp(np.complex(0, 1) * 2 * 2 * math.pi * d.freq * delay * -1)
return Datapoint(d.freq, output.real, output.imag)
@staticmethod
def correctDelay21(d: Datapoint, delay):
input_val = np.complex(d.re, d.im)
output = input_val * np.exp(np.complex(0, 1) * 2 * math.pi * d.freq * delay * -1)
return Datapoint(d.freq, output.real, output.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(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 i, cur_short in enumerate(self.cals["short"]):
cur_open = self.cals["open"][i]
cur_load = self.cals["load"][i]
data = [
cur_short.freq,
cur_short.re, cur_short.im,
cur_open.re, cur_open.im,
cur_load.re, cur_load.im,
]
if self.isValid2Port():
cur_through = self.cals["through"][i]
cur_isolation = self.cals["isolation"][i]
data.extend([
cur_through.re, cur_through.im,
cur_isolation.re, cur_isolation.im
])
calfile.write(" ".join([str(val) for val in data]))
calfile.write("\n")
# TODO: implement tests
# TODO: Exception should be catched by caller
def load(self, filename):
self.source = os.path.basename(filename)
self._reset_cals()
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:
# Check that this is a valid header
if 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()
if cal["throughr"]:
nr_cals = 5
else:
nr_cals = 3
for name in Calibration.CAL_NAMES[:nr_cals]:
self.cals[name].append(
Datapoint(int(cal["freq"]),
float(cal[f"{name}r"]),
float(cal[f"{name}i"])))