'''
==================
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()