<commit_before>"""
The log analyzer module reads and parses Varian linear accelerator machine logs, both Dynalogs and Trajectory logs. The module also
calculates actual and expected fluences as well as performing gamma evaluations. Data is structured to be easily accessible and
easily plottable.

Unlike most other modules of pylinac, the log analyzer module has no end goal. Data is parsed from the logs, but what is done with that
info, and which info is analyzed is up to the user.
"""
from abc import ABCMeta, abstractproperty
import struct
import os
import os.path as osp
import csv
import copy
import warnings

import numpy as np
import scipy.ndimage.filters as spf
import matplotlib.pyplot as plt

from pylinac import MEMORY_PROFILE, DEBUG
from pylinac.core.decorators import type_accept, lazyproperty, value_accept
from pylinac.core.io import is_valid_file, is_valid_dir, get_folder_UI, get_filepath_UI, open_file
from pylinac.core.utilities import is_iterable


if DEBUG and MEMORY_PROFILE:
    from memory_profile import profile

np.seterr(invalid='ignore')  # ignore warnings for invalid numpy operations. Used for np.where() operations on partially-NaN arrays.

log_types = {'dlog': 'Dynalog', 'tlog': 'Trajectory log'}


class MachineLogs(list):
    """
    .. versionadded:: 0.4.1

    Read in machine logs from a directory. Inherits from list. Batch methods are also provided."""
    @type_accept(dir=str)
    def __init__(self, dir=None, recursive=True, verbose=True):
        """
        Parameters
        ----------
        dir : str
            The directory of interest. Will walk through and process any logs, Trajectory or dynalog, it finds.
            Non-log files will be skipped.
        recursive : bool
            Whether to walk through subfolders of passed directory. Only used if ``dir`` is a valid log directory.
        verbose : bool
            If True (default), prints load status at each log. Only used if ``dir`` is a valid log directory.

        Examples
        --------
        Load a directory upon initialization::

            >>> log_dir = r'C:\path\log\directory'
            >>> logs = MachineLogs(log_dir)

        Or load them later directly::

            >>> logs = MachineLogs()
            >>> logs.load_dir(log_dir)

        Or even using a UI dialog::

            >>> logs = MachineLogs()
            >>> logs.load_dir_UI()

        Batch methods include determining the average gamma and average gamma pass value::

            >>> logs.avg_gamma()
            >>> 0.05 # or whatever it is
            >>> logs.avg_gamma_pct()
            >>> 97.2
        """
        super().__init__()
        if dir is not None and is_valid_dir(dir):
            self.load_dir(dir, recursive, verbose)

    @property
    def num_logs(self):
        """Return the number of logs currently loaded."""
        return len(self)

    @value_accept(log_type=log_types)
    def _num_log_type(self, log_type):
        num = 0
        for log in self:
            if log.log_type == log_type:
                num += 1
        return num

    @property
    def num_tlogs(self):
        """Return the number of Trajectory logs currently loaded."""
        return self._num_log_type(log_types['tlog'])

    @property
    def num_dlogs(self):
        """Return the number of Trajectory logs currently loaded."""
        return self._num_log_type(log_types['dlog'])

    def load_dir(self, dir, recursive=True, verbose=True):
        """Load a directory of log files.

        Parameters
        ----------
        dir : str, None
            The directory of interest.
            If a string, will walk through and process any logs, Trajectory or dynalog, it finds.
            Non-log files will be skipped.
            If None, files must be loaded later using .load_dir() or .append().
        recursive : bool
            If True (default), will walk through subfolders of passed directory.
            If False, will only search root directory.
        verbose : bool
            If True (default), prints load status at each log.
        """
        # do initial walk to get file count
        num_logs = 0
        num_skipped = 0
        for root, dirs, files in os.walk(dir):
            _, num_logs, num_skipped = self._clean_log_filenames(files, root, num_logs, num_skipped)
            if not recursive:
                break
        if num_logs == 0:
            warnings.warn("No logs found.")
            return

        # actual log loading
        load_num = 1
        if verbose:
            print("{} logs found. \n{} logs skipped. \nLog loaded:".format(num_logs, num_skipped))
        for root, dirs, files in os.walk(dir):
            cleaned_files, _, _ = self._clean_log_filenames(files, root, num_logs, num_skipped)
            for name in cleaned_files:
                pth = osp.join(root, name)
                self.append(pth)
                if verbose:
                    print("{} of {}".format(load_num, num_logs))
                    load_num += 1
            if not recursive:
                break  # break out of for loop after top level search

    def load_dir_UI(self, recursive=True, verbose=True):
        """Load a directory using a UI dialog box. See load_dir() for parameter info."""
        folder = get_folder_UI()
        if folder:
            self.load_dir(folder, recursive, verbose)

    def _clean_log_filenames(self, filenames, root, num_logs, num_skipped):
        """Extract the names of real log files from a list of files."""
        cleaned_filenames = []
        for idx, filename in enumerate(filenames):
            filename = osp.join(root, filename)
            if is_tlog(filename):
                num_logs += 1
                cleaned_filenames.append(filename)
            elif is_dlog(filename):
                opp_file = _return_other_dlg(filename, raise_find_error=False)
                if opp_file is not None:
                    num_logs += 1
                    opp_file = osp.basename(opp_file)
                    del filenames[filenames.index(opp_file)]
                    cleaned_filenames.append(filename)
                else:
                    num_skipped += 1
                    del filenames[idx]
        return cleaned_filenames, num_logs, num_skipped

    def _check_empty(self):
        if len(self) == 0:
            raise ValueError("No logs have been loaded yet.")

    def report_basic_parameters(self):
        """Report basic parameters of the logs.

        - Number of logs
        - Average gamma value of all logs
        - Average gamma pass percent of all logs
        """
        print("Number of logs: {}".format(self.num_logs))
        print("Average gamma: {:3.2f}".format(self.avg_gamma(verbose=False)))
        print("Average gamma pass percent: {:3.1f}".format(self.avg_gamma_pct(verbose=False)))

    def append(self, obj, recursive=True):
        """Append a log. Overloads list method.

        Parameters
        ----------
        obj : str, MachineLog
            If a string, must point to a log file.
            If a directory, must contain log files.
            If a MachineLog, then simply appends.
        recursive : bool
            Whether to walk through subfolders of passed directory. Only applicable if obj was a directory.
        """
        if isinstance(obj, str):
            if is_log(obj):
                log = MachineLog(obj)
                super().append(log)
            elif is_valid_dir(obj, raise_error=False):
                for root, dirs, files in os.walk(obj):
                    for name in files:
                        pth = osp.join(root, name)
                        self.append(pth)
        elif isinstance(obj, MachineLog):
            super().append(obj)
        else:
            raise TypeError("Can only append MachineLog or string pointing to a log or log directory.")

    def avg_gamma(self, doseTA=1, distTA=1, threshold=10, resolution=0.1, verbose=True):
        """Calculate and return the average gamma of all logs. See :meth:`~pylinac.log_analyzer.GammaFluence.calc_map()`
        for further parameter info."""
        self._check_empty()
        gamma_list = []
        if verbose:
            print("Calculating gammas:")
        for num, log in enumerate(self):
            log.fluence.gamma.calc_map(doseTA, distTA, threshold, resolution)
            gamma_list.append(log.fluence.gamma.avg_gamma)
            if verbose:
                print('{} of {}'.format(num, self.num_logs))
        return np.array(gamma_list).mean()

    def avg_gamma_pct(self, doseTA=1, distTA=1, threshold=10, resolution=0.1, verbose=True):
        """Calculate and return the average gamma pass percent of all logs. See :meth:`~pylinac.log_analyzer.GammaFluence.calc_map()`
        for further parameter info."""
        self._check_empty()
        gamma_list = []
        if verbose:
            print("Calculating gamma pass percent:")
        for num, log in enumerate(self):
            log.fluence.gamma.calc_map(doseTA, distTA, threshold, resolution)
            gamma_list.append(log.fluence.gamma.pass_prcnt)
            if verbose:
                print("{} of {}".format(num, self.num_logs))
        return np.array(gamma_list).mean()

    def to_csv(self):
        """Write trajectory logs to CSV. If there are both dynalogs and trajectory logs,
        only the trajectory logs will be written. File names will be the same as the original log file names."""
        num_written = 0
        for log in self:
            if is_tlog(log._filename):
                log.to_csv()
                num_written += 1
        if num_written:
            print('\n\nAll CSV files written!')
        else:
            print('\n\nNo files written')


