Calibration.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385
  1. # NanoVNASaver
  2. #
  3. # A python program to view and export Touchstone data from a NanoVNA
  4. # Copyright (C) 2019, 2020 Rune B. Broberg
  5. # Copyright (C) 2020 NanoVNA-Saver Authors
  6. #
  7. # This program is free software: you can redistribute it and/or modify
  8. # it under the terms of the GNU General Public License as published by
  9. # the Free Software Foundation, either version 3 of the License, or
  10. # (at your option) any later version.
  11. #
  12. # This program is distributed in the hope that it will be useful,
  13. # but WITHOUT ANY WARRANTY; without even the implied warranty of
  14. # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  15. # GNU General Public License for more details.
  16. #
  17. # You should have received a copy of the GNU General Public License
  18. # along with this program. If not, see <https://www.gnu.org/licenses/>.
  19. import logging
  20. import cmath
  21. import math
  22. import os
  23. import re
  24. from collections import defaultdict, UserDict
  25. from typing import List
  26. from scipy.interpolate import interp1d
  27. from .RFTools import Datapoint
  28. RXP_CAL_LINE = re.compile(r"""^\s*
  29. (?P<freq>\d+) \s+
  30. (?P<shortr>[-0-9Ee.]+) \s+ (?P<shorti>[-0-9Ee.]+) \s+
  31. (?P<openr>[-0-9Ee.]+) \s+ (?P<openi>[-0-9Ee.]+) \s+
  32. (?P<loadr>[-0-9Ee.]+) \s+ (?P<loadi>[-0-9Ee.]+)(?: \s
  33. (?P<throughr>[-0-9Ee.]+) \s+ (?P<throughi>[-0-9Ee.]+) \s+
  34. (?P<isolationr>[-0-9Ee.]+) \s+ (?P<isolationi>[-0-9Ee.]+)
  35. )?
  36. """, re.VERBOSE)
  37. logger = logging.getLogger(__name__)
  38. def correct_delay(d: Datapoint, delay: float, reflect: bool = False):
  39. mult = 2 if reflect else 1
  40. corr_data = d.z * cmath.exp(
  41. complex(0, 1) * 2 * math.pi * d.freq * delay * -1 * mult)
  42. return Datapoint(d.freq, corr_data.real, corr_data.imag)
  43. class CalData(UserDict):
  44. def __init__(self):
  45. data = {
  46. "short": None,
  47. "open": None,
  48. "load": None,
  49. "through": None,
  50. "isolation": None,
  51. # the frequence
  52. "freq": 0,
  53. # 1 Port
  54. "e00": 0.0, # Directivity
  55. "e11": 0.0, # Port match
  56. "delta_e": 0.0, # Tracking
  57. # 2 port
  58. "e30": 0.0, # Port match
  59. "e10e32": 0.0, # Transmission
  60. }
  61. super().__init__(data)
  62. def __str__(self):
  63. d = self.data
  64. s = (f'{d["freq"]}'
  65. f' {d["short"].re} {d["short"].im}'
  66. f' {d["open"].re} {d["open"].im}'
  67. f' {d["load"].re} {d["load"].im}')
  68. if d["through"] is not None:
  69. s += (f' {d["through"].re} {d["through"].im}'
  70. f' {d["isolation"].re} {d["isolation"].im}')
  71. return s
  72. class CalDataSet:
  73. def __init__(self):
  74. self.data = defaultdict(CalData)
  75. def insert(self, name: str, dp: Datapoint):
  76. if name not in self.data[dp.freq]:
  77. raise KeyError(name)
  78. self.data[dp.freq]["freq"] = dp.freq
  79. self.data[dp.freq][name] = dp
  80. def frequencies(self) -> List[int]:
  81. return sorted(self.data.keys())
  82. def get(self, freq: int) -> CalData:
  83. return self.data[freq]
  84. def items(self):
  85. for item in self.data.items():
  86. yield item
  87. def values(self):
  88. for freq in self.frequencies():
  89. yield self.get(freq)
  90. def size_of(self, name: str) -> int:
  91. return len([v for v in self.data.values() if v[name] is not None])
  92. def complete1port(self) -> bool:
  93. for val in self.data.values():
  94. for name in ("short", "open", "load"):
  95. if val[name] is None:
  96. return False
  97. return any(self.data)
  98. def complete2port(self) -> bool:
  99. for val in self.data.values():
  100. for name in ("short", "open", "load", "through", "isolation"):
  101. if val[name] is None:
  102. return False
  103. return any(self.data)
  104. class Calibration:
  105. CAL_NAMES = ("short", "open", "load", "through", "isolation",)
  106. IDEAL_SHORT = complex(-1, 0)
  107. IDEAL_OPEN = complex(1, 0)
  108. IDEAL_LOAD = complex(0, 0)
  109. def __init__(self):
  110. self.notes = []
  111. self.dataset = CalDataSet()
  112. self.interp = {}
  113. self.useIdealShort = True
  114. self.shortL0 = 5.7 * 10E-12
  115. self.shortL1 = -8960 * 10E-24
  116. self.shortL2 = -1100 * 10E-33
  117. self.shortL3 = -41200 * 10E-42
  118. self.shortLength = -34.2 # Picoseconfrequenciesds
  119. # These numbers look very large, considering what Keysight
  120. # suggests their numbers are.
  121. self.useIdealOpen = True
  122. # Subtract 50fF for the nanoVNA calibration if nanoVNA is
  123. # calibrated?
  124. self.openC0 = 2.1 * 10E-14
  125. self.openC1 = 5.67 * 10E-23
  126. self.openC2 = -2.39 * 10E-31
  127. self.openC3 = 2.0 * 10E-40
  128. self.openLength = 0
  129. self.useIdealLoad = True
  130. self.loadR = 25
  131. self.loadL = 0
  132. self.loadC = 0
  133. self.loadLength = 0
  134. self.useIdealThrough = True
  135. self.throughLength = 0
  136. self.isCalculated = False
  137. self.source = "Manual"
  138. def insert(self, name: str, data: List[Datapoint]):
  139. for dp in data:
  140. self.dataset.insert(name, dp)
  141. def size(self) -> int:
  142. return len(self.dataset.frequencies())
  143. def data_size(self, name) -> int:
  144. return self.dataset.size_of(name)
  145. def isValid1Port(self) -> bool:
  146. return self.dataset.complete1port()
  147. def isValid2Port(self) -> bool:
  148. return self.dataset.complete2port()
  149. def calc_corrections(self):
  150. if not self.isValid1Port():
  151. logger.warning(
  152. "Tried to calibrate from insufficient data.")
  153. raise ValueError(
  154. "All of short, open and load calibration steps"
  155. "must be completed for calibration to be applied.")
  156. logger.debug("Calculating calibration for %d points.", self.size())
  157. for freq, caldata in self.dataset.items():
  158. g1 = self.gamma_short(freq)
  159. g2 = self.gamma_open(freq)
  160. g3 = self.gamma_load(freq)
  161. gm1 = caldata["short"].z
  162. gm2 = caldata["open"].z
  163. gm3 = caldata["load"].z
  164. try:
  165. denominator = (g1 * (g2 - g3) * gm1 +
  166. g2 * g3 * gm2 - g2 * g3 * gm3 -
  167. (g2 * gm2 - g3 * gm3) * g1)
  168. caldata["e00"] = - ((g2 * gm3 - g3 * gm3) * g1 * gm2 -
  169. (g2 * g3 * gm2 - g2 * g3 * gm3 -
  170. (g3 * gm2 - g2 * gm3) * g1) * gm1
  171. ) / denominator
  172. caldata["e11"] = ((g2 - g3) * gm1 - g1 * (gm2 - gm3) +
  173. g3 * gm2 - g2 * gm3) / denominator
  174. caldata["delta_e"] = - ((g1 * (gm2 - gm3) - g2 * gm2 + g3 *
  175. gm3) * gm1 + (g2 * gm3 - g3 * gm3) *
  176. gm2) / denominator
  177. except ZeroDivisionError:
  178. self.isCalculated = False
  179. logger.error(
  180. "Division error - did you use the same measurement"
  181. " for two of short, open and load?")
  182. raise ValueError(
  183. f"Two of short, open and load returned the same"
  184. f" values at frequency {freq}Hz.")
  185. if self.isValid2Port():
  186. caldata["e30"] = caldata["isolation"].z
  187. gt = self.gamma_through(freq)
  188. caldata["e10e32"] = (caldata["through"].z / gt - caldata["e30"]
  189. ) * (1 - caldata["e11"]**2)
  190. self.gen_interpolation()
  191. self.isCalculated = True
  192. logger.debug("Calibration correctly calculated.")
  193. def gamma_short(self, freq: int) -> complex:
  194. g = Calibration.IDEAL_SHORT
  195. if not self.useIdealShort:
  196. logger.debug("Using short calibration set values.")
  197. Zsp = complex(0, 1) * 2 * math.pi * freq * (
  198. self.shortL0 + self.shortL1 * freq +
  199. self.shortL2 * freq**2 + self.shortL3 * freq**3)
  200. # Referencing https://arxiv.org/pdf/1606.02446.pdf (18) - (21)
  201. g = (Zsp / 50 - 1) / (Zsp / 50 + 1) * cmath.exp(
  202. complex(0, 1) * 2 * math.pi * 2 * freq *
  203. self.shortLength * -1)
  204. return g
  205. def gamma_open(self, freq: int) -> complex:
  206. g = Calibration.IDEAL_OPEN
  207. if not self.useIdealOpen:
  208. logger.debug("Using open calibration set values.")
  209. divisor = (2 * math.pi * freq * (
  210. self.openC0 + self.openC1 * freq +
  211. self.openC2 * freq**2 + self.openC3 * freq**3))
  212. if divisor != 0:
  213. Zop = complex(0, -1) / divisor
  214. g = ((Zop / 50 - 1) / (Zop / 50 + 1)) * cmath.exp(
  215. complex(0, 1) * 2 * math.pi *
  216. 2 * freq * self.openLength * -1)
  217. return g
  218. def gamma_load(self, freq: int) -> complex:
  219. g = Calibration.IDEAL_LOAD
  220. if not self.useIdealLoad:
  221. logger.debug("Using load calibration set values.")
  222. Zl = self.loadR + (complex(0, 1) * 2 *
  223. math.pi * freq * self.loadL)
  224. g = (Zl / 50 - 1) / (Zl / 50 + 1) * cmath.exp(
  225. complex(0, 1) * 2 * math.pi *
  226. 2 * freq * self.loadLength * -1)
  227. return g
  228. def gamma_through(self, freq: int) -> complex:
  229. g = complex(1, 0)
  230. if not self.useIdealThrough:
  231. logger.debug("Using through calibration set values.")
  232. g = cmath.exp(complex(0, 1) * 2 * math.pi *
  233. self.throughLength * freq * -1)
  234. return g
  235. def gen_interpolation(self):
  236. freq = []
  237. e00 = []
  238. e11 = []
  239. delta_e = []
  240. e30 = []
  241. e10e32 = []
  242. for caldata in self.dataset.values():
  243. freq.append(caldata["freq"])
  244. e00.append(caldata["e00"])
  245. e11.append(caldata["e11"])
  246. delta_e.append(caldata["delta_e"])
  247. e30.append(caldata["e30"])
  248. e10e32.append(caldata["e10e32"])
  249. self.interp = {
  250. "e00": interp1d(freq, e00,
  251. kind="slinear", bounds_error=False,
  252. fill_value=(e00[0], e00[-1])),
  253. "e11": interp1d(freq, e11,
  254. kind="slinear", bounds_error=False,
  255. fill_value=(e11[0], e11[-1])),
  256. "delta_e": interp1d(freq, delta_e,
  257. kind="slinear", bounds_error=False,
  258. fill_value=(delta_e[0], delta_e[-1])),
  259. "e30": interp1d(freq, e30,
  260. kind="slinear", bounds_error=False,
  261. fill_value=(e30[0], e30[-1])),
  262. "e10e32": interp1d(freq, e10e32,
  263. kind="slinear", bounds_error=False,
  264. fill_value=(e10e32[0], e10e32[-1])),
  265. }
  266. def correct11(self, dp: Datapoint):
  267. i = self.interp
  268. s11 = (dp.z - i["e00"](dp.freq)) / (
  269. (dp.z * i["e11"](dp.freq)) - i["delta_e"](dp.freq))
  270. return Datapoint(dp.freq, s11.real, s11.imag)
  271. def correct21(self, dp: Datapoint):
  272. i = self.interp
  273. s21 = (dp.z - i["e30"](dp.freq)) / i["e10e32"](dp.freq)
  274. return Datapoint(dp.freq, s21.real, s21.imag)
  275. # TODO: implement tests
  276. def save(self, filename: str):
  277. # Save the calibration data to file
  278. if not self.isValid1Port():
  279. raise ValueError("Not a valid 1-Port calibration")
  280. with open(f"{filename}", "w") as calfile:
  281. calfile.write("# Calibration data for NanoVNA-Saver\n")
  282. for note in self.notes:
  283. calfile.write(f"! {note}\n")
  284. calfile.write(
  285. "# Hz ShortR ShortI OpenR OpenI LoadR LoadI"
  286. " ThroughR ThroughI IsolationR IsolationI\n")
  287. for freq in self.dataset.frequencies():
  288. calfile.write(f"{self.dataset.get(freq)}\n")
  289. # TODO: implement tests
  290. # TODO: Exception should be catched by caller
  291. def load(self, filename):
  292. self.source = os.path.basename(filename)
  293. self.dataset = CalDataSet()
  294. self.notes = []
  295. parsed_header = False
  296. with open(filename) as calfile:
  297. for i, line in enumerate(calfile):
  298. line = line.strip()
  299. if line.startswith("!"):
  300. note = line[2:]
  301. self.notes.append(note)
  302. continue
  303. if line.startswith("#"):
  304. if not parsed_header:
  305. # Check that this is a valid header
  306. if line == (
  307. "# Hz ShortR ShortI OpenR OpenI LoadR LoadI"
  308. " ThroughR ThroughI IsolationR IsolationI"):
  309. parsed_header = True
  310. continue
  311. if not parsed_header:
  312. logger.warning(
  313. "Warning: Read line without having read header: %s",
  314. line)
  315. continue
  316. m = RXP_CAL_LINE.search(line)
  317. if not m:
  318. logger.warning("Illegal data in cal file. Line %i", i)
  319. cal = m.groupdict()
  320. if cal["throughr"]:
  321. nr_cals = 5
  322. else:
  323. nr_cals = 3
  324. for name in Calibration.CAL_NAMES[:nr_cals]:
  325. self.dataset.insert(
  326. name,
  327. Datapoint(int(cal["freq"]),
  328. float(cal[f"{name}r"]),
  329. float(cal[f"{name}i"])))