diff options
Diffstat (limited to 'lib')
-rw-r--r-- | lib/loader.py | 2321 | ||||
-rw-r--r-- | lib/loader/__init__.py | 878 | ||||
-rw-r--r-- | lib/loader/energytrace.py | 812 | ||||
-rw-r--r-- | lib/loader/keysight.py | 36 | ||||
-rw-r--r-- | lib/loader/mimosa.py | 644 |
5 files changed, 2370 insertions, 2321 deletions
diff --git a/lib/loader.py b/lib/loader.py deleted file mode 100644 index 2f1ad3d..0000000 --- a/lib/loader.py +++ /dev/null @@ -1,2321 +0,0 @@ -#!/usr/bin/env python3 - -import csv -import io -import json -import logging -import numpy as np -import os -import re -import struct -import tarfile -import hashlib -from multiprocessing import Pool - -from .utils import NpEncoder, running_mean, soft_cast_int - -logger = logging.getLogger(__name__) - -try: - from .pubcode import Code128 - import zbar - - zbar_available = True -except ImportError: - zbar_available = False - - -arg_support_enabled = True - - -class KeysightCSV: - """Simple loader for Keysight CSV data, as exported by the windows software.""" - - def __init__(self): - """Create a new KeysightCSV object.""" - pass - - def load_data(self, filename: str): - """ - Load log data from filename, return timestamps and currents. - - Returns two one-dimensional NumPy arrays: timestamps and corresponding currents. - """ - with open(filename) as f: - for i, _ in enumerate(f): - pass - timestamps = np.ndarray((i - 3), dtype=float) - currents = np.ndarray((i - 3), dtype=float) - # basically seek back to start - with open(filename) as f: - for _ in range(4): - next(f) - reader = csv.reader(f, delimiter=",") - for i, row in enumerate(reader): - timestamps[i] = float(row[0]) - currents[i] = float(row[2]) * -1 - return timestamps, currents - - -def _preprocess_mimosa(measurement): - setup = measurement["setup"] - mim = MIMOSA( - float(setup["mimosa_voltage"]), - int(setup["mimosa_shunt"]), - with_traces=measurement["with_traces"], - ) - try: - charges, triggers = mim.load_data(measurement["content"]) - trigidx = mim.trigger_edges(triggers) - except EOFError as e: - mim.errors.append("MIMOSA logfile error: {}".format(e)) - trigidx = list() - - if len(trigidx) == 0: - mim.errors.append("MIMOSA log has no triggers") - return { - "fileno": measurement["fileno"], - "info": measurement["info"], - "errors": mim.errors, - "repeat_id": measurement["repeat_id"], - "valid": False, - } - - cal_edges = mim.calibration_edges( - running_mean(mim.currents_nocal(charges[0 : trigidx[0]]), 10) - ) - calfunc, caldata = mim.calibration_function(charges, cal_edges) - vcalfunc = np.vectorize(calfunc, otypes=[np.float64]) - traces = mim.analyze_states(charges, trigidx, vcalfunc) - - # the last (v0) / first (v1) state is not part of the benchmark - traces.pop(measurement["pop"]) - - mim.validate( - len(trigidx), traces, measurement["expected_trace"], setup["state_duration"] - ) - - processed_data = { - "triggers": len(trigidx), - "first_trig": trigidx[0] * 10, - "calibration": caldata, - "energy_trace": traces, - "errors": mim.errors, - "valid": len(mim.errors) == 0, - } - - for key in ["fileno", "info", "repeat_id"]: - processed_data[key] = measurement[key] - - return processed_data - - -def _preprocess_etlog(measurement): - setup = measurement["setup"] - - energytrace_class = EnergyTraceWithBarcode - if measurement["sync_mode"] == "la": - energytrace_class = EnergyTraceWithLogicAnalyzer - elif measurement["sync_mode"] == "timer": - energytrace_class = EnergyTraceWithTimer - - etlog = energytrace_class( - float(setup["voltage"]), - int(setup["state_duration"]), - measurement["transition_names"], - with_traces=measurement["with_traces"], - ) - states_and_transitions = list() - try: - etlog.load_data(measurement["content"]) - states_and_transitions = etlog.analyze_states( - measurement["expected_trace"], measurement["repeat_id"] - ) - except EOFError as e: - etlog.errors.append("EnergyTrace logfile error: {}".format(e)) - except RuntimeError as e: - etlog.errors.append("EnergyTrace loader error: {}".format(e)) - - processed_data = { - "fileno": measurement["fileno"], - "repeat_id": measurement["repeat_id"], - "info": measurement["info"], - "energy_trace": states_and_transitions, - "valid": len(etlog.errors) == 0, - "errors": etlog.errors, - } - - return processed_data - - -class TimingData: - """ - Loader for timing model traces measured with on-board timers using `harness.OnboardTimerHarness`. - - Excpets a specific trace format and UART log output (as produced by - generate-dfa-benchmark.py). Prunes states from output. (TODO) - """ - - def __init__(self, filenames): - """ - Create a new TimingData object. - - Each filenames element corresponds to a measurement run. - """ - self.filenames = filenames.copy() - # holds the benchmark plan (dfa traces) for each series of benchmark runs. - # Note that a single entry typically has more than one corresponding mimosa/energytrace benchmark files, - # as benchmarks are run repeatedly to distinguish between random and parameter-dependent measurement effects. - self.traces_by_fileno = [] - self.setup_by_fileno = [] - self.preprocessed = False - self.version = 0 - - def _concatenate_analyzed_traces(self): - self.traces = [] - for trace_group in self.traces_by_fileno: - for trace in trace_group: - # TimingHarness logs states, but does not aggregate any data for them at the moment -> throw all states away - transitions = list( - filter(lambda x: x["isa"] == "transition", trace["trace"]) - ) - self.traces.append({"id": trace["id"], "trace": transitions}) - for i, trace in enumerate(self.traces): - trace["orig_id"] = trace["id"] - trace["id"] = i - for log_entry in trace["trace"]: - paramkeys = sorted(log_entry["parameter"].keys()) - if "param" not in log_entry["offline_aggregates"]: - log_entry["offline_aggregates"]["param"] = list() - if "duration" in log_entry["offline_aggregates"]: - for i in range(len(log_entry["offline_aggregates"]["duration"])): - paramvalues = list() - for paramkey in paramkeys: - if type(log_entry["parameter"][paramkey]) is list: - paramvalues.append( - soft_cast_int(log_entry["parameter"][paramkey][i]) - ) - else: - paramvalues.append( - soft_cast_int(log_entry["parameter"][paramkey]) - ) - if arg_support_enabled and "args" in log_entry: - paramvalues.extend(map(soft_cast_int, log_entry["args"])) - log_entry["offline_aggregates"]["param"].append(paramvalues) - - def _preprocess_0(self): - for filename in self.filenames: - with open(filename, "r") as f: - log_data = json.load(f) - self.traces_by_fileno.extend(log_data["traces"]) - self._concatenate_analyzed_traces() - - def get_preprocessed_data(self): - """ - Return a list of DFA traces annotated with timing and parameter data. - - Suitable for the PTAModel constructor. - See PTAModel(...) docstring for format details. - """ - if self.preprocessed: - return self.traces - if self.version == 0: - self._preprocess_0() - self.preprocessed = True - return self.traces - - -def sanity_check_aggregate(aggregate): - for key in aggregate: - if "param" not in aggregate[key]: - raise RuntimeError("aggregate[{}][param] does not exist".format(key)) - if "attributes" not in aggregate[key]: - raise RuntimeError("aggregate[{}][attributes] does not exist".format(key)) - for attribute in aggregate[key]["attributes"]: - if attribute not in aggregate[key]: - raise RuntimeError( - "aggregate[{}][{}] does not exist, even though it is contained in aggregate[{}][attributes]".format( - key, attribute, key - ) - ) - param_len = len(aggregate[key]["param"]) - attr_len = len(aggregate[key][attribute]) - if param_len != attr_len: - raise RuntimeError( - "parameter mismatch: len(aggregate[{}][param]) == {} != len(aggregate[{}][{}]) == {}".format( - key, param_len, key, attribute, attr_len - ) - ) - - -def assert_legacy_compatibility(f1, t1, f2, t2): - expected_param_names = sorted( - t1["expected_trace"][0]["trace"][0]["parameter"].keys() - ) - for run in t2["expected_trace"]: - for state_or_trans in run["trace"]: - actual_param_names = sorted(state_or_trans["parameter"].keys()) - if actual_param_names != expected_param_names: - err = f"parameters in {f1} and {f2} are incompatible: {expected_param_names} ≠ {actual_param_names}" - logger.error(err) - raise ValueError(err) - - -def assert_ptalog_compatibility(f1, pl1, f2, pl2): - param1 = pl1["pta"]["parameters"] - param2 = pl2["pta"]["parameters"] - if param1 != param2: - err = f"parameters in {f1} and {f2} are incompatible: {param1} ≠ {param2}" - logger.error(err) - raise ValueError(err) - - states1 = list(sorted(pl1["pta"]["state"].keys())) - states2 = list(sorted(pl2["pta"]["state"].keys())) - if states1 != states2: - err = f"states in {f1} and {f2} differ: {states1} ≠ {states2}" - logger.warning(err) - - transitions1 = list(sorted(map(lambda t: t["name"], pl1["pta"]["transitions"]))) - transitions2 = list(sorted(map(lambda t: t["name"], pl1["pta"]["transitions"]))) - if transitions1 != transitions2: - err = f"transitions in {f1} and {f2} differ: {transitions1} ≠ {transitions2}" - logger.warning(err) - - -class RawData: - """ - Loader for hardware model traces measured with MIMOSA. - - Expects a specific trace format and UART log output (as produced by the - dfatool benchmark generator). Loads data, prunes bogus measurements, and - provides preprocessed data suitable for PTAModel. Results are cached on the - file system, making subsequent loads near-instant. - """ - - def __init__(self, filenames, with_traces=False, skip_cache=False): - """ - Create a new RawData object. - - Each filename element corresponds to a measurement run. - It must be a tar archive with the following contents: - - Version 0: - - * `setup.json`: measurement setup. Must contain the keys `state_duration` (how long each state is active, in ms), - `mimosa_voltage` (voltage applied to dut, in V), and `mimosa_shunt` (shunt value, in Ohm) - * `src/apps/DriverEval/DriverLog.json`: PTA traces and parameters for this benchmark. - Layout: List of traces, each trace has an 'id' (numeric, starting with 1) and 'trace' (list of states and transitions) element. - Each trace has an even number of elements, starting with the first state (usually `UNINITIALIZED`) and ending with a transition. - Each state/transition must have the members `.parameter` (parameter values, empty string or None if unknown), `.isa` ("state" or "transition") and `.name`. - Each transition must additionally contain `.plan.level` ("user" or "epilogue"). - Example: `[ {"id": 1, "trace": [ {"parameter": {...}, "isa": "state", "name": "UNINITIALIZED"}, ...] }, ... ] - * At least one `*.mim` file. Each file corresponds to a single execution of the entire benchmark (i.e., all runs described in DriverLog.json) and starts with a MIMOSA Autocal calibration sequence. - MIMOSA files are parsed by the `MIMOSA` class. - - Version 1: - - * `ptalog.json`: measurement setup and traces. Contents: - `.opt.sleep`: state duration - `.opt.pta`: PTA - `.opt.traces`: list of sub-benchmark traces (the benchmark may have been split due to code size limitations). Each item is a list of traces as returned by `harness.traces`: - `.opt.traces[]`: List of traces. Each trace has an 'id' (numeric, starting with 1) and 'trace' (list of states and transitions) element. - Each state/transition must have the members '`parameter` (dict with normalized parameter values), `.isa` ("state" or "transition") and `.name` - Each transition must additionally contain `.args` - `.opt.files`: list of coresponding MIMOSA measurements. - `.opt.files[]` = ['abc123.mim', ...] - `.opt.configs`: .... - * MIMOSA log files (`*.mim`) as specified in `.opt.files` - - Version 2: - - * `ptalog.json`: measurement setup and traces. Contents: - `.opt.sleep`: state duration - `.opt.pta`: PTA - `.opt.traces`: list of sub-benchmark traces (the benchmark may have been split due to code size limitations). Each item is a list of traces as returned by `harness.traces`: - `.opt.traces[]`: List of traces. Each trace has an 'id' (numeric, starting with 1) and 'trace' (list of states and transitions) element. - Each state/transition must have the members '`parameter` (dict with normalized parameter values), `.isa` ("state" or "transition") and `.name` - Each transition must additionally contain `.args` and `.duration` - * `.duration`: list of durations, one per repetition - `.opt.files`: list of coresponding EnergyTrace measurements. - `.opt.files[]` = ['abc123.etlog', ...] - `.opt.configs`: .... - * EnergyTrace log files (`*.etlog`) as specified in `.opt.files` - - If a cached result for a file is available, it is loaded and the file - is not preprocessed, unless `with_traces` is set. - - tbd - """ - self.with_traces = with_traces - self.input_filenames = filenames.copy() - self.filenames = list() - self.traces_by_fileno = list() - self.setup_by_fileno = list() - self.version = 0 - self.preprocessed = False - self._parameter_names = None - self.ignore_clipping = False - self.pta = None - self.ptalog = None - - with tarfile.open(filenames[0]) as tf: - for member in tf.getmembers(): - if member.name == "ptalog.json" and self.version == 0: - self.version = 1 - # might also be version 2 - # depends on whether *.etlog exists or not - elif ".etlog" in member.name: - self.version = 2 - break - if self.version >= 1: - self.ptalog = json.load(tf.extractfile(tf.getmember("ptalog.json"))) - self.pta = self.ptalog["pta"] - - if self.ptalog and len(filenames) > 1: - for filename in filenames[1:]: - with tarfile.open(filename) as tf: - new_ptalog = json.load(tf.extractfile(tf.getmember("ptalog.json"))) - assert_ptalog_compatibility( - filenames[0], self.ptalog, filename, new_ptalog - ) - self.ptalog["files"].extend(new_ptalog["files"]) - - self.set_cache_file() - if not with_traces and not skip_cache: - self.load_cache() - - def set_cache_file(self): - cache_key = hashlib.sha256("!".join(self.input_filenames).encode()).hexdigest() - self.cache_dir = os.path.dirname(self.input_filenames[0]) + "/cache" - self.cache_file = "{}/{}.json".format(self.cache_dir, cache_key) - - def load_cache(self): - if os.path.exists(self.cache_file): - with open(self.cache_file, "r") as f: - try: - cache_data = json.load(f) - self.filenames = cache_data["filenames"] - self.traces = cache_data["traces"] - self.preprocessing_stats = cache_data["preprocessing_stats"] - if "pta" in cache_data: - self.pta = cache_data["pta"] - if "ptalog" in cache_data: - self.ptalog = cache_data["ptalog"] - self.setup_by_fileno = cache_data["setup_by_fileno"] - self.preprocessed = True - except json.decoder.JSONDecodeError as e: - logger.info(f"Skipping cache entry {self.cache_file}: {e}") - - def save_cache(self): - if self.with_traces: - return - try: - os.mkdir(self.cache_dir) - except FileExistsError: - pass - with open(self.cache_file, "w") as f: - cache_data = { - "filenames": self.filenames, - "traces": self.traces, - "preprocessing_stats": self.preprocessing_stats, - "pta": self.pta, - "ptalog": self.ptalog, - "setup_by_fileno": self.setup_by_fileno, - } - json.dump(cache_data, f) - - def to_dref(self) -> dict: - return { - "raw measurements/valid": self.preprocessing_stats["num_valid"], - "raw measurements/total": self.preprocessing_stats["num_runs"], - "static state duration/mean": ( - np.mean(list(map(lambda x: x["state_duration"], self.setup_by_fileno))), - r"\milli\second", - ), - } - - def _concatenate_traces(self, list_of_traces): - """ - Concatenate `list_of_traces` (list of lists) into a single trace while adjusting trace IDs. - - :param list_of_traces: List of list of traces. - :returns: List of traces with ['id'] in ascending order and ['orig_id'] as previous ['id'] - """ - - trace_output = list() - for trace in list_of_traces: - trace_output.extend(trace.copy()) - for i, trace in enumerate(trace_output): - trace["orig_id"] = trace["id"] - trace["id"] = i - return trace_output - - def get_preprocessed_data(self): - """ - Return a list of DFA traces annotated with energy, timing, and parameter data. - The list is cached on disk, unless the constructor was called with `with_traces` set. - - Each DFA trace contains the following elements: - * `id`: Numeric ID, starting with 1 - * `total_energy`: Total amount of energy (as measured by MIMOSA) in the entire trace - * `orig_id`: Original trace ID. May differ when concatenating multiple (different) benchmarks into one analysis, i.e., when calling RawData() with more than one file argument. - * `trace`: List of the individual states and transitions in this trace. Always contains an even number of elements, staring with the first state (typically "UNINITIALIZED") and ending with a transition. - - Each trace element (that is, an entry of the `trace` list mentioned above) contains the following elements: - * `isa`: "state" or "transition" - * `name`: name - * `offline`: List of offline measumerents for this state/transition. Each entry contains a result for this state/transition during one benchmark execution. - Entry contents: - - `clip_rate`: rate of clipped energy measurements, 0 .. 1 - - `raw_mean`: mean raw MIMOSA value - - `raw_std`: standard deviation of raw MIMOSA value - - `uW_mean`: mean power draw, uW - - `uw_std`: standard deviation of power draw, uW - - `us`: state/transition duration, us - - `uW_mean_delta_prev`: (only for transitions) difference between uW_mean of this transition and uW_mean of previous state - - `uW_mean_elta_next`: (only for transitions) difference between uW_mean of this transition and uW_mean of next state - - `timeout`: (only for transitions) duration of previous state, us - * `offline_aggregates`: Aggregate of `offline` entries. dict of lists, each list entry has the same length - - `duration`: state/transition durations ("us"), us - - `energy`: state/transition energy ("us * uW_mean"), us - - `power`: mean power draw ("uW_mean"), uW - - `power_std`: standard deviations of power draw ("uW_std"), uW^2 - - `paramkeys`: List of lists, each sub-list contains the parameter names corresponding to the `param` entries - - `param`: List of lists, each sub-list contains the parameter values for this measurement. Typically, all sub-lists are the same. - - `rel_energy_prev`: (only for transitions) transition energy relative to previous state mean power, pJ - - `rel_energy_next`: (only for transitions) transition energy relative to next state mean power, pJ - - `rel_power_prev`: (only for transitions) powerrelative to previous state mean power, µW - - `rel_power_next`: (only for transitions) power relative to next state mean power, µW - - `timeout`: (only for transitions) duration of previous state, us - * `offline_attributes`: List containing the keys of `offline_aggregates` which are meant to be part of the model. - This list ultimately decides which hardware/software attributes the model describes. - If isa == state, it contains power, duration, energy - If isa == transition, it contains power, rel_power_prev, rel_power_next, duration, timeout - * `online`: List of online estimations for this state/transition. Each entry contains a result for this state/transition during one benchmark execution. - Entry contents for isa == state: - - `time`: state/transition - Entry contents for isa == transition: - - `timeout`: Duration of previous state, measured using on-board timers - * `parameter`: dictionary describing parameter values for this state/transition. Parameter values refer to the begin of the state/transition and do not account for changes made by the transition. - * `plan`: Dictionary describing expected behaviour according to schedule / offline model. - Contents for isa == state: `energy`, `power`, `time` - Contents for isa == transition: `energy`, `timeout`, `level`. - If level is "user", the transition is part of the regular driver API. If level is "epilogue", it is an interrupt service routine and not called explicitly. - Each transition also contains: - * `args`: List of arguments the corresponding function call was called with. args entries are strings which are not necessarily numeric - * `code`: List of function name (first entry) and arguments (remaining entries) of the corresponding function call - """ - if self.preprocessed: - return self.traces - if self.version <= 2: - self._preprocess_012(self.version) - else: - raise ValueError(f"Unsupported raw data version: {self.version}") - self.preprocessed = True - self.save_cache() - return self.traces - - def _preprocess_012(self, version): - """Load raw MIMOSA data and turn it into measurements which are ready to be analyzed.""" - offline_data = [] - for i, filename in enumerate(self.input_filenames): - - if version == 0: - - self.filenames = self.input_filenames - with tarfile.open(filename) as tf: - self.setup_by_fileno.append(json.load(tf.extractfile("setup.json"))) - traces = json.load( - tf.extractfile("src/apps/DriverEval/DriverLog.json") - ) - self.traces_by_fileno.append(traces) - for member in tf.getmembers(): - _, extension = os.path.splitext(member.name) - if extension == ".mim": - offline_data.append( - { - "content": tf.extractfile(member).read(), - # only for validation - "expected_trace": traces, - "fileno": i, - # For debug output and warnings - "info": member, - # Strip the last state (it is not part of the scheduled measurement) - "pop": -1, - "repeat_id": 0, # needed to add runtime "return_value.apply_from" parameters to offline_aggregates. Irrelevant in v0. - "setup": self.setup_by_fileno[i], - "with_traces": self.with_traces, - } - ) - - elif version == 1: - - with tarfile.open(filename) as tf: - ptalog = json.load(tf.extractfile(tf.getmember("ptalog.json"))) - - # Benchmark code may be too large to be executed in a single - # run, so benchmarks (a benchmark is basically a list of DFA runs) - # may be split up. To accomodate this, ptalog['traces'] is - # a list of lists: ptalog['traces'][0] corresponds to the - # first benchmark part, ptalog['traces'][1] to the - # second, and so on. ptalog['traces'][0][0] is the first - # trace (a sequence of states and transitions) in the - # first benchmark part, ptalog['traces'][0][1] the second, etc. - # - # As traces are typically repeated to minimize the effect - # of random noise, observations for each benchmark part - # are also lists. In this case, this applies in two - # cases: traces[i][j]['parameter'][some_param] is either - # a value (if the parameter is controlld by software) - # or a list (if the parameter is known a posteriori, e.g. - # "how many retransmissions did this packet take?"). - # - # The second case is the MIMOSA energy measurements, which - # are listed in ptalog['files']. ptalog['files'][0] - # contains a list of files for the first benchmark part, - # ptalog['files'][0][0] is its first iteration/repetition, - # ptalog['files'][0][1] the second, etc. - - for j, traces in enumerate(ptalog["traces"]): - self.filenames.append("{}#{}".format(filename, j)) - self.traces_by_fileno.append(traces) - self.setup_by_fileno.append( - { - "mimosa_voltage": ptalog["configs"][j]["voltage"], - "mimosa_shunt": ptalog["configs"][j]["shunt"], - "state_duration": ptalog["opt"]["sleep"], - } - ) - for repeat_id, mim_file in enumerate(ptalog["files"][j]): - # MIMOSA benchmarks always use a single .mim file per benchmark run. - # However, depending on the dfatool version used to run the - # benchmark, ptalog["files"][j] is either "foo.mim" (before Oct 2020) - # or ["foo.mim"] (from Oct 2020 onwards). - if type(mim_file) is list: - mim_file = mim_file[0] - member = tf.getmember(mim_file) - offline_data.append( - { - "content": tf.extractfile(member).read(), - # only for validation - "expected_trace": traces, - "fileno": len(self.traces_by_fileno) - 1, - # For debug output and warnings - "info": member, - # The first online measurement is the UNINITIALIZED state. In v1, - # it is not part of the expected PTA trace -> remove it. - "pop": 0, - "setup": self.setup_by_fileno[-1], - "repeat_id": repeat_id, # needed to add runtime "return_value.apply_from" parameters to offline_aggregates. - "with_traces": self.with_traces, - } - ) - - elif version == 2: - - with tarfile.open(filename) as tf: - ptalog = json.load(tf.extractfile(tf.getmember("ptalog.json"))) - if "sync" in ptalog["opt"]["energytrace"]: - sync_mode = ptalog["opt"]["energytrace"]["sync"] - else: - sync_mode = "bar" - - # Benchmark code may be too large to be executed in a single - # run, so benchmarks (a benchmark is basically a list of DFA runs) - # may be split up. To accomodate this, ptalog['traces'] is - # a list of lists: ptalog['traces'][0] corresponds to the - # first benchmark part, ptalog['traces'][1] to the - # second, and so on. ptalog['traces'][0][0] is the first - # trace (a sequence of states and transitions) in the - # first benchmark part, ptalog['traces'][0][1] the second, etc. - # - # As traces are typically repeated to minimize the effect - # of random noise, observations for each benchmark part - # are also lists. In this case, this applies in two - # cases: traces[i][j]['parameter'][some_param] is either - # a value (if the parameter is controlld by software) - # or a list (if the parameter is known a posteriori, e.g. - # "how many retransmissions did this packet take?"). - # - # The second case is the MIMOSA energy measurements, which - # are listed in ptalog['files']. ptalog['files'][0] - # contains a list of files for the first benchmark part, - # ptalog['files'][0][0] is its first iteration/repetition, - # ptalog['files'][0][1] the second, etc. - - # generate-dfa-benchmark uses TimingHarness to obtain timing data. - # Data is placed in 'offline_aggregates', which is also - # where we are going to store power/energy data. - # In case of invalid measurements, this can lead to a - # mismatch between duration and power/energy data, e.g. - # where duration = [A, B, C], power = [a, b], B belonging - # to an invalid measurement and thus power[b] corresponding - # to duration[C]. At the moment, this is harmless, but in the - # future it might not be. - if "offline_aggregates" in ptalog["traces"][0][0]["trace"][0]: - for trace_group in ptalog["traces"]: - for trace in trace_group: - for state_or_transition in trace["trace"]: - offline_aggregates = state_or_transition.pop( - "offline_aggregates", None - ) - if offline_aggregates: - state_or_transition[ - "online_aggregates" - ] = offline_aggregates - - for j, traces in enumerate(ptalog["traces"]): - self.filenames.append("{}#{}".format(filename, j)) - self.traces_by_fileno.append(traces) - self.setup_by_fileno.append( - { - "voltage": ptalog["configs"][j]["voltage"], - "state_duration": ptalog["opt"]["sleep"], - } - ) - for repeat_id, etlog_files in enumerate(ptalog["files"][j]): - # legacy measurements supported only one file per run - if type(etlog_files) is not list: - etlog_files = [etlog_files] - members = list(map(tf.getmember, etlog_files)) - offline_data.append( - { - "content": list( - map(lambda f: tf.extractfile(f).read(), members) - ), - # used to determine EnergyTrace class for analysis - "sync_mode": sync_mode, - "fileno": len(self.traces_by_fileno) - 1, - # For debug output and warnings - "info": members[0], - "setup": self.setup_by_fileno[-1], - # needed to add runtime "return_value.apply_from" parameters to offline_aggregates, also for EnergyTraceWithBarcode - "repeat_id": repeat_id, - # only for validation - "expected_trace": traces, - "with_traces": self.with_traces, - # only for EnergyTraceWithBarcode - "transition_names": list( - map( - lambda x: x["name"], - ptalog["pta"]["transitions"], - ) - ), - } - ) - # TODO remove 'offline_aggregates' from pre-parse data and place - # it under 'online_aggregates' or similar instead. This way, if - # a .etlog file fails to parse, its corresponding duration data - # will not linger in 'offline_aggregates' and confuse the hell - # out of other code paths - - if self.version == 0 and len(self.input_filenames) > 1: - for entry in offline_data: - assert_legacy_compatibility( - self.input_filenames[0], - offline_data[0], - self.input_filenames[entry["fileno"]], - entry, - ) - - with Pool() as pool: - if self.version <= 1: - measurements = pool.map(_preprocess_mimosa, offline_data) - elif self.version == 2: - measurements = pool.map(_preprocess_etlog, offline_data) - - num_valid = 0 - for measurement in measurements: - - if "energy_trace" not in measurement: - logger.warning( - "Skipping {ar:s}/{m:s}: {e:s}".format( - ar=self.filenames[measurement["fileno"]], - m=measurement["info"].name, - e="; ".join(measurement["errors"]), - ) - ) - continue - - if version == 0 or version == 1: - if measurement["valid"]: - MIMOSA.add_offline_aggregates( - self.traces_by_fileno[measurement["fileno"]], - measurement["energy_trace"], - measurement["repeat_id"], - ) - num_valid += 1 - else: - logger.warning( - "Skipping {ar:s}/{m:s}: {e:s}".format( - ar=self.filenames[measurement["fileno"]], - m=measurement["info"].name, - e="; ".join(measurement["errors"]), - ) - ) - elif version == 2: - if measurement["valid"]: - try: - EnergyTrace.add_offline_aggregates( - self.traces_by_fileno[measurement["fileno"]], - measurement["energy_trace"], - measurement["repeat_id"], - ) - num_valid += 1 - except Exception as e: - logger.warning( - f"Skipping #{measurement['fileno']} {measurement['info']}:\n{e}" - ) - else: - logger.warning( - "Skipping {ar:s}/{m:s}: {e:s}".format( - ar=self.filenames[measurement["fileno"]], - m=measurement["info"].name, - e="; ".join(measurement["errors"]), - ) - ) - logger.info( - "{num_valid:d}/{num_total:d} measurements are valid".format( - num_valid=num_valid, num_total=len(measurements) - ) - ) - if version == 0: - self.traces = self._concatenate_traces(self.traces_by_fileno) - elif version == 1: - self.traces = self._concatenate_traces(self.traces_by_fileno) - elif version == 2: - self.traces = self._concatenate_traces(self.traces_by_fileno) - self.preprocessing_stats = { - "num_runs": len(measurements), - "num_valid": num_valid, - } - - -def _add_trace_data_to_aggregate(aggregate, key, element): - # Only cares about element['isa'], element['offline_aggregates'], and - # element['plan']['level'] - if key not in aggregate: - aggregate[key] = {"isa": element["isa"]} - for datakey in element["offline_aggregates"].keys(): - aggregate[key][datakey] = [] - if element["isa"] == "state": - aggregate[key]["attributes"] = ["power"] - else: - # TODO do not hardcode values - aggregate[key]["attributes"] = [ - "duration", - "power", - "rel_power_prev", - "rel_power_next", - "energy", - "rel_energy_prev", - "rel_energy_next", - ] - if "plan" in element and element["plan"]["level"] == "epilogue": - aggregate[key]["attributes"].insert(0, "timeout") - attributes = aggregate[key]["attributes"].copy() - for attribute in attributes: - if attribute not in element["offline_aggregates"]: - aggregate[key]["attributes"].remove(attribute) - if "offline_support" in element: - aggregate[key]["supports"] = element["offline_support"] - else: - aggregate[key]["supports"] = list() - for datakey, dataval in element["offline_aggregates"].items(): - aggregate[key][datakey].extend(dataval) - - -def pta_trace_to_aggregate(traces, ignore_trace_indexes=[]): - """ - Convert preprocessed DFA traces from peripherals/drivers to by_name aggregate for PTAModel. - - arguments: - traces -- [ ... Liste von einzelnen Läufen (d.h. eine Zustands- und Transitionsfolge UNINITIALIZED -> foo -> FOO -> bar -> BAR -> ...) - Jeder Lauf: - - id: int Nummer des Laufs, beginnend bei 1 - - trace: [ ... Liste von Zuständen und Transitionen - Jeweils: - - name: str Name - - isa: str state // transition - - parameter: { ... globaler Parameter: aktueller wert. null falls noch nicht eingestellt } - - args: [ Funktionsargumente, falls isa == 'transition' ] - - offline_aggregates: - - power: [float(uW)] Mittlere Leistung während Zustand/Transitions - - power_std: [float(uW^2)] Standardabweichung der Leistung - - duration: [int(us)] Dauer - - energy: [float(pJ)] Energieaufnahme des Zustands / der Transition - - clip_rate: [float(0..1)] Clipping - - paramkeys: [[str]] Name der berücksichtigten Parameter - - param: [int // str] Parameterwerte. Quasi-Duplikat von 'parameter' oben - Falls isa == 'transition': - - timeout: [int(us)] Dauer des vorherigen Zustands - - rel_energy_prev: [int(pJ)] - - rel_energy_next: [int(pJ)] - - rel_power_prev: [int(µW)] - - rel_power_next: [int(µW)] - ] - ] - ignore_trace_indexes -- list of trace indexes. The corresponding taces will be ignored. - - returns a tuple of three elements: - by_name -- measurements aggregated by state/transition name, annotated with parameter values - parameter_names -- list of parameter names - arg_count -- dict mapping transition names to the number of arguments of their corresponding driver function - - by_name layout: - Dictionary with one key per state/transition ('send', 'TX', ...). - Each element is in turn a dict with the following elements: - - isa: 'state' or 'transition' - - power: list of mean power measurements in µW - - duration: list of durations in µs - - power_std: list of stddev of power per state/transition - - energy: consumed energy (power*duration) in pJ - - paramkeys: list of parameter names in each measurement (-> list of lists) - - param: list of parameter values in each measurement (-> list of lists) - - attributes: list of keys that should be analyzed, - e.g. ['power', 'duration'] - additionally, only if isa == 'transition': - - timeout: list of duration of previous state in µs - - rel_energy_prev: transition energy relative to previous state mean power in pJ - - rel_energy_next: transition energy relative to next state mean power in pJ - """ - arg_count = dict() - by_name = dict() - parameter_names = sorted(traces[0]["trace"][0]["parameter"].keys()) - for run in traces: - if run["id"] not in ignore_trace_indexes: - for elem in run["trace"]: - if ( - elem["isa"] == "transition" - and not elem["name"] in arg_count - and "args" in elem - ): - arg_count[elem["name"]] = len(elem["args"]) - if elem["name"] != "UNINITIALIZED": - _add_trace_data_to_aggregate(by_name, elem["name"], elem) - for elem in by_name.values(): - for key in elem["attributes"]: - elem[key] = np.array(elem[key]) - return by_name, parameter_names, arg_count - - -def _load_energytrace(data_string): - """ - Load log data (raw energytrace .txt file, one line per event). - - :param log_data: raw energytrace log file in 4-column .txt format - """ - - lines = data_string.decode("ascii").split("\n") - data_count = sum(map(lambda x: len(x) > 0 and x[0] != "#", lines)) - data_lines = filter(lambda x: len(x) > 0 and x[0] != "#", lines) - - data = np.empty((data_count, 4)) - hardware_states = [None for i in range(data_count)] - - for i, line in enumerate(data_lines): - fields = line.split(" ") - if len(fields) == 4: - timestamp, current, voltage, total_energy = map(int, fields) - elif len(fields) == 5: - hardware_states[i] = fields[0] - timestamp, current, voltage, total_energy = map(int, fields[1:]) - else: - raise RuntimeError('cannot parse line "{}"'.format(line)) - data[i] = [timestamp, current, voltage, total_energy] - - interval_start_timestamp = data[1:, 0] * 1e-6 - interval_duration = (data[1:, 0] - data[:-1, 0]) * 1e-6 - interval_power = (data[1:, 3] - data[:-1, 3]) / (data[1:, 0] - data[:-1, 0]) * 1e-3 - - m_duration_us = data[-1, 0] - data[0, 0] - - sample_rate = data_count / (m_duration_us * 1e-6) - - hardware_state_changes = list() - if hardware_states[0]: - prev_state = hardware_states[0] - # timestamps start at data[1], so hardware state change indexes must start at 1, too - for i, state in enumerate(hardware_states[1:]): - if ( - state != prev_state - and state != "0000000000000000" - and prev_state != "0000000000000000" - ): - hardware_state_changes.append(i) - if state != "0000000000000000": - prev_state = state - - logger.debug( - "got {} samples with {} seconds of log data ({} Hz)".format( - data_count, m_duration_us * 1e-6, sample_rate - ) - ) - - return ( - interval_start_timestamp, - interval_duration, - interval_power, - sample_rate, - hardware_state_changes, - ) - - -class EnergyTrace: - @staticmethod - def add_offline_aggregates(online_traces, offline_trace, repeat_id): - # Edits online_traces[*]['trace'][*]['offline'] - # and online_traces[*]['trace'][*]['offline_aggregates'] in place - # (appends data from offline_trace) - online_datapoints = [] - for run_idx, run in enumerate(online_traces): - for trace_part_idx in range(len(run["trace"])): - online_datapoints.append((run_idx, trace_part_idx)) - for offline_idx, (online_run_idx, online_trace_part_idx) in enumerate( - online_datapoints - ): - try: - offline_trace_part = offline_trace[offline_idx] - except IndexError: - logger.error(f" offline energy_trace data is shorter than online data") - logger.error(f" len(online_datapoints) == {len(online_datapoints)}") - logger.error(f" len(energy_trace) == {len(offline_trace)}") - raise - online_trace_part = online_traces[online_run_idx]["trace"][ - online_trace_part_idx - ] - - if "offline" not in online_trace_part: - online_trace_part["offline"] = [offline_trace_part] - else: - online_trace_part["offline"].append(offline_trace_part) - - paramkeys = sorted(online_trace_part["parameter"].keys()) - - paramvalues = list() - - for paramkey in paramkeys: - if type(online_trace_part["parameter"][paramkey]) is list: - paramvalues.append( - soft_cast_int( - online_trace_part["parameter"][paramkey][repeat_id] - ) - ) - else: - paramvalues.append( - soft_cast_int(online_trace_part["parameter"][paramkey]) - ) - - # NB: Unscheduled transitions do not have an 'args' field set. - # However, they should only be caused by interrupts, and - # interrupts don't have args anyways. - if arg_support_enabled and "args" in online_trace_part: - paramvalues.extend(map(soft_cast_int, online_trace_part["args"])) - - if "offline_aggregates" not in online_trace_part: - online_trace_part["offline_aggregates"] = { - "offline_attributes": ["power", "duration", "energy"], - "duration": list(), - "power": list(), - "power_std": list(), - "energy": list(), - "paramkeys": list(), - "param": list(), - } - if "plot" in offline_trace_part: - online_trace_part["offline_support"] = [ - "power_traces", - "timestamps", - ] - online_trace_part["offline_aggregates"]["power_traces"] = list() - online_trace_part["offline_aggregates"]["timestamps"] = list() - if online_trace_part["isa"] == "transition": - online_trace_part["offline_aggregates"][ - "offline_attributes" - ].extend(["rel_power_prev", "rel_power_next"]) - online_trace_part["offline_aggregates"]["rel_energy_prev"] = list() - online_trace_part["offline_aggregates"]["rel_energy_next"] = list() - online_trace_part["offline_aggregates"]["rel_power_prev"] = list() - online_trace_part["offline_aggregates"]["rel_power_next"] = list() - - offline_aggregates = online_trace_part["offline_aggregates"] - - # if online_trace_part['isa'] == 'transitions': - # online_trace_part['offline_attributes'].extend(['rel_energy_prev', 'rel_energy_next']) - # offline_aggregates['rel_energy_prev'] = list() - # offline_aggregates['rel_energy_next'] = list() - - offline_aggregates["duration"].append(offline_trace_part["s"] * 1e6) - offline_aggregates["power"].append(offline_trace_part["W_mean"] * 1e6) - offline_aggregates["power_std"].append(offline_trace_part["W_std"] * 1e6) - offline_aggregates["energy"].append( - offline_trace_part["W_mean"] * offline_trace_part["s"] * 1e12 - ) - offline_aggregates["paramkeys"].append(paramkeys) - offline_aggregates["param"].append(paramvalues) - - if "plot" in offline_trace_part: - offline_aggregates["power_traces"].append(offline_trace_part["plot"][1]) - offline_aggregates["timestamps"].append(offline_trace_part["plot"][0]) - - if online_trace_part["isa"] == "transition": - offline_aggregates["rel_energy_prev"].append( - offline_trace_part["W_mean_delta_prev"] - * offline_trace_part["s"] - * 1e12 - ) - offline_aggregates["rel_energy_next"].append( - offline_trace_part["W_mean_delta_next"] - * offline_trace_part["s"] - * 1e12 - ) - offline_aggregates["rel_power_prev"].append( - offline_trace_part["W_mean_delta_prev"] * 1e6 - ) - offline_aggregates["rel_power_next"].append( - offline_trace_part["W_mean_delta_next"] * 1e6 - ) - - -class EnergyTraceWithBarcode: - """ - EnergyTrace log loader for DFA traces. - - Expects an EnergyTrace log file generated via msp430-etv / energytrace-util - and a dfatool-generated benchmark. An EnergyTrace log consits of a series - of measurements. Each measurement has a timestamp, mean current, voltage, - and cumulative energy since start of measurement. Each transition is - preceded by a Code128 barcode embedded into the energy consumption by - toggling a LED. - - Note that the baseline power draw of board and peripherals is not subtracted - at the moment. - """ - - def __init__( - self, - voltage: float, - state_duration: int, - transition_names: list, - with_traces=False, - ): - """ - Create a new EnergyTraceWithBarcode object. - - :param voltage: supply voltage [V], usually 3.3 V - :param state_duration: state duration [ms] - :param transition_names: list of transition names in PTA transition order. - Needed to map barcode synchronization numbers to transitions. - """ - self.voltage = voltage - self.state_duration = state_duration * 1e-3 - self.transition_names = transition_names - self.with_traces = with_traces - self.errors = list() - - # TODO auto-detect - self.led_power = 10e-3 - - # multipass/include/object/ptalog.h#startTransition - self.module_duration = 5e-3 - - # multipass/include/object/ptalog.h#startTransition - self.quiet_zone_duration = 60e-3 - - # TODO auto-detect? - # Note that we consider barcode duration after start, so only the - # quiet zone -after- the code is relevant - self.min_barcode_duration = 57 * self.module_duration + self.quiet_zone_duration - self.max_barcode_duration = 68 * self.module_duration + self.quiet_zone_duration - - def load_data(self, log_data): - """ - Load log data (raw energytrace .txt file, one line per event). - - :param log_data: raw energytrace log file in 4-column .txt format - """ - - if not zbar_available: - logger.error("zbar module is not available") - self.errors.append( - 'zbar module is not available. Try "apt install python3-zbar"' - ) - self.interval_power = None - return list() - - ( - self.interval_start_timestamp, - self.interval_duration, - self.interval_power, - self.sample_rate, - self.hw_statechange_indexes, - ) = _load_energytrace(log_data[0]) - - def ts_to_index(self, timestamp): - """ - Convert timestamp in seconds to interval_start_timestamp / interval_duration / interval_power index. - - Returns the index of the interval which timestamp is part of. - """ - return self._ts_to_index(timestamp, 0, len(self.interval_start_timestamp)) - - def _ts_to_index(self, timestamp, left_index, right_index): - if left_index == right_index: - return left_index - if left_index + 1 == right_index: - return left_index - - mid_index = left_index + (right_index - left_index) // 2 - - # I'm feeling lucky - if ( - timestamp > self.interval_start_timestamp[mid_index] - and timestamp - <= self.interval_start_timestamp[mid_index] - + self.interval_duration[mid_index] - ): - return mid_index - - if timestamp <= self.interval_start_timestamp[mid_index]: - return self._ts_to_index(timestamp, left_index, mid_index) - - return self._ts_to_index(timestamp, mid_index, right_index) - - def analyze_states(self, traces, offline_index: int): - """ - Split log data into states and transitions and return duration, energy, and mean power for each element. - - :param traces: expected traces, needed to synchronize with the measurement. - traces is a list of runs, traces[*]['trace'] is a single run - (i.e. a list of states and transitions, starting with a transition - and ending with a state). - :param offline_index: This function uses traces[*]['trace'][*]['online_aggregates']['duration'][offline_index] to find sync codes - - :param charges: raw charges (each element describes the charge in pJ transferred during 10 µs) - :param trigidx: "charges" indexes corresponding to a trigger edge, see `trigger_edges` - :param ua_func: charge(pJ) -> current(µA) function as returned by `calibration_function` - - :returns: maybe returns list of states and transitions, both starting andending with a state. - Each element is a dict containing: - * `isa`: 'state' or 'transition' - * `clip_rate`: range(0..1) Anteil an Clipping im Energieverbrauch - * `raw_mean`: Mittelwert der Rohwerte - * `raw_std`: Standardabweichung der Rohwerte - * `uW_mean`: Mittelwert der (kalibrierten) Leistungsaufnahme - * `uW_std`: Standardabweichung der (kalibrierten) Leistungsaufnahme - * `us`: Dauer - if isa == 'transition, it also contains: - * `timeout`: Dauer des vorherigen Zustands - * `uW_mean_delta_prev`: Differenz zwischen uW_mean und uW_mean des vorherigen Zustands - * `uW_mean_delta_next`: Differenz zwischen uW_mean und uW_mean des Folgezustands - """ - - energy_trace = list() - first_sync = self.find_first_sync() - - if first_sync is None: - logger.error("did not find initial synchronization pulse") - return energy_trace - - expected_transitions = list() - for trace_number, trace in enumerate(traces): - for state_or_transition_number, state_or_transition in enumerate( - trace["trace"] - ): - if state_or_transition["isa"] == "transition": - try: - expected_transitions.append( - ( - state_or_transition["name"], - state_or_transition["online_aggregates"]["duration"][ - offline_index - ] - * 1e-6, - ) - ) - except IndexError: - self.errors.append( - 'Entry #{} ("{}") in trace #{} has no duration entry for offline_index/repeat_id {}'.format( - state_or_transition_number, - state_or_transition["name"], - trace_number, - offline_index, - ) - ) - return energy_trace - - next_barcode = first_sync - - for name, duration in expected_transitions: - bc, start, stop, end = self.find_barcode(next_barcode) - if bc is None: - logger.error('did not find transition "{}"'.format(name)) - break - next_barcode = end + self.state_duration + duration - logger.debug( - '{} barcode "{}" area: {:0.2f} .. {:0.2f} / {:0.2f} seconds'.format( - offline_index, bc, start, stop, end - ) - ) - if bc != name: - logger.error('mismatch: expected "{}", got "{}"'.format(name, bc)) - logger.debug( - "{} estimated transition area: {:0.3f} .. {:0.3f} seconds".format( - offline_index, end, end + duration - ) - ) - - transition_start_index = self.ts_to_index(end) - transition_done_index = self.ts_to_index(end + duration) + 1 - state_start_index = transition_done_index - state_done_index = ( - self.ts_to_index(end + duration + self.state_duration) + 1 - ) - - logger.debug( - "{} estimated transitionindex: {:0.3f} .. {:0.3f} seconds".format( - offline_index, - transition_start_index / self.sample_rate, - transition_done_index / self.sample_rate, - ) - ) - - transition_power_W = self.interval_power[ - transition_start_index:transition_done_index - ] - - transition = { - "isa": "transition", - "W_mean": np.mean(transition_power_W), - "W_std": np.std(transition_power_W), - "s": duration, - "s_coarse": self.interval_start_timestamp[transition_done_index] - - self.interval_start_timestamp[transition_start_index], - } - - if self.with_traces: - timestamps = ( - self.interval_start_timestamp[ - transition_start_index:transition_done_index - ] - - self.interval_start_timestamp[transition_start_index] - ) - transition["plot"] = (timestamps, transition_power_W) - - energy_trace.append(transition) - - if len(energy_trace) > 1: - energy_trace[-1]["W_mean_delta_prev"] = ( - energy_trace[-1]["W_mean"] - energy_trace[-2]["W_mean"] - ) - else: - # TODO this really isn't nice, as W_mean_delta_prev of other setup - # transitions is probably different. The best solution might be - # ignoring the first transition when handling delta_prev values - energy_trace[-1]["W_mean_delta_prev"] = energy_trace[-1]["W_mean"] - - state_power_W = self.interval_power[state_start_index:state_done_index] - state = { - "isa": "state", - "W_mean": np.mean(state_power_W), - "W_std": np.std(state_power_W), - "s": self.state_duration, - "s_coarse": self.interval_start_timestamp[state_done_index] - - self.interval_start_timestamp[state_start_index], - } - - if self.with_traces: - timestamps = ( - self.interval_start_timestamp[state_start_index:state_done_index] - - self.interval_start_timestamp[state_start_index] - ) - state["plot"] = (timestamps, state_power_W) - - energy_trace.append(state) - - energy_trace[-2]["W_mean_delta_next"] = ( - energy_trace[-2]["W_mean"] - energy_trace[-1]["W_mean"] - ) - - expected_transition_count = len(expected_transitions) - recovered_transition_ount = len(energy_trace) // 2 - - if expected_transition_count != recovered_transition_ount: - self.errors.append( - "Expected {:d} transitions, got {:d}".format( - expected_transition_count, recovered_transition_ount - ) - ) - - return energy_trace - - def find_first_sync(self): - # zbar unavailable - if self.interval_power is None: - return None - # LED Power is approx. self.led_power W, use self.led_power/2 W above surrounding median as threshold - sync_threshold_power = ( - np.median(self.interval_power[: int(3 * self.sample_rate)]) - + self.led_power / 3 - ) - for i, ts in enumerate(self.interval_start_timestamp): - if ts > 2 and self.interval_power[i] > sync_threshold_power: - return self.interval_start_timestamp[i - 300] - return None - - def find_barcode(self, start_ts): - """ - Return absolute position and content of the next barcode following `start_ts`. - - :param interval_ts: list of start timestamps (one per measurement interval) [s] - :param interval_power: mean power per measurement interval [W] - :param start_ts: timestamp at which to start looking for a barcode [s] - """ - - for i, ts in enumerate(self.interval_start_timestamp): - if ts >= start_ts: - start_position = i - break - - # Lookaround: 100 ms in both directions - lookaround = int(0.1 * self.sample_rate) - - # LED Power is approx. self.led_power W, use self.led_power/2 W above surrounding median as threshold - sync_threshold_power = ( - np.median( - self.interval_power[ - start_position - lookaround : start_position + lookaround - ] - ) - + self.led_power / 3 - ) - - logger.debug( - "looking for barcode starting at {:0.2f} s, threshold is {:0.1f} mW".format( - start_ts, sync_threshold_power * 1e3 - ) - ) - - sync_area_start = None - sync_start_ts = None - sync_area_end = None - sync_end_ts = None - for i, ts in enumerate(self.interval_start_timestamp): - if ( - sync_area_start is None - and ts >= start_ts - and self.interval_power[i] > sync_threshold_power - ): - sync_area_start = i - 300 - sync_start_ts = ts - if ( - sync_area_start is not None - and sync_area_end is None - and ts > sync_start_ts + self.min_barcode_duration - and ( - ts > sync_start_ts + self.max_barcode_duration - or abs(sync_threshold_power - self.interval_power[i]) - > self.led_power - ) - ): - sync_area_end = i - sync_end_ts = ts - break - - barcode_data = self.interval_power[sync_area_start:sync_area_end] - - logger.debug( - "barcode search area: {:0.2f} .. {:0.2f} seconds ({} samples)".format( - sync_start_ts, sync_end_ts, len(barcode_data) - ) - ) - - bc, start, stop, padding_bits = self.find_barcode_in_power_data(barcode_data) - - if bc is None: - return None, None, None, None - - start_ts = self.interval_start_timestamp[sync_area_start + start] - stop_ts = self.interval_start_timestamp[sync_area_start + stop] - - end_ts = ( - stop_ts + self.module_duration * padding_bits + self.quiet_zone_duration - ) - - # barcode content, barcode start timestamp, barcode stop timestamp, barcode end (stop + padding) timestamp - return bc, start_ts, stop_ts, end_ts - - def find_barcode_in_power_data(self, barcode_data): - - min_power = np.min(barcode_data) - max_power = np.max(barcode_data) - - # zbar seems to be confused by measurement (and thus image) noise - # inside of barcodes. As our barcodes are only 1px high, this is - # likely not trivial to fix. - # -> Create a black and white (not grayscale) image to avoid this. - # Unfortunately, this decreases resilience against background noise - # (e.g. a not-exactly-idle peripheral device or CPU interrupts). - image_data = np.around( - 1 - ((barcode_data - min_power) / (max_power - min_power)) - ) - image_data *= 255 - - # zbar only returns the complete barcode position if it is at least - # two pixels high. For a 1px barcode, it only returns its right border. - - width = len(image_data) - height = 2 - - image_data = bytes(map(int, image_data)) * height - - # img = Image.frombytes('L', (width, height), image_data).resize((width, 100)) - # img.save('/tmp/test-{}.png'.format(os.getpid())) - - zbimg = zbar.Image(width, height, "Y800", image_data) - scanner = zbar.ImageScanner() - scanner.parse_config("enable") - - if scanner.scan(zbimg): - (sym,) = zbimg.symbols - content = sym.data - try: - sym_start = sym.location[1][0] - except IndexError: - sym_start = 0 - sym_end = sym.location[0][0] - - match = re.fullmatch(r"T(\d+)", content) - if match: - content = self.transition_names[int(match.group(1))] - - # PTALog barcode generation operates on bytes, so there may be - # additional non-barcode padding (encoded as LED off / image white). - # Calculate the amount of extra bits to determine the offset until - # the transition starts. - padding_bits = len(Code128(sym.data, charset="B").modules) % 8 - - # sym_start leaves out the first two bars, but we don't do anything about that here - # sym_end leaves out the last three bars, each of which is one padding bit long. - # as a workaround, we unconditionally increment padding_bits by three. - padding_bits += 3 - - return content, sym_start, sym_end, padding_bits - else: - logger.warning("unable to find barcode") - return None, None, None, None - - -class EnergyTraceWithLogicAnalyzer: - def __init__( - self, - voltage: float, - state_duration: int, - transition_names: list, - with_traces=False, - ): - - """ - Create a new EnergyTraceWithLogicAnalyzer object. - - :param voltage: supply voltage [V], usually 3.3 V - :param state_duration: state duration [ms] - :param transition_names: list of transition names in PTA transition order. - Needed to map barcode synchronization numbers to transitions. - """ - self.voltage = voltage - self.state_duration = state_duration * 1e-3 - self.transition_names = transition_names - self.with_traces = with_traces - self.errors = list() - - def load_data(self, log_data): - from dfatool.lennart.SigrokInterface import SigrokResult - from dfatool.lennart.EnergyInterface import EnergyInterface - - # Daten laden - self.sync_data = SigrokResult.fromString(log_data[0]) - ( - self.interval_start_timestamp, - self.interval_duration, - self.interval_power, - self.sample_rate, - self.hw_statechange_indexes, - ) = _load_energytrace(log_data[1]) - - def analyze_states(self, traces, offline_index: int): - """ - Split log data into states and transitions and return duration, energy, and mean power for each element. - - :param traces: expected traces, needed to synchronize with the measurement. - traces is a list of runs, traces[*]['trace'] is a single run - (i.e. a list of states and transitions, starting with a transition - and ending with a state). - :param offline_index: This function uses traces[*]['trace'][*]['online_aggregates']['duration'][offline_index] to find sync codes - - :param charges: raw charges (each element describes the charge in pJ transferred during 10 µs) - :param trigidx: "charges" indexes corresponding to a trigger edge, see `trigger_edges` - :param ua_func: charge(pJ) -> current(µA) function as returned by `calibration_function` - - :returns: returns list of states and transitions, starting with a transition and ending with astate - Each element is a dict containing: - * `isa`: 'state' or 'transition' - * `W_mean`: Mittelwert der Leistungsaufnahme - * `W_std`: Standardabweichung der Leistungsaufnahme - * `s`: Dauer - if isa == 'transition, it also contains: - * `W_mean_delta_prev`: Differenz zwischen W_mean und W_mean des vorherigen Zustands - * `W_mean_delta_next`: Differenz zwischen W_mean und W_mean des Folgezustands - """ - - names = [] - for trace_number, trace in enumerate(traces): - for state_or_transition in trace["trace"]: - names.append(state_or_transition["name"]) - # print(names[:15]) - from dfatool.lennart.DataProcessor import DataProcessor - - dp = DataProcessor( - sync_data=self.sync_data, - et_timestamps=self.interval_start_timestamp, - et_power=self.interval_power, - hw_statechange_indexes=self.hw_statechange_indexes, - offline_index=offline_index, - ) - dp.run() - energy_trace_new = dp.getStatesdfatool( - state_sleep=self.state_duration, with_traces=self.with_traces - ) - # Uncomment to plot traces - if os.getenv("DFATOOL_PLOT_LASYNC") is not None and offline_index == int( - os.getenv("DFATOOL_PLOT_LASYNC") - ): - dp.plot() # <- plot traces with sync annotatons - # dp.plot(names) # <- plot annotated traces (with state/transition names) - if os.getenv("DFATOOL_EXPORT_LASYNC") is not None: - filename = os.getenv("DFATOOL_EXPORT_LASYNC") + f"_{offline_index}.json" - with open(filename, "w") as f: - json.dump(dp.export_sync(), f, cls=NpEncoder) - logger.info("Exported data and LA sync timestamps to {filename}") - - energy_trace = list() - expected_transitions = list() - - # Print for debug purposes - # for number, name in enumerate(names): - # if "P15_8MW" in name: - # print(name, energy_trace_new[number]["W_mean"]) - - # st = "" - # for i, x in enumerate(energy_trace_new[-10:]): - # #st += "(%s|%s|%s)" % (energy_trace[i-10]["name"],x['W_mean'],x['s']) - # st += "(%s|%s|%s)\n" % (energy_trace[i-10]["s"], x['s'], x['W_mean']) - - # print(st, "\n_______________________") - # print(len(self.sync_data.timestamps), " - ", len(energy_trace_new), " - ", len(energy_trace), " - ", ",".join([str(x["s"]) for x in energy_trace_new[-6:]]), " - ", ",".join([str(x["s"]) for x in energy_trace[-6:]])) - # if len(energy_trace_new) < len(energy_trace): - # return None - - return energy_trace_new - - -class EnergyTraceWithTimer(EnergyTraceWithLogicAnalyzer): - def __init__( - self, - voltage: float, - state_duration: int, - transition_names: list, - with_traces=False, - ): - - """ - Create a new EnergyTraceWithLogicAnalyzer object. - - :param voltage: supply voltage [V], usually 3.3 V - :param state_duration: state duration [ms] - :param transition_names: list of transition names in PTA transition order. - Needed to map barcode synchronization numbers to transitions. - """ - - self.voltage = voltage - self.state_duration = state_duration * 1e-3 - self.transition_names = transition_names - self.with_traces = with_traces - self.errors = list() - - super().__init__(voltage, state_duration, transition_names, with_traces) - - def load_data(self, log_data): - self.sync_data = None - ( - self.interval_start_timestamp, - self.interval_duration, - self.interval_power, - self.sample_rate, - self.hw_statechange_indexes, - ) = _load_energytrace(log_data[0]) - - def analyze_states(self, traces, offline_index: int): - - # Start "Synchronization pulse" - timestamps = [0, 10, 1e6, 1e6 + 10] - - # The first trace doesn't start immediately, append offset saved by OnboarTimerHarness - timestamps.append(timestamps[-1] + traces[0]["start_offset"][offline_index]) - for tr in traces: - for t in tr["trace"]: - # print(t["online_aggregates"]["duration"][offline_index]) - try: - timestamps.append( - timestamps[-1] - + t["online_aggregates"]["duration"][offline_index] - ) - except IndexError: - self.errors.append( - f"""offline_index {offline_index} missing in trace {tr["id"]}""" - ) - return list() - - # print(timestamps) - - # Stop "Synchronization pulses". The first one has already started. - timestamps.extend(np.array([10, 1e6, 1e6 + 10]) + timestamps[-1]) - timestamps.extend(np.array([0, 10, 1e6, 1e6 + 10]) + 250e3 + timestamps[-1]) - - timestamps = list(np.array(timestamps) * 1e-6) - - from dfatool.lennart.SigrokInterface import SigrokResult - - self.sync_data = SigrokResult(timestamps, False) - return super().analyze_states(traces, offline_index) - - -class MIMOSA: - """ - MIMOSA log loader for DFA traces with auto-calibration. - - Expects a MIMOSA log file generated via dfatool and a dfatool-generated - benchmark. A MIMOSA log consists of a series of measurements. Each measurement - gives the total charge (in pJ) and binary buzzer/trigger value during a 10µs interval. - - There must be a calibration run consisting of at least two seconds with disconnected DUT, - two seconds with 1 kOhm (984 Ohm), and two seconds with 100 kOhm (99013 Ohm) resistor at - the start. The first ten seconds of data are reserved for calbiration and must not contain - measurements, as trigger/buzzer signals are ignored in this time range. - - Resulting data is a list of state/transition/state/transition/... measurements. - """ - - def __init__(self, voltage: float, shunt: int, with_traces=False): - """ - Initialize MIMOSA loader for a specific voltage and shunt setting. - - :param voltage: MIMOSA DUT supply voltage (V) - :para mshunt: MIMOSA Shunt (Ohms) - """ - self.voltage = voltage - self.shunt = shunt - self.with_traces = with_traces - self.r1 = 984 # "1k" - self.r2 = 99013 # "100k" - self.errors = list() - - def charge_to_current_nocal(self, charge): - """ - Convert charge per 10µs (in pJ) to mean currents (in µA) without accounting for calibration. - - :param charge: numpy array of charges (pJ per 10µs) as returned by `load_data` or `load_file` - - :returns: numpy array of mean currents (µA per 10µs) - """ - ua_max = 1.836 / self.shunt * 1_000_000 - ua_step = ua_max / 65535 - return charge * ua_step - - def _state_is_too_short(self, online, offline, state_duration, next_transition): - # We cannot control when an interrupt causes a state to be left - if next_transition["plan"]["level"] == "epilogue": - return False - - # Note: state_duration is stored as ms, not us - return offline["us"] < state_duration * 500 - - def _state_is_too_long(self, online, offline, state_duration, prev_transition): - # If the previous state was left by an interrupt, we may have some - # waiting time left over. So it's okay if the current state is longer - # than expected. - if prev_transition["plan"]["level"] == "epilogue": - return False - # state_duration is stored as ms, not us - return offline["us"] > state_duration * 1500 - - def _load_tf(self, tf): - """ - Load MIMOSA log data from an open `tarfile` instance. - - :param tf: `tarfile` instance - - :returns: (numpy array of charges (pJ per 10µs), numpy array of triggers (0/1 int, per 10µs)) - """ - num_bytes = tf.getmember("/tmp/mimosa//mimosa_scale_1.tmp").size - charges = np.ndarray(shape=(int(num_bytes / 4)), dtype=np.int32) - triggers = np.ndarray(shape=(int(num_bytes / 4)), dtype=np.int8) - with tf.extractfile("/tmp/mimosa//mimosa_scale_1.tmp") as f: - content = f.read() - iterator = struct.iter_unpack("<I", content) - i = 0 - for word in iterator: - charges[i] = word[0] >> 4 - triggers[i] = (word[0] & 0x08) >> 3 - i += 1 - return charges, triggers - - def load_data(self, raw_data): - """ - Load MIMOSA log data from a MIMOSA log file passed as raw byte string - - :param raw_data: MIMOSA log file, passed as raw byte string - - :returns: (numpy array of charges (pJ per 10µs), numpy array of triggers (0/1 int, per 10µs)) - """ - with io.BytesIO(raw_data) as data_object: - with tarfile.open(fileobj=data_object) as tf: - return self._load_tf(tf) - - def load_file(self, filename): - """ - Load MIMOSA log data from a MIMOSA log file - - :param filename: MIMOSA log file - - :returns: (numpy array of charges (pJ per 10µs), numpy array of triggers (0/1 int, per 10µs)) - """ - with tarfile.open(filename) as tf: - return self._load_tf(tf) - - def currents_nocal(self, charges): - """ - Convert charges (pJ per 10µs) to mean currents without accounting for calibration. - - :param charges: numpy array of charges (pJ per 10µs) - - :returns: numpy array of currents (mean µA per 10µs)""" - ua_max = 1.836 / self.shunt * 1_000_000 - ua_step = ua_max / 65535 - return charges.astype(np.double) * ua_step - - def trigger_edges(self, triggers): - """ - Return indexes of trigger edges (both 0->1 and 1->0) in log data. - - Ignores the first 10 seconds, which are used for calibration and may - contain bogus triggers due to DUT resets. - - :param triggers: trigger array (int, 0/1) as returned by load_data - - :returns: list of int (trigger indices, e.g. [2000000, ...] means the first trigger appears in charges/currents interval 2000000 -> 20s after start of measurements. Keep in mind that each interval is 10µs long, not 1µs, so index values are not µs timestamps) - """ - trigidx = [] - - if len(triggers) < 1_000_000: - self.errors.append("MIMOSA log is too short") - return trigidx - - prevtrig = triggers[999_999] - - # if the first trigger is high (i.e., trigger/buzzer pin is active before the benchmark starts), - # something went wrong and are unable to determine when the first - # transition starts. - if prevtrig != 0: - self.errors.append( - "Unable to find start of first transition (log starts with trigger == {} != 0)".format( - prevtrig - ) - ) - - # if the last trigger is high (i.e., trigger/buzzer pin is active when the benchmark ends), - # it terminated in the middle of a transition -- meaning that it was not - # measured in its entirety. - if triggers[-1] != 0: - self.errors.append("Log ends during a transition".format(prevtrig)) - - # the device is reset for MIMOSA calibration in the first 10s and may - # send bogus interrupts -> bogus triggers - for i in range(1_000_000, triggers.shape[0]): - trig = triggers[i] - if trig != prevtrig: - # Due to MIMOSA's integrate-read-reset cycle, the charge/current - # interval belonging to this trigger comes two intervals (20µs) later - trigidx.append(i + 2) - prevtrig = trig - return trigidx - - def calibration_edges(self, currents): - """ - Return start/stop indexes of calibration measurements. - - :param currents: uncalibrated currents as reported by MIMOSA. For best results, - it may help to use a running mean, like so: - `currents = running_mean(currents_nocal(..., 10))` - - :returns: indices of calibration events in MIMOSA data: - (disconnect start, disconnect stop, R1 (1k) start, R1 (1k) stop, R2 (100k) start, R2 (100k) stop) - indices refer to charges/currents arrays, so 0 refers to the first 10µs interval, 1 to the second, and so on. - """ - r1idx = 0 - r2idx = 0 - ua_r1 = self.voltage / self.r1 * 1_000_000 - # first second may be bogus - for i in range(100_000, len(currents)): - if r1idx == 0 and currents[i] > ua_r1 * 0.6: - r1idx = i - elif ( - r1idx != 0 - and r2idx == 0 - and i > (r1idx + 180_000) - and currents[i] < ua_r1 * 0.4 - ): - r2idx = i - # 2s disconnected, 2s r1, 2s r2 with r1 < r2 -> ua_r1 > ua_r2 - # allow 5ms buffer in both directions to account for bouncing relais contacts - return ( - r1idx - 180_500, - r1idx - 500, - r1idx + 500, - r2idx - 500, - r2idx + 500, - r2idx + 180_500, - ) - - def calibration_function(self, charges, cal_edges): - """ - Calculate calibration function from previously determined calibration edges. - - :param charges: raw charges from MIMOSA - :param cal_edges: calibration edges as returned by calibration_edges - - :returns: (calibration_function, calibration_data): - calibration_function -- charge in pJ (float) -> current in uA (float). - Converts the amount of charge in a 10 µs interval to the - mean current during the same interval. - calibration_data -- dict containing the following keys: - edges -- calibration points in the log file, in µs - offset -- ... - offset2 -- ... - slope_low -- ... - slope_high -- ... - add_low -- ... - add_high -- .. - r0_err_uW -- mean error of uncalibrated data at "∞ Ohm" in µW - r0_std_uW -- standard deviation of uncalibrated data at "∞ Ohm" in µW - r1_err_uW -- mean error of uncalibrated data at 1 kOhm - r1_std_uW -- stddev at 1 kOhm - r2_err_uW -- mean error at 100 kOhm - r2_std_uW -- stddev at 100 kOhm - """ - dis_start, dis_end, r1_start, r1_end, r2_start, r2_end = cal_edges - if dis_start < 0: - dis_start = 0 - chg_r0 = charges[dis_start:dis_end] - chg_r1 = charges[r1_start:r1_end] - chg_r2 = charges[r2_start:r2_end] - cal_0_mean = np.mean(chg_r0) - cal_r1_mean = np.mean(chg_r1) - cal_r2_mean = np.mean(chg_r2) - - ua_r1 = self.voltage / self.r1 * 1_000_000 - ua_r2 = self.voltage / self.r2 * 1_000_000 - - if cal_r2_mean > cal_0_mean: - b_lower = (ua_r2 - 0) / (cal_r2_mean - cal_0_mean) - else: - logger.warning("0 uA == %.f uA during calibration" % (ua_r2)) - b_lower = 0 - - b_upper = (ua_r1 - ua_r2) / (cal_r1_mean - cal_r2_mean) - - a_lower = -b_lower * cal_0_mean - a_upper = -b_upper * cal_r2_mean - - if self.shunt == 680: - # R1 current is higher than shunt range -> only use R2 for calibration - def calfunc(charge): - if charge < cal_0_mean: - return 0 - else: - return charge * b_lower + a_lower - - else: - - def calfunc(charge): - if charge < cal_0_mean: - return 0 - if charge <= cal_r2_mean: - return charge * b_lower + a_lower - else: - return charge * b_upper + a_upper + ua_r2 - - caldata = { - "edges": [x * 10 for x in cal_edges], - "offset": cal_0_mean, - "offset2": cal_r2_mean, - "slope_low": b_lower, - "slope_high": b_upper, - "add_low": a_lower, - "add_high": a_upper, - "r0_err_uW": np.mean(self.currents_nocal(chg_r0)) * self.voltage, - "r0_std_uW": np.std(self.currents_nocal(chg_r0)) * self.voltage, - "r1_err_uW": (np.mean(self.currents_nocal(chg_r1)) - ua_r1) * self.voltage, - "r1_std_uW": np.std(self.currents_nocal(chg_r1)) * self.voltage, - "r2_err_uW": (np.mean(self.currents_nocal(chg_r2)) - ua_r2) * self.voltage, - "r2_std_uW": np.std(self.currents_nocal(chg_r2)) * self.voltage, - } - - # print("if charge < %f : return 0" % cal_0_mean) - # print("if charge <= %f : return charge * %f + %f" % (cal_r2_mean, b_lower, a_lower)) - # print("else : return charge * %f + %f + %f" % (b_upper, a_upper, ua_r2)) - - return calfunc, caldata - - def analyze_states(self, charges, trigidx, ua_func): - """ - Split log data into states and transitions and return duration, energy, and mean power for each element. - - :param charges: raw charges (each element describes the charge in pJ transferred during 10 µs) - :param trigidx: "charges" indexes corresponding to a trigger edge, see `trigger_edges` - :param ua_func: charge(pJ) -> current(µA) function as returned by `calibration_function` - - :returns: list of states and transitions, both starting andending with a state. - Each element is a dict containing: - * `isa`: 'state' or 'transition' - * `clip_rate`: range(0..1) Anteil an Clipping im Energieverbrauch - * `raw_mean`: Mittelwert der Rohwerte - * `raw_std`: Standardabweichung der Rohwerte - * `uW_mean`: Mittelwert der (kalibrierten) Leistungsaufnahme - * `uW_std`: Standardabweichung der (kalibrierten) Leistungsaufnahme - * `us`: Dauer - if isa == 'transition, it also contains: - * `timeout`: Dauer des vorherigen Zustands - * `uW_mean_delta_prev`: Differenz zwischen uW_mean und uW_mean des vorherigen Zustands - * `uW_mean_delta_next`: Differenz zwischen uW_mean und uW_mean des Folgezustands - if `self.with_traces` is true, it also contains: - * `plot`: (timestamps [s], power readings [W]) - """ - previdx = 0 - is_state = True - iterdata = [] - - # The last state (between the last transition and end of file) may also - # be important. Pretend it ends when the log ends. - trigger_indices = trigidx.copy() - trigger_indices.append(len(charges)) - - for idx in trigger_indices: - range_raw = charges[previdx:idx] - range_ua = ua_func(range_raw) - - isa = "state" - if not is_state: - isa = "transition" - - data = { - "isa": isa, - "clip_rate": np.mean(range_raw == 65535), - "raw_mean": np.mean(range_raw), - "raw_std": np.std(range_raw), - "uW_mean": np.mean(range_ua * self.voltage), - "uW_std": np.std(range_ua * self.voltage), - "us": (idx - previdx) * 10, - } - - if self.with_traces: - data["plot"] = ( - np.arange(len(range_ua)) * 1e-5, - range_ua * self.voltage * 1e-6, - ) - - if isa == "transition": - # subtract average power of previous state - # (that is, the state from which this transition originates) - data["uW_mean_delta_prev"] = data["uW_mean"] - iterdata[-1]["uW_mean"] - # placeholder to avoid extra cases in the analysis - data["uW_mean_delta_next"] = data["uW_mean"] - data["timeout"] = iterdata[-1]["us"] - elif len(iterdata) > 0: - # subtract average power of next state - # (the state into which this transition leads) - iterdata[-1]["uW_mean_delta_next"] = ( - iterdata[-1]["uW_mean"] - data["uW_mean"] - ) - - iterdata.append(data) - - previdx = idx - is_state = not is_state - return iterdata - - def validate(self, num_triggers, observed_trace, expected_traces, state_duration): - """ - Check if a dfatool v0 or v1 measurement is valid. - - processed_data layout: - 'fileno' : measurement['fileno'], - 'info' : measurement['info'], - 'triggers' : len(trigidx), - 'first_trig' : trigidx[0] * 10, - 'calibration' : caldata, - 'energy_trace' : mim.analyze_states(charges, trigidx, vcalfunc) - A sequence of unnamed, unparameterized states and transitions with - power and timing data - mim.analyze_states returns a list of (alternating) states and transitions. - Each element is a dict containing: - - isa: 'state' oder 'transition' - - clip_rate: range(0..1) Anteil an Clipping im Energieverbrauch - - raw_mean: Mittelwert der Rohwerte - - raw_std: Standardabweichung der Rohwerte - - uW_mean: Mittelwert der (kalibrierten) Leistungsaufnahme - - uW_std: Standardabweichung der (kalibrierten) Leistungsaufnahme - - us: Dauer - - Nur falls isa == 'transition': - - timeout: Dauer des vorherigen Zustands - - uW_mean_delta_prev: Differenz zwischen uW_mean und uW_mean des vorherigen Zustands - - uW_mean_delta_next: Differenz zwischen uW_mean und uW_mean des Folgezustands - """ - if len(self.errors): - return False - - # Check trigger count - sched_trigger_count = 0 - for run in expected_traces: - sched_trigger_count += len(run["trace"]) - if sched_trigger_count != num_triggers: - self.errors.append( - "got {got:d} trigger edges, expected {exp:d}".format( - got=num_triggers, exp=sched_trigger_count - ) - ) - return False - # Check state durations. Very short or long states can indicate a - # missed trigger signal which wasn't detected due to duplicate - # triggers elsewhere - online_datapoints = [] - for run_idx, run in enumerate(expected_traces): - for trace_part_idx in range(len(run["trace"])): - online_datapoints.append((run_idx, trace_part_idx)) - for offline_idx, (online_run_idx, online_trace_part_idx) in enumerate( - online_datapoints - ): - offline_trace_part = observed_trace[offline_idx] - online_trace_part = expected_traces[online_run_idx]["trace"][ - online_trace_part_idx - ] - - if online_trace_part["isa"] != offline_trace_part["isa"]: - self.errors.append( - "Offline #{off_idx:d} (online {on_name:s} @ {on_idx:d}/{on_sub:d}) claims to be {off_isa:s}, but should be {on_isa:s}".format( - off_idx=offline_idx, - on_idx=online_run_idx, - on_sub=online_trace_part_idx, - on_name=online_trace_part["name"], - off_isa=offline_trace_part["isa"], - on_isa=online_trace_part["isa"], - ) - ) - return False - - # Clipping in UNINITIALIZED (offline_idx == 0) can happen during - # calibration and is handled by MIMOSA - if ( - offline_idx != 0 - and offline_trace_part["clip_rate"] != 0 - and not self.ignore_clipping - ): - self.errors.append( - "Offline #{off_idx:d} (online {on_name:s} @ {on_idx:d}/{on_sub:d}) was clipping {clip:f}% of the time".format( - off_idx=offline_idx, - on_idx=online_run_idx, - on_sub=online_trace_part_idx, - on_name=online_trace_part["name"], - clip=offline_trace_part["clip_rate"] * 100, - ) - ) - return False - - if ( - online_trace_part["isa"] == "state" - and online_trace_part["name"] != "UNINITIALIZED" - and len(expected_traces[online_run_idx]["trace"]) - > online_trace_part_idx + 1 - ): - online_prev_transition = expected_traces[online_run_idx]["trace"][ - online_trace_part_idx - 1 - ] - online_next_transition = expected_traces[online_run_idx]["trace"][ - online_trace_part_idx + 1 - ] - try: - if self._state_is_too_short( - online_trace_part, - offline_trace_part, - state_duration, - online_next_transition, - ): - self.errors.append( - "Offline #{off_idx:d} (online {on_name:s} @ {on_idx:d}/{on_sub:d}) is too short (duration = {dur:d} us)".format( - off_idx=offline_idx, - on_idx=online_run_idx, - on_sub=online_trace_part_idx, - on_name=online_trace_part["name"], - dur=offline_trace_part["us"], - ) - ) - return False - if self._state_is_too_long( - online_trace_part, - offline_trace_part, - state_duration, - online_prev_transition, - ): - self.errors.append( - "Offline #{off_idx:d} (online {on_name:s} @ {on_idx:d}/{on_sub:d}) is too long (duration = {dur:d} us)".format( - off_idx=offline_idx, - on_idx=online_run_idx, - on_sub=online_trace_part_idx, - on_name=online_trace_part["name"], - dur=offline_trace_part["us"], - ) - ) - return False - except KeyError: - pass - # TODO es gibt next_transitions ohne 'plan' - return True - - @staticmethod - def add_offline_aggregates(online_traces, offline_trace, repeat_id): - # Edits online_traces[*]['trace'][*]['offline'] - # and online_traces[*]['trace'][*]['offline_aggregates'] in place - # (appends data from offline_trace) - # "offline_aggregates" is the only data used later on by model.py's by_name / by_param dicts - online_datapoints = [] - for run_idx, run in enumerate(online_traces): - for trace_part_idx in range(len(run["trace"])): - online_datapoints.append((run_idx, trace_part_idx)) - for offline_idx, (online_run_idx, online_trace_part_idx) in enumerate( - online_datapoints - ): - offline_trace_part = offline_trace[offline_idx] - online_trace_part = online_traces[online_run_idx]["trace"][ - online_trace_part_idx - ] - - if "offline" not in online_trace_part: - online_trace_part["offline"] = [offline_trace_part] - else: - online_trace_part["offline"].append(offline_trace_part) - - paramkeys = sorted(online_trace_part["parameter"].keys()) - - paramvalues = list() - - for paramkey in paramkeys: - if type(online_trace_part["parameter"][paramkey]) is list: - paramvalues.append( - soft_cast_int( - online_trace_part["parameter"][paramkey][repeat_id] - ) - ) - else: - paramvalues.append( - soft_cast_int(online_trace_part["parameter"][paramkey]) - ) - - # NB: Unscheduled transitions do not have an 'args' field set. - # However, they should only be caused by interrupts, and - # interrupts don't have args anyways. - if arg_support_enabled and "args" in online_trace_part: - paramvalues.extend(map(soft_cast_int, online_trace_part["args"])) - - # TODO rename offline_aggregates to make it clear that this is what ends up in by_name / by_param and model.py - if "offline_aggregates" not in online_trace_part: - online_trace_part["offline_attributes"] = [ - "power", - "duration", - "energy", - ] - # this is what ends up in by_name / by_param and is used by model.py - online_trace_part["offline_aggregates"] = { - "power": [], - "duration": [], - "power_std": [], - "energy": [], - "paramkeys": [], - "param": [], - } - if online_trace_part["isa"] == "transition": - online_trace_part["offline_attributes"].extend( - [ - "rel_energy_prev", - "rel_energy_next", - "rel_power_prev", - "rel_power_next", - "timeout", - ] - ) - online_trace_part["offline_aggregates"]["rel_energy_prev"] = [] - online_trace_part["offline_aggregates"]["rel_energy_next"] = [] - online_trace_part["offline_aggregates"]["rel_power_prev"] = [] - online_trace_part["offline_aggregates"]["rel_power_next"] = [] - online_trace_part["offline_aggregates"]["timeout"] = [] - if "plot" in offline_trace_part: - online_trace_part["offline_support"] = [ - "power_traces", - "timestamps", - ] - online_trace_part["offline_aggregates"]["power_traces"] = list() - online_trace_part["offline_aggregates"]["timestamps"] = list() - - # Note: All state/transitions are 20us "too long" due to injected - # active wait states. These are needed to work around MIMOSA's - # relatively low sample rate of 100 kHz (10us) and removed here. - online_trace_part["offline_aggregates"]["power"].append( - offline_trace_part["uW_mean"] - ) - online_trace_part["offline_aggregates"]["duration"].append( - offline_trace_part["us"] - 20 - ) - online_trace_part["offline_aggregates"]["power_std"].append( - offline_trace_part["uW_std"] - ) - online_trace_part["offline_aggregates"]["energy"].append( - offline_trace_part["uW_mean"] * (offline_trace_part["us"] - 20) - ) - online_trace_part["offline_aggregates"]["paramkeys"].append(paramkeys) - online_trace_part["offline_aggregates"]["param"].append(paramvalues) - if online_trace_part["isa"] == "transition": - online_trace_part["offline_aggregates"]["rel_energy_prev"].append( - offline_trace_part["uW_mean_delta_prev"] - * (offline_trace_part["us"] - 20) - ) - online_trace_part["offline_aggregates"]["rel_energy_next"].append( - offline_trace_part["uW_mean_delta_next"] - * (offline_trace_part["us"] - 20) - ) - online_trace_part["offline_aggregates"]["rel_power_prev"].append( - offline_trace_part["uW_mean_delta_prev"] - ) - online_trace_part["offline_aggregates"]["rel_power_next"].append( - offline_trace_part["uW_mean_delta_next"] - ) - online_trace_part["offline_aggregates"]["timeout"].append( - offline_trace_part["timeout"] - ) - - if "plot" in offline_trace_part: - online_trace_part["offline_aggregates"]["power_traces"].append( - offline_trace_part["plot"][1] - ) - online_trace_part["offline_aggregates"]["timestamps"].append( - offline_trace_part["plot"][0] - ) diff --git a/lib/loader/__init__.py b/lib/loader/__init__.py new file mode 100644 index 0000000..1b9b18f --- /dev/null +++ b/lib/loader/__init__.py @@ -0,0 +1,878 @@ +#!/usr/bin/env python3 + +import io +import json +import logging +import numpy as np +import os +import re +import struct +import tarfile +import hashlib +from multiprocessing import Pool + +from dfatool.utils import NpEncoder, running_mean, soft_cast_int + +from .energytrace import ( + EnergyTrace, + EnergyTraceWithBarcode, + EnergyTraceWithLogicAnalyzer, + EnergyTraceWithTimer, +) +from .keysight import KeysightCSV +from .mimosa import MIMOSA + +logger = logging.getLogger(__name__) + +try: + from .pubcode import Code128 + import zbar + + zbar_available = True +except ImportError: + zbar_available = False + + +arg_support_enabled = True + + +def _preprocess_mimosa(measurement): + setup = measurement["setup"] + mim = MIMOSA( + float(setup["mimosa_voltage"]), + int(setup["mimosa_shunt"]), + with_traces=measurement["with_traces"], + ) + try: + charges, triggers = mim.load_data(measurement["content"]) + trigidx = mim.trigger_edges(triggers) + except EOFError as e: + mim.errors.append("MIMOSA logfile error: {}".format(e)) + trigidx = list() + + if len(trigidx) == 0: + mim.errors.append("MIMOSA log has no triggers") + return { + "fileno": measurement["fileno"], + "info": measurement["info"], + "errors": mim.errors, + "repeat_id": measurement["repeat_id"], + "valid": False, + } + + cal_edges = mim.calibration_edges( + running_mean(mim.currents_nocal(charges[0 : trigidx[0]]), 10) + ) + calfunc, caldata = mim.calibration_function(charges, cal_edges) + vcalfunc = np.vectorize(calfunc, otypes=[np.float64]) + traces = mim.analyze_states(charges, trigidx, vcalfunc) + + # the last (v0) / first (v1) state is not part of the benchmark + traces.pop(measurement["pop"]) + + mim.validate( + len(trigidx), traces, measurement["expected_trace"], setup["state_duration"] + ) + + processed_data = { + "triggers": len(trigidx), + "first_trig": trigidx[0] * 10, + "calibration": caldata, + "energy_trace": traces, + "errors": mim.errors, + "valid": len(mim.errors) == 0, + } + + for key in ["fileno", "info", "repeat_id"]: + processed_data[key] = measurement[key] + + return processed_data + + +def _preprocess_etlog(measurement): + setup = measurement["setup"] + + energytrace_class = EnergyTraceWithBarcode + if measurement["sync_mode"] == "la": + energytrace_class = EnergyTraceWithLogicAnalyzer + elif measurement["sync_mode"] == "timer": + energytrace_class = EnergyTraceWithTimer + + etlog = energytrace_class( + float(setup["voltage"]), + int(setup["state_duration"]), + measurement["transition_names"], + with_traces=measurement["with_traces"], + ) + states_and_transitions = list() + try: + etlog.load_data(measurement["content"]) + states_and_transitions = etlog.analyze_states( + measurement["expected_trace"], measurement["repeat_id"] + ) + except EOFError as e: + etlog.errors.append("EnergyTrace logfile error: {}".format(e)) + except RuntimeError as e: + etlog.errors.append("EnergyTrace loader error: {}".format(e)) + + processed_data = { + "fileno": measurement["fileno"], + "repeat_id": measurement["repeat_id"], + "info": measurement["info"], + "energy_trace": states_and_transitions, + "valid": len(etlog.errors) == 0, + "errors": etlog.errors, + } + + return processed_data + + +class TimingData: + """ + Loader for timing model traces measured with on-board timers using `harness.OnboardTimerHarness`. + + Excpets a specific trace format and UART log output (as produced by + generate-dfa-benchmark.py). Prunes states from output. (TODO) + """ + + def __init__(self, filenames): + """ + Create a new TimingData object. + + Each filenames element corresponds to a measurement run. + """ + self.filenames = filenames.copy() + # holds the benchmark plan (dfa traces) for each series of benchmark runs. + # Note that a single entry typically has more than one corresponding mimosa/energytrace benchmark files, + # as benchmarks are run repeatedly to distinguish between random and parameter-dependent measurement effects. + self.traces_by_fileno = [] + self.setup_by_fileno = [] + self.preprocessed = False + self.version = 0 + + def _concatenate_analyzed_traces(self): + self.traces = [] + for trace_group in self.traces_by_fileno: + for trace in trace_group: + # TimingHarness logs states, but does not aggregate any data for them at the moment -> throw all states away + transitions = list( + filter(lambda x: x["isa"] == "transition", trace["trace"]) + ) + self.traces.append({"id": trace["id"], "trace": transitions}) + for i, trace in enumerate(self.traces): + trace["orig_id"] = trace["id"] + trace["id"] = i + for log_entry in trace["trace"]: + paramkeys = sorted(log_entry["parameter"].keys()) + if "param" not in log_entry["offline_aggregates"]: + log_entry["offline_aggregates"]["param"] = list() + if "duration" in log_entry["offline_aggregates"]: + for i in range(len(log_entry["offline_aggregates"]["duration"])): + paramvalues = list() + for paramkey in paramkeys: + if type(log_entry["parameter"][paramkey]) is list: + paramvalues.append( + soft_cast_int(log_entry["parameter"][paramkey][i]) + ) + else: + paramvalues.append( + soft_cast_int(log_entry["parameter"][paramkey]) + ) + if arg_support_enabled and "args" in log_entry: + paramvalues.extend(map(soft_cast_int, log_entry["args"])) + log_entry["offline_aggregates"]["param"].append(paramvalues) + + def _preprocess_0(self): + for filename in self.filenames: + with open(filename, "r") as f: + log_data = json.load(f) + self.traces_by_fileno.extend(log_data["traces"]) + self._concatenate_analyzed_traces() + + def get_preprocessed_data(self): + """ + Return a list of DFA traces annotated with timing and parameter data. + + Suitable for the PTAModel constructor. + See PTAModel(...) docstring for format details. + """ + if self.preprocessed: + return self.traces + if self.version == 0: + self._preprocess_0() + self.preprocessed = True + return self.traces + + +def sanity_check_aggregate(aggregate): + for key in aggregate: + if "param" not in aggregate[key]: + raise RuntimeError("aggregate[{}][param] does not exist".format(key)) + if "attributes" not in aggregate[key]: + raise RuntimeError("aggregate[{}][attributes] does not exist".format(key)) + for attribute in aggregate[key]["attributes"]: + if attribute not in aggregate[key]: + raise RuntimeError( + "aggregate[{}][{}] does not exist, even though it is contained in aggregate[{}][attributes]".format( + key, attribute, key + ) + ) + param_len = len(aggregate[key]["param"]) + attr_len = len(aggregate[key][attribute]) + if param_len != attr_len: + raise RuntimeError( + "parameter mismatch: len(aggregate[{}][param]) == {} != len(aggregate[{}][{}]) == {}".format( + key, param_len, key, attribute, attr_len + ) + ) + + +def assert_legacy_compatibility(f1, t1, f2, t2): + expected_param_names = sorted( + t1["expected_trace"][0]["trace"][0]["parameter"].keys() + ) + for run in t2["expected_trace"]: + for state_or_trans in run["trace"]: + actual_param_names = sorted(state_or_trans["parameter"].keys()) + if actual_param_names != expected_param_names: + err = f"parameters in {f1} and {f2} are incompatible: {expected_param_names} ≠ {actual_param_names}" + logger.error(err) + raise ValueError(err) + + +def assert_ptalog_compatibility(f1, pl1, f2, pl2): + param1 = pl1["pta"]["parameters"] + param2 = pl2["pta"]["parameters"] + if param1 != param2: + err = f"parameters in {f1} and {f2} are incompatible: {param1} ≠ {param2}" + logger.error(err) + raise ValueError(err) + + states1 = list(sorted(pl1["pta"]["state"].keys())) + states2 = list(sorted(pl2["pta"]["state"].keys())) + if states1 != states2: + err = f"states in {f1} and {f2} differ: {states1} ≠ {states2}" + logger.warning(err) + + transitions1 = list(sorted(map(lambda t: t["name"], pl1["pta"]["transitions"]))) + transitions2 = list(sorted(map(lambda t: t["name"], pl1["pta"]["transitions"]))) + if transitions1 != transitions2: + err = f"transitions in {f1} and {f2} differ: {transitions1} ≠ {transitions2}" + logger.warning(err) + + +class RawData: + """ + Loader for hardware model traces measured with MIMOSA. + + Expects a specific trace format and UART log output (as produced by the + dfatool benchmark generator). Loads data, prunes bogus measurements, and + provides preprocessed data suitable for PTAModel. Results are cached on the + file system, making subsequent loads near-instant. + """ + + def __init__(self, filenames, with_traces=False, skip_cache=False): + """ + Create a new RawData object. + + Each filename element corresponds to a measurement run. + It must be a tar archive with the following contents: + + Version 0: + + * `setup.json`: measurement setup. Must contain the keys `state_duration` (how long each state is active, in ms), + `mimosa_voltage` (voltage applied to dut, in V), and `mimosa_shunt` (shunt value, in Ohm) + * `src/apps/DriverEval/DriverLog.json`: PTA traces and parameters for this benchmark. + Layout: List of traces, each trace has an 'id' (numeric, starting with 1) and 'trace' (list of states and transitions) element. + Each trace has an even number of elements, starting with the first state (usually `UNINITIALIZED`) and ending with a transition. + Each state/transition must have the members `.parameter` (parameter values, empty string or None if unknown), `.isa` ("state" or "transition") and `.name`. + Each transition must additionally contain `.plan.level` ("user" or "epilogue"). + Example: `[ {"id": 1, "trace": [ {"parameter": {...}, "isa": "state", "name": "UNINITIALIZED"}, ...] }, ... ] + * At least one `*.mim` file. Each file corresponds to a single execution of the entire benchmark (i.e., all runs described in DriverLog.json) and starts with a MIMOSA Autocal calibration sequence. + MIMOSA files are parsed by the `MIMOSA` class. + + Version 1: + + * `ptalog.json`: measurement setup and traces. Contents: + `.opt.sleep`: state duration + `.opt.pta`: PTA + `.opt.traces`: list of sub-benchmark traces (the benchmark may have been split due to code size limitations). Each item is a list of traces as returned by `harness.traces`: + `.opt.traces[]`: List of traces. Each trace has an 'id' (numeric, starting with 1) and 'trace' (list of states and transitions) element. + Each state/transition must have the members '`parameter` (dict with normalized parameter values), `.isa` ("state" or "transition") and `.name` + Each transition must additionally contain `.args` + `.opt.files`: list of coresponding MIMOSA measurements. + `.opt.files[]` = ['abc123.mim', ...] + `.opt.configs`: .... + * MIMOSA log files (`*.mim`) as specified in `.opt.files` + + Version 2: + + * `ptalog.json`: measurement setup and traces. Contents: + `.opt.sleep`: state duration + `.opt.pta`: PTA + `.opt.traces`: list of sub-benchmark traces (the benchmark may have been split due to code size limitations). Each item is a list of traces as returned by `harness.traces`: + `.opt.traces[]`: List of traces. Each trace has an 'id' (numeric, starting with 1) and 'trace' (list of states and transitions) element. + Each state/transition must have the members '`parameter` (dict with normalized parameter values), `.isa` ("state" or "transition") and `.name` + Each transition must additionally contain `.args` and `.duration` + * `.duration`: list of durations, one per repetition + `.opt.files`: list of coresponding EnergyTrace measurements. + `.opt.files[]` = ['abc123.etlog', ...] + `.opt.configs`: .... + * EnergyTrace log files (`*.etlog`) as specified in `.opt.files` + + If a cached result for a file is available, it is loaded and the file + is not preprocessed, unless `with_traces` is set. + + tbd + """ + self.with_traces = with_traces + self.input_filenames = filenames.copy() + self.filenames = list() + self.traces_by_fileno = list() + self.setup_by_fileno = list() + self.version = 0 + self.preprocessed = False + self._parameter_names = None + self.ignore_clipping = False + self.pta = None + self.ptalog = None + + with tarfile.open(filenames[0]) as tf: + for member in tf.getmembers(): + if member.name == "ptalog.json" and self.version == 0: + self.version = 1 + # might also be version 2 + # depends on whether *.etlog exists or not + elif ".etlog" in member.name: + self.version = 2 + break + if self.version >= 1: + self.ptalog = json.load(tf.extractfile(tf.getmember("ptalog.json"))) + self.pta = self.ptalog["pta"] + + if self.ptalog and len(filenames) > 1: + for filename in filenames[1:]: + with tarfile.open(filename) as tf: + new_ptalog = json.load(tf.extractfile(tf.getmember("ptalog.json"))) + assert_ptalog_compatibility( + filenames[0], self.ptalog, filename, new_ptalog + ) + self.ptalog["files"].extend(new_ptalog["files"]) + + self.set_cache_file() + if not with_traces and not skip_cache: + self.load_cache() + + def set_cache_file(self): + cache_key = hashlib.sha256("!".join(self.input_filenames).encode()).hexdigest() + self.cache_dir = os.path.dirname(self.input_filenames[0]) + "/cache" + self.cache_file = "{}/{}.json".format(self.cache_dir, cache_key) + + def load_cache(self): + if os.path.exists(self.cache_file): + with open(self.cache_file, "r") as f: + try: + cache_data = json.load(f) + self.filenames = cache_data["filenames"] + self.traces = cache_data["traces"] + self.preprocessing_stats = cache_data["preprocessing_stats"] + if "pta" in cache_data: + self.pta = cache_data["pta"] + if "ptalog" in cache_data: + self.ptalog = cache_data["ptalog"] + self.setup_by_fileno = cache_data["setup_by_fileno"] + self.preprocessed = True + except json.decoder.JSONDecodeError as e: + logger.info(f"Skipping cache entry {self.cache_file}: {e}") + + def save_cache(self): + if self.with_traces: + return + try: + os.mkdir(self.cache_dir) + except FileExistsError: + pass + with open(self.cache_file, "w") as f: + cache_data = { + "filenames": self.filenames, + "traces": self.traces, + "preprocessing_stats": self.preprocessing_stats, + "pta": self.pta, + "ptalog": self.ptalog, + "setup_by_fileno": self.setup_by_fileno, + } + json.dump(cache_data, f) + + def to_dref(self) -> dict: + return { + "raw measurements/valid": self.preprocessing_stats["num_valid"], + "raw measurements/total": self.preprocessing_stats["num_runs"], + "static state duration/mean": ( + np.mean(list(map(lambda x: x["state_duration"], self.setup_by_fileno))), + r"\milli\second", + ), + } + + def _concatenate_traces(self, list_of_traces): + """ + Concatenate `list_of_traces` (list of lists) into a single trace while adjusting trace IDs. + + :param list_of_traces: List of list of traces. + :returns: List of traces with ['id'] in ascending order and ['orig_id'] as previous ['id'] + """ + + trace_output = list() + for trace in list_of_traces: + trace_output.extend(trace.copy()) + for i, trace in enumerate(trace_output): + trace["orig_id"] = trace["id"] + trace["id"] = i + return trace_output + + def get_preprocessed_data(self): + """ + Return a list of DFA traces annotated with energy, timing, and parameter data. + The list is cached on disk, unless the constructor was called with `with_traces` set. + + Each DFA trace contains the following elements: + * `id`: Numeric ID, starting with 1 + * `total_energy`: Total amount of energy (as measured by MIMOSA) in the entire trace + * `orig_id`: Original trace ID. May differ when concatenating multiple (different) benchmarks into one analysis, i.e., when calling RawData() with more than one file argument. + * `trace`: List of the individual states and transitions in this trace. Always contains an even number of elements, staring with the first state (typically "UNINITIALIZED") and ending with a transition. + + Each trace element (that is, an entry of the `trace` list mentioned above) contains the following elements: + * `isa`: "state" or "transition" + * `name`: name + * `offline`: List of offline measumerents for this state/transition. Each entry contains a result for this state/transition during one benchmark execution. + Entry contents: + - `clip_rate`: rate of clipped energy measurements, 0 .. 1 + - `raw_mean`: mean raw MIMOSA value + - `raw_std`: standard deviation of raw MIMOSA value + - `uW_mean`: mean power draw, uW + - `uw_std`: standard deviation of power draw, uW + - `us`: state/transition duration, us + - `uW_mean_delta_prev`: (only for transitions) difference between uW_mean of this transition and uW_mean of previous state + - `uW_mean_elta_next`: (only for transitions) difference between uW_mean of this transition and uW_mean of next state + - `timeout`: (only for transitions) duration of previous state, us + * `offline_aggregates`: Aggregate of `offline` entries. dict of lists, each list entry has the same length + - `duration`: state/transition durations ("us"), us + - `energy`: state/transition energy ("us * uW_mean"), us + - `power`: mean power draw ("uW_mean"), uW + - `power_std`: standard deviations of power draw ("uW_std"), uW^2 + - `paramkeys`: List of lists, each sub-list contains the parameter names corresponding to the `param` entries + - `param`: List of lists, each sub-list contains the parameter values for this measurement. Typically, all sub-lists are the same. + - `rel_energy_prev`: (only for transitions) transition energy relative to previous state mean power, pJ + - `rel_energy_next`: (only for transitions) transition energy relative to next state mean power, pJ + - `rel_power_prev`: (only for transitions) powerrelative to previous state mean power, µW + - `rel_power_next`: (only for transitions) power relative to next state mean power, µW + - `timeout`: (only for transitions) duration of previous state, us + * `offline_attributes`: List containing the keys of `offline_aggregates` which are meant to be part of the model. + This list ultimately decides which hardware/software attributes the model describes. + If isa == state, it contains power, duration, energy + If isa == transition, it contains power, rel_power_prev, rel_power_next, duration, timeout + * `online`: List of online estimations for this state/transition. Each entry contains a result for this state/transition during one benchmark execution. + Entry contents for isa == state: + - `time`: state/transition + Entry contents for isa == transition: + - `timeout`: Duration of previous state, measured using on-board timers + * `parameter`: dictionary describing parameter values for this state/transition. Parameter values refer to the begin of the state/transition and do not account for changes made by the transition. + * `plan`: Dictionary describing expected behaviour according to schedule / offline model. + Contents for isa == state: `energy`, `power`, `time` + Contents for isa == transition: `energy`, `timeout`, `level`. + If level is "user", the transition is part of the regular driver API. If level is "epilogue", it is an interrupt service routine and not called explicitly. + Each transition also contains: + * `args`: List of arguments the corresponding function call was called with. args entries are strings which are not necessarily numeric + * `code`: List of function name (first entry) and arguments (remaining entries) of the corresponding function call + """ + if self.preprocessed: + return self.traces + if self.version <= 2: + self._preprocess_012(self.version) + else: + raise ValueError(f"Unsupported raw data version: {self.version}") + self.preprocessed = True + self.save_cache() + return self.traces + + def _preprocess_012(self, version): + """Load raw MIMOSA data and turn it into measurements which are ready to be analyzed.""" + offline_data = [] + for i, filename in enumerate(self.input_filenames): + + if version == 0: + + self.filenames = self.input_filenames + with tarfile.open(filename) as tf: + self.setup_by_fileno.append(json.load(tf.extractfile("setup.json"))) + traces = json.load( + tf.extractfile("src/apps/DriverEval/DriverLog.json") + ) + self.traces_by_fileno.append(traces) + for member in tf.getmembers(): + _, extension = os.path.splitext(member.name) + if extension == ".mim": + offline_data.append( + { + "content": tf.extractfile(member).read(), + # only for validation + "expected_trace": traces, + "fileno": i, + # For debug output and warnings + "info": member, + # Strip the last state (it is not part of the scheduled measurement) + "pop": -1, + "repeat_id": 0, # needed to add runtime "return_value.apply_from" parameters to offline_aggregates. Irrelevant in v0. + "setup": self.setup_by_fileno[i], + "with_traces": self.with_traces, + } + ) + + elif version == 1: + + with tarfile.open(filename) as tf: + ptalog = json.load(tf.extractfile(tf.getmember("ptalog.json"))) + + # Benchmark code may be too large to be executed in a single + # run, so benchmarks (a benchmark is basically a list of DFA runs) + # may be split up. To accomodate this, ptalog['traces'] is + # a list of lists: ptalog['traces'][0] corresponds to the + # first benchmark part, ptalog['traces'][1] to the + # second, and so on. ptalog['traces'][0][0] is the first + # trace (a sequence of states and transitions) in the + # first benchmark part, ptalog['traces'][0][1] the second, etc. + # + # As traces are typically repeated to minimize the effect + # of random noise, observations for each benchmark part + # are also lists. In this case, this applies in two + # cases: traces[i][j]['parameter'][some_param] is either + # a value (if the parameter is controlld by software) + # or a list (if the parameter is known a posteriori, e.g. + # "how many retransmissions did this packet take?"). + # + # The second case is the MIMOSA energy measurements, which + # are listed in ptalog['files']. ptalog['files'][0] + # contains a list of files for the first benchmark part, + # ptalog['files'][0][0] is its first iteration/repetition, + # ptalog['files'][0][1] the second, etc. + + for j, traces in enumerate(ptalog["traces"]): + self.filenames.append("{}#{}".format(filename, j)) + self.traces_by_fileno.append(traces) + self.setup_by_fileno.append( + { + "mimosa_voltage": ptalog["configs"][j]["voltage"], + "mimosa_shunt": ptalog["configs"][j]["shunt"], + "state_duration": ptalog["opt"]["sleep"], + } + ) + for repeat_id, mim_file in enumerate(ptalog["files"][j]): + # MIMOSA benchmarks always use a single .mim file per benchmark run. + # However, depending on the dfatool version used to run the + # benchmark, ptalog["files"][j] is either "foo.mim" (before Oct 2020) + # or ["foo.mim"] (from Oct 2020 onwards). + if type(mim_file) is list: + mim_file = mim_file[0] + member = tf.getmember(mim_file) + offline_data.append( + { + "content": tf.extractfile(member).read(), + # only for validation + "expected_trace": traces, + "fileno": len(self.traces_by_fileno) - 1, + # For debug output and warnings + "info": member, + # The first online measurement is the UNINITIALIZED state. In v1, + # it is not part of the expected PTA trace -> remove it. + "pop": 0, + "setup": self.setup_by_fileno[-1], + "repeat_id": repeat_id, # needed to add runtime "return_value.apply_from" parameters to offline_aggregates. + "with_traces": self.with_traces, + } + ) + + elif version == 2: + + with tarfile.open(filename) as tf: + ptalog = json.load(tf.extractfile(tf.getmember("ptalog.json"))) + if "sync" in ptalog["opt"]["energytrace"]: + sync_mode = ptalog["opt"]["energytrace"]["sync"] + else: + sync_mode = "bar" + + # Benchmark code may be too large to be executed in a single + # run, so benchmarks (a benchmark is basically a list of DFA runs) + # may be split up. To accomodate this, ptalog['traces'] is + # a list of lists: ptalog['traces'][0] corresponds to the + # first benchmark part, ptalog['traces'][1] to the + # second, and so on. ptalog['traces'][0][0] is the first + # trace (a sequence of states and transitions) in the + # first benchmark part, ptalog['traces'][0][1] the second, etc. + # + # As traces are typically repeated to minimize the effect + # of random noise, observations for each benchmark part + # are also lists. In this case, this applies in two + # cases: traces[i][j]['parameter'][some_param] is either + # a value (if the parameter is controlld by software) + # or a list (if the parameter is known a posteriori, e.g. + # "how many retransmissions did this packet take?"). + # + # The second case is the MIMOSA energy measurements, which + # are listed in ptalog['files']. ptalog['files'][0] + # contains a list of files for the first benchmark part, + # ptalog['files'][0][0] is its first iteration/repetition, + # ptalog['files'][0][1] the second, etc. + + # generate-dfa-benchmark uses TimingHarness to obtain timing data. + # Data is placed in 'offline_aggregates', which is also + # where we are going to store power/energy data. + # In case of invalid measurements, this can lead to a + # mismatch between duration and power/energy data, e.g. + # where duration = [A, B, C], power = [a, b], B belonging + # to an invalid measurement and thus power[b] corresponding + # to duration[C]. At the moment, this is harmless, but in the + # future it might not be. + if "offline_aggregates" in ptalog["traces"][0][0]["trace"][0]: + for trace_group in ptalog["traces"]: + for trace in trace_group: + for state_or_transition in trace["trace"]: + offline_aggregates = state_or_transition.pop( + "offline_aggregates", None + ) + if offline_aggregates: + state_or_transition[ + "online_aggregates" + ] = offline_aggregates + + for j, traces in enumerate(ptalog["traces"]): + self.filenames.append("{}#{}".format(filename, j)) + self.traces_by_fileno.append(traces) + self.setup_by_fileno.append( + { + "voltage": ptalog["configs"][j]["voltage"], + "state_duration": ptalog["opt"]["sleep"], + } + ) + for repeat_id, etlog_files in enumerate(ptalog["files"][j]): + # legacy measurements supported only one file per run + if type(etlog_files) is not list: + etlog_files = [etlog_files] + members = list(map(tf.getmember, etlog_files)) + offline_data.append( + { + "content": list( + map(lambda f: tf.extractfile(f).read(), members) + ), + # used to determine EnergyTrace class for analysis + "sync_mode": sync_mode, + "fileno": len(self.traces_by_fileno) - 1, + # For debug output and warnings + "info": members[0], + "setup": self.setup_by_fileno[-1], + # needed to add runtime "return_value.apply_from" parameters to offline_aggregates, also for EnergyTraceWithBarcode + "repeat_id": repeat_id, + # only for validation + "expected_trace": traces, + "with_traces": self.with_traces, + # only for EnergyTraceWithBarcode + "transition_names": list( + map( + lambda x: x["name"], + ptalog["pta"]["transitions"], + ) + ), + } + ) + # TODO remove 'offline_aggregates' from pre-parse data and place + # it under 'online_aggregates' or similar instead. This way, if + # a .etlog file fails to parse, its corresponding duration data + # will not linger in 'offline_aggregates' and confuse the hell + # out of other code paths + + if self.version == 0 and len(self.input_filenames) > 1: + for entry in offline_data: + assert_legacy_compatibility( + self.input_filenames[0], + offline_data[0], + self.input_filenames[entry["fileno"]], + entry, + ) + + with Pool() as pool: + if self.version <= 1: + measurements = pool.map(_preprocess_mimosa, offline_data) + elif self.version == 2: + measurements = pool.map(_preprocess_etlog, offline_data) + + num_valid = 0 + for measurement in measurements: + + if "energy_trace" not in measurement: + logger.warning( + "Skipping {ar:s}/{m:s}: {e:s}".format( + ar=self.filenames[measurement["fileno"]], + m=measurement["info"].name, + e="; ".join(measurement["errors"]), + ) + ) + continue + + if version == 0 or version == 1: + if measurement["valid"]: + MIMOSA.add_offline_aggregates( + self.traces_by_fileno[measurement["fileno"]], + measurement["energy_trace"], + measurement["repeat_id"], + ) + num_valid += 1 + else: + logger.warning( + "Skipping {ar:s}/{m:s}: {e:s}".format( + ar=self.filenames[measurement["fileno"]], + m=measurement["info"].name, + e="; ".join(measurement["errors"]), + ) + ) + elif version == 2: + if measurement["valid"]: + try: + EnergyTrace.add_offline_aggregates( + self.traces_by_fileno[measurement["fileno"]], + measurement["energy_trace"], + measurement["repeat_id"], + ) + num_valid += 1 + except Exception as e: + logger.warning( + f"Skipping #{measurement['fileno']} {measurement['info']}:\n{e}" + ) + else: + logger.warning( + "Skipping {ar:s}/{m:s}: {e:s}".format( + ar=self.filenames[measurement["fileno"]], + m=measurement["info"].name, + e="; ".join(measurement["errors"]), + ) + ) + logger.info( + "{num_valid:d}/{num_total:d} measurements are valid".format( + num_valid=num_valid, num_total=len(measurements) + ) + ) + if version == 0: + self.traces = self._concatenate_traces(self.traces_by_fileno) + elif version == 1: + self.traces = self._concatenate_traces(self.traces_by_fileno) + elif version == 2: + self.traces = self._concatenate_traces(self.traces_by_fileno) + self.preprocessing_stats = { + "num_runs": len(measurements), + "num_valid": num_valid, + } + + +def _add_trace_data_to_aggregate(aggregate, key, element): + # Only cares about element['isa'], element['offline_aggregates'], and + # element['plan']['level'] + if key not in aggregate: + aggregate[key] = {"isa": element["isa"]} + for datakey in element["offline_aggregates"].keys(): + aggregate[key][datakey] = [] + if element["isa"] == "state": + aggregate[key]["attributes"] = ["power"] + else: + # TODO do not hardcode values + aggregate[key]["attributes"] = [ + "duration", + "power", + "rel_power_prev", + "rel_power_next", + "energy", + "rel_energy_prev", + "rel_energy_next", + ] + if "plan" in element and element["plan"]["level"] == "epilogue": + aggregate[key]["attributes"].insert(0, "timeout") + attributes = aggregate[key]["attributes"].copy() + for attribute in attributes: + if attribute not in element["offline_aggregates"]: + aggregate[key]["attributes"].remove(attribute) + if "offline_support" in element: + aggregate[key]["supports"] = element["offline_support"] + else: + aggregate[key]["supports"] = list() + for datakey, dataval in element["offline_aggregates"].items(): + aggregate[key][datakey].extend(dataval) + + +def pta_trace_to_aggregate(traces, ignore_trace_indexes=[]): + """ + Convert preprocessed DFA traces from peripherals/drivers to by_name aggregate for PTAModel. + + arguments: + traces -- [ ... Liste von einzelnen Läufen (d.h. eine Zustands- und Transitionsfolge UNINITIALIZED -> foo -> FOO -> bar -> BAR -> ...) + Jeder Lauf: + - id: int Nummer des Laufs, beginnend bei 1 + - trace: [ ... Liste von Zuständen und Transitionen + Jeweils: + - name: str Name + - isa: str state // transition + - parameter: { ... globaler Parameter: aktueller wert. null falls noch nicht eingestellt } + - args: [ Funktionsargumente, falls isa == 'transition' ] + - offline_aggregates: + - power: [float(uW)] Mittlere Leistung während Zustand/Transitions + - power_std: [float(uW^2)] Standardabweichung der Leistung + - duration: [int(us)] Dauer + - energy: [float(pJ)] Energieaufnahme des Zustands / der Transition + - clip_rate: [float(0..1)] Clipping + - paramkeys: [[str]] Name der berücksichtigten Parameter + - param: [int // str] Parameterwerte. Quasi-Duplikat von 'parameter' oben + Falls isa == 'transition': + - timeout: [int(us)] Dauer des vorherigen Zustands + - rel_energy_prev: [int(pJ)] + - rel_energy_next: [int(pJ)] + - rel_power_prev: [int(µW)] + - rel_power_next: [int(µW)] + ] + ] + ignore_trace_indexes -- list of trace indexes. The corresponding taces will be ignored. + + returns a tuple of three elements: + by_name -- measurements aggregated by state/transition name, annotated with parameter values + parameter_names -- list of parameter names + arg_count -- dict mapping transition names to the number of arguments of their corresponding driver function + + by_name layout: + Dictionary with one key per state/transition ('send', 'TX', ...). + Each element is in turn a dict with the following elements: + - isa: 'state' or 'transition' + - power: list of mean power measurements in µW + - duration: list of durations in µs + - power_std: list of stddev of power per state/transition + - energy: consumed energy (power*duration) in pJ + - paramkeys: list of parameter names in each measurement (-> list of lists) + - param: list of parameter values in each measurement (-> list of lists) + - attributes: list of keys that should be analyzed, + e.g. ['power', 'duration'] + additionally, only if isa == 'transition': + - timeout: list of duration of previous state in µs + - rel_energy_prev: transition energy relative to previous state mean power in pJ + - rel_energy_next: transition energy relative to next state mean power in pJ + """ + arg_count = dict() + by_name = dict() + parameter_names = sorted(traces[0]["trace"][0]["parameter"].keys()) + for run in traces: + if run["id"] not in ignore_trace_indexes: + for elem in run["trace"]: + if ( + elem["isa"] == "transition" + and not elem["name"] in arg_count + and "args" in elem + ): + arg_count[elem["name"]] = len(elem["args"]) + if elem["name"] != "UNINITIALIZED": + _add_trace_data_to_aggregate(by_name, elem["name"], elem) + for elem in by_name.values(): + for key in elem["attributes"]: + elem[key] = np.array(elem[key]) + return by_name, parameter_names, arg_count diff --git a/lib/loader/energytrace.py b/lib/loader/energytrace.py new file mode 100644 index 0000000..5b0d42c --- /dev/null +++ b/lib/loader/energytrace.py @@ -0,0 +1,812 @@ +#!/usr/bin/env python3 + +import json +import logging +import numpy as np +import os +import re + +from dfatool.utils import NpEncoder, soft_cast_int + +logger = logging.getLogger(__name__) + +try: + from .pubcode import Code128 + import zbar + + zbar_available = True +except ImportError: + zbar_available = False + +arg_support_enabled = True + + +def _load_energytrace(data_string): + """ + Load log data (raw energytrace .txt file, one line per event). + + :param log_data: raw energytrace log file in 4-column .txt format + """ + + lines = data_string.decode("ascii").split("\n") + data_count = sum(map(lambda x: len(x) > 0 and x[0] != "#", lines)) + data_lines = filter(lambda x: len(x) > 0 and x[0] != "#", lines) + + data = np.empty((data_count, 4)) + hardware_states = [None for i in range(data_count)] + + for i, line in enumerate(data_lines): + fields = line.split(" ") + if len(fields) == 4: + timestamp, current, voltage, total_energy = map(int, fields) + elif len(fields) == 5: + hardware_states[i] = fields[0] + timestamp, current, voltage, total_energy = map(int, fields[1:]) + else: + raise RuntimeError('cannot parse line "{}"'.format(line)) + data[i] = [timestamp, current, voltage, total_energy] + + interval_start_timestamp = data[1:, 0] * 1e-6 + interval_duration = (data[1:, 0] - data[:-1, 0]) * 1e-6 + interval_power = (data[1:, 3] - data[:-1, 3]) / (data[1:, 0] - data[:-1, 0]) * 1e-3 + + m_duration_us = data[-1, 0] - data[0, 0] + + sample_rate = data_count / (m_duration_us * 1e-6) + + hardware_state_changes = list() + if hardware_states[0]: + prev_state = hardware_states[0] + # timestamps start at data[1], so hardware state change indexes must start at 1, too + for i, state in enumerate(hardware_states[1:]): + if ( + state != prev_state + and state != "0000000000000000" + and prev_state != "0000000000000000" + ): + hardware_state_changes.append(i) + if state != "0000000000000000": + prev_state = state + + logger.debug( + "got {} samples with {} seconds of log data ({} Hz)".format( + data_count, m_duration_us * 1e-6, sample_rate + ) + ) + + return ( + interval_start_timestamp, + interval_duration, + interval_power, + sample_rate, + hardware_state_changes, + ) + + +class EnergyTrace: + @staticmethod + def add_offline_aggregates(online_traces, offline_trace, repeat_id): + # Edits online_traces[*]['trace'][*]['offline'] + # and online_traces[*]['trace'][*]['offline_aggregates'] in place + # (appends data from offline_trace) + online_datapoints = [] + for run_idx, run in enumerate(online_traces): + for trace_part_idx in range(len(run["trace"])): + online_datapoints.append((run_idx, trace_part_idx)) + for offline_idx, (online_run_idx, online_trace_part_idx) in enumerate( + online_datapoints + ): + try: + offline_trace_part = offline_trace[offline_idx] + except IndexError: + logger.error(f" offline energy_trace data is shorter than online data") + logger.error(f" len(online_datapoints) == {len(online_datapoints)}") + logger.error(f" len(energy_trace) == {len(offline_trace)}") + raise + online_trace_part = online_traces[online_run_idx]["trace"][ + online_trace_part_idx + ] + + if "offline" not in online_trace_part: + online_trace_part["offline"] = [offline_trace_part] + else: + online_trace_part["offline"].append(offline_trace_part) + + paramkeys = sorted(online_trace_part["parameter"].keys()) + + paramvalues = list() + + for paramkey in paramkeys: + if type(online_trace_part["parameter"][paramkey]) is list: + paramvalues.append( + soft_cast_int( + online_trace_part["parameter"][paramkey][repeat_id] + ) + ) + else: + paramvalues.append( + soft_cast_int(online_trace_part["parameter"][paramkey]) + ) + + # NB: Unscheduled transitions do not have an 'args' field set. + # However, they should only be caused by interrupts, and + # interrupts don't have args anyways. + if arg_support_enabled and "args" in online_trace_part: + paramvalues.extend(map(soft_cast_int, online_trace_part["args"])) + + if "offline_aggregates" not in online_trace_part: + online_trace_part["offline_aggregates"] = { + "offline_attributes": ["power", "duration", "energy"], + "duration": list(), + "power": list(), + "power_std": list(), + "energy": list(), + "paramkeys": list(), + "param": list(), + } + if "plot" in offline_trace_part: + online_trace_part["offline_support"] = [ + "power_traces", + "timestamps", + ] + online_trace_part["offline_aggregates"]["power_traces"] = list() + online_trace_part["offline_aggregates"]["timestamps"] = list() + if online_trace_part["isa"] == "transition": + online_trace_part["offline_aggregates"][ + "offline_attributes" + ].extend(["rel_power_prev", "rel_power_next"]) + online_trace_part["offline_aggregates"]["rel_energy_prev"] = list() + online_trace_part["offline_aggregates"]["rel_energy_next"] = list() + online_trace_part["offline_aggregates"]["rel_power_prev"] = list() + online_trace_part["offline_aggregates"]["rel_power_next"] = list() + + offline_aggregates = online_trace_part["offline_aggregates"] + + # if online_trace_part['isa'] == 'transitions': + # online_trace_part['offline_attributes'].extend(['rel_energy_prev', 'rel_energy_next']) + # offline_aggregates['rel_energy_prev'] = list() + # offline_aggregates['rel_energy_next'] = list() + + offline_aggregates["duration"].append(offline_trace_part["s"] * 1e6) + offline_aggregates["power"].append(offline_trace_part["W_mean"] * 1e6) + offline_aggregates["power_std"].append(offline_trace_part["W_std"] * 1e6) + offline_aggregates["energy"].append( + offline_trace_part["W_mean"] * offline_trace_part["s"] * 1e12 + ) + offline_aggregates["paramkeys"].append(paramkeys) + offline_aggregates["param"].append(paramvalues) + + if "plot" in offline_trace_part: + offline_aggregates["power_traces"].append(offline_trace_part["plot"][1]) + offline_aggregates["timestamps"].append(offline_trace_part["plot"][0]) + + if online_trace_part["isa"] == "transition": + offline_aggregates["rel_energy_prev"].append( + offline_trace_part["W_mean_delta_prev"] + * offline_trace_part["s"] + * 1e12 + ) + offline_aggregates["rel_energy_next"].append( + offline_trace_part["W_mean_delta_next"] + * offline_trace_part["s"] + * 1e12 + ) + offline_aggregates["rel_power_prev"].append( + offline_trace_part["W_mean_delta_prev"] * 1e6 + ) + offline_aggregates["rel_power_next"].append( + offline_trace_part["W_mean_delta_next"] * 1e6 + ) + + +class EnergyTraceWithBarcode: + """ + EnergyTrace log loader for DFA traces. + + Expects an EnergyTrace log file generated via msp430-etv / energytrace-util + and a dfatool-generated benchmark. An EnergyTrace log consits of a series + of measurements. Each measurement has a timestamp, mean current, voltage, + and cumulative energy since start of measurement. Each transition is + preceded by a Code128 barcode embedded into the energy consumption by + toggling a LED. + + Note that the baseline power draw of board and peripherals is not subtracted + at the moment. + """ + + def __init__( + self, + voltage: float, + state_duration: int, + transition_names: list, + with_traces=False, + ): + """ + Create a new EnergyTraceWithBarcode object. + + :param voltage: supply voltage [V], usually 3.3 V + :param state_duration: state duration [ms] + :param transition_names: list of transition names in PTA transition order. + Needed to map barcode synchronization numbers to transitions. + """ + self.voltage = voltage + self.state_duration = state_duration * 1e-3 + self.transition_names = transition_names + self.with_traces = with_traces + self.errors = list() + + # TODO auto-detect + self.led_power = 10e-3 + + # multipass/include/object/ptalog.h#startTransition + self.module_duration = 5e-3 + + # multipass/include/object/ptalog.h#startTransition + self.quiet_zone_duration = 60e-3 + + # TODO auto-detect? + # Note that we consider barcode duration after start, so only the + # quiet zone -after- the code is relevant + self.min_barcode_duration = 57 * self.module_duration + self.quiet_zone_duration + self.max_barcode_duration = 68 * self.module_duration + self.quiet_zone_duration + + def load_data(self, log_data): + """ + Load log data (raw energytrace .txt file, one line per event). + + :param log_data: raw energytrace log file in 4-column .txt format + """ + + if not zbar_available: + logger.error("zbar module is not available") + self.errors.append( + 'zbar module is not available. Try "apt install python3-zbar"' + ) + self.interval_power = None + return list() + + ( + self.interval_start_timestamp, + self.interval_duration, + self.interval_power, + self.sample_rate, + self.hw_statechange_indexes, + ) = _load_energytrace(log_data[0]) + + def ts_to_index(self, timestamp): + """ + Convert timestamp in seconds to interval_start_timestamp / interval_duration / interval_power index. + + Returns the index of the interval which timestamp is part of. + """ + return self._ts_to_index(timestamp, 0, len(self.interval_start_timestamp)) + + def _ts_to_index(self, timestamp, left_index, right_index): + if left_index == right_index: + return left_index + if left_index + 1 == right_index: + return left_index + + mid_index = left_index + (right_index - left_index) // 2 + + # I'm feeling lucky + if ( + timestamp > self.interval_start_timestamp[mid_index] + and timestamp + <= self.interval_start_timestamp[mid_index] + + self.interval_duration[mid_index] + ): + return mid_index + + if timestamp <= self.interval_start_timestamp[mid_index]: + return self._ts_to_index(timestamp, left_index, mid_index) + + return self._ts_to_index(timestamp, mid_index, right_index) + + def analyze_states(self, traces, offline_index: int): + """ + Split log data into states and transitions and return duration, energy, and mean power for each element. + + :param traces: expected traces, needed to synchronize with the measurement. + traces is a list of runs, traces[*]['trace'] is a single run + (i.e. a list of states and transitions, starting with a transition + and ending with a state). + :param offline_index: This function uses traces[*]['trace'][*]['online_aggregates']['duration'][offline_index] to find sync codes + + :param charges: raw charges (each element describes the charge in pJ transferred during 10 µs) + :param trigidx: "charges" indexes corresponding to a trigger edge, see `trigger_edges` + :param ua_func: charge(pJ) -> current(µA) function as returned by `calibration_function` + + :returns: maybe returns list of states and transitions, both starting andending with a state. + Each element is a dict containing: + * `isa`: 'state' or 'transition' + * `clip_rate`: range(0..1) Anteil an Clipping im Energieverbrauch + * `raw_mean`: Mittelwert der Rohwerte + * `raw_std`: Standardabweichung der Rohwerte + * `uW_mean`: Mittelwert der (kalibrierten) Leistungsaufnahme + * `uW_std`: Standardabweichung der (kalibrierten) Leistungsaufnahme + * `us`: Dauer + if isa == 'transition, it also contains: + * `timeout`: Dauer des vorherigen Zustands + * `uW_mean_delta_prev`: Differenz zwischen uW_mean und uW_mean des vorherigen Zustands + * `uW_mean_delta_next`: Differenz zwischen uW_mean und uW_mean des Folgezustands + """ + + energy_trace = list() + first_sync = self.find_first_sync() + + if first_sync is None: + logger.error("did not find initial synchronization pulse") + return energy_trace + + expected_transitions = list() + for trace_number, trace in enumerate(traces): + for state_or_transition_number, state_or_transition in enumerate( + trace["trace"] + ): + if state_or_transition["isa"] == "transition": + try: + expected_transitions.append( + ( + state_or_transition["name"], + state_or_transition["online_aggregates"]["duration"][ + offline_index + ] + * 1e-6, + ) + ) + except IndexError: + self.errors.append( + 'Entry #{} ("{}") in trace #{} has no duration entry for offline_index/repeat_id {}'.format( + state_or_transition_number, + state_or_transition["name"], + trace_number, + offline_index, + ) + ) + return energy_trace + + next_barcode = first_sync + + for name, duration in expected_transitions: + bc, start, stop, end = self.find_barcode(next_barcode) + if bc is None: + logger.error('did not find transition "{}"'.format(name)) + break + next_barcode = end + self.state_duration + duration + logger.debug( + '{} barcode "{}" area: {:0.2f} .. {:0.2f} / {:0.2f} seconds'.format( + offline_index, bc, start, stop, end + ) + ) + if bc != name: + logger.error('mismatch: expected "{}", got "{}"'.format(name, bc)) + logger.debug( + "{} estimated transition area: {:0.3f} .. {:0.3f} seconds".format( + offline_index, end, end + duration + ) + ) + + transition_start_index = self.ts_to_index(end) + transition_done_index = self.ts_to_index(end + duration) + 1 + state_start_index = transition_done_index + state_done_index = ( + self.ts_to_index(end + duration + self.state_duration) + 1 + ) + + logger.debug( + "{} estimated transitionindex: {:0.3f} .. {:0.3f} seconds".format( + offline_index, + transition_start_index / self.sample_rate, + transition_done_index / self.sample_rate, + ) + ) + + transition_power_W = self.interval_power[ + transition_start_index:transition_done_index + ] + + transition = { + "isa": "transition", + "W_mean": np.mean(transition_power_W), + "W_std": np.std(transition_power_W), + "s": duration, + "s_coarse": self.interval_start_timestamp[transition_done_index] + - self.interval_start_timestamp[transition_start_index], + } + + if self.with_traces: + timestamps = ( + self.interval_start_timestamp[ + transition_start_index:transition_done_index + ] + - self.interval_start_timestamp[transition_start_index] + ) + transition["plot"] = (timestamps, transition_power_W) + + energy_trace.append(transition) + + if len(energy_trace) > 1: + energy_trace[-1]["W_mean_delta_prev"] = ( + energy_trace[-1]["W_mean"] - energy_trace[-2]["W_mean"] + ) + else: + # TODO this really isn't nice, as W_mean_delta_prev of other setup + # transitions is probably different. The best solution might be + # ignoring the first transition when handling delta_prev values + energy_trace[-1]["W_mean_delta_prev"] = energy_trace[-1]["W_mean"] + + state_power_W = self.interval_power[state_start_index:state_done_index] + state = { + "isa": "state", + "W_mean": np.mean(state_power_W), + "W_std": np.std(state_power_W), + "s": self.state_duration, + "s_coarse": self.interval_start_timestamp[state_done_index] + - self.interval_start_timestamp[state_start_index], + } + + if self.with_traces: + timestamps = ( + self.interval_start_timestamp[state_start_index:state_done_index] + - self.interval_start_timestamp[state_start_index] + ) + state["plot"] = (timestamps, state_power_W) + + energy_trace.append(state) + + energy_trace[-2]["W_mean_delta_next"] = ( + energy_trace[-2]["W_mean"] - energy_trace[-1]["W_mean"] + ) + + expected_transition_count = len(expected_transitions) + recovered_transition_ount = len(energy_trace) // 2 + + if expected_transition_count != recovered_transition_ount: + self.errors.append( + "Expected {:d} transitions, got {:d}".format( + expected_transition_count, recovered_transition_ount + ) + ) + + return energy_trace + + def find_first_sync(self): + # zbar unavailable + if self.interval_power is None: + return None + # LED Power is approx. self.led_power W, use self.led_power/2 W above surrounding median as threshold + sync_threshold_power = ( + np.median(self.interval_power[: int(3 * self.sample_rate)]) + + self.led_power / 3 + ) + for i, ts in enumerate(self.interval_start_timestamp): + if ts > 2 and self.interval_power[i] > sync_threshold_power: + return self.interval_start_timestamp[i - 300] + return None + + def find_barcode(self, start_ts): + """ + Return absolute position and content of the next barcode following `start_ts`. + + :param interval_ts: list of start timestamps (one per measurement interval) [s] + :param interval_power: mean power per measurement interval [W] + :param start_ts: timestamp at which to start looking for a barcode [s] + """ + + for i, ts in enumerate(self.interval_start_timestamp): + if ts >= start_ts: + start_position = i + break + + # Lookaround: 100 ms in both directions + lookaround = int(0.1 * self.sample_rate) + + # LED Power is approx. self.led_power W, use self.led_power/2 W above surrounding median as threshold + sync_threshold_power = ( + np.median( + self.interval_power[ + start_position - lookaround : start_position + lookaround + ] + ) + + self.led_power / 3 + ) + + logger.debug( + "looking for barcode starting at {:0.2f} s, threshold is {:0.1f} mW".format( + start_ts, sync_threshold_power * 1e3 + ) + ) + + sync_area_start = None + sync_start_ts = None + sync_area_end = None + sync_end_ts = None + for i, ts in enumerate(self.interval_start_timestamp): + if ( + sync_area_start is None + and ts >= start_ts + and self.interval_power[i] > sync_threshold_power + ): + sync_area_start = i - 300 + sync_start_ts = ts + if ( + sync_area_start is not None + and sync_area_end is None + and ts > sync_start_ts + self.min_barcode_duration + and ( + ts > sync_start_ts + self.max_barcode_duration + or abs(sync_threshold_power - self.interval_power[i]) + > self.led_power + ) + ): + sync_area_end = i + sync_end_ts = ts + break + + barcode_data = self.interval_power[sync_area_start:sync_area_end] + + logger.debug( + "barcode search area: {:0.2f} .. {:0.2f} seconds ({} samples)".format( + sync_start_ts, sync_end_ts, len(barcode_data) + ) + ) + + bc, start, stop, padding_bits = self.find_barcode_in_power_data(barcode_data) + + if bc is None: + return None, None, None, None + + start_ts = self.interval_start_timestamp[sync_area_start + start] + stop_ts = self.interval_start_timestamp[sync_area_start + stop] + + end_ts = ( + stop_ts + self.module_duration * padding_bits + self.quiet_zone_duration + ) + + # barcode content, barcode start timestamp, barcode stop timestamp, barcode end (stop + padding) timestamp + return bc, start_ts, stop_ts, end_ts + + def find_barcode_in_power_data(self, barcode_data): + + min_power = np.min(barcode_data) + max_power = np.max(barcode_data) + + # zbar seems to be confused by measurement (and thus image) noise + # inside of barcodes. As our barcodes are only 1px high, this is + # likely not trivial to fix. + # -> Create a black and white (not grayscale) image to avoid this. + # Unfortunately, this decreases resilience against background noise + # (e.g. a not-exactly-idle peripheral device or CPU interrupts). + image_data = np.around( + 1 - ((barcode_data - min_power) / (max_power - min_power)) + ) + image_data *= 255 + + # zbar only returns the complete barcode position if it is at least + # two pixels high. For a 1px barcode, it only returns its right border. + + width = len(image_data) + height = 2 + + image_data = bytes(map(int, image_data)) * height + + # img = Image.frombytes('L', (width, height), image_data).resize((width, 100)) + # img.save('/tmp/test-{}.png'.format(os.getpid())) + + zbimg = zbar.Image(width, height, "Y800", image_data) + scanner = zbar.ImageScanner() + scanner.parse_config("enable") + + if scanner.scan(zbimg): + (sym,) = zbimg.symbols + content = sym.data + try: + sym_start = sym.location[1][0] + except IndexError: + sym_start = 0 + sym_end = sym.location[0][0] + + match = re.fullmatch(r"T(\d+)", content) + if match: + content = self.transition_names[int(match.group(1))] + + # PTALog barcode generation operates on bytes, so there may be + # additional non-barcode padding (encoded as LED off / image white). + # Calculate the amount of extra bits to determine the offset until + # the transition starts. + padding_bits = len(Code128(sym.data, charset="B").modules) % 8 + + # sym_start leaves out the first two bars, but we don't do anything about that here + # sym_end leaves out the last three bars, each of which is one padding bit long. + # as a workaround, we unconditionally increment padding_bits by three. + padding_bits += 3 + + return content, sym_start, sym_end, padding_bits + else: + logger.warning("unable to find barcode") + return None, None, None, None + + +class EnergyTraceWithLogicAnalyzer: + def __init__( + self, + voltage: float, + state_duration: int, + transition_names: list, + with_traces=False, + ): + + """ + Create a new EnergyTraceWithLogicAnalyzer object. + + :param voltage: supply voltage [V], usually 3.3 V + :param state_duration: state duration [ms] + :param transition_names: list of transition names in PTA transition order. + Needed to map barcode synchronization numbers to transitions. + """ + self.voltage = voltage + self.state_duration = state_duration * 1e-3 + self.transition_names = transition_names + self.with_traces = with_traces + self.errors = list() + + def load_data(self, log_data): + from dfatool.lennart.SigrokInterface import SigrokResult + from dfatool.lennart.EnergyInterface import EnergyInterface + + # Daten laden + self.sync_data = SigrokResult.fromString(log_data[0]) + ( + self.interval_start_timestamp, + self.interval_duration, + self.interval_power, + self.sample_rate, + self.hw_statechange_indexes, + ) = _load_energytrace(log_data[1]) + + def analyze_states(self, traces, offline_index: int): + """ + Split log data into states and transitions and return duration, energy, and mean power for each element. + + :param traces: expected traces, needed to synchronize with the measurement. + traces is a list of runs, traces[*]['trace'] is a single run + (i.e. a list of states and transitions, starting with a transition + and ending with a state). + :param offline_index: This function uses traces[*]['trace'][*]['online_aggregates']['duration'][offline_index] to find sync codes + + :param charges: raw charges (each element describes the charge in pJ transferred during 10 µs) + :param trigidx: "charges" indexes corresponding to a trigger edge, see `trigger_edges` + :param ua_func: charge(pJ) -> current(µA) function as returned by `calibration_function` + + :returns: returns list of states and transitions, starting with a transition and ending with astate + Each element is a dict containing: + * `isa`: 'state' or 'transition' + * `W_mean`: Mittelwert der Leistungsaufnahme + * `W_std`: Standardabweichung der Leistungsaufnahme + * `s`: Dauer + if isa == 'transition, it also contains: + * `W_mean_delta_prev`: Differenz zwischen W_mean und W_mean des vorherigen Zustands + * `W_mean_delta_next`: Differenz zwischen W_mean und W_mean des Folgezustands + """ + + names = [] + for trace_number, trace in enumerate(traces): + for state_or_transition in trace["trace"]: + names.append(state_or_transition["name"]) + # print(names[:15]) + from dfatool.lennart.DataProcessor import DataProcessor + + dp = DataProcessor( + sync_data=self.sync_data, + et_timestamps=self.interval_start_timestamp, + et_power=self.interval_power, + hw_statechange_indexes=self.hw_statechange_indexes, + offline_index=offline_index, + ) + dp.run() + energy_trace_new = dp.getStatesdfatool( + state_sleep=self.state_duration, with_traces=self.with_traces + ) + # Uncomment to plot traces + if os.getenv("DFATOOL_PLOT_LASYNC") is not None and offline_index == int( + os.getenv("DFATOOL_PLOT_LASYNC") + ): + dp.plot() # <- plot traces with sync annotatons + # dp.plot(names) # <- plot annotated traces (with state/transition names) + if os.getenv("DFATOOL_EXPORT_LASYNC") is not None: + filename = os.getenv("DFATOOL_EXPORT_LASYNC") + f"_{offline_index}.json" + with open(filename, "w") as f: + json.dump(dp.export_sync(), f, cls=NpEncoder) + logger.info("Exported data and LA sync timestamps to {filename}") + + energy_trace = list() + expected_transitions = list() + + # Print for debug purposes + # for number, name in enumerate(names): + # if "P15_8MW" in name: + # print(name, energy_trace_new[number]["W_mean"]) + + # st = "" + # for i, x in enumerate(energy_trace_new[-10:]): + # #st += "(%s|%s|%s)" % (energy_trace[i-10]["name"],x['W_mean'],x['s']) + # st += "(%s|%s|%s)\n" % (energy_trace[i-10]["s"], x['s'], x['W_mean']) + + # print(st, "\n_______________________") + # print(len(self.sync_data.timestamps), " - ", len(energy_trace_new), " - ", len(energy_trace), " - ", ",".join([str(x["s"]) for x in energy_trace_new[-6:]]), " - ", ",".join([str(x["s"]) for x in energy_trace[-6:]])) + # if len(energy_trace_new) < len(energy_trace): + # return None + + return energy_trace_new + + +class EnergyTraceWithTimer(EnergyTraceWithLogicAnalyzer): + def __init__( + self, + voltage: float, + state_duration: int, + transition_names: list, + with_traces=False, + ): + + """ + Create a new EnergyTraceWithLogicAnalyzer object. + + :param voltage: supply voltage [V], usually 3.3 V + :param state_duration: state duration [ms] + :param transition_names: list of transition names in PTA transition order. + Needed to map barcode synchronization numbers to transitions. + """ + + self.voltage = voltage + self.state_duration = state_duration * 1e-3 + self.transition_names = transition_names + self.with_traces = with_traces + self.errors = list() + + super().__init__(voltage, state_duration, transition_names, with_traces) + + def load_data(self, log_data): + self.sync_data = None + ( + self.interval_start_timestamp, + self.interval_duration, + self.interval_power, + self.sample_rate, + self.hw_statechange_indexes, + ) = _load_energytrace(log_data[0]) + + def analyze_states(self, traces, offline_index: int): + + # Start "Synchronization pulse" + timestamps = [0, 10, 1e6, 1e6 + 10] + + # The first trace doesn't start immediately, append offset saved by OnboarTimerHarness + timestamps.append(timestamps[-1] + traces[0]["start_offset"][offline_index]) + for tr in traces: + for t in tr["trace"]: + # print(t["online_aggregates"]["duration"][offline_index]) + try: + timestamps.append( + timestamps[-1] + + t["online_aggregates"]["duration"][offline_index] + ) + except IndexError: + self.errors.append( + f"""offline_index {offline_index} missing in trace {tr["id"]}""" + ) + return list() + + # print(timestamps) + + # Stop "Synchronization pulses". The first one has already started. + timestamps.extend(np.array([10, 1e6, 1e6 + 10]) + timestamps[-1]) + timestamps.extend(np.array([0, 10, 1e6, 1e6 + 10]) + 250e3 + timestamps[-1]) + + timestamps = list(np.array(timestamps) * 1e-6) + + from dfatool.lennart.SigrokInterface import SigrokResult + + self.sync_data = SigrokResult(timestamps, False) + return super().analyze_states(traces, offline_index) diff --git a/lib/loader/keysight.py b/lib/loader/keysight.py new file mode 100644 index 0000000..b9b298d --- /dev/null +++ b/lib/loader/keysight.py @@ -0,0 +1,36 @@ +#!/usr/bin/env python3 + +import csv +import logging +import numpy as np + +logger = logging.getLogger(__name__) + + +class KeysightCSV: + """Simple loader for Keysight CSV data, as exported by the windows software.""" + + def __init__(self): + """Create a new KeysightCSV object.""" + pass + + def load_data(self, filename: str): + """ + Load log data from filename, return timestamps and currents. + + Returns two one-dimensional NumPy arrays: timestamps and corresponding currents. + """ + with open(filename) as f: + for i, _ in enumerate(f): + pass + timestamps = np.ndarray((i - 3), dtype=float) + currents = np.ndarray((i - 3), dtype=float) + # basically seek back to start + with open(filename) as f: + for _ in range(4): + next(f) + reader = csv.reader(f, delimiter=",") + for i, row in enumerate(reader): + timestamps[i] = float(row[0]) + currents[i] = float(row[2]) * -1 + return timestamps, currents diff --git a/lib/loader/mimosa.py b/lib/loader/mimosa.py new file mode 100644 index 0000000..2893e30 --- /dev/null +++ b/lib/loader/mimosa.py @@ -0,0 +1,644 @@ +#!/usr/bin/env python3 + +import io +import logging +import numpy as np +import struct +import tarfile + +from dfatool.utils import soft_cast_int + +logger = logging.getLogger(__name__) + +arg_support_enabled = True + + +class MIMOSA: + """ + MIMOSA log loader for DFA traces with auto-calibration. + + Expects a MIMOSA log file generated via dfatool and a dfatool-generated + benchmark. A MIMOSA log consists of a series of measurements. Each measurement + gives the total charge (in pJ) and binary buzzer/trigger value during a 10µs interval. + + There must be a calibration run consisting of at least two seconds with disconnected DUT, + two seconds with 1 kOhm (984 Ohm), and two seconds with 100 kOhm (99013 Ohm) resistor at + the start. The first ten seconds of data are reserved for calbiration and must not contain + measurements, as trigger/buzzer signals are ignored in this time range. + + Resulting data is a list of state/transition/state/transition/... measurements. + """ + + def __init__(self, voltage: float, shunt: int, with_traces=False): + """ + Initialize MIMOSA loader for a specific voltage and shunt setting. + + :param voltage: MIMOSA DUT supply voltage (V) + :para mshunt: MIMOSA Shunt (Ohms) + """ + self.voltage = voltage + self.shunt = shunt + self.with_traces = with_traces + self.r1 = 984 # "1k" + self.r2 = 99013 # "100k" + self.errors = list() + + def charge_to_current_nocal(self, charge): + """ + Convert charge per 10µs (in pJ) to mean currents (in µA) without accounting for calibration. + + :param charge: numpy array of charges (pJ per 10µs) as returned by `load_data` or `load_file` + + :returns: numpy array of mean currents (µA per 10µs) + """ + ua_max = 1.836 / self.shunt * 1_000_000 + ua_step = ua_max / 65535 + return charge * ua_step + + def _state_is_too_short(self, online, offline, state_duration, next_transition): + # We cannot control when an interrupt causes a state to be left + if next_transition["plan"]["level"] == "epilogue": + return False + + # Note: state_duration is stored as ms, not us + return offline["us"] < state_duration * 500 + + def _state_is_too_long(self, online, offline, state_duration, prev_transition): + # If the previous state was left by an interrupt, we may have some + # waiting time left over. So it's okay if the current state is longer + # than expected. + if prev_transition["plan"]["level"] == "epilogue": + return False + # state_duration is stored as ms, not us + return offline["us"] > state_duration * 1500 + + def _load_tf(self, tf): + """ + Load MIMOSA log data from an open `tarfile` instance. + + :param tf: `tarfile` instance + + :returns: (numpy array of charges (pJ per 10µs), numpy array of triggers (0/1 int, per 10µs)) + """ + num_bytes = tf.getmember("/tmp/mimosa//mimosa_scale_1.tmp").size + charges = np.ndarray(shape=(int(num_bytes / 4)), dtype=np.int32) + triggers = np.ndarray(shape=(int(num_bytes / 4)), dtype=np.int8) + with tf.extractfile("/tmp/mimosa//mimosa_scale_1.tmp") as f: + content = f.read() + iterator = struct.iter_unpack("<I", content) + i = 0 + for word in iterator: + charges[i] = word[0] >> 4 + triggers[i] = (word[0] & 0x08) >> 3 + i += 1 + return charges, triggers + + def load_data(self, raw_data): + """ + Load MIMOSA log data from a MIMOSA log file passed as raw byte string + + :param raw_data: MIMOSA log file, passed as raw byte string + + :returns: (numpy array of charges (pJ per 10µs), numpy array of triggers (0/1 int, per 10µs)) + """ + with io.BytesIO(raw_data) as data_object: + with tarfile.open(fileobj=data_object) as tf: + return self._load_tf(tf) + + def load_file(self, filename): + """ + Load MIMOSA log data from a MIMOSA log file + + :param filename: MIMOSA log file + + :returns: (numpy array of charges (pJ per 10µs), numpy array of triggers (0/1 int, per 10µs)) + """ + with tarfile.open(filename) as tf: + return self._load_tf(tf) + + def currents_nocal(self, charges): + """ + Convert charges (pJ per 10µs) to mean currents without accounting for calibration. + + :param charges: numpy array of charges (pJ per 10µs) + + :returns: numpy array of currents (mean µA per 10µs)""" + ua_max = 1.836 / self.shunt * 1_000_000 + ua_step = ua_max / 65535 + return charges.astype(np.double) * ua_step + + def trigger_edges(self, triggers): + """ + Return indexes of trigger edges (both 0->1 and 1->0) in log data. + + Ignores the first 10 seconds, which are used for calibration and may + contain bogus triggers due to DUT resets. + + :param triggers: trigger array (int, 0/1) as returned by load_data + + :returns: list of int (trigger indices, e.g. [2000000, ...] means the first trigger appears in charges/currents interval 2000000 -> 20s after start of measurements. Keep in mind that each interval is 10µs long, not 1µs, so index values are not µs timestamps) + """ + trigidx = [] + + if len(triggers) < 1_000_000: + self.errors.append("MIMOSA log is too short") + return trigidx + + prevtrig = triggers[999_999] + + # if the first trigger is high (i.e., trigger/buzzer pin is active before the benchmark starts), + # something went wrong and are unable to determine when the first + # transition starts. + if prevtrig != 0: + self.errors.append( + "Unable to find start of first transition (log starts with trigger == {} != 0)".format( + prevtrig + ) + ) + + # if the last trigger is high (i.e., trigger/buzzer pin is active when the benchmark ends), + # it terminated in the middle of a transition -- meaning that it was not + # measured in its entirety. + if triggers[-1] != 0: + self.errors.append("Log ends during a transition".format(prevtrig)) + + # the device is reset for MIMOSA calibration in the first 10s and may + # send bogus interrupts -> bogus triggers + for i in range(1_000_000, triggers.shape[0]): + trig = triggers[i] + if trig != prevtrig: + # Due to MIMOSA's integrate-read-reset cycle, the charge/current + # interval belonging to this trigger comes two intervals (20µs) later + trigidx.append(i + 2) + prevtrig = trig + return trigidx + + def calibration_edges(self, currents): + """ + Return start/stop indexes of calibration measurements. + + :param currents: uncalibrated currents as reported by MIMOSA. For best results, + it may help to use a running mean, like so: + `currents = running_mean(currents_nocal(..., 10))` + + :returns: indices of calibration events in MIMOSA data: + (disconnect start, disconnect stop, R1 (1k) start, R1 (1k) stop, R2 (100k) start, R2 (100k) stop) + indices refer to charges/currents arrays, so 0 refers to the first 10µs interval, 1 to the second, and so on. + """ + r1idx = 0 + r2idx = 0 + ua_r1 = self.voltage / self.r1 * 1_000_000 + # first second may be bogus + for i in range(100_000, len(currents)): + if r1idx == 0 and currents[i] > ua_r1 * 0.6: + r1idx = i + elif ( + r1idx != 0 + and r2idx == 0 + and i > (r1idx + 180_000) + and currents[i] < ua_r1 * 0.4 + ): + r2idx = i + # 2s disconnected, 2s r1, 2s r2 with r1 < r2 -> ua_r1 > ua_r2 + # allow 5ms buffer in both directions to account for bouncing relais contacts + return ( + r1idx - 180_500, + r1idx - 500, + r1idx + 500, + r2idx - 500, + r2idx + 500, + r2idx + 180_500, + ) + + def calibration_function(self, charges, cal_edges): + """ + Calculate calibration function from previously determined calibration edges. + + :param charges: raw charges from MIMOSA + :param cal_edges: calibration edges as returned by calibration_edges + + :returns: (calibration_function, calibration_data): + calibration_function -- charge in pJ (float) -> current in uA (float). + Converts the amount of charge in a 10 µs interval to the + mean current during the same interval. + calibration_data -- dict containing the following keys: + edges -- calibration points in the log file, in µs + offset -- ... + offset2 -- ... + slope_low -- ... + slope_high -- ... + add_low -- ... + add_high -- .. + r0_err_uW -- mean error of uncalibrated data at "∞ Ohm" in µW + r0_std_uW -- standard deviation of uncalibrated data at "∞ Ohm" in µW + r1_err_uW -- mean error of uncalibrated data at 1 kOhm + r1_std_uW -- stddev at 1 kOhm + r2_err_uW -- mean error at 100 kOhm + r2_std_uW -- stddev at 100 kOhm + """ + dis_start, dis_end, r1_start, r1_end, r2_start, r2_end = cal_edges + if dis_start < 0: + dis_start = 0 + chg_r0 = charges[dis_start:dis_end] + chg_r1 = charges[r1_start:r1_end] + chg_r2 = charges[r2_start:r2_end] + cal_0_mean = np.mean(chg_r0) + cal_r1_mean = np.mean(chg_r1) + cal_r2_mean = np.mean(chg_r2) + + ua_r1 = self.voltage / self.r1 * 1_000_000 + ua_r2 = self.voltage / self.r2 * 1_000_000 + + if cal_r2_mean > cal_0_mean: + b_lower = (ua_r2 - 0) / (cal_r2_mean - cal_0_mean) + else: + logger.warning("0 uA == %.f uA during calibration" % (ua_r2)) + b_lower = 0 + + b_upper = (ua_r1 - ua_r2) / (cal_r1_mean - cal_r2_mean) + + a_lower = -b_lower * cal_0_mean + a_upper = -b_upper * cal_r2_mean + + if self.shunt == 680: + # R1 current is higher than shunt range -> only use R2 for calibration + def calfunc(charge): + if charge < cal_0_mean: + return 0 + else: + return charge * b_lower + a_lower + + else: + + def calfunc(charge): + if charge < cal_0_mean: + return 0 + if charge <= cal_r2_mean: + return charge * b_lower + a_lower + else: + return charge * b_upper + a_upper + ua_r2 + + caldata = { + "edges": [x * 10 for x in cal_edges], + "offset": cal_0_mean, + "offset2": cal_r2_mean, + "slope_low": b_lower, + "slope_high": b_upper, + "add_low": a_lower, + "add_high": a_upper, + "r0_err_uW": np.mean(self.currents_nocal(chg_r0)) * self.voltage, + "r0_std_uW": np.std(self.currents_nocal(chg_r0)) * self.voltage, + "r1_err_uW": (np.mean(self.currents_nocal(chg_r1)) - ua_r1) * self.voltage, + "r1_std_uW": np.std(self.currents_nocal(chg_r1)) * self.voltage, + "r2_err_uW": (np.mean(self.currents_nocal(chg_r2)) - ua_r2) * self.voltage, + "r2_std_uW": np.std(self.currents_nocal(chg_r2)) * self.voltage, + } + + # print("if charge < %f : return 0" % cal_0_mean) + # print("if charge <= %f : return charge * %f + %f" % (cal_r2_mean, b_lower, a_lower)) + # print("else : return charge * %f + %f + %f" % (b_upper, a_upper, ua_r2)) + + return calfunc, caldata + + def analyze_states(self, charges, trigidx, ua_func): + """ + Split log data into states and transitions and return duration, energy, and mean power for each element. + + :param charges: raw charges (each element describes the charge in pJ transferred during 10 µs) + :param trigidx: "charges" indexes corresponding to a trigger edge, see `trigger_edges` + :param ua_func: charge(pJ) -> current(µA) function as returned by `calibration_function` + + :returns: list of states and transitions, both starting andending with a state. + Each element is a dict containing: + * `isa`: 'state' or 'transition' + * `clip_rate`: range(0..1) Anteil an Clipping im Energieverbrauch + * `raw_mean`: Mittelwert der Rohwerte + * `raw_std`: Standardabweichung der Rohwerte + * `uW_mean`: Mittelwert der (kalibrierten) Leistungsaufnahme + * `uW_std`: Standardabweichung der (kalibrierten) Leistungsaufnahme + * `us`: Dauer + if isa == 'transition, it also contains: + * `timeout`: Dauer des vorherigen Zustands + * `uW_mean_delta_prev`: Differenz zwischen uW_mean und uW_mean des vorherigen Zustands + * `uW_mean_delta_next`: Differenz zwischen uW_mean und uW_mean des Folgezustands + if `self.with_traces` is true, it also contains: + * `plot`: (timestamps [s], power readings [W]) + """ + previdx = 0 + is_state = True + iterdata = [] + + # The last state (between the last transition and end of file) may also + # be important. Pretend it ends when the log ends. + trigger_indices = trigidx.copy() + trigger_indices.append(len(charges)) + + for idx in trigger_indices: + range_raw = charges[previdx:idx] + range_ua = ua_func(range_raw) + + isa = "state" + if not is_state: + isa = "transition" + + data = { + "isa": isa, + "clip_rate": np.mean(range_raw == 65535), + "raw_mean": np.mean(range_raw), + "raw_std": np.std(range_raw), + "uW_mean": np.mean(range_ua * self.voltage), + "uW_std": np.std(range_ua * self.voltage), + "us": (idx - previdx) * 10, + } + + if self.with_traces: + data["plot"] = ( + np.arange(len(range_ua)) * 1e-5, + range_ua * self.voltage * 1e-6, + ) + + if isa == "transition": + # subtract average power of previous state + # (that is, the state from which this transition originates) + data["uW_mean_delta_prev"] = data["uW_mean"] - iterdata[-1]["uW_mean"] + # placeholder to avoid extra cases in the analysis + data["uW_mean_delta_next"] = data["uW_mean"] + data["timeout"] = iterdata[-1]["us"] + elif len(iterdata) > 0: + # subtract average power of next state + # (the state into which this transition leads) + iterdata[-1]["uW_mean_delta_next"] = ( + iterdata[-1]["uW_mean"] - data["uW_mean"] + ) + + iterdata.append(data) + + previdx = idx + is_state = not is_state + return iterdata + + def validate(self, num_triggers, observed_trace, expected_traces, state_duration): + """ + Check if a dfatool v0 or v1 measurement is valid. + + processed_data layout: + 'fileno' : measurement['fileno'], + 'info' : measurement['info'], + 'triggers' : len(trigidx), + 'first_trig' : trigidx[0] * 10, + 'calibration' : caldata, + 'energy_trace' : mim.analyze_states(charges, trigidx, vcalfunc) + A sequence of unnamed, unparameterized states and transitions with + power and timing data + mim.analyze_states returns a list of (alternating) states and transitions. + Each element is a dict containing: + - isa: 'state' oder 'transition' + - clip_rate: range(0..1) Anteil an Clipping im Energieverbrauch + - raw_mean: Mittelwert der Rohwerte + - raw_std: Standardabweichung der Rohwerte + - uW_mean: Mittelwert der (kalibrierten) Leistungsaufnahme + - uW_std: Standardabweichung der (kalibrierten) Leistungsaufnahme + - us: Dauer + + Nur falls isa == 'transition': + - timeout: Dauer des vorherigen Zustands + - uW_mean_delta_prev: Differenz zwischen uW_mean und uW_mean des vorherigen Zustands + - uW_mean_delta_next: Differenz zwischen uW_mean und uW_mean des Folgezustands + """ + if len(self.errors): + return False + + # Check trigger count + sched_trigger_count = 0 + for run in expected_traces: + sched_trigger_count += len(run["trace"]) + if sched_trigger_count != num_triggers: + self.errors.append( + "got {got:d} trigger edges, expected {exp:d}".format( + got=num_triggers, exp=sched_trigger_count + ) + ) + return False + # Check state durations. Very short or long states can indicate a + # missed trigger signal which wasn't detected due to duplicate + # triggers elsewhere + online_datapoints = [] + for run_idx, run in enumerate(expected_traces): + for trace_part_idx in range(len(run["trace"])): + online_datapoints.append((run_idx, trace_part_idx)) + for offline_idx, (online_run_idx, online_trace_part_idx) in enumerate( + online_datapoints + ): + offline_trace_part = observed_trace[offline_idx] + online_trace_part = expected_traces[online_run_idx]["trace"][ + online_trace_part_idx + ] + + if online_trace_part["isa"] != offline_trace_part["isa"]: + self.errors.append( + "Offline #{off_idx:d} (online {on_name:s} @ {on_idx:d}/{on_sub:d}) claims to be {off_isa:s}, but should be {on_isa:s}".format( + off_idx=offline_idx, + on_idx=online_run_idx, + on_sub=online_trace_part_idx, + on_name=online_trace_part["name"], + off_isa=offline_trace_part["isa"], + on_isa=online_trace_part["isa"], + ) + ) + return False + + # Clipping in UNINITIALIZED (offline_idx == 0) can happen during + # calibration and is handled by MIMOSA + if ( + offline_idx != 0 + and offline_trace_part["clip_rate"] != 0 + and not self.ignore_clipping + ): + self.errors.append( + "Offline #{off_idx:d} (online {on_name:s} @ {on_idx:d}/{on_sub:d}) was clipping {clip:f}% of the time".format( + off_idx=offline_idx, + on_idx=online_run_idx, + on_sub=online_trace_part_idx, + on_name=online_trace_part["name"], + clip=offline_trace_part["clip_rate"] * 100, + ) + ) + return False + + if ( + online_trace_part["isa"] == "state" + and online_trace_part["name"] != "UNINITIALIZED" + and len(expected_traces[online_run_idx]["trace"]) + > online_trace_part_idx + 1 + ): + online_prev_transition = expected_traces[online_run_idx]["trace"][ + online_trace_part_idx - 1 + ] + online_next_transition = expected_traces[online_run_idx]["trace"][ + online_trace_part_idx + 1 + ] + try: + if self._state_is_too_short( + online_trace_part, + offline_trace_part, + state_duration, + online_next_transition, + ): + self.errors.append( + "Offline #{off_idx:d} (online {on_name:s} @ {on_idx:d}/{on_sub:d}) is too short (duration = {dur:d} us)".format( + off_idx=offline_idx, + on_idx=online_run_idx, + on_sub=online_trace_part_idx, + on_name=online_trace_part["name"], + dur=offline_trace_part["us"], + ) + ) + return False + if self._state_is_too_long( + online_trace_part, + offline_trace_part, + state_duration, + online_prev_transition, + ): + self.errors.append( + "Offline #{off_idx:d} (online {on_name:s} @ {on_idx:d}/{on_sub:d}) is too long (duration = {dur:d} us)".format( + off_idx=offline_idx, + on_idx=online_run_idx, + on_sub=online_trace_part_idx, + on_name=online_trace_part["name"], + dur=offline_trace_part["us"], + ) + ) + return False + except KeyError: + pass + # TODO es gibt next_transitions ohne 'plan' + return True + + @staticmethod + def add_offline_aggregates(online_traces, offline_trace, repeat_id): + # Edits online_traces[*]['trace'][*]['offline'] + # and online_traces[*]['trace'][*]['offline_aggregates'] in place + # (appends data from offline_trace) + # "offline_aggregates" is the only data used later on by model.py's by_name / by_param dicts + online_datapoints = [] + for run_idx, run in enumerate(online_traces): + for trace_part_idx in range(len(run["trace"])): + online_datapoints.append((run_idx, trace_part_idx)) + for offline_idx, (online_run_idx, online_trace_part_idx) in enumerate( + online_datapoints + ): + offline_trace_part = offline_trace[offline_idx] + online_trace_part = online_traces[online_run_idx]["trace"][ + online_trace_part_idx + ] + + if "offline" not in online_trace_part: + online_trace_part["offline"] = [offline_trace_part] + else: + online_trace_part["offline"].append(offline_trace_part) + + paramkeys = sorted(online_trace_part["parameter"].keys()) + + paramvalues = list() + + for paramkey in paramkeys: + if type(online_trace_part["parameter"][paramkey]) is list: + paramvalues.append( + soft_cast_int( + online_trace_part["parameter"][paramkey][repeat_id] + ) + ) + else: + paramvalues.append( + soft_cast_int(online_trace_part["parameter"][paramkey]) + ) + + # NB: Unscheduled transitions do not have an 'args' field set. + # However, they should only be caused by interrupts, and + # interrupts don't have args anyways. + if arg_support_enabled and "args" in online_trace_part: + paramvalues.extend(map(soft_cast_int, online_trace_part["args"])) + + # TODO rename offline_aggregates to make it clear that this is what ends up in by_name / by_param and model.py + if "offline_aggregates" not in online_trace_part: + online_trace_part["offline_attributes"] = [ + "power", + "duration", + "energy", + ] + # this is what ends up in by_name / by_param and is used by model.py + online_trace_part["offline_aggregates"] = { + "power": [], + "duration": [], + "power_std": [], + "energy": [], + "paramkeys": [], + "param": [], + } + if online_trace_part["isa"] == "transition": + online_trace_part["offline_attributes"].extend( + [ + "rel_energy_prev", + "rel_energy_next", + "rel_power_prev", + "rel_power_next", + "timeout", + ] + ) + online_trace_part["offline_aggregates"]["rel_energy_prev"] = [] + online_trace_part["offline_aggregates"]["rel_energy_next"] = [] + online_trace_part["offline_aggregates"]["rel_power_prev"] = [] + online_trace_part["offline_aggregates"]["rel_power_next"] = [] + online_trace_part["offline_aggregates"]["timeout"] = [] + if "plot" in offline_trace_part: + online_trace_part["offline_support"] = [ + "power_traces", + "timestamps", + ] + online_trace_part["offline_aggregates"]["power_traces"] = list() + online_trace_part["offline_aggregates"]["timestamps"] = list() + + # Note: All state/transitions are 20us "too long" due to injected + # active wait states. These are needed to work around MIMOSA's + # relatively low sample rate of 100 kHz (10us) and removed here. + online_trace_part["offline_aggregates"]["power"].append( + offline_trace_part["uW_mean"] + ) + online_trace_part["offline_aggregates"]["duration"].append( + offline_trace_part["us"] - 20 + ) + online_trace_part["offline_aggregates"]["power_std"].append( + offline_trace_part["uW_std"] + ) + online_trace_part["offline_aggregates"]["energy"].append( + offline_trace_part["uW_mean"] * (offline_trace_part["us"] - 20) + ) + online_trace_part["offline_aggregates"]["paramkeys"].append(paramkeys) + online_trace_part["offline_aggregates"]["param"].append(paramvalues) + if online_trace_part["isa"] == "transition": + online_trace_part["offline_aggregates"]["rel_energy_prev"].append( + offline_trace_part["uW_mean_delta_prev"] + * (offline_trace_part["us"] - 20) + ) + online_trace_part["offline_aggregates"]["rel_energy_next"].append( + offline_trace_part["uW_mean_delta_next"] + * (offline_trace_part["us"] - 20) + ) + online_trace_part["offline_aggregates"]["rel_power_prev"].append( + offline_trace_part["uW_mean_delta_prev"] + ) + online_trace_part["offline_aggregates"]["rel_power_next"].append( + offline_trace_part["uW_mean_delta_next"] + ) + online_trace_part["offline_aggregates"]["timeout"].append( + offline_trace_part["timeout"] + ) + + if "plot" in offline_trace_part: + online_trace_part["offline_aggregates"]["power_traces"].append( + offline_trace_part["plot"][1] + ) + online_trace_part["offline_aggregates"]["timestamps"].append( + offline_trace_part["plot"][0] + ) |