class MachineLog:
    """Reads in and analyzes MLC log files, both dynalog and trajectory logs, from Varian linear accelerators.

    If reading Trajectory logs, the .txt file is also loaded if it's around.
    """

    def __init__(self, filename=''):
        """
        Parameters
        ----------
        filename : str
            Path to the log file. For trajectory logs this is a single .bin file.
            For dynalog files, load either the A*.dlg or B*.dlg file.

            .. warning:: Dynalogs must have names like "A*.dlg" and "B*.dlg" and be in the same folder, otherwise errors will ensue.

        Examples
        --------
        Load a file upon initialization::

            >>> mylogfile = "C:/path/to/log.bin"
            >>> log = MachineLog(mylogfile)

        Or::

            >>> mylogfile = "C:/path/to/A1.dlg"  # B must also be here
            >>> log = MachineLog()
            >>> log.load(mylogfile)

        Run the demo::

            >>> MachineLog().run_dlog_demo()
            >>> MachineLog().run_tlog_demo()

        Attributes
        ----------
        header : A :class:`~pylinac.log_analyzer.Tlog_Header` or :class:`~pylinac.log_analyzer.Dlog_Header`, depending on the log type.
        axis_data : :class:`~pylinac.log_analyzer.Tlog_Axis_Data` or :class:`~pylinac.log_analyzer.Dlog_Axis_Data`, depending on the log type.
        subbeams : list
            Only applicable for autosequenced trajectory logs. Will contain instances of :class:`~pylinac.log_analyzer.Subbeam`; will be empty if
            autosequencing was not done.
        txt : dict
            If working with trajectory logs and the associated .txt file is in the same directory, the txt file data will be loaded as a dictionary.
        fluence : :class:`~pylinac.log_analyzer.Fluence_Struct`
            Contains actual and expected fluence data, including gamma.
        log_type : str
            The log type loaded; either 'Dynalog' or 'Trajectory log'
        log_is_loaded : bool
            Whether a log has yet been loaded.
        """
        self._filename = ''
        self._cursor = 0
        self.fluence = Fluence_Struct()

        # Read file if passed in
        if filename is not '':
            self.load(filename)

    def run_tlog_demo(self):
        """Run the Trajectory log demo."""
        self.load_demo_trajectorylog()
        self.report_basic_parameters()
        self.plot_all()

    def run_dlog_demo(self):
        """Run the dynalog demo."""
        self.load_demo_dynalog()
        self.report_basic_parameters()
        self.plot_all()

    def load_demo_dynalog(self, exclude_beam_off=True):
        """Load the demo dynalog file included with the package."""
        self._filename = osp.join(osp.dirname(__file__), 'demo_files', 'log_reader', 'AQA.dlg')
        self._read_log(exclude_beam_off)

    def load_demo_trajectorylog(self, exclude_beam_off=True):
        """Load the demo trajectory log included with the package."""
        filename = osp.join(osp.dirname(__file__), 'demo_files', 'log_reader', 'Tlog.bin')
        self.load(filename, exclude_beam_off)

    def load_UI(self, exclude_beam_off=True):
        """Let user load a log file with a UI dialog box. """
        filename = get_filepath_UI()
        if filename: # if user didn't hit cancel...
            self.load(filename, exclude_beam_off)

    def load(self, filename, exclude_beam_off=True):
        """Load the log file directly by passing the path to the file.

        Parameters
        ----------
        filename : str
            The path to the log file.

        exclude_beam_off : boolean
            If True (default), snapshots where the beam was not on will be removed.
            If False, all data will be included.

            .. warning::
                Including beam off data may affect fluence and gamma results. E.g. in a step-&-shoot IMRT
                delivery, leaves move between segments while the beam is held. If beam-off data is included,
                the RMS and fluence errors may not correspond to what was delivered.
        """
        if is_valid_file(filename):
            if is_log(filename):
                self._filename = filename
                self._read_log(exclude_beam_off)
            else:
                raise IOError("File passed is not a valid log file")

    def plot_all(self):
        """Plot actual & expected fluence, gamma map, gamma histogram,
            MLC error histogram, and MLC RMS histogram.
        """
        if self.fluence.gamma.map_calced:
            # plot the actual fluence
            ax = plt.subplot(2, 3, 1)
            ax.set_title('Actual Fluence', fontsize=10)
            ax.tick_params(axis='both', labelsize=8)
            ax.autoscale(tight=True)
            plt.imshow(self.fluence.actual.pixel_map, aspect='auto', interpolation='none')

            # plot the expected fluence
            ax = plt.subplot(2, 3, 2)
            ax.set_title("Expected Fluence", fontsize=10)
            ax.tick_params(axis='both', labelsize=8)
            plt.imshow(self.fluence.expected.pixel_map, aspect='auto', interpolation='none')

            # plot the gamma map
            gmma = self.fluence.gamma
            ax = plt.subplot(2, 3, 3)
            ax.set_title("Gamma Map ({:2.2f}% passing @ {}%/{}mm)".format(gmma.pass_prcnt, gmma.doseTA, gmma.distTA), fontsize=10)
            ax.tick_params(axis='both', labelsize=8)
            plt.imshow(self.fluence.gamma.pixel_map, aspect='auto', interpolation='none', vmax=1)
            plt.colorbar(ax=ax)

            # plot the gamma histogram
            ax = plt.subplot(2, 3, 4)
            ax.set_yscale('log')
            ax.set_title("Gamma Histogram (Avg: {:2.3f})".format(self.fluence.gamma.avg_gamma), fontsize=10)
            ax.tick_params(axis='both', labelsize=8)
            plt.hist(self.fluence.gamma.pixel_map.flatten(), self.fluence.gamma.bins)

            # plot the MLC error histogram
            ax = plt.subplot(2, 3, 5)
            p95error = self.axis_data.mlc.get_error_percentile()
            ax.set_title("Leaf Error Histogram (95th Perc: {:2.3f}cm)".format(p95error), fontsize=10)
            ax.tick_params(axis='both', labelsize=8)
            plt.hist(self.axis_data.mlc._abs_error_all_leaves.flatten())

            # plot the leaf RMSs
            ax = plt.subplot(2,3,6)
            ax.set_title("Leaf RMS Error (Max: {:2.3f}cm)".format(self.axis_data.mlc.get_RMS_max()), fontsize=10)
            ax.tick_params(axis='both', labelsize=8)
            ax.set_xlim([-0.5, self.axis_data.mlc.num_leaves+0.5])  # bit of padding since bar chart alignment is center
            plt.bar(np.arange(len(self.axis_data.mlc.get_RMS('both')))[::-1], self.axis_data.mlc.get_RMS('both'), align='center')

            # plt.tight_layout()
            plt.show()
        else:
            raise AttributeError("Gamma map has not yet been calculated.")

    def report_basic_parameters(self):
        """Print the common parameters analyzed when investigating machine logs:

        -Log type
        -Average MLC RMS
        -Maximum MLC RMS
        -95th percentile MLC error
        -Number of beam holdoffs
        -Gamma pass percentage
        -Average gamma value
        """
        print("MLC log type: " + self.log_type)
        print("Average RMS of all leaves: {:3.3f} cm".format(self.axis_data.mlc.get_RMS_avg(only_moving_leaves=False)))
        print("Max RMS error of all leaves: {:3.3f} cm".format(self.axis_data.mlc.get_RMS_max()))
        print("95th percentile error: {:3.3f} cm".format(self.axis_data.mlc.get_error_percentile(95, only_moving_leaves=False)))
        print("Number of beam holdoffs: {:1.0f}".format(self.axis_data.num_beamholds))
        self.fluence.gamma.calc_map()
        print("Gamma pass %: {:2.2f}".format(self.fluence.gamma.pass_prcnt))
        print("Gamma average: {:2.3f}".format(self.fluence.gamma.avg_gamma))

    @lazyproperty
    def log_type(self):
        """Determine the MLC log type: Trajectory or Dynalog.

        The file is opened and sampled of
        the first few bytes. If the sample matches what is found in standard dynalog or tlog files it will be
        assigned that log type.

        Returns
        -------
        str : {'Dynalog', 'Trajectory Log'}

        Raises
        ------
        ValueError : If log type cannot be determined.
        """
        if is_dlog(self._filename):
            log_type = log_types['dlog']
        elif is_tlog(self._filename):
            log_type = log_types['tlog']
        return log_type

    @property
    def is_loaded(self):
        """Boolean specifying if a log has been loaded in yet."""
        if self._filename == '':
            return False
        else:
            return True

    def to_csv(self, filename=None):
        """Write the log to a CSV file. Only applicable for Trajectory logs (Dynalogs are already similar to CSV).

        Parameters
        ----------
        filename : None, str
            If None (default), the CSV filename will be the same as the filename of the log.<commit_msg>added treatment_type property<commit_after>"""
