Skip to content
Snippets Groups Projects
Commit f1aac102 authored by Martin Killenberg's avatar Martin Killenberg
Browse files

feat: separate VNA into generic DUT class

All notions of VNA, magnitude and phase in prototype.py and the GUI have been
removed and generalised.
This is just refactoring, no functional change. However, the syntax of the
config file has been adapted.
parent 02959132
No related branches found
No related tags found
1 merge request!5feat: separate VNA into generic DUT class
......@@ -74,8 +74,7 @@ class TestStandMainWindow(QMainWindow):
self.setEnabled(True)
return
meas = prototype.Measurements(config_data['chamber_ip'], config_data['vna_ip'], output_basename,
False, config_data, ext_sensor_channels, config_data['logger_ip'])
meas = prototype.Measurements(config_data, output_basename,False, ext_sensor_channels)
try:
if self.tempSweepButton.isChecked():
temperatures = meas.perform_sweep(self.startParameter.value(), self.stopParameter.value(),
......
from abc import ABC, abstractmethod
class DutMeasurement(ABC):
"""
Interface for DUT measurements
"""
@abstractmethod
def get_dut_measurements(self):
"""
Returns a dictionary with column names a keys and scalar values
"""
pass
@abstractmethod
def get_dut_signal_names(self):
"""
Returns a list of names used as keys used in the dut measurements dictionary
"""
pass
@abstractmethod
def get_dut_reference_signal_names(self):
"""
Returns a list of signal names used for stability checks
"""
pass
@abstractmethod
def get_dut_max_delta_signals(self):
"""
Return the maximum deltas of the reference signals
"""
#!/usr/bin/python3
import csv
import math
import cmath
import time
import numpy
from argparse import ArgumentParser
import pandas as pd
import matplotlib.pyplot as plt
import climate_chamber
import VNA
import vna_measurement
import virtual_time
import json
import MeasurementPlot
......@@ -17,34 +15,28 @@ import analysis
import external_sensors
import PostPlot
import os
import pyvisa
# Only use these when calculating the equilibrium indicator. Don't use in the algorithm.
TEMPERATURE_STABLE = 0x1
HUMIDITY_STABLE = 0x2
MAGNITUDE_STABLE = 0x4
PHASE_STABLE = 0x8
DUT_SIGNAL0_STABLE = 0x4
DUT_SIGNAL1_STABLE = 0x8
MEASUREMENT_STABLE = 0x10
# Class has only attributes which we are using in the read_data_function to read data from VNA and Chamber
# Class has only attributes which we are using in the read_data_function to read data from DUT and Chamber
class MeasurementData:
def __init__(self, timestamp, temp, hum, power, frequency, s11, s21, s12, s22, perc_temp_heater,
perc_hum_heater, temp_dut, temp_room, temp_meas_instr, hum_dut, hum_room,
def __init__(self, timestamp, temp, hum, dut_data, percent_temp_heater,
percent_hum_heater, temp_dut, temp_room, temp_meas_instr, hum_dut, hum_room,
hum_meas_instr, air_press_room, temp_chamber_meas_instr, hum_chamber_meas_instr):
self.timestamp = timestamp
self.temp = temp
self.hum = hum
self.power = power
self.frequency = frequency
self.s11 = s11
self.s21 = s21
self.s12 = s12
self.s22 = s22
self.perc_temp_heater = perc_temp_heater
self.perc_hum_heater = perc_hum_heater
self.dut_data = dut_data
self.percent_temp_heater = percent_temp_heater
self.percent_hum_heater = percent_hum_heater
self.temp_dut = temp_dut
self.temp_room = temp_room
self.temp_meas_instr = temp_meas_instr
......@@ -57,40 +49,35 @@ class MeasurementData:
class Measurements:
def __init__(self, chamber_address, vna_address, output_basename, standby, config_data,
ext_sensor_channels, logger_address):
def __init__(self, config_data, output_basename, standby, ext_sensor_channels):
self.max_delta_temp = config_data['delta_temp']
self.max_delta_hum = config_data['delta_hum']
self.max_delta_mag = config_data['delta_mag']
self.max_delta_phase = config_data['delta_phase']
self.ext_sensor_channels = ext_sensor_channels
self.logger_model = config_data['logger_model']
self.sleep_time = config_data["sleep_time"]
self.frequency = config_data["frequency"]
self.vna_config_file = config_data["vna_config_file"]
target_accuracy = [self.max_delta_temp, self.max_delta_hum]
self.chamber = climate_chamber.create_chamber(chamber_address, target_accuracy)
self.chamber = climate_chamber.create_chamber(config_data['chamber_ip'], target_accuracy)
self.instr_chamber = climate_chamber.create_chamber(config_data['instr_chamber_ip'],
target_accuracy)
self.vna = VNA.create_vna(vna_address, target_accuracy)
# FIXME: Do we want a factory function for the DUTs?
if config_data['dut']['type'] == 'VNA':
self.dut = vna_measurement.VnaMeasurement(config_data['dut'], target_accuracy)
else:
raise Exception('Unknown DUT type: '+config_data['dut']['type'])
self.max_delta_dut_signals = self.dut.get_dut_max_delta_signals()
# data logger for external sensors
self.ext_sensors = external_sensors.create_sensors(config_data['logger_model'], logger_address,
self.ext_sensors = external_sensors.create_sensors(config_data['logger_model'], config_data['logger_ip'],
ext_sensor_channels)
self.standby = standby
self.output_basename = output_basename
self.clock = virtual_time.get_clock(chamber_address, target_accuracy)
self.clock = virtual_time.get_clock(config_data['chamber_ip'], target_accuracy)
self.temperature_stable = False
self.humidity_stable = False
self.magnitude_stable = False
self.phase_stable = False
self.vna.load_config(self.vna_config_file, self.frequency)
self.vna.create_new_trace("Trace1", "S11")
self.vna.create_new_trace("Trace2", "S12")
self.vna.create_new_trace("Trace3", "S21")
self.vna.create_new_trace("Trace4", "S22")
self.dut_signals_stable = [False, False]
self.postplot_obj = None
self.measurement_plot = MeasurementPlot.MeasurementPlot(trace_subplot5=config_data['trace_subplot5'])
......@@ -170,13 +157,11 @@ class Measurements:
def perform_single_measurement(self, output, target_temp, target_hum, soaking_time, n_stable_reads):
with open(output, mode='w', newline='') as csv_file:
fieldnames = ['TIMESTAMP', 'TARGET_TEMPERATURE', 'READBACK_TEMPERATURE', 'TARGET_HUMIDITY',
'READBACK_HUMIDITY', 'RF_POWER', 'RF_FREQUENCY', 'DUT_IDENTIFIER', 'RUN_ID',
'EQUILIBRIUM_INDICATOR', 'TEMP_HEATER', 'HUM_HEATER',
'TEMP_DUT', 'TEMP_ROOM', 'TEMP_MEAS_INSTR',
'HUM_DUT','HUM_ROOM', 'HUM_MEAS_INSTR', 'AIR_PRESS_ROOM',
'READBACK_TEMP_MEAS_INSTR', 'READBACK_HUM_MEAS_INSTR',
'S11_MAGNITUDE', 'S11_PHASE', 'S12_MAGNITUDE',
'S12_PHASE', 'S21_MAGNITUDE', 'S21_PHASE', 'S22_MAGNITUDE', 'S22_PHASE']
'READBACK_HUMIDITY', 'DUT_IDENTIFIER', 'RUN_ID', 'EQUILIBRIUM_INDICATOR', 'TEMP_HEATER',
'HUM_HEATER', 'TEMP_DUT', 'TEMP_ROOM', 'TEMP_MEAS_INSTR', 'HUM_DUT', 'HUM_ROOM',
'HUM_MEAS_INSTR', 'AIR_PRESS_ROOM', 'READBACK_TEMP_MEAS_INSTR', 'READBACK_HUM_MEAS_INSTR']
fieldnames.extend(self.dut.get_dut_signal_names())
# csv.dict writer add adda row wise
writer = csv.DictWriter(csv_file, fieldnames=fieldnames)
writer.writeheader()
......@@ -196,8 +181,7 @@ class Measurements:
next_read_time = self.clock.time() + self.sleep_time
while do_another_measurement:
# wait until set point is reached (+soaking time)
magnitudes_queue = []
phase_queue = []
dut_signal_queues = [[], []]
while True:
data = self.read_data()
......@@ -208,45 +192,35 @@ class Measurements:
self.temperature_stable = self.calculate_temperature_stability(target_temp, float(data.temp))
self.humidity_stable = self.calculate_humidity_stability(target_hum, float(data.hum))
# The queue must not be longer than the max number of soaking reads.
# If the queue is already full, we have to pop the first element before we can add the
# current measurement.
if len(magnitudes_queue) >= number_of_soaking_reads:
magnitudes_queue.pop(0)
if self.temperature_stable and self.humidity_stable:
magnitudes_queue.append(self.calculate_mean_magnitude_db(data.s21))
else:
magnitudes_queue.clear()
# check cable stability parameters
self.magnitude_stable = False
if len(magnitudes_queue) >= number_of_soaking_reads:
spread = max(magnitudes_queue) - min(magnitudes_queue)
if spread < 2*self.max_delta_mag:
self.magnitude_stable = True
if len(phase_queue) >= number_of_soaking_reads:
phase_queue.pop(0)
if self.temperature_stable and self.humidity_stable:
phase_queue.append(self.calculate_mean_phase(data.s21))
else:
phase_queue.clear()
self.phase_stable = False
if len(phase_queue) >= number_of_soaking_reads:
spread = max(phase_queue) - min(phase_queue)
if spread < 2*self.max_delta_phase:
self.phase_stable = True
# Use indexed loop because we need to modify, and zip seems to copy the content :-(
for i, signal_queue in enumerate(dut_signal_queues):
# The queue must not be longer than the max number of soaking reads.
# If the queue is already full, we have to pop the first element before we can add the
# current measurement.
if len(signal_queue) >= number_of_soaking_reads:
signal_queue.pop(0)
if self.temperature_stable and self.humidity_stable:
signal_queue.append(data.dut_data[self.dut.get_dut_reference_signal_names()[i]])
else:
signal_queue.clear()
self.dut_signals_stable[i] = False
if len(signal_queue) >= number_of_soaking_reads:
spread = max(signal_queue) - min(signal_queue)
if spread < 2*self.max_delta_dut_signals[i]:
self.dut_signals_stable[i] = True
print('Setpoint: ' + str(target_temp) + ' ' + str(target_hum) + ' | Temp: ' + data.temp +
' °C' + ' | Humid: ' + data.hum + '%'
+ ' | soaking read nr' + str(len(magnitudes_queue)))
+ ' | soaking read nr' + str(len(dut_signal_queues[0])))
self.store_and_plot_data(target_temp, target_hum, data, self.cook_up_equi_indicator())
writer.writerow(self.data_collection[-1])
if self.temperature_stable and self.humidity_stable and self.magnitude_stable and\
self.phase_stable:
reference_magnitude = magnitudes_queue[-1]
reference_phase = phase_queue[-1]
if self.temperature_stable and self.humidity_stable and self.dut_signals_stable[0] and\
self.dut_signals_stable[1]:
reference_values = []
for signal_queue in dut_signal_queues:
reference_values.append(signal_queue[-1])
print('SOAKING FINISHED!')
break
else:
......@@ -261,17 +235,18 @@ class Measurements:
data = self.read_data()
self.temperature_stable = self.calculate_temperature_stability(target_temp, float(data.temp))
self.humidity_stable = self.calculate_humidity_stability(target_hum, float(data.hum))
mag = self.calculate_mean_magnitude_db(data.s21)
phase = self.calculate_mean_phase(data.s21)
self.magnitude_stable = (reference_magnitude-self.max_delta_mag <= mag) and\
(mag <= reference_magnitude+self.max_delta_mag)
self.phase_stable = (reference_phase-self.max_delta_phase <= phase) and\
(phase <= reference_phase+self.max_delta_phase)
this_measurement_stable = (self.temperature_stable and self.humidity_stable)
for j, max_delta in enumerate(self.max_delta_dut_signals):
value = data.dut_data[self.dut.get_dut_reference_signal_names()[j]]
self.dut_signals_stable[j] = (reference_values[j] - max_delta <= value) and\
(value <= reference_values[j] + max_delta)
if not self.dut_signals_stable[j]:
this_measurement_stable = False
self.store_and_plot_data(target_temp, target_hum, data, self.cook_up_equi_indicator())
supposedly_stable_measurements.append(self.data_collection[-1])
if (self.temperature_stable and self.humidity_stable and self.magnitude_stable and
self.phase_stable):
if this_measurement_stable:
print('Stable measurement ' + str(i+1) + '/' + str(n_stable_reads))
self.sleep_until(next_read_time)
next_read_time += self.sleep_time
......@@ -282,8 +257,8 @@ class Measurements:
for measurement in supposedly_stable_measurements:
if all_measurements_stable:
measurement['EQUILIBRIUM_INDICATOR'] = TEMPERATURE_STABLE | HUMIDITY_STABLE |\
MAGNITUDE_STABLE | PHASE_STABLE |\
measurement['EQUILIBRIUM_INDICATOR'] = TEMPERATURE_STABLE | HUMIDITY_STABLE | \
DUT_SIGNAL0_STABLE | DUT_SIGNAL1_STABLE | \
MEASUREMENT_STABLE
do_another_measurement = False
......@@ -298,35 +273,15 @@ class Measurements:
[temp, hum, mode, alarms] = self.chamber.read_monitor().split(',')
perc_temp_heater, perc_hum_heater = self.chamber.get_heater_percentage()
# loop with 10 iterations in case of VNA readout error
iteration = 0
while iteration < 10:
try:
power = self.vna.get_current_power()
frequency = self.vna.get_current_cw_frequency()
self.vna.do_single_sweep()
s11 = self.get_trace_data("Trace1")
s12 = self.get_trace_data("Trace2")
s21 = self.get_trace_data("Trace3")
s22 = self.get_trace_data("Trace4")
# if readout is successful, leave loop
break
# exception for pyvisa error or timeout
except pyvisa.errors.VisaIOError:
# call reset function for VNA status register and error queue
self.vna.reset_status()
print('An error occurred during VNA read out')
# wait one second for next try
time.sleep(1)
iteration += 1
dut_data = self.dut.get_dut_measurements()
temp_dut, temp_room, temp_meas_instr, hum_dut, hum_room, hum_meas_instr, air_press_room = \
self.ext_sensors.get_sensor_values()
[temp_chamber_meas_instr, hum_chamber_meas_instr, mode_meas_instr, alarms_meas_instr] = \
self.instr_chamber.read_monitor().split(',')
return MeasurementData(int(self.clock.time()), temp, hum, power, frequency, s11, s21, s12, s22,
return MeasurementData(int(self.clock.time()), temp, hum, dut_data,
perc_temp_heater, perc_hum_heater, temp_dut, temp_room, temp_meas_instr,
hum_dut, hum_room, hum_meas_instr, air_press_room, temp_chamber_meas_instr,
hum_chamber_meas_instr)
......@@ -338,11 +293,9 @@ class Measurements:
'READBACK_TEMPERATURE': float(data.temp),
'TARGET_HUMIDITY': target_hum,
'READBACK_HUMIDITY': float(data.hum),
'RF_POWER': data.power,
'RF_FREQUENCY': data.frequency,
'EQUILIBRIUM_INDICATOR': equi_indicator,
'TEMP_HEATER': data.perc_temp_heater,
'HUM_HEATER': data.perc_hum_heater,
'TEMP_HEATER': data.percent_temp_heater,
'HUM_HEATER': data.percent_hum_heater,
'TEMP_DUT': data.temp_dut,
'TEMP_ROOM': data.temp_room,
'TEMP_MEAS_INSTR': data.temp_meas_instr,
......@@ -352,15 +305,8 @@ class Measurements:
'AIR_PRESS_ROOM': data.air_press_room,
'READBACK_TEMP_MEAS_INSTR': float(data.temp_chamber_meas_instr),
'READBACK_HUM_MEAS_INSTR': float(data.hum_chamber_meas_instr),
'S11_PHASE': self.calculate_mean_phase(data.s11),
'S11_MAGNITUDE': self.calculate_mean_magnitude_db(data.s11),
'S21_PHASE': self.calculate_mean_phase(data.s21),
'S21_MAGNITUDE': self.calculate_mean_magnitude_db(data.s21),
'S12_PHASE': self.calculate_mean_phase(data.s12),
'S12_MAGNITUDE': self.calculate_mean_magnitude_db(data.s12),
'S22_PHASE': self.calculate_mean_phase(data.s22),
'S22_MAGNITUDE': self.calculate_mean_magnitude_db(data.s22)
}
measurement.update(data.dut_data)
self.data_collection.append(measurement)
data_frame = pd.DataFrame(self.data_collection)
self.measurement_plot.draw(data_frame)
......@@ -368,38 +314,6 @@ class Measurements:
def current_milli_time(self):
return int(round(self.clock.time() * 1000))
def get_trace_data(self, trace):
return self.vna.get_list_of_measurement_values(trace, "SDAT")
def calculate_complex_numbers(self, values_list):
real_num = values_list[::2]
imaginary_num = values_list[1::2]
complex_numbers = []
for i, q in zip(real_num, imaginary_num):
complex_numbers.append(complex(i, q))
return complex_numbers
def calculate_magnitudes(self, values_list):
complex_numbers = self.calculate_complex_numbers(values_list)
magnitudes = [abs(val) for val in complex_numbers]
return magnitudes
def calculate_mean_magnitude(self, values_list):
magnitudes = self.calculate_magnitudes(values_list)
return numpy.mean(magnitudes)
def calculate_mean_magnitude_db(self, values_list):
return 20*math.log10(self.calculate_mean_magnitude(values_list))
def calculate_phases(self, values_list):
complex_numbers = self.calculate_complex_numbers(values_list)
phases = [math.degrees(cmath.phase(val)) for val in complex_numbers]
return phases
def calculate_mean_phase(self, values_list):
phases = self.calculate_phases(values_list)
return numpy.mean(phases)
def cook_up_equi_indicator(self):
equilibrium_indicator = 0
......@@ -409,11 +323,11 @@ class Measurements:
if self.humidity_stable:
equilibrium_indicator = equilibrium_indicator | HUMIDITY_STABLE
if self.magnitude_stable:
equilibrium_indicator = equilibrium_indicator | MAGNITUDE_STABLE
if self.dut_signals_stable[0]:
equilibrium_indicator = equilibrium_indicator | DUT_SIGNAL0_STABLE
if self.phase_stable:
equilibrium_indicator = equilibrium_indicator | PHASE_STABLE
if self.dut_signals_stable[1]:
equilibrium_indicator = equilibrium_indicator | DUT_SIGNAL1_STABLE
return equilibrium_indicator
......@@ -474,12 +388,6 @@ def run_temperature_sweep_from_file(temperature_sweep_file, meas):
if __name__ == '__main__':
parser = ArgumentParser()
parser.add_argument("-c", "--chamber",
help="IP address of climate chamber", metavar="ADDR",
required=True)
parser.add_argument("-v", "--vna",
help="IP address of VNA", metavar="ADDR",
required=True)
parser.add_argument('-f', '--file',
help='File containing custom list of measurements',
default='')
......@@ -509,7 +417,7 @@ if __name__ == '__main__':
else:
output_basename = args.output
print(args.chamber, args.vna, args.file, output_basename, args.standby)
print(args.file, output_basename, args.standby)
# reading json file for target accuracy
with open('test_stand_parameter.json', 'r') as f:
......@@ -518,8 +426,7 @@ if __name__ == '__main__':
with open('ext_sensor_channels.json', 'r') as f2:
ext_sensor_channels = json.load(f2)
mes = Measurements(args.chamber, args.vna, output_basename, args.standby, config_data, ext_sensor_channels,
config_data['logger_ip'])
mes = Measurements(config_data, output_basename, args.standby, ext_sensor_channels)
try:
if args.file:
n_measurements = mes.perform_measurements(args.file)
......
{"delta_temp": 0.1, "delta_hum": 1, "delta_mag": 0.13 , "delta_phase": 1.5, "sleep_time": 10.0, "frequency": 1300000000, "vna_config_file": "CalSetup2.znxml","chamber_ip":"192.168.115.186", "instr_chamber_ip": "192.168.115.187", "vna_ip":"192.168.115.39", "data_folder":"measurements", "logger_ip": "192.168.115.94", "time_unit": "min", "trace_subplot5": "logger_sens", "logger_model": "710"}
{
"delta_temp": 0.1,
"delta_hum": 1,
"dut": {
"type": "VNA",
"delta_mag": 0.13,
"delta_phase": 1.5,
"frequency": 1300000000,
"vna_ip": "192.168.115.39",
"vna_config_file": "CalSetup2.znxml"
},
"sleep_time": 10,
"chamber_ip": "192.168.115.186",
"instr_chamber_ip": "192.168.115.187",
"data_folder": "measurements",
"logger_ip": "192.168.115.94",
"time_unit": "min",
"trace_subplot5": "logger_sens",
"logger_model": "710"
}
import VNA
import pyvisa
import time
import numpy
import math
import cmath
import dut_measurement
class VnaData:
def __init__(self, power, frequency, s11, s21, s12, s22):
self.power = power
self.frequency = frequency
self.s11 = s11
self.s21 = s21
self.s12 = s12
self.s22 = s22
class VnaMeasurement(dut_measurement.DutMeasurement):
def __init__(self, config_data, target_accuracy):
self.delta_mag = config_data['delta_mag']
self.delta_phase = config_data['delta_phase']
self.vna = VNA.create_vna(config_data['vna_ip'], target_accuracy)
self.vna.load_config(config_data['vna_config_file'], config_data['frequency'])
self.vna.create_new_trace("Trace1", "S11")
self.vna.create_new_trace("Trace2", "S12")
self.vna.create_new_trace("Trace3", "S21")
self.vna.create_new_trace("Trace4", "S22")
def _get_trace_data(self, trace):
return self.vna.get_list_of_measurement_values(trace, "SDAT")
def get_dut_measurements(self):
# FIXME: The try/catch should be way down in the VNA class
for iteration in range(10):
try:
power = self.vna.get_current_power()
frequency = self.vna.get_current_cw_frequency()
self.vna.do_single_sweep()
s11 = self._get_trace_data("Trace1")
s12 = self._get_trace_data("Trace2")
s21 = self._get_trace_data("Trace3")
s22 = self._get_trace_data("Trace4")
return {'RF_POWER': power, 'RF_FREQUENCY': frequency,
'S11_MAGNITUDE': self.calculate_mean_magnitude_db(s11),
'S11_PHASE': self.calculate_mean_phase(s11),
'S12_MAGNITUDE': self.calculate_mean_magnitude_db(s12),
'S12_PHASE': self.calculate_mean_phase(s12),
'S21_MAGNITUDE': self.calculate_mean_magnitude_db(s21),
'S21_PHASE': self.calculate_mean_phase(s21),
'S22_MAGNITUDE': self.calculate_mean_magnitude_db(s22),
'S22_PHASE': self.calculate_mean_phase(s21)}
# exception for pyvisa error or timeout
except pyvisa.errors.VisaIOError:
# call reset function for VNA status register and error queue
self.vna.reset_status()
print('An error occurred during VNA read out')
# wait one second for next try
time.sleep(1)
# FIXME: In case we did not succeed we need something like a stop_measurement exception
raise Exception('FIXME: Throw stop_measurement here, and handle it. Don\'t crash!')
def get_dut_signal_names(self):
return ['RF_POWER', 'RF_FREQUENCY', 'S11_MAGNITUDE', 'S11_PHASE', 'S12_MAGNITUDE',
'S12_PHASE', 'S21_MAGNITUDE', 'S21_PHASE', 'S22_MAGNITUDE', 'S22_PHASE']
def get_dut_reference_signal_names(self):
return ['S21_MAGNITUDE', 'S21_PHASE']
def get_dut_max_delta_signals(self):
return [self.delta_mag, self.delta_phase]
def calculate_complex_numbers(self, values_list):
real_num = values_list[::2]
imaginary_num = values_list[1::2]
complex_numbers = []
for i, q in zip(real_num, imaginary_num):
complex_numbers.append(complex(i, q))
return complex_numbers
def calculate_magnitudes(self, values_list):
complex_numbers = self.calculate_complex_numbers(values_list)
magnitudes = [abs(val) for val in complex_numbers]
return magnitudes
def calculate_mean_magnitude(self, values_list):
magnitudes = self.calculate_magnitudes(values_list)
return numpy.mean(magnitudes)
def calculate_mean_magnitude_db(self, values_list):
return 20*math.log10(self.calculate_mean_magnitude(values_list))
def calculate_phases(self, values_list):
complex_numbers = self.calculate_complex_numbers(values_list)
phases = [math.degrees(cmath.phase(val)) for val in complex_numbers]
return phases
def calculate_mean_phase(self, values_list):
phases = self.calculate_phases(values_list)
return numpy.mean(phases)
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment