diff --git a/py-scripts/lf_interop_video_streaming.py b/py-scripts/lf_interop_video_streaming.py index 96eede88a..3a9b7689f 100755 --- a/py-scripts/lf_interop_video_streaming.py +++ b/py-scripts/lf_interop_video_streaming.py @@ -120,6 +120,7 @@ import os import importlib import argparse +import re import time import pandas as pd import logging @@ -372,6 +373,15 @@ def build(self): self.total_urls_dict = {} self.data_for_webui = {} self.all_cx_list = [] + self.missing_cx_logged = set() + self.missing_signal_logged = set() + self.cx_not_running_logged = set() + self.device_issue_log = [] + self.actual_monitoring_duration_seconds = 0 + self.pre_monitoring_missing_logged = False + self.last_monitor_url = None + self.last_monitor_response = None + self.monitor_start_time = None self.req_total_urls = [] self.req_urls_per_sec = [] @@ -696,6 +706,19 @@ def postcleanup(self): # Cleans the layer 4-7 traffic for created CX end points self.http_profile.cleanup() + def port_label_from_cx_name(self, cx_name): + # CX names embed the resource id right before the trailing "_l4" (e.g. "vs_wlan0_http10_l4" + # -> resource id 10). Used to report device issues against a recognizable port like "1.10". + match = re.search(r'(\d+)_l4$', cx_name) + return "1.{}".format(match.group(1)) if match else cx_name + + def record_device_issue(self, device, issue): + self.device_issue_log.append({ + "Time": datetime.now().strftime('%Y-%m-%d %H:%M:%S'), + "Device": device, + "Issue": issue, + }) + def my_monitor_runtime(self): try: @@ -714,13 +737,14 @@ def my_monitor_runtime(self): 3. Iterates through the retrieved data to extract and append the specified metric values ('data_mon') to 'data1' list. 4. Returns 'data1', which contains monitoring data for the specified metrics across all created CX endpoints. """ - data = self.local_realm.json_get( - "layer4/{}/list?fields={}".format( - ','.join(self.created_cx.keys()), - "name,status,total-urls,urls/s,total-err,video-format-bitrate," - "bytes-rd,total-wait-time,total-buffers,total-err,rx rate,frame-rate,video-quality" - ) + monitor_url = "layer4/{}/list?fields={}".format( + ','.join(self.created_cx.keys()), + "name,status,total-urls,urls/s,total-err,video-format-bitrate," + "bytes-rd,total-wait-time,total-buffers,total-err,rx rate,frame-rate,video-quality" ) + data = self.local_realm.json_get(monitor_url) + self.last_monitor_url = monitor_url + self.last_monitor_response = data names = [] statuses = [] total_urls = [] @@ -733,36 +757,84 @@ def my_monitor_runtime(self): rx_rate = [] frame_rate = [] video_quality = [] - if len(self.created_cx.keys()) > 1: - data = data['endpoint'] - for endpoint in data: - for _key, value in endpoint.items(): - names.append(value['name']) - statuses.append(value['status']) - total_urls.append(value['total-urls']) - urls_per_sec.append(value['urls/s']) - total_err.append(value['total-err']) - video_format_bitrate.append(value['video-format-bitrate']) - bytes_rd.append(value['bytes-rd']) - total_wait_time.append(value['total-wait-time']) - total_buffer.append(value['total-buffers']) - rx_rate.append(value['rx rate']) - frame_rate.append(value['frame-rate']) - video_quality.append(value['video-quality']) - elif len(self.created_cx.keys()) == 1: - endpoint = data.get('endpoint', {}) - names = [endpoint.get('name', '')] - statuses = [endpoint.get('status', '')] - total_urls = [endpoint.get('total-urls', 0)] - urls_per_sec = [endpoint.get('urls/s', 0.0)] - total_err = [endpoint.get('total-err', 0)] - video_format_bitrate = [endpoint.get('video-format-bitrate', 0)] - bytes_rd.append(endpoint.get('bytes-rd', 0)) - total_wait_time.append(endpoint.get('total-wait-time', 0)) - total_buffer.append(endpoint.get('total-buffers', 0)) - rx_rate.append(endpoint.get('rx rate', 0)) - frame_rate.append(endpoint.get('frame-rate', 0)) - video_quality.append(endpoint.get('video-quality', 0)) + + # Map every CX name to its metrics regardless of the order/completeness of the + # LANforge response, so a device that drops out mid-test cannot desync the + # per-device arrays used to build report rows. + # + # LANforge's response shape depends on how many endpoints are actually present, + # not on how many CXs we asked for: 'endpoint' comes back as a list of + # {cx_name: metrics} dicts when 2+ endpoints respond, but as a single flat metrics + # dict (with a 'name' field) when only one does - even if more than one CX was + # requested in the URL. Branching on len(self.created_cx.keys()) here would treat + # that single flat dict as a list and crash, so branch on the actual response shape. + cx_metrics = {} + if data: + endpoint_data = data.get('endpoint') + if isinstance(endpoint_data, list): + for endpoint in endpoint_data: + for key, value in endpoint.items(): + cx_metrics[key] = value + elif isinstance(endpoint_data, dict) and endpoint_data: + cx_name = endpoint_data.get('name') + if cx_name: + cx_metrics[cx_name] = endpoint_data + elif len(self.created_cx.keys()) == 1: + cx_metrics[list(self.created_cx.keys())[0]] = endpoint_data + + default_cx_metrics = { + 'name': '', 'status': 'Stopped', 'total-urls': 0, 'urls/s': 0.0, 'total-err': 0, + 'video-format-bitrate': 0, 'bytes-rd': 0, 'total-wait-time': 0, 'total-buffers': 0, + 'rx rate': 0, 'frame-rate': 0, 'video-quality': 0 + } + + for cx_name in self.created_cx.keys(): + value = cx_metrics.get(cx_name) + if value is None: + if cx_name not in self.missing_cx_logged: + logger.warning( + "CX '{}' is missing from the monitoring data, the device may have disconnected " + "or its connection was not created. Continuing the test with the remaining " + "devices.\n" + "URL : {}\n" + "Response: {}".format(cx_name, monitor_url, data) + ) + self.missing_cx_logged.add(cx_name) + self.record_device_issue(self.port_label_from_cx_name(cx_name), + "CX '{}' missing from monitoring data".format(cx_name)) + value = dict(default_cx_metrics, name=cx_name) + else: + if cx_name in self.missing_cx_logged: + logger.info("CX '{}' data is available again.".format(cx_name)) + self.missing_cx_logged.discard(cx_name) + + status = value.get('status', 'Stopped') + # Give devices a 10s grace period after monitoring starts before treating a + # non-'Run' status as noteworthy, since it's normal for CXs to still be starting + # up right after the monitor loop begins. + past_grace_period = (self.monitor_start_time is None or (datetime.now() - self.monitor_start_time).total_seconds() >= 10) + if status != 'Run': + if past_grace_period and cx_name not in self.cx_not_running_logged: + logger.warning("CX '{}' status is '{}', not running.".format(cx_name, status)) + self.cx_not_running_logged.add(cx_name) + self.record_device_issue(self.port_label_from_cx_name(cx_name), + "CX '{}' status is '{}', not running".format(cx_name, status)) + elif cx_name in self.cx_not_running_logged: + logger.info("CX '{}' status is back to running.".format(cx_name)) + self.cx_not_running_logged.discard(cx_name) + + names.append(value.get('name', cx_name)) + statuses.append(status) + total_urls.append(value.get('total-urls', 0)) + urls_per_sec.append(value.get('urls/s', 0.0)) + total_err.append(value.get('total-err', 0)) + video_format_bitrate.append(value.get('video-format-bitrate', 0)) + bytes_rd.append(value.get('bytes-rd', 0)) + total_wait_time.append(value.get('total-wait-time', 0)) + total_buffer.append(value.get('total-buffers', 0)) + rx_rate.append(value.get('rx rate', 0)) + frame_rate.append(value.get('frame-rate', 0)) + video_quality.append(value.get('video-quality', 0)) self.data['status'] = statuses self.data["total_urls"] = total_urls self.data["urls_per_sec"] = urls_per_sec @@ -779,6 +851,35 @@ def my_monitor_runtime(self): logger.error(f"Error in my_monitor_runtime function: {e}", exc_info=True) logger.info(f"Layer 4 cx data {data}") + def wait_for_any_cx_recovery(self, timeout=40, poll_interval=5): + """ + Polls CX status (via the existing my_monitor_runtime helper) while every created CX is + missing, giving devices a chance to reappear before the caller gives up. Also honors a + user-initiated stop from the webgui (same running.json check used in the monitor loop) + during the wait, so a stop request isn't delayed by the full retry window. + + Returns True as soon as at least one CX responds again or the user stops the test, + False if `timeout` seconds elapse with every CX still missing and no stop request. + """ + wait_start = datetime.now() + while (datetime.now() - wait_start).total_seconds() < timeout: + time.sleep(poll_interval) + if self.dowebgui: + with open(self.result_dir + "/../../Running_instances/{}_{}_running.json".format( + self.host, self.test_name), 'r') as file: + data = json.load(file) + if data["status"] != "Running": + logger.info("Test is stopped by the user during the device-recovery wait.") + self.test_stopped = True + return True + self.my_monitor_runtime() + elapsed = (datetime.now() - wait_start).total_seconds() + if len(self.missing_cx_logged) < len(self.created_cx): + logger.info("Device(s) responded again after {:.0f}s, resuming.".format(elapsed)) + return True + logger.warning("Still no devices responding after {:.0f}s, retrying...".format(elapsed)) + return False + def my_monitor(self, data_mon): # data in json format data = self.local_realm.json_get("layer4/%s/list?fields=%s" % @@ -858,16 +959,17 @@ def get_resource_data(self): phone_radio.append('2G/5G') return station_name - def get_signal_data(self): + def get_signal_data(self, resource_order=None): + # resource_order, when provided, pins the output to one entry per resource id in that + # exact order/count, so a device that drops out of the ports table mid-test (e.g. goes + # phantom / disconnects) cannot shrink the returned lists and desync them from the other + # per-device arrays (device_type, rssi, etc.) built at the start of monitoring. resource_ids = list(map(int, self.resource_ids.split(','))) - rssi = [] - tx_rate = [] - rx_rate = [] - bssid = [] - channel = [] + signal_by_resource = {} + signal_url = "ports?fields=alias,rx-rate,tx-rate,ssid,signal,ap,channel" try: - eid_data = self.json_get("ports?fields=alias,rx-rate,tx-rate,ssid,signal,ap,channel") + eid_data = self.json_get(signal_url) except KeyError: logger.error("Error: 'interfaces' key not found in port data") exit(1) @@ -877,26 +979,59 @@ def get_signal_data(self): if int(i.split(".")[1]) > 1 and alias[i]["alias"] == 'wlan0': resource_hw_data = self.json_get("/resource/" + i.split(".")[0] + "/" + i.split(".")[1]) hw_version = resource_hw_data['resource']['hw version'] - if not hw_version.startswith(('Win', 'Linux', 'Apple')) and int(resource_hw_data['resource']['eid'].split('.')[1]) in resource_ids: + resource_id = int(resource_hw_data['resource']['eid'].split('.')[1]) + if not hw_version.startswith(('Win', 'Linux', 'Apple')) and resource_id in resource_ids: if "dBm" in alias[i]['signal']: - rssi.append(alias[i]['signal'].split(" ")[0]) + rssi_value = alias[i]['signal'].split(" ")[0] else: - rssi.append(alias[i]['signal']) + rssi_value = alias[i]['signal'] + rssi_value = 0 if str(rssi_value).strip() == "" else int(rssi_value) - tx_rate.append(alias[i]['tx-rate']) - rx_rate.append(alias[i]['rx-rate']) - bssid.append(alias[i]['ap']) channel_value = str(alias[i].get('channel', '')) - if channel_value in ('', '0', '-1'): - channel.append('NA') - else: - channel.append(alias[i]['channel']) + channel_value = 'NA' if channel_value in ('', '0', '-1') else alias[i]['channel'] + + signal_by_resource[resource_id] = { + 'rssi': rssi_value, + 'tx_rate': alias[i]['tx-rate'], + 'bssid': alias[i]['ap'], + 'channel': channel_value, + } + + if resource_order is None: + resource_order = list(signal_by_resource.keys()) + + default_signal = {'rssi': -90, 'tx_rate': 0, 'bssid': 'NA', 'channel': 'NA'} + rssi = [] + tx_rate = [] + bssid = [] + channel = [] + for resource_id in resource_order: + value = signal_by_resource.get(resource_id) + if value is None: + if resource_id not in self.missing_signal_logged: + logger.warning( + "Signal data for device on port 1.{} is unavailable, it may have disconnected. " + "Continuing the test with the remaining devices.\n" + "URL : {}\n" + "Response: {}".format(resource_id, signal_url, eid_data) + ) + self.missing_signal_logged.add(resource_id) + self.record_device_issue("1.{}".format(resource_id), "Signal data unavailable (device may have disconnected)") + value = default_signal + else: + if resource_id in self.missing_signal_logged: + logger.info("Signal data for device on port 1.{} is available again.".format(resource_id)) + self.missing_signal_logged.discard(resource_id) + rssi.append(value['rssi']) + tx_rate.append(value['tx_rate']) + bssid.append(value['bssid']) + channel.append(value['channel']) - rssi = [0 if i.strip() == "" else int(i) for i in rssi] return rssi, tx_rate, bssid, channel def monitor_for_runtime_csv(self, duration, file_path, individual_df, iteration, actual_start_time, cx_list=None, curr_coordinate=None, curr_rotation=None, monitor_charge_time=None): + starttime = None try: if cx_list is None: cx_list = [] @@ -905,6 +1040,8 @@ def monitor_for_runtime_csv(self, duration, file_path, individual_df, iteration, resource_ids = list(map(int, self.resource_ids.split(','))) self.data_for_webui['resources'] = resource_ids starttime = datetime.now() + if self.monitor_start_time is None: + self.monitor_start_time = starttime self.data["name"] = self.my_monitor('name') current_time = datetime.now() endtime = "" @@ -923,6 +1060,7 @@ def monitor_for_runtime_csv(self, duration, file_path, individual_df, iteration, tx_rate = [] rx_rate = [] bssid = [] + resource_order = [] resource_ids = list(map(int, self.resource_ids.split(','))) eid_data = self.json_get("ports?fields=alias,mac,mode,Parent Dev,rx-rate,tx-rate,ssid,signal,channel,ap") @@ -950,16 +1088,48 @@ def monitor_for_runtime_csv(self, duration, file_path, individual_df, iteration, tx_rate.append(alias[i]['tx-rate']) rx_rate.append(alias[i]['rx-rate']) bssid.append(alias[i]['ap']) + resource_order.append(int(resource_hw_data['resource']['eid'].split('.')[1])) incremental_capacity_list = self.get_incremental_capacity_list() video_rate_dict = {i: [] for i in range(len(device_type))} + # Verify CX endpoints are responding before monitoring starts. A device with no CX + # data is left in self.created_cx (my_monitor_runtime already reports it with default + # 'Stopped' metrics so the per-device arrays built above stay in sync) but is logged + # here so it's clear from the start which devices the test will continue without. + if self.created_cx: + self.my_monitor_runtime() + if len(self.missing_cx_logged) == len(self.created_cx): + logger.warning("No devices are responding before monitoring starts, retrying for " + "up to 40 seconds before failing the test.") + if not self.wait_for_any_cx_recovery(timeout=40, poll_interval=5): + logger.error("No devices responded within 40 seconds before monitoring could " + "start, failing the test.") + raise RuntimeError( + "Video streaming test failed: no devices are available to run the test " + "(all CX endpoints missing after 40s retry).") + if self.missing_cx_logged and not self.pre_monitoring_missing_logged: + # This pre-check runs on every call to monitor_for_runtime_csv, which in + # bandsteering/robot mode is invoked repeatedly as the monitor_function tick. + # Only print this summary once per test instead of on every tick. + logger.warning("The following device(s) are missing before monitoring starts, " + "continuing the test with the remaining {} device(s): {}\n" + "URL : {}\n" + "Response: {}".format( + len(self.created_cx) - len(self.missing_cx_logged), + sorted(self.port_label_from_cx_name(cx) for cx in self.missing_cx_logged), + self.last_monitor_url, self.last_monitor_response)) + self.pre_monitoring_missing_logged = True + # Loop until the current time is less than the end time while current_time < endtime_check or self.background_run: if self.test_stopped: break if self.robot_test: - if self.rotation_enabled: + # monitor_charge_time is None when this function is invoked as the + # bandsteering monitor_function tick (that call site never passes it), so + # skip the rotation charge-pause check rather than crashing on None here. + if self.rotation_enabled and monitor_charge_time is not None: if (datetime.now() - monitor_charge_time).total_seconds() >= 300: pause_start = datetime.now() pause = False @@ -986,7 +1156,7 @@ def monitor_for_runtime_csv(self, duration, file_path, individual_df, iteration, monitor_charge_time = datetime.now() # Get signal data for RSSI and link speed - rssi_data, link_speed_data, bssid_data, channel_data = self.get_signal_data() + rssi_data, link_speed_data, bssid_data, channel_data = self.get_signal_data(resource_order=resource_order) individual_df_data = [] @@ -1012,6 +1182,19 @@ def monitor_for_runtime_csv(self, duration, file_path, individual_df, iteration, present_time = datetime.now().strftime("%H:%M:%S") self.my_monitor_runtime() + # If every device has stopped responding, retry for up to 40 seconds before + # giving up on this monitor loop. Unlike the pre-monitoring check, this does not + # fail the test: the loop just ends gracefully and execution continues with + # whatever data was already collected. + if self.created_cx and len(self.missing_cx_logged) == len(self.created_cx): + logger.warning("All devices have stopped responding during monitoring, retrying " + "for up to 40 seconds before ending the monitor loop.") + if not self.wait_for_any_cx_recovery(timeout=40, poll_interval=5): + logger.error("No devices responded within 40 seconds during monitoring, " + "ending the monitor loop gracefully; the test will continue with " + "the data collected so far.") + break + overall_video_rate = [] # Iterate through the total wait time data for i in range(len(self.data["total_wait_time"])): @@ -1182,10 +1365,20 @@ def monitor_for_runtime_csv(self, duration, file_path, individual_df, iteration, self.data['remaining_time_webGUI'] = [str(datetime.strptime(self.data['end_time_webGUI'][0], "%Y-%m-%d %H:%M:%S") - datetime.strptime(curr_time, "%Y-%m-%d %H:%M:%S"))] return test_stopped_by_user + except RuntimeError: + # Let the "no devices responding before monitoring starts" failure propagate and + # fail the test, instead of being swallowed by the generic handler below. + raise except Exception as e: logger.error(f"Error in monitor_for_runtime_csv function: {e}", exc_info=True) logger.info(f"eid_data {eid_data}") return test_stopped_by_user + finally: + # Accumulate the time actually spent monitoring on every exit path (normal + # completion, an early break/return, or an exception) so the report can show how + # long monitoring actually ran for, separate from the configured "Duration (min)". + if starttime is not None: + self.actual_monitoring_duration_seconds += (datetime.now() - starttime).total_seconds() def get_incremental_capacity_list(self): keys = list(self.http_profile.created_cx.keys()) @@ -1499,13 +1692,16 @@ def generate_report(self, date, iterations_before_test_stopped_by_user, test_set wait_time_data.append(filtered_df[[col for col in filtered_df.columns if "total_wait_time" in col][0]].values.tolist()[-1]) rssi_data.append(int(round(sum(filtered_df[[col for col in filtered_df.columns if "RSSI" in col][0]].values.tolist()) / len(filtered_df[[col for col in filtered_df.columns if "RSSI" in col][0]].values.tolist()), 2)) * -1) - # Extract maximum bytes read for the device - max_bytes_rd = max(filtered_df[[col for col in filtered_df.columns if "bytes_rd" in col][0]].values.tolist()) - max_bytes_rd_list.append(max_bytes_rd) + # Extract the last read bytes value for the device. If the CX ended up missing + # (still missing at the last poll), this is 0 by design - a device that dropped + # out shouldn't report a stale byte count as if it finished the test normally. + last_bytes_rd = filtered_df[[col for col in filtered_df.columns if "bytes_rd" in col][0]].values.tolist()[-1] + max_bytes_rd_list.append(last_bytes_rd) - # Calculate and append the average RX rate in Mbps - rx_rate_values = filtered_df[[col for col in filtered_df.columns if "rx rate" in col][0]].values.tolist() - avg_rx_rate_list.append(round((sum(rx_rate_values) / len(rx_rate_values)) / 1_000_000, 2)) # Convert bps to Mbps + # Use the last read RX rate for the device instead of averaging across the whole + # run. Same as bytes read, this is 0 if the CX was still missing at the last poll. + last_rx_rate = filtered_df[[col for col in filtered_df.columns if "rx rate" in col][0]].values.tolist()[-1] + avg_rx_rate_list.append(round(last_rx_rate / 1_000_000, 2)) # Convert bps to Mbps if iter != 0: # Calculate the difference in total URLs between the current and previous iterations @@ -1682,9 +1878,13 @@ def generate_report(self, date, iterations_before_test_stopped_by_user, test_set self.get_bandsteering_stats(report, realtime_dataset, devices_on_running_state, device_names_on_running) if iot_summary: self.build_iot_report_section(report, iot_summary) + if self.device_issue_log: + issues_df = pd.DataFrame(self.device_issue_log) + issues_df.to_csv(os.path.join(report_path_date_time, "clients_issue.csv"), index=False) report.build_footer() report.write_html() report.write_pdf() + logger.info("Monitoring Duration: {}".format(self.format_monitoring_duration())) def copy_reports_to_home_dir(self): curr_path = self.result_dir @@ -2014,6 +2214,17 @@ def process_incremental_capacity(self, incremental_capacity_list_values, availab self.test_setup_info_incremental_values = "No Incremental Value provided" self.total_duration = test_setup_info_total_duration + def format_monitoring_duration(self): + """ + Formats the time actually spent monitoring (self.actual_monitoring_duration_seconds, + accumulated across every monitor_for_runtime_csv call) as "Xm Ys". This reflects the + configured duration when monitoring ran to completion, or less than that when monitoring + ended early (e.g. all devices missing, or the test was stopped). + """ + total_seconds = int(self.actual_monitoring_duration_seconds) + minutes, seconds = divmod(total_seconds, 60) + return "{}m {}s".format(minutes, seconds) + def create_test_setup_info(self, media_source, media_quality): if self.resource_ids: username = [] @@ -2220,9 +2431,13 @@ def generate_report_for_robo(self, test_setup_info, report_path='', passed_coord self.data = self.vs_data[self.coordinate_list[coordinate]]["self_data"] shutil.move('video_streaming_realtime_data{}.csv'.format(csv_suffix), report_path_date_time) self.generate_individual_coordinate(report, device_type, username, ssid, mac, channel, mode, rssi, tx_rate, created_incremental_values, keys) + if self.device_issue_log: + issues_df = pd.DataFrame(self.device_issue_log) + issues_df.to_csv(os.path.join(report_path_date_time, "clients_issue.csv"), index=False) report.build_footer() report.write_html() report.write_pdf() + logger.info("Monitoring Duration: {}".format(self.format_monitoring_duration())) def generate_individual_coordinate(self, report, device_type, username, ssid, mac, channel, mode, rssi, tx_rate, created_incremental_values, keys, report_path=""): """ @@ -2287,13 +2502,16 @@ def generate_individual_coordinate(self, report, device_type, username, ssid, ma wait_time_data.append(filtered_df[[col for col in filtered_df.columns if "total_wait_time" in col][0]].values.tolist()[-1]) rssi_data.append(int(round(sum(filtered_df[[col for col in filtered_df.columns if "RSSI" in col][0]].values.tolist()) / len(filtered_df[[col for col in filtered_df.columns if "RSSI" in col][0]].values.tolist()), 2)) * -1) - # Extract maximum bytes read for the device - max_bytes_rd = max(filtered_df[[col for col in filtered_df.columns if "bytes_rd" in col][0]].values.tolist()) - max_bytes_rd_list.append(max_bytes_rd) - - # Calculate and append the average RX rate in Mbps - rx_rate_values = filtered_df[[col for col in filtered_df.columns if "rx rate" in col][0]].values.tolist() - avg_rx_rate_list.append(round((sum(rx_rate_values) / len(rx_rate_values)) / 1_000_000, 2)) # Convert bps to Mbps + # Extract the last read bytes value for the device. If the CX ended up missing + # (still missing at the last poll), this is 0 by design - a device that dropped + # out shouldn't report a stale byte count as if it finished the test normally. + last_bytes_rd = filtered_df[[col for col in filtered_df.columns if "bytes_rd" in col][0]].values.tolist()[-1] + max_bytes_rd_list.append(last_bytes_rd) + + # Use the last read RX rate for the device instead of averaging across the whole + # run. Same as bytes read, this is 0 if the CX was still missing at the last poll. + last_rx_rate = filtered_df[[col for col in filtered_df.columns if "rx rate" in col][0]].values.tolist()[-1] + avg_rx_rate_list.append(round(last_rx_rate / 1_000_000, 2)) # Convert bps to Mbps if iter != 0: # Calculate the difference in total URLs between the current and previous iterations