The log analyzer module reads and parses Varian linear accelerator machine logs, both Dynalogs and Trajectory logs. The module also
calculates actual and expected fluences as well as performing gamma evaluations. Data is structured to be easily accessible and
easily plottable.

Unlike most other modules of pylinac, the log analyzer module has no end goal. Data is parsed from the logs, but what is done with that
info, and which info is analyzed is up to the user.
"""
from abc import ABCMeta, abstractproperty
import struct
import os
import os.path as osp
import csv
import copy
import warnings

import numpy as np
import scipy.ndimage.filters as spf
import matplotlib.pyplot as plt

from pylinac import MEMORY_PROFILE, DEBUG
from pylinac.core.decorators import type_accept, lazyproperty, value_accept
from pylinac.core.io import is_valid_file, is_valid_dir, get_folder_UI, get_filepath_UI, open_file
from pylinac.core.utilities import is_iterable


if DEBUG and MEMORY_PROFILE:
    from memory_profile import profile

np.seterr(invalid='ignore')  # ignore warnings for invalid numpy operations. Used for np.where() operations on partially-NaN arrays.

log_types = {'dlog': 'Dynalog', 'tlog': 'Trajectory log'}


class MachineLogs(list):
    """
    .. versionadded:: 0.4.1

    Read in machine logs from a directory. Inherits from list. Batch methods are also provided."""
    @type_accept(dir=str)
    def __init__(self, dir=None, recursive=True, verbose=True):
        """
        Parameters
        ----------
        dir : str
            The directory of interest. Will walk through and process any logs, Trajectory or dynalog, it finds.
            Non-log files will be skipped.
        recursive : bool
            Whether to walk through subfolders of passed directory. Only used if ``dir`` is a valid log directory.
        verbose : bool
            If True (default), prints load status at each log. Only used if ``dir`` is a valid log directory.

        Examples
        --------
        Load a directory upon initialization::

            >>> log_dir = r'C:\path\log\directory'
            >>> logs = MachineLogs(log_dir)

        Or load them later directly::

            >>> logs = MachineLogs()
            >>> logs.load_dir(log_dir)

        Or even using a UI dialog::

            >>> logs = MachineLogs()
            >>> logs.load_dir_UI()

        Batch methods include determining the average gamma and average gamma pass value::

            >>> logs.avg_gamma()
            >>> 0.05 # or whatever it is
            >>> logs.avg_gamma_pct()
            >>> 97.2
        """
        super().__init__()
        if dir is not None and is_valid_dir(dir):
            self.load_dir(dir, recursive, verbose)

    @property
    def num_logs(self):
        """Return the number of logs currently loaded."""
        return len(self)

    @value_accept(log_type=log_types)
    def _num_log_type(self, log_type):
        num = 0
        for log in self:
            if log.log_type == log_type:
                num += 1
        return num

    @property
    def num_tlogs(self):
        """Return the number of Trajectory logs currently loaded."""
        return self._num_log_type(log_types['tlog'])

    @property
    def num_dlogs(self):
        """Return the number of Trajectory logs currently loaded."""
        return self._num_log_type(log_types['dlog'])

    def load_dir(self, dir, recursive=True, verbose=True):
        """Load a directory of log files.

        Parameters
        ----------
        dir : str, None
            The directory of interest.
            If a string, will walk through and process any logs, Trajectory or dynalog, it finds.
            Non-log files will be skipped.
            If None, files must be loaded later using .load_dir() or .append().
        recursive : bool
            If True (default), will walk through subfolders of passed directory.
            If False, will only search root directory.
        verbose : bool
            If True (default), prints load status at each log.
        """
        # do initial walk to get file count
        num_logs = 0
        num_skipped = 0
        for root, dirs, files in os.walk(dir):
            _, num_logs, num_skipped = self._clean_log_filenames(files, root, num_logs, num_skipped)
            if not recursive:
                break
        if num_logs == 0:
            warnings.warn("No logs found.")
            return

        # actual log loading
        load_num = 1
        if verbose:
            print("{} logs found. \n{} logs skipped. \nLog loaded:".format(num_logs, num_skipped))
        for root, dirs, files in os.walk(dir):
            cleaned_files, _, _ = self._clean_log_filenames(files, root, num_logs, num_skipped)
            for name in cleaned_files:
                pth = osp.join(root, name)
                self.append(pth)
                if verbose:
                    print("{} of {}".format(load_num, num_logs))
                    load_num += 1
            if not recursive:
                break  # break out of for loop after top level search

    def load_dir_UI(self, recursive=True, verbose=True):
        """Load a directory using a UI dialog box. See load_dir() for parameter info."""
        folder = get_folder_UI()
        if folder:
            self.load_dir(folder, recursive, verbose)

    def _clean_log_filenames(self, filenames, root, num_logs, num_skipped):
        """Extract the names of real log files from a list of files."""
        cleaned_filenames = []
        for idx, filename in enumerate(filenames):
            filename = osp.join(root, filename)
            if is_tlog(filename):
                num_logs += 1
                cleaned_filenames.append(filename)
            elif is_dlog(filename):
                opp_file = _return_other_dlg(filename, raise_find_error=False)
                if opp_file is not None:
                    num_logs += 1
                    opp_file = osp.basename(opp_file)
                    del filenames[filenames.index(opp_file)]
                    cleaned_filenames.append(filename)
                else:
                    num_skipped += 1
                    del filenames[idx]
        return cleaned_filenames, num_logs, num_skipped

    def _check_empty(self):
        if len(self) == 0:
            raise ValueError("No logs have been loaded yet.")

    def report_basic_parameters(self):
        """Report basic parameters of the logs.

        - Number of logs
        - Average gamma value of all logs
        - Average gamma pass percent of all logs
        """
        print("Number of logs: {}".format(self.num_logs))
        print("Average gamma: {:3.2f}".format(self.avg_gamma(verbose=False)))
        print("Average gamma pass percent: {:3.1f}".format(self.avg_gamma_pct(verbose=False)))

    def append(self, obj, recursive=True):
        """Append a log. Overloads list method.

        Parameters
        ----------
        obj : str, MachineLog
            If a string, must point to a log file.
            If a directory, must contain log files.
            If a MachineLog, then simply appends.
        recursive : bool
            Whether to walk through subfolders of passed directory. Only applicable if obj was a directory.
        """
        if isinstance(obj, str):
            if is_log(obj):
                log = MachineLog(obj)
                super().append(log)
            elif is_valid_dir(obj, raise_error=False):
                for root, dirs, files in os.walk(obj):
                    for name in files:
                        pth = osp.join(root, name)
                        self.append(pth)
        elif isinstance(obj, MachineLog):
            super().append(obj)
        else:
            raise TypeError("Can only append MachineLog or string pointing to a log or log directory.")

    def avg_gamma(self, doseTA=1, distTA=1, threshold=10, resolution=0.1, verbose=True):
        """Calculate and return the average gamma of all logs. See :meth:`~pylinac.log_analyzer.GammaFluence.calc_map()`
        for further parameter info."""
        self._check_empty()
        gamma_list = []
        if verbose:
            print("Calculating gammas:")
        for num, log in enumerate(self):
            log.fluence.gamma.calc_map(doseTA, distTA, threshold, resolution)
            gamma_list.append(log.fluence.gamma.avg_gamma)
            if verbose:
                print('{} of {}'.format(num, self.num_logs))
        return np.array(gamma_list).mean()

    def avg_gamma_pct(self, doseTA=1, distTA=1, threshold=10, resolution=0.1, verbose=True):
        """Calculate and return the average gamma pass percent of all logs. See :meth:`~pylinac.log_analyzer.GammaFluence.calc_map()`
        for further parameter info."""
        self._check_empty()
        gamma_list = []
        if verbose:
            print("Calculating gamma pass percent:")
        for num, log in enumerate(self):
            log.fluence.gamma.calc_map(doseTA, distTA, threshold, resolution)
            gamma_list.append(log.fluence.gamma.pass_prcnt)
            if verbose:
                print("{} of {}".format(num, self.num_logs))
        return np.array(gamma_list).mean()

    def to_csv(self):
        """Write trajectory logs to CSV. If there are both dynalogs and trajectory logs,
        only the trajectory logs will be written. File names will be the same as the original log file names."""
        num_written = 0
        for log in self:
            if is_tlog(log._filename):
                log.to_csv()
                num_written += 1
        if num_written:
            print('\n\nAll CSV files written!')
        else:
            print('\n\nNo files written')


class MachineLog:
    """Reads in and analyzes MLC log files, both dynalog and trajectory logs, from Varian linear accelerators.

    If reading Trajectory logs, the .txt file is also loaded if it's around.
    """

    def __init__(self, filename=''):
        """
        Parameters
        ----------
        filename : str
            Path to the log file. For trajectory logs this is a single .bin file.
            For dynalog files, load either the A*.dlg or B*.dlg file.

            .. warning:: Dynalogs must have names like "A*.dlg" and "B*.dlg" and be in the same folder, otherwise errors will ensue.

        Examples
        --------
        Load a file upon initialization::

            >>> mylogfile = "C:/path/to/log.bin"
            >>> log = MachineLog(mylogfile)

        Or::

            >>> mylogfile = "C:/path/to/A1.dlg"  # B must also be here
            >>> log = MachineLog()
            >>> log.load(mylogfile)

        Run the demo::

            >>> MachineLog().run_dlog_demo()
            >>> MachineLog().run_tlog_demo()

        Attributes
        ----------
        header : A :class:`~pylinac.log_analyzer.Tlog_Header` or :class:`~pylinac.log_analyzer.Dlog_Header`, depending on the log type.
        axis_data : :class:`~pylinac.log_analyzer.Tlog_Axis_Data` or :class:`~pylinac.log_analyzer.Dlog_Axis_Data`, depending on the log type.
        subbeams : list
            Only applicable for autosequenced trajectory logs. Will contain instances of :class:`~pylinac.log_analyzer.Subbeam`; will be empty if
            autosequencing was not done.
        txt : dict
            If working with trajectory logs and the associated .txt file is in the same directory, the txt file data will be loaded as a dictionary.
        fluence : :class:`~pylinac.log_analyzer.Fluence_Struct`
            Contains actual and expected fluence data, including gamma.
        log_type : str
            The log type loaded; either 'Dynalog' or 'Trajectory log'
        log_is_loaded : bool
            Whether a log has yet been loaded.
        """
        self._filename = ''
        self._cursor = 0
        self.fluence = Fluence_Struct()

        # Read file if passed in
        if filename is not '':
            self.load(filename)

    def run_tlog_demo(self):
        """Run the Trajectory log demo."""
        self.load_demo_trajectorylog()
        self.report_basic_parameters()
        self.plot_all()

    def run_dlog_demo(self):
        """Run the dynalog demo."""
        self.load_demo_dynalog()
        self.report_basic_parameters()
        self.plot_all()

    def load_demo_dynalog(self, exclude_beam_off=True):
        """Load the demo dynalog file included with the package."""
        self._filename = osp.join(osp.dirname(__file__), 'demo_files', 'log_reader', 'AQA.dlg')
        self._read_log(exclude_beam_off)

    def load_demo_trajectorylog(self, exclude_beam_off=True):
        """Load the demo trajectory log included with the package."""
        filename = osp.join(osp.dirname(__file__), 'demo_files', 'log_reader', 'Tlog.bin')
        self.load(filename, exclude_beam_off)

    def load_UI(self, exclude_beam_off=True):
        """Let user load a log file with a UI dialog box. """
        filename = get_filepath_UI()
        if filename: # if user didn't hit cancel...
            self.load(filename, exclude_beam_off)

    def load(self, filename, exclude_beam_off=True):
        """Load the log file directly by passing the path to the file.

        Parameters
        ----------
        filename : str
            The path to the log file.

        exclude_beam_off : boolean
            If True (default), snapshots where the beam was not on will be removed.
            If False, all data will be included.

            .. warning::
                Including beam off data may affect fluence and gamma results. E.g. in a step-&-shoot IMRT
                delivery, leaves move between segments while the beam is held. If beam-off data is included,
                the RMS and fluence errors may not correspond to what was delivered.
        """
        if is_valid_file(filename):
            if is_log(filename):
                self._filename = filename
                self._read_log(exclude_beam_off)
            else:
                raise IOError("File passed is not a valid log file")

    def plot_all(self):
        """Plot actual & expected fluence, gamma map, gamma histogram,
            MLC error histogram, and MLC RMS histogram.
        """
        if self.fluence.gamma.map_calced:
            # plot the actual fluence
            ax = plt.subplot(2, 3, 1)
            ax.set_title('Actual Fluence', fontsize=10)
            ax.tick_params(axis='both', labelsize=8)
            ax.autoscale(tight=True)
            plt.imshow(self.fluence.actual.pixel_map, aspect='auto', interpolation='none')

            # plot the expected fluence
            ax = plt.subplot(2, 3, 2)
            ax.set_title("Expected Fluence", fontsize=10)
            ax.tick_params(axis='both', labelsize=8)
            plt.imshow(self.fluence.expected.pixel_map, aspect='auto', interpolation='none')

            # plot the gamma map
            gmma = self.fluence.gamma
            ax = plt.subplot(2, 3, 3)
            ax.set_title("Gamma Map ({:2.2f}% passing @ {}%/{}mm)".format(gmma.pass_prcnt, gmma.doseTA, gmma.distTA), fontsize=10)
            ax.tick_params(axis='both', labelsize=8)
            plt.imshow(self.fluence.gamma.pixel_map, aspect='auto', interpolation='none', vmax=1)
            plt.colorbar(ax=ax)

            # plot the gamma histogram
            ax = plt.subplot(2, 3, 4)
            ax.set_yscale('log')
            ax.set_title("Gamma Histogram (Avg: {:2.3f})".format(self.fluence.gamma.avg_gamma), fontsize=10)
            ax.tick_params(axis='both', labelsize=8)
            plt.hist(self.fluence.gamma.pixel_map.flatten(), self.fluence.gamma.bins)

            # plot the MLC error histogram
            ax = plt.subplot(2, 3, 5)
            p95error = self.axis_data.mlc.get_error_percentile()
            ax.set_title("Leaf Error Histogram (95th Perc: {:2.3f}cm)".format(p95error), fontsize=10)
            ax.tick_params(axis='both', labelsize=8)
            plt.hist(self.axis_data.mlc._abs_error_all_leaves.flatten())

            # plot the leaf RMSs
            ax = plt.subplot(2,3,6)
            ax.set_title("Leaf RMS Error (Max: {:2.3f}cm)".format(self.axis_data.mlc.get_RMS_max()), fontsize=10)
            ax.tick_params(axis='both', labelsize=8)
            ax.set_xlim([-0.5, self.axis_data.mlc.num_leaves+0.5])  # bit of padding since bar chart alignment is center
            plt.bar(np.arange(len(self.axis_data.mlc.get_RMS('both')))[::-1], self.axis_data.mlc.get_RMS('both'), align='center')

            # plt.tight_layout()
            plt.show()
        else:
            raise AttributeError("Gamma map has not yet been calculated.")

    def report_basic_parameters(self):
        """Print the common parameters analyzed when investigating machine logs:

        -Log type
        -Average MLC RMS
        -Maximum MLC RMS
        -95th percentile MLC error
        -Number of beam holdoffs
        -Gamma pass percentage
        -Average gamma value
        """
        print("MLC log type: " + self.log_type)
        print("Average RMS of all leaves: {:3.3f} cm".format(self.axis_data.mlc.get_RMS_avg(only_moving_leaves=False)))
        print("Max RMS error of all leaves: {:3.3f} cm".format(self.axis_data.mlc.get_RMS_max()))
        print("95th percentile error: {:3.3f} cm".format(self.axis_data.mlc.get_error_percentile(95, only_moving_leaves=False)))
        print("Number of beam holdoffs: {:1.0f}".format(self.axis_data.num_beamholds))
        self.fluence.gamma.calc_map()
        print("Gamma pass %: {:2.2f}".format(self.fluence.gamma.pass_prcnt))
        print("Gamma average: {:2.3f}".format(self.fluence.gamma.avg_gamma))

    @lazyproperty
    def log_type(self):
        """Determine the MLC log type: Trajectory or Dynalog.

        The file is opened and sampled of
        the first few bytes. If the sample matches what is found in standard dynalog or tlog files it will be
        assigned that log type.

        Returns
        -------
        str : {'Dynalog', 'Trajectory Log'}

        Raises
        ------
        ValueError : If log type cannot be determined.
        """
        if is_dlog(self._filename):
            log_type = log_types['dlog']
        elif is_tlog(self._filename):
            log_type = log_types['tlog']
        return log_type

    @property
    def is_loaded(self):
        """Boolean specifying if a log has been loaded in yet."""
        if self._filename == '':
            return False
        else:
            return True

    @property
    def treatment_type(self):
        try:
            gantry_std = self.subbeams[0].gantry_angle.actual.std()
            if gantry_std > 0.5:
                return 'VMAT'
            elif self.axis_data.mlc.get_RMS_avg(only_moving_leaves=True) > 0.05:
                return 'Dynamic IMRT'
            else:
                return 'Static IMRT'
        except IndexError:
            return 'Unknown'

    def to_csv(self, filename=None):
        """Write the log to a CSV file. Only applicable for Trajectory logs (Dynalogs are already similar to CSV).

        Parameters
        ----------
        filename : None, str
            If None (default), the CSV filename will be the same as the filename of the log.
{'fraction_non_alphanumeric': 0.09969875573018991, 'fraction_numerical': 0.00612966601178782, 'mean_word_length': 2.113104460572454, 'pattern_counts': {'":': 0, '<': 3, '<?xml version=': 0, '>': 107, 'https://': 0, 'lorem ipsum': 0, 'www.': 0, 'xml': 0}, 'pii_count': 0, 'substrings_counts': 0, 'word_list_counts': {'cursed_substrings.json': 0, 'profanity_word_list.json': 0, 'sexual_word_list.json': 0, 'zh_pornsignals.json': 0}}
{'dir': 'git-commits-cleaned', 'id': '2607819', 'n_tokens_mistral': 11257, 'n_tokens_neox': 10666, 'n_words': 3364}