Touchstone.py 9.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290
  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 math
  21. import cmath
  22. import io
  23. from operator import attrgetter
  24. from typing import List
  25. from scipy.interpolate import interp1d
  26. from NanoVNASaver.RFTools import Datapoint
  27. logger = logging.getLogger(__name__)
  28. class Options:
  29. # Fun fact: In Touchstone 1.1 spec all params are optional unordered.
  30. # Just the line has to start with "#"
  31. UNIT_TO_FACTOR = {
  32. "ghz": 10**9,
  33. "mhz": 10**6,
  34. "khz": 10**3,
  35. "hz": 10**0,
  36. }
  37. VALID_UNITS = UNIT_TO_FACTOR.keys()
  38. VALID_PARAMETERS = "syzgh"
  39. VALID_FORMATS = ("ma", "db", "ri")
  40. def __init__(self,
  41. unit: str = "GHZ",
  42. parameter: str = "S",
  43. t_format: str = "ma",
  44. resistance: int = 50):
  45. # set defaults
  46. assert unit.lower() in Options.VALID_UNITS
  47. assert parameter.lower() in Options.VALID_PARAMETERS
  48. assert t_format.lower() in Options.VALID_FORMATS
  49. assert resistance > 0
  50. self.unit = unit.lower()
  51. self.parameter = parameter.lower()
  52. self.format = t_format.lower()
  53. self.resistance = resistance
  54. @property
  55. def factor(self) -> int:
  56. return Options.UNIT_TO_FACTOR[self.unit]
  57. def __str__(self) -> str:
  58. return (
  59. f"# {self.unit} {self.parameter}"
  60. f" {self.format} r {self.resistance}"
  61. ).upper()
  62. def parse(self, line: str):
  63. if not line.startswith("#"):
  64. raise TypeError("Not an option line: " + line)
  65. punit = pparam = pformat = presist = False
  66. params = iter(line[1:].lower().split())
  67. for p in params:
  68. if p in Options.VALID_UNITS and not punit:
  69. self.unit = p
  70. punit = True
  71. elif p in Options.VALID_PARAMETERS and not pparam:
  72. self.parameter = p
  73. pparam = True
  74. elif p in Options.VALID_FORMATS and not pformat:
  75. self.format = p
  76. pformat = True
  77. elif p == "r" and not presist:
  78. rstr = next(params)
  79. try:
  80. self.resistance = int(rstr)
  81. except ValueError:
  82. logger.warning("Non integer resistance value: %s", rstr)
  83. self.resistance = int(float(rstr))
  84. else:
  85. raise TypeError("Illegal option line: " + line)
  86. class Touchstone:
  87. FIELD_ORDER = ("11", "21", "12", "22")
  88. def __init__(self, filename: str):
  89. self.filename = filename
  90. self.sdata = [[], [], [], []] # at max 4 data pairs
  91. self.comments = []
  92. self.opts = Options()
  93. self._interp = {}
  94. @property
  95. def s11data(self) -> List[Datapoint]:
  96. return self.s("11")
  97. @s11data.setter
  98. def s11data(self, value: List[Datapoint]):
  99. self.sdata[0] = value
  100. @property
  101. def s12data(self) -> List[Datapoint]:
  102. return self.s("12")
  103. @s12data.setter
  104. def s12data(self, value: List[Datapoint]):
  105. self.sdata[2] = value
  106. @property
  107. def s21data(self) -> List[Datapoint]:
  108. return self.s("21")
  109. @s21data.setter
  110. def s21data(self, value: List[Datapoint]):
  111. self.sdata[1] = value
  112. @property
  113. def s22data(self) -> List[Datapoint]:
  114. return self.s("22")
  115. @s22data.setter
  116. def s22data(self, value: List[Datapoint]):
  117. self.sdata[3] = value
  118. @property
  119. def r(self) -> int:
  120. return self.opts.resistance
  121. def s(self, name: str) -> List[Datapoint]:
  122. return self.sdata[Touchstone.FIELD_ORDER.index(name)]
  123. def s_freq(self, name: str, freq: int) -> Datapoint:
  124. return Datapoint(freq,
  125. float(self._interp[name]["real"](freq)),
  126. float(self._interp[name]["imag"](freq)))
  127. def min_freq(self) -> int:
  128. return self.s("11")[0].freq
  129. def max_freq(self) -> int:
  130. return self.s("11")[-1].freq
  131. def gen_interpolation(self):
  132. for i in Touchstone.FIELD_ORDER:
  133. freq = []
  134. real = []
  135. imag = []
  136. for dp in self.s(i):
  137. freq.append(dp.freq)
  138. real.append(dp.re)
  139. imag.append(dp.im)
  140. self._interp[i] = {
  141. "real": interp1d(freq, real,
  142. kind="slinear", bounds_error=False,
  143. fill_value=(real[0], real[-1])),
  144. "imag": interp1d(freq, imag,
  145. kind="slinear", bounds_error=False,
  146. fill_value=(imag[0], imag[-1])),
  147. }
  148. def _parse_comments(self, fp) -> str:
  149. for line in fp:
  150. line = line.strip()
  151. if line.startswith("!"):
  152. logger.info(line)
  153. self.comments.append(line)
  154. continue
  155. return line
  156. def _append_line_data(self, freq: int, data: list):
  157. data_list = iter(self.sdata)
  158. vals = iter(data)
  159. for v in vals:
  160. if self.opts.format == "ri":
  161. next(data_list).append(Datapoint(freq, float(v), float(next(vals))))
  162. if self.opts.format == "ma":
  163. z = cmath.rect(float(v), math.radians(float(next(vals))))
  164. next(data_list).append(Datapoint(freq, z.real, z.imag))
  165. if self.opts.format == "db":
  166. z = cmath.rect(10 ** (float(v) / 20), math.radians(float(next(vals))))
  167. next(data_list).append(Datapoint(freq, z.real, z.imag))
  168. def load(self):
  169. logger.info("Attempting to open file %s", self.filename)
  170. try:
  171. with open(self.filename) as infile:
  172. self.loads(infile.read())
  173. except IOError as e:
  174. logger.exception("Failed to open %s: %s", self.filename, e)
  175. def loads(self, s: str):
  176. """Parse touchstone 1.1 string input
  177. appends to existing sdata if Touchstone object exists
  178. """
  179. try:
  180. self._loads(s)
  181. except TypeError as e:
  182. logger.exception("Failed to parse %s: %s", self.filename, e)
  183. def _loads(self, s: str):
  184. need_reorder = False
  185. with io.StringIO(s) as file:
  186. opts_line = self._parse_comments(file)
  187. self.opts.parse(opts_line)
  188. prev_freq = 0.0
  189. prev_len = 0
  190. for line in file:
  191. line = line.strip()
  192. # ignore empty lines (even if not specified)
  193. if line == "":
  194. continue
  195. # accept comment lines after header
  196. if line.startswith("!"):
  197. logger.warning("Comment after header: %s", line)
  198. self.comments.append(line)
  199. continue
  200. # ignore comments at data end
  201. data = line.split('!')[0]
  202. data = data.split()
  203. freq, data = round(float(data[0]) * self.opts.factor), data[1:]
  204. data_len = len(data)
  205. # consistency checks
  206. if freq <= prev_freq:
  207. logger.warning("Frequency not ascending: %s", line)
  208. need_reorder = True
  209. prev_freq = freq
  210. if prev_len == 0:
  211. prev_len = data_len
  212. if data_len % 2:
  213. raise TypeError("Data values aren't pairs: " + line)
  214. elif data_len != prev_len:
  215. raise TypeError("Inconsistent number of pairs: " + line)
  216. self._append_line_data(freq, data)
  217. if need_reorder:
  218. logger.warning("Reordering data")
  219. for datalist in self.sdata:
  220. datalist.sort(key=attrgetter("freq"))
  221. def save(self, nr_params: int = 1):
  222. """Save touchstone data to file.
  223. Args:
  224. nr_params: Number of s-parameters. 2 for s1p, 4 for s2p
  225. """
  226. logger.info("Attempting to open file %s for writing",
  227. self.filename)
  228. with open(self.filename, "w") as outfile:
  229. outfile.write(self.saves(nr_params))
  230. def saves(self, nr_params: int = 1) -> str:
  231. """Returns touchstone data as string.
  232. Args:
  233. nr_params: Number of s-parameters. 1 for s1p, 4 for s2p
  234. """
  235. assert nr_params in (1, 4)
  236. ts_str = "# HZ S RI R 50\n"
  237. for i, dp_s11 in enumerate(self.s11data):
  238. ts_str += f"{dp_s11.freq} {dp_s11.re} {dp_s11.im}"
  239. for j in range(1, nr_params):
  240. dp = self.sdata[j][i]
  241. if dp.freq != dp_s11.freq:
  242. raise LookupError("Frequencies of sdata not correlated")
  243. ts_str += f" {dp.re} {dp.im}"
  244. ts_str += "\n"
  245. return ts_str