Source code for ads.citi_to_touchstone

'''
==================
Citi to Touchstone
==================
Citifile to Touchstone fileformat converter

Currently tested with 1, 2, 3, 4, 10 and 17-port citifile generated by Momentum.
The generated Touchstone .sNp files have been tested using Python scikit-rf library and
ADS data file tool.

CITIFile documentation:
http://literature.cdn.keysight.com/litweb/pdf/ads15/cktsim/ck2016.html

Touchstone documentation:
http://literature.cdn.keysight.com/litweb/pdf/genesys200801/sim/linear_sim/sparams/touchstone_file_format.htm


Initially written by Veeti Lahtinen 2021

'''
from thesdk import *
from ads import *

import os
import sys
import numpy as np
import time

[docs] class citi_to_touchstone(thesdk): """ This class parses a citifile (.cti) file and writes the data to a Touchstone (.sNp) file. This class is utilized by the main ads class. """ @property def _classfile(self): return os.path.dirname(os.path.realpath(__file__)) + "/"+__name__ def __init__(self,**kwargs): pass @property def input_file(self): '''String Path to the CITIfile to convert ('self.adssrc/proj.cti') Should be given in run_ads! ''' if not hasattr(self, '_input_file'): self._input_file='' return self._input_file @input_file.setter def input_file(self,value): self._input_file=value @property def output_file(self): ''' String Path to the output touchstone file to be generated ('self.adssimpath/self.sparam_filename') Should be given in run_ads! Give the sparam_filename without the .sNp extension, because the name depends on the number of ports in the layout! This is automatically generated in self.output_file_extension!! ''' if not hasattr(self,'_output_file'): self._output_file='' return self._output_file @output_file.setter def output_file(self,value): self._output_file = f'{value}' @property def output_file_extension(self): ''' String Output file extension. Always in the format .sNp, where N depends on the number of ports. Automatically read from the citifile. This should not be changed manually. ''' if not hasattr(self,'_output_file_extension'): self._output_file_extension = '' return self._output_file_extension @output_file_extension.setter def output_file_extension(self,value): self._output_file_extension = value @property def nbr_of_ports(self): '''Integer Number of ports in the layout. Automatically read from the citifile. This should not be set manually. ''' if not hasattr(self,'_nbr_of_ports'): self._nbr_of_ports = 0 return self._nbr_of_ports @nbr_of_ports.setter def nbr_of_ports(self,value): self._nbr_of_ports = value @property def normalization(self): ''' Integer Normalization value. Automatically read from the citifile. This should not be set manually. ''' if not hasattr(self,'_normalization'): self._normalization = 0 return self._normalization @normalization.setter def normalization(self,value): self._normalization = value @property def comments(self): ''' List List of comments in the CITIfile. Automatically read from the citifile. This can not be changed manually. ''' if not hasattr(self,'_comments'): self._comments = [] return self._comments @property def var_name(self): ''' String CITIfile VAR name. Should always be "freq" from Momentum. Automatically read from the citifile. This should not be changed manually. ''' if not hasattr(self,'_var_name'): self._var_name = 'freq' return self._var_name @var_name.setter def var_name(self,value): self._var_name = value @property def var_format(self): ''' String CITIfile VAR format. Should always be "MAG" after reading. Raises error if not. Automatically read from the citifile. This should not be changed manually. ''' if not hasattr(self,'_var_format'): self._var_format = '' return self._var_format @var_format.setter def var_format(self,value): self._var_format = value @property def var_nbr_of_points(self): ''' Integer Number of frequency points in the CITIfile. This parameter is VERY important, since it is also used to determine the lenghts of the data vectors. Automatically read from the citifile. This should not be changed manually. ''' if not hasattr(self,'_var_nbr_of_points'): self._var_nbr_of_points = 0 return self._var_nbr_of_points @var_nbr_of_points.setter def var_nbr_of_points(self,value): self._var_nbr_of_points = value @property def var_data(self): ''' Numpy array Frequency data read from the CITIfile. The lenght of this array is self.var_nbr_of_points. Automatically read from the citifile. This should not be changed manually. ''' if not hasattr(self,'_var_data'): self._var_data = None return self._var_data @var_data.setter def var_data(self,value): self._var_data = value @property def data_names(self): ''' List List containing all of the S-parameter and Port data names. The order is important, because Momentum does not return any data headers on the CITIfile, so this is used to determine which data vector corresponds to what parameter. Automatically read from the citifile. This can not be changed manually. ''' if not hasattr(self,'_data_names'): self._data_names = [] return self._data_names @property def data(self): ''' Dictionary Contains all the information about the data. The dictionary key is the data name, that can be retreived using self.data_names. The dictionary contains another dictionary containing the following keys: 'format' the format of the data. Should always be "RI" from a CITIfile 'unit' the unit of the data. Momentum does not seem to output the unit 'data' numpy array containing the parameter data. Lenght of this array can be found from self.var_nbr_of_points Automatically read from the citifile. This can not be changed manually. ''' if not hasattr(self,'_data'): self._data = {} return self._data @property def lines(self): ''' List Contains all of the lines in the citifile. If the citifile can not be found, it gives 5 tries with one second delays. IF the citifile is not found after the 5 tries, the citifile is not converted to touchstone. Automatically read from the citifile. This should not be changed manually. ''' if not hasattr(self,'_lines'): # Check that given file exists. # Try to see it 5 times, with 1s delays. count = 0 while not os.path.exists(self.input_file): os.system('sync %s' % self.input_file) self.print_log(type='I',msg=f"Attempting to find the CITIfile {self.input_file}. Try number {count+1}") if count >= 4: self.print_log(type='E',msg=f"Can't find the CITIfile in the path {self.input_file}.") break time.sleep(1) count+=1 if os.path.exists(self.input_file): with open(self.input_file,'r') as openfile: self._lines = openfile.readlines() else: self._lines=[] self.print_log(type='E',msg=f"File {self.input_file} does not exist.") return self._lines
[docs] def parse_citi(self): ''' Automatically called function to parse through the citifile. ''' self.print_log(type='I',msg=f'Parsing {self.input_file} citifile') i = 0 # Which line we are currently reading data_sequence_counter = 0 # required, because Momentum citifile does not name the data. while i < len(self.lines): line = self.lines[i].strip() # Get data from comment lines, convert them to touchstone comments # Which means that "#"->"!" if line.startswith('#'): line = line.replace('#','! ') self.comments.append(line) # Skip empty lines elif len(line) == 0: pass else: # Split the line to max 2 parts using space delimiter parts = line.split(' ',1) # Start checking headers if 'CONSTANT' in parts[0]: constant, value = parts[1].split(' ', 1) if 'NBR_OF_PORTS' in constant: self.nbr_of_ports = int(value) self.output_file_extension = f'.s{self.nbr_of_ports}p' elif 'NORMALIZATION' in constant: self.normalization = value elif parts[0] == 'VAR': # == used to distinguis from VAR_LIST_BEGIN self.var_name,self.var_format,var_nbr_of_points = parts[1].split(' ') self.var_nbr_of_points = int(var_nbr_of_points) elif 'DATA' in parts[0]: name, fmt = parts[1].split(' ',1) # Because Momentum doesnt output name with data, have to # keep track of the order they are given. self.data_names.append(name) self.data[name] = { 'format': fmt, 'unit': None, 'data': None } elif 'VAR_LIST_BEGIN' in parts[0]: # Might contain additional info or might not. # Have not seen any with momentum so far. # Check it anyway: if len(parts) > 1: unit = parts[1].split(' ',1)[1] else: unit = "" self.var_unit = unit # Move to next line to find correct row i+=1 # Read from current row to var_nbr_of_points if 'MAG' in self.var_format: self.var_data = np.loadtxt(self.input_file,dtype=float,skiprows=i,max_rows=self.var_nbr_of_points) else: self.print_log(type='E',msg=f"Frequency format: {self.var_format} should not exist. Check your CITIFile...") # Add the numpy read lines to the line number index. i+=self.var_nbr_of_points elif 'BEGIN' in parts[0]: # Might contain additional info or might not. # Have not seen any with momentum so far. # Check it anyways if len(parts) > 1: name,unit = parts[1].split(' ',1) else: name = None unit = "" # Does not seem to come from Momentum # Will have to rely on the sequence they were listed earlier. if not name: name = self.data_names[data_sequence_counter] self.data[name]['unit'] = unit # Move to next line containing the data i+=1 # Read from current row to current row + var_nbr_of_points if 'RI' in self.data[name]['format']: self.data[name]['data']= np.loadtxt(self.input_file,dtype=float,skiprows=i,max_rows=self.var_nbr_of_points,delimiter=',').reshape(-1,2) else: self.print_log(type='E',msg=f"Data format: {self.data[name]['format']} should not exist. Check your CITIFile...") # Add the numpy read lines to the index i+=self.var_nbr_of_points # Add one to the data_sequence_counter, to see which data point is next. data_sequence_counter+=1 else: # Not implemented keyword checks: CITIFILE, COMMENT, VAR_LIST_END, SEG_LIST_BEGIN # SEG_LIST_END, END and NAME # Are these required? pass i+=1
[docs] def write_touchstone(self): ''' Automatically called function to generate a Touchstone .sNp file from the data read from the CITIfile.''' try: filename = self.output_file+self.output_file_extension if not os.path.exists(filename): self.print_log(type='I',msg=f'Writing the S-Parameter data to {filename}') with open(filename,'w') as outfile: # Begin by writing the comments to the file for comment in self.comments: outfile.write(comment+'\n') # Write normalization info to the sNp file as comment outfile.write(f'! NORMALIZATION: {self.normalization}\n') # Write the data header part # Documentation for the data header part: # "# <FREQ_UNITS> <TYPE> <FORMAT> <Rn>", where "#" is the option line delimiter. #<FREQ_UNITS> = Units of the frequency data. Options are GHz, MHz, KHz, or Hz. #<TYPE> = Type of file data. Options are: S, Y or Z for S1P components, S, Y, Z, G, or H for S2P components, S for 3 or more ports #<FORMAT> = S-parameter format. Options are: DB for dB-angle, MA for magnitude angle, RI for real-imaginary #<Rn> = Reference resistance in ohms, where n is a positive number. This is the impedance the S-parameters were normalized to. if self.var_unit == '': freq_unit = 'hz' # Is this true? Seems to be working... # Assumption: all ports have same impedance. It does not seem possible to define different # port impedances in touchstone format. outfile.write(f'# {freq_unit} S {self.data[self.data_names[0]]["format"].lower()} R {self.data["PORTZ[1]"]["data"][0][0]}\n') # Another comment that is often in Touchstone files outfile.write(f'! {self.nbr_of_ports} Port Network Data from data block\n') # For some ungodly reason, Touchstone has different logic / order of ports for 2-port than any other # amount of ports... Also for less than 3 ports, data comes on the same line. # 2 ports: if self.nbr_of_ports == 2: # Positions hard coded. Since Keysight Momentum does not label the data in the citifile, this is probably the only way S11 = self.data_names[0] S12 = self.data_names[1] S21 = self.data_names[2] S22 = self.data_names[3] # S11 S21 S12 S22 is the order for 2-port... # Write this to the file to improve readability. outfile.write(f'! freq Re{S11} Im{S11} Re{S21} Im{S21} Re{S12} Im{S12} Re{S22} Im{S22}\n! \n') for i in range(self.var_nbr_of_points): # Hope that Keysight does not change the order the data comes in S11_data = self.data[S11]["data"][i] # Index 0 = real values, 1 = imaginary. S12_data = self.data[S12]["data"][i] S21_data = self.data[S21]["data"][i] S22_data = self.data[S22]["data"][i] datastring = f'{self.var_data[i]} {S11_data[0]} {S11_data[1]} {S21_data[0]} {S21_data[1]} {S12_data[0]} {S12_data[1]} {S22_data[0]} {S22_data[1]}\n' outfile.write(datastring) else: # Write the comment outlining the structure of the file. Makes readability better nextstring = '! freq ' for i in range(len(self.data_names)): # List all of the ports not including the port impedances. if not 'PORTZ' in self.data_names[i]: nextstring = nextstring + f'Re{self.data_names[i]} Im{self.data_names[i]} ' # Add linebreak after listing every port. if (i+1) % self.nbr_of_ports == 0: #i+1 to avoid first linebreak on the first line # Add linebreak and create new comment line nextstring = nextstring + '\n! ' # Stop writing when it starts listing the port impedances else: break # Write it to the file, add final linebreak. outfile.write(nextstring+'\n') # Writing the actual data. for i in range(self.var_nbr_of_points): nextstring = f'{self.var_data[i]} ' # Frequency for j in range(len(self.data_names)): if not 'PORTZ' in self.data_names[j]: # a solution to skip Z-port data nextstring = f'{nextstring} {self.data[self.data_names[j]]["data"][i][0]} {self.data[self.data_names[j]]["data"][i][1] }' # Add linebreak after listing every port. # Example for 3-port: # <FREQ> |S11| <S11 |S12| <S12 |S13| <S13 # |S21| <S21 |S22| <S22 |S23| <S23 # |S31| <S31 |S32| <S32 |S33| <S33 if (j+1) % self.nbr_of_ports == 0: #j+1 to avoid first linebreak on the first line nextstring = nextstring + '\n' else: break outfile.write(nextstring+'\n') else: self.print_log(type='E',msg=f"File: {filename} already exists!") except AttributeError: self.print_log(type='E',msg="Error writing the S-Parameter Touchstone file.")
[docs] def generate_contents(self): ''' Externally called function to read the citifile data and generate the touchstone file.''' self.parse_citi() self.write_touchstone()