def test_init(self): """Verify that we can load in a .so file. This example and compiled object files come from ibisami a related module that this command is used with. """ if sys.platform == "win32": example_so = Path(__file__).parent.joinpath( "examples", "example_tx_x86_amd64.dll") elif sys.platform.startswith("linux"): example_so = Path(__file__).parent.joinpath( "examples", "example_tx_x86_amd64.so") else: # darwin aka OS X example_so = Path(__file__).parent.joinpath( "examples", "example_tx_x86_amd64_osx.so") the_model = AMIModel(example_so) initializer = AMIModelInitializer({'root_name': "exampleTx"}) the_model.initialize(initializer) assert the_model.msg == b"Initializing Tx...\n\n" assert the_model.ami_params_out == ( "(example_tx (tx_tap_units 27) (taps[0] 0) (taps[1] 27) (taps[2] 0) " "(taps[3] 0) (tap_weights_[0] -0) (tap_weights_[1] 1.0989) (tap_weights_[2] -0) " "(tap_weights_[3] -0)\n").encode("utf-8")
def my_run_simulation(self, initial_run=False, update_plots=True): """ Runs the simulation. Args: self(PyBERT): Reference to an instance of the *PyBERT* class. initial_run(Bool): If True, don't update the eye diagrams, since they haven't been created, yet. (Optional; default = False.) update_plots(Bool): If True, update the plots, after simulation completes. This option can be used by larger scripts, which import *pybert*, in order to avoid graphical back-end conflicts and speed up this function's execution time. (Optional; default = True.) """ num_sweeps = self.num_sweeps sweep_num = self.sweep_num start_time = clock() self.status = "Running channel...(sweep %d of %d)" % (sweep_num, num_sweeps) self.run_count += 1 # Force regeneration of bit stream. # Pull class variables into local storage, performing unit conversion where necessary. t = self.t w = self.w bits = self.bits symbols = self.symbols ffe = self.ffe nbits = self.nbits nui = self.nui bit_rate = self.bit_rate * 1.0e9 eye_bits = self.eye_bits eye_uis = self.eye_uis nspb = self.nspb nspui = self.nspui rn = self.rn pn_mag = self.pn_mag pn_freq = self.pn_freq * 1.0e6 pattern_len = self.pattern_len rx_bw = self.rx_bw * 1.0e9 peak_freq = self.peak_freq * 1.0e9 peak_mag = self.peak_mag ctle_offset = self.ctle_offset ctle_mode = self.ctle_mode delta_t = self.delta_t * 1.0e-12 alpha = self.alpha ui = self.ui n_taps = self.n_taps gain = self.gain n_ave = self.n_ave decision_scaler = self.decision_scaler n_lock_ave = self.n_lock_ave rel_lock_tol = self.rel_lock_tol lock_sustain = self.lock_sustain bandwidth = self.sum_bw * 1.0e9 rel_thresh = self.thresh mod_type = self.mod_type[0] try: # Calculate misc. values. fs = bit_rate * nspb Ts = t[1] ts = Ts # Generate the ideal over-sampled signal. # # Duo-binary is problematic, in that it requires convolution with the ideal duobinary # impulse response, in order to produce the proper ideal signal. x = repeat(symbols, nspui) self.x = x if mod_type == 1: # Handle duo-binary case. duob_h = array(([0.5] + [0.0] * (nspui - 1)) * 2) x = convolve(x, duob_h)[:len(t)] self.ideal_signal = x # Find the ideal crossing times, for subsequent jitter analysis of transmitted signal. ideal_xings = find_crossings(t, x, decision_scaler, min_delay=(ui / 2.0), mod_type=mod_type) self.ideal_xings = ideal_xings # Calculate the channel output. # # Note: We're not using 'self.ideal_signal', because we rely on the system response to # create the duobinary waveform. We only create it explicitly, above, # so that we'll have an ideal reference for comparison. chnl_h = self.calc_chnl_h() self.log("Channel impulse response is {} samples long.".format( len(chnl_h))) chnl_out = convolve(self.x, chnl_h)[:len(t)] self.channel_perf = nbits * nspb / (clock() - start_time) split_time = clock() self.status = "Running Tx...(sweep %d of %d)" % (sweep_num, num_sweeps) except Exception: self.status = "Exception: channel" raise self.chnl_out = chnl_out self.chnl_out_H = fft(chnl_out) # Generate the output from, and the incremental/cumulative impulse/step/frequency responses of, the Tx. try: if self.tx_use_ami: # Note: Within the PyBERT computational environment, we use normalized impulse responses, # which have units of (V/ts), where 'ts' is the sample interval. However, IBIS-AMI models expect # units of (V/s). So, we have to scale accordingly, as we transit the boundary between these two worlds. tx_cfg = self._tx_cfg # Grab the 'AMIParamConfigurator' instance for this model. # Get the model invoked and initialized, except for 'channel_response', which # we need to do several different ways, in order to gather all the data we need. tx_param_dict = tx_cfg.input_ami_params tx_model_init = AMIModelInitializer(tx_param_dict) tx_model_init.sample_interval = ts # Must be set, before 'channel_response'! tx_model_init.channel_response = [1.0 / ts] + [0.0] * ( len(chnl_h) - 1 ) # Start with a delta function, to capture the model's impulse response. tx_model_init.bit_time = ui tx_model = AMIModel(self.tx_dll_file) tx_model.initialize(tx_model_init) self.log( "Tx IBIS-AMI model initialization results:\nInput parameters: {}\nOutput parameters: {}\nMessage: {}" .format(tx_model.ami_params_in, tx_model.ami_params_out, tx_model.msg)) if tx_cfg.fetch_param_val( ["Reserved_Parameters", "Init_Returns_Impulse"]): tx_h = array(tx_model.initOut) * ts elif not tx_cfg.fetch_param_val( ["Reserved_Parameters", "GetWave_Exists"]): self.handle_error( "ERROR: Both 'Init_Returns_Impulse' and 'GetWave_Exists' are False!\n \ I cannot continue.\nThis condition is supposed to be caught sooner in the flow." ) self.status = "Simulation Error." return elif not self.tx_use_getwave: self.handle_error( "ERROR: You have elected not to use GetWave for a model, which does not return an impulse response!\n \ I cannot continue.\nPlease, select 'Use GetWave' and try again.", "PyBERT Alert", ) self.status = "Simulation Error." return if self.tx_use_getwave: # For GetWave, use a step to extract the model's native properties. # Position the input edge at the center of the vector, in # order to minimize high frequency artifactual energy # introduced by frequency domain processing in some models. half_len = len(chnl_h) // 2 tx_s = tx_model.getWave( array([0.0] * half_len + [1.0] * half_len)) # Shift the result back to the correct location, extending the last sample. tx_s = pad(tx_s[half_len:], (0, half_len), "edge") tx_h = diff(concatenate( (array([0.0]), tx_s))) # Without the leading 0, we miss the pre-tap. tx_out = tx_model.getWave(self.x) else: # Init()-only. tx_s = tx_h.cumsum() tx_out = convolve(tx_h, self.x) else: # - Generate the ideal, post-preemphasis signal. # To consider: use 'scipy.interp()'. This is what Mark does, in order to induce jitter in the Tx output. ffe_out = convolve(symbols, ffe)[:len(symbols)] self.rel_power = mean( ffe_out** 2) # Store the relative average power dissipated in the Tx. tx_out = repeat(ffe_out, nspui) # oversampled output # - Calculate the responses. # - (The Tx is unique in that the calculated responses aren't used to form the output. # This is partly due to the out of order nature in which we combine the Tx and channel, # and partly due to the fact that we're adding noise to the Tx output.) tx_h = array(sum([[x] + list(zeros(nspui - 1)) for x in ffe], [])) # Using sum to concatenate. tx_h.resize(len(chnl_h)) tx_s = tx_h.cumsum() tx_out.resize(len(t)) temp = tx_h.copy() temp.resize(len(w)) tx_H = fft(temp) tx_H *= tx_s[-1] / abs(tx_H[0]) # - Generate the uncorrelated periodic noise. (Assume capacitive coupling.) # - Generate the ideal rectangular aggressor waveform. pn_period = 1.0 / pn_freq pn_samps = int(pn_period / Ts + 0.5) pn = zeros(pn_samps) pn[pn_samps // 2:] = pn_mag pn = resize(pn, len(tx_out)) # - High pass filter it. (Simulating capacitive coupling.) (b, a) = iirfilter(2, gFc / (fs / 2), btype="highpass") pn = lfilter(b, a, pn)[:len(pn)] # - Add the uncorrelated periodic and random noise to the Tx output. tx_out += pn tx_out += normal(scale=rn, size=(len(tx_out), )) # - Convolve w/ channel. tx_out_h = convolve(tx_h, chnl_h)[:len(chnl_h)] temp = tx_out_h.copy() temp.resize(len(w)) tx_out_H = fft(temp) rx_in = convolve(tx_out, chnl_h)[:len(tx_out)] self.tx_s = tx_s self.tx_out = tx_out self.rx_in = rx_in self.tx_out_s = tx_out_h.cumsum() self.tx_out_p = self.tx_out_s[nspui:] - self.tx_out_s[:-nspui] self.tx_H = tx_H self.tx_h = tx_h self.tx_out_H = tx_out_H self.tx_out_h = tx_out_h self.tx_perf = nbits * nspb / (clock() - split_time) split_time = clock() self.status = "Running CTLE...(sweep %d of %d)" % (sweep_num, num_sweeps) except Exception: self.status = "Exception: Tx" raise # Generate the output from, and the incremental/cumulative impulse/step/frequency responses of, the CTLE. try: if self.rx_use_ami: rx_cfg = self._rx_cfg # Grab the 'AMIParamConfigurator' instance for this model. # Get the model invoked and initialized, except for 'channel_response', which # we need to do several different ways, in order to gather all the data we need. rx_param_dict = rx_cfg.input_ami_params rx_model_init = AMIModelInitializer(rx_param_dict) rx_model_init.sample_interval = ts # Must be set, before 'channel_response'! rx_model_init.channel_response = tx_out_h / ts rx_model_init.bit_time = ui rx_model = AMIModel(self.rx_dll_file) rx_model.initialize(rx_model_init) self.log( "Rx IBIS-AMI model initialization results:\nInput parameters: {}\nMessage: {}\nOutput parameters: {}" .format(rx_model.ami_params_in, rx_model.msg, rx_model.ami_params_out)) if rx_cfg.fetch_param_val( ["Reserved_Parameters", "Init_Returns_Impulse"]): ctle_out_h = array(rx_model.initOut) * ts elif not rx_cfg.fetch_param_val( ["Reserved_Parameters", "GetWave_Exists"]): self.handle_error( "ERROR: Both 'Init_Returns_Impulse' and 'GetWave_Exists' are False!\n \ I cannot continue.\nThis condition is supposed to be caught sooner in the flow." ) self.status = "Simulation Error." return elif not self.rx_use_getwave: self.handle_error( "ERROR: You have elected not to use GetWave for a model, which does not return an impulse response!\n \ I cannot continue.\nPlease, select 'Use GetWave' and try again.", "PyBERT Alert", ) self.status = "Simulation Error." return if self.rx_use_getwave: if False: ctle_out, clock_times = rx_model.getWave(rx_in, 32) else: ctle_out, clock_times = rx_model.getWave(rx_in, len(rx_in)) self.log(rx_model.ami_params_out) ctle_H = fft(ctle_out * hann(len(ctle_out))) / fft( rx_in * hann(len(rx_in))) ctle_h = real(ifft(ctle_H)[:len(chnl_h)]) ctle_out_h = convolve(ctle_h, tx_out_h)[:len(chnl_h)] else: # Init() only. ctle_out_h_padded = pad( ctle_out_h, (nspb, len(rx_in) - nspb - len(ctle_out_h)), "linear_ramp", end_values=(0.0, 0.0), ) tx_out_h_padded = pad( tx_out_h, (nspb, len(rx_in) - nspb - len(tx_out_h)), "linear_ramp", end_values=(0.0, 0.0), ) ctle_H = fft(ctle_out_h_padded) / fft(tx_out_h_padded) ctle_h = real(ifft(ctle_H)[:len(chnl_h)]) ctle_out = convolve(rx_in, ctle_h) ctle_s = ctle_h.cumsum() else: if self.use_ctle_file: ctle_h = import_channel(self.ctle_file, ts) if max(abs(ctle_h)) < 100.0: # step response? ctle_h = diff( ctle_h ) # impulse response is derivative of step response. else: ctle_h *= ts # Normalize to (V/sample) ctle_h.resize(len(t)) ctle_H = fft(ctle_h) ctle_H *= sum(ctle_h) / ctle_H[0] else: _, ctle_H = make_ctle(rx_bw, peak_freq, peak_mag, w, ctle_mode, ctle_offset) ctle_h = real(ifft(ctle_H))[:len(chnl_h)] ctle_h *= abs(ctle_H[0]) / sum(ctle_h) ctle_out = convolve(rx_in, ctle_h) ctle_out -= mean(ctle_out) # Force zero mean. if self.ctle_mode == "AGC": # Automatic gain control engaged? ctle_out *= 2.0 * decision_scaler / ctle_out.ptp() ctle_s = ctle_h.cumsum() ctle_out_h = convolve(tx_out_h, ctle_h)[:len(tx_out_h)] ctle_out.resize(len(t)) self.ctle_s = ctle_s ctle_out_h_main_lobe = where(ctle_out_h >= max(ctle_out_h) / 2.0)[0] if ctle_out_h_main_lobe.size: conv_dly_ix = ctle_out_h_main_lobe[0] else: conv_dly_ix = self.chnl_dly // Ts # TEMPORARY DEBUGGING try: conv_dly = t[conv_dly_ix] # Keep this line only. except: print("chnl_dly:", self.chnl_dly) print("conv_dly_ix:", conv_dly_ix) print("tx_h:", tx_h) print("chnl_h:", chnl_h) raise ##### ctle_out_s = ctle_out_h.cumsum() temp = ctle_out_h.copy() temp.resize(len(w)) ctle_out_H = fft(temp) # - Store local variables to class instance. self.ctle_out_s = ctle_out_s # Consider changing this; it could be sensitive to insufficient "front porch" in the CTLE output step response. self.ctle_out_p = self.ctle_out_s[nspui:] - self.ctle_out_s[:-nspui] self.ctle_H = ctle_H self.ctle_h = ctle_h self.ctle_out_H = ctle_out_H self.ctle_out_h = ctle_out_h self.ctle_out = ctle_out self.conv_dly = conv_dly self.conv_dly_ix = conv_dly_ix self.ctle_perf = nbits * nspb / (clock() - split_time) split_time = clock() self.status = "Running DFE/CDR...(sweep %d of %d)" % (sweep_num, num_sweeps) except Exception: self.status = "Exception: Rx" raise # Generate the output from, and the incremental/cumulative impulse/step/frequency responses of, the DFE. try: if self.use_dfe: dfe = DFE( n_taps, gain, delta_t, alpha, ui, nspui, decision_scaler, mod_type, n_ave=n_ave, n_lock_ave=n_lock_ave, rel_lock_tol=rel_lock_tol, lock_sustain=lock_sustain, bandwidth=bandwidth, ideal=self.sum_ideal, ) else: dfe = DFE( n_taps, 0.0, delta_t, alpha, ui, nspui, decision_scaler, mod_type, n_ave=n_ave, n_lock_ave=n_lock_ave, rel_lock_tol=rel_lock_tol, lock_sustain=lock_sustain, bandwidth=bandwidth, ideal=True, ) (dfe_out, tap_weights, ui_ests, clocks, lockeds, clock_times, bits_out) = dfe.run(t, ctle_out) dfe_out = array(dfe_out) dfe_out.resize(len(t)) bits_out = array(bits_out) auto_corr = (1.0 * correlate(bits_out[(nbits - eye_bits):], bits[(nbits - eye_bits):], mode="same") / sum(bits[(nbits - eye_bits):])) auto_corr = auto_corr[len(auto_corr) // 2:] self.auto_corr = auto_corr bit_dly = where(auto_corr == max(auto_corr))[0][0] bits_ref = bits[(nbits - eye_bits):] bits_tst = bits_out[(nbits + bit_dly - eye_bits):] if len(bits_ref) > len(bits_tst): bits_ref = bits_ref[:len(bits_tst)] elif len(bits_tst) > len(bits_ref): bits_tst = bits_tst[:len(bits_ref)] bit_errs = where(bits_tst ^ bits_ref)[0] self.bit_errs = len(bit_errs) dfe_h = array([1.0] + list(zeros(nspb - 1)) + sum([[-x] + list(zeros(nspb - 1)) for x in tap_weights[-1]], [])) dfe_h.resize(len(ctle_out_h)) temp = dfe_h.copy() temp.resize(len(w)) dfe_H = fft(temp) self.dfe_s = dfe_h.cumsum() dfe_out_H = ctle_out_H * dfe_H dfe_out_h = convolve(ctle_out_h, dfe_h)[:len(ctle_out_h)] dfe_out_s = dfe_out_h.cumsum() self.dfe_out_p = dfe_out_s - pad( dfe_out_s[:-nspui], (nspui, 0), "constant", constant_values=(0, 0)) self.dfe_H = dfe_H self.dfe_h = dfe_h self.dfe_out_H = dfe_out_H self.dfe_out_h = dfe_out_h self.dfe_out_s = dfe_out_s self.dfe_out = dfe_out self.dfe_perf = nbits * nspb / (clock() - split_time) split_time = clock() self.status = "Analyzing jitter...(sweep %d of %d)" % (sweep_num, num_sweeps) except Exception: self.status = "Exception: DFE" raise # Save local variables to class instance for state preservation, performing unit conversion where necessary. self.adaptation = tap_weights self.ui_ests = array(ui_ests) * 1.0e12 # (ps) self.clocks = clocks self.lockeds = lockeds self.clock_times = clock_times # Analyze the jitter. self.thresh_tx = array([]) self.jitter_ext_tx = array([]) self.jitter_tx = array([]) self.jitter_spectrum_tx = array([]) self.jitter_ind_spectrum_tx = array([]) self.thresh_ctle = array([]) self.jitter_ext_ctle = array([]) self.jitter_ctle = array([]) self.jitter_spectrum_ctle = array([]) self.jitter_ind_spectrum_ctle = array([]) self.thresh_dfe = array([]) self.jitter_ext_dfe = array([]) self.jitter_dfe = array([]) self.jitter_spectrum_dfe = array([]) self.jitter_ind_spectrum_dfe = array([]) self.f_MHz_dfe = array([]) self.jitter_rejection_ratio = array([]) try: if mod_type == 1: # Handle duo-binary case. pattern_len *= 2 # Because, the XOR pre-coding can invert every other pattern rep. if mod_type == 2: # Handle PAM-4 case. if pattern_len % 2: pattern_len *= 2 # Because, the bits are taken in pairs, to form the symbols. # - channel output actual_xings = find_crossings(t, chnl_out, decision_scaler, mod_type=mod_type) ( _, t_jitter, isi, dcd, pj, rj, _, thresh, jitter_spectrum, jitter_ind_spectrum, spectrum_freqs, hist, hist_synth, bin_centers, ) = calc_jitter(ui, nui, pattern_len, ideal_xings, actual_xings, rel_thresh) self.t_jitter = t_jitter self.isi_chnl = isi self.dcd_chnl = dcd self.pj_chnl = pj self.rj_chnl = rj self.thresh_chnl = thresh self.jitter_chnl = hist self.jitter_ext_chnl = hist_synth self.jitter_bins = bin_centers self.jitter_spectrum_chnl = jitter_spectrum self.jitter_ind_spectrum_chnl = jitter_ind_spectrum self.f_MHz = array(spectrum_freqs) * 1.0e-6 # - Tx output actual_xings = find_crossings(t, rx_in, decision_scaler, mod_type=mod_type) ( _, t_jitter, isi, dcd, pj, rj, _, thresh, jitter_spectrum, jitter_ind_spectrum, spectrum_freqs, hist, hist_synth, bin_centers, ) = calc_jitter(ui, nui, pattern_len, ideal_xings, actual_xings, rel_thresh) self.isi_tx = isi self.dcd_tx = dcd self.pj_tx = pj self.rj_tx = rj self.thresh_tx = thresh self.jitter_tx = hist self.jitter_ext_tx = hist_synth self.jitter_spectrum_tx = jitter_spectrum self.jitter_ind_spectrum_tx = jitter_ind_spectrum # - CTLE output actual_xings = find_crossings(t, ctle_out, decision_scaler, mod_type=mod_type) ( jitter, t_jitter, isi, dcd, pj, rj, jitter_ext, thresh, jitter_spectrum, jitter_ind_spectrum, spectrum_freqs, hist, hist_synth, bin_centers, ) = calc_jitter(ui, nui, pattern_len, ideal_xings, actual_xings, rel_thresh) self.isi_ctle = isi self.dcd_ctle = dcd self.pj_ctle = pj self.rj_ctle = rj self.thresh_ctle = thresh self.jitter_ctle = hist self.jitter_ext_ctle = hist_synth self.jitter_spectrum_ctle = jitter_spectrum self.jitter_ind_spectrum_ctle = jitter_ind_spectrum # - DFE output ignore_until = ( nui - eye_uis ) * ui + 0.75 * ui # 0.5 was causing an occasional misalignment. ideal_xings = array([x for x in list(ideal_xings) if x > ignore_until]) min_delay = ignore_until + conv_dly actual_xings = find_crossings(t, dfe_out, decision_scaler, min_delay=min_delay, mod_type=mod_type, rising_first=False) ( jitter, t_jitter, isi, dcd, pj, rj, jitter_ext, thresh, jitter_spectrum, jitter_ind_spectrum, spectrum_freqs, hist, hist_synth, bin_centers, ) = calc_jitter(ui, eye_uis, pattern_len, ideal_xings, actual_xings, rel_thresh) self.isi_dfe = isi self.dcd_dfe = dcd self.pj_dfe = pj self.rj_dfe = rj self.thresh_dfe = thresh self.jitter_dfe = hist self.jitter_ext_dfe = hist_synth self.jitter_spectrum_dfe = jitter_spectrum self.jitter_ind_spectrum_dfe = jitter_ind_spectrum self.f_MHz_dfe = array(spectrum_freqs) * 1.0e-6 dfe_spec = self.jitter_spectrum_dfe self.jitter_rejection_ratio = zeros(len(dfe_spec)) self.jitter_perf = nbits * nspb / (clock() - split_time) self.total_perf = nbits * nspb / (clock() - start_time) split_time = clock() self.status = "Updating plots...(sweep %d of %d)" % (sweep_num, num_sweeps) except Exception: self.status = "Exception: jitter" # raise # Update plots. try: if update_plots: update_results(self) if not initial_run: update_eyes(self) self.plotting_perf = nbits * nspb / (clock() - split_time) self.status = "Ready." except Exception: self.status = "Exception: plotting" raise
def run_tests(**kwargs): """Provide a thin wrapper around the click interface so that we can test the operation.""" # Fetch options and cast into local independent variables. test_dir = Path(kwargs["test_dir"]).resolve() ref_dir = Path(kwargs["ref_dir"]) if not ref_dir.exists(): ref_dir = None model = Path(kwargs["model"]).resolve() out_dir = Path(kwargs["out_dir"]) out_dir.mkdir(exist_ok=True) xml_filename = out_dir.joinpath(kwargs["xml_file"]) # Some browsers demand that the stylesheet be located in the same # folder as the *.XML file. Besides, this allows the model tester # to zip up her 'test_results' directory and send it off to # someone, whom may not have the PyIBIS-AMI package installed. shutil.copy(str(Path(__file__).parent.joinpath("test_results.xsl")), str(out_dir)) print("Testing model: {}".format(model)) print("Using tests in: {}".format(test_dir)) params = expand_params(kwargs["params"]) # Run the tests. print("Sending XHTML output to: {}".format(xml_filename)) with open(xml_filename, "w") as xml_file: xml_file.write('<?xml version="1.0" encoding="ISO-8859-1"?>\n') xml_file.write('<?xml-stylesheet type="text/xsl" href="test_results.xsl"?>\n') xml_file.write("<tests>\n") if kwargs["tests"]: tests = kwargs["tests"] else: tests = list(test_dir.glob("*.em")) for test in tests: # print("Running test: {} ...".format(test.stem)) print("Running test: {} ...".format(test)) theModel = AMIModel(model.__str__()) for cfg_item in params: cfg_name = cfg_item[0] print("\tRunning test configuration: {} ...".format(cfg_name)) description = cfg_item[1] param_list = cfg_item[2] colors = color_picker(num_hues=len(param_list)) with open(xml_filename, "a") as xml_file: interpreter = em.Interpreter( output=xml_file, globals={ "name": "{} ({})".format(test, cfg_name), "model": theModel, "data": param_list, "plot_names": plot_name(xml_filename.stem), "description": description, "plot_colors": colors, "ref_dir": ref_dir, }, ) try: cwd = Path().cwd() chdir(out_dir) # So that the images are saved in the output directory. interpreter.file(open(Path(test_dir, test))) chdir(cwd) finally: interpreter.shutdown() print("Test:", test, "complete.") with open(xml_filename, "a") as xml_file: xml_file.write("</tests>\n") print("Please, open file, `{}` in a Web browser, in order to view the test results.".format(xml_filename))